0ae59f3550
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
401 lines
18 KiB
JavaScript
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;
|
|
}
|
|
}
|