Files
roast_command_center/server/app.js
snowspeeder 59645e59b5 Add green-bean inventory and cupping features
Ports inventory management and SCA-style cupping scoring from
hope_roaster: lot tracking with audit-logged consumption, cupping
sessions with server-authoritative scoring and a live radar chart,
and links from the planner (draw-from-lot, open-cupping-session).

Also fixes the Blend/Single-origin toggle layout, replaces tooltips
with an in-context "why" teaching layer, and stages the planner UI
into pre-roast vs. post-roast phases.
2026-07-30 14:47:15 -04:00

1636 lines
54 KiB
JavaScript

import express from "express";
import path from "node:path";
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { fetchPageText } from "./fetch-page.js";
import { runPrefill } from "./prefill.js";
import { parseAlog } from "./alog.js";
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
import { sendMail } from "./mailer.js";
import { coerceSession, computeTotalScore, blankSession } from "../shared/cupping.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",
]);
/** Creates the HTTP app separately from listening, so tests can use an isolated database. */
export function createApp({ db, root, env = process.env } = {}) {
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"
)
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();
});
app.use(express.json({ limit: "1mb" }));
// 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) {
const raw = cookie(req, "rp_session");
if (!raw) return null;
const r = await db.query(
"SELECT s.csrf_hash,u.id,u.email,u.role,u.created_at FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now() AND u.disabled_at IS NULL",
[hash(raw)],
);
const row = r.rows[0];
if (!row) return null;
// Sliding expiry + last-seen, throttled to once/hour so an active user is never logged
// out mid-session; best-effort and never blocks the request.
db.query(
"UPDATE sessions SET expires_at=now()+interval '14 days', last_seen_at=now() WHERE token_hash=$1 AND (last_seen_at IS NULL OR last_seen_at<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) => {
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("/admin", async (req, res, next) => {
try {
const user = await session(req);
if (!user) return res.redirect("/login");
if (user.role !== "admin") return res.redirect("/app");
res.sendFile(path.join(root, "public", "admin.html"));
} catch (error) {
next(error);
}
});
// ─── Auth ──────────────────────────────────────────────────────────────
app.get("/api/auth/signup-enabled", async (_req, res, next) => {
try {
const setting = await db.query(
"SELECT value FROM app_settings WHERE key='signup_enabled'",
);
res.json({ ok: true, enabled: setting.rows[0]?.value === "true" });
} catch (e) {
next(e);
}
});
app.post(
"/api/auth/signup",
rateLimit("signup", 8, 60_000),
async (req, res, next) => {
try {
const email = emailOf(req.body.email),
password = req.body.password;
if (!/^\S+@\S+\.\S+$/.test(email) || !PASSWORD_OK(password))
return res.status(400).json({
ok: false,
code: "invalid_credentials",
error:
"Use a valid email and a password of at least 12 characters.",
});
// Reserved for the one-time /api/auth/bootstrap flow — never claimable by regular
// signup, or a fresh deployment's first visitor could permanently lock out the
// real administrator before they ever get to set it up.
if (email === ADMIN_EMAIL)
return res.status(409).json({ ok: false, code: "email_exists" });
const setting = await db.query(
"SELECT value FROM app_settings WHERE key='signup_enabled'",
);
if (setting.rows[0]?.value !== "true")
return res.status(403).json({ ok: false, code: "signup_disabled" });
const password_hash = await bcrypt.hash(password, 12);
const user = (
await db.query(
"INSERT INTO users(email,password_hash) VALUES($1,$2) RETURNING id,email,role",
[email, password_hash],
)
).rows[0];
const s = await createSession(user, req);
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
await audit(user.id, "signup");
res.status(201).json({
ok: true,
user: { email: user.email, role: user.role },
csrfToken: s.csrfToken,
});
} catch (e) {
if (e.code === "23505")
return res.status(409).json({ ok: false, code: "email_exists" });
next(e);
}
},
);
app.post(
"/api/auth/bootstrap",
rateLimit("bootstrap", 4, 60_000),
async (req, res, next) => {
try {
const exists = await db.query("SELECT 1 FROM users WHERE email=$1", [
ADMIN_EMAIL,
]);
// Once the administrator exists, the deployment no longer needs to retain
// the bootstrap secret. Do not reveal whether a supplied token was valid.
if (exists.rowCount)
return res.status(409).json({ ok: false, code: "bootstrap_used" });
const bootstrap = String(req.body.setupToken || "");
if (!env.BOOTSTRAP_SETUP_TOKEN)
return res
.status(503)
.json({ ok: false, code: "bootstrap_unavailable" });
if (
bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length ||
!crypto.timingSafeEqual(
Buffer.from(bootstrap),
Buffer.from(env.BOOTSTRAP_SETUP_TOKEN),
)
)
return res
.status(403)
.json({ ok: false, code: "invalid_setup_token" });
if (
emailOf(req.body.email) !== ADMIN_EMAIL ||
!PASSWORD_OK(req.body.password)
)
return res
.status(400)
.json({ ok: false, code: "invalid_credentials" });
const user = (
await db.query(
"INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin') RETURNING id,email,role",
[ADMIN_EMAIL, await bcrypt.hash(req.body.password, 12)],
)
).rows[0];
const s = await createSession(user, req);
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
await audit(user.id, "bootstrap_admin");
res.status(201).json({
ok: true,
user: { email: user.email, role: user.role },
csrfToken: s.csrfToken,
});
} catch (e) {
next(e);
}
},
);
app.post(
"/api/auth/login",
rateLimit("login", 10, 60_000),
async (req, res, next) => {
try {
const email = emailOf(req.body.email);
const user = (
await db.query(
"SELECT id,email,role,password_hash,disabled_at FROM users WHERE email=$1",
[email],
)
).rows[0];
// Always run bcrypt against something, even for an unknown email, so the
// response time doesn't reveal whether the account exists; and check the lock
// only *after* a failed compare, so a correct password always gets the owner in
// even while other guesses have it locked — the lock throttles guessing, it must
// never let an attacker who merely knows the address lock the owner out.
const passwordOk = await bcrypt.compare(
String(req.body.password || ""),
user?.password_hash || DUMMY_PASSWORD_HASH,
);
if (!user || user.disabled_at || !passwordOk) {
if (emailLocked(email))
return res
.status(429)
.json({ ok: false, code: "too_many_attempts" });
recordLoginFailure(email);
return res
.status(401)
.json({ ok: false, code: "invalid_credentials" });
}
clearLoginFailures(email);
const s = await createSession(user, req);
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
await audit(user.id, "login");
res.json({
ok: true,
user: { email: user.email, role: user.role },
csrfToken: s.csrfToken,
});
} catch (e) {
next(e);
}
},
);
app.get("/api/auth/me", requireAuth, (req, res) =>
res.json({
ok: true,
user: {
id: req.user.id,
email: req.user.email,
role: req.user.role,
createdAt: req.user.created_at,
},
}),
);
// Logout deliberately does not require CSRF: the worst a forged cross-site request can do
// is log the visitor out, and requiring CSRF here can strand a user with a stale/missing
// csrf cookie in a state where they can neither use the app nor sign out of it.
app.post("/api/auth/logout", requireAuth, async (req, res, next) => {
try {
await db.query("DELETE FROM sessions WHERE token_hash=$1", [
hash(cookie(req, "rp_session")),
]);
setSessionCookie(res, "", 0, "");
res.json({ ok: true });
} catch (e) {
next(e);
}
});
app.post(
"/api/auth/forgot",
rateLimit("forgot", 5, 15 * 60_000),
async (req, res, next) => {
try {
const email = emailOf(req.body.email);
const user = (
await db.query("SELECT id,email,role FROM users WHERE email=$1", [
email,
])
).rows[0];
// Always answer the same way regardless of whether the account exists.
if (!user) return res.json({ ok: true });
await db.query(
"DELETE FROM password_reset_tokens WHERE user_id=$1",
[user.id],
);
const resetToken = token();
await db.query(
"INSERT INTO password_reset_tokens(token_hash,user_id,expires_at) VALUES($1,$2,now()+interval '1 hour')",
[hash(resetToken), user.id],
);
const resetUrl = `${origin}/reset?token=${resetToken}`;
const sent = await sendMail({
smtpUrl: env.SMTP_URL,
from: mailFrom,
to: user.email,
subject: "Reset your Roast Planner password",
text: `Reset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`,
}).catch((e) => {
console.error("password_reset_email_failed", e);
return false;
});
if (!sent) {
console.log(`[password reset] ${user.email}: ${resetUrl}`);
// Admin-account resets are deliberately kept out of the shared admin panel:
// any other admin could otherwise read the link and take over that account
// (including the bootstrap admin, which every other guard protects).
// Recovering an admin without SMTP configured requires server console access.
if (user.role !== "admin")
pendingResets.set(hash(resetToken), {
email: user.email,
url: resetUrl,
expiresAt: Date.now() + 60 * 60_000,
});
}
await audit(user.id, "password_reset_requested");
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
app.post(
"/api/auth/reset",
rateLimit("reset", 10, 15 * 60_000),
async (req, res, next) => {
try {
const rawToken = String(req.body.token || "");
const password = req.body.password;
if (!rawToken || !PASSWORD_OK(password))
return res.status(400).json({ ok: false, code: "invalid_request" });
const tokenHash = hash(rawToken);
const row = (
await db.query(
"SELECT user_id FROM password_reset_tokens WHERE token_hash=$1 AND expires_at>now()",
[tokenHash],
)
).rows[0];
if (!row)
return res.status(400).json({ ok: false, code: "invalid_token" });
const password_hash = await bcrypt.hash(password, 12);
await db.query("UPDATE users SET password_hash=$1 WHERE id=$2", [
password_hash,
row.user_id,
]);
await db.query("DELETE FROM password_reset_tokens WHERE user_id=$1", [
row.user_id,
]);
await db.query("DELETE FROM sessions WHERE user_id=$1", [
row.user_id,
]);
pendingResets.delete(tokenHash);
const user = (
await db.query(
"SELECT id,email,role,disabled_at FROM users WHERE id=$1",
[row.user_id],
)
).rows[0];
await audit(user.id, "password_reset_completed");
// The password is changed either way, but a disabled account still can't sign
// in — say so plainly instead of handing back a session the very next request
// will reject with a bare 401.
if (user.disabled_at)
return res
.status(403)
.json({ ok: false, code: "account_disabled" });
const s = await createSession(user, req);
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
res.json({
ok: true,
user: { email: user.email, role: user.role },
csrfToken: s.csrfToken,
});
} catch (e) {
next(e);
}
},
);
// ─── Plans ─────────────────────────────────────────────────────────────
app.get("/api/plans", requireAuth, async (req, res, next) => {
try {
res.json({
ok: true,
plans: (
await db.query(
"SELECT id,plan,created_at,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC",
[req.user.id],
)
).rows,
});
} catch (e) {
next(e);
}
});
app.post("/api/plans", requireAuth, csrf, async (req, res, next) => {
try {
if (!req.body.plan || typeof req.body.plan !== "object")
return res.status(400).json({ ok: false, code: "bad_plan" });
const p = (
await db.query(
"INSERT INTO roast_plans(user_id,plan) VALUES($1,$2) RETURNING id,plan,created_at,updated_at",
[req.user.id, req.body.plan],
)
).rows[0];
res.status(201).json({ ok: true, plan: p });
} catch (e) {
next(e);
}
});
app.put(
"/api/plans/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
if (!req.body.plan || typeof req.body.plan !== "object")
return res.status(400).json({ ok: false, code: "bad_plan" });
const r = await db.query(
"UPDATE roast_plans SET plan=$1,updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING id,plan,updated_at",
[req.body.plan, req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true, plan: r.rows[0] });
} catch (e) {
next(e);
}
},
);
app.delete(
"/api/plans/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const r = await db.query(
"DELETE FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
// ─── Inventory ─────────────────────────────────────────────────────────
// Strict on purpose: JSON.stringify silently turns a client-side NaN (e.g. a non-numeric
// weight/cost typed into a field that isn't really constrained to digits) into null, which
// is indistinguishable from "the user explicitly cleared this field" unless the server
// requires the *type* to be number, not just coerces whatever arrives with Number(). A loose
// Number(value) here previously let a garbage weight silently become 0 and corrupt
// remaining_weight_g.
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
function parseOptionalNumber(value, fieldName) {
if (value === undefined || value === null || value === "") return { value: null };
if (!isFiniteNumber(value))
throw new Error(`${fieldName} must be a number`);
return { value };
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function parseOptionalDate(value) {
if (value === undefined || value === null || value === "") return { value: null };
if (typeof value !== "string" || !DATE_RE.test(value) || Number.isNaN(Date.parse(value)))
throw new Error("purchaseDate must be a YYYY-MM-DD date");
return { value };
}
const toLotRow = (row) => ({
id: row.id,
origin: row.origin,
variety: row.variety,
process: row.process,
producer: row.producer,
purchaseDate: row.purchase_date,
initialWeightG: Number(row.initial_weight_g),
remainingWeightG: Number(row.remaining_weight_g),
costTotal: row.cost_total == null ? null : Number(row.cost_total),
moisturePct: row.moisture_pct == null ? null : Number(row.moisture_pct),
densityGL: row.density_g_l == null ? null : Number(row.density_g_l),
notes: row.notes,
archived: row.archived,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
app.get("/api/inventory", requireAuth, async (req, res, next) => {
try {
const rows = (
await db.query(
"SELECT * FROM green_bean_lots WHERE user_id=$1 ORDER BY archived ASC, purchase_date DESC NULLS LAST, created_at DESC",
[req.user.id],
)
).rows;
res.json({ ok: true, lots: rows.map(toLotRow) });
} catch (e) {
next(e);
}
});
app.post("/api/inventory", requireAuth, csrf, async (req, res, next) => {
try {
const origin = String(req.body.origin || "").trim();
if (!origin || !isFiniteNumber(req.body.initialWeightG) || req.body.initialWeightG < 0)
return res.status(400).json({ ok: false, code: "bad_lot" });
let costTotal, moisturePct, densityGL, purchaseDate;
try {
costTotal = parseOptionalNumber(req.body.costTotal, "costTotal");
moisturePct = parseOptionalNumber(req.body.moisturePct, "moisturePct");
densityGL = parseOptionalNumber(req.body.densityGL, "densityGL");
purchaseDate = parseOptionalDate(req.body.purchaseDate);
} catch (e) {
return res.status(400).json({ ok: false, code: "bad_lot", error: e.message });
}
const row = (
await db.query(
`INSERT INTO green_bean_lots
(user_id,origin,variety,process,producer,purchase_date,initial_weight_g,remaining_weight_g,cost_total,moisture_pct,density_g_l,notes)
VALUES($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11) RETURNING *`,
[
req.user.id,
origin,
String(req.body.variety || ""),
String(req.body.process || ""),
String(req.body.producer || ""),
purchaseDate.value,
req.body.initialWeightG,
costTotal.value,
moisturePct.value,
densityGL.value,
String(req.body.notes || ""),
],
)
).rows[0];
res.status(201).json({ ok: true, lot: toLotRow(row) });
} catch (e) {
next(e);
}
});
app.get(
"/api/inventory/:id",
requireAuth,
requireUuidParam("id"),
async (req, res, next) => {
try {
const lot = (
await db.query(
"SELECT * FROM green_bean_lots WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
)
).rows[0];
if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
const log = (
await db.query(
`SELECT c.id,c.weight_g,c.roast_plan_id,c.created_at,p.plan->'fields'->>'0.1' AS plan_title
FROM bean_consumption c LEFT JOIN roast_plans p ON p.id=c.roast_plan_id AND p.user_id=c.user_id
WHERE c.lot_id=$1 ORDER BY c.created_at DESC`,
[req.params.id],
)
).rows;
res.json({
ok: true,
lot: toLotRow(lot),
log: log.map((r) => ({
id: r.id,
weightG: Number(r.weight_g),
roastPlanId: r.roast_plan_id,
planTitle: r.plan_title,
createdAt: r.created_at,
})),
});
} catch (e) {
next(e);
}
},
);
app.put(
"/api/inventory/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const existing = (
await db.query(
"SELECT * FROM green_bean_lots WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
)
).rows[0];
if (!existing)
return res.status(404).json({ ok: false, code: "not_found" });
const b = req.body;
const origin =
b.origin === undefined ? existing.origin : String(b.origin || "").trim();
if (!origin) return res.status(400).json({ ok: false, code: "bad_lot" });
if (
b.initialWeightG !== undefined &&
(!isFiniteNumber(b.initialWeightG) || b.initialWeightG < 0)
)
return res.status(400).json({ ok: false, code: "bad_lot" });
let costTotal, moisturePct, densityGL, purchaseDate;
try {
costTotal =
b.costTotal === undefined
? existing.cost_total
: parseOptionalNumber(b.costTotal, "costTotal").value;
moisturePct =
b.moisturePct === undefined
? existing.moisture_pct
: parseOptionalNumber(b.moisturePct, "moisturePct").value;
densityGL =
b.densityGL === undefined
? existing.density_g_l
: parseOptionalNumber(b.densityGL, "densityGL").value;
purchaseDate =
b.purchaseDate === undefined
? existing.purchase_date
: parseOptionalDate(b.purchaseDate).value;
} catch (e) {
return res.status(400).json({ ok: false, code: "bad_lot", error: e.message });
}
// remainingWeightG/consumptionLog are never read from the request body: remaining
// only ever moves via consume(), except for the delta below, which is the
// sanctioned correction path (recount the bag, fix the paperwork). The delta is
// computed inside the UPDATE itself (against the pre-statement initial_weight_g)
// rather than from the `existing` row read above, so a concurrent PUT can't lose
// the other's delta the way a JS-side read-then-write would.
const row = (
await db.query(
`UPDATE green_bean_lots SET
origin=$1, variety=$2, process=$3, producer=$4, purchase_date=$5,
remaining_weight_g = remaining_weight_g + COALESCE($6::numeric, initial_weight_g) - initial_weight_g,
initial_weight_g = COALESCE($6::numeric, initial_weight_g),
cost_total=$7, moisture_pct=$8, density_g_l=$9, notes=$10, archived=$11,
updated_at=now()
WHERE id=$12 AND user_id=$13 RETURNING *`,
[
origin,
b.variety === undefined ? existing.variety : String(b.variety || ""),
b.process === undefined ? existing.process : String(b.process || ""),
b.producer === undefined ? existing.producer : String(b.producer || ""),
purchaseDate,
b.initialWeightG === undefined ? null : b.initialWeightG,
costTotal,
moisturePct,
densityGL,
b.notes === undefined ? existing.notes : String(b.notes || ""),
b.archived === undefined ? existing.archived : Boolean(b.archived),
req.params.id,
req.user.id,
],
)
).rows[0];
res.json({ ok: true, lot: toLotRow(row) });
} catch (e) {
next(e);
}
},
);
app.delete(
"/api/inventory/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const r = await db.query(
"DELETE FROM green_bean_lots WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
app.post(
"/api/inventory/:id/consume",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const weightG = Number(req.body.weightG);
if (!Number.isFinite(weightG) || weightG <= 0)
return res.status(400).json({ ok: false, code: "bad_weight" });
const lotExists = (
await db.query(
"SELECT 1 FROM green_bean_lots WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
)
).rowCount;
if (!lotExists)
return res.status(404).json({ ok: false, code: "not_found" });
let roastPlanId = null;
if (req.body.roastPlanId) {
if (!UUID_RE.test(req.body.roastPlanId))
return res.status(404).json({ ok: false, code: "not_found" });
const ownsPlan = (
await db.query(
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.body.roastPlanId, req.user.id],
)
).rowCount;
if (!ownsPlan)
return res.status(404).json({ ok: false, code: "not_found" });
roastPlanId = req.body.roastPlanId;
}
// The log insert and the balance update must commit together — on separate pool
// connections a crash or error between them would leave a consumption row logged
// with no corresponding decrement, and the unique index means that plan could
// never be retried. See withTransaction's own comment for why plain sequential
// db.query("BEGIN")/db.query(...) calls do not actually guarantee this.
let row;
try {
row = await withTransaction(async (client) => {
try {
await client.query(
"INSERT INTO bean_consumption(lot_id,user_id,roast_plan_id,weight_g) VALUES($1,$2,$3,$4)",
[req.params.id, req.user.id, roastPlanId, weightG],
);
} catch (e) {
if (e.code === "23505") {
const err = new Error("already_consumed");
err.httpCode = "already_consumed";
throw err;
}
throw e;
}
const updated = (
await client.query(
"UPDATE green_bean_lots SET remaining_weight_g=remaining_weight_g-$1, updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING *",
[weightG, req.params.id, req.user.id],
)
).rows[0];
if (!updated) {
// The lot was deleted by a concurrent request between the ownership
// check above and here — roll back the log insert too.
const err = new Error("not_found");
err.httpCode = "not_found";
throw err;
}
return updated;
});
} catch (e) {
if (e.httpCode === "already_consumed")
return res.status(409).json({ ok: false, code: "already_consumed" });
if (e.httpCode === "not_found")
return res.status(404).json({ ok: false, code: "not_found" });
throw e;
}
res.status(201).json({ ok: true, lot: toLotRow(row) });
} catch (e) {
next(e);
}
},
);
// ─── Cupping ───────────────────────────────────────────────────────────
const toSessionRow = (row, { full = false } = {}) => {
const data = row.data || {};
const base = {
id: row.id,
roastPlanId: row.roast_plan_id,
planTitle: row.plan_title ?? undefined,
cupCount: data.cup_count,
totalScore: Number(row.total_score),
flavorTags: data.flavor_tags || [],
createdAt: row.created_at,
updatedAt: row.updated_at,
};
if (row.plan_title === undefined) delete base.planTitle;
return full ? { ...base, data } : base;
};
app.get("/api/cupping", requireAuth, async (req, res, next) => {
try {
const planFilter =
typeof req.query.plan === "string" && UUID_RE.test(req.query.plan)
? req.query.plan
: null;
const rows = (
await db.query(
`SELECT s.id,s.roast_plan_id,s.data,s.total_score,s.created_at,s.updated_at,
p.plan->'fields'->>'0.1' AS plan_title
FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id
WHERE s.user_id=$1 AND ($2::uuid IS NULL OR s.roast_plan_id=$2)
ORDER BY s.updated_at DESC`,
[req.user.id, planFilter],
)
).rows;
res.json({ ok: true, sessions: rows.map((r) => toSessionRow(r)) });
} catch (e) {
next(e);
}
});
app.post("/api/cupping", requireAuth, csrf, async (req, res, next) => {
try {
let roastPlanId = null;
if (req.body.roastPlanId) {
if (!UUID_RE.test(req.body.roastPlanId))
return res.status(404).json({ ok: false, code: "not_found" });
const ownsPlan = (
await db.query(
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.body.roastPlanId, req.user.id],
)
).rowCount;
if (!ownsPlan)
return res.status(404).json({ ok: false, code: "not_found" });
roastPlanId = req.body.roastPlanId;
}
const cupCount = req.body.cupCount == null ? undefined : Number(req.body.cupCount);
let data;
try {
data = coerceSession(blankSession(Number.isFinite(cupCount) ? cupCount : undefined));
} catch (e) {
return res.status(400).json({ ok: false, code: "bad_session", error: e.message });
}
const row = (
await db.query(
"INSERT INTO cupping_sessions(user_id,roast_plan_id,data,total_score) VALUES($1,$2,$3,0) RETURNING *",
[req.user.id, roastPlanId, data],
)
).rows[0];
res.status(201).json({ ok: true, session: toSessionRow(row, { full: true }) });
} catch (e) {
next(e);
}
});
app.get(
"/api/cupping/:id",
requireAuth,
requireUuidParam("id"),
async (req, res, next) => {
try {
const row = (
await db.query(
`SELECT s.*, p.plan->'fields'->>'0.1' AS plan_title
FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id
WHERE s.id=$1 AND s.user_id=$2`,
[req.params.id, req.user.id],
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true, session: toSessionRow(row, { full: true }) });
} catch (e) {
next(e);
}
},
);
app.put(
"/api/cupping/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
if (!req.body.data || typeof req.body.data !== "object")
return res.status(400).json({ ok: false, code: "bad_session" });
let coerced;
try {
coerced = coerceSession(req.body.data);
} catch (e) {
return res
.status(400)
.json({ ok: false, code: "bad_session", error: e.message });
}
const total = computeTotalScore(
coerced.scores,
coerced.ticks,
coerced.taint_cups,
coerced.fault_cups,
coerced.cup_count,
);
const row = (
await db.query(
"UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING *",
[coerced, total, req.params.id, req.user.id],
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true, session: toSessionRow(row, { full: true }) });
} catch (e) {
next(e);
}
},
);
app.delete(
"/api/cupping/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const r = await db.query(
"DELETE FROM cupping_sessions WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
// ─── Account ───────────────────────────────────────────────────────────
app.put("/api/account/email", requireAuth, csrf, async (req, res, next) => {
try {
const email = emailOf(req.body.email);
if (!/^\S+@\S+\.\S+$/.test(email))
return res.status(400).json({ ok: false, code: "invalid_email" });
// The bootstrap admin's email is fixed: they can't change away from it (which would
// strip the protection every other guard relies on), and nobody else can claim it
// (which would falsely grant that same protection to an unrelated account).
if (email === ADMIN_EMAIL || (await isBootstrapAdmin(req.user.id)))
return res.status(403).json({ ok: false, code: "forbidden" });
const user = (
await db.query("SELECT password_hash FROM users WHERE id=$1", [
req.user.id,
])
).rows[0];
if (!(await bcrypt.compare(String(req.body.password || ""), user.password_hash)))
return res.status(401).json({ ok: false, code: "invalid_credentials" });
await db.query("UPDATE users SET email=$1 WHERE id=$2", [
email,
req.user.id,
]);
await audit(req.user.id, "email_changed", email);
res.json({ ok: true, user: { email, role: req.user.role } });
} catch (e) {
if (e.code === "23505")
return res.status(409).json({ ok: false, code: "email_exists" });
next(e);
}
});
app.put(
"/api/account/password",
requireAuth,
csrf,
async (req, res, next) => {
try {
if (!PASSWORD_OK(req.body.newPassword))
return res.status(400).json({ ok: false, code: "invalid_password" });
const user = (
await db.query("SELECT password_hash FROM users WHERE id=$1", [
req.user.id,
])
).rows[0];
if (
!(await bcrypt.compare(
String(req.body.currentPassword || ""),
user.password_hash,
))
)
return res
.status(401)
.json({ ok: false, code: "invalid_credentials" });
const password_hash = await bcrypt.hash(req.body.newPassword, 12);
await db.query("UPDATE users SET password_hash=$1 WHERE id=$2", [
password_hash,
req.user.id,
]);
// Keep only the session that just proved the current password.
await db.query(
"DELETE FROM sessions WHERE user_id=$1 AND token_hash<>$2",
[req.user.id, hash(cookie(req, "rp_session"))],
);
await audit(req.user.id, "password_changed");
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
app.get("/api/account/sessions", requireAuth, async (req, res, next) => {
try {
const currentHash = hash(cookie(req, "rp_session"));
const rows = (
await db.query(
"SELECT token_hash,user_agent,ip,created_at,last_seen_at,expires_at FROM sessions WHERE user_id=$1 ORDER BY COALESCE(last_seen_at,created_at) DESC",
[req.user.id],
)
).rows;
res.json({
ok: true,
sessions: rows.map((row) => ({
id: row.token_hash.slice(0, 16),
userAgent: row.user_agent,
ip: row.ip,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at,
expiresAt: row.expires_at,
current: row.token_hash === currentHash,
})),
});
} catch (e) {
next(e);
}
});
app.delete(
"/api/account/sessions/:id",
requireAuth,
csrf,
async (req, res, next) => {
try {
const id = String(req.params.id || "");
if (!/^[0-9a-f]{16}$/i.test(id))
return res.status(404).json({ ok: false, code: "not_found" });
const r = await db.query(
"DELETE FROM sessions WHERE user_id=$1 AND token_hash LIKE $2",
[req.user.id, `${id}%`],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
app.post(
"/api/account/sessions/revoke-others",
requireAuth,
csrf,
async (req, res, next) => {
try {
await db.query(
"DELETE FROM sessions WHERE user_id=$1 AND token_hash<>$2",
[req.user.id, hash(cookie(req, "rp_session"))],
);
await audit(req.user.id, "sessions_revoked_others");
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
app.delete("/api/account", requireAuth, csrf, async (req, res, next) => {
try {
const user = (
await db.query("SELECT password_hash,role FROM users WHERE id=$1", [
req.user.id,
])
).rows[0];
if (!(await bcrypt.compare(String(req.body.password || ""), user.password_hash)))
return res.status(401).json({ ok: false, code: "invalid_credentials" });
// The bootstrap admin can never remove themselves — deleting that account would
// re-open /setup to anyone still holding the deployment's bootstrap token.
if (await isBootstrapAdmin(req.user.id))
return res.status(403).json({ ok: false, code: "forbidden" });
if (user.role === "admin") {
const admins = await db.query(
"SELECT count(*)::int AS count FROM users WHERE role='admin'",
);
if (admins.rows[0].count <= 1)
return res.status(409).json({ ok: false, code: "last_admin" });
}
await audit(req.user.id, "account_self_deleted");
await db.query("DELETE FROM users WHERE id=$1", [req.user.id]);
setSessionCookie(res, "", 0, "");
res.json({ ok: true });
} catch (e) {
next(e);
}
});
// ─── Admin ─────────────────────────────────────────────────────────────
app.get("/api/admin/users", requireAuth, admin, async (req, res, next) => {
try {
res.json({
ok: true,
signupEnabled:
(
await db.query(
"SELECT value FROM app_settings WHERE key='signup_enabled'",
)
).rows[0]?.value === "true",
users: (
await db.query(
"SELECT u.id,u.email,u.role,u.created_at,u.disabled_at,count(p.id)::int AS plan_count FROM users u LEFT JOIN roast_plans p ON p.user_id=u.id GROUP BY u.id,u.email,u.role,u.created_at,u.disabled_at ORDER BY u.created_at",
)
).rows,
});
} catch (e) {
next(e);
}
});
app.get("/api/admin/metrics", requireAuth, admin, async (req, res, next) => {
try {
const [users, plans, recentPlans, sessions] = await Promise.all([
db.query("SELECT count(*)::int AS count FROM users"),
db.query("SELECT count(*)::int AS count FROM roast_plans"),
db.query(
"SELECT count(*)::int AS count FROM roast_plans WHERE updated_at > now() - interval '7 days'",
),
db.query(
"SELECT count(*)::int AS count FROM sessions WHERE expires_at > now()",
),
]);
res.json({
ok: true,
metrics: {
totalUsers: users.rows[0].count,
totalPlans: plans.rows[0].count,
plansUpdatedLast7Days: recentPlans.rows[0].count,
activeSessions: sessions.rows[0].count,
},
});
} catch (e) {
next(e);
}
});
app.get("/api/admin/plans", requireAuth, admin, async (req, res, next) => {
try {
const userFilter =
typeof req.query.user === "string" && UUID_RE.test(req.query.user)
? req.query.user
: null;
const rows = (
await db.query(
`SELECT p.id,p.plan->'fields'->>'0.1' AS title,p.updated_at,u.id AS user_id,u.email
FROM roast_plans p JOIN users u ON u.id=p.user_id
WHERE $1::uuid IS NULL OR p.user_id=$1
ORDER BY p.updated_at DESC`,
[userFilter],
)
).rows;
res.json({ ok: true, plans: rows });
} catch (e) {
next(e);
}
});
app.get("/api/admin/audit", requireAuth, admin, async (req, res, next) => {
try {
const rows = (
await db.query(
`SELECT a.id,a.action,a.target,a.created_at,u.email AS actor_email
FROM audit_events a LEFT JOIN users u ON u.id=a.actor_user_id
ORDER BY a.created_at DESC LIMIT 50`,
)
).rows;
res.json({ ok: true, events: rows });
} catch (e) {
next(e);
}
});
app.get(
"/api/admin/password-resets",
requireAuth,
admin,
async (_req, res) => {
const now = Date.now();
const links = [];
for (const [tokenHash, entry] of pendingResets) {
if (entry.expiresAt <= now) {
pendingResets.delete(tokenHash);
continue;
}
links.push({
email: entry.email,
url: entry.url,
expiresAt: new Date(entry.expiresAt).toISOString(),
});
}
res.json({ ok: true, links });
},
);
app.put(
"/api/admin/signup-enabled",
requireAuth,
csrf,
admin,
async (req, res, next) => {
try {
if (typeof req.body.enabled !== "boolean")
return res.status(400).json({ ok: false, code: "bad_request" });
await db.query(
"UPDATE app_settings SET value=$1 WHERE key='signup_enabled'",
[String(req.body.enabled)],
);
await audit(req.user.id, "signup_toggled", String(req.body.enabled));
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
app.put(
"/api/admin/users/:id/role",
requireAuth,
csrf,
admin,
requireUuidParam("id"),
async (req, res, next) => {
try {
if (!["user", "admin"].includes(req.body.role))
return res.status(400).json({ ok: false, code: "bad_request" });
if (isSelf(req) || (await isBootstrapAdmin(req.params.id)))
return res.status(403).json({ ok: false, code: "forbidden" });
const r = await db.query(
"UPDATE users SET role=$1 WHERE id=$2 RETURNING id,email,role",
[req.body.role, req.params.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
await audit(req.user.id, "role_changed", `${r.rows[0].email}->${req.body.role}`);
res.json({ ok: true, user: r.rows[0] });
} catch (e) {
next(e);
}
},
);
app.put(
"/api/admin/users/:id/disabled",
requireAuth,
csrf,
admin,
requireUuidParam("id"),
async (req, res, next) => {
try {
if (typeof req.body.disabled !== "boolean")
return res.status(400).json({ ok: false, code: "bad_request" });
if (isSelf(req) || (await isBootstrapAdmin(req.params.id)))
return res.status(403).json({ ok: false, code: "forbidden" });
const r = await db.query(
"UPDATE users SET disabled_at=$1 WHERE id=$2 RETURNING id,email,disabled_at",
[req.body.disabled ? new Date() : null, req.params.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
if (req.body.disabled)
await db.query("DELETE FROM sessions WHERE user_id=$1", [
req.params.id,
]);
await audit(
req.user.id,
req.body.disabled ? "user_disabled" : "user_enabled",
r.rows[0].email,
);
res.json({ ok: true, user: r.rows[0] });
} catch (e) {
next(e);
}
},
);
app.delete(
"/api/admin/users/:id",
requireAuth,
csrf,
admin,
requireUuidParam("id"),
async (req, res, next) => {
try {
if (isSelf(req) || (await isBootstrapAdmin(req.params.id)))
return res.status(403).json({ ok: false, code: "forbidden" });
const r = await db.query(
"DELETE FROM users WHERE id=$1 RETURNING email",
[req.params.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
await audit(req.user.id, "user_deleted", r.rows[0].email);
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
// Existing integrations remain authenticated but CSRF-protected for writes.
app.post("/api/prefill", requireAuth, csrf, async (req, res) => {
const url = typeof req.body?.url === "string" ? req.body.url.trim() : "";
if (!url)
return res
.status(400)
.json({ ok: false, code: "bad_url", error: "Missing url." });
try {
const result = await runPrefill(await fetchPageText(url));
res.json({ ok: true, ...result });
} catch (err) {
const code = err.code ?? "prefill_failed";
res
.status(
code === "fetch_timeout"
? 504
: code === "bad_url"
? 400
: code === "no_model"
? 503
: 422,
)
.json({ ok: false, code, error: err.message });
}
});
app.post("/api/alog", requireAuth, csrf, (req, res) => {
try {
if (typeof req.body?.content !== "string" || !req.body.content.trim())
return res.status(400).json({ ok: false, code: "bad_request" });
res.json({
ok: true,
...parseAlog(req.body.content, req.body.filename ?? "upload.alog"),
});
} catch (err) {
res
.status(422)
.json({ ok: false, code: "unparseable_alog", error: err.message });
}
});
app.get("/api/alog/library", requireAuth, async (_q, res) =>
res.json({ ok: true, files: await listAlogLibrary() }),
);
app.get("/api/alog/library/:filename", requireAuth, async (req, res) => {
try {
res.json({
ok: true,
...(await readAlogFromLibrary(req.params.filename)),
});
} catch (e) {
res
.status(e.code === "not_found" ? 404 : 400)
.json({ ok: false, code: e.code, error: e.message });
}
});
app.use((req, res, next) => {
// express.static resolves percent-encoding and repeated slashes before hitting disk, so
// the blocklist must compare against the same normalized form or a request like
// /%69ndex.html or //index.html would slip past this check and still be served below.
let normalized;
try {
normalized = path.posix.normalize(decodeURIComponent(req.path));
} catch {
normalized = req.path;
}
return PUBLIC_SHELL_FILES.has(normalized)
? res.status(302).location("/").end()
: next();
});
// index:false — otherwise express.static serves index.html for any request path that
// normalizes to a directory (e.g. "/", "/./", "//"), bypassing the shell-file blocklist
// above the same way a direct filename request would.
app.use(express.static(path.join(root, "public"), { index: false }));
app.use("/shared", express.static(path.join(root, "shared")));
app.use((err, _req, res, _next) => {
// Malformed JSON / an oversized body are client errors (from express.json()'s parser),
// not server faults — respect the status it already picked instead of masking every
// bad request as a 500 with a logged stack trace.
const status = err.status || err.statusCode;
if (status >= 400 && status < 500)
return res.status(status).json({ ok: false, code: "bad_request" });
console.error(err);
res.status(500).json({ ok: false, code: "internal_error" });
});
return app;
}