Public: anyone can read this; the crew can change it.
auth + default anon #18
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7-auth-default-anon → main 2 commits fork
2 files
changed +363
−10
src/apps/scaffold.ts +350 −8
| @@ -25,6 +25,7 @@ export function scaffoldFiles(input: ScaffoldInput): FileWrite[] { | ||
| 25 | 25 | { path: ".gitignore", content: GITIGNORE }, |
| 26 | 26 | { path: "README.md", content: readme(title, description) }, |
| 27 | 27 | { path: "AGENTS.md", content: AGENTS_MD }, |
| 28 | { path: "src/auth.ts", content: AUTH }, | |
| 28 | 29 | { path: "src/server.ts", content: server(title, description) }, |
| 29 | 30 | { path: "src/server.test.ts", content: SERVER_TEST }, |
| 30 | 31 | { path: "src/tty.ts", content: tty(title) }, |
| @@ -140,6 +141,167 @@ preview at its own address, deployed from your branch as you push. | ||
| 140 | 141 | what is real versus simulated, \`forge.json\` says how to run it. |
| 141 | 142 | `; |
| 142 | 143 | |
| 144 | const AUTH = `/** | |
| 145 | * Auth, in one small file. The two ideas that matter: | |
| 146 | * | |
| 147 | * 1. Default allow anon. Nothing here forces a sign-in — the app works for | |
| 148 | * a visitor with no cookie at all. Add a role check only where an action | |
| 149 | * truly needs one; every route unchecked is anon by default. | |
| 150 | * 2. Clear roles for RBAC. A session carries a role. Today there are two | |
| 151 | * ("anon" — no session, the implicit default — and "admin"); add a third | |
| 152 | * by extending the union below and the checks that read it, never by | |
| 153 | * stringly-typed comparisons scattered through routes. | |
| 154 | * | |
| 155 | * An app is never claimed by whoever visits first: the first admin needs a | |
| 156 | * secret only the operator holds — ADMIN_PASSWORD from the environment, or | |
| 157 | * a one-time code this file generates and prints to the run log and the | |
| 158 | * app's terminal. Sessions store a hash of their token, never the token | |
| 159 | * itself, so a leaked database yields nothing usable. | |
| 160 | */ | |
| 161 | ||
| 162 | import type { Database } from "bun:sqlite"; | |
| 163 | ||
| 164 | export type Role = "anon" | "admin"; | |
| 165 | ||
| 166 | const SESSION_COOKIE = "session"; | |
| 167 | const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; | |
| 168 | ||
| 169 | export function ensureAuthSchema(db: Database): void { | |
| 170 | db.exec( | |
| 171 | "CREATE TABLE IF NOT EXISTS session (token_hash TEXT PRIMARY KEY, role TEXT NOT NULL, expires_at TEXT NOT NULL)", | |
| 172 | ); | |
| 173 | db.exec( | |
| 174 | "CREATE TABLE IF NOT EXISTS admin_claim (id INTEGER PRIMARY KEY CHECK (id = 1), code TEXT NOT NULL, claimed INTEGER NOT NULL DEFAULT 0)", | |
| 175 | ); | |
| 176 | } | |
| 177 | ||
| 178 | function randomToken(bytes: number): string { | |
| 179 | const raw = new Uint8Array(bytes); | |
| 180 | crypto.getRandomValues(raw); | |
| 181 | return Array.from(raw, (b) => b.toString(16).padStart(2, "0")).join(""); | |
| 182 | } | |
| 183 | ||
| 184 | function hash(token: string): string { | |
| 185 | return new Bun.CryptoHasher("sha256").update(token).digest("hex"); | |
| 186 | } | |
| 187 | ||
| 188 | // Equal-length, constant-time: a random token never leaks its prefix | |
| 189 | // through how long a comparison takes to fail. | |
| 190 | function timingSafeEqual(a: string, b: string): boolean { | |
| 191 | if (a.length !== b.length) return false; | |
| 192 | let diff = 0; | |
| 193 | for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| 194 | return diff === 0; | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * The secret that proves someone is the operator. ADMIN_PASSWORD from the | |
| 199 | * environment wins and never expires or is marked used — it is the | |
| 200 | * operator's own password, good for as many sign-ins as they like. With | |
| 201 | * none set, a one-time code is generated on first boot and persisted | |
| 202 | * (unclaimed) so every process — the server, the terminal — shows the same | |
| 203 | * value, and using it once retires it for good. | |
| 204 | */ | |
| 205 | export interface AdminSecret { | |
| 206 | readonly value: string; | |
| 207 | readonly fromEnv: boolean; | |
| 208 | } | |
| 209 | ||
| 210 | export function resolveAdminSecret(db: Database): AdminSecret { | |
| 211 | const configured = process.env.ADMIN_PASSWORD?.trim(); | |
| 212 | if (configured) return { value: configured, fromEnv: true }; | |
| 213 | const row = db | |
| 214 | .query<{ code: string; claimed: number }, []>( | |
| 215 | "SELECT code, claimed FROM admin_claim WHERE id = 1", | |
| 216 | ) | |
| 217 | .get(); | |
| 218 | if (row) return { value: row.claimed ? "" : row.code, fromEnv: false }; | |
| 219 | const code = randomToken(10); | |
| 220 | db.query( | |
| 221 | "INSERT INTO admin_claim (id, code, claimed) VALUES (1, ?1, 0)", | |
| 222 | ).run(code); | |
| 223 | return { value: code, fromEnv: false }; | |
| 224 | } | |
| 225 | ||
| 226 | export function isAdminClaimed(db: Database): boolean { | |
| 227 | if (process.env.ADMIN_PASSWORD?.trim()) return false; // never "claimed"; the password just keeps working | |
| 228 | const row = db | |
| 229 | .query<{ claimed: number }, []>("SELECT claimed FROM admin_claim WHERE id = 1") | |
| 230 | .get(); | |
| 231 | return row?.claimed === 1; | |
| 232 | } | |
| 233 | ||
| 234 | export function verifyAdminSecret(db: Database, provided: string): boolean { | |
| 235 | const secret = resolveAdminSecret(db); | |
| 236 | if (!secret.value) return false; | |
| 237 | if (!secret.fromEnv && isAdminClaimed(db)) return false; | |
| 238 | return timingSafeEqual(provided, secret.value); | |
| 239 | } | |
| 240 | ||
| 241 | export function claimAdmin(db: Database): void { | |
| 242 | db.query("UPDATE admin_claim SET claimed = 1 WHERE id = 1").run(); | |
| 243 | } | |
| 244 | ||
| 245 | export function createSession(db: Database, role: Role): string { | |
| 246 | const token = randomToken(24); | |
| 247 | const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString(); | |
| 248 | db.query( | |
| 249 | "INSERT INTO session (token_hash, role, expires_at) VALUES (?1, ?2, ?3)", | |
| 250 | ).run(hash(token), role, expiresAt); | |
| 251 | return token; | |
| 252 | } | |
| 253 | ||
| 254 | export function destroySession(db: Database, token: string): void { | |
| 255 | db.query("DELETE FROM session WHERE token_hash = ?1").run(hash(token)); | |
| 256 | } | |
| 257 | ||
| 258 | function cookieValue(request: Request, name: string): string | undefined { | |
| 259 | const header = request.headers.get("cookie"); | |
| 260 | if (!header) return undefined; | |
| 261 | for (const part of header.split(";")) { | |
| 262 | const i = part.indexOf("="); | |
| 263 | if (i === -1) continue; | |
| 264 | if (part.slice(0, i).trim() === name) return part.slice(i + 1).trim(); | |
| 265 | } | |
| 266 | return undefined; | |
| 267 | } | |
| 268 | ||
| 269 | /** Anon unless a live, unexpired session says otherwise. That is the whole default-allow-anon rule. */ | |
| 270 | export function roleFromRequest(db: Database, request: Request): Role { | |
| 271 | const token = cookieValue(request, SESSION_COOKIE); | |
| 272 | if (!token) return "anon"; | |
| 273 | const row = db | |
| 274 | .query<{ role: string; expires_at: string }, [string]>( | |
| 275 | "SELECT role, expires_at FROM session WHERE token_hash = ?1", | |
| 276 | ) | |
| 277 | .get(hash(token)); | |
| 278 | if (!row || row.expires_at <= new Date().toISOString()) return "anon"; | |
| 279 | return row.role === "admin" ? "admin" : "anon"; | |
| 280 | } | |
| 281 | ||
| 282 | export function sessionToken(request: Request): string | undefined { | |
| 283 | return cookieValue(request, SESSION_COOKIE); | |
| 284 | } | |
| 285 | ||
| 286 | export function setCookie(token: string, secure: boolean): string { | |
| 287 | const attrs = [ | |
| 288 | \`\${SESSION_COOKIE}=\${token}\`, | |
| 289 | "Path=/", | |
| 290 | "HttpOnly", | |
| 291 | "SameSite=Lax", | |
| 292 | \`Max-Age=\${SESSION_TTL_MS / 1000}\`, | |
| 293 | ]; | |
| 294 | if (secure) attrs.push("Secure"); | |
| 295 | return attrs.join("; "); | |
| 296 | } | |
| 297 | ||
| 298 | export function clearCookie(secure: boolean): string { | |
| 299 | const attrs = [\`\${SESSION_COOKIE}=\`, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"]; | |
| 300 | if (secure) attrs.push("Secure"); | |
| 301 | return attrs.join("; "); | |
| 302 | } | |
| 303 | `; | |
| 304 | ||
| 143 | 305 | const server = (title: string, description: string): string => `/** |
| 144 | 306 | * ${title} — a small app with real data. |
| 145 | 307 | * |
| @@ -151,6 +313,20 @@ const server = (title: string, description: string): string => `/** | ||
| 151 | 313 | import { Database } from "bun:sqlite"; |
| 152 | 314 | import { mkdirSync } from "node:fs"; |
| 153 | 315 | import { join } from "node:path"; |
| 316 | import { | |
| 317 | claimAdmin, | |
| 318 | clearCookie, | |
| 319 | createSession, | |
| 320 | destroySession, | |
| 321 | ensureAuthSchema, | |
| 322 | isAdminClaimed, | |
| 323 | resolveAdminSecret, | |
| 324 | roleFromRequest, | |
| 325 | sessionToken, | |
| 326 | setCookie, | |
| 327 | verifyAdminSecret, | |
| 328 | type Role, | |
| 329 | } from "./auth.ts"; | |
| 154 | 330 | |
| 155 | 331 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; |
| 156 | 332 | mkdirSync(dataDir, { recursive: true }); |
| @@ -159,11 +335,20 @@ db.exec("PRAGMA journal_mode = WAL"); | ||
| 159 | 335 | db.exec( |
| 160 | 336 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", |
| 161 | 337 | ); |
| 338 | ensureAuthSchema(db); | |
| 162 | 339 | |
| 163 | 340 | const env = process.env.FORGE_APP_ENV ?? "local"; |
| 164 | 341 | const pull = process.env.FORGE_APP_PULL; |
| 165 | 342 | const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); |
| 166 | 343 | |
| 344 | // Anon is the default for everything; admin is the one role above it, for | |
| 345 | // the one thing anon should not do (delete). Printed once, here and in the | |
| 346 | // terminal (src/tty.ts) — never on a page — so whoever is at the keyboard | |
| 347 | // when the app first boots is the one who can claim it. | |
| 348 | const adminSecret = resolveAdminSecret(db); | |
| 349 | if (!adminSecret.fromEnv && !isAdminClaimed(db)) | |
| 350 | console.log(\`Admin claim code (use once, at /admin): \${adminSecret.value}\`); | |
| 351 | ||
| 167 | 352 | const escape = (s: string): string => |
| 168 | 353 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); |
| 169 | 354 | |
| @@ -246,7 +431,7 @@ async function readForm(request: Request): Promise<FormData | undefined> { | ||
| 246 | 431 | }).formData(); |
| 247 | 432 | } |
| 248 | 433 | |
| 249 | function page(): string { | |
| 434 | function page(role: Role): string { | |
| 250 | 435 | const notes = db |
| 251 | 436 | .query<{ id: number; body: string; created_at: string }, []>( |
| 252 | 437 | "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100", |
| @@ -254,6 +439,18 @@ function page(): string { | ||
| 254 | 439 | .all(); |
| 255 | 440 | const where = |
| 256 | 441 | env === "preview" ? \`preview of pull #\${pull ?? "?"}\` : env; |
| 442 | // Anon sees everything above; the one extra a session brings is a way to | |
| 443 | // delete, so admin gets a button and a way to sign out. | |
| 444 | const roleLine = | |
| 445 | role === "admin" | |
| 446 | ? \`admin · <form method="post" action="/admin/logout" class="inline"><button class="link">log out</button></form>\` | |
| 447 | : \`<a href="/admin">admin</a>\`; | |
| 448 | const noteItem = (n: { id: number; body: string; created_at: string }): string => | |
| 449 | \`<li><span>\${escape(n.body)}</span> <small>\${n.created_at}</small>\${ | |
| 450 | role === "admin" | |
| 451 | ? \` <form method="post" action="/notes/delete" class="inline"><input type="hidden" name="id" value="\${n.id}"><button class="ghost" aria-label="Delete note">×</button></form>\` | |
| 452 | : "" | |
| 453 | }</li>\`; | |
| 257 | 454 | return \`<!doctype html> |
| 258 | 455 | <meta charset="utf-8"> |
| 259 | 456 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| @@ -264,25 +461,63 @@ function page(): string { | ||
| 264 | 461 | .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem; |
| 265 | 462 | background: \${env === "production" ? "#16a34a" : "#2563eb"}; color: white; } |
| 266 | 463 | form { display: flex; gap: .5rem; margin: 1.5rem 0; } |
| 464 | form.inline { display: inline; margin: 0; } | |
| 267 | 465 | input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; } |
| 268 | 466 | button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; } |
| 269 | li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; } | |
| 467 | button.link { background: none; color: inherit; text-decoration: underline; padding: 0; font-size: .8rem; } | |
| 468 | button.ghost { background: none; color: #b91c1c; padding: 0 .4rem; font-size: 1rem; } | |
| 469 | li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; display: flex; justify-content: space-between; align-items: center; gap: .5rem; } | |
| 470 | li span { overflow-wrap: anywhere; } | |
| 270 | 471 | small { opacity: .6; } |
| 271 | 472 | </style> |
| 272 | 473 | <h1>${escape(title)}</h1> |
| 273 | 474 | <p>${escape(description || "A new app on the forge.")}</p> |
| 274 | <p><span class="env">\${escape(where)}</span> <small>\${commit ? "@ " + commit : ""}</small></p> | |
| 475 | <p><span class="env">\${escape(where)}</span> <small>\${commit ? "@ " + commit : ""} · \${roleLine}</small></p> | |
| 275 | 476 | <form method="post" action="/notes"> |
| 276 | 477 | <input name="body" placeholder="Write a note" required maxlength="500" autofocus> |
| 277 | 478 | <button>Add</button> |
| 278 | 479 | </form> |
| 279 | <ul>\${notes | |
| 280 | .map((n) => \`<li>\${escape(n.body)} <small>\${n.created_at}</small></li>\`) | |
| 281 | .join("")}</ul> | |
| 480 | <ul>\${notes.map(noteItem).join("")}</ul> | |
| 282 | 481 | <p><small>\${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : \`\${notes.length} note\${notes.length === 1 ? "" : "s"} in \${escape(where)} data.\`}</small></p> |
| 283 | 482 | \`; |
| 284 | 483 | } |
| 285 | 484 | |
| 485 | function adminPage(role: Role, error?: string): string { | |
| 486 | const claimed = isAdminClaimed(db); | |
| 487 | const body = | |
| 488 | role === "admin" | |
| 489 | ? \`<h1>Signed in as admin</h1> | |
| 490 | <p>Notes can now be deleted from the home page.</p> | |
| 491 | <form method="post" action="/admin/logout"><button>Log out</button></form>\` | |
| 492 | : \`<h1>Claim admin</h1> | |
| 493 | <p>\${ | |
| 494 | claimed | |
| 495 | ? "This app already has an admin. Ask them, or sign in from where it was first claimed." | |
| 496 | : "The operator holds a secret: ADMIN_PASSWORD from the environment, or the one-time code printed to the run log and the app's terminal when it first booted." | |
| 497 | }</p> | |
| 498 | \${error ? \`<p class="error">\${escape(error)}</p>\` : ""} | |
| 499 | <form method="post" action="/admin/claim"> | |
| 500 | <input name="secret" type="password" placeholder="Admin code" required autofocus> | |
| 501 | <button>Claim</button> | |
| 502 | </form>\`; | |
| 503 | return \`<!doctype html> | |
| 504 | <meta charset="utf-8"> | |
| 505 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 506 | <title>Admin — ${escape(title)}</title> | |
| 507 | <style> | |
| 508 | :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; } | |
| 509 | body { max-width: 30rem; margin: 3rem auto; padding: 0 1rem; } | |
| 510 | form { display: flex; gap: .5rem; margin: 1.5rem 0; } | |
| 511 | input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; } | |
| 512 | button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; } | |
| 513 | .error { color: #b91c1c; } | |
| 514 | a { color: inherit; } | |
| 515 | </style> | |
| 516 | <p><a href="/">← ${escape(title)}</a></p> | |
| 517 | \${body} | |
| 518 | \`; | |
| 519 | } | |
| 520 | ||
| 286 | 521 | export const server = Bun.serve({ |
| 287 | 522 | port: Number(process.env.PORT ?? 8080), |
| 288 | 523 | hostname: process.env.HOST ?? "127.0.0.1", |
| @@ -290,6 +525,10 @@ export const server = Bun.serve({ | ||
| 290 | 525 | const url = new URL(request.url); |
| 291 | 526 | if (request.method === "POST" && !sameOrigin(request)) |
| 292 | 527 | return new Response("forbidden", { status: 403 }); |
| 528 | // Read once per request; every route below is anon unless it says otherwise. | |
| 529 | const role = roleFromRequest(db, request); | |
| 530 | const secure = publicOrigin.startsWith("https:"); | |
| 531 | ||
| 293 | 532 | if (request.method === "POST" && url.pathname === "/notes") { |
| 294 | 533 | const form = await readForm(request); |
| 295 | 534 | if (form === undefined) |
| @@ -298,9 +537,43 @@ export const server = Bun.serve({ | ||
| 298 | 537 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body); |
| 299 | 538 | return Response.redirect("/", 303); |
| 300 | 539 | } |
| 540 | // Deleting is the one thing this app reserves for admin; everything else | |
| 541 | // above stays open to anon, by default. | |
| 542 | if (request.method === "POST" && url.pathname === "/notes/delete") { | |
| 543 | if (role !== "admin") return new Response("forbidden", { status: 403, headers: SECURITY }); | |
| 544 | const form = await readForm(request); | |
| 545 | if (form === undefined) | |
| 546 | return new Response("too large", { status: 413, headers: SECURITY }); | |
| 547 | const id = Number(form.get("id")); | |
| 548 | if (Number.isInteger(id)) db.query("DELETE FROM note WHERE id = ?1").run(id); | |
| 549 | return Response.redirect("/", 303); | |
| 550 | } | |
| 551 | if (url.pathname === "/admin" && request.method === "GET") return html(adminPage(role)); | |
| 552 | if (request.method === "POST" && url.pathname === "/admin/claim") { | |
| 553 | const form = await readForm(request); | |
| 554 | if (form === undefined) | |
| 555 | return new Response("too large", { status: 413, headers: SECURITY }); | |
| 556 | const secret = String(form.get("secret") ?? ""); | |
| 557 | if (!verifyAdminSecret(db, secret)) | |
| 558 | return html(adminPage("anon", "That code didn't match."), 403); | |
| 559 | claimAdmin(db); // no-op when the secret is ADMIN_PASSWORD, which never expires | |
| 560 | const token = createSession(db, "admin"); | |
| 561 | return new Response(null, { | |
| 562 | status: 303, | |
| 563 | headers: { location: "/admin", "set-cookie": setCookie(token, secure), ...SECURITY }, | |
| 564 | }); | |
| 565 | } | |
| 566 | if (request.method === "POST" && url.pathname === "/admin/logout") { | |
| 567 | const token = sessionToken(request); | |
| 568 | if (token) destroySession(db, token); | |
| 569 | return new Response(null, { | |
| 570 | status: 303, | |
| 571 | headers: { location: "/", "set-cookie": clearCookie(secure), ...SECURITY }, | |
| 572 | }); | |
| 573 | } | |
| 301 | 574 | // HEAD is GET without the body: a health check sees the app up. |
| 302 | 575 | if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) { |
| 303 | const full = html(page()); | |
| 576 | const full = html(page(role)); | |
| 304 | 577 | return request.method === "HEAD" |
| 305 | 578 | ? new Response(null, { status: full.status, headers: full.headers }) |
| 306 | 579 | : full; |
| @@ -339,6 +612,7 @@ const tty = (title: string): string => `/** | ||
| 339 | 612 | import { Database } from "bun:sqlite"; |
| 340 | 613 | import { mkdirSync } from "node:fs"; |
| 341 | 614 | import { join } from "node:path"; |
| 615 | import { ensureAuthSchema, isAdminClaimed, resolveAdminSecret } from "./auth.ts"; | |
| 342 | 616 | |
| 343 | 617 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; |
| 344 | 618 | mkdirSync(dataDir, { recursive: true }); |
| @@ -347,7 +621,13 @@ db.exec("PRAGMA journal_mode = WAL"); | ||
| 347 | 621 | db.exec( |
| 348 | 622 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", |
| 349 | 623 | ); |
| 624 | ensureAuthSchema(db); | |
| 350 | 625 | const where = process.env.FORGE_APP_ENV === "preview" ? \`preview of #\${process.env.FORGE_APP_PULL ?? "?"}\` : (process.env.FORGE_APP_ENV ?? "local"); |
| 626 | // Same code the server printed on boot (both read the same persisted row), | |
| 627 | // shown here too — a terminal is a valid place to have first found it. | |
| 628 | const adminSecret = resolveAdminSecret(db); | |
| 629 | const claimLine = | |
| 630 | !adminSecret.fromEnv && !isAdminClaimed(db) ? \`admin claim code: \${adminSecret.value}\` : undefined; | |
| 351 | 631 | |
| 352 | 632 | type Note = { id: number; body: string; created_at: string }; |
| 353 | 633 | const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all(); |
| @@ -361,6 +641,7 @@ function draw(): void { | ||
| 361 | 641 | const rows = notes(); |
| 362 | 642 | const lines: string[] = []; |
| 363 | 643 | lines.push(\`\${esc}1m${title}\${esc}0m \${esc}2m\${where}\${esc}0m\`); |
| 644 | if (claimLine) lines.push(\`\${esc}2m\${claimLine} (use once, at /admin)\${esc}0m\`); | |
| 364 | 645 | lines.push(""); |
| 365 | 646 | if (rows.length === 0) lines.push(\`\${esc}2mNo notes yet. Press a to add one.\${esc}0m\`); |
| 366 | 647 | rows.forEach((n, i) => { |
| @@ -412,8 +693,10 @@ import { join } from "node:path"; | ||
| 412 | 693 | |
| 413 | 694 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 414 | 695 | process.env.PORT = "0"; |
| 696 | process.env.ADMIN_PASSWORD = "test-admin-secret"; | |
| 415 | 697 | const { server } = await import("./server.ts"); |
| 416 | 698 | const base = \`http://\${server.hostname}:\${server.port}\`; |
| 699 | let adminCookie = ""; | |
| 417 | 700 | |
| 418 | 701 | test("serves the page", async () => { |
| 419 | 702 | const response = await fetch(base + "/"); |
| @@ -421,7 +704,7 @@ test("serves the page", async () => { | ||
| 421 | 704 | expect(await response.text()).toContain("<h1>"); |
| 422 | 705 | }); |
| 423 | 706 | |
| 424 | test("keeps a note", async () => { | |
| 707 | test("an anonymous visitor can read and write, with no sign-in at all", async () => { | |
| 425 | 708 | const form = new FormData(); |
| 426 | 709 | form.set("body", "hello from a test"); |
| 427 | 710 | const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" }); |
| @@ -435,6 +718,65 @@ test("stands on the security floor", async () => { | ||
| 435 | 718 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 436 | 719 | }); |
| 437 | 720 | |
| 721 | test("anon cannot delete a note", async () => { | |
| 722 | const form = new FormData(); | |
| 723 | form.set("id", "1"); | |
| 724 | const response = await fetch(base + "/notes/delete", { method: "POST", body: form, redirect: "manual" }); | |
| 725 | expect(response.status).toBe(403); | |
| 726 | }); | |
| 727 | ||
| 728 | test("the wrong secret does not claim admin", async () => { | |
| 729 | const form = new FormData(); | |
| 730 | form.set("secret", "not-it"); | |
| 731 | const response = await fetch(base + "/admin/claim", { method: "POST", body: form, redirect: "manual" }); | |
| 732 | expect(response.status).toBe(403); | |
| 733 | expect(response.headers.get("set-cookie")).toBeNull(); | |
| 734 | }); | |
| 735 | ||
| 736 | test("the right secret claims admin", async () => { | |
| 737 | const form = new FormData(); | |
| 738 | form.set("secret", "test-admin-secret"); | |
| 739 | const response = await fetch(base + "/admin/claim", { method: "POST", body: form, redirect: "manual" }); | |
| 740 | expect(response.status).toBe(303); | |
| 741 | expect(response.headers.get("location")).toBe("/admin"); | |
| 742 | const cookie = response.headers.get("set-cookie") ?? ""; | |
| 743 | expect(cookie).toContain("HttpOnly"); | |
| 744 | adminCookie = cookie.split(";")[0] ?? ""; | |
| 745 | const page = await (await fetch(base + "/", { headers: { cookie: adminCookie } })).text(); | |
| 746 | expect(page).toContain("admin"); | |
| 747 | }); | |
| 748 | ||
| 749 | test("admin can delete a note, anon still cannot", async () => { | |
| 750 | const form = new FormData(); | |
| 751 | form.set("body", "to be deleted"); | |
| 752 | await fetch(base + "/notes", { method: "POST", body: form }); | |
| 753 | const page = await (await fetch(base + "/", { headers: { cookie: adminCookie } })).text(); | |
| 754 | const id = page.match(/name="id" value="(\\d+)"/)?.[1]; | |
| 755 | expect(id).toBeDefined(); | |
| 756 | ||
| 757 | const asAnon = new FormData(); | |
| 758 | asAnon.set("id", id!); | |
| 759 | expect((await fetch(base + "/notes/delete", { method: "POST", body: asAnon })).status).toBe(403); | |
| 760 | ||
| 761 | const asAdmin = new FormData(); | |
| 762 | asAdmin.set("id", id!); | |
| 763 | const response = await fetch(base + "/notes/delete", { | |
| 764 | method: "POST", | |
| 765 | body: asAdmin, | |
| 766 | headers: { cookie: adminCookie }, | |
| 767 | redirect: "manual", | |
| 768 | }); | |
| 769 | expect(response.status).toBe(303); | |
| 770 | expect(await (await fetch(base + "/")).text()).not.toContain("to be deleted"); | |
| 771 | }); | |
| 772 | ||
| 773 | test("logging out ends the session", async () => { | |
| 774 | const response = await fetch(base + "/admin/logout", { method: "POST", headers: { cookie: adminCookie }, redirect: "manual" }); | |
| 775 | expect(response.status).toBe(303); | |
| 776 | const page = await (await fetch(base + "/admin", { headers: { cookie: adminCookie } })).text(); | |
| 777 | expect(page).toContain("Claim admin"); | |
| 778 | }); | |
| 779 | ||
| 438 | 780 | test("a bug answers with the floor, not the runtime's page", async () => { |
| 439 | 781 | // A percent-escape that is not one makes the search params throw. |
| 440 | 782 | const response = await fetch(base + "/notes", { |
test/crew.test.ts +13 −2
| @@ -542,9 +542,20 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW | ||
| 542 | 542 | runs("agent").find((r) => r.reason === "assigned" && r.state === "ok"), |
| 543 | 543 | ); |
| 544 | 544 | expect(built.summary).toMatch(/^opened pull #\d+/); |
| 545 | // The pull's own commit is checked in `pull-<number>`; the initial | |
| 546 | // commit to the default branch (no CREW.md yet) is checked too, in | |
| 547 | // `production`, and fails for its own reason (no such file) — so the | |
| 548 | // pull's check is picked out by its environment, not just "failed". | |
| 549 | const pullNumber = Number(built.summary.match(/#(\d+)/)?.[1]); | |
| 550 | const pullChecks = () => | |
| 551 | listRuns(app.db, orchard.id, { | |
| 552 | kind: "check", | |
| 553 | env: `pull-${pullNumber}`, | |
| 554 | limit: 50, | |
| 555 | }); | |
| 545 | 556 | // The forge checks the pull: no daffodils yet. |
| 546 | 557 | const failed = await until(() => |
| 547 | runs("check").find((r) => r.state === "failed"), | |
| 558 | pullChecks().find((r) => r.state === "failed"), | |
| 548 | 559 | ); |
| 549 | 560 | expect(failed.summary).toBe("check failed: check exited with code 1"); |
| 550 | 561 | // …and the agent hears about it, with the log, and answers. |
| @@ -559,7 +570,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW | ||
| 559 | 570 | expect(prompt).toContain("check exited with code 1"); |
| 560 | 571 | expect(prompt).toContain("The end of the check log:"); |
| 561 | 572 | const passed = await until(() => |
| 562 | runs("check").find((r) => r.state === "ok"), | |
| 573 | pullChecks().find((r) => r.state === "ok"), | |
| 563 | 574 | ); |
| 564 | 575 | expect(passed.summary).toMatch(/^check passed/); |
| 565 | 576 | }, 120_000); |
new above
offline