← all branches

feat/pub-api-rl

safeviewing older commit
ae8bb22 · incrementalPR #266reviewed 2026-07-07 20:38 UTC0H · 0M · 9L · 3I
The branch
Purpose
Raise public v1 API rate-limit budgets from dashboard-inherited defaults (200/15min read) to external-client-appropriate limits (300/min read, 60/min write), and extract the rate-limit logic into a testable utility module with regression tests.
Goal
Published, stable per-API-key rate-limit budgets that are pinned by CI and can't silently revert — separated from the dashboard's DEFAULT_RATE_LIMITS.
Sub-goals
  • SG-1: Raise budgets to published limits (300 read/min, 60 write/min) — decoupled from dashboard defaults
  • SG-2: Extract RATE_LIMIT_BUDGETS + bucketing functions into public-v1-rate-limit.ts for testability
  • SG-3: Pin budgets with a regression test that turns CI red if values revert
The changes (whole branch)
What
Extracted RATE_LIMIT_BUDGETS const, rateLimitBucket, and classifyRateLimitKind/rateLimitIdentity from the v1 route handler into a new utility module (public-v1-rate-limit.ts) + added an 85-line vitest suite pinning the budgets and covering all exported functions. route.ts is -25/+7: inline functions removed, module imported.
Why
Separation of concerns: the rate-limit configuration was inlined in a large route handler with no test coverage, making silent budget regressions possible (e.g. a merge reverting to DEFAULT_RATE_LIMITS). The extraction makes budgets a first-class, independently-testable contract.
Areas
apps/platform/src/api/utils+1480apps/platform/src/app/api/v1+725.claude/rules/api-patterns.md+21
Blast
4 files, +157/−26 total. Change is contained to the public v1 API utility layer — no domain code, no DB migrations, no infra, no contracts changed. route.ts integration is a drop-in (NextRequest satisfies the widened type structurally).
pre-existing: x-forwarded-for key for auth rate-limit is client-spoofable — see finding #2
ci· No CI checks registered on this PR at review timecoderabbit· No .coderabbit.yaml configured

Findings · 12

correctness2

low

rateLimitBucket URL widening — throws TypeError on relative URLs

apps/platform/src/api/utils/public-v1-rate-limit.ts:61

The refactored signature widens `url` from `NextRequest` (Fetch spec guarantees an absolute URL) to plain `string`. Calling `new URL(request.url)` on a relative URL (e.g. '/api/v1/bills') throws an unhandled TypeError. The live call site always passes a `NextRequest` so there is no runtime regression today, but the loose type is a trap for future callers or unit tests that construct the argument directly. Fix: `new URL(request.url, 'http://localhost')` or narrow the type back to `URL | string` with a note.

info

Authorization header scheme casing produces distinct buckets (pre-existing)

apps/platform/src/api/utils/public-v1-rate-limit.ts:49

'Bearer token' and 'bearer token' produce different hashes for the same API key. Pre-existing behavior (same logic before extraction). Real clients normalize to 'Bearer'; negligible collision risk in practice.

security1

low

x-forwarded-for key for auth rate-limit is client-spoofable (pre-existing)

apps/platform/src/api/utils/public-v1-rate-limit.ts:53

POST /v1/auth/token carries no Bearer header, so unauthenticated callers fall back to the IP branch. x-forwarded-for is client-controlled — its first entry is attacker-supplied. On Vercel, the real IP is appended but not used here. An attacker can rotate the header to exhaust 10 separate auth buckets/min cheaply. The fix is to use the rightmost x-forwarded-for entry (platform-injected) or x-vercel-forwarded-for. (pre-existing — same logic was inline before this extraction; extraction is the right moment to address it)

conventions4

low

File-level JSDoc is a 20-line multi-paragraph block — convention requires ≤1 short line

apps/platform/src/api/utils/public-v1-rate-limit.ts:1

CLAUDE.md: 'Never write multi-paragraph docstrings or multi-line comment blocks — one short line max.' The only non-obvious constraint here is the per-instance memory note (lines 17–20). Everything else (extraction rationale, budget philosophy) is restated in api-patterns.md and the test file. Trim to a single inline comment on RATE_LIMIT_BUDGETS: '// in-memory per-Lambda-instance — approximate per-instance floor, not a hard global quota'.

low

JSDoc on classifyRateLimitKind explains WHAT the code already shows

apps/platform/src/api/utils/public-v1-rate-limit.ts:33

The 4-line JSDoc restates the function name, parameter names, and body ('POST /v1/auth/token → auth', 'GET → read', 'else → write'). Convention: 'Don't explain WHAT the code does, since well-named identifiers already do that.' Remove the JSDoc entirely.

low

JSDoc on rateLimitIdentity explains WHAT the code does

apps/platform/src/api/utils/public-v1-rate-limit.ts:43

The 4-line JSDoc describes mechanics ('hash of bearer credential', 'else client IP', 'Never stores the raw token') visible in 3 lines of code. 'Never stores the raw token' is the closest to a non-obvious invariant but still visible from the code. Remove or collapse to one short note if the never-log constraint needs emphasizing.

low

Test regression comment references PR #266 and task context

apps/platform/src/api/utils/__tests__/public-v1-rate-limit.test.ts:3

Convention: 'Don't reference the current task, fix, or callers — those belong in the PR description and rot as the codebase evolves.' The 3-line comment inside the first `it()` says 'See PR #266' and references 'the old 200/15min read cap'. The `it()` label already states the intent. Remove the comment; the PR description has the history.

tests4

low

classifyRateLimitKind not tested for lowercase HTTP methods

apps/platform/src/api/utils/__tests__/public-v1-rate-limit.test.ts

The function uses strict equality (`method === 'GET'`, `method === 'POST'`). A lowercase method string ('get', 'post') routes to 'write'. If any caller passes non-uppercased methods, auth/read traffic silently becomes write-bucketed. Worth a one-liner negative assertion to document this as intentional: `classifyRateLimitKind('get', '/api/v1/bills')` → 'write'.

low

GET /v1/auth/token not covered — documents that auth is POST-only

apps/platform/src/api/utils/__tests__/public-v1-rate-limit.test.ts

The auth bucket requires method === 'POST' AND path ends with '/auth/token'. A GET to /auth/token correctly falls through to 'read'. Adding one assertion pins this intentional behavior and guards against a future change that drops the method guard.

info

No test for rateLimitBucket with malformed URL

apps/platform/src/api/utils/__tests__/public-v1-rate-limit.test.ts

new URL(request.url) throws on an invalid URL string. The call site (NextRequest) always provides a valid URL, so this is a theoretical edge case — worth noting for future callers who construct the argument directly.

info

Budget snapshot is an adequate regression guard

apps/platform/src/api/utils/__tests__/public-v1-rate-limit.test.ts:14

toStrictEqual on the full RATE_LIMIT_BUDGETS object pins all three kinds and both fields simultaneously. Any reduction in limit or increase in windowMinutes fails CI immediately. Well-chosen.

improvement1

low

rateLimitIdentity hashes the full Authorization header value — not the normalized token

apps/platform/src/api/utils/public-v1-rate-limit.ts:50

The function hashes `bearer` (full header value, e.g. 'Bearer sk_abc'). A client sending 'bearer sk_abc' (lowercase scheme) or 'Bearer sk_abc' (double space) gets a different rate-limit bucket for the same credential. Normalizing before hashing — `bearer.replace(/^[Bb]earer\s+/, '')` — makes the identity token-stable regardless of header formatting, matching the documented intent.

History · 6 commits

  1. edc190esafeincremental0H · 0M · 3L2026-07-09 00:52
  2. 1f85ae7needs attentionincremental0H · 1M · 4L2026-07-08 20:16
  3. d77720dneeds attentionincremental0H · 1M · 4L2026-07-08 18:03
  4. 28ed416needs attentionincremental0H · 1M · 4L2026-07-08 17:41
  5. ae8bb22safeincremental0H · 0M · 9L2026-07-07 20:38current
  6. c095510needs attentionfull3H · 3M · 4L2026-07-07 19:36