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

forked from trevato/cs

The doors the board is written through get their first test files: src/gate.ts and src/run.ts #363

Merged opened this

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

Merged

torque/cs-engine:torque/347-the-doors-the-board-is-written-through-g → main 1 commit fork

  • Merged. torque/347-the-doors-the-board-is-written-through-g was merged into main as 28ae061.

Review

Sign in to review this change.
6 files changed +525 −64
AGENTS.md +1 −1
@@ -33,7 +33,7 @@ preview at its own address, deployed from your branch as you push.
3333 come from the environment, never the repository; no `eval`, no shell
3434 built from user text; a private thing needs a session in an HttpOnly cookie.
3535 Every request body is capped by the bytes actually read (`readBody` in
36 `src/server.ts`) — never by a Content-Length, which a chunked request does
36 `src/gate.ts`) — never by a Content-Length, which a chunked request does
3737 not have and a lie does not honour. `HEAD` answers like `GET`.
3838 An app with an owner or admin is never claimed by whoever visits first:
3939 the first sign-in needs a secret only the operator holds — a password from
src/gate.test.ts added +209
@@ -0,0 +1,209 @@
1 /**
2 * The door every posted score comes in through, held without a server. Each
3 * step `src/gate.ts` owns is called here with plain objects — a `Request` built
4 * in the test, a `Gate` written in the test, a `Who` typed out — and each
5 * refusal is asserted by what a caller sees: the status and the words the
6 * server answers with, or the player a score is written as.
7 *
8 * The HTTP end of these doors — same-origin, the limiter, the cookie, the
9 * board — is `src/server.test.ts`'s. This file is what those tests stand on.
10 */
11
12 import { expect, test } from "bun:test";
13 import {
14 admit, BODY_MAX, type Checked, cleanName, type Gate, integer, playerOf, readBody, type Refused, refused,
15 } from "./gate.ts";
16 import { ANON, type Who } from "./who.ts";
17
18 /** Most a test gate's one number may be. */
19 const MOST = 9;
20
21 /**
22 * A gate that knows no game: its body is `{ n, name }`, with `n` counted up to
23 * `MOST`. What it accepts carries the name it was given and writes nothing.
24 */
25 const GATE: Gate = {
26 path: "/api/test",
27 refusal: "bad test",
28 accept: (value) => {
29 if (typeof value !== "object" || value === null) return null;
30 const body = value as Record<string, unknown>;
31 const n = integer(body.n, MOST);
32 return n === null ? null : { name: cleanName(body.name), write: () => true };
33 },
34 board: () => "<p>board</p>",
35 };
36
37 /** A POST whose body is exactly `body`, the way the runtime hands one to `route`. */
38 const post = (body: BodyInit | null): Request => new Request("http://door.test/api/test", { method: "POST", body });
39
40 /** A chunked body: `chunks` pieces of `size` bytes each, and no Content-Length at all. */
41 const chunked = (size: number, chunks: number): Request => {
42 let sent = 0;
43 const stream = new ReadableStream<Uint8Array>({
44 pull(controller) {
45 if (sent === chunks) return controller.close();
46 sent += 1;
47 controller.enqueue(new Uint8Array(size).fill(0x20));
48 },
49 });
50 return post(stream);
51 };
52
53 /** What the door said, in the two words a caller can see, or "through" and the name. */
54 const said = (came: Checked | Refused): string =>
55 refused(came) ? `${came.status} ${came.reason}` : `through as ${came.name}`;
56
57 // --- the cap on the body
58
59 test("a body at the cap is read whole, and one byte past it is too large", async () => {
60 // A JSON string exactly BODY_MAX bytes long: read, parsed, and refused only
61 // for its shape — which proves the cap let every byte of it through.
62 const at = JSON.stringify("x".repeat(BODY_MAX - 2));
63 expect(at.length).toBe(BODY_MAX);
64 expect(said(await admit(GATE, post(at)))).toBe("422 bad test");
65 expect(said(await admit(GATE, post(at + " ")))).toBe("413 too large");
66 expect(await readBody(post(at + " "))).toBeUndefined();
67 expect((await readBody(post(at)))?.byteLength).toBe(BODY_MAX);
68 });
69
70 test("a chunked body is capped by what arrives, not by a length it never declared", async () => {
71 const request = chunked(1024, BODY_MAX / 1024 + 1);
72 expect(request.headers.get("content-length")).toBeNull();
73 expect(said(await admit(GATE, request))).toBe("413 too large");
74 // Split into pieces that land exactly on the cap, it is read whole.
75 expect((await readBody(chunked(1024, BODY_MAX / 1024)))?.byteLength).toBe(BODY_MAX);
76 });
77
78 test("a body that never ends is cut off rather than read for ever", async () => {
79 // A stream with no end: without the cut at eight times the cap this await
80 // would never settle, and the test would time out rather than pass.
81 let pulled = 0;
82 const endless = post(new ReadableStream<Uint8Array>({
83 pull(controller) {
84 pulled += 1;
85 controller.enqueue(new Uint8Array(1024));
86 },
87 }));
88 expect(said(await admit(GATE, endless))).toBe("413 too large");
89 expect(pulled * 1024).toBeLessThanOrEqual(BODY_MAX * 8 + 1024 * 2);
90 });
91
92 test("a smaller cap is honoured, which is what the plan routes read under", async () => {
93 expect(await readBody(post("x".repeat(1025)), 1024)).toBeUndefined();
94 expect((await readBody(post("x".repeat(1024)), 1024))?.byteLength).toBe(1024);
95 });
96
97 // --- the JSON
98
99 test("a body that is not JSON is bad json, and so is no body at all", async () => {
100 for (const body of ["", "{", "n=3", "{'n': 3}", "{\"n\": 3,}", "\u0000"]) {
101 expect(said(await admit(GATE, post(body)))).toBe("400 bad json");
102 }
103 expect(said(await admit(GATE, post(null)))).toBe("400 bad json");
104 // Bytes that are not UTF-8 decode to replacement characters, which is not JSON.
105 expect(said(await admit(GATE, post(new Uint8Array([0xff, 0xfe, 0x7b]))))).toBe("400 bad json");
106 });
107
108 // --- the shape
109
110 test("JSON that is not the gate's shape is refused in the gate's own words", async () => {
111 for (const body of ["null", "3", "\"n\"", "[]", "[3]", "{}", "{\"m\": 3}", "{\"n\": \"3\"}"]) {
112 expect(said(await admit(GATE, post(body)))).toBe("422 bad test");
113 }
114 const other: Gate = { ...GATE, refusal: "bad run" };
115 expect(said(await admit(other, post("{}")))).toBe("422 bad run");
116 });
117
118 test("the gate's shape, met, comes through with the name it claimed", async () => {
119 expect(said(await admit(GATE, post(JSON.stringify({ n: 3, name: " vesper " }))))).toBe("through as vesper");
120 expect(said(await admit(GATE, post(JSON.stringify({ n: 0 }))))).toBe("through as anon");
121 // What came through is the gate's, whole: its write is the one that is called.
122 const came = await admit(GATE, post(JSON.stringify({ n: MOST })));
123 expect(refused(came)).toBe(false);
124 });
125
126 // --- a counted number
127
128 test("a count is a whole number from 0 to its ceiling, and a fraction is floored", () => {
129 expect(integer(0, MOST)).toBe(0);
130 expect(integer(MOST, MOST)).toBe(MOST);
131 expect(integer(3, MOST)).toBe(3);
132 expect(integer(3.9, MOST)).toBe(3);
133 expect(integer(MOST + 0.5, MOST)).toBe(MOST);
134 });
135
136 test("a number that is not counted is not a count", () => {
137 expect(integer(MOST + 1, MOST)).toBeNull();
138 expect(integer(-1, MOST)).toBeNull();
139 expect(integer(-0.5, MOST)).toBeNull();
140 expect(integer(-0, MOST)).toBeNull();
141 for (const value of [Number.NaN, Infinity, -Infinity, "3", "", null, undefined, true, [3], { n: 3 }, 3n]) {
142 expect(integer(value, MOST)).toBeNull();
143 }
144 });
145
146 test("a count that is not counted is refused at the door, not stored", async () => {
147 for (const n of [MOST + 1, -1, 1e308, "3", null, true]) {
148 expect(said(await admit(GATE, post(JSON.stringify({ n }))))).toBe("422 bad test");
149 }
150 // JSON has no NaN or Infinity, and a body that tries one is not JSON.
151 expect(said(await admit(GATE, post("{\"n\": NaN}")))).toBe("400 bad json");
152 expect(said(await admit(GATE, post("{\"n\": 1e999}")))).toBe("422 bad test");
153 });
154
155 // --- a printable name
156
157 test("a name is kept as typed, trimmed and cut at 24", () => {
158 expect(cleanName("vesper")).toBe("vesper");
159 expect(cleanName(" vesper ")).toBe("vesper");
160 expect(cleanName("x".repeat(30))).toBe("x".repeat(24));
161 expect(cleanName("été \u{1F600}")).toBe("été \u{1F600}");
162 });
163
164 test("what is not printable in a name is a space, and a name of nothing but that is anon", () => {
165 expect(cleanName("a\u0000b")).toBe("a b");
166 expect(cleanName("one\r\ntwo")).toBe("one two");
167 expect(cleanName("\u001b[2Jwiped")).toBe("[2Jwiped");
168 expect(cleanName("del\u007fete")).toBe("del ete");
169 // The C1 controls: U+009B is a CSI to a terminal that reads eight-bit
170 // controls, and U+0085 is a line break to some.
171 expect(cleanName("a\u009b2Jb")).toBe("a 2Jb");
172 expect(cleanName("a\u0085b")).toBe("a b");
173 expect(cleanName("a\u0080b\u009fc")).toBe("a b c");
174 // One line: the two separators JavaScript and text layout both break on.
175 expect(cleanName("one\u2028two\u2029three")).toBe("one two three");
176 // Printable neighbours of those ranges are left alone.
177 expect(cleanName("~\u00a0¡")).toBe("~\u00a0¡");
178 for (const value of ["", " ", "\t\n", "\u0000\u009b\u2028"]) expect(cleanName(value)).toBe("anon");
179 });
180
181 test("a name that is not a string is anon", () => {
182 for (const value of [undefined, null, 7, true, ["vesper"], { name: "vesper" }]) {
183 expect(cleanName(value)).toBe("anon");
184 }
185 });
186
187 // --- who it belongs to
188
189 const VESPER: Who = { role: "user", id: "u-42", name: "vesper" };
190
191 test("a guest is written under the name they claimed, cleaned, and no identity", () => {
192 expect(playerOf(ANON, "s-1", " guest\u0000one ")).toEqual({ userId: "", session: "s-1", name: "guest one" });
193 expect(playerOf(ANON, "s-1", "")).toEqual({ userId: "", session: "s-1", name: "anon" });
194 });
195
196 test("a verified identity is written under its own name, whatever the browser claimed", () => {
197 expect(playerOf(VESPER, "s-2", "somebody else")).toEqual({ userId: "u-42", session: "s-2", name: "vesper" });
198 const admin: Who = { role: "admin", id: "a-1", name: "root\u009b" };
199 // The forge's name is cleaned too: it is printed on the same board.
200 expect(playerOf(admin, "s-3", "vesper")).toEqual({ userId: "a-1", session: "s-3", name: "root" });
201 });
202
203 test("a guest can never be written as a signed-in player's row", () => {
204 // Claiming a verified player's name gives a guest that name and nothing
205 // else: the row a board keys by identity is a userId, and a guest's is empty.
206 const guest = playerOf(ANON, "s-4", VESPER.name);
207 expect(guest.userId).toBe("");
208 expect(guest.name).toBe(VESPER.name);
209 });
src/gate.ts +94 −4
@@ -16,8 +16,11 @@
1616 * accepted at it, and who the thing that came through belongs to.
1717 *
1818 * Nothing here knows a game. It holds the two primitives every boundary in this
19 * app needs — a counted number and a printable name — and the two shapes the
20 * server calls: `Gate`, one game's door, and `Checked`, what came through it.
19 * app needs — a counted number and a printable name — the two shapes the
20 * server calls: `Gate`, one game's door, and `Checked`, what came through it —
21 * and the steps of the door that are plain functions: `readBody` under its cap,
22 * `admit` through JSON and a gate's shape, and `playerOf` for who it belongs
23 * to. `src/gate.test.ts` holds each of them without a server.
2124 * The games' own gates are assembled in `src/server.ts` beside `RENDER`, for
2225 * the same reason the pages are: a gate holds functions, and a function is not
2326 * data in a registry.
@@ -25,6 +28,7 @@
2528
2629 import type { Database } from "bun:sqlite";
2730 import type { Player } from "./db.ts";
31 import type { Who } from "./who.ts";
2832
2933 /**
3034 * A whole number between 0 and `max`, or null. Everything either game posts is
@@ -42,10 +46,17 @@ export function integer(value: unknown, max: number): number | null {
4246 return n < 0 || n > max ? null : n;
4347 }
4448
45 /** A name is one line of printable text, short, and never empty. */
49 /**
50 * A name is one line of printable text, short, and never empty. Unprintable is
51 * the C0 controls, DEL and the C1 controls — the same class the terminal's
52 * `printable` in `src/terminal/screen.ts` blots out on the way out, so U+009B,
53 * which a terminal can read as CSI, is not stored as part of a name — and one
54 * line also means no U+2028 or U+2029, the two separators that are a line
55 * break to JavaScript and to every text layout.
56 */
4657 export function cleanName(value: unknown): string {
4758 if (typeof value !== "string") return "anon";
48 const stripped = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 24);
59 const stripped = value.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").trim().slice(0, 24);
4960 return stripped.length === 0 ? "anon" : stripped;
5061 }
5162
@@ -74,3 +85,82 @@ export type Gate = {
7485 /** What a post that landed answers with: this game's board, rendered. */
7586 board(db: Database): string;
7687 };
88
89 /**
90 * The most a posted score's body may be, in bytes actually read. A match is a
91 * dozen fields and a run is eight; sixteen kilobytes is generous for either.
92 */
93 export const BODY_MAX = 16 * 1024;
94
95 /**
96 * A request body is read up to a cap and no further, counting the bytes as
97 * they arrive. A declared Content-Length is a claim: a chunked request has
98 * none, and a lie is free. Undefined means "too large" — refuse it. Past the
99 * cap the rest is drained and dropped so the connection stays usable for
100 * the next request; past eight times the cap it is cut off instead.
101 */
102 export async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> {
103 const reader = request.body?.getReader();
104 if (reader === undefined) return new Uint8Array();
105 const parts: Uint8Array[] = [];
106 let size = 0;
107 for (;;) {
108 const { done, value } = await reader.read();
109 if (done) break;
110 size += value.byteLength;
111 if (size > max) {
112 for (;;) {
113 const next = await reader.read();
114 if (next.done) return undefined;
115 size += next.value.byteLength;
116 if (size > max * 8) {
117 await reader.cancel();
118 return undefined;
119 }
120 }
121 }
122 parts.push(value);
123 }
124 const body = new Uint8Array(size);
125 let at = 0;
126 for (const part of parts) {
127 body.set(part, at);
128 at += part.byteLength;
129 }
130 return body;
131 }
132
133 /** What a door says when it will not take a body: the status, and the words. */
134 export type Refused = { status: 413 | 400 | 422; reason: string };
135
136 /**
137 * The body of a post, through one game's door: read under the cap, parsed as
138 * JSON and checked by that game's shape. Either what came through, or the
139 * refusal the server answers with — "too large", "bad json", or the gate's own
140 * words for a body that is JSON and not its game's numbers.
141 */
142 export async function admit(gate: Gate, request: Request): Promise<Checked | Refused> {
143 const body = await readBody(request);
144 if (body === undefined) return { status: 413, reason: "too large" };
145 let parsed: unknown;
146 try {
147 parsed = JSON.parse(new TextDecoder().decode(body));
148 } catch {
149 return { status: 400, reason: "bad json" };
150 }
151 return gate.accept(parsed) ?? { status: 422, reason: gate.refusal };
152 }
153
154 /** Whether what came back from `admit` is a refusal rather than a score. */
155 export const refused = (came: Checked | Refused): came is Refused => "status" in came;
156
157 /**
158 * Who a score is recorded as. A verified forge identity plays under its own
159 * name and the browser's claim is ignored; everyone else is a guest, whose
160 * name is theirs to pick and is shown as a guest's wherever it appears. So
161 * nobody who is not signed in can ever land in a signed-in player's row.
162 */
163 export const playerOf = (visitor: Who, session: string, claimed: string): Player =>
164 visitor.role === "anon"
165 ? { userId: "", session, name: cleanName(claimed) }
166 : { userId: visitor.id, session, name: cleanName(visitor.name) };
src/independence.test.ts +8
@@ -126,6 +126,10 @@ test("every game has the sources this test is about, and the app has none", () =
126126 expect(gameOf("src/pages/range.ts")).toBeNull();
127127 expect(gameOf("src/games.ts")).toBeNull();
128128 expect(gameOf("scripts/build.ts")).toBeNull();
129 // The doors both games' scores come in through are the app's, not a game's.
130 expect(gameOf("src/gate.ts")).toBeNull();
131 expect(gameOf("src/run.ts")).toBeNull();
132 expect(gameOf("src/result.ts")).toBeNull();
129133 });
130134
131135 test("no game's sources import another game's, in either direction", () => {
@@ -197,6 +201,10 @@ test("the engine's door, the shell, a sibling and the app are what a game may re
197201 expect(importsAnotherGame("src/server.ts", "./pages/range.ts")).toBeNull();
198202 expect(importsAnotherGame("src/pages/games.ts", "../games.ts")).toBeNull();
199203 expect(importsAnotherGame("scripts/build.ts", "../src/range/main.ts")).toBeNull();
204 // The range's door reads the range's drills: the app knowing a game, which
205 // is allowed, and its test reads them the same way.
206 expect(importsAnotherGame("src/run.ts", "./range/targets.ts")).toBeNull();
207 expect(importsAnotherGame("src/run.test.ts", "./range/targets.ts")).toBeNull();
200208 });
201209
202210 test("a shooter file that reached into the range is caught by reading it", () => {
src/run.test.ts added +208
@@ -0,0 +1,208 @@
1 /**
2 * What a browser may say about a range run, held from both sides of every
3 * ceiling in `src/run.ts`. The limits are the drill's own — every expectation
4 * below is read out of `DRILLS` in `src/range/targets.ts`, never typed again —
5 * so a tuning that moves a drill moves these tests with it, and a ceiling
6 * deleted from `run.ts` turns its own pair red.
7 *
8 * Each refusal is asserted by what the door does with it: `parseRun` answers
9 * null and the server answers "bad run". No server, no database: a real run is
10 * played here through the range's own verbs, which takes milliseconds.
11 */
12
13 import { expect, test } from "bun:test";
14 import { sub, type Vec3 } from "./engine/index.ts";
15 import {
16 centreOf, createRun, DRILL_NAMES, DRILLS, type Drill, type DrillName, EYE, report, shoot, start, step,
17 } from "./range/targets.ts";
18 import {
19 drillNamed, MAX_SEED, mostPerPlate, mostPlates, mostScore, mostShots, parseRun, type RangeRun, SHOTS_A_SECOND,
20 } from "./run.ts";
21
22 const RUN_ID = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
23
24 /** The range's page steps at 50 Hz; so does the script here. */
25 const TICK = 0.02;
26
27 /**
28 * Thirty seconds of a drill, played by a script that cannot miss: every tick,
29 * every plate standing is shot through its centre from the firing line. This
30 * is the most a real run of the drill hits and scores, and it is still less
31 * than the ceilings, which are what a run *could* not exceed rather than what
32 * one does.
33 */
34 function perfect(drill: Drill, seed: number): RangeRun {
35 const run = createRun(seed, drill);
36 start(run);
37 let ticks = 0;
38 const centre: Vec3 = [0, 0, 0];
39 while (run.phase === "live") {
40 step(run, TICK);
41 ticks += 1;
42 for (const target of [...run.targets]) shoot(run, EYE, sub(centreOf(target, centre), EYE));
43 }
44 const r = report(run);
45 return {
46 runId: RUN_ID, name: "tester", drill: drill.name,
47 score: r.score, hits: r.hits, shots: r.shots, seed, seconds: Math.ceil(ticks * TICK),
48 };
49 }
50
51 /** A posted body: a real run's numbers, with the named fields changed. */
52 const body = (drill: Drill, over: Record<string, unknown> = {}): Record<string, unknown> => ({
53 runId: RUN_ID, name: "tester", drill: drill.name,
54 score: 0, hits: 0, shots: 0, seed: 1, seconds: drill.runTime,
55 ...over,
56 });
57
58 /** Every drill, so a third one is held to all of this by existing. */
59 const EACH: Drill[] = DRILL_NAMES.map((name) => DRILLS[name]);
60
61 /** The most seconds a run of the drill may say it lasted: its clock and one second of slack. */
62 const mostSeconds = (drill: Drill): number => Math.ceil(drill.runTime) + 1;
63
64 /** The fullest run a drill admits: every plate raised, hit, at its best, on the last second. */
65 const fullest = (drill: Drill): Record<string, unknown> =>
66 body(drill, {
67 hits: mostPlates(drill), shots: mostShots(drill), score: mostScore(drill),
68 seed: MAX_SEED, seconds: mostSeconds(drill),
69 });
70
71 // --- the ceilings are the drill's
72
73 test("each ceiling is the drill's own numbers, worked the way the module says", () => {
74 for (const drill of EACH) {
75 // The plates up at the start, one per gap of the clock, and the last tick's.
76 expect(mostPlates(drill)).toBe(drill.atOnce + Math.floor(drill.runTime / drill.gap) + 1);
77 // The farther end of the lane at the fastest slide.
78 expect(mostPerPlate(drill)).toBe(
79 Math.max(drill.points.near, drill.points.far) + drill.points.perSpeed * drill.speed.max,
80 );
81 expect(mostScore(drill)).toBe(Math.ceil(mostPlates(drill) * mostPerPlate(drill)));
82 expect(mostShots(drill)).toBe(Math.ceil(drill.runTime * SHOTS_A_SECOND));
83 }
84 });
85
86 // --- the run a real thirty seconds produces is taken
87
88 test("a perfect run of every drill, played through the range's own rules, is taken whole", () => {
89 for (const drill of EACH) {
90 for (const seed of [1, 7, 0xdead_beef, MAX_SEED]) {
91 const run = perfect(drill, seed);
92 expect(run.hits).toBeGreaterThan(0);
93 expect(run.hits).toBeLessThanOrEqual(mostPlates(drill));
94 expect(parseRun(run)).toEqual(run);
95 }
96 }
97 });
98
99 test("the fullest run each drill admits is taken, and a run of nothing is too", () => {
100 for (const drill of EACH) {
101 expect(parseRun(fullest(drill))).not.toBeNull();
102 expect(parseRun(body(drill))).toEqual({
103 runId: RUN_ID, name: "tester", drill: drill.name, score: 0, hits: 0, shots: 0, seed: 1, seconds: drill.runTime,
104 });
105 }
106 });
107
108 // --- and one past each ceiling is refused
109
110 test("one hit more than the drill can raise plates for is refused", () => {
111 for (const drill of EACH) {
112 const most = mostPlates(drill);
113 expect(parseRun(body(drill, { hits: most, shots: most }))).not.toBeNull();
114 expect(parseRun(body(drill, { hits: most + 1, shots: most + 1 }))).toBeNull();
115 }
116 });
117
118 test("one shot fewer than the hits is refused: every hit was a shot", () => {
119 for (const drill of EACH) {
120 expect(parseRun(body(drill, { hits: 5, shots: 5 }))).not.toBeNull();
121 expect(parseRun(body(drill, { hits: 5, shots: 4 }))).toBeNull();
122 expect(parseRun(body(drill, { hits: 1, shots: 0 }))).toBeNull();
123 }
124 });
125
126 test("one shot more than a trigger can be pulled in the clock is refused", () => {
127 for (const drill of EACH) {
128 expect(parseRun(body(drill, { shots: mostShots(drill) }))).not.toBeNull();
129 expect(parseRun(body(drill, { shots: mostShots(drill) + 1 }))).toBeNull();
130 }
131 });
132
133 test("a score the drill cannot reach is refused, overall and for the hits it claims", () => {
134 for (const drill of EACH) {
135 const most = mostPlates(drill);
136 expect(parseRun(body(drill, { hits: most, shots: most, score: mostScore(drill) + 1 }))).toBeNull();
137 // Three hits pay at most three plates' worth, whatever the ceiling overall.
138 const three = Math.ceil(3 * mostPerPlate(drill));
139 expect(parseRun(body(drill, { hits: 3, shots: 3, score: three }))).not.toBeNull();
140 expect(parseRun(body(drill, { hits: 3, shots: 3, score: three + 1 }))).toBeNull();
141 // No hits is no score.
142 expect(parseRun(body(drill, { score: 1 }))).toBeNull();
143 }
144 });
145
146 test("a clock that is negative, or longer than the drill lasts, is refused", () => {
147 for (const drill of EACH) {
148 expect(parseRun(body(drill, { seconds: 0 }))).not.toBeNull();
149 expect(parseRun(body(drill, { seconds: mostSeconds(drill) }))).not.toBeNull();
150 expect(parseRun(body(drill, { seconds: mostSeconds(drill) + 1 }))).toBeNull();
151 expect(parseRun(body(drill, { seconds: -1 }))).toBeNull();
152 expect(parseRun(body(drill, { seconds: -0 }))).toBeNull();
153 }
154 });
155
156 test("a seed that is not a 32-bit whole number is not a seed", () => {
157 const drill = DRILLS.popup;
158 expect(parseRun(body(drill, { seed: 0 }))?.seed).toBe(0);
159 expect(parseRun(body(drill, { seed: MAX_SEED }))?.seed).toBe(MAX_SEED);
160 expect(MAX_SEED).toBe(2 ** 32 - 1);
161 for (const seed of [MAX_SEED + 1, -1, 2 ** 53, "7", null, undefined, true, Number.NaN, Infinity]) {
162 expect(parseRun(body(drill, { seed }))).toBeNull();
163 }
164 });
165
166 test("every counted field refuses what is not a count", () => {
167 for (const field of ["score", "hits", "shots", "seed", "seconds"]) {
168 for (const value of ["1", null, undefined, -1, Number.NaN, Infinity, [1], { n: 1 }]) {
169 expect(parseRun(body(DRILLS.popup, { [field]: value }))).toBeNull();
170 }
171 }
172 });
173
174 // --- the drill, the id, and the name
175
176 test("a drill is one of the table's names and nothing else", () => {
177 for (const name of DRILL_NAMES) expect(drillNamed(name)).toBe(DRILLS[name]);
178 for (const name of ["", "Popup", "sniper", "__proto__", "toString", "constructor", "hasOwnProperty", 1, null]) {
179 expect(drillNamed(name)).toBeNull();
180 expect(parseRun(body(DRILLS.popup, { drill: name }))).toBeNull();
181 }
182 });
183
184 test("a ceiling is the drill's that was named, not the most generous drill's", () => {
185 // Whichever drill raises the fewer plates is refused a hit count the other allows.
186 const [low, high] = [...EACH].sort((a, b) => mostPlates(a) - mostPlates(b)) as [Drill, Drill];
187 if (mostPlates(low) === mostPlates(high)) return;
188 const hits = mostPlates(high);
189 expect(parseRun(body(high, { hits, shots: hits }))).not.toBeNull();
190 expect(parseRun(body(low, { hits, shots: hits }))).toBeNull();
191 });
192
193 test("a run id is the app's id shape, and a run that is not an object is no run", () => {
194 expect(parseRun(body(DRILLS.popup, { runId: "abc" }))).toBeNull();
195 expect(parseRun(body(DRILLS.popup, { runId: "g".repeat(16) }))).toBeNull();
196 expect(parseRun(body(DRILLS.popup, { runId: 12345678 }))).toBeNull();
197 for (const value of [null, undefined, "run", 3, true, []]) expect(parseRun(value)).toBeNull();
198 });
199
200 test("the name is cleaned at the door, and nothing the body invented comes through", () => {
201 const drill: DrillName = "popup";
202 const parsed = parseRun({ ...body(DRILLS[drill], { name: "\u0000 vesper\n" }), accuracy: 1, admin: true });
203 expect(parsed?.name).toBe("vesper");
204 expect(Object.keys(parsed ?? {}).sort()).toEqual(
205 ["runId", "name", "drill", "score", "hits", "shots", "seed", "seconds"].sort(),
206 );
207 expect(parseRun(body(DRILLS[drill], { name: 7 }))?.name).toBe("anon");
208 });
src/server.ts +5 −59
@@ -53,8 +53,8 @@ import { buildBundles, bundlesDir, buildSummary } from "./bundles.ts";
5353 import { kB } from "./bytes.ts";
5454 import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts";
5555 import { measureExample } from "./cost.ts";
56 import { openDatabase, type Player, saveMatch, saveRun } from "./db.ts";
57 import { type Gate } from "./gate.ts";
56 import { openDatabase, saveMatch, saveRun } from "./db.ts";
57 import { admit, type Gate, playerOf, readBody, refused } from "./gate.ts";
5858 import { GAMES } from "./games.ts";
5959 import { createLimiter } from "./limit.ts";
6060 import { examplePage } from "./pages/example.ts";
@@ -73,7 +73,7 @@ import { roomCodeFrom } from "./engine/index.ts";
7373 import { createHub, CLOSE_GONE, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts";
7474 import { playedOn } from "./mapname.ts";
7575 import { styleFor, type Linked } from "./pages/shell.ts";
76 import { cleanName, parseResult } from "./result.ts";
76 import { parseResult } from "./result.ts";
7777 import { parseRun } from "./run.ts";
7878 import { who, type Who } from "./who.ts";
7979
@@ -176,44 +176,8 @@ const sameOrigin = (request: Request): boolean => {
176176 return origin === null || origin === publicOrigin;
177177 };
178178
179 // A request body is read up to a cap and no further, counting the bytes as
180 // they arrive. A declared Content-Length is a claim: a chunked request has
181 // none, and a lie is free. Undefined means "too large" — refuse it. Past the
182 // cap the rest is drained and dropped so the connection stays usable for
183 // the next request; past eight times the cap it is cut off instead.
184 const BODY_MAX = 16 * 1024;
185179 /** A situation is a map name and five numbers; a kilobyte is already generous. */
186180 const PLAN_BODY_MAX = 1024;
187 async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> {
188 const reader = request.body?.getReader();
189 if (reader === undefined) return new Uint8Array();
190 const parts: Uint8Array[] = [];
191 let size = 0;
192 for (;;) {
193 const { done, value } = await reader.read();
194 if (done) break;
195 size += value.byteLength;
196 if (size > max) {
197 for (;;) {
198 const next = await reader.read();
199 if (next.done) return undefined;
200 size += next.value.byteLength;
201 if (size > max * 8) {
202 await reader.cancel();
203 return undefined;
204 }
205 }
206 }
207 parts.push(value);
208 }
209 const body = new Uint8Array(size);
210 let at = 0;
211 for (const part of parts) {
212 body.set(part, at);
213 at += part.byteLength;
214 }
215 return body;
216 }
217181
218182 // A session is only ever a name for "the browser that started this match", so
219183 // one browser cannot overwrite another's result. It carries nothing else.
@@ -334,16 +298,6 @@ type Joined = { id: string; code: string; name: string };
334298 const roomClock = setInterval(() => hub.advance(Date.now()), 10);
335299 roomClock.unref();
336300
337 /**
338 * Who a result is recorded as. A verified forge identity plays under its own
339 * name and the browser's claim is ignored; everyone else is a guest, whose
340 * name is theirs to pick and is shown as a guest's wherever it appears. So
341 * nobody who is not signed in can ever land in a signed-in player's row.
342 */
343 const playerOf = (visitor: Who, session: string, claimed: string): Player =>
344 visitor.role === "anon"
345 ? { userId: "", session, name: cleanName(claimed) }
346 : { userId: visitor.id, session, name: cleanName(visitor.name) };
347301
348302 /**
349303 * The build, before anything is resolved and long before anything is served:
@@ -817,16 +771,8 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re
817771 const held = readSession(request);
818772 if (!mayWrite(visitor, held, listener.requestIP(request)?.address))
819773 return text("slow down", 429, { "retry-after": String(Math.ceil(MATCH_LIMIT.per / 1000)) });
820 const body = await readBody(request);
821 if (body === undefined) return text("too large", 413);
822 let parsed: unknown;
823 try {
824 parsed = JSON.parse(new TextDecoder().decode(body));
825 } catch {
826 return text("bad json", 400);
827 }
828 const checked = gate.accept(parsed);
829 if (checked === null) return text(gate.refusal, 422);
774 const checked = await admit(gate, request);
775 if (refused(checked)) return text(checked.reason, checked.status);
830776 const session = held ?? newSession();
831777 const stored = checked.write(db, playerOf(visitor, session, checked.name));
832778 if (!stored) return text("not yours", 409);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs