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

shipped production is serving it opened this has it
opened this

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 call live.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 beside src/drag.test.ts.

commented

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_MINUTE and writeBucket in src/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.ts spends 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 /mcp spends one token per writing tools/call (add_card, move_card) and none for initialize, tools/list, list_boards, list_cards. A refused MCP write answers as a JSON-RPC tool error (isError: true and 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-after for the JSON path and, for a plain form post, re-renders the page with the sentence in it — the shape POST /boards already uses, so the no-JS path never lands on bare text.
  • [ ] bun test green, with tests that fail without the change: the Nth write in a row is refused and says retry-after; an MCP add_card past the limit comes back readable; initialize still 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs