pike/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine

Start glyph

benjivers committed acd87b6 Browse files
39 files changed +5801 −0
.gitignore added +4
@@ -0,0 +1,4 @@
1 node_modules/
2 *.db
3 *.db-wal
4 *.db-shm
AGENTS.md added +131
@@ -0,0 +1,131 @@
1 # Working on this app
2
3 You are one of the crew on this repository. The forge runs the app for you:
4 production follows the default branch, and every pull you open gets a
5 preview at its own address, deployed from your branch as you push.
6
7 ## The rules
8
9 - Work on the branch you were given. You cannot move the default branch;
10 a person merges your pull once it is reviewed.
11 - Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime.
12 - Run `bun test` before you push. `forge.json`'s `app.test` runs it beside
13 the app on every deploy, not before it: the preview comes up the moment
14 the app answers and says `live`, or `serving · tests failed` the moment
15 the suite disagrees. Every behaviour you add gets a test that would fail
16 without it.
17 - `src/tty.ts` is the app's terminal. The exact command that reaches it is
18 `FORGE_APP_SSH` at runtime; print that verbatim wherever the app tells
19 people how to connect. What a person can do on the page they can do here,
20 and nothing more: the terminal gates its reads and writes exactly as the
21 page does (`FORGE_APP_USER_ROLE` is the forge's word on who is there), and a
22 write made there reaches web viewers live like any other.
23 - Keep it dependency-free unless a dependency is clearly worth it, and say
24 why in the pull. A dependency is code you now maintain.
25 - When you are done, push and stop. Your last message becomes the pull's
26 description: say what changed, what you chose not to do, and what to look
27 at in the preview.
28
29 ## The shape
30
31 One implementation, many doors. Every operation is a function in
32 `src/actions.ts` — `(db, who, input)` in, data or a named failure out — and
33 the page (`src/routes.ts`, `src/page.ts`), the JSON API (`src/routes.ts`),
34 the MCP server (`src/mcp.ts`) and the terminal (`src/tty.ts`) call it. A
35 door reads its wire format and shapes the answer; it decides nothing. A
36 rule in an action holds at every door at once; a rule anywhere else is a
37 bug waiting for the door you forgot.
38
39 Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`,
40 verified by signature, never by a cookie the app invents.
41
42 Every request passes the door (`src/security.ts`) before a route sees it:
43 writes are rationed, a write from another site is refused, and a request
44 under a resource's `api` path is refused — before its body is read — when
45 the table lets that person through that door for no row at all. The door
46 asks the same table the actions do; it never compares roles by hand, and
47 the action still decides about the real row. Do not route around it.
48
49 Every change is written down. An action that creates, updates or deletes
50 returns through `audited` (`src/actions.ts`), which appends a row to the
51 `audit` table — who, the resource and verb, the row, when, and whether
52 they were let — refused attempts included; the door records the writes it
53 turns away. Admins read it at `GET /api/audit` (the `audit` resource in
54 `contract.json`). Recording never fails a request: a row that cannot be
55 written is a log line.
56
57 Who may do what is a table, not code: `contract.json`. It declares the
58 roles — each names the roles below it, and how it is held: by everyone, by
59 the identity the forge signed, or by a row's column naming the person — and,
60 for every resource, the least role each of create, read, update, delete and
61 list needs. An action asks `permit(who, resource, verb, row)`
62 (`src/contract.ts`) and obeys the answer; it never compares `who.role`
63 itself. `src/can.ts` is the forge's evaluator, carried verbatim: do not
64 edit it. `src/contract.test.ts` acts as every identity at every door and
65 fails when a door and the table disagree.
66
67 A new kind of person is a new role in the table, under an existing one. A
68 change to a cell is a change of policy: say so in the pull's description.
69 A cell loosened — anything an identity could not do before that it can
70 after — is a wall: the forge shows the pull to a person and never lands it
71 on its own. Do not loosen a cell unless the issue asks for exactly that;
72 prefer a new role under an existing one, or a new resource, to widening a
73 door that was decided.
74
75 To add a concept:
76
77 - **An action** — a function in `src/actions.ts` that asks `permit`, its
78 resource's row in `contract.json`, its door in `src/contract.test.ts`,
79 and a test in `src/actions.test.ts` for what the table cannot say.
80 - **A migration** — append to `migrations` in `src/db.ts`. Never edit one
81 that has run. `bun run src/db.ts --check` shows the version.
82 - **A page** — a row in `src/routes.ts` and a function in `src/page.ts`.
83 With a second page, move them under `src/pages/` and keep one `layout`.
84 - **An API route** — a row in `src/routes.ts` that calls the action and
85 answers with `answer(...)`; the status follows the failure. Give it an
86 `api` entry beside it (a sentence, the body and answer as JSON Schema)
87 and run `bun run contract:emit`: `contract/openapi.json` is committed,
88 served at `/openapi.json`, and `bun test` fails while it is stale. A
89 resource's routes live under the `api` path its `contract.json` entry
90 names, so the door and the forge's probe find them.
91 - **A tool** — an entry in `tools` in `src/mcp.ts`: a name, one sentence,
92 a JSON schema, the action.
93 - **A terminal command** — a key in `src/tty.ts` that calls the action and
94 reports on the status line.
95 - **Something live** — `emit(type, data)` in the action, and
96 `data-live="<types>"` on the region of the page that shows it.
97 - **A colour, a space, a size** — a token in `design/tokens.json` (a
98 colour needs its dark value in `$extensions.mode.dark`), used as
99 `var(--…)`. `bun run lint:design` refuses a raw `#hex`, `rgb(` or
100 `hsl(` anywhere in `src/` or `static/`, and a `var(--…)` that names no
101 token; `src/design.test.ts` runs it, and holds every pair of colours a
102 person reads to 4.5:1 in both themes.
103
104 ## The bar
105
106 - Security: every input is validated at the boundary and every output is
107 escaped; every response carries the floor in `src/http.ts` (keep it,
108 extend it); a POST that is not same-origin is refused; writes are
109 rationed per person; secrets come from the environment (Settings → App →
110 Environment), never the repository; no `eval`, no shell built from user
111 text. A request body is capped by the bytes actually read (`readBody`),
112 never by a Content-Length. `HEAD` answers like `GET`. A private thing
113 needs an identity from `who`, and a cookie the app sets itself is
114 `HttpOnly; SameSite=Lax`.
115 - Speed: the first paint is server-rendered HTML; JavaScript is small, in
116 `static/`, linked by content (`asset()`) and cached for a year; no
117 layout shift; what changes arrives over `/events`, never by polling;
118 work that can wait is done after the response.
119 - A phone is a first-class client: the page works at 360px wide with no
120 horizontal scroll, every control is at least 44px on a side
121 (`--size-tap`), an input's text is never below 16px (a phone zooms into
122 a smaller one), focus is always visible, whoever asked for less motion
123 gets none, and the keyboard reaches everything a mouse does.
124 - Beauty is the tokens': light and dark both, from one file. A new page
125 uses `layout`, which carries the tokens, the description and the Open
126 Graph and card metadata a shared link unfurls with — pass the page's
127 path so `og:url` is its own.
128 - Finish: the app boots, `/healthz` answers `{ ok, version, uptime }`
129 (the forge polls it; keep it cheap and apart from the page), `/`
130 answers, `bun test` is green, the README says how to use it and what is
131 real versus simulated, `forge.json` says how to run it.
README.md added +125
@@ -0,0 +1,125 @@
1 # glyph
2
3 Type a prompt, get SVG logos and icons drawn by Claude; preview in your theme, copy or download.
4
5 This is an app: the forge runs it. Production follows the default branch;
6 every open pull gets a preview at its own address with its own copy of the
7 data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what
8 makes "production data" and "preview data" two different things.
9
10 ```sh
11 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
12 bun test # what `forge.json`'s app.test runs on every push
13 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal
14 FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity
15 bun run lint:design # no colour but the tokens'
16 bun run contract:emit # rewrite contract/openapi.json from the routes
17 ```
18
19 `app.test` in `forge.json` runs beside `app.run` in every deploy, not before
20 it: the preview comes up the moment the app answers, and says `live`, or
21 `serving · tests failed` the moment the suite disagrees.
22
23 ## The shape
24
25 One implementation, many doors. `src/actions.ts` is every operation as a
26 plain function — `(db, who, input)` in, data or a named failure out — and
27 the page, the JSON API, the MCP server and the terminal all call it and
28 decide nothing themselves. `AGENTS.md` says how to add to it.
29
30 | file | what it is |
31 | ---------------------------------- | -------------------------------------------------------------- |
32 | `src/actions.ts` | list, add, delete; the audit trail, read |
33 | `contract.json` | who may do what: roles, and the least role each verb needs |
34 | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim |
35 | `src/security.ts` | the door: rationing, same origin, the table before the body |
36 | `src/audit.ts` | every change and every refused one, as a row |
37 | `src/who.ts` | who is asking, from the forge's signed headers |
38 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
39 | `src/live.ts` | the in-process bus and `GET /events` |
40 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
41 | `src/routes.ts` | every path, in one table, each API route described beside it |
42 | `src/openapi.ts` | the routes as OpenAPI; committed at `contract/openapi.json` |
43 | `src/page.ts` | the HTML, rendered on the server, with its share metadata |
44 | `design/tokens.json`, `src/tokens.ts` | the look as data, light and dark; compiled to `/tokens.css` |
45 | `src/http.ts` | the floor: headers, body cap, files |
46 | `src/server.ts` | boot: open the database, serve |
47 | `src/tty.ts` | the terminal, over the same actions |
48 | `src/env.ts` | what the forge told this process |
49 | `static/app.css`, `static/live.js` | the look in the tokens' words, and the list that stays current |
50
51 ## What every app stands on
52
53 - **The door.** Before a route sees a request, `src/security.ts` rations
54 writes (and a stranger's API calls), refuses a write from another site,
55 and turns away — before the body is read — anyone the table lets through
56 a resource's door for no row at all: 401 for a stranger, 403 for anyone
57 else. The action still decides about the real row.
58 - **The audit trail.** Every change, and every change refused, is a row:
59 who, which resource and verb, which row, when, and whether they were
60 let. Admins read it at `GET /api/audit`. A row that cannot be written
61 is a log line, never a failed request.
62 - **The API, described.** `GET /openapi.json` is `contract/openapi.json`,
63 written from the routes by `bun run contract:emit`; each operation names
64 its cell of the table (`x-contract`). `bun test` fails while it is stale.
65 - **Up, or not.** `GET /healthz` answers `{ ok, version, uptime }`; the
66 forge waits on it before it sends anyone to a deploy.
67 - **The look.** `design/tokens.json` holds every colour, space and size,
68 light and dark; nothing else spells out a colour (`bun run lint:design`),
69 and every pair a person reads meets 4.5:1 in both themes.
70 - **The open web.** A shared link unfurls (description, Open Graph, a
71 card); `/robots.txt` keeps crawlers to the page, and out of previews
72 entirely; `/.well-known/security.txt` sends a reporter to the forge
73 that runs the app.
74
75 ## Who you are
76
77 The forge says who is asking on every request it proxies, and the app
78 verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
79
80 - **anon** — anyone. Reads.
81 - **user** — signed in to the forge and able to see this app. Adds notes, as
82 themselves, and removes their own.
83 - **admin** — able to write this repository. Removes any note.
84
85 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
86 `/__forge/signout`; the forge answers both on the app's own host, and
87 `/__forge/report` too: it sends anyone, with no account, to report a
88 problem on this app's repository, if that repository is public.
89
90 ## Any interface
91
92 The same actions, the same rules, at every door. A script is the person
93 whose forge grant it carries; a bare script is anonymous.
94
95 ```sh
96 curl https://<app>/api/notes
97 curl -X POST https://<app>/api/notes -H 'content-type: application/json' -d '{"body":"hi"}'
98 curl -X DELETE https://<app>/api/notes/1
99 ```
100
101 As an MCP server, for Claude Code or Claude Desktop — `list_notes`,
102 `add_note`, `delete_note`:
103
104 ```sh
105 claude mcp add --transport http __TITLE__ https://<app>/mcp
106 ```
107
108 ```json
109 {
110 "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } }
111 }
112 ```
113
114 The page stays current over `/events`: a note someone else adds or removes
115 appears without a reload. Without JavaScript it is the same page, one
116 reload behind.
117
118 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
119 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
120 the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on
121 a real pty over the same data, as the account at the keyboard.
122
123 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
124 `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and
125 `FORGE_APP_IDENTITY_KEY` when it starts the app. `forge.json` says how.
contract.json added +30
@@ -0,0 +1,30 @@
1 {
2 "roles": {
3 "anon": { "above": [], "held": { "by": "everyone" } },
4 "user": { "above": ["anon"], "held": { "by": "identity", "role": "user" } },
5 "author": { "above": ["user"], "held": { "by": "row", "column": "author_id" } },
6 "admin": { "above": ["author"], "held": { "by": "identity", "role": "admin" } }
7 },
8 "resources": {
9 "note": {
10 "api": "/api/notes",
11 "can": {
12 "create": "user",
13 "read": "anon",
14 "update": "author",
15 "delete": "author",
16 "list": "anon"
17 }
18 },
19 "audit": {
20 "api": "/api/audit",
21 "can": {
22 "create": "nobody",
23 "read": "admin",
24 "update": "nobody",
25 "delete": "nobody",
26 "list": "admin"
27 }
28 }
29 }
30 }
contract/openapi.json added +578
@@ -0,0 +1,578 @@
1 {
2 "openapi": "3.1.0",
3 "info": {
4 "title": "glyph",
5 "version": "0.1.0",
6 "description": "Type a prompt, get SVG logos and icons drawn by Claude; preview in your theme, copy or download."
7 },
8 "paths": {
9 "/api/notes": {
10 "get": {
11 "summary": "The notes, newest first.",
12 "operationId": "getApiNotes",
13 "parameters": [
14 {
15 "name": "limit",
16 "in": "query",
17 "schema": {
18 "type": "integer",
19 "minimum": 1,
20 "maximum": 500,
21 "default": 100
22 }
23 }
24 ],
25 "responses": {
26 "200": {
27 "description": "The notes, newest first.",
28 "content": {
29 "application/json": {
30 "schema": {
31 "type": "array",
32 "items": {
33 "$ref": "#/components/schemas/Note"
34 }
35 }
36 }
37 }
38 },
39 "default": {
40 "description": "{ error, message }: why not",
41 "content": {
42 "application/json": {
43 "schema": {
44 "$ref": "#/components/schemas/Error"
45 }
46 }
47 }
48 }
49 },
50 "x-contract": {
51 "resource": "note",
52 "verb": "list",
53 "least": "anon"
54 }
55 },
56 "post": {
57 "summary": "Add a note as the person asking.",
58 "operationId": "postApiNotes",
59 "requestBody": {
60 "required": true,
61 "content": {
62 "application/json": {
63 "schema": {
64 "type": "object",
65 "properties": {
66 "body": {
67 "type": "string",
68 "minLength": 1,
69 "maxLength": 500
70 }
71 },
72 "required": [
73 "body"
74 ]
75 }
76 }
77 }
78 },
79 "responses": {
80 "201": {
81 "description": "Add a note as the person asking.",
82 "content": {
83 "application/json": {
84 "schema": {
85 "$ref": "#/components/schemas/Note"
86 }
87 }
88 }
89 },
90 "default": {
91 "description": "{ error, message }: why not",
92 "content": {
93 "application/json": {
94 "schema": {
95 "$ref": "#/components/schemas/Error"
96 }
97 }
98 }
99 }
100 },
101 "x-contract": {
102 "resource": "note",
103 "verb": "create",
104 "least": "user"
105 }
106 }
107 },
108 "/api/notes/{id}": {
109 "get": {
110 "summary": "One note.",
111 "operationId": "getApiNotesId",
112 "parameters": [
113 {
114 "name": "id",
115 "in": "path",
116 "required": true,
117 "schema": {
118 "type": "integer"
119 }
120 }
121 ],
122 "responses": {
123 "200": {
124 "description": "One note.",
125 "content": {
126 "application/json": {
127 "schema": {
128 "$ref": "#/components/schemas/Note"
129 }
130 }
131 }
132 },
133 "default": {
134 "description": "{ error, message }: why not",
135 "content": {
136 "application/json": {
137 "schema": {
138 "$ref": "#/components/schemas/Error"
139 }
140 }
141 }
142 }
143 },
144 "x-contract": {
145 "resource": "note",
146 "verb": "read",
147 "least": "anon"
148 }
149 },
150 "patch": {
151 "summary": "Change a note's words.",
152 "operationId": "patchApiNotesId",
153 "parameters": [
154 {
155 "name": "id",
156 "in": "path",
157 "required": true,
158 "schema": {
159 "type": "integer"
160 }
161 }
162 ],
163 "requestBody": {
164 "required": true,
165 "content": {
166 "application/json": {
167 "schema": {
168 "type": "object",
169 "properties": {
170 "body": {
171 "type": "string",
172 "minLength": 1,
173 "maxLength": 500
174 }
175 },
176 "required": [
177 "body"
178 ]
179 }
180 }
181 }
182 },
183 "responses": {
184 "200": {
185 "description": "Change a note's words.",
186 "content": {
187 "application/json": {
188 "schema": {
189 "$ref": "#/components/schemas/Note"
190 }
191 }
192 }
193 },
194 "default": {
195 "description": "{ error, message }: why not",
196 "content": {
197 "application/json": {
198 "schema": {
199 "$ref": "#/components/schemas/Error"
200 }
201 }
202 }
203 }
204 },
205 "x-contract": {
206 "resource": "note",
207 "verb": "update",
208 "least": "author"
209 }
210 },
211 "delete": {
212 "summary": "Remove a note.",
213 "operationId": "deleteApiNotesId",
214 "parameters": [
215 {
216 "name": "id",
217 "in": "path",
218 "required": true,
219 "schema": {
220 "type": "integer"
221 }
222 }
223 ],
224 "responses": {
225 "200": {
226 "description": "Remove a note.",
227 "content": {
228 "application/json": {
229 "schema": {
230 "type": "object",
231 "properties": {
232 "id": {
233 "type": "integer"
234 }
235 },
236 "required": [
237 "id"
238 ]
239 }
240 }
241 }
242 },
243 "default": {
244 "description": "{ error, message }: why not",
245 "content": {
246 "application/json": {
247 "schema": {
248 "$ref": "#/components/schemas/Error"
249 }
250 }
251 }
252 }
253 },
254 "x-contract": {
255 "resource": "note",
256 "verb": "delete",
257 "least": "author"
258 }
259 }
260 },
261 "/api/audit": {
262 "get": {
263 "summary": "The audit trail, newest first; `before` pages back by id.",
264 "operationId": "getApiAudit",
265 "parameters": [
266 {
267 "name": "limit",
268 "in": "query",
269 "schema": {
270 "type": "integer",
271 "minimum": 1,
272 "maximum": 500,
273 "default": 100
274 }
275 },
276 {
277 "name": "before",
278 "in": "query",
279 "schema": {
280 "type": "integer"
281 }
282 }
283 ],
284 "responses": {
285 "200": {
286 "description": "The audit trail, newest first; `before` pages back by id.",
287 "content": {
288 "application/json": {
289 "schema": {
290 "type": "array",
291 "items": {
292 "$ref": "#/components/schemas/AuditRow"
293 }
294 }
295 }
296 }
297 },
298 "default": {
299 "description": "{ error, message }: why not",
300 "content": {
301 "application/json": {
302 "schema": {
303 "$ref": "#/components/schemas/Error"
304 }
305 }
306 }
307 }
308 },
309 "x-contract": {
310 "resource": "audit",
311 "verb": "list",
312 "least": "admin"
313 }
314 }
315 },
316 "/api/audit/{id}": {
317 "get": {
318 "summary": "One row of the audit trail.",
319 "operationId": "getApiAuditId",
320 "parameters": [
321 {
322 "name": "id",
323 "in": "path",
324 "required": true,
325 "schema": {
326 "type": "integer"
327 }
328 }
329 ],
330 "responses": {
331 "200": {
332 "description": "One row of the audit trail.",
333 "content": {
334 "application/json": {
335 "schema": {
336 "$ref": "#/components/schemas/AuditRow"
337 }
338 }
339 }
340 },
341 "default": {
342 "description": "{ error, message }: why not",
343 "content": {
344 "application/json": {
345 "schema": {
346 "$ref": "#/components/schemas/Error"
347 }
348 }
349 }
350 }
351 },
352 "x-contract": {
353 "resource": "audit",
354 "verb": "read",
355 "least": "admin"
356 }
357 }
358 },
359 "/mcp": {
360 "post": {
361 "summary": "One JSON-RPC 2.0 message to the MCP server; its tools are the actions.",
362 "operationId": "postMcp",
363 "requestBody": {
364 "required": true,
365 "content": {
366 "application/json": {
367 "schema": {
368 "type": "object",
369 "properties": {
370 "jsonrpc": {
371 "const": "2.0"
372 },
373 "id": {},
374 "method": {
375 "type": "string"
376 },
377 "params": {
378 "type": "object"
379 }
380 },
381 "required": [
382 "jsonrpc",
383 "method"
384 ]
385 }
386 }
387 }
388 },
389 "responses": {
390 "200": {
391 "description": "One JSON-RPC 2.0 message to the MCP server; its tools are the actions.",
392 "content": {
393 "application/json": {
394 "schema": {
395 "type": "object"
396 }
397 }
398 }
399 },
400 "default": {
401 "description": "{ error, message }: why not",
402 "content": {
403 "application/json": {
404 "schema": {
405 "$ref": "#/components/schemas/Error"
406 }
407 }
408 }
409 }
410 }
411 }
412 },
413 "/healthz": {
414 "get": {
415 "summary": "Whether the app is up: its version, commit, environment and seconds since it started.",
416 "operationId": "getHealthz",
417 "responses": {
418 "200": {
419 "description": "Whether the app is up: its version, commit, environment and seconds since it started.",
420 "content": {
421 "application/json": {
422 "schema": {
423 "type": "object",
424 "properties": {
425 "ok": {
426 "type": "boolean"
427 },
428 "version": {
429 "type": "string"
430 },
431 "commit": {
432 "type": "string"
433 },
434 "env": {
435 "type": "string"
436 },
437 "uptime": {
438 "type": "integer"
439 }
440 },
441 "required": [
442 "ok",
443 "version",
444 "uptime"
445 ]
446 }
447 }
448 }
449 },
450 "default": {
451 "description": "{ error, message }: why not",
452 "content": {
453 "application/json": {
454 "schema": {
455 "$ref": "#/components/schemas/Error"
456 }
457 }
458 }
459 }
460 }
461 }
462 }
463 },
464 "components": {
465 "schemas": {
466 "Note": {
467 "type": "object",
468 "properties": {
469 "id": {
470 "type": "integer"
471 },
472 "body": {
473 "type": "string",
474 "minLength": 1,
475 "maxLength": 500
476 },
477 "author_id": {
478 "type": "string"
479 },
480 "author_name": {
481 "type": "string"
482 },
483 "created_at": {
484 "type": "string",
485 "format": "date-time"
486 },
487 "updated_at": {
488 "type": "string",
489 "format": "date-time"
490 }
491 },
492 "required": [
493 "id",
494 "body",
495 "author_id",
496 "author_name",
497 "created_at",
498 "updated_at"
499 ]
500 },
501 "AuditRow": {
502 "type": "object",
503 "properties": {
504 "id": {
505 "type": "integer"
506 },
507 "at": {
508 "type": "string",
509 "format": "date-time"
510 },
511 "who_id": {
512 "type": [
513 "string",
514 "null"
515 ]
516 },
517 "who_name": {
518 "type": [
519 "string",
520 "null"
521 ]
522 },
523 "role": {
524 "enum": [
525 "anon",
526 "user",
527 "admin"
528 ]
529 },
530 "resource": {
531 "type": "string"
532 },
533 "verb": {
534 "type": "string"
535 },
536 "target": {
537 "type": [
538 "string",
539 "null"
540 ]
541 },
542 "allowed": {
543 "enum": [
544 0,
545 1
546 ]
547 },
548 "outcome": {
549 "type": "string"
550 }
551 },
552 "required": [
553 "id",
554 "at",
555 "role",
556 "resource",
557 "verb",
558 "allowed",
559 "outcome"
560 ]
561 },
562 "Error": {
563 "type": "object",
564 "properties": {
565 "error": {
566 "type": "string"
567 },
568 "message": {
569 "type": "string"
570 }
571 },
572 "required": [
573 "error"
574 ]
575 }
576 }
577 }
578 }
design/tokens.json added +95
@@ -0,0 +1,95 @@
1 {
2 "$description": "The look, as data (W3C design tokens). Every colour the app shows is here and nowhere else: src/tokens.ts compiles this to /tokens.css at boot, and bun run lint:design refuses a raw colour anywhere else. A colour's $value is the light theme; $extensions.mode.dark is the dark one.",
3 "color": {
4 "$type": "color",
5 "bg": {
6 "$value": "#f6f6f3",
7 "$extensions": { "mode": { "dark": "#131312" } }
8 },
9 "surface": {
10 "$value": "#ffffff",
11 "$extensions": { "mode": { "dark": "#1d1d1b" } }
12 },
13 "fg": {
14 "$value": "#1c1c1a",
15 "$extensions": { "mode": { "dark": "#ecece7" } }
16 },
17 "muted": {
18 "$value": "#6b6b65",
19 "$extensions": { "mode": { "dark": "#9a9a93" } }
20 },
21 "line": {
22 "$value": "#e3e3dd",
23 "$extensions": { "mode": { "dark": "#2c2c29" } }
24 },
25 "accent": {
26 "$value": "#2f5bea",
27 "$extensions": { "mode": { "dark": "#7c96ff" } }
28 },
29 "accent-fg": {
30 "$value": "#ffffff",
31 "$extensions": { "mode": { "dark": "#0f1a4d" } }
32 },
33 "danger": {
34 "$value": "#c2410c",
35 "$extensions": { "mode": { "dark": "#fb923c" } }
36 },
37 "toast-bg": {
38 "$value": "#1c1c1a",
39 "$extensions": { "mode": { "dark": "#ecece7" } }
40 },
41 "toast-fg": {
42 "$value": "#f6f6f3",
43 "$extensions": { "mode": { "dark": "#131312" } }
44 },
45 "shadow": {
46 "$value": "#0000002e",
47 "$extensions": { "mode": { "dark": "#00000080" } }
48 }
49 },
50 "space": {
51 "$type": "dimension",
52 "1": { "$value": "0.25rem" },
53 "2": { "$value": "0.5rem" },
54 "3": { "$value": "0.75rem" },
55 "4": { "$value": "1rem" },
56 "5": { "$value": "1.5rem" },
57 "6": { "$value": "2.5rem" }
58 },
59 "radius": {
60 "$type": "dimension",
61 "md": { "$value": "10px" },
62 "pill": { "$value": "999px" }
63 },
64 "size": {
65 "$type": "dimension",
66 "tap": {
67 "$value": "44px",
68 "$description": "The least a control measures on a side."
69 }
70 },
71 "font": {
72 "$type": "fontFamily",
73 "sans": {
74 "$value": ["system-ui", "-apple-system", "Segoe UI", "sans-serif"]
75 },
76 "mono": {
77 "$value": ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"]
78 }
79 },
80 "text": {
81 "$type": "dimension",
82 "sm": { "$value": "0.875rem" },
83 "base": {
84 "$value": "1rem",
85 "$description": "Never below 16px: a phone zooms into a smaller input."
86 },
87 "lg": { "$value": "1.125rem" },
88 "xl": { "$value": "1.5rem" }
89 },
90 "leading": {
91 "$type": "number",
92 "body": { "$value": 1.55 },
93 "tight": { "$value": 1.2 }
94 }
95 }
forge.json added +8
@@ -0,0 +1,8 @@
1 {
2 "app": {
3 "run": "bun run src/server.ts",
4 "test": "bun test",
5 "health": "/healthz",
6 "tty": "bun run src/tty.ts"
7 }
8 }
package.json added +13
@@ -0,0 +1,13 @@
1 {
2 "name": "glyph",
3 "version": "0.1.0",
4 "description": "Type a prompt, get SVG logos and icons drawn by Claude; preview in your theme, copy or download.",
5 "private": true,
6 "type": "module",
7 "scripts": {
8 "start": "bun run src/server.ts",
9 "test": "bun test",
10 "lint:design": "bun run scripts/lint-design.ts",
11 "contract:emit": "bun run scripts/contract-emit.ts"
12 }
13 }
scripts/contract-emit.ts added +14
@@ -0,0 +1,14 @@
1 /**
2 * Write the API's description where git keeps it: contract/openapi.json,
3 * from the routes' own `api` entries (src/openapi.ts). Run it after
4 * changing a route or a cell of contract.json; the tests say when it is due.
5 */
6
7 import { mkdirSync, writeFileSync } from "node:fs";
8 import { join } from "node:path";
9 import { emit } from "../src/openapi.ts";
10
11 const dir = join(import.meta.dir, "..", "contract");
12 mkdirSync(dir, { recursive: true });
13 writeFileSync(join(dir, "openapi.json"), emit());
14 console.log("wrote contract/openapi.json");
scripts/lint-design.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * Every colour comes from `design/tokens.json`. This refuses a raw one —
3 * `#hex`, `rgb(`, `hsl(` — anywhere else the app draws from: `static/`
4 * and `src/`, tests aside. A colour spelled out in a stylesheet is a colour
5 * the dark theme never hears about. It refuses a `var(--…)` in a stylesheet
6 * that names no token, too: a typo there is a page quietly unstyled.
7 *
8 * bun run lint:design exit 1 and one line per raw colour, or silence
9 */
10
11 import { readdirSync, readFileSync } from "node:fs";
12 import { join } from "node:path";
13 import { tokens } from "../src/tokens.ts";
14
15 const RAW =
16 /(?<![\w&])#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])|\b(?:rgba?|hsla?)\(/gi;
17
18 /** The raw colours in one file's text: `path:line: …`, one per find. */
19 export function rawColours(path: string, text: string): string[] {
20 return text
21 .split("\n")
22 .flatMap((line, i) =>
23 [...line.matchAll(RAW)].map(
24 (m) =>
25 `${path}:${i + 1}: raw colour ${m[0]} — name it in design/tokens.json and use var(--…)`,
26 ),
27 );
28 }
29
30 /** The custom properties a stylesheet uses that no token defines. */
31 export function unknownTokens(
32 path: string,
33 text: string,
34 known = new Set(tokens.map((t) => t.name)),
35 ): string[] {
36 return text
37 .split("\n")
38 .flatMap((line, i) =>
39 [...line.matchAll(/var\((--[\w-]+)/g)]
40 .filter((m) => !known.has(m[1]!))
41 .map(
42 (m) =>
43 `${path}:${i + 1}: ${m[1]} is not a token in design/tokens.json`,
44 ),
45 );
46 }
47
48 /** The files the lint reads: what the page is drawn from, never the tokens themselves or the tests. */
49 export function drawnFrom(root: string): string[] {
50 const walk = (dir: string): string[] =>
51 readdirSync(join(root, dir), { withFileTypes: true }).flatMap((entry) => {
52 const path = join(dir, entry.name);
53 if (entry.isDirectory()) return walk(path);
54 return /\.(css|js|ts|html)$/.test(entry.name) &&
55 !/\.test\.ts$/.test(entry.name)
56 ? [path]
57 : [];
58 });
59 return ["static", "src"].flatMap(walk);
60 }
61
62 export function lint(root: string): string[] {
63 return drawnFrom(root).flatMap((path) => {
64 const text = readFileSync(join(root, path), "utf8");
65 return [
66 ...rawColours(path, text),
67 ...(path.endsWith(".css") ? unknownTokens(path, text) : []),
68 ];
69 });
70 }
71
72 if (import.meta.main) {
73 const problems = lint(join(import.meta.dir, ".."));
74 // Generated app's lint CLI: stderr is its output.
75 for (const problem of problems) console.error(problem);
76 process.exit(problems.length === 0 ? 0 : 1);
77 }
src/actions.test.ts added +58
@@ -0,0 +1,58 @@
1 import { expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { addNote, deleteNote, listNotes } from "./actions.ts";
6 import { openDb } from "./db.ts";
7 import type { Who } from "./who.ts";
8
9 const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-")));
10 const anon: Who = { role: "anon" };
11 const alice: Who = { role: "user", id: "u1", name: "alice" };
12 const bob: Who = { role: "user", id: "u2", name: "bob" };
13 const root: Who = { role: "admin", id: "u0", name: "root" };
14
15 test("anyone reads; only a signed-in person adds, as themselves", () => {
16 expect(listNotes(db, anon)).toEqual({ ok: true, value: [] });
17 const refused = addNote(db, anon, { body: "hello" });
18 expect(refused.ok).toBe(false);
19 if (!refused.ok) expect(refused.error).toBe("signin_required");
20 const added = addNote(db, alice, { body: " hello " });
21 expect(added.ok).toBe(true);
22 if (!added.ok) return;
23 expect(added.value.body).toBe("hello");
24 expect(added.value.author_id).toBe("u1");
25 expect(added.value.author_name).toBe("alice");
26 const all = listNotes(db, anon);
27 expect(all.ok && all.value.length).toBe(1);
28 });
29
30 test("a note is one to five hundred characters", () => {
31 for (const body of ["", " ", "x".repeat(501)]) {
32 const r = addNote(db, alice, { body });
33 expect(r.ok).toBe(false);
34 if (!r.ok) expect(r.error).toBe("invalid");
35 }
36 });
37
38 test("only the author or an admin removes a note", () => {
39 const mine = addNote(db, alice, { body: "mine" });
40 if (!mine.ok) throw new Error(mine.message);
41 const byBob = deleteNote(db, bob, { id: mine.value.id });
42 expect(byBob.ok).toBe(false);
43 if (!byBob.ok) expect(byBob.error).toBe("forbidden");
44 const byAnon = deleteNote(db, anon, { id: mine.value.id });
45 expect(byAnon.ok).toBe(false);
46 if (!byAnon.ok) expect(byAnon.error).toBe("signin_required");
47 expect(deleteNote(db, alice, { id: mine.value.id }).ok).toBe(true);
48 const gone = deleteNote(db, alice, { id: mine.value.id });
49 expect(gone.ok).toBe(false);
50 if (!gone.ok) expect(gone.error).toBe("not_found");
51
52 const bobs = addNote(db, bob, { body: "bob's" });
53 if (!bobs.ok) throw new Error(bobs.message);
54 expect(deleteNote(db, root, { id: bobs.value.id }).ok).toBe(true);
55 const odd = deleteNote(db, root, { id: 1.5 });
56 expect(odd.ok).toBe(false);
57 if (!odd.ok) expect(odd.error).toBe("invalid");
58 });
src/actions.ts added +271
@@ -0,0 +1,271 @@
1 /**
2 * Everything the app can do, as plain functions: `(db, who, input)` in,
3 * data or a named failure out. The page, the JSON API, the MCP server and
4 * the terminal are doors onto these and decide nothing themselves — so a
5 * rule written here holds at every door, and a new door is only a new
6 * way to call.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { record, type AuditRow } from "./audit.ts";
11 import { permit } from "./contract.ts";
12 import { emit } from "./live.ts";
13 import type { Who } from "./who.ts";
14
15 export interface Note {
16 readonly id: number;
17 readonly body: string;
18 readonly author_id: string;
19 readonly author_name: string;
20 readonly created_at: string;
21 readonly updated_at: string;
22 }
23
24 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
25 export type Result<T> =
26 | { ok: true; value: T }
27 | { ok: false; error: Failure; message: string };
28 const ok = <T>(value: T): Result<T> => ({ ok: true, value });
29 const fail = <T>(error: Failure, message: string): Result<T> => ({
30 ok: false,
31 error,
32 message,
33 });
34
35 export const BODY_MAX = 500;
36
37 /** The refusal for a "no": who is not signed in is asked to; who is, is told. */
38 const refuse = <T>(who: Who, signIn: string, no: string): Result<T> =>
39 who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no);
40
41 /**
42 * A change, written into the audit trail whichever way it went: the row it
43 * made or touched, or the one it was refused. Every action that changes
44 * something returns through here, so the trail holds at every door.
45 */
46 const audited = <T extends { readonly id: number }>(
47 db: Database,
48 who: Who,
49 resource: string,
50 verb: "create" | "update" | "delete",
51 target: number | undefined,
52 result: Result<T>,
53 ): Result<T> => {
54 record(db, {
55 who,
56 resource,
57 verb,
58 target: result.ok ? result.value.id : target,
59 outcome: result.ok ? "ok" : result.error,
60 });
61 return result;
62 };
63
64 export function listNotes(
65 db: Database,
66 who: Who,
67 input: { limit?: number } = {},
68 ): Result<Note[]> {
69 const limit = Math.min(
70 Math.max(Math.trunc(input.limit ?? 100) || 100, 1),
71 500,
72 );
73 // "yes" is every note. When the table gives `list` to a role a row holds
74 // (`author`), the answer names the columns instead, and the list is the
75 // notes that name whoever asks. The names are the contract's, checked when
76 // it was read; the value is bound.
77 const may = permit(who, "note", "list");
78 if (may === "no")
79 return refuse(who, "Sign in to see notes.", "You may not see these notes.");
80 if (may === "yes")
81 return ok(
82 db
83 .query<Note, [number]>("SELECT * FROM note ORDER BY id DESC LIMIT ?1")
84 .all(limit),
85 );
86 const mine = may.where.map((column) => `${column} = ?2`).join(" OR ");
87 return ok(
88 db
89 .query<
90 Note,
91 [number, string]
92 >(`SELECT * FROM note WHERE ${mine} ORDER BY id DESC LIMIT ?1`)
93 .all(limit, who.role === "anon" ? "" : who.id),
94 );
95 }
96
97 export const addNote = (
98 db: Database,
99 who: Who,
100 input: { body: string },
101 ): Result<Note> =>
102 audited(db, who, "note", "create", undefined, add(db, who, input));
103
104 function add(db: Database, who: Who, input: { body: string }): Result<Note> {
105 // The table decides; the type only learns it here: whoever may create is
106 // signed in, and carries a name to write beside the note.
107 if (who.role === "anon" || permit(who, "note", "create") !== "yes")
108 return refuse(who, "Sign in to add a note.", "You may not add a note.");
109 const body = input.body.trim();
110 if (body.length === 0 || body.length > BODY_MAX)
111 return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`);
112 const note = db
113 .query<
114 Note,
115 [string, string, string]
116 >("INSERT INTO note (body, author_id, author_name) VALUES (?1, ?2, ?3) RETURNING *")
117 .get(body, who.id, who.name)!;
118 emit("note.added", note);
119 return ok(note);
120 }
121
122 /**
123 * One note, for `who` to do `verb` to. The door is asked before the row is
124 * looked for, so whoever may not is told that and never learns whether the
125 * row exists; whoever may, and asked for nothing, hears `not_found`.
126 */
127 function noteFor(
128 db: Database,
129 who: Who,
130 verb: "read" | "update" | "delete",
131 id: number,
132 said: { signIn: string; no: string },
133 ): Result<Note> {
134 const note = Number.isInteger(id)
135 ? db.query<Note, [number]>("SELECT * FROM note WHERE id = ?1").get(id)
136 : null;
137 // With no row to show, the door is asked about a row of their own: whoever
138 // could not pass even then is refused, and learns nothing about what
139 // exists; whoever could is told there is no such note.
140 const row: Readonly<Record<string, unknown>> =
141 note === null ? { author_id: who.role === "anon" ? null : who.id } : { ...note };
142 if (permit(who, "note", verb, row) !== "yes")
143 return refuse(who, said.signIn, said.no);
144 if (note !== null) return ok(note);
145 if (!Number.isInteger(id))
146 return fail("invalid", "A note id is a whole number.");
147 return fail("not_found", "No such note.");
148 }
149
150 export function getNote(
151 db: Database,
152 who: Who,
153 input: { id: number },
154 ): Result<Note> {
155 return noteFor(db, who, "read", input.id, {
156 signIn: "Sign in to read this note.",
157 no: "You may not read this note.",
158 });
159 }
160
161 export const updateNote = (
162 db: Database,
163 who: Who,
164 input: { id: number; body: string },
165 ): Result<Note> =>
166 audited(db, who, "note", "update", input.id, update(db, who, input));
167
168 function update(
169 db: Database,
170 who: Who,
171 input: { id: number; body: string },
172 ): Result<Note> {
173 const found = noteFor(db, who, "update", input.id, {
174 signIn: "Sign in to edit a note.",
175 no: "Only the author or an admin can edit a note.",
176 });
177 if (!found.ok) return found;
178 const body = input.body.trim();
179 if (body.length === 0 || body.length > BODY_MAX)
180 return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`);
181 const note = db
182 .query<
183 Note,
184 [string, number]
185 >("UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *")
186 .get(body, found.value.id)!;
187 emit("note.changed", note);
188 return ok(note);
189 }
190
191 export const deleteNote = (
192 db: Database,
193 who: Who,
194 input: { id: number },
195 ): Result<{ id: number }> =>
196 audited(db, who, "note", "delete", input.id, remove(db, who, input));
197
198 function remove(
199 db: Database,
200 who: Who,
201 input: { id: number },
202 ): Result<{ id: number }> {
203 const found = noteFor(db, who, "delete", input.id, {
204 signIn: "Sign in to remove a note.",
205 no: "Only the author or an admin can remove a note.",
206 });
207 if (!found.ok) return found;
208 db.query("DELETE FROM note WHERE id = ?1").run(found.value.id);
209 emit("note.removed", { id: found.value.id });
210 return ok({ id: found.value.id });
211 }
212
213 /** Whether `who` may remove `note`: the table's answer, for the page and the terminal to draw from. */
214 export const canDelete = (who: Who, note: Note): boolean =>
215 permit(who, "note", "delete", { ...note }) === "yes";
216
217 /**
218 * The audit trail, newest first, for whoever the table lets read it (an
219 * admin, as the scaffold has it). `before` pages back by id. Audit rows name
220 * no author, so a cell that gave `list` to a row's role would find nothing
221 * of its own: anything but "yes" is a refusal here.
222 */
223 export function listAudit(
224 db: Database,
225 who: Who,
226 input: { limit?: number; before?: number } = {},
227 ): Result<AuditRow[]> {
228 if (permit(who, "audit", "list") !== "yes")
229 return refuse(
230 who,
231 "Sign in to read the audit trail.",
232 "Only an admin can read the audit trail.",
233 );
234 const limit = Math.min(
235 Math.max(Math.trunc(input.limit ?? 100) || 100, 1),
236 500,
237 );
238 const before = Number.isInteger(input.before)
239 ? input.before!
240 : Number.MAX_SAFE_INTEGER;
241 return ok(
242 db
243 .query<
244 AuditRow,
245 [number, number]
246 >("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2")
247 .all(before, limit),
248 );
249 }
250
251 export function getAudit(
252 db: Database,
253 who: Who,
254 input: { id: number },
255 ): Result<AuditRow> {
256 const row = Number.isInteger(input.id)
257 ? db
258 .query<AuditRow, [number]>("SELECT * FROM audit WHERE id = ?1")
259 .get(input.id)
260 : null;
261 if (permit(who, "audit", "read", { ...row }) !== "yes")
262 return refuse(
263 who,
264 "Sign in to read the audit trail.",
265 "Only an admin can read the audit trail.",
266 );
267 if (row !== null) return ok(row);
268 if (!Number.isInteger(input.id))
269 return fail("invalid", "An audit id is a whole number.");
270 return fail("not_found", "No such audit row.");
271 }
src/audit.test.ts added +188
@@ -0,0 +1,188 @@
1 /**
2 * The trail: an allowed change and a refused one each leave a row, through
3 * the real server; only an admin reads them; and a trail that cannot be
4 * written costs a log line, never the request.
5 */
6 import { afterAll, expect, spyOn, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { addNote } from "./actions.ts";
11 import type { AuditRow } from "./audit.ts";
12 import { openDb } from "./db.ts";
13 import { serve } from "./server.ts";
14 import { sign } from "./who.ts";
15
16 const KEY = "a-key-only-the-forge-holds";
17 const server = serve({
18 port: 0,
19 dataDir: mkdtempSync(join(tmpdir(), "app-audit-")),
20 identityKey: KEY,
21 });
22 const base = `http://${server.hostname}:${server.port}`;
23 afterAll(() => server.stop(true));
24
25 const as = (
26 id: string,
27 name: string,
28 role: "user" | "admin" = "user",
29 ): Record<string, string> => {
30 const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`;
31 return {
32 "x-forge-identity": identity,
33 "x-forge-signature": sign(identity, KEY),
34 };
35 };
36 const alice = as("u1", "alice");
37 const bob = as("u2", "bob");
38 const root = as("u0", "root", "admin");
39 const call = (
40 path: string,
41 init: RequestInit = {},
42 who: Record<string, string> = {},
43 ) =>
44 fetch(base + path, {
45 ...init,
46 headers: { "content-type": "application/json", ...who },
47 });
48 const trail = async (): Promise<AuditRow[]> =>
49 (await (await call("/api/audit", {}, root)).json()) as AuditRow[];
50
51 test("an allowed change and a refused one each leave a row", async () => {
52 const made = await call(
53 "/api/notes",
54 { method: "POST", body: JSON.stringify({ body: "kept" }) },
55 alice,
56 );
57 expect(made.status).toBe(201);
58 const note = (await made.json()) as { id: number };
59 // Refused at the door: a stranger may create nothing.
60 expect(
61 (
62 await call("/api/notes", {
63 method: "POST",
64 body: JSON.stringify({ body: "x" }),
65 })
66 ).status,
67 ).toBe(401);
68 // Refused by the action: bob could delete a note of his own, not alice's.
69 expect(
70 (await call(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status,
71 ).toBe(403);
72 // Refused as cross-site, before the table is asked.
73 const forged = await fetch(`${base}/api/notes/${note.id}`, {
74 method: "DELETE",
75 headers: { ...alice, "sec-fetch-site": "cross-site" },
76 });
77 expect(forged.status).toBe(403);
78
79 const rows = (await trail()).slice(0, 4).reverse();
80 expect(
81 rows.map(
82 ({ who_name, role, resource, verb, target, allowed, outcome }) => ({
83 who_name,
84 role,
85 resource,
86 verb,
87 target,
88 allowed,
89 outcome,
90 }),
91 ),
92 ).toEqual([
93 {
94 who_name: "alice",
95 role: "user",
96 resource: "note",
97 verb: "create",
98 target: String(note.id),
99 allowed: 1,
100 outcome: "ok",
101 },
102 {
103 who_name: null,
104 role: "anon",
105 resource: "note",
106 verb: "create",
107 target: null,
108 allowed: 0,
109 outcome: "signin_required",
110 },
111 {
112 who_name: "bob",
113 role: "user",
114 resource: "note",
115 verb: "delete",
116 target: String(note.id),
117 allowed: 0,
118 outcome: "forbidden",
119 },
120 {
121 who_name: "alice",
122 role: "user",
123 resource: "note",
124 verb: "delete",
125 target: null,
126 allowed: 0,
127 outcome: "cross_site",
128 },
129 ]);
130 for (const row of rows)
131 expect(Date.parse(row.at)).toBeGreaterThan(Date.now() - 60_000);
132 });
133
134 test("reading is not an event: a GET leaves no row", async () => {
135 const before = (await trail()).length;
136 await call("/api/notes");
137 await call("/", {}, alice);
138 expect((await trail()).length).toBe(before);
139 });
140
141 test("only an admin reads the trail, at every door the table names", async () => {
142 expect((await call("/api/audit")).status).toBe(401);
143 expect((await call("/api/audit", {}, alice)).status).toBe(403);
144 const rows = await trail();
145 expect(rows.length).toBeGreaterThan(0);
146 expect((await call(`/api/audit/${rows[0]!.id}`, {}, root)).status).toBe(200);
147 expect((await call(`/api/audit/${rows[0]!.id}`, {}, alice)).status).toBe(403);
148 expect((await call("/api/audit/999999", {}, root)).status).toBe(404);
149 // Nobody writes the trail through a door: not even an admin.
150 expect(
151 (await call("/api/audit", { method: "POST", body: "{}" }, root)).status,
152 ).toBe(403);
153 expect(
154 (await call(`/api/audit/${rows[0]!.id}`, { method: "DELETE" }, root))
155 .status,
156 ).toBe(403);
157 // Paging back: `before` an id is everything older.
158 const older = (await (
159 await call(`/api/audit?before=${rows[0]!.id}&limit=1`, {}, root)
160 ).json()) as AuditRow[];
161 expect(older.map((r) => r.id)).toEqual([rows[1]!.id]);
162 });
163
164 test("a trail that cannot be written is a log line, and the change still happens", () => {
165 const db = openDb(mkdtempSync(join(tmpdir(), "app-audit-broken-")));
166 db.exec("DROP TABLE audit");
167 const said = spyOn(console, "error").mockImplementation(() => {});
168 try {
169 const added = addNote(
170 db,
171 { role: "user", id: "u1", name: "alice" },
172 { body: "still here" },
173 );
174 expect(added.ok).toBe(true);
175 expect(said).toHaveBeenCalledTimes(1);
176 const line = JSON.parse(String(said.mock.calls[0]![0]));
177 expect(line.audit).toMatchObject({
178 who_name: "alice",
179 resource: "note",
180 verb: "create",
181 allowed: 1,
182 outcome: "ok",
183 });
184 expect(line.unrecorded).toContain("audit");
185 } finally {
186 said.mockRestore();
187 }
188 });
src/audit.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * The audit trail: every attempt to change something, as one row — who,
3 * what (a resource and a verb from the contract), on which row, when, and
4 * whether they were let. Refusals are rows too: a stranger knocking on a
5 * door that stays shut is exactly what an owner wants to read later.
6 *
7 * Recording never fails a request. A row that cannot be written becomes a
8 * line in the log, with everything the row would have said, and the
9 * request goes on; the log is where the forge keeps a run's output.
10 *
11 * Reads are not recorded (a page view is not an event), and neither is a
12 * request refused for coming too fast: a flood must not become the history,
13 * or push the real history out.
14 */
15
16 import type { Database } from "bun:sqlite";
17 import type { Who } from "./who.ts";
18
19 export interface AuditRow {
20 readonly id: number;
21 readonly at: string;
22 readonly who_id: string | null;
23 readonly who_name: string | null;
24 readonly role: string;
25 readonly resource: string;
26 readonly verb: string;
27 readonly target: string | null;
28 readonly allowed: 0 | 1;
29 /** `ok`, or the failure: `signin_required`, `forbidden`, `invalid`, `not_found`, `cross_site`. */
30 readonly outcome: string;
31 }
32
33 export interface Attempt {
34 readonly who: Who;
35 readonly resource: string;
36 readonly verb: string;
37 readonly target?: string | number | undefined;
38 readonly outcome: string;
39 }
40
41 /** The newest rows kept; older ones go as new ones come. */
42 export const KEEP = 50_000;
43
44 /** Was it let? Only the door says no; a bad body or a missing row got past it. */
45 export const allowed = (outcome: string): boolean =>
46 outcome !== "signin_required" &&
47 outcome !== "forbidden" &&
48 outcome !== "cross_site";
49
50 export function record(db: Database, attempt: Attempt): void {
51 const { who, resource, verb, target, outcome } = attempt;
52 const row = {
53 who_id: who.role === "anon" ? null : who.id,
54 who_name: who.role === "anon" ? null : who.name,
55 role: who.role,
56 resource,
57 verb,
58 target: target === undefined ? null : String(target),
59 allowed: allowed(outcome) ? 1 : 0,
60 outcome,
61 };
62 try {
63 const { lastInsertRowid } = db
64 .query(
65 "INSERT INTO audit (who_id, who_name, role, resource, verb, target, allowed, outcome) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
66 )
67 .run(
68 row.who_id,
69 row.who_name,
70 row.role,
71 row.resource,
72 row.verb,
73 row.target,
74 row.allowed,
75 row.outcome,
76 );
77 db.query("DELETE FROM audit WHERE id <= ?1").run(
78 Number(lastInsertRowid) - KEEP,
79 );
80 } catch (error) {
81 // Generated app: one JSON line to stderr, its own log.
82 console.error(
83 JSON.stringify({
84 audit: row,
85 at: new Date().toISOString(),
86 unrecorded: String(error),
87 }),
88 );
89 }
90 }
src/can.ts added +389
@@ -0,0 +1,389 @@
1 /**
2 * Who may do what, decided from a table.
3 *
4 * An app's permissions are data: `contract.json` declares its roles and, for
5 * every resource, the least role each of the five verbs needs. This file is
6 * the whole evaluator. It is self-contained on purpose — no imports, no
7 * classes, nothing but values and total functions — because it is carried
8 * verbatim into every app the forge scaffolds, read by the forge to audit an
9 * app without running it, and meant to be ported line for line.
10 *
11 * Roles form an order, not a list. `above` names the roles directly below a
12 * role, and a role may do whatever anything below it may. How a role is
13 * *held* is a value too: by everyone, by an identity the forge signed, or by
14 * the row naming the actor. Adding a role adds a line to the contract and
15 * nothing here: the evaluator is the same size for three roles or three
16 * hundred.
17 *
18 * Everything fails closed: an undeclared resource, verb or role is "no".
19 */
20
21 export type Verb = "create" | "read" | "update" | "delete" | "list";
22 export const VERBS: readonly Verb[] = [
23 "create",
24 "read",
25 "update",
26 "delete",
27 "list",
28 ];
29
30 /** The cell that lets nobody in. Not a role: nothing can be above it. */
31 export const NOBODY = "nobody";
32
33 /** How a role is held. */
34 export type Held =
35 /** By anyone at all, signed in or not. A contract has exactly one. */
36 | { readonly by: "everyone" }
37 /** By an identity the forge signed as carrying `role`. */
38 | { readonly by: "identity"; readonly role: string }
39 /** By the row: its `column` holds the actor's id. */
40 | { readonly by: "row"; readonly column: string };
41
42 export interface Role {
43 /** The roles directly below this one. */
44 readonly above: readonly string[];
45 readonly held: Held;
46 }
47
48 export interface Resource {
49 /** The least role each verb needs, or `nobody`. */
50 readonly can: Readonly<Record<Verb, string>>;
51 /**
52 * The collection path of this resource's JSON API, when it has one: five
53 * doors follow by convention (`GET api` lists, `POST api` creates,
54 * `GET`/`PATCH`/`DELETE api/<id>` read, update and delete one), which is
55 * how the forge can knock on a running app and compare with the table.
56 */
57 readonly api?: string;
58 }
59
60 export interface Contract {
61 readonly roles: Readonly<Record<string, Role>>;
62 readonly resources: Readonly<Record<string, Resource>>;
63 }
64
65 /**
66 * Who is asking: an id when signed in, and the roles the forge signed. An
67 * empty id is nobody: it never names a row, however a row was filled.
68 */
69 export interface Actor {
70 readonly id: string | null;
71 readonly roles: readonly string[];
72 }
73
74 /**
75 * The answer. `where` is for a verb with no row in hand (`list`): the actor
76 * may have exactly the rows whose named columns hold their id.
77 */
78 export type Decision = "yes" | "no" | { readonly where: readonly string[] };
79
80 /**
81 * A declared thing by name, and nothing else: `constructor` and its kin are
82 * on every object and are not roles.
83 */
84 function own<T>(of: Readonly<Record<string, T>>, name: string): T | undefined {
85 return Object.prototype.hasOwnProperty.call(of, name) ? of[name] : undefined;
86 }
87
88 /**
89 * Every role with everything at or below it, computed once per contract
90 * and kept beside it. A door is asked on every request, and an order of a
91 * few hundred roles must answer in the time a hash lookup takes, not the
92 * time a walk takes; the walk happens once, when a contract is first used.
93 * The cache is keyed by the contract object itself, so it needs no
94 * invalidation: a new contract is a new object.
95 */
96 const closures = new WeakMap<Contract, Map<string, Set<string>>>();
97
98 function closureOf(contract: Contract): Map<string, Set<string>> {
99 const kept = closures.get(contract);
100 if (kept !== undefined) return kept;
101 const out = new Map<string, Set<string>>();
102 for (const name of Object.keys(contract.roles)) {
103 const seen = new Set<string>([name]);
104 const walk = [name];
105 while (walk.length > 0) {
106 const at = walk.pop()!;
107 for (const below of own(contract.roles, at)?.above ?? [])
108 if (!seen.has(below)) {
109 seen.add(below);
110 walk.push(below);
111 }
112 }
113 out.set(name, seen);
114 }
115 closures.set(contract, out);
116 return out;
117 }
118
119 /** Whether role `low` is `high` or below it; an undeclared role is neither. */
120 export function atOrBelow(
121 contract: Contract,
122 low: string,
123 high: string,
124 ): boolean {
125 return closureOf(contract).get(high)?.has(low) ?? false;
126 }
127
128 export function decide(
129 contract: Contract,
130 resource: string,
131 verb: Verb,
132 actor: Actor,
133 row?: Readonly<Record<string, unknown>>,
134 ): Decision {
135 const can = own(contract.resources, resource)?.can;
136 const need = can === undefined ? undefined : own(can, verb);
137 if (need === undefined || own(contract.roles, need) === undefined)
138 return "no";
139 const where: string[] = [];
140 for (const name of Object.keys(contract.roles)) {
141 if (!atOrBelow(contract, need, name)) continue;
142 const held = contract.roles[name]!.held;
143 if (held.by === "everyone") return "yes";
144 if (held.by === "identity") {
145 if (actor.roles.includes(held.role)) return "yes";
146 continue;
147 }
148 if (actor.id === null || actor.id === "") continue;
149 if (row !== undefined) {
150 if (own(row, held.column) === actor.id) return "yes";
151 } else if (verb === "list" && !where.includes(held.column))
152 where.push(held.column);
153 }
154 return where.length > 0 ? { where } : "no";
155 }
156
157 /** `decide`, for the doors that only need yes or no about one row. */
158 export const may = (
159 contract: Contract,
160 resource: string,
161 verb: Verb,
162 actor: Actor,
163 row?: Readonly<Record<string, unknown>>,
164 ): boolean => decide(contract, resource, verb, actor, row) === "yes";
165
166 // ---- reading a contract: strict, total, every problem named ----
167
168 export type Read =
169 | { readonly ok: true; readonly contract: Contract }
170 | { readonly ok: false; readonly problems: readonly string[] };
171
172 const isObject = (v: unknown): v is Record<string, unknown> =>
173 typeof v === "object" && v !== null && !Array.isArray(v);
174 const NAME = /^[a-z][a-z0-9_]{0,39}$/;
175 /** An absolute path of plain segments: no query, no dot-segments, no host. */
176 const API_PATH = /^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/;
177
178 function readHeld(v: unknown): Held | undefined {
179 if (!isObject(v)) return undefined;
180 if (v["by"] === "everyone") return { by: "everyone" };
181 if (v["by"] === "identity" && typeof v["role"] === "string")
182 return NAME.test(v["role"])
183 ? { by: "identity", role: v["role"] }
184 : undefined;
185 if (v["by"] === "row" && typeof v["column"] === "string")
186 return NAME.test(v["column"])
187 ? { by: "row", column: v["column"] }
188 : undefined;
189 return undefined;
190 }
191
192 /** Parse and check a contract from untrusted JSON text. */
193 export function readContract(text: string): Read {
194 let raw: unknown;
195 try {
196 raw = JSON.parse(text);
197 } catch {
198 return { ok: false, problems: ["contract.json is not JSON"] };
199 }
200 if (!isObject(raw) || !isObject(raw["roles"]) || !isObject(raw["resources"]))
201 return {
202 ok: false,
203 problems: ["a contract is an object with `roles` and `resources`"],
204 };
205 const problems: string[] = [];
206 const roles: Record<string, Role> = {};
207 for (const [name, v] of Object.entries(raw["roles"])) {
208 const held = isObject(v) ? readHeld(v["held"]) : undefined;
209 const above = isObject(v) ? (v["above"] ?? []) : undefined;
210 if (!NAME.test(name) || name === NOBODY)
211 problems.push(`role \`${name}\`: not a usable name`);
212 else if (
213 held === undefined ||
214 !Array.isArray(above) ||
215 !above.every((a): a is string => typeof a === "string")
216 )
217 problems.push(`role \`${name}\`: needs \`held\` and a list \`above\``);
218 else roles[name] = { above, held };
219 }
220 const resources: Record<string, Resource> = {};
221 for (const [name, v] of Object.entries(raw["resources"])) {
222 const can = isObject(v) && isObject(v["can"]) ? v["can"] : undefined;
223 if (!NAME.test(name) || !isObject(v) || can === undefined) {
224 problems.push(`resource \`${name}\`: needs a name and a \`can\` table`);
225 continue;
226 }
227 const cells = {} as Record<Verb, string>;
228 for (const verb of VERBS) {
229 const cell = can[verb];
230 if (typeof cell === "string") cells[verb] = cell;
231 else
232 problems.push(`${name}.${verb}: every verb names a role or \`nobody\``);
233 }
234 for (const extra of Object.keys(can))
235 if (!VERBS.includes(extra as Verb))
236 problems.push(`${name}.${extra}: not one of ${VERBS.join(", ")}`);
237 const api = v["api"];
238 if (api !== undefined && (typeof api !== "string" || !API_PATH.test(api)))
239 problems.push(
240 `resource \`${name}\`: \`api\` is an absolute path of plain segments, like /api/${name}s`,
241 );
242 resources[name] =
243 typeof api === "string" && API_PATH.test(api)
244 ? { can: cells, api }
245 : { can: cells };
246 }
247 const contract: Contract = { roles, resources };
248 problems.push(...check(contract));
249 // A cell read here and checked there is one problem, said once.
250 const unique = [...new Set(problems)];
251 return unique.length > 0
252 ? { ok: false, problems: unique }
253 : { ok: true, contract };
254 }
255
256 /** What is wrong with a contract that parsed. Empty means sound. */
257 export function check(contract: Contract): string[] {
258 const problems: string[] = [];
259 const names = Object.keys(contract.roles);
260 const floors = names.filter((n) => contract.roles[n]!.held.by === "everyone");
261 if (floors.length !== 1)
262 problems.push("exactly one role is held by everyone: the floor");
263 // Two roles held by one identity are one role with two names: the table
264 // would show two columns that are the same person.
265 const byIdentity = new Map<string, string>();
266 for (const name of names) {
267 const held = contract.roles[name]!.held;
268 if (held.by !== "identity") continue;
269 const other = byIdentity.get(held.role);
270 if (other !== undefined)
271 problems.push(
272 `roles \`${other}\` and \`${name}\` are both held by the identity \`${held.role}\``,
273 );
274 else byIdentity.set(held.role, name);
275 }
276 for (const name of names) {
277 for (const below of contract.roles[name]!.above) {
278 if (own(contract.roles, below) === undefined)
279 problems.push(
280 `role \`${name}\` is above \`${below}\`, which is not declared`,
281 );
282 else if (atOrBelow(contract, name, below))
283 problems.push(`role \`${name}\` is both above and below \`${below}\``);
284 }
285 const floor = floors[0];
286 if (floor !== undefined && !atOrBelow(contract, floor, name))
287 problems.push(
288 `role \`${name}\` does not stand on the floor \`${floor}\``,
289 );
290 }
291 for (const [resource, { can }] of Object.entries(contract.resources))
292 for (const verb of VERBS) {
293 const need = own(can, verb);
294 if (need === undefined) {
295 problems.push(
296 `${resource}.${verb}: every verb names a role or \`nobody\``,
297 );
298 continue;
299 }
300 if (need === NOBODY) continue;
301 const role = own(contract.roles, need);
302 if (role === undefined)
303 problems.push(
304 `${resource}.${verb} needs \`${need}\`, which is not declared`,
305 );
306 else if (verb === "create" && role.held.by === "row")
307 problems.push(
308 `${resource}.create needs \`${need}\`, which a row holds: there is no row yet`,
309 );
310 }
311 return problems;
312 }
313
314 // ---- the table: the contract as the audit reads it ----
315
316 export interface Cell {
317 readonly resource: string;
318 readonly verb: Verb;
319 /** Who is asking: the floor, or a role an identity carries. */
320 readonly as: string;
321 /** `own`: only the rows that name the actor. */
322 readonly answer: "yes" | "no" | "own";
323 }
324
325 /**
326 * Who someone can be. A role a row holds is not one of them: it is what an
327 * actor is *to a row*, and shows in the table as `own`.
328 */
329 export const identities = (contract: Contract): string[] =>
330 Object.keys(contract.roles).filter(
331 (name) => contract.roles[name]!.held.by !== "row",
332 );
333
334 /**
335 * The facts that make someone `identity` and nothing more: the forge acts as
336 * each identity this way, for the table here and for the audit's probes.
337 * `mine` is a row that names the actor wherever a row can; `theirs` names
338 * someone else. Anything that is not an identity acts as a stranger.
339 */
340 export function actingAs(
341 contract: Contract,
342 identity: string,
343 ): {
344 actor: Actor;
345 mine: Record<string, unknown>;
346 theirs: Record<string, unknown>;
347 } {
348 const held = own(contract.roles, identity)?.held;
349 const actor: Actor =
350 held?.by === "identity"
351 ? { id: "the-actor", roles: [held.role] }
352 : { id: null, roles: [] };
353 const mine: Record<string, unknown> = {};
354 const theirs: Record<string, unknown> = {};
355 for (const role of Object.values(contract.roles))
356 if (role.held.by === "row") {
357 mine[role.held.column] = "the-actor";
358 theirs[role.held.column] = "someone-else";
359 }
360 return { actor, mine, theirs };
361 }
362
363 /** Every resource, verb and identity, answered. The policy, as a table. */
364 export function table(contract: Contract): Cell[] {
365 const out: Cell[] = [];
366 for (const resource of Object.keys(contract.resources))
367 for (const verb of VERBS)
368 for (const as of identities(contract)) {
369 const { actor, mine, theirs } = actingAs(contract, as);
370 const rowless = verb === "create" || verb === "list";
371 const any = decide(
372 contract,
373 resource,
374 verb,
375 actor,
376 rowless ? undefined : theirs,
377 );
378 const owned = rowless
379 ? any
380 : decide(contract, resource, verb, actor, mine);
381 out.push({
382 resource,
383 verb,
384 as,
385 answer: any === "yes" ? "yes" : owned === "no" ? "no" : "own",
386 });
387 }
388 return out;
389 }
src/contract.test.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * Every door answers as the table says. The test acts as each identity the
3 * contract declares — on a note that names them and on one that names
4 * someone else — and calls the real actions. A rule written by hand in an
5 * action, or a cell changed without its door, fails here.
6 */
7 import { expect, test } from "bun:test";
8 import { mkdtempSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import {
12 addNote,
13 deleteNote,
14 getAudit,
15 getNote,
16 listAudit,
17 listNotes,
18 updateNote,
19 } from "./actions.ts";
20 import { record } from "./audit.ts";
21 import { actingAs, identities, table, type Verb } from "./can.ts";
22 import { contract } from "./contract.ts";
23 import { openDb } from "./db.ts";
24 import type { Who } from "./who.ts";
25
26 const db = openDb(mkdtempSync(join(tmpdir(), "app-contract-")));
27
28 /** The identity the evaluator acts as, as the forge would have signed it. */
29 function whoIs(identity: string): Who {
30 const { actor } = actingAs(contract, identity);
31 const role = actor.roles[0];
32 if (actor.id === null || (role !== "user" && role !== "admin"))
33 return { role: "anon" };
34 return { role, id: actor.id, name: identity };
35 }
36
37 const noteBy = (authorId: string): number =>
38 db
39 .query<
40 { id: number },
41 [string]
42 >("INSERT INTO note (body, author_id, author_name) VALUES ('n', ?1, ?1) RETURNING id")
43 .get(authorId)!.id;
44
45 const auditRow = (): number => {
46 record(db, {
47 who: { role: "anon" },
48 resource: "note",
49 verb: "create",
50 outcome: "signin_required",
51 });
52 return db.query<{ id: number }, []>("SELECT max(id) AS id FROM audit").get()!
53 .id;
54 };
55
56 /** One door per verb an action serves: did it let `who` through, on `row`? */
57 const doors: Record<
58 string,
59 Partial<Record<Verb, (who: Who, authorId: string) => boolean>>
60 > = {
61 note: {
62 create: (who) => addNote(db, who, { body: "hello" }).ok,
63 list: (who) => listNotes(db, who).ok,
64 read: (who, authorId) => getNote(db, who, { id: noteBy(authorId) }).ok,
65 update: (who, authorId) =>
66 updateNote(db, who, { id: noteBy(authorId), body: "changed" }).ok,
67 delete: (who, authorId) => deleteNote(db, who, { id: noteBy(authorId) }).ok,
68 },
69 // The trail is written by the app itself, never through a door: there
70 // is no action that creates, changes or removes a row of it.
71 audit: {
72 list: (who) => listAudit(db, who).ok,
73 read: (who) => getAudit(db, who, { id: auditRow() }).ok,
74 create: () => false,
75 update: () => false,
76 delete: () => false,
77 },
78 };
79
80 test("the contract names the identities the forge signs", () => {
81 expect(identities(contract).sort()).toEqual(["admin", "anon", "user"]);
82 });
83
84 test("every resource in the contract has every door here", () => {
85 expect(Object.keys(doors).sort()).toEqual(
86 Object.keys(contract.resources).sort(),
87 );
88 for (const resource of Object.keys(contract.resources))
89 expect(Object.keys(doors[resource] ?? {}).sort()).toEqual([
90 "create",
91 "delete",
92 "list",
93 "read",
94 "update",
95 ]);
96 });
97
98 test("every door answers as the table says, as every identity", () => {
99 let asked = 0;
100 for (const cell of table(contract)) {
101 const door = doors[cell.resource]?.[cell.verb];
102 if (door === undefined) continue;
103 const who = whoIs(cell.as);
104 const { mine, theirs } = actingAs(contract, cell.as);
105 const onMine = door(who, String(mine["author_id"]));
106 const onTheirs = door(who, String(theirs["author_id"]));
107 const said = `${cell.resource}.${cell.verb} as ${cell.as}`;
108 asked++;
109 if (cell.answer === "yes")
110 expect([said, onMine, onTheirs]).toEqual([said, true, true]);
111 else if (cell.answer === "no")
112 expect([said, onMine, onTheirs]).toEqual([said, false, false]);
113 else if (cell.verb === "list") expect([said, onMine]).toEqual([said, true]);
114 else expect([said, onMine, onTheirs]).toEqual([said, true, false]);
115 }
116 expect(asked).toBeGreaterThan(0);
117 });
src/contract.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * Who may do what. `contract.json` is the table — the roles, and the least
3 * role each verb on each resource needs — and `src/can.ts` is the forge's
4 * evaluator, carried here verbatim. Actions ask `permit` and nothing else
5 * in the app decides who may do what, so the table *is* the policy: it can
6 * be read, compared and audited without running anything.
7 *
8 * A contract that does not read is not a warning. The app does not start.
9 */
10
11 import { readFileSync } from "node:fs";
12 import {
13 decide,
14 readContract,
15 type Actor,
16 type Decision,
17 type Verb,
18 } from "./can.ts";
19 import type { Who } from "./who.ts";
20
21 const read = readContract(
22 readFileSync(new URL("../contract.json", import.meta.url), "utf8"),
23 );
24 if (!read.ok) throw new Error(`contract.json: ${read.problems.join("; ")}`);
25
26 export const contract = read.contract;
27
28 /** The forge's word for who is asking, as the evaluator takes it. */
29 export const actorOf = (who: Who): Actor =>
30 who.role === "anon"
31 ? { id: null, roles: [] }
32 : { id: who.id, roles: [who.role] };
33
34 /** May `who` do `verb` to `resource` — to `row`, when there is one? */
35 export const permit = (
36 who: Who,
37 resource: string,
38 verb: Verb,
39 row?: Readonly<Record<string, unknown>>,
40 ): Decision => decide(contract, resource, verb, actorOf(who), row);
src/db.test.ts added +23
@@ -0,0 +1,23 @@
1 import { expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { check, migrate, migrations, openDb } from "./db.ts";
6
7 test("migrations apply once, in order, and the file checks out", () => {
8 const dir = mkdtempSync(join(tmpdir(), "app-db-"));
9 const db = openDb(dir);
10 expect(check(db).version).toBe(migrations.length);
11 // Opening again finds nothing to do — a migration that has run is history.
12 expect(migrate(db)).toBe(migrations.length);
13 expect(() => openDb(dir)).not.toThrow();
14 expect(check(db)).toEqual({ version: migrations.length, integrity: "ok" });
15 expect(
16 db.query<{ journal_mode: string }, []>("PRAGMA journal_mode").get()
17 ?.journal_mode,
18 ).toBe("wal");
19 expect(
20 db.query<{ foreign_keys: number }, []>("PRAGMA foreign_keys").get()
21 ?.foreign_keys,
22 ).toBe(1);
23 });
src/db.ts added +96
@@ -0,0 +1,96 @@
1 /**
2 * The database: one SQLite file under FORGE_APP_DATA, so production and
3 * every preview each have their own. WAL so readers never wait on the
4 * writer (the terminal is a second process on the same file), foreign keys
5 * on, and a forward-only list of migrations applied once, in a
6 * transaction, when the file is opened.
7 *
8 * bun run src/db.ts --check the schema version and an integrity check
9 */
10
11 import { Database } from "bun:sqlite";
12 import { mkdirSync } from "node:fs";
13 import { join } from "node:path";
14 import { dataDir } from "./env.ts";
15
16 /** Append, never edit: a migration that has run is history. */
17 export const migrations: string[] = [
18 `CREATE TABLE note (
19 id INTEGER PRIMARY KEY,
20 body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 500),
21 author_id TEXT NOT NULL,
22 author_name TEXT NOT NULL,
23 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
24 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
25 )`,
26 `CREATE INDEX note_author ON note (author_id)`,
27 // Who tried to change what, and whether they were let. Appended by
28 // src/audit.ts, never edited; read by admins at GET /api/audit.
29 `CREATE TABLE audit (
30 id INTEGER PRIMARY KEY,
31 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
32 who_id TEXT,
33 who_name TEXT,
34 role TEXT NOT NULL,
35 resource TEXT NOT NULL,
36 verb TEXT NOT NULL,
37 target TEXT,
38 allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)),
39 outcome TEXT NOT NULL
40 )`,
41 ];
42
43 export function openDb(dir = dataDir): Database {
44 mkdirSync(dir, { recursive: true });
45 const db = new Database(join(dir, "app.db"));
46 db.exec("PRAGMA journal_mode = WAL");
47 db.exec("PRAGMA foreign_keys = ON");
48 db.exec("PRAGMA busy_timeout = 5000");
49 migrate(db);
50 return db;
51 }
52
53 /** Apply the migrations past the recorded version, all or none. Returns the version. */
54 export function migrate(db: Database): number {
55 db.exec(
56 "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))",
57 );
58 return db.transaction(() => {
59 const current = db
60 .query<
61 { version: number },
62 []
63 >("SELECT coalesce(max(version), 0) AS version FROM schema_version")
64 .get()!.version;
65 migrations.slice(current).forEach((sql, i) => {
66 db.exec(sql);
67 db.query("INSERT INTO schema_version (version) VALUES (?1)").run(
68 current + i + 1,
69 );
70 });
71 return migrations.length;
72 })();
73 }
74
75 export function check(db: Database): { version: number; integrity: string } {
76 const version = db
77 .query<
78 { version: number },
79 []
80 >("SELECT max(version) AS version FROM schema_version")
81 .get()!.version;
82 const integrity = db
83 .query<{ integrity_check: string }, []>("PRAGMA integrity_check")
84 .get()!.integrity_check;
85 return { version, integrity };
86 }
87
88 if (import.meta.main) {
89 const db = openDb();
90 const { version, integrity } = check(db);
91 console.log(`schema version ${version}`);
92 if (process.argv.includes("--check")) {
93 console.log(`integrity ${integrity}`);
94 process.exit(integrity === "ok" ? 0 : 1);
95 }
96 }
src/design.test.ts added +111
@@ -0,0 +1,111 @@
1 /**
2 * The look holds: the tokens compile to both themes, every pair of colours
3 * a person must read meets WCAG AA in both, and no colour is spelled out
4 * anywhere but design/tokens.json.
5 */
6 import { expect, test } from "bun:test";
7 import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { lint, rawColours, unknownTokens } from "../scripts/lint-design.ts";
11 import { compile, flatten, token, tokens, tokensCss } from "./tokens.ts";
12
13 test("the tokens compile: light on :root, dark for the system and for data-theme", () => {
14 expect(tokensCss).toContain(`--color-bg: ${token("color-bg")};`);
15 expect(tokensCss).toContain("--space-4: 1rem;");
16 expect(tokensCss).toContain(
17 '--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;',
18 );
19 expect(tokensCss).toContain("@media (prefers-color-scheme: dark)");
20 expect(tokensCss).toContain(':root:not([data-theme="light"])');
21 expect(tokensCss).toContain(':root[data-theme="dark"]');
22 // Every colour has a dark value, or the dark theme would inherit a light one.
23 for (const t of tokens.filter((t) => t.type === "color"))
24 expect([t.name, t.dark !== undefined]).toEqual([t.name, true]);
25 expect(
26 tokensCss.split(`--color-bg: ${token("color-bg", "dark")};`).length,
27 ).toBe(3);
28 });
29
30 test("tokens that do not read stop the app, with the path named", () => {
31 expect(() => flatten({ color: { bg: { $value: "#fff" } } })).toThrow(
32 "color.bg: no $type",
33 );
34 expect(() =>
35 flatten({
36 color: { $type: "color", bg: { $value: "red; } body { display: none" } },
37 }),
38 ).toThrow("color.bg: $value is not a CSS value");
39 expect(() =>
40 flatten({ color: { $type: "color", "b g": { $value: "#fff" } } }),
41 ).toThrow("not a name CSS can carry");
42 expect(
43 compile(flatten({ size: { $type: "dimension", tap: { $value: "44px" } } })),
44 ).toContain("--size-tap: 44px;");
45 });
46
47 // WCAG 2 relative luminance and contrast, from a #rrggbb.
48 const luminance = (hex: string): number => {
49 const [r, g, b] = [1, 3, 5].map((i) => {
50 const c = parseInt(hex.slice(i, i + 2), 16) / 255;
51 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
52 }) as [number, number, number];
53 return 0.2126 * r + 0.7152 * g + 0.0722 * b;
54 };
55 const contrast = (a: string, b: string): number => {
56 const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) as [
57 number,
58 number,
59 ];
60 return (x + 0.05) / (y + 0.05);
61 };
62
63 test("every pair a person reads is at least 4.5:1, in both themes", () => {
64 const pairs = [
65 ["color-fg", "color-bg"],
66 ["color-fg", "color-surface"],
67 ["color-muted", "color-bg"],
68 ["color-muted", "color-surface"],
69 ["color-accent", "color-bg"],
70 ["color-accent", "color-surface"],
71 ["color-accent-fg", "color-accent"],
72 ["color-toast-fg", "color-toast-bg"],
73 ] as const;
74 for (const mode of ["light", "dark"] as const)
75 for (const [fg, bg] of pairs) {
76 const ratio = contrast(token(fg, mode), token(bg, mode));
77 expect([mode, fg, bg, ratio >= 4.5]).toEqual([mode, fg, bg, true]);
78 }
79 });
80
81 test("the app spells out no colour but in its tokens", () => {
82 expect(lint(join(import.meta.dir, ".."))).toEqual([]);
83 });
84
85 test("the lint refuses a raw colour and a token that is not there", () => {
86 expect(
87 rawColours(
88 "a.css",
89 ".x { color: #ff0000; }\n.y { background: rgb(0 0 0); }",
90 ),
91 ).toEqual([
92 "a.css:1: raw colour #ff0000 — name it in design/tokens.json and use var(--…)",
93 "a.css:2: raw colour rgb( — name it in design/tokens.json and use var(--…)",
94 ]);
95 // Not colours: an entity, an id selector's word, a pull number.
96 expect(rawColours("a.ts", "`&#39;` #notes pull #12 #abcdefg")).toEqual([]);
97 expect(
98 unknownTokens(
99 "a.css",
100 ".x { color: var(--color-fg); gap: var(--spcae-2); }",
101 ),
102 ).toEqual(["a.css:1: --spcae-2 is not a token in design/tokens.json"]);
103 const root = mkdtempSync(join(tmpdir(), "app-lint-"));
104 mkdirSync(join(root, "static"));
105 mkdirSync(join(root, "src"));
106 writeFileSync(join(root, "static", "extra.css"), "a {\n color: #fff;\n}\n");
107 writeFileSync(join(root, "src", "ok.test.ts"), "const red = '#f00';\n");
108 expect(lint(root)).toEqual([
109 "static/extra.css:2: raw colour #fff — name it in design/tokens.json and use var(--…)",
110 ]);
111 });
src/env.ts added +32
@@ -0,0 +1,32 @@
1 /**
2 * What the forge told this process, read once and named once. Every other
3 * module takes these from here, so nothing else spells an environment
4 * variable — and running the app by hand needs none of them.
5 */
6
7 import pkg from "../package.json";
8
9 export const title: string = pkg.name;
10 /** The app's own version (package.json): what `/healthz` and `/openapi.json` say. */
11 export const version: string = pkg.version ?? "0.0.0";
12 /** When this process started, for `/healthz`'s uptime. */
13 export const startedAt: number = Date.now();
14 export const description: string = pkg.description || "A new app on the forge.";
15 export const dataDir: string = process.env.FORGE_APP_DATA ?? "./data";
16 export const env: string = process.env.FORGE_APP_ENV ?? "local";
17 export const commit: string = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
18 /** Where this process runs, for a person: `production`, `preview of pull #3`, `local`. */
19 export const where: string =
20 env === "preview"
21 ? `preview of pull #${process.env.FORGE_APP_PULL ?? "?"}`
22 : env;
23 /** The exact command that opens this environment's terminal. Print it verbatim, never a guess. */
24 export const ssh: string | undefined = process.env.FORGE_APP_SSH;
25 /** The address the forge serves us at — what a browser sees, never the socket's. */
26 export const publicOrigin: string = new URL(
27 process.env.FORGE_APP_URL ?? "http://localhost",
28 ).origin;
29 /** The forge that runs us. Its own well-known files (security.txt) speak for its apps. */
30 export const forgeUrl: string | undefined = process.env.FORGE_URL;
31 /** The key the forge signs identities with (`who.ts`). Unset, everyone is anonymous. */
32 export const identityKey: string = process.env.FORGE_APP_IDENTITY_KEY ?? "";
src/http.ts added +193
@@ -0,0 +1,193 @@
1 /**
2 * The floor every response stands on, and the few things a route needs to
3 * read a request safely. Nothing in here knows what a note is; who may
4 * come in at all is `security.ts`.
5 */
6
7 import { createHash } from "node:crypto";
8 import { existsSync, readFileSync } from "node:fs";
9 import { join } from "node:path";
10 import { env, publicOrigin } from "./env.ts";
11
12 // Scripts and styles come from this origin (inline styles allowed, inline
13 // scripts not), nothing frames us, browsers never guess a content type, and
14 // referrers stay on this site. Once served over https, always: browsers
15 // remember for a year. Anything but production asks not to be indexed: a
16 // preview is a draft, and its address goes away with its pull.
17 export const SECURITY: Record<string, string> = {
18 "content-security-policy":
19 "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
20 "x-content-type-options": "nosniff",
21 "referrer-policy": "strict-origin-when-cross-origin",
22 "permissions-policy": "camera=(), microphone=(), geolocation=()",
23 ...(publicOrigin.startsWith("https:")
24 ? { "strict-transport-security": "max-age=31536000" }
25 : {}),
26 ...(env === "production" ? {} : { "x-robots-tag": "noindex" }),
27 };
28
29 export const escape = (s: string): string =>
30 s.replace(
31 /[&<>"']/g,
32 (c) =>
33 ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
34 c
35 ] ?? c,
36 );
37
38 export const html = (
39 body: string,
40 status = 200,
41 headers: Record<string, string> = {},
42 ): Response =>
43 new Response(body, {
44 status,
45 headers: {
46 "content-type": "text/html; charset=utf-8",
47 ...SECURITY,
48 ...headers,
49 },
50 });
51
52 export const json = (data: unknown, status = 200): Response =>
53 new Response(JSON.stringify(data), {
54 status,
55 headers: { "content-type": "application/json; charset=utf-8", ...SECURITY },
56 });
57
58 export const text = (body: string, status = 200): Response =>
59 new Response(body, {
60 status,
61 headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY },
62 });
63
64 /** Back to the page, with a word for it to show once (`readNotice`). */
65 export const back = (notice?: string): Response =>
66 new Response(null, {
67 status: 303,
68 headers: {
69 location: "/",
70 ...SECURITY,
71 ...(notice === undefined
72 ? {}
73 : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }),
74 },
75 });
76
77 /** The notice a redirect left, and the header that clears it. */
78 export function readNotice(request: Request): {
79 notice?: string;
80 clear: Record<string, string>;
81 } {
82 const raw = /(?:^|;\s*)notice=([^;]*)/.exec(
83 request.headers.get("cookie") ?? "",
84 )?.[1];
85 if (raw === undefined) return { clear: {} };
86 return {
87 notice: decodeURIComponent(raw),
88 clear: { "set-cookie": cookie("notice", "", 0) },
89 };
90 }
91
92 // Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure
93 // wherever the public address is https.
94 const cookie = (name: string, value: string, maxAge: number): string =>
95 `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
96
97 // A request body is read up to a cap and no further, counting the bytes as
98 // they arrive. A declared Content-Length is a claim: a chunked request has
99 // none, and a lie is free. Undefined means "too large" — refuse it. Past the
100 // cap the rest is drained and dropped so the connection stays usable for
101 // the next request; past eight times the cap it is cut off instead.
102 export const BODY_MAX = 64 * 1024;
103 export async function readBody(
104 request: Request,
105 max = BODY_MAX,
106 ): Promise<Uint8Array | undefined> {
107 const reader = request.body?.getReader();
108 if (reader === undefined) return new Uint8Array();
109 const parts: Uint8Array[] = [];
110 let size = 0;
111 for (;;) {
112 const { done, value } = await reader.read();
113 if (done) break;
114 size += value.byteLength;
115 if (size > max) {
116 for (;;) {
117 const next = await reader.read();
118 if (next.done) return undefined;
119 size += next.value.byteLength;
120 if (size > max * 8) {
121 await reader.cancel();
122 return undefined;
123 }
124 }
125 }
126 parts.push(value);
127 }
128 const body = new Uint8Array(size);
129 let at = 0;
130 for (const part of parts) {
131 body.set(part, at);
132 at += part.byteLength;
133 }
134 return body;
135 }
136
137 /** The form fields of a capped body, decoded the way the browser sent them. */
138 export async function readForm(request: Request) {
139 const body = await readBody(request);
140 if (body === undefined) return undefined;
141 return new Request(request.url, {
142 method: "POST",
143 headers: { "content-type": request.headers.get("content-type") ?? "" },
144 body: new Uint8Array(body),
145 }).formData();
146 }
147
148 /** The JSON of a capped body: `undefined` when too large, `null` when not JSON. */
149 export async function readJson(request: Request): Promise<unknown> {
150 const body = await readBody(request);
151 if (body === undefined) return undefined;
152 try {
153 return JSON.parse(new TextDecoder().decode(body));
154 } catch {
155 return null;
156 }
157 }
158
159 /** A JSON object's fields, whatever the wire carried. */
160 export const fields = (value: unknown): Record<string, unknown> =>
161 typeof value === "object" && value !== null && !Array.isArray(value)
162 ? (value as Record<string, unknown>)
163 : {};
164
165 // Files under ./static are served as they are. A page links each one by
166 // its content (`/static/app.css?v=3f2a9c1e`), and that address is cached
167 // for a year: a change is a new address, never a stale cache.
168 const STATIC = join(import.meta.dir, "..", "static");
169 const versions = new Map<string, string>();
170 export function asset(name: string): string {
171 let v = versions.get(name);
172 if (v === undefined) {
173 v = createHash("sha256")
174 .update(readFileSync(join(STATIC, name)))
175 .digest("hex")
176 .slice(0, 8);
177 versions.set(name, v);
178 }
179 return `/static/${name}?v=${v}`;
180 }
181 export function staticFile(name: string, versioned: boolean): Response {
182 const path = join(STATIC, name);
183 if (name.includes("/") || name.includes("..") || !existsSync(path))
184 return text("not found", 404);
185 return new Response(Bun.file(path), {
186 headers: {
187 "cache-control": versioned
188 ? "public, max-age=31536000, immutable"
189 : "no-cache",
190 ...SECURITY,
191 },
192 });
193 }
src/live.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * Realtime without a framework. Actions `emit` what changed; `GET /events`
3 * streams it to every open page as server-sent events, numbered, with the
4 * last hundred kept so a client that reconnects with `Last-Event-ID` (or
5 * `?since=`) hears what it missed. `static/live.js` turns the stream into
6 * DOM events. The bus is this process's: the terminal is another process,
7 * and its writes show on the next load rather than live.
8 */
9
10 export interface LiveEvent {
11 readonly id: number;
12 readonly type: string;
13 readonly data: unknown;
14 }
15
16 const KEEP = 100;
17 const HEARTBEAT_MS = 25_000;
18 const kept: LiveEvent[] = [];
19 const listeners = new Set<(event: LiveEvent) => void>();
20 let last = 0;
21
22 export function emit(type: string, data: unknown): LiveEvent {
23 const event: LiveEvent = { id: ++last, type, data };
24 kept.push(event);
25 if (kept.length > KEEP) kept.shift();
26 for (const listen of listeners) listen(event);
27 return event;
28 }
29
30 export function subscribe(listen: (event: LiveEvent) => void): () => void {
31 listeners.add(listen);
32 return () => listeners.delete(listen);
33 }
34
35 /** `GET /events`: a stream that replays what `since` missed, then follows. */
36 export function events(request: Request): Response {
37 const since = Number(
38 request.headers.get("last-event-id") ??
39 new URL(request.url).searchParams.get("since") ??
40 0,
41 );
42 const encoder = new TextEncoder();
43 let stop = (): void => {};
44 const stream = new ReadableStream<Uint8Array>({
45 start(controller) {
46 const write = (text: string): void => {
47 try {
48 controller.enqueue(encoder.encode(text));
49 } catch {
50 stop();
51 }
52 };
53 const send = (event: LiveEvent): void =>
54 write(
55 `id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`,
56 );
57 const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
58 const unsubscribe = subscribe(send);
59 stop = () => {
60 clearInterval(heartbeat);
61 unsubscribe();
62 try {
63 controller.close();
64 } catch {}
65 };
66 request.signal.addEventListener("abort", stop);
67 write("retry: 2000\n\n");
68 for (const event of kept) if (event.id > since) send(event);
69 },
70 cancel() {
71 stop();
72 },
73 });
74 return new Response(stream, {
75 headers: {
76 "content-type": "text/event-stream",
77 "cache-control": "no-store",
78 "x-accel-buffering": "no",
79 },
80 });
81 }
src/mcp.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * An MCP server over plain HTTP: `POST /mcp` takes one JSON-RPC 2.0
3 * message and answers with JSON (streamable HTTP without the stream —
4 * nothing here needs to push). The tools are the actions, called with the
5 * same identity the page gets, so an agent speaking MCP through the forge
6 * is the person whose grant it carries, and a bare one is anonymous.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { addNote, deleteNote, listNotes, type Result } from "./actions.ts";
11 import { title } from "./env.ts";
12 import { fields } from "./http.ts";
13 import type { Who } from "./who.ts";
14
15 interface Tool {
16 readonly name: string;
17 readonly description: string;
18 readonly inputSchema: Record<string, unknown>;
19 readonly run: (
20 db: Database,
21 who: Who,
22 args: Record<string, unknown>,
23 ) => Result<unknown>;
24 }
25
26 export const tools: readonly Tool[] = [
27 {
28 name: "list_notes",
29 description: "The notes, newest first.",
30 inputSchema: {
31 type: "object",
32 properties: {
33 limit: {
34 type: "integer",
35 minimum: 1,
36 maximum: 500,
37 description: "At most this many (100).",
38 },
39 },
40 },
41 run: (db, who, args) =>
42 listNotes(db, who, { limit: Number(args.limit ?? 100) }),
43 },
44 {
45 name: "add_note",
46 description:
47 "Add a note as the signed-in person. Anonymous callers cannot.",
48 inputSchema: {
49 type: "object",
50 properties: { body: { type: "string", maxLength: 500 } },
51 required: ["body"],
52 },
53 run: (db, who, args) => addNote(db, who, { body: String(args.body ?? "") }),
54 },
55 {
56 name: "delete_note",
57 description: "Remove a note by id. Only its author or an admin can.",
58 inputSchema: {
59 type: "object",
60 properties: { id: { type: "integer" } },
61 required: ["id"],
62 },
63 run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }),
64 },
65 ];
66
67 /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */
68 export function rpc(
69 db: Database,
70 who: Who,
71 message: unknown,
72 ): { status: number; body?: unknown } {
73 const m = fields(message);
74 const id = m.id ?? null;
75 const reply = (result: unknown) => ({
76 status: 200,
77 body: { jsonrpc: "2.0", id, result },
78 });
79 const refuse = (code: number, text: string) => ({
80 status: 200,
81 body: { jsonrpc: "2.0", id, error: { code, message: text } },
82 });
83 if (m.jsonrpc !== "2.0" || typeof m.method !== "string")
84 return refuse(-32600, "not a JSON-RPC 2.0 request");
85 if (m.id === undefined) return { status: 202 };
86 const params = fields(m.params);
87 switch (m.method) {
88 case "initialize":
89 return reply({
90 protocolVersion: "2025-06-18",
91 capabilities: { tools: {} },
92 serverInfo: { name: title, version: "1" },
93 });
94 case "ping":
95 return reply({});
96 case "tools/list":
97 return reply({
98 tools: tools.map(({ name, description, inputSchema }) => ({
99 name,
100 description,
101 inputSchema,
102 })),
103 });
104 case "tools/call": {
105 const tool = tools.find((t) => t.name === params.name);
106 if (tool === undefined)
107 return refuse(-32602, `no tool named ${String(params.name)}`);
108 const result = tool.run(db, who, fields(params.arguments));
109 return reply(
110 result.ok
111 ? { content: [{ type: "text", text: JSON.stringify(result.value) }] }
112 : {
113 content: [
114 { type: "text", text: `${result.error}: ${result.message}` },
115 ],
116 isError: true,
117 },
118 );
119 }
120 default:
121 return refuse(-32601, `no method ${m.method}`);
122 }
123 }
src/openapi.test.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * The API's description is the routes' own, committed: emitting it twice
3 * says the same bytes, the file in git is what the table says now, and the
4 * app serves exactly that file.
5 */
6 import { afterAll, expect, test } from "bun:test";
7 import { mkdtempSync, readFileSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { emit, openapi } from "./openapi.ts";
11 import { routes } from "./routes.ts";
12 import { serve } from "./server.ts";
13
14 const committed = readFileSync(
15 new URL("../contract/openapi.json", import.meta.url),
16 "utf8",
17 );
18
19 test("emission is stable", () => {
20 expect(emit()).toBe(emit());
21 });
22
23 test("contract/openapi.json is what the routes say now (else: bun run contract:emit)", () => {
24 const now = JSON.parse(emit());
25 const stale = !Bun.deepEquals(JSON.parse(committed), now);
26 if (stale)
27 // Generated app's contract CLI: stderr is its output.
28 console.error(
29 "contract/openapi.json is stale: run `bun run contract:emit` and commit it",
30 );
31 expect(stale).toBe(false);
32 });
33
34 test("every API route is described, and every door names its cell of the table", () => {
35 const doc = openapi() as {
36 paths: Record<
37 string,
38 Record<
39 string,
40 { "x-contract"?: { resource: string; verb: string; least: string } }
41 >
42 >;
43 };
44 for (const route of routes.filter((r) => r.path.startsWith("/api/"))) {
45 const op =
46 doc.paths[route.path.replace(/:(\w+)/g, "{$1}")]?.[
47 route.method.toLowerCase()
48 ];
49 expect([route.method, route.path, op !== undefined]).toEqual([
50 route.method,
51 route.path,
52 true,
53 ]);
54 expect(typeof op?.["x-contract"]?.least).toBe("string");
55 }
56 expect(doc.paths["/api/audit"]?.get?.["x-contract"]).toEqual({
57 resource: "audit",
58 verb: "list",
59 least: "admin",
60 });
61 });
62
63 test("GET /openapi.json serves the committed file", async () => {
64 const server = serve({
65 port: 0,
66 dataDir: mkdtempSync(join(tmpdir(), "app-openapi-")),
67 identityKey: "k",
68 });
69 afterAll(() => server.stop(true));
70 const response = await fetch(
71 `http://${server.hostname}:${server.port}/openapi.json`,
72 );
73 expect(response.status).toBe(200);
74 expect(response.headers.get("content-type")).toContain("application/json");
75 expect(await response.text()).toBe(committed);
76 });
src/openapi.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * The API, described: every route in `routes.ts` that declares `api`, as an
3 * OpenAPI 3.1 document. Each operation also names the contract cell it is
4 * (`x-contract`: the resource, the verb, and the least role the table asks
5 * for), so a change of policy is a change here too, and shows in the pull.
6 *
7 * bun run contract:emit write contract/openapi.json from the table
8 *
9 * `src/openapi.test.ts` fails when the committed file and the routes
10 * disagree: the description can go stale only in a commit that fails.
11 */
12
13 import { contract } from "./contract.ts";
14 import { description, title, version } from "./env.ts";
15 import { AUDIT_ROW, ERROR, NOTE, routes, type Schema } from "./routes.ts";
16 import { doorOf } from "./security.ts";
17
18 // The shapes said once, under components, and referred to wherever they appear.
19 const NAMED = new Map<Schema, string>([
20 [NOTE, "Note"],
21 [AUDIT_ROW, "AuditRow"],
22 [ERROR, "Error"],
23 ]);
24 const named = (schema: Schema): Schema => {
25 const name = NAMED.get(schema);
26 if (name !== undefined) return { $ref: `#/components/schemas/${name}` };
27 return Object.fromEntries(
28 Object.entries(schema).map(([key, value]) => [
29 key,
30 typeof value === "object" && value !== null && !Array.isArray(value)
31 ? named(value as Schema)
32 : value,
33 ]),
34 );
35 };
36 const content = (schema: Schema) => ({
37 "application/json": { schema: named(schema) },
38 });
39
40 export function openapi(): Record<string, unknown> {
41 const paths: Record<string, Record<string, unknown>> = {};
42 for (const route of routes) {
43 if (route.api === undefined) continue;
44 const { summary, body, answer, status, query } = route.api;
45 const path = route.path.replace(/:(\w+)/g, "{$1}");
46 const params = [...route.path.matchAll(/:(\w+)/g)].map(([, name]) => ({
47 name,
48 in: "path",
49 required: true,
50 schema: { type: "integer" },
51 }));
52 const door = doorOf(route.method, route.path.replace(/:\w+/g, "0"));
53 const least =
54 door === undefined
55 ? undefined
56 : contract.resources[door.resource]?.can[door.verb];
57 paths[path] ??= {};
58 paths[path][route.method.toLowerCase()] = {
59 summary,
60 operationId: `${route.method.toLowerCase()}${path.replace(/[{}]/g, "").replace(/(^|[/._-])(\w)/g, (_, __, c: string) => c.toUpperCase())}`,
61 ...(params.length > 0 || query !== undefined
62 ? {
63 parameters: [
64 ...params,
65 ...Object.entries(query ?? {}).map(([name, schema]) => ({
66 name,
67 in: "query",
68 schema: named(schema),
69 })),
70 ],
71 }
72 : {}),
73 ...(body === undefined
74 ? {}
75 : { requestBody: { required: true, content: content(body) } }),
76 responses: {
77 [String(status ?? 200)]: {
78 description: summary,
79 content: content(answer),
80 },
81 default: {
82 description: "{ error, message }: why not",
83 content: content(ERROR),
84 },
85 },
86 ...(door === undefined ? {} : { "x-contract": { ...door, least } }),
87 };
88 }
89 return {
90 openapi: "3.1.0",
91 info: { title, version, description },
92 paths,
93 components: {
94 schemas: Object.fromEntries(
95 [...NAMED].map(([schema, name]) => [name, schema]),
96 ),
97 },
98 };
99 }
100
101 /** The file's text: stable, so the same table always writes the same bytes. */
102 export const emit = (): string => `${JSON.stringify(openapi(), null, 2)}\n`;
src/otel.ts added +728
@@ -0,0 +1,728 @@
1 /**
2 * OpenTelemetry in one file, with nothing to install: W3C trace context in
3 * and out, spans, metrics and logs exported as OTLP/JSON over plain `fetch`. The
4 * forge traces its own requests with it, and every app the forge scaffolds
5 * carries this same file as `src/otel.ts` — one implementation, read by both.
6 *
7 * Configured by the standard variables, so any OpenTelemetry collector works:
8 *
9 * OTEL_EXPORTER_OTLP_ENDPOINT e.g. http://otel-collector:4318 (unset = off)
10 * OTEL_SERVICE_NAME what this process is called in a trace
11 * OTEL_RESOURCE_ATTRIBUTES k=v,k=v added to every span and metric
12 * OTEL_SDK_DISABLED=true off, whatever else is set
13 *
14 * With no endpoint every call here is a cheap no-op: spans are still made
15 * (so code reads the same) but nothing is kept or sent. Export never throws
16 * and never blocks a request; a collector that is down costs a dropped
17 * batch, not an error page. The queue is bounded — the oldest spans go first.
18 */
19
20 import { AsyncLocalStorage } from "node:async_hooks";
21 import { randomBytes } from "node:crypto";
22
23 // The real fetch, taken before anyone (an app's own setup) can replace the
24 // global with `tracedFetch`: export and tracedFetch's own call use it, so
25 // neither traces itself.
26 const nativeFetch: typeof fetch = globalThis.fetch.bind(globalThis);
27
28 export type Attr = string | number | boolean;
29 export type Attrs = Record<string, Attr | undefined>;
30
31 export const SpanKind = { internal: 1, server: 2, client: 3 } as const;
32 export type SpanKindName = keyof typeof SpanKind;
33
34 export interface SpanContext {
35 readonly traceId: string;
36 readonly spanId: string;
37 readonly sampled: boolean;
38 }
39
40 interface Config {
41 readonly endpoint: string | undefined;
42 readonly service: string;
43 readonly resource: Attrs;
44 }
45
46 const env = (name: string): string | undefined => {
47 const value = process.env[name]?.trim();
48 return value === "" ? undefined : value;
49 };
50
51 /** `k=v,k=v` as the spec writes it (values may be percent-encoded). */
52 export function parseResourceAttributes(raw: string | undefined): Attrs {
53 const out: Attrs = {};
54 for (const pair of (raw ?? "").split(",")) {
55 const at = pair.indexOf("=");
56 if (at <= 0) continue;
57 const key = pair.slice(0, at).trim();
58 let value = pair.slice(at + 1).trim();
59 try {
60 value = decodeURIComponent(value);
61 } catch {
62 // A stray % is kept as written.
63 }
64 if (key !== "") out[key] = value;
65 }
66 return out;
67 }
68
69 function configFromEnv(): Config {
70 const disabled = env("OTEL_SDK_DISABLED") === "true";
71 const resource = parseResourceAttributes(env("OTEL_RESOURCE_ATTRIBUTES"));
72 const service =
73 env("OTEL_SERVICE_NAME") ??
74 (typeof resource["service.name"] === "string"
75 ? resource["service.name"]
76 : "unknown_service:bun");
77 return {
78 endpoint: disabled
79 ? undefined
80 : env("OTEL_EXPORTER_OTLP_ENDPOINT")?.replace(/\/+$/, ""),
81 service,
82 resource,
83 };
84 }
85
86 let config: Config = configFromEnv();
87
88 /** Re-read the environment, or set explicitly (tests, embedders). */
89 export function configure(next?: Partial<Config>): void {
90 config = { ...configFromEnv(), ...next };
91 }
92
93 export const enabled = (): boolean => config.endpoint !== undefined;
94
95 // ---- trace context ----------------------------------------------------------
96
97 const hex = (bytes: number): string => randomBytes(bytes).toString("hex");
98 const ZERO_TRACE = "0".repeat(32);
99 const ZERO_SPAN = "0".repeat(16);
100
101 /** `00-<trace>-<span>-<flags>`, or undefined for anything malformed. */
102 export function parseTraceparent(
103 header: string | null | undefined,
104 ): SpanContext | undefined {
105 const match = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(
106 (header ?? "").trim().toLowerCase(),
107 );
108 if (match === null) return undefined;
109 const [, version, traceId, spanId, flags] = match as unknown as [
110 string,
111 string,
112 string,
113 string,
114 string,
115 ];
116 if (version === "ff" || traceId === ZERO_TRACE || spanId === ZERO_SPAN)
117 return undefined;
118 return { traceId, spanId, sampled: (Number.parseInt(flags, 16) & 1) === 1 };
119 }
120
121 export const formatTraceparent = (ctx: SpanContext): string =>
122 `00-${ctx.traceId}-${ctx.spanId}-${ctx.sampled ? "01" : "00"}`;
123
124 // ---- spans ------------------------------------------------------------------
125
126 /** Wall-clock nanoseconds, as a decimal string (OTLP/JSON's uint64 form). */
127 const nowNs = (): bigint =>
128 BigInt(Math.round((performance.timeOrigin + performance.now()) * 1e3)) *
129 1000n;
130
131 export class Span implements SpanContext {
132 readonly traceId: string;
133 readonly spanId: string;
134 readonly parentSpanId: string | undefined;
135 readonly sampled: boolean;
136 readonly startNs: bigint;
137 endNs: bigint | undefined;
138 readonly attributes: Attrs;
139 status: { code: 0 | 1 | 2; message?: string } = { code: 0 };
140 readonly events: { name: string; timeNs: bigint; attributes: Attrs }[] = [];
141
142 constructor(
143 public name: string,
144 readonly kind: SpanKindName,
145 parent: SpanContext | undefined,
146 attributes: Attrs,
147 startTime?: number,
148 ) {
149 // A span recorded after the fact (a run that finished) says when it began.
150 this.startNs = startTime === undefined ? nowNs() : BigInt(Math.round(startTime * 1e3)) * 1000n;
151 this.traceId = parent?.traceId ?? hex(16);
152 this.parentSpanId = parent?.spanId;
153 this.sampled = parent?.sampled ?? true;
154 this.spanId = hex(8);
155 this.attributes = { ...attributes };
156 }
157
158 set(key: string, value: Attr | undefined): this {
159 if (value !== undefined) this.attributes[key] = value;
160 return this;
161 }
162
163 event(name: string, attributes: Attrs = {}): this {
164 this.events.push({ name, timeNs: nowNs(), attributes });
165 return this;
166 }
167
168 /** Mark failed. An exception's message is kept; its stack is not. */
169 fail(error: unknown): this {
170 const message = error instanceof Error ? error.message : String(error);
171 this.status = { code: 2, message };
172 this.event("exception", {
173 "exception.type": error instanceof Error ? error.name : typeof error,
174 "exception.message": message,
175 });
176 return this;
177 }
178
179 end(): void {
180 if (this.endNs !== undefined) return;
181 this.endNs = nowNs();
182 if (this.sampled && enabled()) enqueue(this);
183 }
184
185 get durationSeconds(): number {
186 return Number((this.endNs ?? nowNs()) - this.startNs) / 1e9;
187 }
188 }
189
190 const active = new AsyncLocalStorage<Span>();
191
192 /** The span the current code runs inside, if any. */
193 export const currentSpan = (): Span | undefined => active.getStore();
194
195 export interface SpanOptions {
196 readonly kind?: SpanKindName;
197 /** Parent explicitly; default is the current span. `null` = a new trace. */
198 readonly parent?: SpanContext | null;
199 readonly attributes?: Attrs;
200 /** When it began, in epoch milliseconds, if before now. */
201 readonly startTime?: number;
202 }
203
204 export function startSpan(name: string, options: SpanOptions = {}): Span {
205 const parent =
206 options.parent === null ? undefined : (options.parent ?? currentSpan());
207 return new Span(name, options.kind ?? "internal", parent, options.attributes ?? {}, options.startTime);
208 }
209
210 /**
211 * Run `fn` inside a new span: it is current for everything `fn` awaits, it
212 * ends when `fn` settles, and a throw marks it failed (and still throws).
213 */
214 export async function span<T>(
215 name: string,
216 fn: (span: Span) => T | Promise<T>,
217 options: SpanOptions = {},
218 ): Promise<T> {
219 const s = startSpan(name, options);
220 try {
221 return await active.run(s, () => fn(s));
222 } catch (error) {
223 s.fail(error);
224 throw error;
225 } finally {
226 s.end();
227 }
228 }
229
230 /** Run `fn` with `s` as the current span, without ending it. */
231 export const withSpan = <T>(s: Span, fn: () => T): T => active.run(s, fn);
232
233 // ---- HTTP -------------------------------------------------------------------
234
235 /**
236 * A server span for one request, parented on the caller's `traceparent`.
237 * Records `http.server.request.duration` too. `route` names the matched
238 * route template (`/notes/:id`) once it is known — set it on the span.
239 */
240 export async function serveSpan<R extends Response | undefined>(
241 request: Request,
242 handle: (span: Span) => R | Promise<R>,
243 attributes: Attrs = {},
244 ): Promise<R> {
245 const url = new URL(request.url);
246 const s = startSpan(request.method, {
247 kind: "server",
248 parent: parseTraceparent(request.headers.get("traceparent")) ?? null,
249 attributes: {
250 "http.request.method": request.method,
251 "url.path": url.pathname,
252 "url.scheme":
253 request.headers.get("x-forwarded-proto") ?? url.protocol.slice(0, -1),
254 "server.address": (
255 request.headers.get("x-forwarded-host") ??
256 request.headers.get("host") ??
257 url.hostname
258 ).replace(/:\d+$/, ""),
259 "user_agent.original": request.headers.get("user-agent") ?? undefined,
260 ...attributes,
261 },
262 });
263 let status = 500;
264 try {
265 const response = await active.run(s, () => handle(s));
266 // undefined = the socket was upgraded (a websocket): it answered 101.
267 status = response?.status ?? 101;
268 return response;
269 } catch (error) {
270 s.fail(error);
271 throw error;
272 } finally {
273 endServerSpan(s, status);
274 }
275 }
276
277 /**
278 * Trace a whole `Bun.serve` options object: its `fetch` runs inside
279 * `serveSpan`, everything else (websocket handlers, port, error) untouched.
280 * The one-line retrofit for an app that routes by hand:
281 *
282 * Bun.serve(traceServe({ port, fetch(request, server) { ... } }))
283 *
284 * A route template isn't known here, so spans are named by method only —
285 * set `http.route` on `currentSpan()` from the handler if you route by name.
286 */
287 export function traceServe<
288 T extends { fetch: (request: Request, ...rest: never[]) => unknown },
289 >(options: T): T {
290 const inner = options.fetch;
291 return {
292 ...options,
293 fetch(this: unknown, request: Request, ...rest: never[]) {
294 return serveSpan(
295 request,
296 async () =>
297 (await inner.call(this, request, ...rest)) as Response | undefined,
298 );
299 },
300 };
301 }
302
303 /**
304 * Finish a server span made by hand (a framework's request hooks): status,
305 * the `METHOD /route` name, and the duration histogram — what `serveSpan`
306 * does in its `finally`.
307 */
308 export function endServerSpan(s: Span, status: number): void {
309 const method = s.attributes["http.request.method"];
310 s.set("http.response.status_code", status);
311 if (status >= 500 && s.status.code === 0) s.status = { code: 2 };
312 const route = s.attributes["http.route"];
313 if (typeof route === "string" && typeof method === "string")
314 s.name = `${method} ${route}`;
315 s.end();
316 requestDuration.record(s.durationSeconds, {
317 "http.request.method": typeof method === "string" ? method : undefined,
318 "http.route": typeof route === "string" ? route : undefined,
319 "http.response.status_code": status,
320 });
321 }
322
323 /**
324 * `fetch` that makes a client span and hands the callee our `traceparent`,
325 * so its spans join this trace. Same signature as `fetch`.
326 */
327 export async function tracedFetch(
328 input: string | URL | Request,
329 init: RequestInit = {},
330 ): Promise<Response> {
331 const request =
332 input instanceof Request
333 ? new Request(input, init)
334 : new Request(String(input), init);
335 const url = new URL(request.url);
336 return span(
337 `${request.method}`,
338 async (s) => {
339 const headers = new Headers(request.headers);
340 headers.set("traceparent", formatTraceparent(s));
341 const response = await nativeFetch(new Request(request, { headers }));
342 s.set("http.response.status_code", response.status);
343 if (response.status >= 500) s.status = { code: 2 };
344 return response;
345 },
346 {
347 kind: "client",
348 attributes: {
349 "http.request.method": request.method,
350 "server.address": url.hostname,
351 "server.port": url.port === "" ? undefined : Number(url.port),
352 "url.full": `${url.origin}${url.pathname}`,
353 },
354 },
355 );
356 }
357
358 // ---- metrics ----------------------------------------------------------------
359
360 const DURATION_BUCKETS = [
361 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
362 ];
363
364 const attrKey = (attrs: Attrs): string =>
365 JSON.stringify(
366 Object.entries(attrs)
367 .filter(([, v]) => v !== undefined)
368 .sort(([a], [b]) => (a < b ? -1 : 1)),
369 );
370
371 interface Instrument {
372 readonly name: string;
373 readonly unit: string;
374 readonly description: string;
375 toOtlp(startNs: string, nowNs: string): unknown | undefined;
376 }
377
378 const instruments: Instrument[] = [];
379 const processStartNs = nowNs().toString();
380
381 export interface Histogram {
382 record(value: number, attributes?: Attrs): void;
383 }
384
385 /** A cumulative explicit-bucket histogram. Keep attribute values few. */
386 export function histogram(
387 name: string,
388 options: { unit?: string; description?: string; buckets?: number[] } = {},
389 ): Histogram {
390 const bounds = options.buckets ?? DURATION_BUCKETS;
391 const points = new Map<
392 string,
393 { attrs: Attrs; count: number; sum: number; buckets: number[] }
394 >();
395 instruments.push({
396 name,
397 unit: options.unit ?? "s",
398 description: options.description ?? "",
399 toOtlp(startNs, timeNs) {
400 if (points.size === 0) return undefined;
401 return {
402 histogram: {
403 aggregationTemporality: 2,
404 dataPoints: [...points.values()].map((p) => ({
405 attributes: otlpAttrs(p.attrs),
406 startTimeUnixNano: startNs,
407 timeUnixNano: timeNs,
408 count: String(p.count),
409 sum: p.sum,
410 bucketCounts: p.buckets.map(String),
411 explicitBounds: bounds,
412 })),
413 },
414 };
415 },
416 });
417 return {
418 record(value, attributes = {}) {
419 if (!enabled() || !Number.isFinite(value)) return;
420 const key = attrKey(attributes);
421 let p = points.get(key);
422 if (p === undefined) {
423 p = {
424 attrs: attributes,
425 count: 0,
426 sum: 0,
427 buckets: new Array(bounds.length + 1).fill(0),
428 };
429 points.set(key, p);
430 }
431 p.count += 1;
432 p.sum += value;
433 let i = bounds.findIndex((b) => value <= b);
434 if (i === -1) i = bounds.length;
435 p.buckets[i] = (p.buckets[i] ?? 0) + 1;
436 },
437 };
438 }
439
440 export interface Counter {
441 add(value?: number, attributes?: Attrs): void;
442 }
443
444 /** A cumulative monotonic sum. */
445 export function counter(
446 name: string,
447 options: { unit?: string; description?: string } = {},
448 ): Counter {
449 const points = new Map<string, { attrs: Attrs; value: number }>();
450 instruments.push({
451 name,
452 unit: options.unit ?? "1",
453 description: options.description ?? "",
454 toOtlp(startNs, timeNs) {
455 if (points.size === 0) return undefined;
456 return {
457 sum: {
458 aggregationTemporality: 2,
459 isMonotonic: true,
460 dataPoints: [...points.values()].map((p) => ({
461 attributes: otlpAttrs(p.attrs),
462 startTimeUnixNano: startNs,
463 timeUnixNano: timeNs,
464 asDouble: p.value,
465 })),
466 },
467 };
468 },
469 });
470 return {
471 add(value = 1, attributes = {}) {
472 if (!enabled() || !(value >= 0)) return;
473 const key = attrKey(attributes);
474 const p = points.get(key);
475 if (p === undefined) points.set(key, { attrs: attributes, value });
476 else p.value += value;
477 },
478 };
479 }
480
481 /** A value read at export time: queue depth, rows in a table, players online. */
482 export function gauge(
483 name: string,
484 read: () => number | { value: number; attributes?: Attrs }[],
485 options: { unit?: string; description?: string } = {},
486 ): void {
487 instruments.push({
488 name,
489 unit: options.unit ?? "1",
490 description: options.description ?? "",
491 toOtlp(_startNs, timeNs) {
492 let values: { value: number; attributes?: Attrs }[];
493 try {
494 const got = read();
495 values = typeof got === "number" ? [{ value: got }] : got;
496 } catch {
497 return undefined;
498 }
499 if (values.length === 0) return undefined;
500 return {
501 gauge: {
502 dataPoints: values.map((v) => ({
503 attributes: otlpAttrs(v.attributes ?? {}),
504 timeUnixNano: timeNs,
505 asDouble: v.value,
506 })),
507 },
508 };
509 },
510 });
511 }
512
513 /** The semantic-convention HTTP server histogram `serveSpan` records into. */
514 const requestDuration = histogram("http.server.request.duration", {
515 unit: "s",
516 description: "Duration of HTTP server requests.",
517 });
518
519 // ---- logs -------------------------------------------------------------------
520
521 export type Severity = "debug" | "info" | "warn" | "error";
522 const SEVERITY_NUMBER: Record<Severity, number> = { debug: 5, info: 9, warn: 13, error: 17 };
523
524 export interface LogOptions {
525 readonly severity?: Severity;
526 readonly attributes?: Attrs;
527 /**
528 * Who is speaking, when it is not this process: the forge files each
529 * app's output under the app's own `service.name`. Added to (and wins
530 * over) this process's resource.
531 */
532 readonly resource?: Attrs;
533 }
534
535 interface LogEntry {
536 readonly timeNs: bigint;
537 readonly body: string;
538 readonly severity: Severity;
539 readonly attributes: Attrs;
540 readonly resource: Attrs | undefined;
541 readonly traceId: string | undefined;
542 readonly spanId: string | undefined;
543 }
544
545 const MAX_LOG_QUEUE = 4096;
546 let logQueue: LogEntry[] = [];
547
548 /**
549 * One log record, tied to the current span when there is one. A no-op
550 * without an endpoint; never throws, never blocks.
551 */
552 export function emitLog(body: string, options: LogOptions = {}): void {
553 if (!enabled()) return;
554 const s = currentSpan();
555 logQueue.push({
556 timeNs: nowNs(),
557 body,
558 severity: options.severity ?? "info",
559 attributes: options.attributes ?? {},
560 resource: options.resource,
561 traceId: s?.traceId,
562 spanId: s?.spanId,
563 });
564 // The oldest go first: a flood keeps its newest lines.
565 if (logQueue.length > MAX_LOG_QUEUE) logQueue = logQueue.slice(-MAX_LOG_QUEUE);
566 startTimer();
567 }
568
569 /** Log records in OTLP/JSON, grouped by who spoke. */
570 export function logsToOtlp(entries: readonly LogEntry[]): unknown {
571 const byResource = new Map<string, { resource: Attrs | undefined; records: unknown[] }>();
572 for (const e of entries) {
573 const key = e.resource === undefined ? "" : attrKey(e.resource);
574 let group = byResource.get(key);
575 if (group === undefined) byResource.set(key, (group = { resource: e.resource, records: [] }));
576 group.records.push({
577 timeUnixNano: e.timeNs.toString(),
578 observedTimeUnixNano: e.timeNs.toString(),
579 severityNumber: SEVERITY_NUMBER[e.severity],
580 severityText: e.severity.toUpperCase(),
581 body: { stringValue: e.body },
582 attributes: otlpAttrs(e.attributes),
583 ...(e.traceId === undefined ? {} : { traceId: e.traceId }),
584 ...(e.spanId === undefined ? {} : { spanId: e.spanId }),
585 });
586 }
587 return {
588 resourceLogs: [...byResource.values()].map((g) => ({
589 resource: resource(g.resource),
590 scopeLogs: [{ scope: SCOPE, logRecords: g.records }],
591 })),
592 };
593 }
594
595 // ---- export -----------------------------------------------------------------
596
597 function otlpValue(value: Attr): Record<string, unknown> {
598 if (typeof value === "boolean") return { boolValue: value };
599 if (typeof value === "number")
600 return Number.isInteger(value)
601 ? { intValue: String(value) }
602 : { doubleValue: value };
603 return { stringValue: value };
604 }
605
606 export function otlpAttrs(attrs: Attrs): unknown[] {
607 return Object.entries(attrs)
608 .filter((e): e is [string, Attr] => e[1] !== undefined)
609 .map(([key, value]) => ({ key, value: otlpValue(value) }));
610 }
611
612 const resource = (over?: Attrs) => ({
613 attributes: otlpAttrs({
614 "telemetry.sdk.name": "forge-otel",
615 "telemetry.sdk.language": "js",
616 "process.runtime.name": process.versions.bun === undefined ? "node" : "bun",
617 "process.runtime.version": process.versions.bun ?? process.versions.node,
618 ...config.resource,
619 "service.name": config.service,
620 ...over,
621 }),
622 });
623
624 const SCOPE = { name: "forge-otel", version: "1" };
625
626 /** One span in OTLP/JSON (ids are lowercase hex, times decimal ns). */
627 export function spanToOtlp(s: Span): unknown {
628 return {
629 traceId: s.traceId,
630 spanId: s.spanId,
631 ...(s.parentSpanId === undefined ? {} : { parentSpanId: s.parentSpanId }),
632 name: s.name,
633 kind: SpanKind[s.kind],
634 startTimeUnixNano: s.startNs.toString(),
635 endTimeUnixNano: (s.endNs ?? s.startNs).toString(),
636 attributes: otlpAttrs(s.attributes),
637 events: s.events.map((e) => ({
638 name: e.name,
639 timeUnixNano: e.timeNs.toString(),
640 attributes: otlpAttrs(e.attributes),
641 })),
642 status: s.status,
643 };
644 }
645
646 const MAX_QUEUE = 2048;
647 const BATCH = 512;
648 let queue: Span[] = [];
649 let timer: ReturnType<typeof setInterval> | undefined;
650 let lastMetrics = 0;
651
652 function enqueue(s: Span): void {
653 queue.push(s);
654 if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE);
655 startTimer();
656 }
657
658 function startTimer(): void {
659 if (timer === undefined) {
660 timer = setInterval(() => void flush(), 5_000);
661 // Telemetry must never be the reason a process stays alive.
662 (timer as { unref?: () => void }).unref?.();
663 }
664 }
665
666 async function post(path: string, body: unknown): Promise<boolean> {
667 if (config.endpoint === undefined) return false;
668 try {
669 const response = await nativeFetch(`${config.endpoint}${path}`, {
670 method: "POST",
671 headers: { "content-type": "application/json" },
672 body: JSON.stringify(body),
673 signal: AbortSignal.timeout(5_000),
674 });
675 return response.ok;
676 } catch {
677 return false;
678 }
679 }
680
681 /** Send what is queued now. Resolves when sent (or dropped); never throws. */
682 export async function flush(): Promise<void> {
683 while (queue.length > 0) {
684 const batch = queue.splice(0, BATCH);
685 await post("/v1/traces", {
686 resourceSpans: [
687 { resource: resource(), scopeSpans: [{ scope: SCOPE, spans: batch.map(spanToOtlp) }] },
688 ],
689 });
690 }
691 while (logQueue.length > 0) {
692 const batch = logQueue.splice(0, BATCH);
693 await post("/v1/logs", logsToOtlp(batch));
694 }
695 if (Date.now() - lastMetrics >= 30_000) {
696 lastMetrics = Date.now();
697 await flushMetrics();
698 }
699 }
700
701 export function metricsToOtlp(): unknown | undefined {
702 const timeNs = nowNs().toString();
703 const metrics = instruments.flatMap((i) => {
704 const data = i.toOtlp(processStartNs, timeNs);
705 return data === undefined
706 ? []
707 : [{ name: i.name, unit: i.unit, description: i.description, ...(data as object) }];
708 });
709 if (metrics.length === 0) return undefined;
710 return {
711 resourceMetrics: [
712 { resource: resource(), scopeMetrics: [{ scope: SCOPE, metrics }] },
713 ],
714 };
715 }
716
717 export async function flushMetrics(): Promise<void> {
718 if (!enabled()) return;
719 const body = metricsToOtlp();
720 if (body !== undefined) await post("/v1/metrics", body);
721 }
722
723 /** Start the periodic exporter even before the first span (metrics-only apps). */
724 export function start(): void {
725 if (!enabled() || timer !== undefined) return;
726 timer = setInterval(() => void flush(), 5_000);
727 (timer as { unref?: () => void }).unref?.();
728 }
src/page.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * The page, rendered on the server: the first paint is complete HTML, and
3 * `static/live.js` keeps the notes fresh afterwards. When a second page
4 * arrives, move `layout` to `src/pages/layout.ts` and give each page a file.
5 */
6
7 import { canDelete, type Note } from "./actions.ts";
8 import {
9 commit,
10 description,
11 env,
12 publicOrigin,
13 ssh,
14 title,
15 where,
16 } from "./env.ts";
17 import { asset, escape } from "./http.ts";
18 import { token, tokensHref } from "./tokens.ts";
19 import type { Who } from "./who.ts";
20
21 /**
22 * The frame every page shares: the tokens and the look, who you are, and
23 * what a link to the page says when it is shared — a title, a sentence
24 * and the address, for Open Graph and the card a chat app unfurls.
25 */
26 export function layout(body: string, who: Who, path = "/"): string {
27 const door =
28 who.role === "anon"
29 ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>`
30 : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span>
31 <a class="button button-quiet" href="/__forge/signout">Sign out</a>`;
32 return `<!doctype html>
33 <html lang="en">
34 <meta charset="utf-8">
35 <meta name="viewport" content="width=device-width, initial-scale=1">
36 <meta name="color-scheme" content="light dark">
37 <meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)">
38 <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
39 <link rel="icon" href="data:,">
40 <title>${escape(title)}</title>
41 <meta name="description" content="${escape(description)}">
42 <meta property="og:type" content="website">
43 <meta property="og:site_name" content="${escape(title)}">
44 <meta property="og:title" content="${escape(title)}">
45 <meta property="og:description" content="${escape(description)}">
46 <meta property="og:url" content="${escape(publicOrigin + path)}">
47 <meta name="twitter:card" content="summary">
48 <meta name="twitter:title" content="${escape(title)}">
49 <meta name="twitter:description" content="${escape(description)}">
50 <link rel="stylesheet" href="${tokensHref}">
51 <link rel="stylesheet" href="${asset("app.css")}">
52 <script type="module" src="${asset("live.js")}"></script>
53 <body>
54 <div class="page">
55 <header class="head">
56 <div>
57 <h1>${escape(title)}</h1>
58 <p class="muted">${escape(description)}</p>
59 </div>
60 <nav class="row">
61 <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""}
62 ${door}
63 </nav>
64 </header>
65 <main>${body}</main>
66 <footer class="muted">
67 ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`}
68 <p>Also at <a href="/api/notes">/api/notes</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules.</p>
69 <p>Something wrong? <a href="/__forge/report">Report a problem</a>, no account needed.</p>
70 </footer>
71 </div>
72 </body>
73 </html>
74 `;
75 }
76
77 export function home(input: {
78 who: Who;
79 notes: readonly Note[];
80 notice?: string | undefined;
81 }): string {
82 const { who, notes, notice } = input;
83 const composer =
84 who.role === "anon"
85 ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>`
86 : `<form class="row" method="post" action="/notes">
87 <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus>
88 <button class="button">Add</button>
89 </form>`;
90 const list =
91 notes.length === 0
92 ? `<p class="empty">No notes yet. Notes written here stay in ${escape(where)} data.</p>`
93 : `<ul class="list">${notes.map((note) => item(note, who)).join("")}</ul>`;
94 return layout(
95 `<section class="card">${composer}</section>
96 <section id="notes" data-live="note.added note.removed">${list}</section>
97 ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`,
98 who,
99 );
100 }
101
102 const item = (note: Note, who: Who): string => `<li class="note">
103 <div>
104 <p>${escape(note.body)}</p>
105 <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small>
106 </div>
107 ${
108 canDelete(who, note)
109 ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>`
110 : ""
111 }
112 </li>`;
113
114 const ago = (iso: string): string => {
115 const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
116 if (s < 60) return "just now";
117 if (s < 3600) return `${Math.floor(s / 60)}m ago`;
118 if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
119 return new Date(iso).toLocaleDateString("en", {
120 month: "short",
121 day: "numeric",
122 });
123 };
src/routes.ts added +464
@@ -0,0 +1,464 @@
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 }
src/security.test.ts added +173
@@ -0,0 +1,173 @@
1 /**
2 * The door turns away, before a byte of the body is read, exactly who the
3 * table says could never pass — and nobody it would let through.
4 */
5 import { afterAll, expect, test } from "bun:test";
6 import { mkdtempSync } from "node:fs";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { readContract } from "./can.ts";
10 import { openDb } from "./db.ts";
11 import {
12 allow,
13 door,
14 doorOf,
15 mayKnock,
16 openToStrangers,
17 STRANGERS,
18 } from "./security.ts";
19 import { serve } from "./server.ts";
20 import { sign } from "./who.ts";
21
22 const KEY = "a-key-only-the-forge-holds";
23 const server = serve({
24 port: 0,
25 dataDir: mkdtempSync(join(tmpdir(), "app-door-")),
26 identityKey: KEY,
27 });
28 const base = `http://${server.hostname}:${server.port}`;
29 afterAll(() => server.stop(true));
30 const as = (id: string, role: "user" | "admin"): Record<string, string> => {
31 const identity = `${id}|${id}|${role}|${Date.now() + 60_000}`;
32 return {
33 "x-forge-identity": identity,
34 "x-forge-signature": sign(identity, KEY),
35 };
36 };
37
38 test("a path is a resource's door by the contract's api, and nothing else is", () => {
39 expect(doorOf("GET", "/api/notes")).toEqual({
40 resource: "note",
41 verb: "list",
42 });
43 expect(doorOf("POST", "/api/notes")).toEqual({
44 resource: "note",
45 verb: "create",
46 });
47 expect(doorOf("GET", "/api/notes/7")).toEqual({
48 resource: "note",
49 verb: "read",
50 });
51 expect(doorOf("PATCH", "/api/notes/7")).toEqual({
52 resource: "note",
53 verb: "update",
54 });
55 expect(doorOf("DELETE", "/api/notes/7")).toEqual({
56 resource: "note",
57 verb: "delete",
58 });
59 expect(doorOf("GET", "/api/audit")).toEqual({
60 resource: "audit",
61 verb: "list",
62 });
63 for (const [method, path] of [
64 ["PUT", "/api/notes"],
65 ["GET", "/api/notes/7/x"],
66 ["GET", "/api/notesy"],
67 ["GET", "/api/notes/"],
68 ["POST", "/notes"],
69 ] as const)
70 expect(doorOf(method, path)).toBeUndefined();
71 });
72
73 test("the door refuses only what no row would open", () => {
74 const anon = { role: "anon" } as const;
75 const alice = { role: "user", id: "u1", name: "alice" } as const;
76 const root = { role: "admin", id: "u0", name: "root" } as const;
77 expect(mayKnock(anon, "note", "list")).toBe(true);
78 expect(mayKnock(anon, "note", "create")).toBe(false);
79 expect(mayKnock(anon, "note", "delete")).toBe(false);
80 // alice may delete a note of her own, so the door lets her ask; the action decides which.
81 expect(mayKnock(alice, "note", "delete")).toBe(true);
82 expect(mayKnock(alice, "audit", "list")).toBe(false);
83 expect(mayKnock(root, "audit", "list")).toBe(true);
84 expect(mayKnock(root, "audit", "create")).toBe(false);
85 });
86
87 test("a stranger is refused /api before the body is read, when the contract closes it", async () => {
88 // A body past the cap: read, it would be 413. The door answers first.
89 const huge = "x".repeat(128 * 1024);
90 const refused = await fetch(`${base}/api/notes`, {
91 method: "POST",
92 headers: { "content-type": "application/json" },
93 body: huge,
94 });
95 expect(refused.status).toBe(401);
96 const signedIn = await fetch(`${base}/api/notes`, {
97 method: "POST",
98 headers: { "content-type": "application/json", ...as("u1", "user") },
99 body: huge,
100 });
101 expect(signedIn.status).toBe(413);
102 expect(await refused.json()).toEqual({
103 error: "signin_required",
104 message: "Sign in first.",
105 });
106 expect((await fetch(`${base}/api/audit`)).status).toBe(401);
107 expect((await fetch(`${base}/api/audit/1`)).status).toBe(401);
108 expect(
109 (await fetch(`${base}/api/audit`, { headers: as("u1", "user") })).status,
110 ).toBe(403);
111 // What the table opens stays open.
112 expect((await fetch(`${base}/api/notes`)).status).toBe(200);
113 expect((await fetch(`${base}/api/notes/0`)).status).toBe(404);
114 expect((await fetch(`${base}/openapi.json`)).status).toBe(200);
115 });
116
117 test("the API's description is shut to strangers when the table opens them no door", () => {
118 const closed = readContract(
119 JSON.stringify({
120 roles: {
121 anon: { above: [], held: { by: "everyone" } },
122 user: { above: ["anon"], held: { by: "identity", role: "user" } },
123 },
124 resources: {
125 note: {
126 api: "/api/notes",
127 can: {
128 create: "user",
129 read: "user",
130 update: "user",
131 delete: "user",
132 list: "user",
133 },
134 },
135 },
136 }),
137 );
138 if (!closed.ok) throw new Error(closed.problems.join("; "));
139 expect(openToStrangers(closed.contract)).toBe(false);
140 expect(openToStrangers()).toBe(true);
141 });
142
143 test("strangers are rationed at the API, reads too", () => {
144 const db = openDb(mkdtempSync(join(tmpdir(), "app-door-rate-")));
145 const knock = () =>
146 door(
147 db,
148 new Request("http://app/api/notes"),
149 new URL("http://app/api/notes"),
150 { role: "anon" },
151 "203.0.113.7",
152 );
153 const answers = Array.from({ length: STRANGERS.burst + 5 }, knock);
154 expect(answers.slice(0, STRANGERS.burst).every((a) => a === undefined)).toBe(
155 true,
156 );
157 expect(answers.at(-1)?.status).toBe(429);
158 // The page itself is not the API: a stranger reading it is never rationed here.
159 expect(
160 door(
161 db,
162 new Request("http://app/"),
163 new URL("http://app/"),
164 { role: "anon" },
165 "203.0.113.7",
166 ),
167 ).toBeUndefined();
168 // A bucket refills.
169 const t = Date.now();
170 for (let i = 0; i < 25; i++) allow("k", { burst: 20, perSecond: 2 }, t);
171 expect(allow("k", { burst: 20, perSecond: 2 }, t)).toBe(false);
172 expect(allow("k", { burst: 20, perSecond: 2 }, t + 1000)).toBe(true);
173 });
src/security.ts added +189
@@ -0,0 +1,189 @@
1 /**
2 * The door: what every request passes before a route sees it, and before
3 * a byte of its body is read. Three checks, in order:
4 *
5 * 1. **Rationing.** Writes are rationed per person (per address, for a
6 * stranger), and a stranger's calls to the API are rationed too.
7 * 2. **Same origin.** A write that a browser says came from another site
8 * is refused: that is another page making this person's browser act.
9 * 3. **The contract.** A request under a resource's `api` is that
10 * resource's verb (see docs/CONTRACT.md in the forge: `GET api` lists,
11 * `POST api` creates, `GET|PATCH|DELETE api/<id>` read, update,
12 * delete). If the table lets this person through that door for no row
13 * at all, they are refused here — 401 for a stranger, 403 for anyone
14 * else. The action asks again, about the real row; the door only turns
15 * away who could never pass. `/openapi.json` is shut to strangers when
16 * the table opens no door to them.
17 *
18 * Nothing here compares roles by hand: the answer is `decide`'s, from the
19 * same table the actions read. Refused writes go into the audit trail.
20 */
21
22 import type { Database } from "bun:sqlite";
23 import { record } from "./audit.ts";
24 import { decide, VERBS, type Contract, type Verb } from "./can.ts";
25 import { actorOf, contract } from "./contract.ts";
26 import { publicOrigin } from "./env.ts";
27 import { json, text } from "./http.ts";
28 import type { Who } from "./who.ts";
29
30 // A browser on another site can make a person's browser POST here; it cannot
31 // make that request look same-origin. Browsers say where a request came from
32 // (Sec-Fetch-Site, or Origin); a request that names another site is refused.
33 // A script sends neither and is judged by its identity instead.
34 export const sameOrigin = (request: Request): boolean => {
35 const site = request.headers.get("sec-fetch-site");
36 if (site !== null) return site === "same-origin" || site === "none";
37 const origin = request.headers.get("origin");
38 return origin === null || origin === publicOrigin;
39 };
40
41 // A bucket of `burst` tokens that refills at `perSecond`. In memory, so a
42 // restart forgets — which is the right size for one process on one machine.
43 export interface Rate {
44 readonly burst: number;
45 readonly perSecond: number;
46 }
47 export const WRITES: Rate = { burst: 20, perSecond: 2 };
48 /** A stranger's API calls, reads too. Behind the forge every stranger shares one address, so this is all of them together. */
49 export const STRANGERS: Rate = { burst: 60, perSecond: 10 };
50 const buckets = new Map<string, { tokens: number; at: number }>();
51 export function allow(
52 key: string,
53 rate: Rate = WRITES,
54 now = Date.now(),
55 ): boolean {
56 const bucket = buckets.get(key) ?? { tokens: rate.burst, at: now };
57 bucket.tokens = Math.min(
58 rate.burst,
59 bucket.tokens + ((now - bucket.at) / 1000) * rate.perSecond,
60 );
61 bucket.at = now;
62 buckets.set(key, bucket);
63 if (buckets.size > 10_000)
64 for (const [k, b] of buckets) if (now - b.at > 60_000) buckets.delete(k);
65 if (bucket.tokens < 1) return false;
66 bucket.tokens -= 1;
67 return true;
68 }
69
70 const ROW_VERB: Readonly<Record<string, Verb>> = {
71 GET: "read",
72 PATCH: "update",
73 DELETE: "delete",
74 };
75 const SET_VERB: Readonly<Record<string, Verb>> = {
76 GET: "list",
77 POST: "create",
78 };
79
80 /** The resource and verb a request is, by the contract's `api` paths; undefined for any other path. */
81 export function doorOf(
82 method: string,
83 pathname: string,
84 table: Contract = contract,
85 ): { resource: string; verb: Verb } | undefined {
86 for (const [resource, { api }] of Object.entries(table.resources)) {
87 if (api === undefined) continue;
88 if (pathname === api) {
89 const verb = SET_VERB[method];
90 return verb === undefined ? undefined : { resource, verb };
91 }
92 const rest = pathname.startsWith(`${api}/`)
93 ? pathname.slice(api.length + 1)
94 : undefined;
95 if (rest !== undefined && rest !== "" && !rest.includes("/")) {
96 const verb = ROW_VERB[method];
97 return verb === undefined ? undefined : { resource, verb };
98 }
99 }
100 return undefined;
101 }
102
103 /**
104 * Could `who` pass this door for any row at all? The door asks about a row
105 * that names them in every column a role is held by — the best row they
106 * could hope for — so it refuses only what no row would open.
107 */
108 export function mayKnock(
109 who: Who,
110 resource: string,
111 verb: Verb,
112 table: Contract = contract,
113 ): boolean {
114 const actor = actorOf(who);
115 const columns = Object.values(table.roles).flatMap((role) =>
116 role.held.by === "row" ? [role.held.column] : [],
117 );
118 const best =
119 verb === "list" || verb === "create" || actor.id === null
120 ? undefined
121 : Object.fromEntries(columns.map((column) => [column, actor.id]));
122 return decide(table, resource, verb, actor, best) !== "no";
123 }
124
125 /** Does the table open any door with an `api` to a stranger? Then the API's description is theirs to read. */
126 export const openToStrangers = (table: Contract = contract): boolean =>
127 Object.entries(table.resources).some(
128 ([resource, { api }]) =>
129 api !== undefined &&
130 VERBS.some((verb) => mayKnock({ role: "anon" }, resource, verb, table)),
131 );
132 const describedToStrangers = openToStrangers();
133
134 /**
135 * The door. `undefined` lets the request through to its route; a response
136 * is the refusal. `from` is the caller's address, for rationing a stranger.
137 */
138 export function door(
139 db: Database,
140 request: Request,
141 url: URL,
142 who: Who,
143 from: string,
144 ): Response | undefined {
145 const method = request.method === "HEAD" ? "GET" : request.method;
146 const writing = method !== "GET";
147 const stranger = who.role === "anon";
148 const api =
149 url.pathname.startsWith("/api/") ||
150 url.pathname === "/mcp" ||
151 url.pathname === "/openapi.json";
152 const key = stranger ? `ip:${from}` : `user:${who.id}`;
153
154 if (writing && !allow(key)) return text("too many requests", 429);
155 if (stranger && api && !allow(`api:${from}`, STRANGERS))
156 return text("too many requests", 429);
157
158 const found = doorOf(method, url.pathname);
159 if (writing && !sameOrigin(request)) {
160 record(db, {
161 who,
162 resource: found?.resource ?? url.pathname,
163 verb: found?.verb ?? method,
164 outcome: "cross_site",
165 });
166 return text("forbidden", 403);
167 }
168
169 const shut =
170 found === undefined
171 ? url.pathname === "/openapi.json" && stranger && !describedToStrangers
172 : !mayKnock(who, found.resource, found.verb);
173 if (!shut) return undefined;
174 const outcome = stranger ? "signin_required" : "forbidden";
175 if (writing && found !== undefined)
176 record(db, {
177 who,
178 resource: found.resource,
179 verb: found.verb,
180 target:
181 url.pathname === contract.resources[found.resource]?.api
182 ? undefined
183 : url.pathname.split("/").at(-1),
184 outcome,
185 });
186 return stranger
187 ? json({ error: outcome, message: "Sign in first." }, 401)
188 : json({ error: outcome, message: "You may not." }, 403);
189 }
src/server.test.ts added +342
@@ -0,0 +1,342 @@
1 import { afterAll, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { env } from "./env.ts";
6 import { serve } from "./server.ts";
7 import { sign } from "./who.ts";
8
9 const KEY = "a-key-only-the-forge-holds";
10 const server = serve({
11 port: 0,
12 dataDir: mkdtempSync(join(tmpdir(), "app-")),
13 identityKey: KEY,
14 });
15 const base = `http://${server.hostname}:${server.port}`;
16 afterAll(() => server.stop(true));
17
18 /** The headers the forge sends for a signed-in person. */
19 const as = (
20 id: string,
21 name: string,
22 role: "user" | "admin" = "user",
23 ): Record<string, string> => {
24 const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`;
25 return {
26 "x-forge-identity": identity,
27 "x-forge-signature": sign(identity, KEY),
28 };
29 };
30 const alice = as("u1", "alice");
31 const bob = as("u2", "bob");
32 const root = as("u0", "root", "admin");
33 const jsonBody = (body: unknown): RequestInit => ({
34 headers: { "content-type": "application/json" },
35 body: JSON.stringify(body),
36 });
37 const api = (
38 path: string,
39 init: RequestInit = {},
40 who: Record<string, string> = {},
41 ) =>
42 fetch(base + path, {
43 ...init,
44 headers: { ...(init.headers as Record<string, string>), ...who },
45 });
46 const rpc = (message: unknown, who: Record<string, string> = {}) =>
47 api("/mcp", { method: "POST", ...jsonBody(message) }, who).then(
48 (r) => r.json() as Promise<any>,
49 );
50
51 test("the page knows who you are and offers the right door", async () => {
52 const guest = await (await fetch(base + "/")).text();
53 expect(guest).toContain("<h1>");
54 expect(guest).toContain("/__forge/signin?to=/");
55 expect(guest).toContain('href="/__forge/report"');
56 expect(guest).not.toContain('action="/notes"');
57 const signed = await (await api("/", {}, alice)).text();
58 expect(signed).toContain("<b>alice</b>");
59 expect(signed).toContain("/__forge/signout");
60 expect(signed).toContain('action="/notes"');
61 });
62
63 test("a form post adds as the person; a guest is sent to sign in", async () => {
64 const form = new FormData();
65 form.set("body", "hello from a form");
66 const refused = await api("/notes", {
67 method: "POST",
68 body: form,
69 redirect: "manual",
70 });
71 expect(refused.status).toBe(303);
72 expect(refused.headers.get("set-cookie")).toContain("notice=");
73 expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax");
74 expect(await (await fetch(base + "/")).text()).not.toContain(
75 "hello from a form",
76 );
77 const posted = await api(
78 "/notes",
79 { method: "POST", body: form, redirect: "manual" },
80 alice,
81 );
82 expect(posted.status).toBe(303);
83 expect(posted.headers.get("set-cookie")).toBeNull();
84 const page = await (await api("/", {}, alice)).text();
85 expect(page).toContain("hello from a form");
86 expect(page).toContain("alice ·");
87 });
88
89 test("the JSON API is the same actions with the same identity", async () => {
90 const created = await api(
91 "/api/notes",
92 { method: "POST", ...jsonBody({ body: "over the api" }) },
93 alice,
94 );
95 expect(created.status).toBe(201);
96 const note = (await created.json()) as { id: number; author_name: string };
97 expect(note.author_name).toBe("alice");
98 const listed = (await (await fetch(base + "/api/notes")).json()) as {
99 id: number;
100 }[];
101 expect(listed.map((n) => n.id)).toContain(note.id);
102 expect(
103 (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) }))
104 .status,
105 ).toBe(401);
106 expect(
107 (await api(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status,
108 ).toBe(403);
109 expect(
110 (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status,
111 ).toBe(200);
112 expect(
113 (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status,
114 ).toBe(404);
115 expect(
116 (
117 await api(
118 "/api/notes",
119 { method: "POST", ...jsonBody({ body: "" }) },
120 alice,
121 )
122 ).status,
123 ).toBe(422);
124 });
125
126 test("the MCP server lists the tools and calls them as the caller", async () => {
127 const hello = await rpc({
128 jsonrpc: "2.0",
129 id: 1,
130 method: "initialize",
131 params: {},
132 });
133 expect(hello.result.capabilities).toEqual({ tools: {} });
134 const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
135 expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([
136 "list_notes",
137 "add_note",
138 "delete_note",
139 ]);
140 const call = (name: string, args: unknown, who = {}) =>
141 rpc(
142 {
143 jsonrpc: "2.0",
144 id: 3,
145 method: "tools/call",
146 params: { name, arguments: args },
147 },
148 who,
149 );
150 const refused = await call("add_note", { body: "as nobody" });
151 expect(refused.result.isError).toBe(true);
152 const added = await call("add_note", { body: "over mcp" }, root);
153 expect(added.result.isError).toBeUndefined();
154 const note = JSON.parse(added.result.content[0].text);
155 expect(note.author_name).toBe("root");
156 const notes = JSON.parse(
157 (await call("list_notes", {})).result.content[0].text,
158 );
159 expect(notes.map((n: { id: number }) => n.id)).toContain(note.id);
160 expect(
161 (await call("delete_note", { id: note.id }, root)).result.isError,
162 ).toBeUndefined();
163 expect(
164 (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code,
165 ).toBe(-32601);
166 const notified = await api("/mcp", {
167 method: "POST",
168 ...jsonBody({ jsonrpc: "2.0", method: "notifications/initialized" }),
169 });
170 expect(notified.status).toBe(202);
171 });
172
173 test("a change is on the event stream, and a late client hears what it missed", async () => {
174 const stream = await fetch(base + "/events");
175 expect(stream.headers.get("content-type")).toBe("text/event-stream");
176 const reader = stream.body!.getReader();
177 const next = async (): Promise<{ id: string; type: string }> => {
178 let buffer = "";
179 for (;;) {
180 const { value } = await reader.read();
181 buffer += new TextDecoder().decode(value);
182 const frame = /id: (\d+)\ndata: (.*)\n\n/.exec(buffer);
183 if (frame) return { id: frame[1]!, type: JSON.parse(frame[2]!).type };
184 }
185 };
186 await api(
187 "/api/notes",
188 { method: "POST", ...jsonBody({ body: "live" }) },
189 alice,
190 );
191 const heard = await next();
192 expect(heard.type).toBe("note.added");
193 await reader.cancel();
194 const late = await fetch(base + "/events", {
195 headers: { "last-event-id": String(Number(heard.id) - 1) },
196 });
197 const replay = new TextDecoder().decode(
198 (await late.body!.getReader().read()).value,
199 );
200 expect(replay).toContain(`id: ${heard.id}\n`);
201 expect(replay).toContain("note.added");
202 });
203
204 test("writes are rationed per person", async () => {
205 const flood = as("u9", "flood");
206 const answers = await Promise.all(
207 Array.from({ length: 40 }, () =>
208 api(
209 "/api/notes",
210 { method: "POST", ...jsonBody({ body: "again" }) },
211 flood,
212 ),
213 ),
214 );
215 const codes = answers.map((r) => r.status);
216 expect(codes).toContain(201);
217 expect(codes).toContain(429);
218 });
219
220 test("stands on the security floor", async () => {
221 const response = await fetch(base + "/");
222 expect(response.headers.get("content-security-policy")).toContain(
223 "default-src 'self'",
224 );
225 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
226 expect(
227 (await fetch(base + "/api/notes")).headers.get("content-security-policy"),
228 ).toContain("default-src");
229 });
230
231 test("a body is capped by the bytes read, whatever it claims", async () => {
232 const response = await api(
233 "/notes",
234 {
235 method: "POST",
236 headers: { "content-type": "application/x-www-form-urlencoded" },
237 body: "body=" + "x".repeat(1024 * 1024),
238 },
239 alice,
240 );
241 expect(response.status).toBe(413);
242 });
243
244 test("HEAD sees the app up", async () => {
245 const response = await fetch(base + "/", { method: "HEAD" });
246 expect(response.status).toBe(200);
247 expect(await response.text()).toBe("");
248 });
249
250 test("refuses a cross-site post", async () => {
251 const form = new FormData();
252 form.set("body", "from another site");
253 const posted = await api(
254 "/notes",
255 {
256 method: "POST",
257 body: form,
258 redirect: "manual",
259 headers: {
260 origin: "https://evil.example",
261 "sec-fetch-site": "cross-site",
262 },
263 },
264 alice,
265 );
266 expect(posted.status).toBe(403);
267 });
268
269 test("static files are cached for a year when linked by content", async () => {
270 const page = await (await fetch(base + "/")).text();
271 const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
272 expect(href).toBeDefined();
273 const css = await fetch(base + href!);
274 expect(css.headers.get("cache-control")).toContain("immutable");
275 expect(
276 (await fetch(base + "/static/app.css")).headers.get("cache-control"),
277 ).toBe("no-cache");
278 expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404);
279 });
280
281 test("/healthz says up, apart from the page", async () => {
282 const response = await fetch(base + "/healthz");
283 expect(response.status).toBe(200);
284 expect(response.headers.get("cache-control")).toBe("no-store");
285 const health = (await response.json()) as Record<string, unknown>;
286 expect(health.ok).toBe(true);
287 expect(health.version).toMatch(/^\d+\.\d+\.\d+/);
288 expect(Number.isInteger(health.uptime)).toBe(true);
289 expect((await fetch(base + "/healthz", { method: "HEAD" })).status).toBe(200);
290 });
291
292 test("a link to the page unfurls: description, Open Graph, a card", async () => {
293 const page = await (await fetch(base + "/")).text();
294 for (const meta of [
295 'name="description"',
296 'property="og:title"',
297 'property="og:description"',
298 'property="og:url"',
299 'property="og:type" content="website"',
300 'name="twitter:card" content="summary"',
301 'name="theme-color"',
302 ])
303 expect([meta, page.includes(meta)]).toEqual([meta, true]);
304 });
305
306 test("the look is the tokens: /tokens.css is linked by content and cached for a year", async () => {
307 const page = await (await fetch(base + "/")).text();
308 const href = /href="(\/tokens\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
309 expect(href).toBeDefined();
310 const css = await fetch(base + href!);
311 expect(css.headers.get("content-type")).toContain("text/css");
312 expect(css.headers.get("cache-control")).toContain("immutable");
313 expect(await css.text()).toContain("--color-bg:");
314 // The tokens come first, so every rule after them can use them.
315 expect(page.indexOf("/tokens.css")).toBeLessThan(
316 page.indexOf("/static/app.css"),
317 );
318 });
319
320 test("what the web expects: robots.txt, and security.txt from the forge that runs us", async () => {
321 // Production is read by crawlers, the API is not; anything else is a
322 // draft and asks not to be indexed at all.
323 const robots = await (await fetch(base + "/robots.txt")).text();
324 const noindex = (await fetch(base + "/")).headers.get("x-robots-tag");
325 if (env === "production") {
326 expect(robots).toContain("Disallow: /api/\n");
327 expect(noindex).toBeNull();
328 } else {
329 expect(robots).toBe("User-agent: *\nDisallow: /\n");
330 expect(noindex).toBe("noindex");
331 }
332 const security = await fetch(base + "/.well-known/security.txt", {
333 redirect: "manual",
334 });
335 if (process.env.FORGE_URL === undefined) expect(security.status).toBe(404);
336 else {
337 expect(security.status).toBe(302);
338 expect(security.headers.get("location")).toBe(
339 new URL("/.well-known/security.txt", process.env.FORGE_URL).toString(),
340 );
341 }
342 });
src/server.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * The process the forge runs. `serve` opens the database and listens:
3 * every request gets the floor (`http.ts`), an identity (`who.ts`), the
4 * door (`security.ts`) and one route (`routes.ts`) — so a request from
5 * another site, from someone going too fast, or from someone the contract
6 * turns away is refused before a byte of it is read. Run directly, it
7 * serves what the forge said; a test calls `serve` with its own.
8 */
9
10 import { openDb } from "./db.ts";
11 import { dataDir, identityKey, title, where } from "./env.ts";
12 import { html, text } from "./http.ts";
13 import { serveSpan } from "./otel.ts";
14 import { match } from "./routes.ts";
15 import { door } from "./security.ts";
16 import { who } from "./who.ts";
17
18 export interface ServeOptions {
19 readonly port: number;
20 readonly hostname?: string | undefined;
21 readonly dataDir: string;
22 readonly identityKey: string;
23 }
24
25 export function serve(options: ServeOptions) {
26 const db = openDb(options.dataDir);
27 return Bun.serve({
28 port: options.port,
29 hostname: options.hostname ?? "127.0.0.1",
30 // A connection that says nothing for a minute is dropped. The event
31 // stream heartbeats every 25 seconds to stay well inside that.
32 idleTimeout: 60,
33 // Every request is a span (src/otel.ts), a child of the forge's: it
34 // shows in the platform's traces with its route, status and timing.
35 // Wrap slow work in `span("name", () => ...)` to see it there too.
36 fetch: (request, server) =>
37 serveSpan(request, async (span) => {
38 const url = new URL(request.url);
39 const me = who(request, options.identityKey);
40 const refused = door(
41 db,
42 request,
43 url,
44 me,
45 server.requestIP(request)?.address ?? "?",
46 );
47 if (refused !== undefined) return head(request, refused);
48 const found = match(
49 request.method === "HEAD" ? "GET" : request.method,
50 url.pathname,
51 );
52 if (found === undefined) return text("not found", 404);
53 span.set("http.route", found.route.path);
54 const response = await found.route.handle({
55 request,
56 url,
57 db,
58 who: me,
59 params: found.params,
60 });
61 return head(request, response);
62 }),
63 // A bug answers with the floor and nothing else: never the runtime's
64 // own error page, which carries source and paths, and never without
65 // the headers. The detail goes to the log, where it belongs.
66 error(error) {
67 // Inside the generated app, which has no forge log module.
68 console.error(error);
69 return html("<h1>Something went wrong</h1>", 500);
70 },
71 });
72 }
73
74 // HEAD is GET without the body: a health check sees the app up.
75 const head = (request: Request, response: Response): Response =>
76 request.method === "HEAD"
77 ? new Response(null, { status: response.status, headers: response.headers })
78 : response;
79
80 if (import.meta.main) {
81 const server = serve({
82 port: Number(process.env.PORT ?? 8080),
83 hostname: process.env.HOST,
84 dataDir,
85 identityKey,
86 });
87 console.log(
88 `${title} listening on http://${server.hostname}:${server.port} (${where})`,
89 );
90 }
src/tokens.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * The look, compiled. `design/tokens.json` is the app's design tokens in the
3 * W3C format — groups, `$value`, a `$type` a group hands down — and this
4 * turns them into custom properties once, at boot: `color.bg` becomes
5 * `--color-bg`. A colour's `$value` is the light theme and
6 * `$extensions.mode.dark` the dark one; dark follows the system unless
7 * `<html data-theme="light|dark">` decides. The page links the result as
8 * `/tokens.css`, and every other stylesheet speaks only in `var(--…)`.
9 *
10 * Tokens that do not read are not a warning. The app does not start.
11 */
12
13 import { createHash } from "node:crypto";
14 import { readFileSync } from "node:fs";
15
16 export interface Token {
17 /** The custom property: `--color-bg`. */
18 readonly name: string;
19 readonly type: string;
20 readonly light: string;
21 readonly dark?: string;
22 }
23
24 const isGroup = (v: unknown): v is Record<string, unknown> =>
25 typeof v === "object" && v !== null && !Array.isArray(v);
26
27 /** Every token in the tree, in order, with its type and both themes' values. */
28 export function flatten(
29 tree: unknown,
30 path: string[] = [],
31 type = "",
32 ): Token[] {
33 if (!isGroup(tree))
34 throw new Error(`${path.join(".") || "tokens"}: not a group`);
35 const here = typeof tree.$type === "string" ? tree.$type : type;
36 if ("$value" in tree) {
37 const name = `--${path.join("-")}`;
38 if (!/^--[a-z0-9-]+$/.test(name))
39 throw new Error(`${path.join(".")}: not a name CSS can carry`);
40 if (here === "") throw new Error(`${path.join(".")}: no $type`);
41 const dark =
42 isGroup(tree.$extensions) && isGroup(tree.$extensions.mode)
43 ? tree.$extensions.mode.dark
44 : undefined;
45 return [
46 {
47 name,
48 type: here,
49 light: css(tree.$value, path),
50 ...(dark === undefined ? {} : { dark: css(dark, path) }),
51 },
52 ];
53 }
54 return Object.entries(tree)
55 .filter(([key]) => !key.startsWith("$"))
56 .flatMap(([key, child]) => flatten(child, [...path, key], here));
57 }
58
59 // A value is a string, a number, or a font stack; nothing in it may close
60 // the declaration it sits in.
61 function css(value: unknown, path: string[]): string {
62 const text = Array.isArray(value)
63 ? value
64 .map((f) =>
65 /^[\w-]+$/.test(String(f)) ? String(f) : JSON.stringify(String(f)),
66 )
67 .join(", ")
68 : typeof value === "string" || typeof value === "number"
69 ? String(value)
70 : "";
71 if (text === "" || /[;{}<]/.test(text))
72 throw new Error(`${path.join(".")}: $value is not a CSS value`);
73 return text;
74 }
75
76 /** The stylesheet: every token on `:root`, the dark ones twice — for the system's choice and for the page's. */
77 export function compile(tokens: readonly Token[]): string {
78 const block = (selector: string, lines: string[]): string =>
79 `${selector} {\n${lines.map((l) => ` ${l}\n`).join("")}}\n`;
80 const light = tokens.map((t) => `${t.name}: ${t.light};`);
81 const dark = tokens.flatMap((t) =>
82 t.dark === undefined ? [] : [`${t.name}: ${t.dark};`],
83 );
84 return [
85 block(":root", ["color-scheme: light dark;", ...light]),
86 block(':root[data-theme="light"]', ["color-scheme: light;"]),
87 `@media (prefers-color-scheme: dark) {\n${block(':root:not([data-theme="light"])', dark)}}\n`,
88 block(':root[data-theme="dark"]', ["color-scheme: dark;", ...dark]),
89 ].join("\n");
90 }
91
92 export const tokens: readonly Token[] = flatten(
93 JSON.parse(
94 readFileSync(new URL("../design/tokens.json", import.meta.url), "utf8"),
95 ),
96 );
97 export const tokensCss: string = compile(tokens);
98 /** `/tokens.css`, addressed by its content, so it can be cached for a year. */
99 export const tokensHref = `/tokens.css?v=${createHash("sha256").update(tokensCss).digest("hex").slice(0, 8)}`;
100
101 /** One token's value, for the few places a colour must be spelled out (the browser's own chrome). */
102 export const token = (
103 name: string,
104 mode: "light" | "dark" = "light",
105 ): string => {
106 const found = tokens.find((t) => t.name === `--${name}`);
107 if (found === undefined) throw new Error(`no token ${name}`);
108 return mode === "dark" ? (found.dark ?? found.light) : found.light;
109 };
src/tty.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * The app in a terminal. The forge runs this on a pty when someone
3 * `ssh`es to the app, over the same data as the page and through the same
4 * actions, as the account at the keyboard. j/k move, a adds, d removes the
5 * note under the cursor, q leaves. Plain ANSI, no dependencies.
6 */
7
8 import {
9 addNote,
10 canDelete,
11 deleteNote,
12 listNotes,
13 type Note,
14 } from "./actions.ts";
15 import { openDb } from "./db.ts";
16 import { ssh, title, where } from "./env.ts";
17 import { whoAtTty } from "./who.ts";
18
19 const db = openDb();
20 const me = whoAtTty();
21 const esc = "\x1b[";
22 const dim = (s: string): string => `${esc}2m${s}${esc}0m`;
23 const bold = (s: string): string => `${esc}1m${s}${esc}0m`;
24 const out = (s: string): void => {
25 process.stdout.write(s);
26 };
27
28 let cursor = 0;
29 let typing: string | undefined;
30 let said = "";
31
32 const notes = (): Note[] => {
33 const listed = listNotes(db, me);
34 return listed.ok ? listed.value : [];
35 };
36
37 function draw(): void {
38 const rows = notes();
39 const lines = [
40 `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`,
41 ssh === undefined ? "" : dim(`connect: ${ssh}`),
42 "",
43 ];
44 if (rows.length === 0)
45 lines.push(
46 dim(
47 me.role === "anon"
48 ? "No notes yet."
49 : "No notes yet. Press a to add one.",
50 ),
51 );
52 rows.forEach((note, i) => {
53 const body = i === cursor ? bold(note.body) : note.body;
54 lines.push(
55 `${i === cursor ? "▸" : " "} ${body} ${dim(`${note.author_name} · ${note.created_at.slice(0, 16).replace("T", " ")}`)}`,
56 );
57 });
58 lines.push("");
59 if (typing !== undefined) lines.push(`New note: ${typing}${esc}7m ${esc}0m`);
60 else if (said !== "") lines.push(said);
61 else
62 lines.push(
63 dim(
64 me.role === "anon"
65 ? "j/k move · q quit · sign in with a key the forge knows to add"
66 : "j/k move · a add · d delete · q quit",
67 ),
68 );
69 out(`${esc}2J${esc}H${lines.join("\r\n")}`);
70 }
71
72 process.stdin.setRawMode?.(true);
73 process.stdin.resume();
74 process.stdin.setEncoding("utf8");
75 draw();
76 process.stdin.on("data", (key: string) => {
77 said = "";
78 if (typing !== undefined) {
79 if (key === "\r") {
80 const added = addNote(db, me, { body: typing });
81 if (!added.ok) said = added.message;
82 typing = undefined;
83 cursor = 0;
84 } else if (key === "\x1b") typing = undefined;
85 else if (key === "\x7f") typing = typing.slice(0, -1);
86 else if (key >= " ") typing += key;
87 draw();
88 return;
89 }
90 if (key === "q" || key === "\x03") {
91 out(`${esc}2J${esc}H`);
92 process.exit(0);
93 }
94 const rows = notes();
95 if (key === "j" || key === "\x1b[B")
96 cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1));
97 if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
98 if (key === "a") {
99 if (me.role === "anon") said = "Sign in to add a note.";
100 else typing = "";
101 }
102 if (key === "d") {
103 const note = rows[cursor];
104 if (note === undefined) said = "Nothing to remove.";
105 else if (!canDelete(me, note))
106 said = "Only the author or an admin can remove a note.";
107 else {
108 const removed = deleteNote(db, me, { id: note.id });
109 if (!removed.ok) said = removed.message;
110 cursor = Math.min(cursor, Math.max(0, rows.length - 2));
111 }
112 }
113 draw();
114 });
115 process.stdout.on("resize", draw);
src/who.test.ts added +59
@@ -0,0 +1,59 @@
1 import { expect, test } from "bun:test";
2 import { sign, who, whoAtTty } from "./who.ts";
3
4 const KEY = "a-key-only-the-forge-holds";
5 const later = Date.now() + 60_000;
6 const request = (identity: string, signature: string): Request =>
7 new Request("http://app/", {
8 headers: { "x-forge-identity": identity, "x-forge-signature": signature },
9 });
10
11 test("a signed identity is who it says", () => {
12 const identity = `u1|alice|admin|${later}`;
13 expect(who(request(identity, sign(identity, KEY)), KEY)).toEqual({
14 role: "admin",
15 id: "u1",
16 name: "alice",
17 });
18 });
19
20 test("a forged, tampered, expired or unsigned identity is anonymous", () => {
21 const identity = `u1|alice|admin|${later}`;
22 expect(who(request(identity, sign(identity, "another key")), KEY)).toEqual({
23 role: "anon",
24 });
25 expect(
26 who(request(`u1|alice|admin|${later + 1}`, sign(identity, KEY)), KEY),
27 ).toEqual({ role: "anon" });
28 const expired = `u1|alice|user|${Date.now() - 1}`;
29 expect(who(request(expired, sign(expired, KEY)), KEY)).toEqual({
30 role: "anon",
31 });
32 const odd = `u1|alice|owner|${later}`;
33 expect(who(request(odd, sign(odd, KEY)), KEY)).toEqual({ role: "anon" });
34 expect(who(new Request("http://app/"), KEY)).toEqual({ role: "anon" });
35 // No key configured: nothing verifies, whoever signed it.
36 expect(who(request(identity, sign(identity, KEY)), "")).toEqual({
37 role: "anon",
38 });
39 });
40
41 test("the terminal trusts its environment", () => {
42 expect(whoAtTty({})).toEqual({ role: "anon" });
43 expect(whoAtTty({ FORGE_APP_USER: "alice" })).toEqual({
44 role: "user",
45 id: "alice",
46 name: "alice",
47 });
48 expect(
49 whoAtTty({
50 FORGE_APP_USER: "alice",
51 FORGE_APP_USER_ID: "u1",
52 FORGE_APP_USER_ROLE: "admin",
53 }),
54 ).toEqual({
55 role: "admin",
56 id: "u1",
57 name: "alice",
58 });
59 });
src/who.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * Who is asking. The forge signs every request it proxies to the app:
3 *
4 * x-forge-identity: <userId>|<username>|<role>|<expiresMs>
5 * x-forge-signature: base64url(HMAC-SHA256(FORGE_APP_IDENTITY_KEY, that string))
6 *
7 * `role` is `user` (signed in and allowed to see this app) or `admin`
8 * (allowed to write its repository). The forge strips these two headers
9 * from whatever a client sends, so a signature that verifies is the only
10 * trust there is: no headers, no key, a bad signature or a stamp in the
11 * past all mean `anon`. Nothing here reads a cookie.
12 */
13
14 import { createHmac, timingSafeEqual } from "node:crypto";
15
16 export type Who =
17 | { role: "anon" }
18 | { role: "user" | "admin"; id: string; name: string };
19
20 export const ANON: Who = { role: "anon" };
21
22 export function who(request: Request, key: string, now = Date.now()): Who {
23 const identity = request.headers.get("x-forge-identity");
24 const signature = request.headers.get("x-forge-signature");
25 if (key === "" || identity === null || signature === null) return ANON;
26 if (!same(sign(identity, key), signature)) return ANON;
27 const parts = identity.split("|");
28 if (parts.length !== 4) return ANON;
29 const [id, name, role, expires] = parts as [string, string, string, string];
30 if (role !== "user" && role !== "admin") return ANON;
31 if (
32 id === "" ||
33 name === "" ||
34 !/^\d+$/.test(expires) ||
35 Number(expires) <= now
36 )
37 return ANON;
38 return { role, id, name };
39 }
40
41 /** The signature the forge computes. Exported so a test can be the forge. */
42 export const sign = (identity: string, key: string): string =>
43 createHmac("sha256", key).update(identity).digest("base64url");
44
45 const same = (a: string, b: string): boolean => {
46 const x = Buffer.from(a);
47 const y = Buffer.from(b);
48 return x.length === y.length && timingSafeEqual(x, y);
49 };
50
51 /**
52 * The person at the terminal. A tty session is started by the forge itself,
53 * so its environment is trusted: `FORGE_APP_USER` is the account at the
54 * keyboard (empty for a guest), `FORGE_APP_USER_ID` and `FORGE_APP_USER_ROLE`
55 * say the rest when the forge knows them.
56 */
57 export function whoAtTty(
58 env: Record<string, string | undefined> = process.env,
59 ): Who {
60 const name = env.FORGE_APP_USER ?? "";
61 if (name === "") return ANON;
62 return {
63 role: env.FORGE_APP_USER_ROLE === "admin" ? "admin" : "user",
64 id: env.FORGE_APP_USER_ID ?? name,
65 name,
66 };
67 }
static/app.css added +209
@@ -0,0 +1,209 @@
1 /* The look, in the tokens' words. Every value that is a choice — a colour, a
2 space, a radius, a size — is a var(--…) from /tokens.css, which
3 design/tokens.json compiles to at boot; `bun run lint:design` refuses a raw
4 colour here. Light and dark are the tokens' business, not this file's. */
5
6 /* The page. */
7 html {
8 -webkit-text-size-adjust: 100%;
9 text-size-adjust: 100%;
10 }
11 body {
12 margin: 0;
13 background: var(--color-bg);
14 color: var(--color-fg);
15 font: var(--text-base) / var(--leading-body) var(--font-sans);
16 }
17 .page {
18 max-width: 40rem;
19 margin: 0 auto;
20 padding: var(--space-5) var(--space-4) var(--space-6);
21 }
22 .head {
23 display: flex;
24 flex-wrap: wrap;
25 justify-content: space-between;
26 align-items: flex-start;
27 gap: var(--space-3) var(--space-4);
28 margin-bottom: var(--space-5);
29 }
30 h1 {
31 margin: 0;
32 font-size: var(--text-xl);
33 font-weight: 650;
34 letter-spacing: -0.01em;
35 line-height: var(--leading-tight);
36 }
37 p {
38 margin: 0;
39 }
40 a {
41 color: var(--color-accent);
42 text-decoration-thickness: 1px;
43 text-underline-offset: 0.15em;
44 }
45 code,
46 .mono {
47 font-family: var(--font-mono);
48 font-size: var(--text-sm);
49 }
50 :focus-visible {
51 outline: 2px solid var(--color-accent);
52 outline-offset: 2px;
53 }
54 .muted {
55 color: var(--color-muted);
56 }
57 .you {
58 color: var(--color-muted);
59 white-space: nowrap;
60 }
61
62 /* Blocks. */
63 .card {
64 background: var(--color-surface);
65 border: 1px solid var(--color-line);
66 border-radius: var(--radius-md);
67 padding: var(--space-4);
68 margin-bottom: var(--space-5);
69 }
70 .row {
71 display: flex;
72 align-items: center;
73 gap: var(--space-2);
74 }
75 .list {
76 list-style: none;
77 margin: 0;
78 padding: 0;
79 }
80 .note {
81 display: flex;
82 justify-content: space-between;
83 align-items: flex-start;
84 gap: var(--space-3);
85 padding: var(--space-3) 0;
86 border-top: 1px solid var(--color-line);
87 }
88 .note:first-child {
89 border-top: 0;
90 }
91 .note p {
92 overflow-wrap: anywhere;
93 }
94 .note small {
95 font-size: var(--text-sm);
96 }
97 .empty {
98 padding: var(--space-6) var(--space-4);
99 text-align: center;
100 color: var(--color-muted);
101 border: 1px dashed var(--color-line);
102 border-radius: var(--radius-md);
103 }
104 footer {
105 margin-top: var(--space-6);
106 font-size: var(--text-sm);
107 display: grid;
108 gap: var(--space-1);
109 }
110 footer code {
111 overflow-wrap: anywhere;
112 }
113
114 /* Controls: at least 44px on a side, keyboard and mouse alike. */
115 .input {
116 flex: 1;
117 min-width: 0;
118 min-height: var(--size-tap);
119 padding: 0 var(--space-3);
120 font: inherit;
121 font-size: max(16px, var(--text-base));
122 color: inherit;
123 background: var(--color-bg);
124 border: 1px solid var(--color-line);
125 border-radius: var(--radius-md);
126 }
127 .input::placeholder {
128 color: var(--color-muted);
129 }
130 .button {
131 min-height: var(--size-tap);
132 padding: 0 var(--space-4);
133 font: inherit;
134 font-weight: 600;
135 color: var(--color-accent-fg);
136 background: var(--color-accent);
137 border: 1px solid transparent;
138 border-radius: var(--radius-md);
139 cursor: pointer;
140 white-space: nowrap;
141 text-decoration: none;
142 display: inline-flex;
143 align-items: center;
144 }
145 .button:hover {
146 filter: brightness(1.08);
147 }
148 .button-quiet {
149 color: var(--color-muted);
150 background: transparent;
151 padding: 0 var(--space-3);
152 }
153 .button-quiet:hover {
154 color: var(--color-fg);
155 border-color: var(--color-line);
156 filter: none;
157 }
158 .pill {
159 display: inline-block;
160 padding: 0.1rem 0.6rem;
161 font-size: var(--text-sm);
162 font-weight: 600;
163 color: var(--color-muted);
164 border: 1px solid var(--color-line);
165 border-radius: var(--radius-pill);
166 }
167 .pill[data-env="production"] {
168 color: var(--color-accent);
169 border-color: currentColor;
170 }
171
172 /* A word from the last request, shown once, gone in a moment. */
173 .toast {
174 position: fixed;
175 left: 50%;
176 bottom: var(--space-5);
177 transform: translateX(-50%);
178 max-width: calc(100% - 2 * var(--space-4));
179 padding: var(--space-3) var(--space-4);
180 color: var(--color-toast-fg);
181 background: var(--color-toast-bg);
182 border-radius: var(--radius-pill);
183 box-shadow: 0 8px 24px var(--color-shadow);
184 animation: toast 6s forwards;
185 }
186 @keyframes toast {
187 0%,
188 85% {
189 opacity: 1;
190 }
191 100% {
192 opacity: 0;
193 visibility: hidden;
194 }
195 }
196 /* Motion is a courtesy: whoever asked for less gets none. */
197 @media (prefers-reduced-motion: reduce) {
198 *,
199 *::before,
200 *::after {
201 animation-duration: 0.01ms !important;
202 animation-iteration-count: 1 !important;
203 transition-duration: 0.01ms !important;
204 scroll-behavior: auto !important;
205 }
206 .toast {
207 animation: none;
208 }
209 }
static/live.js added +68
@@ -0,0 +1,68 @@
1 // The page stays current without a reload. /events is a stream of what
2 // changed; each event becomes `live` and `live:<type>` on `document`, and
3 // any element with data-live="<types>" is refetched and swapped when one of
4 // its types arrives — server-rendered, so the page never renders twice.
5 // Without this file the page is the same page, one reload behind.
6
7 let since = "";
8 let tries = 0;
9 let source;
10
11 function connect() {
12 source = new EventSource(since ? `/events?since=${since}` : "/events");
13 source.onopen = () => {
14 if (tries > 0) refresh(); // Reconnected: whatever was missed, catch up.
15 tries = 0;
16 };
17 source.onmessage = (message) => {
18 since = message.lastEventId;
19 const { type, data } = JSON.parse(message.data);
20 document.dispatchEvent(new CustomEvent("live", { detail: { type, data } }));
21 document.dispatchEvent(new CustomEvent(`live:${type}`, { detail: data }));
22 };
23 // The browser retries a dropped connection itself; a refused one it gives
24 // up on, and then we try again, later each time.
25 source.onerror = () => {
26 if (source.readyState !== EventSource.CLOSED) return;
27 source.close();
28 setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++));
29 };
30 }
31
32 let refreshing = false;
33 let again = false;
34 async function refresh(types) {
35 const regions = [...document.querySelectorAll("[data-live]")].filter(
36 (el) =>
37 types === undefined ||
38 el.dataset.live.split(" ").some((t) => types.includes(t)),
39 );
40 if (regions.length === 0) return;
41 if (refreshing) {
42 again = true;
43 return;
44 }
45 refreshing = true;
46 try {
47 const response = await fetch(location.href, {
48 headers: { accept: "text/html" },
49 });
50 const fresh = new DOMParser().parseFromString(
51 await response.text(),
52 "text/html",
53 );
54 for (const el of regions) {
55 const next = fresh.getElementById(el.id);
56 if (next) el.replaceWith(next);
57 }
58 } finally {
59 refreshing = false;
60 if (again) {
61 again = false;
62 refresh();
63 }
64 }
65 }
66
67 document.addEventListener("live", (event) => refresh([event.detail.type]));
68 connect();
↑↓ navigate ↵ open esc close

Keyboard shortcuts