// Tenant (multi-user) auth middleware. // // Two modes: // - single (the original self-hosted experience): no-op. Sets // req.userId = "owner" so downstream code can scope everything // uniformly to a single synthetic user. // - multi (cloud / family-share): validates the recap_session cookie // against the sessions table, looks up the user, attaches both to // req. Non-public paths without a valid session get a 401. // // This is layered AFTER the admin-auth middleware (which gates /api/* // behind the operator's password in single mode). The two are // orthogonal: // - admin-auth: "is the OPERATOR allowed in?" (single mode only) // - tenant-auth: "which user is this request from?" (multi mode only) // In multi mode the admin-auth gate is disabled — tenant accounts // authenticate themselves directly. import { getDb } from "./db.js"; import { TRIAL_COOKIE, lookupTrial, hasTrialBudget, } from "./anon-trial.js"; const SESSION_COOKIE = "recap_session"; // Endpoints that must remain reachable WITHOUT a session — the auth // flow itself, health check, static assets, the BTCPay webhook. The // frontend's static files are served outside the /api/* tree so they // bypass this middleware entirely; this list only matters for /api/* // and /auth/* paths. const PUBLIC_PATH_PREFIXES = [ "/auth/", // /auth/request-link, /auth/verify, /auth/signout "/api/health", "/api/auth/", // future client-facing auth shims (CSRF token issue, etc.) "/api/btcpay/webhook", // BTCPay needs to reach this without a session "/api/digest/unsubscribe", // one-click unsubscribe from a digest email (no session) "/api/network-mode", // returns lan-vs-local; safe to expose "/api/relay/status", // public relay capabilities — pre-trial visibility "/api/account/whoami", // returns state — anonymous visitors must call this // License-status family — anonymous visitors must call these on page // load to render the right header/badge. Multi-mode handlers branch // on req.user/req.trial to return per-user views. "/api/license-status", "/api/install-id", // Credit-purchase family — accepts both signed-in users AND anon // trial cookies (the buy handler routes credits to the right local // balance based on which identity made the call). Each handler // validates buyer presence inline, so leaving these "public" is // safe — it just defers the auth check from the middleware to the // handler where buyer-type-specific logic lives anyway. "/api/credits/", // License purchase + poll — the 3-tier signup modal lets anon // visitors buy a Pro/Max license at the same moment they create // their account. /policies is a passthrough to Keysat's public // /v1/products/.../policies (no auth needed there). /purchase // accepts an anon buyer_email and records a pending_signups row // so the poll-settle handler can create the user + attach the // license + send a magic-link email. /poll just reads invoice // status from Keysat — same trust model as the credit flow. // // Note: /api/license/activate and /api/license/deactivate are // NOT in this prefix (they're operator-only single-mode endpoints // that write /data/license.txt; tenants shouldn't reach them). "/api/license/policies", "/api/license/purchase", "/api/license/poll/", ]; // Paths where an unauthenticated visitor is allowed to obtain (or use) // an anonymous-trial cookie. Restricting trials to the actual // "use the product" endpoint keeps bots that scrape the homepage from // minting trial rows just by visiting /. const TRIAL_ELIGIBLE_PATHS = new Set(["/api/process"]); function isTrialEligiblePath(reqPath, method) { if (method !== "POST") return false; return TRIAL_ELIGIBLE_PATHS.has(reqPath); } function isPublicPath(reqPath) { for (const p of PUBLIC_PATH_PREFIXES) { if (reqPath === p || reqPath.startsWith(p)) return true; } return false; } // We only gate /api/* paths. Static assets (/, /auth.html, /assets/*) // and the auth-flow routes (/auth/*) are ALWAYS reachable so anonymous // visitors can see the landing page and the sign-in form. The handlers // behind /api/* are where actual access decisions need to happen. function isGatedPath(reqPath) { return reqPath.startsWith("/api/"); } // Lightweight cookie parser — same dep (`cookie` v1) the rest of the // project uses. Keeps us from pulling in cookie-parser middleware just // for one header. import * as cookie from "cookie"; function parseCookies(req) { const header = req.headers?.cookie; if (!header) return {}; try { return cookie.parse(header); } catch { return {}; } } // Factory — returns the middleware closure. `mode` is the RECAP_MODE // env value ("single" | "multi"). Captured at boot so we don't branch // on a hot path on every request. export function buildTenantAuthMiddleware({ mode }) { if (mode !== "multi") { // Single-mode shim: stamp every request with the synthetic owner // userId so user-scoped handlers (history reads, library inserts, // etc.) keep working without per-call branching. return function singleModeAuth(req, _res, next) { req.userId = "owner"; req.user = null; req.recapMode = "single"; next(); }; } // Multi-mode: validate session on every request. Cache one prepared // statement per query in module scope (better-sqlite3 idiom — `.prepare` // returns a reusable handle). let sessionLookupStmt = null; let userLookupStmt = null; let sessionTouchStmt = null; function stmts() { const db = getDb(); if (!sessionLookupStmt) { sessionLookupStmt = db.prepare( "SELECT * FROM sessions WHERE id = ? AND expires_at > ?", ); userLookupStmt = db.prepare("SELECT * FROM users WHERE id = ?"); sessionTouchStmt = db.prepare( "UPDATE sessions SET last_used_at = ? WHERE id = ?", ); } return { sessionLookupStmt, userLookupStmt, sessionTouchStmt }; } // Touch debounce — every authenticated request would otherwise issue // an UPDATE for last_used_at. We coalesce per-session: at most one // touch per LAST_USED_DEBOUNCE_MS window. const LAST_USED_DEBOUNCE_MS = 60_000; const lastTouchedAt = new Map(); function maybeTouch(sessionId) { const now = Date.now(); const prev = lastTouchedAt.get(sessionId) || 0; if (now - prev < LAST_USED_DEBOUNCE_MS) return; lastTouchedAt.set(sessionId, now); try { stmts().sessionTouchStmt.run(now, sessionId); } catch { // Touch is best-effort; a write contention here shouldn't fail // the request. } } return function multiModeAuth(req, res, next) { req.recapMode = "multi"; // Static assets and the auth-flow pages aren't gated at all — they // need to be reachable for any visitor to see the landing page or // sign-in form. Just pass through without attaching anything. if (!isGatedPath(req.path)) { return next(); } const cookies = parseCookies(req); const sessionId = cookies[SESSION_COOKIE]; if (!sessionId) { // Look up the trial cookie FIRST (regardless of path) so handlers // like /api/account/whoami can see req.trial even on public paths. // We separate "trial is present" from "trial gates access" — // attachment is unconditional, gating is path-dependent below. let trial = null; const trialCookieId = cookies[TRIAL_COOKIE]; if (trialCookieId) { try { trial = lookupTrial(trialCookieId); } catch (err) { console.warn("[tenant-auth] trial lookup failed:", err); } if (trial) { req.trial = trial; } } // Public paths (auth flow, health, whoami, etc.) pass through. // Handlers should treat req.userId === undefined as "anonymous" // and inspect req.trial separately to render appropriate UI. if (isPublicPath(req.path)) { req.user = null; return next(); } // Second lane: anonymous trial cookie with remaining budget. // Attach a synthetic userId so /api/process and other gated // endpoints can run. If the cookie exists but is exhausted, // fall through to 401 — the UI's "sign up for more" nudge // takes it from here. if (trial && hasTrialBudget(trial)) { req.userId = `anon:${trial.cookie_id}`; req.user = null; return next(); } // No session, no usable trial. The /api/process handler is the // one place where a fresh trial cookie CAN still be minted on // first POST — let it through with no user attached so it can // call issueIfEligible() and either issue + proceed or 401. if (isTrialEligiblePath(req.path, req.method)) { req.userId = undefined; // signal: "pre-trial, mint if eligible" req.user = null; return next(); } return res.status(401).json({ error: "auth_required" }); } let session; try { session = stmts().sessionLookupStmt.get(sessionId, Date.now()); } catch (err) { console.warn("[tenant-auth] session lookup failed:", err); if (isPublicPath(req.path)) return next(); return res.status(500).json({ error: "auth_lookup_failed" }); } if (!session) { // Stale or expired cookie. Clear it so the browser stops sending // a dead token on every request. res.clearCookie?.(SESSION_COOKIE); if (isPublicPath(req.path)) return next(); return res.status(401).json({ error: "session_expired" }); } let user; try { user = stmts().userLookupStmt.get(session.user_id); } catch (err) { console.warn("[tenant-auth] user lookup failed:", err); if (isPublicPath(req.path)) return next(); return res.status(500).json({ error: "user_lookup_failed" }); } if (!user) { // User row was deleted but the session row survived — shouldn't // happen under ON DELETE CASCADE, but defend in depth. res.clearCookie?.(SESSION_COOKIE); if (isPublicPath(req.path)) return next(); return res.status(401).json({ error: "user_gone" }); } req.userId = user.id; req.user = user; req.session = session; maybeTouch(sessionId); next(); }; } // Convenience: a guard middleware to chain after auth, for endpoints // that MUST have a user (where the single-mode synthetic "owner" // won't do, e.g. /api/account/*). Single-mode falls through because // req.userId === "owner" is truthy. export function requireUser(req, res, next) { if (!req.userId) { return res.status(401).json({ error: "auth_required" }); } next(); } // Operator-only guard: passes through in single mode (the operator // IS the only user), in multi mode requires req.user.is_admin === 1. // Used to gate the full settings panel (provider keys, prompts, etc.). export function requireOperator(req, res, next) { if (req.recapMode !== "multi") return next(); if (!req.user || !req.user.is_admin) { return res.status(403).json({ error: "operator_only" }); } next(); }