/** * 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 { addNote, BODY_MAX, deleteNote, getAudit, getNote, listAudit, listNotes, updateNote, 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 { 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 NOTE: Schema = { type: "object", properties: { id: { type: "integer" }, body: { type: "string", minLength: 1, maxLength: BODY_MAX }, author_id: { type: "string" }, author_name: { type: "string" }, created_at: { type: "string", format: "date-time" }, updated_at: { type: "string", format: "date-time" }, }, required: [ "id", "body", "author_id", "author_name", "created_at", "updated_at", ], }; const NOTE_BODY: Schema = { type: "object", properties: { body: { type: "string", minLength: 1, maxLength: BODY_MAX } }, required: ["body"], }; 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, }; /** 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 list stays live over /events. { method: "GET", path: "/", handle: (c) => { const { notice, clear } = readNotice(c.request); const notes = listNotes(c.db, c.who); return html( home({ who: c.who, notes: notes.ok ? notes.value : [], notice }), 200, clear, ); }, }, { method: "POST", path: "/notes", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); return done( addNote(c.db, c.who, { body: String(form.get("body") ?? "") }), ); }, }, { method: "POST", path: "/notes/:id/delete", handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })), }, // The JSON API: the same actions, the same identity, plain status codes. { method: "GET", path: "/api/notes", handle: (c) => answer( listNotes(c.db, c.who, { limit: Number(c.url.searchParams.get("limit") ?? 100), }), ), api: { summary: "The notes, newest first.", query: { limit: LIMIT }, answer: list(NOTE), }, }, { method: "POST", path: "/api/notes", handle: async (c) => { const body = await readJson(c.request); if (body === undefined) return json({ error: "too_large" }, 413); return answer( addNote(c.db, c.who, { body: String(fields(body).body ?? "") }), 201, ); }, api: { summary: "Add a note as the person asking.", body: NOTE_BODY, answer: NOTE, status: 201, }, }, { method: "GET", path: "/api/notes/:id", handle: (c) => answer(getNote(c.db, c.who, { id: Number(c.params.id) })), api: { summary: "One note.", answer: NOTE }, }, { method: "PATCH", path: "/api/notes/:id", handle: async (c) => { const body = await readJson(c.request); if (body === undefined) return json({ error: "too_large" }, 413); return answer( updateNote(c.db, c.who, { id: Number(c.params.id), body: String(fields(body).body ?? ""), }), ); }, api: { summary: "Change a note's words.", body: NOTE_BODY, answer: NOTE }, }, { method: "DELETE", path: "/api/notes/:id", handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })), api: { summary: "Remove a note.", 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; }