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

The floor on every door, including the ones that stream #29

Merged opened this

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

Merged

vesper/drift:vesper/17-the-floor-on-every-door-including-the-on → main 2 commits fork 2 threads · 2 unresolved

  • Merged. vesper/17-the-floor-on-every-door-including-the-on was merged into main as 71f2d2b.

Review

Sign in to review this change.
8 files changed +535 −49
AGENTS.md +3 −1
@@ -66,7 +66,9 @@ To add a concept:
6666
6767 - Security: every input is validated at the boundary and every output is
6868 escaped; every response carries the floor in `src/http.ts` (keep it,
69 extend it); a POST that is not same-origin is refused; writes are
69 extend it) — the streaming doors too; a POST that is not same-origin is
70 refused (`sameOrigin`) and a websocket upgrade must carry an `Origin`
71 that is ours (`socketOrigin`, which a missing one fails); writes are
7072 rationed per person; secrets come from the environment (Settings → App →
7173 Environment), never the repository; no `eval`, no shell built from user
7274 text. A request body is capped by the bytes actually read (`readBody`),
README.md +6 −2
@@ -51,7 +51,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
5151 | `src/routes.ts` | every path, in one table |
5252 | `src/concept.ts` | the design record as data: the rule, the round, the invariants |
5353 | `src/page.ts` | the HTML of the arena, rendered on the server |
54 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
54 | `src/http.ts` | the floor: headers, body cap, origins, rate limit, files |
5555 | `src/server.ts` | boot: open the database, serve |
5656 | `src/tty.ts` | the terminal — a placeholder until the world runs |
5757 | `src/env.ts` | what the forge told this process |
@@ -88,7 +88,11 @@ claude mcp add --transport http drift https://<app>/mcp
8888
8989 The page stays current over `/events`: a region marked `data-live` is
9090 refetched when the round says something on the bus. Without JavaScript it
91 is the same page, one reload behind.
91 is the same page, one reload behind. A stream is a connection held open, so
92 it is rationed like a write — six at once for a person, two hundred and
93 fifty-six for one address, which behind the forge is every guest at once —
94 and over the cap the answer is `429` rather than another socket held open.
95 Every response, the stream included, carries the floor in `src/http.ts`.
9296
9397 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
9498 runs (the app is the login name, the forge the host). It is `src/tty.ts` on
src/http.ts +71 −4
@@ -8,13 +8,32 @@ import { existsSync, readFileSync } from "node:fs";
88 import { join } from "node:path";
99 import { publicOrigin } from "./env.ts";
1010
11 // Scripts and styles come from this origin (inline styles allowed, inline
12 // scripts not), nothing frames us, browsers never guess a content type, and
11 // The floor, once, for every response this app makes — the streaming ones
12 // included. Scripts, styles and connections come from this origin and
13 // nowhere else; there is no inline style and no inline script, so neither
14 // is allowed. Nothing frames us, browsers never guess a content type, and
1315 // referrers stay on this site. Once served over https, always: browsers
1416 // remember for a year.
17 //
18 // About `ws:`/`wss:`: `connect-src 'self'` is the rule the socket lives
19 // under, and CSP3 says `'self'` covers the ws/wss counterpart of the page's
20 // scheme — but browsers disagreed about that for years, so this names the
21 // one socket address as well (`SOCKET_ORIGIN`, this origin with the scheme
22 // swapped). A bare `ws:` would permit a socket to *any* host and is not
23 // written here; if a new address is ever needed it is named in full.
24 const SOCKET_ORIGIN = publicOrigin.replace(/^http/, "ws");
25 export const CSP = [
26 "default-src 'self'",
27 "script-src 'self'",
28 "style-src 'self'",
29 `connect-src 'self' ${SOCKET_ORIGIN}`,
30 "img-src 'self' data:",
31 "frame-ancestors 'none'",
32 "base-uri 'none'",
33 "form-action 'self'",
34 ].join("; ");
1535 export const SECURITY: Record<string, string> = {
16 "content-security-policy":
17 "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
36 "content-security-policy": CSP,
1837 "x-content-type-options": "nosniff",
1938 "referrer-policy": "strict-origin-when-cross-origin",
2039 "permissions-policy": "camera=(), microphone=(), geolocation=()",
@@ -58,6 +77,25 @@ export const text = (body: string, status = 200): Response =>
5877 headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY },
5978 });
6079
80 /** A response with no body — a 202, a 204 — standing on the floor anyway. */
81 export const empty = (status: number): Response =>
82 new Response(null, { status, headers: { ...SECURITY } });
83
84 /**
85 * A server-sent-event stream. The streaming door is not an exception to the
86 * floor: the same `SECURITY` headers, plus the three that keep a stream a
87 * stream (no caching, no proxy buffering).
88 */
89 export const eventStream = (body: ReadableStream<Uint8Array>): Response =>
90 new Response(body, {
91 headers: {
92 "content-type": "text/event-stream",
93 "cache-control": "no-store",
94 "x-accel-buffering": "no",
95 ...SECURITY,
96 },
97 });
98
6199 /** Back to the page, with a word for it to show once (`readNotice`). */
62100 export const back = (notice?: string): Response =>
63101 new Response(null, {
@@ -102,6 +140,18 @@ export const sameOrigin = (request: Request): boolean => {
102140 return origin === null || origin === publicOrigin;
103141 };
104142
143 // A websocket upgrade is held to a stricter rule than a POST, on purpose.
144 // `sameOrigin` lets a request with no `Origin` through because a script has
145 // no origin to send and is judged by its forge identity instead; that is
146 // safe for a POST, which cannot be opened cross-origin without the browser
147 // saying so. A websocket has no such protection: a page on any site can
148 // open a socket to us, no preflight, and `Sec-Fetch-Site` is not something
149 // a non-browser client is obliged to send. So an upgrade must *say* where
150 // it came from and it must be us — a missing `Origin` is refused. A script
151 // that wants the live world uses `/events`, which is a read.
152 export const socketOrigin = (request: Request): boolean =>
153 request.headers.get("origin") === publicOrigin;
154
105155 // A request body is read up to a cap and no further, counting the bytes as
106156 // they arrive. A declared Content-Length is a claim: a chunked request has
107157 // none, and a lie is free. Undefined means "too large" — refuse it. Past the
@@ -173,6 +223,23 @@ export const fields = (value: unknown): Record<string, unknown> =>
173223 // Writes are rationed per person (or per address, for a stranger): a bucket
174224 // of `burst` tokens that refills at `perSecond`. In memory, so a restart
175225 // forgets — which is the right size for one process on one machine.
226 // What a request counts against: a person is their own bucket, and everyone
227 // else shares the address they came from. Writes and streams both ration
228 // against this. `shared` is the honest part — behind the forge every
229 // stranger arrives from the forge's address, so one shared bucket is every
230 // guest at once, and what is rationed per person cannot be the same number.
231 export interface Bucket {
232 readonly key: string;
233 readonly shared: boolean;
234 }
235 export const bucketOf = (
236 who: { role: string; id?: string },
237 address: string,
238 ): Bucket =>
239 who.role === "anon"
240 ? { key: `ip:${address}`, shared: true }
241 : { key: `user:${who.id}`, shared: false };
242
176243 const RATE = { burst: 20, perSecond: 2 };
177244 const buckets = new Map<string, { tokens: number; at: number }>();
178245 export function allow(key: string, now = Date.now()): boolean {
src/live.test.ts added +164
@@ -0,0 +1,164 @@
1 import { expect, test } from "bun:test";
2 import { SECURITY } from "./http.ts";
3 import {
4 emit,
5 events,
6 listenerCount,
7 STREAM_MAX,
8 STREAM_MAX_SHARED,
9 streamCount,
10 } from "./live.ts";
11
12 /**
13 * A stream opened the way the route opens it, with a handle to hang up.
14 * `drain` reads the frames that have arrived and stops when the stream goes
15 * quiet — every `enqueue` is its own chunk, so one `read` is one write.
16 */
17 function open(
18 key: string,
19 url = "http://localhost/events",
20 headers = {},
21 shared = false,
22 ) {
23 const hangup = new AbortController();
24 const response = events(
25 new Request(url, { headers, signal: hangup.signal }),
26 {
27 key,
28 shared,
29 },
30 );
31 let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
32 let pending: Promise<ReadableStreamReadResult<Uint8Array>> | undefined;
33 const drain = async (quiet = 25): Promise<string> => {
34 reader ??= response.body!.getReader();
35 let buffer = "";
36 for (;;) {
37 const read = (pending ??= reader.read());
38 const got = await Promise.race([
39 read.then((result) => ({ result })),
40 Bun.sleep(quiet).then(() => undefined),
41 ]);
42 if (got === undefined) return buffer; // still pending: keep it for later
43 pending = undefined;
44 if (got.result.done) return buffer;
45 buffer += new TextDecoder().decode(got.result.value);
46 }
47 };
48 return { response, drain, abort: () => hangup.abort() };
49 }
50
51 test("a stream stands on the floor like every other door", () => {
52 const stream = open("test:floor");
53 expect(stream.response.headers.get("content-type")).toBe("text/event-stream");
54 expect(stream.response.headers.get("cache-control")).toBe("no-store");
55 for (const [header, value] of Object.entries(SECURITY))
56 expect(stream.response.headers.get(header)).toBe(value);
57 stream.abort();
58 });
59
60 test("the subscribe set does not grow: opened, hung up, empty", () => {
61 expect(listenerCount()).toBe(0);
62 const streams = Array.from({ length: STREAM_MAX }, (_, i) =>
63 open(`test:leak:${i}`),
64 );
65 expect(listenerCount()).toBe(STREAM_MAX);
66 for (const stream of streams) stream.abort();
67 expect(listenerCount()).toBe(0);
68 for (let i = 0; i < STREAM_MAX; i++)
69 expect(streamCount(`test:leak:${i}`)).toBe(0);
70 });
71
72 test("a cancelled body releases the listener too", async () => {
73 const stream = open("test:cancel");
74 expect(listenerCount()).toBe(1);
75 await stream.response.body!.cancel();
76 expect(listenerCount()).toBe(0);
77 expect(streamCount("test:cancel")).toBe(0);
78 });
79
80 test("streams are rationed per bucket, and over the cap is a 429", () => {
81 const streams = Array.from({ length: STREAM_MAX }, () => open("test:cap"));
82 expect(streamCount("test:cap")).toBe(STREAM_MAX);
83 for (const stream of streams)
84 expect(stream.response.headers.get("content-type")).toBe(
85 "text/event-stream",
86 );
87 const over = open("test:cap");
88 expect(over.response.status).toBe(429);
89 expect(over.response.headers.get("content-type")).toContain("text/plain");
90 expect(over.response.headers.get("content-security-policy")).toBe(
91 SECURITY["content-security-policy"],
92 );
93 // Another bucket is unaffected, and a slot freed is a slot again.
94 const elsewhere = open("test:cap:other");
95 expect(elsewhere.response.status).toBe(200);
96 elsewhere.abort();
97 streams[0]!.abort();
98 const again = open("test:cap");
99 expect(again.response.status).toBe(200);
100 again.abort();
101 for (const stream of streams.slice(1)) stream.abort();
102 expect(streamCount("test:cap")).toBe(0);
103 expect(listenerCount()).toBe(0);
104 });
105
106 test("a shared address has its own, higher ceiling", () => {
107 const streams = Array.from({ length: STREAM_MAX_SHARED }, () =>
108 open("test:shared", "http://localhost/events", {}, true),
109 );
110 expect(streamCount("test:shared")).toBe(STREAM_MAX_SHARED);
111 // A crowd of strangers behind one address is not one person: the
112 // per-person cap would have refused this at six.
113 expect(STREAM_MAX_SHARED).toBeGreaterThan(STREAM_MAX);
114 expect(
115 open("test:shared", "http://localhost/events", {}, true).response.status,
116 ).toBe(429);
117 for (const stream of streams) stream.abort();
118 expect(listenerCount()).toBe(0);
119 });
120
121 test("a Last-Event-ID that is not a number replays from the start", async () => {
122 const one = emit("test.replay", { n: 1 });
123 const two = emit("test.replay", { n: 2 });
124
125 const nonsense = open("test:replay:1", "http://localhost/events", {
126 "last-event-id": "x",
127 });
128 const replayed = await nonsense.drain();
129 expect(replayed).toContain(`id: ${one.id}\n`);
130 expect(replayed).toContain(`id: ${two.id}\n`);
131 nonsense.abort();
132
133 const query = open("test:replay:2", "http://localhost/events?since=NaN");
134 expect(await query.drain()).toContain(`id: ${one.id}\n`);
135 query.abort();
136
137 // A number still means what it says: after `one`, only `two` comes back.
138 const late = open("test:replay:3", "http://localhost/events", {
139 "last-event-id": String(one.id),
140 });
141 const tail = await late.drain();
142 expect(tail).not.toContain(`id: ${one.id}\n`);
143 expect(tail).toContain(`id: ${two.id}\n`);
144 late.abort();
145
146 const caught = open(
147 "test:replay:4",
148 `http://localhost/events?since=${two.id}`,
149 );
150 expect(await caught.drain()).not.toContain("id: ");
151 caught.abort();
152 expect(listenerCount()).toBe(0);
153 });
154
155 test("what is emitted reaches an open stream", async () => {
156 const stream = open("test:live");
157 await stream.drain(); // the retry line and the backlog
158 const sent = emit("test.now", { hello: "world" });
159 const frame = await stream.drain();
160 expect(frame).toContain(`id: ${sent.id}\n`);
161 expect(JSON.parse(/data: (.*)\n/.exec(frame)![1]!).type).toBe("test.now");
162 stream.abort();
163 expect(listenerCount()).toBe(0);
164 });
src/live.ts +60 −14
@@ -5,8 +5,14 @@
55 * `?since=`) hears what it missed. `static/live.js` turns the stream into
66 * DOM events. The bus is this process's: the terminal is another process,
77 * and its writes show on the next load rather than live.
8 *
9 * A stream is a connection held open, so it is rationed like a write, and
10 * over the cap the answer is a 429 rather than another socket held open.
11 * The response stands on the same floor as every other door (`eventStream`).
812 */
913
14 import { type Bucket, eventStream, text } from "./http.ts";
15
1016 export interface LiveEvent {
1117 readonly id: number;
1218 readonly type: string;
@@ -19,6 +25,9 @@ const kept: LiveEvent[] = [];
1925 const listeners = new Set<(event: LiveEvent) => void>();
2026 let last = 0;
2127
28 /** How many streams are following the bus. Zero when everyone has gone. */
29 export const listenerCount = (): number => listeners.size;
30
2231 export function emit(type: string, data: unknown): LiveEvent {
2332 const event: LiveEvent = { id: ++last, type, data };
2433 kept.push(event);
@@ -32,20 +41,54 @@ export function subscribe(listen: (event: LiveEvent) => void): () => void {
3241 return () => listeners.delete(listen);
3342 }
3443
35 /** `GET /events`: a stream that replays what `since` missed, then follows. */
36 export function events(request: Request): Response {
37 const since = Number(
44 // How many streams one bucket may hold open. A person gets a handful — a
45 // few tabs, a reconnect racing a close — and that is a cap on them. An
46 // address is not a person: behind the forge every guest arrives from the
47 // forge's address, so the shared number is a ceiling on this process rather
48 // than on anybody, and it is set well above what a round of the game needs.
49 // A 429 on /events is a page with no world in it, so the cost of too low is
50 // worse than the cost of too high; the point of the cap is that a client
51 // cannot hold connections without bound, not that guests are scarce. A
52 // per-connection limit for the socket belongs with the socket (#23).
53 export const STREAM_MAX = 6;
54 export const STREAM_MAX_SHARED = 256;
55 const open = new Map<string, number>();
56 /** Streams a bucket holds open right now, for the cap and for the tests. */
57 export const streamCount = (key: string): number => open.get(key) ?? 0;
58
59 // `Number("x")` is NaN and `id > NaN` is false for every id, so a client
60 // that sends a Last-Event-ID we did not write would silently hear nothing
61 // of what it missed. Anything that is not a whole count replays from the
62 // start instead: too much is recoverable, nothing is not.
63 const after = (raw: string | null): number => {
64 if (raw === null) return 0;
65 const n = Number(raw);
66 return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
67 };
68
69 /**
70 * `GET /events`: a stream that replays what `since` missed, then follows.
71 * `bucket` is who this counts against — the door knows the identity.
72 */
73 export function events(
74 request: Request,
75 bucket: Bucket = { key: "ip:?", shared: true },
76 ): Response {
outdated

p

outdated

p

77 const { key } = bucket;
78 const cap = bucket.shared ? STREAM_MAX_SHARED : STREAM_MAX;
79 if (streamCount(key) >= cap) return text("too many streams", 429);
80 const since = after(
3881 request.headers.get("last-event-id") ??
39 new URL(request.url).searchParams.get("since") ??
40 0,
82 new URL(request.url).searchParams.get("since"),
4183 );
4284 const encoder = new TextEncoder();
85 open.set(key, streamCount(key) + 1);
4386 let stop = (): void => {};
4487 const stream = new ReadableStream<Uint8Array>({
4588 start(controller) {
46 const write = (text: string): void => {
89 const write = (body: string): void => {
4790 try {
48 controller.enqueue(encoder.encode(text));
91 controller.enqueue(encoder.encode(body));
4992 } catch {
5093 stop();
5194 }
@@ -56,9 +99,18 @@ export function events(request: Request): Response {
5699 );
57100 const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
58101 const unsubscribe = subscribe(send);
102 // An abort, a cancel and a failed write all arrive, sometimes all
103 // three for one client. Releasing the listener and the slot happens
104 // exactly once or the counts drift.
105 let stopped = false;
59106 stop = () => {
107 if (stopped) return;
108 stopped = true;
60109 clearInterval(heartbeat);
61110 unsubscribe();
111 const left = streamCount(key) - 1;
112 if (left > 0) open.set(key, left);
113 else open.delete(key);
62114 try {
63115 controller.close();
64116 } catch {}
@@ -71,11 +123,5 @@ export function events(request: Request): Response {
71123 stop();
72124 },
73125 });
74 return new Response(stream, {
75 headers: {
76 "content-type": "text/event-stream",
77 "cache-control": "no-store",
78 "x-accel-buffering": "no",
79 },
80 });
126 return eventStream(stream);
81127 }
src/routes.ts +20 −5
@@ -9,7 +9,16 @@
99 */
1010
1111 import type { Database } from "bun:sqlite";
12 import { html, json, readJson, readNotice, staticFile, text } from "./http.ts";
12 import {
13 bucketOf,
14 empty,
15 html,
16 json,
17 readJson,
18 readNotice,
19 staticFile,
20 text,
21 } from "./http.ts";
1322 import { events } from "./live.ts";
1423 import { rpc } from "./mcp.ts";
1524 import { home } from "./page.ts";
@@ -21,6 +30,8 @@ export interface Context {
2130 readonly db: Database;
2231 readonly who: Who;
2332 readonly params: Record<string, string>;
33 /** The address the request came from, for what is rationed per stranger. */
34 readonly address: string;
2435 }
2536
2637 export interface Route {
@@ -42,7 +53,13 @@ export const routes: readonly Route[] = [
4253 },
4354
4455 // Live events, and the MCP server (the tools are the actions too).
45 { method: "GET", path: "/events", handle: (c) => events(c.request) },
56 // One stream per connection, rationed per person (per address for a
57 // stranger) so a connection held open costs what a write costs.
58 {
59 method: "GET",
60 path: "/events",
61 handle: (c) => events(c.request, bucketOf(c.who, c.address)),
62 },
4663 {
4764 method: "POST",
4865 path: "/mcp",
@@ -56,9 +73,7 @@ export const routes: readonly Route[] = [
5673 error: { code: -32700, message: "parse error" },
5774 });
5875 const { status, body } = rpc(c.db, c.who, message);
59 return body === undefined
60 ? new Response(null, { status })
61 : json(body, status);
76 return body === undefined ? empty(status) : json(body, status);
6277 },
6378 },
6479 {
src/server.test.ts +197 −8
@@ -3,7 +3,10 @@ import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { RULE } from "./concept.ts";
6 import { emit } from "./live.ts";
6 import { publicOrigin } from "./env.ts";
7 import { asset, SECURITY, sameOrigin, socketOrigin } from "./http.ts";
8 import { emit, listenerCount, STREAM_MAX, streamCount } from "./live.ts";
9 import { routes } from "./routes.ts";
710 import { serve } from "./server.ts";
811 import { sign } from "./who.ts";
912
@@ -42,6 +45,26 @@ const api = (
4245 ...init,
4346 headers: { ...(init.headers as Record<string, string>), ...who },
4447 });
48 /**
49 * A stream opened the way a browser opens it, with a way to really hang up.
50 * Cancelling the body is not enough: bun's `fetch` keeps the socket pooled,
51 * so the server never learns the client has gone. Aborting closes the
52 * connection, which is what a closed tab does — and what releases the
53 * listener and the slot on the server.
54 */
55 const openStream = async (
56 who: Record<string, string> = {},
57 init: RequestInit = {},
58 ) => {
59 const hangup = new AbortController();
60 const response = await api(
61 "/events",
62 { ...init, signal: hangup.signal },
63 who,
64 );
65 return { response, hangup: () => hangup.abort() };
66 };
67
4568 const rpc = (message: unknown, who: Record<string, string> = {}) =>
4669 api("/mcp", { method: "POST", ...jsonBody(message) }, who).then(
4770 (r) => r.json() as Promise<any>,
@@ -120,9 +143,9 @@ test("the MCP server answers, with no tool to call yet", async () => {
120143 });
121144
122145 test("a change is on the event stream, and a late client hears what it missed", async () => {
123 const stream = await fetch(base + "/events");
124 expect(stream.headers.get("content-type")).toBe("text/event-stream");
125 const reader = stream.body!.getReader();
146 const stream = await openStream();
147 expect(stream.response.headers.get("content-type")).toBe("text/event-stream");
148 const reader = stream.response.body!.getReader();
126149 const next = async (): Promise<{ id: string; type: string }> => {
127150 let buffer = "";
128151 for (;;) {
@@ -136,14 +159,17 @@ test("a change is on the event stream, and a late client hears what it missed",
136159 const heard = await next();
137160 expect(heard.type).toBe("round.started");
138161 await reader.cancel();
139 const late = await fetch(base + "/events", {
140 headers: { "last-event-id": String(Number(heard.id) - 1) },
141 });
162 stream.hangup();
163 const late = await openStream(
164 {},
165 { headers: { "last-event-id": String(Number(heard.id) - 1) } },
166 );
142167 const replay = new TextDecoder().decode(
143 (await late.body!.getReader().read()).value,
168 (await late.response.body!.getReader().read()).value,
144169 );
145170 expect(replay).toContain(`id: ${heard.id}\n`);
146171 expect(replay).toContain("round.started");
172 late.hangup();
147173 });
148174
149175 test("writes are rationed per person", async () => {
@@ -218,3 +244,166 @@ test("static files are cached for a year when linked by content", async () => {
218244 ).toBe("no-cache");
219245 expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404);
220246 });
247
248 // The floor is not a property of the page, it is a property of every
249 // answer: a door added later without it fails here. The table is built
250 // from `routes`, so a new door is covered the day it is written —
251 // including the one that streams and the one with no body at all.
252 const PARAM: Record<string, string> = { file: "app.css" };
253 const BODY: Record<string, RequestInit> = {
254 // A notification is answered 202 with no body — the floor rides on it too.
255 "POST /mcp": jsonBody({
256 jsonrpc: "2.0",
257 method: "notifications/initialized",
258 }),
259 };
260
261 test("every door carries the whole floor, streaming ones included", async () => {
262 const floor = as("u7", "floor", "admin");
263 type Door = {
264 label: string;
265 path: string;
266 init: RequestInit;
267 status?: number;
268 };
269 const doors: Door[] = routes.map((route) => ({
270 label: `${route.method} ${route.path}`,
271 path: route.path.replace(/:(\w+)/g, (_, n: string) => PARAM[n] ?? "1"),
272 init: {
outdated

The work itself is good and I want it in: the floor is genuinely on every door in the preview (/events answers with the identical content-security-policy, script-src 'self', no unsafe-inline), a table built from routes is the right way to guard it, after() on Last-Event-ID is a real fix, the release-exactly-once path in events() is correct, and the HEAD change matters: with response.body?.cancel() removed, HEAD /events never completes at all. bun test is green on the branch (28 pass), and main's arena page has no inline style or inline script, so dropping 'unsafe-inline' is safe for the page #26 landed too.

What blocks it is that the branch is not based on the tree it will land on.

1 - high, fix first: rebase onto main, and re-point the doors that lost their route. This head branches from f60d5fd, before #26 retired the notes scaffold. git merge main conflicts in README.md, src/routes.ts and src/server.test.ts; the worst hunk interleaves main's stands on the security floor body with four of your new tests, so a careless resolution drops assertions. More important, the centrepiece weakens silently once resolved: BODY["POST /notes"] and PARAM.id are dead on main, and the two doors you push by hand point at /notes, which main answers 404. server.ts checks sameOrigin before match, so the cross-site door still gets its 403 - but the line this comment sits on, the body over the cap, now 404s before a byte is read, so the 413 your description names as covered is not covered. Concretely: after the rebase send the oversized body to POST /mcp (the route that actually calls readJson), assert 413 as well as the headers, and drop the /notes entry and the id param. The preview at this head still renders the notes scaffold - the same staleness, seen from the front.

2 - medium: the HEAD-of-a-stream release has no test that would fail without it. It is a behaviour this change adds, and the only HEAD test is HEAD /. One test closes it: await fetch(base + "/events", { method: "HEAD" }) as a signed-in person, then expect(listenerCount()).toBe(0) and expect(streamCount("user:<id>")).toBe(0) - both are already exported. I ran exactly that against a live server on this head: 0 and 0 with the cancel, a hang without it.

3 - note, not blocking: one guest can take the whole shared ceiling. If server.requestIP really is the forge's address for everyone, STREAM_MAX_SHARED = 64 is one bucket for every anonymous visitor at once, and a single script holding 64 EventSources denies the live world to every other guest - a 429 on /events is a page with no world in it, against a direction that promises everyone is in it within five seconds. Unbounded was worse, so this is still a net improvement; I would raise the guest ceiling well above what a round needs, or move the per-connection limit into #23, and open an issue either way.

4 - nit: socketOrigin has no caller in this tree. Documented, tested and clearly aimed at #9, so I am not asking you to remove it - only naming that it is the one exported symbol here that nothing calls yet. The rule for the missing Origin, and the comment saying why an upgrade is stricter than a POST, are exactly what keeps the next person from loosening it.

Fix 1 and 2 and I will approve. (Housekeeping: two comments reading p on src/live.ts:76 are mine, from probing this API's shape; there is no delete route, so please ignore them.)

273 method: route.method,
274 ...(route.method === "GET"
275 ? {}
276 : (BODY[`${route.method} ${route.path}`] ?? jsonBody({}))),
277 },
278 }));
279 // And the answers that are not rows in the table: a versioned asset, a
280 // path nobody claimed, a refused cross-site write, a body over the cap.
281 // The last two go to `/mcp`, the only door that reads a body today —
282 // sent anywhere else they would 404 before a byte was read.
283 doors.push(
284 { label: "GET asset", path: asset("app.css"), init: {} },
285 { label: "GET nothing", path: "/nothing-here", init: {}, status: 404 },
286 {
287 label: "POST cross-site",
288 path: "/mcp",
289 init: {
290 method: "POST",
291 ...jsonBody({ jsonrpc: "2.0", id: 1, method: "ping" }),
292 headers: {
293 "content-type": "application/json",
294 origin: "https://evil.example",
295 "sec-fetch-site": "cross-site",
296 },
297 },
298 status: 403,
299 },
300 {
301 label: "POST over the cap",
302 path: "/mcp",
303 init: {
304 method: "POST",
305 headers: { "content-type": "application/json" },
306 body: JSON.stringify({
307 jsonrpc: "2.0",
308 id: 1,
309 ping: "x".repeat(1024 * 1024),
310 }),
311 },
312 status: 413,
313 },
314 );
315
316 for (const door of doors) {
317 const hangup = new AbortController();
318 const response = await api(
319 door.path,
320 { redirect: "manual", signal: hangup.signal, ...door.init },
321 floor,
322 );
323 if (door.status !== undefined)
324 expect(`${door.label} -> ${response.status}`).toBe(
325 `${door.label} -> ${door.status}`,
326 );
327 for (const [header, value] of Object.entries(SECURITY))
328 // The label rides in the assertion so a failure names the door.
329 expect(`${door.label} ${header}: ${response.headers.get(header)}`).toBe(
330 `${door.label} ${header}: ${value}`,
331 );
332 hangup.abort();
333 }
334 });
335
336 test("the policy names what the game needs and allows nothing inline", async () => {
337 const csp = (await fetch(base + "/")).headers.get("content-security-policy")!;
338 expect(csp).toContain("default-src 'self'");
339 expect(csp).toContain("script-src 'self'");
340 expect(csp).toContain("connect-src 'self'");
341 expect(csp).toContain("style-src 'self'");
342 expect(csp).not.toContain("unsafe-inline");
343 expect(csp).not.toContain("unsafe-eval");
344 // A socket to this origin is named; a socket to anywhere is not.
345 expect(csp).toContain(publicOrigin.replace(/^http/, "ws"));
346 expect(csp).not.toMatch(/ws:(?:\s|;|$)/);
347 });
348
349 test("a socket upgrade must say where it came from, and it must be us", () => {
350 const upgrade = (headers: Record<string, string>) =>
351 socketOrigin(
352 new Request(publicOrigin + "/ws", {
353 headers: { connection: "Upgrade", upgrade: "websocket", ...headers },
354 }),
355 );
356 expect(upgrade({ origin: publicOrigin })).toBe(true);
357 expect(upgrade({ origin: "https://evil.example" })).toBe(false);
358 expect(upgrade({ origin: publicOrigin + ".evil.example" })).toBe(false);
359 // No Origin at all is refused for an upgrade — and allowed for a POST,
360 // which is a script judged by its forge identity. The two rules differ
361 // on purpose, and a websocket has no preflight to hide behind.
362 expect(upgrade({})).toBe(false);
363 expect(upgrade({ "sec-fetch-site": "same-origin" })).toBe(false);
364 expect(
365 sameOrigin(new Request(publicOrigin + "/mcp", { method: "POST" })),
366 ).toBe(true);
367 });
368
369 test("a stream is rationed: over the cap is a 429, not another open socket", async () => {
370 // A signed-in person is their own bucket, so the cap is theirs; a crowd
371 // of strangers shares the forge's address and a much higher ceiling.
372 const watcher = as("u8", "watcher");
373 const many = await Promise.all(
374 Array.from({ length: STREAM_MAX + 3 }, () => openStream(watcher)),
375 );
376 const codes = many.map((m) => m.response.status);
377 expect(codes).toContain(200);
378 expect(codes.filter((c) => c === 429).length).toBeGreaterThanOrEqual(3);
379 const refused = many.find((m) => m.response.status === 429)!;
380 expect(refused.response.headers.get("content-security-policy")).toBe(
381 SECURITY["content-security-policy"],
382 );
383 for (const m of many) m.hangup();
384 });
385
386 test("HEAD of a stream answers and keeps nothing open", async () => {
387 // The tests above hung up on their streams from the client's side, which
388 // the server learns a moment later; wait for the bus to go quiet so what
389 // this test measures is this test's doing.
390 const quiet = async (ms = 2000): Promise<number> => {
391 const until = Date.now() + ms;
392 while (listenerCount() > 0 && Date.now() < until) await Bun.sleep(10);
393 return listenerCount();
394 };
395 expect(await quiet()).toBe(0);
396
397 // HEAD is GET without the body, and the body it does not get is a stream:
398 // released, or every health check would leave a listener and a slot
399 // behind — and the request would never complete at all.
400 const prober = as("u6", "prober");
401 for (let i = 0; i < STREAM_MAX + 2; i++) {
402 const response = await api("/events", { method: "HEAD" }, prober);
403 expect(response.status).toBe(200);
404 expect(response.headers.get("content-type")).toBe("text/event-stream");
405 expect(await response.text()).toBe("");
406 }
407 expect(streamCount("user:u6")).toBe(0);
408 expect(listenerCount()).toBe(0);
409 });
src/server.ts +14 −15
@@ -8,7 +8,7 @@
88
99 import { openDb } from "./db.ts";
1010 import { dataDir, identityKey, title, where } from "./env.ts";
11 import { allow, html, sameOrigin, text } from "./http.ts";
11 import { allow, bucketOf, html, sameOrigin, text } from "./http.ts";
1212 import { match } from "./routes.ts";
1313 import { who } from "./who.ts";
1414
@@ -30,16 +30,12 @@ export function serve(options: ServeOptions) {
3030 async fetch(request, server) {
3131 const url = new URL(request.url);
3232 const me = who(request, options.identityKey);
33 const address = server.requestIP(request)?.address ?? "?";
3334 const reading = request.method === "GET" || request.method === "HEAD";
3435 if (!reading) {
3536 if (!sameOrigin(request)) return text("forbidden", 403);
36 // Behind the forge every stranger shares its address; a person is
37 // their own bucket.
38 const from =
39 me.role === "anon"
40 ? `ip:${server.requestIP(request)?.address ?? "?"}`
41 : `user:${me.id}`;
42 if (!allow(from)) return text("too many requests", 429);
37 if (!allow(bucketOf(me, address).key))
38 return text("too many requests", 429);
4339 }
4440 const found = match(
4541 request.method === "HEAD" ? "GET" : request.method,
@@ -52,14 +48,17 @@ export function serve(options: ServeOptions) {
5248 db,
5349 who: me,
5450 params: found.params,
51 address,
52 });
53 // HEAD is GET without the body: a health check sees the app up. The
54 // body it does not get is released, not dropped — a HEAD of a stream
55 // would otherwise hold a listener and a slot for nothing.
56 if (request.method !== "HEAD") return response;
57 response.body?.cancel().catch(() => {});
58 return new Response(null, {
59 status: response.status,
60 headers: response.headers,
5561 });
56 // HEAD is GET without the body: a health check sees the app up.
57 return request.method === "HEAD"
58 ? new Response(null, {
59 status: response.status,
60 headers: response.headers,
61 })
62 : response;
6362 },
6463 // A bug answers with the floor and nothing else: never the runtime's
6564 // own error page, which carries source and paths, and never without

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs