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
293 lines
11 KiB
JavaScript
293 lines
11 KiB
JavaScript
// 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();
|
|
}
|