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

Merged opened this in answer to

draw → main 1 commit

Review

Sign in to review this change.
25 files changed +1598 −555
README.md +49 −15
@@ -2,6 +2,36 @@
22
33 Type a prompt, get SVG logos and icons drawn by Claude; preview in your theme, copy or download.
44
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
535 This is an app: the forge runs it. Production follows the default branch;
636 every open pull gets a preview at its own address with its own copy of the
737 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.
2959
3060 | file | what it is |
3161 | ---------------------------------- | -------------------------------------------------------------- |
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 |
3366 | `contract.json` | who may do what: roles, and the least role each verb needs |
3467 | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim |
3568 | `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.
4679 | `src/server.ts` | boot: open the database, serve |
4780 | `src/tty.ts` | the terminal, over the same actions |
4881 | `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 |
5084
5185 ## What every app stands on
5286
@@ -77,10 +111,10 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
77111 The forge says who is asking on every request it proxies, and the app
78112 verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
79113
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.
84118
85119 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
86120 `/__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
93127 whose forge grant it carries; a bare script is anonymous.
94128
95129 ```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
99133 ```
100134
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`:
103137
104138 ```sh
105 claude mcp add --transport http __TITLE__ https://<app>/mcp
139 claude mcp add --transport http glyph https://<app>/mcp
106140 ```
107141
108142 ```json
109143 {
110 "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } }
144 "mcpServers": { "glyph": { "type": "http", "url": "https://<app>/mcp" } }
111145 }
112146 ```
113147
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
116150 reload behind.
117151
118152 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
contract.json +5 −5
@@ -6,14 +6,14 @@
66 "admin": { "above": ["author"], "held": { "by": "identity", "role": "admin" } }
77 },
88 "resources": {
9 "note": {
10 "api": "/api/notes",
9 "drawing": {
10 "api": "/api/drawings",
1111 "can": {
1212 "create": "user",
13 "read": "anon",
14 "update": "author",
13 "read": "author",
14 "update": "nobody",
1515 "delete": "author",
16 "list": "anon"
16 "list": "author"
1717 }
1818 },
1919 "audit": {
contract/openapi.json +72 −93
@@ -6,10 +6,10 @@
66 "description": "Type a prompt, get SVG logos and icons drawn by Claude; preview in your theme, copy or download."
77 },
88 "paths": {
9 "/api/notes": {
9 "/api/drawings": {
1010 "get": {
11 "summary": "The notes, newest first.",
12 "operationId": "getApiNotes",
11 "summary": "Your drawings, newest first (an admin sees everyone's).",
12 "operationId": "getApiDrawings",
1313 "parameters": [
1414 {
1515 "name": "limit",
@@ -17,20 +17,20 @@
1717 "schema": {
1818 "type": "integer",
1919 "minimum": 1,
20 "maximum": 500,
21 "default": 100
20 "maximum": 200,
21 "default": 50
2222 }
2323 }
2424 ],
2525 "responses": {
2626 "200": {
27 "description": "The notes, newest first.",
27 "description": "Your drawings, newest first (an admin sees everyone's).",
2828 "content": {
2929 "application/json": {
3030 "schema": {
3131 "type": "array",
3232 "items": {
33 "$ref": "#/components/schemas/Note"
33 "$ref": "#/components/schemas/Drawing"
3434 }
3535 }
3636 }
@@ -48,14 +48,14 @@
4848 }
4949 },
5050 "x-contract": {
51 "resource": "note",
51 "resource": "drawing",
5252 "verb": "list",
53 "least": "anon"
53 "least": "author"
5454 }
5555 },
5656 "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",
5959 "requestBody": {
6060 "required": true,
6161 "content": {
@@ -63,26 +63,35 @@
6363 "schema": {
6464 "type": "object",
6565 "properties": {
66 "body": {
66 "prompt": {
6767 "type": "string",
6868 "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"
7079 }
7180 },
7281 "required": [
73 "body"
82 "prompt"
7483 ]
7584 }
7685 }
7786 }
7887 },
7988 "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.",
8291 "content": {
8392 "application/json": {
8493 "schema": {
85 "$ref": "#/components/schemas/Note"
94 "$ref": "#/components/schemas/Drawing"
8695 }
8796 }
8897 }
@@ -99,16 +108,16 @@
99108 }
100109 },
101110 "x-contract": {
102 "resource": "note",
111 "resource": "drawing",
103112 "verb": "create",
104113 "least": "user"
105114 }
106115 }
107116 },
108 "/api/notes/{id}": {
117 "/api/drawings/{id}": {
109118 "get": {
110 "summary": "One note.",
111 "operationId": "getApiNotesId",
119 "summary": "One drawing.",
120 "operationId": "getApiDrawingsId",
112121 "parameters": [
113122 {
114123 "name": "id",
@@ -121,11 +130,11 @@
121130 ],
122131 "responses": {
123132 "200": {
124 "description": "One note.",
133 "description": "One drawing.",
125134 "content": {
126135 "application/json": {
127136 "schema": {
128 "$ref": "#/components/schemas/Note"
137 "$ref": "#/components/schemas/Drawing"
129138 }
130139 }
131140 }
@@ -142,75 +151,14 @@
142151 }
143152 },
144153 "x-contract": {
145 "resource": "note",
154 "resource": "drawing",
146155 "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",
208156 "least": "author"
209157 }
210158 },
211159 "delete": {
212 "summary": "Remove a note.",
213 "operationId": "deleteApiNotesId",
160 "summary": "Remove a drawing.",
161 "operationId": "deleteApiDrawingsId",
214162 "parameters": [
215163 {
216164 "name": "id",
@@ -223,7 +171,7 @@
223171 ],
224172 "responses": {
225173 "200": {
226 "description": "Remove a note.",
174 "description": "Remove a drawing.",
227175 "content": {
228176 "application/json": {
229177 "schema": {
@@ -252,7 +200,7 @@
252200 }
253201 },
254202 "x-contract": {
255 "resource": "note",
203 "resource": "drawing",
256204 "verb": "delete",
257205 "least": "author"
258206 }
@@ -463,16 +411,44 @@
463411 },
464412 "components": {
465413 "schemas": {
466 "Note": {
414 "Drawing": {
467415 "type": "object",
468416 "properties": {
469417 "id": {
470418 "type": "integer"
471419 },
472 "body": {
420 "prompt": {
473421 "type": "string",
474422 "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 ]
476452 },
477453 "author_id": {
478454 "type": "string"
@@ -491,7 +467,10 @@
491467 },
492468 "required": [
493469 "id",
494 "body",
470 "prompt",
471 "kind",
472 "status",
473 "svgs",
495474 "author_id",
496475 "author_name",
497476 "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 @@
11 {
2 "egress": ["api.anthropic.com", "claude.ai"],
23 "app": {
34 "run": "bun run src/server.ts",
45 "test": "bun test",
src/actions.test.ts +78 −34
@@ -2,8 +2,17 @@ import { expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 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";
614 import { openDb } from "./db.ts";
15 import { recover, setDrawer, settled } from "./draw.ts";
716 import type { Who } from "./who.ts";
817
918 const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-")));
@@ -11,48 +20,83 @@ const anon: Who = { role: "anon" };
1120 const alice: Who = { role: "user", id: "u1", name: "alice" };
1221 const bob: Who = { role: "user", id: "u2", name: "bob" };
1322 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]);
1425
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" });
1828 expect(refused.ok).toBe(false);
1929 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]);
2838 });
2939
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);
3343 expect(r.ok).toBe(false);
3444 if (!r.ok) expect(r.error).toBe("invalid");
3545 }
46 const icon = startDrawing(db, bob, { prompt: "ok", kind: "icon" });
47 expect(icon.ok && icon.value.kind).toBe("icon");
3648 });
3749
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" });
4053 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");
58102 });
src/actions.ts +103 −111
@@ -10,18 +10,30 @@ import type { Database } from "bun:sqlite";
1010 import { record, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
1212 import { emit } from "./live.ts";
13 import { kick } from "./draw.ts";
14 import { isKind, KINDS, PROMPT_MAX, type Kind } from "./glyph.ts";
1315 import type { Who } from "./who.ts";
1416
15 export interface Note {
17 export interface Drawing {
1618 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;
1825 readonly author_id: string;
1926 readonly author_name: string;
2027 readonly created_at: string;
2128 readonly updated_at: string;
2229 }
2330
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";
2537 export type Result<T> =
2638 | { ok: true; value: T }
2739 | { ok: false; error: Failure; message: string };
@@ -32,8 +44,6 @@ const fail = <T>(error: Failure, message: string): Result<T> => ({
3244 message,
3345 });
3446
35 export const BODY_MAX = 500;
36
3747 /** The refusal for a "no": who is not signed in is asked to; who is, is told. */
3848 const refuse = <T>(who: Who, signIn: string, no: string): Result<T> =>
3949 who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no);
@@ -61,158 +71,140 @@ const audited = <T extends { readonly id: number }>(
6171 return result;
6272 };
6373
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(
6588 db: Database,
6689 who: Who,
6790 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");
7896 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.");
8098 if (may === "yes")
8199 return ok(
82100 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")
84102 .all(limit),
85103 );
86104 const mine = may.where.map((column) => `${column} = ?2`).join(" OR ");
87105 return ok(
88106 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 )
93110 .all(limit, who.role === "anon" ? "" : who.id),
94111 );
95112 }
96113
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 = (
98119 db: Database,
99120 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));
103124
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);
120155 }
121156
122157 /**
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
124159 * looked for, so whoever may not is told that and never learns whether the
125160 * row exists; whoever may, and asked for nothing, hears `not_found`.
126161 */
127 function noteFor(
162 function drawingFor(
128163 db: Database,
129164 who: Who,
130 verb: "read" | "update" | "delete",
165 verb: "read" | "delete",
131166 id: number,
132167 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)
136171 : 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.
140172 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.");
148178 }
149179
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.",
158184 });
159185 }
160186
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 = (
192188 db: Database,
193189 who: Who,
194190 input: { id: number },
195191 ): 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));
197193
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.",
206198 });
207199 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 });
210202 return ok({ id: found.value.id });
211203 }
212204
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";
216208
217209 /**
218210 * 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";
77 import { mkdtempSync } from "node:fs";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
10 import { addNote } from "./actions.ts";
10 import { startDrawing } from "./actions.ts";
1111 import type { AuditRow } from "./audit.ts";
1212 import { openDb } from "./db.ts";
1313 import { serve } from "./server.ts";
@@ -50,27 +50,27 @@ const trail = async (): Promise<AuditRow[]> =>
5050
5151 test("an allowed change and a refused one each leave a row", async () => {
5252 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" }) },
5555 alice,
5656 );
57 expect(made.status).toBe(201);
57 expect(made.status).toBe(202);
5858 const note = (await made.json()) as { id: number };
5959 // Refused at the door: a stranger may create nothing.
6060 expect(
6161 (
62 await call("/api/notes", {
62 await call("/api/drawings", {
6363 method: "POST",
64 body: JSON.stringify({ body: "x" }),
64 body: JSON.stringify({ prompt: "x" }),
6565 })
6666 ).status,
6767 ).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.
6969 expect(
70 (await call(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status,
70 (await call(`/api/drawings/${note.id}`, { method: "DELETE" }, bob)).status,
7171 ).toBe(403);
7272 // 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}`, {
7474 method: "DELETE",
7575 headers: { ...alice, "sec-fetch-site": "cross-site" },
7676 });
@@ -93,7 +93,7 @@ test("an allowed change and a refused one each leave a row", async () => {
9393 {
9494 who_name: "alice",
9595 role: "user",
96 resource: "note",
96 resource: "drawing",
9797 verb: "create",
9898 target: String(note.id),
9999 allowed: 1,
@@ -102,7 +102,7 @@ test("an allowed change and a refused one each leave a row", async () => {
102102 {
103103 who_name: null,
104104 role: "anon",
105 resource: "note",
105 resource: "drawing",
106106 verb: "create",
107107 target: null,
108108 allowed: 0,
@@ -111,7 +111,7 @@ test("an allowed change and a refused one each leave a row", async () => {
111111 {
112112 who_name: "bob",
113113 role: "user",
114 resource: "note",
114 resource: "drawing",
115115 verb: "delete",
116116 target: String(note.id),
117117 allowed: 0,
@@ -120,7 +120,7 @@ test("an allowed change and a refused one each leave a row", async () => {
120120 {
121121 who_name: "alice",
122122 role: "user",
123 resource: "note",
123 resource: "drawing",
124124 verb: "delete",
125125 target: null,
126126 allowed: 0,
@@ -133,7 +133,7 @@ test("an allowed change and a refused one each leave a row", async () => {
133133
134134 test("reading is not an event: a GET leaves no row", async () => {
135135 const before = (await trail()).length;
136 await call("/api/notes");
136 await call("/api/drawings");
137137 await call("/", {}, alice);
138138 expect((await trail()).length).toBe(before);
139139 });
@@ -166,17 +166,17 @@ test("a trail that cannot be written is a log line, and the change still happens
166166 db.exec("DROP TABLE audit");
167167 const said = spyOn(console, "error").mockImplementation(() => {});
168168 try {
169 const added = addNote(
169 const added = startDrawing(
170170 db,
171171 { role: "user", id: "u1", name: "alice" },
172 { body: "still here" },
172 { prompt: "still here" },
173173 );
174174 expect(added.ok).toBe(true);
175175 expect(said).toHaveBeenCalledTimes(1);
176176 const line = JSON.parse(String(said.mock.calls[0]![0]));
177177 expect(line.audit).toMatchObject({
178178 who_name: "alice",
179 resource: "note",
179 resource: "drawing",
180180 verb: "create",
181181 allowed: 1,
182182 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 @@
11 /**
22 * 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
44 * someone else — and calls the real actions. A rule written by hand in an
55 * action, or a cell changed without its door, fails here.
66 */
@@ -9,18 +9,18 @@ import { mkdtempSync } from "node:fs";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import {
12 addNote,
13 deleteNote,
12 deleteDrawing,
1413 getAudit,
15 getNote,
14 getDrawing,
1615 listAudit,
17 listNotes,
18 updateNote,
16 listDrawings,
17 startDrawing,
1918 } from "./actions.ts";
2019 import { record } from "./audit.ts";
2120 import { actingAs, identities, table, type Verb } from "./can.ts";
2221 import { contract } from "./contract.ts";
2322 import { openDb } from "./db.ts";
23 import { setDrawer } from "./draw.ts";
2424 import type { Who } from "./who.ts";
2525
2626 const db = openDb(mkdtempSync(join(tmpdir(), "app-contract-")));
@@ -34,18 +34,22 @@ function whoIs(identity: string): Who {
3434 return { role, id: actor.id, name: identity };
3535 }
3636
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 =>
3840 db
3941 .query<
4042 { id: number },
4143 [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")
4345 .get(authorId)!.id;
46 /** Each create is a new person, so "one at a time" never answers for the table. */
47 let fresh = 0;
4448
4549 const auditRow = (): number => {
4650 record(db, {
4751 who: { role: "anon" },
48 resource: "note",
52 resource: "drawing",
4953 verb: "create",
5054 outcome: "signin_required",
5155 });
@@ -58,13 +62,18 @@ const doors: Record<
5862 string,
5963 Partial<Record<Verb, (who: Who, authorId: string) => boolean>>
6064 > = {
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,
6877 },
6978 // The trail is written by the app itself, never through a door: there
7079 // is no action that creates, changes or removes a row of it.
src/db.ts +16
@@ -38,6 +38,22 @@ export const migrations: string[] = [
3838 allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)),
3939 outcome TEXT NOT NULL
4040 )`,
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)`,
4157 ];
4258
4359 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 &amp; B <b>bold</b></title><circle cx="4" cy="4" r="2" fill="&quot;&gt;&lt;script&gt;"/></svg>`)!;
48 // An element inside a title goes with its content; the entity stays an entity.
49 expect(svg).toContain("<title>A &amp; B </title>");
50 expect(sanitize(`<svg viewBox="0 0 8 8"><title>R&D</title><circle r="1"/></svg>`)).toContain("<title>R&amp;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, "&amp;")
91 .replace(/</g, "&lt;")
92 .replace(/>/g, "&gt;");
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(/&amp;/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 @@
77 */
88
99 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";
1120 import { title } from "./env.ts";
1221 import { fields } from "./http.ts";
1322 import type { Who } from "./who.ts";
@@ -23,44 +32,67 @@ interface Tool {
2332 ) => Result<unknown>;
2433 }
2534
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
2646 export const tools: readonly Tool[] = [
2747 {
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.`,
3050 inputSchema: {
3151 type: "object",
3252 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)." },
3955 },
56 required: ["prompt"],
4057 },
4158 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 ),
4365 },
4466 {
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.",
5869 inputSchema: {
5970 type: "object",
6071 properties: { id: { type: "integer" } },
6172 required: ["id"],
6273 },
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) }),
6496 },
6597 ];
6698
src/openapi.test.ts +8 −3
@@ -10,6 +10,7 @@ import { join } from "node:path";
1010 import { emit, openapi } from "./openapi.ts";
1111 import { routes } from "./routes.ts";
1212 import { serve } from "./server.ts";
13 import { sign } from "./who.ts";
1314
1415 const committed = readFileSync(
1516 new URL("../contract/openapi.json", import.meta.url),
@@ -67,9 +68,13 @@ test("GET /openapi.json serves the committed file", async () => {
6768 identityKey: "k",
6869 });
6970 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 });
7378 expect(response.status).toBe(200);
7479 expect(response.headers.get("content-type")).toContain("application/json");
7580 expect(await response.text()).toBe(committed);
src/openapi.ts +2 −2
@@ -12,12 +12,12 @@
1212
1313 import { contract } from "./contract.ts";
1414 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";
1616 import { doorOf } from "./security.ts";
1717
1818 // The shapes said once, under components, and referred to wherever they appear.
1919 const NAMED = new Map<Schema, string>([
20 [NOTE, "Note"],
20 [DRAWING, "Drawing"],
2121 [AUDIT_ROW, "AuditRow"],
2222 [ERROR, "Error"],
2323 ]);
src/page.ts +101 −26
@@ -1,10 +1,21 @@
11 /**
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
45 * arrives, move `layout` to `src/pages/layout.ts` and give each page a file.
56 */
67
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";
819 import {
920 commit,
1021 description,
@@ -50,6 +61,7 @@ export function layout(body: string, who: Who, path = "/"): string {
5061 <link rel="stylesheet" href="${tokensHref}">
5162 <link rel="stylesheet" href="${asset("app.css")}">
5263 <script type="module" src="${asset("live.js")}"></script>
64 <script type="module" src="${asset("glyph.js")}"></script>
5365 <body>
5466 <div class="page">
5567 <header class="head">
@@ -65,7 +77,7 @@ export function layout(body: string, who: Who, path = "/"): string {
6577 <main>${body}</main>
6678 <footer class="muted">
6779 ${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>
6981 <p>Something wrong? <a href="/__forge/report">Report a problem</a>, no account needed.</p>
7082 </footer>
7183 </div>
@@ -76,41 +88,104 @@ export function layout(body: string, who: Who, path = "/"): string {
7688
7789 export function home(input: {
7890 who: Who;
79 notes: readonly Note[];
91 drawings: readonly Drawing[];
92 palette?: string | null;
8093 notice?: string | undefined;
8194 }): string {
82 const { who, notes, notice } = input;
95 const { who, drawings, notice } = input;
96 const palette: Palette = isPalette(input.palette) ? input.palette : "sage";
8397 const composer =
8498 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>
89116 </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>`;
94117 return layout(
95118 `<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>
97121 ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`,
98122 who,
99123 );
100124 }
101125
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 ];
113132
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 }
114189 const ago = (iso: string): string => {
115190 const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
116191 if (s < 60) return "just now";
src/routes.ts +97 −61
@@ -14,14 +14,14 @@
1414 import type { Database } from "bun:sqlite";
1515 import { existsSync, readFileSync } from "node:fs";
1616 import {
17 addNote,
18 BODY_MAX,
19 deleteNote,
17 deleteDrawing,
18 getDrawing,
2019 getAudit,
21 getNote,
2220 listAudit,
23 listNotes,
24 updateNote,
21 listDrawings,
22 startDrawing,
23 svgsOf,
24 type Drawing,
2525 type Failure,
2626 type Result,
2727 } from "./actions.ts";
@@ -38,6 +38,7 @@ import {
3838 staticFile,
3939 text,
4040 } from "./http.ts";
41 import { inPalette, isPalette, KINDS, nameOf, PROMPT_MAX } from "./glyph.ts";
4142 import { events } from "./live.ts";
4243 import { rpc } from "./mcp.ts";
4344 import { home } from "./page.ts";
@@ -75,29 +76,33 @@ export interface Route {
7576 }
7677
7778 // The shapes the API speaks in, named once.
78 export const NOTE: Schema = {
79 export const DRAWING: Schema = {
7980 type: "object",
8081 properties: {
8182 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"] },
8392 author_id: { type: "string" },
8493 author_name: { type: "string" },
8594 created_at: { type: "string", format: "date-time" },
8695 updated_at: { type: "string", format: "date-time" },
8796 },
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"],
9698 };
97 const NOTE_BODY: Schema = {
99 const DRAW_BODY: Schema = {
98100 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"],
101106 };
102107 export const AUDIT_ROW: Schema = {
103108 type: "object",
@@ -133,8 +138,14 @@ const STATUS: Record<Failure, number> = {
133138 forbidden: 403,
134139 not_found: 404,
135140 invalid: 422,
141 busy: 429,
136142 };
137143
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
138149 /** A result as JSON: the value, or `{ error, message }` with the matching status. */
139150 const answer = <T>(result: Result<T>, status = 200): Response =>
140151 result.ok
@@ -157,15 +168,20 @@ const OPENAPI = new URL("../contract/openapi.json", import.meta.url);
157168 const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined;
158169
159170 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.
161172 {
162173 method: "GET",
163174 path: "/",
164175 handle: (c) => {
165176 const { notice, clear } = readNotice(c.request);
166 const notes = listNotes(c.db, c.who);
177 const drawings = listDrawings(c.db, c.who);
167178 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 }),
169185 200,
170186 clear,
171187 );
@@ -173,82 +189,102 @@ export const routes: readonly Route[] = [
173189 },
174190 {
175191 method: "POST",
176 path: "/notes",
192 path: "/drawings",
177193 handle: async (c) => {
178194 const form = await readForm(c.request);
179195 if (form === undefined) return text("too large", 413);
180196 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 }),
182201 );
183202 },
184203 },
185204 {
186205 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 },
189233 },
190234
191235 // The JSON API: the same actions, the same identity, plain status codes.
192236 {
193237 method: "GET",
194 path: "/api/notes",
238 path: "/api/drawings",
195239 handle: (c) =>
196240 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 ),
200244 ),
201245 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),
205249 },
206250 },
207251 {
208252 method: "POST",
209 path: "/api/notes",
253 path: "/api/drawings",
210254 handle: async (c) => {
211255 const body = await readJson(c.request);
212256 if (body === undefined) return json({ error: "too_large" }, 413);
257 const f = fields(body);
213258 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,
216266 );
217267 },
218268 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,
223274 },
224275 },
225276 {
226277 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 },
245281 },
246282 {
247283 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) })),
250286 api: {
251 summary: "Remove a note.",
287 summary: "Remove a drawing.",
252288 answer: {
253289 type: "object",
254290 properties: { id: { type: "integer" } },
src/security.test.ts +41 −32
@@ -36,24 +36,24 @@ const as = (id: string, role: "user" | "admin"): Record<string, string> => {
3636 };
3737
3838 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",
4141 verb: "list",
4242 });
43 expect(doorOf("POST", "/api/notes")).toEqual({
44 resource: "note",
43 expect(doorOf("POST", "/api/drawings")).toEqual({
44 resource: "drawing",
4545 verb: "create",
4646 });
47 expect(doorOf("GET", "/api/notes/7")).toEqual({
48 resource: "note",
47 expect(doorOf("GET", "/api/drawings/7")).toEqual({
48 resource: "drawing",
4949 verb: "read",
5050 });
51 expect(doorOf("PATCH", "/api/notes/7")).toEqual({
52 resource: "note",
51 expect(doorOf("PATCH", "/api/drawings/7")).toEqual({
52 resource: "drawing",
5353 verb: "update",
5454 });
55 expect(doorOf("DELETE", "/api/notes/7")).toEqual({
56 resource: "note",
55 expect(doorOf("DELETE", "/api/drawings/7")).toEqual({
56 resource: "drawing",
5757 verb: "delete",
5858 });
5959 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", (
6161 verb: "list",
6262 });
6363 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"],
6969 ] as const)
7070 expect(doorOf(method, path)).toBeUndefined();
7171 });
@@ -74,11 +74,15 @@ test("the door refuses only what no row would open", () => {
7474 const anon = { role: "anon" } as const;
7575 const alice = { role: "user", id: "u1", name: "alice" } as const;
7676 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);
8286 expect(mayKnock(alice, "audit", "list")).toBe(false);
8387 expect(mayKnock(root, "audit", "list")).toBe(true);
8488 expect(mayKnock(root, "audit", "create")).toBe(false);
@@ -87,13 +91,13 @@ test("the door refuses only what no row would open", () => {
8791 test("a stranger is refused /api before the body is read, when the contract closes it", async () => {
8892 // A body past the cap: read, it would be 413. The door answers first.
8993 const huge = "x".repeat(128 * 1024);
90 const refused = await fetch(`${base}/api/notes`, {
94 const refused = await fetch(`${base}/api/drawings`, {
9195 method: "POST",
9296 headers: { "content-type": "application/json" },
9397 body: huge,
9498 });
9599 expect(refused.status).toBe(401);
96 const signedIn = await fetch(`${base}/api/notes`, {
100 const signedIn = await fetch(`${base}/api/drawings`, {
97101 method: "POST",
98102 headers: { "content-type": "application/json", ...as("u1", "user") },
99103 body: huge,
@@ -108,10 +112,13 @@ test("a stranger is refused /api before the body is read, when the contract clos
108112 expect(
109113 (await fetch(`${base}/api/audit`, { headers: as("u1", "user") })).status,
110114 ).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);
115122 });
116123
117124 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
122129 user: { above: ["anon"], held: { by: "identity", role: "user" } },
123130 },
124131 resources: {
125 note: {
126 api: "/api/notes",
132 drawing: {
133 api: "/api/drawings",
127134 can: {
128135 create: "user",
129136 read: "user",
@@ -137,7 +144,8 @@ test("the API's description is shut to strangers when the table opens them no do
137144 );
138145 if (!closed.ok) throw new Error(closed.problems.join("; "));
139146 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);
141149 });
142150
143151 test("strangers are rationed at the API, reads too", () => {
@@ -145,13 +153,14 @@ test("strangers are rationed at the API, reads too", () => {
145153 const knock = () =>
146154 door(
147155 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"),
150158 { role: "anon" },
151159 "203.0.113.7",
152160 );
153161 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(
155164 true,
156165 );
157166 expect(answers.at(-1)?.status).toBe(429);
src/server.test.ts +123 −69
@@ -2,7 +2,9 @@ import { afterAll, expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { setDrawer, settled } from "./draw.ts";
56 import { env } from "./env.ts";
7 import { GLOW, INK, PALETTES } from "./glyph.ts";
68 import { serve } from "./server.ts";
79 import { sign } from "./who.ts";
810
@@ -48,22 +50,30 @@ const rpc = (message: unknown, who: Record<string, string> = {}) =>
4850 (r) => r.json() as Promise<any>,
4951 );
5052
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
5160 test("the page knows who you are and offers the right door", async () => {
5261 const guest = await (await fetch(base + "/")).text();
5362 expect(guest).toContain("<h1>");
5463 expect(guest).toContain("/__forge/signin?to=/");
5564 expect(guest).toContain('href="/__forge/report"');
56 expect(guest).not.toContain('action="/notes"');
65 expect(guest).not.toContain('action="/drawings"');
5766 const signed = await (await api("/", {}, alice)).text();
5867 expect(signed).toContain("<b>alice</b>");
5968 expect(signed).toContain("/__forge/signout");
60 expect(signed).toContain('action="/notes"');
69 expect(signed).toContain('action="/drawings"');
6170 });
6271
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 () => {
6473 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", {
6777 method: "POST",
6878 body: form,
6979 redirect: "manual",
@@ -71,56 +81,102 @@ test("a form post adds as the person; a guest is sent to sign in", async () => {
7181 expect(refused.status).toBe(303);
7282 expect(refused.headers.get("set-cookie")).toContain("notice=");
7383 expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax");
74 expect(await (await fetch(base + "/")).text()).not.toContain(
75 "hello from a form",
76 );
7784 const posted = await api(
78 "/notes",
85 "/drawings",
7986 { method: "POST", body: form, redirect: "manual" },
8087 alice,
8188 );
8289 expect(posted.status).toBe(303);
8390 expect(posted.headers.get("set-cookie")).toBeNull();
91 await settled();
8492 const page = await (await api("/", {}, alice)).text();
8593 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));
87103 });
88104
89105 test("the JSON API is the same actions with the same identity", async () => {
90106 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" }) },
93109 alice,
94110 );
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);
102124 expect(
103 (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) }))
104 .status,
125 (await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "anon" }) })).status,
105126 ).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);
124180 });
125181
126182 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 () =>
133189 expect(hello.result.capabilities).toEqual({ tools: {} });
134190 const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
135191 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",
139196 ]);
140197 const call = (name: string, args: unknown, who = {}) =>
141198 rpc(
@@ -147,19 +204,18 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
147204 },
148205 who,
149206 );
150 const refused = await call("add_note", { body: "as nobody" });
207 const refused = await call("draw", { prompt: "as nobody" });
151208 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);
153210 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();
163219 expect(
164220 (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code,
165221 ).toBe(-32601);
@@ -184,13 +240,14 @@ test("a change is on the event stream, and a late client hears what it missed",
184240 }
185241 };
186242 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"),
190246 );
191247 const heard = await next();
192 expect(heard.type).toBe("note.added");
248 expect(heard.type).toBe("drawing.added");
193249 await reader.cancel();
250 await settled();
194251 const late = await fetch(base + "/events", {
195252 headers: { "last-event-id": String(Number(heard.id) - 1) },
196253 });
@@ -198,22 +255,19 @@ test("a change is on the event stream, and a late client hears what it missed",
198255 (await late.body!.getReader().read()).value,
199256 );
200257 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");
202260 });
203261
204262 test("writes are rationed per person", async () => {
205263 const flood = as("u9", "flood");
206264 const answers = await Promise.all(
207265 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),
213267 ),
214268 );
215269 const codes = answers.map((r) => r.status);
216 expect(codes).toContain(201);
270 expect(codes).toContain(404);
217271 expect(codes).toContain(429);
218272 });
219273
@@ -224,17 +278,17 @@ test("stands on the security floor", async () => {
224278 );
225279 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
226280 expect(
227 (await fetch(base + "/api/notes")).headers.get("content-security-policy"),
281 (await fetch(base + "/api/drawings")).headers.get("content-security-policy"),
228282 ).toContain("default-src");
229283 });
230284
231285 test("a body is capped by the bytes read, whatever it claims", async () => {
232286 const response = await api(
233 "/notes",
287 "/drawings",
234288 {
235289 method: "POST",
236290 headers: { "content-type": "application/x-www-form-urlencoded" },
237 body: "body=" + "x".repeat(1024 * 1024),
291 body: "prompt=" + "x".repeat(1024 * 1024),
238292 },
239293 alice,
240294 );
@@ -249,9 +303,9 @@ test("HEAD sees the app up", async () => {
249303
250304 test("refuses a cross-site post", async () => {
251305 const form = new FormData();
252 form.set("body", "from another site");
306 form.set("prompt", "from another site");
253307 const posted = await api(
254 "/notes",
308 "/drawings",
255309 {
256310 method: "POST",
257311 body: form,
src/server.ts +7
@@ -8,6 +8,7 @@
88 */
99
1010 import { openDb } from "./db.ts";
11 import { drawerFromEnv, recover, setDrawer } from "./draw.ts";
1112 import { dataDir, identityKey, title, where } from "./env.ts";
1213 import { html, text } from "./http.ts";
1314 import { serveSpan } from "./otel.ts";
@@ -24,6 +25,8 @@ export interface ServeOptions {
2425
2526 export function serve(options: ServeOptions) {
2627 const db = openDb(options.dataDir);
28 // A drawing the last process left half done will never finish: say so.
29 recover(db);
2730 return Bun.serve({
2831 port: options.port,
2932 hostname: options.hostname ?? "127.0.0.1",
@@ -78,6 +81,10 @@ const head = (request: Request, response: Response): Response =>
7881 : response;
7982
8083 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");
8188 const server = serve({
8289 port: Number(process.env.PORT ?? 8080),
8390 hostname: process.env.HOST,
src/tty.ts +45 −46
@@ -1,22 +1,27 @@
11 /**
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.
67 */
78
89 import {
9 addNote,
10 canDelete,
11 deleteNote,
12 listNotes,
13 type Note,
10 deleteDrawing,
11 listDrawings,
12 startDrawing,
13 svgsOf,
14 type Drawing,
1415 } from "./actions.ts";
1516 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";
1720 import { whoAtTty } from "./who.ts";
1821
1922 const db = openDb();
23 const drawer = drawerFromEnv(dataDir);
24 if (drawer !== undefined) setDrawer(drawer);
2025 const me = whoAtTty();
2126 const esc = "\x1b[";
2227 const dim = (s: string): string => `${esc}2m${s}${esc}0m`;
@@ -29,43 +34,36 @@ let cursor = 0;
2934 let typing: string | undefined;
3035 let said = "";
3136
32 const notes = (): Note[] => {
33 const listed = listNotes(db, me);
37 const drawings = (): Drawing[] => {
38 const listed = listDrawings(db, me);
3439 return listed.ok ? listed.value : [];
3540 };
3641
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
3749 function draw(): void {
38 const rows = notes();
50 const rows = drawings();
3951 const lines = [
4052 `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`,
4153 ssh === undefined ? "" : dim(`connect: ${ssh}`),
54 dim(`see them at ${publicOrigin}`),
4255 "",
4356 ];
4457 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)}`)}`);
5762 });
5863 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`);
6065 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"));
6967 out(`${esc}2J${esc}H${lines.join("\r\n")}`);
7068 }
7169
@@ -73,12 +71,16 @@ process.stdin.setRawMode?.(true);
7371 process.stdin.resume();
7472 process.stdin.setEncoding("utf8");
7573 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);
7678 process.stdin.on("data", (key: string) => {
7779 said = "";
7880 if (typing !== undefined) {
7981 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;
8284 typing = undefined;
8385 cursor = 0;
8486 } else if (key === "\x1b") typing = undefined;
@@ -91,21 +93,18 @@ process.stdin.on("data", (key: string) => {
9193 out(`${esc}2J${esc}H`);
9294 process.exit(0);
9395 }
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));
9798 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.";
100101 else typing = "";
101102 }
102103 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.";
107106 else {
108 const removed = deleteNote(db, me, { id: note.id });
107 const removed = deleteDrawing(db, me, { id: d.id });
109108 if (!removed.ok) said = removed.message;
110109 cursor = Math.min(cursor, Math.max(0, rows.length - 2));
111110 }
static/app.css +173 −1
@@ -15,7 +15,7 @@ body {
1515 font: var(--text-base) / var(--leading-body) var(--font-sans);
1616 }
1717 .page {
18 max-width: 40rem;
18 max-width: 60rem;
1919 margin: 0 auto;
2020 padding: var(--space-5) var(--space-4) var(--space-6);
2121 }
@@ -111,6 +111,178 @@ footer code {
111111 overflow-wrap: anywhere;
112112 }
113113
114 /* Drawing. */
115 .compose {
116 display: grid;
117 gap: var(--space-3);
118 }
119 .label {
120 font-weight: 600;
121 }
122 .prompt {
123 padding: var(--space-3);
124 resize: vertical;
125 line-height: var(--leading-body);
126 }
127 .wrap {
128 flex-wrap: wrap;
129 }
130 .tight {
131 gap: var(--space-1);
132 }
133 .spacer {
134 flex: 1;
135 }
136 .kinds {
137 display: flex;
138 flex-wrap: wrap;
139 gap: var(--space-2);
140 margin: 0;
141 padding: 0;
142 border: 0;
143 }
144 .examples {
145 display: flex;
146 flex-wrap: wrap;
147 gap: var(--space-2);
148 }
149 .examples[hidden] {
150 display: none;
151 }
152 .chip {
153 display: inline-flex;
154 align-items: center;
155 gap: var(--space-2);
156 min-height: var(--size-tap);
157 padding: 0 var(--space-3);
158 font: inherit;
159 font-size: var(--text-sm);
160 color: var(--color-fg);
161 background: var(--color-bg);
162 border: 1px solid var(--color-line);
163 border-radius: var(--radius-pill);
164 cursor: pointer;
165 text-decoration: none;
166 }
167 .chip:hover {
168 border-color: var(--color-muted);
169 }
170 .chip:has(input:checked),
171 .chip[aria-current] {
172 border-color: var(--color-accent);
173 color: var(--color-accent);
174 font-weight: 600;
175 }
176 .chip input {
177 accent-color: var(--color-accent);
178 margin: 0;
179 }
180 .palettes {
181 margin-bottom: var(--space-4);
182 }
183 .dots {
184 display: inline-flex;
185 gap: 3px;
186 padding: 4px;
187 border-radius: var(--radius-pill);
188 border: 1px solid var(--color-line);
189 }
190 .dots i {
191 width: 10px;
192 height: 10px;
193 border-radius: 50%;
194 }
195 .drawing {
196 padding: var(--space-4) 0;
197 border-top: 1px solid var(--color-line);
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(--space-3);
207 margin-bottom: var(--space-3);
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(--space-3);
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(--space-1);
227 }
228 .tile {
229 aspect-ratio: 1;
230 border-radius: var(--radius-md);
231 border: 1px solid var(--color-line);
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(--space-1);
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(--space-1);
264 }
265 .name {
266 font-size: var(--text-sm);
267 font-weight: 600;
268 overflow: hidden;
269 text-overflow: ellipsis;
270 white-space: nowrap;
271 }
272 .small {
273 font-size: var(--text-sm);
274 }
275 .button.small {
276 padding: 0 var(--space-2);
277 }
278 .error {
279 color: var(--color-danger);
280 overflow-wrap: anywhere;
281 }
282 [hidden] {
283 display: none !important;
284 }
285
114286 /* Controls: at least 44px on a side, keyboard and mouse alike. */
115287 .input {
116288 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();

↑↓ navigate ↵ open esc close

Keyboard shortcuts