← all branches

feat/pub-api-rl

needs attentionviewing older commit
1f85ae7 · incrementalPR #266reviewed 2026-07-08 20:16 UTC0H · 1M · 4L · 7I
The branch
Purpose
Raise public v1 API rate-limit budgets so external machine clients are not throttled at the old dashboard-inherited limits (~13 req/min read), and decouple them from the internal dashboard rate limits.
Goal
Explicit, generous read/write/auth budgets for the public v1 bearer-token API — protecting platform stability rather than enforcing hard quotas.
Sub-goals
  • SG-1: Raise read bucket from 200/15 min to 300/min, write from 50/15 min to 60/min; auth unchanged at 10/min
  • SG-2: Decouple public budgets from DEFAULT_RATE_LIMITS so changes don't silently loosen dashboard limits
  • SG-3: Extract rate-limit logic and identity bucketing into a testable pure module (public-v1-rate-limit.ts)
  • SG-4: Fix HEAD method misclassification — Next.js routes HEAD through GET, so HEAD should get read budget
  • SG-5: Fix api-patterns.md stale claim that the public API has 'no rate limit (deferred)'
The changes (whole branch)
What
This incremental commit adds 'HEAD' to the read-method check in classifyRateLimitKind and adds a corresponding test assertion. One-line logic change + one test line.
Why
Without the fix, a HEAD request would be classified as 'write' (60/min) instead of 'read' (300/min). Next.js invokes the GET export for HEAD, so the handler runs with GET semantics but would have burned the stricter write budget.
Areas
apps/platform/src/api/utils/public-v1-rate-limit.ts+440apps/platform/src/api/utils/__tests__/public-v1-rate-limit.test.ts+940apps/platform/src/app/api/v1/[...ts-rest]/route.ts+925.claude/rules/api-patterns.md+32
Blast
4 files, +150/-27 total. Scoped entirely to apps/platform rate-limiting utils and the v1 route mount. No DB, no API contracts, no frontend changes.
in-memory RL store is per-Lambda-instance (pre-existing, not a hard global quota — acknowledged in PR description)
typecheck· not run in this review; PR description confirms tsc --noEmit clean for changed filesci· statusCheckRollup not accessible with current token permissionscoderabbit· no .coderabbit.yaml in repo

Findings · 12

correctness2

low

HEAD branch may be dead code — Next.js promotes HEAD→GET before the handler sees the method

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

Next.js App Router serves HEAD by invoking the GET export; `request.method` arrives as 'GET', not 'HEAD'. So `classifyRateLimitKind` never sees 'HEAD' at runtime — the `method === 'HEAD'` guard is dead code. The correct bucket ('read') is still selected via the existing GET branch. The fix is harmless and defensive (pure function, direct testability), but the comment's framing ('Next routes it through the GET handler') is slightly misleading: it suggests method is preserved, when it is actually promoted.

info

Test validates a path that is not exercised at runtime

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

The unit test calls classifyRateLimitKind('HEAD', ...) directly, which is valid for a pure function, but the real route never delivers 'HEAD' to this function at runtime (Next.js promotes it to GET). The test is not wrong; it is defensive against direct call-site usage. Worth a note so a future reader does not assume the HEAD guard is load-bearing at runtime.

security4

low

Per-instance in-memory store multiplies effective rate limit under concurrent cold-starts

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

Pre-existing architectural property, not introduced by this diff. At 300 read req/min per Lambda instance, an attacker forcing N concurrent cold-starts gets up to N×300/min effective throughput. PR description correctly labels this 'approximate per-instance floor, not a hard global quota'. A shared-store limiter (Redis/Upstash) is the known upgrade path for a contractual hard cap.

info

HEAD classified as read shares the same per-token bucket as GET — no bypass possible

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

Both HEAD and GET decrement the same 'pub_v1:read:<tokenHash>' counter (300/min). An attacker cannot gain extra probing budget by mixing HEAD and GET requests.

info

HEAD /auth/token falls to read budget (not auth) — intentional and harmless

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

The auth guard requires POST + /auth/token. HEAD on that path gets the read budget (300/min). In practice ts-rest returns 405 for an unregistered HEAD route, so scrypt never runs. No exploitable path.

info

OPTIONS CORS preflight intentionally bypasses rate limiting

apps/platform/src/app/api/v1/[...ts-rest]/route.ts

The OPTIONS export bypasses withRateLimit entirely (routes directly to baseHandler). This is correct for browser CORS preflights. A credentialed OPTIONS request would not be rate-limited, but that is an edge case and an acceptable design choice documented in api-patterns.md.

conventions1

info

Test inline comment is marginally redundant with the test name

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

'// Next routes HEAD through the GET handler, so it must get the read budget.' restates the non-obvious Next.js routing invariant. The test name already signals HEAD belongs here. The comment is defensible (non-obvious WHY, consistent with sibling auth test precedent) but could be omitted without losing context.

tests3

medium

HEAD /auth/token not asserted in the auth-path test group

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

The 'only treats POST /auth/token as auth — other methods on that path are not' test covers GET, DELETE, and PUT but omits HEAD. HEAD /api/v1/auth/token should return 'read' (correct per current logic), but there is no assertion pinning that. If a future refactor re-orders the auth guard or adds a HEAD-specific early-return, CI would not catch the regression. Fix: add `expect(classifyRateLimitKind('HEAD', '/api/v1/auth/token')).toBe('read')` to that test.

low

'every other method' test never included HEAD — could not have caught the pre-fix regression

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

Before this fix HEAD was classified as 'write'. The 'classifies every other method as write' test never listed HEAD, so CI would have passed despite the incorrect behavior. Now that HEAD → 'read', the test still does not assert HEAD's absence from 'write', meaning a future regression moving HEAD back to 'write' would go undetected by that test group.

info

New HEAD test covers only a general-resource path; cross-path coverage thin

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

Adding `classifyRateLimitKind('HEAD', '/api/v1/auth/token') → 'read'` (medium finding above) would complete HEAD's coverage across all path categories.

improvement2

low

Consider a Set for read-method classification to ease future additions

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

A `const READ_METHODS = new Set(['GET', 'HEAD'])` + `READ_METHODS.has(method)` would make adding OPTIONS or other safe methods a one-line data change rather than a logic change. Both are O(1); this is a readability/extensibility preference, not a bug.

info

OPTIONS and TRACE are safe methods but their omission from read-set is correct for this context

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

OPTIONS is handled by Next.js CORS middleware before reaching this classifier; TRACE is typically blocked at the reverse-proxy layer. Both omissions are intentional and correct.

History · 6 commits

  1. edc190esafeincremental0H · 0M · 3L2026-07-09 00:52
  2. 1f85ae7needs attentionincremental0H · 1M · 4L2026-07-08 20:16current
  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:38
  6. c095510needs attentionfull3H · 3M · 4L2026-07-07 19:36