wren/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files acd87b6b6146da11e0c9272c92a02a8e707adf4a
| 1 | /** |
| 2 | * Every path the app answers, in one table. A route reads its request, |
| 3 | * calls an action, and shapes the answer for its door — HTML for a |
| 4 | * browser, JSON for a script — and that is all a route does. |
| 5 | * |
| 6 | * A route that is part of the API says so beside itself (`api`): one |
| 7 | * sentence, and the shape of what it takes and gives, as JSON Schema. |
| 8 | * `bun run contract:emit` writes those into `contract/openapi.json`, the |
| 9 | * tests fail when the committed file and this table disagree, and |
| 10 | * `GET /openapi.json` serves it. The shapes describe; the checks that hold |
| 11 | * are still the actions' own. |
| 12 | */ |
| 13 | |
| 14 | import type { Database } from "bun:sqlite"; |
| 15 | import { existsSync, readFileSync } from "node:fs"; |
| 16 | import { |
| 17 | addNote, |
| 18 | BODY_MAX, |
| 19 | deleteNote, |
| 20 | getAudit, |
| 21 | getNote, |
| 22 | listAudit, |
| 23 | listNotes, |
| 24 | updateNote, |
| 25 | type Failure, |
| 26 | type Result, |
| 27 | } from "./actions.ts"; |
| 28 | import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; |
| 29 | import { |
| 30 | back, |
| 31 | fields, |
| 32 | html, |
| 33 | json, |
| 34 | readForm, |
| 35 | readJson, |
| 36 | readNotice, |
| 37 | SECURITY, |
| 38 | staticFile, |
| 39 | text, |
| 40 | } from "./http.ts"; |
| 41 | import { events } from "./live.ts"; |
| 42 | import { rpc } from "./mcp.ts"; |
| 43 | import { home } from "./page.ts"; |
| 44 | import { tokensCss } from "./tokens.ts"; |
| 45 | import type { Who } from "./who.ts"; |
| 46 | |
| 47 | export interface Context { |
| 48 | readonly request: Request; |
| 49 | readonly url: URL; |
| 50 | readonly db: Database; |
| 51 | readonly who: Who; |
| 52 | readonly params: Record<string, string>; |
| 53 | } |
| 54 | |
| 55 | /** A JSON Schema, as plain data. */ |
| 56 | export type Schema = Readonly<Record<string, unknown>>; |
| 57 | |
| 58 | /** What an API route is, for the description: never consulted to decide anything. */ |
| 59 | export interface Api { |
| 60 | readonly summary: string; |
| 61 | /** The body it reads. */ |
| 62 | readonly body?: Schema; |
| 63 | /** What it answers when it succeeds, and with which status (200). */ |
| 64 | readonly answer: Schema; |
| 65 | readonly status?: number; |
| 66 | /** The query parameters it reads. */ |
| 67 | readonly query?: Readonly<Record<string, Schema>>; |
| 68 | } |
| 69 | |
| 70 | export interface Route { |
| 71 | readonly method: "GET" | "POST" | "PATCH" | "DELETE"; |
| 72 | readonly path: string; |
| 73 | readonly handle: (ctx: Context) => Response | Promise<Response>; |
| 74 | readonly api?: Api; |
| 75 | } |
| 76 | |
| 77 | // The shapes the API speaks in, named once. |
| 78 | export const NOTE: Schema = { |
| 79 | type: "object", |
| 80 | properties: { |
| 81 | id: { type: "integer" }, |
| 82 | body: { type: "string", minLength: 1, maxLength: BODY_MAX }, |
| 83 | author_id: { type: "string" }, |
| 84 | author_name: { type: "string" }, |
| 85 | created_at: { type: "string", format: "date-time" }, |
| 86 | updated_at: { type: "string", format: "date-time" }, |
| 87 | }, |
| 88 | required: [ |
| 89 | "id", |
| 90 | "body", |
| 91 | "author_id", |
| 92 | "author_name", |
| 93 | "created_at", |
| 94 | "updated_at", |
| 95 | ], |
| 96 | }; |
| 97 | const NOTE_BODY: Schema = { |
| 98 | type: "object", |
| 99 | properties: { body: { type: "string", minLength: 1, maxLength: BODY_MAX } }, |
| 100 | required: ["body"], |
| 101 | }; |
| 102 | export const AUDIT_ROW: Schema = { |
| 103 | type: "object", |
| 104 | properties: { |
| 105 | id: { type: "integer" }, |
| 106 | at: { type: "string", format: "date-time" }, |
| 107 | who_id: { type: ["string", "null"] }, |
| 108 | who_name: { type: ["string", "null"] }, |
| 109 | role: { enum: ["anon", "user", "admin"] }, |
| 110 | resource: { type: "string" }, |
| 111 | verb: { type: "string" }, |
| 112 | target: { type: ["string", "null"] }, |
| 113 | allowed: { enum: [0, 1] }, |
| 114 | outcome: { type: "string" }, |
| 115 | }, |
| 116 | required: ["id", "at", "role", "resource", "verb", "allowed", "outcome"], |
| 117 | }; |
| 118 | export const ERROR: Schema = { |
| 119 | type: "object", |
| 120 | properties: { error: { type: "string" }, message: { type: "string" } }, |
| 121 | required: ["error"], |
| 122 | }; |
| 123 | const LIMIT: Schema = { |
| 124 | type: "integer", |
| 125 | minimum: 1, |
| 126 | maximum: 500, |
| 127 | default: 100, |
| 128 | }; |
| 129 | const list = (items: Schema): Schema => ({ type: "array", items }); |
| 130 | |
| 131 | const STATUS: Record<Failure, number> = { |
| 132 | signin_required: 401, |
| 133 | forbidden: 403, |
| 134 | not_found: 404, |
| 135 | invalid: 422, |
| 136 | }; |
| 137 | |
| 138 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ |
| 139 | const answer = <T>(result: Result<T>, status = 200): Response => |
| 140 | result.ok |
| 141 | ? json(result.value, status) |
| 142 | : json( |
| 143 | { error: result.error, message: result.message }, |
| 144 | STATUS[result.error], |
| 145 | ); |
| 146 | |
| 147 | /** A result for a form: back to the page, with what went wrong if it did. */ |
| 148 | const done = <T>(result: Result<T>): Response => |
| 149 | result.ok ? back() : back(result.message); |
| 150 | |
| 151 | /** A whole number from the query, or undefined. */ |
| 152 | const whole = (value: string | null): number | undefined => |
| 153 | value !== null && /^\d+$/.test(value) ? Number(value) : undefined; |
| 154 | |
| 155 | // The API's description, read from git once: what the tests hold equal to this table. |
| 156 | const OPENAPI = new URL("../contract/openapi.json", import.meta.url); |
| 157 | const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined; |
| 158 | |
| 159 | export const routes: readonly Route[] = [ |
| 160 | // The page. Forms post and come back; the list stays live over /events. |
| 161 | { |
| 162 | method: "GET", |
| 163 | path: "/", |
| 164 | handle: (c) => { |
| 165 | const { notice, clear } = readNotice(c.request); |
| 166 | const notes = listNotes(c.db, c.who); |
| 167 | return html( |
| 168 | home({ who: c.who, notes: notes.ok ? notes.value : [], notice }), |
| 169 | 200, |
| 170 | clear, |
| 171 | ); |
| 172 | }, |
| 173 | }, |
| 174 | { |
| 175 | method: "POST", |
| 176 | path: "/notes", |
| 177 | handle: async (c) => { |
| 178 | const form = await readForm(c.request); |
| 179 | if (form === undefined) return text("too large", 413); |
| 180 | return done( |
| 181 | addNote(c.db, c.who, { body: String(form.get("body") ?? "") }), |
| 182 | ); |
| 183 | }, |
| 184 | }, |
| 185 | { |
| 186 | method: "POST", |
| 187 | path: "/notes/:id/delete", |
| 188 | handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })), |
| 189 | }, |
| 190 | |
| 191 | // The JSON API: the same actions, the same identity, plain status codes. |
| 192 | { |
| 193 | method: "GET", |
| 194 | path: "/api/notes", |
| 195 | handle: (c) => |
| 196 | answer( |
| 197 | listNotes(c.db, c.who, { |
| 198 | limit: Number(c.url.searchParams.get("limit") ?? 100), |
| 199 | }), |
| 200 | ), |
| 201 | api: { |
| 202 | summary: "The notes, newest first.", |
| 203 | query: { limit: LIMIT }, |
| 204 | answer: list(NOTE), |
| 205 | }, |
| 206 | }, |
| 207 | { |
| 208 | method: "POST", |
| 209 | path: "/api/notes", |
| 210 | handle: async (c) => { |
| 211 | const body = await readJson(c.request); |
| 212 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 213 | return answer( |
| 214 | addNote(c.db, c.who, { body: String(fields(body).body ?? "") }), |
| 215 | 201, |
| 216 | ); |
| 217 | }, |
| 218 | api: { |
| 219 | summary: "Add a note as the person asking.", |
| 220 | body: NOTE_BODY, |
| 221 | answer: NOTE, |
| 222 | status: 201, |
| 223 | }, |
| 224 | }, |
| 225 | { |
| 226 | method: "GET", |
| 227 | path: "/api/notes/:id", |
| 228 | handle: (c) => answer(getNote(c.db, c.who, { id: Number(c.params.id) })), |
| 229 | api: { summary: "One note.", answer: NOTE }, |
| 230 | }, |
| 231 | { |
| 232 | method: "PATCH", |
| 233 | path: "/api/notes/:id", |
| 234 | handle: async (c) => { |
| 235 | const body = await readJson(c.request); |
| 236 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 237 | return answer( |
| 238 | updateNote(c.db, c.who, { |
| 239 | id: Number(c.params.id), |
| 240 | body: String(fields(body).body ?? ""), |
| 241 | }), |
| 242 | ); |
| 243 | }, |
| 244 | api: { summary: "Change a note's words.", body: NOTE_BODY, answer: NOTE }, |
| 245 | }, |
| 246 | { |
| 247 | method: "DELETE", |
| 248 | path: "/api/notes/:id", |
| 249 | handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })), |
| 250 | api: { |
| 251 | summary: "Remove a note.", |
| 252 | answer: { |
| 253 | type: "object", |
| 254 | properties: { id: { type: "integer" } }, |
| 255 | required: ["id"], |
| 256 | }, |
| 257 | }, |
| 258 | }, |
| 259 | |
| 260 | // Who tried to change what: the audit trail, for whoever the table lets read it. |
| 261 | { |
| 262 | method: "GET", |
| 263 | path: "/api/audit", |
| 264 | handle: (c) => |
| 265 | answer( |
| 266 | listAudit(c.db, c.who, { |
| 267 | limit: whole(c.url.searchParams.get("limit")) ?? 100, |
| 268 | ...(whole(c.url.searchParams.get("before")) === undefined |
| 269 | ? {} |
| 270 | : { before: whole(c.url.searchParams.get("before"))! }), |
| 271 | }), |
| 272 | ), |
| 273 | api: { |
| 274 | summary: "The audit trail, newest first; `before` pages back by id.", |
| 275 | query: { limit: LIMIT, before: { type: "integer" } }, |
| 276 | answer: list(AUDIT_ROW), |
| 277 | }, |
| 278 | }, |
| 279 | { |
| 280 | method: "GET", |
| 281 | path: "/api/audit/:id", |
| 282 | handle: (c) => answer(getAudit(c.db, c.who, { id: Number(c.params.id) })), |
| 283 | api: { summary: "One row of the audit trail.", answer: AUDIT_ROW }, |
| 284 | }, |
| 285 | |
| 286 | // Live events, and the MCP server (the tools are the actions too). |
| 287 | { method: "GET", path: "/events", handle: (c) => events(c.request) }, |
| 288 | { |
| 289 | method: "POST", |
| 290 | path: "/mcp", |
| 291 | handle: async (c) => { |
| 292 | const message = await readJson(c.request); |
| 293 | if (message === undefined) return json({ error: "too_large" }, 413); |
| 294 | if (message === null) |
| 295 | return json({ |
| 296 | jsonrpc: "2.0", |
| 297 | id: null, |
| 298 | error: { code: -32700, message: "parse error" }, |
| 299 | }); |
| 300 | const { status, body } = rpc(c.db, c.who, message); |
| 301 | return body === undefined |
| 302 | ? new Response(null, { status }) |
| 303 | : json(body, status); |
| 304 | }, |
| 305 | api: { |
| 306 | summary: |
| 307 | "One JSON-RPC 2.0 message to the MCP server; its tools are the actions.", |
| 308 | body: { |
| 309 | type: "object", |
| 310 | properties: { |
| 311 | jsonrpc: { const: "2.0" }, |
| 312 | id: {}, |
| 313 | method: { type: "string" }, |
| 314 | params: { type: "object" }, |
| 315 | }, |
| 316 | required: ["jsonrpc", "method"], |
| 317 | }, |
| 318 | answer: { type: "object" }, |
| 319 | }, |
| 320 | }, |
| 321 | { |
| 322 | method: "GET", |
| 323 | path: "/mcp", |
| 324 | handle: () => text("this endpoint speaks JSON-RPC over POST", 405), |
| 325 | }, |
| 326 | |
| 327 | // Up or not, for the forge and for anyone watching: a separate path from |
| 328 | // the page, so a health check never renders it. |
| 329 | { |
| 330 | method: "GET", |
| 331 | path: "/healthz", |
| 332 | handle: (c) => { |
| 333 | let ok = true; |
| 334 | try { |
| 335 | c.db.query("SELECT 1").get(); |
| 336 | } catch { |
| 337 | ok = false; |
| 338 | } |
| 339 | const uptime = Math.floor((Date.now() - startedAt) / 1000); |
| 340 | return new Response( |
| 341 | JSON.stringify({ ok, version, commit, env, uptime }), |
| 342 | { |
| 343 | status: ok ? 200 : 503, |
| 344 | headers: { |
| 345 | "content-type": "application/json; charset=utf-8", |
| 346 | "cache-control": "no-store", |
| 347 | ...SECURITY, |
| 348 | }, |
| 349 | }, |
| 350 | ); |
| 351 | }, |
| 352 | api: { |
| 353 | summary: |
| 354 | "Whether the app is up: its version, commit, environment and seconds since it started.", |
| 355 | answer: { |
| 356 | type: "object", |
| 357 | properties: { |
| 358 | ok: { type: "boolean" }, |
| 359 | version: { type: "string" }, |
| 360 | commit: { type: "string" }, |
| 361 | env: { type: "string" }, |
| 362 | uptime: { type: "integer" }, |
| 363 | }, |
| 364 | required: ["ok", "version", "uptime"], |
| 365 | }, |
| 366 | }, |
| 367 | }, |
| 368 | { |
| 369 | method: "GET", |
| 370 | path: "/openapi.json", |
| 371 | handle: () => |
| 372 | openapi === undefined |
| 373 | ? text("not found", 404) |
| 374 | : new Response(openapi, { |
| 375 | headers: { |
| 376 | "content-type": "application/json; charset=utf-8", |
| 377 | "cache-control": "no-cache", |
| 378 | ...SECURITY, |
| 379 | }, |
| 380 | }), |
| 381 | }, |
| 382 | |
| 383 | // The look: design/tokens.json, compiled at boot, cached for a year when linked by content. |
| 384 | { |
| 385 | method: "GET", |
| 386 | path: "/tokens.css", |
| 387 | handle: (c) => |
| 388 | new Response(tokensCss, { |
| 389 | headers: { |
| 390 | "content-type": "text/css; charset=utf-8", |
| 391 | "cache-control": c.url.searchParams.has("v") |
| 392 | ? "public, max-age=31536000, immutable" |
| 393 | : "no-cache", |
| 394 | ...SECURITY, |
| 395 | }, |
| 396 | }), |
| 397 | }, |
| 398 | |
| 399 | // What the web expects of a site. Crawlers read the page, not the API; |
| 400 | // a preview is a draft and is read by nobody. Where to report a |
| 401 | // vulnerability is the forge's to say: it runs this app. |
| 402 | { |
| 403 | method: "GET", |
| 404 | path: "/robots.txt", |
| 405 | handle: () => |
| 406 | text( |
| 407 | env === "production" |
| 408 | ? "User-agent: *\nDisallow: /api/\nDisallow: /mcp\nDisallow: /events\nDisallow: /__forge/\n" |
| 409 | : "User-agent: *\nDisallow: /\n", |
| 410 | ), |
| 411 | }, |
| 412 | { |
| 413 | method: "GET", |
| 414 | path: "/.well-known/security.txt", |
| 415 | handle: () => |
| 416 | forgeUrl === undefined |
| 417 | ? text("not found", 404) |
| 418 | : new Response(null, { |
| 419 | status: 302, |
| 420 | headers: { |
| 421 | location: new URL( |
| 422 | "/.well-known/security.txt", |
| 423 | forgeUrl, |
| 424 | ).toString(), |
| 425 | ...SECURITY, |
| 426 | }, |
| 427 | }), |
| 428 | }, |
| 429 | |
| 430 | // Files under ./static, cached for a year when linked by content (`asset`). |
| 431 | { |
| 432 | method: "GET", |
| 433 | path: "/static/:file", |
| 434 | handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")), |
| 435 | }, |
| 436 | ]; |
| 437 | |
| 438 | /** The route for a method and path, with what its `:names` matched. */ |
| 439 | export function match( |
| 440 | method: string, |
| 441 | pathname: string, |
| 442 | ): { route: Route; params: Record<string, string> } | undefined { |
| 443 | const parts = pathname.split("/"); |
| 444 | for (const route of routes) { |
| 445 | if (route.method !== method) continue; |
| 446 | const pattern = route.path.split("/"); |
| 447 | if (pattern.length !== parts.length) continue; |
| 448 | const params: Record<string, string> = {}; |
| 449 | const hit = pattern.every((p, i) => { |
| 450 | if (p.startsWith(":")) { |
| 451 | // A malformed escape is no match, never a thrown error. |
| 452 | try { |
| 453 | params[p.slice(1)] = decodeURIComponent(parts[i] ?? ""); |
| 454 | return true; |
| 455 | } catch { |
| 456 | return false; |
| 457 | } |
| 458 | } |
| 459 | return p === parts[i]; |
| 460 | }); |
| 461 | if (hit) return { route, params }; |
| 462 | } |
| 463 | return undefined; |
| 464 | } |