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

One ration at the door, and a 429 that tells the truth: src/ration.ts in src/server.ts #49

Merged opened this

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

Merged

torque/relay:torque/40-one-ration-at-the-door-and-a-429-that-te → main 1 commit fork

  • Merged. torque/40-one-ration-at-the-door-and-a-429-that-te was merged into main as 1be64f6.

Review

Sign in to review this change.
6 files changed +150 −39
README.md +12 −1
@@ -37,7 +37,8 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
3737 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
3838 | `src/routes.ts` | every path, in one table |
3939 | `src/page.ts` | the HTML, rendered on the server |
40 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
40 | `src/http.ts` | the floor: headers, body cap, same-origin, files |
41 | `src/ration.ts` | how much a caller may write, and exactly when to retry |
4142 | `src/server.ts` | boot: open the database, serve |
4243 | `src/tty.ts` | the terminal, over the same actions |
4344 | `src/env.ts` | what the forge told this process |
@@ -91,6 +92,16 @@ curl -X POST https://<app>/api/events -H 'content-type: application/json' \
9192 -d '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in.","source":"shop"}'
9293 ```
9394
95 Writes are rationed per person, twenty in hand and two a second (per
96 address for a stranger, since a person is their own bucket). Over it, a
97 write is refused with `429` and a `retry-after` in whole seconds; the JSON
98 doors (`/api/*` and `/mcp`) also carry the exact wait, so a script can
99 sleep precisely rather than a rounded second too long:
100
101 ```json
102 { "error": "too_many_requests", "message": "...", "retry_after_ms": 500 }
103 ```
104
94105 As an MCP server, for Claude Code or Claude Desktop — `list_events`,
95106 `post_event`, `get_event`:
96107
src/http.ts +23 −25
@@ -1,6 +1,8 @@
11 /**
22 * The floor every response stands on, and the few things a route needs to
3 * read a request safely. Nothing in here knows what an event is.
3 * read a request safely. Nothing in here knows what an event is, and
4 * nothing in here counts: how much a caller may write is `src/ration.ts`,
5 * spent at the door in `src/server.ts`.
46 */
57
68 import { createHash } from "node:crypto";
@@ -46,16 +48,32 @@ export const html = (
4648 },
4749 });
4850
49 export const json = (data: unknown, status = 200): Response =>
51 export const json = (
52 data: unknown,
53 status = 200,
54 headers: Record<string, string> = {},
55 ): Response =>
5056 new Response(JSON.stringify(data), {
5157 status,
52 headers: { "content-type": "application/json; charset=utf-8", ...SECURITY },
58 headers: {
59 "content-type": "application/json; charset=utf-8",
60 ...SECURITY,
61 ...headers,
62 },
5363 });
5464
55 export const text = (body: string, status = 200): Response =>
65 export const text = (
66 body: string,
67 status = 200,
68 headers: Record<string, string> = {},
69 ): Response =>
5670 new Response(body, {
5771 status,
58 headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY },
72 headers: {
73 "content-type": "text/plain; charset=utf-8",
74 ...SECURITY,
75 ...headers,
76 },
5977 });
6078
6179 // A browser on another site can make a person's browser POST here; it cannot
@@ -126,26 +144,6 @@ export const fields = (value: unknown): Record<string, unknown> =>
126144 ? (value as Record<string, unknown>)
127145 : {};
128146
129 // Writes are rationed per person (or per address, for a stranger): a bucket
130 // of `burst` tokens that refills at `perSecond`. In memory, so a restart
131 // forgets — which is the right size for one process on one machine.
132 const RATE = { burst: 20, perSecond: 2 };
133 const buckets = new Map<string, { tokens: number; at: number }>();
134 export function allow(key: string, now = Date.now()): boolean {
135 const bucket = buckets.get(key) ?? { tokens: RATE.burst, at: now };
136 bucket.tokens = Math.min(
137 RATE.burst,
138 bucket.tokens + ((now - bucket.at) / 1000) * RATE.perSecond,
139 );
140 bucket.at = now;
141 buckets.set(key, bucket);
142 if (buckets.size > 10_000)
143 for (const [k, b] of buckets) if (now - b.at > 60_000) buckets.delete(k);
144 if (bucket.tokens < 1) return false;
145 bucket.tokens -= 1;
146 return true;
147 }
148
149147 // Files under ./static are served as they are. A page links each one by
150148 // its content (`/static/app.css?v=3f2a9c1e`), and that address is cached
151149 // for a year: a change is a new address, never a stale cache.
src/ration.test.ts +7 −9
@@ -1,5 +1,4 @@
11 import { expect, test } from "bun:test";
2 import { allow } from "./http.ts";
32 import {
43 APP_EVENTS,
54 keyCount,
@@ -98,17 +97,16 @@ test("an app's ration is a hundred at once, then five a second", () => {
9897 });
9998 });
10099
101 test("a person's ration is the one src/http.ts applies today", () => {
102 // #8 swaps the doors over; this is the check that nothing changes when
103 // it does. `allow()` keeps its own map, so the keys are separate.
104 let allowed = 0;
105 for (let i = 0; i < 25; i++) if (allow("parity-allow", T0)) allowed++;
100 test("a person's ration is twenty at once, then two a second", () => {
101 // The numbers the door applied before this module took the job over, so
102 // the swap changed who computes the ration and not the ration.
106103 let taken = 0;
107104 for (let i = 0; i < 25; i++) if (take("parity-take", T0).ok) taken++;
108 expect(taken).toBe(allowed);
109 expect(taken).toBe(PERSON_WRITES.burst);
110 expect(allow("parity-allow", T0 + 500)).toBe(true);
105 expect(taken).toBe(20);
106 expect(PERSON_WRITES).toEqual({ burst: 20, perSecond: 2 });
107 // Half a second buys exactly one more.
111108 expect(take("parity-take", T0 + 500).ok).toBe(true);
109 expect(take("parity-take", T0 + 500).ok).toBe(false);
112110 });
113111
114112 // Last: this one fills the map, which drops every key the tests above used.
src/ration.ts +1 −1
@@ -46,7 +46,7 @@ export type Ration =
4646 | { ok: false; retryAfterMs: number };
4747
4848 /**
49 * A person's writes, the ration `allow()` in `src/http.ts` applies today:
49 * A person's writes, the ration the door in `src/server.ts` spends:
5050 * twenty in hand is more than a person types in a sitting, and two a second
5151 * is faster than a form can be filled, so the only caller that ever feels
5252 * it is a script wearing a person's cookie.
src/server.test.ts +69
@@ -272,6 +272,75 @@ test("writes are rationed per person", async () => {
272272 expect(codes).toContain(429);
273273 });
274274
275 test("a refused write says exactly when to come back", async () => {
276 // Its own clock, so the wait is arithmetic and not a sleep: the server
277 // reads `now()` for every ration it spends.
278 let now = 1_700_000_000_000;
279 const start = now;
280 const clocked = serve({
281 port: 0,
282 dataDir: mkdtempSync(join(tmpdir(), "app-")),
283 identityKey: KEY,
284 now: () => now,
285 });
286 try {
287 const at = `http://${clocked.hostname}:${clocked.port}`;
288 const scribe = as("u-clock", "scribe", "admin");
289 const post = (path = "/api/events") =>
290 fetch(at + path, {
291 method: "POST",
292 ...jsonBody({ ...event, source: "clock" }),
293 headers: { "content-type": "application/json", ...scribe },
294 });
295
296 // Twenty in hand, spent one at a time on the same millisecond.
297 for (let i = 0; i < 20; i++) expect((await post()).status).toBe(201);
298
299 const refused = await post();
300 expect(refused.status).toBe(429);
301 const body = (await refused.json()) as {
302 error: string;
303 message: string;
304 retry_after_ms: number;
305 };
306 expect(body.error).toBe("too_many_requests");
307 // 2 per second: the 21st write waits half a second for its token.
308 expect(body.retry_after_ms).toBe(500);
309 const seconds = Number(refused.headers.get("retry-after"));
310 expect(seconds).toBeGreaterThanOrEqual(1);
311 expect(seconds).toBe(Math.ceil(body.retry_after_ms / 1000));
312 expect(refused.headers.get("content-security-policy")).toContain(
313 "default-src 'self'",
314 );
315
316 // The MCP door answers JSON too; a door that is not JSON keeps its
317 // sentence, and both carry the same header.
318 const mcp = await post("/mcp");
319 expect(mcp.status).toBe(429);
320 expect(mcp.headers.get("retry-after")).toBe(String(seconds));
321 expect(((await mcp.json()) as { error: string }).error).toBe(
322 "too_many_requests",
323 );
324 const plain = await post("/");
325 expect(plain.status).toBe(429);
326 expect(plain.headers.get("content-type")).toBe(
327 "text/plain; charset=utf-8",
328 );
329 expect(plain.headers.get("retry-after")).toBe(String(seconds));
330 expect(await plain.text()).toBe("too many requests");
331 expect(plain.headers.get("x-content-type-options")).toBe("nosniff");
332
333 // A millisecond before the promise is refused; the promise itself is
334 // kept. Refusals in between spend nothing, so they cannot push it out.
335 now = start + body.retry_after_ms - 1;
336 expect((await post()).status).toBe(429);
337 now = start + body.retry_after_ms;
338 expect((await post()).status).toBe(201);
339 } finally {
340 clocked.stop(true);
341 }
342 });
343
275344 test("stands on the security floor", async () => {
276345 const response = await fetch(base + "/");
277346 expect(response.headers.get("content-security-policy")).toContain(
src/server.ts +38 −3
@@ -2,13 +2,15 @@
22 * The process the forge runs. `serve` opens the database and listens:
33 * every request gets the floor (`http.ts`), an identity (`who.ts`) and
44 * one route (`routes.ts`), and a write is refused before it is read if it
5 * came from another site or from someone writing too fast. Run directly,
5 * came from another site or from someone past their ration (`ration.ts`,
6 * one bucket per person or address). Run directly,
67 * it serves what the forge said; a test calls `serve` with its own.
78 */
89
910 import { openDb } from "./db.ts";
1011 import { dataDir, identityKey, title, where } from "./env.ts";
11 import { allow, html, sameOrigin, text } from "./http.ts";
12 import { html, json, sameOrigin, text } from "./http.ts";
13 import { PERSON_WRITES, take } from "./ration.ts";
1214 import { match } from "./routes.ts";
1315 import { who } from "./who.ts";
1416
@@ -17,10 +19,41 @@ export interface ServeOptions {
1719 readonly hostname?: string | undefined;
1820 readonly dataDir: string;
1921 readonly identityKey: string;
22 /** The clock the ration is measured against. A test brings its own. */
23 readonly now?: (() => number) | undefined;
24 }
25
26 /**
27 * A refused write says when to come back. `retry-after` is whole seconds
28 * rounded up from the exact milliseconds — rounding down would name a
29 * moment that is still refused, and a bare 0 invites an instant retry that
30 * must fail, so the minimum on the wire is 1.
31 *
32 * The body follows the door it came to: a JSON door carries the exact
33 * `retry_after_ms` so a script can sleep precisely rather than a rounded
34 * second too long; anything else gets the sentence a browser would show.
35 * Both stand on the floor in `src/http.ts`.
36 */
37 function tooManyRequests(pathname: string, retryAfterMs: number): Response {
38 const seconds = Math.max(1, Math.ceil(retryAfterMs / 1000));
39 const headers = { "retry-after": String(seconds) };
40 const message = `too many requests: retry in ${retryAfterMs}ms`;
41 return pathname === "/mcp" || pathname.startsWith("/api/")
42 ? json(
43 {
44 error: "too_many_requests",
45 message,
46 retry_after_ms: retryAfterMs,
47 },
48 429,
49 headers,
50 )
51 : text("too many requests", 429, headers);
2052 }
2153
2254 export function serve(options: ServeOptions) {
2355 const db = openDb(options.dataDir);
56 const clock = options.now ?? Date.now;
2457 return Bun.serve({
2558 port: options.port,
2659 hostname: options.hostname ?? "127.0.0.1",
@@ -39,7 +72,9 @@ export function serve(options: ServeOptions) {
3972 me.role === "anon"
4073 ? `ip:${server.requestIP(request)?.address ?? "?"}`
4174 : `user:${me.id}`;
42 if (!allow(from)) return text("too many requests", 429);
75 const ration = take(from, clock(), PERSON_WRITES);
76 if (!ration.ok)
77 return tooManyRequests(url.pathname, ration.retryAfterMs);
4378 }
4479 const found = match(
4580 request.method === "HEAD" ? "GET" : request.method,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs