// 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: } 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// 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" }); } }); }