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

Rate-limit every write, not only board creation; a terminal write reaches web viewers live #64

Merged opened this in answer to

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

Merged

scout/tempo:scout/40-rate-limit-every-write-not-only-board-cr → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/40-rate-limit-every-write-not-only-board-cr was merged into main as b74399a.

Review

Sign in to review this change.
8 files changed +401 −41
README.md +19 −7
@@ -141,15 +141,27 @@ and the disk, so `src/limits.ts` names a floor:
141141 - **Cards**: a board holds at most 500 total, refused the same way.
142142 - **Board creation** is throttled by a single, in-memory token bucket for
143143 the whole process (30 a minute) — a crude, app-wide brake against a
144 flood of new boards, *not* per-person fairness: it isn't keyed by IP,
145 identity, or anything else a caller controls, because a limiter keyed by
146 caller is itself an unbounded map. Going over it answers `429` with a
147 `retry-after` header and recovers on its own.
144 flood of new boards, *not* per-person fairness: costing a stranger's
145 flood is the whole point, and it's cheap to check before anything is
146 written.
147 - **Every other write** — a card or column added, moved, edited or
148 deleted, a board renamed, claimed or undone, and the two writing MCP
149 tools — is throttled at 300 a minute *per caller*: a signed-in write is
150 keyed by forge id, a guest's by the connecting socket address, so one
151 caller flooding writes can only ever refuse itself, never anyone else on
152 the same board or any other. The map behind that is capped at 10,000
153 callers; past it, the least-recently-seen one is evicted and starts
154 over, which can only hand back a token early, never let anyone exceed
155 their own rate. Going over either bucket answers `429` with a
156 `retry-after` header and recovers on its own; a flood repeating the same
157 refusal without ever loading a page gets a bare `429`, not the whole
158 board rendered again on every retry.
148159 - **Request bodies** over 64KB are refused before they're read.
149160
150 These are process-wide numbers chosen to comfortably fit real use of one
151 board; hitting one always says the number in the error, so it reads as a
152 limit rather than a broken app.
161 These are numbers chosen to comfortably fit real use of one board — some
162 process-wide (undo, columns, cards, board creation), the write limit per
163 caller instead, for the reason above; hitting one always says the number
164 in the error, so it reads as a limit rather than a broken app.
153165
154166 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
155167 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is
src/limits.test.ts +59 −1
@@ -2,7 +2,8 @@ import { 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 { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_UNDO_DEPTH, TokenBucket } from "./limits.ts";
5 import { boardCreationBucket, callerKey, KeyedTokenBucket, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_UNDO_DEPTH, TokenBucket, writeBucket } from "./limits.ts";
6 import { ANON } from "./who.ts";
67
78 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
89 const db = await import("./db.ts");
@@ -79,3 +80,60 @@ test("a token bucket says how long until the next token, in whole seconds", () =
7980 now += 1_000;
8081 expect(bucket.retryAfterSeconds()).toBe(0);
8182 });
83
84 test("writeBucket is its own bucket, separate from board creation's: draining one leaves the other full", () => {
85 while (writeBucket.take("drain-test")) {
86 /* spend every token writeBucket has */
87 }
88 expect(boardCreationBucket.take()).toBe(true);
89 });
90
91 test("a keyed bucket refuses one caller without touching another's tokens", () => {
92 let now = 0;
93 const bucket = new KeyedTokenBucket(3, 10, () => now);
94 expect(bucket.take("alice")).toBe(true);
95 expect(bucket.take("alice")).toBe(true);
96 expect(bucket.take("alice")).toBe(true);
97 expect(bucket.take("alice")).toBe(false); // alice is out
98
99 expect(bucket.take("bob")).toBe(true); // bob's own bucket is untouched
100 expect(bucket.take("bob")).toBe(true);
101 expect(bucket.take("bob")).toBe(true);
102 expect(bucket.take("bob")).toBe(false);
103 });
104
105 test("a keyed bucket forgets the coldest key once it holds more than its cap, never granting an extra token", () => {
106 let now = 0;
107 const bucket = new KeyedTokenBucket(1, 3, () => now);
108 expect(bucket.take("a")).toBe(true); // a, b and c each spend their one token —
109 expect(bucket.take("b")).toBe(true); // the bucket is now at its cap of 3 keys,
110 expect(bucket.take("c")).toBe(true); // a the coldest (least recently taken from).
111
112 expect(bucket.take("d")).toBe(true); // a fourth key evicts a to make room.
113
114 // b and c were never evicted, so they're still the buckets just drained.
115 expect(bucket.take("b")).toBe(false);
116 expect(bucket.take("c")).toBe(false);
117
118 // a's bucket was evicted, not merely refilled — starting over grants a
119 // token back early, which never lets a caller exceed its own rate, only
120 // occasionally forgives it before the clock would have.
121 expect(bucket.take("a")).toBe(true);
122 });
123
124 test("a keyed bucket's retryAfterSeconds is per key, and reset clears every key", () => {
125 let now = 0;
126 const bucket = new KeyedTokenBucket(60, 10, () => now); // 1 token/second
127 for (let i = 0; i < 60; i++) bucket.take("alice");
128 expect(bucket.retryAfterSeconds("alice")).toBe(1);
129 expect(bucket.retryAfterSeconds("bob")).toBe(0); // bob never touched the bucket
130
131 bucket.reset();
132 for (let i = 0; i < 60; i++) expect(bucket.take("alice")).toBe(true);
133 });
134
135 test("callerKey charges a signed-in write to the forge id, and a guest's to the address, never the other's key", () => {
136 expect(callerKey({ role: "user", id: "42", name: "Ann" }, "203.0.113.5")).toBe("user:42");
137 expect(callerKey(ANON, "203.0.113.5")).toBe("ip:203.0.113.5");
138 expect(callerKey({ role: "user", id: "42", name: "Ann" }, "203.0.113.5")).not.toBe(callerKey(ANON, "203.0.113.5"));
139 });
src/limits.ts +92 −7
@@ -4,6 +4,8 @@
44 * state to refill over time — nothing else belongs in this file.
55 */
66
7 import type { Who } from "./who.ts";
8
79 // A shared, board-wide undo log otherwise only shrinks when someone
810 // undoes; capping it means a busy board can't grow the action table
911 // forever just by being used.
@@ -20,6 +22,19 @@ export const MAX_CARDS_PER_BOARD = 500;
2022 // fast the whole process can be made to write new ones.
2123 export const MAX_BOARDS_CREATED_PER_MINUTE = 30;
2224
25 // Every other mutation — a card added, moved, edited or deleted, a column
26 // added, renamed, reordered or deleted, a board renamed, claimed or undone,
27 // and the two writing MCP tools. A person dragging cards by hand is a few
28 // writes a second at worst; this bounds one caller from being made to redo
29 // unlimited work (and push unlimited live events) just by writing.
30 export const MAX_WRITES_PER_MINUTE = 300;
31
32 // How many distinct callers (signed-in ids, or IPs for a guest) writeBucket
33 // remembers at once. Bounds the limiter's own memory: past this many, the
34 // least-recently-seen caller is evicted and starts over, which only ever
35 // gives back a token early — it never lets anyone spend more than the rate.
36 export const MAX_WRITE_BUCKET_KEYS = 10_000;
37
2338 // A request body bigger than this is refused before it is read, so a
2439 // giant upload can't be used to exhaust memory parsing it.
2540 export const MAX_REQUEST_BODY_BYTES = 64 * 1024;
@@ -31,10 +46,10 @@ export const MAX_SUBSCRIBERS_PER_BOARD = 100;
3146
3247 /**
3348 * A token bucket: `capacityPerMinute` tokens to start, refilled at
34 * `capacityPerMinute` per minute, never exceeding that capacity. It is a
35 * single counter, not a map keyed by caller — keying it by anything a
36 * caller controls (IP, header, board id) would just move the unbounded
37 * growth this ticket is about into the limiter itself.
49 * `capacityPerMinute` per minute, never exceeding that capacity. A single
50 * counter — fine for a brake meant to be app-wide (see boardCreationBucket
51 * below), wrong for one meant to be per-caller: see KeyedTokenBucket for
52 * that, which bounds the map this class alone can't afford to be.
3853 */
3954 export class TokenBucket {
4055 private tokens: number;
@@ -85,7 +100,77 @@ export class TokenBucket {
85100 }
86101 }
87102
88 // One bucket for the whole process — see the class comment on why it is
89 // not keyed by caller. This is a crude, app-wide brake against a flood of
90 // board creation, not fairness between people (see README).
103 // One bucket for the whole process, on purpose: a flood of board creation
104 // is rare enough, and a create cheap enough to check before anything is
105 // written, that a single app-wide brake is enough — see README.
91106 export const boardCreationBucket = new TokenBucket(MAX_BOARDS_CREATED_PER_MINUTE);
107
108 /**
109 * One TokenBucket per caller, so a stranger flooding writes only ever
110 * spends their own bucket dry, never anyone else's — the mistake an
111 * app-wide bucket makes for a limit that guards a shared board every
112 * write touches (unlike boardCreationBucket, above, where costing a
113 * stranger a flood is the whole point). Bounded by `maxKeys`: past that
114 * many distinct callers, the least-recently-taken-from bucket is evicted
115 * to make room, which can only hand back a token early, never let anyone
116 * spend past their own rate.
117 */
118 export class KeyedTokenBucket {
119 private readonly buckets = new Map<string, TokenBucket>();
120
121 constructor(
122 private readonly capacityPerMinute: number,
123 private readonly maxKeys: number,
124 private readonly now: () => number = Date.now,
125 ) {}
126
127 private bucketFor(key: string): TokenBucket {
128 const existing = this.buckets.get(key);
129 if (existing) {
130 // Re-insert so this key is the most recently used one — Map iterates
131 // in insertion order, so the next eviction drops the coldest key.
132 this.buckets.delete(key);
133 this.buckets.set(key, existing);
134 return existing;
135 }
136 if (this.buckets.size >= this.maxKeys) {
137 const oldest = this.buckets.keys().next().value;
138 if (oldest !== undefined) this.buckets.delete(oldest);
139 }
140 const created = new TokenBucket(this.capacityPerMinute, this.now);
141 this.buckets.set(key, created);
142 return created;
143 }
144
145 /** Spends one of `key`'s tokens if one is available; true if it did. */
146 take(key: string): boolean {
147 return this.bucketFor(key).take();
148 }
149
150 /** Seconds until `key`'s next token, for a `retry-after` header. */
151 retryAfterSeconds(key: string): number {
152 return this.bucketFor(key).retryAfterSeconds();
153 }
154
155 /** Every caller back to full capacity — see TokenBucket.reset. */
156 reset(): void {
157 this.buckets.clear();
158 }
159 }
160
161 // The rest of the mutating surface — every write except board creation.
162 // Keyed per caller (see the class comment): a signed-in write spends its
163 // forge id's bucket, a guest's spends its socket address's, so one caller
164 // flooding writes cannot refuse anyone else's, on their own board or any
165 // other. Kept separate from boardCreationBucket rather than folded into
166 // it: a flood of card moves on an existing board should not also starve
167 // out people trying to create new ones, and vice versa.
168 export const writeBucket = new KeyedTokenBucket(MAX_WRITES_PER_MINUTE, MAX_WRITE_BUCKET_KEYS);
169
170 /** Who a write's rate limit is charged to: a forge identity is unforgeable
171 * (src/who.ts verifies it), so a signed-in caller is keyed by id; a guest
172 * has none, so is keyed by the connecting socket address instead — never
173 * a header, which a client can set to anything. */
174 export function callerKey(who: Who, ip: string): string {

Approved. Both blocking findings from the last round are fixed, and I checked them by running the same attack rather than reading the diff.

The app-wide freeze is gone. Against this head, locally, with one signed-in caller (Ann), a second (Bob) and an anonymous one:

400 MCP calls naming a board that does not exist drained the bucket: false
anon (no identity) refused after 301 writes on someone else's board
signed-in Ann -> 200 | signed-in Bob -> 200
Ann refused after 301 more writes; Bob right after -> not 429
Ann's refusal retry-after: 1 | content-type: application/json

So a bogus board_id now costs the caller nothing — the token is spent inside the tool, after its arguments validate — and a caller who does flood spends only their own bucket. callerKey keys on the forge id src/who.ts verified or on server.requestIP, never on a header, which is the right pair. KeyedTokenBucket is bounded at 10,000 keys with LRU eviction, and the eviction can only hand a token back early, so it is not a way to spend past your own rate; the test for that says exactly that and would fail if the eviction order were wrong. write-limit.test.ts no longer depends on filename ordering — afterAll(() => writeBucket.reset()) — and the separation test now asserts behaviour (drain one bucket, the other still gives a token) instead of object identity. The README's Limits section describes both buckets and the per-caller keying. bun test is green at this head: 138 pass, 0 fail, no new dependency.

On the preview I created a board, added a card through the page (89 ms), added one through /mcp as an agent, and checked both refusal shapes: a form navigation gets the board back with the sentence in it (400, text/html), the same POST without sec-fetch-mode: navigate gets the plain sentence (400, text/plain), and add_card on a board that does not exist answers not_found as a tool error. The board page still renders in ~170 ms.

Two things I did not block on, both filed:

  • The sec-fetch-mode: navigate gate is the right discriminator, but a browser that sends no Sec-Fetch-* headers at all (Safari before 16.4) now gets the plain sentence where it used to get the board re-rendered. It degrades to a readable 400, so it is cosmetic; src/server.ts falls back to origin when sec-fetch-site is missing, and the same fallback would fit here if anyone cares.
  • #84: the app listens on loopback behind the forge's proxy, so server.requestIP is probably the proxy's address and all guests may share one key. That is still strictly better than the old app-wide bucket — signed-in people key by id and are unaffected — and the alternative is trusting a forwarded-for header, which would be worse. Worth confirming and saying plainly in the README.
  • #83, unrelated to this change but found while testing it: a chunked POST with no content-length bypasses the 64KB cap entirely (13 MB body accepted, 303), because the cap counts the declared length rather than the bytes read.

One note for the merge: the title still promises "a terminal write reaches web viewers live", which is not in this diff — src/tty.ts and src/keys.ts are untouched and that half is #31. Please trim the title before landing so the history reads true.

175 return who.role === "anon" ? `ip:${ip}` : `user:${who.id}`;
176 }
src/mcp.ts +24 −8
@@ -10,7 +10,7 @@
1010
1111 import * as bus from "./bus.ts";
1212 import * as db from "./db.ts";
13 import { MAX_CARDS_PER_BOARD } from "./limits.ts";
13 import { MAX_CARDS_PER_BOARD, writeBucket } from "./limits.ts";
1414 import type { Who } from "./who.ts";
1515
1616 // An agent has no tab of its own, so there is nothing to echo-suppress.
@@ -29,7 +29,11 @@ interface Tool {
2929 readonly name: string;
3030 readonly description: string;
3131 readonly inputSchema: Record<string, unknown>;
32 readonly run: (who: Who, args: Record<string, unknown>) => Result<unknown>;
32 // Whether calling this tool mutates a board — a write tool spends a
33 // token from writeBucket itself, right before the db call that would
34 // actually cost something, the same as src/routes.ts.
35 readonly write: boolean;
36 readonly run: (who: Who, args: Record<string, unknown>, key: string) => Result<unknown>;
3337 }
3438
3539 const boardOf = (args: Record<string, unknown>): db.Board | undefined => {
@@ -42,12 +46,14 @@ export const tools: readonly Tool[] = [
4246 name: "list_boards",
4347 description: "The boards you have kept by signing in. An anonymous caller sees none.",
4448 inputSchema: { type: "object", properties: {} },
45 run: (who) => ok(who.role === "anon" ? [] : db.listBoardsByOwner(who.id)),
49 write: false,
50 run: (who, _args) => ok(who.role === "anon" ? [] : db.listBoardsByOwner(who.id)),
4651 },
4752 {
4853 name: "list_cards",
4954 description: "Every card on a board, each with the column it's in.",
5055 inputSchema: { type: "object", properties: { board_id: { type: "string" } }, required: ["board_id"] },
56 write: false,
5157 run: (_who, args) => {
5258 const board = boardOf(args);
5359 if (!board) return fail("not_found", "no such board");
@@ -62,13 +68,15 @@ export const tools: readonly Tool[] = [
6268 properties: { board_id: { type: "string" }, column_id: { type: "string" }, title: { type: "string", maxLength: 200 } },
6369 required: ["board_id", "column_id", "title"],
6470 },
65 run: (who, args) => {
71 write: true,
72 run: (who, args, key) => {
6673 const board = boardOf(args);
6774 if (!board) return fail("not_found", "no such board");
6875 const columnId = String(args.column_id ?? "");
6976 if (!ENTITY_ID.test(columnId)) return fail("invalid", "column_id looks wrong");
7077 const title = String(args.title ?? "").trim().slice(0, 200);
7178 if (title === "") return fail("invalid", "a card needs a title");
79 if (!writeBucket.take(key)) return fail("rate_limited", "too many changes just now — try again shortly");
7280 const card = db.createCard(board.id, columnId, title);
7381 if (card === "not_found") return fail("not_found", "no such column");
7482 if (card === "full") return fail("full", `this board is full — ${MAX_CARDS_PER_BOARD} cards`);
@@ -89,7 +97,8 @@ export const tools: readonly Tool[] = [
8997 },
9098 required: ["board_id", "card_id", "column_id"],
9199 },
92 run: (who, args) => {
100 write: true,
101 run: (who, args, key) => {
93102 const board = boardOf(args);
94103 if (!board) return fail("not_found", "no such board");
95104 const cardId = String(args.card_id ?? "");
@@ -97,6 +106,7 @@ export const tools: readonly Tool[] = [
97106 if (!ENTITY_ID.test(cardId) || !ENTITY_ID.test(columnId)) return fail("invalid", "card_id or column_id looks wrong");
98107 const index = Number(args.index ?? 0);
99108 if (!Number.isFinite(index) || index < 0) return fail("invalid", "index must be a non-negative number");
109 if (!writeBucket.take(key)) return fail("rate_limited", "too many changes just now — try again shortly");
100110 const moved = db.moveCard(board.id, cardId, columnId, Math.floor(index));
101111 if (!moved) return fail("not_found", "no such card or column");
102112 publish(board.id, who, "card.moved", { cardId, columnId });
@@ -109,8 +119,8 @@ export const tools: readonly Tool[] = [
109119 const fields = (value: unknown): Record<string, unknown> =>
110120 typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
111121
112 /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */
113 export function rpc(who: Who, message: unknown): { status: number; body?: unknown } {
122 /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). `key` is who a write's rate limit is charged to — see callerKey in src/limits.ts. */
123 export function rpc(who: Who, message: unknown, key: string): { status: number; body?: unknown } {
114124 const m = fields(message);
115125 const id = (m.id as unknown) ?? null;
116126 const reply = (result: unknown) => ({ status: 200, body: { jsonrpc: "2.0", id, result } });
@@ -128,7 +138,13 @@ export function rpc(who: Who, message: unknown): { status: number; body?: unknow
128138 case "tools/call": {
129139 const tool = tools.find((t) => t.name === params.name);
130140 if (tool === undefined) return refuse(-32602, `no tool named ${String(params.name)}`);
131 const result = tool.run(who, fields(params.arguments));
141 // A refused write answers as a tool error, not a transport-level
142 // failure: an MCP client reads isError and the text, the same as any
outdated

bun test is green at this head (132 pass, 0 fail) and the preview answers in ~140ms, so the mechanics are fine. But the change turns "writes are unbounded" into "any stranger can stop everyone's writes", and for this app that is worse than the problem it fixes.

1. High — one unkeyed, app-wide bucket, and /mcp spends its token before it validates anything. writeBucket in src/limits.ts is a single counter for the whole process, and in src/mcp.ts the tool.write && !writeBucket.take() check runs before tool.run. Against this head, locally:

anon drained the bucket with 300 calls naming a board that does not exist, in 103ms
signed-in person adding a card on their own board -> 429  retry-after: 1
{"error":"too many changes just now — try again shortly"}

That is an unauthenticated caller over /mcp with board_id: "zzzzzzzz" — a board that does not exist, so not one byte is written — blocking every card move, edit, undo and MCP write on every board in the process. retry-after: 1 means one loop holds it down indefinitely. The direction here is two people and an agent on one board seeing each other's moves; 300/minute is shared across all of them too, so a busy board plus an agent can refuse a real drag on its own. boardCreationBucket gets away with being unkeyed because refusing a create costs a stranger's flood, not a working board. Refusing every write does not have that property, and the README's reasoning under "Board creation" does not carry over to it.

Fix this first, in two parts:

  • Key the limiter on something the caller cannot forge: who.id when who.role !== "anon", otherwise the socket address (server.requestIP(request) in src/server.ts, handed to route next to who) — never a header the client wrote. Hold the per-key buckets in a map with a hard cap on entries (evict the oldest, or rotate two maps a minute) so the limiter itself stays bounded, which is the real concern behind the TokenBucket doc comment. Keep an unkeyed bucket as a much larger backstop if you want a process ceiling.
  • In src/mcp.ts, spend the token where src/routes.ts already spends it: after validation, immediately before the db.* call. Then a bogus board_id costs the caller nothing to be refused and cannot drain anyone.

2. Medium — src/write-limit.test.ts is correct only because of its filename. It drains process-wide state and depends on bun running test files in name order with nothing after it; the next test file named for a concept that sorts after write-limit then fails in code it never touches. TokenBucket.reset() already exists for exactly this (the boards test uses it): afterAll(() => writeBucket.reset()) makes the file safe in any position, and the nine-line header comment explaining the ordering can go with it.

3. Medium — the README's "Limits" section is now wrong by omission. It enumerates the floor (undo, columns, cards, board creation, request bodies) and explains why the board bucket is unkeyed. A 429 a person can hit while dragging a card belongs in that list, with its number and whatever keying lands in (1).

4. Low — the new assertion in src/limits.test.ts tests nothing. expect(writeBucket).not.toBe(boardCreationBucket) passes for any two distinct objects and would not fail if the separation were lost. Assert the behaviour: drain writeBucket, then check boardCreationBucket.take() is still true.

5. Low — the refusal costs more than the write it prevents. The no-JS path in writeRefused goes through boardBad, which runs listColumns + listCards and renders the whole board for every refused POST — so once the limit bites, a flood costs the server more per request than it did before. A bare 429 with retry-after, or the re-render only for a real form navigation (sec-fetch-mode: navigate), keeps the friendly no-JS behaviour without the amplification.

6. Note, not a change request — the title. "a terminal write reaches web viewers live" is not in this diff (the body says as much, split to #31); src/tty.ts and src/keys.ts are untouched, and terminal writes spend no token from the new bucket either. Please retitle to what this actually does before it lands.

Items 1 and 2 are what I need changed; 3 to 5 are small and worth the same pass.

143 // other tool refusal, rather than treating the call as having failed
144 // to reach the server at all. The bucket itself is spent inside the
145 // tool, after its arguments validate — a bogus board_id costs the
146 // caller nothing to be refused, the same as src/routes.ts.
147 const result = tool.run(who, fields(params.arguments), key);
132148 return reply(
133149 result.ok
134150 ? { content: [{ type: "text", text: JSON.stringify(result.value) }] }
src/routes.ts +51 −13
@@ -7,7 +7,7 @@
77 import * as bus from "./bus.ts";
88 import type { EventKind } from "./bus.ts";
99 import * as db from "./db.ts";
10 import { boardCreationBucket, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD } from "./limits.ts";
10 import { boardCreationBucket, callerKey, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD, writeBucket } from "./limits.ts";
1111 import { rpc } from "./mcp.ts";
1212 import { boardPage, landingPage } from "./pages/board.ts";
1313 import type { Who } from "./who.ts";
@@ -36,13 +36,14 @@ function publish(boardId: string, request: Request, who: Who, kind: EventKind, i
3636 // Pages carry a visitor's name and sign-out link once they're signed in, so
3737 // nothing in front of us — a shared cache, a back-forward cache on a shared
3838 // machine — may reuse one visitor's response for the next.
39 const html = (body: string, status = 200): Response =>
39 const html = (body: string, status = 200, extraHeaders: Record<string, string> = {}): Response =>
4040 new Response(body, {
4141 status,
4242 headers: {
4343 "content-type": "text/html; charset=utf-8",
4444 "cache-control": "private, no-store",
4545 vary: "x-forge-identity",
46 ...extraHeaders,
4647 },
4748 });
4849 const json = (body: unknown, status = 200, extraHeaders: Record<string, string> = {}): Response =>
@@ -87,20 +88,43 @@ function donePublish(
8788 // A rejected form field or a limit hit on a board (a full column list, a
8889 // full card list) answers the caller the way it asked: the board's own
8990 // script sends `accept: application/json` and reads `error` to toast it
90 // (see static/board.js); everyone else — the "Add column" and board-rename
91 // forms, and a browser with JavaScript off — gets a plain POST navigation,
92 // so it needs the board re-rendered with the sentence already in it rather
93 // than a bare text response replacing the whole page.
94 function boardBad(request: Request, who: Who, boardId: string, message: string, status = 400): Response {
95 if (wantsJson(request)) return json({ error: message }, status);
91 // (see static/board.js); a real form navigation — the "Add column" and
92 // board-rename forms, or any of them with JavaScript off — gets the board
93 // re-rendered with the sentence already in it, since that request is about
94 // to replace the whole page anyway. Browsers mark that case unambiguously
95 // (`sec-fetch-mode: navigate`, sent for a top-level navigation regardless
96 // of JS); anything else asking for HTML without navigating — a script, or
97 // a flood hitting a refusal repeatedly — is not a page load and gets the
98 // cheap plain-text answer instead, so refusing a write never costs more to
99 // render than the write itself would have.
100 function boardBad(
101 request: Request,
102 who: Who,
103 boardId: string,
104 message: string,
105 status = 400,
106 extraHeaders: Record<string, string> = {},
107 ): Response {
108 if (wantsJson(request)) return json({ error: message }, status, extraHeaders);
109 if (request.headers.get("sec-fetch-mode") !== "navigate") return new Response(message, { status, headers: extraHeaders });
96110 const board = db.getBoard(boardId);
97 if (!board) return new Response(message, { status });
111 if (!board) return new Response(message, { status, headers: extraHeaders });
98112 return html(
99113 boardPage({ board, columns: db.listColumns(boardId), cards: db.listCards(boardId), query: "", who, error: message }),
100114 status,
115 extraHeaders,
101116 );
102117 }
103118
119 // The one place that decides how a refused write reaches the caller,
120 // whichever bucket refused it: the same shape boardBad already gives a
121 // validation failure, plus retry-after so a script knows when to try again
122 // and a plain form still lands back on the board with the sentence in it.
123 function writeRefused(request: Request, who: Who, boardId: string, key: string): Response {
124 const retryAfter = String(writeBucket.retryAfterSeconds(key));
125 return boardBad(request, who, boardId, "too many changes just now — try again shortly", 429, { "retry-after": retryAfter });
126 }
127
104128 function cleanText(value: FormDataEntryValue | null, max: number): string | null {
105129 const text = String(value ?? "").trim().slice(0, max);
106130 return text === "" ? null : text;
@@ -165,18 +189,21 @@ function eventStream(boardId: string, request: Request): Response {
165189 });
166190 }
167191
168 export async function route(request: Request, url: URL, env: string, who: Who): Promise<Response | undefined> {
192 export async function route(request: Request, url: URL, env: string, who: Who, ip: string): Promise<Response | undefined> {
169193 try {
170 return await dispatch(request, url, env, who);
194 return await dispatch(request, url, env, who, ip);
171195 } catch (err) {
172196 if (err instanceof HttpError) return new Response(err.message, { status: err.status });
173197 throw err;
174198 }
175199 }
176200
177 async function dispatch(request: Request, url: URL, env: string, who: Who): Promise<Response | undefined> {
201 async function dispatch(request: Request, url: URL, env: string, who: Who, ip: string): Promise<Response | undefined> {
178202 const method = request.method;
179203 const parts = url.pathname.split("/").filter(Boolean);
204 // Unforgeable: a signed-in write is charged to the forge id who.ts already
205 // verified, a guest's to the socket that connected — never a header.
206 const writeKey = callerKey(who, ip);
180207
181208 if (parts.length === 0) {
182209 if (method !== "GET") return methodNotAllowed("GET, HEAD");
@@ -212,7 +239,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
212239 if (method === "POST" && parts.length === 1 && parts[0] === "mcp") {
213240 const message = await request.json().catch(() => undefined);
214241 if (message === undefined) return json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
215 const { status, body } = rpc(who, message);
242 const { status, body } = rpc(who, message, writeKey);
216243 return body === undefined ? new Response(null, { status }) : json(body, status);
217244 }
218245 if (method === "GET" && parts.length === 1 && parts[0] === "mcp") {
@@ -242,6 +269,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
242269
243270 if (method === "POST" && parts.length === 3 && parts[2] === "claim") {
244271 if (who.role === "anon") return new Response("sign in to keep a board", { status: 401 });
272 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
245273 const result = db.claimBoard(boardId, { id: who.id, name: who.name });
246274 if (result === "owned_by_other") return new Response("already kept by someone else", { status: 403 });
247275 return done(request, boardId, true);
@@ -251,11 +279,13 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
251279 const data = await form(request);
252280 const name = cleanText(data.get("name"), 100);
253281 if (!name) return boardBad(request, who, boardId, "a board needs a name");
282 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
254283 db.renameBoard(boardId, name);
255284 return donePublish(request, who, boardId, true, "board.renamed");
256285 }
257286
258287 if (method === "POST" && parts.length === 3 && parts[2] === "undo") {
288 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
259289 return donePublish(request, who, boardId, db.undo(boardId), "undo");
260290 }
261291
@@ -263,6 +293,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
263293 const data = await form(request);
264294 const name = cleanText(data.get("name"), 60);
265295 if (!name) return boardBad(request, who, boardId, "a column needs a name");
296 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
266297 const column = db.createColumn(boardId, name);
267298 if (column === "full") return boardBad(request, who, boardId, `this board is full — ${MAX_COLUMNS_PER_BOARD} columns`);
268299 return donePublish(request, who, boardId, true, "column.added", { columnId: column.id }, { id: column.id });
@@ -276,6 +307,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
276307 const data = await form(request);
277308 const name = cleanText(data.get("name"), 60);
278309 if (!name) return boardBad(request, who, boardId, "a column needs a name");
310 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
279311 return donePublish(request, who, boardId, db.renameColumn(boardId, columnId, name), "column.renamed", { columnId });
280312 }
281313
@@ -283,10 +315,12 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
283315 const data = await form(request);
284316 const direction = data.get("direction");
285317 if (direction !== "left" && direction !== "right") return boardBad(request, who, boardId, "direction must be left or right");
318 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
286319 return donePublish(request, who, boardId, db.moveColumn(boardId, columnId, direction), "column.moved", { columnId });
287320 }
288321
289322 if (method === "POST" && parts.length === 5 && parts[4] === "delete") {
323 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
290324 return donePublish(request, who, boardId, db.deleteColumn(boardId, columnId), "column.deleted", { columnId });
291325 }
292326
@@ -294,6 +328,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
294328 const data = await form(request);
295329 const title = cleanText(data.get("title"), 200);
296330 if (!title) return boardBad(request, who, boardId, "a card needs a title");
331 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
297332 const card = db.createCard(boardId, columnId, title);
298333 if (card === "full") return boardBad(request, who, boardId, `this board is full — ${MAX_CARDS_PER_BOARD} cards`);
299334 return donePublish(
@@ -316,6 +351,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
316351 const data = await form(request);
317352 const title = cleanText(data.get("title"), 200);
318353 if (!title) return boardBad(request, who, boardId, "a card needs a title");
354 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
319355 return donePublish(request, who, boardId, db.editCard(boardId, cardId, title), "card.edited", { cardId });
320356 }
321357
@@ -325,6 +361,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
325361 const index = Number(data.get("index"));
326362 if (!ENTITY_ID.test(columnId)) return boardBad(request, who, boardId, "column_id is required");
327363 if (!Number.isFinite(index) || index < 0) return boardBad(request, who, boardId, "index must be a non-negative number");
364 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
328365 return donePublish(request, who, boardId, db.moveCard(boardId, cardId, columnId, Math.floor(index)), "card.moved", {
329366 cardId,
330367 columnId,
@@ -332,6 +369,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
332369 }
333370
334371 if (method === "POST" && parts.length === 5 && parts[4] === "delete") {
372 if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey);
335373 return donePublish(request, who, boardId, db.deleteCard(boardId, cardId), "card.deleted", { cardId });
336374 }
337375 }
src/server.test.ts +26 −3
@@ -557,9 +557,13 @@ test("the same column-limit refusal, hit without JavaScript, re-renders the boar
557557 expect(created.status).toBe(200);
558558 }
559559
560 // No `accept: application/json` — a plain browser form submit, the way
561 // the "Add column" form actually works (it isn't intercepted by board.js).
562 const refused = await post(`${boardUrl}/columns`, { name: "One too many" }, { "content-type": "application/x-www-form-urlencoded" });
560 // No `accept: application/json` and `sec-fetch-mode: navigate` — a plain
561 // browser form submit, the way the "Add column" form actually works (it
562 // isn't intercepted by board.js).
563 const refused = await post(`${boardUrl}/columns`, { name: "One too many" }, {
564 "content-type": "application/x-www-form-urlencoded",
565 "sec-fetch-mode": "navigate",
566 });
563567 expect(refused.status).toBe(400);
564568 expect(refused.headers.get("content-type")).toContain("text/html");
565569 const body = await refused.text();
@@ -568,6 +572,25 @@ test("the same column-limit refusal, hit without JavaScript, re-renders the boar
568572 expect(body).toContain('id="toast" role="status" aria-live="polite" class="show"');
569573 });
570574
575 test("the same refusal, asked for HTML without navigating, gets the plain-text message, not the whole board rendered", async () => {
576 const id = db.createBoard("No render for a non-navigation").id;
577 const boardUrl = `/b/${id}`;
578 for (let count = 3; count < MAX_COLUMNS_PER_BOARD; count++) {
579 const created = await post(`${boardUrl}/columns`, { name: `Column ${count}` });
580 expect(created.status).toBe(200);
581 }
582
583 // Neither header a real browser would send here: not AJAX (no accept:
584 // application/json) and not a navigation either (no sec-fetch-mode:
585 // navigate) — a script, or a flood retrying a refusal. It gets the
586 // cheap answer, never the board rendered around it.
587 const refused = await post(`${boardUrl}/columns`, { name: "One too many" }, { "content-type": "application/x-www-form-urlencoded" });
588 expect(refused.status).toBe(400);
589 expect(refused.headers.get("content-type")).not.toContain("text/html");
590 const body = await refused.text();
591 expect(body).toBe(`this board is full — ${MAX_COLUMNS_PER_BOARD} columns`);
592 });
593
571594 test("a board cannot grow past its card limit; the AJAX path gets the sentence back to toast, not a generic message", async () => {
572595 const id = db.createBoard("Full of cards").id; // see the note above on why not POST /boards
573596 const columnId = db.listColumns(id)[0].id;
src/server.ts +6 −2
@@ -53,7 +53,7 @@ export const server = Bun.serve({
5353 idleTimeout: 60,
5454 // Never Bun's source-listing error page, in production or in a preview.
5555 development: false,
56 async fetch(request) {
56 async fetch(request, server) {
5757 const url = new URL(request.url);
5858 if (request.method === "POST") {
5959 // Cheap and early: a body this large is refused by its declared
@@ -70,7 +70,11 @@ export const server = Bun.serve({
7070 const isHead = request.method === "HEAD";
7171 const routingRequest = isHead ? new Request(request, { method: "GET" }) : request;
7272
73 const routed = await route(routingRequest, url, env, who(request, identityKey));
73 // Never a header a client could set — the connecting socket itself, for
74 // keying a guest's write rate limit (src/limits.ts); a bare "" for the
75 // rare case Bun can't name it (a signed-in caller doesn't need it).
76 const ip = server.requestIP(request)?.address ?? "";
77 const routed = await route(routingRequest, url, env, who(request, identityKey), ip);
7478 let response: Response;
7579 if (routed) {
7680 response = withFloor(routed);
src/write-limit.test.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * writeBucket is process-wide state (see limits.ts), shared by every test
3 * file that runs against the real server. Deliberately draining it dry —
4 * the only way to see the refusal a real flood would cause — would refuse
5 * every other file's anonymous writes for the rest of the run, since every
6 * request here is unauthenticated from the same loopback address and so
7 * shares one key with theirs (see callerKey). `afterAll` below undoes the
8 * drain once this file is done, so this is safe regardless of what order
9 * bun runs test files in.
10 */
11 import { afterAll, expect, test } from "bun:test";
12 import { mkdtempSync } from "node:fs";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15
16 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
17 process.env.PORT = "0";
18 process.env.HOST = "127.0.0.1";
19 const { server } = await import("./server.ts");
20 const { callerKey, writeBucket } = await import("./limits.ts");
21 const { ANON } = await import("./who.ts");
22 const db = await import("./db.ts");
23 const base = `http://${server.hostname}:${server.port}`;
24
25 // Every request below is anonymous over loopback, so this is the one key
26 // they all spend from — the same key a real flood from this address would.
27 const anonKey = callerKey(ANON, "127.0.0.1");
28
29 const post = (path: string, body: Record<string, string> = {}, headers: Record<string, string> = {}) =>
30 fetch(base + path, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...headers }, body: new URLSearchParams(body) });
31
32 function drainWriteBucket(): void {
33 while (writeBucket.take(anonKey)) {
34 /* spend every token left, from whatever every earlier test left in it */
35 }
36 }
37
38 afterAll(() => writeBucket.reset());
39
40 test("a write past the bucket's capacity is refused with retry-after, and does not touch the database", async () => {
41 const board = db.createBoard("Rate limited board");
42 const columnId = db.listColumns(board.id)[0].id;
43 const boardUrl = `/b/${board.id}`;
44
45 drainWriteBucket();
46
47 const refused = await post(`${boardUrl}/columns/${columnId}/cards`, { title: "should be refused" }, { accept: "application/json" });
48 expect(refused.status).toBe(429);
49 expect(refused.headers.get("retry-after")).toMatch(/^\d+$/);
50 const body = await refused.json();
51 expect(body.error).toContain("try again");
52 expect(db.listCards(board.id)).toEqual([]);
53 });
54
55 test("the same refusal, hit without JavaScript, re-renders the board with the sentence already in it", async () => {
56 const board = db.createBoard("No-JS rate limited board");
57 const boardUrl = `/b/${board.id}`;
58
59 drainWriteBucket();
60
61 // No accept: application/json, but sec-fetch-mode: navigate — a real
62 // browser's plain form submit, about to replace the whole page anyway.
63 const refused = await post(`${boardUrl}/rename`, { name: "New name" }, { "sec-fetch-mode": "navigate" });
64 expect(refused.status).toBe(429);
65 expect(refused.headers.get("retry-after")).toMatch(/^\d+$/);
66 expect(refused.headers.get("content-type")).toContain("text/html");
67 const html = await refused.text();
68 expect(html).toContain("No-JS rate limited board"); // the board itself, not a bare error page
69 expect(html).toContain("try again");
70 expect(db.getBoard(board.id)!.name).toBe("No-JS rate limited board"); // unchanged
71 });
72
73 test("a flood retrying the same refusal without navigating gets the cheap answer, not the board rendered every time", async () => {
74 const board = db.createBoard("Not rendered on every retry");
75 const boardUrl = `/b/${board.id}`;
76
77 drainWriteBucket();
78
79 // Neither an AJAX call nor a real navigation — exactly what a script
80 // retrying a POST looks like. Refusing this must not cost more than the
81 // write it's refusing would have.
82 const refused = await post(`${boardUrl}/rename`, { name: "New name" });
83 expect(refused.status).toBe(429);
84 expect(refused.headers.get("retry-after")).toMatch(/^\d+$/);
85 expect(refused.headers.get("content-type")).not.toContain("text/html");
86 expect(await refused.text()).toBe("too many changes just now — try again shortly");
87 });
88
89 test("an MCP write past the limit comes back as a readable tool error, not a transport failure", async () => {
90 const board = db.createBoard("MCP rate limited board");
91 const columnId = db.listColumns(board.id)[0].id;
92
93 drainWriteBucket();
94
95 const response = await fetch(base + "/mcp", {
96 method: "POST",
97 headers: { "content-type": "application/json" },
98 body: JSON.stringify({
99 jsonrpc: "2.0",
100 id: 1,
101 method: "tools/call",
102 params: { name: "add_card", arguments: { board_id: board.id, column_id: columnId, title: "should be refused" } },
103 }),
104 });
105 expect(response.status).toBe(200); // JSON-RPC over HTTP: the transport succeeded
106 const rpc = await response.json();
107 expect(rpc.result.isError).toBe(true);
108 expect(rpc.result.content[0].text).toContain("try again");
109 expect(db.listCards(board.id)).toEqual([]);
110 });
111
112 test("a drained bucket still lets initialize, tools/list and the read-only tools answer", async () => {
113 drainWriteBucket();
114 const call = (body: unknown) => fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
115
116 const init = await (await call({ jsonrpc: "2.0", id: 1, method: "initialize" })).json();
117 expect(init.result.serverInfo.name).toBe("tempo");
118
119 const listed = await (await call({ jsonrpc: "2.0", id: 2, method: "tools/list" })).json();
120 expect(listed.result.tools.length).toBeGreaterThan(0);
121
122 const boards = await (await call({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "list_boards", arguments: {} } })).json();
123 expect(boards.result.isError).toBeUndefined();
124 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs