// 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; } }