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
This commit is contained in:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+292
View File
@@ -0,0 +1,292 @@
// 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/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();
}