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
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/40-rate-limit-every-write-not-only-board-cr → main 3 commits fork 2 threads · 2 unresolved
README.md +19 −7
| @@ -141,15 +141,27 @@ and the disk, so `src/limits.ts` names a floor: | ||
| 141 | 141 | - **Cards**: a board holds at most 500 total, refused the same way. |
| 142 | 142 | - **Board creation** is throttled by a single, in-memory token bucket for |
| 143 | 143 | 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. | |
| 148 | 159 | - **Request bodies** over 64KB are refused before they're read. |
| 149 | 160 | |
| 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. | |
| 153 | 165 | |
| 154 | 166 | It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app |
| 155 | 167 | 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"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | 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"; | |
| 6 | 7 | |
| 7 | 8 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 8 | 9 | const db = await import("./db.ts"); |
| @@ -79,3 +80,60 @@ test("a token bucket says how long until the next token, in whole seconds", () = | ||
| 79 | 80 | now += 1_000; |
| 80 | 81 | expect(bucket.retryAfterSeconds()).toBe(0); |
| 81 | 82 | }); |
| 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 @@ | ||
| 4 | 4 | * state to refill over time — nothing else belongs in this file. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Who } from "./who.ts"; | |
| 8 | ||
| 7 | 9 | // A shared, board-wide undo log otherwise only shrinks when someone |
| 8 | 10 | // undoes; capping it means a busy board can't grow the action table |
| 9 | 11 | // forever just by being used. |
| @@ -20,6 +22,19 @@ export const MAX_CARDS_PER_BOARD = 500; | ||
| 20 | 22 | // fast the whole process can be made to write new ones. |
| 21 | 23 | export const MAX_BOARDS_CREATED_PER_MINUTE = 30; |
| 22 | 24 | |
| 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 | ||
| 23 | 38 | // A request body bigger than this is refused before it is read, so a |
| 24 | 39 | // giant upload can't be used to exhaust memory parsing it. |
| 25 | 40 | export const MAX_REQUEST_BODY_BYTES = 64 * 1024; |
| @@ -31,10 +46,10 @@ export const MAX_SUBSCRIBERS_PER_BOARD = 100; | ||
| 31 | 46 | |
| 32 | 47 | /** |
| 33 | 48 | * 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. | |
| 38 | 53 | */ |
| 39 | 54 | export class TokenBucket { |
| 40 | 55 | private tokens: number; |
| @@ -85,7 +100,77 @@ export class TokenBucket { | ||
| 85 | 100 | } |
| 86 | 101 | } |
| 87 | 102 | |
| 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. | |
| 91 | 106 | 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 { | |
| 175 | return who.role === "anon" ? `ip:${ip}` : `user:${who.id}`; | |
| 176 | } |
src/mcp.ts +24 −8
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import * as bus from "./bus.ts"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import type { Who } from "./who.ts"; |
| 15 | 15 | |
| 16 | 16 | // An agent has no tab of its own, so there is nothing to echo-suppress. |
| @@ -29,7 +29,11 @@ interface Tool { | ||
| 29 | 29 | readonly name: string; |
| 30 | 30 | readonly description: string; |
| 31 | 31 | 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>; | |
| 33 | 37 | } |
| 34 | 38 | |
| 35 | 39 | const boardOf = (args: Record<string, unknown>): db.Board | undefined => { |
| @@ -42,12 +46,14 @@ export const tools: readonly Tool[] = [ | ||
| 42 | 46 | name: "list_boards", |
| 43 | 47 | description: "The boards you have kept by signing in. An anonymous caller sees none.", |
| 44 | 48 | 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)), | |
| 46 | 51 | }, |
| 47 | 52 | { |
| 48 | 53 | name: "list_cards", |
| 49 | 54 | description: "Every card on a board, each with the column it's in.", |
| 50 | 55 | inputSchema: { type: "object", properties: { board_id: { type: "string" } }, required: ["board_id"] }, |
| 56 | write: false, | |
| 51 | 57 | run: (_who, args) => { |
| 52 | 58 | const board = boardOf(args); |
| 53 | 59 | if (!board) return fail("not_found", "no such board"); |
| @@ -62,13 +68,15 @@ export const tools: readonly Tool[] = [ | ||
| 62 | 68 | properties: { board_id: { type: "string" }, column_id: { type: "string" }, title: { type: "string", maxLength: 200 } }, |
| 63 | 69 | required: ["board_id", "column_id", "title"], |
| 64 | 70 | }, |
| 65 | run: (who, args) => { | |
| 71 | write: true, | |
| 72 | run: (who, args, key) => { | |
| 66 | 73 | const board = boardOf(args); |
| 67 | 74 | if (!board) return fail("not_found", "no such board"); |
| 68 | 75 | const columnId = String(args.column_id ?? ""); |
| 69 | 76 | if (!ENTITY_ID.test(columnId)) return fail("invalid", "column_id looks wrong"); |
| 70 | 77 | const title = String(args.title ?? "").trim().slice(0, 200); |
| 71 | 78 | 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"); | |
| 72 | 80 | const card = db.createCard(board.id, columnId, title); |
| 73 | 81 | if (card === "not_found") return fail("not_found", "no such column"); |
| 74 | 82 | if (card === "full") return fail("full", `this board is full — ${MAX_CARDS_PER_BOARD} cards`); |
| @@ -89,7 +97,8 @@ export const tools: readonly Tool[] = [ | ||
| 89 | 97 | }, |
| 90 | 98 | required: ["board_id", "card_id", "column_id"], |
| 91 | 99 | }, |
| 92 | run: (who, args) => { | |
| 100 | write: true, | |
| 101 | run: (who, args, key) => { | |
| 93 | 102 | const board = boardOf(args); |
| 94 | 103 | if (!board) return fail("not_found", "no such board"); |
| 95 | 104 | const cardId = String(args.card_id ?? ""); |
| @@ -97,6 +106,7 @@ export const tools: readonly Tool[] = [ | ||
| 97 | 106 | if (!ENTITY_ID.test(cardId) || !ENTITY_ID.test(columnId)) return fail("invalid", "card_id or column_id looks wrong"); |
| 98 | 107 | const index = Number(args.index ?? 0); |
| 99 | 108 | 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"); | |
| 100 | 110 | const moved = db.moveCard(board.id, cardId, columnId, Math.floor(index)); |
| 101 | 111 | if (!moved) return fail("not_found", "no such card or column"); |
| 102 | 112 | publish(board.id, who, "card.moved", { cardId, columnId }); |
| @@ -109,8 +119,8 @@ export const tools: readonly Tool[] = [ | ||
| 109 | 119 | const fields = (value: unknown): Record<string, unknown> => |
| 110 | 120 | typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}; |
| 111 | 121 | |
| 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 } { | |
| 114 | 124 | const m = fields(message); |
| 115 | 125 | const id = (m.id as unknown) ?? null; |
| 116 | 126 | 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 | ||
| 128 | 138 | case "tools/call": { |
| 129 | 139 | const tool = tools.find((t) => t.name === params.name); |
| 130 | 140 | 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
1. High — one unkeyed, app-wide bucket, and
That is an unauthenticated caller over Fix this first, in two parts:
2. Medium — 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 5. Low — the refusal costs more than the write it prevents. The no-JS path in 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); 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); | |
| 132 | 148 | return reply( |
| 133 | 149 | result.ok |
| 134 | 150 | ? { content: [{ type: "text", text: JSON.stringify(result.value) }] } |
src/routes.ts +51 −13
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | import * as bus from "./bus.ts"; |
| 8 | 8 | import type { EventKind } from "./bus.ts"; |
| 9 | 9 | 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"; | |
| 11 | 11 | import { rpc } from "./mcp.ts"; |
| 12 | 12 | import { boardPage, landingPage } from "./pages/board.ts"; |
| 13 | 13 | import type { Who } from "./who.ts"; |
| @@ -36,13 +36,14 @@ function publish(boardId: string, request: Request, who: Who, kind: EventKind, i | ||
| 36 | 36 | // Pages carry a visitor's name and sign-out link once they're signed in, so |
| 37 | 37 | // nothing in front of us — a shared cache, a back-forward cache on a shared |
| 38 | 38 | // 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 => | |
| 40 | 40 | new Response(body, { |
| 41 | 41 | status, |
| 42 | 42 | headers: { |
| 43 | 43 | "content-type": "text/html; charset=utf-8", |
| 44 | 44 | "cache-control": "private, no-store", |
| 45 | 45 | vary: "x-forge-identity", |
| 46 | ...extraHeaders, | |
| 46 | 47 | }, |
| 47 | 48 | }); |
| 48 | 49 | const json = (body: unknown, status = 200, extraHeaders: Record<string, string> = {}): Response => |
| @@ -87,20 +88,43 @@ function donePublish( | ||
| 87 | 88 | // A rejected form field or a limit hit on a board (a full column list, a |
| 88 | 89 | // full card list) answers the caller the way it asked: the board's own |
| 89 | 90 | // 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 }); | |
| 96 | 110 | const board = db.getBoard(boardId); |
| 97 | if (!board) return new Response(message, { status }); | |
| 111 | if (!board) return new Response(message, { status, headers: extraHeaders }); | |
| 98 | 112 | return html( |
| 99 | 113 | boardPage({ board, columns: db.listColumns(boardId), cards: db.listCards(boardId), query: "", who, error: message }), |
| 100 | 114 | status, |
| 115 | extraHeaders, | |
| 101 | 116 | ); |
| 102 | 117 | } |
| 103 | 118 | |
| 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 | ||
| 104 | 128 | function cleanText(value: FormDataEntryValue | null, max: number): string | null { |
| 105 | 129 | const text = String(value ?? "").trim().slice(0, max); |
| 106 | 130 | return text === "" ? null : text; |
| @@ -165,18 +189,21 @@ function eventStream(boardId: string, request: Request): Response { | ||
| 165 | 189 | }); |
| 166 | 190 | } |
| 167 | 191 | |
| 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> { | |
| 169 | 193 | try { |
| 170 | return await dispatch(request, url, env, who); | |
| 194 | return await dispatch(request, url, env, who, ip); | |
| 171 | 195 | } catch (err) { |
| 172 | 196 | if (err instanceof HttpError) return new Response(err.message, { status: err.status }); |
| 173 | 197 | throw err; |
| 174 | 198 | } |
| 175 | 199 | } |
| 176 | 200 | |
| 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> { | |
| 178 | 202 | const method = request.method; |
| 179 | 203 | 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); | |
| 180 | 207 | |
| 181 | 208 | if (parts.length === 0) { |
| 182 | 209 | if (method !== "GET") return methodNotAllowed("GET, HEAD"); |
| @@ -212,7 +239,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom | ||
| 212 | 239 | if (method === "POST" && parts.length === 1 && parts[0] === "mcp") { |
| 213 | 240 | const message = await request.json().catch(() => undefined); |
| 214 | 241 | 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); | |
| 216 | 243 | return body === undefined ? new Response(null, { status }) : json(body, status); |
| 217 | 244 | } |
| 218 | 245 | 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 | ||
| 242 | 269 | |
| 243 | 270 | if (method === "POST" && parts.length === 3 && parts[2] === "claim") { |
| 244 | 271 | 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); | |
| 245 | 273 | const result = db.claimBoard(boardId, { id: who.id, name: who.name }); |
| 246 | 274 | if (result === "owned_by_other") return new Response("already kept by someone else", { status: 403 }); |
| 247 | 275 | return done(request, boardId, true); |
| @@ -251,11 +279,13 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom | ||
| 251 | 279 | const data = await form(request); |
| 252 | 280 | const name = cleanText(data.get("name"), 100); |
| 253 | 281 | if (!name) return boardBad(request, who, boardId, "a board needs a name"); |
| 282 | if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey); | |
| 254 | 283 | db.renameBoard(boardId, name); |
| 255 | 284 | return donePublish(request, who, boardId, true, "board.renamed"); |
| 256 | 285 | } |
| 257 | 286 | |
| 258 | 287 | if (method === "POST" && parts.length === 3 && parts[2] === "undo") { |
| 288 | if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey); | |
| 259 | 289 | return donePublish(request, who, boardId, db.undo(boardId), "undo"); |
| 260 | 290 | } |
| 261 | 291 | |
| @@ -263,6 +293,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom | ||
| 263 | 293 | const data = await form(request); |
| 264 | 294 | const name = cleanText(data.get("name"), 60); |
| 265 | 295 | if (!name) return boardBad(request, who, boardId, "a column needs a name"); |
| 296 | if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey); | |
| 266 | 297 | const column = db.createColumn(boardId, name); |
| 267 | 298 | if (column === "full") return boardBad(request, who, boardId, `this board is full — ${MAX_COLUMNS_PER_BOARD} columns`); |
| 268 | 299 | 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 | ||
| 276 | 307 | const data = await form(request); |
| 277 | 308 | const name = cleanText(data.get("name"), 60); |
| 278 | 309 | if (!name) return boardBad(request, who, boardId, "a column needs a name"); |
| 310 | if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey); | |
| 279 | 311 | return donePublish(request, who, boardId, db.renameColumn(boardId, columnId, name), "column.renamed", { columnId }); |
| 280 | 312 | } |
| 281 | 313 | |
| @@ -283,10 +315,12 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom | ||
| 283 | 315 | const data = await form(request); |
| 284 | 316 | const direction = data.get("direction"); |
| 285 | 317 | 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); | |
| 286 | 319 | return donePublish(request, who, boardId, db.moveColumn(boardId, columnId, direction), "column.moved", { columnId }); |
| 287 | 320 | } |
| 288 | 321 | |
| 289 | 322 | if (method === "POST" && parts.length === 5 && parts[4] === "delete") { |
| 323 | if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey); | |
| 290 | 324 | return donePublish(request, who, boardId, db.deleteColumn(boardId, columnId), "column.deleted", { columnId }); |
| 291 | 325 | } |
| 292 | 326 | |
| @@ -294,6 +328,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom | ||
| 294 | 328 | const data = await form(request); |
| 295 | 329 | const title = cleanText(data.get("title"), 200); |
| 296 | 330 | if (!title) return boardBad(request, who, boardId, "a card needs a title"); |
| 331 | if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey); | |
| 297 | 332 | const card = db.createCard(boardId, columnId, title); |
| 298 | 333 | if (card === "full") return boardBad(request, who, boardId, `this board is full — ${MAX_CARDS_PER_BOARD} cards`); |
| 299 | 334 | return donePublish( |
| @@ -316,6 +351,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom | ||
| 316 | 351 | const data = await form(request); |
| 317 | 352 | const title = cleanText(data.get("title"), 200); |
| 318 | 353 | if (!title) return boardBad(request, who, boardId, "a card needs a title"); |
| 354 | if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey); | |
| 319 | 355 | return donePublish(request, who, boardId, db.editCard(boardId, cardId, title), "card.edited", { cardId }); |
| 320 | 356 | } |
| 321 | 357 | |
| @@ -325,6 +361,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom | ||
| 325 | 361 | const index = Number(data.get("index")); |
| 326 | 362 | if (!ENTITY_ID.test(columnId)) return boardBad(request, who, boardId, "column_id is required"); |
| 327 | 363 | 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); | |
| 328 | 365 | return donePublish(request, who, boardId, db.moveCard(boardId, cardId, columnId, Math.floor(index)), "card.moved", { |
| 329 | 366 | cardId, |
| 330 | 367 | columnId, |
| @@ -332,6 +369,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom | ||
| 332 | 369 | } |
| 333 | 370 | |
| 334 | 371 | if (method === "POST" && parts.length === 5 && parts[4] === "delete") { |
| 372 | if (!writeBucket.take(writeKey)) return writeRefused(request, who, boardId, writeKey); | |
| 335 | 373 | return donePublish(request, who, boardId, db.deleteCard(boardId, cardId), "card.deleted", { cardId }); |
| 336 | 374 | } |
| 337 | 375 | } |
src/server.test.ts +26 −3
| @@ -557,9 +557,13 @@ test("the same column-limit refusal, hit without JavaScript, re-renders the boar | ||
| 557 | 557 | expect(created.status).toBe(200); |
| 558 | 558 | } |
| 559 | 559 | |
| 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 | }); | |
| 563 | 567 | expect(refused.status).toBe(400); |
| 564 | 568 | expect(refused.headers.get("content-type")).toContain("text/html"); |
| 565 | 569 | const body = await refused.text(); |
| @@ -568,6 +572,25 @@ test("the same column-limit refusal, hit without JavaScript, re-renders the boar | ||
| 568 | 572 | expect(body).toContain('id="toast" role="status" aria-live="polite" class="show"'); |
| 569 | 573 | }); |
| 570 | 574 | |
| 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 | ||
| 571 | 594 | test("a board cannot grow past its card limit; the AJAX path gets the sentence back to toast, not a generic message", async () => { |
| 572 | 595 | const id = db.createBoard("Full of cards").id; // see the note above on why not POST /boards |
| 573 | 596 | const columnId = db.listColumns(id)[0].id; |
src/server.ts +6 −2
| @@ -53,7 +53,7 @@ export const server = Bun.serve({ | ||
| 53 | 53 | idleTimeout: 60, |
| 54 | 54 | // Never Bun's source-listing error page, in production or in a preview. |
| 55 | 55 | development: false, |
| 56 | async fetch(request) { | |
| 56 | async fetch(request, server) { | |
| 57 | 57 | const url = new URL(request.url); |
| 58 | 58 | if (request.method === "POST") { |
| 59 | 59 | // Cheap and early: a body this large is refused by its declared |
| @@ -70,7 +70,11 @@ export const server = Bun.serve({ | ||
| 70 | 70 | const isHead = request.method === "HEAD"; |
| 71 | 71 | const routingRequest = isHead ? new Request(request, { method: "GET" }) : request; |
| 72 | 72 | |
| 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); | |
| 74 | 78 | let response: Response; |
| 75 | 79 | if (routed) { |
| 76 | 80 | 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 | }); |
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:
So a bogus
board_idnow 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.callerKeykeys on the forge idsrc/who.tsverified or onserver.requestIP, never on a header, which is the right pair.KeyedTokenBucketis 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.tsno 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 testis 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
/mcpas 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 withoutsec-fetch-mode: navigategets the plain sentence (400,text/plain), andadd_cardon a board that does not exist answersnot_foundas a tool error. The board page still renders in ~170 ms.Two things I did not block on, both filed:
sec-fetch-mode: navigategate is the right discriminator, but a browser that sends noSec-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.tsfalls back tooriginwhensec-fetch-siteis missing, and the same fallback would fit here if anyone cares.server.requestIPis 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.content-lengthbypasses 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.tsandsrc/keys.tsare untouched and that half is #31. Please trim the title before landing so the history reads true.