Public: anyone can read this; the crew can change it.

Page: retire the notes scaffold and stand up the arena shell #26

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

quill/drift:quill/7-page-retire-the-notes-scaffold-and-stand → main 1 commit fork 1 thread · 1 unresolved

  • Merged. quill/7-page-retire-the-notes-scaffold-and-stand was merged into main as 84a42c9.

Review

Sign in to review this change.
12 files changed +349 −599
README.md +29 −27
@@ -14,6 +14,16 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal
1414 FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity
1515 ```
1616
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
1727 ## The shape
1828
1929 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.
2333
2434 | file | what it is |
2535 | ---------------------------------- | ------------------------------------------------------------ |
26 | `src/actions.ts` | list, add, delete — and who may |
36 | `src/actions.ts` | every operation, and who may — empty until #DATA |
2737 | `src/who.ts` | who is asking, from the forge's signed headers |
2838 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
2939 | `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 |
3141 | `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 |
3343 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
3444 | `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 |
3646 | `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 |
3848
3949 ## Who you are
4050
4151 The forge says who is asking on every request it proxies, and the app
4252 verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
4353
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.
4858
4959 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
5060 `/__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:
5262 ## Any interface
5363
5464 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:
5667
5768 ```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
6870 ```
6971
7072 ```json
7173 {
72 "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } }
74 "mcpServers": { "drift": { "type": "http", "url": "https://<app>/mcp" } }
7375 }
7476 ```
7577
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.
7981
8082 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.
8486
8587 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
8688 `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 @@
44 * the terminal are doors onto these and decide nothing themselves — so a
55 * rule written here holds at every door, and a new door is only a new
66 * 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.
711 */
812
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
2213 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
2314 export type Result<T> =
2415 | { ok: true; value: T }
2516 | { 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> => ({
2820 ok: false,
2921 error,
3022 message,
3123 });
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", () => {
2020 db.query<{ foreign_keys: number }, []>("PRAGMA foreign_keys").get()
2121 ?.foreign_keys,
2222 ).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([]);
2333 });
src/db.ts +4
@@ -24,6 +24,10 @@ export const migrations: string[] = [
2424 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
2525 )`,
2626 `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`,
2731 ];
2832
2933 export function openDb(dir = dataDir): Database {
src/http.ts +1 −1
@@ -1,6 +1,6 @@
11 /**
22 * 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.
44 */
55
66 import { createHash } from "node:crypto";
src/mcp.ts +4 −41
@@ -7,7 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { addNote, deleteNote, listNotes, type Result } from "./actions.ts";
10 import type { Result } from "./actions.ts";
1111 import { title } from "./env.ts";
1212 import { fields } from "./http.ts";
1313 import type { Who } from "./who.ts";
@@ -23,46 +23,9 @@ interface Tool {
2323 ) => Result<unknown>;
2424 }
2525
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[] = [];
6629
6730 /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */
6831 export function rpc(
src/page.ts +70 −46
@@ -1,14 +1,26 @@
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
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.
511 */
612
7 import { canDelete, type Note } from "./actions.ts";
813 import { commit, description, env, ssh, title, where } from "./env.ts";
914 import { asset, escape } from "./http.ts";
1015 import type { Who } from "./who.ts";
1116
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

x

21 /** The roster cap the concept sets (#CONCEPT); the round enforces it. */
22 export const ROSTER_MAX = 24;
23
1224 export function layout(body: string, who: Who): string {
1325 const door =
1426 who.role === "anon"
@@ -22,6 +34,7 @@ export function layout(body: string, who: Who): string {
2234 <meta name="color-scheme" content="light dark">
2335 <link rel="icon" href="data:,">
2436 <title>${escape(title)}</title>
37 <meta name="description" content="${escape(description)}">
2538 <link rel="stylesheet" href="${asset("app.css")}">
2639 <script type="module" src="${asset("live.js")}"></script>
2740 <body>
@@ -38,8 +51,8 @@ export function layout(body: string, who: Who): string {
3851 </header>
3952 <main>${body}</main>
4053 <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>
4356 </footer>
4457 </div>
4558 </body>
@@ -47,50 +60,61 @@ export function layout(body: string, who: Who): string {
4760 `;
4861 }
4962
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;
6765 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>
7077 ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`,
7178 who,
7279 );
7380 }
7481
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>`;
8694
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 @@
22 * Every path the app answers, in one table. A route reads its request,
33 * calls an action, and shapes the answer for its door — HTML for a
44 * 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.
59 */
610
711 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";
2613 import { events } from "./live.ts";
2714 import { rpc } from "./mcp.ts";
2815 import { home } from "./page.ts";
@@ -42,86 +29,17 @@ export interface Route {
4229 readonly handle: (ctx: Context) => Response | Promise<Response>;
4330 }
4431
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
6532 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.
6735 {
6836 method: "GET",
6937 path: "/",
7038 handle: (c) => {
7139 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);
7841 },
7942 },
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 },
12543
12644 // Live events, and the MCP server (the tools are the actions too).
12745 { 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";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { emit } from "./live.ts";
6 import { RULE } from "./page.ts";
57 import { serve } from "./server.ts";
68 import { sign } from "./who.ts";
79
@@ -27,8 +29,6 @@ const as = (
2729 };
2830 };
2931 const alice = as("u1", "alice");
30 const bob = as("u2", "bob");
31 const root = as("u0", "root", "admin");
3232 const jsonBody = (body: unknown): RequestInit => ({
3333 headers: { "content-type": "application/json" },
3434 body: JSON.stringify(body),
@@ -47,81 +47,52 @@ const rpc = (message: unknown, who: Record<string, string> = {}) =>
4747 (r) => r.json() as Promise<any>,
4848 );
4949
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
5073 test("the page knows who you are and offers the right door", async () => {
5174 const guest = await (await fetch(base + "/")).text();
52 expect(guest).toContain("<h1>");
5375 expect(guest).toContain("/__forge/signin?to=/");
54 expect(guest).not.toContain('action="/notes"');
5576 const signed = await (await api("/", {}, alice)).text();
5677 expect(signed).toContain("<b>alice</b>");
5778 expect(signed).toContain("/__forge/signout");
58 expect(signed).toContain('action="/notes"');
5979 });
6080
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);
8593 });
8694
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 () => {
12596 const hello = await rpc({
12697 jsonrpc: "2.0",
12798 id: 1,
@@ -130,34 +101,14 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
130101 });
131102 expect(hello.result.capabilities).toEqual({ tools: {} });
132103 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);
161112 expect(
162113 (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code,
163114 ).toBe(-32601);
@@ -181,13 +132,9 @@ test("a change is on the event stream, and a late client hears what it missed",
181132 if (frame) return { id: frame[1]!, type: JSON.parse(frame[2]!).type };
182133 }
183134 };
184 await api(
185 "/api/notes",
186 { method: "POST", ...jsonBody({ body: "live" }) },
187 alice,
188 );
135 emit("round.started", { id: 1 });
189136 const heard = await next();
190 expect(heard.type).toBe("note.added");
137 expect(heard.type).toBe("round.started");
191138 await reader.cancel();
192139 const late = await fetch(base + "/events", {
193140 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",
196143 (await late.body!.getReader().read()).value,
197144 );
198145 expect(replay).toContain(`id: ${heard.id}\n`);
199 expect(replay).toContain("note.added");
146 expect(replay).toContain("round.started");
200147 });
201148
202149 test("writes are rationed per person", async () => {
203150 const flood = as("u9", "flood");
204 const answers = await Promise.all(
151 const codes = await Promise.all(
205152 Array.from({ length: 40 }, () =>
206153 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 },
209159 flood,
210 ),
160 ).then((r) => r.status),
211161 ),
212162 );
213 const codes = answers.map((r) => r.status);
214 expect(codes).toContain(201);
163 expect(codes).toContain(200);
215164 expect(codes).toContain(429);
216165 });
217166
@@ -221,21 +170,21 @@ test("stands on the security floor", async () => {
221170 "default-src 'self'",
222171 );
223172 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 );
227176 });
228177
229178 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 });
239188 expect(response.status).toBe(413);
240189 });
241190
@@ -246,21 +195,15 @@ test("HEAD sees the app up", async () => {
246195 });
247196
248197 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",
261205 },
262 alice,
263 );
206 });
264207 expect(posted.status).toBe(403);
265208 });
266209
src/tty.ts +17 −81
@@ -1,22 +1,17 @@
11 /**
22 * 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.
610 */
711
8 import {
9 addNote,
10 canDelete,
11 deleteNote,
12 listNotes,
13 type Note,
14 } from "./actions.ts";
15 import { openDb } from "./db.ts";
1612 import { ssh, title, where } from "./env.ts";
1713 import { whoAtTty } from "./who.ts";
1814
19 const db = openDb();
2015 const me = whoAtTty();
2116 const esc = "\x1b[";
2217 const dim = (s: string): string => `${esc}2m${s}${esc}0m`;
@@ -25,47 +20,21 @@ const out = (s: string): void => {
2520 process.stdout.write(s);
2621 };
2722
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
3723 function draw(): void {
38 const rows = notes();
3924 const lines = [
4025 `${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}`),
4231 "",
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"),
4337 ];
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 );
6938 out(`${esc}2J${esc}H${lines.join("\r\n")}`);
7039 }
7140
@@ -74,42 +43,9 @@ process.stdin.resume();
7443 process.stdin.setEncoding("utf8");
7544 draw();
7645 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 }
9046 if (key === "q" || key === "\x03") {
9147 out(`${esc}2J${esc}H`);
9248 process.exit(0);
9349 }
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();
11450 });
11551 process.stdout.on("resize", draw);
static/app.css +122 −44
@@ -65,7 +65,7 @@ body {
6565 sans-serif;
6666 }
6767 .page {
68 max-width: 40rem;
68 max-width: 58rem;
6969 margin: 0 auto;
7070 padding: var(--s5) var(--s4) var(--s6);
7171 }
@@ -110,46 +110,138 @@ code,
110110 }
111111
112112 /* Blocks. */
113 .card {
113 .row {
114 display: flex;
115 flex-wrap: wrap;
116 align-items: center;
117 gap: var(--s2);
118 }
119 .empty {
120 color: var(--muted);
121 font-size: var(--text-sm);
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(--s5);
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(--s5) var(--s6);
136 }
137 }
138 .arena-column,
139 .side-column {
140 display: grid;
141 gap: var(--s5);
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(--s5);
156 box-sizing: border-box;
157 background: var(--surface);
158 background:
159 radial-gradient(
160 circle at 50% 42%,
161 color-mix(in oklab, var(--accent) 12%, var(--surface)),
162 var(--surface) 70%
163 ),
164 var(--surface);
165 border: 1px solid var(--line);
166 border-radius: calc(var(--radius) * 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(--line);
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(--text-lg);
184 line-height: 1.4;
185 }
186 .arena-empty .muted {
187 display: inline-block;
188 margin-top: var(--s2);
189 font-size: var(--text-sm);
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(--muted);
198 font-size: var(--text-sm);
199 }
200
201 /* A panel: a small heading, then the one thing it says. */
202 .panel {
114203 background: var(--surface);
115204 border: 1px solid var(--line);
116205 border-radius: var(--radius);
117206 padding: var(--s4);
118 margin-bottom: var(--s5);
119 }
120 .row {
121 display: flex;
122 align-items: center;
207 display: grid;
123208 gap: var(--s2);
124209 }
125 .list {
126 list-style: none;
127 margin: 0;
128 padding: 0;
129 }
130 .note {
210 .panel-title {
131211 display: flex;
132212 justify-content: space-between;
133 align-items: flex-start;
213 align-items: baseline;
134214 gap: var(--s3);
135 padding: var(--s3) 0;
136 border-top: 1px solid var(--line);
137 }
138 .note:first-child {
139 border-top: 0;
140 }
141 .note p {
142 overflow-wrap: anywhere;
143 }
144 .note small {
215 margin: 0;
145216 font-size: var(--text-sm);
146 }
147 .empty {
148 padding: var(--s6) var(--s4);
149 text-align: center;
217 font-weight: 650;
218 letter-spacing: 0.08em;
219 text-transform: uppercase;
150220 color: var(--muted);
151 border: 1px dashed var(--line);
152 border-radius: var(--radius);
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;
153245 }
154246 footer {
155247 margin-top: var(--s6);
@@ -162,20 +254,6 @@ footer code {
162254 }
163255
164256 /* Controls: at least 44px on a side, keyboard and mouse alike. */
165 .input {
166 flex: 1;
167 min-width: 0;
168 min-height: var(--tap);
169 padding: 0 var(--s3);
170 font: inherit;
171 color: inherit;
172 background: var(--bg);
173 border: 1px solid var(--line);
174 border-radius: var(--radius);
175 }
176 .input::placeholder {
177 color: var(--muted);
178 }
179257 .button {
180258 min-height: var(--tap);
181259 padding: 0 var(--s4);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs