/** * Every path the app answers, in one table. A route reads its request, * calls an action, and shapes the answer for its door — HTML for a * browser, JSON for a script — and that is all a route does. * * A route that is part of the API says so beside itself (`api`): one * sentence, and the shape of what it takes and gives, as JSON Schema. * `bun run contract:emit` writes those into `contract/openapi.json`, the * tests fail when the committed file and this table disagree, and * `GET /openapi.json` serves it. The shapes describe; the checks that hold * are still the actions' own. */ import type { Database } from "bun:sqlite"; import { existsSync, readFileSync } from "node:fs"; import { deleteDrawing, getDrawing, getAudit, listAudit, listDrawings, startDrawing, svgsOf, type Drawing, type Failure, type Result, } from "./actions.ts"; import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; import { back, fields, html, json, readForm, readJson, readNotice, SECURITY, staticFile, text, } from "./http.ts"; import { inPalette, isPalette, KINDS, nameOf, PROMPT_MAX } from "./glyph.ts"; import { events } from "./live.ts"; import { rpc } from "./mcp.ts"; import { home } from "./page.ts"; import { tokensCss } from "./tokens.ts"; import type { Who } from "./who.ts"; export interface Context { readonly request: Request; readonly url: URL; readonly db: Database; readonly who: Who; readonly params: Record; } /** A JSON Schema, as plain data. */ export type Schema = Readonly>; /** What an API route is, for the description: never consulted to decide anything. */ export interface Api { readonly summary: string; /** The body it reads. */ readonly body?: Schema; /** What it answers when it succeeds, and with which status (200). */ readonly answer: Schema; readonly status?: number; /** The query parameters it reads. */ readonly query?: Readonly>; } export interface Route { readonly method: "GET" | "POST" | "PATCH" | "DELETE"; readonly path: string; readonly handle: (ctx: Context) => Response | Promise; readonly api?: Api; } // The shapes the API speaks in, named once. export const DRAWING: Schema = { type: "object", properties: { id: { type: "integer" }, prompt: { type: "string", minLength: 1, maxLength: PROMPT_MAX }, kind: { enum: Object.keys(KINDS) }, status: { enum: ["drawing", "done", "failed"] }, svgs: { type: "array", items: { type: "string" }, description: "The drawings, as SVG, in the placeholder ink and glow colours.", }, error: { type: ["string", "null"] }, author_id: { type: "string" }, author_name: { type: "string" }, created_at: { type: "string", format: "date-time" }, updated_at: { type: "string", format: "date-time" }, }, required: ["id", "prompt", "kind", "status", "svgs", "author_id", "author_name", "created_at", "updated_at"], }; const DRAW_BODY: Schema = { type: "object", properties: { prompt: { type: "string", minLength: 1, maxLength: PROMPT_MAX }, kind: { enum: Object.keys(KINDS), default: "mark" }, }, required: ["prompt"], }; export const AUDIT_ROW: Schema = { type: "object", properties: { id: { type: "integer" }, at: { type: "string", format: "date-time" }, who_id: { type: ["string", "null"] }, who_name: { type: ["string", "null"] }, role: { enum: ["anon", "user", "admin"] }, resource: { type: "string" }, verb: { type: "string" }, target: { type: ["string", "null"] }, allowed: { enum: [0, 1] }, outcome: { type: "string" }, }, required: ["id", "at", "role", "resource", "verb", "allowed", "outcome"], }; export const ERROR: Schema = { type: "object", properties: { error: { type: "string" }, message: { type: "string" } }, required: ["error"], }; const LIMIT: Schema = { type: "integer", minimum: 1, maximum: 500, default: 100, }; const list = (items: Schema): Schema => ({ type: "array", items }); const STATUS: Record = { signin_required: 401, forbidden: 403, not_found: 404, invalid: 422, busy: 429, }; /** A drawing as the API speaks it: its SVGs as an array, not the column's JSON. */ const shaped = (d: Drawing) => ({ ...d, svgs: svgsOf(d) }); const shapedResult = (r: Result): Result => r.ok ? { ok: true, value: Array.isArray(r.value) ? r.value.map(shaped) : shaped(r.value as Drawing) } : r; /** A result as JSON: the value, or `{ error, message }` with the matching status. */ const answer = (result: Result, status = 200): Response => result.ok ? json(result.value, status) : json( { error: result.error, message: result.message }, STATUS[result.error], ); /** A result for a form: back to the page, with what went wrong if it did. */ const done = (result: Result): Response => result.ok ? back() : back(result.message); /** A whole number from the query, or undefined. */ const whole = (value: string | null): number | undefined => value !== null && /^\d+$/.test(value) ? Number(value) : undefined; // The API's description, read from git once: what the tests hold equal to this table. const OPENAPI = new URL("../contract/openapi.json", import.meta.url); const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined; export const routes: readonly Route[] = [ // The page. Forms post and come back; the board stays live over /events. { method: "GET", path: "/", handle: (c) => { const { notice, clear } = readNotice(c.request); const drawings = listDrawings(c.db, c.who); return html( home({ who: c.who, drawings: drawings.ok ? drawings.value : [], palette: c.url.searchParams.get("palette"), notice, }), 200, clear, ); }, }, { method: "POST", path: "/drawings", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); return done( startDrawing(c.db, c.who, { prompt: String(form.get("prompt") ?? ""), kind: String(form.get("kind") ?? ""), }), ); }, }, { method: "POST", path: "/drawings/:id/delete", handle: (c) => done(deleteDrawing(c.db, c.who, { id: Number(c.params.id) })), }, // One SVG to save, in a palette: a file with a name, never rendered here. { method: "GET", path: "/drawings/:id/:n", handle: (c) => { const found = getDrawing(c.db, c.who, { id: Number(c.params.id) }); if (!found.ok) return answer(found); const n = Number(c.params.n?.replace(/\.svg$/, "")); const svg = Number.isInteger(n) ? svgsOf(found.value)[n] : undefined; if (svg === undefined) return text("not found", 404); const palette = c.url.searchParams.get("palette"); const body = isPalette(palette) ? inPalette(svg, palette) : svg; const file = `${nameOf(svg).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "glyph"}.svg`; return new Response(body, { headers: { "content-type": "image/svg+xml; charset=utf-8", "content-disposition": `attachment; filename="${file}"`, "cache-control": "private, no-store", ...SECURITY, // A saved file is opened on its own one day: it may run nothing. "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", }, }); }, }, // The JSON API: the same actions, the same identity, plain status codes. { method: "GET", path: "/api/drawings", handle: (c) => answer( shapedResult( listDrawings(c.db, c.who, { limit: Number(c.url.searchParams.get("limit") ?? 50) }), ), ), api: { summary: "Your drawings, newest first (an admin sees everyone's).", query: { limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }, answer: list(DRAWING), }, }, { method: "POST", path: "/api/drawings", handle: async (c) => { const body = await readJson(c.request); if (body === undefined) return json({ error: "too_large" }, 413); const f = fields(body); return answer( shapedResult( startDrawing(c.db, c.who, { prompt: String(f.prompt ?? ""), ...(f.kind === undefined ? {} : { kind: String(f.kind) }), }), ), 202, ); }, api: { summary: "Ask Claude for a drawing. Answers at once with status `drawing`; read it again (or listen on /events for `drawing.changed`) until it is `done` or `failed`. One at a time per person.", body: DRAW_BODY, answer: DRAWING, status: 202, }, }, { method: "GET", path: "/api/drawings/:id", handle: (c) => answer(shapedResult(getDrawing(c.db, c.who, { id: Number(c.params.id) }))), api: { summary: "One drawing.", answer: DRAWING }, }, { method: "DELETE", path: "/api/drawings/:id", handle: (c) => answer(deleteDrawing(c.db, c.who, { id: Number(c.params.id) })), api: { summary: "Remove a drawing.", answer: { type: "object", properties: { id: { type: "integer" } }, required: ["id"], }, }, }, // Who tried to change what: the audit trail, for whoever the table lets read it. { method: "GET", path: "/api/audit", handle: (c) => answer( listAudit(c.db, c.who, { limit: whole(c.url.searchParams.get("limit")) ?? 100, ...(whole(c.url.searchParams.get("before")) === undefined ? {} : { before: whole(c.url.searchParams.get("before"))! }), }), ), api: { summary: "The audit trail, newest first; `before` pages back by id.", query: { limit: LIMIT, before: { type: "integer" } }, answer: list(AUDIT_ROW), }, }, { method: "GET", path: "/api/audit/:id", handle: (c) => answer(getAudit(c.db, c.who, { id: Number(c.params.id) })), api: { summary: "One row of the audit trail.", answer: AUDIT_ROW }, }, // Live events, and the MCP server (the tools are the actions too). { method: "GET", path: "/events", handle: (c) => events(c.request) }, { method: "POST", path: "/mcp", handle: async (c) => { const message = await readJson(c.request); if (message === undefined) return json({ error: "too_large" }, 413); if (message === null) return json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" }, }); const { status, body } = rpc(c.db, c.who, message); return body === undefined ? new Response(null, { status }) : json(body, status); }, api: { summary: "One JSON-RPC 2.0 message to the MCP server; its tools are the actions.", body: { type: "object", properties: { jsonrpc: { const: "2.0" }, id: {}, method: { type: "string" }, params: { type: "object" }, }, required: ["jsonrpc", "method"], }, answer: { type: "object" }, }, }, { method: "GET", path: "/mcp", handle: () => text("this endpoint speaks JSON-RPC over POST", 405), }, // Up or not, for the forge and for anyone watching: a separate path from // the page, so a health check never renders it. { method: "GET", path: "/healthz", handle: (c) => { let ok = true; try { c.db.query("SELECT 1").get(); } catch { ok = false; } const uptime = Math.floor((Date.now() - startedAt) / 1000); return new Response( JSON.stringify({ ok, version, commit, env, uptime }), { status: ok ? 200 : 503, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...SECURITY, }, }, ); }, api: { summary: "Whether the app is up: its version, commit, environment and seconds since it started.", answer: { type: "object", properties: { ok: { type: "boolean" }, version: { type: "string" }, commit: { type: "string" }, env: { type: "string" }, uptime: { type: "integer" }, }, required: ["ok", "version", "uptime"], }, }, }, { method: "GET", path: "/openapi.json", handle: () => openapi === undefined ? text("not found", 404) : new Response(openapi, { headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-cache", ...SECURITY, }, }), }, // The look: design/tokens.json, compiled at boot, cached for a year when linked by content. { method: "GET", path: "/tokens.css", handle: (c) => new Response(tokensCss, { headers: { "content-type": "text/css; charset=utf-8", "cache-control": c.url.searchParams.has("v") ? "public, max-age=31536000, immutable" : "no-cache", ...SECURITY, }, }), }, // What the web expects of a site. Crawlers read the page, not the API; // a preview is a draft and is read by nobody. Where to report a // vulnerability is the forge's to say: it runs this app. { method: "GET", path: "/robots.txt", handle: () => text( env === "production" ? "User-agent: *\nDisallow: /api/\nDisallow: /mcp\nDisallow: /events\nDisallow: /__forge/\n" : "User-agent: *\nDisallow: /\n", ), }, { method: "GET", path: "/.well-known/security.txt", handle: () => forgeUrl === undefined ? text("not found", 404) : new Response(null, { status: 302, headers: { location: new URL( "/.well-known/security.txt", forgeUrl, ).toString(), ...SECURITY, }, }), }, // Files under ./static, cached for a year when linked by content (`asset`). { method: "GET", path: "/static/:file", handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")), }, ]; /** The route for a method and path, with what its `:names` matched. */ export function match( method: string, pathname: string, ): { route: Route; params: Record } | undefined { const parts = pathname.split("/"); for (const route of routes) { if (route.method !== method) continue; const pattern = route.path.split("/"); if (pattern.length !== parts.length) continue; const params: Record = {}; const hit = pattern.every((p, i) => { if (p.startsWith(":")) { // A malformed escape is no match, never a thrown error. try { params[p.slice(1)] = decodeURIComponent(parts[i] ?? ""); return true; } catch { return false; } } return p === parts[i]; }); if (hit) return { route, params }; } return undefined; }