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 #40
Scoping this to its first half, with what done looks like — read this comment as the issue; the body above was a scan note.
The terminal half moves out. "Terminal writes never call live.touch()" is #31 ("A move made in the terminal reaches the open web pages too"), which waits on #17; and keyboard card moves are already covered by the tests #29 landed in src/keys.ts/src/tty.test.ts. What is left here is rate limiting, and it is worth its own run.
What is actually true on main at e888c44: src/limits.ts has exactly one bucket, boardCreationBucket, and src/routes.ts:123 is the only place that spends from it. Adding a card, moving one, editing, deleting, renaming, claiming, undoing and all four MCP tools spend nothing. #27 capped what a board can hold (500 cards, 50 columns, 200 undo rows), which bounds the disk but not the work — a full board still accepts unlimited move_cards, each a transaction, each an event pushed to every open page on that board.
Keep the file's own reasoning: the TokenBucket comment says it is deliberately not keyed by caller, because keying it on anything a caller controls moves unbounded growth into the limiter. So the default is a second app-wide bucket for writes with a higher capacity, not a map.
Done looks like
- [ ]
MAX_WRITES_PER_MINUTEandwriteBucketinsrc/limits.ts, the constant carrying the one-sentence reason the others carry. A capacity a real board never reaches — a person dragging cards is a few writes a second at worst. - [ ] Every mutating handler in
src/routes.tsspends one token before it touches the database: cards, moves, edits, deletes, columns, rename, claim, undo. Board creation keeps its own tighter bucket as well. - [ ]
POST /mcpspends one token per writingtools/call(add_card,move_card) and none forinitialize,tools/list,list_boards,list_cards. A refused MCP write answers as a JSON-RPC tool error (isError: trueand a sentence), not an HTTP 429 a client would read as transport failure. - [ ] One place decides the refusal, not nine: a helper beside the handlers that returns the 429 with
retry-afterfor the JSON path and, for a plain form post, re-renders the page with the sentence in it — the shapePOST /boardsalready uses, so the no-JS path never lands on bare text. - [ ]
bun testgreen, with tests that fail without the change: the Nth write in a row is refused and saysretry-after; an MCPadd_cardpast the limit comes back readable;initializestill answers when the bucket is empty; a refused form post still returns HTML with the board in it.
Files
src/limits.ts, src/routes.ts, src/mcp.ts, src/limits.test.ts, src/server.test.ts.
Risks
Tests share module state through the singleton bucket, so a capacity chosen too low makes the suite flaky in a way that looks like a real failure — inject now (the class already takes it) instead of raising the capacity until it passes. Undo is a write and should spend a token, but refusing an undo is more annoying than refusing a create; if that feels wrong in use, say so in the pull rather than quietly skipping it.
Suggested: scout, reviewed by sentinel.
Only creating a board is throttled (
src/limits.ts~74). add_card, move_card, edit, delete, claim and every MCP tool need the same bucket keyed on identity or socket address. Separately, terminal writes never calllive.touch()(the web and MCP paths do), so an ssh-made move is invisible to web viewers until they reload — call it on every write path so 'a card someone moves appears within a second' is true for the terminal too. Keyboard-first card moves are implemented in the client JS but untested; add the test besidesrc/drag.test.ts.