b4fa5d7be8
Multi-mode, off by default. Each new recap is synthesized into a 1-2 paragraph overview via the relay (operator-absorbed) and cached onto the session JSON; a daily 08:00 scan emails opted-in users their fresh recaps, deduped by a per-user watermark that never skips a failed or over-cap recap. One-click tokenized unsubscribe; settings-modal toggle; admin test trigger. Bumps to 0.2.158.
549 lines
22 KiB
JavaScript
549 lines
22 KiB
JavaScript
// 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),
|
|
});
|
|
}
|
|
});
|
|
|
|
// Daily-digest test trigger. With {test_email}, sends a sample digest
|
|
// to that address so the operator can eyeball the rendering without
|
|
// opted-in users or waiting for the send hour. Without it, forces a
|
|
// real scan now (bypassing the 08:00 gate, NOT the per-user resend gate).
|
|
app.post("/api/admin/digest/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 { renderDigestEmail } = 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 = renderDigestEmail({
|
|
brandName: "Recaps",
|
|
episodes: [
|
|
{
|
|
title: "Sample podcast episode",
|
|
type: "podcast",
|
|
url: "https://example.com/episode",
|
|
overview:
|
|
"This is a sample overview paragraph so you can see how a digest entry renders. The real thing is synthesized from each recap's stored topic summaries.",
|
|
},
|
|
{
|
|
title: "Sample YouTube video",
|
|
type: "youtube",
|
|
url: "https://youtube.com/watch?v=example",
|
|
overview:
|
|
"A second sample entry, showing how multiple recaps stack in one email.",
|
|
},
|
|
],
|
|
overflowCount: 0,
|
|
manageUrl: `${publicUrl}/`,
|
|
unsubscribeUrl: `${publicUrl}/api/digest/unsubscribe?token=sample`,
|
|
});
|
|
await sendMail({
|
|
to: testEmail,
|
|
subject: msg.subject,
|
|
text: msg.text,
|
|
html: msg.html,
|
|
});
|
|
return res.json({ ok: true, test_email_sent_to: testEmail });
|
|
}
|
|
const { runDigestScan } = await import("./daily-digest.js");
|
|
const result = await runDigestScan({ force: true });
|
|
res.json({ ok: true, ...result });
|
|
} catch (err) {
|
|
console.error("[admin] digest run failed:", err?.message || err);
|
|
res.status(500).json({
|
|
error: "digest_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" });
|
|
}
|
|
});
|
|
}
|