// Magic-link auth endpoints for multi-tenant cloud mode. // // Flow: // 1. POST /auth/request-link { email } // - Normalize, rate-limit, generate token, hash, store hash // - Send email with verifyUrl containing the plaintext token // - Always returns { ok: true } — never leaks whether email exists // // 2. GET /auth/verify?token= // - Hash, look up, validate (unused + unexpired) // - Mark used; upsert user; issue session cookie // - If first user ever, mark is_admin = 1 (operator bootstrap) // - If request carries a recap_anon_trial cookie, link the trial // row to the new user_id so their trial summary lands in their // library and the conversion gets recorded // - 302 redirect to / // // 3. POST /auth/signout // - Delete session row, clear cookie, redirect to / (or 204 if API) // // Multi-mode only. The route registration helper itself returns early // in single mode so single-mode boot doesn't initialize SMTP / DB // codepaths. import { randomBytes, createHash, scryptSync, timingSafeEqual, } from "crypto"; import * as cookie from "cookie"; import { getDb } from "./db.js"; import { sendMail, isSmtpReady } from "./smtp.js"; import { renderMagicLinkEmail } from "./email-template.js"; import { getConfigSnapshot } from "./config.js"; import { getClientIp, TRIAL_COOKIE, linkToUser } from "./anon-trial.js"; import { renameScopeDir } from "./history.js"; import { requireUser } from "./tenant-auth.js"; export const SESSION_COOKIE = "recap_session"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes // ── Password hashing ───────────────────────────────────────────────── // scrypt with a per-password 16-byte salt, stored as // "scrypt$<saltHex>$<hashHex>" in users.password_hash. Same primitive // admin-auth.js uses for the single-mode operator password, so the // crypto surface area stays consistent across the codebase. N=2^15 // (KDF cost) is what the Node docs suggest for interactive logins — // ~50ms on commodity hardware, slow enough to deter brute force, // fast enough to not feel laggy. const SCRYPT_KEYLEN = 64; const SCRYPT_OPTS = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; const SCRYPT_PREFIX = "scrypt$"; export function hashPassword(plain) { const salt = randomBytes(16); const derived = scryptSync(plain, salt, SCRYPT_KEYLEN, SCRYPT_OPTS); return `${SCRYPT_PREFIX}${salt.toString("hex")}$${derived.toString("hex")}`; } function verifyPassword(plain, stored) { if (!stored || !stored.startsWith(SCRYPT_PREFIX)) return false; const [, saltHex, hashHex] = stored.split("$"); if (!saltHex || !hashHex) return false; let salt, expected; try { salt = Buffer.from(saltHex, "hex"); expected = Buffer.from(hashHex, "hex"); } catch { return false; } if (expected.length !== SCRYPT_KEYLEN) return false; let actual; try { actual = scryptSync(plain, salt, SCRYPT_KEYLEN, SCRYPT_OPTS); } catch { return false; } // timingSafeEqual requires equal length, which we just enforced above. return timingSafeEqual(actual, expected); } // Mild policy — 8 char minimum, 256 max. We deliberately don't enforce // "one uppercase + one digit + ..." style rules; the consensus modern // view (NIST 800-63B) is that length matters far more than composition, // and rules that demand specific shapes just push users toward // predictable substitutions ("Password1!"). Min 8 catches the worst // cases without alienating people using passphrases. export function validatePasswordPolicy(plain) { if (typeof plain !== "string") return "password_required"; if (plain.length < 8) return "password_too_short"; if (plain.length > 256) return "password_too_long"; return null; } // ── Rate limits ──────────────────────────────────────────────────────── // Both buckets are in-memory: not durable across restarts, but the worst // case is one extra link request per email/IP per restart, which is fine. // For abuse on the scale where in-memory limits are insufficient, the // operator's IP/UA logs + manual deny-list are the next layer. const MAX_LINKS_PER_EMAIL_PER_HOUR = 5; const MAX_LINKS_PER_IP_PER_HOUR = 10; const ONE_HOUR_MS = 60 * 60 * 1000; const emailBuckets = new Map(); // email → [timestamp, ...] const ipBuckets = new Map(); // ip → [timestamp, ...] function pushBucket(map, key, now) { const arr = map.get(key) || []; const fresh = arr.filter((t) => now - t < ONE_HOUR_MS); fresh.push(now); map.set(key, fresh); return fresh.length; } function bucketCount(map, key, now) { const arr = map.get(key) || []; const fresh = arr.filter((t) => now - t < ONE_HOUR_MS); if (fresh.length !== arr.length) map.set(key, fresh); return fresh.length; } // Periodically prune old bucket entries so the maps don't grow // unbounded under heavy traffic. Fire-and-forget; the filter above // also self-prunes per access. const BUCKET_GC_MS = 5 * 60 * 1000; setInterval(() => { const cutoff = Date.now() - ONE_HOUR_MS; for (const map of [emailBuckets, ipBuckets]) { for (const [key, arr] of map.entries()) { const fresh = arr.filter((t) => t > cutoff); if (fresh.length === 0) map.delete(key); else if (fresh.length !== arr.length) map.set(key, fresh); } } }, BUCKET_GC_MS).unref?.(); function normalizeEmail(raw) { if (typeof raw !== "string") return ""; return raw.trim().toLowerCase(); } function isPlausibleEmail(s) { // Deliberately permissive — the user's mail server is the source of // truth for whether an address works (they either receive the link or // they don't). Just sanity-check that we have something with @ and a // dot in the domain. return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) && s.length <= 254; } function sha256(s) { return createHash("sha256").update(s).digest("hex"); } function uuid() { // 16 random bytes formatted as a UUIDv4-ish hex with hyphens. Doesn't // need to be RFC-compliant — uniqueness is the only requirement. return randomBytes(16).toString("hex"); } function clipUA(ua) { return String(ua || "").slice(0, 256); } // ── Public URL plumbing ──────────────────────────────────────────────── // The verify URL in the email needs the operator's ClearNet URL set via // the "Set Recap Public URL" StartOS action. We refuse to send magic // links if it's empty — otherwise the email would link to localhost or // an internal hostname. async function getPublicUrl() { const snap = await getConfigSnapshot(); const url = (snap.recap_public_url || "").trim(); return url.replace(/\/$/, ""); } // ── Route handlers ───────────────────────────────────────────────────── async function handleRequestLink(req, res) { const email = normalizeEmail(req.body?.email); if (!email || !isPlausibleEmail(email)) { // Deliberately vague — don't help enumerate valid emails. Just say // "ok" even on bad input so the response shape is the same. return res.json({ ok: true }); } const publicUrl = await getPublicUrl(); if (!publicUrl) { console.error( "[auth] /auth/request-link blocked: recap_public_url not set. Run the 'Set Recap Public URL' StartOS action first.", ); return res.status(503).json({ error: "public_url_not_set", message: "This Recap instance hasn't been fully configured yet. Ask the operator to set the public URL.", }); } if (!isSmtpReady()) { console.error( "[auth] /auth/request-link blocked: SMTP not ready. Configure StartOS System SMTP.", ); return res.status(503).json({ error: "smtp_not_ready", message: "This Recap instance can't send email yet. Ask the operator to configure SMTP.", }); } const ip = getClientIp(req); const now = Date.now(); // Rate limits. Two buckets — email and IP. Either trips → 429 with a // generic message that doesn't leak which limit was hit. if (bucketCount(emailBuckets, email, now) >= MAX_LINKS_PER_EMAIL_PER_HOUR) { return res.status(429).json({ error: "rate_limited", message: "Too many sign-in requests for this email. Try again in an hour.", }); } if (ip && bucketCount(ipBuckets, ip, now) >= MAX_LINKS_PER_IP_PER_HOUR) { return res.status(429).json({ error: "rate_limited", message: "Too many sign-in requests from this network. Try again in an hour.", }); } pushBucket(emailBuckets, email, now); if (ip) pushBucket(ipBuckets, ip, now); // Determine intent: signin (existing user) vs signup (new email). // Both flows are identical to the server — the field is just for // analytics. We don't leak intent in the response. const existing = getDb() .prepare("SELECT id FROM users WHERE email = ?") .get(email); const intent = existing ? "signin" : "signup"; // Capture the trial cookie at request-link time and store it // server-side alongside the token. At /auth/verify we'll use it // to link the trial → user even when the magic-link click lands // in a different browser / cookie jar than the one that requested // the link (the iOS Private mode + in-app email webview case). // We read it directly from req.headers.cookie because the trial // cookie middleware may or may not have populated req.trial // depending on path matching — being explicit here is safer. let trialCookieId = null; try { const parsed = cookie.parse(req.headers?.cookie || ""); if (parsed[TRIAL_COOKIE]) trialCookieId = parsed[TRIAL_COOKIE]; } catch { // cookie parse failures are non-fatal; fall through with null } // Issue + send via the shared helper. We deliberately don't // surface send failures back to the user — the standard advice for // magic-link auth is "always pretend we sent it" so attackers can't // probe which emails are configured. Operator sees the error in // logs and can investigate (usually SMTP creds wrong or Gmail // rate-limited). await sendSignInLink({ email, intent, ip, userAgent: req.headers?.["user-agent"], trialCookieId, }); res.json({ ok: true }); } async function handleVerify(req, res) { const plaintext = String(req.query?.token || "").trim(); if (!plaintext) { return res.status(400).send(renderErrorPage("Missing token.")); } const tokenHash = sha256(plaintext); const now = Date.now(); const tx = getDb().transaction(() => { const row = getDb() .prepare( "SELECT * FROM magic_link_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?", ) .get(tokenHash, now); if (!row) return { error: "invalid_or_expired" }; getDb() .prepare("UPDATE magic_link_tokens SET used_at = ? WHERE token_hash = ?") .run(now, tokenHash); return { row }; }); let result; try { result = tx(); } catch (err) { console.error("[auth] verify tx failed:", err); return res.status(500).send(renderErrorPage("Internal error.")); } if (result.error) { return res .status(400) .send( renderErrorPage( "This sign-in link has expired or already been used. Request a fresh one.", ), ); } const email = result.row.email; const ip = getClientIp(req); const ua = clipUA(req.headers?.["user-agent"]); // Upsert user row. let user = getDb() .prepare("SELECT * FROM users WHERE email = ?") .get(email); if (!user) { const id = uuid(); const syntheticInstallId = uuid(); // First user ever = operator bootstrap. We could also gate this on // "is the install fresh (no users at all)" — which is what this // check does. Once at least one user exists, subsequent signups // are regular tenants with is_admin = 0. const userCountRow = getDb() .prepare("SELECT COUNT(*) AS n FROM users") .get(); const isAdmin = (userCountRow?.n || 0) === 0 ? 1 : 0; getDb() .prepare( `INSERT INTO users (id, email, created_at, last_signin_at, synthetic_install_id, is_admin, signup_ip, signup_user_agent) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run(id, email, now, now, syntheticInstallId, isAdmin, ip || null, ua); user = getDb().prepare("SELECT * FROM users WHERE id = ?").get(id); // Seed tenant_credits for new NON-ADMIN users with the operator's // configured default. Goes into the REPLENISHABLE bucket so // setReplenishPeriod=daily/weekly/monthly refills it on schedule. // Admin users skip this entirely — their relay calls bill the // operator pool (via /data/license.txt), not a local balance. // // Pro/Max users (license attached at creation, e.g. via the // anon-signup-with-purchase flow) ALSO skip this — they spend // from their license-keyed relay pool, so a tenant_credits row // would just sit unused and confuse the admin Tenants view. if (!isAdmin && !user.keysat_license) { try { const { seedSignup } = await import("./tenant-credits.js"); await seedSignup(id); } catch (err) { console.warn("[auth] tenant_credits seed failed:", err); } } if (isAdmin) { console.log( `[auth] First user signed up — ${email} bootstrapped as operator (is_admin=1)`, ); // Legacy library at /data/history/owner/ stays where it is — // admin's scopeForRequest() returns "owner" regardless of mode, // so single-mode and multi-mode admin both read the same path. // No rename. Switching back to single mode keeps the operator's // library accessible at /data/history/owner/. } } else { getDb() .prepare("UPDATE users SET last_signin_at = ? WHERE id = ?") .run(now, user.id); } // Resolve which anon trial cookie to link. Two sources: // 1. result.row.trial_cookie_id — captured server-side at // /auth/request-link time. Survives cross-browser / in-app- // webview magic-link clicks because it doesn't depend on the // verify request carrying the cookie. // 2. req.cookies[TRIAL_COOKIE] — the legacy path, used when the // magic-link click lands in the SAME browser that did the // anon activity (typical desktop / non-private-mode flow). // // Priority: token-row wins. It represents the explicit intent // captured at signup-request time and is the more reliable // signal. The req cookie is a fallback for old token rows from // before the trial_cookie_id column existed, and for any // edge-case where the request-link path didn't capture it. // // We also rename the trial's history folder (anon/<cookie_id>/) to // the user's id so the summary they ran before signing up shows // up in their library. Skipped for returning users (existing // account) — they already have a library and we can't safely // merge filesystem-side. The trial row still gets linked; the // anon/<cookie_id>/ folder just stays as an orphan. try { let trialCookieId = result.row.trial_cookie_id || null; if (!trialCookieId) { const cookies = cookie.parse(req.headers?.cookie || ""); trialCookieId = cookies[TRIAL_COOKIE] || null; } if (trialCookieId) { await linkToUser(trialCookieId, user.id); if (user.created_at === user.last_signin_at) { try { await renameScopeDir(`anon/${trialCookieId}`, user.id); } catch (err) { console.warn( "[auth] trial→user scope rename failed:", err?.message || err, ); } } } } catch { // best-effort; trial linking isn't on the critical signin path } // Issue session — shared with /auth/signin-password so cookie shape // + sessions-row format stay identical regardless of which auth // method got the user here. issueSession({ userId: user.id, req, res }); res.redirect(302, "/"); } // Shared session-issuance helper. Used by both /auth/verify (magic-link // success path) and /auth/signin-password (password success path) so // the cookie shape + sessions-row format stay identical. function issueSession({ userId, req, res }) { const sessionId = randomBytes(32).toString("base64url"); const now = Date.now(); const expiresAt = now + SESSION_MAX_AGE_MS; const ua = clipUA(req.headers?.["user-agent"]); const ip = getClientIp(req); getDb() .prepare( `INSERT INTO sessions (id, user_id, created_at, expires_at, last_used_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(sessionId, userId, now, expiresAt, now, ua, ip || null); const maxAgeSeconds = Math.floor(SESSION_MAX_AGE_MS / 1000); res.setHeader( "Set-Cookie", [ `${SESSION_COOKIE}=${sessionId}`, `Max-Age=${maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax", "Secure", ].join("; "), ); return sessionId; } // ── Internal sessions for the subscription background processor ────────────── // Per-tenant subscriptions: the processor summarizes an approved auto-queue // item by calling /api/process over loopback, which in multi mode is // authenticated. So it mints a SHORT-LIVED real session for the item's // owning user, sends it as the recap_session cookie, and deletes it right // after. This reuses the real auth path — NOT a bypass: a bad/expired token // just 401s and the item is marked failed, never an auth hole. export function mintInternalSession(userId) { const sessionId = randomBytes(32).toString("base64url"); const now = Date.now(); getDb() .prepare( `INSERT INTO sessions (id, user_id, created_at, expires_at, last_used_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run( sessionId, userId, now, now + 30 * 60 * 1000, // 30 min is plenty for one summarize run now, "subscription-processor", "127.0.0.1", ); return sessionId; } export function deleteInternalSession(sessionId) { if (!sessionId) return; try { getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sessionId); } catch {} } // The operator/admin owns the "owner" scope in multi mode — resolve their // user id so the processor can run owner-scoped items as the operator. export function adminUserId() { try { const row = getDb() .prepare("SELECT id FROM users WHERE is_admin = 1 ORDER BY created_at LIMIT 1") .get(); return row?.id || null; } catch { return null; } } // /auth/signin-password — accept { email, password }, verify, issue a // session cookie. Used by the auth.html form when the user opts to // type a password (faster than waiting on an email). // // We DELIBERATELY don't differentiate "no such email" vs "wrong // password" in the error response — both return 401 with a generic // message. This stops credential-stuffing tools from using us as an // email-existence oracle. // // Rate limits: same email + IP buckets as /auth/request-link, so the // password endpoint can't be brute-forced any faster than someone // could spam magic-link emails. async function handleSignInPassword(req, res) { const email = normalizeEmail(req.body?.email); const password = req.body?.password; if (!email || !isPlausibleEmail(email) || typeof password !== "string") { return res.status(401).json({ error: "bad_credentials", message: "Email or password is wrong.", }); } const ip = getClientIp(req); const now = Date.now(); if (bucketCount(emailBuckets, email, now) >= MAX_LINKS_PER_EMAIL_PER_HOUR) { return res .status(429) .json({ error: "rate_limited", message: "Too many sign-in attempts." }); } if (ip && bucketCount(ipBuckets, ip, now) >= MAX_LINKS_PER_IP_PER_HOUR) { return res .status(429) .json({ error: "rate_limited", message: "Too many sign-in attempts." }); } pushBucket(emailBuckets, email, now); if (ip) pushBucket(ipBuckets, ip, now); let user; try { user = getDb() .prepare("SELECT * FROM users WHERE email = ?") .get(email); } catch (err) { console.error("[auth] signin-password lookup failed:", err); return res.status(500).json({ error: "internal_error" }); } // Run scrypt even when the user doesn't exist so timing doesn't // betray which emails are registered. The dummy verify spends the // same ~50ms scrypt-or-so as the real path. const stored = user?.password_hash || `${SCRYPT_PREFIX}0000$0000`; const ok = verifyPassword(password, stored); if (!user || !user.password_hash || !ok) { return res .status(401) .json({ error: "bad_credentials", message: "Email or password is wrong." }); } // Authenticated. Update last_signin_at and issue a session. getDb() .prepare("UPDATE users SET last_signin_at = ? WHERE id = ?") .run(now, user.id); issueSession({ userId: user.id, req, res }); res.json({ ok: true }); } async function handleSignout(req, res) { try { const cookies = cookie.parse(req.headers?.cookie || ""); const sessionId = cookies[SESSION_COOKIE]; if (sessionId) { getDb() .prepare("DELETE FROM sessions WHERE id = ?") .run(sessionId); } } catch {} // Expire the cookie immediately. res.setHeader( "Set-Cookie", `${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`, ); // Honor JSON callers (UI fetch) vs link callers (form submit). if (req.accepts?.("json") && !req.accepts?.("html")) { return res.status(204).end(); } res.redirect(302, "/"); } // Self-contained branded error page used when /auth/verify fails. // Doesn't pull in the full app shell so it works even if the static // bundle is broken or partially served. Visual style matches /auth.html // (same dark-glass card, same primary-button accent) so the user feels // they're still in the same product flow. function renderErrorPage(message) { const safe = String(message) .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;"); return `<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <title>Sign-in failed · Recaps</title> <link rel="icon" type="image/png" href="/assets/icon.png"> <meta name="theme-color" content="#0a0e1a"> <style> *{box-sizing:border-box;margin:0;padding:0} html,body{height:100%} body{ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:#0a0e1a;color:#e2e8f0; display:flex;align-items:center;justify-content:center;padding:24px; } .card{ width:100%;max-width:420px;background:#121828; border:1px solid #1f2942;border-radius:12px;padding:32px 28px; } .logo{display:flex;align-items:center;gap:12px;margin-bottom:24px} .logo img{width:32px;height:32px;border-radius:6px} .logo span{font-size:18px;font-weight:600;color:#f5f9ff} h1{font-size:20px;font-weight:600;color:#f5f9ff;margin-bottom:10px} p.msg{font-size:14px;line-height:1.55;color:#cbd5e1;margin-bottom:22px} .btn-row{display:flex;gap:8px;flex-wrap:wrap} a.btn-primary{ display:inline-block;background:#3b82f6;color:#fff;text-decoration:none; font-size:14px;font-weight:600;padding:10px 18px;border-radius:8px; } a.btn-primary:hover{background:#2563eb} a.btn-secondary{ display:inline-block;background:transparent;color:#94a3b8;text-decoration:none; font-size:14px;font-weight:500;padding:10px 14px;border-radius:8px;border:1px solid #334155; } a.btn-secondary:hover{color:#cbd5e1;border-color:#475569} </style> </head> <body> <div class="card"> <div class="logo"> <img src="/assets/icon.png" alt="Recaps" onerror="this.style.display='none'"> <span>Recaps</span> </div> <h1>Sign-in didn't work</h1> <p class="msg">${safe}</p> <div class="btn-row"> <a href="/auth.html" class="btn-primary">Get a new sign-in link</a> <a href="/" class="btn-secondary">Back to Recaps</a> </div> </div> </body> </html>`; } // sendSignInLink({ email, intent, ip?, userAgent?, emailBody? }) — // reusable magic-link issuance + email send. Used by: // • /auth/request-link — visitor-initiated sign-in // • license-purchase poll-settle — system-initiated post-purchase // "your account is ready" send // // Generates a 32-byte token, hashes it, stores the hash in // magic_link_tokens, builds a verifyUrl, sends the email with either // the default magic-link body OR a caller-supplied (subject, text, // html) tuple for custom flows. Returns { ok: true, expires_at } on // success; { ok: false, error, message? } on failure. // // Doesn't enforce rate limits — that's the caller's job. /auth/request-link // has the per-email + per-IP buckets; the post-purchase path is // inherently rate-limited by the actual payment, so no extra bucket // needed. export async function sendSignInLink({ email, intent = "signin", ip = null, userAgent = "", emailBody = null, trialCookieId = null, }) { if (!email || !isPlausibleEmail(email)) { return { ok: false, error: "bad_email" }; } const publicUrl = await getPublicUrl(); if (!publicUrl) { return { ok: false, error: "public_url_not_set" }; } if (!isSmtpReady()) { return { ok: false, error: "smtp_not_ready" }; } const now = Date.now(); const plaintext = randomBytes(32).toString("base64url"); const tokenHash = sha256(plaintext); const expiresAt = now + MAGIC_LINK_TTL_MS; try { getDb() .prepare( `INSERT INTO magic_link_tokens (token_hash, email, created_at, expires_at, intent, request_ip, request_ua, trial_cookie_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( tokenHash, email, now, expiresAt, intent, ip || null, clipUA(userAgent), trialCookieId || null, ); } catch (err) { console.error("[auth] sendSignInLink insert failed:", err); return { ok: false, error: "internal_error" }; } const verifyUrl = `${publicUrl}/auth/verify?token=${encodeURIComponent(plaintext)}`; // Default to the standard sign-in email body; callers can override // either with a pre-built {subject,text,html} object OR a function // that receives the verifyUrl and returns that shape. The function // form is what license-purchase uses to inject the celebratory // "your Pro account is ready" copy with the verifyUrl pre-rendered // into the body. let message; if (typeof emailBody === "function") { message = emailBody(verifyUrl); } else if (emailBody && typeof emailBody === "object") { message = emailBody; } else { message = renderMagicLinkEmail({ verifyUrl, brandName: "Recaps", expiresInMinutes: 15, }); } try { await sendMail({ to: email, subject: message.subject, text: message.text, html: message.html, }); } catch (err) { console.error( "[auth] sendSignInLink sendMail failed for", email, ":", err?.message || err, ); // The token is already inserted; if the operator's SMTP is flaky // the user can re-request a link. Return error so the caller can // decide whether to surface it or swallow. return { ok: false, error: "send_failed" }; } return { ok: true, expires_at: expiresAt }; } // setupAuthRoutes(app) — registers /auth/* endpoints. Multi-mode only; // wired in server/index.js behind the RECAP_MODE === 'multi' branch. // // Magic-link is the primary auth surface: // POST /auth/request-link — issue a magic link by email // GET /auth/verify?token=... — consume the link, issue session // POST /auth/signout — drop the session // GET /auth/signout — same (link-click convenience) // // Password endpoints are the optional faster-signin add-on: // POST /auth/set-password — set OR overwrite my password // POST /auth/clear-password — remove my password (magic-link only) // POST /auth/signin-password — sign in with email + password // // Note there is no /auth/reset-password endpoint by design — reset is // implemented as "request a magic link, sign in, then call // /auth/set-password with the new one." Adding a dedicated reset // endpoint would duplicate the magic-link flow without adding any // security or UX. export function setupAuthRoutes(app) { app.post("/auth/request-link", (req, res) => { handleRequestLink(req, res).catch((err) => { console.error("[auth] /auth/request-link unhandled:", err); res.status(500).json({ error: "internal_error" }); }); }); app.get("/auth/verify", (req, res) => { handleVerify(req, res).catch((err) => { console.error("[auth] /auth/verify unhandled:", err); res.status(500).send(renderErrorPage("Internal error.")); }); }); app.post("/auth/signin-password", (req, res) => { handleSignInPassword(req, res).catch((err) => { console.error("[auth] /auth/signin-password unhandled:", err); res.status(500).json({ error: "internal_error" }); }); }); // Note: /api/account/password (set + clear) is registered by // account-routes.js, not here — those endpoints REQUIRE an existing // session, so they live outside the /auth/* public-path namespace // (which is allowed through the tenant-auth middleware unauthenticated). app.post("/auth/signout", (req, res) => { handleSignout(req, res).catch((err) => { console.error("[auth] /auth/signout unhandled:", err); res.status(500).json({ error: "internal_error" }); }); }); // Convenience GET version for plain link clicks ("Sign out") from // the UI without needing a form POST. app.get("/auth/signout", (req, res) => { handleSignout(req, res).catch((err) => { console.error("[auth] /auth/signout unhandled:", err); res.status(500).send(renderErrorPage("Internal error.")); }); }); }