chore: format auth code and update runtime

This commit is contained in:
2026-07-29 22:11:08 -04:00
parent 892479dceb
commit 22e6e721a7
9 changed files with 132 additions and 73 deletions
+2 -1
View File
@@ -16,7 +16,8 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
expose: ["8090"] # Nginx Proxy Manager routes https://roast.srmr.xyz to this host port.
ports: ["8090:8090"]
restart: unless-stopped restart: unless-stopped
db: db:
image: postgres:16-alpine image: postgres:16-alpine
+20 -20
View File
@@ -8,7 +8,7 @@
"name": "roast-planner-webapp", "name": "roast-planner-webapp",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.82.1", "@earendil-works/pi-coding-agent": "^0.83.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"express": "^5.0.1", "express": "^5.0.1",
"pg": "^8.22.0" "pg": "^8.22.0"
@@ -19,15 +19,15 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent": { "node_modules/@earendil-works/pi-coding-agent": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.83.0.tgz",
"integrity": "sha512-zbkAhoIuDPMF3pKuja0ajZabrMWU29FUMV9A/XMXT/XC1yXs5xt6t6t13GogQFsDrDqbFP4DkZQO1w8rWRAzYA==", "integrity": "sha512-uYhF+FsZxogoSX/AxBcUdiY+ZklubwaXyAoEGA2eQwsHcyEAhUYIKh/WLXe/a8+k8eTCmxb+ZN2Zo9mzQtzbWw==",
"hasShrinkwrap": true, "hasShrinkwrap": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.82.1", "@earendil-works/pi-agent-core": "^0.83.0",
"@earendil-works/pi-ai": "^0.82.1", "@earendil-works/pi-ai": "^0.83.0",
"@earendil-works/pi-tui": "^0.82.1", "@earendil-works/pi-tui": "^0.83.0",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -40,7 +40,7 @@
"minimatch": "10.2.5", "minimatch": "10.2.5",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"semver": "7.8.0", "semver": "7.8.0",
"typebox": "1.1.38", "typebox": "1.3.7",
"undici": "8.5.0", "undici": "8.5.0",
"yaml": "2.9.0" "yaml": "2.9.0"
}, },
@@ -490,14 +490,14 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.83.0.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.82.1", "@earendil-works/pi-ai": "^0.83.0",
"diff": "8.0.4", "diff": "8.0.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.3.7",
"yaml": "2.9.0" "yaml": "2.9.0"
}, },
"engines": { "engines": {
@@ -505,8 +505,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.83.0.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
@@ -519,7 +519,7 @@
"https-proxy-agent": "7.0.6", "https-proxy-agent": "7.0.6",
"openai": "6.26.0", "openai": "6.26.0",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"typebox": "1.1.38" "typebox": "1.3.7"
}, },
"bin": { "bin": {
"pi-ai": "dist/cli.js" "pi-ai": "dist/cli.js"
@@ -529,8 +529,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.83.0.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"get-east-asian-width": "1.6.0", "get-east-asian-width": "1.6.0",
@@ -1724,9 +1724,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": {
"version": "1.1.38", "version": "1.3.7",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz",
"integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": {
+1 -1
View File
@@ -10,7 +10,7 @@
"test": "node --test" "test": "node --test"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.82.1", "@earendil-works/pi-coding-agent": "^0.83.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"express": "^5.0.1", "express": "^5.0.1",
"pg": "^8.22.0" "pg": "^8.22.0"
+3 -1
View File
@@ -37,7 +37,9 @@ document.querySelector("#save").addEventListener("click", async () => {
"content-type": "application/json", "content-type": "application/json",
"x-csrf-token": csrf(), "x-csrf-token": csrf(),
}, },
body: JSON.stringify({ enabled: document.querySelector("#signup").checked }), body: JSON.stringify({
enabled: document.querySelector("#signup").checked,
}),
}); });
await load(); await load();
}); });
+3 -3
View File
@@ -19,6 +19,6 @@ form.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
submit("/api/auth/login"); submit("/api/auth/login");
}); });
document.querySelector("#signup").addEventListener("click", () => document
submit("/api/auth/signup"), .querySelector("#signup")
); .addEventListener("click", () => submit("/api/auth/signup"));
+6 -2
View File
@@ -409,7 +409,8 @@ function autosave() {
clearTimeout(autosave._t); clearTimeout(autosave._t);
autosave._t = setTimeout(() => { autosave._t = setTimeout(() => {
try { 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. // Local storage is namespaced per authenticated account; the account copy is authoritative when online.
if (navigator.onLine && csrfToken()) { if (navigator.onLine && csrfToken()) {
const method = remotePlanId ? "PUT" : "POST"; 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. // A shared browser must never retain a previous account's local-only draft.
for (let i = localStorage.length - 1; i >= 0; i--) { for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(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); localStorage.removeItem(key);
} }
const raw = localStorage.getItem(storageKey); const raw = localStorage.getItem(storageKey);
+19 -12
View File
@@ -28,7 +28,14 @@ export function createApp({ db, root, env = process.env } = {}) {
const buckets = new Map(); const buckets = new Map();
const MAX_RATE_BUCKETS = 10_000; const MAX_RATE_BUCKETS = 10_000;
const rateLimit = (name, max, windowMs) => { 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"); throw new Error("Invalid rate-limit configuration");
return (req, res, next) => { return (req, res, next) => {
const key = `${name}:${req.ip}`; 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. // identifies its proxy. A numeric hop count is unsafe when the topology changes.
app.set("trust proxy", env.TRUST_PROXY || false); app.set("trust proxy", env.TRUST_PROXY || false);
app.use((req, res, next) => { 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("Cache-Control", "no-store, private");
res.set({ res.set({
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
@@ -146,9 +157,7 @@ export function createApp({ db, root, env = process.env } = {}) {
const email = emailOf(req.body.email), const email = emailOf(req.body.email),
password = req.body.password; password = req.body.password;
if (!/^\S+@\S+\.\S+$/.test(email) || !PASSWORD_OK(password)) if (!/^\S+@\S+\.\S+$/.test(email) || !PASSWORD_OK(password))
return res return res.status(400).json({
.status(400)
.json({
ok: false, ok: false,
code: "invalid_credentials", code: "invalid_credentials",
error: error:
@@ -168,9 +177,7 @@ export function createApp({ db, root, env = process.env } = {}) {
).rows[0]; ).rows[0];
const s = await createSession(user); const s = await createSession(user);
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
res res.status(201).json({
.status(201)
.json({
ok: true, ok: true,
user: { email: user.email, role: user.role }, user: { email: user.email, role: user.role },
csrfToken: s.csrfToken, csrfToken: s.csrfToken,
@@ -196,7 +203,9 @@ export function createApp({ db, root, env = process.env } = {}) {
return res.status(409).json({ ok: false, code: "bootstrap_used" }); return res.status(409).json({ ok: false, code: "bootstrap_used" });
const bootstrap = String(req.body.setupToken || ""); const bootstrap = String(req.body.setupToken || "");
if (!env.BOOTSTRAP_SETUP_TOKEN) 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 ( if (
bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length || bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length ||
!crypto.timingSafeEqual( !crypto.timingSafeEqual(
@@ -222,9 +231,7 @@ export function createApp({ db, root, env = process.env } = {}) {
).rows[0]; ).rows[0];
const s = await createSession(user); const s = await createSession(user);
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
res res.status(201).json({
.status(201)
.json({
ok: true, ok: true,
user: { email: user.email, role: user.role }, user: { email: user.email, role: user.role },
csrfToken: s.csrfToken, csrfToken: s.csrfToken,
+57 -18
View File
@@ -20,11 +20,16 @@ async function setup() {
}); });
const pg = mem.adapters.createPg(); const pg = mem.adapters.createPg();
const db = new pg.Pool(); 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({ const app = createApp({
db, db,
root, 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) }; 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("/"); const landing = await anonymous.get("/");
assert.equal(landing.status, 200); 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( assert.doesNotMatch(
landing.headers["content-security-policy"], landing.headers["content-security-policy"],
/(?:default-src|script-src)[^;]*unsafe-inline/, /(?:default-src|script-src)[^;]*unsafe-inline/,
); );
assert.match(landing.text, /<script type="module" src="\/js\/landing\.js"><\/script>/); assert.match(
landing.text,
/<script type="module" src="\/js\/landing\.js"><\/script>/,
);
assert.doesNotMatch(landing.text, /<script type="module">/); assert.doesNotMatch(landing.text, /<script type="module">/);
const adminHtml = await anonymous.get("/admin"); const adminHtml = await anonymous.get("/admin");
assert.equal(adminHtml.status, 401); assert.equal(adminHtml.status, 401);
assert.equal(adminHtml.headers["cache-control"], "no-store, private"); assert.equal(adminHtml.headers["cache-control"], "no-store, private");
assert.equal((await anonymous.get("/api/plans")).status, 401); assert.equal((await anonymous.get("/api/plans")).status, 401);
assert.equal((await anonymous.get("/api/plans")).headers["cache-control"], "no-store, private"); assert.equal(
assert.match((await anonymous.get("/js/admin.js")).text, /async function load/); (await anonymous.get("/api/plans")).headers["cache-control"],
"no-store, private",
);
assert.match(
(await anonymous.get("/js/admin.js")).text,
/async function load/,
);
const mainScript = await anonymous.get("/js/main.js"); const mainScript = await anonymous.get("/js/main.js");
assert.match(mainScript.text, /roastPlannerPlan\.v2/); assert.match(mainScript.text, /roastPlannerPlan\.v2/);
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/); assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
assert.match((await anonymous.get("/sw.js")).text, /Never serve authenticated/); assert.match(
(await anonymous.get("/sw.js")).text,
/Never serve authenticated/,
);
const one = await signup(first, "[email protected]"); const one = await signup(first, "[email protected]");
const two = await signup(second, "[email protected]"); const two = await signup(second, "[email protected]");
@@ -68,9 +88,12 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
const plan = await first const plan = await first
.post("/api/plans") .post("/api/plans")
.set("x-csrf-token", one.csrf) .set("x-csrf-token", one.csrf)
.send({ plan: { fields: { "0.1": "Private" } } }); .send({ plan: { fields: { 0.1: "Private" } } });
assert.equal(plan.status, 201); assert.equal(plan.status, 201);
assert.equal((await first.get("/api/plans")).headers["cache-control"], "no-store, private"); assert.equal(
(await first.get("/api/plans")).headers["cache-control"],
"no-store, private",
);
assert.equal( assert.equal(
( (
await second await second
@@ -84,13 +107,22 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
const login = request.agent(app); const login = request.agent(app);
assert.equal( assert.equal(
(await login.post("/api/auth/login").send({ email: "[email protected]", password })).status, (
await login
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).status,
200, 200,
); );
assert.equal((await login.get("/api/auth/me")).status, 200); assert.equal((await login.get("/api/auth/me")).status, 200);
const loginCsrf = (await login.post("/api/auth/login").send({ email: "[email protected]", password })).body.csrfToken; const loginCsrf = (
await login
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).body.csrfToken;
assert.equal( assert.equal(
(await login.post("/api/auth/logout").set("x-csrf-token", loginCsrf)).status, (await login.post("/api/auth/logout").set("x-csrf-token", loginCsrf))
.status,
200, 200,
); );
assert.equal((await login.get("/api/auth/me")).status, 401); assert.equal((await login.get("/api/auth/me")).status, 401);
@@ -113,10 +145,17 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
200, 200,
); );
assert.equal( assert.equal(
(await anonymous.post("/api/auth/signup").send({ email: "[email protected]", password })).status, (
await anonymous
.post("/api/auth/signup")
.send({ email: "[email protected]", password })
).status,
403, 403,
); );
assert.equal((await db.query("SELECT count(*)::int AS count FROM users")).rows[0].count, 3); assert.equal(
(await db.query("SELECT count(*)::int AS count FROM users")).rows[0].count,
3,
);
}); });
test("bootstrap token is optional after first setup and unavailable before setup without one", async () => { test("bootstrap token is optional after first setup and unavailable before setup without one", async () => {
@@ -142,10 +181,10 @@ test("bootstrap token is optional after first setup and unavailable before setup
).status, ).status,
503, 503,
); );
await db.query("INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin')", [ await db.query(
"[email protected]", "INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin')",
"not-used-in-this-test", ["[email protected]", "not-used-in-this-test"],
]); );
assert.equal( assert.equal(
( (
await request(noTokenApp).post("/api/auth/bootstrap").send({ await request(noTokenApp).post("/api/auth/bootstrap").send({
+8 -2
View File
@@ -23,7 +23,9 @@ test("Docker image starts against PostgreSQL and applies migrations", async (t)
t.skip("Docker daemon is unavailable"); t.skip("Docker daemon is unavailable");
return; return;
} }
const temp = await mkdtemp(path.join(os.tmpdir(), "roast-planner-container-")); const temp = await mkdtemp(
path.join(os.tmpdir(), "roast-planner-container-"),
);
const agentDir = path.join(temp, "pi-agent"); const agentDir = path.join(temp, "pi-agent");
await mkdir(agentDir); await mkdir(agentDir);
const envFile = path.join(temp, "compose.env"); const envFile = path.join(temp, "compose.env");
@@ -66,7 +68,11 @@ test("Docker image starts against PostgreSQL and applies migrations", async (t)
await new Promise((resolve) => setTimeout(resolve, 1_000)); await new Promise((resolve) => setTimeout(resolve, 1_000));
} }
} }
assert.equal(lastError, null, "application never became ready in its container"); assert.equal(
lastError,
null,
"application never became ready in its container",
);
const migration = await compose([ const migration = await compose([
"exec", "exec",
"-T", "-T",