Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-1860Done

11.1.4 Per-token rate limiting for `/api/v1` — the atomic counter, `X-RateLimit-*` headers and `429`

Repo: motir-core. One PR. The one genuinely NEW primitive in this story — grep over lib/ and app/ confirms no rate-limit helper exists anywhere in the codebase (the rateLimit hits are Better-Auth's own config plus account-settings copy). A public API without a limiter is a denial-of-service surface on a shared Postgres, so this is part of the foundation, not a hardening pass to schedule later.

blocked_by 11.1.2 — it composes into that wrapper, so every /api/v1 route is limited by construction rather than opting in. Independent of 11.1.3 (a fan off the wrapper — neither consumes the other).

What to build

A per-TOKEN fixed-window limiter, applied inside the wrapper:

  • Keyed on the token id — not the IP (shared NATs and CI runners collide), not the user (one user's runaway script would starve their own integrations). Per-token means one integration cannot exhaust another's budget, and a compromised token can be revoked to stop its traffic.
  • Headers on EVERY response, not only on refusalsX-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. A client can only back off politely if it can see its budget while succeeding; headers that appear only on a 429 force clients to discover the limit by hitting it.
  • 429 when the budget is spent, carrying the same headers plus the { code, error } envelope, so a client learns when to retry rather than just that it failed.
  • The budget comes from the ADR; make it configurable per environment without a code change, since a self-hoster's limit is not Motir Cloud's.

⚠️ The counter is a READ-DERIVED WRITE — it must be atomic

Increment-then-compare is the textbook check-then-write race: two concurrent requests both read the stale count and both pass, so the limit leaks under exactly the concurrent load it exists to control. Per motir-core/CLAUDE.md's concurrency rule this must be an atomic increment — a single statement that increments and returns the new value (or a SELECT … FOR UPDATE + re-read inside one $transaction) — never a read, then a compare, then a write.

And the test must drive genuine concurrency: a serial loop passes with a broken implementation. Fire N simultaneous requests against a budget of N−1 and assert exactly one 429 — the warm-pool TOCTOU case that a sequential test cannot see.

If the counter lands in Postgres it follows the 4-layer contract (repository method, tx on the write). Choosing the store is part of this card; whatever it is, the atomicity property above is non-negotiable, and a failure of the limiter's own store must not fail the request — degrade to allowing the call and log it, per the side-effects rule. An outage in the limiter must not take the API down.

Scope BOUNDARY

Ends at the /api/v1 limiter. It does NOT rate-limit /api/mcp, the web app's app/api/**, or the auth routes (Better-Auth has its own) — those are separate surfaces with separate budgets and are not this card's to change. It does NOT add per-endpoint or per-scope differentiated budgets (one budget for v1 in the first cut; differentiated budgets are additive later and must not be pre-built). It does NOT add quota UI, usage reporting, or a billing tie-in. It does NOT add a new env var to any deploy target beyond the documented default.

Acceptance criteria

  • Every /api/v1 response — success and failure alike — carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
  • Exceeding the budget returns 429 with those headers and the { code, error } envelope; X-RateLimit-Reset is a time a client can actually wait for.
  • The limit is per TOKEN: two tokens belonging to the SAME user have independent budgets, asserted directly.
  • A real-concurrency test: N simultaneous requests against a budget of N−1 produce exactly one 429 — no leak. A serial-only test does not satisfy this criterion.
  • The increment is atomic — no read-compare-write sequence exists in the implementation.
  • The window resets: after the reset time the budget is available again.
  • A limiter-store failure does not fail the request — the call is allowed and the failure logged, asserted by forcing the store to throw.
  • Every /api/v1 route is limited by virtue of the wrapper; a newly added route cannot forget to opt in — asserted with a fixture route that adds no limiter code of its own.
  • The budget is configurable per environment; the default is documented.
  • Unit + integration tests ship in the same PR against real Postgres; the per-file coverage floor (≥90%) holds on every new file.

Context refs

  • lib/api/v1/ — the wrapper from 11.1.2 the limiter installs into.
  • lib/apiTokens/routeAuth.ts — resolves the token; the limiter keys on its identity.
  • motir-core/CLAUDE.md § 4-Layer Architecture — the read-derived-write / SELECT … FOR UPDATE rule and the side-effects-outside-the-transaction rule this card is governed by.
  • lib/auth/index.ts — Better-Auth's own rate-limit config; read it to confirm the auth surface already has one and is out of scope here.
  • Decision: 11.1.1 (the rate-limit budget + headers). Parent story: 11.1.