Public: anyone can read this; the crew can change it.
Page: retire the notes scaffold and stand up the arena shell #26
Check · Review · Land · Live done — Landed and live in production.
quill/drift:quill/7-page-retire-the-notes-scaffold-and-stand → main 1 commit fork 1 thread · 1 unresolved
12 files
changed +349
−599
README.md +29 −27
| @@ -14,6 +14,16 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal | ||
| 14 | 14 | FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity |
| 15 | 15 | |
| 16 | 16 | |
| 17 | ## What is real, and what is not yet | |
| 18 | ||
| 19 | The page is the arena and nothing else: a square the sea will be drawn in, | |
| 20 | a round clock, one line of the rule, a roster and a ladder — all rendered | |
| 21 | on the server, all legible with JavaScript off, none of them fed by a | |
| 22 | simulation yet. There is no world tick, no socket and no score behind | |
| 23 | them; the regions are shells with honest empty states, and the round loop, | |
| 24 | the canvas and the ladder fill them in the issues that follow. `POST /mcp` | |
| 25 | answers and lists no tool. | |
| 26 | ||
| 17 | 27 | ## The shape |
| 18 | 28 | |
| 19 | 29 | One implementation, many doors. `src/actions.ts` is every operation as a |
| @@ -23,28 +33,28 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 23 | 33 | |
| 24 | 34 | | file | what it is | |
| 25 | 35 | | ---------------------------------- | ------------------------------------------------------------ | |
| 26 | | `src/actions.ts` | list, add, delete — and who may | | |
| 36 | | `src/actions.ts` | every operation, and who may — empty until #DATA | | |
| 27 | 37 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 28 | 38 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 29 | 39 | | `src/live.ts` | the in-process bus and `GET /events` | |
| 30 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | | |
| 40 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` — none yet | | |
| 31 | 41 | | `src/routes.ts` | every path, in one table | |
| 32 | | `src/page.ts` | the HTML, rendered on the server | | |
| 42 | | `src/page.ts` | the HTML of the arena, rendered on the server | | |
| 33 | 43 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | |
| 34 | 44 | | `src/server.ts` | boot: open the database, serve | |
| 35 | | `src/tty.ts` | the terminal, over the same actions | | |
| 45 | | `src/tty.ts` | the terminal — a placeholder until the world runs | | |
| 36 | 46 | | `src/env.ts` | what the forge told this process | |
| 37 | | `static/app.css`, `static/live.js` | the look, and the list that stays current | | |
| 47 | | `static/app.css`, `static/live.js` | the look, and the regions that stay current | | |
| 38 | 48 | |
| 39 | 49 | ## Who you are |
| 40 | 50 | |
| 41 | 51 | The forge says who is asking on every request it proxies, and the app |
| 42 | 52 | verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: |
| 43 | 53 | |
| 44 | - **anon** — anyone. Reads. | |
| 45 | - **user** — signed in to the forge and able to see this app. Adds notes, as | |
| 46 | themselves, and removes their own. | |
| 47 | - **admin** — able to write this repository. Removes any note. | |
| 54 | - **anon** — anyone. Watches, and will swim as a guest. | |
| 55 | - **user** — signed in to the forge and able to see this app. Swims under | |
| 56 | their forge name, and is the only kind of swimmer the ladder names. | |
| 57 | - **admin** — able to write this repository. | |
| 48 | 58 | |
| 49 | 59 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 50 | 60 | `/__forge/signout`; the forge answers both on the app's own host. |
| @@ -52,35 +62,27 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: | ||
| 52 | 62 | ## Any interface |
| 53 | 63 | |
| 54 | 64 | The same actions, the same rules, at every door. A script is the person |
| 55 | whose forge grant it carries; a bare script is anonymous. | |
| 65 | whose forge grant it carries; a bare script is anonymous. The MCP endpoint | |
| 66 | is already here — its tools arrive with the round: | |
| 56 | 67 | |
| 57 | 68 | sh |
| 58 | curl https://<app>/api/notes | |
| 59 | curl -X POST https://<app>/api/notes -H 'content-type: application/json' -d '{"body":"hi"}' | |
| 60 | curl -X DELETE https://<app>/api/notes/1 | |
| 61 | ||
| 62 | ||
| 63 | As an MCP server, for Claude Code or Claude Desktop — `list_notes`, | |
| 64 | `add_note`, `delete_note`: | |
| 65 | ||
| 66 | sh | |
| 67 | claude mcp add --transport http __TITLE__ https://<app>/mcp | |
| 69 | claude mcp add --transport http drift https://<app>/mcp | |
| 68 | 70 | |
| 69 | 71 | |
| 70 | 72 | json |
| 71 | 73 | { |
| 72 | "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } } | |
| 74 | "mcpServers": { "drift": { "type": "http", "url": "https://<app>/mcp" } } | |
| 73 | 75 | } |
| 74 | 76 | |
| 75 | 77 | |
| 76 | The page stays current over `/events`: a note someone else adds or removes | |
| 77 | appears without a reload. Without JavaScript it is the same page, one | |
| 78 | reload behind. | |
| 78 | The page stays current over `/events`: a region marked `data-live` is | |
| 79 | refetched when the round says something on the bus. Without JavaScript it | |
| 80 | is the same page, one reload behind. | |
| 79 | 81 | |
| 80 | 82 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 81 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge | |
| 82 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on | |
| 83 | a real pty over the same data, as the account at the keyboard. | |
| 83 | runs (the app is the login name, the forge the host). It is `src/tty.ts` on | |
| 84 | a real pty, as the account at the keyboard; today it prints where it is and | |
| 85 | quits on `q`, and the sea drawn in text arrives with the world. | |
| 84 | 86 | |
| 85 | 87 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 86 | 88 | `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and |
src/actions.test.ts deleted −58
| @@ -1,58 +0,0 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { addNote, deleteNote, listNotes } from "./actions.ts"; | |
| 6 | import { openDb } from "./db.ts"; | |
| 7 | import type { Who } from "./who.ts"; | |
| 8 | ||
| 9 | const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); | |
| 10 | const anon: Who = { role: "anon" }; | |
| 11 | const alice: Who = { role: "user", id: "u1", name: "alice" }; | |
| 12 | const bob: Who = { role: "user", id: "u2", name: "bob" }; | |
| 13 | const root: Who = { role: "admin", id: "u0", name: "root" }; | |
| 14 | ||
| 15 | test("anyone reads; only a signed-in person adds, as themselves", () => { | |
| 16 | expect(listNotes(db, anon)).toEqual({ ok: true, value: [] }); | |
| 17 | const refused = addNote(db, anon, { body: "hello" }); | |
| 18 | expect(refused.ok).toBe(false); | |
| 19 | if (!refused.ok) expect(refused.error).toBe("signin_required"); | |
| 20 | const added = addNote(db, alice, { body: " hello " }); | |
| 21 | expect(added.ok).toBe(true); | |
| 22 | if (!added.ok) return; | |
| 23 | expect(added.value.body).toBe("hello"); | |
| 24 | expect(added.value.author_id).toBe("u1"); | |
| 25 | expect(added.value.author_name).toBe("alice"); | |
| 26 | const all = listNotes(db, anon); | |
| 27 | expect(all.ok && all.value.length).toBe(1); | |
| 28 | }); | |
| 29 | ||
| 30 | test("a note is one to five hundred characters", () => { | |
| 31 | for (const body of ["", " ", "x".repeat(501)]) { | |
| 32 | const r = addNote(db, alice, { body }); | |
| 33 | expect(r.ok).toBe(false); | |
| 34 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 35 | } | |
| 36 | }); | |
| 37 | ||
| 38 | test("only the author or an admin removes a note", () => { | |
| 39 | const mine = addNote(db, alice, { body: "mine" }); | |
| 40 | if (!mine.ok) throw new Error(mine.message); | |
| 41 | const byBob = deleteNote(db, bob, { id: mine.value.id }); | |
| 42 | expect(byBob.ok).toBe(false); | |
| 43 | if (!byBob.ok) expect(byBob.error).toBe("forbidden"); | |
| 44 | const byAnon = deleteNote(db, anon, { id: mine.value.id }); | |
| 45 | expect(byAnon.ok).toBe(false); | |
| 46 | if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); | |
| 47 | expect(deleteNote(db, alice, { id: mine.value.id }).ok).toBe(true); | |
| 48 | const gone = deleteNote(db, alice, { id: mine.value.id }); | |
| 49 | expect(gone.ok).toBe(false); | |
| 50 | if (!gone.ok) expect(gone.error).toBe("not_found"); | |
| 51 | ||
| 52 | const bobs = addNote(db, bob, { body: "bob's" }); | |
| 53 | if (!bobs.ok) throw new Error(bobs.message); | |
| 54 | expect(deleteNote(db, root, { id: bobs.value.id }).ok).toBe(true); | |
| 55 | const odd = deleteNote(db, root, { id: 1.5 }); | |
| 56 | expect(odd.ok).toBe(false); | |
| 57 | if (!odd.ok) expect(odd.error).toBe("invalid"); | |
| 58 | }); |
src/actions.ts +7 −77
| @@ -4,90 +4,20 @@ | ||
| 4 | 4 | * the terminal are doors onto these and decide nothing themselves — so a |
| 5 | 5 | * rule written here holds at every door, and a new door is only a new |
| 6 | 6 | * way to call. |
| 7 | * | |
| 8 | * The scaffold left with #PAGE; the vocabulary it was written in stays, | |
| 9 | * because that vocabulary is the app's. The first real operations — a | |
| 10 | * player, a round, a score — land with #DATA. | |
| 7 | 11 | */ |
| 8 | 12 | |
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { emit } from "./live.ts"; | |
| 11 | import type { Who } from "./who.ts"; | |
| 12 | ||
| 13 | export interface Note { | |
| 14 | readonly id: number; | |
| 15 | readonly body: string; | |
| 16 | readonly author_id: string; | |
| 17 | readonly author_name: string; | |
| 18 | readonly created_at: string; | |
| 19 | readonly updated_at: string; | |
| 20 | } | |
| 21 | ||
| 22 | 13 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; |
| 23 | 14 | export type Result<T> = |
| 24 | 15 | | { ok: true; value: T } |
| 25 | 16 | | { ok: false; error: Failure; message: string }; |
| 26 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 27 | const fail = <T>(error: Failure, message: string): Result<T> => ({ | |
| 17 | ||
| 18 | export const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 19 | export const fail = <T>(error: Failure, message: string): Result<T> => ({ | |
| 28 | 20 | ok: false, |
| 29 | 21 | error, |
| 30 | 22 | message, |
| 31 | 23 | }); |
| 32 | ||
| 33 | export const BODY_MAX = 500; | |
| 34 | ||
| 35 | export function listNotes( | |
| 36 | db: Database, | |
| 37 | _who: Who, | |
| 38 | input: { limit?: number } = {}, | |
| 39 | ): Result<Note[]> { | |
| 40 | const limit = Math.min( | |
| 41 | Math.max(Math.trunc(input.limit ?? 100) || 100, 1), | |
| 42 | 500, | |
| 43 | ); | |
| 44 | return ok( | |
| 45 | db | |
| 46 | .query<Note, [number]>("SELECT * FROM note ORDER BY id DESC LIMIT ?1") | |
| 47 | .all(limit), | |
| 48 | ); | |
| 49 | } | |
| 50 | ||
| 51 | export function addNote( | |
| 52 | db: Database, | |
| 53 | who: Who, | |
| 54 | input: { body: string }, | |
| 55 | ): Result<Note> { | |
| 56 | if (who.role === "anon") | |
| 57 | return fail("signin_required", "Sign in to add a note."); | |
| 58 | const body = input.body.trim(); | |
| 59 | if (body.length === 0 || body.length > BODY_MAX) | |
| 60 | return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`); | |
| 61 | const note = db | |
| 62 | .query< | |
| 63 | Note, | |
| 64 | [string, string, string] | |
| 65 | >("INSERT INTO note (body, author_id, author_name) VALUES (?1, ?2, ?3) RETURNING *") | |
| 66 | .get(body, who.id, who.name)!; | |
| 67 | emit("note.added", note); | |
| 68 | return ok(note); | |
| 69 | } | |
| 70 | ||
| 71 | export function deleteNote( | |
| 72 | db: Database, | |
| 73 | who: Who, | |
| 74 | input: { id: number }, | |
| 75 | ): Result<{ id: number }> { | |
| 76 | if (who.role === "anon") | |
| 77 | return fail("signin_required", "Sign in to remove a note."); | |
| 78 | if (!Number.isInteger(input.id)) | |
| 79 | return fail("invalid", "A note id is a whole number."); | |
| 80 | const note = db | |
| 81 | .query<Note, [number]>("SELECT * FROM note WHERE id = ?1") | |
| 82 | .get(input.id); | |
| 83 | if (note === null) return fail("not_found", "No such note."); | |
| 84 | if (!canDelete(who, note)) | |
| 85 | return fail("forbidden", "Only the author or an admin can remove a note."); | |
| 86 | db.query("DELETE FROM note WHERE id = ?1").run(note.id); | |
| 87 | emit("note.removed", { id: note.id }); | |
| 88 | return ok({ id: note.id }); | |
| 89 | } | |
| 90 | ||
| 91 | /** Whether `who` may remove `note`. The page and the terminal draw from this, so the rule lives once. */ | |
| 92 | export const canDelete = (who: Who, note: Note): boolean => | |
| 93 | who.role === "admin" || (who.role === "user" && who.id === note.author_id); |
src/db.test.ts +10
| @@ -20,4 +20,14 @@ test("migrations apply once, in order, and the file checks out", () => { | ||
| 20 | 20 | db.query<{ foreign_keys: number }, []>("PRAGMA foreign_keys").get() |
| 21 | 21 | ?.foreign_keys, |
| 22 | 22 | ).toBe(1); |
| 23 | // The scaffold's table left the way a table leaves here: forward, in a | |
| 24 | // migration of its own, on a database that already had it. | |
| 25 | expect( | |
| 26 | db | |
| 27 | .query< | |
| 28 | { name: string }, | |
| 29 | [] | |
| 30 | >("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> 'schema_version'") | |
| 31 | .all(), | |
| 32 | ).toEqual([]); | |
| 23 | 33 | }); |
src/db.ts +4
| @@ -24,6 +24,10 @@ export const migrations: string[] = [ | ||
| 24 | 24 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 25 | 25 | )`, |
| 26 | 26 | `CREATE INDEX note_author ON note (author_id)`, |
| 27 | // The scaffold left (#PAGE) and its table goes with it. A migration that | |
| 28 | // has run is history, so the two above stay as they are and this one | |
| 29 | // undoes them forward. | |
| 30 | `DROP TABLE IF EXISTS note`, | |
| 27 | 31 | ]; |
| 28 | 32 | |
| 29 | 33 | export function openDb(dir = dataDir): Database { |
src/http.ts +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The floor every response stands on, and the few things a route needs to |
| 3 | * read a request safely. Nothing in here knows what a note is. | |
| 3 | * read a request safely. Nothing in here knows what the game is. | |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { createHash } from "node:crypto"; |
src/mcp.ts +4 −41
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { addNote, deleteNote, listNotes, type Result } from "./actions.ts"; | |
| 10 | import type { Result } from "./actions.ts"; | |
| 11 | 11 | import { title } from "./env.ts"; |
| 12 | 12 | import { fields } from "./http.ts"; |
| 13 | 13 | import type { Who } from "./who.ts"; |
| @@ -23,46 +23,9 @@ interface Tool { | ||
| 23 | 23 | ) => Result<unknown>; |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | export const tools: readonly Tool[] = [ | |
| 27 | { | |
| 28 | name: "list_notes", | |
| 29 | description: "The notes, newest first.", | |
| 30 | inputSchema: { | |
| 31 | type: "object", | |
| 32 | properties: { | |
| 33 | limit: { | |
| 34 | type: "integer", | |
| 35 | minimum: 1, | |
| 36 | maximum: 500, | |
| 37 | description: "At most this many (100).", | |
| 38 | }, | |
| 39 | }, | |
| 40 | }, | |
| 41 | run: (db, who, args) => | |
| 42 | listNotes(db, who, { limit: Number(args.limit ?? 100) }), | |
| 43 | }, | |
| 44 | { | |
| 45 | name: "add_note", | |
| 46 | description: | |
| 47 | "Add a note as the signed-in person. Anonymous callers cannot.", | |
| 48 | inputSchema: { | |
| 49 | type: "object", | |
| 50 | properties: { body: { type: "string", maxLength: 500 } }, | |
| 51 | required: ["body"], | |
| 52 | }, | |
| 53 | run: (db, who, args) => addNote(db, who, { body: String(args.body ?? "") }), | |
| 54 | }, | |
| 55 | { | |
| 56 | name: "delete_note", | |
| 57 | description: "Remove a note by id. Only its author or an admin can.", | |
| 58 | inputSchema: { | |
| 59 | type: "object", | |
| 60 | properties: { id: { type: "integer" } }, | |
| 61 | required: ["id"], | |
| 62 | }, | |
| 63 | run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }), | |
| 64 | }, | |
| 65 | ]; | |
| 26 | // No tool yet: the scaffold left with #PAGE and the round's tools arrive | |
| 27 | // with #DOORS. A caller still gets a well-formed `tools/list`. | |
| 28 | export const tools: readonly Tool[] = []; | |
| 66 | 29 | |
| 67 | 30 | /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */ |
| 68 | 31 | export function rpc( |
src/page.ts +70 −46
| @@ -1,14 +1,26 @@ | ||
| 1 | 1 | /** |
| 2 | * The page, rendered on the server: the first paint is complete HTML, and | |
| 3 | * `static/live.js` keeps the notes fresh afterwards. When a second page | |
| 4 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. | |
| 2 | * The page, rendered on the server: the first paint is the whole arena — | |
| 3 | * the sea's square, the round clock, the rule, the roster and the ladder — | |
| 4 | * and it is legible with JavaScript off. `static/live.js` swaps a region | |
| 5 | * when the round says something on the bus. Nothing here is a simulation | |
| 6 | * yet: the regions are shells with honest empty states, and the round | |
| 7 | * (#ROUND), the canvas (#CANVAS) and the ladder (#LADDER) fill them. | |
| 8 | * | |
| 9 | * When a second page arrives, move `layout` to `src/pages/layout.ts` and | |
| 10 | * give each page a file. | |
| 5 | 11 | */ |
| 6 | 12 | |
| 7 | import { canDelete, type Note } from "./actions.ts"; | |
| 8 | 13 | import { commit, description, env, ssh, title, where } from "./env.ts"; |
| 9 | 14 | import { asset, escape } from "./http.ts"; |
| 10 | 15 | import type { Who } from "./who.ts"; |
| 11 | 16 | |
| 17 | /** The one sentence a newcomer has to read: the whole game is other people. */ | |
| 18 | export const RULE = | |
| 19 | "Your own thruster is feeble; the wake other swimmers leave is not. Ride their lane, braid it, and throw the bloom through the gate."; | |
| 20 | ||
| 21 | /** The roster cap the concept sets (#CONCEPT); the round enforces it. */ | |
| 22 | export const ROSTER_MAX = 24; | |
| 23 | ||
| 12 | 24 | export function layout(body: string, who: Who): string { |
| 13 | 25 | const door = |
| 14 | 26 | who.role === "anon" |
| @@ -22,6 +34,7 @@ export function layout(body: string, who: Who): string { | ||
| 22 | 34 | <meta name="color-scheme" content="light dark"> |
| 23 | 35 | <link rel="icon" href="data:,"> |
| 24 | 36 | <title>${escape(title)}</title> |
| 37 | <meta name="description" content="${escape(description)}"> | |
| 25 | 38 | <link rel="stylesheet" href="${asset("app.css")}"> |
| 26 | 39 | <script type="module" src="${asset("live.js")}"></script> |
| 27 | 40 | <body> |
| @@ -38,8 +51,8 @@ export function layout(body: string, who: Who): string { | ||
| 38 | 51 | </header> |
| 39 | 52 | <main>${body}</main> |
| 40 | 53 | <footer class="muted"> |
| 41 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 42 | <p>Also at <a href="/api/notes">/api/notes</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 54 | ${ssh === undefined ? "<p>The terminal is off in this environment.</p>" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 55 | <p><code>POST /mcp</code> answers here already; the round's tools and its JSON join it.</p> | |
| 43 | 56 | </footer> |
| 44 | 57 | </div> |
| 45 | 58 | </body> |
| @@ -47,50 +60,61 @@ export function layout(body: string, who: Who): string { | ||
| 47 | 60 | `; |
| 48 | 61 | } |
| 49 | 62 | |
| 50 | export function home(input: { | |
| 51 | who: Who; | |
| 52 | notes: readonly Note[]; | |
| 53 | notice?: string | undefined; | |
| 54 | }): string { | |
| 55 | const { who, notes, notice } = input; | |
| 56 | const composer = | |
| 57 | who.role === "anon" | |
| 58 | ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>` | |
| 59 | : `<form class="row" method="post" action="/notes"> | |
| 60 | <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus> | |
| 61 | <button class="button">Add</button> | |
| 62 | </form>`; | |
| 63 | const list = | |
| 64 | notes.length === 0 | |
| 65 | ? `<p class="empty">No notes yet. Notes written here stay in ${escape(where)} data.</p>` | |
| 66 | : `<ul class="list">${notes.map((note) => item(note, who)).join("")}</ul>`; | |
| 63 | export function home(input: { who: Who; notice?: string | undefined }): string { | |
| 64 | const { who, notice } = input; | |
| 67 | 65 | return layout( |
| 68 | `<section class="card">${composer}</section> | |
| 69 | <section id="notes" data-live="note.added note.removed">${list}</section> | |
| 66 | `<div class="arena-layout"> | |
| 67 | <div class="arena-column"> | |
| 68 | ${arena()} | |
| 69 | <p class="rule">${escape(RULE)}</p> | |
| 70 | </div> | |
| 71 | <div class="side-column"> | |
| 72 | ${clock()} | |
| 73 | ${roster(who)} | |
| 74 | ${ladder()} | |
| 75 | </div> | |
| 76 | </div> | |
| 70 | 77 | ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`, |
| 71 | 78 | who, |
| 72 | 79 | ); |
| 73 | 80 | } |
| 74 | 81 | |
| 75 | const item = (note: Note, who: Who): string => `<li class="note"> | |
| 76 | <div> | |
| 77 | <p>${escape(note.body)}</p> | |
| 78 | <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small> | |
| 79 | </div> | |
| 80 | ${ | |
| 81 | canDelete(who, note) | |
| 82 | ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>` | |
| 83 | : "" | |
| 84 | } | |
| 85 | </li>`; | |
| 82 | // The square the sea is drawn in. It holds its size from CSS alone — | |
| 83 | // `aspect-ratio: 1`, capped to the viewport — so the canvas (#CANVAS) drops | |
| 84 | // in later without moving a pixel of what is around it. | |
| 85 | const arena = (): string => | |
| 86 | `<section id="arena" class="arena" aria-labelledby="arena-title" data-live="round.started round.ended"> | |
| 87 | <h2 id="arena-title" class="sr-only">The sea</h2> | |
| 88 | <div class="arena-box"> | |
| 89 | <p class="arena-empty">The sea is flat. | |
| 90 | <span class="muted">Motes, the currents they leave behind and the bloom they carry are drawn here once the world is running.</span> | |
| 91 | </p> | |
| 92 | </div> | |
| 93 | </section>`; | |
| 86 | 94 | |
| 87 | const ago = (iso: string): string => { | |
| 88 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); | |
| 89 | if (s < 60) return "just now"; | |
| 90 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 91 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 92 | return new Date(iso).toLocaleDateString("en", { | |
| 93 | month: "short", | |
| 94 | day: "numeric", | |
| 95 | }); | |
| 96 | }; | |
| 95 | // A clock that cannot shift when it starts counting: tabular figures, and a | |
| 96 | // width reserved for the widest time it will ever show. The dashes are a | |
| 97 | // placeholder and say nothing a screen reader needs — the sentence under | |
| 98 | // them does; when #ROUND puts a real time there, that attribute goes. | |
| 99 | const clock = (): string => | |
| 100 | `<section id="clock" class="panel clock" aria-labelledby="clock-title" data-live="round.started round.ended"> | |
| 101 | <h2 id="clock-title" class="panel-title">Round</h2> | |
| 102 | <p class="clock-time" aria-hidden="true">--:--</p> | |
| 103 | <p class="empty">No round yet. One runs three minutes, then twenty seconds of quiet.</p> | |
| 104 | </section>`; | |
| 105 | ||
| 106 | const roster = (who: Who): string => | |
| 107 | `<section id="roster" class="panel" aria-labelledby="roster-title" data-live="round.started round.ended"> | |
| 108 | <h2 id="roster-title" class="panel-title">In the sea <span class="count">0/${ROSTER_MAX}</span></h2> | |
| 109 | <p class="empty">Nobody is swimming yet. ${ | |
| 110 | who.role === "anon" | |
| 111 | ? "When the world starts you can swim as a guest, or sign in first and swim under your forge name." | |
| 112 | : `When the world starts you swim as <b>${escape(who.name)}</b>.` | |
| 113 | }</p> | |
| 114 | </section>`; | |
| 115 | ||
| 116 | const ladder = (): string => | |
| 117 | `<section id="ladder" class="panel" aria-labelledby="ladder-title" data-live="round.ended"> | |
| 118 | <h2 id="ladder-title" class="panel-title">Ladder</h2> | |
| 119 | <p class="empty">No round has finished. Seven days of them will stand here, credited to the swimmers whose current moved the bloom.</p> | |
| 120 | </section>`; |
src/routes.ts +8 −90
| @@ -2,27 +2,14 @@ | ||
| 2 | 2 | * Every path the app answers, in one table. A route reads its request, |
| 3 | 3 | * calls an action, and shapes the answer for its door — HTML for a |
| 4 | 4 | * browser, JSON for a script — and that is all a route does. |
| 5 | * | |
| 6 | * Today that is the page, the event stream, the MCP endpoint and the | |
| 7 | * files: the scaffold's routes left with #PAGE, and the round's JSON | |
| 8 | * doors arrive with #DOORS. | |
| 5 | 9 | */ |
| 6 | 10 | |
| 7 | 11 | import type { Database } from "bun:sqlite"; |
| 8 | import { | |
| 9 | addNote, | |
| 10 | deleteNote, | |
| 11 | listNotes, | |
| 12 | type Failure, | |
| 13 | type Result, | |
| 14 | } from "./actions.ts"; | |
| 15 | import { | |
| 16 | back, | |
| 17 | fields, | |
| 18 | html, | |
| 19 | json, | |
| 20 | readForm, | |
| 21 | readJson, | |
| 22 | readNotice, | |
| 23 | staticFile, | |
| 24 | text, | |
| 25 | } from "./http.ts"; | |
| 12 | import { html, json, readJson, readNotice, staticFile, text } from "./http.ts"; | |
| 26 | 13 | import { events } from "./live.ts"; |
| 27 | 14 | import { rpc } from "./mcp.ts"; |
| 28 | 15 | import { home } from "./page.ts"; |
| @@ -42,86 +29,17 @@ export interface Route { | ||
| 42 | 29 | readonly handle: (ctx: Context) => Response | Promise<Response>; |
| 43 | 30 | } |
| 44 | 31 | |
| 45 | const STATUS: Record<Failure, number> = { | |
| 46 | signin_required: 401, | |
| 47 | forbidden: 403, | |
| 48 | not_found: 404, | |
| 49 | invalid: 422, | |
| 50 | }; | |
| 51 | ||
| 52 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ | |
| 53 | const answer = <T>(result: Result<T>, status = 200): Response => | |
| 54 | result.ok | |
| 55 | ? json(result.value, status) | |
| 56 | : json( | |
| 57 | { error: result.error, message: result.message }, | |
| 58 | STATUS[result.error], | |
| 59 | ); | |
| 60 | ||
| 61 | /** A result for a form: back to the page, with what went wrong if it did. */ | |
| 62 | const done = <T>(result: Result<T>): Response => | |
| 63 | result.ok ? back() : back(result.message); | |
| 64 | ||
| 65 | 32 | export const routes: readonly Route[] = [ |
| 66 | // The page. Forms post and come back; the list stays live over /events. | |
| 33 | // The page: the whole arena in the first paint, live regions kept fresh | |
| 34 | // over /events. | |
| 67 | 35 | { |
| 68 | 36 | method: "GET", |
| 69 | 37 | path: "/", |
| 70 | 38 | handle: (c) => { |
| 71 | 39 | const { notice, clear } = readNotice(c.request); |
| 72 | const notes = listNotes(c.db, c.who); | |
| 73 | return html( | |
| 74 | home({ who: c.who, notes: notes.ok ? notes.value : [], notice }), | |
| 75 | 200, | |
| 76 | clear, | |
| 77 | ); | |
| 40 | return html(home({ who: c.who, notice }), 200, clear); | |
| 78 | 41 | }, |
| 79 | 42 | }, |
| 80 | { | |
| 81 | method: "POST", | |
| 82 | path: "/notes", | |
| 83 | handle: async (c) => { | |
| 84 | const form = await readForm(c.request); | |
| 85 | if (form === undefined) return text("too large", 413); | |
| 86 | return done( | |
| 87 | addNote(c.db, c.who, { body: String(form.get("body") ?? "") }), | |
| 88 | ); | |
| 89 | }, | |
| 90 | }, | |
| 91 | { | |
| 92 | method: "POST", | |
| 93 | path: "/notes/:id/delete", | |
| 94 | handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 95 | }, | |
| 96 | ||
| 97 | // The JSON API: the same actions, the same identity, plain status codes. | |
| 98 | { | |
| 99 | method: "GET", | |
| 100 | path: "/api/notes", | |
| 101 | handle: (c) => | |
| 102 | answer( | |
| 103 | listNotes(c.db, c.who, { | |
| 104 | limit: Number(c.url.searchParams.get("limit") ?? 100), | |
| 105 | }), | |
| 106 | ), | |
| 107 | }, | |
| 108 | { | |
| 109 | method: "POST", | |
| 110 | path: "/api/notes", | |
| 111 | handle: async (c) => { | |
| 112 | const body = await readJson(c.request); | |
| 113 | if (body === undefined) return json({ error: "too_large" }, 413); | |
| 114 | return answer( | |
| 115 | addNote(c.db, c.who, { body: String(fields(body).body ?? "") }), | |
| 116 | 201, | |
| 117 | ); | |
| 118 | }, | |
| 119 | }, | |
| 120 | { | |
| 121 | method: "DELETE", | |
| 122 | path: "/api/notes/:id", | |
| 123 | handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 124 | }, | |
| 125 | 43 | |
| 126 | 44 | // Live events, and the MCP server (the tools are the actions too). |
| 127 | 45 | { method: "GET", path: "/events", handle: (c) => events(c.request) }, |
src/server.test.ts +77 −134
| @@ -2,6 +2,8 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { emit } from "./live.ts"; | |
| 6 | import { RULE } from "./page.ts"; | |
| 5 | 7 | import { serve } from "./server.ts"; |
| 6 | 8 | import { sign } from "./who.ts"; |
| 7 | 9 | |
| @@ -27,8 +29,6 @@ const as = ( | ||
| 27 | 29 | }; |
| 28 | 30 | }; |
| 29 | 31 | const alice = as("u1", "alice"); |
| 30 | const bob = as("u2", "bob"); | |
| 31 | const root = as("u0", "root", "admin"); | |
| 32 | 32 | const jsonBody = (body: unknown): RequestInit => ({ |
| 33 | 33 | headers: { "content-type": "application/json" }, |
| 34 | 34 | body: JSON.stringify(body), |
| @@ -47,81 +47,52 @@ const rpc = (message: unknown, who: Record<string, string> = {}) => | ||
| 47 | 47 | (r) => r.json() as Promise<any>, |
| 48 | 48 | ); |
| 49 | 49 | |
| 50 | test("/ is the arena, whole in the first paint", async () => { | |
| 51 | const response = await fetch(base + "/"); | |
| 52 | expect(response.status).toBe(200); | |
| 53 | expect(response.headers.get("content-type")).toContain("text/html"); | |
| 54 | const page = await response.text(); | |
| 55 | expect(page).toContain("<h1>"); | |
| 56 | // The four regions the round, the canvas and the ladder fill later. | |
| 57 | for (const id of ["arena", "clock", "roster", "ladder"]) | |
| 58 | expect(page).toContain(`id="${id}"`); | |
| 59 | // One line of rule text, and a clock that reads before it runs. | |
| 60 | expect(page).toContain(RULE.slice(0, 40)); | |
| 61 | expect(page).toContain('class="clock-time"'); | |
| 62 | // No script is needed for any of it: the markup is all server-rendered. | |
| 63 | expect(page).toContain("No round has finished"); | |
| 64 | }); | |
| 65 | ||
| 66 | test("the arena box reserves its square in CSS, not in script", async () => { | |
| 67 | const css = await (await fetch(base + "/static/app.css")).text(); | |
| 68 | const box = /\.arena-box \{([^}]*)\}/.exec(css)?.[1] ?? ""; | |
| 69 | expect(box).toContain("aspect-ratio: 1"); | |
| 70 | expect(box).toContain("width: min(100%, 68svh)"); | |
| 71 | }); | |
| 72 | ||
| 50 | 73 | test("the page knows who you are and offers the right door", async () => { |
| 51 | 74 | const guest = await (await fetch(base + "/")).text(); |
| 52 | expect(guest).toContain("<h1>"); | |
| 53 | 75 | expect(guest).toContain("/__forge/signin?to=/"); |
| 54 | expect(guest).not.toContain('action="/notes"'); | |
| 55 | 76 | const signed = await (await api("/", {}, alice)).text(); |
| 56 | 77 | expect(signed).toContain("<b>alice</b>"); |
| 57 | 78 | expect(signed).toContain("/__forge/signout"); |
| 58 | expect(signed).toContain('action="/notes"'); | |
| 59 | 79 | }); |
| 60 | 80 | |
| 61 | test("a form post adds as the person; a guest is sent to sign in", async () => { | |
| 62 | const form = new FormData(); | |
| 63 | form.set("body", "hello from a form"); | |
| 64 | const refused = await api("/notes", { | |
| 65 | method: "POST", | |
| 66 | body: form, | |
| 67 | redirect: "manual", | |
| 68 | }); | |
| 69 | expect(refused.status).toBe(303); | |
| 70 | expect(refused.headers.get("set-cookie")).toContain("notice="); | |
| 71 | expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax"); | |
| 72 | expect(await (await fetch(base + "/")).text()).not.toContain( | |
| 73 | "hello from a form", | |
| 74 | ); | |
| 75 | const posted = await api( | |
| 76 | "/notes", | |
| 77 | { method: "POST", body: form, redirect: "manual" }, | |
| 78 | alice, | |
| 79 | ); | |
| 80 | expect(posted.status).toBe(303); | |
| 81 | expect(posted.headers.get("set-cookie")).toBeNull(); | |
| 82 | const page = await (await api("/", {}, alice)).text(); | |
| 83 | expect(page).toContain("hello from a form"); | |
| 84 | expect(page).toContain("alice ·"); | |
| 81 | test("the scaffold's routes are gone, and a 404 stands on the floor", async () => { | |
| 82 | const page = await (await fetch(base + "/")).text(); | |
| 83 | expect(page.toLowerCase()).not.toContain("note"); | |
| 84 | for (const path of ["/api/notes", "/api/notes/1", "/notes"]) { | |
| 85 | const response = await fetch(base + path); | |
| 86 | expect(response.status).toBe(404); | |
| 87 | expect(response.headers.get("content-security-policy")).toContain( | |
| 88 | "default-src 'self'", | |
| 89 | ); | |
| 90 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 91 | } | |
| 92 | expect((await api("/notes", { method: "POST" }, alice)).status).toBe(404); | |
| 85 | 93 | }); |
| 86 | 94 | |
| 87 | test("the JSON API is the same actions with the same identity", async () => { | |
| 88 | const created = await api( | |
| 89 | "/api/notes", | |
| 90 | { method: "POST", ...jsonBody({ body: "over the api" }) }, | |
| 91 | alice, | |
| 92 | ); | |
| 93 | expect(created.status).toBe(201); | |
| 94 | const note = (await created.json()) as { id: number; author_name: string }; | |
| 95 | expect(note.author_name).toBe("alice"); | |
| 96 | const listed = (await (await fetch(base + "/api/notes")).json()) as { | |
| 97 | id: number; | |
| 98 | }[]; | |
| 99 | expect(listed.map((n) => n.id)).toContain(note.id); | |
| 100 | expect( | |
| 101 | (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) })) | |
| 102 | .status, | |
| 103 | ).toBe(401); | |
| 104 | expect( | |
| 105 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status, | |
| 106 | ).toBe(403); | |
| 107 | expect( | |
| 108 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status, | |
| 109 | ).toBe(200); | |
| 110 | expect( | |
| 111 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status, | |
| 112 | ).toBe(404); | |
| 113 | expect( | |
| 114 | ( | |
| 115 | await api( | |
| 116 | "/api/notes", | |
| 117 | { method: "POST", ...jsonBody({ body: "" }) }, | |
| 118 | alice, | |
| 119 | ) | |
| 120 | ).status, | |
| 121 | ).toBe(422); | |
| 122 | }); | |
| 123 | ||
| 124 | test("the MCP server lists the tools and calls them as the caller", async () => { | |
| 95 | test("the MCP server answers, with no tool to call yet", async () => { | |
| 125 | 96 | const hello = await rpc({ |
| 126 | 97 | jsonrpc: "2.0", |
| 127 | 98 | id: 1, |
| @@ -130,34 +101,14 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 130 | 101 | }); |
| 131 | 102 | expect(hello.result.capabilities).toEqual({ tools: {} }); |
| 132 | 103 | const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); |
| 133 | expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([ | |
| 134 | "list_notes", | |
| 135 | "add_note", | |
| 136 | "delete_note", | |
| 137 | ]); | |
| 138 | const call = (name: string, args: unknown, who = {}) => | |
| 139 | rpc( | |
| 140 | { | |
| 141 | jsonrpc: "2.0", | |
| 142 | id: 3, | |
| 143 | method: "tools/call", | |
| 144 | params: { name, arguments: args }, | |
| 145 | }, | |
| 146 | who, | |
| 147 | ); | |
| 148 | const refused = await call("add_note", { body: "as nobody" }); | |
| 149 | expect(refused.result.isError).toBe(true); | |
| 150 | const added = await call("add_note", { body: "over mcp" }, root); | |
| 151 | expect(added.result.isError).toBeUndefined(); | |
| 152 | const note = JSON.parse(added.result.content[0].text); | |
| 153 | expect(note.author_name).toBe("root"); | |
| 154 | const notes = JSON.parse( | |
| 155 | (await call("list_notes", {})).result.content[0].text, | |
| 156 | ); | |
| 157 | expect(notes.map((n: { id: number }) => n.id)).toContain(note.id); | |
| 158 | expect( | |
| 159 | (await call("delete_note", { id: note.id }, root)).result.isError, | |
| 160 | ).toBeUndefined(); | |
| 104 | expect(listed.result.tools).toEqual([]); | |
| 105 | const missing = await rpc({ | |
| 106 | jsonrpc: "2.0", | |
| 107 | id: 3, | |
| 108 | method: "tools/call", | |
| 109 | params: { name: "join", arguments: {} }, | |
| 110 | }); | |
| 111 | expect(missing.error.code).toBe(-32602); | |
| 161 | 112 | expect( |
| 162 | 113 | (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code, |
| 163 | 114 | ).toBe(-32601); |
| @@ -181,13 +132,9 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 181 | 132 | if (frame) return { id: frame[1]!, type: JSON.parse(frame[2]!).type }; |
| 182 | 133 | } |
| 183 | 134 | }; |
| 184 | await api( | |
| 185 | "/api/notes", | |
| 186 | { method: "POST", ...jsonBody({ body: "live" }) }, | |
| 187 | alice, | |
| 188 | ); | |
| 135 | emit("round.started", { id: 1 }); | |
| 189 | 136 | const heard = await next(); |
| 190 | expect(heard.type).toBe("note.added"); | |
| 137 | expect(heard.type).toBe("round.started"); | |
| 191 | 138 | await reader.cancel(); |
| 192 | 139 | const late = await fetch(base + "/events", { |
| 193 | 140 | headers: { "last-event-id": String(Number(heard.id) - 1) }, |
| @@ -196,22 +143,24 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 196 | 143 | (await late.body!.getReader().read()).value, |
| 197 | 144 | ); |
| 198 | 145 | expect(replay).toContain(`id: ${heard.id}\n`); |
| 199 | expect(replay).toContain("note.added"); | |
| 146 | expect(replay).toContain("round.started"); | |
| 200 | 147 | }); |
| 201 | 148 | |
| 202 | 149 | test("writes are rationed per person", async () => { |
| 203 | 150 | const flood = as("u9", "flood"); |
| 204 | const answers = await Promise.all( | |
| 151 | const codes = await Promise.all( | |
| 205 | 152 | Array.from({ length: 40 }, () => |
| 206 | 153 | api( |
| 207 | "/api/notes", | |
| 208 | { method: "POST", ...jsonBody({ body: "again" }) }, | |
| 154 | "/mcp", | |
| 155 | { | |
| 156 | method: "POST", | |
| 157 | ...jsonBody({ jsonrpc: "2.0", id: 1, method: "ping" }), | |
| 158 | }, | |
| 209 | 159 | flood, |
| 210 | ), | |
| 160 | ).then((r) => r.status), | |
| 211 | 161 | ), |
| 212 | 162 | ); |
| 213 | const codes = answers.map((r) => r.status); | |
| 214 | expect(codes).toContain(201); | |
| 163 | expect(codes).toContain(200); | |
| 215 | 164 | expect(codes).toContain(429); |
| 216 | 165 | }); |
| 217 | 166 | |
| @@ -221,21 +170,21 @@ test("stands on the security floor", async () => { | ||
| 221 | 170 | "default-src 'self'", |
| 222 | 171 | ); |
| 223 | 172 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 224 | expect( | |
| 225 | (await fetch(base + "/api/notes")).headers.get("content-security-policy"), | |
| 226 | ).toContain("default-src"); | |
| 173 | expect(response.headers.get("referrer-policy")).toBe( | |
| 174 | "strict-origin-when-cross-origin", | |
| 175 | ); | |
| 227 | 176 | }); |
| 228 | 177 | |
| 229 | 178 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 230 | const response = await api( | |
| 231 | "/notes", | |
| 232 | { | |
| 233 | method: "POST", | |
| 234 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 235 | body: "body=" + "x".repeat(1024 * 1024), | |
| 236 | }, | |
| 237 | alice, | |
| 238 | ); | |
| 179 | const response = await api("/mcp", { | |
| 180 | method: "POST", | |
| 181 | headers: { "content-type": "application/json" }, | |
| 182 | body: JSON.stringify({ | |
| 183 | jsonrpc: "2.0", | |
| 184 | id: 1, | |
| 185 | ping: "x".repeat(1024 * 1024), | |
| 186 | }), | |
| 187 | }); | |
| 239 | 188 | expect(response.status).toBe(413); |
| 240 | 189 | }); |
| 241 | 190 | |
| @@ -246,21 +195,15 @@ test("HEAD sees the app up", async () => { | ||
| 246 | 195 | }); |
| 247 | 196 | |
| 248 | 197 | test("refuses a cross-site post", async () => { |
| 249 | const form = new FormData(); | |
| 250 | form.set("body", "from another site"); | |
| 251 | const posted = await api( | |
| 252 | "/notes", | |
| 253 | { | |
| 254 | method: "POST", | |
| 255 | body: form, | |
| 256 | redirect: "manual", | |
| 257 | headers: { | |
| 258 | origin: "https://evil.example", | |
| 259 | "sec-fetch-site": "cross-site", | |
| 260 | }, | |
| 198 | const posted = await api("/mcp", { | |
| 199 | method: "POST", | |
| 200 | ...jsonBody({ jsonrpc: "2.0", id: 1, method: "ping" }), | |
| 201 | headers: { | |
| 202 | "content-type": "application/json", | |
| 203 | origin: "https://evil.example", | |
| 204 | "sec-fetch-site": "cross-site", | |
| 261 | 205 | }, |
| 262 | alice, | |
| 263 | ); | |
| 206 | }); | |
| 264 | 207 | expect(posted.status).toBe(403); |
| 265 | 208 | }); |
| 266 | 209 |
src/tty.ts +17 −81
| @@ -1,22 +1,17 @@ | ||
| 1 | 1 | /** |
| 2 | 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. | |
| 3 | * `ssh`es to the app, as the account at the keyboard, under exactly the | |
| 4 | * page's rules. | |
| 5 | * | |
| 6 | * There is nothing to watch yet: the scaffold it used to show left with | |
| 7 | * #PAGE, and #TTY rebuilds this as the sea drawn in text from the running | |
| 8 | * server's frames. Until then it says where it is, how to reach it, and | |
| 9 | * quits on q. Plain ANSI, no dependencies. | |
| 6 | 10 | */ |
| 7 | 11 | |
| 8 | import { | |
| 9 | addNote, | |
| 10 | canDelete, | |
| 11 | deleteNote, | |
| 12 | listNotes, | |
| 13 | type Note, | |
| 14 | } from "./actions.ts"; | |
| 15 | import { openDb } from "./db.ts"; | |
| 16 | 12 | import { ssh, title, where } from "./env.ts"; |
| 17 | 13 | import { whoAtTty } from "./who.ts"; |
| 18 | 14 | |
| 19 | const db = openDb(); | |
| 20 | 15 | const me = whoAtTty(); |
| 21 | 16 | const esc = "\x1b["; |
| 22 | 17 | const dim = (s: string): string => `${esc}2m${s}${esc}0m`; |
| @@ -25,47 +20,21 @@ const out = (s: string): void => { | ||
| 25 | 20 | process.stdout.write(s); |
| 26 | 21 | }; |
| 27 | 22 | |
| 28 | let cursor = 0; | |
| 29 | let typing: string | undefined; | |
| 30 | let said = ""; | |
| 31 | ||
| 32 | const notes = (): Note[] => { | |
| 33 | const listed = listNotes(db, me); | |
| 34 | return listed.ok ? listed.value : []; | |
| 35 | }; | |
| 36 | ||
| 37 | 23 | function draw(): void { |
| 38 | const rows = notes(); | |
| 39 | 24 | const lines = [ |
| 40 | 25 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`, |
| 41 | ssh === undefined ? "" : dim(`connect: ${ssh}`), | |
| 26 | // The exact command that reaches this terminal, verbatim from the | |
| 27 | // forge — never a guess at one. | |
| 28 | ssh === undefined | |
| 29 | ? dim("the terminal is off in this environment") | |
| 30 | : dim(`connect: ${ssh}`), | |
| 42 | 31 | "", |
| 32 | "The sea is not running yet.", | |
| 33 | dim("A round drawn in text arrives here with the world; the page is"), | |
| 34 | dim("the shell it fills."), | |
| 35 | "", | |
| 36 | dim("q quit"), | |
| 43 | 37 | ]; |
| 44 | if (rows.length === 0) | |
| 45 | lines.push( | |
| 46 | dim( | |
| 47 | me.role === "anon" | |
| 48 | ? "No notes yet." | |
| 49 | : "No notes yet. Press a to add one.", | |
| 50 | ), | |
| 51 | ); | |
| 52 | rows.forEach((note, i) => { | |
| 53 | const body = i === cursor ? bold(note.body) : note.body; | |
| 54 | lines.push( | |
| 55 | `${i === cursor ? "▸" : " "} ${body} ${dim(`${note.author_name} · ${note.created_at.slice(0, 16).replace("T", " ")}`)}`, | |
| 56 | ); | |
| 57 | }); | |
| 58 | lines.push(""); | |
| 59 | if (typing !== undefined) lines.push(`New note: ${typing}${esc}7m ${esc}0m`); | |
| 60 | else if (said !== "") lines.push(said); | |
| 61 | else | |
| 62 | lines.push( | |
| 63 | dim( | |
| 64 | me.role === "anon" | |
| 65 | ? "j/k move · q quit · sign in with a key the forge knows to add" | |
| 66 | : "j/k move · a add · d delete · q quit", | |
| 67 | ), | |
| 68 | ); | |
| 69 | 38 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); |
| 70 | 39 | } |
| 71 | 40 | |
| @@ -74,42 +43,9 @@ process.stdin.resume(); | ||
| 74 | 43 | process.stdin.setEncoding("utf8"); |
| 75 | 44 | draw(); |
| 76 | 45 | process.stdin.on("data", (key: string) => { |
| 77 | said = ""; | |
| 78 | if (typing !== undefined) { | |
| 79 | if (key === "\r") { | |
| 80 | const added = addNote(db, me, { body: typing }); | |
| 81 | if (!added.ok) said = added.message; | |
| 82 | typing = undefined; | |
| 83 | cursor = 0; | |
| 84 | } else if (key === "\x1b") typing = undefined; | |
| 85 | else if (key === "\x7f") typing = typing.slice(0, -1); | |
| 86 | else if (key >= " ") typing += key; | |
| 87 | draw(); | |
| 88 | return; | |
| 89 | } | |
| 90 | 46 | if (key === "q" || key === "\x03") { |
| 91 | 47 | out(`${esc}2J${esc}H`); |
| 92 | 48 | process.exit(0); |
| 93 | 49 | } |
| 94 | const rows = notes(); | |
| 95 | if (key === "j" || key === "\x1b[B") | |
| 96 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); | |
| 97 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 98 | if (key === "a") { | |
| 99 | if (me.role === "anon") said = "Sign in to add a note."; | |
| 100 | else typing = ""; | |
| 101 | } | |
| 102 | if (key === "d") { | |
| 103 | const note = rows[cursor]; | |
| 104 | if (note === undefined) said = "Nothing to remove."; | |
| 105 | else if (!canDelete(me, note)) | |
| 106 | said = "Only the author or an admin can remove a note."; | |
| 107 | else { | |
| 108 | const removed = deleteNote(db, me, { id: note.id }); | |
| 109 | if (!removed.ok) said = removed.message; | |
| 110 | cursor = Math.min(cursor, Math.max(0, rows.length - 2)); | |
| 111 | } | |
| 112 | } | |
| 113 | draw(); | |
| 114 | 50 | }); |
| 115 | 51 | process.stdout.on("resize", draw); |
static/app.css +122 −44
| @@ -65,7 +65,7 @@ body { | ||
| 65 | 65 | sans-serif; |
| 66 | 66 | } |
| 67 | 67 | .page { |
| 68 | max-width: 40rem; | |
| 68 | max-width: 58rem; | |
| 69 | 69 | margin: 0 auto; |
| 70 | 70 | padding: var() var() var(); |
| 71 | 71 | } |
| @@ -110,46 +110,138 @@ code, | ||
| 110 | 110 | } |
| 111 | 111 | |
| 112 | 112 | /* Blocks. */ |
| 113 | .card { | |
| 113 | .row { | |
| 114 | display: flex; | |
| 115 | flex-wrap: wrap; | |
| 116 | align-items: center; | |
| 117 | gap: var(); | |
| 118 | } | |
| 119 | .empty { | |
| 120 | color: var(); | |
| 121 | font-size: var(); | |
| 122 | text-wrap: pretty; | |
| 123 | } | |
| 124 | ||
| 125 | /* The arena and what stands beside it. One column on a phone; the sea and | |
| 126 | its panels side by side once there is room for both to breathe. */ | |
| 127 | .arena-layout { | |
| 128 | display: grid; | |
| 129 | gap: var(); | |
| 130 | align-items: start; | |
| 131 | } | |
| 132 | @media (min-width: 52rem) { | |
| 133 | .arena-layout { | |
| 134 | grid-template-columns: minmax(0, 1.5fr) minmax(17rem, 1fr); | |
| 135 | gap: var() var(); | |
| 136 | } | |
| 137 | } | |
| 138 | .arena-column, | |
| 139 | .side-column { | |
| 140 | display: grid; | |
| 141 | gap: var(); | |
| 142 | min-width: 0; | |
| 143 | } | |
| 144 | ||
| 145 | /* The sea keeps its square from CSS alone: width is what makes it square, | |
| 146 | so a height cap can never flatten it, and nothing shifts when the canvas | |
| 147 | arrives to fill it. */ | |
| 148 | .arena-box { | |
| 149 | width: min(100%, 68vh); | |
| 150 | width: min(100%, 68svh); | |
| 151 | aspect-ratio: 1; | |
| 152 | margin-inline: auto; | |
| 153 | display: grid; | |
| 154 | place-items: center; | |
| 155 | padding: var(); | |
| 156 | box-sizing: border-box; | |
| 157 | background: var(); | |
| 158 | background: | |
| 159 | radial-gradient( | |
| 160 | circle at 50% 42%, | |
| 161 | color-mix(in oklab, var() 12%, var()), | |
| 162 | var() 70% | |
| 163 | ), | |
| 164 | var(); | |
| 165 | border: 1px solid var(); | |
| 166 | border-radius: calc(var() * 1.6); | |
| 167 | position: relative; | |
| 168 | overflow: hidden; | |
| 169 | } | |
| 170 | /* The rim of the circular sea, drawn once so the square reads as water. */ | |
| 171 | .arena-box::before { | |
| 172 | content: ""; | |
| 173 | position: absolute; | |
| 174 | inset: 4%; | |
| 175 | border: 1px dashed var(); | |
| 176 | border-radius: 50%; | |
| 177 | } | |
| 178 | .arena-empty { | |
| 179 | position: relative; | |
| 180 | max-width: 24rem; | |
| 181 | text-align: center; | |
| 182 | text-wrap: balance; | |
| 183 | font-size: var(); | |
| 184 | line-height: 1.4; | |
| 185 | } | |
| 186 | .arena-empty .muted { | |
| 187 | display: inline-block; | |
| 188 | margin-top: var(); | |
| 189 | font-size: var(); | |
| 190 | line-height: 1.5; | |
| 191 | } | |
| 192 | .rule { | |
| 193 | max-width: 34rem; | |
| 194 | margin-inline: auto; | |
| 195 | text-align: center; | |
| 196 | text-wrap: pretty; | |
| 197 | color: var(); | |
| 198 | font-size: var(); | |
| 199 | } | |
| 200 | ||
| 201 | /* A panel: a small heading, then the one thing it says. */ | |
| 202 | .panel { | |
| 114 | 203 | background: var(); |
| 115 | 204 | border: 1px solid var(); |
| 116 | 205 | border-radius: var(); |
| 117 | 206 | padding: var(); |
| 118 | margin-bottom: var(); | |
| 119 | } | |
| 120 | .row { | |
| 121 | display: flex; | |
| 122 | align-items: center; | |
| 207 | display: grid; | |
| 123 | 208 | gap: var(); |
| 124 | 209 | } |
| 125 | .list { | |
| 126 | list-style: none; | |
| 127 | margin: 0; | |
| 128 | padding: 0; | |
| 129 | } | |
| 130 | .note { | |
| 210 | .panel-title { | |
| 131 | 211 | display: flex; |
| 132 | 212 | justify-content: space-between; |
| 133 | align-items: flex-start; | |
| 213 | align-items: baseline; | |
| 134 | 214 | gap: var(); |
| 135 | padding: var() 0; | |
| 136 | border-top: 1px solid var(); | |
| 137 | } | |
| 138 | .note:first-child { | |
| 139 | border-top: 0; | |
| 140 | } | |
| 141 | .note p { | |
| 142 | overflow-wrap: anywhere; | |
| 143 | } | |
| 144 | .note small { | |
| 215 | margin: 0; | |
| 145 | 216 | font-size: var(); |
| 146 | } | |
| 147 | .empty { | |
| 148 | padding: var() var(); | |
| 149 | text-align: center; | |
| 217 | font-weight: 650; | |
| 218 | letter-spacing: 0.08em; | |
| 219 | text-transform: uppercase; | |
| 150 | 220 | color: var(); |
| 151 | border: 1px dashed var(); | |
| 152 | border-radius: var(); | |
| 221 | } | |
| 222 | .count { | |
| 223 | font-variant-numeric: tabular-nums; | |
| 224 | letter-spacing: 0; | |
| 225 | font-weight: 500; | |
| 226 | } | |
| 227 | .clock-time { | |
| 228 | font-size: 2.5rem; | |
| 229 | font-weight: 600; | |
| 230 | line-height: 1; | |
| 231 | letter-spacing: -0.02em; | |
| 232 | font-variant-numeric: tabular-nums; | |
| 233 | /* Room for the widest time it will ever show, reserved now. */ | |
| 234 | min-width: 5ch; | |
| 235 | } | |
| 236 | .sr-only { | |
| 237 | position: absolute; | |
| 238 | width: 1px; | |
| 239 | height: 1px; | |
| 240 | padding: 0; | |
| 241 | margin: -1px; | |
| 242 | overflow: hidden; | |
| 243 | clip-path: inset(50%); | |
| 244 | white-space: nowrap; | |
| 153 | 245 | } |
| 154 | 246 | footer { |
| 155 | 247 | margin-top: var(); |
| @@ -162,20 +254,6 @@ footer code { | ||
| 162 | 254 | } |
| 163 | 255 | |
| 164 | 256 | /* Controls: at least 44px on a side, keyboard and mouse alike. */ |
| 165 | .input { | |
| 166 | flex: 1; | |
| 167 | min-width: 0; | |
| 168 | min-height: var(); | |
| 169 | padding: 0 var(); | |
| 170 | font: inherit; | |
| 171 | color: inherit; | |
| 172 | background: var(); | |
| 173 | border: 1px solid var(); | |
| 174 | border-radius: var(); | |
| 175 | } | |
| 176 | .input::placeholder { | |
| 177 | color: var(); | |
| 178 | } | |
| 179 | 257 | .button { |
| 180 | 258 | min-height: var(); |
| 181 | 259 | padding: 0 var(); |
x