feat/pub-api-rl
needs attentionviewing older commitc095510 · fullPR #266reviewed 2026-07-07 19:36 UTC3H · 3M · 4L · 3I- Purpose
- Unblock external API clients throttled by limits inherited from the dashboard's internal defaults
- Goal
- Raise public v1 API rate-limit budgets to values appropriate for machine clients doing historical backfills and large-portfolio polling
- Sub-goals
- Decouple RATE_LIMIT_BUDGETS from DEFAULT_RATE_LIMITS (dashboard limits)
- Raise read budget: 200/15min → 300/min
- Raise write budget: 50/15min → 60/min
- Keep auth budget: 10/min (unchanged)
- Fix stale api-patterns.md documentation
- What
- Two files: (1) route.ts removes DEFAULT_RATE_LIMITS import and replaces the three-entry RATE_LIMIT_BUDGETS object with explicit literals at more generous rates; (2) api-patterns.md removes the stale 'no rate limit (deferred)' clause and adds a paragraph documenting the actual rate-limit architecture.
- Why
- A prospective API client asked about limits before integrating. The read bucket was inherited from the dashboard's cookie-authed browser session defaults (~13 req/min effective), far too low for machine clients. Credit-plan billing governs volume; rate limits only protect platform stability.
- Areas
- apps/platform/src/app/api/v1/[...ts-rest]/route.ts+12−6.claude/rules/api-patterns.md+2−1
- Blast
- 2 files, +14/-7 lines. Public-API mount only — no changes to dashboard auth, contracts, or handlers.
Findings · 13
correctness2
X-RateLimit-Reset header is ISO timestamp; de-facto standard is Unix epoch seconds
apps/platform/src/app/api/v1/[...ts-rest]/route.ts:236
X-RateLimit-Reset is set to result.resetAt.toISOString(). GitHub/Stripe/Twitter all use Unix epoch seconds (integer). Client libraries that do 'resetAt * 1000 to get ms' will compute a date 1000× in the future. Retry-After is correctly a number. Consider: `String(Math.ceil(result.resetAt.getTime() / 1000))`.
Token exchange keys on IP when no Bearer present — spoofable if not behind Vercel edge
apps/platform/src/app/api/v1/[...ts-rest]/route.ts:208
POST /auth/token submits the raw API key, so no Bearer token exists yet. The fallback to x-forwarded-for is correct in production (Vercel manages the header), but is undocumented. If the origin is ever exposed directly, the 10/min auth budget per IP becomes bypassable. A comment asserting the Vercel-edge invariant would help future maintainers.
security1
Per-instance in-memory store means published limits are a floor, not a ceiling
apps/platform/src/app/api/v1/[...ts-rest]/route.ts
The code comment correctly documents this, but the PR raises the read limit 23× (13/min effective → 300/min per instance). With N warm Lambda instances a single key gets 300×N req/min. Publishing these as 'limits' when they are actually per-instance floors overstates the protection. The comment+PR description acknowledge the follow-up (Redis/Upstash) — the verdict here is informational: acceptable as an abuse-prevention floor but a shared-store limiter should precede any contractual SLA.
conventions3
Stale 'sliding window, 100 req/15min' line in Security section not removed
.claude/rules/api-patterns.md:80
Line 80 still reads 'Rate limiting: sliding window, 100 req/15min default, fail-open on errors'. The PR adds a correct replacement paragraph at line 90 but leaves the contradicting line 80 in place. Any reader who stops at the Security bullet gets wrong numbers. This line should be removed.
429 body uses 'error' key — should be 'code' per JSend fail convention
apps/platform/src/app/api/v1/[...ts-rest]/route.ts:225
The JSend fail format documented in api-patterns.md prescribes `{ status: 'fail', data: { code: 'ERROR_CODE', message: '...' } }`. The 429 body uses `error: 'rate_limited'` instead of `code`. Because this fires pre-dispatch (outside ts-rest), it bypasses the registered PublicApiErrorCode check. Callers parsing the discriminant by key need a special case. A one-word rename aligns with the rest of the API surface.
DEFAULT_RATE_LIMITS still exports old values — no guard against re-import
packages/api/src/middleware/rate-limit.ts
DEFAULT_RATE_LIMITS still lists read:200/15min, write:50/15min. The decoupling is correct but nothing prevents a future developer from accidentally re-importing it into the v1 route, silently restoring old limits. A comment at the DEFAULT_RATE_LIMITS declaration noting 'public-API uses RATE_LIMIT_BUDGETS in route.ts — do not use here' would make the separation explicit.
tests4
No automated regression test pins the new RATE_LIMIT_BUDGETS values
apps/platform/src/app/api/v1/[...ts-rest]/route.ts:195
RATE_LIMIT_BUDGETS is an inline literal with no test asserting its values. A merge conflict or future refactor could silently revert to the old 200/15min limits. A unit test on rateLimitBucket verifying the config handed to checkRateLimit — plus a snapshot on the three budget entries — would catch any regression permanently.
Only rate-limit behavioral tests are inside describe.skip — dead in CI
apps/platform/src/__tests__/integration/auth-e2e.test.ts
The sole behavioral tests for checkRateLimit (sliding window, block-on-limit, reset) live in a describe.skip block. CI provides zero automated coverage of rate-limit behavior. Removing the skip or extracting a standalone vitest unit test (no DB needed) would give real regression coverage.
X-RateLimit-Limit header value is never asserted in any test
apps/platform/src/app/api/v1/[...ts-rest]/route.ts:243
The X-RateLimit-Limit response header is the primary client-observable signal of the new limits and is explicitly called out in the PR's manual test plan. No automated test asserts this header at any value — a silent rollback of the constant to 200 would go undetected.
Window unit change (15min → 1min) is untested at the boundary
apps/platform/src/app/api/v1/[...ts-rest]/route.ts:197
The semantic change is not just the ceiling but the window duration. Clients pacing at 200/15min could now get 429s if they burst above 300 within a single minute. No test covers this windowing boundary. A simple test (send 300, confirm 200; send 301st, confirm 429; wait window; confirm next allowed) would protect it permanently.
improvement3
X-RateLimit-Remaining hardcoded to '0' on 429 instead of derived from result
apps/platform/src/app/api/v1/[...ts-rest]/route.ts:234
The 429 path sets X-RateLimit-Remaining: '0' as a string literal. Using String(result.remaining) is consistent with the success path and future-proofs against rate-limit semantics that might return allowed:false with remaining>0 (e.g. backpressure). Low risk today; easy one-character fix.
Write bucket conflates different-cost operations — no granularity for future differentiation
apps/platform/src/app/api/v1/[...ts-rest]/route.ts:204
60/min covers webhooks, credential creates, ZIP generation, and asset provisioning — very different cost profiles. At launch this is fine. Adding an 'expensive' bucket type now (even at 60/min) would let future PRs differentiate ZIP/provision limits without restructuring rateLimitBucket.
Comment block is proportionate and appropriate
apps/platform/src/app/api/v1/[...ts-rest]/route.ts:185
The added comment explaining the per-instance floor and decoupling rationale matches the CLAUDE.md rule (comment only when the WHY is non-obvious). The in-memory + per-Lambda semantics are genuinely surprising. No action needed.
History · 6 commits
- edc190esafeincremental0H · 0M · 3L2026-07-09 00:52
- 1f85ae7needs attentionincremental0H · 1M · 4L2026-07-08 20:16
- d77720dneeds attentionincremental0H · 1M · 4L2026-07-08 18:03
- 28ed416needs attentionincremental0H · 1M · 4L2026-07-08 17:41
- ae8bb22safeincremental0H · 0M · 9L2026-07-07 20:38
- c095510needs attentionfull3H · 3M · 4L2026-07-07 19:36current