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

A guest gets a new seat every time they open a socket: `seatOf` still mints a random key #65

Merged opened this

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

Merged

vesper/drift:vesper/59-a-guest-gets-a-new-seat-every-time-they → main 3 commits fork 1 thread · 1 unresolved

  • Merged. vesper/59-a-guest-gets-a-new-seat-every-time-they was merged into main as feb7d92.

Review

Sign in to review this change.
5 files changed +587 −80
README.md +33 −15
@@ -51,9 +51,11 @@ simulation yet. There is no world tick and no score behind them; the
5151 regions are shells with honest empty states, and the round loop, the canvas
5252 and the ladder fill them in the issues that follow. `POST /mcp` answers and
5353 lists no tool. `GET /ws` upgrades and enforces every rule a connection has
54 — origin, identity, one seat per person, a ration, backpressure — but
54 — origin, identity, one seat per player key, a ration, backpressure — but
5555 nothing publishes frames on it yet, so a socket that opens stays quiet
56 until the round drives it.
56 until the round drives it. No door sets the guest cookie yet either, so
57 today every browser reaching `/ws` is a spectator and is told so; the page
58 that calls `session` and hands out a key lands with the swimmer's view.
5759
5860 The sea, on the other hand, is real arithmetic that nothing is plugged into
5961 yet. `src/world.ts` advances one tick — motes, momentum, the current field,
@@ -78,7 +80,7 @@ issue after this one.
7880 The only terrain is the wake other players leave. Your thruster is feeble;
7981 the currents other swimmers deposit are not, and the bloom only reaches its
8082 gate when several people braid one lane. [`docs/wake.md`](docs/wake.md) is
81 the design record — the mechanic, the starting numbers and the seven
83 the design record — the mechanic, the starting numbers and the eight
8284 invariants every build issue is measured against. It is not decoration: the
8385 numbers the page states live in `src/concept.ts` and `bun test` fails if
8486 they and the record drift apart. A seat in the sea belongs to a player key
@@ -99,7 +101,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
99101 | `src/actions.ts` | every operation, and who may: players, rounds, the ladder |
100102 | `src/world.ts` | the simulation: motes, momentum and the current field |
101103 | `src/round.ts` | the round: its clock, its end, its seats, and effects as data |
102 | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized |
104 | `src/protocol.ts` | the wire: a frame or a sentence down, a steer up, quantised and sized |
103105 | `src/frame.ts` | one world and one clock in, one frame out, pure and measured |
104106 | `src/who.ts` | who is asking, from the forge's signed headers |
105107 | `src/session.ts` | the guest key in a cookie, and the player id it derives |
@@ -159,10 +161,10 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
159161
160162 ### Playing as a guest
161163
162 *Written, not yet wired: `src/session.ts` and the player actions are in the
163 app, with their tests, but no door issues the cookie yet — that lands with
164 the socket that admits a swimmer to the world. What follows is how guests
165 work the moment one does.*
164 *Half wired: the socket reads the cookie and keys a seat by it, so a guest
165 who has one is the same swimmer in every tab — but no door issues one yet,
166 and a browser carrying no cookie watches. What follows is how guests work
167 the moment a door hands one out.*
166168
167169 Nobody has to sign in to swim. A visitor the forge cannot name is given a
168170 **guest key** — 32 random bytes in an `HttpOnly; SameSite=Lax` cookie — and
@@ -233,13 +235,29 @@ the connection: no message ever names its own sender. Every message is
233235 validated by `parseSteer` and by nothing else, and a message that fails
234236 closes the socket with `1008`. Steering is rationed per socket — 20 Hz
235237 passes forever, a burst over the bucket is dropped where it lands rather
236 than queued, and a client that keeps flooding is closed. **A seat is a
237 person, not a tab:** a second socket on the same player key closes the
238 first with `4000`, so one person is one mote however many tabs they open
239 and nobody can hold the roster alone. A socket is pinged every 25 seconds
240 and closed with `4001` after 90 seconds of silence, and a client whose send
241 buffer is backed up is skipped for that frame rather than waited on, so a
242 slow phone degrades alone.
238 than queued, and a client that keeps flooding is closed. **A seat is keyed
239 by the player key and by nothing the connection invents** — the forge id
240 for a signed-in person, the guest id derived from the `HttpOnly` cookie for
241 everybody else (`playerKey` in `src/session.ts`), never an address and
242 never a key a query string, a header or a message offered. **A seat is a
243 person, not a tab:** every socket under one key is another window on the
244 same seat and steers the same mote, so the sixth tab is refused no more
245 than the first and none of them closes another; the roster shows one
246 swimmer because there is one. Eight windows per key is the ration, and a
247 connection past it is not refused either — it watches. A request carrying
248 no session has no key, so it gets no seat: it is admitted as a
249 **spectator**, told so in the record's own sentence (`SPECTATOR` in
250 `src/concept.ts`) as its first message, and every frame still goes down it
251 while everything it steers is dropped. The upgrade has no response to set a
252 cookie on, so a first visit is a page load and then a socket — one round
253 trip — and nothing is ever minted at the upgrade to avoid it. The one
254 socket ever closed for something its person did not do is the guest window
255 still open when that browser signs in: signing in is a change of key, so
256 the guest seat is released with `4000` and the page comes back as who it is
257 now. A socket is pinged every 25 seconds and closed with `4001` after
258 90 seconds of silence, and a client whose send buffer is backed up is
259 skipped for that frame rather than waited on, so a slow phone degrades
260 alone.
243261
244262 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
245263 runs (the app is the login name, the forge the host). It is `src/tty.ts` on
src/protocol.test.ts +43
@@ -1,8 +1,11 @@
11 import { expect, test } from "bun:test";
2 import { SPECTATOR } from "./concept.ts";
23 import {
34 BLOOM_STRIDE,
45 decodeFrame,
6 decodeMessage,
57 encodeFrame,
8 encodeSentence,
69 FIELD_LENGTH,
710 FIELD_MAX,
811 FRAME_MAX_BYTES,
@@ -14,6 +17,7 @@ import {
1417 PROTOCOL_VERSION,
1518 type ProtocolFailure,
1619 ROSTER_MAX,
20 SENTENCE_MAX_BYTES,
1721 SEQ_MAX,
1822 STEER_MAX_BYTES,
1923 THRUST_SCALE,
@@ -257,3 +261,42 @@ test("a steer at the size cap is still accepted", () => {
257261 expect(padded.length).toBeLessThanOrEqual(STEER_MAX_BYTES);
258262 expect(parseSteer(padded).ok).toBe(true);
259263 });
264
265 // ------------------------------------------------- two shapes going down
266
267 test("bytes are a frame, text is a sentence, and one reader tells them apart", () => {
268 const frame = encodeFrame(sampleFrame(3));
269 const asFrame = decodeMessage(frame);
270 expect(asFrame.ok && asFrame.value.kind).toBe("frame");
271 if (asFrame.ok && asFrame.value.kind === "frame")
272 expect(asFrame.value.frame.motes.length).toBe(3 * MOTE_STRIDE);
273
274 // A sentence comes back as itself: not parsed, not unwrapped, not JSON.
275 const said = decodeMessage(SPECTATOR);
276 expect(said.ok && said.value.kind).toBe("sentence");
277 if (said.ok && said.value.kind === "sentence")
278 expect(said.value.sentence).toBe(SPECTATOR);
279
280 // A client that hands a sentence to `decodeFrame` gets `foreign` for
281 // something that was never a frame — which is why this reader exists.
282 expect(decodeFrame(SPECTATOR).ok).toBe(false);
283 // And a payload that is neither is still a named failure, never a throw.
284 const rubbish = decodeMessage(new Uint8Array([1, 2, 3]));
285 expect(rubbish.ok).toBe(false);
286 if (!rubbish.ok) expect(rubbish.error).toBe("truncated");
287 });
288
289 test("a sentence is capped, and the longest one the app says fits", () => {
290 expect(encodeSentence(SPECTATOR)).toBe(SPECTATOR);
291 expect(new TextEncoder().encode(SPECTATOR).length).toBeLessThanOrEqual(
292 SENTENCE_MAX_BYTES,
293 );
294
295 // A wall of words is the sender's bug going out and a named failure
296 // coming in: neither ever reaches a page.
297 const wall = "a".repeat(SENTENCE_MAX_BYTES + 1);
298 expect(() => encodeSentence(wall)).toThrow(RangeError);
299 const read = decodeMessage(wall);
300 expect(read.ok).toBe(false);
301 if (!read.ok) expect(read.error).toBe("too_large");
302 });
src/protocol.ts +58 −4
@@ -1,8 +1,15 @@
11 /**
2 * The wire, written once. A frame goes down (server → everyone, 10 Hz) and a
3 * steer comes up (one player → server); this module is the only place either
4 * shape is defined, so the round, the socket, the canvas, the terminal and
5 * the JSON door all read the same bytes instead of each inventing them.
2 * The wire, written once. A frame goes down (server → everyone, 10 Hz), a
3 * sentence goes down when the app has something to say to the person at the
4 * socket, and a steer comes up (one player → server); this module is the
5 * only place any of the three shapes is defined, so the round, the socket,
6 * the canvas, the terminal and the JSON door all read the same bytes instead
7 * of each inventing them.
8 *
9 * Downward there are two shapes and no third, told apart by their type and
10 * nothing else: **bytes are a frame, text is a sentence**. `decodeMessage`
11 * is the one reader that says so, so a client never hands a sentence to
12 * `decodeFrame` and gets `foreign` back for something that was never a frame.
613 *
714 * Plain data in, plain data out: nothing here imports the world, the
815 * database, the bus or the HTTP floor — only the roster cap, from the design
@@ -67,6 +74,12 @@ export const FRAME_HEADER_BYTES = 28;
6774 export const FRAME_MAX_BYTES = 4096;
6875 /** A steer is a handful of numbers; anything bigger is refused unread. */
6976 export const STEER_MAX_BYTES = 128;
77 /**
78 * A sentence is one short paragraph for a person to read — the spectator's
79 * answer in `src/concept.ts` is the longest there is. The cap is the wire's,
80 * not the reader's: nothing large is ever a sentence.
81 */
82 export const SENTENCE_MAX_BYTES = 512;
7083 /** A steer's sequence number is a non-negative int32. */
7184 export const SEQ_MAX = 2_147_483_647;
7285
@@ -91,6 +104,15 @@ export interface DecodedFrame extends Frame {
91104 readonly field: number[];
92105 }
93106
107 /**
108 * One message on its way down: a frame, or a sentence for the person at the
109 * socket. Two shapes and no third — a client that reads this reads
110 * everything the server ever sends.
111 */
112 export type Message =
113 | { readonly kind: "frame"; readonly frame: DecodedFrame }
114 | { readonly kind: "sentence"; readonly sentence: string };
115
94116 /** One player's intent: a direction in the unit disc and the count they sent it under. */
95117 export interface Steer {
96118 readonly x: number;
@@ -271,6 +293,38 @@ export function decodeFrame(payload: unknown): Parsed<DecodedFrame> {
271293 });
272294 }
273295
296 /**
297 * A sentence as it goes on the wire: the words themselves, as text, because
298 * a frame is bytes and text is therefore never mistaken for one. Nothing is
299 * wrapped around it — a person reads it, and `decodeMessage` hands it back
300 * unchanged. A sentence over the cap is the caller's bug, like a frame of
301 * the wrong shape, and throws rather than shipping a truncated one.
302 */
303 export function encodeSentence(sentence: string): string {
304 if (byteLength(sentence) > SENTENCE_MAX_BYTES)
305 throw new RangeError(`a sentence is at most ${SENTENCE_MAX_BYTES} bytes`);
306 return sentence;
307 }
308
309 /**
310 * One message off the socket, whichever shape it is. Never throws: bytes go
311 * to `decodeFrame` and come back as a frame or its named failure, text is a
312 * sentence, and a sentence past the cap is `too_large` rather than a wall of
313 * words on a page.
314 */
315 export function decodeMessage(payload: unknown): Parsed<Message> {
316 if (typeof payload === "string") {
317 if (byteLength(payload) > SENTENCE_MAX_BYTES)
318 return fail(
319 "too_large",
320 `A sentence is at most ${SENTENCE_MAX_BYTES} bytes.`,
321 );
322 return ok({ kind: "sentence", sentence: payload });
323 }
324 const frame = decodeFrame(payload);
325 return frame.ok ? ok({ kind: "frame", frame: frame.value }) : frame;
326 }
327
274328 /**
275329 * The only place steering is validated. Takes what the socket actually hands
276330 * over — a string, bytes, or an already-parsed object — and returns either a
src/socket.test.ts +265 −17
@@ -2,10 +2,12 @@ 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 { INVARIANTS, SPECTATOR } from "./concept.ts";
56 import { publicOrigin } from "./env.ts";
67 import { SECURITY } from "./http.ts";
78 import { STEER_MAX_BYTES } from "./protocol.ts";
89 import { serve } from "./server.ts";
10 import { GUEST_COOKIE, guestId, newGuestKey } from "./session.ts";
911 import {
1012 admit,
1113 backedUp,
@@ -14,11 +16,16 @@ import {
1416 publish,
1517 receive,
1618 seatHolder,
19 seatOf,
20 seating,
21 seatWindows,
1722 SEND_MAX_BYTES,
1823 SILENCE_MS,
1924 socketCount,
2025 socketsFrom,
26 socketsOn,
2127 SOCKET_MAX_SHARED,
28 SOCKETS_PER_SEAT,
2229 type Sock,
2330 type SocketData,
2431 type Steering,
@@ -55,6 +62,11 @@ const as = (id: string, name: string): Record<string, string> => {
5562 };
5663 };
5764
65 /** The cookie jar a guest's browser carries — the only way a guest is known. */
66 const jar = (key: string): Record<string, string> => ({
67 cookie: `${GUEST_COOKIE}=${key}`,
68 });
69
5870 interface Client {
5971 readonly ws: WebSocket;
6072 readonly opened: Promise<boolean>;
@@ -188,7 +200,7 @@ test("identity is fixed at upgrade, and no message can name its own sender", asy
188200 // the socket's own seat.
189201 client.ws.send(JSON.stringify({ x: 1, y: 0, seq: 1, key: "user:u2" }));
190202 await until("a steer", () => heard.length === 1);
191 expect(heard[0]!.key).toBe("user:u1");
203 expect(heard[0]!.key).toBe("u1");
192204 expect(heard[0]!.who).toEqual({ role: "user", id: "u1", name: "alice" });
193205 expect(Object.keys(heard[0]!.steer).sort()).toEqual(["seq", "x", "y"]);
194206
@@ -255,7 +267,7 @@ test("a malformed message closes the socket with 1008", async () => {
255267 test("a normal 20 Hz steer passes, minute after minute", () => {
256268 const heard: number[] = [];
257269 const stop = onSteer((steering) => heard.push(steering.steer.seq));
258 const sock = fake("10.4.4.4");
270 const sock = fake("10.4.4.4", ANON, newGuestKey());
259271 websocket.open(sock);
260272 // Two minutes of a thumb on the stick, at the rate the page will send:
261273 // 2400 steers, 50 ms apart on the clock, and not one of them dropped.
@@ -302,31 +314,257 @@ test("a socket that keeps flooding is closed with 1008", async () => {
302314
303315 // ---------------------------------------------------------------- a seat
304316
305 test("a seat is a person, not a tab: the newest socket wins", async () => {
317 test("a seat is a person, not a tab: both tabs live and steer one mote", async () => {
318 const heard: Steering[] = [];
319 const stop = onSteer((steering) => heard.push(steering));
306320 const first = connect(as("u9", "ida"));
307321 expect(await first.opened).toBe(true);
308 expect(seatHolder("user:u9")).toBeDefined();
322 expect(socketsOn("u9")).toBe(1);
309323
324 // The second tab is refused no more than the first, and it does not close
325 // it: two windows, one seat, and the roster still shows one swimmer.
310326 const second = connect(as("u9", "ida"));
311327 expect(await second.opened).toBe(true);
312
313 const end = await first.closed;
314 expect(end.code).toBe(CLOSE.replaced);
328 await until("both windows", () => socketsOn("u9") === 2);
329 expect(first.ws.readyState).toBe(WebSocket.OPEN);
315330 expect(second.ws.readyState).toBe(WebSocket.OPEN);
316 // One person, one seat — and the seat is the survivor's, not the ghost's.
317 await until("the old socket to be forgotten", () => socketCount() === 1);
318 expect(seatHolder("user:u9")).toBeDefined();
319331
332 // Both steer the same seat, so the mote they move is the same one.
333 first.ws.send(JSON.stringify({ x: 1, y: 0, seq: 1 }));
334 second.ws.send(JSON.stringify({ x: 0, y: 1, seq: 2 }));
335 await until("two steers", () => heard.length === 2);
336 expect(heard.map((steering) => steering.key)).toEqual(["u9", "u9"]);
337
338 // Closing one window leaves the seat with the other; the key goes when the
339 // last of them does.
340 first.bye();
341 await first.closed;
342 await until("one window", () => socketsOn("u9") === 1);
343 expect(seatHolder("u9")).toBeDefined();
344
345 stop();
320346 second.bye();
321347 await second.closed;
322 await until("an empty world", () => seatHolder("user:u9") === undefined);
348 await until("an empty world", () => seatHolder("u9") === undefined);
349 });
350
351 test("a key past its windows watches, rather than closing one that broke no rule", () => {
352 const key = newGuestKey();
353 const id = guestId(key);
354 const open: Fake[] = [];
355 for (let i = 0; i < SOCKETS_PER_SEAT; i++) {
356 const sock = fake(`10.8.8.${i}`, ANON, key);
357 websocket.open(sock);
358 open.push(sock);
359 }
360 expect(socketsOn(id)).toBe(SOCKETS_PER_SEAT);
361 for (const sock of open) expect(sock.closes).toEqual([]);
362
363 // One window too many: admitted, seated nowhere, and told so — the same
364 // answer overflow gets everywhere else. Nothing already open is disturbed.
365 const extra = fake("10.8.8.9", ANON, key);
366 websocket.open(extra);
367 expect(extra.data.key).toBeUndefined();
368 expect(seating(extra.data)).toBe("spectator");
369 expect(extra.sent).toEqual([SPECTATOR]);
370 expect(socketsOn(id)).toBe(SOCKETS_PER_SEAT);
371 for (const sock of open) expect(sock.closes).toEqual([]);
372
373 // A window closes, and the seat has room again.
374 open[0]!.close();
375 expect(socketsOn(id)).toBe(SOCKETS_PER_SEAT - 1);
376 const back = fake("10.8.8.10", ANON, key);
377 expect(back.data.key).toBe(id);
378
379 extra.close();
380 for (const sock of open.slice(1)) sock.close();
381 expect(socketsOn(id)).toBe(0);
382 expect(seatHolder(id)).toBeUndefined();
383 });
384
385 test("a seat is the player key and nothing a connection invents (invariant 7)", () => {
386 // The whole sentence, not the half the code finds convenient: a second
387 // connection under a key *steers that key's own mote*, and a request with
388 // no session key gets no mote at all.
389 expect(INVARIANTS[6]).toStartWith("One key, one seat");
390 expect(INVARIANTS[6]).toContain(
391 "a second connection under a key steers that key's own mote",
392 );
393 expect(INVARIANTS[6]).toContain(
394 "a request carrying no session key gets no mote at all",
395 );
396 const key = newGuestKey();
397 const mine = upgradeHeaders({ origin: publicOrigin, ...jar(key) });
398
399 // The same cookie is the same seat, every time it is asked — no minting.
400 const again = upgradeHeaders({ origin: publicOrigin, ...jar(key) });
401 const theirs = upgradeHeaders({
402 origin: publicOrigin,
403 ...jar(newGuestKey()),
404 });
405 expect(seatOf(mine, ANON)).toBe(guestId(key));
406 expect(seatOf(again, ANON)).toBe(guestId(key));
407 // Another cookie is another guest.
408 expect(seatOf(theirs, ANON)).not.toBe(guestId(key));
409 // The key the cookie holds is a bearer secret; the seat is a one-way
410 // derivation of it, and the raw key is never the seat.
411 expect(seatOf(mine, ANON)).not.toBe(key);
412 // No cookie at all is no seat: nothing is minted to fill the hole.
413 expect(
414 seatOf(upgradeHeaders({ origin: publicOrigin }), ANON),
415 ).toBeUndefined();
416 // A signed-in person is their forge id, whatever cookie they also carry.
417 const kit: Who = { role: "user", id: "u11", name: "kit" };
418 expect(seatOf(mine, kit)).toBe("u11");
419 expect(seatOf(upgradeHeaders({ origin: publicOrigin }), kit)).toBe("u11");
420 });
421
422 test("a guest is only ever the cookie: nothing a client can choose is read", () => {
423 const key = newGuestKey();
424 // The guest key offered in a query string, in a header of the client's
425 // own choosing, or as a bearer token is not a session and never will be.
426 const smuggled = new Request(
427 `${publicOrigin}/ws?guest=${key}&key=${guestId(key)}`,
428 {
429 headers: {
430 connection: "Upgrade",
431 upgrade: "websocket",
432 origin: publicOrigin,
433 "x-guest": key,
434 authorization: `Bearer ${key}`,
435 },
436 },
437 );
438 expect(seatOf(smuggled, ANON)).toBeUndefined();
439 const seat = admit(smuggled, ANON, "10.3.3.1");
440 expect(seat.ok && seating(seat.data)).toBe("spectator");
441 });
442
443 test("two guest sockets carrying one cookie are two windows on one seat", async () => {
444 const heard: Steering[] = [];
445 const stop = onSteer((steering) => heard.push(steering));
446 const key = newGuestKey();
447 const id = guestId(key);
448 const first = connect(jar(key));
449 expect(await first.opened).toBe(true);
450 expect(socketsOn(id)).toBe(1);
451
452 // The second tab is the same key, so it is the same seat: admitted, given
453 // no seat of its own, and taking nothing from the tab that was already
454 // there. One key, one seat, two windows.
455 const second = connect(jar(key));
456 expect(await second.opened).toBe(true);
457 await until("both windows", () => socketsOn(id) === 2);
458 expect(first.ws.readyState).toBe(WebSocket.OPEN);
459 expect(second.ws.readyState).toBe(WebSocket.OPEN);
460 for (const ws of seatWindows(id)) expect(ws.data.key).toBe(id);
461
462 second.ws.send(JSON.stringify({ x: 0, y: 1, seq: 1 }));
463 await until("a steer", () => heard.length === 1);
464 expect(heard[0]!.key).toBe(id);
465
466 stop();
467 first.bye();
468 second.bye();
469 await first.closed;
470 await second.closed;
471 await until("an empty world", () => seatHolder(id) === undefined);
472 });
473
474 test("two guest sockets with different cookies hold two seats", async () => {
475 const mine = newGuestKey();
476 const yours = newGuestKey();
477 const one = connect(jar(mine));
478 const two = connect(jar(yours));
479 expect(await one.opened).toBe(true);
480 expect(await two.opened).toBe(true);
481
482 expect(seatHolder(guestId(mine))).toBeDefined();
483 expect(seatHolder(guestId(yours))).toBeDefined();
484 expect(seatHolder(guestId(mine))).not.toBe(seatHolder(guestId(yours)));
485 expect(one.ws.readyState).toBe(WebSocket.OPEN);
486 expect(two.ws.readyState).toBe(WebSocket.OPEN);
487
488 one.bye();
489 two.bye();
490 await one.closed;
491 await two.closed;
492 await until("an empty world", () => socketCount() === 0);
493 });
494
495 test("signing in releases the guest seat in the tick the forge id takes one", async () => {
496 const key = newGuestKey();
497 const guest = connect(jar(key));
498 const otherTab = connect(jar(key));
499 expect(await guest.opened).toBe(true);
500 expect(await otherTab.opened).toBe(true);
501 await until("both windows", () => socketsOn(guestId(key)) === 2);
502
503 // The same browser, now named by the forge and still carrying its cookie:
504 // the guest seat is let go, every window that was open on it with it, so
505 // nobody holds two and nothing keeps steering a key its person has left.
506 // What the new mote inherits from the old one is the round's.
507 const signedIn = connect({ ...as("u12", "lune"), ...jar(key) });
508 expect(await signedIn.opened).toBe(true);
509 expect((await guest.closed).code).toBe(CLOSE.replaced);
510 expect((await otherTab.closed).code).toBe(CLOSE.replaced);
511 await until("the guest sockets to be forgotten", () => socketCount() === 1);
512 expect(seatHolder(guestId(key))).toBeUndefined();
513 expect(seatHolder("u12")?.data.key).toBe("u12");
514
515 signedIn.bye();
516 await signedIn.closed;
517 await until("an empty world", () => socketCount() === 0);
518 });
519
520 // ----------------------------------------------------------- a spectator
521
522 test("a socket with no cookie holds no seat, opens anyway, and is told so", async () => {
523 const client = connect();
524 expect(await client.opened).toBe(true);
525 // Nobody is turned away and nothing is refused silently: the sentence is
526 // the record's, word for word, not a second wording invented here.
527 await until("the sentence", () => client.got.length === 1);
528 expect(client.got[0]).toBe(SPECTATOR);
529 // And the frames still come down it.
530 expect(publish("a frame").sent).toBe(1);
531 await until("the frame", () => client.got.length === 2);
532 expect(client.got[1]).toBe("a frame");
533
534 client.bye();
535 await client.closed;
536 await until("an empty world", () => socketCount() === 0);
537 });
538
539 test("steering from a spectator moves nothing, and costs them no socket", () => {
540 const heard: Steering[] = [];
541 const stop = onSteer((steering) => heard.push(steering));
542 const sock = fake("10.7.7.7");
543 websocket.open(sock);
544 expect(sock.data.key).toBeUndefined();
545 expect(seating(sock.data)).toBe("spectator");
546 expect(sock.sent).toEqual([SPECTATOR]);
547
548 // A perfectly good steer, dropped where it arrives the way a steer over
549 // the ration is: counted, not delivered, and not a reason to close.
550 let at = sock.data.heard;
551 for (let seq = 0; seq < 5; seq++, at += 50)
552 receive(sock, JSON.stringify({ x: 0, y: 1, seq }), at);
553 expect(heard).toEqual([]);
554 expect(sock.data.dropped).toBe(5);
555 expect(sock.closes).toEqual([]);
556
557 // The rules a message breaks are still the rules: watching is not licence.
558 receive(sock, "steer left a bit", at);
559 expect(sock.closes[0]?.code).toBe(CLOSE.policy);
560 stop();
323561 });
324562
325563 // ------------------------------------------------------- frames going out
326564
327565 test("publish skips a backed-up socket and never waits for it", () => {
328 const quick = fake("10.5.5.5");
329 const slow = fake("10.5.5.6");
566 const quick = fake("10.5.5.5", ANON, newGuestKey());
567 const slow = fake("10.5.5.6", ANON, newGuestKey());
330568 slow.buffered = SEND_MAX_BYTES + 1;
331569 websocket.open(quick);
332570 websocket.open(slow);
@@ -348,11 +586,11 @@ test("publish skips a backed-up socket and never waits for it", () => {
348586 });
349587
350588 test("a socket that fails to send is closed, and the rest still get the frame", () => {
351 const broken = fake("10.5.5.7");
589 const broken = fake("10.5.5.7", ANON, newGuestKey());
352590 broken.send = () => {
353591 throw new Error("the peer went away mid-frame");
354592 };
355 const fine = fake("10.5.5.8");
593 const fine = fake("10.5.5.8", ANON, newGuestKey());
356594 websocket.open(broken);
357595 websocket.open(fine);
358596
@@ -416,9 +654,19 @@ interface Fake extends Sock {
416654 * part of `ServerWebSocket` they use, so a test can be one and turn the
417655 * clock by hand. Closing behaves the way Bun behaves — the close handler
418656 * runs — so the registry is exercised, not bypassed.
657 *
658 * With no `guest` key it carries no session at all, which is a spectator;
659 * a test that needs a seat hands it one, as a browser's cookie would.
419660 */
420 function fake(address: string, who: Who = ANON): Fake {
421 const seat = admit(upgradeHeaders({ origin: publicOrigin }), who, address);
661 function fake(address: string, who: Who = ANON, guest?: string): Fake {
662 const seat = admit(
663 upgradeHeaders({
664 origin: publicOrigin,
665 ...(guest === undefined ? {} : jar(guest)),
666 }),
667 who,
668 address,
669 );
422670 if (!seat.ok) throw new Error(`admission refused: ${seat.why}`);
423671 const data: SocketData = seat.data;
424672 const sock: Fake = {
src/socket.ts +188 −44
@@ -18,9 +18,18 @@
1818 * ration a token bucket per socket: 20 Hz steering passes, a flood is
1919 * dropped where it arrives rather than queued, and a socket
2020 * that keeps flooding is closed.
21 * seat a seat is a person, not a tab: newest wins. A second socket
22 * on one player key closes the first with 4000, so one person
23 * holds one mote however many tabs they open.
21 * seat a seat is a player key (`playerKey` in `session.ts`), not a
22 * tab: every socket under one key is another window on the
23 * same seat, steering the same mote. The sixth tab is refused
24 * no more than the first, and none of them closes another.
25 * Signing in is a change of key, and the guest seat the same
26 * browser still holds is released then — the one thing that
27 * closes a socket that broke no rule, with 4000.
28 * spectator a request carrying no session has no key, so it has no seat,
29 * and so does one past `SOCKETS_PER_SEAT` windows on its own.
30 * It is admitted anyway, told so in the record's own sentence,
31 * and steers nothing: nobody is turned away and nothing is
32 * refused silently.
2433 * liveness a ping every 25 s (inside the server's 60 s `idleTimeout`),
2534 * and a socket that has said nothing — message or pong — for
2635 * 90 s is closed with 4001.
@@ -32,11 +41,17 @@
3241 * drive at 10 Hz; today a test drives it.
3342 */
3443
35 import { randomBytes } from "node:crypto";
44 import { SPECTATOR } from "./concept.ts";
3645 import { SECURITY, socketOrigin, text } from "./http.ts";
37 import { FRAME_MAX_BYTES, parseSteer, type Steer } from "./protocol.ts";
46 import {
47 encodeSentence,
48 FRAME_MAX_BYTES,
49 parseSteer,
50 type Steer,
51 } from "./protocol.ts";
3852 import type { Context } from "./routes.ts";
39 import type { Who } from "./who.ts";
53 import { playerKey } from "./session.ts";
54 import { ANON, type Who } from "./who.ts";
4055
4156 /** How many steers a socket may spend at once, and how fast it earns more. */
4257 const STEER_BURST = 40;
@@ -65,19 +80,32 @@ export const SEND_MAX_BYTES = FRAME_MAX_BYTES * 8;
6580 */
6681 const MAX_PAYLOAD_BYTES = 8 * 1024;
6782 /**
68 * How many sockets one address may hold open at once. The seat rule bounds a
69 * person at one; this bounds everyone the app cannot tell apart, which behind
83 * How many sockets one address may hold open at once. `SOCKETS_PER_SEAT`
84 * bounds one key; this bounds everyone the app cannot tell apart, which behind
7085 * the forge is every guest arriving from the forge's address — so it is a
7186 * ceiling on this process, like `STREAM_MAX_SHARED` in `live.ts`, rather than
72 * a quota on anybody.
87 * a quota on anybody. It is also the only bound on sockets holding no
88 * session, and it can be: they take no seat and steer nothing.
7389 */
7490 export const SOCKET_MAX_SHARED = 256;
91 /**
92 * How many windows one seat may have open at once. The record's own story is
93 * a person with six tabs when a round stalls, so six costs them nothing;
94 * past this a connection is not refused, it watches, because a browser with
95 * a stuck reconnect is a person, not an attacker. Every one of them steers
96 * the same mote, so this bounds bookkeeping rather than anybody's influence.
97 */
98 export const SOCKETS_PER_SEAT = 8;
7599
76100 /** Why a socket closed. 1008 is the protocol's word for "you broke a rule". */
77101 export const CLOSE = {
78102 /** A message the rules refuse, or a flood that would not stop. */
79103 policy: 1008,
80 /** The same person opened another socket; this one is the older tab. */
104 /**
105 * The key under this socket is not this browser's any more: it signed in,
106 * and the guest seat it was holding is released. Its own code so a client
107 * can tell "reconnect, you are somebody else now" from "you broke a rule".
108 */
81109 replaced: 4000,
82110 /** Nothing heard for `SILENCE_MS`. */
83111 silent: 4001,
@@ -85,12 +113,26 @@ export const CLOSE = {
85113 broken: 1011,
86114 } as const;
87115
116 /** What a socket may do with the world: steer one mote, or only watch it. */
117 export type Seating = "swimmer" | "spectator";
118
88119 /** What a socket knows about itself, decided once, at upgrade. */
89120 export interface SocketData {
90121 /** The identity the forge signed, or `anon`. Never anything a message said. */
91122 readonly who: Who;
92 /** The seat: one player key, one mote. */
93 readonly key: string;
123 /**
124 * The seat: one player key, one mote, however many sockets sit under it.
125 * Nothing at all when the request carried no session, or when that key
126 * already has its windows open — either way this socket is a spectator.
127 */
128 readonly key: string | undefined;
129 /**
130 * The guest seat this same browser also carries, when the forge named its
131 * person too: signing in is a change of key, so the guest seat is let go in
132 * the tick the forge id takes one. Nothing for a socket that is not signed
133 * in — a guest's own key is in `key` already.
134 */
135 readonly guest: string | undefined;
94136 /** What the shared cap counts against. */
95137 readonly address: string;
96138 readonly openedAt: number;
@@ -106,6 +148,14 @@ export interface SocketData {
106148 skipped: number;
107149 }
108150
151 /**
152 * Which of the two a socket is, read from the one field that decides it: a
153 * socket with a key steers a mote, a socket without one watches. Derived,
154 * never stored, so there is no second copy to disagree with the first.
155 */
156 export const seating = (data: SocketData): Seating =>
157 data.key === undefined ? "spectator" : "swimmer";
158
109159 /** The part of a `ServerWebSocket` this module uses, so a test can be one. */
110160 export interface Sock {
111161 readonly data: SocketData;
@@ -124,7 +174,12 @@ export interface Steering {
124174 }
125175
126176 const live = new Set<Sock>();
127 const seats = new Map<string, Sock>();
177 /**
178 * One key, one seat, many windows: every socket under a key sits in the same
179 * seat and steers the same mote. A key with no sockets left is not a seat at
180 * all, and the entry goes.
181 */
182 const seats = new Map<string, Set<Sock>>();
128183 const byAddress = new Map<string, number>();
129184 const listeners = new Set<(steering: Steering) => void>();
130185
@@ -134,8 +189,17 @@ export const socketCount = (): number => live.size;
134189 /** How many sockets an address holds open, for the cap and for the tests. */
135190 export const socketsFrom = (address: string): number =>
136191 byAddress.get(address) ?? 0;
137 /** The socket sitting in a seat, if anybody is. */
138 export const seatHolder = (key: string): Sock | undefined => seats.get(key);
192 /** Every window on one seat. Empty when nobody holds it. */
193 export const seatWindows = (key: string): ReadonlySet<Sock> =>
194 seats.get(key) ?? EMPTY;
195 /** How many windows a seat has open, for the per-key cap and for the tests. */
196 export const socketsOn = (key: string): number => seats.get(key)?.size ?? 0;
197 /** A socket sitting in a seat, if anybody is — any of them, since it is one seat. */
198 export const seatHolder = (key: string): Sock | undefined => {
199 for (const ws of seatWindows(key)) return ws;
200 return undefined;
201 };
202 const EMPTY: ReadonlySet<Sock> = new Set();
139203
140204 /**
141205 * Steering, as it arrives. #BUS and the round listen; a listener is handed
@@ -147,21 +211,31 @@ export function onSteer(listen: (steering: Steering) => void): () => void {
147211 }
148212
149213 /**
150 * The seat a request plays as. A person the forge named is their forge id,
151 * which is the same across tabs, reloads and reconnects — that is what makes
152 * a seat a person.
214 * The seat a request plays as: the player key and nothing else. A person the
215 * forge named is their forge id, which is the same across tabs, reloads and
216 * reconnects; everybody else is the guest id derived from the `HttpOnly`
217 * cookie the app set (`playerKey` in `src/session.ts`). That derivation is
218 * the only way a guest is recognised here — never a query string, never a
219 * header a client chose, never a message the socket sent.
153220 *
154 * A guest is the one thing this module cannot decide alone: a guest's
155 * identity is a cookie, and reading that cookie is `src/session.ts` in
156 * #DATA, which is not merged yet. Until it is, an anonymous socket is its
157 * own seat — every guest tab is its own connection — and the shared cap
158 * above is what bounds them. When #DATA lands this is one import and one
159 * line: `playerKey(request, who)`.
221 * A request carrying no session at all gets nothing, and nothing is minted
222 * to fill the hole: an upgrade has no response to set a cookie on, so a key
223 * invented here would be a new seat on every connection, which is the bug
224 * this replaced. A browser's first visit is a page load — which sets the
225 * cookie — and then a socket, one round trip, and until it holds a cookie it
226 * watches (`SPECTATOR`).
160227 */
161 export const seatOf = (_request: Request, who: Who): string =>
162 who.role === "anon"
163 ? `anon:${randomBytes(12).toString("base64url")}`
164 : `user:${who.id}`;
228 export const seatOf = (request: Request, who: Who): string | undefined =>
229 playerKey(request, who);
230
231 /**
232 * The guest seat this request *also* carries, when the forge has named its
233 * person. Signing in is a change of key, so it is a change of seat: this is
234 * the key to release, and it is the same derivation `seatOf` makes for a
235 * guest — the id this browser would swim under if it were not signed in.
236 */
237 const guestSeatOf = (request: Request, who: Who): string | undefined =>
238 who.role === "anon" ? undefined : playerKey(request, ANON);
165239
166240 export type Admission =
167241 | { readonly ok: true; readonly data: SocketData }
@@ -186,11 +260,21 @@ export function admit(
186260 return { ok: false, status: 403, why: "forbidden" };
187261 if (socketsFrom(address) >= SOCKET_MAX_SHARED)
188262 return { ok: false, status: 503, why: "too many sockets" };
263 // No session, no seat — and a seat already showing `SOCKETS_PER_SEAT`
264 // windows takes no more of them. Neither is refused: the overflow answer
265 // is one answer everywhere, and it is that you see the whole round, steer
266 // nothing, and are told so.
267 const seat = seatOf(request, who);
268 const key =
269 seat !== undefined && socketsOn(seat) >= SOCKETS_PER_SEAT
270 ? undefined
271 : seat;
189272 return {
190273 ok: true,
191274 data: {
192275 who,
193 key: seatOf(request, who),
276 key,
277 guest: guestSeatOf(request, who),
194278 address,
195279 openedAt: now,
196280 heard: now,
@@ -233,20 +317,24 @@ export const websocket = {
233317 perMessageDeflate: false,
234318
235319 open(ws: Sock): void {
236 const { key, address } = ws.data;
237 // A seat is a person, not a tab: newest wins. The older socket is closed
238 // with its own code so a client can tell "you opened me somewhere else"
239 // from "you broke a rule", and a reconnect after a deploy takes the seat
240 // back rather than fighting a ghost for it.
241 const held = seats.get(key);
242 if (held !== undefined && held !== ws) {
243 seats.delete(key);
244 close(held, CLOSE.replaced, "replaced by a newer connection");
245 }
246 seats.set(key, ws);
320 const { key, guest, address } = ws.data;
321 // Signing in is a change of key, so it is a change of seat: the guest
322 // seat this same browser still holds is let go in the tick the forge id
323 // takes one, so nobody holds two. What the new mote inherits from the old
324 // one is the round's (#8); the socket's half is only the release.
325 if (guest !== undefined) release(guest, ws, "signed in");
326 // A seat is a person, not a tab: this socket joins the windows already
327 // on its key rather than taking one from them. Six tabs are six windows
328 // on one mote — the sixth is refused no more than the first, and neither
329 // of them can close the other. A spectator has no key and sits in no
330 // seat, so it displaces nobody either.
331 if (key !== undefined) window(key).add(ws);
247332 live.add(ws);
248333 byAddress.set(address, socketsFrom(address) + 1);
249334 start();
335 // Told, in the record's own words, the moment the socket is real: frames
336 // will still come down it and nothing it sends will move them.
337 if (seating(ws.data) === "spectator") tell(ws, SPECTATOR);
250338 },
251339
252340 message(ws: Sock, message: string | Uint8Array): void {
@@ -259,9 +347,15 @@ export const websocket = {
259347
260348 close(ws: Sock): void {
261349 live.delete(ws);
262 // Only if the seat is still this socket's: a replaced socket closes
263 // *after* the new one took the seat, and must not take it away again.
264 if (seats.get(ws.data.key) === ws) seats.delete(ws.data.key);
350 // One window closes; the others keep the seat. The key goes only when
351 // the last of them has gone — and a released socket closes *after* its
352 // key was taken away, which this handles by taking away only itself.
353 const { key } = ws.data;
354 const held = key === undefined ? undefined : seats.get(key);
355 if (key !== undefined && held !== undefined) {
356 held.delete(ws);
357 if (held.size === 0) seats.delete(key);
358 }
265359 const left = socketsFrom(ws.data.address) - 1;
266360 if (left > 0) byAddress.set(ws.data.address, left);
267361 else byAddress.delete(ws.data.address);
@@ -296,10 +390,19 @@ export function receive(
296390 const parsed = parseSteer(message);
297391 if (!parsed.ok) return close(ws, CLOSE.policy, parsed.error);
298392 if (ws.data.strikes > 0) ws.data.strikes -= 1;
393 // A spectator has no seat to steer. Their message is dropped where it
394 // arrives, exactly as a steer over the ration is — counted, not delivered,
395 // and never a reason to close a socket that broke no rule. They were told
396 // they were watching when they connected.
397 const key = ws.data.key;
398 if (key === undefined) {
399 ws.data.dropped += 1;
400 return;
401 }
299402 // The identity is the socket's, fixed at upgrade. A message that names a
300403 // player names nothing: `parseSteer` keeps three numbers and drops the rest.
301404 const steering: Steering = {
302 key: ws.data.key,
405 key,
303406 who: ws.data.who,
304407 steer: parsed.value,
305408 at: now,
@@ -402,6 +505,47 @@ const buffered = (ws: Sock): number => {
402505 }
403506 };
404507
508 /** The windows on one seat, made the first time somebody sits in it. */
509 function window(key: string): Set<Sock> {
510 const held = seats.get(key);
511 if (held !== undefined) return held;
512 const made = new Set<Sock>();
513 seats.set(key, made);
514 return made;
515 }
516
517 /**
518 * Let a key go, and close every window that was open on it. The one thing
519 * that closes a socket which broke no rule, and it happens for one reason:
520 * the browser signed in, so the guest key it was sitting under is not its
521 * key any more. Leaving those sockets open would leave them steering a seat
522 * their person no longer holds; closing them with `replaced` tells the page
523 * to come back as who it is now.
524 */
525 function release(key: string, taking: Sock, reason: string): void {
526 const held = seats.get(key);
527 if (held === undefined) return;
528 seats.delete(key);
529 for (const ws of held) if (ws !== taking) close(ws, CLOSE.replaced, reason);
530 }
531
532 /**
533 * One sentence, to one socket. The shape is the wire's, not this module's:
534 * `encodeSentence` in `protocol.ts` says text is a sentence and bytes are a
535 * frame, and `decodeMessage` is what the canvas, the terminal and any other
536 * client read it with. The words are the record's, from `src/concept.ts`.
537 */
538 function tell(ws: Sock, sentence: string): void {
539 // Encoded outside the try: a sentence over the wire's cap is our bug and
540 // throws, and it is not a broken peer to be quietly closed.
541 const words = encodeSentence(sentence);
542 try {
543 ws.send(words);
544 } catch {
545 close(ws, CLOSE.broken, "send failed");
546 }
547 }
548
405549 /** Closing never throws: a socket that has already gone is gone. */
406550 function close(ws: Sock, code: number, reason: string): void {
407551 try {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs