wren/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Start glyph
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", "`'` #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 | ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ | |
| 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(); | |
| 14 | color: var(); | |
| 15 | font: var() / var() var(); | |
| 16 | } | |
| 17 | .page { | |
| 18 | max-width: 40rem; | |
| 19 | margin: 0 auto; | |
| 20 | padding: var() var() var(); | |
| 21 | } | |
| 22 | .head { | |
| 23 | display: flex; | |
| 24 | flex-wrap: wrap; | |
| 25 | justify-content: space-between; | |
| 26 | align-items: flex-start; | |
| 27 | gap: var() var(); | |
| 28 | margin-bottom: var(); | |
| 29 | } | |
| 30 | h1 { | |
| 31 | margin: 0; | |
| 32 | font-size: var(); | |
| 33 | font-weight: 650; | |
| 34 | letter-spacing: -0.01em; | |
| 35 | line-height: var(); | |
| 36 | } | |
| 37 | p { | |
| 38 | margin: 0; | |
| 39 | } | |
| 40 | a { | |
| 41 | color: var(); | |
| 42 | text-decoration-thickness: 1px; | |
| 43 | text-underline-offset: 0.15em; | |
| 44 | } | |
| 45 | code, | |
| 46 | .mono { | |
| 47 | font-family: var(); | |
| 48 | font-size: var(); | |
| 49 | } | |
| 50 | :focus-visible { | |
| 51 | outline: 2px solid var(); | |
| 52 | outline-offset: 2px; | |
| 53 | } | |
| 54 | .muted { | |
| 55 | color: var(); | |
| 56 | } | |
| 57 | .you { | |
| 58 | color: var(); | |
| 59 | white-space: nowrap; | |
| 60 | } | |
| 61 | ||
| 62 | /* Blocks. */ | |
| 63 | .card { | |
| 64 | background: var(); | |
| 65 | border: 1px solid var(); | |
| 66 | border-radius: var(); | |
| 67 | padding: var(); | |
| 68 | margin-bottom: var(); | |
| 69 | } | |
| 70 | .row { | |
| 71 | display: flex; | |
| 72 | align-items: center; | |
| 73 | gap: var(); | |
| 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(); | |
| 85 | padding: var() 0; | |
| 86 | border-top: 1px solid var(); | |
| 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(); | |
| 96 | } | |
| 97 | .empty { | |
| 98 | padding: var() var(); | |
| 99 | text-align: center; | |
| 100 | color: var(); | |
| 101 | border: 1px dashed var(); | |
| 102 | border-radius: var(); | |
| 103 | } | |
| 104 | footer { | |
| 105 | margin-top: var(); | |
| 106 | font-size: var(); | |
| 107 | display: grid; | |
| 108 | gap: var(); | |
| 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(); | |
| 119 | padding: 0 var(); | |
| 120 | font: inherit; | |
| 121 | font-size: max(16px, var()); | |
| 122 | color: inherit; | |
| 123 | background: var(); | |
| 124 | border: 1px solid var(); | |
| 125 | border-radius: var(); | |
| 126 | } | |
| 127 | .input::placeholder { | |
| 128 | color: var(); | |
| 129 | } | |
| 130 | .button { | |
| 131 | min-height: var(); | |
| 132 | padding: 0 var(); | |
| 133 | font: inherit; | |
| 134 | font-weight: 600; | |
| 135 | color: var(); | |
| 136 | background: var(); | |
| 137 | border: 1px solid transparent; | |
| 138 | border-radius: var(); | |
| 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(); | |
| 150 | background: transparent; | |
| 151 | padding: 0 var(); | |
| 152 | } | |
| 153 | .button-quiet:hover { | |
| 154 | color: var(); | |
| 155 | border-color: var(); | |
| 156 | filter: none; | |
| 157 | } | |
| 158 | .pill { | |
| 159 | display: inline-block; | |
| 160 | padding: 0.1rem 0.6rem; | |
| 161 | font-size: var(); | |
| 162 | font-weight: 600; | |
| 163 | color: var(); | |
| 164 | border: 1px solid var(); | |
| 165 | border-radius: var(); | |
| 166 | } | |
| 167 | .pill[data-env="production"] { | |
| 168 | color: var(); | |
| 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(); | |
| 177 | transform: translateX(-50%); | |
| 178 | max-width: calc(100% - 2 * var()); | |
| 179 | padding: var() var(); | |
| 180 | color: var(); | |
| 181 | background: var(); | |
| 182 | border-radius: var(); | |
| 183 | box-shadow: 0 8px 24px var(); | |
| 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 ; | |
| 202 | animation-iteration-count: 1 ; | |
| 203 | transition-duration: 0.01ms ; | |
| 204 | scroll-behavior: auto ; | |
| 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(); |