From 22e6e721a72fe8bbc5d510d801d93684fb91109a Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Wed, 29 Jul 2026 22:11:08 -0400 Subject: [PATCH] chore: format auth code and update runtime --- docker-compose.yml | 3 +- package-lock.json | 40 +++++++++--------- package.json | 2 +- public/js/admin.js | 4 +- public/js/landing.js | 6 +-- public/js/main.js | 8 +++- server/app.js | 57 ++++++++++++++------------ test/auth.test.js | 75 ++++++++++++++++++++++++++-------- test/container-startup.test.js | 10 ++++- 9 files changed, 132 insertions(+), 73 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d710bdb..aac5b13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,8 @@ services: depends_on: db: condition: service_healthy - expose: ["8090"] + # Nginx Proxy Manager routes https://roast.srmr.xyz to this host port. + ports: ["8090:8090"] restart: unless-stopped db: image: postgres:16-alpine diff --git a/package-lock.json b/package-lock.json index aea2254..4aaed8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "roast-planner-webapp", "version": "0.1.0", "dependencies": { - "@earendil-works/pi-coding-agent": "^0.82.1", + "@earendil-works/pi-coding-agent": "^0.83.0", "bcryptjs": "^3.0.3", "express": "^5.0.1", "pg": "^8.22.0" @@ -19,15 +19,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.1.tgz", - "integrity": "sha512-zbkAhoIuDPMF3pKuja0ajZabrMWU29FUMV9A/XMXT/XC1yXs5xt6t6t13GogQFsDrDqbFP4DkZQO1w8rWRAzYA==", + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.83.0.tgz", + "integrity": "sha512-uYhF+FsZxogoSX/AxBcUdiY+ZklubwaXyAoEGA2eQwsHcyEAhUYIKh/WLXe/a8+k8eTCmxb+ZN2Zo9mzQtzbWw==", "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.82.1", - "@earendil-works/pi-ai": "^0.82.1", - "@earendil-works/pi-tui": "^0.82.1", + "@earendil-works/pi-agent-core": "^0.83.0", + "@earendil-works/pi-ai": "^0.83.0", + "@earendil-works/pi-tui": "^0.83.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -40,7 +40,7 @@ "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", - "typebox": "1.1.38", + "typebox": "1.3.7", "undici": "8.5.0", "yaml": "2.9.0" }, @@ -490,14 +490,14 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz", + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.83.0.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.82.1", + "@earendil-works/pi-ai": "^0.83.0", "diff": "8.0.4", "ignore": "7.0.5", - "typebox": "1.1.38", + "typebox": "1.3.7", "yaml": "2.9.0" }, "engines": { @@ -505,8 +505,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz", + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.83.0.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -519,7 +519,7 @@ "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", - "typebox": "1.1.38" + "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" @@ -529,8 +529,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz", + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.83.0.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -1724,9 +1724,9 @@ "license": "0BSD" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { diff --git a/package.json b/package.json index 6330cea..068d3fd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "test": "node --test" }, "dependencies": { - "@earendil-works/pi-coding-agent": "^0.82.1", + "@earendil-works/pi-coding-agent": "^0.83.0", "bcryptjs": "^3.0.3", "express": "^5.0.1", "pg": "^8.22.0" diff --git a/public/js/admin.js b/public/js/admin.js index f227012..6cf01ad 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -37,7 +37,9 @@ document.querySelector("#save").addEventListener("click", async () => { "content-type": "application/json", "x-csrf-token": csrf(), }, - body: JSON.stringify({ enabled: document.querySelector("#signup").checked }), + body: JSON.stringify({ + enabled: document.querySelector("#signup").checked, + }), }); await load(); }); diff --git a/public/js/landing.js b/public/js/landing.js index 98a3ac1..0df9cd0 100644 --- a/public/js/landing.js +++ b/public/js/landing.js @@ -19,6 +19,6 @@ form.addEventListener("submit", (event) => { event.preventDefault(); submit("/api/auth/login"); }); -document.querySelector("#signup").addEventListener("click", () => - submit("/api/auth/signup"), -); +document + .querySelector("#signup") + .addEventListener("click", () => submit("/api/auth/signup")); diff --git a/public/js/main.js b/public/js/main.js index 727934a..d2fac8a 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -409,7 +409,8 @@ function autosave() { clearTimeout(autosave._t); autosave._t = setTimeout(() => { try { - if (storageKey) localStorage.setItem(storageKey, JSON.stringify(state.plan)); + if (storageKey) + localStorage.setItem(storageKey, JSON.stringify(state.plan)); // Local storage is namespaced per authenticated account; the account copy is authoritative when online. if (navigator.onLine && csrfToken()) { const method = remotePlanId ? "PUT" : "POST"; @@ -455,7 +456,10 @@ function loadFromStorage(userId) { // A shared browser must never retain a previous account's local-only draft. for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); - if ((key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey) || key === "roastPlannerPlan.v1") + if ( + (key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey) || + key === "roastPlannerPlan.v1" + ) localStorage.removeItem(key); } const raw = localStorage.getItem(storageKey); diff --git a/server/app.js b/server/app.js index 377ec2e..f8ffe95 100644 --- a/server/app.js +++ b/server/app.js @@ -28,7 +28,14 @@ export function createApp({ db, root, env = process.env } = {}) { 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) + 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}`; @@ -53,7 +60,11 @@ export function createApp({ db, root, env = process.env } = {}) { // 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") + if ( + req.path.startsWith("/api/") || + req.path === "/app" || + req.path === "/admin" + ) res.set("Cache-Control", "no-store, private"); res.set({ "X-Content-Type-Options": "nosniff", @@ -146,14 +157,12 @@ export function createApp({ db, root, env = process.env } = {}) { 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.", - }); + return res.status(400).json({ + ok: false, + code: "invalid_credentials", + error: + "Use a valid email and a password of at least 12 characters.", + }); const setting = await db.query( "SELECT value FROM app_settings WHERE key='signup_enabled'", ); @@ -168,13 +177,11 @@ export function createApp({ db, root, env = process.env } = {}) { ).rows[0]; const s = await createSession(user); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); - res - .status(201) - .json({ - ok: true, - user: { email: user.email, role: user.role }, - csrfToken: s.csrfToken, - }); + 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" }); @@ -196,7 +203,9 @@ export function createApp({ db, root, env = process.env } = {}) { 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" }); + return res + .status(503) + .json({ ok: false, code: "bootstrap_unavailable" }); if ( bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length || !crypto.timingSafeEqual( @@ -222,13 +231,11 @@ export function createApp({ db, root, env = process.env } = {}) { ).rows[0]; const s = await createSession(user); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); - res - .status(201) - .json({ - ok: true, - user: { email: user.email, role: user.role }, - csrfToken: s.csrfToken, - }); + res.status(201).json({ + ok: true, + user: { email: user.email, role: user.role }, + csrfToken: s.csrfToken, + }); } catch (e) { next(e); } diff --git a/test/auth.test.js b/test/auth.test.js index 3fb2773..d1bd634 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -20,11 +20,16 @@ async function setup() { }); const pg = mem.adapters.createPg(); const db = new pg.Pool(); - await db.query(`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now()); CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id),csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now()); CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id),plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now()); CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL); INSERT INTO app_settings VALUES('signup_enabled','true')`); + await db.query( + `CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now()); CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id),csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now()); CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id),plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now()); CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL); INSERT INTO app_settings VALUES('signup_enabled','true')`, + ); const app = createApp({ db, root, - env: { NODE_ENV: "test", BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token" }, + env: { + NODE_ENV: "test", + BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token", + }, }); return { db, app, agent: request.agent(app) }; } @@ -43,23 +48,38 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh const landing = await anonymous.get("/"); assert.equal(landing.status, 200); - assert.match(landing.headers["content-security-policy"], /default-src 'self'/); + assert.match( + landing.headers["content-security-policy"], + /default-src 'self'/, + ); assert.doesNotMatch( landing.headers["content-security-policy"], /(?:default-src|script-src)[^;]*unsafe-inline/, ); - assert.match(landing.text, /