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
+814
View File
@@ -0,0 +1,814 @@
// 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=<plaintext>
// - 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."));
});
});
}