Skip to content

feat(api): Add rate limiting to public endpoints#1165

Open
jeromehardaway wants to merge 1 commit into
masterfrom
feat/1064-rate-limit-public-endpoints
Open

feat(api): Add rate limiting to public endpoints#1165
jeromehardaway wants to merge 1 commit into
masterfrom
feat/1064-rate-limit-public-endpoints

Conversation

@jeromehardaway

Copy link
Copy Markdown
Contributor

Problem

Public API endpoints — the J0dI3 proxy routes, /api/contact, and /api/health — had no rate limiting, leaving them open to abuse (the contact endpoint in particular triggers AI spam classification and Slack webhooks on every request).

Solution

Issue #1064 suggests Upstash Redis, but this repo already ships a tested in-memory sliding-window limiter (src/lib/rate-limit.ts) that the docs note is single-instance only, with Redis as the scale-out path. This PR deliberately reuses that limiter rather than adding a dependency; the Redis swap is documented as the follow-up in docs/RATE_LIMITS.md.

  • Added a one-line opt-in helper enforceRateLimit(req, res, { name, maxRequests, windowMs, key? }) to src/lib/rate-limit.ts. Buckets are namespaced per endpoint so limits never bleed across routes. On limit it sends 429 with a Retry-After header derived from resetAt and a safe JSON error; every response carries X-RateLimit-Remaining / X-RateLimit-Reset.
  • J0dI3 proxy — enforced centrally in src/lib/j0di3-proxy.ts dispatch(), so every /api/j0di3/* route (including admin proxies) is covered in one place: 30 req/min keyed by troopId, falling back to user id, then client IP.
  • /api/contact — 5 req/min per IP, checked before validation/classification.
  • /api/health — 30 req/min per IP.
  • Added @swagger blocks for /api/contact and /api/health (429 responses included); regenerated public/swagger-spec.json.
  • Documented the limits and the Redis upgrade path in docs/RATE_LIMITS.md.

Tests

  • Unit coverage for enforceRateLimit: allow path + headers, 429 + Retry-After, per-name bucket isolation, custom key override.
  • 429 regression tests for all three wired surfaces: contact (6th request in a minute), health (31st request), and the J0dI3 proxy (31st request for one troop, asserting the upstream client is not called).
  • Existing mocks updated to carry unique per-test IPs and setHeader so the limiter never bleeds across unrelated tests.

Verification

  • npm run typecheck — 7 errors, all pre-existing on master (verified by stashing); no new errors introduced.
  • npm run lint — changed files clean (0 errors); remaining repo errors are pre-existing in unrelated component tests.
  • npm test — 41 files, 391 tests, all passing.
  • npm run build — succeeds; swagger spec regenerated with the new paths.

Follow-up

Swap the in-memory Map for a shared store (e.g. Upstash Redis) if the app scales past a single instance — call sites stay the same.

Closes #1064

Apply the existing in-memory sliding-window limiter to public
endpoints via a new enforceRateLimit helper in src/lib/rate-limit.ts:

- /api/j0di3/* — 30 req/min, enforced centrally in j0di3-proxy,
  keyed by troopId (falls back to user id, then client IP)
- /api/contact — 5 req/min per IP
- /api/health — 30 req/min per IP

Exceeding a limit returns 429 with a Retry-After header derived from
resetAt and a safe JSON error. Buckets are namespaced per endpoint so
limits never bleed across routes. Limits are documented in
docs/RATE_LIMITS.md, including the Redis upgrade path for
multi-instance deployments.
@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vets-who-code-app Ready Ready Preview, Comment Jun 11, 2026 4:49pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add rate limiting to public API endpoints (J0dI3 proxy, contact, health)

1 participant