Test and deploy / test-and-deploy (push) Successful in 1m26s
Replaces the flat additive batch-size correction with a per-user multiplicative pace factor learned from each account's own logged roasts (shared/learn.js, GET /api/machine-profile), plus a lot-scoped "last refine" auto-suggestion. Also fixes the reference data being framed as "your own roasts" on a now-multi-user product, and reclassifies the drying/Maillard sanity checks as informational since they're algebraically derived from the DTR check rather than independent (yellow = 0.56 x first crack, not entered separately). Bug fixes found by an adversarial Opus review of the first pass: - Unicode minus sign (U+2212) broke duration parsing against the app's own generated refine-suggestion text - Printed sanity-checks table still showed a bare pass/fail glyph for the now-informational drying/Maillard rows - Printed time-ledger box didn't show the pace multiplication step, so it stopped reconciling by hand once pace != 1 - Field 6.4 (manual batch correction) was double-counted: excluded from the learned-pace fit but added back after the multiplication - Learned pace had no outlier rejection or hard clamp - computeLedger had no test coverage - Batch-size help copy overstated what the pace factor models (it's a single blanket ratio, not conditioned on batch weight) A follow-up Opus pass also caught the per-user profile cache surviving logout/account-switch in a shared browser; fixed by sweeping it alongside the existing plan-draft cleanup. Co-Authored-By: Claude Sonnet 5 <[email protected]>
1692 lines
56 KiB
JavaScript
1692 lines
56 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";
|
|
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",
|
|
]);
|
|
|
|
/** 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);
|
|
}
|
|
},
|
|
);
|
|
|
|
// Learned per-user machine profile (pace factor + temperature bands) from this account's own
|
|
// completed roasts — see shared/learn.js. Deliberately reads every one of the user's plans
|
|
// rather than paginating: a personal roast log tops out at low hundreds of rows, and this is
|
|
// the only place that number gets reduced, so there's nothing to cache incrementally against.
|
|
app.get("/api/machine-profile", requireAuth, async (req, res, next) => {
|
|
try {
|
|
const rows = (
|
|
await db.query("SELECT plan FROM roast_plans WHERE user_id=$1", [req.user.id])
|
|
).rows;
|
|
res.json({ ok: true, profile: computeMachineProfile(rows.map((r) => r.plan)) });
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
});
|
|
|
|
// ─── Inventory ─────────────────────────────────────────────────────────
|
|
// Strict on purpose: JSON.stringify silently turns a client-side NaN (e.g. a non-numeric
|
|
// weight/cost typed into a field that isn't really constrained to digits) into null, which
|
|
// is indistinguishable from "the user explicitly cleared this field" unless the server
|
|
// requires the *type* to be number, not just coerces whatever arrives with Number(). A loose
|
|
// Number(value) here previously let a garbage weight silently become 0 and corrupt
|
|
// remaining_weight_g.
|
|
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
function parseOptionalNumber(value, fieldName) {
|
|
if (value === undefined || value === null || value === "") return { value: null };
|
|
if (!isFiniteNumber(value))
|
|
throw new Error(`${fieldName} must be a number`);
|
|
return { value };
|
|
}
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
function parseOptionalDate(value) {
|
|
if (value === undefined || value === null || value === "") return { value: null };
|
|
if (typeof value !== "string" || !DATE_RE.test(value) || Number.isNaN(Date.parse(value)))
|
|
throw new Error("purchaseDate must be a YYYY-MM-DD date");
|
|
return { value };
|
|
}
|
|
const toLotRow = (row) => ({
|
|
id: row.id,
|
|
origin: row.origin,
|
|
variety: row.variety,
|
|
process: row.process,
|
|
producer: row.producer,
|
|
purchaseDate: row.purchase_date,
|
|
initialWeightG: Number(row.initial_weight_g),
|
|
remainingWeightG: Number(row.remaining_weight_g),
|
|
costTotal: row.cost_total == null ? null : Number(row.cost_total),
|
|
moisturePct: row.moisture_pct == null ? null : Number(row.moisture_pct),
|
|
densityGL: row.density_g_l == null ? null : Number(row.density_g_l),
|
|
notes: row.notes,
|
|
archived: row.archived,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
});
|
|
app.get("/api/inventory", requireAuth, async (req, res, next) => {
|
|
try {
|
|
const rows = (
|
|
await db.query(
|
|
"SELECT * FROM green_bean_lots WHERE user_id=$1 ORDER BY archived ASC, purchase_date DESC NULLS LAST, created_at DESC",
|
|
[req.user.id],
|
|
)
|
|
).rows;
|
|
res.json({ ok: true, lots: rows.map(toLotRow) });
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
});
|
|
app.post("/api/inventory", requireAuth, csrf, async (req, res, next) => {
|
|
try {
|
|
const origin = String(req.body.origin || "").trim();
|
|
if (!origin || !isFiniteNumber(req.body.initialWeightG) || req.body.initialWeightG < 0)
|
|
return res.status(400).json({ ok: false, code: "bad_lot" });
|
|
let costTotal, moisturePct, densityGL, purchaseDate;
|
|
try {
|
|
costTotal = parseOptionalNumber(req.body.costTotal, "costTotal");
|
|
moisturePct = parseOptionalNumber(req.body.moisturePct, "moisturePct");
|
|
densityGL = parseOptionalNumber(req.body.densityGL, "densityGL");
|
|
purchaseDate = parseOptionalDate(req.body.purchaseDate);
|
|
} catch (e) {
|
|
return res.status(400).json({ ok: false, code: "bad_lot", error: e.message });
|
|
}
|
|
const row = (
|
|
await db.query(
|
|
`INSERT INTO green_bean_lots
|
|
(user_id,origin,variety,process,producer,purchase_date,initial_weight_g,remaining_weight_g,cost_total,moisture_pct,density_g_l,notes)
|
|
VALUES($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11) RETURNING *`,
|
|
[
|
|
req.user.id,
|
|
origin,
|
|
String(req.body.variety || ""),
|
|
String(req.body.process || ""),
|
|
String(req.body.producer || ""),
|
|
purchaseDate.value,
|
|
req.body.initialWeightG,
|
|
costTotal.value,
|
|
moisturePct.value,
|
|
densityGL.value,
|
|
String(req.body.notes || ""),
|
|
],
|
|
)
|
|
).rows[0];
|
|
res.status(201).json({ ok: true, lot: toLotRow(row) });
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
});
|
|
app.get(
|
|
"/api/inventory/:id",
|
|
requireAuth,
|
|
requireUuidParam("id"),
|
|
async (req, res, next) => {
|
|
try {
|
|
const lot = (
|
|
await db.query(
|
|
"SELECT * FROM green_bean_lots WHERE id=$1 AND user_id=$2",
|
|
[req.params.id, req.user.id],
|
|
)
|
|
).rows[0];
|
|
if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
|
|
const log = (
|
|
await db.query(
|
|
`SELECT c.id,c.weight_g,c.roast_plan_id,c.created_at,p.plan->'fields'->>'0.1' AS plan_title
|
|
FROM bean_consumption c LEFT JOIN roast_plans p ON p.id=c.roast_plan_id AND p.user_id=c.user_id
|
|
WHERE c.lot_id=$1 ORDER BY c.created_at DESC`,
|
|
[req.params.id],
|
|
)
|
|
).rows;
|
|
res.json({
|
|
ok: true,
|
|
lot: toLotRow(lot),
|
|
log: log.map((r) => ({
|
|
id: r.id,
|
|
weightG: Number(r.weight_g),
|
|
roastPlanId: r.roast_plan_id,
|
|
planTitle: r.plan_title,
|
|
createdAt: r.created_at,
|
|
})),
|
|
});
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
},
|
|
);
|
|
// The auto-refine-carry-forward feature: the most recent past roast against this lot whose
|
|
// "One change next batch" note was actually filled in, so the planner can offer it as a
|
|
// starting ± Refine value instead of the user retyping their own prior conclusion. Reads
|
|
// inventory.lotId straight off the plan JSONB — no join needed, and scoping to this user's own
|
|
// roast_plans is what keeps this ownership-safe regardless of whether :id even belongs to them.
|
|
app.get(
|
|
"/api/inventory/:id/last-refine",
|
|
requireAuth,
|
|
requireUuidParam("id"),
|
|
async (req, res, next) => {
|
|
try {
|
|
const lot = (
|
|
await db.query(
|
|
"SELECT id FROM green_bean_lots WHERE id=$1 AND user_id=$2",
|
|
[req.params.id, req.user.id],
|
|
)
|
|
).rows[0];
|
|
if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
|
|
const row = (
|
|
await db.query(
|
|
`SELECT plan->'afterRoast'->>'oneChange' AS one_change,
|
|
plan->'fields'->>'0.1' AS plan_title, updated_at
|
|
FROM roast_plans
|
|
WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2
|
|
AND coalesce(plan->'afterRoast'->>'oneChange','')<>''
|
|
ORDER BY updated_at DESC LIMIT 1`,
|
|
[req.user.id, req.params.id],
|
|
)
|
|
).rows[0];
|
|
res.json({
|
|
ok: true,
|
|
refine: row
|
|
? { oneChange: row.one_change, planTitle: row.plan_title, atIso: row.updated_at }
|
|
: null,
|
|
});
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
},
|
|
);
|
|
app.put(
|
|
"/api/inventory/:id",
|
|
requireAuth,
|
|
csrf,
|
|
requireUuidParam("id"),
|
|
async (req, res, next) => {
|
|
try {
|
|
const existing = (
|
|
await db.query(
|
|
"SELECT * FROM green_bean_lots WHERE id=$1 AND user_id=$2",
|
|
[req.params.id, req.user.id],
|
|
)
|
|
).rows[0];
|
|
if (!existing)
|
|
return res.status(404).json({ ok: false, code: "not_found" });
|
|
const b = req.body;
|
|
const origin =
|
|
b.origin === undefined ? existing.origin : String(b.origin || "").trim();
|
|
if (!origin) return res.status(400).json({ ok: false, code: "bad_lot" });
|
|
if (
|
|
b.initialWeightG !== undefined &&
|
|
(!isFiniteNumber(b.initialWeightG) || b.initialWeightG < 0)
|
|
)
|
|
return res.status(400).json({ ok: false, code: "bad_lot" });
|
|
let costTotal, moisturePct, densityGL, purchaseDate;
|
|
try {
|
|
costTotal =
|
|
b.costTotal === undefined
|
|
? existing.cost_total
|
|
: parseOptionalNumber(b.costTotal, "costTotal").value;
|
|
moisturePct =
|
|
b.moisturePct === undefined
|
|
? existing.moisture_pct
|
|
: parseOptionalNumber(b.moisturePct, "moisturePct").value;
|
|
densityGL =
|
|
b.densityGL === undefined
|
|
? existing.density_g_l
|
|
: parseOptionalNumber(b.densityGL, "densityGL").value;
|
|
purchaseDate =
|
|
b.purchaseDate === undefined
|
|
? existing.purchase_date
|
|
: parseOptionalDate(b.purchaseDate).value;
|
|
} catch (e) {
|
|
return res.status(400).json({ ok: false, code: "bad_lot", error: e.message });
|
|
}
|
|
// remainingWeightG/consumptionLog are never read from the request body: remaining
|
|
// only ever moves via consume(), except for the delta below, which is the
|
|
// sanctioned correction path (recount the bag, fix the paperwork). The delta is
|
|
// computed inside the UPDATE itself (against the pre-statement initial_weight_g)
|
|
// rather than from the `existing` row read above, so a concurrent PUT can't lose
|
|
// the other's delta the way a JS-side read-then-write would.
|
|
const row = (
|
|
await db.query(
|
|
`UPDATE green_bean_lots SET
|
|
origin=$1, variety=$2, process=$3, producer=$4, purchase_date=$5,
|
|
remaining_weight_g = remaining_weight_g + COALESCE($6::numeric, initial_weight_g) - initial_weight_g,
|
|
initial_weight_g = COALESCE($6::numeric, initial_weight_g),
|
|
cost_total=$7, moisture_pct=$8, density_g_l=$9, notes=$10, archived=$11,
|
|
updated_at=now()
|
|
WHERE id=$12 AND user_id=$13 RETURNING *`,
|
|
[
|
|
origin,
|
|
b.variety === undefined ? existing.variety : String(b.variety || ""),
|
|
b.process === undefined ? existing.process : String(b.process || ""),
|
|
b.producer === undefined ? existing.producer : String(b.producer || ""),
|
|
purchaseDate,
|
|
b.initialWeightG === undefined ? null : b.initialWeightG,
|
|
costTotal,
|
|
moisturePct,
|
|
densityGL,
|
|
b.notes === undefined ? existing.notes : String(b.notes || ""),
|
|
b.archived === undefined ? existing.archived : Boolean(b.archived),
|
|
req.params.id,
|
|
req.user.id,
|
|
],
|
|
)
|
|
).rows[0];
|
|
res.json({ ok: true, lot: toLotRow(row) });
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
},
|
|
);
|
|
app.delete(
|
|
"/api/inventory/:id",
|
|
requireAuth,
|
|
csrf,
|
|
requireUuidParam("id"),
|
|
async (req, res, next) => {
|
|
try {
|
|
const r = await db.query(
|
|
"DELETE FROM green_bean_lots WHERE id=$1 AND user_id=$2",
|
|
[req.params.id, req.user.id],
|
|
);
|
|
if (!r.rowCount)
|
|
return res.status(404).json({ ok: false, code: "not_found" });
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
},
|
|
);
|
|
app.post(
|
|
"/api/inventory/:id/consume",
|
|
requireAuth,
|
|
csrf,
|
|
requireUuidParam("id"),
|
|
async (req, res, next) => {
|
|
try {
|
|
const weightG = Number(req.body.weightG);
|
|
if (!Number.isFinite(weightG) || weightG <= 0)
|
|
return res.status(400).json({ ok: false, code: "bad_weight" });
|
|
const lotExists = (
|
|
await db.query(
|
|
"SELECT 1 FROM green_bean_lots WHERE id=$1 AND user_id=$2",
|
|
[req.params.id, req.user.id],
|
|
)
|
|
).rowCount;
|
|
if (!lotExists)
|
|
return res.status(404).json({ ok: false, code: "not_found" });
|
|
let roastPlanId = null;
|
|
if (req.body.roastPlanId) {
|
|
if (!UUID_RE.test(req.body.roastPlanId))
|
|
return res.status(404).json({ ok: false, code: "not_found" });
|
|
const ownsPlan = (
|
|
await db.query(
|
|
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
|
|
[req.body.roastPlanId, req.user.id],
|
|
)
|
|
).rowCount;
|
|
if (!ownsPlan)
|
|
return res.status(404).json({ ok: false, code: "not_found" });
|
|
roastPlanId = req.body.roastPlanId;
|
|
}
|
|
// The log insert and the balance update must commit together — on separate pool
|
|
// connections a crash or error between them would leave a consumption row logged
|
|
// with no corresponding decrement, and the unique index means that plan could
|
|
// never be retried. See withTransaction's own comment for why plain sequential
|
|
// db.query("BEGIN")/db.query(...) calls do not actually guarantee this.
|
|
let row;
|
|
try {
|
|
row = await withTransaction(async (client) => {
|
|
try {
|
|
await client.query(
|
|
"INSERT INTO bean_consumption(lot_id,user_id,roast_plan_id,weight_g) VALUES($1,$2,$3,$4)",
|
|
[req.params.id, req.user.id, roastPlanId, weightG],
|
|
);
|
|
} catch (e) {
|
|
if (e.code === "23505") {
|
|
const err = new Error("already_consumed");
|
|
err.httpCode = "already_consumed";
|
|
throw err;
|
|
}
|
|
throw e;
|
|
}
|
|
const updated = (
|
|
await client.query(
|
|
"UPDATE green_bean_lots SET remaining_weight_g=remaining_weight_g-$1, updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING *",
|
|
[weightG, req.params.id, req.user.id],
|
|
)
|
|
).rows[0];
|
|
if (!updated) {
|
|
// The lot was deleted by a concurrent request between the ownership
|
|
// check above and here — roll back the log insert too.
|
|
const err = new Error("not_found");
|
|
err.httpCode = "not_found";
|
|
throw err;
|
|
}
|
|
return updated;
|
|
});
|
|
} catch (e) {
|
|
if (e.httpCode === "already_consumed")
|
|
return res.status(409).json({ ok: false, code: "already_consumed" });
|
|
if (e.httpCode === "not_found")
|
|
return res.status(404).json({ ok: false, code: "not_found" });
|
|
throw e;
|
|
}
|
|
res.status(201).json({ ok: true, lot: toLotRow(row) });
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
},
|
|
);
|
|
|
|
// ─── 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;
|
|
}
|