Repository navigation
feat(rate-limiting): publish the tier matrix and enforce it in CI (#614) - #627
Merged
robertocarlous merged 1 commit intoOct 19, 2026
Merged
Conversation
…urowealth#614) The limits lived only in code: every limiter was constructed inline and mounted by hand, so "what is the budget on POST /api/v1/deposit, and does it apply to a session or an API key?" had no answer that was not an archaeology exercise across three files — and no check that the answer stayed true. This adds the missing single source of truth and makes CI enforce it. - src/middleware/rateLimitTiers.ts — the tier catalogue (window, burst, principal, auth types, env vars) and the route → tier matrix, plus the resolver and the Markdown renderers the doc is generated from. - src/middleware/rateLimiter.ts — every limiter now carries the tier it enforces, and buildRateLimiter requires one, so a limiter the matrix cannot describe is a compile error rather than a silent gap. - src/routes/registry.ts — the route table, extracted from src/index.ts so the check walks the routes the server actually serves instead of a hand-written list. - src/middleware/rateLimiterChain.ts — the app-wide global + tiered chain, named so the check can assert that no route is ever unlimited. - docs/RATE_LIMITING.md — the generated matrix, the auth-type mapping and the fail-open/fail-closed decision. - tests/unit/middleware/rateLimitTierMatrix.test.ts — the CI half: an unmapped route, a matrix that disagrees with the mounted limiter, a matrix entry that no longer matches a real route, an undescribed limiter, or a stale doc each fail the build. The fail-open / fail-closed split is deliberate and now stated in one place. At runtime an unmapped route still gets the global + tiered budgets, so it is never unlimited; in CI a gap is a hard failure, because the matrix is the contract and the contract is enforced. Fixes the erasure limiter's metrics label, which read "admin" and so was indistinguishable from the general admin budget it is deliberately held far below. Closes Neurowealth#614
|
@johnsmccain Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #614
Context
The rate limits lived only in code. Every limiter was constructed inline in
src/middleware/rateLimiter.tsand mounted by hand insrc/index.tsor inside a route file, so:POST /api/v1/deposit?" needed an archaeology exercise across three files.Proposal
What is in the PR
src/middleware/rateLimitTiers.tssrc/middleware/rateLimiter.tsbuildRateLimiternow requires one, so a limiter the matrix cannot describe is a compile error rather than a silent gapsrc/routes/registry.tssrc/index.tsso the check walks the routes the server actually serves instead of a hand-written listsrc/middleware/rateLimiterChain.tsglobal+tieredchain, named so the check can assert that no route is ever unlimiteddocs/RATE_LIMITING.mdtests/unit/middleware/rateLimitTierMatrix.test.tsThe CI check
tests/unit/middleware/rateLimitTierMatrix.test.tswalks all 249 routes the server serves and fails when:docs/RATE_LIMITING.mdis not the table this module generates.It caught two real drifts while this was being written:
/api/v1/agent/decisionsis a user-facing read (session auth) rather than a service-to-service endpoint, and the erasure limiter's metrics label readadmin, making it indistinguishable from the general admin budget it is deliberately held far below.Fail-open vs fail-closed
Runtime: fail-open. The app-wide chain (
global+tiered) is mounted before routing, so a route missing from the matrix still gets a budget. No route is ever unlimited — "fail-open" here means "degrade to the generic budget", not "let the request through unchecked".CI: fail-closed. The matrix is the contract, and the contract is enforced. The runtime behaviour is a safety net for the case where someone forgot; CI is where forgetting is caught.
Acceptance criteria
docs/RATE_LIMITING.md, with both tables generated from the source of truth (npm run docs:rate-limits)authTypeson every tier in the catalogueTesting
tests/unit/middleware/rateLimitTierMatrix.test.tstsc --noEmitandeslintcleanmainare unchanged by this PR (baseline onmain: 9 failed suites / 64 failed tests; on this branch: 9 failed / 57 failed)