Batch 4: dynamic kata listing, narrow cookie path, drop noisy HEALTHCHECK
- Game now carries a description field; spec.ts holds the canonical one-liner. listGames() walks content/games/<id>/ and loads each kata via loadGame, so the games index, sitemap, and any future kata-aware pages stop hardcoding "string-calc". - /games index renders the kata table from listGames() instead of a hand-written row. Adding a kata is now: drop a folder under content/games/, restart. - /sitemap.xml emits a <url> entry per loaded kata. - OAuth state cookie scope tightened from Path=/ to Path=/auth — same HttpOnly/Secure/SameSite=Lax/Max-Age=600. The cookie was never read outside /auth/github/callback anyway. - Containerfile drops HEALTHCHECK. OCI builds were rejecting it with warnings on every build, and we already probe /healthz from cloudflared upstream + the deploy script. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
4 files changed · +45 −10
Containerfile
+3
−3
| @@ -21,8 +21,8 @@ COPY public ./public | ||
| 21 | 21 | ENV PORT=3000 |
| 22 | 22 | EXPOSE 3000 |
| 23 | 23 | |
| 24 | -# Healthcheck via /healthz (Bun's HTTP server start <100ms). | |
| 25 | -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ | |
| 26 | - CMD wget -q -O- http://localhost:3000/healthz || exit 1 | |
| 24 | +# No HEALTHCHECK — podman's OCI builds drop it with a warning, and our | |
| 25 | +# external probes (cloudflared upstream + the deploy script's /healthz | |
| 26 | +# poll) already cover liveness. | |
| 27 | 27 | |
| 28 | 28 | CMD ["bun", "src/server.ts"] |
content/games/string-calc/spec.ts
+1
−0
| @@ -2,6 +2,7 @@ import type { Game } from "../../../src/games"; | ||
| 2 | 2 | |
| 3 | 3 | export const spec: Game = { |
| 4 | 4 | id: "string-calc", |
| 5 | + description: "Add comma-separated numbers, one rule at a time. Seven steps.", | |
| 5 | 6 | signature: "add(numbers: string): number", |
| 6 | 7 | importPath: "./add", |
| 7 | 8 | steps: [ |
src/games.ts
+26
−0
| @@ -10,6 +10,8 @@ export interface Step { | ||
| 10 | 10 | |
| 11 | 11 | export interface Game { |
| 12 | 12 | id: string; |
| 13 | + // One-line summary shown on the games index and OG previews. | |
| 14 | + description: string; | |
| 13 | 15 | // Human-readable function signature the agent must export. Documented |
| 14 | 16 | // on the kata page so authors know what to build. |
| 15 | 17 | signature: string; |
| @@ -19,6 +21,30 @@ export interface Game { | ||
| 19 | 21 | steps: Step[]; |
| 20 | 22 | } |
| 21 | 23 | |
| 24 | +import { readdir } from "node:fs/promises"; | |
| 25 | + | |
| 26 | +// Reads every kata under content/games/ and returns the loaded specs in | |
| 27 | +// alphabetical order. Used to build the games index and sitemap without | |
| 28 | +// hard-coding individual kata ids. | |
| 29 | +export async function listGames(): Promise<Game[]> { | |
| 30 | + let entries; | |
| 31 | + try { | |
| 32 | + entries = await readdir("./content/games", { withFileTypes: true }); | |
| 33 | + } catch { | |
| 34 | + return []; | |
| 35 | + } | |
| 36 | + const ids = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(); | |
| 37 | + const games: Game[] = []; | |
| 38 | + for (const id of ids) { | |
| 39 | + try { | |
| 40 | + games.push(await loadGame(id)); | |
| 41 | + } catch { | |
| 42 | + // skip katas that fail to load (missing spec.ts, etc.) | |
| 43 | + } | |
| 44 | + } | |
| 45 | + return games; | |
| 46 | +} | |
| 47 | + | |
| 22 | 48 | export async function loadGame(id: string): Promise<Game> { |
| 23 | 49 | const file = Bun.file(`./content/games/${id}/spec.ts`); |
| 24 | 50 | if (!(await file.exists())) { |
src/server.ts
+15
−7
| @@ -2,7 +2,7 @@ import { renderPage, renderNotFound } from "./render"; | ||
| 2 | 2 | import * as github from "./github_oauth"; |
| 3 | 3 | import * as forgejo from "./forgejo"; |
| 4 | 4 | import { parseCommit, computeProgress, type Phase } from "./commits"; |
| 5 | -import { loadGame } from "./games"; | |
| 5 | +import { loadGame, listGames } from "./games"; | |
| 6 | 6 | import { judge } from "./judge"; |
| 7 | 7 | import { latestRun, allLatestRuns } from "./db"; |
| 8 | 8 | |
| @@ -26,11 +26,16 @@ const HOME_HTML = await renderPage({ | ||
| 26 | 26 | }, |
| 27 | 27 | }); |
| 28 | 28 | |
| 29 | +const ALL_GAMES = await listGames(); | |
| 30 | + | |
| 29 | 31 | const gamesIndexBody = `# games |
| 30 | 32 | |
| 31 | -| kata | description | language | | |
| 32 | -|---|---|---| | |
| 33 | -| [string-calc](/games/string-calc) | Add comma-separated numbers, one rule at a time. Seven steps. | TypeScript | | |
| 33 | +${ALL_GAMES.length === 0 | |
| 34 | + ? "_No katas registered yet._" | |
| 35 | + : `| kata | description | steps |\n|---|---|---|\n${ALL_GAMES.map( | |
| 36 | + (g) => `| [${g.id}](/games/${g.id}) | ${g.description} | ${g.steps.length} |`, | |
| 37 | + ).join("\n")}` | |
| 38 | +} | |
| 34 | 39 | |
| 35 | 40 | > Ready to play? [Register your agent →](/agents/register) |
| 36 | 41 | `; |
| @@ -472,11 +477,14 @@ const server = Bun.serve({ | ||
| 472 | 477 | const today = new Date().toISOString().slice(0, 10); |
| 473 | 478 | const url = (loc: string, priority: string) => |
| 474 | 479 | `<url><loc>${loc}</loc><lastmod>${today}</lastmod><priority>${priority}</priority></url>`; |
| 480 | + const kataUrls = ALL_GAMES.map((g) => | |
| 481 | + url(`https://tdd.md/games/${g.id}`, "0.8"), | |
| 482 | + ).join("\n"); | |
| 475 | 483 | const xml = `<?xml version="1.0" encoding="UTF-8"?> |
| 476 | 484 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> |
| 477 | 485 | ${url("https://tdd.md/", "1.0")} |
| 478 | 486 | ${url("https://tdd.md/games", "0.9")} |
| 479 | -${url("https://tdd.md/games/string-calc", "0.8")} | |
| 487 | +${kataUrls} | |
| 480 | 488 | ${url("https://tdd.md/agents", "0.7")} |
| 481 | 489 | ${url("https://tdd.md/leaderboard", "0.7")} |
| 482 | 490 | </urlset>`; |
| @@ -625,7 +633,7 @@ ${url("https://tdd.md/leaderboard", "0.7")} | ||
| 625 | 633 | status: 302, |
| 626 | 634 | headers: { |
| 627 | 635 | Location: github.authorizeUrl(nonce, CALLBACK_URL), |
| 628 | - "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, | |
| 636 | + "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, | |
| 629 | 637 | }, |
| 630 | 638 | }); |
| 631 | 639 | }, |
| @@ -712,7 +720,7 @@ When you push, the judge replays your commits and posts the verdict at [/agents/ | ||
| 712 | 720 | return new Response(html, { |
| 713 | 721 | headers: { |
| 714 | 722 | "Content-Type": "text/html; charset=utf-8", |
| 715 | - "Set-Cookie": "tdd_oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0", | |
| 723 | + "Set-Cookie": "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0", | |
| 716 | 724 | }, |
| 717 | 725 | }); |
| 718 | 726 | }, |