Public: anyone can read this; the crew can change it.
The floor on every door, including the ones that stream #29
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/17-the-floor-on-every-door-including-the-on → main 2 commits fork 2 threads · 2 unresolved
AGENTS.md +3 −1
| @@ -66,7 +66,9 @@ To add a concept: | ||
| 66 | 66 | |
| 67 | 67 | - Security: every input is validated at the boundary and every output is |
| 68 | 68 | 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 | |
| 70 | 72 | rationed per person; secrets come from the environment (Settings → App → |
| 71 | 73 | Environment), never the repository; no `eval`, no shell built from user |
| 72 | 74 | 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. | ||
| 51 | 51 | | `src/routes.ts` | every path, in one table | |
| 52 | 52 | | `src/concept.ts` | the design record as data: the rule, the round, the invariants | |
| 53 | 53 | | `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 | | |
| 55 | 55 | | `src/server.ts` | boot: open the database, serve | |
| 56 | 56 | | `src/tty.ts` | the terminal — a placeholder until the world runs | |
| 57 | 57 | | `src/env.ts` | what the forge told this process | |
| @@ -88,7 +88,11 @@ claude mcp add --transport http drift https://<app>/mcp | ||
| 88 | 88 | |
| 89 | 89 | The page stays current over `/events`: a region marked `data-live` is |
| 90 | 90 | 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`. | |
| 92 | 96 | |
| 93 | 97 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 94 | 98 | 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"; | ||
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { publicOrigin } from "./env.ts"; |
| 10 | 10 | |
| 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 | |
| 13 | 15 | // referrers stay on this site. Once served over https, always: browsers |
| 14 | 16 | // 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("; "); | |
| 15 | 35 | 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, | |
| 18 | 37 | "x-content-type-options": "nosniff", |
| 19 | 38 | "referrer-policy": "strict-origin-when-cross-origin", |
| 20 | 39 | "permissions-policy": "camera=(), microphone=(), geolocation=()", |
| @@ -58,6 +77,25 @@ export const text = (body: string, status = 200): Response => | ||
| 58 | 77 | headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY }, |
| 59 | 78 | }); |
| 60 | 79 | |
| 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 | ||
| 61 | 99 | /** Back to the page, with a word for it to show once (`readNotice`). */ |
| 62 | 100 | export const back = (notice?: string): Response => |
| 63 | 101 | new Response(null, { |
| @@ -102,6 +140,18 @@ export const sameOrigin = (request: Request): boolean => { | ||
| 102 | 140 | return origin === null || origin === publicOrigin; |
| 103 | 141 | }; |
| 104 | 142 | |
| 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 | ||
| 105 | 155 | // A request body is read up to a cap and no further, counting the bytes as |
| 106 | 156 | // they arrive. A declared Content-Length is a claim: a chunked request has |
| 107 | 157 | // 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> => | ||
| 173 | 223 | // Writes are rationed per person (or per address, for a stranger): a bucket |
| 174 | 224 | // of `burst` tokens that refills at `perSecond`. In memory, so a restart |
| 175 | 225 | // 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 | ||
| 176 | 243 | const RATE = { burst: 20, perSecond: 2 }; |
| 177 | 244 | const buckets = new Map<string, { tokens: number; at: number }>(); |
| 178 | 245 | 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 @@ | ||
| 5 | 5 | * `?since=`) hears what it missed. `static/live.js` turns the stream into |
| 6 | 6 | * DOM events. The bus is this process's: the terminal is another process, |
| 7 | 7 | * 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`). | |
| 8 | 12 | */ |
| 9 | 13 | |
| 14 | import { type Bucket, eventStream, text } from "./http.ts"; | |
| 15 | ||
| 10 | 16 | export interface LiveEvent { |
| 11 | 17 | readonly id: number; |
| 12 | 18 | readonly type: string; |
| @@ -19,6 +25,9 @@ const kept: LiveEvent[] = []; | ||
| 19 | 25 | const listeners = new Set<(event: LiveEvent) => void>(); |
| 20 | 26 | let last = 0; |
| 21 | 27 | |
| 28 | /** How many streams are following the bus. Zero when everyone has gone. */ | |
| 29 | export const listenerCount = (): number => listeners.size; | |
| 30 | ||
| 22 | 31 | export function emit(type: string, data: unknown): LiveEvent { |
| 23 | 32 | const event: LiveEvent = { id: ++last, type, data }; |
| 24 | 33 | kept.push(event); |
| @@ -32,20 +41,54 @@ export function subscribe(listen: (event: LiveEvent) => void): () => void { | ||
| 32 | 41 | return () => listeners.delete(listen); |
| 33 | 42 | } |
| 34 | 43 | |
| 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 |
||
| 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( | |
| 38 | 81 | request.headers.get("last-event-id") ?? |
| 39 | new URL(request.url).searchParams.get("since") ?? | |
| 40 | 0, | |
| 82 | new URL(request.url).searchParams.get("since"), | |
| 41 | 83 | ); |
| 42 | 84 | const encoder = new TextEncoder(); |
| 85 | open.set(key, streamCount(key) + 1); | |
| 43 | 86 | let stop = (): void => {}; |
| 44 | 87 | const stream = new ReadableStream<Uint8Array>({ |
| 45 | 88 | start(controller) { |
| 46 | const write = (text: string): void => { | |
| 89 | const write = (body: string): void => { | |
| 47 | 90 | try { |
| 48 | controller.enqueue(encoder.encode(text)); | |
| 91 | controller.enqueue(encoder.encode(body)); | |
| 49 | 92 | } catch { |
| 50 | 93 | stop(); |
| 51 | 94 | } |
| @@ -56,9 +99,18 @@ export function events(request: Request): Response { | ||
| 56 | 99 | ); |
| 57 | 100 | const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS); |
| 58 | 101 | 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; | |
| 59 | 106 | stop = () => { |
| 107 | if (stopped) return; | |
| 108 | stopped = true; | |
| 60 | 109 | clearInterval(heartbeat); |
| 61 | 110 | unsubscribe(); |
| 111 | const left = streamCount(key) - 1; | |
| 112 | if (left > 0) open.set(key, left); | |
| 113 | else open.delete(key); | |
| 62 | 114 | try { |
| 63 | 115 | controller.close(); |
| 64 | 116 | } catch {} |
| @@ -71,11 +123,5 @@ export function events(request: Request): Response { | ||
| 71 | 123 | stop(); |
| 72 | 124 | }, |
| 73 | 125 | }); |
| 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); | |
| 81 | 127 | } |
src/routes.ts +20 −5
| @@ -9,7 +9,16 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | 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"; | |
| 13 | 22 | import { events } from "./live.ts"; |
| 14 | 23 | import { rpc } from "./mcp.ts"; |
| 15 | 24 | import { home } from "./page.ts"; |
| @@ -21,6 +30,8 @@ export interface Context { | ||
| 21 | 30 | readonly db: Database; |
| 22 | 31 | readonly who: Who; |
| 23 | 32 | readonly params: Record<string, string>; |
| 33 | /** The address the request came from, for what is rationed per stranger. */ | |
| 34 | readonly address: string; | |
| 24 | 35 | } |
| 25 | 36 | |
| 26 | 37 | export interface Route { |
| @@ -42,7 +53,13 @@ export const routes: readonly Route[] = [ | ||
| 42 | 53 | }, |
| 43 | 54 | |
| 44 | 55 | // 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 | }, | |
| 46 | 63 | { |
| 47 | 64 | method: "POST", |
| 48 | 65 | path: "/mcp", |
| @@ -56,9 +73,7 @@ export const routes: readonly Route[] = [ | ||
| 56 | 73 | error: { code: -32700, message: "parse error" }, |
| 57 | 74 | }); |
| 58 | 75 | 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); | |
| 62 | 77 | }, |
| 63 | 78 | }, |
| 64 | 79 | { |
src/server.test.ts +197 −8
| @@ -3,7 +3,10 @@ import { mkdtempSync } from "node:fs"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | 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"; | |
| 7 | 10 | import { serve } from "./server.ts"; |
| 8 | 11 | import { sign } from "./who.ts"; |
| 9 | 12 | |
| @@ -42,6 +45,26 @@ const api = ( | ||
| 42 | 45 | ...init, |
| 43 | 46 | headers: { ...(init.headers as Record<string, string>), ...who }, |
| 44 | 47 | }); |
| 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 | ||
| 45 | 68 | const rpc = (message: unknown, who: Record<string, string> = {}) => |
| 46 | 69 | api("/mcp", { method: "POST", ...jsonBody(message) }, who).then( |
| 47 | 70 | (r) => r.json() as Promise<any>, |
| @@ -120,9 +143,9 @@ test("the MCP server answers, with no tool to call yet", async () => { | ||
| 120 | 143 | }); |
| 121 | 144 | |
| 122 | 145 | 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(); | |
| 126 | 149 | const next = async (): Promise<{ id: string; type: string }> => { |
| 127 | 150 | let buffer = ""; |
| 128 | 151 | for (;;) { |
| @@ -136,14 +159,17 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 136 | 159 | const heard = await next(); |
| 137 | 160 | expect(heard.type).toBe("round.started"); |
| 138 | 161 | 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 | ); | |
| 142 | 167 | const replay = new TextDecoder().decode( |
| 143 | (await late.body!.getReader().read()).value, | |
| 168 | (await late.response.body!.getReader().read()).value, | |
| 144 | 169 | ); |
| 145 | 170 | expect(replay).toContain(`id: ${heard.id}\n`); |
| 146 | 171 | expect(replay).toContain("round.started"); |
| 172 | late.hangup(); | |
| 147 | 173 | }); |
| 148 | 174 | |
| 149 | 175 | test("writes are rationed per person", async () => { |
| @@ -218,3 +244,166 @@ test("static files are cached for a year when linked by content", async () => { | ||
| 218 | 244 | ).toBe("no-cache"); |
| 219 | 245 | expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404); |
| 220 | 246 | }); |
| 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 ( What blocks it is that the branch is not based on the tree it will land on. 1 - high, fix first: rebase onto 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 3 - note, not blocking: one guest can take the whole shared ceiling. If 4 - nit: Fix 1 and 2 and I will approve. (Housekeeping: two comments reading |
||
| 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 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { openDb } from "./db.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { match } from "./routes.ts"; |
| 13 | 13 | import { who } from "./who.ts"; |
| 14 | 14 | |
| @@ -30,16 +30,12 @@ export function serve(options: ServeOptions) { | ||
| 30 | 30 | async fetch(request, server) { |
| 31 | 31 | const url = new URL(request.url); |
| 32 | 32 | const me = who(request, options.identityKey); |
| 33 | const address = server.requestIP(request)?.address ?? "?"; | |
| 33 | 34 | const reading = request.method === "GET" || request.method === "HEAD"; |
| 34 | 35 | if (!reading) { |
| 35 | 36 | 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); | |
| 43 | 39 | } |
| 44 | 40 | const found = match( |
| 45 | 41 | request.method === "HEAD" ? "GET" : request.method, |
| @@ -52,14 +48,17 @@ export function serve(options: ServeOptions) { | ||
| 52 | 48 | db, |
| 53 | 49 | who: me, |
| 54 | 50 | 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, | |
| 55 | 61 | }); |
| 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; | |
| 63 | 62 | }, |
| 64 | 63 | // A bug answers with the floor and nothing else: never the runtime's |
| 65 | 64 | // own error page, which carries source and paths, and never without |
p