+
+`;
+}
+
+// sendSignInLink({ email, intent, ip?, userAgent?, emailBody? }) —
+// reusable magic-link issuance + email send. Used by:
+// • /auth/request-link — visitor-initiated sign-in
+// • license-purchase poll-settle — system-initiated post-purchase
+// "your account is ready" send
+//
+// Generates a 32-byte token, hashes it, stores the hash in
+// magic_link_tokens, builds a verifyUrl, sends the email with either
+// the default magic-link body OR a caller-supplied (subject, text,
+// html) tuple for custom flows. Returns { ok: true, expires_at } on
+// success; { ok: false, error, message? } on failure.
+//
+// Doesn't enforce rate limits — that's the caller's job. /auth/request-link
+// has the per-email + per-IP buckets; the post-purchase path is
+// inherently rate-limited by the actual payment, so no extra bucket
+// needed.
+export async function sendSignInLink({
+ email,
+ intent = "signin",
+ ip = null,
+ userAgent = "",
+ emailBody = null,
+ trialCookieId = null,
+}) {
+ if (!email || !isPlausibleEmail(email)) {
+ return { ok: false, error: "bad_email" };
+ }
+ const publicUrl = await getPublicUrl();
+ if (!publicUrl) {
+ return { ok: false, error: "public_url_not_set" };
+ }
+ if (!isSmtpReady()) {
+ return { ok: false, error: "smtp_not_ready" };
+ }
+ const now = Date.now();
+ const plaintext = randomBytes(32).toString("base64url");
+ const tokenHash = sha256(plaintext);
+ const expiresAt = now + MAGIC_LINK_TTL_MS;
+ try {
+ getDb()
+ .prepare(
+ `INSERT INTO magic_link_tokens
+ (token_hash, email, created_at, expires_at, intent, request_ip, request_ua, trial_cookie_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .run(
+ tokenHash,
+ email,
+ now,
+ expiresAt,
+ intent,
+ ip || null,
+ clipUA(userAgent),
+ trialCookieId || null,
+ );
+ } catch (err) {
+ console.error("[auth] sendSignInLink insert failed:", err);
+ return { ok: false, error: "internal_error" };
+ }
+ const verifyUrl = `${publicUrl}/auth/verify?token=${encodeURIComponent(plaintext)}`;
+ // Default to the standard sign-in email body; callers can override
+ // either with a pre-built {subject,text,html} object OR a function
+ // that receives the verifyUrl and returns that shape. The function
+ // form is what license-purchase uses to inject the celebratory
+ // "your Pro account is ready" copy with the verifyUrl pre-rendered
+ // into the body.
+ let message;
+ if (typeof emailBody === "function") {
+ message = emailBody(verifyUrl);
+ } else if (emailBody && typeof emailBody === "object") {
+ message = emailBody;
+ } else {
+ message = renderMagicLinkEmail({
+ verifyUrl,
+ brandName: "Recaps",
+ expiresInMinutes: 15,
+ });
+ }
+ try {
+ await sendMail({
+ to: email,
+ subject: message.subject,
+ text: message.text,
+ html: message.html,
+ });
+ } catch (err) {
+ console.error(
+ "[auth] sendSignInLink sendMail failed for",
+ email,
+ ":",
+ err?.message || err,
+ );
+ // The token is already inserted; if the operator's SMTP is flaky
+ // the user can re-request a link. Return error so the caller can
+ // decide whether to surface it or swallow.
+ return { ok: false, error: "send_failed" };
+ }
+ return { ok: true, expires_at: expiresAt };
+}
+
+// setupAuthRoutes(app) — registers /auth/* endpoints. Multi-mode only;
+// wired in server/index.js behind the RECAP_MODE === 'multi' branch.
+//
+// Magic-link is the primary auth surface:
+// POST /auth/request-link — issue a magic link by email
+// GET /auth/verify?token=... — consume the link, issue session
+// POST /auth/signout — drop the session
+// GET /auth/signout — same (link-click convenience)
+//
+// Password endpoints are the optional faster-signin add-on:
+// POST /auth/set-password — set OR overwrite my password
+// POST /auth/clear-password — remove my password (magic-link only)
+// POST /auth/signin-password — sign in with email + password
+//
+// Note there is no /auth/reset-password endpoint by design — reset is
+// implemented as "request a magic link, sign in, then call
+// /auth/set-password with the new one." Adding a dedicated reset
+// endpoint would duplicate the magic-link flow without adding any
+// security or UX.
+export function setupAuthRoutes(app) {
+ app.post("/auth/request-link", (req, res) => {
+ handleRequestLink(req, res).catch((err) => {
+ console.error("[auth] /auth/request-link unhandled:", err);
+ res.status(500).json({ error: "internal_error" });
+ });
+ });
+
+ app.get("/auth/verify", (req, res) => {
+ handleVerify(req, res).catch((err) => {
+ console.error("[auth] /auth/verify unhandled:", err);
+ res.status(500).send(renderErrorPage("Internal error."));
+ });
+ });
+
+ app.post("/auth/signin-password", (req, res) => {
+ handleSignInPassword(req, res).catch((err) => {
+ console.error("[auth] /auth/signin-password unhandled:", err);
+ res.status(500).json({ error: "internal_error" });
+ });
+ });
+
+ // Note: /api/account/password (set + clear) is registered by
+ // account-routes.js, not here — those endpoints REQUIRE an existing
+ // session, so they live outside the /auth/* public-path namespace
+ // (which is allowed through the tenant-auth middleware unauthenticated).
+
+ app.post("/auth/signout", (req, res) => {
+ handleSignout(req, res).catch((err) => {
+ console.error("[auth] /auth/signout unhandled:", err);
+ res.status(500).json({ error: "internal_error" });
+ });
+ });
+
+ // Convenience GET version for plain link clicks ("Sign out") from
+ // the UI without needing a form POST.
+ app.get("/auth/signout", (req, res) => {
+ handleSignout(req, res).catch((err) => {
+ console.error("[auth] /auth/signout unhandled:", err);
+ res.status(500).send(renderErrorPage("Internal error."));
+ });
+ });
+}
diff --git a/server/billing-routes.js b/server/billing-routes.js
new file mode 100644
index 0000000..12c58c0
--- /dev/null
+++ b/server/billing-routes.js
@@ -0,0 +1,252 @@
+// Self-serve subscription purchase (multi-mode / cloud only).
+//
+// Lets a signed-in cloud user buy their OWN prepaid Pro/Max period
+// instead of waiting for the operator to grant it by hand. The relay
+// owns the subscription (keyed by Recaps user-id, per the core-
+// decoupling); Recaps just brokers the purchase:
+//
+// POST /api/billing/buy → ask the relay to mint a BTCPay invoice
+// for {tier}; return the checkout URL the
+// frontend opens. On settlement the relay's
+// webhook extends the user's tier.
+// GET /api/billing/status → pull the user's current (expiry-enforced)
+// tier from the relay and refresh the local
+// users.tier cache so the badge flips the
+// moment payment lands. The frontend polls
+// this after opening checkout.
+//
+// Auth: both routes require a real signed-in user (req.user.id). Anon /
+// trial visitors (req.userId = "anon:") are refused — a tier is
+// keyed to a durable user-id, which a trial cookie isn't.
+//
+// These live under /api/billing (NOT /api/subscriptions — that prefix is
+// the channel-subscriptions feature, which is itself Pro-gated; a free
+// user must be able to reach the buy flow). The prefix is added to the
+// license middleware's open list so the activation gate lets Core users
+// through to purchase.
+
+import { getDb } from "./db.js";
+import { requireUser } from "./tenant-auth.js";
+import {
+ createRelayTierInvoice,
+ createRelayZapriteOrder,
+ getRelayUserTier,
+ getRelayTierPlans,
+} from "./providers/relay.js";
+
+const BUYABLE_TIERS = new Set(["pro", "max"]);
+const PAYMENT_METHODS = new Set(["bitcoin", "card"]);
+
+// Fallback prices (sats / 30-day period) used only when the relay is
+// unreachable while rendering the picker — matches the relay config
+// defaults so the UI never shows a blank price. The actual charge is
+// always computed relay-side at invoice time.
+const FALLBACK_PLANS = {
+ period_days: 30,
+ plans: [
+ { tier: "pro", sats: 21000 },
+ { tier: "max", sats: 42000 },
+ ],
+};
+
+// Pull the user's effective (expiry-enforced) tier from the relay — the
+// authoritative subscription owner — and update the cached users.tier if
+// it drifted. Returns { tier, expires_at, synced } or { synced:false }
+// when the relay is unreachable / unconfigured (caller falls back to the
+// cached value rather than erroring the request).
+export async function syncUserTierFromRelay(userId) {
+ if (!userId) return { synced: false };
+ let report;
+ try {
+ report = await getRelayUserTier({ userId });
+ } catch (err) {
+ console.warn(
+ `[billing] relay tier read failed for ${userId}: ${err?.message || err}`,
+ );
+ return { synced: false };
+ }
+ // getRelayUserTier swallows errors and returns null when the relay
+ // base URL / operator key isn't configured. Treat that as "can't
+ // sync" rather than "downgrade to core".
+ if (!report || typeof report.tier !== "string") {
+ return { synced: false };
+ }
+ const tier = report.tier; // already expiry-enforced by the relay
+ const expiresAt = report.subscription_expires_at || null;
+ try {
+ const db = getDb();
+ const row = db.prepare("SELECT tier FROM users WHERE id = ?").get(userId);
+ if (row && row.tier !== tier) {
+ db.prepare("UPDATE users SET tier = ? WHERE id = ?").run(tier, userId);
+ console.log(
+ `[billing] synced ${userId} tier ${row.tier || "core"} → ${tier} from relay`,
+ );
+ }
+ } catch (err) {
+ console.warn(
+ `[billing] tier cache update failed for ${userId}: ${err?.message || err}`,
+ );
+ }
+ return {
+ tier,
+ expires_at: expiresAt,
+ tier_snapshot: report.tier_snapshot || tier,
+ subscription_expired: !!report.subscription_expired,
+ synced: true,
+ };
+}
+
+// Build the buyer-facing origin so the BTCPay checkout can redirect back
+// to the app after settlement. Honors the reverse-proxy forwarding
+// headers StartOS sets in front of the service.
+function originFor(req) {
+ const proto =
+ (req.headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
+ req.protocol ||
+ "https";
+ const host = req.headers["x-forwarded-host"] || req.headers.host || "";
+ return host ? `${proto}://${host}` : "";
+}
+
+export function setupBillingRoutes(app) {
+ // GET /api/billing/plans → { period_days, plans: [{tier, sats}] }
+ // Powers the purchase picker. Prices come from the relay (the pricing
+ // source of truth); falls back to the config defaults if the relay is
+ // briefly unreachable so the modal still renders.
+ app.get("/api/billing/plans", requireUser, async (req, res) => {
+ if (!req.user || !req.user.id) {
+ return res.status(403).json({ error: "must_be_signed_in" });
+ }
+ try {
+ const data = await getRelayTierPlans();
+ if (data && Array.isArray(data.plans) && data.plans.length) {
+ return res.json({
+ period_days: data.period_days || FALLBACK_PLANS.period_days,
+ plans: data.plans,
+ // Whether the card (Zaprite) rail is configured — the UI hides
+ // the "Pay by card" link when false so it never offers a rail
+ // that 503s. Bitcoin is always available (the BTCPay rail).
+ card_available: !!data.card_available,
+ source: "relay",
+ });
+ }
+ } catch (err) {
+ console.warn(`[billing] plans read failed: ${err?.message || err}`);
+ }
+ return res.json({ ...FALLBACK_PLANS, card_available: false, source: "fallback" });
+ });
+
+ // POST /api/billing/buy body: { tier: "pro" | "max", method?: "bitcoin" | "card" }
+ // Bitcoin (default) → BTCPay invoice; card → Zaprite hosted checkout.
+ // Returns { ok, method, checkout_url, tier, period_days, ... }.
+ app.post("/api/billing/buy", requireUser, async (req, res) => {
+ // Must be a real signed-in user — a tier is keyed to a durable
+ // user-id, not an anon trial cookie.
+ if (!req.user || !req.user.id) {
+ return res.status(403).json({
+ error: "must_be_signed_in",
+ message: "Sign in to buy a subscription.",
+ });
+ }
+ const tier = String(req.body?.tier || "").trim().toLowerCase();
+ if (!BUYABLE_TIERS.has(tier)) {
+ return res.status(400).json({
+ error: "bad_tier",
+ message: 'tier must be "pro" or "max"',
+ });
+ }
+ const method = String(req.body?.method || "bitcoin").trim().toLowerCase();
+ if (!PAYMENT_METHODS.has(method)) {
+ return res.status(400).json({
+ error: "bad_method",
+ message: 'method must be "bitcoin" or "card"',
+ });
+ }
+ const origin = originFor(req);
+ // Land back on the app with a marker the frontend uses to kick an
+ // immediate status sync (the modal also polls, so this is a courtesy
+ // for buyers who follow the checkout redirect).
+ const returnUrl = origin ? `${origin}/?billing=success` : null;
+ try {
+ if (method === "card") {
+ const order = await createRelayZapriteOrder({
+ userId: req.user.id,
+ tier,
+ returnUrl,
+ });
+ return res.json({
+ ok: true,
+ method: "card",
+ checkout_url: order.checkout_url || null,
+ order_id: order.order_id || null,
+ amount: order.amount ?? null,
+ currency: order.currency || null,
+ tier: order.tier || tier,
+ period_days: order.period_days ?? null,
+ });
+ }
+ // Bitcoin (default) — BTCPay invoice.
+ const invoice = await createRelayTierInvoice({
+ userId: req.user.id,
+ tier,
+ returnUrl,
+ });
+ res.json({
+ ok: true,
+ method: "bitcoin",
+ checkout_url: invoice.checkout_url || null,
+ invoice_id: invoice.invoice_id || null,
+ sats: invoice.sats ?? null,
+ tier: invoice.tier || tier,
+ period_days: invoice.period_days ?? null,
+ // Lightning BOLT11 for the inline QR (no redirect). Null → the app
+ // falls back to opening the hosted checkout_url.
+ bolt11: invoice.bolt11 || null,
+ lightning_payment_link: invoice.lightning_payment_link || null,
+ lightning_expires_at: invoice.lightning_expires_at || null,
+ });
+ } catch (err) {
+ const status = err?.status || 502;
+ console.error(
+ `[billing] buy failed for ${req.user.id} (${tier}/${method}): ${err?.message || err}`,
+ );
+ // 503 from the relay = that rail isn't configured; surface a hint.
+ const notConfigured =
+ status === 503 || /not[_ ]configured/i.test(err?.message || "");
+ const rail = method === "card" ? "Card" : "Bitcoin";
+ const tool = method === "card" ? "Zaprite" : "BTCPay";
+ res.status(notConfigured ? 503 : 502).json({
+ error: notConfigured ? "payments_unavailable" : "checkout_failed",
+ message: notConfigured
+ ? `${rail} payments aren't set up on this server yet. Ask the operator to configure ${tool}.`
+ : "Couldn't start the payment. Please try again in a moment.",
+ });
+ }
+ });
+
+ // GET /api/billing/status
+ // Returns { tier, expires_at, synced } — the user's current relay-owned
+ // tier, with the local cache refreshed as a side effect.
+ app.get("/api/billing/status", requireUser, async (req, res) => {
+ if (!req.user || !req.user.id) {
+ return res.status(403).json({ error: "must_be_signed_in" });
+ }
+ const synced = await syncUserTierFromRelay(req.user.id);
+ if (synced.synced) {
+ return res.json({
+ tier: synced.tier,
+ expires_at: synced.expires_at,
+ tier_snapshot: synced.tier_snapshot,
+ subscription_expired: synced.subscription_expired,
+ synced: true,
+ });
+ }
+ // Relay unreachable / unconfigured — fall back to the cached tier so
+ // the UI still renders a sane badge instead of erroring.
+ return res.json({
+ tier: req.user.tier || "core",
+ expires_at: null,
+ synced: false,
+ });
+ });
+}
diff --git a/server/chunked-analyze.js b/server/chunked-analyze.js
new file mode 100644
index 0000000..301b9b1
--- /dev/null
+++ b/server/chunked-analyze.js
@@ -0,0 +1,487 @@
+// Chunked topic-analysis: split a long transcript into overlapping
+// time-windowed slices, analyze each slice in parallel, stitch the
+// returned sections back into one coherent list.
+//
+// Why: a single-shot analyze call against a 2-hour transcript spends
+// most of its wall-time on prefill (typically 25K+ tokens). Splitting
+// into 18-min slices gives the model a much smaller prompt per call,
+// and firing the slices concurrently lets the backend (relay/vLLM or
+// Gemini) batch them. End-to-end wall-time drops from minutes to
+// tens of seconds for long content, with no quality regression as
+// long as the slice boundaries are chosen with overlap and the
+// stitcher trusts the second slice for the overlap region.
+//
+// Public entry point: runChunkedAnalysis().
+
+import { buildAnalysisPrompt } from "./gemini-helpers.js";
+
+// ── Tunables ────────────────────────────────────────────────────────────────
+// Window body: the part of a chunk that "owns" its topic boundaries.
+// Overlap: a tail appended to each window so a topic spanning a
+// boundary still gets seen in full by at least one window.
+// Stride = body. Windows advance by `body` seconds; each window
+// covers `body + overlap` seconds of audio.
+const WINDOW_BODY_SECONDS = 18 * 60; // 18 min
+const WINDOW_OVERLAP_SECONDS = 2 * 60; // 2 min
+// Don't chunk below this duration. A single analyze call against
+// <25 min is fast on its own and avoids the stitching complexity
+// for the common short-content case.
+// Exported so the orchestrator can mirror the decision when picking
+// whether to coalesce: above this duration the chunker handles
+// granularity per-window, so the pre-chunk coalesce is unnecessary
+// and would hurt section-boundary precision.
+export const CHUNKING_CUTOFF_SECONDS = 25 * 60; // 25 min
+// Max concurrent analyze calls in flight. Gemini paid Tier 1 allows
+// ~1000 RPM for flash and ~150 RPM for pro — 12 in-flight is well
+// under either ceiling and saturates most operator workloads
+// without queueing. Operator hardware (vLLM on a single Spark) caps
+// out around 8-12 concurrent for our prompt size, so 12 is a
+// reasonable cross-backend default.
+const DEFAULT_CONCURRENCY = 12;
+
+// ── Window planning ─────────────────────────────────────────────────────────
+// Plans a set of overlapping windows over the entries array. Each
+// window has:
+// - startIdx, endIdx: inclusive bounds into the entries array
+// - bodyStartIdx: index where this window's "body" begins
+// (i.e., everything before this index is the
+// overlap with the previous window's tail)
+// The first window has bodyStartIdx === startIdx. Windows after the
+// first have bodyStartIdx > startIdx by ~overlap seconds.
+//
+// The stitcher uses bodyStartIdx of window N+1 to decide whether a
+// section from window N falls in the contested overlap region.
+export function planAnalysisWindows(entries, opts = {}) {
+ const bodySec = opts.bodySeconds ?? WINDOW_BODY_SECONDS;
+ const overlapSec = opts.overlapSeconds ?? WINDOW_OVERLAP_SECONDS;
+ const totalSec = (entries[entries.length - 1].offset || 0) +
+ (entries[entries.length - 1].duration || 0);
+ const cutoffSec = opts.cutoffSeconds ?? CHUNKING_CUTOFF_SECONDS;
+ if (totalSec <= cutoffSec) {
+ return [{ startIdx: 0, endIdx: entries.length - 1, bodyStartIdx: 0 }];
+ }
+
+ const windows = [];
+ let bodyStartSec = 0;
+ while (bodyStartSec < totalSec) {
+ // The window's covered span (body + tail overlap):
+ const windowEndSec = bodyStartSec + bodySec + overlapSec;
+ // Body start in entry-index space: first entry with offset >= bodyStartSec.
+ const bodyStartIdx = firstEntryAtOrAfter(entries, bodyStartSec);
+ // If there are NO entries at or after bodyStartSec, we've consumed
+ // all entries. Stop the loop.
+ if (bodyStartIdx >= entries.length) break;
+ // GAP HANDLING: if the next entry after bodyStartSec is far in
+ // the future (past this window's body + overlap), there's a gap
+ // in the transcript timeline. This commonly happens when the
+ // transcribe step truncated a middle chunk — the timeline has
+ // valid entries at, e.g., 0-31 min and 90-94 min but nothing in
+ // between. Without this fix, the old loop would BREAK at the gap
+ // (because endIdx < bodyStartIdx triggered the "sparse trailing
+ // window" exit), silently dropping the entries past the gap from
+ // analysis entirely. Now we jump bodyStartSec forward to the
+ // next entry's offset (rounded down to a body-stride boundary
+ // so subsequent window alignment stays sensible) and continue.
+ const nextEntryOffset = entries[bodyStartIdx].offset || 0;
+ if (nextEntryOffset >= windowEndSec) {
+ bodyStartSec = Math.max(
+ bodyStartSec + bodySec,
+ Math.floor(nextEntryOffset / bodySec) * bodySec
+ );
+ continue;
+ }
+ // Window's entry range: from the start of overlap-with-prior
+ // (i.e., bodyStartSec - overlapSec, clamped at 0) through windowEndSec.
+ const overlapWithPriorSec = Math.max(0, bodyStartSec - overlapSec);
+ const startIdx = firstEntryAtOrAfter(entries, overlapWithPriorSec);
+ const endIdx = lastEntryBefore(entries, windowEndSec);
+ if (endIdx < bodyStartIdx) {
+ // Defensive: shouldn't happen with the gap-handling above, but
+ // if it does, advance the body cursor rather than break so we
+ // don't get stuck.
+ bodyStartSec += bodySec;
+ continue;
+ }
+ windows.push({ startIdx, endIdx, bodyStartIdx });
+ // Stop if this window already covers the last entry.
+ if (endIdx >= entries.length - 1) break;
+ bodyStartSec += bodySec;
+ }
+ return windows;
+}
+
+function firstEntryAtOrAfter(entries, sec) {
+ // Linear scan; entries are sorted by offset.
+ for (let i = 0; i < entries.length; i++) {
+ if ((entries[i].offset || 0) >= sec) return i;
+ }
+ return entries.length;
+}
+
+function lastEntryBefore(entries, sec) {
+ // Largest i s.t. entries[i].offset < sec.
+ let ans = -1;
+ for (let i = 0; i < entries.length; i++) {
+ if ((entries[i].offset || 0) < sec) ans = i;
+ else break;
+ }
+ // If no entry has offset < sec, return -1 → caller treats as empty.
+ // If the whole array fits, return entries.length - 1.
+ return ans === -1 ? -1 : ans;
+}
+
+// ── Parallel analyzer ───────────────────────────────────────────────────────
+// Fires N analyze calls concurrently with a bounded in-flight count.
+// Each call gets its own slice of entries plus a freshly-built prompt.
+// Returns array of { window, ok, sections | error, cost, model }.
+//
+// Errors are isolated per window — a single-window failure doesn't
+// fail the whole batch. The stitcher gets to decide what to do
+// about gaps.
+async function analyzeWindowsInParallel({
+ entries,
+ windows,
+ analyzer,
+ fallbackModels,
+ concurrency,
+ onProgress,
+ onWindowComplete,
+ signal,
+ jobId,
+ // Total audio duration in seconds — passed through to
+ // buildAnalysisPrompt so the section-count target scales with the
+ // full video length (not just per-window). Recap-relay does the
+ // same; matching here keeps segmentation density consistent
+ // across both pipelines. When omitted, buildAnalysisPrompt falls
+ // back to deriving from the entries themselves.
+ totalAudioSec = 0,
+}) {
+ const results = new Array(windows.length);
+ let next = 0;
+ let completed = 0;
+
+ async function worker() {
+ while (true) {
+ if (signal?.aborted) return;
+ const my = next++;
+ if (my >= windows.length) return;
+ const w = windows[my];
+ const windowEntries = entries.slice(w.startIdx, w.endIdx + 1);
+ const prompt = buildAnalysisPrompt(windowEntries, { totalAudioSec });
+ // Try the configured model first, then walk fallbacks.
+ let lastErr = null;
+ let result = null;
+ let usedModel = null;
+ for (const tryModel of fallbackModels) {
+ try {
+ result = await analyzer.analyzeText({
+ prompt,
+ model: tryModel,
+ onProgress: () => {}, // suppress per-chunk progress noise
+ signal,
+ jobId,
+ });
+ usedModel = tryModel;
+ break;
+ } catch (err) {
+ if (signal?.aborted) return;
+ lastErr = err;
+ }
+ }
+ if (!result) {
+ results[my] = { window: w, ok: false, error: lastErr };
+ completed++;
+ onProgress?.(`Window ${my + 1}/${windows.length} failed: ${lastErr?.message?.slice(0, 100) || "unknown"}`);
+ continue;
+ }
+ const parsed = safeParseSections(result.text);
+ if (!parsed) {
+ results[my] = { window: w, ok: false, error: new Error("invalid JSON") };
+ completed++;
+ onProgress?.(`Window ${my + 1}/${windows.length} returned invalid JSON`);
+ continue;
+ }
+ results[my] = {
+ window: w,
+ ok: true,
+ sections: parsed.sections,
+ model: usedModel,
+ cost: result.cost,
+ };
+ completed++;
+ onProgress?.(`Window ${my + 1}/${windows.length} done (${parsed.sections.length} topics)`);
+
+ // Fire the streaming callback with this window's BODY-OWNED
+ // sections — the ones the final stitcher will keep from this
+ // window. Computed deterministically per-window so the UI can
+ // render incrementally as windows arrive (even out of order),
+ // without later having to "undo" any displayed sections.
+ //
+ // Rule: window N owns sections whose globalStart falls before
+ // window(N+1).bodyStartIdx. Sections starting at or after the
+ // next window's body are deferred — window N+1 will produce an
+ // authoritative version of them with more downstream context.
+ if (onWindowComplete) {
+ const nextBody = my + 1 < windows.length
+ ? windows[my + 1].bodyStartIdx
+ : Infinity;
+ const offset = w.startIdx;
+ const owned = [];
+ for (const s of parsed.sections) {
+ const globalStart = offset + (s.startIndex ?? 0);
+ const globalEnd = offset + (s.endIndex ?? 0);
+ if (globalStart >= nextBody) continue;
+ owned.push({
+ startIndex: globalStart,
+ endIndex: globalEnd,
+ title: s.title,
+ summary: s.summary,
+ });
+ }
+ try {
+ await onWindowComplete({
+ windowIdx: my,
+ totalWindows: windows.length,
+ ownedSections: owned,
+ });
+ } catch (cbErr) {
+ // Callback errors must not derail the analyze loop —
+ // streaming is best-effort and the canonical result still
+ // ships at the end.
+ console.warn(
+ `[chunked-analyze] onWindowComplete callback failed: ${cbErr?.message || cbErr}`
+ );
+ }
+ }
+ }
+ }
+
+ const workers = Array.from({ length: Math.min(concurrency, windows.length) }, worker);
+ await Promise.all(workers);
+ return results;
+}
+
+function safeParseSections(text) {
+ if (!text) return null;
+ let jsonStr = text.trim();
+ const cb = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
+ if (cb) jsonStr = cb[1].trim();
+ try {
+ const parsed = JSON.parse(jsonStr);
+ if (!parsed || !Array.isArray(parsed.sections)) return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+// ── Stitcher ────────────────────────────────────────────────────────────────
+// Merges per-window section lists into a single ordered list of
+// non-overlapping sections referencing entries by their position in
+// the FULL (un-chunked) entries array.
+//
+// The rule: each window N owns sections whose globalStart falls in
+// its body (i.e., globalStart < window(N+1).bodyStartIdx). Any
+// section starting at or after the next window's body boundary is
+// dropped because the next window will have produced a better
+// version of that same section with more downstream context. The
+// last window has no successor, so all its sections are kept.
+//
+// After collection, sections are sorted and any residual overlap
+// (which shouldn't happen if windows are well-formed but might
+// arise from model index errors) is repaired by clamping endIndex
+// to the next section's startIndex - 1.
+export function stitchAnalysisResults(results) {
+ const out = [];
+ for (let i = 0; i < results.length; i++) {
+ const r = results[i];
+ if (!r || !r.ok) continue;
+ const next = results[i + 1];
+ const nextBody = next && next.window
+ ? next.window.bodyStartIdx
+ : Infinity;
+ const offset = r.window.startIdx;
+ for (const s of r.sections) {
+ const globalStart = offset + (s.startIndex ?? 0);
+ const globalEnd = offset + (s.endIndex ?? 0);
+ // Drop sections that begin in the next window's body — the
+ // next window's analysis is authoritative for that range.
+ if (globalStart >= nextBody) continue;
+ out.push({
+ startIndex: globalStart,
+ endIndex: globalEnd,
+ title: s.title,
+ summary: s.summary,
+ });
+ }
+ }
+ // Order + repair overlaps (defensive — shouldn't trigger with
+ // well-behaved model output, but the existing single-shot path
+ // doesn't either and this matches its robustness).
+ out.sort((a, b) => a.startIndex - b.startIndex);
+ for (let i = 0; i < out.length - 1; i++) {
+ if (out[i].endIndex >= out[i + 1].startIndex) {
+ out[i].endIndex = out[i + 1].startIndex - 1;
+ }
+ }
+ return out.filter((s) => s.endIndex >= s.startIndex);
+}
+
+// ── Public entry point ──────────────────────────────────────────────────────
+// Runs chunked analysis end-to-end. Returns the same envelope shape
+// callers expect from a single-shot analyzer.analyzeText() call:
+// {
+// text: "", // for prompt/result parity
+// model: "",
+// cost: { total cost across all windows, summed },
+// usage: null, // no aggregate usage
+// attempts: { windows: N, failed: K } // diagnostic
+// }
+// The caller parses .text the same way it parses a single-shot
+// response — no changes to the downstream chunk-building code.
+//
+// Falls back to single-shot if planning produces just one window
+// (i.e., content is below the chunking cutoff). If all windows fail,
+// throws so the caller's existing fallback (try next model) kicks in.
+export async function runChunkedAnalysis({
+ entries,
+ analyzer,
+ fallbackModels,
+ concurrency = DEFAULT_CONCURRENCY,
+ onProgress = () => {},
+ onWindowComplete = null,
+ signal,
+ jobId,
+}) {
+ const windows = planAnalysisWindows(entries);
+ if (windows.length === 1) {
+ // Single-shot path — same as the legacy code does, but routed
+ // through here so callers have one entry point. Log message
+ // distinguishes the two reasons we end up here:
+ // (a) totalSec ≤ cutoff — short content, intentionally not chunked
+ // (b) entries are too sparse for multi-window planning — the loop
+ // broke after one window. Surfaces an awkward state that's
+ // usually a sign of bad upstream data (e.g. transcribe emitted
+ // bogus far-future timestamps that the sanity-cap dropped).
+ const lastEntry = entries[entries.length - 1];
+ const totalSec = (lastEntry?.offset || 0) + (lastEntry?.duration || 0);
+ if (totalSec <= CHUNKING_CUTOFF_SECONDS) {
+ onProgress(
+ `Content ≤${Math.round(CHUNKING_CUTOFF_SECONDS / 60)} min — running single-shot analysis`
+ );
+ } else {
+ onProgress(
+ `Single window planned over ${entries.length} entries (last @ ${Math.round(totalSec / 60)} min) — running single-shot analysis`
+ );
+ }
+ return await runSingleShot({
+ entries,
+ analyzer,
+ fallbackModels,
+ onProgress,
+ signal,
+ jobId,
+ });
+ }
+ onProgress(
+ `Chunked analysis: ${windows.length} windows of ~18 min each, up to ${concurrency} in parallel`
+ );
+ // Compute total audio duration from the last entry's offset so the
+ // section-count target (in buildAnalysisPrompt) scales with the
+ // FULL video length, not just per-window. Matches recap-relay's
+ // per-video-duration target methodology for consistent segmentation
+ // density across both pipelines.
+ const totalAudioSec = entries.length > 0
+ ? (entries[entries.length - 1].offset || 0) + (entries[entries.length - 1].duration || 0)
+ : 0;
+ const results = await analyzeWindowsInParallel({
+ entries,
+ windows,
+ analyzer,
+ fallbackModels,
+ concurrency,
+ onProgress,
+ onWindowComplete,
+ signal,
+ jobId,
+ totalAudioSec,
+ });
+ // If the caller aborted mid-flight, some result slots may be empty.
+ // Surface cancellation cleanly to the outer pipeline.
+ if (signal?.aborted) {
+ const e = new Error("aborted");
+ e.name = "AbortError";
+ throw e;
+ }
+ const completed = results.filter(Boolean);
+ const failures = completed.filter((r) => !r.ok);
+ if (completed.length === 0 || failures.length === completed.length) {
+ throw new Error(
+ `All ${results.length} analyze windows failed. First error: ${
+ failures[0]?.error?.message || "unknown"
+ }`
+ );
+ }
+ const stitched = stitchAnalysisResults(results);
+ // Aggregate model attribution: pick the most-used successful model.
+ const modelTally = new Map();
+ let totalCost = 0;
+ for (const r of results) {
+ if (!r.ok) continue;
+ modelTally.set(r.model, (modelTally.get(r.model) || 0) + 1);
+ const c = typeof r.cost?.totalCost === "string"
+ ? parseFloat(r.cost.totalCost)
+ : r.cost?.totalCost || 0;
+ if (Number.isFinite(c)) totalCost += c;
+ }
+ const dominantModel = [...modelTally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || null;
+ onProgress(
+ `Chunked analysis complete — ${results.length - failures.length}/${results.length} windows succeeded, ${stitched.length} topics`
+ );
+ return {
+ text: JSON.stringify({ sections: stitched }),
+ model: dominantModel,
+ cost: {
+ totalCost: totalCost.toFixed(6),
+ totalCostDisplay: totalCost < 0.01
+ ? `$${(totalCost * 100).toFixed(3)}¢`
+ : `$${totalCost.toFixed(4)}`,
+ },
+ usage: null,
+ attempts: { windows: results.length, failed: failures.length },
+ };
+}
+
+async function runSingleShot({
+ entries,
+ analyzer,
+ fallbackModels,
+ onProgress,
+ signal,
+ jobId,
+}) {
+ // Single-shot path: the whole transcript IS the "window". Compute
+ // totalAudioSec from the entries so the section-count target picker
+ // chooses the right bucket (<30 min → 6 sections, 30-60 → 8, etc.).
+ const totalAudioSec = entries.length > 0
+ ? (entries[entries.length - 1].offset || 0) + (entries[entries.length - 1].duration || 0)
+ : 0;
+ const prompt = buildAnalysisPrompt(entries, { totalAudioSec });
+ let lastErr = null;
+ for (const tryModel of fallbackModels) {
+ try {
+ const result = await analyzer.analyzeText({
+ prompt,
+ model: tryModel,
+ onProgress,
+ signal,
+ jobId,
+ });
+ return result;
+ } catch (err) {
+ if (signal?.aborted) throw err;
+ lastErr = err;
+ }
+ }
+ throw lastErr || new Error("All analysis models failed");
+}
diff --git a/server/config.js b/server/config.js
index 9e87f17..0185fc7 100644
--- a/server/config.js
+++ b/server/config.js
@@ -25,6 +25,14 @@ let startosConfigPath = null;
export let serverApiKey = "";
+// Core-decoupling shared "operator key" — read live from the StartOS
+// config sidecar the same way serverApiKey is, so the operator can set it
+// via the "Set Relay Operator Key" action without a service restart.
+// `RECAP_RELAY_OPERATOR_KEY` env pins the value (local dev). Consumed by
+// relay-default.js's getRelayOperatorKey(); see that for the semantics.
+let envRelayOperatorKey = "";
+export let relayOperatorKey = "";
+
// ── Init ────────────────────────────────────────────────────────────────────
// Call once at boot. Sets up paths, reads the initial value, kicks off the
// poll loop. Idempotent if you really want to call it twice (the interval
@@ -35,13 +43,17 @@ export async function initConfig({ dataDir }) {
startosConfigPath = path.join(configDir, "startos-config.json");
envApiKey = process.env.GEMINI_API_KEY || "";
serverApiKey = envApiKey;
+ envRelayOperatorKey = (process.env.RECAP_RELAY_OPERATOR_KEY || "").trim();
+ relayOperatorKey = envRelayOperatorKey;
await fs.mkdir(configDir, { recursive: true }).catch(() => {});
await refreshServerApiKey("startup");
+ await refreshRelayOperatorKey("startup");
const pollMs = parseInt(process.env.RECAP_CONFIG_POLL_MS || "3000", 10);
setInterval(() => {
refreshServerApiKey("config poll").catch(() => {});
+ refreshRelayOperatorKey("config poll").catch(() => {});
}, pollMs);
}
@@ -75,6 +87,27 @@ async function refreshServerApiKey(reason) {
}
}
+async function readRelayOperatorKeyFromConfig() {
+ try {
+ const content = await fs.readFile(startosConfigPath, "utf-8");
+ const config = JSON.parse(content);
+ return (config.recap_relay_operator_key || "").trim();
+ } catch {
+ return "";
+ }
+}
+
+async function refreshRelayOperatorKey(reason) {
+ if (envRelayOperatorKey) return; // env var pins the value
+ const next = await readRelayOperatorKeyFromConfig();
+ if (next !== relayOperatorKey) {
+ relayOperatorKey = next;
+ console.log(
+ `[config] relay operator key ${next ? "loaded" : "cleared"} (${reason})`,
+ );
+ }
+}
+
// ── Public helpers ──────────────────────────────────────────────────────────
// Resolves the per-request key — either the client's own (BYO) or the
// server's stored key (when the client signals USE_SERVER_KEY or sends
@@ -132,4 +165,7 @@ export async function mergeConfig(patch) {
if (Object.prototype.hasOwnProperty.call(patch, "gemini_api_key")) {
await refreshServerApiKey("merge config");
}
+ if (Object.prototype.hasOwnProperty.call(patch, "recap_relay_operator_key")) {
+ await refreshRelayOperatorKey("merge config");
+ }
}
diff --git a/server/credits-purchase.js b/server/credits-purchase.js
new file mode 100644
index 0000000..a8232a6
--- /dev/null
+++ b/server/credits-purchase.js
@@ -0,0 +1,671 @@
+// Recap-side proxy to the relay's credit-purchase endpoints.
+//
+// Architecture is identical to license-purchase.js: Recap doesn't
+// hold the BTCPay credentials, the relay does. Recap just forwards
+// the buyer's pick to the relay and proxies the polling. The relay
+// returns the BTCPay checkout URL which the Recap UI displays in a
+// modal styled to match the license-purchase modal.
+//
+// Endpoints:
+// GET /api/credits/packages → relay GET /relay/credits/packages
+// POST /api/credits/buy → relay POST /relay/credits/buy
+// GET /api/credits/invoice/:id → relay GET /relay/credits/invoice/:id
+//
+// Auth headers (X-Recap-Install-Id + Authorization Bearer LIC1-...)
+// are added by this proxy, not by the buyer-side JS — keeping the
+// install identity + license key out of any client-side code.
+
+import { getRelayBaseURL } from "./relay-default.js";
+import { getInstallId } from "./install-id.js";
+import { getRawLicenseKey } from "./license.js";
+
+// Multi-mode toggle. In multi mode every credit purchase is recorded
+// in pending_purchases so we know WHO (signed-in user vs. anon trial
+// cookie) to credit locally when the invoice settles. Single mode is
+// the legacy "operator-pool only" flow — no local accounting layer,
+// the relay's credits.json IS the source of truth.
+const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single";
+
+function relayHeaders({ json = false, req = null } = {}) {
+ const h = {};
+ // Identity routing for the credit-purchase + credit-poll flow:
+ //
+ // Pro/Max signed-in tenant (req.user.keysat_license set)
+ // → use THEIR install ID + license. The buy invoice gets
+ // stashed with THEIR license_fingerprint so the BTCPay
+ // settle-webhook credits THEIR license-keyed pool — the
+ // same pool /api/relay/status reads when displaying their
+ // balance. Without this, credits land on the operator's
+ // pool and the buyer sees their balance unchanged after
+ // paying (the bug Grant hit on 2026-05-18).
+ //
+ // Anon visitor (trial cookie only) / free signed-in tenant
+ // (no license) / single-mode operator
+ // → fall back to operator identity. The operator's pool is
+ // what's being topped up; Recaps' own accounting layer
+ // (anon_trials / tenant_credits) handles the per-user
+ // attribution locally via pending_purchases.
+ let installId = null;
+ let licenseKey = null;
+ if (req?.user?.keysat_license && req.user.synthetic_install_id) {
+ installId = req.user.synthetic_install_id;
+ licenseKey = req.user.keysat_license;
+ }
+ if (!installId) {
+ try {
+ const id = getInstallId();
+ if (id) installId = id;
+ } catch {}
+ }
+ if (!licenseKey) {
+ try {
+ const key = getRawLicenseKey();
+ if (key) licenseKey = key;
+ } catch {}
+ }
+ if (installId) h["X-Recap-Install-Id"] = installId;
+ if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`;
+ if (json) h["Content-Type"] = "application/json";
+ return h;
+}
+
+export function setupCreditsPurchaseRoutes(app) {
+ // List bundles the operator has configured. Cheap, no auth gating —
+ // the buyer needs the price menu before they decide whether to pay.
+ app.get("/api/credits/packages", async (_req, res) => {
+ const base = getRelayBaseURL();
+ if (!base) {
+ return res.status(503).json({
+ error: "relay_not_configured",
+ message: "Relay base URL not set on this Recaps install.",
+ });
+ }
+ try {
+ // 10s timeout — was 5s, but a cold relay request from mobile
+ // cellular can take 6-8s, and Safari iOS surfaces the abort as
+ // a generic "Load failed" with no other info, so the buyer
+ // sees an error and has to manually retry. 10s is still snappy
+ // enough that a legit failure doesn't hang the UI for long.
+ const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/packages`, {
+ signal: AbortSignal.timeout(10000),
+ });
+ const text = await r.text();
+ let body = null;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {}
+ if (!r.ok) {
+ return res.status(r.status).json(body || { error: "relay_packages_failed" });
+ }
+ res.json(body || { packages: [] });
+ } catch (err) {
+ console.error(`[credits/packages] failed: ${err?.message || err}`);
+ res.status(502).json({
+ error: "packages_fetch_failed",
+ message: (err?.message || String(err)).slice(0, 300),
+ });
+ }
+ });
+
+ // Initiate a purchase. Body: { credits: 1|5|10|20 }. Returns the
+ // raw relay envelope (so the UI sees credits_remaining + tier +
+ // result.checkout_url + result.invoice_id).
+ //
+ // Multi-mode: identifies the buyer (signed-in user or anon trial
+ // cookie), records a pending_purchases row keyed by the invoice_id
+ // the relay returns. The settle-handler (in /api/credits/invoice/:id
+ // below) uses that row to know WHERE to apply the credits locally.
+ //
+ // Single-mode: skips the pending_purchases bookkeeping entirely;
+ // the operator IS the buyer and the relay's credits.json directly
+ // tracks their pool.
+ app.post("/api/credits/buy", async (req, res) => {
+ const base = getRelayBaseURL();
+ if (!base) {
+ return res
+ .status(503)
+ .json({ error: "relay_not_configured" });
+ }
+ const credits = Number(req.body?.credits);
+ const returnUrl =
+ typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http")
+ ? req.body.return_url
+ : null;
+ if (!Number.isFinite(credits) || credits <= 0) {
+ return res
+ .status(400)
+ .json({ error: "credits_required" });
+ }
+
+ // Identify the buyer for multi-mode. Either a signed-in user OR
+ // an anon trial cookie. If neither, attempt to auto-mint a trial
+ // cookie — anon visitors who click "Buy more" from the toolbar
+ // (before they've spent their pre-trial allowance) shouldn't be
+ // dead-ended into a sign-in nag. Same auto-mint pattern as
+ // /api/process for pre-trial visitors. Only refuse if trials are
+ // disabled or the IP is over its lifetime cap.
+ let buyerType = null;
+ let buyerId = null;
+ if (RECAP_MODE === "multi") {
+ if (req.user && req.user.id) {
+ buyerType = "user";
+ buyerId = req.user.id;
+ } else if (req.trial && req.trial.cookie_id) {
+ buyerType = "anon";
+ buyerId = req.trial.cookie_id;
+ } else {
+ // Try to mint a fresh trial cookie so the purchase has
+ // somewhere to land. forceMint=true bypasses the lifetime
+ // IP cap and the trials-disabled config — a paying buyer is
+ // by definition not abusing a free quota, and without a
+ // tracking cookie the settle handler has nowhere to credit
+ // the purchase locally (the relay still credits the operator
+ // pool; we just lose the visibility to apply it to this
+ // specific buyer).
+ try {
+ const { issueIfEligible } = await import("./anon-trial.js");
+ const trial = await issueIfEligible({ req, res, forceMint: true });
+ if (trial) {
+ buyerType = "anon";
+ buyerId = trial.cookie_id;
+ // Stash on req for downstream code paths
+ req.trial = trial;
+ }
+ } catch (err) {
+ console.warn(
+ "[credits/buy] anon-trial mint failed:",
+ err?.message || err,
+ );
+ }
+ if (!buyerId) {
+ return res.status(401).json({
+ error: "buyer_unknown",
+ message:
+ "Couldn't create a buyer record for this purchase. Sign up for a free account so we have somewhere to credit it.",
+ });
+ }
+ }
+ }
+
+ try {
+ const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/buy`, {
+ method: "POST",
+ headers: relayHeaders({ json: true, req }),
+ body: JSON.stringify({ credits, return_url: returnUrl || undefined }),
+ signal: AbortSignal.timeout(15_000),
+ });
+ const text = await r.text();
+ let body = null;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {}
+ if (!r.ok) {
+ return res
+ .status(r.status)
+ .json(body || { error: "relay_buy_failed" });
+ }
+
+ // Record the pending purchase BEFORE we respond, so even if the
+ // browser refreshes / crashes between buy + settle, the next
+ // poll for this invoice id will still know who to credit.
+ // Invoice id lives under result.invoice_id per the relay's
+ // envelope contract (same shape license-purchase uses).
+ const invoiceId =
+ body?.result?.invoice_id ||
+ body?.invoice_id ||
+ body?.btcpay_invoice_id ||
+ null;
+ if (RECAP_MODE === "multi") {
+ if (!invoiceId) {
+ // Loud warning — without an invoice id we can't reconcile
+ // on settle. Surface the response shape so we can see what
+ // the relay actually returned and fix the field-name
+ // assumption if this fires.
+ console.warn(
+ `[credits/buy] NO invoice_id in relay response — skipping pending_purchases. Top-level keys: ${Object.keys(body || {}).join(", ")} | result keys: ${Object.keys(body?.result || {}).join(", ")}`,
+ );
+ } else if (!buyerType || !buyerId) {
+ console.warn(
+ `[credits/buy] invoice ${invoiceId}: buyer identity missing — won't auto-apply on settle.`,
+ );
+ } else {
+ try {
+ const { getDb } = await import("./db.js");
+ const result = getDb()
+ .prepare(
+ `INSERT OR IGNORE INTO pending_purchases
+ (invoice_id, buyer_type, buyer_id, credits, created_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ )
+ .run(invoiceId, buyerType, buyerId, credits, Date.now());
+ console.log(
+ `[credits/buy] tracked pending purchase invoice=${invoiceId} buyer=${buyerType}:${buyerId} credits=${credits} rowsInserted=${result.changes}`,
+ );
+ } catch (err) {
+ console.error(
+ `[credits/buy] failed to record pending purchase ${invoiceId}: ${err?.message || err}`,
+ );
+ }
+ }
+ }
+
+ res.json(body || {});
+ } catch (err) {
+ console.error(`[credits/buy] failed: ${err?.message || err}`);
+ res.status(502).json({
+ error: "purchase_failed",
+ message: (err?.message || String(err)).slice(0, 300),
+ });
+ }
+ });
+
+ // Poll an invoice's status. Returns the relay envelope; the UI
+ // reads `result.status` ("new" | "processing" | "settled" |
+ // "expired" | "invalid") and refreshes when settled.
+ //
+ // Multi-mode side effect: when the relay reports settled, we look
+ // up the matching pending_purchases row and apply the credits to
+ // the right local balance. Idempotent via applied_at — if the same
+ // invoice is polled multiple times after settle, only the first
+ // application takes effect.
+ app.get("/api/credits/invoice/:id", async (req, res) => {
+ const base = getRelayBaseURL();
+ if (!base) {
+ return res.status(503).json({ error: "relay_not_configured" });
+ }
+ const id = (req.params.id || "").trim();
+ if (!id) {
+ return res.status(400).json({ error: "missing_invoice_id" });
+ }
+ try {
+ const r = await fetch(
+ `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(id)}`,
+ {
+ headers: relayHeaders({ req }),
+ signal: AbortSignal.timeout(10_000),
+ }
+ );
+ const text = await r.text();
+ let body = null;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {}
+ if (!r.ok) {
+ return res
+ .status(r.status)
+ .json(body || { error: "relay_poll_failed" });
+ }
+
+ // Multi-mode: settle-and-apply. Status path mirrors the
+ // license-purchase poll-settle handler.
+ const status =
+ body?.result?.status || body?.status || null;
+ if (RECAP_MODE === "multi" && status === "settled") {
+ try {
+ await applyPendingPurchase(id);
+ } catch (err) {
+ console.error(
+ `[credits/invoice] apply failed for ${id}: ${err?.message || err}`,
+ );
+ // Don't fail the response — the relay reported settled and
+ // the operator pool has the credits. Local apply can be
+ // retried by hitting this endpoint again, or by a future
+ // reconciliation tool.
+ }
+ }
+
+ res.json(body || {});
+ } catch (err) {
+ console.error(`[credits/invoice] failed: ${err?.message || err}`);
+ res.status(502).json({
+ error: "poll_failed",
+ message: (err?.message || String(err)).slice(0, 300),
+ });
+ }
+ });
+
+ // POST /api/credits/claim { invoice_id }
+ // Manual self-service recovery: a signed-in user pastes the BTCPay
+ // invoice ID of a purchase they made anonymously (e.g., Safari
+ // Private mode where the trial cookie didn't survive the magic-
+ // link click). We verify the invoice is settled at the relay AND
+ // the pending_purchases row is anon-buyer + unapplied, then credit
+ // their account.
+ //
+ // Safety:
+ // - Requires authenticated user (req.user.id must be set)
+ // - Only claims buyer_type='anon' rows (no user-to-user takeover)
+ // - applied_at idempotency guard prevents double-credit
+ // - BTCPay invoice IDs are 30+ char random — not enumerable
+ // - User-buyer rows are never claimable here, regardless of
+ // ownership — those are the cookie sweep's job
+ app.post("/api/credits/claim", async (req, res) => {
+ if (RECAP_MODE !== "multi") {
+ return res.status(404).json({ error: "not_available" });
+ }
+ if (!req.user || !req.user.id) {
+ return res.status(401).json({
+ error: "auth_required",
+ message: "Sign in first to claim a purchase to your account.",
+ });
+ }
+ const invoiceId = String(req.body?.invoice_id || "").trim();
+ if (!invoiceId) {
+ return res.status(400).json({
+ error: "missing_invoice_id",
+ message: "Paste the invoice ID from your purchase email.",
+ });
+ }
+
+ const { getDb } = await import("./db.js");
+ const db = getDb();
+ const row = db
+ .prepare(
+ `SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
+ FROM pending_purchases WHERE invoice_id = ?`,
+ )
+ .get(invoiceId);
+
+ if (!row) {
+ return res.status(404).json({
+ error: "invoice_not_found",
+ message:
+ "We don't have a record of that invoice ID. Double-check it — the ID is shown in your BTCPay payment confirmation.",
+ });
+ }
+ if (row.buyer_type !== "anon") {
+ // user-buyer rows are claimable only by their original buyer
+ // (cookie sweep) — refusing this avoids user-to-user takeover.
+ return res.status(403).json({
+ error: "not_anon_purchase",
+ message:
+ "This invoice was bought from a signed-in account and can only be claimed by that account.",
+ });
+ }
+ if (row.applied_at) {
+ return res.status(409).json({
+ error: "already_applied",
+ message:
+ "Those credits were already applied. Check your balance — they may have transferred automatically.",
+ });
+ }
+
+ // Verify settled at the relay before crediting. We do NOT trust
+ // the local row alone — the buyer could have initiated the
+ // invoice and never paid; without this check, anyone could
+ // claim N credits just by knowing an invoice ID.
+ const base = getRelayBaseURL();
+ if (!base) {
+ return res.status(503).json({ error: "relay_not_configured" });
+ }
+ let status = null;
+ try {
+ const r = await fetch(
+ `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
+ { headers: relayHeaders({ req }), signal: AbortSignal.timeout(10_000) },
+ );
+ if (r.ok) {
+ const body = await r.json().catch(() => ({}));
+ status = body?.result?.status || body?.status || null;
+ }
+ } catch (err) {
+ console.warn(
+ `[credits/claim] relay status check failed for ${invoiceId}: ${err?.message || err}`,
+ );
+ return res.status(502).json({
+ error: "relay_unreachable",
+ message:
+ "Couldn't verify the invoice with the payment server. Try again in a minute.",
+ });
+ }
+ if (status !== "settled") {
+ return res.status(409).json({
+ error: "not_settled",
+ message: `That invoice is not settled (status: ${status || "unknown"}). If you just paid, wait a minute and try again.`,
+ });
+ }
+
+ try {
+ await applyPendingPurchase(invoiceId, { forceUserId: req.user.id });
+ } catch (err) {
+ console.error(
+ `[credits/claim] apply failed for ${invoiceId}: ${err?.message || err}`,
+ );
+ return res.status(500).json({
+ error: "apply_failed",
+ message: "Something went wrong applying the credits. Try again.",
+ });
+ }
+ console.log(
+ `[credits/claim] user ${req.user.id} claimed invoice ${invoiceId} (${row.credits} credits)`,
+ );
+ res.json({ ok: true, credits: row.credits });
+ });
+}
+
+// applyPendingPurchase(invoiceId, opts?) — credit the buyer's local
+// balance for a settled invoice. Idempotent: bails if the row is
+// already marked applied. If the buyer was an anon trial that has
+// since been converted to a real user, credits route to the user
+// instead.
+//
+// opts.forceUserId (optional) — route credits to this user instead
+// of the row's recorded buyer. Used by the manual-claim endpoint:
+// when a signed-in user pastes a BTCPay invoice ID for an anon
+// purchase whose trial cookie was lost (e.g., Safari Private mode
+// where the magic-link click landed in a different cookie jar), we
+// trust the invoice ID as proof-of-ownership and direct the credits
+// to their tenant_credits.
+//
+// Exported so the sweep helper below — and any future server-side
+// flow that wants to reconcile a known-settled invoice — can call it
+// without going through the /api/credits/invoice/:id route.
+export async function applyPendingPurchase(invoiceId, opts = {}) {
+ const { getDb } = await import("./db.js");
+ const db = getDb();
+ const row = db
+ .prepare(
+ `SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
+ FROM pending_purchases WHERE invoice_id = ?`,
+ )
+ .get(invoiceId);
+ if (!row) {
+ // Either the buy came from a different Recap instance, or the
+ // bookkeeping insert in /api/credits/buy failed earlier. Nothing
+ // to do; the operator pool still has the credits from BTCPay.
+ // Log so operator can reconcile manually if this fires.
+ console.warn(
+ `[credits/invoice] settled invoice ${invoiceId} has NO matching pending_purchases row — local balance NOT auto-applied. The credits ARE in the operator pool at the relay; operator should grant manually to the buyer.`,
+ );
+ return;
+ }
+ if (row.applied_at) {
+ return; // already applied, idempotent no-op
+ }
+
+ // Resolve buyer → target user_id (for tenant_credits) or trial
+ // cookie_id (for anon_trials.credits_total). Anon-buyers who have
+ // since converted to a real user get their credits routed to the
+ // user's tenant_credits — that's the cleaner outcome and matches
+ // the "credits transfer on signup" semantics the design promises.
+ let targetUserId = null;
+ let targetCookieId = null;
+ if (opts.forceUserId) {
+ targetUserId = opts.forceUserId;
+ } else if (row.buyer_type === "user") {
+ targetUserId = row.buyer_id;
+ } else if (row.buyer_type === "anon") {
+ const trial = db
+ .prepare(
+ "SELECT cookie_id, converted_to_user_id FROM anon_trials WHERE cookie_id = ?",
+ )
+ .get(row.buyer_id);
+ if (trial?.converted_to_user_id) {
+ targetUserId = trial.converted_to_user_id;
+ } else {
+ targetCookieId = row.buyer_id;
+ }
+ }
+
+ // Apply + mark applied in one transaction so a crash mid-way
+ // doesn't leave a half-credited buyer. Purchased credits land in
+ // the PERMANENT bucket (purchased_balance) so they're not wiped on
+ // the next replenishment refresh.
+ const tx = db.transaction(() => {
+ if (targetUserId) {
+ const existing = db
+ .prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?")
+ .get(targetUserId);
+ if (existing) {
+ db.prepare(
+ `UPDATE tenant_credits
+ SET purchased_balance = purchased_balance + ?,
+ lifetime_granted = lifetime_granted + ?
+ WHERE user_id = ?`,
+ ).run(row.credits, row.credits, targetUserId);
+ } else {
+ db.prepare(
+ `INSERT INTO tenant_credits
+ (user_id, purchased_balance, replenish_balance, last_replenish_at,
+ lifetime_granted, lifetime_consumed)
+ VALUES (?, ?, 0, ?, ?, 0)`,
+ ).run(targetUserId, row.credits, Date.now(), row.credits);
+ }
+ } else if (targetCookieId) {
+ // Anon trial: credits go into the trial's credits_total (single
+ // bucket — anons don't have the purchased/replenish split).
+ // They'll move to purchased_balance on signup via linkToUser.
+ db.prepare(
+ `UPDATE anon_trials
+ SET credits_total = credits_total + ?
+ WHERE cookie_id = ?`,
+ ).run(row.credits, targetCookieId);
+ }
+ db.prepare(
+ "UPDATE pending_purchases SET applied_at = ? WHERE invoice_id = ?",
+ ).run(Date.now(), invoiceId);
+ });
+ tx();
+ console.log(
+ `[credits/invoice] applied ${row.credits} credits for ${row.buyer_type}:${row.buyer_id} → ${
+ targetUserId ? "user " + targetUserId : "anon " + targetCookieId
+ }`,
+ );
+}
+
+// sweepUnappliedPurchases({ buyerType, buyerId, cookieIds }) — catch
+// up on settled-but-unapplied purchases for a buyer.
+//
+// Why this exists: the buy → BTCPay → settle → apply pipeline depends
+// on the buyer's browser tab polling /api/credits/invoice/:id after
+// BTCPay redirects back. But BTCPay redirects in the SAME tab (the
+// poll loop dies before it gets a chance to see "settled"), and even
+// when the redirect lands back on Recap the buyer might close it
+// before the next poll tick. Result: the relay knows the invoice is
+// settled and the operator pool has the credits, but the LOCAL
+// pending_purchases row never flips to applied — so the buyer's
+// balance stays stale until they manually re-poll, which they have no
+// way to do.
+//
+// Fix: opportunistically sweep on every /api/account/whoami and
+// /api/relay/status. Cheap (small bounded query + a few relay HTTP
+// calls), idempotent (applyPendingPurchase no-ops on already-applied
+// rows), and self-healing.
+//
+// Also called from anon-trial.js linkToUser BEFORE the transfer, so
+// any anon-bought credits that hadn't yet been applied locally are
+// rolled into anon_trials.credits_total before we copy them over to
+// the new user's tenant_credits.
+//
+// Scope: only sweeps the buyer's OWN pending rows. cookieIds is an
+// optional list of additional anon cookie_ids the caller wants
+// swept on this buyer's behalf (used by /whoami for the new-signup
+// case where the just-converted cookie may still have unapplied
+// purchases). Cap at 5 invoices per sweep + 30-minute lookback so a
+// degenerate case can't fan out into hundreds of relay calls per
+// request.
+export async function sweepUnappliedPurchases({
+ buyerType,
+ buyerId,
+ cookieIds = [],
+ req = null,
+} = {}) {
+ if (RECAP_MODE !== "multi") return;
+ if (!buyerType && (!cookieIds || cookieIds.length === 0)) return;
+ const base = getRelayBaseURL();
+ if (!base) return; // no relay configured, nothing to sweep against
+
+ const { getDb } = await import("./db.js");
+ const db = getDb();
+
+ // 30-minute lookback. Older unapplied purchases probably failed for
+ // a reason we don't want to keep retrying every page-load (relay
+ // unreachable, invoice expired, etc.). Operator can reconcile
+ // manually if they fire.
+ const since = Date.now() - 30 * 60 * 1000;
+
+ // Build the WHERE clause. Always include the primary buyer; OR in
+ // any extra cookieIds the caller passed.
+ const conditions = [];
+ const params = [];
+ if (buyerType && buyerId) {
+ conditions.push("(buyer_type = ? AND buyer_id = ?)");
+ params.push(buyerType, buyerId);
+ }
+ for (const cid of cookieIds) {
+ if (typeof cid === "string" && cid) {
+ conditions.push("(buyer_type = 'anon' AND buyer_id = ?)");
+ params.push(cid);
+ }
+ }
+ if (conditions.length === 0) return;
+ params.push(since);
+
+ let rows = [];
+ try {
+ rows = db
+ .prepare(
+ `SELECT invoice_id FROM pending_purchases
+ WHERE (${conditions.join(" OR ")})
+ AND applied_at IS NULL
+ AND created_at >= ?
+ ORDER BY created_at DESC
+ LIMIT 5`,
+ )
+ .all(...params);
+ } catch (err) {
+ console.warn(
+ `[credits/sweep] query failed: ${err?.message || err}`,
+ );
+ return;
+ }
+ if (rows.length === 0) return;
+
+ for (const { invoice_id: invoiceId } of rows) {
+ try {
+ const r = await fetch(
+ `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
+ {
+ headers: relayHeaders({ req }),
+ signal: AbortSignal.timeout(5_000),
+ },
+ );
+ if (!r.ok) continue;
+ const text = await r.text();
+ let body = null;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {}
+ const status = body?.result?.status || body?.status || null;
+ if (status === "settled") {
+ await applyPendingPurchase(invoiceId);
+ }
+ } catch (err) {
+ // Best-effort; swallow per-invoice errors so one bad invoice
+ // doesn't block the others (or the page-load).
+ console.warn(
+ `[credits/sweep] invoice ${invoiceId} check failed: ${err?.message || err}`,
+ );
+ }
+ }
+}
diff --git a/server/db.js b/server/db.js
new file mode 100644
index 0000000..9a09835
--- /dev/null
+++ b/server/db.js
@@ -0,0 +1,400 @@
+// Multi-tenant SQLite store — single source of truth for users,
+// sessions, magic-link tokens, subscriptions, tenant credits, and
+// the library_meta index over /data/history//*.json files.
+//
+// Created only when RECAP_MODE === 'multi'. In single mode this module
+// is never imported — `getDb()` would crash trying to require
+// better-sqlite3 anyway, but the auth-middleware short-circuits before
+// reaching it. Keep all SQLite access funneled through `getDb()` so
+// single-mode boots don't touch the native binding at all.
+//
+// Forward-only schema. No migration framework — every release is one
+// `db.exec(SCHEMA_SQL)` at boot. New columns get `ALTER TABLE …`
+// statements appended below the original CREATEs and guarded with an
+// existence check; new tables just go in fresh. Rollback is
+// "checkpoint your /data dir before upgrading."
+
+import path from "path";
+
+let dbInstance = null;
+
+const SCHEMA_SQL = `
+-- ── users ──────────────────────────────────────────────────────────────
+-- One row per authenticated end-user. The operator-owner is also a row
+-- here (is_admin = 1) so per-user library scoping works uniformly.
+CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT,
+ created_at INTEGER NOT NULL,
+ last_signin_at INTEGER,
+ synthetic_install_id TEXT NOT NULL UNIQUE,
+ keysat_license TEXT,
+ display_name TEXT,
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ -- Core-decoupling: the user's subscription tier ("core" | "pro" | "max").
+ -- The Recap Relay is the source of truth (keyed by user-id); this is the
+ -- Recaps-side cache used for feature gating, kept in sync by the operator
+ -- grant flow (which writes here AND POSTs the relay's /relay/user-tier).
+ tier TEXT NOT NULL DEFAULT 'core',
+ -- Captured at first signup for forensic / abuse-investigation use.
+ -- NOT used for auth decisions — just data for the operator to grep
+ -- when an abuse pattern shows up in the admin dashboard.
+ signup_ip TEXT,
+ signup_user_agent TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+CREATE INDEX IF NOT EXISTS idx_users_signup_ip ON users(signup_ip);
+
+-- ── sessions ───────────────────────────────────────────────────────────
+-- Server-side session store so we can revoke individual sessions from
+-- the dashboard. Cookies carry only the random session id.
+CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ last_used_at INTEGER,
+ user_agent TEXT,
+ ip_address TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
+CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
+
+-- ── magic_link_tokens ──────────────────────────────────────────────────
+-- Plaintext token only ever exists in the outbound email and the
+-- inbound verify URL — what we persist is the SHA-256 hash. Tokens are
+-- single-use (used_at NOT NULL = spent) and short-lived (15 min).
+CREATE TABLE IF NOT EXISTS magic_link_tokens (
+ token_hash TEXT PRIMARY KEY,
+ email TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ used_at INTEGER,
+ intent TEXT NOT NULL,
+ -- Request context for abuse investigation. Captured at /auth/request-link
+ -- time, never used for auth decisions — just for the recent-signups admin
+ -- view to surface scripted abuse patterns.
+ request_ip TEXT,
+ request_ua TEXT,
+ -- Anon trial cookie that was present at /auth/request-link time.
+ -- Stored server-side (NOT in the magic-link URL itself — that would
+ -- leak it to anyone who saw the email) so that at /auth/verify we
+ -- can link the trial → user even when the magic-link click lands
+ -- in a different browser / cookie jar than the one that initiated
+ -- the request (Safari Private mode + email-app in-app browser is
+ -- the canonical case). Server-side binding means the cookie ID
+ -- can't be spoofed: an attacker who intercepts the magic link
+ -- still can't change which trial gets linked.
+ trial_cookie_id TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_magic_email ON magic_link_tokens(email);
+CREATE INDEX IF NOT EXISTS idx_magic_ip ON magic_link_tokens(request_ip, created_at);
+
+-- ── subscriptions ──────────────────────────────────────────────────────
+-- One row per paid period. Multiple rows accumulate as a user renews.
+-- We don't try to model "the active subscription" — joins to MAX(started_at)
+-- with status='active' do the job and stay honest about history.
+CREATE TABLE IF NOT EXISTS subscriptions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ tier TEXT NOT NULL,
+ started_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ cancelled_at INTEGER,
+ btcpay_invoice_id TEXT,
+ amount_sats INTEGER,
+ status TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_subs_user ON subscriptions(user_id);
+
+-- ── tenant_credits ─────────────────────────────────────────────────────
+-- Per-tenant local credit ledger. Cloud users with their OWN keysat
+-- license bill the relay directly (via the license-keyed pool); this
+-- table is the source of truth for everyone else — signed-in users on
+-- the free / cloud-default tier, and family-share tenants on a self-
+-- hosted multi-tenant Recap.
+--
+-- Two buckets per user:
+-- purchased_balance — a la carte purchases + admin grants + carry-over
+-- from anon trial conversions. PERMANENT — never
+-- wiped or refilled.
+-- replenish_balance — initial signup allowance + periodic refills.
+-- REFILLED to tenant_default_credits on each
+-- anniversary period boundary (period set via
+-- the tenant_credit_replenish_period config).
+-- Leftover replenish credits at the end of a
+-- period are FORFEIT (use-it-or-lose-it).
+--
+-- Spend order: debit replenish_balance first (it'll refresh anyway),
+-- then purchased_balance only when the refillable bucket is empty.
+-- last_replenish_at: epoch-ms of the most recent refill, used to compute
+-- the next anniversary boundary.
+CREATE TABLE IF NOT EXISTS tenant_credits (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ purchased_balance INTEGER NOT NULL DEFAULT 0,
+ replenish_balance INTEGER NOT NULL DEFAULT 0,
+ last_replenish_at INTEGER,
+ lifetime_granted INTEGER NOT NULL DEFAULT 0,
+ lifetime_consumed INTEGER NOT NULL DEFAULT 0
+);
+
+-- ── anon_trials ────────────────────────────────────────────────────────
+-- Cookie-gated "taste before sign-up" trial. The first time an
+-- unauthenticated visitor hits /api/process, we issue a recap_anon_trial
+-- cookie (32-byte random), insert a row here with N credits (set by
+-- the trial_credits_per_visitor operator config), and let them
+-- summarize without signing up. After credits_used >= credits_total,
+-- the UI nudges them to sign up for more.
+--
+-- Trial requests forward the OPERATOR's install_id + license to the
+-- relay, so the operator's credit pool is what actually pays for the
+-- Gemini call. tenant_credits.balance is irrelevant for trials —
+-- the credits_total field on this row is the only gate.
+--
+-- ip_address rate-limits trial-cookie issuance: trials_per_ip_per_day
+-- caps how many fresh trial cookies one IP can mint in 24h. Doesn't
+-- stop sophisticated abuse (IP rotation), but raises the floor for
+-- scripted laptop attacks and gives the operator a column to grep on.
+--
+-- converted_to_user_id is set when the trial holder signs up — links
+-- the trial summary into their library and lets the operator measure
+-- the trial → signup conversion rate.
+CREATE TABLE IF NOT EXISTS anon_trials (
+ cookie_id TEXT PRIMARY KEY,
+ ip_address TEXT,
+ user_agent TEXT,
+ created_at INTEGER NOT NULL,
+ credits_total INTEGER NOT NULL,
+ credits_used INTEGER NOT NULL DEFAULT 0,
+ last_used_at INTEGER,
+ converted_to_user_id TEXT REFERENCES users(id) ON DELETE SET NULL
+);
+CREATE INDEX IF NOT EXISTS idx_anon_trials_ip ON anon_trials(ip_address, created_at);
+CREATE INDEX IF NOT EXISTS idx_anon_trials_created ON anon_trials(created_at);
+
+-- ── pending_purchases ──────────────────────────────────────────────────
+-- Tracks every credit-purchase invoice initiated through Recap so that
+-- when the invoice settles (via BTCPay webhook → relay → poll round-
+-- trip back to us) we know WHO to credit locally.
+--
+-- The BTCPay invoice on the relay side credits the OPERATOR's pool —
+-- the operator paid for the underlying Gemini/etc capacity at the
+-- relay. Recap's local accounting layer (tenant_credits for signed-in
+-- users, anon_trials.credits_total for trial cookies) is what gates
+-- the actual buyer's spend, so we mark this row applied once the
+-- relevant local balance is incremented. applied_at being non-null is
+-- the idempotency guard — a poll firing twice doesn't double-credit.
+--
+-- buyer_type values:
+-- "user" → buyer_id is users.id; credits land in tenant_credits
+-- "anon" → buyer_id is anon_trials.cookie_id; credits land in
+-- anon_trials.credits_total. If the cookie has since been
+-- converted to a user (anon_trials.converted_to_user_id),
+-- credits route to that user's tenant_credits instead.
+CREATE TABLE IF NOT EXISTS pending_purchases (
+ invoice_id TEXT PRIMARY KEY,
+ buyer_type TEXT NOT NULL,
+ buyer_id TEXT NOT NULL,
+ credits INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ applied_at INTEGER
+);
+CREATE INDEX IF NOT EXISTS idx_pending_purchases_buyer ON pending_purchases(buyer_type, buyer_id);
+CREATE INDEX IF NOT EXISTS idx_pending_purchases_unapplied ON pending_purchases(applied_at) WHERE applied_at IS NULL;
+
+-- ── pending_signups ────────────────────────────────────────────────────
+-- Buyer-creates-account flow: when an anon visitor picks Pro / Max
+-- from the tier signup modal, they enter an email and pay BTCPay
+-- BEFORE any user account exists. We record the (invoice_id, email,
+-- policy_slug) here so the poll-settle handler can create the user +
+-- attach the issued license + send a magic-link email once payment
+-- lands. applied_at is the idempotency guard — multiple polls after
+-- settle don't double-create the user.
+--
+-- Distinct from pending_purchases (credit-pack buys) because the
+-- settle effects are completely different: pending_signups creates
+-- a USER and sends an email; pending_purchases just credits an
+-- existing buyer's local balance.
+CREATE TABLE IF NOT EXISTS pending_signups (
+ invoice_id TEXT PRIMARY KEY,
+ email TEXT NOT NULL,
+ policy_slug TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ applied_at INTEGER
+);
+CREATE INDEX IF NOT EXISTS idx_pending_signups_email ON pending_signups(email);
+CREATE INDEX IF NOT EXISTS idx_pending_signups_unapplied ON pending_signups(applied_at) WHERE applied_at IS NULL;
+
+-- ── subscription_reminders ─────────────────────────────────────────────
+-- Dedup ledger for the self-serve expiry-reminder emails. The relay owns
+-- the subscription expiry; a daily Recaps scan asks it who's expiring and
+-- emails them. This table guarantees each (user, period, kind) email goes
+-- out at most once. period_expires_at is the ISO expiry instant the
+-- reminder is for — when the user renews, expiry changes, so a fresh set
+-- of reminders re-arms for the new period without re-sending old ones.
+-- kind is one of 'upcoming_7d', 'upcoming_1d', or 'lapsed'.
+CREATE TABLE IF NOT EXISTS subscription_reminders (
+ user_id TEXT NOT NULL,
+ period_expires_at TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ sent_at INTEGER NOT NULL,
+ PRIMARY KEY (user_id, period_expires_at, kind)
+);
+
+-- ── library_meta ───────────────────────────────────────────────────────
+-- Index over /data/history//.json. The summary
+-- content stays on disk; this table is just for fast listing without
+-- scanning the filesystem.
+CREATE TABLE IF NOT EXISTS library_meta (
+ session_id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ video_id TEXT,
+ url TEXT,
+ title TEXT,
+ type TEXT,
+ topic_count INTEGER,
+ segment_count INTEGER,
+ created_at INTEGER NOT NULL,
+ upload_date TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_library_user ON library_meta(user_id, created_at DESC);
+`;
+
+// initDb({ dataDir })
+// Idempotent. Opens /data/recap.db, applies the schema, returns the
+// connection. Safe to call multiple times — repeat calls return the
+// existing handle.
+export async function initDb({ dataDir }) {
+ if (dbInstance) return dbInstance;
+
+ // Lazy import so single-mode never loads the native binding.
+ const { default: Database } = await import("better-sqlite3");
+
+ const dbPath = path.join(dataDir, "recap.db");
+ const db = new Database(dbPath);
+
+ // WAL mode for the obvious reasons: concurrent reads while a write
+ // is in flight, and durable enough for our small write volume
+ // (signups, sessions, library inserts). `synchronous = NORMAL` is
+ // the standard pairing — fsync on checkpoint, not every commit.
+ db.pragma("journal_mode = WAL");
+ db.pragma("synchronous = NORMAL");
+ db.pragma("foreign_keys = ON");
+
+ db.exec(SCHEMA_SQL);
+
+ // ── In-place schema migrations ──────────────────────────────────────
+ // SCHEMA_SQL above is the FRESH-INSTALL schema. Existing installs
+ // may have an older shape (e.g. tenant_credits with the legacy
+ // `balance` column). We bring them up to current by introspecting
+ // PRAGMA table_info and ALTER-ing only where needed. Each migration
+ // is idempotent — running boot multiple times is safe.
+ migrateTenantCreditsSchema(db);
+ migrateMagicLinkTokensTrialCookie(db);
+ migrateUsersTier(db);
+
+ dbInstance = db;
+ console.log(`[db] opened ${dbPath} (multi-tenant store)`);
+ return db;
+}
+
+// Core-decoupling — add users.tier to existing DBs (fresh installs get it
+// from SCHEMA_SQL). Idempotent: ALTERs only when the column is missing.
+function migrateUsersTier(db) {
+ let cols;
+ try {
+ cols = db.prepare("PRAGMA table_info(users)").all();
+ } catch {
+ return;
+ }
+ if (!cols.some((c) => c.name === "tier")) {
+ db.exec("ALTER TABLE users ADD COLUMN tier TEXT NOT NULL DEFAULT 'core'");
+ console.log("[db] added users.tier column (core-decoupling)");
+ }
+}
+
+// v0.2.92 — split the single tenant_credits.balance into two buckets
+// (purchased + replenish) so we can refill the latter periodically
+// without wiping the former.
+function migrateTenantCreditsSchema(db) {
+ let cols;
+ try {
+ cols = db.prepare("PRAGMA table_info(tenant_credits)").all();
+ } catch {
+ return; // table doesn't exist yet (shouldn't happen post-SCHEMA_SQL)
+ }
+ const colNames = new Set(cols.map((c) => c.name));
+
+ // 1. Rename legacy `balance` → `purchased_balance`. Existing balances
+ // were a mix of signup-grant + admin-grant + purchase; treating
+ // them all as "purchased" (permanent) is the safe interpretation
+ // — we'd rather over-preserve than wipe credits on upgrade.
+ if (colNames.has("balance") && !colNames.has("purchased_balance")) {
+ db.exec(
+ "ALTER TABLE tenant_credits RENAME COLUMN balance TO purchased_balance",
+ );
+ console.log(
+ "[db] migrated tenant_credits.balance → tenant_credits.purchased_balance",
+ );
+ colNames.delete("balance");
+ colNames.add("purchased_balance");
+ }
+
+ if (!colNames.has("replenish_balance")) {
+ db.exec(
+ "ALTER TABLE tenant_credits ADD COLUMN replenish_balance INTEGER NOT NULL DEFAULT 0",
+ );
+ console.log("[db] added tenant_credits.replenish_balance");
+ }
+ if (!colNames.has("last_replenish_at")) {
+ db.exec(
+ "ALTER TABLE tenant_credits ADD COLUMN last_replenish_at INTEGER",
+ );
+ console.log("[db] added tenant_credits.last_replenish_at");
+ }
+}
+
+// v0.2.104 — add trial_cookie_id to magic_link_tokens so cross-cookie-
+// jar magic-link clicks (Safari Private → Gmail webview, etc.) still
+// link the anon trial to the new user at /auth/verify time. Existing
+// installs get the column added in-place; pre-existing rows just keep
+// trial_cookie_id = NULL (no linking via the new path, falls back to
+// the legacy req.cookies path).
+function migrateMagicLinkTokensTrialCookie(db) {
+ let cols;
+ try {
+ cols = db.prepare("PRAGMA table_info(magic_link_tokens)").all();
+ } catch {
+ return;
+ }
+ const colNames = new Set(cols.map((c) => c.name));
+ if (!colNames.has("trial_cookie_id")) {
+ db.exec(
+ "ALTER TABLE magic_link_tokens ADD COLUMN trial_cookie_id TEXT",
+ );
+ console.log("[db] added magic_link_tokens.trial_cookie_id");
+ }
+}
+
+
+// Returns the open handle. Throws if initDb hasn't run — that's a
+// programming error (some single-mode caller reached a multi-mode
+// codepath). Callers in multi-mode should assume the handle exists.
+export function getDb() {
+ if (!dbInstance) {
+ throw new Error(
+ "[db] getDb() called before initDb(); check RECAP_MODE wiring",
+ );
+ }
+ return dbInstance;
+}
+
+// Test/teardown helper. Closes the connection so the next initDb()
+// call reopens fresh. Not used in production.
+export function closeDb() {
+ if (dbInstance) {
+ dbInstance.close();
+ dbInstance = null;
+ }
+}
diff --git a/server/email-template.js b/server/email-template.js
new file mode 100644
index 0000000..8abf586
--- /dev/null
+++ b/server/email-template.js
@@ -0,0 +1,181 @@
+// Magic-link email body builder. Returns { subject, text, html } for
+// nodemailer. Keeps the HTML and text in sync — both carry the same
+// verifyUrl and the same expiry copy.
+//
+// Style is deliberately minimal: one paragraph, one button, no images,
+// no fancy CSS. Spam filters like simple emails; users skim them and
+// click the link. Anything fancier risks the email landing in spam,
+// which is fatal to a magic-link auth flow.
+
+// renderMagicLinkEmail({ verifyUrl, brandName, expiresInMinutes })
+// → { subject, text, html }
+export function renderMagicLinkEmail({
+ verifyUrl,
+ brandName = "Recaps",
+ expiresInMinutes = 15,
+}) {
+ const subject = `Sign in to ${brandName}`;
+
+ const text = [
+ `Sign in to ${brandName} by opening this link:`,
+ "",
+ verifyUrl,
+ "",
+ `This link expires in ${expiresInMinutes} minutes and can only be used once.`,
+ "",
+ `If you didn't request this, you can safely ignore this email — no one else can use this link without access to your inbox.`,
+ ].join("\n");
+
+ // Inline-styled HTML. Most email clients strip