Files
roast_command_center/server/app.js
T
2026-08-09 14:30:42 -04:00

3313 lines
110 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { buildUpdatedAlog, 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 = "[email protected]";
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",
"/academy.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 <[email protected]>";
// 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 === "/academy" ||
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.<field> 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<now()-interval '1 hour')",
[hash(raw)],
).catch(() => {});
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"],
["/academy", "academy.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,name,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 name = String(req.body.name ?? "").trim().slice(0, 120);
const p = (
await db.query(
"INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3) RETURNING id,plan,name,created_at,updated_at",
[req.user.id, req.body.plan, name],
)
).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 {
const hasPlan = req.body.plan !== undefined;
const hasName = req.body.name !== undefined;
if (hasPlan && (!req.body.plan || typeof req.body.plan !== "object"))
return res.status(400).json({ ok: false, code: "bad_plan" });
if (!hasPlan && !hasName)
return res.status(400).json({ ok: false, code: "bad_plan" });
const existing = hasPlan && hasName
? null
: (
await db.query(
"SELECT plan,name FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
)
).rows[0];
const plan = hasPlan ? req.body.plan : existing?.plan;
const name = hasName
? String(req.body.name ?? "").trim().slice(0, 120)
: (existing?.name ?? "");
const r = await db.query(
"UPDATE roast_plans SET plan=$1,name=$2,updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING id,plan,name,updated_at",
[plan, name, 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,
name: row.name,
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 name = String(req.body.name ?? "").trim().slice(0, 120);
const row = (
await db.query(
`INSERT INTO green_bean_lots
(user_id,origin,name,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,$8,$8,$9,$10,$11,$12) RETURNING *`,
[
req.user.id,
origin,
name,
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,CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END 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" });
// Two sources, newest wins: legacy plans that stored the note in the worksheet's
// afterRoast, and actual roasts (where after-the-roast now lives) linked to a
// plan against this lot.
const row = (
await db.query(
`SELECT one_change, plan_title, updated_at FROM (
SELECT plan->'afterRoast'->>'oneChange' AS one_change,
CASE WHEN COALESCE(name,'')<>'' THEN name ELSE plan->'fields'->>'0.1' END AS plan_title, updated_at
FROM roast_plans
WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2
AND coalesce(plan->'afterRoast'->>'oneChange','')<>''
UNION ALL
SELECT a.after->>'oneChange' AS one_change,
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, a.updated_at
FROM actual_roasts a
JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.user_id=$1 AND p.plan->'inventory'->>'lotId'=$2
AND coalesce(a.after->>'oneChange','')<>''
) src 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 name =
b.name === undefined ? existing.name : String(b.name || "").trim().slice(0, 120);
const row = (
await db.query(
`UPDATE green_bean_lots SET
origin=$1, name=$2, variety=$3, process=$4, producer=$5, purchase_date=$6,
remaining_weight_g = remaining_weight_g + COALESCE($7::numeric, initial_weight_g) - initial_weight_g,
initial_weight_g = COALESCE($7::numeric, initial_weight_g),
cost_total=$8, moisture_pct=$9, density_g_l=$10, notes=$11, archived=$12,
updated_at=now()
WHERE id=$13 AND user_id=$14 RETURNING *`,
[
origin,
name,
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, after: row.after ?? {}, plan: row.plan ?? null }
: base;
};
// "After the roast" observations recorded against an actual roast (moved here from the
// plan worksheet). All free-text/short strings — the numbers among them (weights, DTR)
// stay strings like the worksheet always stored them.
const AFTER_FIELDS = [
"greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour",
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
];
function sanitizeAfter(raw) {
if (!raw || typeof raw !== "object") return null;
const out = {};
for (const field of AFTER_FIELDS)
if (raw[field] !== undefined && raw[field] !== null)
out[field] = String(raw[field]).slice(0, 2_000);
return out;
}
// 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,
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END 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.*, CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END 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 a.filename, a.original_content, a.after, a.evaluation, a.parsed,
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, a.roast_plan_id
FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.id=$1 AND a.user_id=$2`,
[req.params.id, req.user.id],
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
let name = row.filename.replace(/[^\w.\- ]+/g, "_").trim() || "roast";
name = name.replace(/\.alog$/i, "");
const updated = req.query.variant === "updated";
let content = row.original_content;
if (updated) {
// Supplemental copy: everything this app tracks about the roast written back
// as valid Artisan fields (Python-literal serialization — Artisan re-imports
// it with ast.literal_eval). The original stays untouched.
const after = row.after ?? {};
const num = (v) => {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : null;
};
const greenIn = num(after.greenIn) ?? row.parsed?.roast?.weightInG ?? null;
const out = num(after.out) ?? row.parsed?.roast?.weightOutG ?? null;
const cupping = row.roast_plan_id
? (
await db.query(
"SELECT data,total_score FROM cupping_sessions WHERE roast_plan_id=$1 AND user_id=$2 ORDER BY updated_at DESC LIMIT 1",
[row.roast_plan_id, req.user.id],
)
).rows[0]
: null;
const cuppingParts = [
after.cupNotes,
cupping ? `Cupping score ${Number(cupping.total_score).toFixed(2)}` : null,
cupping?.data?.flavor_tags?.length
? `Flavors: ${cupping.data.flavor_tags.join(", ")}`
: null,
after.brewRatio || after.method
? `Brewed ${[after.method, after.brewRatio].filter(Boolean).join(" ")}`
: null,
after.restedDays ? `Rested ${after.restedDays} days` : null,
].filter(Boolean);
const roastingParts = [
after.oneChange ? `One change next batch: ${after.oneChange}` : null,
after.disproof ? `Would disprove it: ${after.disproof}` : null,
after.colour ? `Colour ${after.colour}` : null,
after.actualDtrPct ? `Actual DTR ${after.actualDtrPct}%` : null,
row.evaluation?.summary ? `LLM review: ${row.evaluation.summary}` : null,
].filter(Boolean);
content = buildUpdatedAlog(row.original_content, {
weight:
greenIn !== null && out !== null ? [greenIn, out, "g"] : undefined,
beans: row.plan_title || undefined,
cuppingnotes: cuppingParts.length ? cuppingParts.join("\n") : undefined,
roastingnotes: roastingParts.length ? roastingParts.join("\n") : undefined,
});
}
res.set({
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${name}${updated ? "-updated" : ""}.alog"`,
});
res.send(content);
} catch (e) {
next(e);
}
},
);
// Mutable parts of an upload: which machine it belongs to, and the after-the-roast
// observations. The file and its parse are immutable history.
app.put(
"/api/roasts/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const sets = [];
const params = [];
if (req.body.roasterId !== undefined) {
let roasterId = null;
if (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;
}
params.push(roasterId);
sets.push(`roaster_id=$${params.length}`);
}
if (req.body.roastPlanId !== undefined) {
let roastPlanId = null;
if (req.body.roastPlanId !== null && 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;
}
params.push(roastPlanId);
sets.push(`roast_plan_id=$${params.length}`);
}
if (req.body.after !== undefined) {
const after = sanitizeAfter(req.body.after);
if (!after)
return res.status(400).json({ ok: false, code: "bad_after" });
params.push(after);
sets.push(`after=$${params.length}`);
}
if (!sets.length)
return res.status(400).json({ ok: false, code: "bad_request" });
params.push(req.params.id, req.user.id);
const r = await db.query(
`UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, roast_plan_id, after`,
params,
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({
ok: true,
roasterId: r.rows[0].roaster_id,
roastPlanId: r.rows[0].roast_plan_id,
after: r.rows[0].after,
});
} 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,
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END 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.*, CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END 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 <img>/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,CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END 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",
"after", "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;
}