Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling

Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
This commit is contained in:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+484
View File
@@ -0,0 +1,484 @@
// Operator (is_admin = 1) endpoints for multi-tenant Recap. Every
// route here is gated by requireOperator from tenant-auth.js — non-
// admin users get 403. Single-mode never mounts these.
//
// What's exposed:
// GET /api/admin/tenants — list all users + credits + license status
// POST /api/admin/tenants/:id/grant — add credits to a user's local balance
// POST /api/admin/tenants/:id/tier — set a user's subscription tier (relay-owned)
// GET /api/admin/tenants/:id/sessions — list a user's active sessions
// DELETE /api/admin/tenants/:id/sessions — revoke ALL of a user's sessions
// DELETE /api/admin/sessions/:sessionId — revoke a specific session
// GET /api/admin/recent-signups — signups grouped by IP/UA in the last N hours
//
// All responses are JSON. Tenant rows shape:
// { id, email, display_name, is_admin, tier, has_license, balance,
// lifetime_granted, lifetime_consumed, created_at, last_signin_at,
// signup_ip, signup_user_agent, session_count }
//
// Volume notes: SQLite, ~100s of tenants tops for an alpha cohort.
// No pagination yet — caps at LIMIT 500 to be defensive.
import { getDb } from "./db.js";
import { requireOperator } from "./tenant-auth.js";
import fs from "fs/promises";
import path from "path";
import { getHistoryDir } from "./history.js";
import { getRelayOperatorKey } from "./relay-default.js";
const MAX_TENANT_ROWS = 500;
const RECENT_SIGNUPS_MAX_HOURS = 24 * 14; // 2 weeks
export function setupAdminRoutes(app) {
// ── Run / test the subscription expiry-reminder scan ───────────────────
// Operator-only. With { test_email } it sends a SAMPLE reminder to that
// address — verifies the recaps.cc SMTP + the email rendering without
// needing a real near-expiry subscription. Without it, forces an
// immediate scan of real expiring subscriptions and returns the summary
// ({ sent, skipped } or a { skipped: <reason> } if a precondition fails).
app.post("/api/admin/reminders/run", requireOperator, async (req, res) => {
try {
const testEmail =
typeof req.body?.test_email === "string" ? req.body.test_email.trim() : "";
if (testEmail) {
const { isSmtpReady, sendMail } = await import("./smtp.js");
if (!isSmtpReady()) {
return res.status(503).json({
error: "smtp_not_ready",
message: "Configure StartOS System SMTP first.",
});
}
const { renderSubscriptionReminderEmail } = await import(
"./email-template.js"
);
const { getConfigSnapshot } = await import("./config.js");
const snap = await getConfigSnapshot();
const publicUrl = (snap.recap_public_url || "")
.trim()
.replace(/\/$/, "");
const msg = renderSubscriptionReminderEmail({
brandName: "Recaps",
tier: "pro",
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
daysLeft: 7,
kind: "upcoming_7d",
manageUrl: `${publicUrl}/?renew=1`,
});
await sendMail({
to: testEmail,
subject: msg.subject,
text: msg.text,
html: msg.html,
});
return res.json({ ok: true, test_email_sent_to: testEmail });
}
const { runReminderScan } = await import("./subscription-reminders.js");
const result = await runReminderScan({ force: true });
res.json({ ok: true, ...result });
} catch (err) {
console.error("[admin] reminder run failed:", err?.message || err);
res.status(500).json({
error: "reminder_run_failed",
message: err?.message || String(err),
});
}
});
// ── List all tenants ───────────────────────────────────────────────────
app.get("/api/admin/tenants", requireOperator, (req, res) => {
try {
const rows = getDb()
.prepare(
`SELECT
u.id, u.email, u.display_name, u.is_admin, u.tier,
u.created_at, u.last_signin_at,
u.signup_ip, u.signup_user_agent,
CASE WHEN u.keysat_license IS NOT NULL AND length(u.keysat_license) > 0 THEN 1 ELSE 0 END AS has_license,
COALESCE(tc.purchased_balance, 0) AS purchased_balance,
COALESCE(tc.replenish_balance, 0) AS replenish_balance,
COALESCE(tc.purchased_balance, 0) + COALESCE(tc.replenish_balance, 0) AS balance,
COALESCE(tc.lifetime_granted, 0) AS lifetime_granted,
COALESCE(tc.lifetime_consumed, 0) AS lifetime_consumed,
(SELECT COUNT(*) FROM sessions s
WHERE s.user_id = u.id AND s.expires_at > ?) AS session_count
FROM users u
LEFT JOIN tenant_credits tc ON tc.user_id = u.id
ORDER BY u.created_at DESC
LIMIT ?`,
)
.all(Date.now(), MAX_TENANT_ROWS);
// The relay-owned tier can only be set when this server holds the
// matching operator key (otherwise the grant just 502s). The UI hides
// the per-row "Tier" control when this is false, so a self-hosted
// operator — who has no matching key for the canonical relay — doesn't
// see a button that can't work.
res.json({
tenants: rows,
relay_operator_key_configured: !!getRelayOperatorKey(),
});
} catch (err) {
console.error("[admin] /tenants failed:", err);
res.status(500).json({ error: "internal_error" });
}
});
// ── Grant credits to a tenant ──────────────────────────────────────────
// Body: { amount }. Positive integer.
//
// Upserts tenant_credits and increments balance + lifetime_granted.
// Records the operator's id + a timestamp so we have an audit trail
// when /api/admin/audit lands later (currently the grant is implicit
// via lifetime_granted; the trail can be reconstructed by diffing).
app.post(
"/api/admin/tenants/:id/grant",
requireOperator,
async (req, res) => {
const userId = req.params.id;
const amount = parseInt(req.body?.amount, 10);
if (!Number.isFinite(amount) || amount <= 0 || amount > 100000) {
return res
.status(400)
.json({ error: "bad_amount", message: "amount must be 1..100000" });
}
try {
const db = getDb();
// Ensure the user exists before we upsert credits.
const user = db
.prepare("SELECT id, email FROM users WHERE id = ?")
.get(userId);
if (!user) {
return res.status(404).json({ error: "user_not_found" });
}
// Admin grants go into the PURCHASED bucket — they're explicit
// operator-initiated credits and shouldn't get wiped by the
// next replenishment cycle. Helper handles upsert + lifetime_granted.
const { addPurchased } = await import("./tenant-credits.js");
const row = addPurchased(userId, amount);
const total =
(row?.purchased_balance || 0) + (row?.replenish_balance || 0);
console.log(
`[admin] granted ${amount} credits to ${user.email} (by ${req.user.email}) — total balance ${total}`,
);
res.json({
ok: true,
user_id: userId,
balance: total,
purchased_balance: row?.purchased_balance || 0,
replenish_balance: row?.replenish_balance || 0,
granted: amount,
});
} catch (err) {
console.error("[admin] grant failed:", err);
res.status(500).json({ error: "internal_error" });
}
},
);
// ── Set a tenant's subscription tier ───────────────────────────────────
// Body: { tier }. One of "core" | "pro" | "max".
//
// Core-decoupling: the RELAY owns the subscription (keyed by Recaps
// user-id), NOT a per-user Keysat license. This route writes the
// relay-side tier FIRST (the authoritative owner), and only on success
// caches it on the local users row. That ordering keeps the two from
// drifting — we never show a user as Pro in the UI while the relay still
// rejects their cloud calls. The cached users.tier is what the per-user
// feature gates (tts-routes, license-status) and the providers'
// cloud-identity selection read on each request.
app.post(
"/api/admin/tenants/:id/tier",
requireOperator,
async (req, res) => {
const userId = req.params.id;
const tier = String(req.body?.tier || "").trim().toLowerCase();
if (!["core", "pro", "max"].includes(tier)) {
return res.status(400).json({
error: "bad_tier",
message: 'tier must be "core", "pro", or "max"',
});
}
try {
const db = getDb();
const user = db
.prepare("SELECT id, email FROM users WHERE id = ?")
.get(userId);
if (!user) {
return res.status(404).json({ error: "user_not_found" });
}
// 1) Authoritative write to the relay (the subscription owner).
// Throws if the operator key / relay base URL isn't configured,
// or if the relay rejects the call — in which case we DON'T
// touch the local cache.
const { setRelayUserTier } = await import("./providers/relay.js");
try {
await setRelayUserTier({ userId, tier });
} catch (err) {
console.error(
"[admin] relay tier push failed:",
err?.message || err,
);
return res.status(502).json({
error: "relay_tier_failed",
message:
"Couldn't set the tier on the relay (the subscription owner). " +
"Check the relay operator key + base URL, then retry. " +
(err?.message || ""),
});
}
// 2) Cache locally so feature gates + the badge see it immediately.
db.prepare("UPDATE users SET tier = ? WHERE id = ?").run(tier, userId);
console.log(
`[admin] set tier=${tier} for ${user.email} (by ${req.user.email})`,
);
res.json({ ok: true, user_id: userId, tier });
} catch (err) {
console.error("[admin] set tier failed:", err);
res.status(500).json({ error: "internal_error" });
}
},
);
// ── List a tenant's active sessions ────────────────────────────────────
app.get(
"/api/admin/tenants/:id/sessions",
requireOperator,
(req, res) => {
const userId = req.params.id;
try {
const rows = getDb()
.prepare(
`SELECT id, created_at, expires_at, last_used_at, user_agent, ip_address
FROM sessions
WHERE user_id = ? AND expires_at > ?
ORDER BY last_used_at DESC, created_at DESC`,
)
.all(userId, Date.now());
res.json({ sessions: rows });
} catch (err) {
console.error("[admin] sessions list failed:", err);
res.status(500).json({ error: "internal_error" });
}
},
);
// ── Revoke ALL sessions for a tenant ───────────────────────────────────
// Operator-side "sign this user out everywhere" button. Useful when
// a tenant flags account theft, or for ban-hammer scenarios. The
// user can sign back in via magic link unless the operator also
// disables their email (future feature).
app.delete(
"/api/admin/tenants/:id/sessions",
requireOperator,
(req, res) => {
const userId = req.params.id;
try {
const result = getDb()
.prepare("DELETE FROM sessions WHERE user_id = ?")
.run(userId);
console.log(
`[admin] revoked ${result.changes} session(s) for user ${userId} (by ${req.user.email})`,
);
res.json({ ok: true, revoked: result.changes });
} catch (err) {
console.error("[admin] revoke all sessions failed:", err);
res.status(500).json({ error: "internal_error" });
}
},
);
// ── Revoke a single session by id ──────────────────────────────────────
app.delete(
"/api/admin/sessions/:sessionId",
requireOperator,
(req, res) => {
const sessionId = req.params.sessionId;
try {
const result = getDb()
.prepare("DELETE FROM sessions WHERE id = ?")
.run(sessionId);
res.json({ ok: true, revoked: result.changes });
} catch (err) {
console.error("[admin] revoke session failed:", err);
res.status(500).json({ error: "internal_error" });
}
},
);
// ── Delete a tenant (admin ban-hammer) ─────────────────────────────────
// Operator removes a tenant — abuse response, account cleanup, etc.
// Mirrors the user-side /api/account DELETE but operator-scoped:
// - Refuses to delete another admin (admins protect each other)
// - Refuses to delete the calling operator (use the user-side
// endpoint or SQL if you really need that)
// - Cascades sessions, tenant_credits, library_meta via FK ON DELETE
// - FS cleanup of /data/history/<user_id>/ is best-effort
app.delete(
"/api/admin/tenants/:id",
requireOperator,
async (req, res) => {
const userId = req.params.id;
if (userId === req.user.id) {
return res.status(400).json({
error: "cannot_delete_self",
message:
"Use the user-side endpoint to delete your own account, or demote first.",
});
}
try {
const db = getDb();
const target = db
.prepare("SELECT id, email, is_admin FROM users WHERE id = ?")
.get(userId);
if (!target) {
return res.status(404).json({ error: "user_not_found" });
}
if (target.is_admin) {
return res.status(400).json({
error: "cannot_delete_admin",
message:
"Demote this user from admin first if you really want to delete them.",
});
}
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
console.log(
`[admin] deleted tenant ${target.email} (id ${userId}) (by ${req.user.email})`,
);
// FS cleanup, best-effort.
try {
const userDir = path.join(getHistoryDir(), userId);
await fs.rm(userDir, { recursive: true, force: true });
} catch (err) {
console.warn(
`[admin] history-dir cleanup failed for ${userId}:`,
err?.message || err,
);
}
res.json({ ok: true });
} catch (err) {
console.error("[admin] delete tenant failed:", err);
res.status(500).json({ error: "internal_error" });
}
},
);
// ── Recent signups (forensic / abuse detection) ────────────────────────
// Query: ?hours=N (default 24, clamped to RECENT_SIGNUPS_MAX_HOURS).
// Returns:
// {
// window_hours: 24,
// by_ip: [{ ip, count, emails[], first_seen, last_seen }],
// by_ua: [{ ua, count, ... }],
// by_hour: [{ hour, count }],
// totals: { signups, magic_link_requests }
// }
app.get("/api/admin/recent-signups", requireOperator, (req, res) => {
const hoursReq = parseInt(req.query?.hours, 10) || 24;
const hours = Math.min(
Math.max(1, hoursReq),
RECENT_SIGNUPS_MAX_HOURS,
);
const cutoff = Date.now() - hours * 60 * 60 * 1000;
try {
const db = getDb();
// Tracked sources:
// - users.signup_* — confirmed signups (someone clicked magic link)
// - magic_link_tokens.request_* — link requests, includes unconverted
//
// Top-line metrics show both so the operator can spot patterns
// like "10000 link requests, 2 actual signups" → scripted abuse.
// Signups by IP
const byIp = db
.prepare(
`SELECT signup_ip AS ip, COUNT(*) AS count,
MIN(created_at) AS first_seen,
MAX(created_at) AS last_seen,
GROUP_CONCAT(email, '|') AS emails_joined
FROM users
WHERE created_at > ? AND signup_ip IS NOT NULL AND signup_ip != ''
GROUP BY signup_ip
ORDER BY count DESC, last_seen DESC
LIMIT 50`,
)
.all(cutoff)
.map((r) => ({
ip: r.ip,
count: r.count,
first_seen: r.first_seen,
last_seen: r.last_seen,
emails: (r.emails_joined || "").split("|").filter(Boolean),
}));
// Signups by UA (truncated for readability — group on the first
// 80 chars so "Mozilla/5.0 ... Chrome/120" rows collapse into
// one even if patch versions differ slightly).
const byUa = db
.prepare(
`SELECT substr(signup_user_agent, 1, 80) AS ua, COUNT(*) AS count,
MIN(created_at) AS first_seen,
MAX(created_at) AS last_seen
FROM users
WHERE created_at > ? AND signup_user_agent IS NOT NULL AND signup_user_agent != ''
GROUP BY substr(signup_user_agent, 1, 80)
ORDER BY count DESC, last_seen DESC
LIMIT 50`,
)
.all(cutoff);
// Signups per hour (for a sparkline; small array).
const byHour = db
.prepare(
`SELECT (created_at / 3600000) * 3600000 AS hour, COUNT(*) AS count
FROM users
WHERE created_at > ?
GROUP BY hour
ORDER BY hour ASC`,
)
.all(cutoff);
const signupCount = db
.prepare("SELECT COUNT(*) AS n FROM users WHERE created_at > ?")
.get(cutoff).n;
const magicLinkCount = db
.prepare(
"SELECT COUNT(*) AS n FROM magic_link_tokens WHERE created_at > ?",
)
.get(cutoff).n;
// Magic-link request distribution by IP (catches "lots of
// requests, no signups" abuse — abusers who keep requesting
// links but never click them, or who can't because the email
// isn't real). Truncate emails-joined to first 5 to keep
// payload reasonable.
const linkByIp = db
.prepare(
`SELECT request_ip AS ip, COUNT(*) AS count,
MIN(created_at) AS first_seen,
MAX(created_at) AS last_seen,
COUNT(DISTINCT email) AS distinct_emails
FROM magic_link_tokens
WHERE created_at > ? AND request_ip IS NOT NULL AND request_ip != ''
GROUP BY request_ip
ORDER BY count DESC, last_seen DESC
LIMIT 50`,
)
.all(cutoff);
res.json({
window_hours: hours,
totals: {
signups: signupCount,
magic_link_requests: magicLinkCount,
},
signups_by_ip: byIp,
signups_by_ua: byUa,
signups_by_hour: byHour,
magic_links_by_ip: linkByIp,
});
} catch (err) {
console.error("[admin] recent-signups failed:", err);
res.status(500).json({ error: "internal_error" });
}
});
}