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
Check · Review · Land · Live done — Landed and live in production.
torque/relay:torque/40-one-ration-at-the-door-and-a-429-that-te → main 1 commit fork
6 files
changed +150
−39
README.md +12 −1
| @@ -37,7 +37,8 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 37 | 37 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | |
| 38 | 38 | | `src/routes.ts` | every path, in one table | |
| 39 | 39 | | `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 | | |
| 41 | 42 | | `src/server.ts` | boot: open the database, serve | |
| 42 | 43 | | `src/tty.ts` | the terminal, over the same actions | |
| 43 | 44 | | `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' \ | ||
| 91 | 92 | -d '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in.","source":"shop"}' |
| 92 | 93 | |
| 93 | 94 | |
| 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 | ||
| 94 | 105 | As an MCP server, for Claude Code or Claude Desktop — `list_events`, |
| 95 | 106 | `post_event`, `get_event`: |
| 96 | 107 |
src/http.ts +23 −25
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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`. | |
| 4 | 6 | */ |
| 5 | 7 | |
| 6 | 8 | import { createHash } from "node:crypto"; |
| @@ -46,16 +48,32 @@ export const html = ( | ||
| 46 | 48 | }, |
| 47 | 49 | }); |
| 48 | 50 | |
| 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 => | |
| 50 | 56 | new Response(JSON.stringify(data), { |
| 51 | 57 | 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 | }, | |
| 53 | 63 | }); |
| 54 | 64 | |
| 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 => | |
| 56 | 70 | new Response(body, { |
| 57 | 71 | 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 | }, | |
| 59 | 77 | }); |
| 60 | 78 | |
| 61 | 79 | // 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> => | ||
| 126 | 144 | ? (value as Record<string, unknown>) |
| 127 | 145 | : {}; |
| 128 | 146 | |
| 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 | ||
| 149 | 147 | // Files under ./static are served as they are. A page links each one by |
| 150 | 148 | // its content (`/static/app.css?v=3f2a9c1e`), and that address is cached |
| 151 | 149 | // for a year: a change is a new address, never a stale cache. |
src/ration.test.ts +7 −9
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { allow } from "./http.ts"; | |
| 3 | 2 | import { |
| 4 | 3 | APP_EVENTS, |
| 5 | 4 | keyCount, |
| @@ -98,17 +97,16 @@ test("an app's ration is a hundred at once, then five a second", () => { | ||
| 98 | 97 | }); |
| 99 | 98 | }); |
| 100 | 99 | |
| 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. | |
| 106 | 103 | let taken = 0; |
| 107 | 104 | 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. | |
| 111 | 108 | expect(take("parity-take", T0 + 500).ok).toBe(true); |
| 109 | expect(take("parity-take", T0 + 500).ok).toBe(false); | |
| 112 | 110 | }); |
| 113 | 111 | |
| 114 | 112 | // 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 = | ||
| 46 | 46 | | { ok: false; retryAfterMs: number }; |
| 47 | 47 | |
| 48 | 48 | /** |
| 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: | |
| 50 | 50 | * twenty in hand is more than a person types in a sitting, and two a second |
| 51 | 51 | * is faster than a form can be filled, so the only caller that ever feels |
| 52 | 52 | * 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 () => { | ||
| 272 | 272 | expect(codes).toContain(429); |
| 273 | 273 | }); |
| 274 | 274 | |
| 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 | ||
| 275 | 344 | test("stands on the security floor", async () => { |
| 276 | 345 | const response = await fetch(base + "/"); |
| 277 | 346 | expect(response.headers.get("content-security-policy")).toContain( |
src/server.ts +38 −3
| @@ -2,13 +2,15 @@ | ||
| 2 | 2 | * The process the forge runs. `serve` opens the database and listens: |
| 3 | 3 | * every request gets the floor (`http.ts`), an identity (`who.ts`) and |
| 4 | 4 | * 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, | |
| 6 | 7 | * it serves what the forge said; a test calls `serve` with its own. |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import { openDb } from "./db.ts"; |
| 10 | 11 | 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"; | |
| 12 | 14 | import { match } from "./routes.ts"; |
| 13 | 15 | import { who } from "./who.ts"; |
| 14 | 16 | |
| @@ -17,10 +19,41 @@ export interface ServeOptions { | ||
| 17 | 19 | readonly hostname?: string | undefined; |
| 18 | 20 | readonly dataDir: string; |
| 19 | 21 | 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); | |
| 20 | 52 | } |
| 21 | 53 | |
| 22 | 54 | export function serve(options: ServeOptions) { |
| 23 | 55 | const db = openDb(options.dataDir); |
| 56 | const clock = options.now ?? Date.now; | |
| 24 | 57 | return Bun.serve({ |
| 25 | 58 | port: options.port, |
| 26 | 59 | hostname: options.hostname ?? "127.0.0.1", |
| @@ -39,7 +72,9 @@ export function serve(options: ServeOptions) { | ||
| 39 | 72 | me.role === "anon" |
| 40 | 73 | ? `ip:${server.requestIP(request)?.address ?? "?"}` |
| 41 | 74 | : `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); | |
| 43 | 78 | } |
| 44 | 79 | const found = match( |
| 45 | 80 | request.method === "HEAD" ? "GET" : request.method, |