apps/glyph Public The code is public; the app answers only to 1 person who can change it and the crew.
glyph: Claude draws SVG logos and icons (#2)
Requested-by: benjivers <[email protected]> Co-authored-by: benjivers <[email protected]>
25 files
changed +1598
−555
README.md +49 −15
| @@ -2,6 +2,36 @@ | ||
| 2 | 2 | |
| 3 | 3 | Type a prompt, get SVG logos and icons drawn by Claude; preview in your theme, copy or download. |
| 4 | 4 | |
| 5 | Describe something — "a hex chip with a circuit b", "a lighthouse for a | |
| 6 | monitoring app" — pick a kind (logo mark, app icon, badge, spot | |
| 7 | illustration), and Claude draws four different takes as SVG in about a | |
| 8 | minute. Switch palette (Sage, Moss, Neon, Mono, Blueprint) to see them in | |
| 9 | any theme, then **Copy** the SVG or **Save** it as a file in that palette. | |
| 10 | ||
| 11 | ## What is real | |
| 12 | ||
| 13 | - **The drawing is real**: the app runs Claude Code (`claude -p`, no tools, | |
| 14 | model `claude-opus-5-5`) with the token in `CLAUDE_CODE_OAUTH_TOKEN`. Set it | |
| 15 | in Settings → App → Environment (make one with `claude setup-token`). | |
| 16 | Without it a drawing fails at once with a sentence saying exactly that. | |
| 17 | - **Every SVG is rebuilt from an allowlist** (`src/glyph.ts` `sanitize`): | |
| 18 | shapes, `title` and plain attributes only. No script, style, link, image, | |
| 19 | `url(…)` or event handler survives, and previews are `<img>` data URIs, so | |
| 20 | nothing in a drawing can run on the page. A saved file is served with a | |
| 21 | sandboxing CSP. | |
| 22 | - **Colours**: Claude draws in two placeholder colours (ink and glow, | |
| 23 | `design/palettes.json`); a palette swaps those two. Downloads carry the | |
| 24 | palette's real colours. | |
| 25 | - **Drawings are private to whoever asked** (`contract.json`); an admin sees | |
| 26 | everyone's. One drawing at a time per person, three at once for everyone. | |
| 27 | A drawing left half done by a restart is marked failed with a reason. | |
| 28 | - Also an API (`/api/drawings`, `POST` answers `202` then the row fills in; | |
| 29 | listen on `/events` for `drawing.changed`), MCP tools (`draw`, | |
| 30 | `get_drawing`, `list_drawings`, `delete_drawing`) and a terminal (list and | |
| 31 | ask; a terminal can't show pictures). | |
| 32 | - On the forge an app reaches the internet only through its egress proxy: | |
| 33 | `forge.json` declares `api.anthropic.com` and `claude.ai`. | |
| 34 | ||
| 5 | 35 | This is an app: the forge runs it. Production follows the default branch; |
| 6 | 36 | every open pull gets a preview at its own address with its own copy of the |
| 7 | 37 | data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what |
| @@ -29,7 +59,10 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 29 | 59 | |
| 30 | 60 | | file | what it is | |
| 31 | 61 | | ---------------------------------- | -------------------------------------------------------------- | |
| 32 | | `src/actions.ts` | list, add, delete; the audit trail, read | | |
| 62 | | `src/actions.ts` | start, list, read, delete a drawing; the audit trail, read | | |
| 63 | | `src/glyph.ts` | the ask, the reply read back, the SVG allowlist, palettes | | |
| 64 | | `src/draw.ts`, `src/claude.ts` | the work after the response: Claude Code, run with no tools | | |
| 65 | | `design/palettes.json` | the drawing palettes and the two placeholder colours | | |
| 33 | 66 | | `contract.json` | who may do what: roles, and the least role each verb needs | |
| 34 | 67 | | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim | |
| 35 | 68 | | `src/security.ts` | the door: rationing, same origin, the table before the body | |
| @@ -46,7 +79,8 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 46 | 79 | | `src/server.ts` | boot: open the database, serve | |
| 47 | 80 | | `src/tty.ts` | the terminal, over the same actions | |
| 48 | 81 | | `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 | | |
| 82 | | `static/app.css`, `static/live.js` | the look in the tokens' words, and the board that stays current | | |
| 83 | | `static/glyph.js` | copy, the palette swapped in place, the example prompts | | |
| 50 | 84 | |
| 51 | 85 | ## What every app stands on |
| 52 | 86 | |
| @@ -77,10 +111,10 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 77 | 111 | The forge says who is asking on every request it proxies, and the app |
| 78 | 112 | verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: |
| 79 | 113 | |
| 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. | |
| 114 | - **anon** — anyone. Sees the page and is asked to sign in; nothing else. | |
| 115 | - **user** — signed in to the forge and able to see this app. Draws, as | |
| 116 | themselves, and sees and removes their own drawings. | |
| 117 | - **admin** — able to write this repository. Sees and removes any drawing. | |
| 84 | 118 | |
| 85 | 119 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 86 | 120 | `/__forge/signout`; the forge answers both on the app's own host, and |
| @@ -93,26 +127,26 @@ The same actions, the same rules, at every door. A script is the person | ||
| 93 | 127 | whose forge grant it carries; a bare script is anonymous. |
| 94 | 128 | |
| 95 | 129 | 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 | |
| 130 | curl -X POST https://<app>/api/drawings -H 'content-type: application/json' -d '{"prompt":"a fox made of circuit traces","kind":"mark"}' | |
| 131 | curl https://<app>/api/drawings/1 # status: drawing → done, with svgs[] | |
| 132 | curl -X DELETE https://<app>/api/drawings/1 | |
| 99 | 133 | |
| 100 | 134 | |
| 101 | As an MCP server, for Claude Code or Claude Desktop — `list_notes`, | |
| 102 | `add_note`, `delete_note`: | |
| 135 | As an MCP server, for Claude Code or Claude Desktop — `draw`, | |
| 136 | `get_drawing`, `list_drawings`, `delete_drawing`: | |
| 103 | 137 | |
| 104 | 138 | sh |
| 105 | claude mcp add --transport http __TITLE__ https://<app>/mcp | |
| 139 | claude mcp add --transport http glyph https://<app>/mcp | |
| 106 | 140 | |
| 107 | 141 | |
| 108 | 142 | json |
| 109 | 143 | { |
| 110 | "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } } | |
| 144 | "mcpServers": { "glyph": { "type": "http", "url": "https://<app>/mcp" } } | |
| 111 | 145 | } |
| 112 | 146 | |
| 113 | 147 | |
| 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 | |
| 148 | The page stays current over `/events`: a drawing fills in on its own when | |
| 149 | Claude is done, without a reload. Without JavaScript it is the same page, one | |
| 116 | 150 | reload behind. |
| 117 | 151 | |
| 118 | 152 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
contract.json +5 −5
| @@ -6,14 +6,14 @@ | ||
| 6 | 6 | "admin": { "above": ["author"], "held": { "by": "identity", "role": "admin" } } |
| 7 | 7 | }, |
| 8 | 8 | "resources": { |
| 9 | "note": { | |
| 10 | "api": "/api/notes", | |
| 9 | "drawing": { | |
| 10 | "api": "/api/drawings", | |
| 11 | 11 | "can": { |
| 12 | 12 | "create": "user", |
| 13 | "read": "anon", | |
| 14 | "update": "author", | |
| 13 | "read": "author", | |
| 14 | "update": "nobody", | |
| 15 | 15 | "delete": "author", |
| 16 | "list": "anon" | |
| 16 | "list": "author" | |
| 17 | 17 | } |
| 18 | 18 | }, |
| 19 | 19 | "audit": { |
contract/openapi.json +72 −93
| @@ -6,10 +6,10 @@ | ||
| 6 | 6 | "description": "Type a prompt, get SVG logos and icons drawn by Claude; preview in your theme, copy or download." |
| 7 | 7 | }, |
| 8 | 8 | "paths": { |
| 9 | "/api/notes": { | |
| 9 | "/api/drawings": { | |
| 10 | 10 | "get": { |
| 11 | "summary": "The notes, newest first.", | |
| 12 | "operationId": "getApiNotes", | |
| 11 | "summary": "Your drawings, newest first (an admin sees everyone's).", | |
| 12 | "operationId": "getApiDrawings", | |
| 13 | 13 | "parameters": [ |
| 14 | 14 | { |
| 15 | 15 | "name": "limit", |
| @@ -17,20 +17,20 @@ | ||
| 17 | 17 | "schema": { |
| 18 | 18 | "type": "integer", |
| 19 | 19 | "minimum": 1, |
| 20 | "maximum": 500, | |
| 21 | "default": 100 | |
| 20 | "maximum": 200, | |
| 21 | "default": 50 | |
| 22 | 22 | } |
| 23 | 23 | } |
| 24 | 24 | ], |
| 25 | 25 | "responses": { |
| 26 | 26 | "200": { |
| 27 | "description": "The notes, newest first.", | |
| 27 | "description": "Your drawings, newest first (an admin sees everyone's).", | |
| 28 | 28 | "content": { |
| 29 | 29 | "application/json": { |
| 30 | 30 | "schema": { |
| 31 | 31 | "type": "array", |
| 32 | 32 | "items": { |
| 33 | "$ref": "#/components/schemas/Note" | |
| 33 | "$ref": "#/components/schemas/Drawing" | |
| 34 | 34 | } |
| 35 | 35 | } |
| 36 | 36 | } |
| @@ -48,14 +48,14 @@ | ||
| 48 | 48 | } |
| 49 | 49 | }, |
| 50 | 50 | "x-contract": { |
| 51 | "resource": "note", | |
| 51 | "resource": "drawing", | |
| 52 | 52 | "verb": "list", |
| 53 | "least": "anon" | |
| 53 | "least": "author" | |
| 54 | 54 | } |
| 55 | 55 | }, |
| 56 | 56 | "post": { |
| 57 | "summary": "Add a note as the person asking.", | |
| 58 | "operationId": "postApiNotes", | |
| 57 | "summary": "Ask Claude for a drawing. Answers at once with status `drawing`; read it again (or listen on /events for `drawing.changed`) until it is `done` or `failed`. One at a time per person.", | |
| 58 | "operationId": "postApiDrawings", | |
| 59 | 59 | "requestBody": { |
| 60 | 60 | "required": true, |
| 61 | 61 | "content": { |
| @@ -63,26 +63,35 @@ | ||
| 63 | 63 | "schema": { |
| 64 | 64 | "type": "object", |
| 65 | 65 | "properties": { |
| 66 | "body": { | |
| 66 | "prompt": { | |
| 67 | 67 | "type": "string", |
| 68 | 68 | "minLength": 1, |
| 69 | "maxLength": 500 | |
| 69 | "maxLength": 400 | |
| 70 | }, | |
| 71 | "kind": { | |
| 72 | "enum": [ | |
| 73 | "mark", | |
| 74 | "icon", | |
| 75 | "badge", | |
| 76 | "spot" | |
| 77 | ], | |
| 78 | "default": "mark" | |
| 70 | 79 | } |
| 71 | 80 | }, |
| 72 | 81 | "required": [ |
| 73 | "body" | |
| 82 | "prompt" | |
| 74 | 83 | ] |
| 75 | 84 | } |
| 76 | 85 | } |
| 77 | 86 | } |
| 78 | 87 | }, |
| 79 | 88 | "responses": { |
| 80 | "201": { | |
| 81 | "description": "Add a note as the person asking.", | |
| 89 | "202": { | |
| 90 | "description": "Ask Claude for a drawing. Answers at once with status `drawing`; read it again (or listen on /events for `drawing.changed`) until it is `done` or `failed`. One at a time per person.", | |
| 82 | 91 | "content": { |
| 83 | 92 | "application/json": { |
| 84 | 93 | "schema": { |
| 85 | "$ref": "#/components/schemas/Note" | |
| 94 | "$ref": "#/components/schemas/Drawing" | |
| 86 | 95 | } |
| 87 | 96 | } |
| 88 | 97 | } |
| @@ -99,16 +108,16 @@ | ||
| 99 | 108 | } |
| 100 | 109 | }, |
| 101 | 110 | "x-contract": { |
| 102 | "resource": "note", | |
| 111 | "resource": "drawing", | |
| 103 | 112 | "verb": "create", |
| 104 | 113 | "least": "user" |
| 105 | 114 | } |
| 106 | 115 | } |
| 107 | 116 | }, |
| 108 | "/api/notes/{id}": { | |
| 117 | "/api/drawings/{id}": { | |
| 109 | 118 | "get": { |
| 110 | "summary": "One note.", | |
| 111 | "operationId": "getApiNotesId", | |
| 119 | "summary": "One drawing.", | |
| 120 | "operationId": "getApiDrawingsId", | |
| 112 | 121 | "parameters": [ |
| 113 | 122 | { |
| 114 | 123 | "name": "id", |
| @@ -121,11 +130,11 @@ | ||
| 121 | 130 | ], |
| 122 | 131 | "responses": { |
| 123 | 132 | "200": { |
| 124 | "description": "One note.", | |
| 133 | "description": "One drawing.", | |
| 125 | 134 | "content": { |
| 126 | 135 | "application/json": { |
| 127 | 136 | "schema": { |
| 128 | "$ref": "#/components/schemas/Note" | |
| 137 | "$ref": "#/components/schemas/Drawing" | |
| 129 | 138 | } |
| 130 | 139 | } |
| 131 | 140 | } |
| @@ -142,75 +151,14 @@ | ||
| 142 | 151 | } |
| 143 | 152 | }, |
| 144 | 153 | "x-contract": { |
| 145 | "resource": "note", | |
| 154 | "resource": "drawing", | |
| 146 | 155 | "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 | 156 | "least": "author" |
| 209 | 157 | } |
| 210 | 158 | }, |
| 211 | 159 | "delete": { |
| 212 | "summary": "Remove a note.", | |
| 213 | "operationId": "deleteApiNotesId", | |
| 160 | "summary": "Remove a drawing.", | |
| 161 | "operationId": "deleteApiDrawingsId", | |
| 214 | 162 | "parameters": [ |
| 215 | 163 | { |
| 216 | 164 | "name": "id", |
| @@ -223,7 +171,7 @@ | ||
| 223 | 171 | ], |
| 224 | 172 | "responses": { |
| 225 | 173 | "200": { |
| 226 | "description": "Remove a note.", | |
| 174 | "description": "Remove a drawing.", | |
| 227 | 175 | "content": { |
| 228 | 176 | "application/json": { |
| 229 | 177 | "schema": { |
| @@ -252,7 +200,7 @@ | ||
| 252 | 200 | } |
| 253 | 201 | }, |
| 254 | 202 | "x-contract": { |
| 255 | "resource": "note", | |
| 203 | "resource": "drawing", | |
| 256 | 204 | "verb": "delete", |
| 257 | 205 | "least": "author" |
| 258 | 206 | } |
| @@ -463,16 +411,44 @@ | ||
| 463 | 411 | }, |
| 464 | 412 | "components": { |
| 465 | 413 | "schemas": { |
| 466 | "Note": { | |
| 414 | "Drawing": { | |
| 467 | 415 | "type": "object", |
| 468 | 416 | "properties": { |
| 469 | 417 | "id": { |
| 470 | 418 | "type": "integer" |
| 471 | 419 | }, |
| 472 | "body": { | |
| 420 | "prompt": { | |
| 473 | 421 | "type": "string", |
| 474 | 422 | "minLength": 1, |
| 475 | "maxLength": 500 | |
| 423 | "maxLength": 400 | |
| 424 | }, | |
| 425 | "kind": { | |
| 426 | "enum": [ | |
| 427 | "mark", | |
| 428 | "icon", | |
| 429 | "badge", | |
| 430 | "spot" | |
| 431 | ] | |
| 432 | }, | |
| 433 | "status": { | |
| 434 | "enum": [ | |
| 435 | "drawing", | |
| 436 | "done", | |
| 437 | "failed" | |
| 438 | ] | |
| 439 | }, | |
| 440 | "svgs": { | |
| 441 | "type": "array", | |
| 442 | "items": { | |
| 443 | "type": "string" | |
| 444 | }, | |
| 445 | "description": "The drawings, as SVG, in the placeholder ink and glow colours." | |
| 446 | }, | |
| 447 | "error": { | |
| 448 | "type": [ | |
| 449 | "string", | |
| 450 | "null" | |
| 451 | ] | |
| 476 | 452 | }, |
| 477 | 453 | "author_id": { |
| 478 | 454 | "type": "string" |
| @@ -491,7 +467,10 @@ | ||
| 491 | 467 | }, |
| 492 | 468 | "required": [ |
| 493 | 469 | "id", |
| 494 | "body", | |
| 470 | "prompt", | |
| 471 | "kind", | |
| 472 | "status", | |
| 473 | "svgs", | |
| 495 | 474 | "author_id", |
| 496 | 475 | "author_name", |
| 497 | 476 | "created_at", |
design/palettes.json added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | { | |
| 2 | "$description": "The colours a drawing is drawn and shown in. Claude draws every SVG in the placeholder ink and glow; a palette swaps those two for its own and sits the drawing on its ground. Drawing content, not the app's look: the app's own colours are design/tokens.json.", | |
| 3 | "placeholder": { "ink": "#1e2a21", "glow": "#9c4830" }, | |
| 4 | "palettes": { | |
| 5 | "sage": { "label": "Sage", "ink": "#1e2a21", "glow": "#9c4830", "ground": "#dfe6d7" }, | |
| 6 | "moss": { "label": "Moss", "ink": "#e3e9df", "glow": "#ec9478", "ground": "#171e19" }, | |
| 7 | "neon": { "label": "Neon", "ink": "#7df9ff", "glow": "#ff3df2", "ground": "#0b0f1a" }, | |
| 8 | "mono": { "label": "Mono", "ink": "#111111", "glow": "#5c5c5c", "ground": "#ffffff" }, | |
| 9 | "blueprint": { "label": "Blueprint", "ink": "#ffffff", "glow": "#ffd24a", "ground": "#1b3a8a" } | |
| 10 | } | |
| 11 | } |
forge.json +1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "egress": ["api.anthropic.com", "claude.ai"], | |
| 2 | 3 | "app": { |
| 3 | 4 | "run": "bun run src/server.ts", |
| 4 | 5 | "test": "bun test", |
src/actions.test.ts +78 −34
| @@ -2,8 +2,17 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { addNote, deleteNote, listNotes } from "./actions.ts"; | |
| 5 | import { | |
| 6 | AT_ONCE, | |
| 7 | canDelete, | |
| 8 | deleteDrawing, | |
| 9 | getDrawing, | |
| 10 | listDrawings, | |
| 11 | startDrawing, | |
| 12 | svgsOf, | |
| 13 | } from "./actions.ts"; | |
| 6 | 14 | import { openDb } from "./db.ts"; |
| 15 | import { recover, setDrawer, settled } from "./draw.ts"; | |
| 7 | 16 | import type { Who } from "./who.ts"; |
| 8 | 17 | |
| 9 | 18 | const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); |
| @@ -11,48 +20,83 @@ const anon: Who = { role: "anon" }; | ||
| 11 | 20 | const alice: Who = { role: "user", id: "u1", name: "alice" }; |
| 12 | 21 | const bob: Who = { role: "user", id: "u2", name: "bob" }; |
| 13 | 22 | const root: Who = { role: "admin", id: "u0", name: "root" }; |
| 23 | const SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="9"/></svg>`; | |
| 24 | setDrawer(async () => [SVG]); | |
| 14 | 25 | |
| 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" }); | |
| 26 | test("only a signed-in person draws, as themselves; the drawing fills in after", async () => { | |
| 27 | const refused = startDrawing(db, anon, { prompt: "hello" }); | |
| 18 | 28 | expect(refused.ok).toBe(false); |
| 19 | 29 | 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); | |
| 30 | const started = startDrawing(db, alice, { prompt: " a fox " }); | |
| 31 | expect(started.ok).toBe(true); | |
| 32 | if (!started.ok) return; | |
| 33 | expect(started.value).toMatchObject({ prompt: "a fox", kind: "mark", status: "drawing", author_id: "u1" }); | |
| 34 | await settled(); | |
| 35 | const done = getDrawing(db, alice, { id: started.value.id }); | |
| 36 | expect(done.ok && done.value.status).toBe("done"); | |
| 37 | expect(done.ok && svgsOf(done.value)).toEqual([SVG]); | |
| 28 | 38 | }); |
| 29 | 39 | |
| 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 }); | |
| 40 | test("a prompt is 1 to 400 characters and a kind is one of four", () => { | |
| 41 | for (const input of [{ prompt: "" }, { prompt: " " }, { prompt: "x".repeat(401) }, { prompt: "ok", kind: "poster" }]) { | |
| 42 | const r = startDrawing(db, alice, input); | |
| 33 | 43 | expect(r.ok).toBe(false); |
| 34 | 44 | if (!r.ok) expect(r.error).toBe("invalid"); |
| 35 | 45 | } |
| 46 | const icon = startDrawing(db, bob, { prompt: "ok", kind: "icon" }); | |
| 47 | expect(icon.ok && icon.value.kind).toBe("icon"); | |
| 36 | 48 | }); |
| 37 | 49 | |
| 38 | test("only the author or an admin removes a note", () => { | |
| 39 | const mine = addNote(db, alice, { body: "mine" }); | |
| 50 | test("drawings are their asker's: listed, read and removed by them or an admin", async () => { | |
| 51 | await settled(); | |
| 52 | const mine = startDrawing(db, alice, { prompt: "mine" }); | |
| 40 | 53 | 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"); | |
| 54 | await settled(); | |
| 55 | const bobs = listDrawings(db, bob); | |
| 56 | expect(bobs.ok && bobs.value.every((d) => d.author_id === "u2")).toBe(true); | |
| 57 | expect(listDrawings(db, anon).ok).toBe(false); | |
| 58 | const all = listDrawings(db, root); | |
| 59 | expect(all.ok && all.value.some((d) => d.author_id === "u1") && all.value.some((d) => d.author_id === "u2")).toBe(true); | |
| 60 | const peek = getDrawing(db, bob, { id: mine.value.id }); | |
| 61 | expect(!peek.ok && peek.error).toBe("forbidden"); | |
| 62 | expect(canDelete(bob, mine.value)).toBe(false); | |
| 63 | expect(canDelete(alice, mine.value)).toBe(true); | |
| 64 | const byBob = deleteDrawing(db, bob, { id: mine.value.id }); | |
| 65 | expect(!byBob.ok && byBob.error).toBe("forbidden"); | |
| 66 | expect(deleteDrawing(db, root, { id: mine.value.id }).ok).toBe(true); | |
| 67 | const gone = deleteDrawing(db, alice, { id: mine.value.id }); | |
| 68 | expect(!gone.ok && gone.error).toBe("not_found"); | |
| 69 | const odd = deleteDrawing(db, root, { id: 1.5 }); | |
| 70 | expect(!odd.ok && odd.error).toBe("invalid"); | |
| 71 | }); | |
| 72 | ||
| 73 | test("one drawing at a time per person, and a few at once for everyone", async () => { | |
| 74 | await settled(); | |
| 75 | const waits: (() => void)[] = []; | |
| 76 | setDrawer(() => new Promise((resolve) => waits.push(() => resolve([SVG])))); | |
| 77 | try { | |
| 78 | expect(startDrawing(db, alice, { prompt: "one" }).ok).toBe(true); | |
| 79 | const again = startDrawing(db, alice, { prompt: "two" }); | |
| 80 | expect(!again.ok && again.error).toBe("busy"); | |
| 81 | for (let i = 1; i < AT_ONCE; i++) | |
| 82 | expect(startDrawing(db, { role: "user", id: `x${i}`, name: `x${i}` }, { prompt: "p" }).ok).toBe(true); | |
| 83 | const full = startDrawing(db, bob, { prompt: "three" }); | |
| 84 | expect(!full.ok && full.message).toContain("others"); | |
| 85 | } finally { | |
| 86 | for (const go of waits) go(); | |
| 87 | await settled(); | |
| 88 | setDrawer(async () => [SVG]); | |
| 89 | } | |
| 90 | expect(startDrawing(db, alice, { prompt: "after" }).ok).toBe(true); | |
| 91 | await settled(); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a drawing a stopped process left half done is failed, not waited on forever", () => { | |
| 95 | const row = db | |
| 96 | .query<{ id: number }, []>("INSERT INTO drawing (prompt, kind, author_id, author_name) VALUES ('p', 'mark', 'u1', 'alice') RETURNING id") | |
| 97 | .get()!; | |
| 98 | expect(recover(db)).toBe(1); | |
| 99 | const after = getDrawing(db, alice, { id: row.id }); | |
| 100 | expect(after.ok && after.value.status).toBe("failed"); | |
| 101 | expect(after.ok && after.value.error).toContain("restarted"); | |
| 58 | 102 | }); |
src/actions.ts +103 −111
| @@ -10,18 +10,30 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { record, type AuditRow } from "./audit.ts"; |
| 11 | 11 | import { permit } from "./contract.ts"; |
| 12 | 12 | import { emit } from "./live.ts"; |
| 13 | import { kick } from "./draw.ts"; | |
| 14 | import { isKind, KINDS, PROMPT_MAX, type Kind } from "./glyph.ts"; | |
| 13 | 15 | import type { Who } from "./who.ts"; |
| 14 | 16 | |
| 15 | export interface Note { | |
| 17 | export interface Drawing { | |
| 16 | 18 | readonly id: number; |
| 17 | readonly body: string; | |
| 19 | readonly prompt: string; | |
| 20 | readonly kind: Kind; | |
| 21 | readonly status: "drawing" | "done" | "failed"; | |
| 22 | /** The SVGs, as a JSON array of strings: `svgsOf` reads it. */ | |
| 23 | readonly svgs: string; | |
| 24 | readonly error: string | null; | |
| 18 | 25 | readonly author_id: string; |
| 19 | 26 | readonly author_name: string; |
| 20 | 27 | readonly created_at: string; |
| 21 | 28 | readonly updated_at: string; |
| 22 | 29 | } |
| 23 | 30 | |
| 24 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; | |
| 31 | export type Failure = | |
| 32 | | "signin_required" | |
| 33 | | "forbidden" | |
| 34 | | "not_found" | |
| 35 | | "invalid" | |
| 36 | | "busy"; | |
| 25 | 37 | export type Result<T> = |
| 26 | 38 | | { ok: true; value: T } |
| 27 | 39 | | { ok: false; error: Failure; message: string }; |
| @@ -32,8 +44,6 @@ const fail = <T>(error: Failure, message: string): Result<T> => ({ | ||
| 32 | 44 | message, |
| 33 | 45 | }); |
| 34 | 46 | |
| 35 | export const BODY_MAX = 500; | |
| 36 | ||
| 37 | 47 | /** The refusal for a "no": who is not signed in is asked to; who is, is told. */ |
| 38 | 48 | const refuse = <T>(who: Who, signIn: string, no: string): Result<T> => |
| 39 | 49 | who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no); |
| @@ -61,158 +71,140 @@ const audited = <T extends { readonly id: number }>( | ||
| 61 | 71 | return result; |
| 62 | 72 | }; |
| 63 | 73 | |
| 64 | export function listNotes( | |
| 74 | /** The SVGs a drawing holds. */ | |
| 75 | export const svgsOf = (drawing: Drawing): string[] => { | |
| 76 | try { | |
| 77 | const parsed: unknown = JSON.parse(drawing.svgs); | |
| 78 | return Array.isArray(parsed) ? parsed.filter((s) => typeof s === "string") : []; | |
| 79 | } catch { | |
| 80 | return []; | |
| 81 | } | |
| 82 | }; | |
| 83 | ||
| 84 | /** At most this many drawings are with Claude at once, across everyone. */ | |
| 85 | export const AT_ONCE = 3; | |
| 86 | ||
| 87 | export function listDrawings( | |
| 65 | 88 | db: Database, |
| 66 | 89 | who: Who, |
| 67 | 90 | 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"); | |
| 91 | ): Result<Drawing[]> { | |
| 92 | const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 200); | |
| 93 | // "yes" is every drawing (an admin). For anyone else the table answers with | |
| 94 | // the columns that make a drawing theirs, and the list is theirs alone. | |
| 95 | const may = permit(who, "drawing", "list"); | |
| 78 | 96 | if (may === "no") |
| 79 | return refuse(who, "Sign in to see notes.", "You may not see these notes."); | |
| 97 | return refuse(who, "Sign in to see your drawings.", "You may not see these drawings."); | |
| 80 | 98 | if (may === "yes") |
| 81 | 99 | return ok( |
| 82 | 100 | db |
| 83 | .query<Note, [number]>("SELECT * FROM note ORDER BY id DESC LIMIT ?1") | |
| 101 | .query<Drawing, [number]>("SELECT * FROM drawing ORDER BY id DESC LIMIT ?1") | |
| 84 | 102 | .all(limit), |
| 85 | 103 | ); |
| 86 | 104 | const mine = may.where.map((column) => `${column} = ?2`).join(" OR "); |
| 87 | 105 | return ok( |
| 88 | 106 | db |
| 89 | .query< | |
| 90 | Note, | |
| 91 | [number, string] | |
| 92 | >(`SELECT * FROM note WHERE ${mine} ORDER BY id DESC LIMIT ?1`) | |
| 107 | .query<Drawing, [number, string]>( | |
| 108 | `SELECT * FROM drawing WHERE ${mine} ORDER BY id DESC LIMIT ?1`, | |
| 109 | ) | |
| 93 | 110 | .all(limit, who.role === "anon" ? "" : who.id), |
| 94 | 111 | ); |
| 95 | 112 | } |
| 96 | 113 | |
| 97 | export const addNote = ( | |
| 114 | /** | |
| 115 | * Ask for a drawing. The row is made at once, `drawing`; Claude fills it in | |
| 116 | * after the response (`src/draw.ts`) and the page hears `drawing.changed`. | |
| 117 | */ | |
| 118 | export const startDrawing = ( | |
| 98 | 119 | db: Database, |
| 99 | 120 | who: Who, |
| 100 | input: { body: string }, | |
| 101 | ): Result<Note> => | |
| 102 | audited(db, who, "note", "create", undefined, add(db, who, input)); | |
| 121 | input: { prompt: string; kind?: string }, | |
| 122 | ): Result<Drawing> => | |
| 123 | audited(db, who, "drawing", "create", undefined, start(db, who, input)); | |
| 103 | 124 | |
| 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); | |
| 125 | function start( | |
| 126 | db: Database, | |
| 127 | who: Who, | |
| 128 | input: { prompt: string; kind?: string }, | |
| 129 | ): Result<Drawing> { | |
| 130 | if (who.role === "anon" || permit(who, "drawing", "create") !== "yes") | |
| 131 | return refuse(who, "Sign in to draw.", "You may not draw here."); | |
| 132 | const prompt = input.prompt.trim().replace(/\s+/g, " "); | |
| 133 | if (prompt.length === 0 || prompt.length > PROMPT_MAX) | |
| 134 | return fail("invalid", `Describe it in 1 to ${PROMPT_MAX} characters.`); | |
| 135 | const kind = input.kind === undefined || input.kind === "" ? "mark" : input.kind; | |
| 136 | if (!isKind(kind)) | |
| 137 | return fail("invalid", `A kind is one of: ${Object.keys(KINDS).join(", ")}.`); | |
| 138 | const busy = db | |
| 139 | .query<{ mine: number; everyone: number }, [string]>( | |
| 140 | "SELECT count(*) FILTER (WHERE author_id = ?1) AS mine, count(*) AS everyone FROM drawing WHERE status = 'drawing'", | |
| 141 | ) | |
| 142 | .get(who.id)!; | |
| 143 | if (busy.mine > 0) | |
| 144 | return fail("busy", "Your last drawing is still on the board. One at a time."); | |
| 145 | if (busy.everyone >= AT_ONCE) | |
| 146 | return fail("busy", "Claude is drawing for others right now. Try again in a minute."); | |
| 147 | const drawing = db | |
| 148 | .query<Drawing, [string, string, string, string]>( | |
| 149 | "INSERT INTO drawing (prompt, kind, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *", | |
| 150 | ) | |
| 151 | .get(prompt, kind, who.id, who.name)!; | |
| 152 | emit("drawing.added", drawing); | |
| 153 | kick(db, drawing.id, prompt, kind); | |
| 154 | return ok(drawing); | |
| 120 | 155 | } |
| 121 | 156 | |
| 122 | 157 | /** |
| 123 | * One note, for `who` to do `verb` to. The door is asked before the row is | |
| 158 | * One drawing, for `who` to do `verb` to. The door is asked before the row is | |
| 124 | 159 | * looked for, so whoever may not is told that and never learns whether the |
| 125 | 160 | * row exists; whoever may, and asked for nothing, hears `not_found`. |
| 126 | 161 | */ |
| 127 | function noteFor( | |
| 162 | function drawingFor( | |
| 128 | 163 | db: Database, |
| 129 | 164 | who: Who, |
| 130 | verb: "read" | "update" | "delete", | |
| 165 | verb: "read" | "delete", | |
| 131 | 166 | id: number, |
| 132 | 167 | 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) | |
| 168 | ): Result<Drawing> { | |
| 169 | const drawing = Number.isInteger(id) | |
| 170 | ? db.query<Drawing, [number]>("SELECT * FROM drawing WHERE id = ?1").get(id) | |
| 136 | 171 | : 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 | 172 | 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."); | |
| 173 | drawing === null ? { author_id: who.role === "anon" ? null : who.id } : { ...drawing }; | |
| 174 | if (permit(who, "drawing", verb, row) !== "yes") return refuse(who, said.signIn, said.no); | |
| 175 | if (drawing !== null) return ok(drawing); | |
| 176 | if (!Number.isInteger(id)) return fail("invalid", "A drawing id is a whole number."); | |
| 177 | return fail("not_found", "No such drawing."); | |
| 148 | 178 | } |
| 149 | 179 | |
| 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.", | |
| 180 | export function getDrawing(db: Database, who: Who, input: { id: number }): Result<Drawing> { | |
| 181 | return drawingFor(db, who, "read", input.id, { | |
| 182 | signIn: "Sign in to see this drawing.", | |
| 183 | no: "Only whoever asked for a drawing can see it.", | |
| 158 | 184 | }); |
| 159 | 185 | } |
| 160 | 186 | |
| 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 = ( | |
| 187 | export const deleteDrawing = ( | |
| 192 | 188 | db: Database, |
| 193 | 189 | who: Who, |
| 194 | 190 | input: { id: number }, |
| 195 | 191 | ): Result<{ id: number }> => |
| 196 | audited(db, who, "note", "delete", input.id, remove(db, who, input)); | |
| 192 | audited(db, who, "drawing", "delete", input.id, remove(db, who, input)); | |
| 197 | 193 | |
| 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.", | |
| 194 | function remove(db: Database, who: Who, input: { id: number }): Result<{ id: number }> { | |
| 195 | const found = drawingFor(db, who, "delete", input.id, { | |
| 196 | signIn: "Sign in to remove a drawing.", | |
| 197 | no: "Only whoever asked for a drawing, or an admin, can remove it.", | |
| 206 | 198 | }); |
| 207 | 199 | 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 }); | |
| 200 | db.query("DELETE FROM drawing WHERE id = ?1").run(found.value.id); | |
| 201 | emit("drawing.removed", { id: found.value.id }); | |
| 210 | 202 | return ok({ id: found.value.id }); |
| 211 | 203 | } |
| 212 | 204 | |
| 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"; | |
| 205 | /** Whether `who` may remove `drawing`: the table's answer, for the page and the terminal to draw from. */ | |
| 206 | export const canDelete = (who: Who, drawing: Drawing): boolean => | |
| 207 | permit(who, "drawing", "delete", { ...drawing }) === "yes"; | |
| 216 | 208 | |
| 217 | 209 | /** |
| 218 | 210 | * The audit trail, newest first, for whoever the table lets read it (an |
src/audit.test.ts +17 −17
| @@ -7,7 +7,7 @@ import { afterAll, expect, spyOn, test } from "bun:test"; | ||
| 7 | 7 | import { mkdtempSync } from "node:fs"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | import { addNote } from "./actions.ts"; | |
| 10 | import { startDrawing } from "./actions.ts"; | |
| 11 | 11 | import type { AuditRow } from "./audit.ts"; |
| 12 | 12 | import { openDb } from "./db.ts"; |
| 13 | 13 | import { serve } from "./server.ts"; |
| @@ -50,27 +50,27 @@ const trail = async (): Promise<AuditRow[]> => | ||
| 50 | 50 | |
| 51 | 51 | test("an allowed change and a refused one each leave a row", async () => { |
| 52 | 52 | const made = await call( |
| 53 | "/api/notes", | |
| 54 | { method: "POST", body: JSON.stringify({ body: "kept" }) }, | |
| 53 | "/api/drawings", | |
| 54 | { method: "POST", body: JSON.stringify({ prompt: "kept" }) }, | |
| 55 | 55 | alice, |
| 56 | 56 | ); |
| 57 | expect(made.status).toBe(201); | |
| 57 | expect(made.status).toBe(202); | |
| 58 | 58 | const note = (await made.json()) as { id: number }; |
| 59 | 59 | // Refused at the door: a stranger may create nothing. |
| 60 | 60 | expect( |
| 61 | 61 | ( |
| 62 | await call("/api/notes", { | |
| 62 | await call("/api/drawings", { | |
| 63 | 63 | method: "POST", |
| 64 | body: JSON.stringify({ body: "x" }), | |
| 64 | body: JSON.stringify({ prompt: "x" }), | |
| 65 | 65 | }) |
| 66 | 66 | ).status, |
| 67 | 67 | ).toBe(401); |
| 68 | // Refused by the action: bob could delete a note of his own, not alice's. | |
| 68 | // Refused by the action: bob could delete a drawing of his own, not alice's. | |
| 69 | 69 | expect( |
| 70 | (await call(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status, | |
| 70 | (await call(`/api/drawings/${note.id}`, { method: "DELETE" }, bob)).status, | |
| 71 | 71 | ).toBe(403); |
| 72 | 72 | // Refused as cross-site, before the table is asked. |
| 73 | const forged = await fetch(`${base}/api/notes/${note.id}`, { | |
| 73 | const forged = await fetch(`${base}/api/drawings/${note.id}`, { | |
| 74 | 74 | method: "DELETE", |
| 75 | 75 | headers: { ...alice, "sec-fetch-site": "cross-site" }, |
| 76 | 76 | }); |
| @@ -93,7 +93,7 @@ test("an allowed change and a refused one each leave a row", async () => { | ||
| 93 | 93 | { |
| 94 | 94 | who_name: "alice", |
| 95 | 95 | role: "user", |
| 96 | resource: "note", | |
| 96 | resource: "drawing", | |
| 97 | 97 | verb: "create", |
| 98 | 98 | target: String(note.id), |
| 99 | 99 | allowed: 1, |
| @@ -102,7 +102,7 @@ test("an allowed change and a refused one each leave a row", async () => { | ||
| 102 | 102 | { |
| 103 | 103 | who_name: null, |
| 104 | 104 | role: "anon", |
| 105 | resource: "note", | |
| 105 | resource: "drawing", | |
| 106 | 106 | verb: "create", |
| 107 | 107 | target: null, |
| 108 | 108 | allowed: 0, |
| @@ -111,7 +111,7 @@ test("an allowed change and a refused one each leave a row", async () => { | ||
| 111 | 111 | { |
| 112 | 112 | who_name: "bob", |
| 113 | 113 | role: "user", |
| 114 | resource: "note", | |
| 114 | resource: "drawing", | |
| 115 | 115 | verb: "delete", |
| 116 | 116 | target: String(note.id), |
| 117 | 117 | allowed: 0, |
| @@ -120,7 +120,7 @@ test("an allowed change and a refused one each leave a row", async () => { | ||
| 120 | 120 | { |
| 121 | 121 | who_name: "alice", |
| 122 | 122 | role: "user", |
| 123 | resource: "note", | |
| 123 | resource: "drawing", | |
| 124 | 124 | verb: "delete", |
| 125 | 125 | target: null, |
| 126 | 126 | allowed: 0, |
| @@ -133,7 +133,7 @@ test("an allowed change and a refused one each leave a row", async () => { | ||
| 133 | 133 | |
| 134 | 134 | test("reading is not an event: a GET leaves no row", async () => { |
| 135 | 135 | const before = (await trail()).length; |
| 136 | await call("/api/notes"); | |
| 136 | await call("/api/drawings"); | |
| 137 | 137 | await call("/", {}, alice); |
| 138 | 138 | expect((await trail()).length).toBe(before); |
| 139 | 139 | }); |
| @@ -166,17 +166,17 @@ test("a trail that cannot be written is a log line, and the change still happens | ||
| 166 | 166 | db.exec("DROP TABLE audit"); |
| 167 | 167 | const said = spyOn(console, "error").mockImplementation(() => {}); |
| 168 | 168 | try { |
| 169 | const added = addNote( | |
| 169 | const added = startDrawing( | |
| 170 | 170 | db, |
| 171 | 171 | { role: "user", id: "u1", name: "alice" }, |
| 172 | { body: "still here" }, | |
| 172 | { prompt: "still here" }, | |
| 173 | 173 | ); |
| 174 | 174 | expect(added.ok).toBe(true); |
| 175 | 175 | expect(said).toHaveBeenCalledTimes(1); |
| 176 | 176 | const line = JSON.parse(String(said.mock.calls[0]![0])); |
| 177 | 177 | expect(line.audit).toMatchObject({ |
| 178 | 178 | who_name: "alice", |
| 179 | resource: "note", | |
| 179 | resource: "drawing", | |
| 180 | 180 | verb: "create", |
| 181 | 181 | allowed: 1, |
| 182 | 182 | outcome: "ok", |
src/claude.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * Running Claude Code to draw. The forge puts `claude` on the PATH every app | |
| 3 | * inherits; it signs in with the token in CLAUDE_CODE_OAUTH_TOKEN and keeps | |
| 4 | * its state under the app's data directory, the one place an app may write. | |
| 5 | * It is started with no tools at all — drawing is words in, words out — and | |
| 6 | * with the environment named here and nothing else, so no other secret of | |
| 7 | * the app reaches it. | |
| 8 | * | |
| 9 | * On the forge an app runs in a cell whose only way out is the forge's | |
| 10 | * egress proxy, named in HTTPS_PROXY; without it Claude Code finds no DNS. | |
| 11 | * So the proxy variables go through, and the API's hosts are declared in | |
| 12 | * forge.json `egress`. (Learned on apps/radar.) | |
| 13 | */ | |
| 14 | ||
| 15 | import { mkdirSync } from "node:fs"; | |
| 16 | import { join } from "node:path"; | |
| 17 | ||
| 18 | /** Claude Code with these arguments, this prompt on stdin, its stdout back. */ | |
| 19 | export type RunClaude = (args: readonly string[], prompt: string) => Promise<string>; | |
| 20 | ||
| 21 | export const MODEL = "claude-opus-5-5"; | |
| 22 | const TIMEOUT_MS = 5 * 60 * 1000; | |
| 23 | ||
| 24 | /** Passed through when set: the cell's way out, and a CA the machine may add. */ | |
| 25 | const PASSED = [ | |
| 26 | "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "NO_PROXY", "no_proxy", "NODE_EXTRA_CA_CERTS", | |
| 27 | ] as const; | |
| 28 | ||
| 29 | /** The arguments for one drawing: print mode, one JSON answer, no tools, no session kept. */ | |
| 30 | export function drawArgs(model = MODEL): string[] { | |
| 31 | return [ | |
| 32 | "-p", "--output-format", "json", | |
| 33 | "--tools", "", "--strict-mcp-config", "--no-session-persistence", | |
| 34 | "--disable-slash-commands", "--model", model, | |
| 35 | ]; | |
| 36 | } | |
| 37 | ||
| 38 | /** The whole environment Claude Code is started with. */ | |
| 39 | export function claudeEnv( | |
| 40 | token: string, | |
| 41 | config: string, | |
| 42 | from: Readonly<Record<string, string | undefined>> = process.env, | |
| 43 | ): Record<string, string> { | |
| 44 | const env: Record<string, string> = { | |
| 45 | PATH: from.PATH ?? "/usr/local/bin:/usr/bin:/bin", | |
| 46 | HOME: from.HOME ?? config, | |
| 47 | LANG: from.LANG ?? "C.UTF-8", | |
| 48 | CLAUDE_CONFIG_DIR: config, | |
| 49 | CLAUDE_CODE_OAUTH_TOKEN: token, | |
| 50 | // No telemetry, error reports or self-updates: hosts the cell does not declare. | |
| 51 | CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", | |
| 52 | }; | |
| 53 | for (const name of PASSED) { | |
| 54 | const value = from[name]; | |
| 55 | if (value) env[name] = value; | |
| 56 | } | |
| 57 | return env; | |
| 58 | } | |
| 59 | ||
| 60 | export function claudeRunner(token: string, dataDir: string, timeoutMs = TIMEOUT_MS): RunClaude { | |
| 61 | return async (args, prompt) => { | |
| 62 | const config = join(dataDir, "claude"); | |
| 63 | mkdirSync(config, { recursive: true }); | |
| 64 | const env = claudeEnv(token, config); | |
| 65 | if (Bun.which("claude", { PATH: env.PATH ?? "" }) === null) | |
| 66 | throw new Error("claude is not on the PATH here"); | |
| 67 | const proc = Bun.spawn(["claude", ...args], { | |
| 68 | env, | |
| 69 | cwd: config, | |
| 70 | stdin: new Blob([prompt]), | |
| 71 | stdout: "pipe", | |
| 72 | stderr: "pipe", | |
| 73 | }); | |
| 74 | const timer = setTimeout(() => proc.kill(), timeoutMs); | |
| 75 | try { | |
| 76 | const [out, err, code] = await Promise.all([ | |
| 77 | new Response(proc.stdout).text(), | |
| 78 | new Response(proc.stderr).text(), | |
| 79 | proc.exited, | |
| 80 | ]); | |
| 81 | if (code !== 0 && !out.includes('"result"')) | |
| 82 | throw new Error(`claude exited ${code}: ${err.trim().slice(0, 200)}`); | |
| 83 | return out; | |
| 84 | } finally { | |
| 85 | clearTimeout(timer); | |
| 86 | } | |
| 87 | }; | |
| 88 | } | |
| 89 | ||
| 90 | /** The model's final text from `--output-format json`, or why there is none. */ | |
| 91 | export function resultText(stdout: string): { ok: true; text: string } | { ok: false; why: string } { | |
| 92 | let parsed: unknown; | |
| 93 | try { | |
| 94 | parsed = JSON.parse(stdout.trim().split("\n").at(-1) ?? ""); | |
| 95 | } catch { | |
| 96 | return { ok: false, why: "Claude's answer was not JSON." }; | |
| 97 | } | |
| 98 | const r = (parsed ?? {}) as Record<string, unknown>; | |
| 99 | if (r.is_error === true || typeof r.result !== "string") | |
| 100 | return { ok: false, why: `Claude stopped: ${String(r.result ?? r.subtype ?? "no result").slice(0, 200)}` }; | |
| 101 | return { ok: true, text: r.result }; | |
| 102 | } |
src/contract.test.ts +25 −16
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 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 | |
| 3 | * contract declares — on a drawing that names them and on one that names | |
| 4 | 4 | * someone else — and calls the real actions. A rule written by hand in an |
| 5 | 5 | * action, or a cell changed without its door, fails here. |
| 6 | 6 | */ |
| @@ -9,18 +9,18 @@ import { mkdtempSync } from "node:fs"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { |
| 12 | addNote, | |
| 13 | deleteNote, | |
| 12 | deleteDrawing, | |
| 14 | 13 | getAudit, |
| 15 | getNote, | |
| 14 | getDrawing, | |
| 16 | 15 | listAudit, |
| 17 | listNotes, | |
| 18 | updateNote, | |
| 16 | listDrawings, | |
| 17 | startDrawing, | |
| 19 | 18 | } from "./actions.ts"; |
| 20 | 19 | import { record } from "./audit.ts"; |
| 21 | 20 | import { actingAs, identities, table, type Verb } from "./can.ts"; |
| 22 | 21 | import { contract } from "./contract.ts"; |
| 23 | 22 | import { openDb } from "./db.ts"; |
| 23 | import { setDrawer } from "./draw.ts"; | |
| 24 | 24 | import type { Who } from "./who.ts"; |
| 25 | 25 | |
| 26 | 26 | const db = openDb(mkdtempSync(join(tmpdir(), "app-contract-"))); |
| @@ -34,18 +34,22 @@ function whoIs(identity: string): Who { | ||
| 34 | 34 | return { role, id: actor.id, name: identity }; |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | const noteBy = (authorId: string): number => | |
| 37 | // Drawings finish at once here; a row made by hand is already done. | |
| 38 | setDrawer(async () => []); | |
| 39 | const drawingBy = (authorId: string): number => | |
| 38 | 40 | db |
| 39 | 41 | .query< |
| 40 | 42 | { id: number }, |
| 41 | 43 | [string] |
| 42 | >("INSERT INTO note (body, author_id, author_name) VALUES ('n', ?1, ?1) RETURNING id") | |
| 44 | >("INSERT INTO drawing (prompt, kind, status, author_id, author_name) VALUES ('p', 'mark', 'done', ?1, ?1) RETURNING id") | |
| 43 | 45 | .get(authorId)!.id; |
| 46 | /** Each create is a new person, so "one at a time" never answers for the table. */ | |
| 47 | let fresh = 0; | |
| 44 | 48 | |
| 45 | 49 | const auditRow = (): number => { |
| 46 | 50 | record(db, { |
| 47 | 51 | who: { role: "anon" }, |
| 48 | resource: "note", | |
| 52 | resource: "drawing", | |
| 49 | 53 | verb: "create", |
| 50 | 54 | outcome: "signin_required", |
| 51 | 55 | }); |
| @@ -58,13 +62,18 @@ const doors: Record< | ||
| 58 | 62 | string, |
| 59 | 63 | Partial<Record<Verb, (who: Who, authorId: string) => boolean>> |
| 60 | 64 | > = { |
| 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, | |
| 65 | drawing: { | |
| 66 | create: (who) => { | |
| 67 | const made = startDrawing(db, who.role === "anon" ? who : { ...who, id: `${who.id}-${++fresh}` }, { prompt: "hello" }); | |
| 68 | // Settled at once, so the cap on drawings in flight never answers for the table. | |
| 69 | db.exec("UPDATE drawing SET status = 'done' WHERE status = 'drawing'"); | |
| 70 | return made.ok; | |
| 71 | }, | |
| 72 | list: (who) => listDrawings(db, who).ok, | |
| 73 | read: (who, authorId) => getDrawing(db, who, { id: drawingBy(authorId) }).ok, | |
| 74 | // There is no action that changes a drawing: Claude's answer is kept as it came. | |
| 75 | update: () => false, | |
| 76 | delete: (who, authorId) => deleteDrawing(db, who, { id: drawingBy(authorId) }).ok, | |
| 68 | 77 | }, |
| 69 | 78 | // The trail is written by the app itself, never through a door: there |
| 70 | 79 | // is no action that creates, changes or removes a row of it. |
src/db.ts +16
| @@ -38,6 +38,22 @@ export const migrations: string[] = [ | ||
| 38 | 38 | allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)), |
| 39 | 39 | outcome TEXT NOT NULL |
| 40 | 40 | )`, |
| 41 | // The template's example notes give way to drawings: one prompt, the SVGs | |
| 42 | // Claude drew for it (a JSON array), and where the drawing stands. | |
| 43 | `DROP TABLE note`, | |
| 44 | `CREATE TABLE drawing ( | |
| 45 | id INTEGER PRIMARY KEY, | |
| 46 | prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 400), | |
| 47 | kind TEXT NOT NULL CHECK (kind IN ('mark', 'icon', 'badge', 'spot')), | |
| 48 | status TEXT NOT NULL DEFAULT 'drawing' CHECK (status IN ('drawing', 'done', 'failed')), | |
| 49 | svgs TEXT NOT NULL DEFAULT '[]', | |
| 50 | error TEXT, | |
| 51 | author_id TEXT NOT NULL, | |
| 52 | author_name TEXT NOT NULL, | |
| 53 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 54 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 55 | )`, | |
| 56 | `CREATE INDEX drawing_author ON drawing (author_id, id)`, | |
| 41 | 57 | ]; |
| 42 | 58 | |
| 43 | 59 | export function openDb(dir = dataDir): Database { |
src/draw.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** | |
| 2 | * The work after the response: a drawing row is made at once (status | |
| 3 | * `drawing`) and this fills it in when Claude answers — `done` with its SVGs, | |
| 4 | * or `failed` with a sentence a person can act on. Each change is emitted, | |
| 5 | * so the page updates live. | |
| 6 | * | |
| 7 | * The drawer is set once at boot (`src/server.ts`) from the environment; a | |
| 8 | * test sets its own. With no token there is still a drawer: one that says | |
| 9 | * what to set, so the page never just spins. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { claudeRunner, drawArgs, resultText, type RunClaude } from "./claude.ts"; | |
| 14 | import { ask, COUNT, parseReply, type Kind } from "./glyph.ts"; | |
| 15 | import { emit } from "./live.ts"; | |
| 16 | ||
| 17 | /** A prompt and a kind in, the kept SVGs out — or a thrown Error whose message a person reads. */ | |
| 18 | export type Drawer = (prompt: string, kind: Kind) => Promise<string[]>; | |
| 19 | ||
| 20 | export const NO_TOKEN = | |
| 21 | "Drawing needs a Claude token: set CLAUDE_CODE_OAUTH_TOKEN in Settings → App → Environment (from `claude setup-token`), then try again."; | |
| 22 | ||
| 23 | let drawer: Drawer = async () => { | |
| 24 | throw new Error(NO_TOKEN); | |
| 25 | }; | |
| 26 | ||
| 27 | export function setDrawer(next: Drawer): void { | |
| 28 | drawer = next; | |
| 29 | } | |
| 30 | ||
| 31 | /** A drawer over Claude Code. */ | |
| 32 | export function claudeDrawer(run: RunClaude): Drawer { | |
| 33 | return async (prompt, kind) => { | |
| 34 | const answer = resultText(await run(drawArgs(), ask(prompt, kind))); | |
| 35 | if (!answer.ok) throw new Error(answer.why); | |
| 36 | const svgs = parseReply(answer.text); | |
| 37 | if (svgs.length === 0) throw new Error("Claude answered, but no drawing in it passed the checks. Try again."); | |
| 38 | return svgs.slice(0, COUNT); | |
| 39 | }; | |
| 40 | } | |
| 41 | ||
| 42 | /** The drawer this process uses, from what the forge told it. */ | |
| 43 | export function drawerFromEnv( | |
| 44 | dataDir: string, | |
| 45 | from: Readonly<Record<string, string | undefined>> = process.env, | |
| 46 | ): Drawer | undefined { | |
| 47 | const token = from.CLAUDE_CODE_OAUTH_TOKEN ?? ""; | |
| 48 | return token === "" ? undefined : claudeDrawer(claudeRunner(token, dataDir)); | |
| 49 | } | |
| 50 | ||
| 51 | /** The work in flight, so a test (or a shutdown) can wait for it. */ | |
| 52 | const running = new Set<Promise<void>>(); | |
| 53 | export const settled = (): Promise<unknown> => Promise.all([...running]); | |
| 54 | ||
| 55 | /** Fill in drawing `id` in the background. */ | |
| 56 | export function kick(db: Database, id: number, prompt: string, kind: Kind): void { | |
| 57 | const job = (async () => { | |
| 58 | let row: unknown; | |
| 59 | try { | |
| 60 | const svgs = await drawer(prompt, kind); | |
| 61 | row = db | |
| 62 | .query("UPDATE drawing SET status = 'done', svgs = ?1, error = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 AND status = 'drawing' RETURNING *") | |
| 63 | .get(JSON.stringify(svgs), id); | |
| 64 | } catch (error) { | |
| 65 | const message = error instanceof Error ? error.message : String(error); | |
| 66 | row = db | |
| 67 | .query("UPDATE drawing SET status = 'failed', error = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 AND status = 'drawing' RETURNING *") | |
| 68 | .get(message.slice(0, 500), id); | |
| 69 | } | |
| 70 | if (row !== null) emit("drawing.changed", row); | |
| 71 | })().catch((error) => console.error("drawing", id, error)); | |
| 72 | running.add(job); | |
| 73 | void job.finally(() => running.delete(job)); | |
| 74 | } | |
| 75 | ||
| 76 | /** Drawings a stopped process left half done are failed, so nobody waits on them forever. */ | |
| 77 | export function recover(db: Database): number { | |
| 78 | return db | |
| 79 | .query("UPDATE drawing SET status = 'failed', error = 'Interrupted: the app restarted while drawing. Try again.' WHERE status = 'drawing'") | |
| 80 | .run().changes; | |
| 81 | } |
src/glyph.test.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** | |
| 2 | * The wall every SVG passes, the reply read back, and the palette swap. | |
| 3 | * The wall is the one place a model's output meets the page, so it is | |
| 4 | * tested by what gets through: nothing that runs, loads or links. | |
| 5 | */ | |
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { claudeEnv, drawArgs, resultText } from "./claude.ts"; | |
| 8 | import { claudeDrawer } from "./draw.ts"; | |
| 9 | import { ask, GLOW, INK, inPalette, nameOf, PALETTES, parseReply, sanitize } from "./glyph.ts"; | |
| 10 | ||
| 11 | const ok = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Hex Chip</title><path d="M12 2 L52 2" stroke="${INK}" stroke-width="4" stroke-linecap="round"/><circle cx="32" cy="32" r="6" fill="${GLOW}"/></svg>`; | |
| 12 | ||
| 13 | test("a clean SVG comes through as it was", () => { | |
| 14 | expect(sanitize(ok)).toBe(ok); | |
| 15 | expect(nameOf(ok)).toBe("Hex Chip"); | |
| 16 | }); | |
| 17 | ||
| 18 | test("nothing that runs, loads or links gets through", () => { | |
| 19 | const evil = `<svg viewBox="0 0 64 64" onload="alert(1)" style="x"> | |
| 20 | <script>alert(1)</script> | |
| 21 | <foreignObject><div onclick="x">hi</div></foreignObject> | |
| 22 | <image href="https://evil.example/x.png"/> | |
| 23 | <a href="javascript:alert(1)"><circle cx="1" cy="1" r="1"/></a> | |
| 24 | <use href="https://evil.example/s.svg#x"/> | |
| 25 | <style>@import url(https://evil.example)</style> | |
| 26 | <path d="M0 0L9 9" fill="url(https://evil.example/#g)" onmouseover="x" stroke="${INK}" transform="translate(2 2)"/> | |
| 27 | <rect x="1" y="1" width="5" height="5" transform="expression(alert(1))"/> | |
| 28 | <!-- <script>alert(2)</script> --> | |
| 29 | <![CDATA[ <script>alert(3)</script> ]]> | |
| 30 | </svg>`; | |
| 31 | const clean = sanitize(evil)!; | |
| 32 | expect(clean).toBeDefined(); | |
| 33 | for (const bad of ["script", "onload", "onclick", "onmouseover", "foreignObject", "image", "href", "javascript", "style", "url(", "evil", "expression", "<a", "<use", "alert"]) | |
| 34 | expect([bad, clean.includes(bad)]).toEqual([bad, false]); | |
| 35 | // What was allowed inside survives: the path, its colour and its transform; the link's child went with it. | |
| 36 | expect(clean).toContain(`<path d="M0 0L9 9" stroke="${INK}" transform="translate(2 2)"/>`); | |
| 37 | expect(clean).toContain(`<rect x="1" y="1" width="5" height="5"/>`); | |
| 38 | expect(clean).not.toContain("<circle"); | |
| 39 | }); | |
| 40 | ||
| 41 | test("a value is a value: no url(#…), no colour functions, only a transform's own", () => { | |
| 42 | const svg = sanitize(`<svg viewBox="0 0 8 8"><circle r="1" fill="url(#g)" stroke="rgb(1,2,3)" transform="rotate(45 4 4)"/></svg>`)!; | |
| 43 | expect(svg).toContain(`<circle r="1" transform="rotate(45 4 4)"/>`); | |
| 44 | }); | |
| 45 | ||
| 46 | test("a title's text is text, and markup in values is refused", () => { | |
| 47 | const svg = sanitize(`<svg viewBox="0 0 8 8"><title>A & B <b>bold</b></title><circle cx="4" cy="4" r="2" fill=""><script>"/></svg>`)!; | |
| 48 | // An element inside a title goes with its content; the entity stays an entity. | |
| 49 | expect(svg).toContain("<title>A & B </title>"); | |
| 50 | expect(sanitize(`<svg viewBox="0 0 8 8"><title>R&D</title><circle r="1"/></svg>`)).toContain("<title>R&D</title>"); | |
| 51 | expect(svg).toContain(`<circle cx="4" cy="4" r="2"/>`); | |
| 52 | }); | |
| 53 | ||
| 54 | test("no SVG, an empty one, a second root or a huge one is not kept", () => { | |
| 55 | expect(sanitize("just words")).toBeUndefined(); | |
| 56 | expect(sanitize(`<svg viewBox="0 0 8 8"><title>Nothing</title></svg>`)).toBeUndefined(); | |
| 57 | expect(sanitize(`<svg><circle r="2"/></svg>`)).toBeUndefined(); // no viewBox | |
| 58 | expect(sanitize(`<svg viewBox="0 0 8 8"><svg viewBox="0 0 8 8"><circle r="1"/></svg></svg>`)).toBeUndefined(); | |
| 59 | const huge = `<svg viewBox="0 0 8 8">${`<circle cx="1" cy="1" r="1"/>`.repeat(2000)}</svg>`; | |
| 60 | expect(sanitize(huge)).toBeUndefined(); | |
| 61 | // Unclosed tags are closed. | |
| 62 | expect(sanitize(`<svg viewBox="0 0 8 8"><g><circle r="1"/>`)).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><g><circle r="1"/></g></svg>`); | |
| 63 | }); | |
| 64 | ||
| 65 | test("the reply's fenced blocks are read, bad ones dropped, at most four kept", () => { | |
| 66 | const reply = ["```svg", ok, "```", "```svg", "<svg>nope</svg>", "```", ...Array(5).fill(["```svg", ok, "```"]).flat()].join("\n"); | |
| 67 | expect(parseReply(reply)).toEqual([ok, ok, ok, ok]); | |
| 68 | // No fences: the bare SVGs in the text. | |
| 69 | expect(parseReply(`Here: ${ok} and ${ok}`)).toEqual([ok, ok]); | |
| 70 | expect(parseReply("I can't draw that.")).toEqual([]); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a palette swaps ink and glow, even when one palette's ink is another's placeholder", () => { | |
| 74 | const neon = inPalette(ok, "neon"); | |
| 75 | expect(neon).toContain(`stroke="${PALETTES.neon!.ink}"`); | |
| 76 | expect(neon).toContain(`fill="${PALETTES.neon!.glow}"`); | |
| 77 | expect(neon).not.toContain(INK); | |
| 78 | expect(inPalette(ok, "sage")).toBe(ok); | |
| 79 | expect(inPalette(ok.toUpperCase().replace("<SVG", "<svg"), "mono")).toContain(PALETTES.mono!.ink); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the ask names the brief, the kind, the colours and the count", () => { | |
| 83 | const text = ask("a fox", "badge"); | |
| 84 | for (const part of ["a fox", "badge", INK, GLOW, "4 distinct", "viewBox=\"0 0 64 64\""]) | |
| 85 | expect([part, text.includes(part)]).toEqual([part, true]); | |
| 86 | }); | |
| 87 | ||
| 88 | test("Claude is started with no tools, and with nothing of ours but its token and the way out", () => { | |
| 89 | const args = drawArgs(); | |
| 90 | expect(args.slice(args.indexOf("--tools"), args.indexOf("--tools") + 2)).toEqual(["--tools", ""]); | |
| 91 | expect(args).toContain("--no-session-persistence"); | |
| 92 | const env = claudeEnv("tok", "/data/claude", { | |
| 93 | PATH: "/bin", | |
| 94 | HTTPS_PROXY: "http://127.0.0.1:3128", | |
| 95 | FORGE_APP_IDENTITY_KEY: "secret", | |
| 96 | SOMETHING_ELSE: "x", | |
| 97 | }); | |
| 98 | expect(env).toMatchObject({ CLAUDE_CODE_OAUTH_TOKEN: "tok", CLAUDE_CONFIG_DIR: "/data/claude", HTTPS_PROXY: "http://127.0.0.1:3128" }); | |
| 99 | expect(env.FORGE_APP_IDENTITY_KEY).toBeUndefined(); | |
| 100 | expect(env.SOMETHING_ELSE).toBeUndefined(); | |
| 101 | }); | |
| 102 | ||
| 103 | test("Claude's answer is read, and a refusal or junk is a sentence, not a crash", async () => { | |
| 104 | const answer = JSON.stringify({ type: "result", is_error: false, result: "```svg\n" + ok + "\n```" }); | |
| 105 | expect(resultText(answer)).toEqual({ ok: true, text: "```svg\n" + ok + "\n```" }); | |
| 106 | expect(resultText("not json").ok).toBe(false); | |
| 107 | expect(resultText(JSON.stringify({ is_error: true, result: "Not logged in" }))).toEqual({ ok: false, why: "Claude stopped: Not logged in" }); | |
| 108 | const seen: string[][] = []; | |
| 109 | const draw = claudeDrawer(async (args) => { | |
| 110 | seen.push([...args]); | |
| 111 | return answer; | |
| 112 | }); | |
| 113 | expect(await draw("a fox", "mark")).toEqual([ok]); | |
| 114 | expect(seen[0]).toEqual(drawArgs()); | |
| 115 | const empty = claudeDrawer(async () => JSON.stringify({ result: "Sorry, no." })); | |
| 116 | await expect(empty("a fox", "mark")).rejects.toThrow("no drawing in it passed"); | |
| 117 | }); |
src/glyph.ts added +173
| @@ -0,0 +1,173 @@ | ||
| 1 | /** | |
| 2 | * What a drawing is made of: the ask Claude is given, the reply read back | |
| 3 | * into SVGs, and the wall every SVG passes before it is kept. Pure | |
| 4 | * functions, no I/O — `src/draw.ts` runs Claude, `src/actions.ts` keeps | |
| 5 | * the rows. | |
| 6 | * | |
| 7 | * Every SVG is drawn in two placeholder colours, INK and GLOW. A palette is | |
| 8 | * those two swapped for two others, here for a download and in | |
| 9 | * `static/glyph.js` for the preview, so one drawing reads in any theme. | |
| 10 | * The colours themselves are data: `design/palettes.json`. | |
| 11 | */ | |
| 12 | ||
| 13 | import colours from "../design/palettes.json"; | |
| 14 | ||
| 15 | export const KINDS = { | |
| 16 | mark: "logo mark", | |
| 17 | icon: "app icon", | |
| 18 | badge: "badge", | |
| 19 | spot: "spot illustration", | |
| 20 | } as const; | |
| 21 | export type Kind = keyof typeof KINDS; | |
| 22 | export const isKind = (value: unknown): value is Kind => | |
| 23 | typeof value === "string" && Object.hasOwn(KINDS, value); | |
| 24 | ||
| 25 | /** The placeholder colours every SVG is drawn in. */ | |
| 26 | export const INK: string = colours.placeholder.ink; | |
| 27 | export const GLOW: string = colours.placeholder.glow; | |
| 28 | ||
| 29 | /** Palettes a drawing can be shown and saved in. */ | |
| 30 | export const PALETTES: Readonly< | |
| 31 | Record<string, { label: string; ink: string; glow: string; ground: string }> | |
| 32 | > = colours.palettes; | |
| 33 | export type Palette = keyof typeof colours.palettes; | |
| 34 | export const isPalette = (value: unknown): value is Palette => | |
| 35 | typeof value === "string" && Object.hasOwn(PALETTES, value); | |
| 36 | ||
| 37 | export const COUNT = 4; | |
| 38 | export const PROMPT_MAX = 400; | |
| 39 | /** The most a kept SVG may weigh, in characters. */ | |
| 40 | export const SVG_MAX = 16_000; | |
| 41 | ||
| 42 | /** What Claude is asked, for one prompt. */ | |
| 43 | export function ask(prompt: string, kind: Kind): string { | |
| 44 | return `You are a sharp vector designer. Draw ${COUNT} distinct ${KINDS[kind]} concepts for this brief: | |
| 45 | ||
| 46 | ${prompt} | |
| 47 | ||
| 48 | Rules for every SVG: | |
| 49 | - Root element exactly: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> | |
| 50 | - First child: <title> with a two-to-four word name for the concept. | |
| 51 | - Use ONLY these colours, written as hex in fill/stroke attributes: ${INK} (main ink) and ${GLOW} (accent glow), plus "none". No other colours, no gradients, no opacity tricks, no CSS, no <style>. | |
| 52 | - Allowed elements: g, path, circle, ellipse, rect, line, polyline, polygon, title. No text, no images, no filters, no scripts, no links. | |
| 53 | - Bold and simple: it must still read at 24 pixels. Round line caps and joins. Stay inside the viewBox with a little margin. | |
| 54 | - Make the ${COUNT} concepts genuinely different ideas, not variations of one. | |
| 55 | ||
| 56 | Reply with exactly ${COUNT} fenced code blocks marked svg, one per concept, and nothing else.`; | |
| 57 | } | |
| 58 | ||
| 59 | /** The SVGs in Claude's reply: each fenced block, through the wall; the ones that do not pass are dropped. */ | |
| 60 | export function parseReply(reply: string): string[] { | |
| 61 | const blocks = [...reply.matchAll(/```(?:svg|xml)?\s*\n([\s\S]*?)```/g)].map( | |
| 62 | (m) => m[1] ?? "", | |
| 63 | ); | |
| 64 | const found = | |
| 65 | blocks.length > 0 ? blocks : [...reply.matchAll(/<svg[\s\S]*?<\/svg>/g)].map((m) => m[0]); | |
| 66 | return found | |
| 67 | .map((svg) => sanitize(svg)) | |
| 68 | .filter((svg): svg is string => svg !== undefined) | |
| 69 | .slice(0, COUNT); | |
| 70 | } | |
| 71 | ||
| 72 | // The wall. An SVG is rebuilt from what is allowed, never filtered from what is | |
| 73 | // not: an element or attribute missing from these lists does not survive, so a | |
| 74 | // trick nobody thought of has nothing to ride in on. | |
| 75 | const ELEMENTS = new Set([ | |
| 76 | "svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "title", | |
| 77 | ]); | |
| 78 | const ATTRIBUTES = new Set([ | |
| 79 | "viewBox", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2", | |
| 80 | "width", "height", "points", "fill", "stroke", "stroke-width", "stroke-linecap", | |
| 81 | "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset", | |
| 82 | "fill-rule", "clip-rule", "opacity", "fill-opacity", "stroke-opacity", "transform", | |
| 83 | ]); | |
| 84 | /** A value: numbers, path data, a colour keyword or hex — never a url, a function but a transform's, or markup. */ | |
| 85 | const VALUE = /^[-\w\s.,#%()]*$/; | |
| 86 | ||
| 87 | /** Text as text: a bare & is escaped, an entity already written stays one (entities are inert). */ | |
| 88 | const escapeText = (s: string): string => | |
| 89 | s | |
| 90 | .replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/gi, "&") | |
| 91 | .replace(/</g, "<") | |
| 92 | .replace(/>/g, ">"); | |
| 93 | ||
| 94 | /** The SVG rebuilt from what is allowed, or undefined when there is no SVG worth keeping. */ | |
| 95 | export function sanitize(input: string): string | undefined { | |
| 96 | if (input.length > SVG_MAX * 2) return undefined; | |
| 97 | // Comments, CDATA, doctypes and processing instructions go before reading tags. | |
| 98 | const source = input | |
| 99 | .replace(/<!--[\s\S]*?-->/g, "") | |
| 100 | .replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "") | |
| 101 | .replace(/<![^>]*>/g, "") | |
| 102 | .replace(/<\?[\s\S]*?\?>/g, ""); | |
| 103 | const out: string[] = []; | |
| 104 | const open: string[] = []; | |
| 105 | let skipping = 0; // depth inside an element that is dropped with its content | |
| 106 | let rootSeen = false; | |
| 107 | const tag = /<(\/?)([A-Za-z][\w:-]*)((?:\s+[^\s=>\/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)|</g; | |
| 108 | for (const m of source.matchAll(tag)) { | |
| 109 | const [, closing, rawName, attrs = "", selfClosing, text] = m; | |
| 110 | if (text !== undefined || rawName === undefined) { | |
| 111 | // Text is kept only inside a title; anything else between tags is whitespace or noise. | |
| 112 | if (skipping === 0 && open.at(-1) === "title" && text !== undefined) | |
| 113 | out.push(escapeText(text)); | |
| 114 | continue; | |
| 115 | } | |
| 116 | const name = rawName; | |
| 117 | if (closing === "/") { | |
| 118 | if (skipping > 0) { | |
| 119 | skipping--; | |
| 120 | continue; | |
| 121 | } | |
| 122 | if (open.at(-1) === name) { | |
| 123 | open.pop(); | |
| 124 | out.push(`</${name}>`); | |
| 125 | if (name === "svg" && open.length === 0) break; | |
| 126 | } | |
| 127 | continue; | |
| 128 | } | |
| 129 | const allowed = ELEMENTS.has(name) && (name === "svg" ? !rootSeen : rootSeen && open.length > 0); | |
| 130 | if (!allowed || skipping > 0) { | |
| 131 | if (selfClosing !== "/") skipping++; | |
| 132 | continue; | |
| 133 | } | |
| 134 | if (name === "svg") rootSeen = true; | |
| 135 | const kept: string[] = name === "svg" ? [`xmlns="http://www.w3.org/2000/svg"`] : []; | |
| 136 | for (const a of attrs.matchAll(/([^\s=>\/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) { | |
| 137 | const key = a[1] ?? ""; | |
| 138 | const value = a[2] ?? a[3] ?? a[4] ?? ""; | |
| 139 | if (!ATTRIBUTES.has(key) || !VALUE.test(value) || value.length > 4000) continue; | |
| 140 | if (/\(/.test(value) && key !== "transform") continue; | |
| 141 | if (key === "transform" && !/^(\s*(matrix|translate|scale|rotate|skewX|skewY)\([-\d.,\s]*\)\s*)+$/.test(value)) continue; | |
| 142 | kept.push(`${key}="${value}"`); | |
| 143 | } | |
| 144 | const head = `<${name}${kept.length ? ` ${kept.join(" ")}` : ""}`; | |
| 145 | if (selfClosing === "/") out.push(`${head}/>`); | |
| 146 | else { | |
| 147 | out.push(`${head}>`); | |
| 148 | open.push(name); | |
| 149 | } | |
| 150 | } | |
| 151 | if (!rootSeen) return undefined; | |
| 152 | while (open.length > 0) out.push(`</${open.pop()}>`); | |
| 153 | const svg = out.join(""); | |
| 154 | // A drawing has something in it besides its name. | |
| 155 | if (!/<(path|circle|ellipse|rect|line|polyline|polygon)\b/.test(svg)) return undefined; | |
| 156 | if (!/viewBox=/.test(svg)) return undefined; | |
| 157 | return svg.length <= SVG_MAX ? svg : undefined; | |
| 158 | } | |
| 159 | ||
| 160 | /** The concept's name, from its <title>. */ | |
| 161 | export function nameOf(svg: string): string { | |
| 162 | const title = /<title>([^<]*)<\/title>/.exec(svg)?.[1]?.trim(); | |
| 163 | return title === undefined || title === "" ? "Untitled" : title.replace(/&/g, "&"); | |
| 164 | } | |
| 165 | ||
| 166 | /** The SVG in a palette: INK and GLOW swapped for the palette's two. */ | |
| 167 | export function inPalette(svg: string, palette: Palette): string { | |
| 168 | const { ink, glow } = PALETTES[palette]!; | |
| 169 | return svg | |
| 170 | .replace(new RegExp(INK, "gi"), "\u0000ink") | |
| 171 | .replace(new RegExp(GLOW, "gi"), glow) | |
| 172 | .replace(/\u0000ink/g, ink); | |
| 173 | } |
src/mcp.ts +56 −24
| @@ -7,7 +7,16 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { addNote, deleteNote, listNotes, type Result } from "./actions.ts"; | |
| 10 | import { | |
| 11 | deleteDrawing, | |
| 12 | getDrawing, | |
| 13 | listDrawings, | |
| 14 | startDrawing, | |
| 15 | svgsOf, | |
| 16 | type Drawing, | |
| 17 | type Result, | |
| 18 | } from "./actions.ts"; | |
| 19 | import { GLOW, INK, KINDS, PROMPT_MAX } from "./glyph.ts"; | |
| 11 | 20 | import { title } from "./env.ts"; |
| 12 | 21 | import { fields } from "./http.ts"; |
| 13 | 22 | import type { Who } from "./who.ts"; |
| @@ -23,44 +32,67 @@ interface Tool { | ||
| 23 | 32 | ) => Result<unknown>; |
| 24 | 33 | } |
| 25 | 34 | |
| 35 | /** A drawing as a tool answers it: its SVGs as an array. */ | |
| 36 | const shaped = <T extends Drawing | Drawing[]>(r: Result<T>): Result<unknown> => | |
| 37 | r.ok | |
| 38 | ? { | |
| 39 | ok: true, | |
| 40 | value: Array.isArray(r.value) | |
| 41 | ? r.value.map((d) => ({ ...d, svgs: svgsOf(d) })) | |
| 42 | : { ...(r.value as Drawing), svgs: svgsOf(r.value as Drawing) }, | |
| 43 | } | |
| 44 | : r; | |
| 45 | ||
| 26 | 46 | export const tools: readonly Tool[] = [ |
| 27 | 47 | { |
| 28 | name: "list_notes", | |
| 29 | description: "The notes, newest first.", | |
| 48 | name: "draw", | |
| 49 | description: `Ask Claude for SVG concepts from a prompt. Answers at once with status \`drawing\`; call get_drawing until it is \`done\` (about a minute). The SVGs use ${INK} as ink and ${GLOW} as glow: swap those two for your own colours.`, | |
| 30 | 50 | inputSchema: { |
| 31 | 51 | type: "object", |
| 32 | 52 | properties: { |
| 33 | limit: { | |
| 34 | type: "integer", | |
| 35 | minimum: 1, | |
| 36 | maximum: 500, | |
| 37 | description: "At most this many (100).", | |
| 38 | }, | |
| 53 | prompt: { type: "string", maxLength: PROMPT_MAX }, | |
| 54 | kind: { enum: Object.keys(KINDS), description: "What to draw (mark)." }, | |
| 39 | 55 | }, |
| 56 | required: ["prompt"], | |
| 40 | 57 | }, |
| 41 | 58 | run: (db, who, args) => |
| 42 | listNotes(db, who, { limit: Number(args.limit ?? 100) }), | |
| 59 | shaped( | |
| 60 | startDrawing(db, who, { | |
| 61 | prompt: String(args.prompt ?? ""), | |
| 62 | ...(args.kind === undefined ? {} : { kind: String(args.kind) }), | |
| 63 | }), | |
| 64 | ), | |
| 43 | 65 | }, |
| 44 | 66 | { |
| 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.", | |
| 67 | name: "get_drawing", | |
| 68 | description: "One drawing by id, with its SVGs once it is done.", | |
| 58 | 69 | inputSchema: { |
| 59 | 70 | type: "object", |
| 60 | 71 | properties: { id: { type: "integer" } }, |
| 61 | 72 | required: ["id"], |
| 62 | 73 | }, |
| 63 | run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }), | |
| 74 | run: (db, who, args) => shaped(getDrawing(db, who, { id: Number(args.id) })), | |
| 75 | }, | |
| 76 | { | |
| 77 | name: "list_drawings", | |
| 78 | description: "Your drawings, newest first.", | |
| 79 | inputSchema: { | |
| 80 | type: "object", | |
| 81 | properties: { | |
| 82 | limit: { type: "integer", minimum: 1, maximum: 200, description: "At most this many (50)." }, | |
| 83 | }, | |
| 84 | }, | |
| 85 | run: (db, who, args) => shaped(listDrawings(db, who, { limit: Number(args.limit ?? 50) })), | |
| 86 | }, | |
| 87 | { | |
| 88 | name: "delete_drawing", | |
| 89 | description: "Remove a drawing by id. Only whoever asked for it, or an admin, can.", | |
| 90 | inputSchema: { | |
| 91 | type: "object", | |
| 92 | properties: { id: { type: "integer" } }, | |
| 93 | required: ["id"], | |
| 94 | }, | |
| 95 | run: (db, who, args) => deleteDrawing(db, who, { id: Number(args.id) }), | |
| 64 | 96 | }, |
| 65 | 97 | ]; |
| 66 | 98 |
src/openapi.test.ts +8 −3
| @@ -10,6 +10,7 @@ import { join } from "node:path"; | ||
| 10 | 10 | import { emit, openapi } from "./openapi.ts"; |
| 11 | 11 | import { routes } from "./routes.ts"; |
| 12 | 12 | import { serve } from "./server.ts"; |
| 13 | import { sign } from "./who.ts"; | |
| 13 | 14 | |
| 14 | 15 | const committed = readFileSync( |
| 15 | 16 | new URL("../contract/openapi.json", import.meta.url), |
| @@ -67,9 +68,13 @@ test("GET /openapi.json serves the committed file", async () => { | ||
| 67 | 68 | identityKey: "k", |
| 68 | 69 | }); |
| 69 | 70 | afterAll(() => server.stop(true)); |
| 70 | const response = await fetch( | |
| 71 | `http://${server.hostname}:${server.port}/openapi.json`, | |
| 72 | ); | |
| 71 | // Drawings open no door to a stranger, so neither does their description. | |
| 72 | const url = `http://${server.hostname}:${server.port}/openapi.json`; | |
| 73 | expect((await fetch(url)).status).toBe(401); | |
| 74 | const identity = `u1|alice|user|${Date.now() + 60_000}`; | |
| 75 | const response = await fetch(url, { | |
| 76 | headers: { "x-forge-identity": identity, "x-forge-signature": sign(identity, "k") }, | |
| 77 | }); | |
| 73 | 78 | expect(response.status).toBe(200); |
| 74 | 79 | expect(response.headers.get("content-type")).toContain("application/json"); |
| 75 | 80 | expect(await response.text()).toBe(committed); |
src/openapi.ts +2 −2
| @@ -12,12 +12,12 @@ | ||
| 12 | 12 | |
| 13 | 13 | import { contract } from "./contract.ts"; |
| 14 | 14 | import { description, title, version } from "./env.ts"; |
| 15 | import { AUDIT_ROW, ERROR, NOTE, routes, type Schema } from "./routes.ts"; | |
| 15 | import { AUDIT_ROW, DRAWING, ERROR, routes, type Schema } from "./routes.ts"; | |
| 16 | 16 | import { doorOf } from "./security.ts"; |
| 17 | 17 | |
| 18 | 18 | // The shapes said once, under components, and referred to wherever they appear. |
| 19 | 19 | const NAMED = new Map<Schema, string>([ |
| 20 | [NOTE, "Note"], | |
| 20 | [DRAWING, "Drawing"], | |
| 21 | 21 | [AUDIT_ROW, "AuditRow"], |
| 22 | 22 | [ERROR, "Error"], |
| 23 | 23 | ]); |
src/page.ts +101 −26
| @@ -1,10 +1,21 @@ | ||
| 1 | 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 | |
| 2 | * The page, rendered on the server: the first paint is complete HTML, | |
| 3 | * `static/live.js` keeps the board fresh afterwards, and `static/glyph.js` | |
| 4 | * adds what only a script can (copy, the palette swapped in place). When a second page | |
| 4 | 5 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | import { canDelete, type Note } from "./actions.ts"; | |
| 8 | import { canDelete, svgsOf, type Drawing } from "./actions.ts"; | |
| 9 | import { | |
| 10 | COUNT, | |
| 11 | inPalette, | |
| 12 | isPalette, | |
| 13 | KINDS, | |
| 14 | nameOf, | |
| 15 | PALETTES, | |
| 16 | PROMPT_MAX, | |
| 17 | type Palette, | |
| 18 | } from "./glyph.ts"; | |
| 8 | 19 | import { |
| 9 | 20 | commit, |
| 10 | 21 | description, |
| @@ -50,6 +61,7 @@ export function layout(body: string, who: Who, path = "/"): string { | ||
| 50 | 61 | <link rel="stylesheet" href="${tokensHref}"> |
| 51 | 62 | <link rel="stylesheet" href="${asset("app.css")}"> |
| 52 | 63 | <script type="module" src="${asset("live.js")}"></script> |
| 64 | <script type="module" src="${asset("glyph.js")}"></script> | |
| 53 | 65 | <body> |
| 54 | 66 | <div class="page"> |
| 55 | 67 | <header class="head"> |
| @@ -65,7 +77,7 @@ export function layout(body: string, who: Who, path = "/"): string { | ||
| 65 | 77 | <main>${body}</main> |
| 66 | 78 | <footer class="muted"> |
| 67 | 79 | ${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> | |
| 80 | <p>Also at <a href="/api/drawings">/api/drawings</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules. Drawn by Claude; every SVG is rebuilt from an allowlist before it is kept.</p> | |
| 69 | 81 | <p>Something wrong? <a href="/__forge/report">Report a problem</a>, no account needed.</p> |
| 70 | 82 | </footer> |
| 71 | 83 | </div> |
| @@ -76,41 +88,104 @@ export function layout(body: string, who: Who, path = "/"): string { | ||
| 76 | 88 | |
| 77 | 89 | export function home(input: { |
| 78 | 90 | who: Who; |
| 79 | notes: readonly Note[]; | |
| 91 | drawings: readonly Drawing[]; | |
| 92 | palette?: string | null; | |
| 80 | 93 | notice?: string | undefined; |
| 81 | 94 | }): string { |
| 82 | const { who, notes, notice } = input; | |
| 95 | const { who, drawings, notice } = input; | |
| 96 | const palette: Palette = isPalette(input.palette) ? input.palette : "sage"; | |
| 83 | 97 | const composer = |
| 84 | 98 | 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> | |
| 99 | ? `<p class="muted">Drawings are made for whoever asks. <a href="/__forge/signin?to=/">Sign in</a> to draw.</p>` | |
| 100 | : `<form class="compose" method="post" action="/drawings"> | |
| 101 | <label class="label" for="prompt">What should Claude draw?</label> | |
| 102 | <textarea class="input prompt" id="prompt" name="prompt" rows="2" required maxlength="${PROMPT_MAX}" placeholder="a fox made of circuit traces, for a home lab" autocomplete="off"></textarea> | |
| 103 | <div class="examples" hidden>${EXAMPLES.map((e) => `<button type="button" class="chip" data-example="${escape(e)}">${escape(e)}</button>`).join("")}</div> | |
| 104 | <div class="row wrap"> | |
| 105 | <fieldset class="kinds" aria-label="Kind"> | |
| 106 | ${Object.entries(KINDS) | |
| 107 | .map( | |
| 108 | ([k, label], i) => | |
| 109 | `<label class="chip"><input type="radio" name="kind" value="${k}"${i === 0 ? " checked" : ""}> ${escape(label)}</label>`, | |
| 110 | ) | |
| 111 | .join("")} | |
| 112 | </fieldset> | |
| 113 | <span class="spacer"></span> | |
| 114 | <button class="button">Draw</button> | |
| 115 | </div> | |
| 89 | 116 | </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 | 117 | return layout( |
| 95 | 118 | `<section class="card">${composer}</section> |
| 96 | <section id="notes" data-live="note.added note.removed">${list}</section> | |
| 119 | ${palettes(palette)} | |
| 120 | <section id="board" data-live="drawing.added drawing.changed drawing.removed">${board(drawings, who, palette)}</section> | |
| 97 | 121 | ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`, |
| 98 | 122 | who, |
| 99 | 123 | ); |
| 100 | 124 | } |
| 101 | 125 | |
| 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>`; | |
| 126 | const EXAMPLES = [ | |
| 127 | "a hex chip with a circuit b", | |
| 128 | "a lighthouse for a monitoring app", | |
| 129 | "a rocket leaving a git branch", | |
| 130 | "a fox made of circuit traces", | |
| 131 | ]; | |
| 113 | 132 | |
| 133 | /** The palette switch: links that work without JavaScript, swapped in place with it. */ | |
| 134 | const palettes = (current: Palette): string => `<nav class="palettes row wrap" id="palettes" aria-label="Palette"> | |
| 135 | ${Object.entries(PALETTES) | |
| 136 | .map( | |
| 137 | ([key, p]) => | |
| 138 | `<a class="chip swatch" href="?palette=${key}" data-palette="${key}"${key === current ? ` aria-current="true"` : ""}><span class="dots" style="background:${p.ground}"><i style="background:${p.ink}"></i><i style="background:${p.glow}"></i></span>${escape(p.label)}</a>`, | |
| 139 | ) | |
| 140 | .join("")} | |
| 141 | </nav>`; | |
| 142 | ||
| 143 | function board(drawings: readonly Drawing[], who: Who, palette: Palette): string { | |
| 144 | if (drawings.length === 0) | |
| 145 | return `<p class="empty">Nothing drawn yet. Describe something above: Claude draws ${COUNT} different takes in about a minute.</p>`; | |
| 146 | return `<ul class="list">${drawings.map((d) => card(d, who, palette)).join("")}</ul>`; | |
| 147 | } | |
| 148 | ||
| 149 | function card(d: Drawing, who: Who, palette: Palette): string { | |
| 150 | const { ground } = PALETTES[palette]!; | |
| 151 | const svgs = svgsOf(d); | |
| 152 | const body = | |
| 153 | d.status === "drawing" | |
| 154 | ? `<div class="tiles" aria-busy="true">${Array.from({ length: COUNT }, () => `<div class="tile pending" style="background:${ground}"></div>`).join("")}</div> | |
| 155 | <p class="muted small" data-since="${escape(d.created_at)}">Claude is drawing…</p>` | |
| 156 | : d.status === "failed" | |
| 157 | ? `<p class="error">${escape(d.error ?? "Something went wrong.")}</p>` | |
| 158 | : `<div class="tiles">${svgs | |
| 159 | .map((svg, n) => { | |
| 160 | const name = nameOf(svg); | |
| 161 | const href = `/drawings/${d.id}/${n}.svg?palette=${palette}`; | |
| 162 | return `<figure class="tile-wrap"> | |
| 163 | <div class="tile" style="background:${ground}"><img alt="${escape(name)}" src="data:image/svg+xml,${encodeURIComponent(inPalette(svg, palette))}"></div> | |
| 164 | <figcaption> | |
| 165 | <span class="name">${escape(name)}</span> | |
| 166 | <span class="row tight"> | |
| 167 | <button type="button" class="button button-quiet small js-copy" data-href="${href}" hidden>Copy</button> | |
| 168 | <a class="button button-quiet small" href="${href}" download>Save</a> | |
| 169 | </span> | |
| 170 | </figcaption> | |
| 171 | </figure>`; | |
| 172 | }) | |
| 173 | .join("")}</div>`; | |
| 174 | return `<li class="drawing" id="drawing-${d.id}"> | |
| 175 | <div class="drawing-head"> | |
| 176 | <div> | |
| 177 | <p class="prompt-text">${escape(d.prompt)}</p> | |
| 178 | <small class="muted">${escape(KINDS[d.kind] ?? d.kind)}${d.author_id === (who.role === "anon" ? "" : who.id) ? "" : ` · ${escape(d.author_name)}`} · <time datetime="${escape(d.created_at)}">${ago(d.created_at)}</time></small> | |
| 179 | </div> | |
| 180 | ${ | |
| 181 | canDelete(who, d) && d.status !== "drawing" | |
| 182 | ? `<form method="post" action="/drawings/${d.id}/delete"><button class="button button-quiet" aria-label="Remove this drawing">Remove</button></form>` | |
| 183 | : "" | |
| 184 | } | |
| 185 | </div> | |
| 186 | ${body} | |
| 187 | </li>`; | |
| 188 | } | |
| 114 | 189 | const ago = (iso: string): string => { |
| 115 | 190 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); |
| 116 | 191 | if (s < 60) return "just now"; |
src/routes.ts +97 −61
| @@ -14,14 +14,14 @@ | ||
| 14 | 14 | import type { Database } from "bun:sqlite"; |
| 15 | 15 | import { existsSync, readFileSync } from "node:fs"; |
| 16 | 16 | import { |
| 17 | addNote, | |
| 18 | BODY_MAX, | |
| 19 | deleteNote, | |
| 17 | deleteDrawing, | |
| 18 | getDrawing, | |
| 20 | 19 | getAudit, |
| 21 | getNote, | |
| 22 | 20 | listAudit, |
| 23 | listNotes, | |
| 24 | updateNote, | |
| 21 | listDrawings, | |
| 22 | startDrawing, | |
| 23 | svgsOf, | |
| 24 | type Drawing, | |
| 25 | 25 | type Failure, |
| 26 | 26 | type Result, |
| 27 | 27 | } from "./actions.ts"; |
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | staticFile, |
| 39 | 39 | text, |
| 40 | 40 | } from "./http.ts"; |
| 41 | import { inPalette, isPalette, KINDS, nameOf, PROMPT_MAX } from "./glyph.ts"; | |
| 41 | 42 | import { events } from "./live.ts"; |
| 42 | 43 | import { rpc } from "./mcp.ts"; |
| 43 | 44 | import { home } from "./page.ts"; |
| @@ -75,29 +76,33 @@ export interface Route { | ||
| 75 | 76 | } |
| 76 | 77 | |
| 77 | 78 | // The shapes the API speaks in, named once. |
| 78 | export const NOTE: Schema = { | |
| 79 | export const DRAWING: Schema = { | |
| 79 | 80 | type: "object", |
| 80 | 81 | properties: { |
| 81 | 82 | id: { type: "integer" }, |
| 82 | body: { type: "string", minLength: 1, maxLength: BODY_MAX }, | |
| 83 | prompt: { type: "string", minLength: 1, maxLength: PROMPT_MAX }, | |
| 84 | kind: { enum: Object.keys(KINDS) }, | |
| 85 | status: { enum: ["drawing", "done", "failed"] }, | |
| 86 | svgs: { | |
| 87 | type: "array", | |
| 88 | items: { type: "string" }, | |
| 89 | description: "The drawings, as SVG, in the placeholder ink and glow colours.", | |
| 90 | }, | |
| 91 | error: { type: ["string", "null"] }, | |
| 83 | 92 | author_id: { type: "string" }, |
| 84 | 93 | author_name: { type: "string" }, |
| 85 | 94 | created_at: { type: "string", format: "date-time" }, |
| 86 | 95 | updated_at: { type: "string", format: "date-time" }, |
| 87 | 96 | }, |
| 88 | required: [ | |
| 89 | "id", | |
| 90 | "body", | |
| 91 | "author_id", | |
| 92 | "author_name", | |
| 93 | "created_at", | |
| 94 | "updated_at", | |
| 95 | ], | |
| 97 | required: ["id", "prompt", "kind", "status", "svgs", "author_id", "author_name", "created_at", "updated_at"], | |
| 96 | 98 | }; |
| 97 | const NOTE_BODY: Schema = { | |
| 99 | const DRAW_BODY: Schema = { | |
| 98 | 100 | type: "object", |
| 99 | properties: { body: { type: "string", minLength: 1, maxLength: BODY_MAX } }, | |
| 100 | required: ["body"], | |
| 101 | properties: { | |
| 102 | prompt: { type: "string", minLength: 1, maxLength: PROMPT_MAX }, | |
| 103 | kind: { enum: Object.keys(KINDS), default: "mark" }, | |
| 104 | }, | |
| 105 | required: ["prompt"], | |
| 101 | 106 | }; |
| 102 | 107 | export const AUDIT_ROW: Schema = { |
| 103 | 108 | type: "object", |
| @@ -133,8 +138,14 @@ const STATUS: Record<Failure, number> = { | ||
| 133 | 138 | forbidden: 403, |
| 134 | 139 | not_found: 404, |
| 135 | 140 | invalid: 422, |
| 141 | busy: 429, | |
| 136 | 142 | }; |
| 137 | 143 | |
| 144 | /** A drawing as the API speaks it: its SVGs as an array, not the column's JSON. */ | |
| 145 | const shaped = (d: Drawing) => ({ ...d, svgs: svgsOf(d) }); | |
| 146 | const shapedResult = <T extends Drawing | Drawing[]>(r: Result<T>): Result<unknown> => | |
| 147 | r.ok ? { ok: true, value: Array.isArray(r.value) ? r.value.map(shaped) : shaped(r.value as Drawing) } : r; | |
| 148 | ||
| 138 | 149 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ |
| 139 | 150 | const answer = <T>(result: Result<T>, status = 200): Response => |
| 140 | 151 | result.ok |
| @@ -157,15 +168,20 @@ const OPENAPI = new URL("../contract/openapi.json", import.meta.url); | ||
| 157 | 168 | const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined; |
| 158 | 169 | |
| 159 | 170 | export const routes: readonly Route[] = [ |
| 160 | // The page. Forms post and come back; the list stays live over /events. | |
| 171 | // The page. Forms post and come back; the board stays live over /events. | |
| 161 | 172 | { |
| 162 | 173 | method: "GET", |
| 163 | 174 | path: "/", |
| 164 | 175 | handle: (c) => { |
| 165 | 176 | const { notice, clear } = readNotice(c.request); |
| 166 | const notes = listNotes(c.db, c.who); | |
| 177 | const drawings = listDrawings(c.db, c.who); | |
| 167 | 178 | return html( |
| 168 | home({ who: c.who, notes: notes.ok ? notes.value : [], notice }), | |
| 179 | home({ | |
| 180 | who: c.who, | |
| 181 | drawings: drawings.ok ? drawings.value : [], | |
| 182 | palette: c.url.searchParams.get("palette"), | |
| 183 | notice, | |
| 184 | }), | |
| 169 | 185 | 200, |
| 170 | 186 | clear, |
| 171 | 187 | ); |
| @@ -173,82 +189,102 @@ export const routes: readonly Route[] = [ | ||
| 173 | 189 | }, |
| 174 | 190 | { |
| 175 | 191 | method: "POST", |
| 176 | path: "/notes", | |
| 192 | path: "/drawings", | |
| 177 | 193 | handle: async (c) => { |
| 178 | 194 | const form = await readForm(c.request); |
| 179 | 195 | if (form === undefined) return text("too large", 413); |
| 180 | 196 | return done( |
| 181 | addNote(c.db, c.who, { body: String(form.get("body") ?? "") }), | |
| 197 | startDrawing(c.db, c.who, { | |
| 198 | prompt: String(form.get("prompt") ?? ""), | |
| 199 | kind: String(form.get("kind") ?? ""), | |
| 200 | }), | |
| 182 | 201 | ); |
| 183 | 202 | }, |
| 184 | 203 | }, |
| 185 | 204 | { |
| 186 | 205 | method: "POST", |
| 187 | path: "/notes/:id/delete", | |
| 188 | handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 206 | path: "/drawings/:id/delete", | |
| 207 | handle: (c) => done(deleteDrawing(c.db, c.who, { id: Number(c.params.id) })), | |
| 208 | }, | |
| 209 | // One SVG to save, in a palette: a file with a name, never rendered here. | |
| 210 | { | |
| 211 | method: "GET", | |
| 212 | path: "/drawings/:id/:n", | |
| 213 | handle: (c) => { | |
| 214 | const found = getDrawing(c.db, c.who, { id: Number(c.params.id) }); | |
| 215 | if (!found.ok) return answer(found); | |
| 216 | const n = Number(c.params.n?.replace(/\.svg$/, "")); | |
| 217 | const svg = Number.isInteger(n) ? svgsOf(found.value)[n] : undefined; | |
| 218 | if (svg === undefined) return text("not found", 404); | |
| 219 | const palette = c.url.searchParams.get("palette"); | |
| 220 | const body = isPalette(palette) ? inPalette(svg, palette) : svg; | |
| 221 | const file = `${nameOf(svg).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "glyph"}.svg`; | |
| 222 | return new Response(body, { | |
| 223 | headers: { | |
| 224 | "content-type": "image/svg+xml; charset=utf-8", | |
| 225 | "content-disposition": `attachment; filename="${file}"`, | |
| 226 | "cache-control": "private, no-store", | |
| 227 | ...SECURITY, | |
| 228 | // A saved file is opened on its own one day: it may run nothing. | |
| 229 | "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", | |
| 230 | }, | |
| 231 | }); | |
| 232 | }, | |
| 189 | 233 | }, |
| 190 | 234 | |
| 191 | 235 | // The JSON API: the same actions, the same identity, plain status codes. |
| 192 | 236 | { |
| 193 | 237 | method: "GET", |
| 194 | path: "/api/notes", | |
| 238 | path: "/api/drawings", | |
| 195 | 239 | handle: (c) => |
| 196 | 240 | answer( |
| 197 | listNotes(c.db, c.who, { | |
| 198 | limit: Number(c.url.searchParams.get("limit") ?? 100), | |
| 199 | }), | |
| 241 | shapedResult( | |
| 242 | listDrawings(c.db, c.who, { limit: Number(c.url.searchParams.get("limit") ?? 50) }), | |
| 243 | ), | |
| 200 | 244 | ), |
| 201 | 245 | api: { |
| 202 | summary: "The notes, newest first.", | |
| 203 | query: { limit: LIMIT }, | |
| 204 | answer: list(NOTE), | |
| 246 | summary: "Your drawings, newest first (an admin sees everyone's).", | |
| 247 | query: { limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }, | |
| 248 | answer: list(DRAWING), | |
| 205 | 249 | }, |
| 206 | 250 | }, |
| 207 | 251 | { |
| 208 | 252 | method: "POST", |
| 209 | path: "/api/notes", | |
| 253 | path: "/api/drawings", | |
| 210 | 254 | handle: async (c) => { |
| 211 | 255 | const body = await readJson(c.request); |
| 212 | 256 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 257 | const f = fields(body); | |
| 213 | 258 | return answer( |
| 214 | addNote(c.db, c.who, { body: String(fields(body).body ?? "") }), | |
| 215 | 201, | |
| 259 | shapedResult( | |
| 260 | startDrawing(c.db, c.who, { | |
| 261 | prompt: String(f.prompt ?? ""), | |
| 262 | ...(f.kind === undefined ? {} : { kind: String(f.kind) }), | |
| 263 | }), | |
| 264 | ), | |
| 265 | 202, | |
| 216 | 266 | ); |
| 217 | 267 | }, |
| 218 | 268 | api: { |
| 219 | summary: "Add a note as the person asking.", | |
| 220 | body: NOTE_BODY, | |
| 221 | answer: NOTE, | |
| 222 | status: 201, | |
| 269 | summary: | |
| 270 | "Ask Claude for a drawing. Answers at once with status `drawing`; read it again (or listen on /events for `drawing.changed`) until it is `done` or `failed`. One at a time per person.", | |
| 271 | body: DRAW_BODY, | |
| 272 | answer: DRAWING, | |
| 273 | status: 202, | |
| 223 | 274 | }, |
| 224 | 275 | }, |
| 225 | 276 | { |
| 226 | 277 | 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 }, | |
| 278 | path: "/api/drawings/:id", | |
| 279 | handle: (c) => answer(shapedResult(getDrawing(c.db, c.who, { id: Number(c.params.id) }))), | |
| 280 | api: { summary: "One drawing.", answer: DRAWING }, | |
| 245 | 281 | }, |
| 246 | 282 | { |
| 247 | 283 | method: "DELETE", |
| 248 | path: "/api/notes/:id", | |
| 249 | handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 284 | path: "/api/drawings/:id", | |
| 285 | handle: (c) => answer(deleteDrawing(c.db, c.who, { id: Number(c.params.id) })), | |
| 250 | 286 | api: { |
| 251 | summary: "Remove a note.", | |
| 287 | summary: "Remove a drawing.", | |
| 252 | 288 | answer: { |
| 253 | 289 | type: "object", |
| 254 | 290 | properties: { id: { type: "integer" } }, |
src/security.test.ts +41 −32
| @@ -36,24 +36,24 @@ const as = (id: string, role: "user" | "admin"): Record<string, string> => { | ||
| 36 | 36 | }; |
| 37 | 37 | |
| 38 | 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", | |
| 39 | expect(doorOf("GET", "/api/drawings")).toEqual({ | |
| 40 | resource: "drawing", | |
| 41 | 41 | verb: "list", |
| 42 | 42 | }); |
| 43 | expect(doorOf("POST", "/api/notes")).toEqual({ | |
| 44 | resource: "note", | |
| 43 | expect(doorOf("POST", "/api/drawings")).toEqual({ | |
| 44 | resource: "drawing", | |
| 45 | 45 | verb: "create", |
| 46 | 46 | }); |
| 47 | expect(doorOf("GET", "/api/notes/7")).toEqual({ | |
| 48 | resource: "note", | |
| 47 | expect(doorOf("GET", "/api/drawings/7")).toEqual({ | |
| 48 | resource: "drawing", | |
| 49 | 49 | verb: "read", |
| 50 | 50 | }); |
| 51 | expect(doorOf("PATCH", "/api/notes/7")).toEqual({ | |
| 52 | resource: "note", | |
| 51 | expect(doorOf("PATCH", "/api/drawings/7")).toEqual({ | |
| 52 | resource: "drawing", | |
| 53 | 53 | verb: "update", |
| 54 | 54 | }); |
| 55 | expect(doorOf("DELETE", "/api/notes/7")).toEqual({ | |
| 56 | resource: "note", | |
| 55 | expect(doorOf("DELETE", "/api/drawings/7")).toEqual({ | |
| 56 | resource: "drawing", | |
| 57 | 57 | verb: "delete", |
| 58 | 58 | }); |
| 59 | 59 | expect(doorOf("GET", "/api/audit")).toEqual({ |
| @@ -61,11 +61,11 @@ test("a path is a resource's door by the contract's api, and nothing else is", ( | ||
| 61 | 61 | verb: "list", |
| 62 | 62 | }); |
| 63 | 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"], | |
| 64 | ["PUT", "/api/drawings"], | |
| 65 | ["GET", "/api/drawings/7/x"], | |
| 66 | ["GET", "/api/drawingsy"], | |
| 67 | ["GET", "/api/drawings/"], | |
| 68 | ["POST", "/drawings"], | |
| 69 | 69 | ] as const) |
| 70 | 70 | expect(doorOf(method, path)).toBeUndefined(); |
| 71 | 71 | }); |
| @@ -74,11 +74,15 @@ test("the door refuses only what no row would open", () => { | ||
| 74 | 74 | const anon = { role: "anon" } as const; |
| 75 | 75 | const alice = { role: "user", id: "u1", name: "alice" } as const; |
| 76 | 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); | |
| 77 | // Drawings are their asker's: a stranger has no door to any of them. | |
| 78 | expect(mayKnock(anon, "drawing", "list")).toBe(false); | |
| 79 | expect(mayKnock(anon, "drawing", "create")).toBe(false); | |
| 80 | expect(mayKnock(anon, "drawing", "delete")).toBe(false); | |
| 81 | expect(mayKnock(alice, "drawing", "list")).toBe(true); | |
| 82 | expect(mayKnock(alice, "drawing", "create")).toBe(true); | |
| 83 | // alice may delete a drawing of her own, so the door lets her ask; the action decides which. | |
| 84 | expect(mayKnock(alice, "drawing", "delete")).toBe(true); | |
| 85 | expect(mayKnock(alice, "drawing", "update")).toBe(false); | |
| 82 | 86 | expect(mayKnock(alice, "audit", "list")).toBe(false); |
| 83 | 87 | expect(mayKnock(root, "audit", "list")).toBe(true); |
| 84 | 88 | expect(mayKnock(root, "audit", "create")).toBe(false); |
| @@ -87,13 +91,13 @@ test("the door refuses only what no row would open", () => { | ||
| 87 | 91 | test("a stranger is refused /api before the body is read, when the contract closes it", async () => { |
| 88 | 92 | // A body past the cap: read, it would be 413. The door answers first. |
| 89 | 93 | const huge = "x".repeat(128 * 1024); |
| 90 | const refused = await fetch(`${base}/api/notes`, { | |
| 94 | const refused = await fetch(`${base}/api/drawings`, { | |
| 91 | 95 | method: "POST", |
| 92 | 96 | headers: { "content-type": "application/json" }, |
| 93 | 97 | body: huge, |
| 94 | 98 | }); |
| 95 | 99 | expect(refused.status).toBe(401); |
| 96 | const signedIn = await fetch(`${base}/api/notes`, { | |
| 100 | const signedIn = await fetch(`${base}/api/drawings`, { | |
| 97 | 101 | method: "POST", |
| 98 | 102 | headers: { "content-type": "application/json", ...as("u1", "user") }, |
| 99 | 103 | body: huge, |
| @@ -108,10 +112,13 @@ test("a stranger is refused /api before the body is read, when the contract clos | ||
| 108 | 112 | expect( |
| 109 | 113 | (await fetch(`${base}/api/audit`, { headers: as("u1", "user") })).status, |
| 110 | 114 | ).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 | // A stranger has no door to a drawing, nor to the description of doors they cannot use. | |
| 116 | expect((await fetch(`${base}/api/drawings`)).status).toBe(401); | |
| 117 | expect((await fetch(`${base}/openapi.json`)).status).toBe(401); | |
| 118 | // What the table opens to a signed-in person stays open. | |
| 119 | expect((await fetch(`${base}/api/drawings`, { headers: as("u1", "user") })).status).toBe(200); | |
| 120 | expect((await fetch(`${base}/api/drawings/0`, { headers: as("u1", "user") })).status).toBe(404); | |
| 121 | expect((await fetch(`${base}/openapi.json`, { headers: as("u1", "user") })).status).toBe(200); | |
| 115 | 122 | }); |
| 116 | 123 | |
| 117 | 124 | test("the API's description is shut to strangers when the table opens them no door", () => { |
| @@ -122,8 +129,8 @@ test("the API's description is shut to strangers when the table opens them no do | ||
| 122 | 129 | user: { above: ["anon"], held: { by: "identity", role: "user" } }, |
| 123 | 130 | }, |
| 124 | 131 | resources: { |
| 125 | note: { | |
| 126 | api: "/api/notes", | |
| 132 | drawing: { | |
| 133 | api: "/api/drawings", | |
| 127 | 134 | can: { |
| 128 | 135 | create: "user", |
| 129 | 136 | read: "user", |
| @@ -137,7 +144,8 @@ test("the API's description is shut to strangers when the table opens them no do | ||
| 137 | 144 | ); |
| 138 | 145 | if (!closed.ok) throw new Error(closed.problems.join("; ")); |
| 139 | 146 | expect(openToStrangers(closed.contract)).toBe(false); |
| 140 | expect(openToStrangers()).toBe(true); | |
| 147 | // This app's own table opens a stranger nothing. | |
| 148 | expect(openToStrangers()).toBe(false); | |
| 141 | 149 | }); |
| 142 | 150 | |
| 143 | 151 | test("strangers are rationed at the API, reads too", () => { |
| @@ -145,13 +153,14 @@ test("strangers are rationed at the API, reads too", () => { | ||
| 145 | 153 | const knock = () => |
| 146 | 154 | door( |
| 147 | 155 | db, |
| 148 | new Request("http://app/api/notes"), | |
| 149 | new URL("http://app/api/notes"), | |
| 156 | new Request("http://app/api/drawings"), | |
| 157 | new URL("http://app/api/drawings"), | |
| 150 | 158 | { role: "anon" }, |
| 151 | 159 | "203.0.113.7", |
| 152 | 160 | ); |
| 153 | 161 | const answers = Array.from({ length: STRANGERS.burst + 5 }, knock); |
| 154 | expect(answers.slice(0, STRANGERS.burst).every((a) => a === undefined)).toBe( | |
| 162 | // Within the burst the contract answers (a stranger is asked to sign in); past it, the ration. | |
| 163 | expect(answers.slice(0, STRANGERS.burst).every((a) => a?.status === 401)).toBe( | |
| 155 | 164 | true, |
| 156 | 165 | ); |
| 157 | 166 | expect(answers.at(-1)?.status).toBe(429); |
src/server.test.ts +123 −69
| @@ -2,7 +2,9 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { setDrawer, settled } from "./draw.ts"; | |
| 5 | 6 | import { env } from "./env.ts"; |
| 7 | import { GLOW, INK, PALETTES } from "./glyph.ts"; | |
| 6 | 8 | import { serve } from "./server.ts"; |
| 7 | 9 | import { sign } from "./who.ts"; |
| 8 | 10 | |
| @@ -48,22 +50,30 @@ const rpc = (message: unknown, who: Record<string, string> = {}) => | ||
| 48 | 50 | (r) => r.json() as Promise<any>, |
| 49 | 51 | ); |
| 50 | 52 | |
| 53 | /** The drawer every test here uses: Claude is never called. */ | |
| 54 | const SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Test Mark</title><circle cx="32" cy="32" r="20" fill="${INK}" stroke="${GLOW}"/></svg>`; | |
| 55 | setDrawer(async (prompt) => { | |
| 56 | if (prompt.includes("fail")) throw new Error("Claude had a bad day."); | |
| 57 | return [SVG, SVG]; | |
| 58 | }); | |
| 59 | ||
| 51 | 60 | test("the page knows who you are and offers the right door", async () => { |
| 52 | 61 | const guest = await (await fetch(base + "/")).text(); |
| 53 | 62 | expect(guest).toContain("<h1>"); |
| 54 | 63 | expect(guest).toContain("/__forge/signin?to=/"); |
| 55 | 64 | expect(guest).toContain('href="/__forge/report"'); |
| 56 | expect(guest).not.toContain('action="/notes"'); | |
| 65 | expect(guest).not.toContain('action="/drawings"'); | |
| 57 | 66 | const signed = await (await api("/", {}, alice)).text(); |
| 58 | 67 | expect(signed).toContain("<b>alice</b>"); |
| 59 | 68 | expect(signed).toContain("/__forge/signout"); |
| 60 | expect(signed).toContain('action="/notes"'); | |
| 69 | expect(signed).toContain('action="/drawings"'); | |
| 61 | 70 | }); |
| 62 | 71 | |
| 63 | test("a form post adds as the person; a guest is sent to sign in", async () => { | |
| 72 | test("a form post draws as the person; a guest is sent to sign in; the board shows it", async () => { | |
| 64 | 73 | const form = new FormData(); |
| 65 | form.set("body", "hello from a form"); | |
| 66 | const refused = await api("/notes", { | |
| 74 | form.set("prompt", "hello from a form"); | |
| 75 | form.set("kind", "icon"); | |
| 76 | const refused = await api("/drawings", { | |
| 67 | 77 | method: "POST", |
| 68 | 78 | body: form, |
| 69 | 79 | redirect: "manual", |
| @@ -71,56 +81,102 @@ test("a form post adds as the person; a guest is sent to sign in", async () => { | ||
| 71 | 81 | expect(refused.status).toBe(303); |
| 72 | 82 | expect(refused.headers.get("set-cookie")).toContain("notice="); |
| 73 | 83 | 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 | 84 | const posted = await api( |
| 78 | "/notes", | |
| 85 | "/drawings", | |
| 79 | 86 | { method: "POST", body: form, redirect: "manual" }, |
| 80 | 87 | alice, |
| 81 | 88 | ); |
| 82 | 89 | expect(posted.status).toBe(303); |
| 83 | 90 | expect(posted.headers.get("set-cookie")).toBeNull(); |
| 91 | await settled(); | |
| 84 | 92 | const page = await (await api("/", {}, alice)).text(); |
| 85 | 93 | expect(page).toContain("hello from a form"); |
| 86 | expect(page).toContain("alice ·"); | |
| 94 | expect(page).toContain("app icon"); | |
| 95 | expect(page).toContain('alt="Test Mark"'); | |
| 96 | expect(page).toContain("data:image/svg+xml,"); | |
| 97 | // Nobody else sees alice's drawing. | |
| 98 | expect(await (await api("/", {}, bob)).text()).not.toContain("hello from a form"); | |
| 99 | // A palette in the address is the palette the tiles are drawn in. | |
| 100 | const neon = await (await api("/?palette=neon", {}, alice)).text(); | |
| 101 | expect(neon).toContain('data-palette="neon" aria-current="true"'); | |
| 102 | expect(neon).toContain(encodeURIComponent(PALETTES.neon!.ink)); | |
| 87 | 103 | }); |
| 88 | 104 | |
| 89 | 105 | test("the JSON API is the same actions with the same identity", async () => { |
| 90 | 106 | const created = await api( |
| 91 | "/api/notes", | |
| 92 | { method: "POST", ...jsonBody({ body: "over the api" }) }, | |
| 107 | "/api/drawings", | |
| 108 | { method: "POST", ...jsonBody({ prompt: "over the api", kind: "badge" }) }, | |
| 93 | 109 | alice, |
| 94 | 110 | ); |
| 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); | |
| 111 | expect(created.status).toBe(202); | |
| 112 | const drawing = (await created.json()) as { id: number; author_name: string; status: string; svgs: string[] }; | |
| 113 | expect(drawing.author_name).toBe("alice"); | |
| 114 | expect(drawing.status).toBe("drawing"); | |
| 115 | expect(drawing.svgs).toEqual([]); | |
| 116 | await settled(); | |
| 117 | const done = (await (await api(`/api/drawings/${drawing.id}`, {}, alice)).json()) as { status: string; svgs: string[] }; | |
| 118 | expect(done.status).toBe("done"); | |
| 119 | expect(done.svgs).toEqual([SVG, SVG]); | |
| 120 | const listed = (await (await api("/api/drawings", {}, alice)).json()) as { id: number }[]; | |
| 121 | expect(listed.map((n) => n.id)).toContain(drawing.id); | |
| 122 | expect((await (await api("/api/drawings", {}, bob)).json()) as unknown[]).toEqual([]); | |
| 123 | expect((await api(`/api/drawings/${drawing.id}`, {}, bob)).status).toBe(403); | |
| 102 | 124 | expect( |
| 103 | (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) })) | |
| 104 | .status, | |
| 125 | (await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "anon" }) })).status, | |
| 105 | 126 | ).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); | |
| 127 | expect((await api(`/api/drawings/${drawing.id}`, { method: "DELETE" }, bob)).status).toBe(403); | |
| 128 | expect((await api(`/api/drawings/${drawing.id}`, { method: "DELETE" }, alice)).status).toBe(200); | |
| 129 | expect((await api(`/api/drawings/${drawing.id}`, { method: "DELETE" }, alice)).status).toBe(404); | |
| 130 | for (const body of [{ prompt: "" }, { prompt: "ok", kind: "poster" }]) | |
| 131 | expect( | |
| 132 | (await api("/api/drawings", { method: "POST", ...jsonBody(body) }, alice)).status, | |
| 133 | ).toBe(422); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a failed drawing says why, and a busy person is asked to wait", async () => { | |
| 137 | const carol = as("u3", "carol"); | |
| 138 | let release = (): void => {}; | |
| 139 | setDrawer(() => new Promise((resolve) => (release = () => resolve([SVG])))); | |
| 140 | try { | |
| 141 | const first = await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "slow" }) }, carol); | |
| 142 | expect(first.status).toBe(202); | |
| 143 | const second = await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "again" }) }, carol); | |
| 144 | expect(second.status).toBe(429); | |
| 145 | expect(((await second.json()) as { error: string }).error).toBe("busy"); | |
| 146 | release(); | |
| 147 | await settled(); | |
| 148 | } finally { | |
| 149 | setDrawer(async (prompt) => { | |
| 150 | if (prompt.includes("fail")) throw new Error("Claude had a bad day."); | |
| 151 | return [SVG, SVG]; | |
| 152 | }); | |
| 153 | } | |
| 154 | const failed = (await ( | |
| 155 | await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "please fail" }) }, carol) | |
| 156 | ).json()) as { id: number }; | |
| 157 | await settled(); | |
| 158 | const row = (await (await api(`/api/drawings/${failed.id}`, {}, carol)).json()) as { status: string; error: string }; | |
| 159 | expect(row).toMatchObject({ status: "failed", error: "Claude had a bad day." }); | |
| 160 | expect(await (await api("/", {}, carol)).text()).toContain("Claude had a bad day."); | |
| 161 | }); | |
| 162 | ||
| 163 | test("a drawing saves as a file, in the palette asked for, and runs nothing", async () => { | |
| 164 | const made = (await ( | |
| 165 | await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "to save" }) }, alice) | |
| 166 | ).json()) as { id: number }; | |
| 167 | await settled(); | |
| 168 | const saved = await api(`/drawings/${made.id}/1.svg?palette=blueprint`, {}, alice); | |
| 169 | expect(saved.status).toBe(200); | |
| 170 | expect(saved.headers.get("content-type")).toContain("image/svg+xml"); | |
| 171 | expect(saved.headers.get("content-disposition")).toBe('attachment; filename="test-mark.svg"'); | |
| 172 | expect(saved.headers.get("content-security-policy")).toContain("sandbox"); | |
| 173 | const body = await saved.text(); | |
| 174 | expect(body).toContain(PALETTES.blueprint!.ink); | |
| 175 | expect(body).not.toContain(INK); | |
| 176 | expect((await api(`/drawings/${made.id}/0.svg`, {}, alice)).status).toBe(200); | |
| 177 | expect((await api(`/drawings/${made.id}/7.svg`, {}, alice)).status).toBe(404); | |
| 178 | expect((await api(`/drawings/${made.id}/0.svg`, {}, bob)).status).toBe(403); | |
| 179 | expect((await api(`/drawings/${made.id}/0.svg`)).status).toBe(401); | |
| 124 | 180 | }); |
| 125 | 181 | |
| 126 | 182 | test("the MCP server lists the tools and calls them as the caller", async () => { |
| @@ -133,9 +189,10 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 133 | 189 | expect(hello.result.capabilities).toEqual({ tools: {} }); |
| 134 | 190 | const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); |
| 135 | 191 | expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([ |
| 136 | "list_notes", | |
| 137 | "add_note", | |
| 138 | "delete_note", | |
| 192 | "draw", | |
| 193 | "get_drawing", | |
| 194 | "list_drawings", | |
| 195 | "delete_drawing", | |
| 139 | 196 | ]); |
| 140 | 197 | const call = (name: string, args: unknown, who = {}) => |
| 141 | 198 | rpc( |
| @@ -147,19 +204,18 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 147 | 204 | }, |
| 148 | 205 | who, |
| 149 | 206 | ); |
| 150 | const refused = await call("add_note", { body: "as nobody" }); | |
| 207 | const refused = await call("draw", { prompt: "as nobody" }); | |
| 151 | 208 | expect(refused.result.isError).toBe(true); |
| 152 | const added = await call("add_note", { body: "over mcp" }, root); | |
| 209 | const added = await call("draw", { prompt: "over mcp" }, root); | |
| 153 | 210 | 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(); | |
| 211 | const drawing = JSON.parse(added.result.content[0].text); | |
| 212 | expect(drawing.author_name).toBe("root"); | |
| 213 | await settled(); | |
| 214 | const got = JSON.parse((await call("get_drawing", { id: drawing.id }, root)).result.content[0].text); | |
| 215 | expect(got.svgs).toEqual([SVG, SVG]); | |
| 216 | const mine = JSON.parse((await call("list_drawings", {}, root)).result.content[0].text); | |
| 217 | expect(mine.map((n: { id: number }) => n.id)).toContain(drawing.id); | |
| 218 | expect((await call("delete_drawing", { id: drawing.id }, root)).result.isError).toBeUndefined(); | |
| 163 | 219 | expect( |
| 164 | 220 | (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code, |
| 165 | 221 | ).toBe(-32601); |
| @@ -184,13 +240,14 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 184 | 240 | } |
| 185 | 241 | }; |
| 186 | 242 | await api( |
| 187 | "/api/notes", | |
| 188 | { method: "POST", ...jsonBody({ body: "live" }) }, | |
| 189 | alice, | |
| 243 | "/api/drawings", | |
| 244 | { method: "POST", ...jsonBody({ prompt: "live" }) }, | |
| 245 | as("u4", "dana"), | |
| 190 | 246 | ); |
| 191 | 247 | const heard = await next(); |
| 192 | expect(heard.type).toBe("note.added"); | |
| 248 | expect(heard.type).toBe("drawing.added"); | |
| 193 | 249 | await reader.cancel(); |
| 250 | await settled(); | |
| 194 | 251 | const late = await fetch(base + "/events", { |
| 195 | 252 | headers: { "last-event-id": String(Number(heard.id) - 1) }, |
| 196 | 253 | }); |
| @@ -198,22 +255,19 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 198 | 255 | (await late.body!.getReader().read()).value, |
| 199 | 256 | ); |
| 200 | 257 | expect(replay).toContain(`id: ${heard.id}\n`); |
| 201 | expect(replay).toContain("note.added"); | |
| 258 | expect(replay).toContain("drawing.added"); | |
| 259 | expect(replay).toContain("drawing.changed"); | |
| 202 | 260 | }); |
| 203 | 261 | |
| 204 | 262 | test("writes are rationed per person", async () => { |
| 205 | 263 | const flood = as("u9", "flood"); |
| 206 | 264 | const answers = await Promise.all( |
| 207 | 265 | Array.from({ length: 40 }, () => |
| 208 | api( | |
| 209 | "/api/notes", | |
| 210 | { method: "POST", ...jsonBody({ body: "again" }) }, | |
| 211 | flood, | |
| 212 | ), | |
| 266 | api("/api/drawings/999999", { method: "DELETE" }, flood), | |
| 213 | 267 | ), |
| 214 | 268 | ); |
| 215 | 269 | const codes = answers.map((r) => r.status); |
| 216 | expect(codes).toContain(201); | |
| 270 | expect(codes).toContain(404); | |
| 217 | 271 | expect(codes).toContain(429); |
| 218 | 272 | }); |
| 219 | 273 | |
| @@ -224,17 +278,17 @@ test("stands on the security floor", async () => { | ||
| 224 | 278 | ); |
| 225 | 279 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 226 | 280 | expect( |
| 227 | (await fetch(base + "/api/notes")).headers.get("content-security-policy"), | |
| 281 | (await fetch(base + "/api/drawings")).headers.get("content-security-policy"), | |
| 228 | 282 | ).toContain("default-src"); |
| 229 | 283 | }); |
| 230 | 284 | |
| 231 | 285 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 232 | 286 | const response = await api( |
| 233 | "/notes", | |
| 287 | "/drawings", | |
| 234 | 288 | { |
| 235 | 289 | method: "POST", |
| 236 | 290 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 237 | body: "body=" + "x".repeat(1024 * 1024), | |
| 291 | body: "prompt=" + "x".repeat(1024 * 1024), | |
| 238 | 292 | }, |
| 239 | 293 | alice, |
| 240 | 294 | ); |
| @@ -249,9 +303,9 @@ test("HEAD sees the app up", async () => { | ||
| 249 | 303 | |
| 250 | 304 | test("refuses a cross-site post", async () => { |
| 251 | 305 | const form = new FormData(); |
| 252 | form.set("body", "from another site"); | |
| 306 | form.set("prompt", "from another site"); | |
| 253 | 307 | const posted = await api( |
| 254 | "/notes", | |
| 308 | "/drawings", | |
| 255 | 309 | { |
| 256 | 310 | method: "POST", |
| 257 | 311 | body: form, |
src/server.ts +7
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { openDb } from "./db.ts"; |
| 11 | import { drawerFromEnv, recover, setDrawer } from "./draw.ts"; | |
| 11 | 12 | import { dataDir, identityKey, title, where } from "./env.ts"; |
| 12 | 13 | import { html, text } from "./http.ts"; |
| 13 | 14 | import { serveSpan } from "./otel.ts"; |
| @@ -24,6 +25,8 @@ export interface ServeOptions { | ||
| 24 | 25 | |
| 25 | 26 | export function serve(options: ServeOptions) { |
| 26 | 27 | const db = openDb(options.dataDir); |
| 28 | // A drawing the last process left half done will never finish: say so. | |
| 29 | recover(db); | |
| 27 | 30 | return Bun.serve({ |
| 28 | 31 | port: options.port, |
| 29 | 32 | hostname: options.hostname ?? "127.0.0.1", |
| @@ -78,6 +81,10 @@ const head = (request: Request, response: Response): Response => | ||
| 78 | 81 | : response; |
| 79 | 82 | |
| 80 | 83 | if (import.meta.main) { |
| 84 | // With no token the default drawer explains what to set; see src/draw.ts. | |
| 85 | const drawer = drawerFromEnv(dataDir); | |
| 86 | if (drawer !== undefined) setDrawer(drawer); | |
| 87 | else console.warn("CLAUDE_CODE_OAUTH_TOKEN is not set: drawings will ask for it"); | |
| 81 | 88 | const server = serve({ |
| 82 | 89 | port: Number(process.env.PORT ?? 8080), |
| 83 | 90 | hostname: process.env.HOST, |
src/tty.ts +45 −46
| @@ -1,22 +1,27 @@ | ||
| 1 | 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. | |
| 2 | * The app in a terminal. The forge runs this on a pty when someone `ssh`es | |
| 3 | * to the app, over the same data as the page and through the same actions, | |
| 4 | * as the account at the keyboard. A terminal cannot show a drawing, so it | |
| 5 | * keeps the list: j/k move, n asks for a new one, d removes the one under | |
| 6 | * the cursor, r reloads, q leaves. The page shows what was drawn. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import { |
| 9 | addNote, | |
| 10 | canDelete, | |
| 11 | deleteNote, | |
| 12 | listNotes, | |
| 13 | type Note, | |
| 10 | deleteDrawing, | |
| 11 | listDrawings, | |
| 12 | startDrawing, | |
| 13 | svgsOf, | |
| 14 | type Drawing, | |
| 14 | 15 | } from "./actions.ts"; |
| 15 | 16 | import { openDb } from "./db.ts"; |
| 16 | import { ssh, title, where } from "./env.ts"; | |
| 17 | import { drawerFromEnv, setDrawer } from "./draw.ts"; | |
| 18 | import { dataDir, publicOrigin, ssh, title, where } from "./env.ts"; | |
| 19 | import { nameOf } from "./glyph.ts"; | |
| 17 | 20 | import { whoAtTty } from "./who.ts"; |
| 18 | 21 | |
| 19 | 22 | const db = openDb(); |
| 23 | const drawer = drawerFromEnv(dataDir); | |
| 24 | if (drawer !== undefined) setDrawer(drawer); | |
| 20 | 25 | const me = whoAtTty(); |
| 21 | 26 | const esc = "\x1b["; |
| 22 | 27 | const dim = (s: string): string => `${esc}2m${s}${esc}0m`; |
| @@ -29,43 +34,36 @@ let cursor = 0; | ||
| 29 | 34 | let typing: string | undefined; |
| 30 | 35 | let said = ""; |
| 31 | 36 | |
| 32 | const notes = (): Note[] => { | |
| 33 | const listed = listNotes(db, me); | |
| 37 | const drawings = (): Drawing[] => { | |
| 38 | const listed = listDrawings(db, me); | |
| 34 | 39 | return listed.ok ? listed.value : []; |
| 35 | 40 | }; |
| 36 | 41 | |
| 42 | const state = (d: Drawing): string => | |
| 43 | d.status === "done" | |
| 44 | ? svgsOf(d).map(nameOf).join(" · ") | |
| 45 | : d.status === "drawing" | |
| 46 | ? "drawing…" | |
| 47 | : `failed: ${d.error ?? ""}`; | |
| 48 | ||
| 37 | 49 | function draw(): void { |
| 38 | const rows = notes(); | |
| 50 | const rows = drawings(); | |
| 39 | 51 | const lines = [ |
| 40 | 52 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`, |
| 41 | 53 | ssh === undefined ? "" : dim(`connect: ${ssh}`), |
| 54 | dim(`see them at ${publicOrigin}`), | |
| 42 | 55 | "", |
| 43 | 56 | ]; |
| 44 | 57 | 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 | ); | |
| 58 | lines.push(dim(me.role === "anon" ? "Sign in to draw." : "Nothing drawn yet. Press n.")); | |
| 59 | rows.forEach((d, i) => { | |
| 60 | const prompt = i === cursor ? bold(d.prompt) : d.prompt; | |
| 61 | lines.push(`${i === cursor ? "▸" : " "} ${prompt} ${dim(`${d.kind} · ${state(d)}`)}`); | |
| 57 | 62 | }); |
| 58 | 63 | lines.push(""); |
| 59 | if (typing !== undefined) lines.push(`New note: ${typing}${esc}7m ${esc}0m`); | |
| 64 | if (typing !== undefined) lines.push(`Draw: ${typing}${esc}7m ${esc}0m`); | |
| 60 | 65 | 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 | ); | |
| 66 | else lines.push(dim("j/k move · n new · d delete · r reload · q quit")); | |
| 69 | 67 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); |
| 70 | 68 | } |
| 71 | 69 | |
| @@ -73,12 +71,16 @@ process.stdin.setRawMode?.(true); | ||
| 73 | 71 | process.stdin.resume(); |
| 74 | 72 | process.stdin.setEncoding("utf8"); |
| 75 | 73 | draw(); |
| 74 | // Claude answers in this process too: redraw now and then while one is out. | |
| 75 | setInterval(() => { | |
| 76 | if (typing === undefined && drawings().some((d) => d.status === "drawing")) draw(); | |
| 77 | }, 3000); | |
| 76 | 78 | process.stdin.on("data", (key: string) => { |
| 77 | 79 | said = ""; |
| 78 | 80 | if (typing !== undefined) { |
| 79 | 81 | if (key === "\r") { |
| 80 | const added = addNote(db, me, { body: typing }); | |
| 81 | if (!added.ok) said = added.message; | |
| 82 | const started = startDrawing(db, me, { prompt: typing }); | |
| 83 | said = started.ok ? "Drawing… it shows here and on the page when Claude is done." : started.message; | |
| 82 | 84 | typing = undefined; |
| 83 | 85 | cursor = 0; |
| 84 | 86 | } else if (key === "\x1b") typing = undefined; |
| @@ -91,21 +93,18 @@ process.stdin.on("data", (key: string) => { | ||
| 91 | 93 | out(`${esc}2J${esc}H`); |
| 92 | 94 | process.exit(0); |
| 93 | 95 | } |
| 94 | const rows = notes(); | |
| 95 | if (key === "j" || key === "\x1b[B") | |
| 96 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); | |
| 96 | const rows = drawings(); | |
| 97 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); | |
| 97 | 98 | 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."; | |
| 99 | if (key === "n") { | |
| 100 | if (me.role === "anon") said = "Sign in to draw."; | |
| 100 | 101 | else typing = ""; |
| 101 | 102 | } |
| 102 | 103 | 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."; | |
| 104 | const d = rows[cursor]; | |
| 105 | if (d === undefined) said = "Nothing to remove."; | |
| 107 | 106 | else { |
| 108 | const removed = deleteNote(db, me, { id: note.id }); | |
| 107 | const removed = deleteDrawing(db, me, { id: d.id }); | |
| 109 | 108 | if (!removed.ok) said = removed.message; |
| 110 | 109 | cursor = Math.min(cursor, Math.max(0, rows.length - 2)); |
| 111 | 110 | } |
static/app.css +173 −1
| @@ -15,7 +15,7 @@ body { | ||
| 15 | 15 | font: var() / var() var(); |
| 16 | 16 | } |
| 17 | 17 | .page { |
| 18 | max-width: 40rem; | |
| 18 | max-width: 60rem; | |
| 19 | 19 | margin: 0 auto; |
| 20 | 20 | padding: var() var() var(); |
| 21 | 21 | } |
| @@ -111,6 +111,178 @@ footer code { | ||
| 111 | 111 | overflow-wrap: anywhere; |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | /* Drawing. */ | |
| 115 | .compose { | |
| 116 | display: grid; | |
| 117 | gap: var(); | |
| 118 | } | |
| 119 | .label { | |
| 120 | font-weight: 600; | |
| 121 | } | |
| 122 | .prompt { | |
| 123 | padding: var(); | |
| 124 | resize: vertical; | |
| 125 | line-height: var(); | |
| 126 | } | |
| 127 | .wrap { | |
| 128 | flex-wrap: wrap; | |
| 129 | } | |
| 130 | .tight { | |
| 131 | gap: var(); | |
| 132 | } | |
| 133 | .spacer { | |
| 134 | flex: 1; | |
| 135 | } | |
| 136 | .kinds { | |
| 137 | display: flex; | |
| 138 | flex-wrap: wrap; | |
| 139 | gap: var(); | |
| 140 | margin: 0; | |
| 141 | padding: 0; | |
| 142 | border: 0; | |
| 143 | } | |
| 144 | .examples { | |
| 145 | display: flex; | |
| 146 | flex-wrap: wrap; | |
| 147 | gap: var(); | |
| 148 | } | |
| 149 | .examples[hidden] { | |
| 150 | display: none; | |
| 151 | } | |
| 152 | .chip { | |
| 153 | display: inline-flex; | |
| 154 | align-items: center; | |
| 155 | gap: var(); | |
| 156 | min-height: var(); | |
| 157 | padding: 0 var(); | |
| 158 | font: inherit; | |
| 159 | font-size: var(); | |
| 160 | color: var(); | |
| 161 | background: var(); | |
| 162 | border: 1px solid var(); | |
| 163 | border-radius: var(); | |
| 164 | cursor: pointer; | |
| 165 | text-decoration: none; | |
| 166 | } | |
| 167 | .chip:hover { | |
| 168 | border-color: var(); | |
| 169 | } | |
| 170 | .chip:has(input:checked), | |
| 171 | .chip[aria-current] { | |
| 172 | border-color: var(); | |
| 173 | color: var(); | |
| 174 | font-weight: 600; | |
| 175 | } | |
| 176 | .chip input { | |
| 177 | accent-color: var(); | |
| 178 | margin: 0; | |
| 179 | } | |
| 180 | .palettes { | |
| 181 | margin-bottom: var(); | |
| 182 | } | |
| 183 | .dots { | |
| 184 | display: inline-flex; | |
| 185 | gap: 3px; | |
| 186 | padding: 4px; | |
| 187 | border-radius: var(); | |
| 188 | border: 1px solid var(); | |
| 189 | } | |
| 190 | .dots i { | |
| 191 | width: 10px; | |
| 192 | height: 10px; | |
| 193 | border-radius: 50%; | |
| 194 | } | |
| 195 | .drawing { | |
| 196 | padding: var() 0; | |
| 197 | border-top: 1px solid var(); | |
| 198 | } | |
| 199 | .drawing:first-child { | |
| 200 | border-top: 0; | |
| 201 | } | |
| 202 | .drawing-head { | |
| 203 | display: flex; | |
| 204 | justify-content: space-between; | |
| 205 | align-items: flex-start; | |
| 206 | gap: var(); | |
| 207 | margin-bottom: var(); | |
| 208 | } | |
| 209 | .prompt-text { | |
| 210 | font-weight: 600; | |
| 211 | overflow-wrap: anywhere; | |
| 212 | } | |
| 213 | .tiles { | |
| 214 | display: grid; | |
| 215 | grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| 216 | gap: var(); | |
| 217 | } | |
| 218 | @media (min-width: 720px) { | |
| 219 | .tiles { | |
| 220 | grid-template-columns: repeat(4, minmax(0, 1fr)); | |
| 221 | } | |
| 222 | } | |
| 223 | .tile-wrap { | |
| 224 | margin: 0; | |
| 225 | display: grid; | |
| 226 | gap: var(); | |
| 227 | } | |
| 228 | .tile { | |
| 229 | aspect-ratio: 1; | |
| 230 | border-radius: var(); | |
| 231 | border: 1px solid var(); | |
| 232 | display: grid; | |
| 233 | place-items: center; | |
| 234 | overflow: hidden; | |
| 235 | } | |
| 236 | .tile img { | |
| 237 | width: 72%; | |
| 238 | height: 72%; | |
| 239 | transition: transform 160ms ease; | |
| 240 | } | |
| 241 | .tile:hover img { | |
| 242 | transform: scale(1.06); | |
| 243 | } | |
| 244 | .tile.pending { | |
| 245 | animation: pulse 1.4s ease-in-out infinite; | |
| 246 | } | |
| 247 | @keyframes pulse { | |
| 248 | 50% { | |
| 249 | opacity: 0.45; | |
| 250 | } | |
| 251 | } | |
| 252 | figcaption { | |
| 253 | display: flex; | |
| 254 | flex-wrap: wrap; | |
| 255 | align-items: center; | |
| 256 | justify-content: space-between; | |
| 257 | gap: 0 var(); | |
| 258 | min-width: 0; | |
| 259 | } | |
| 260 | /* The name has its own line on a narrow tile; the buttons follow. */ | |
| 261 | figcaption .name { | |
| 262 | flex: 1 0 100%; | |
| 263 | padding-top: var(); | |
| 264 | } | |
| 265 | .name { | |
| 266 | font-size: var(); | |
| 267 | font-weight: 600; | |
| 268 | overflow: hidden; | |
| 269 | text-overflow: ellipsis; | |
| 270 | white-space: nowrap; | |
| 271 | } | |
| 272 | .small { | |
| 273 | font-size: var(); | |
| 274 | } | |
| 275 | .button.small { | |
| 276 | padding: 0 var(); | |
| 277 | } | |
| 278 | .error { | |
| 279 | color: var(); | |
| 280 | overflow-wrap: anywhere; | |
| 281 | } | |
| 282 | [hidden] { | |
| 283 | display: none ; | |
| 284 | } | |
| 285 | ||
| 114 | 286 | /* Controls: at least 44px on a side, keyboard and mouse alike. */ |
| 115 | 287 | .input { |
| 116 | 288 | flex: 1; |
static/glyph.js added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | // What only a script can add to the page; without it the page still draws, | |
| 2 | // saves and switches palettes by link. | |
| 3 | // - Copy: the SVG, in the palette on screen, onto the clipboard. | |
| 4 | // - Palettes swap in place (the address keeps the choice, so a live | |
| 5 | // refresh and a reload render the same one). | |
| 6 | // - The example prompts fill the box; a drawing in progress counts up. | |
| 7 | ||
| 8 | const board = () => document.getElementById("board"); | |
| 9 | ||
| 10 | async function swapIn(url) { | |
| 11 | const response = await fetch(url, { headers: { accept: "text/html" } }); | |
| 12 | const fresh = new DOMParser().parseFromString(await response.text(), "text/html"); | |
| 13 | for (const id of ["board", "palettes"]) { | |
| 14 | const next = fresh.getElementById(id); | |
| 15 | const here = document.getElementById(id); | |
| 16 | if (next && here) here.replaceWith(next); | |
| 17 | } | |
| 18 | wake(); | |
| 19 | } | |
| 20 | ||
| 21 | document.addEventListener("click", async (event) => { | |
| 22 | const swatch = event.target.closest("[data-palette]"); | |
| 23 | if (swatch) { | |
| 24 | event.preventDefault(); | |
| 25 | const url = new URL(location.href); | |
| 26 | url.searchParams.set("palette", swatch.dataset.palette); | |
| 27 | history.replaceState(null, "", url); | |
| 28 | try { | |
| 29 | localStorage.setItem("glyph.palette", swatch.dataset.palette); | |
| 30 | } catch {} | |
| 31 | await swapIn(url); | |
| 32 | return; | |
| 33 | } | |
| 34 | const copy = event.target.closest(".js-copy"); | |
| 35 | if (copy) { | |
| 36 | const said = copy.textContent; | |
| 37 | try { | |
| 38 | const svg = fetch(copy.dataset.href).then((r) => r.text()); | |
| 39 | // Safari keeps the click's permission only for a ClipboardItem given a promise. | |
| 40 | if (window.ClipboardItem) | |
| 41 | await navigator.clipboard.write([ | |
| 42 | new ClipboardItem({ "text/plain": svg.then((t) => new Blob([t], { type: "text/plain" })) }), | |
| 43 | ]); | |
| 44 | else await navigator.clipboard.writeText(await svg); | |
| 45 | copy.textContent = "Copied"; | |
| 46 | } catch { | |
| 47 | copy.textContent = "Couldn't copy"; | |
| 48 | } | |
| 49 | setTimeout(() => (copy.textContent = said), 1600); | |
| 50 | return; | |
| 51 | } | |
| 52 | const example = event.target.closest("[data-example]"); | |
| 53 | if (example) { | |
| 54 | const box = document.getElementById("prompt"); | |
| 55 | if (box) { | |
| 56 | box.value = example.dataset.example; | |
| 57 | box.focus(); | |
| 58 | } | |
| 59 | } | |
| 60 | }); | |
| 61 | ||
| 62 | // Enter draws; shift+enter is a new line. | |
| 63 | document.addEventListener("keydown", (event) => { | |
| 64 | if (event.target.id === "prompt" && event.key === "Enter" && !event.shiftKey) { | |
| 65 | event.preventDefault(); | |
| 66 | event.target.form.requestSubmit(); | |
| 67 | } | |
| 68 | }); | |
| 69 | ||
| 70 | function wake() { | |
| 71 | for (const el of document.querySelectorAll(".js-copy, .examples")) el.hidden = false; | |
| 72 | } | |
| 73 | ||
| 74 | // A drawing in progress says how long it has been at it. | |
| 75 | setInterval(() => { | |
| 76 | for (const el of document.querySelectorAll("[data-since]")) { | |
| 77 | const s = Math.max(0, Math.round((Date.now() - Date.parse(el.dataset.since)) / 1000)); | |
| 78 | el.textContent = `Claude is drawing… ${s}s`; | |
| 79 | } | |
| 80 | }, 1000); | |
| 81 | ||
| 82 | // live.js swaps the board when a drawing changes: wake what it brought. | |
| 83 | new MutationObserver(wake).observe(document.body, { childList: true, subtree: true }); | |
| 84 | ||
| 85 | // The palette last picked, when the address names none. | |
| 86 | try { | |
| 87 | const kept = localStorage.getItem("glyph.palette"); | |
| 88 | const url = new URL(location.href); | |
| 89 | if (kept && !url.searchParams.has("palette") && board()) { | |
| 90 | url.searchParams.set("palette", kept); | |
| 91 | history.replaceState(null, "", url); | |
| 92 | swapIn(url); | |
| 93 | } | |
| 94 | } catch {} | |
| 95 | wake(); |