import express from "express"; import path from "node:path"; import crypto from "node:crypto"; import bcrypt from "bcryptjs"; import { fetchPageText } from "./fetch-page.js"; import { runPrefill } from "./prefill.js"; import { parseAlog } from "./alog.js"; import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js"; import { evaluateRoast as defaultEvaluateRoast } from "./evaluate-roast.js"; import { sendMail } from "./mailer.js"; import { coerceSession, computeTotalScore, blankSession } from "../shared/cupping.js"; import { computeMachineProfile } from "../shared/learn.js"; const hash = (value) => crypto.createHash("sha256").update(value).digest("hex"); const token = () => crypto.randomBytes(32).toString("base64url"); const ADMIN_EMAIL = "snowspeeder@gmail.com"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const emailOf = (value) => String(value || "") .trim() .toLowerCase(); const PASSWORD_OK = (value) => typeof value === "string" && value.length >= 12 && value.length <= 256; // Compared against when no user row exists, so a non-existent email costs the same bcrypt work // as a real one and login can't be used to enumerate accounts by response timing. const DUMMY_PASSWORD_HASH = bcrypt.hashSync("no-such-account-password", 12); // Pages the browser reaches only through the routes below — never served directly by name, // so anonymous-vs-authenticated redirects and no-store headers can never be bypassed. const PUBLIC_SHELL_FILES = new Set([ "/index.html", "/admin.html", "/account.html", "/landing.html", "/login.html", "/signup.html", "/forgot.html", "/reset.html", "/setup.html", "/inventory.html", "/cupping.html", "/roasts.html", ]); /** Creates the HTTP app separately from listening, so tests can use an isolated database. */ export function createApp({ db, root, env = process.env, // Injectable so tests can stub the Pi-agent call; production always uses the real one. evaluateRoast = defaultEvaluateRoast, } = {}) { const app = express(); const production = env.NODE_ENV === "production"; const cookieSecure = env.COOKIE_SECURE ? env.COOKIE_SECURE === "true" : production; const origin = env.APP_ORIGIN || (production ? "https://roast.srmr.xyz" : ""); const mailFrom = env.SMTP_FROM || "Roast Planner "; // Password-reset links when no SMTP is configured: kept only in memory (never persisted), // so an admin can hand a user their link without the raw token ever touching the database. const pendingResets = new Map(); // token_hash -> { email, url, expiresAt } const buckets = new Map(); const MAX_RATE_BUCKETS = 10_000; const rateLimit = (name, max, windowMs) => { if ( !Number.isInteger(max) || max < 1 || max > 1_000 || !Number.isInteger(windowMs) || windowMs < 1_000 || windowMs > 3_600_000 ) throw new Error("Invalid rate-limit configuration"); return (req, res, next) => { const key = `${name}:${req.ip}`; const now = Date.now(); for (const [bucketKey, bucket] of buckets) { if (bucket.reset <= now) buckets.delete(bucketKey); } if (buckets.size >= MAX_RATE_BUCKETS && !buckets.has(key)) return res.status(429).json({ ok: false, code: "rate_limited" }); const bucket = buckets.get(key) || { count: 0, reset: now + windowMs }; bucket.count++; buckets.set(key, bucket); res.set("RateLimit-Limit", String(max)); res.set("RateLimit-Reset", String(Math.ceil(bucket.reset / 1_000))); if (bucket.count > max) return res.status(429).json({ ok: false, code: "rate_limited" }); next(); }; }; // Per-email login lockout: independent of the IP-keyed rateLimit above, so many accounts // sharing one proxy IP cannot lock each other out, and one attacker cannot brute-force a // single account from many IPs either. const loginFailures = new Map(); const LOGIN_MAX_FAILURES = 10; const LOGIN_LOCK_WINDOW = 15 * 60_000; const MAX_LOGIN_TRACKED = 10_000; function emailLocked(email) { const entry = loginFailures.get(email); return !!entry && entry.reset > Date.now() && entry.count >= LOGIN_MAX_FAILURES; } function recordLoginFailure(email) { const now = Date.now(); for (const [key, entry] of loginFailures) { if (entry.reset <= now) loginFailures.delete(key); } if (loginFailures.size >= MAX_LOGIN_TRACKED && !loginFailures.has(email)) return; let entry = loginFailures.get(email); if (!entry || entry.reset <= now) entry = { count: 0, reset: now + LOGIN_LOCK_WINDOW }; entry.count++; loginFailures.set(email, entry); } const clearLoginFailures = (email) => loginFailures.delete(email); app.disable("x-powered-by"); // Do not accept client-supplied forwarding headers unless the deployment explicitly // identifies its proxy. A numeric hop count is unsafe when the topology changes. app.set("trust proxy", env.TRUST_PROXY || false); app.use((req, res, next) => { if ( req.path.startsWith("/api/") || req.path === "/app" || req.path === "/admin" || req.path === "/account" || req.path === "/inventory" || req.path === "/cupping" || req.path === "/roasts" ) res.set("Cache-Control", "no-store, private"); res.set({ "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin-when-cross-origin", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", "Cross-Origin-Opener-Policy": "same-origin", "Content-Security-Policy": "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:", }); next(); }); // Finished-roast uploads carry a whole Artisan .alog (full telemetry arrays) inside a JSON // string — those legitimately run to a few MB, so that one route gets a larger body cap // without loosening the 1mb limit everything else keeps. const jsonBody = express.json({ limit: "1mb" }); const jsonBodyLarge = express.json({ limit: "8mb" }); app.use((req, res, next) => (req.method === "POST" && req.path === "/api/roasts" ? jsonBodyLarge : jsonBody)( req, res, next, ), ); // express.json() leaves req.body undefined when the request has no body or a non-JSON // content-type (Express 5 no longer defaults it to {}), so every route below that reads // req.body. would 500 instead of validating and returning 400. app.use((req, _res, next) => { req.body ??= {}; next(); }); const cookie = (req, name) => Object.fromEntries( (req.headers.cookie || "") .split(";") .map((x) => x.trim().split("=")) .filter((x) => x[0]), )[name]; const setSessionCookie = (res, value, maxAge, csrfToken = "") => { res.cookie("rp_session", value, { httpOnly: true, secure: cookieSecure, sameSite: "lax", path: "/", maxAge, }); res.cookie("rp_csrf", csrfToken, { httpOnly: false, secure: cookieSecure, sameSite: "lax", path: "/", maxAge, }); }; async function session(req) { const raw = cookie(req, "rp_session"); if (!raw) return null; const r = await db.query( "SELECT s.csrf_hash,u.id,u.email,u.role,u.created_at FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now() AND u.disabled_at IS NULL", [hash(raw)], ); const row = r.rows[0]; if (!row) return null; // Sliding expiry + last-seen, throttled to once/hour so an active user is never logged // out mid-session; best-effort and never blocks the request. db.query( "UPDATE sessions SET expires_at=now()+interval '14 days', last_seen_at=now() WHERE token_hash=$1 AND (last_seen_at IS NULL OR last_seen_at {}); return row; } async function requireAuth(req, res, next) { try { req.user = await session(req); if (!req.user) return res.status(401).json({ ok: false, code: "unauthorized" }); next(); } catch (e) { next(e); } } const csrf = (req, res, next) => { if (origin && req.get("origin") && req.get("origin") !== origin) return res.status(403).json({ ok: false, code: "bad_origin" }); const value = req.get("x-csrf-token"); if ( !value || value !== cookie(req, "rp_csrf") || !req.user || !crypto.timingSafeEqual( Buffer.from(hash(value)), Buffer.from(req.user.csrf_hash), ) ) return res.status(403).json({ ok: false, code: "csrf_failed" }); next(); }; const admin = (req, res, next) => req.user.role === "admin" ? next() : res.status(403).json({ ok: false, code: "forbidden" }); const requireUuidParam = (name) => (req, res, next) => UUID_RE.test(req.params[name]) ? next() : res.status(404).json({ ok: false, code: "not_found" }); const isSelf = (req) => req.params.id === req.user.id; const isBootstrapAdmin = async (id) => { const r = await db.query("SELECT 1 FROM users WHERE id=$1 AND email=$2", [ id, ADMIN_EMAIL, ]); return !!r.rowCount; }; async function audit(actorUserId, action, target = null) { try { await db.query( "INSERT INTO audit_events(actor_user_id,action,target) VALUES($1,$2,$3)", [actorUserId, action, target], ); } catch (e) { console.error("audit_log_failed", action, e); } } /** Runs `fn(client)` inside BEGIN/COMMIT on a single checked-out connection — required for * genuine atomicity (pool.query() calls are not guaranteed to share a connection, so a bare * sequence of db.query("BEGIN")/db.query(...)/db.query("COMMIT") does not actually protect * against a crash or error between statements the way it looks like it does). */ async function withTransaction(fn) { const client = await db.connect(); try { await client.query("BEGIN"); const result = await fn(client); await client.query("COMMIT"); return result; } catch (e) { await client.query("ROLLBACK").catch(() => {}); throw e; } finally { client.release(); } } const createSession = async (user, req) => { const raw = token(), csrfToken = token(); await db.query("DELETE FROM sessions WHERE expires_at < now()").catch(() => {}); await db.query( "INSERT INTO sessions(token_hash,user_id,csrf_hash,expires_at,user_agent,ip,last_seen_at) VALUES($1,$2,$3,now()+interval '14 days',$4,$5,now())", [ hash(raw), user.id, hash(csrfToken), req?.get("user-agent")?.slice(0, 256) || null, req?.ip || null, ], ); return { raw, csrfToken }; }; // ─── Public shell pages ──────────────────────────────────────────────── app.get("/", async (req, res, next) => { try { // Returning users should not be left on the marketing page after signing in. if (await session(req)) return res.status(302).location("/app").end(); res.set("Cache-Control", "no-store, private"); res.sendFile(path.join(root, "public", "landing.html")); } catch (error) { next(error); } }); for (const route of ["/login", "/signup", "/forgot"]) { app.get(route, async (req, res, next) => { try { if (await session(req)) return res.status(302).location("/app").end(); res.set("Cache-Control", "no-store, private"); res.sendFile(path.join(root, "public", `${route.slice(1)}.html`)); } catch (error) { next(error); } }); } app.get("/reset", async (req, res, next) => { try { if (await session(req)) return res.status(302).location("/app").end(); res.set("Cache-Control", "no-store, private"); res.sendFile(path.join(root, "public", "reset.html")); } catch (error) { next(error); } }); app.get("/setup", async (req, res, next) => { try { if (await session(req)) return res.status(302).location("/app").end(); const exists = await db.query("SELECT 1 FROM users WHERE email=$1", [ ADMIN_EMAIL, ]); if (exists.rowCount) return res.status(302).location("/login").end(); res.set("Cache-Control", "no-store, private"); res.sendFile(path.join(root, "public", "setup.html")); } catch (error) { next(error); } }); // These three serve HTML to a browser navigation, so on failure they redirect to another // page rather than the requireAuth/admin API middleware's JSON 401/403 — a signed-out visit // or an expired session must land back on /login, not a bare JSON error page. app.get("/app", async (req, res, next) => { try { const user = await session(req); if (!user) return res.redirect("/login"); res.sendFile(path.join(root, "public", "index.html")); } catch (error) { next(error); } }); app.get("/account", async (req, res, next) => { try { const user = await session(req); if (!user) return res.redirect("/login"); res.sendFile(path.join(root, "public", "account.html")); } catch (error) { next(error); } }); app.get("/inventory", async (req, res, next) => { try { const user = await session(req); if (!user) return res.redirect("/login"); res.sendFile(path.join(root, "public", "inventory.html")); } catch (error) { next(error); } }); app.get("/cupping", async (req, res, next) => { try { const user = await session(req); if (!user) return res.redirect("/login"); res.sendFile(path.join(root, "public", "cupping.html")); } catch (error) { next(error); } }); app.get("/roasts", async (req, res, next) => { try { const user = await session(req); if (!user) return res.redirect("/login"); res.sendFile(path.join(root, "public", "roasts.html")); } catch (error) { next(error); } }); app.get("/admin", async (req, res, next) => { try { const user = await session(req); if (!user) return res.redirect("/login"); if (user.role !== "admin") return res.redirect("/app"); res.sendFile(path.join(root, "public", "admin.html")); } catch (error) { next(error); } }); // ─── Auth ────────────────────────────────────────────────────────────── app.get("/api/auth/signup-enabled", async (_req, res, next) => { try { const setting = await db.query( "SELECT value FROM app_settings WHERE key='signup_enabled'", ); res.json({ ok: true, enabled: setting.rows[0]?.value === "true" }); } catch (e) { next(e); } }); app.post( "/api/auth/signup", rateLimit("signup", 8, 60_000), async (req, res, next) => { try { const email = emailOf(req.body.email), password = req.body.password; if (!/^\S+@\S+\.\S+$/.test(email) || !PASSWORD_OK(password)) return res.status(400).json({ ok: false, code: "invalid_credentials", error: "Use a valid email and a password of at least 12 characters.", }); // Reserved for the one-time /api/auth/bootstrap flow — never claimable by regular // signup, or a fresh deployment's first visitor could permanently lock out the // real administrator before they ever get to set it up. if (email === ADMIN_EMAIL) return res.status(409).json({ ok: false, code: "email_exists" }); const setting = await db.query( "SELECT value FROM app_settings WHERE key='signup_enabled'", ); if (setting.rows[0]?.value !== "true") return res.status(403).json({ ok: false, code: "signup_disabled" }); const password_hash = await bcrypt.hash(password, 12); const user = ( await db.query( "INSERT INTO users(email,password_hash) VALUES($1,$2) RETURNING id,email,role", [email, password_hash], ) ).rows[0]; const s = await createSession(user, req); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); await audit(user.id, "signup"); res.status(201).json({ ok: true, user: { email: user.email, role: user.role }, csrfToken: s.csrfToken, }); } catch (e) { if (e.code === "23505") return res.status(409).json({ ok: false, code: "email_exists" }); next(e); } }, ); app.post( "/api/auth/bootstrap", rateLimit("bootstrap", 4, 60_000), async (req, res, next) => { try { const exists = await db.query("SELECT 1 FROM users WHERE email=$1", [ ADMIN_EMAIL, ]); // Once the administrator exists, the deployment no longer needs to retain // the bootstrap secret. Do not reveal whether a supplied token was valid. if (exists.rowCount) return res.status(409).json({ ok: false, code: "bootstrap_used" }); const bootstrap = String(req.body.setupToken || ""); if (!env.BOOTSTRAP_SETUP_TOKEN) return res .status(503) .json({ ok: false, code: "bootstrap_unavailable" }); if ( bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length || !crypto.timingSafeEqual( Buffer.from(bootstrap), Buffer.from(env.BOOTSTRAP_SETUP_TOKEN), ) ) return res .status(403) .json({ ok: false, code: "invalid_setup_token" }); if ( emailOf(req.body.email) !== ADMIN_EMAIL || !PASSWORD_OK(req.body.password) ) return res .status(400) .json({ ok: false, code: "invalid_credentials" }); const user = ( await db.query( "INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin') RETURNING id,email,role", [ADMIN_EMAIL, await bcrypt.hash(req.body.password, 12)], ) ).rows[0]; const s = await createSession(user, req); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); await audit(user.id, "bootstrap_admin"); res.status(201).json({ ok: true, user: { email: user.email, role: user.role }, csrfToken: s.csrfToken, }); } catch (e) { next(e); } }, ); app.post( "/api/auth/login", rateLimit("login", 10, 60_000), async (req, res, next) => { try { const email = emailOf(req.body.email); const user = ( await db.query( "SELECT id,email,role,password_hash,disabled_at FROM users WHERE email=$1", [email], ) ).rows[0]; // Always run bcrypt against something, even for an unknown email, so the // response time doesn't reveal whether the account exists; and check the lock // only *after* a failed compare, so a correct password always gets the owner in // even while other guesses have it locked — the lock throttles guessing, it must // never let an attacker who merely knows the address lock the owner out. const passwordOk = await bcrypt.compare( String(req.body.password || ""), user?.password_hash || DUMMY_PASSWORD_HASH, ); if (!user || user.disabled_at || !passwordOk) { if (emailLocked(email)) return res .status(429) .json({ ok: false, code: "too_many_attempts" }); recordLoginFailure(email); return res .status(401) .json({ ok: false, code: "invalid_credentials" }); } clearLoginFailures(email); const s = await createSession(user, req); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); await audit(user.id, "login"); res.json({ ok: true, user: { email: user.email, role: user.role }, csrfToken: s.csrfToken, }); } catch (e) { next(e); } }, ); app.get("/api/auth/me", requireAuth, (req, res) => res.json({ ok: true, user: { id: req.user.id, email: req.user.email, role: req.user.role, createdAt: req.user.created_at, }, }), ); // Logout deliberately does not require CSRF: the worst a forged cross-site request can do // is log the visitor out, and requiring CSRF here can strand a user with a stale/missing // csrf cookie in a state where they can neither use the app nor sign out of it. app.post("/api/auth/logout", requireAuth, async (req, res, next) => { try { await db.query("DELETE FROM sessions WHERE token_hash=$1", [ hash(cookie(req, "rp_session")), ]); setSessionCookie(res, "", 0, ""); res.json({ ok: true }); } catch (e) { next(e); } }); app.post( "/api/auth/forgot", rateLimit("forgot", 5, 15 * 60_000), async (req, res, next) => { try { const email = emailOf(req.body.email); const user = ( await db.query("SELECT id,email,role FROM users WHERE email=$1", [ email, ]) ).rows[0]; // Always answer the same way regardless of whether the account exists. if (!user) return res.json({ ok: true }); await db.query( "DELETE FROM password_reset_tokens WHERE user_id=$1", [user.id], ); const resetToken = token(); await db.query( "INSERT INTO password_reset_tokens(token_hash,user_id,expires_at) VALUES($1,$2,now()+interval '1 hour')", [hash(resetToken), user.id], ); const resetUrl = `${origin}/reset?token=${resetToken}`; const sent = await sendMail({ smtpUrl: env.SMTP_URL, from: mailFrom, to: user.email, subject: "Reset your Roast Planner password", text: `Reset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`, }).catch((e) => { console.error("password_reset_email_failed", e); return false; }); if (!sent) { console.log(`[password reset] ${user.email}: ${resetUrl}`); // Admin-account resets are deliberately kept out of the shared admin panel: // any other admin could otherwise read the link and take over that account // (including the bootstrap admin, which every other guard protects). // Recovering an admin without SMTP configured requires server console access. if (user.role !== "admin") pendingResets.set(hash(resetToken), { email: user.email, url: resetUrl, expiresAt: Date.now() + 60 * 60_000, }); } await audit(user.id, "password_reset_requested"); res.json({ ok: true }); } catch (e) { next(e); } }, ); app.post( "/api/auth/reset", rateLimit("reset", 10, 15 * 60_000), async (req, res, next) => { try { const rawToken = String(req.body.token || ""); const password = req.body.password; if (!rawToken || !PASSWORD_OK(password)) return res.status(400).json({ ok: false, code: "invalid_request" }); const tokenHash = hash(rawToken); const row = ( await db.query( "SELECT user_id FROM password_reset_tokens WHERE token_hash=$1 AND expires_at>now()", [tokenHash], ) ).rows[0]; if (!row) return res.status(400).json({ ok: false, code: "invalid_token" }); const password_hash = await bcrypt.hash(password, 12); await db.query("UPDATE users SET password_hash=$1 WHERE id=$2", [ password_hash, row.user_id, ]); await db.query("DELETE FROM password_reset_tokens WHERE user_id=$1", [ row.user_id, ]); await db.query("DELETE FROM sessions WHERE user_id=$1", [ row.user_id, ]); pendingResets.delete(tokenHash); const user = ( await db.query( "SELECT id,email,role,disabled_at FROM users WHERE id=$1", [row.user_id], ) ).rows[0]; await audit(user.id, "password_reset_completed"); // The password is changed either way, but a disabled account still can't sign // in — say so plainly instead of handing back a session the very next request // will reject with a bare 401. if (user.disabled_at) return res .status(403) .json({ ok: false, code: "account_disabled" }); const s = await createSession(user, req); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); res.json({ ok: true, user: { email: user.email, role: user.role }, csrfToken: s.csrfToken, }); } catch (e) { next(e); } }, ); // ─── Plans ───────────────────────────────────────────────────────────── app.get("/api/plans", requireAuth, async (req, res, next) => { try { res.json({ ok: true, plans: ( await db.query( "SELECT id,plan,created_at,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC", [req.user.id], ) ).rows, }); } catch (e) { next(e); } }); app.post("/api/plans", requireAuth, csrf, async (req, res, next) => { try { if (!req.body.plan || typeof req.body.plan !== "object") return res.status(400).json({ ok: false, code: "bad_plan" }); const p = ( await db.query( "INSERT INTO roast_plans(user_id,plan) VALUES($1,$2) RETURNING id,plan,created_at,updated_at", [req.user.id, req.body.plan], ) ).rows[0]; res.status(201).json({ ok: true, plan: p }); } catch (e) { next(e); } }); app.put( "/api/plans/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { if (!req.body.plan || typeof req.body.plan !== "object") return res.status(400).json({ ok: false, code: "bad_plan" }); const r = await db.query( "UPDATE roast_plans SET plan=$1,updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING id,plan,updated_at", [req.body.plan, req.params.id, req.user.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true, plan: r.rows[0] }); } catch (e) { next(e); } }, ); app.delete( "/api/plans/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const r = await db.query( "DELETE FROM roast_plans WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true }); } catch (e) { next(e); } }, ); // Learned per-user machine profile (pace factor + temperature bands) from this account's own // completed roasts — see shared/learn.js. Deliberately reads every one of the user's plans // rather than paginating: a personal roast log tops out at low hundreds of rows, and this is // the only place that number gets reduced, so there's nothing to cache incrementally against. app.get("/api/machine-profile", requireAuth, async (req, res, next) => { try { const rows = ( await db.query("SELECT plan FROM roast_plans WHERE user_id=$1", [req.user.id]) ).rows; res.json({ ok: true, profile: computeMachineProfile(rows.map((r) => r.plan)) }); } catch (e) { next(e); } }); // ─── Inventory ───────────────────────────────────────────────────────── // Strict on purpose: JSON.stringify silently turns a client-side NaN (e.g. a non-numeric // weight/cost typed into a field that isn't really constrained to digits) into null, which // is indistinguishable from "the user explicitly cleared this field" unless the server // requires the *type* to be number, not just coerces whatever arrives with Number(). A loose // Number(value) here previously let a garbage weight silently become 0 and corrupt // remaining_weight_g. const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v); function parseOptionalNumber(value, fieldName) { if (value === undefined || value === null || value === "") return { value: null }; if (!isFiniteNumber(value)) throw new Error(`${fieldName} must be a number`); return { value }; } const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; function parseOptionalDate(value) { if (value === undefined || value === null || value === "") return { value: null }; if (typeof value !== "string" || !DATE_RE.test(value) || Number.isNaN(Date.parse(value))) throw new Error("purchaseDate must be a YYYY-MM-DD date"); return { value }; } const toLotRow = (row) => ({ id: row.id, origin: row.origin, variety: row.variety, process: row.process, producer: row.producer, purchaseDate: row.purchase_date, initialWeightG: Number(row.initial_weight_g), remainingWeightG: Number(row.remaining_weight_g), costTotal: row.cost_total == null ? null : Number(row.cost_total), moisturePct: row.moisture_pct == null ? null : Number(row.moisture_pct), densityGL: row.density_g_l == null ? null : Number(row.density_g_l), notes: row.notes, archived: row.archived, createdAt: row.created_at, updatedAt: row.updated_at, }); app.get("/api/inventory", requireAuth, async (req, res, next) => { try { const rows = ( await db.query( "SELECT * FROM green_bean_lots WHERE user_id=$1 ORDER BY archived ASC, purchase_date DESC NULLS LAST, created_at DESC", [req.user.id], ) ).rows; res.json({ ok: true, lots: rows.map(toLotRow) }); } catch (e) { next(e); } }); app.post("/api/inventory", requireAuth, csrf, async (req, res, next) => { try { const origin = String(req.body.origin || "").trim(); if (!origin || !isFiniteNumber(req.body.initialWeightG) || req.body.initialWeightG < 0) return res.status(400).json({ ok: false, code: "bad_lot" }); let costTotal, moisturePct, densityGL, purchaseDate; try { costTotal = parseOptionalNumber(req.body.costTotal, "costTotal"); moisturePct = parseOptionalNumber(req.body.moisturePct, "moisturePct"); densityGL = parseOptionalNumber(req.body.densityGL, "densityGL"); purchaseDate = parseOptionalDate(req.body.purchaseDate); } catch (e) { return res.status(400).json({ ok: false, code: "bad_lot", error: e.message }); } const row = ( await db.query( `INSERT INTO green_bean_lots (user_id,origin,variety,process,producer,purchase_date,initial_weight_g,remaining_weight_g,cost_total,moisture_pct,density_g_l,notes) VALUES($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11) RETURNING *`, [ req.user.id, origin, String(req.body.variety || ""), String(req.body.process || ""), String(req.body.producer || ""), purchaseDate.value, req.body.initialWeightG, costTotal.value, moisturePct.value, densityGL.value, String(req.body.notes || ""), ], ) ).rows[0]; res.status(201).json({ ok: true, lot: toLotRow(row) }); } catch (e) { next(e); } }); app.get( "/api/inventory/:id", requireAuth, requireUuidParam("id"), async (req, res, next) => { try { const lot = ( await db.query( "SELECT * FROM green_bean_lots WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ) ).rows[0]; if (!lot) return res.status(404).json({ ok: false, code: "not_found" }); const log = ( await db.query( `SELECT c.id,c.weight_g,c.roast_plan_id,c.created_at,p.plan->'fields'->>'0.1' AS plan_title FROM bean_consumption c LEFT JOIN roast_plans p ON p.id=c.roast_plan_id AND p.user_id=c.user_id WHERE c.lot_id=$1 ORDER BY c.created_at DESC`, [req.params.id], ) ).rows; res.json({ ok: true, lot: toLotRow(lot), log: log.map((r) => ({ id: r.id, weightG: Number(r.weight_g), roastPlanId: r.roast_plan_id, planTitle: r.plan_title, createdAt: r.created_at, })), }); } catch (e) { next(e); } }, ); // The auto-refine-carry-forward feature: the most recent past roast against this lot whose // "One change next batch" note was actually filled in, so the planner can offer it as a // starting ± Refine value instead of the user retyping their own prior conclusion. Reads // inventory.lotId straight off the plan JSONB — no join needed, and scoping to this user's own // roast_plans is what keeps this ownership-safe regardless of whether :id even belongs to them. app.get( "/api/inventory/:id/last-refine", requireAuth, requireUuidParam("id"), async (req, res, next) => { try { const lot = ( await db.query( "SELECT id FROM green_bean_lots WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ) ).rows[0]; if (!lot) return res.status(404).json({ ok: false, code: "not_found" }); const row = ( await db.query( `SELECT plan->'afterRoast'->>'oneChange' AS one_change, plan->'fields'->>'0.1' AS plan_title, updated_at FROM roast_plans WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2 AND coalesce(plan->'afterRoast'->>'oneChange','')<>'' ORDER BY updated_at DESC LIMIT 1`, [req.user.id, req.params.id], ) ).rows[0]; res.json({ ok: true, refine: row ? { oneChange: row.one_change, planTitle: row.plan_title, atIso: row.updated_at } : null, }); } catch (e) { next(e); } }, ); app.put( "/api/inventory/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const existing = ( await db.query( "SELECT * FROM green_bean_lots WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ) ).rows[0]; if (!existing) return res.status(404).json({ ok: false, code: "not_found" }); const b = req.body; const origin = b.origin === undefined ? existing.origin : String(b.origin || "").trim(); if (!origin) return res.status(400).json({ ok: false, code: "bad_lot" }); if ( b.initialWeightG !== undefined && (!isFiniteNumber(b.initialWeightG) || b.initialWeightG < 0) ) return res.status(400).json({ ok: false, code: "bad_lot" }); let costTotal, moisturePct, densityGL, purchaseDate; try { costTotal = b.costTotal === undefined ? existing.cost_total : parseOptionalNumber(b.costTotal, "costTotal").value; moisturePct = b.moisturePct === undefined ? existing.moisture_pct : parseOptionalNumber(b.moisturePct, "moisturePct").value; densityGL = b.densityGL === undefined ? existing.density_g_l : parseOptionalNumber(b.densityGL, "densityGL").value; purchaseDate = b.purchaseDate === undefined ? existing.purchase_date : parseOptionalDate(b.purchaseDate).value; } catch (e) { return res.status(400).json({ ok: false, code: "bad_lot", error: e.message }); } // remainingWeightG/consumptionLog are never read from the request body: remaining // only ever moves via consume(), except for the delta below, which is the // sanctioned correction path (recount the bag, fix the paperwork). The delta is // computed inside the UPDATE itself (against the pre-statement initial_weight_g) // rather than from the `existing` row read above, so a concurrent PUT can't lose // the other's delta the way a JS-side read-then-write would. const row = ( await db.query( `UPDATE green_bean_lots SET origin=$1, variety=$2, process=$3, producer=$4, purchase_date=$5, remaining_weight_g = remaining_weight_g + COALESCE($6::numeric, initial_weight_g) - initial_weight_g, initial_weight_g = COALESCE($6::numeric, initial_weight_g), cost_total=$7, moisture_pct=$8, density_g_l=$9, notes=$10, archived=$11, updated_at=now() WHERE id=$12 AND user_id=$13 RETURNING *`, [ origin, b.variety === undefined ? existing.variety : String(b.variety || ""), b.process === undefined ? existing.process : String(b.process || ""), b.producer === undefined ? existing.producer : String(b.producer || ""), purchaseDate, b.initialWeightG === undefined ? null : b.initialWeightG, costTotal, moisturePct, densityGL, b.notes === undefined ? existing.notes : String(b.notes || ""), b.archived === undefined ? existing.archived : Boolean(b.archived), req.params.id, req.user.id, ], ) ).rows[0]; res.json({ ok: true, lot: toLotRow(row) }); } catch (e) { next(e); } }, ); app.delete( "/api/inventory/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const r = await db.query( "DELETE FROM green_bean_lots WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true }); } catch (e) { next(e); } }, ); app.post( "/api/inventory/:id/consume", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const weightG = Number(req.body.weightG); if (!Number.isFinite(weightG) || weightG <= 0) return res.status(400).json({ ok: false, code: "bad_weight" }); const lotExists = ( await db.query( "SELECT 1 FROM green_bean_lots WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ) ).rowCount; if (!lotExists) return res.status(404).json({ ok: false, code: "not_found" }); let roastPlanId = null; if (req.body.roastPlanId) { if (!UUID_RE.test(req.body.roastPlanId)) return res.status(404).json({ ok: false, code: "not_found" }); const ownsPlan = ( await db.query( "SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2", [req.body.roastPlanId, req.user.id], ) ).rowCount; if (!ownsPlan) return res.status(404).json({ ok: false, code: "not_found" }); roastPlanId = req.body.roastPlanId; } // The log insert and the balance update must commit together — on separate pool // connections a crash or error between them would leave a consumption row logged // with no corresponding decrement, and the unique index means that plan could // never be retried. See withTransaction's own comment for why plain sequential // db.query("BEGIN")/db.query(...) calls do not actually guarantee this. let row; try { row = await withTransaction(async (client) => { try { await client.query( "INSERT INTO bean_consumption(lot_id,user_id,roast_plan_id,weight_g) VALUES($1,$2,$3,$4)", [req.params.id, req.user.id, roastPlanId, weightG], ); } catch (e) { if (e.code === "23505") { const err = new Error("already_consumed"); err.httpCode = "already_consumed"; throw err; } throw e; } const updated = ( await client.query( "UPDATE green_bean_lots SET remaining_weight_g=remaining_weight_g-$1, updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING *", [weightG, req.params.id, req.user.id], ) ).rows[0]; if (!updated) { // The lot was deleted by a concurrent request between the ownership // check above and here — roll back the log insert too. const err = new Error("not_found"); err.httpCode = "not_found"; throw err; } return updated; }); } catch (e) { if (e.httpCode === "already_consumed") return res.status(409).json({ ok: false, code: "already_consumed" }); if (e.httpCode === "not_found") return res.status(404).json({ ok: false, code: "not_found" }); throw e; } res.status(201).json({ ok: true, lot: toLotRow(row) }); } catch (e) { next(e); } }, ); // ─── Actual roasts (finished .alog uploads) ──────────────────────────── const toRoastRow = (row, { full = false } = {}) => { const parsed = row.parsed || {}; const evaluation = row.evaluation || null; const base = { id: row.id, roastPlanId: row.roast_plan_id, planTitle: row.plan_title ?? null, filename: row.filename, roast: parsed.roast ?? null, derived: parsed.derived ?? null, evaluationStatus: row.evaluation_status, evaluationError: row.evaluation_error, evaluationGrade: evaluation?.grade ?? null, evaluationSummary: evaluation?.summary ?? null, createdAt: row.created_at, }; return full ? { ...base, parsed, evaluation, plan: row.plan ?? null } : base; }; // Fire-and-forget: the upload response never waits on the model (a deep review takes tens of // seconds, and uploads arrive in batches); the row starts 'pending' and the client polls. // Failure is recorded on the row rather than lost — 'failed' + evaluation_error, and the // re-evaluate endpoint below is the retry path (e.g. once a model is configured). function startEvaluation(roastId, userId) { const run = (async () => { const row = ( await db.query( `SELECT a.parsed, p.plan FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id WHERE a.id=$1 AND a.user_id=$2`, [roastId, userId], ) ).rows[0]; if (!row) return; try { const evaluation = await evaluateRoast(row.parsed, row.plan ?? null); await db.query( "UPDATE actual_roasts SET evaluation=$1, evaluation_status='done', evaluation_error=NULL, updated_at=now() WHERE id=$2", [evaluation, roastId], ); } catch (e) { await db.query( "UPDATE actual_roasts SET evaluation_status='failed', evaluation_error=$1, updated_at=now() WHERE id=$2", [`${e.code ?? "error"}: ${e.message}`.slice(0, 500), roastId], ); } })().catch((e) => console.error("roast_evaluation_failed", roastId, e)); return run; } app.post("/api/roasts", requireAuth, csrf, async (req, res, next) => { try { const content = req.body.content; if (typeof content !== "string" || !content.trim()) return res.status(400).json({ ok: false, code: "bad_request" }); const filename = String(req.body.filename || "upload.alog").slice(0, 200); let roastPlanId = null; if (req.body.roastPlanId) { if (!UUID_RE.test(req.body.roastPlanId)) return res.status(404).json({ ok: false, code: "not_found" }); const ownsPlan = ( await db.query( "SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2", [req.body.roastPlanId, req.user.id], ) ).rowCount; if (!ownsPlan) return res.status(404).json({ ok: false, code: "not_found" }); roastPlanId = req.body.roastPlanId; } let parsed; try { parsed = parseAlog(content, filename); } catch (err) { return res .status(422) .json({ ok: false, code: "unparseable_alog", error: err.message }); } const row = ( await db.query( `INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed) VALUES($1,$2,$3,$4,$5) RETURNING *`, [req.user.id, roastPlanId, filename, content, parsed], ) ).rows[0]; startEvaluation(row.id, req.user.id); res.status(201).json({ ok: true, roast: toRoastRow(row) }); } catch (e) { next(e); } }); app.get("/api/roasts", requireAuth, async (req, res, next) => { try { const planFilter = typeof req.query.plan === "string" && UUID_RE.test(req.query.plan) ? req.query.plan : null; const rows = ( await db.query( `SELECT a.id,a.roast_plan_id,a.filename,a.parsed,a.evaluation,a.evaluation_status,a.evaluation_error,a.created_at, p.plan->'fields'->>'0.1' AS plan_title FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id WHERE a.user_id=$1 AND ($2::uuid IS NULL OR a.roast_plan_id=$2) ORDER BY a.created_at DESC`, [req.user.id, planFilter], ) ).rows; res.json({ ok: true, roasts: rows.map((r) => toRoastRow(r)) }); } catch (e) { next(e); } }); app.get( "/api/roasts/:id", requireAuth, requireUuidParam("id"), async (req, res, next) => { try { const row = ( await db.query( `SELECT a.*, p.plan->'fields'->>'0.1' AS plan_title, p.plan FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id WHERE a.id=$1 AND a.user_id=$2`, [req.params.id, req.user.id], ) ).rows[0]; if (!row) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true, roast: toRoastRow(row, { full: true }) }); } catch (e) { next(e); } }, ); app.get( "/api/roasts/:id/download", requireAuth, requireUuidParam("id"), async (req, res, next) => { try { const row = ( await db.query( "SELECT filename,original_content FROM actual_roasts WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ) ).rows[0]; if (!row) return res.status(404).json({ ok: false, code: "not_found" }); let name = row.filename.replace(/[^\w.\- ]+/g, "_").trim() || "roast"; if (!/\.alog$/i.test(name)) name += ".alog"; res.set({ "Content-Type": "application/octet-stream", "Content-Disposition": `attachment; filename="${name}"`, }); res.send(row.original_content); } catch (e) { next(e); } }, ); app.post( "/api/roasts/:id/evaluate", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const r = await db.query( "UPDATE actual_roasts SET evaluation_status='pending', evaluation_error=NULL, updated_at=now() WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); startEvaluation(req.params.id, req.user.id); res.json({ ok: true, evaluationStatus: "pending" }); } catch (e) { next(e); } }, ); app.delete( "/api/roasts/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const r = await db.query( "DELETE FROM actual_roasts WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true }); } catch (e) { next(e); } }, ); // ─── Cupping ─────────────────────────────────────────────────────────── const toSessionRow = (row, { full = false } = {}) => { const data = row.data || {}; const base = { id: row.id, roastPlanId: row.roast_plan_id, planTitle: row.plan_title ?? undefined, cupCount: data.cup_count, totalScore: Number(row.total_score), flavorTags: data.flavor_tags || [], createdAt: row.created_at, updatedAt: row.updated_at, }; if (row.plan_title === undefined) delete base.planTitle; return full ? { ...base, data } : base; }; app.get("/api/cupping", requireAuth, async (req, res, next) => { try { const planFilter = typeof req.query.plan === "string" && UUID_RE.test(req.query.plan) ? req.query.plan : null; const rows = ( await db.query( `SELECT s.id,s.roast_plan_id,s.data,s.total_score,s.created_at,s.updated_at, p.plan->'fields'->>'0.1' AS plan_title FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id WHERE s.user_id=$1 AND ($2::uuid IS NULL OR s.roast_plan_id=$2) ORDER BY s.updated_at DESC`, [req.user.id, planFilter], ) ).rows; res.json({ ok: true, sessions: rows.map((r) => toSessionRow(r)) }); } catch (e) { next(e); } }); app.post("/api/cupping", requireAuth, csrf, async (req, res, next) => { try { let roastPlanId = null; if (req.body.roastPlanId) { if (!UUID_RE.test(req.body.roastPlanId)) return res.status(404).json({ ok: false, code: "not_found" }); const ownsPlan = ( await db.query( "SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2", [req.body.roastPlanId, req.user.id], ) ).rowCount; if (!ownsPlan) return res.status(404).json({ ok: false, code: "not_found" }); roastPlanId = req.body.roastPlanId; } const cupCount = req.body.cupCount == null ? undefined : Number(req.body.cupCount); let data; try { data = coerceSession(blankSession(Number.isFinite(cupCount) ? cupCount : undefined)); } catch (e) { return res.status(400).json({ ok: false, code: "bad_session", error: e.message }); } const row = ( await db.query( "INSERT INTO cupping_sessions(user_id,roast_plan_id,data,total_score) VALUES($1,$2,$3,0) RETURNING *", [req.user.id, roastPlanId, data], ) ).rows[0]; res.status(201).json({ ok: true, session: toSessionRow(row, { full: true }) }); } catch (e) { next(e); } }); app.get( "/api/cupping/:id", requireAuth, requireUuidParam("id"), async (req, res, next) => { try { const row = ( await db.query( `SELECT s.*, p.plan->'fields'->>'0.1' AS plan_title FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id WHERE s.id=$1 AND s.user_id=$2`, [req.params.id, req.user.id], ) ).rows[0]; if (!row) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true, session: toSessionRow(row, { full: true }) }); } catch (e) { next(e); } }, ); app.put( "/api/cupping/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { if (!req.body.data || typeof req.body.data !== "object") return res.status(400).json({ ok: false, code: "bad_session" }); let coerced; try { coerced = coerceSession(req.body.data); } catch (e) { return res .status(400) .json({ ok: false, code: "bad_session", error: e.message }); } const total = computeTotalScore( coerced.scores, coerced.ticks, coerced.taint_cups, coerced.fault_cups, coerced.cup_count, ); const row = ( await db.query( "UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING *", [coerced, total, req.params.id, req.user.id], ) ).rows[0]; if (!row) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true, session: toSessionRow(row, { full: true }) }); } catch (e) { next(e); } }, ); app.delete( "/api/cupping/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const r = await db.query( "DELETE FROM cupping_sessions WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true }); } catch (e) { next(e); } }, ); // ─── Account ─────────────────────────────────────────────────────────── app.put("/api/account/email", requireAuth, csrf, async (req, res, next) => { try { const email = emailOf(req.body.email); if (!/^\S+@\S+\.\S+$/.test(email)) return res.status(400).json({ ok: false, code: "invalid_email" }); // The bootstrap admin's email is fixed: they can't change away from it (which would // strip the protection every other guard relies on), and nobody else can claim it // (which would falsely grant that same protection to an unrelated account). if (email === ADMIN_EMAIL || (await isBootstrapAdmin(req.user.id))) return res.status(403).json({ ok: false, code: "forbidden" }); const user = ( await db.query("SELECT password_hash FROM users WHERE id=$1", [ req.user.id, ]) ).rows[0]; if (!(await bcrypt.compare(String(req.body.password || ""), user.password_hash))) return res.status(401).json({ ok: false, code: "invalid_credentials" }); await db.query("UPDATE users SET email=$1 WHERE id=$2", [ email, req.user.id, ]); await audit(req.user.id, "email_changed", email); res.json({ ok: true, user: { email, role: req.user.role } }); } catch (e) { if (e.code === "23505") return res.status(409).json({ ok: false, code: "email_exists" }); next(e); } }); app.put( "/api/account/password", requireAuth, csrf, async (req, res, next) => { try { if (!PASSWORD_OK(req.body.newPassword)) return res.status(400).json({ ok: false, code: "invalid_password" }); const user = ( await db.query("SELECT password_hash FROM users WHERE id=$1", [ req.user.id, ]) ).rows[0]; if ( !(await bcrypt.compare( String(req.body.currentPassword || ""), user.password_hash, )) ) return res .status(401) .json({ ok: false, code: "invalid_credentials" }); const password_hash = await bcrypt.hash(req.body.newPassword, 12); await db.query("UPDATE users SET password_hash=$1 WHERE id=$2", [ password_hash, req.user.id, ]); // Keep only the session that just proved the current password. await db.query( "DELETE FROM sessions WHERE user_id=$1 AND token_hash<>$2", [req.user.id, hash(cookie(req, "rp_session"))], ); await audit(req.user.id, "password_changed"); res.json({ ok: true }); } catch (e) { next(e); } }, ); app.get("/api/account/sessions", requireAuth, async (req, res, next) => { try { const currentHash = hash(cookie(req, "rp_session")); const rows = ( await db.query( "SELECT token_hash,user_agent,ip,created_at,last_seen_at,expires_at FROM sessions WHERE user_id=$1 ORDER BY COALESCE(last_seen_at,created_at) DESC", [req.user.id], ) ).rows; res.json({ ok: true, sessions: rows.map((row) => ({ id: row.token_hash.slice(0, 16), userAgent: row.user_agent, ip: row.ip, createdAt: row.created_at, lastSeenAt: row.last_seen_at, expiresAt: row.expires_at, current: row.token_hash === currentHash, })), }); } catch (e) { next(e); } }); app.delete( "/api/account/sessions/:id", requireAuth, csrf, async (req, res, next) => { try { const id = String(req.params.id || ""); if (!/^[0-9a-f]{16}$/i.test(id)) return res.status(404).json({ ok: false, code: "not_found" }); const r = await db.query( "DELETE FROM sessions WHERE user_id=$1 AND token_hash LIKE $2", [req.user.id, `${id}%`], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true }); } catch (e) { next(e); } }, ); app.post( "/api/account/sessions/revoke-others", requireAuth, csrf, async (req, res, next) => { try { await db.query( "DELETE FROM sessions WHERE user_id=$1 AND token_hash<>$2", [req.user.id, hash(cookie(req, "rp_session"))], ); await audit(req.user.id, "sessions_revoked_others"); res.json({ ok: true }); } catch (e) { next(e); } }, ); app.delete("/api/account", requireAuth, csrf, async (req, res, next) => { try { const user = ( await db.query("SELECT password_hash,role FROM users WHERE id=$1", [ req.user.id, ]) ).rows[0]; if (!(await bcrypt.compare(String(req.body.password || ""), user.password_hash))) return res.status(401).json({ ok: false, code: "invalid_credentials" }); // The bootstrap admin can never remove themselves — deleting that account would // re-open /setup to anyone still holding the deployment's bootstrap token. if (await isBootstrapAdmin(req.user.id)) return res.status(403).json({ ok: false, code: "forbidden" }); if (user.role === "admin") { const admins = await db.query( "SELECT count(*)::int AS count FROM users WHERE role='admin'", ); if (admins.rows[0].count <= 1) return res.status(409).json({ ok: false, code: "last_admin" }); } await audit(req.user.id, "account_self_deleted"); await db.query("DELETE FROM users WHERE id=$1", [req.user.id]); setSessionCookie(res, "", 0, ""); res.json({ ok: true }); } catch (e) { next(e); } }); // ─── Admin ───────────────────────────────────────────────────────────── app.get("/api/admin/users", requireAuth, admin, async (req, res, next) => { try { res.json({ ok: true, signupEnabled: ( await db.query( "SELECT value FROM app_settings WHERE key='signup_enabled'", ) ).rows[0]?.value === "true", users: ( await db.query( "SELECT u.id,u.email,u.role,u.created_at,u.disabled_at,count(p.id)::int AS plan_count FROM users u LEFT JOIN roast_plans p ON p.user_id=u.id GROUP BY u.id,u.email,u.role,u.created_at,u.disabled_at ORDER BY u.created_at", ) ).rows, }); } catch (e) { next(e); } }); app.get("/api/admin/metrics", requireAuth, admin, async (req, res, next) => { try { const [users, plans, recentPlans, sessions] = await Promise.all([ db.query("SELECT count(*)::int AS count FROM users"), db.query("SELECT count(*)::int AS count FROM roast_plans"), db.query( "SELECT count(*)::int AS count FROM roast_plans WHERE updated_at > now() - interval '7 days'", ), db.query( "SELECT count(*)::int AS count FROM sessions WHERE expires_at > now()", ), ]); res.json({ ok: true, metrics: { totalUsers: users.rows[0].count, totalPlans: plans.rows[0].count, plansUpdatedLast7Days: recentPlans.rows[0].count, activeSessions: sessions.rows[0].count, }, }); } catch (e) { next(e); } }); app.get("/api/admin/plans", requireAuth, admin, async (req, res, next) => { try { const userFilter = typeof req.query.user === "string" && UUID_RE.test(req.query.user) ? req.query.user : null; const rows = ( await db.query( `SELECT p.id,p.plan->'fields'->>'0.1' AS title,p.updated_at,u.id AS user_id,u.email FROM roast_plans p JOIN users u ON u.id=p.user_id WHERE $1::uuid IS NULL OR p.user_id=$1 ORDER BY p.updated_at DESC`, [userFilter], ) ).rows; res.json({ ok: true, plans: rows }); } catch (e) { next(e); } }); app.get("/api/admin/audit", requireAuth, admin, async (req, res, next) => { try { const rows = ( await db.query( `SELECT a.id,a.action,a.target,a.created_at,u.email AS actor_email FROM audit_events a LEFT JOIN users u ON u.id=a.actor_user_id ORDER BY a.created_at DESC LIMIT 50`, ) ).rows; res.json({ ok: true, events: rows }); } catch (e) { next(e); } }); app.get( "/api/admin/password-resets", requireAuth, admin, async (_req, res) => { const now = Date.now(); const links = []; for (const [tokenHash, entry] of pendingResets) { if (entry.expiresAt <= now) { pendingResets.delete(tokenHash); continue; } links.push({ email: entry.email, url: entry.url, expiresAt: new Date(entry.expiresAt).toISOString(), }); } res.json({ ok: true, links }); }, ); app.put( "/api/admin/signup-enabled", requireAuth, csrf, admin, async (req, res, next) => { try { if (typeof req.body.enabled !== "boolean") return res.status(400).json({ ok: false, code: "bad_request" }); await db.query( "UPDATE app_settings SET value=$1 WHERE key='signup_enabled'", [String(req.body.enabled)], ); await audit(req.user.id, "signup_toggled", String(req.body.enabled)); res.json({ ok: true }); } catch (e) { next(e); } }, ); app.put( "/api/admin/users/:id/role", requireAuth, csrf, admin, requireUuidParam("id"), async (req, res, next) => { try { if (!["user", "admin"].includes(req.body.role)) return res.status(400).json({ ok: false, code: "bad_request" }); if (isSelf(req) || (await isBootstrapAdmin(req.params.id))) return res.status(403).json({ ok: false, code: "forbidden" }); const r = await db.query( "UPDATE users SET role=$1 WHERE id=$2 RETURNING id,email,role", [req.body.role, req.params.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); await audit(req.user.id, "role_changed", `${r.rows[0].email}->${req.body.role}`); res.json({ ok: true, user: r.rows[0] }); } catch (e) { next(e); } }, ); app.put( "/api/admin/users/:id/disabled", requireAuth, csrf, admin, requireUuidParam("id"), async (req, res, next) => { try { if (typeof req.body.disabled !== "boolean") return res.status(400).json({ ok: false, code: "bad_request" }); if (isSelf(req) || (await isBootstrapAdmin(req.params.id))) return res.status(403).json({ ok: false, code: "forbidden" }); const r = await db.query( "UPDATE users SET disabled_at=$1 WHERE id=$2 RETURNING id,email,disabled_at", [req.body.disabled ? new Date() : null, req.params.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); if (req.body.disabled) await db.query("DELETE FROM sessions WHERE user_id=$1", [ req.params.id, ]); await audit( req.user.id, req.body.disabled ? "user_disabled" : "user_enabled", r.rows[0].email, ); res.json({ ok: true, user: r.rows[0] }); } catch (e) { next(e); } }, ); app.delete( "/api/admin/users/:id", requireAuth, csrf, admin, requireUuidParam("id"), async (req, res, next) => { try { if (isSelf(req) || (await isBootstrapAdmin(req.params.id))) return res.status(403).json({ ok: false, code: "forbidden" }); const r = await db.query( "DELETE FROM users WHERE id=$1 RETURNING email", [req.params.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); await audit(req.user.id, "user_deleted", r.rows[0].email); res.json({ ok: true }); } catch (e) { next(e); } }, ); // Existing integrations remain authenticated but CSRF-protected for writes. app.post("/api/prefill", requireAuth, csrf, async (req, res) => { const url = typeof req.body?.url === "string" ? req.body.url.trim() : ""; if (!url) return res .status(400) .json({ ok: false, code: "bad_url", error: "Missing url." }); try { const result = await runPrefill(await fetchPageText(url)); res.json({ ok: true, ...result }); } catch (err) { const code = err.code ?? "prefill_failed"; res .status( code === "fetch_timeout" ? 504 : code === "bad_url" ? 400 : code === "no_model" ? 503 : 422, ) .json({ ok: false, code, error: err.message }); } }); app.post("/api/alog", requireAuth, csrf, (req, res) => { try { if (typeof req.body?.content !== "string" || !req.body.content.trim()) return res.status(400).json({ ok: false, code: "bad_request" }); res.json({ ok: true, ...parseAlog(req.body.content, req.body.filename ?? "upload.alog"), }); } catch (err) { res .status(422) .json({ ok: false, code: "unparseable_alog", error: err.message }); } }); app.get("/api/alog/library", requireAuth, async (_q, res) => res.json({ ok: true, files: await listAlogLibrary() }), ); app.get("/api/alog/library/:filename", requireAuth, async (req, res) => { try { res.json({ ok: true, ...(await readAlogFromLibrary(req.params.filename)), }); } catch (e) { res .status(e.code === "not_found" ? 404 : 400) .json({ ok: false, code: e.code, error: e.message }); } }); app.use((req, res, next) => { // express.static resolves percent-encoding and repeated slashes before hitting disk, so // the blocklist must compare against the same normalized form or a request like // /%69ndex.html or //index.html would slip past this check and still be served below. let normalized; try { normalized = path.posix.normalize(decodeURIComponent(req.path)); } catch { normalized = req.path; } return PUBLIC_SHELL_FILES.has(normalized) ? res.status(302).location("/").end() : next(); }); // index:false — otherwise express.static serves index.html for any request path that // normalizes to a directory (e.g. "/", "/./", "//"), bypassing the shell-file blocklist // above the same way a direct filename request would. app.use(express.static(path.join(root, "public"), { index: false })); app.use("/shared", express.static(path.join(root, "shared"))); app.use((err, _req, res, _next) => { // Malformed JSON / an oversized body are client errors (from express.json()'s parser), // not server faults — respect the status it already picked instead of masking every // bad request as a 500 with a logged stack trace. const status = err.status || err.statusCode; if (status >= 400 && status < 500) return res.status(status).json({ ok: false, code: "bad_request" }); console.error(err); res.status(500).json({ ok: false, code: "internal_error" }); }); return app; }