Files
recap/server/db.js
T
Keysat 0ae59f3550 Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00

401 lines
18 KiB
JavaScript

// 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/<userId>/*.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/<userId>/<sessionId>.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;
}
}