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 { listAvailableModels as defaultListModels } from "./llm.js"; import { buildOpenApiSpec } from "./openapi.js"; import { computeRoasterProfile } from "./roaster-profile.js"; import { coerceChatMessages, runPlanChat as defaultRunPlanChat } from "./plan-chat.js"; import { BREW_CATEGORIES, BREW_METHODS } from "../shared/brew-data.js"; import { createRequire } from "node:module"; 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", "/beans.html", "/brews.html", "/gear.html", "/roasters.html", "/api-docs.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 LLM calls; production always uses the real ones. evaluateRoast = defaultEvaluateRoast, listModels = defaultListModels, runPlanChat = defaultRunPlanChat, } = {}) { 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" || req.path === "/beans" || req.path === "/brews" || req.path === "/gear" || req.path === "/roasters" || req.path === "/api-docs" ) 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 — and a full-database restore can be far // larger still. Those specific routes get bigger body caps without loosening the 1mb limit // everything else keeps. const jsonBody = express.json({ limit: "1mb" }); const jsonBodyLarge = express.json({ limit: "8mb" }); const jsonBodyBackup = express.json({ limit: "256mb" }); app.use((req, res, next) => { const parser = req.method === "POST" && req.path === "/api/admin/backup/import" ? jsonBodyBackup : req.method === "POST" && req.path === "/api/roasts" ? jsonBodyLarge : jsonBody; return parser(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) { // API tokens: an Authorization: Bearer header authenticates exactly like a session for // the owning user. Header-borne credentials can't be sent by a cross-site form, so // token-authenticated requests are exempt from CSRF (see csrf() below). const authHeader = req.get("authorization") || ""; if (authHeader.startsWith("Bearer ")) { const rawToken = authHeader.slice(7).trim(); if (!rawToken) return null; const r = await db.query( "SELECT u.id,u.email,u.role,u.created_at FROM api_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=$1 AND u.disabled_at IS NULL", [hash(rawToken)], ); const row = r.rows[0]; if (!row) return null; db.query( "UPDATE api_tokens SET last_used_at=now() WHERE token_hash=$1", [hash(rawToken)], ).catch(() => {}); return { ...row, token_auth: true }; } 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,(u.avatar IS NOT NULL) AS has_avatar 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) => { // Bearer-token requests carry no cookies for a cross-site attacker to ride on. if (req.user?.token_auth) return 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); } }); for (const [route, file] of [ ["/beans", "beans.html"], ["/brews", "brews.html"], ["/gear", "gear.html"], ["/roasters", "roasters.html"], ["/api-docs", "api-docs.html"], ]) { app.get(route, async (req, res, next) => { try { const user = await session(req); if (!user) return res.redirect("/login"); res.sendFile(path.join(root, "public", file)); } 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, hasAvatar: Boolean(req.user.has_avatar), }, }), ); // 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); } }, ); // Admin-chosen LLM model ("provider:id"), empty = auto. Read per call so a change takes // effect immediately without restarting the app. const llmModelSetting = async () => ( await db.query("SELECT value FROM app_settings WHERE key='llm_model'") ).rows[0]?.value || ""; // ─── Roasters (per-user machines) ────────────────────────────────────── // Numeric override keys the user may tweak on a roaster — these replace the corresponding // LEARNED values wherever the profile is applied (plan curve fallbacks, pace). const OVERRIDE_KEYS = { chargeTempC: [0, 400], turningPointS: [10, 300], turningPointTempC: [0, 400], yellowTempC: [0, 400], firstCrackTempC: [0, 400], dropTempC: [0, 400], paceFactor: [0.5, 2], }; function parseOverrides(raw, existing = {}) { if (raw === undefined) return { value: existing }; if (!raw || typeof raw !== "object") return { error: "overrides must be an object" }; const out = {}; for (const [key, [lo, hi]] of Object.entries(OVERRIDE_KEYS)) { const v = raw[key]; if (v === undefined || v === null || v === "") continue; if (typeof v !== "number" || !Number.isFinite(v) || v < lo || v > hi) return { error: `${key} must be a number between ${lo} and ${hi}` }; out[key] = v; } return { value: out }; } const toRoasterRow = (row) => ({ id: row.id, name: row.name, model: row.model, notes: row.notes, isDefault: row.is_default, overrides: row.overrides ?? {}, roastCount: row.roast_count == null ? undefined : Number(row.roast_count), createdAt: row.created_at, updatedAt: row.updated_at, }); app.get("/api/roasters", requireAuth, async (req, res, next) => { try { const [rows, counts] = await Promise.all([ db .query( "SELECT * FROM roasters WHERE user_id=$1 ORDER BY created_at", [req.user.id], ) .then((r) => r.rows), db .query( "SELECT roaster_id, COUNT(*) AS roast_count FROM actual_roasts WHERE user_id=$1 AND roaster_id IS NOT NULL GROUP BY roaster_id", [req.user.id], ) .then((r) => new Map(r.rows.map((x) => [x.roaster_id, x.roast_count]))), ]); res.json({ ok: true, roasters: rows.map((row) => toRoasterRow({ ...row, roast_count: counts.get(row.id) ?? 0 }), ), }); } catch (e) { next(e); } }); app.post("/api/roasters", requireAuth, csrf, async (req, res, next) => { try { const name = String(req.body.name || "").trim(); if (!name) return res.status(400).json({ ok: false, code: "bad_roaster" }); const overrides = parseOverrides(req.body.overrides, {}); if (overrides.error) return res .status(400) .json({ ok: false, code: "bad_roaster", error: overrides.error }); // The user's first roaster becomes the default automatically. const hasAny = ( await db.query("SELECT 1 FROM roasters WHERE user_id=$1 LIMIT 1", [req.user.id]) ).rowCount; const makeDefault = !hasAny || Boolean(req.body.isDefault); if (makeDefault && hasAny) await db.query( "UPDATE roasters SET is_default=false WHERE user_id=$1", [req.user.id], ); const row = ( await db.query( "INSERT INTO roasters(user_id,name,model,notes,is_default,overrides) VALUES($1,$2,$3,$4,$5,$6) RETURNING *", [ req.user.id, name, String(req.body.model || ""), String(req.body.notes || ""), makeDefault, JSON.stringify(overrides.value), ], ) ).rows[0]; res.status(201).json({ ok: true, roaster: toRoasterRow(row) }); } catch (e) { next(e); } }); app.put( "/api/roasters/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const existing = ( await db.query("SELECT * FROM roasters 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 name = b.name === undefined ? existing.name : String(b.name || "").trim(); if (!name) return res.status(400).json({ ok: false, code: "bad_roaster" }); const overrides = parseOverrides(b.overrides, existing.overrides ?? {}); if (overrides.error) return res .status(400) .json({ ok: false, code: "bad_roaster", error: overrides.error }); const makeDefault = b.isDefault === undefined ? existing.is_default : Boolean(b.isDefault); if (makeDefault && !existing.is_default) await db.query( "UPDATE roasters SET is_default=false WHERE user_id=$1", [req.user.id], ); const row = ( await db.query( "UPDATE roasters SET name=$1, model=$2, notes=$3, is_default=$4, overrides=$5, updated_at=now() WHERE id=$6 AND user_id=$7 RETURNING *", [ name, b.model === undefined ? existing.model : String(b.model || ""), b.notes === undefined ? existing.notes : String(b.notes || ""), makeDefault, JSON.stringify(overrides.value), req.params.id, req.user.id, ], ) ).rows[0]; res.json({ ok: true, roaster: toRoasterRow(row) }); } catch (e) { next(e); } }, ); app.delete( "/api/roasters/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const r = await db.query( "DELETE FROM roasters 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 roaster behavior aggregated from this user's uploaded .alogs — grounds the plan // chat, the roast evaluations, and the planner's suggested curve temps. Scoped to one // machine: an explicit roasterId, else the user's default roaster, else all uploads // (covers pre-roaster history and users with a single unmanaged machine). The returned // medians already have that roaster's user overrides applied; the raw learned values ride // along so the report page can show learned-vs-applied. async function userRoasterProfile(userId, roasterId = null) { let roaster = null; if (roasterId) { roaster = ( await db.query("SELECT * FROM roasters WHERE id=$1 AND user_id=$2", [ roasterId, userId, ]) ).rows[0]; if (!roaster) return null; } else { roaster = ( await db.query( "SELECT * FROM roasters WHERE user_id=$1 AND is_default=true", [userId], ) ).rows[0] ?? null; } const rows = roaster ? ( await db.query( "SELECT parsed FROM actual_roasts WHERE user_id=$1 AND roaster_id=$2", [userId, roaster.id], ) ).rows : ( await db.query("SELECT parsed FROM actual_roasts WHERE user_id=$1", [userId]) ).rows; const learned = computeRoasterProfile(rows.map((r) => r.parsed)); const overrides = roaster?.overrides ?? {}; const { paceFactor, ...tempOverrides } = overrides; return { ...learned, medians: { ...(learned.medians ?? {}), ...tempOverrides }, learnedMedians: learned.medians ?? null, overrides, paceFactorOverride: Number.isFinite(paceFactor) ? paceFactor : null, roaster: roaster ? { id: roaster.id, name: roaster.name, isDefault: roaster.is_default } : null, }; } app.get("/api/roaster-profile", requireAuth, async (req, res, next) => { try { const roasterId = typeof req.query.roaster === "string" && UUID_RE.test(req.query.roaster) ? req.query.roaster : null; const profile = await userRoasterProfile(req.user.id, roasterId); if (!profile) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true, profile }); } catch (e) { next(e); } }); app.post("/api/plan-chat", 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" }); let messages; try { messages = coerceChatMessages(req.body.messages); } catch (e) { return res.status(400).json({ ok: false, code: "bad_messages", error: e.message }); } const [roasterProfile, planRows, preferredModel] = await Promise.all([ userRoasterProfile(req.user.id), db .query("SELECT plan FROM roast_plans WHERE user_id=$1", [req.user.id]) .then((r) => r.rows.map((row) => row.plan)), llmModelSetting(), ]); const result = await runPlanChat({ plan: req.body.plan, messages, machineProfile: computeMachineProfile(planRows), roasterProfile, preferredModel, }); res.json({ ok: true, ...result }); } catch (err) { const code = err.code ?? "chat_failed"; res .status(code === "no_model" ? 503 : 422) .json({ ok: false, code, error: err.message }); } }); // ─── 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, roasterId: row.roaster_id ?? null, roasterName: row.roaster_name ?? 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, a.roaster_id, 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 llmModelSetting(), await userRoasterProfile(userId, row.roaster_id ?? 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 }); } // Attach to the requested roaster (must be the user's own), else the default one. let roasterId = null; if (req.body.roasterId) { if (!UUID_RE.test(req.body.roasterId)) return res.status(404).json({ ok: false, code: "not_found" }); const owns = ( await db.query("SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2", [ req.body.roasterId, req.user.id, ]) ).rowCount; if (!owns) return res.status(404).json({ ok: false, code: "not_found" }); roasterId = req.body.roasterId; } else { roasterId = ( await db.query( "SELECT id FROM roasters WHERE user_id=$1 AND is_default=true", [req.user.id], ) ).rows[0]?.id ?? null; } const row = ( await db.query( `INSERT INTO actual_roasts(user_id,roast_plan_id,roaster_id,filename,original_content,parsed) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, [req.user.id, roastPlanId, roasterId, 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.roaster_id,a.filename,a.parsed,a.evaluation,a.evaluation_status,a.evaluation_error,a.created_at, p.plan->'fields'->>'0.1' AS plan_title, m.name AS roaster_name FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id LEFT JOIN roasters m ON m.id=a.roaster_id AND m.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, m.name AS roaster_name FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id LEFT JOIN roasters m ON m.id=a.roaster_id AND m.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); } }, ); // Reassign a roast to another of the user's machines (the only mutable field on an upload — // the file and its parse are immutable history). app.put( "/api/roasts/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { let roasterId = null; if (req.body.roasterId !== undefined && req.body.roasterId !== null && req.body.roasterId !== "") { if (!UUID_RE.test(req.body.roasterId)) return res.status(404).json({ ok: false, code: "not_found" }); const owns = ( await db.query("SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2", [ req.body.roasterId, req.user.id, ]) ).rowCount; if (!owns) return res.status(404).json({ ok: false, code: "not_found" }); roasterId = req.body.roasterId; } const r = await db.query( "UPDATE actual_roasts SET roaster_id=$1, updated_at=now() WHERE id=$2 AND user_id=$3", [roasterId, req.params.id, req.user.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true, roasterId }); } 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); } }, ); // ─── Roasted beans (brewing-side bean management) ────────────────────── // Remaining weight is DERIVED (initial − Σ brew doses), never stored, so concurrent brew // logging can't corrupt it and deleting a brew automatically "returns" its dose. const BEAN_TEXT_FIELDS = [ "roaster", "origin", "process", "variety", "roastLevel", "url", "tastingNotes", "notes", ]; const BEAN_COLUMN = { roaster: "roaster", origin: "origin", process: "process", variety: "variety", roastLevel: "roast_level", url: "url", tastingNotes: "tasting_notes", notes: "notes", }; const toBeanRow = (row) => ({ id: row.id, name: row.name, roaster: row.roaster, origin: row.origin, process: row.process, variety: row.variety, roastLevel: row.roast_level, roastDate: row.roast_date, initialWeightG: row.initial_weight_g == null ? null : Number(row.initial_weight_g), remainingWeightG: row.initial_weight_g == null ? null : Number(row.initial_weight_g) - Number(row.used_g ?? 0), url: row.url, tastingNotes: row.tasting_notes, notes: row.notes, roastPlanId: row.roast_plan_id, archived: row.archived, brewCount: row.brew_count == null ? undefined : Number(row.brew_count), createdAt: row.created_at, updatedAt: row.updated_at, }); // Two plain queries merged in JS (not a correlated subquery/GROUP BY b.*) so the identical // SQL runs on both real PostgreSQL and the pg-mem test database. async function beanUsage(userId) { const rows = ( await db.query( "SELECT bean_id, SUM(dose_g) AS used_g, COUNT(*) AS brew_count FROM brews WHERE user_id=$1 AND bean_id IS NOT NULL GROUP BY bean_id", [userId], ) ).rows; return new Map(rows.map((r) => [r.bean_id, r])); } app.get("/api/beans", requireAuth, async (req, res, next) => { try { const [rows, usage] = await Promise.all([ db .query( "SELECT * FROM roasted_beans WHERE user_id=$1 ORDER BY archived ASC, created_at DESC", [req.user.id], ) .then((r) => r.rows), beanUsage(req.user.id), ]); res.json({ ok: true, beans: rows.map((row) => toBeanRow({ ...row, ...usage.get(row.id) })), }); } catch (e) { next(e); } }); app.post("/api/beans", requireAuth, csrf, async (req, res, next) => { try { const name = String(req.body.name || "").trim(); if (!name) return res.status(400).json({ ok: false, code: "bad_bean" }); let initialWeightG, roastDate; try { initialWeightG = parseOptionalNumber(req.body.initialWeightG, "initialWeightG").value; roastDate = parseOptionalDate(req.body.roastDate).value; } catch (e) { return res.status(400).json({ ok: false, code: "bad_bean", error: e.message }); } 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 owns = ( await db.query("SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2", [ req.body.roastPlanId, req.user.id, ]) ).rowCount; if (!owns) return res.status(404).json({ ok: false, code: "not_found" }); roastPlanId = req.body.roastPlanId; } const row = ( await db.query( `INSERT INTO roasted_beans (user_id,name,roaster,origin,process,variety,roast_level,roast_date,initial_weight_g,url,tasting_notes,notes,roast_plan_id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, [ req.user.id, name, ...BEAN_TEXT_FIELDS.slice(0, 4).map((f) => String(req.body[f] || "")), String(req.body.roastLevel || ""), roastDate, initialWeightG, String(req.body.url || ""), String(req.body.tastingNotes || ""), String(req.body.notes || ""), roastPlanId, ], ) ).rows[0]; res.status(201).json({ ok: true, bean: toBeanRow(row) }); } catch (e) { next(e); } }); app.put( "/api/beans/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const existing = ( await db.query( "SELECT * FROM roasted_beans 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 name = b.name === undefined ? existing.name : String(b.name || "").trim(); if (!name) return res.status(400).json({ ok: false, code: "bad_bean" }); let initialWeightG, roastDate; try { initialWeightG = b.initialWeightG === undefined ? existing.initial_weight_g : parseOptionalNumber(b.initialWeightG, "initialWeightG").value; roastDate = b.roastDate === undefined ? existing.roast_date : parseOptionalDate(b.roastDate).value; } catch (e) { return res.status(400).json({ ok: false, code: "bad_bean", error: e.message }); } const text = {}; for (const f of BEAN_TEXT_FIELDS) text[f] = b[f] === undefined ? existing[BEAN_COLUMN[f]] : String(b[f] || ""); const row = ( await db.query( `UPDATE roasted_beans SET name=$1, roaster=$2, origin=$3, process=$4, variety=$5, roast_level=$6, roast_date=$7, initial_weight_g=$8, url=$9, tasting_notes=$10, notes=$11, archived=$12, updated_at=now() WHERE id=$13 AND user_id=$14 RETURNING *`, [ name, text.roaster, text.origin, text.process, text.variety, text.roastLevel, roastDate, initialWeightG, text.url, text.tastingNotes, text.notes, b.archived === undefined ? existing.archived : Boolean(b.archived), req.params.id, req.user.id, ], ) ).rows[0]; res.json({ ok: true, bean: toBeanRow(row) }); } catch (e) { next(e); } }, ); app.delete( "/api/beans/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const r = await db.query( "DELETE FROM roasted_beans 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); } }, ); // ─── Brews ───────────────────────────────────────────────────────────── const BREW_METHOD_KEYS = new Set(BREW_METHODS.map((m) => m.key)); const toBrewRow = (row) => ({ id: row.id, beanId: row.bean_id, beanName: row.bean_name ?? null, method: row.method, doseG: row.dose_g == null ? null : Number(row.dose_g), waterG: row.water_g == null ? null : Number(row.water_g), yieldG: row.yield_g == null ? null : Number(row.yield_g), grinder: row.grinder, grindSetting: row.grind_setting, waterTempC: row.water_temp_c == null ? null : Number(row.water_temp_c), brewTimeS: row.brew_time_s, bloomTimeS: row.bloom_time_s, rating: row.rating == null ? null : Number(row.rating), recipe: row.recipe, tastingNotes: row.tasting_notes, notes: row.notes, brewedAt: row.brewed_at, createdAt: row.created_at, updatedAt: row.updated_at, }); /** Shared validation for create/update; returns {error} or the normalized values. */ function parseBrewBody(b, existing = null) { const method = b.method === undefined ? existing?.method : String(b.method || ""); if (!method || !BREW_METHOD_KEYS.has(method)) return { error: "unknown brew method" }; const values = { method }; try { for (const [field, name] of [ ["doseG", "dose_g"], ["waterG", "water_g"], ["yieldG", "yield_g"], ["waterTempC", "water_temp_c"], ["rating", "rating"], ]) values[name] = b[field] === undefined ? (existing?.[name] ?? null) : parseOptionalNumber(b[field], field).value; } catch (e) { return { error: e.message }; } if (values.rating !== null && (values.rating < 0 || values.rating > 10)) return { error: "rating must be 0-10" }; for (const [field, name] of [ ["brewTimeS", "brew_time_s"], ["bloomTimeS", "bloom_time_s"], ]) { const v = b[field] === undefined ? (existing?.[name] ?? null) : b[field]; if (v === null || v === undefined || v === "") values[name] = null; else if (!Number.isInteger(v) || v < 0 || v > 86_400) return { error: `${field} must be a whole number of seconds` }; else values[name] = v; } for (const [field, name] of [ ["grinder", "grinder"], ["grindSetting", "grind_setting"], ["recipe", "recipe"], ["tastingNotes", "tasting_notes"], ["notes", "notes"], ]) values[name] = b[field] === undefined ? (existing?.[name] ?? "") : String(b[field] || ""); return { values }; } app.get("/api/brews", requireAuth, async (req, res, next) => { try { const beanFilter = typeof req.query.bean === "string" && UUID_RE.test(req.query.bean) ? req.query.bean : null; const rows = ( await db.query( `SELECT w.*, b.name AS bean_name FROM brews w LEFT JOIN roasted_beans b ON b.id=w.bean_id AND b.user_id=w.user_id WHERE w.user_id=$1 AND ($2::uuid IS NULL OR w.bean_id=$2) ORDER BY w.brewed_at DESC`, [req.user.id, beanFilter], ) ).rows; res.json({ ok: true, brews: rows.map(toBrewRow) }); } catch (e) { next(e); } }); app.post("/api/brews", requireAuth, csrf, async (req, res, next) => { try { let beanId = null; if (req.body.beanId) { if (!UUID_RE.test(req.body.beanId)) return res.status(404).json({ ok: false, code: "not_found" }); const owns = ( await db.query("SELECT 1 FROM roasted_beans WHERE id=$1 AND user_id=$2", [ req.body.beanId, req.user.id, ]) ).rowCount; if (!owns) return res.status(404).json({ ok: false, code: "not_found" }); beanId = req.body.beanId; } const parsedBody = parseBrewBody(req.body); if (parsedBody.error) return res .status(400) .json({ ok: false, code: "bad_brew", error: parsedBody.error }); const v = parsedBody.values; const row = ( await db.query( `INSERT INTO brews (user_id,bean_id,method,dose_g,water_g,yield_g,grinder,grind_setting,water_temp_c,brew_time_s,bloom_time_s,rating,recipe,tasting_notes,notes) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, [ req.user.id, beanId, v.method, v.dose_g, v.water_g, v.yield_g, v.grinder, v.grind_setting, v.water_temp_c, v.brew_time_s, v.bloom_time_s, v.rating, v.recipe, v.tasting_notes, v.notes, ], ) ).rows[0]; res.status(201).json({ ok: true, brew: toBrewRow(row) }); } catch (e) { next(e); } }); app.put( "/api/brews/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const existing = ( await db.query("SELECT * FROM brews 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" }); let beanId = existing.bean_id; if (req.body.beanId !== undefined) { if (req.body.beanId === null || req.body.beanId === "") beanId = null; else { if (!UUID_RE.test(req.body.beanId)) return res.status(404).json({ ok: false, code: "not_found" }); const owns = ( await db.query( "SELECT 1 FROM roasted_beans WHERE id=$1 AND user_id=$2", [req.body.beanId, req.user.id], ) ).rowCount; if (!owns) return res.status(404).json({ ok: false, code: "not_found" }); beanId = req.body.beanId; } } const parsedBody = parseBrewBody(req.body, existing); if (parsedBody.error) return res .status(400) .json({ ok: false, code: "bad_brew", error: parsedBody.error }); const v = parsedBody.values; const row = ( await db.query( `UPDATE brews SET bean_id=$1, method=$2, dose_g=$3, water_g=$4, yield_g=$5, grinder=$6, grind_setting=$7, water_temp_c=$8, brew_time_s=$9, bloom_time_s=$10, rating=$11, recipe=$12, tasting_notes=$13, notes=$14, updated_at=now() WHERE id=$15 AND user_id=$16 RETURNING *`, [ beanId, v.method, v.dose_g, v.water_g, v.yield_g, v.grinder, v.grind_setting, v.water_temp_c, v.brew_time_s, v.bloom_time_s, v.rating, v.recipe, v.tasting_notes, v.notes, req.params.id, req.user.id, ], ) ).rows[0]; res.json({ ok: true, brew: toBrewRow(row) }); } catch (e) { next(e); } }, ); app.delete( "/api/brews/:id", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { try { const r = await db.query( "DELETE FROM brews 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); } }, ); // ─── API tokens ──────────────────────────────────────────────────────── app.get("/api/tokens", requireAuth, async (req, res, next) => { try { const rows = ( await db.query( "SELECT token_hash,name,created_at,last_used_at FROM api_tokens WHERE user_id=$1 ORDER BY created_at DESC", [req.user.id], ) ).rows; res.json({ ok: true, tokens: rows.map((row) => ({ id: row.token_hash.slice(0, 16), name: row.name, createdAt: row.created_at, lastUsedAt: row.last_used_at, })), }); } catch (e) { next(e); } }); app.post("/api/tokens", requireAuth, csrf, async (req, res, next) => { try { const name = String(req.body.name || "").slice(0, 100); const count = ( await db.query( "SELECT count(*)::int AS count FROM api_tokens WHERE user_id=$1", [req.user.id], ) ).rows[0].count; if (count >= 20) return res.status(409).json({ ok: false, code: "too_many_tokens" }); // "rpt_" marks the string as a Roast Planner token in secret scanners and logs. const raw = `rpt_${token()}`; await db.query( "INSERT INTO api_tokens(token_hash,user_id,name) VALUES($1,$2,$3)", [hash(raw), req.user.id, name], ); await audit(req.user.id, "api_token_created", name || null); // The raw token is shown exactly once and never stored. res.status(201).json({ ok: true, token: raw, id: hash(raw).slice(0, 16), name, }); } catch (e) { next(e); } }); app.delete("/api/tokens/: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 api_tokens 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" }); await audit(req.user.id, "api_token_revoked"); 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); } }, ); // Profile picture: a small data URL, resized client-side before upload. Served back as a // real image so /background-image can use it without shipping base64 in every page. const AVATAR_RE = /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/=]+)$/; app.put("/api/account/avatar", requireAuth, csrf, async (req, res, next) => { try { const dataUrl = req.body.dataUrl; if ( typeof dataUrl !== "string" || dataUrl.length > 300_000 || !AVATAR_RE.test(dataUrl) ) return res.status(400).json({ ok: false, code: "bad_avatar" }); await db.query("UPDATE users SET avatar=$1 WHERE id=$2", [ dataUrl, req.user.id, ]); res.json({ ok: true }); } catch (e) { next(e); } }); app.delete("/api/account/avatar", requireAuth, csrf, async (req, res, next) => { try { await db.query("UPDATE users SET avatar=NULL WHERE id=$1", [req.user.id]); res.json({ ok: true }); } catch (e) { next(e); } }); app.get("/api/account/avatar", requireAuth, async (req, res, next) => { try { const avatar = ( await db.query("SELECT avatar FROM users WHERE id=$1", [req.user.id]) ).rows[0]?.avatar; const match = avatar ? avatar.match(AVATAR_RE) : null; if (!match) return res.status(404).json({ ok: false, code: "not_found" }); res.set({ "Content-Type": `image/${match[1]}`, "Cache-Control": "private, max-age=300", }); res.send(Buffer.from(match[2], "base64")); } 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 }); }, ); // ─── Backup (full-database export/import) ────────────────────────────── // Per-table column allowlists, in FK-dependency order (parents first). Insert follows this // order, delete runs it reversed. sessions and password_reset_tokens are deliberately not // part of a backup: they are short-lived secrets, and restoring them would resurrect revoked // access. Deleting users cascades both away on import anyway. const BACKUP_TABLES = [ ["users", ["id", "email", "password_hash", "role", "created_at", "disabled_at", "avatar"]], ["app_settings", ["key", "value"]], ["roast_plans", ["id", "user_id", "plan", "created_at", "updated_at"]], [ "green_bean_lots", [ "id", "user_id", "origin", "variety", "process", "producer", "purchase_date", "initial_weight_g", "remaining_weight_g", "cost_total", "moisture_pct", "density_g_l", "notes", "archived", "created_at", "updated_at", ], ], ["bean_consumption", ["id", "lot_id", "user_id", "roast_plan_id", "weight_g", "created_at"]], ["cupping_sessions", ["id", "user_id", "roast_plan_id", "data", "total_score", "created_at", "updated_at"]], ["roasters", ["id", "user_id", "name", "model", "notes", "is_default", "overrides", "created_at", "updated_at"]], [ "actual_roasts", [ "id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at", ], ], [ "roasted_beans", [ "id", "user_id", "name", "roaster", "origin", "process", "variety", "roast_level", "roast_date", "initial_weight_g", "url", "tasting_notes", "notes", "roast_plan_id", "archived", "created_at", "updated_at", ], ], [ "brews", [ "id", "user_id", "bean_id", "method", "dose_g", "water_g", "yield_g", "grinder", "grind_setting", "water_temp_c", "brew_time_s", "bloom_time_s", "rating", "recipe", "tasting_notes", "notes", "brewed_at", "created_at", "updated_at", ], ], ["api_tokens", ["token_hash", "user_id", "name", "created_at", "last_used_at"]], ["user_gear", ["user_id", "brewers", "grinders", "updated_at"]], ["audit_events", ["id", "actor_user_id", "action", "target", "created_at"]], ]; const BACKUP_VERSION = 1; app.get("/api/admin/backup", requireAuth, admin, async (req, res, next) => { try { const tables = {}; for (const [table, columns] of BACKUP_TABLES) tables[table] = ( await db.query(`SELECT ${columns.join(",")} FROM ${table}`) ).rows; await audit(req.user.id, "backup_exported"); res.set( "Content-Disposition", `attachment; filename="roast-planner-backup-${new Date().toISOString().slice(0, 10)}.json"`, ); res.json({ ok: true, format: "roast-planner-backup", version: BACKUP_VERSION, exportedAt: new Date().toISOString(), tables, }); } catch (e) { next(e); } }); app.post( "/api/admin/backup/import", requireAuth, csrf, admin, async (req, res, next) => { try { const body = req.body; if ( body.format !== "roast-planner-backup" || body.version !== BACKUP_VERSION || !body.tables || typeof body.tables !== "object" ) return res.status(400).json({ ok: false, code: "bad_backup" }); // The restored database must still contain at least one active admin, or the // import would permanently lock everyone (including this caller) out. const users = Array.isArray(body.tables.users) ? body.tables.users : []; if (!users.some((u) => u.role === "admin" && !u.disabled_at)) return res.status(400).json({ ok: false, code: "backup_has_no_admin" }); const counts = {}; const currentSessionHash = req.user.token_auth ? null : hash(cookie(req, "rp_session")); const currentCsrfHash = req.user.csrf_hash ?? null; const currentUserId = req.user.id; await withTransaction(async (client) => { for (const [table] of [...BACKUP_TABLES].reverse()) await client.query(`DELETE FROM ${table}`); for (const [table, columns] of BACKUP_TABLES) { const rows = Array.isArray(body.tables[table]) ? body.tables[table] : []; for (const row of rows) { const params = columns.map((column) => { const value = row[column]; if (value === undefined) return null; // jsonb columns arrive as objects; everything else is scalar. return value !== null && typeof value === "object" ? JSON.stringify(value) : value; }); await client.query( `INSERT INTO ${table}(${columns.join(",")}) VALUES(${columns.map((_, i) => `$${i + 1}`).join(",")})`, params, ); } counts[table] = rows.length; } // Deleting users cascaded this caller's session away. If the same user id // exists in the restored data, re-create the session so the admin who ran the // import stays signed in; otherwise they must log in with restored credentials. if ( currentSessionHash && currentCsrfHash && users.some((u) => u.id === currentUserId) ) await client.query( "INSERT INTO sessions(token_hash,user_id,csrf_hash,expires_at,last_seen_at) VALUES($1,$2,$3,now()+interval '14 days',now())", [currentSessionHash, currentUserId, currentCsrfHash], ); }); await audit(currentUserId, "backup_imported", JSON.stringify(counts).slice(0, 500)); res.json({ ok: true, counts, sessionKept: users.some((u) => u.id === currentUserId), }); } catch (e) { next(e); } }, ); // Per-user export: the same shape, restricted to the caller's own rows (no password hashes, // no tokens) — a personal data takeout rather than a restorable server backup. app.get("/api/account/export", requireAuth, async (req, res, next) => { try { const mine = async (table, columns, userColumn = "user_id") => ( await db.query( `SELECT ${columns.join(",")} FROM ${table} WHERE ${userColumn}=$1`, [req.user.id], ) ).rows; const tables = {}; for (const [table, columns] of BACKUP_TABLES) { if (table === "users" || table === "app_settings" || table === "api_tokens" || table === "audit_events") continue; tables[table] = await mine(table, columns); } res.set( "Content-Disposition", `attachment; filename="roast-planner-my-data-${new Date().toISOString().slice(0, 10)}.json"`, ); res.json({ ok: true, format: "roast-planner-user-export", version: BACKUP_VERSION, exportedAt: new Date().toISOString(), user: { email: req.user.email }, tables, }); } catch (e) { next(e); } }); // The LLM model picker: which configured model runs prefill and roast reviews. Listing can // legitimately fail (no models configured yet) — the GET still succeeds so the admin page can // say so instead of erroring, and only a non-empty PUT requires the list for validation. app.get("/api/admin/llm", requireAuth, admin, async (req, res, next) => { try { const current = await llmModelSetting(); let models = []; let modelsError = null; try { models = await listModels(); } catch (e) { modelsError = "llm_unavailable"; } res.json({ ok: true, current, models, modelsError }); } catch (e) { next(e); } }); app.put( "/api/admin/llm", requireAuth, csrf, admin, async (req, res, next) => { try { const model = req.body.model; if (typeof model !== "string" || model.length > 200) return res.status(400).json({ ok: false, code: "bad_request" }); if (model !== "") { let models; try { models = await listModels(); } catch (e) { return res .status(503) .json({ ok: false, code: "llm_unavailable" }); } if (!models.some((m) => m.key === model)) return res .status(400) .json({ ok: false, code: "unknown_model" }); } await db.query( "UPDATE app_settings SET value=$1 WHERE key='llm_model'", [model], ); await audit(req.user.id, "llm_model_changed", model || "auto"); res.json({ ok: true, current: model }); } catch (e) { next(e); } }, ); 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), await llmModelSetting(), ); 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 }); } }); // ─── Equipment (owned brewers + grinders) ────────────────────────────── app.get("/api/gear", requireAuth, async (req, res, next) => { try { const row = ( await db.query("SELECT brewers,grinders FROM user_gear WHERE user_id=$1", [ req.user.id, ]) ).rows[0]; res.json({ ok: true, gear: { brewers: row?.brewers ?? [], grinders: row?.grinders ?? [] }, }); } catch (e) { next(e); } }); app.put("/api/gear", requireAuth, csrf, async (req, res, next) => { try { const rawBrewers = req.body.brewers; const rawGrinders = req.body.grinders; if (!Array.isArray(rawBrewers) || !Array.isArray(rawGrinders)) return res.status(400).json({ ok: false, code: "bad_gear" }); const brewers = [...new Set(rawBrewers)]; if ( brewers.length > BREW_METHODS.length || !brewers.every((k) => BREW_METHOD_KEYS.has(k)) ) return res .status(400) .json({ ok: false, code: "bad_gear", error: "unknown brewer key" }); const grinders = [ ...new Set( rawGrinders .filter((g) => typeof g === "string") .map((g) => g.trim().slice(0, 100)) .filter(Boolean), ), ].slice(0, 20); const row = ( await db.query( `INSERT INTO user_gear(user_id,brewers,grinders,updated_at) VALUES($1,$2,$3,now()) ON CONFLICT (user_id) DO UPDATE SET brewers=$2, grinders=$3, updated_at=now() RETURNING brewers,grinders`, [req.user.id, JSON.stringify(brewers), JSON.stringify(grinders)], ) ).rows[0]; res.json({ ok: true, gear: { brewers: row.brewers, grinders: row.grinders } }); } catch (e) { next(e); } }); app.get("/api/brew-methods", requireAuth, (_req, res) => res.json({ ok: true, categories: BREW_CATEGORIES, methods: BREW_METHODS, }), ); app.get("/api/openapi.json", requireAuth, (req, res) => res.json(buildOpenApiSpec({ origin })), ); 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"))); // Self-hosted Swagger UI assets for /api-docs (CSP forbids CDN scripts). app.use( "/swagger", express.static(createRequire(import.meta.url).resolve("swagger-ui-dist/swagger-ui.css").replace(/swagger-ui\.css$/, "")), ); 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; }