chore: format auth code and update runtime
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
Generated
+20
-20
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+3
-1
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
+6
-2
@@ -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);
|
||||
|
||||
+19
-12
@@ -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,9 +157,7 @@ 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({
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
code: "invalid_credentials",
|
||||
error:
|
||||
@@ -168,9 +177,7 @@ 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({
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
user: { email: user.email, role: user.role },
|
||||
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" });
|
||||
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,9 +231,7 @@ 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({
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
user: { email: user.email, role: user.role },
|
||||
csrfToken: s.csrfToken,
|
||||
|
||||
+57
-18
@@ -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, /<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">/);
|
||||
const adminHtml = await anonymous.get("/admin");
|
||||
assert.equal(adminHtml.status, 401);
|
||||
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")).headers["cache-control"], "no-store, private");
|
||||
assert.match((await anonymous.get("/js/admin.js")).text, /async function load/);
|
||||
assert.equal(
|
||||
(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");
|
||||
assert.match(mainScript.text, /roastPlannerPlan\.v2/);
|
||||
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 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
|
||||
.post("/api/plans")
|
||||
.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((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(
|
||||
(
|
||||
await second
|
||||
@@ -84,13 +107,22 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
|
||||
|
||||
const login = request.agent(app);
|
||||
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,
|
||||
);
|
||||
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(
|
||||
(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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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 () => {
|
||||
@@ -142,10 +181,10 @@ test("bootstrap token is optional after first setup and unavailable before setup
|
||||
).status,
|
||||
503,
|
||||
);
|
||||
await db.query("INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin')", [
|
||||
"[email protected]",
|
||||
"not-used-in-this-test",
|
||||
]);
|
||||
await db.query(
|
||||
"INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin')",
|
||||
["[email protected]", "not-used-in-this-test"],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(noTokenApp).post("/api/auth/bootstrap").send({
|
||||
|
||||
@@ -23,7 +23,9 @@ test("Docker image starts against PostgreSQL and applies migrations", async (t)
|
||||
t.skip("Docker daemon is unavailable");
|
||||
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");
|
||||
await mkdir(agentDir);
|
||||
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));
|
||||
}
|
||||
}
|
||||
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([
|
||||
"exec",
|
||||
"-T",
|
||||
|
||||
Reference in New Issue
Block a user