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
815 lines
30 KiB
JavaScript
815 lines
30 KiB
JavaScript
// 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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
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."));
|
|
});
|
|
});
|
|
}
|