From 4919d809ffd88f771eb9b3c937dc431c07ee9a4c Mon Sep 17 00:00:00 2001 From: johnsmccain Date: Mon, 19 Oct 2026 17:53:54 +0100 Subject: [PATCH] feat(rate-limiting): publish the tier matrix and enforce it in CI (#614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #614 --- docs/DOCUMENTATION_INDEX.md | 1 + docs/RATE_LIMITING.md | 198 ++++++ package.json | 1 + scripts/rate-limit-doc-tables.ts | 67 ++ src/index.ts | 122 +--- src/middleware/rateLimitTiers.ts | 634 ++++++++++++++++++ src/middleware/rateLimiter.ts | 69 +- src/middleware/rateLimiterChain.ts | 26 + src/routes/registry.ts | 150 +++++ tests/helpers/routeTable.ts | 118 ++++ .../rateLimiter.integration.test.ts | 4 + .../rateLimiterTiering.integration.test.ts | 1 + .../middleware/rateLimitTierMatrix.test.ts | 220 ++++++ 13 files changed, 1497 insertions(+), 114 deletions(-) create mode 100644 docs/RATE_LIMITING.md create mode 100644 scripts/rate-limit-doc-tables.ts create mode 100644 src/middleware/rateLimitTiers.ts create mode 100644 src/middleware/rateLimiterChain.ts create mode 100644 src/routes/registry.ts create mode 100644 tests/helpers/routeTable.ts create mode 100644 tests/unit/middleware/rateLimitTierMatrix.test.ts diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 7e3edbf8..c2809356 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -22,6 +22,7 @@ - **[PRISMA_QUERY_PERFORMANCE.md](PRISMA_QUERY_PERFORMANCE.md)** - Hot webhook query plans, supporting indexes, and the query-index regression check - **[NET_WORTH.md](NET_WORTH.md)** - Read-only external Stellar links, valuation/staleness semantics, and private scope - **[CONSENT_AND_PRIVACY_POLICY.md](CONSENT_AND_PRIVACY_POLICY.md)** - User consent boundaries, high-risk workflow checks & auditable bypass policy (#510) +- **[RATE_LIMITING.md](RATE_LIMITING.md)** - Rate-limit tier catalogue & route→tier matrix: auth-type mapping, effective budgets, fail-open/fail-closed policy (#614) ### For DevOps/Deployment diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md new file mode 100644 index 00000000..22196380 --- /dev/null +++ b/docs/RATE_LIMITING.md @@ -0,0 +1,198 @@ +# Rate Limiting + +Every route in this API is rate limited. This document is the map from a +route to the budget that applies to it, and the rules that decide which +budget a given caller draws on. + +It is not hand-maintained prose. The tables below are generated from +`src/middleware/rateLimitTiers.ts`, and +`tests/unit/middleware/rateLimitTierMatrix.test.ts` fails CI if the tables, +the code and the route table disagree. If you add a route, the matrix entry +is part of the change — not a follow-up. + +--- + +## The two layers every request passes through + +Before a request reaches a route, it passes through the **app-wide chain** +(`src/middleware/rateLimiterChain.ts`): + +| Tier | What it does | +| --- | --- | +| `global` | A flat budget over every request the server serves, matched or not. | +| `tiered` | The identity split. Anonymous callers draw on a strict per-IP budget; callers that have *proved* an identity draw on a generous per-principal one. | + +A route-specific tier (`sensitive`, `optimizer`, …) is applied **in addition +to** the app-wide chain, never instead of it. A withdrawal is charged to the +global budget, the tiered budget and the sensitive budget — the sensitive one +is the tightest and therefore the one that bites first, but the other two +still cap a client that spreads its traffic across many endpoints. + +The tiered tier is the only place where "who are you?" changes the answer. +An API key's own `rateLimitPerMin` overrides both the anonymous and the +authenticated allowance for that key. + +--- + +## Which auth type maps to which tier + +| Caller | How it is identified | Budget charged against | +| --- | --- | --- | +| **Session** | `req.userId`, set by `requireAuth` after the JWT is verified | The user id — one bucket per user, shared across that user's devices | +| **API key** | `nwk__` — self-describing in the `Authorization` header | The key id — two keys behind one NAT get two buckets, and the key's own `rateLimitPerMin` wins | +| **Admin key** | `X-Admin-Token` / `Authorization: Bearer` | The admin credential | +| **Webhook provider** | HMAC signature over the raw body (Twilio, Telegram, fiat) | The client IP — the signature is the credential, not a bearer token | +| **Internal service** | `X-Internal-Token` | The client IP — the internal token is a single shared credential | +| **Anonymous** | Nothing proven | The client IP, at the strict anonymous allowance | + +A request is only ever charged to an identity the auth middleware has +*verified*. A `Authorization` header alone never upgrades a caller from +anonymous: otherwise an attacker could mint unlimited buckets by sending +garbage bearer tokens. + +--- + +## The tier catalogue + +Every tier in the system, with its live window and burst. The numbers come +from `config.security`, so this table cannot drift from what the limiter +actually enforces. + + +| Tier | Window | Burst | Principal | Auth types | Purpose | +| --- | --- | --- | --- | --- | --- | +| `global` | 900000ms | 100 | principal | session, api_key, admin, webhook, internal | Baseline budget for every route. Catches runaway clients and gives the more specific tiers something to sit on top of. | +| `tiered` | 900000ms | 60 | principal | session, api_key, admin, internal | Identity tiering. Anonymous callers draw on a strict per-IP budget; callers that proved an identity draw on a generous per-principal one. An API key’s own allowance overrides this tier entirely. | +| `auth` | 900000ms | 20 | ip | anonymous, session | Credential endpoints. Deliberately IP-keyed: credential stuffing happens before any identity is proven, so a per-user key would be meaningless here. | +| `admin` | 900000ms | 10 | principal | admin, internal | The management API. Tightest of the general tiers — these routes move reserves, mint keys and settle referrals. | +| `erasure` | 900000ms | 5 | principal | admin, internal | Right-to-erasure. Irreversible, so it is held far below the general admin budget — a bug that loops here would delete data repeatedly. | +| `webhook` | 60000ms | 30 | ip | webhook | Inbound provider callbacks. Unauthenticated at the transport layer — the HMAC signature is the credential — so the budget resists spoofed and replayed floods. | +| `sensitive` | 900000ms | 10 | principal | session, api_key | Money movement and credential changes. Applied per-endpoint IN ADDITION to the route’s normal budget, so a burst of withdrawals is stopped without throttling the balance reads the user needs to decide. | +| `optimizer` | 60000ms | 5 | principal | session, api_key | The CPU-bound allocation suggester. Pairs with the in-flight semaphore in src/utils/concurrency.ts: that bounds how many run at once, this bounds how often they start. | +| `simulate` | 60000ms | 6 | principal | session, api_key | The CPU-bound historical replay. Tight enough that the replay cannot be used as a CPU-exhaustion vector; pairs with the short-TTL result cache so identical previews are served without recomputing. | +| `internal` | 60000ms | 500 | ip | internal | Service-to-service calls (the agent loop). Higher throughput than the user-facing tiers, and IP-keyed because the internal token is a single shared credential. | + + +### Moving a tier + +Each tier is controlled by a pair of environment variables (see +`src/config/env.ts`). The `tiered` tier has two pairs: the anonymous +allowance and the authenticated one. + +| Tier | Window | Burst | +| --- | --- | --- | +| `global` | `RATE_LIMIT_WINDOW_MS` | `RATE_LIMIT_MAX` | +| `tiered` (anonymous) | `ANONYMOUS_RATE_LIMIT_WINDOW_MS` | `ANONYMOUS_RATE_LIMIT_MAX` | +| `tiered` (authenticated) | `AUTHENTICATED_RATE_LIMIT_WINDOW_MS` | `AUTHENTICATED_RATE_LIMIT_MAX` | +| `auth` | `AUTH_RATE_LIMIT_WINDOW_MS` | `AUTH_RATE_LIMIT_MAX` | +| `admin` | `ADMIN_RATE_LIMIT_WINDOW_MS` | `ADMIN_RATE_LIMIT_MAX` | +| `erasure` | fixed (15 min) | fixed (5) | +| `webhook` | `WEBHOOK_RATE_LIMIT_WINDOW_MS` | `WEBHOOK_RATE_LIMIT_MAX` | +| `sensitive` | `SENSITIVE_RATE_LIMIT_WINDOW_MS` | `SENSITIVE_RATE_LIMIT_MAX` | +| `optimizer` | `OPTIMIZER_RATE_LIMIT_WINDOW_MS` | `OPTIMIZER_RATE_LIMIT_MAX` | +| `simulate` | `SIMULATE_RATE_LIMIT_WINDOW_MS` | `SIMULATE_RATE_LIMIT_MAX` | +| `internal` | `INTERNAL_RATE_LIMIT_WINDOW_MS` | `INTERNAL_RATE_LIMIT_MAX` | + +--- + +## The route → tier matrix + +Paths are the canonical `/api/v1/*` ones. Every route is also served at the +legacy `/api/*` alias with the same handlers, and therefore the same tier. + + +| Route | Method | Tier | Window | Burst | Why | +| --- | --- | --- | --- | --- | --- | +| `/api/v1/auth` | ANY | `auth` | 900000ms | 20 | Login, refresh, logout — credential stuffing surface. | +| `/api/v1/auth/webauthn` | ANY | `auth` | 900000ms | 20 | WebAuthn ceremonies — same credential surface as password auth. | +| `/api/v1/webauthn` | ANY | `auth` | 900000ms | 20 | Top-level WebAuthn alias, same ceremony budget. | +| `/api/v1/whatsapp` | ANY | `webhook` | 60000ms | 30 | Inbound WhatsApp provider callbacks, HMAC-signed. | +| `/api/v1/telegram` | ANY | `webhook` | 60000ms | 30 | Inbound Telegram provider callbacks, HMAC-signed. | +| `/api/v1/admin` | ANY | `admin` | 900000ms | 10 | Management API — reserves, key minting, referral settlement. | +| `/api/v1/admin/support/tickets` | ANY | `admin` | 900000ms | 10 | Admin view of support tickets. | +| `/api/v1/admin/notifications/dlq` | ANY | `admin` | 900000ms | 10 | Dead-letter queue inspection and replay. | +| `/api/v1/admin/erasure/requests` | POST | `erasure` | 900000ms | 5 | Creating an erasure request is a destructive operation. | +| `/api/v1/admin/erasure/requests/:id/approve` | POST | `erasure` | 900000ms | 5 | Approving an erasure request authorises data deletion. | +| `/api/v1/admin/erasure/requests/:id/reject` | POST | `erasure` | 900000ms | 5 | Rejecting an erasure request is part of the erasure workflow. | +| `/api/v1/admin/erasure/requests/:id/execute` | POST | `erasure` | 900000ms | 5 | Executing an erasure request deletes data — the sharpest edge. | +| `/api/v1/admin/erasure/preview/:userId` | GET | `erasure` | 900000ms | 5 | Previewing an erasure walks the same deletion path as the execute. | +| `/api/v1/deposit` | POST | `sensitive` | 900000ms | 10 | Deposits move money — irreversible once submitted. | +| `/api/v1/withdraw` | POST | `sensitive` | 900000ms | 10 | Withdrawals move money — irreversible once submitted. | +| `/api/v1/loans` | POST | `sensitive` | 900000ms | 10 | Loan origination moves money. | +| `/api/v1/loans/:loanId/repay` | POST | `sensitive` | 900000ms | 10 | Loan repayment moves money. | +| `/api/v1/keys` | POST | `sensitive` | 900000ms | 10 | Minting an API key is a takeover vector — credential change. | +| `/api/v1/keys/:id/rotate` | POST | `sensitive` | 900000ms | 10 | Rotating a key is a credential change. | +| `/api/v1/support/tickets` | POST | `sensitive` | 900000ms | 10 | Opening a ticket is a state change on a customer-visible record. | +| `/api/v1/portfolio/:userId/suggest-allocation` | POST | `optimizer` | 60000ms | 5 | CPU-bound allocation solver — the classic exhaustion vector. | +| `/api/v1/strategies/simulate` | POST | `simulate` | 60000ms | 6 | CPU-bound historical replay. | +| `/api/v1/agent/decisions` | ANY | `tiered` | 900000ms / 600 authenticated | 60 | Agent decision history is a user-facing read (session auth), not a service-to-service call — only the agent status endpoints are. | +| `/api/v1/agent` | ANY | `internal` | 60000ms | 500 | Agent loop endpoints — service-to-service, guarded by the internal token. | +| `/api/v1` | ANY | `tiered` | 900000ms / 600 authenticated | 60 | Default for every remaining versioned API route: the global budget plus the identity tier (strict per-IP when anonymous, generous per-principal once authenticated). | +| `/api/webhooks` | ANY | `tiered` | 900000ms / 600 authenticated | 60 | Pre-version webhook management surface; same baseline as /api/v1. | +| `/api/analytics` | ANY | `tiered` | 900000ms / 600 authenticated | 60 | Pre-version analytics alias; same baseline as /api/v1. | +| `/api/stellar` | ANY | `tiered` | 900000ms / 600 authenticated | 60 | Pre-version Stellar alias; same baseline as /api/v1. | +| `/health` | ANY | `tiered` | 900000ms / 600 authenticated | 60 | Liveness and readiness probes. The GLOBAL limiter skips these so k8s probes never consume the budget; the tiered limiter still applies. | +| `/metrics` | ANY | `tiered` | 900000ms / 600 authenticated | 60 | Prometheus scrape endpoint, guarded by the internal auth guard. | + + +--- + +## Fail-open vs fail-closed + +**Runtime: fail-open.** The app-wide chain (`global` + `tiered`) is mounted +before routing, so a route that is missing from the matrix still gets a +budget. No route is ever unlimited, and the "fail-open" label does not mean +"let the request through unchecked" — it means "degrade to the generic +budget instead of to none". + +**CI: fail-closed.** The matrix is the contract, and the contract is +enforced. `tests/unit/middleware/rateLimitTierMatrix.test.ts` fails the +build when: + +- a mounted route resolves to **no** matrix entry (an undocumented budget); +- the matrix says a route is in one tier while the code mounts a limiter + from **another** (the table is lying); +- a matrix entry **no longer matches** any real route (the table has + rotted); +- a limiter exists that the tier catalogue does **not** describe; +- `docs/RATE_LIMITING.md` is **not** the table this module generates. + +The rationale: the runtime behaviour is a safety net for the case where +someone forgot; CI is where forgetting is caught. A gap in the matrix is +exactly the kind of silent drift this catalogue exists to prevent, and a +safety net that nobody notices is not a safety net. + +--- + +## Response headers + +Every limited response carries the IETF draft headers: + +| Header | When | Meaning | +| --- | --- | --- | +| `RateLimit-Limit` | every response | The quota that was applied to *this* request | +| `RateLimit-Remaining` | every response | Requests left in the current window | +| `RateLimit-Reset` | every response | Seconds until the window resets | +| `RateLimit-Policy` | every response | `;w=` — the effective policy | +| `Retry-After` | 429 only | Seconds until the caller may retry | + +`RateLimit-Policy` reports the limit **actually applied**, not the tier +default. An API key with `rateLimitPerMin: 5` is told it has 5, so a client +that reads the header to schedule its retries does not keep hammering a +5/min endpoint believing it has 100. + +--- + +## Adding a route + +1. Decide the tier. Most routes take the default (`tiered`). A route that + moves money or changes a credential takes `sensitive`. A CPU-bound route + takes `optimizer` or `simulate`. +2. Add the entry to `RATE_LIMIT_MATRIX` in `src/middleware/rateLimitTiers.ts`. + Use an **exact** path for a single endpoint; set `prefix: true` only when + the entry is meant to cover a whole router. +3. If the route needs its own limiter, build it with `buildRateLimiter({ …, + tier: 'sensitive' })` and mount it on the route. The tier tag is what + makes the matrix checkable. +4. Run `npm test -- rateLimitTierMatrix`. It will tell you if the matrix and + the code disagree. diff --git a/package.json b/package.json index 323bb009..928c53d2 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "outdated:deps": "npm outdated || true", "validate:spec": "npx --yes @redocly/cli@latest lint docs/openapi.yaml --format=stylish", "check:openapi-drift": "node scripts/check-openapi-drift.js", + "docs:rate-limits": "ts-node scripts/rate-limit-doc-tables.ts", "build:spec": "mkdir -p dist/docs && cp docs/openapi.yaml dist/docs/openapi.yaml", "prebuild": "npm run clean && npm run build:spec", "prestart": "npm run build", diff --git a/scripts/rate-limit-doc-tables.ts b/scripts/rate-limit-doc-tables.ts new file mode 100644 index 00000000..b503e53f --- /dev/null +++ b/scripts/rate-limit-doc-tables.ts @@ -0,0 +1,67 @@ +/** + * Regenerate the generated tables in docs/RATE_LIMITING.md (#614). + * + * The prose in that document is hand-written; the two tables are generated + * from src/middleware/rateLimitTiers.ts. This script rewrites the blocks + * between the GENERATED markers, so a contributor who changes a tier or adds + * a route can refresh the doc with one command: + * + * npm run docs:rate-limits + * + * tests/unit/middleware/rateLimitTierMatrix.test.ts asserts the committed + * doc still matches what this script would write, so a stale doc fails CI. + */ + +import fs from 'node:fs' +import path from 'node:path' +import dotenv from 'dotenv' + +// The env must be loaded before src/config/env.ts validates it, and a static +// import of the catalogue would be hoisted above this line — hence the +// dynamic import below. +dotenv.config({ + path: path.join(__dirname, '..', '.env.test'), + override: true, +}) + +const DOC_PATH = path.join(__dirname, '..', 'docs', 'RATE_LIMITING.md') + +const BLOCKS = [ + { marker: 'matrix', render: 'renderRateLimitMatrixMarkdown' }, + { marker: 'tier-catalog', render: 'renderRateLimitTierCatalogMarkdown' }, +] as const + +async function main(): Promise { + const catalogue = await import('../src/middleware/rateLimitTiers') + + let doc = fs.readFileSync(DOC_PATH, 'utf8') + + for (const block of BLOCKS) { + const begin = `` + const end = `` + const start = doc.indexOf(begin) + const stop = doc.indexOf(end) + + if (start === -1 || stop === -1) { + throw new Error( + `docs/RATE_LIMITING.md is missing the "${block.marker}" generated block` + ) + } + + const table = catalogue[block.render]() + doc = + doc.slice(0, start + begin.length) + + '\n' + + table + + '\n' + + doc.slice(stop) + } + + fs.writeFileSync(DOC_PATH, doc) + console.log(`Updated ${path.relative(process.cwd(), DOC_PATH)}`) +} + +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/src/index.ts b/src/index.ts index 96d82fb8..e3212c7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,14 +32,13 @@ import { import { requestLogger } from './middleware/logger' import { requestTimeoutMiddleware } from './middleware/requestTimeout' import { - rateLimiter, - tieredRateLimiter, authRateLimiter, adminRateLimiter, internalRateLimiter, webhookRateLimiter, trustedIpBypass, } from './middleware/rateLimiter' +import { APP_LEVEL_LIMITERS } from './middleware/rateLimiterChain' import { configureTrustProxy, securityHeaders, @@ -74,47 +73,7 @@ import { startEventListener, stopEventListener } from './stellar/events' import { startEventBridge, stopEventBridge } from './events/bridge' import { attachWebSocketServer, closeWebSocketServer } from './ws/server' import { validateStellarNetworkReady } from './config/readiness' -import healthRouter from './routes/health' -import agentRouter from './routes/agent' -import agentDecisionsRouter from './routes/agent-decisions' -import authRouter from './routes/auth' -import whatsappRouter from './routes/whatsapp' -import telegramRouter from './routes/telegram' -import portfolioRouter from './routes/portfolio' -import transactionsRouter from './routes/transactions' -import protocolsRouter from './routes/protocols' -import depositRouter from './routes/deposit' -import withdrawRouter from './routes/withdraw' -import loansRouter from './routes/loans' -import protectionFundRouter from './routes/protection-fund' -import settingsRouter from './routes/settings' -import vaultRouter from './routes/vault' -import analyticsRouter from './routes/analytics' -import adminRouter from './routes/admin' -import metricsRouter from './routes/metrics' -import stellarRouter from './routes/stellar' -import webhooksRouter from './routes/webhooks' -import fiatRouter from './routes/fiat' -import referralsRouter from './routes/referrals' -import recurringDepositRouter from './routes/recurring-deposits' -import recurringWithdrawalRouter from './routes/recurring-withdrawals' -import alertsRouter from './routes/alerts' -import strategiesRouter from './routes/strategies' -import subAccountsRouter from './routes/sub-accounts' -import supportTicketsRouter from './routes/support-tickets' -import adminSupportTicketsRouter from './routes/admin/support-tickets' -import assistantRouter from './routes/assistant' -import approvalsRouter from './routes/approvals' -import approvalPoliciesRouter from './routes/approval-policies' -import keysRouter from './routes/keys' -import sessionsRouter from './routes/sessions' -import webAuthnRouter from './routes/webauthn' -import streamRouter from './routes/stream' -import notificationsRouter from './routes/notifications' -import liquidityFloorRouter from './routes/liquidity-floor' -import notificationDlqRouter from './routes/notification-dlq' -import networkRouter from './routes/network' -import netWorthRouter from './routes/net-worth' +import { API_ROUTES, EXTRA_MOUNTS } from './routes/registry' import { corsMiddleware, jsonBodyParser, @@ -235,14 +194,11 @@ app.use((req: Request & { user?: { id: string } }, _res: Response, next) => { app.use(requestLogger) app.use(trustedIpBypass) -app.use(rateLimiter) -// #473 — identity tiering. This single limiter charges anonymous callers to a -// strict per-IP budget and authenticated callers to a generous per-principal one -// (see tieredRateLimiter). It resolves identity from the request itself, so it -// works here, ahead of each route's auth middleware: API keys are self- -// describing in the Authorization header, and session tokens are classified once -// the auth middleware has resolved req.userId. -app.use(tieredRateLimiter) +// The app-wide rate-limit chain: the flat global budget, then the identity +// tier. Both apply to every request, so no route is ever unlimited. Kept in +// src/middleware/rateLimiterChain.ts so the tier-matrix CI check can assert +// that claim against the real chain. +app.use(...APP_LEVEL_LIMITERS) app.use(requestTimeoutMiddleware) // Advertise the served API version on every response — must be registered @@ -351,66 +307,16 @@ interface ApiRoute { handlers: express.RequestHandler[] } -const apiRoutes: ApiRoute[] = [ - { path: 'network', handlers: [networkRouter] }, - { path: 'net-worth', handlers: [netWorthRouter] }, - { path: 'agent/decisions', handlers: [agentDecisionsRouter] }, - { path: 'agent', handlers: [internalRateLimiter, agentRouter] }, - { path: 'auth/webauthn', handlers: [authRateLimiter, webAuthnRouter] }, - { path: 'auth', handlers: [authRateLimiter, authRouter] }, - { path: 'webauthn', handlers: [authRateLimiter, webAuthnRouter] }, - { path: 'whatsapp', handlers: [webhookRateLimiter, whatsappRouter] }, - { path: 'telegram', handlers: [webhookRateLimiter, telegramRouter] }, - { path: 'portfolio', handlers: [portfolioRouter] }, - { path: 'transactions', handlers: [transactionsRouter] }, - { path: 'protocols', handlers: [protocolsRouter] }, - { path: 'deposit', handlers: [depositRouter] }, - { path: 'withdraw', handlers: [withdrawRouter] }, - // #532 — collateral loans. Mounted beside withdraw rather than inside - // portfolio: a loan is its own lifecycle with its own approval and rate - // limits, and it must not inherit portfolio's read-only scope set. - { path: 'loans', handlers: [loansRouter] }, - // #533 — protocol-risk protection fund - { path: 'protection-fund', handlers: [protectionFundRouter] }, - // #534 — user settings (display currency) - { path: 'settings', handlers: [settingsRouter] }, - { path: 'vault', handlers: [vaultRouter] }, - { path: 'analytics', handlers: [analyticsRouter] }, - { path: 'stellar', handlers: [stellarRouter] }, - { path: 'fiat', handlers: [fiatRouter] }, - { path: 'referrals', handlers: [referralsRouter] }, - { path: 'deposit/recurring', handlers: [recurringDepositRouter] }, - { path: 'recurring-withdrawals', handlers: [recurringWithdrawalRouter] }, - { path: 'alerts', handlers: [alertsRouter] }, - { path: 'strategies', handlers: [strategiesRouter] }, - { path: 'sub-accounts', handlers: [subAccountsRouter] }, - { path: 'support/tickets', handlers: [supportTicketsRouter] }, - { - path: 'admin/support/tickets', - handlers: [adminRateLimiter, adminSupportTicketsRouter], - }, - { path: 'assistant', handlers: [assistantRouter] }, - { path: 'approvals', handlers: [approvalsRouter] }, - { path: 'approval-policies', handlers: [approvalPoliciesRouter] }, - { path: 'keys', handlers: [keysRouter] }, - { path: 'sessions', handlers: [sessionsRouter] }, - { path: 'stream', handlers: [streamRouter] }, - { path: 'notifications', handlers: [notificationsRouter] }, - { path: 'liquidity-floor', handlers: [liquidityFloorRouter] }, - { - path: 'admin/notifications/dlq', - handlers: [adminRateLimiter, notificationDlqRouter], - }, - { path: 'admin', handlers: [adminRateLimiter, adminRouter] }, -] +const apiRoutes: ApiRoute[] = API_ROUTES // ── Application routes ──────────────────────────────────────────────────────── -app.use('/health', healthRouter) -app.use('/api/analytics', analyticsRouter) -app.use('/api/stellar', stellarRouter) -app.use('/api/webhooks', webhooksRouter) -app.use('/metrics', metricsRouter) +// Operational endpoints and the pre-version paths (health, metrics, the legacy +// webhooks surface). Declared in src/routes/registry.ts so the tier matrix +// can describe every route the server serves, not just the versioned ones. +for (const mount of EXTRA_MOUNTS) { + app.use(mount.path, ...mount.handlers) +} // Primary, versioned mounts: /api/v1/* for (const route of apiRoutes) { diff --git a/src/middleware/rateLimitTiers.ts b/src/middleware/rateLimitTiers.ts new file mode 100644 index 00000000..77faf6e7 --- /dev/null +++ b/src/middleware/rateLimitTiers.ts @@ -0,0 +1,634 @@ +/** + * The rate-limit tier catalog and the route → tier matrix (#614). + * + * Before this module existed, the limits lived only in code: every limiter was + * constructed inline in src/middleware/rateLimiter.ts and mounted by hand in + * src/index.ts or inside a route file. A contributor asking "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 a archaeology exercise across three files — and no + * check that the answer stayed true. + * + * This module is the single, machine-readable source of truth: + * + * 1. RATE_LIMIT_TIERS — every tier, its window/burst, the principal it + * charges, the auth types that land in it, and the + * env vars that move it. + * 2. RATE_LIMIT_MATRIX — the route → tier bindings. + * + * tests/unit/middleware/rateLimitTierMatrix.test.ts fails CI when the two + * drift from the code: an unmapped route, a matrix entry that no longer + * matches any real route, a limiter that is not in the catalog, or a route + * whose attached limiter disagrees with the matrix. + * + * The matrix is expressed against the CANONICAL /api/v1/* paths. The legacy + * unversioned /api/* aliases are mounted from the same route table and + * therefore carry the same tier; see src/routes/registry.ts. + */ + +import { config } from '../config/env' + +// ── Tier identity ─────────────────────────────────────────────────────────── + +/** + * The tier ids. These are also the `limiterType` strings that reach the + * Prometheus metrics (src/utils/metrics.ts), so a tier id appearing in a + * dashboard can be traced straight back to this file. + */ +export const RATE_LIMIT_TIER_IDS = [ + 'global', + 'tiered', + 'auth', + 'admin', + 'erasure', + 'webhook', + 'sensitive', + 'optimizer', + 'simulate', + 'internal', +] as const + +export type RateLimitTierId = (typeof RATE_LIMIT_TIER_IDS)[number] + +/** + * How a request is identified when its budget is spent. + * + * ip — one bucket per client address. Used where no identity has + * been proven yet (login attempts) or where the caller is + * anonymous by design (inbound webhooks). + * principal — one bucket per proven identity: a session's user id, an API + * key's key id, the admin credential, or the internal service + * token. Two keys behind one NAT get two budgets. + */ +export type RateLimitPrincipalKind = 'ip' | 'principal' + +/** + * The caller identities a tier is designed for. `webhook` is the inbound + * provider callbacks (Twilio/Telegram/fiat) — authenticated by HMAC signature + * rather than by a bearer token, so it is a tier of its own. + */ +export type RateLimitAuthType = + 'session' | 'api_key' | 'admin' | 'webhook' | 'internal' | 'anonymous' + +export interface RateLimitTierDefinition { + id: RateLimitTierId + /** What this tier is for, and why it exists. */ + purpose: string + /** What the budget is charged against. */ + principal: RateLimitPrincipalKind + /** Which caller identities land in this tier. */ + authTypes: RateLimitAuthType[] + /** Effective window in milliseconds. */ + windowMs: number + /** Effective burst — requests allowed per window. */ + max: number + /** The env vars that move this tier, so an operator can find them. */ + env: { windowMs: string; max: string } + /** + * What happens to a request that reaches a tier this catalogue does not + * describe. Documented per tier because the answer is not the same + * everywhere: a route with no matrix entry still gets the global + tiered + * budgets (never unlimited), but the CI check treats the missing entry as + * a failure — see RATE_LIMIT_MATRIX_POLICY below. + */ + onUnknownCaller: 'anonymous-tier' | 'strict-tier' +} + +// ── The catalogue ─────────────────────────────────────────────────────────── + +const { security } = config + +/** + * Every rate-limit tier, with its live values. The numbers come from + * `config.security` so the catalogue can never drift from what the limiter + * actually enforces — only the env var NAMES are spelled out here, as a + * pointer for operators. + */ +export const RATE_LIMIT_TIERS: Record< + RateLimitTierId, + RateLimitTierDefinition +> = { + global: { + id: 'global', + purpose: + 'Baseline budget for every route. Catches runaway clients and gives ' + + 'the more specific tiers something to sit on top of.', + principal: 'principal', + authTypes: ['session', 'api_key', 'admin', 'webhook', 'internal'], + windowMs: security.rateLimit.windowMs, + max: security.rateLimit.max, + env: { windowMs: 'RATE_LIMIT_WINDOW_MS', max: 'RATE_LIMIT_MAX' }, + onUnknownCaller: 'strict-tier', + }, + tiered: { + id: 'tiered', + purpose: + 'Identity tiering. Anonymous callers draw on a strict per-IP budget; ' + + 'callers that proved an identity draw on a generous per-principal one. ' + + 'An API key’s own allowance overrides this tier entirely.', + principal: 'principal', + authTypes: ['session', 'api_key', 'admin', 'internal'], + windowMs: security.anonymousRateLimit.windowMs, + max: security.anonymousRateLimit.max, + env: { + windowMs: 'ANONYMOUS_RATE_LIMIT_WINDOW_MS', + max: 'ANONYMOUS_RATE_LIMIT_MAX', + }, + onUnknownCaller: 'strict-tier', + }, + auth: { + id: 'auth', + purpose: + 'Credential endpoints. Deliberately IP-keyed: credential stuffing ' + + 'happens before any identity is proven, so a per-user key would be ' + + 'meaningless here.', + principal: 'ip', + authTypes: ['anonymous', 'session'], + windowMs: security.authRateLimit.windowMs, + max: security.authRateLimit.max, + env: { windowMs: 'AUTH_RATE_LIMIT_WINDOW_MS', max: 'AUTH_RATE_LIMIT_MAX' }, + onUnknownCaller: 'strict-tier', + }, + admin: { + id: 'admin', + purpose: + 'The management API. Tightest of the general tiers — these routes ' + + 'move reserves, mint keys and settle referrals.', + principal: 'principal', + authTypes: ['admin', 'internal'], + windowMs: security.adminRateLimit.windowMs, + max: security.adminRateLimit.max, + env: { + windowMs: 'ADMIN_RATE_LIMIT_WINDOW_MS', + max: 'ADMIN_RATE_LIMIT_MAX', + }, + onUnknownCaller: 'strict-tier', + }, + erasure: { + id: 'erasure', + purpose: + 'Right-to-erasure. Irreversible, so it is held far below the general ' + + 'admin budget — a bug that loops here would delete data repeatedly.', + principal: 'principal', + authTypes: ['admin', 'internal'], + windowMs: 15 * 60 * 1000, + max: 5, + env: { windowMs: '(fixed)', max: '(fixed)' }, + onUnknownCaller: 'strict-tier', + }, + webhook: { + id: 'webhook', + purpose: + 'Inbound provider callbacks. Unauthenticated at the transport layer — ' + + 'the HMAC signature is the credential — so the budget resists ' + + 'spoofed and replayed floods.', + principal: 'ip', + authTypes: ['webhook'], + windowMs: security.webhookRateLimit.windowMs, + max: security.webhookRateLimit.max, + env: { + windowMs: 'WEBHOOK_RATE_LIMIT_WINDOW_MS', + max: 'WEBHOOK_RATE_LIMIT_MAX', + }, + onUnknownCaller: 'strict-tier', + }, + sensitive: { + id: 'sensitive', + purpose: + 'Money movement and credential changes. Applied per-endpoint IN ' + + 'ADDITION to the route’s normal budget, so a burst of withdrawals is ' + + 'stopped without throttling the balance reads the user needs to ' + + 'decide.', + principal: 'principal', + authTypes: ['session', 'api_key'], + windowMs: security.sensitiveRateLimit.windowMs, + max: security.sensitiveRateLimit.max, + env: { + windowMs: 'SENSITIVE_RATE_LIMIT_WINDOW_MS', + max: 'SENSITIVE_RATE_LIMIT_MAX', + }, + onUnknownCaller: 'strict-tier', + }, + optimizer: { + id: 'optimizer', + purpose: + 'The CPU-bound allocation suggester. Pairs with the in-flight ' + + 'semaphore in src/utils/concurrency.ts: that bounds how many run at ' + + 'once, this bounds how often they start.', + principal: 'principal', + authTypes: ['session', 'api_key'], + windowMs: security.optimizerRateLimit.windowMs, + max: security.optimizerRateLimit.max, + env: { + windowMs: 'OPTIMIZER_RATE_LIMIT_WINDOW_MS', + max: 'OPTIMIZER_RATE_LIMIT_MAX', + }, + onUnknownCaller: 'strict-tier', + }, + simulate: { + id: 'simulate', + purpose: + 'The CPU-bound historical replay. Tight enough that the replay cannot ' + + 'be used as a CPU-exhaustion vector; pairs with the short-TTL result ' + + 'cache so identical previews are served without recomputing.', + principal: 'principal', + authTypes: ['session', 'api_key'], + windowMs: security.simulateRateLimit.windowMs, + max: security.simulateRateLimit.max, + env: { + windowMs: 'SIMULATE_RATE_LIMIT_WINDOW_MS', + max: 'SIMULATE_RATE_LIMIT_MAX', + }, + onUnknownCaller: 'strict-tier', + }, + internal: { + id: 'internal', + purpose: + 'Service-to-service calls (the agent loop). Higher throughput than ' + + 'the user-facing tiers, and IP-keyed because the internal token is a ' + + 'single shared credential.', + principal: 'ip', + authTypes: ['internal'], + windowMs: security.internalRateLimit.windowMs, + max: security.internalRateLimit.max, + env: { + windowMs: 'INTERNAL_RATE_LIMIT_WINDOW_MS', + max: 'INTERNAL_RATE_LIMIT_MAX', + }, + onUnknownCaller: 'strict-tier', + }, +} + +/** + * The authenticated tier of `tiered` — a larger budget for callers that have + * proved an identity. Exposed separately because it is a real allowance with + * its own env vars, not a variant of the anonymous one. + */ +export const AUTHENTICATED_TIER_LIMITS = { + windowMs: security.authenticatedRateLimit.windowMs, + max: security.authenticatedRateLimit.max, + env: { + windowMs: 'AUTHENTICATED_RATE_LIMIT_WINDOW_MS', + max: 'AUTHENTICATED_RATE_LIMIT_MAX', + }, +} as const + +// ── The route → tier matrix ───────────────────────────────────────────────── + +export interface RateLimitMatrixEntry { + /** + * The canonical (versioned) path this entry covers, in Express's own route + * syntax — `:id` for a parameter. + */ + path: string + /** + * When true, the entry covers this path AND everything under it. When + * false (the default) the path must match exactly. + * + * The distinction matters: `/api/v1/deposit` is a single money-moving + * endpoint, while `/api/v1/deposit/recurring` is a separate router whose + * routes are scheduled deposits. A prefix entry on `/api/v1/deposit` would + * silently sweep the recurring router into the sensitive tier. + */ + prefix?: boolean + /** HTTP method, when the binding is method-specific. */ + method?: string + /** The tier that applies. */ + tier: RateLimitTierId + /** Why this route has this tier. */ + note: string +} + +/** + * The matrix. + * + * The resolver picks the most specific match: an exact entry beats a prefix + * entry, a longer path beats a shorter one, and a method-specific entry + * beats a path-only one. So the order here is for readability, not for + * correctness. + */ +export const RATE_LIMIT_MATRIX: readonly RateLimitMatrixEntry[] = [ + // ── Credential endpoints (auth tier) ───────────────────────────────────── + { + path: '/api/v1/auth', + prefix: true, + tier: 'auth', + note: 'Login, refresh, logout — credential stuffing surface.', + }, + { + path: '/api/v1/auth/webauthn', + prefix: true, + tier: 'auth', + note: 'WebAuthn ceremonies — same credential surface as password auth.', + }, + { + path: '/api/v1/webauthn', + prefix: true, + tier: 'auth', + note: 'Top-level WebAuthn alias, same ceremony budget.', + }, + + // ── Inbound webhooks (webhook tier) ───────────────────────────────────── + { + path: '/api/v1/whatsapp', + prefix: true, + tier: 'webhook', + note: 'Inbound WhatsApp provider callbacks, HMAC-signed.', + }, + { + path: '/api/v1/telegram', + prefix: true, + tier: 'webhook', + note: 'Inbound Telegram provider callbacks, HMAC-signed.', + }, + + // ── The management API (admin tier) ───────────────────────────────────── + { + path: '/api/v1/admin', + prefix: true, + tier: 'admin', + note: 'Management API — reserves, key minting, referral settlement.', + }, + { + path: '/api/v1/admin/support/tickets', + prefix: true, + tier: 'admin', + note: 'Admin view of support tickets.', + }, + { + path: '/api/v1/admin/notifications/dlq', + prefix: true, + tier: 'admin', + note: 'Dead-letter queue inspection and replay.', + }, + + // ── Erasure (erasure tier) ────────────────────────────────────────────── + // Irreversible, so these are held far below the general admin budget. + { + path: '/api/v1/admin/erasure/requests', + method: 'POST', + tier: 'erasure', + note: 'Creating an erasure request is a destructive operation.', + }, + { + path: '/api/v1/admin/erasure/requests/:id/approve', + method: 'POST', + tier: 'erasure', + note: 'Approving an erasure request authorises data deletion.', + }, + { + path: '/api/v1/admin/erasure/requests/:id/reject', + method: 'POST', + tier: 'erasure', + note: 'Rejecting an erasure request is part of the erasure workflow.', + }, + { + path: '/api/v1/admin/erasure/requests/:id/execute', + method: 'POST', + tier: 'erasure', + note: 'Executing an erasure request deletes data — the sharpest edge.', + }, + { + path: '/api/v1/admin/erasure/preview/:userId', + method: 'GET', + tier: 'erasure', + note: 'Previewing an erasure walks the same deletion path as the execute.', + }, + + // ── Money movement and credential changes (sensitive tier) ────────────── + { + path: '/api/v1/deposit', + method: 'POST', + tier: 'sensitive', + note: 'Deposits move money — irreversible once submitted.', + }, + { + path: '/api/v1/withdraw', + method: 'POST', + tier: 'sensitive', + note: 'Withdrawals move money — irreversible once submitted.', + }, + { + path: '/api/v1/loans', + method: 'POST', + tier: 'sensitive', + note: 'Loan origination moves money.', + }, + { + path: '/api/v1/loans/:loanId/repay', + method: 'POST', + tier: 'sensitive', + note: 'Loan repayment moves money.', + }, + { + path: '/api/v1/keys', + method: 'POST', + tier: 'sensitive', + note: 'Minting an API key is a takeover vector — credential change.', + }, + { + path: '/api/v1/keys/:id/rotate', + method: 'POST', + tier: 'sensitive', + note: 'Rotating a key is a credential change.', + }, + { + path: '/api/v1/support/tickets', + method: 'POST', + tier: 'sensitive', + note: 'Opening a ticket is a state change on a customer-visible record.', + }, + + // ── Compute-bound endpoints (optimizer / simulate tiers) ──────────────── + { + path: '/api/v1/portfolio/:userId/suggest-allocation', + method: 'POST', + tier: 'optimizer', + note: 'CPU-bound allocation solver — the classic exhaustion vector.', + }, + { + path: '/api/v1/strategies/simulate', + method: 'POST', + tier: 'simulate', + note: 'CPU-bound historical replay.', + }, + + // ── Service-to-service (internal tier) ────────────────────────────────── + { + path: '/api/v1/agent/decisions', + prefix: true, + tier: 'tiered', + note: + 'Agent decision history is a user-facing read (session auth), not a ' + + 'service-to-service call — only the agent status endpoints are.', + }, + { + path: '/api/v1/agent', + prefix: true, + tier: 'internal', + note: 'Agent loop endpoints — service-to-service, guarded by the internal token.', + }, + + // ── Everything else: the global + tiered baseline ────────────────────── + { + path: '/api/v1', + prefix: true, + tier: 'tiered', + note: + 'Default for every remaining versioned API route: the global budget ' + + 'plus the identity tier (strict per-IP when anonymous, generous ' + + 'per-principal once authenticated).', + }, + { + path: '/api/webhooks', + prefix: true, + tier: 'tiered', + note: 'Pre-version webhook management surface; same baseline as /api/v1.', + }, + { + path: '/api/analytics', + prefix: true, + tier: 'tiered', + note: 'Pre-version analytics alias; same baseline as /api/v1.', + }, + { + path: '/api/stellar', + prefix: true, + tier: 'tiered', + note: 'Pre-version Stellar alias; same baseline as /api/v1.', + }, + { + path: '/health', + prefix: true, + tier: 'tiered', + note: + 'Liveness and readiness probes. The GLOBAL limiter skips these so ' + + 'k8s probes never consume the budget; the tiered limiter still applies.', + }, + { + path: '/metrics', + prefix: true, + tier: 'tiered', + note: 'Prometheus scrape endpoint, guarded by the internal auth guard.', + }, +] as const + +/** + * The fail-open vs fail-closed decision, stated once so it can be cited. + * + * RUNTIME — fail-OPEN in the sense that no route is ever unlimited: the + * global and tiered limiters are mounted app-wide (src/index.ts), so an + * unmapped route still gets a budget. It is not "fail-open" in the sense of + * letting a request through unchecked. + * + * CI — fail-CLOSED: a route with no matrix entry, or a matrix entry that no + * longer matches a real route, fails the build. The runtime behaviour is a + * safety net; the matrix is the contract, and the contract is enforced. + */ +export const RATE_LIMIT_MATRIX_POLICY = { + runtime: 'fail-open' as const, + ci: 'fail-closed' as const, + rationale: + 'Every route inherits the global + tiered budgets at runtime, so an ' + + 'unmapped route is never unlimited. CI is strict instead, because a ' + + 'silent gap in the matrix is exactly the kind of drift this catalogue ' + + 'exists to prevent.', +} + +// ── Resolution ───────────────────────────────────────────────────────────── + +/** Normalise a path for matching: strip the query, the version prefix and a trailing slash. */ +function normalisePath(path: string): string { + let p = path.split('?')[0] + if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1) + return p || '/' +} + +/** + * Resolve the tier for a request. + * + * Specificity, strongest first: + * 1. an exact path match beats a prefix match + * 2. a longer path beats a shorter one + * 3. at equal path, a method-specific entry beats a path-only one + * + * Returns undefined when nothing matches — the caller (the CI check) treats + * that as a failure. + */ +export function resolveRateLimitTier( + method: string, + path: string +): RateLimitMatrixEntry | undefined { + const target = normalisePath(path) + const verb = method.toUpperCase() + + let best: RateLimitMatrixEntry | undefined + let bestScore = -1 + + for (const entry of RATE_LIMIT_MATRIX) { + const entryPath = normalisePath(entry.path) + + let pathMatches: boolean + if (entry.prefix) { + // A prefix entry covers the path itself and everything under it, but + // only on a path-segment boundary: `/api/v1/keys` must not match + // `/api/v1/keystore`. + pathMatches = + target === entryPath || + (entryPath !== '/' && target.startsWith(entryPath + '/')) + } else { + pathMatches = target === entryPath + } + if (!pathMatches) continue + + // A method-specific entry only applies to that method; a path-only entry + // applies to every method. + if (entry.method && entry.method.toUpperCase() !== verb) continue + + const score = + entryPath.length * 100 + (entry.prefix ? 0 : 10) + (entry.method ? 1 : 0) + if (score > bestScore) { + best = entry + bestScore = score + } + } + + return best +} + +/** The tier for a request, or undefined when the route is not in the matrix. */ +export function tierForRoute( + method: string, + path: string +): RateLimitTierId | undefined { + return resolveRateLimitTier(method, path)?.tier +} + +/** + * Render the matrix as a Markdown table — the source of the table in + * docs/RATE_LIMITING.md. Kept here (rather than hand-written in the doc) so + * the two cannot drift: the doc is generated, the test asserts it is current. + */ +export function renderRateLimitMatrixMarkdown(): string { + const header = '| Route | Method | Tier | Window | Burst | Why |' + const divider = '| --- | --- | --- | --- | --- | --- |' + const rows = RATE_LIMIT_MATRIX.map((entry) => { + const tier = RATE_LIMIT_TIERS[entry.tier] + const window = + entry.tier === 'tiered' + ? `${tier.windowMs}ms / ${AUTHENTICATED_TIER_LIMITS.max} authenticated` + : `${tier.windowMs}ms` + return `| \`${entry.path}\` | ${entry.method ?? 'ANY'} | \`${entry.tier}\` | ${window} | ${tier.max} | ${entry.note} |` + }) + return [header, divider, ...rows].join('\n') +} + +/** Render the tier catalogue as a Markdown table. */ +export function renderRateLimitTierCatalogMarkdown(): string { + const header = '| Tier | Window | Burst | Principal | Auth types | Purpose |' + const divider = '| --- | --- | --- | --- | --- | --- |' + const rows = RATE_LIMIT_TIER_IDS.map((id) => { + const tier = RATE_LIMIT_TIERS[id] + return `| \`${id}\` | ${tier.windowMs}ms | ${tier.max} | ${tier.principal} | ${tier.authTypes.join(', ')} | ${tier.purpose} |` + }) + return [header, divider, ...rows].join('\n') +} diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index 20a0aa11..a179b7af 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -5,6 +5,7 @@ import { recordRateLimitHit, updateRateLimitViolations } from '../utils/metrics' import { logger } from '../utils/logger' import db from '../db' import crypto from 'node:crypto' +import type { RateLimitTierId } from './rateLimitTiers' // ── Trusted-IP / service-token bypass ───────────────────────────────────── @@ -209,6 +210,47 @@ function handleRateLimitExceeded( // ── Rate limiter factory ─────────────────────────────────────────────────── +/** + * The symbol under which a limiter records the tier it belongs to. + * + * A limiter is just a function to Express, so nothing in the routing layer + * says "this route is rate-limited at the sensitive tier". Tagging the + * function makes that fact machine-readable: the tier-matrix CI check + * (tests/unit/middleware/rateLimitTierMatrix.test.ts) walks the real route + * table, reads the tag off each route's middleware chain, and fails if the + * code and the matrix in src/middleware/rateLimitTiers.ts disagree. + */ +export const RATE_LIMIT_TIER_TAG = Symbol.for('neuro.rateLimitTier') + +/** A rate-limiting middleware, tagged with the tier it enforces. */ +export type TaggedRateLimiter = (( + req: Request, + res: Response, + next: NextFunction +) => void) & { + [RATE_LIMIT_TIER_TAG]?: RateLimitTierId +} + +/** Record the tier a limiter enforces. */ +export function tagLimiter( + limiter: (req: Request, res: Response, next: NextFunction) => void, + tier: RateLimitTierId +): TaggedRateLimiter { + ;(limiter as TaggedRateLimiter)[RATE_LIMIT_TIER_TAG] = tier + return limiter as TaggedRateLimiter +} + +/** + * The tier a limiter enforces, or undefined when the handler is not one of + * ours. Used by the tier-matrix CI check to read the effective tier straight + * off a route's middleware chain. + */ +export function limiterTier(handler: unknown): RateLimitTierId | undefined { + if (typeof handler !== 'function') return undefined + const tier = (handler as TaggedRateLimiter)[RATE_LIMIT_TIER_TAG] + return tier +} + export interface BuildRateLimiterOptions { windowMs: number max: number @@ -222,6 +264,12 @@ export interface BuildRateLimiterOptions { * client can rotate IPs freely to escape the budget. */ keyByPrincipal?: boolean + /** + * The catalogue tier this limiter enforces. Required for every limiter so + * the tier matrix stays complete — an untagged limiter is a limiter the + * matrix cannot describe. + */ + tier: RateLimitTierId } /** @@ -232,7 +280,7 @@ export interface BuildRateLimiterOptions { */ export function buildRateLimiter( opts: BuildRateLimiterOptions -): (req: Request, res: Response, next: NextFunction) => void { +): TaggedRateLimiter { const usePrincipal = opts.keyByPrincipal !== false /** @@ -280,12 +328,12 @@ export function buildRateLimiter( }), }) - return (req: Request, res: Response, next: NextFunction): void => { + return tagLimiter((req: Request, res: Response, next: NextFunction): void => { limiter(req, res, () => { applyPolicyHeader(req, res, { max: opts.max, windowMs: opts.windowMs }) next() }) - } + }, opts.tier) } // ── Rate limiters ────────────────────────────────────────────────────────── @@ -299,6 +347,7 @@ export const rateLimiter = buildRateLimiter({ max: config.security.rateLimit.max, skip: skipUnlessLimited, limiterType: 'global', + tier: 'global', }) /** @@ -362,7 +411,7 @@ export const tieredRateLimiter = (() => { }), }) - return (req: Request, res: Response, next: NextFunction): void => { + return tagLimiter((req: Request, res: Response, next: NextFunction): void => { limiter(req, res, () => { applyPolicyHeader(req, res, { max: anonymousMax, @@ -370,7 +419,7 @@ export const tieredRateLimiter = (() => { }) next() }) - } + }, 'tiered') })() /** @@ -386,6 +435,7 @@ export const authRateLimiter = buildRateLimiter({ skip: isTrusted, limiterType: 'auth', keyByPrincipal: false, + tier: 'auth', message: 'Too many authentication attempts. Please try again in 15 minutes.', }) @@ -398,6 +448,7 @@ export const adminRateLimiter = buildRateLimiter({ max: config.security.adminRateLimit.max, skip: isHealthProbe, limiterType: 'admin', + tier: 'admin', message: 'Too many requests to the admin API. Please try again later.', }) @@ -409,7 +460,8 @@ export const erasureRateLimiter = buildRateLimiter({ windowMs: 15 * 60 * 1000, max: 5, skip: isHealthProbe, - limiterType: 'admin', + limiterType: 'erasure', + tier: 'erasure', message: 'Too many erasure operations. Please try again later.', }) @@ -423,6 +475,7 @@ export const webhookRateLimiter = buildRateLimiter({ skip: isTrusted, limiterType: 'webhook', keyByPrincipal: false, + tier: 'webhook', message: 'Too many webhook requests. Please try again later.', }) @@ -439,6 +492,7 @@ export const sensitiveRateLimiter = buildRateLimiter({ max: config.security.sensitiveRateLimit.max, skip: isTrusted, limiterType: 'sensitive', + tier: 'sensitive', message: 'Too many sensitive operations. Please wait before retrying this action.', }) @@ -460,6 +514,7 @@ export const optimizerRateLimiter = buildRateLimiter({ max: config.security.optimizerRateLimit.max, skip: isTrusted, limiterType: 'optimizer', + tier: 'optimizer', message: 'Too many optimization requests. Portfolio optimization is compute-intensive; please try again shortly.', }) @@ -476,6 +531,7 @@ export const simulateRateLimiter = buildRateLimiter({ max: config.security.simulateRateLimit.max, skip: isTrusted, limiterType: 'simulate', + tier: 'simulate', message: 'Too many simulation requests. Historical replay is compute-intensive; please try again shortly.', }) @@ -489,5 +545,6 @@ export const internalRateLimiter = buildRateLimiter({ skip: isTrusted, limiterType: 'internal', keyByPrincipal: false, + tier: 'internal', message: 'Too many requests from this service. Please slow down.', }) diff --git a/src/middleware/rateLimiterChain.ts b/src/middleware/rateLimiterChain.ts new file mode 100644 index 00000000..85f368c3 --- /dev/null +++ b/src/middleware/rateLimiterChain.ts @@ -0,0 +1,26 @@ +/** + * The app-wide rate-limit chain (#614). + * + * These two limiters are mounted before routing, so they apply to EVERY + * request the server serves — including requests that match no route at all. + * Keeping them in one named list (rather than two `app.use` calls inline in + * src/index.ts) means the tier-matrix CI check can read the chain and + * assert the claim "every route is covered by the global + tiered budgets" + * instead of taking it on trust. + * + * global — a flat budget over everything. + * tiered — the identity split: strict per-IP for anonymous callers, + * generous per-principal for callers that proved an identity. + */ + +import { + rateLimiter, + tieredRateLimiter, + type TaggedRateLimiter, +} from './rateLimiter' + +/** Mounted in order: the flat budget, then the identity tier. */ +export const APP_LEVEL_LIMITERS: TaggedRateLimiter[] = [ + rateLimiter, + tieredRateLimiter, +] diff --git a/src/routes/registry.ts b/src/routes/registry.ts new file mode 100644 index 00000000..ad5b92d1 --- /dev/null +++ b/src/routes/registry.ts @@ -0,0 +1,150 @@ +/** + * The route table (#614). + * + * This is the single list of routers the API mounts, extracted from + * src/index.ts so that the tier-matrix CI check + * (tests/unit/middleware/rateLimitTierMatrix.test.ts) can walk the REAL route + * table — every method and path the server actually serves — instead of a + * hand-written list that could fall behind the code. + * + * The legacy unversioned /api/* aliases are mounted from the same table, so a + * path listed here is served at BOTH /api/v1/ and /api/ with the + * same handlers and therefore the same rate-limit tier. + */ + +import { type RequestHandler } from 'express' +import { + adminRateLimiter, + authRateLimiter, + internalRateLimiter, + webhookRateLimiter, +} from '../middleware/rateLimiter' +import agentRouter from './agent' +import agentDecisionsRouter from './agent-decisions' +import adminRouter from './admin' +import adminSupportTicketsRouter from './admin/support-tickets' +import alertsRouter from './alerts' +import analyticsRouter from './analytics' +import approvalPoliciesRouter from './approval-policies' +import approvalsRouter from './approvals' +import assistantRouter from './assistant' +import authRouter from './auth' +import depositRouter from './deposit' +import fiatRouter from './fiat' +import healthRouter from './health' +import keysRouter from './keys' +import liquidityFloorRouter from './liquidity-floor' +import loansRouter from './loans' +import metricsRouter from './metrics' +import netWorthRouter from './net-worth' +import networkRouter from './network' +import notificationDlqRouter from './notification-dlq' +import notificationsRouter from './notifications' +import portfolioRouter from './portfolio' +import protocolsRouter from './protocols' +import protectionFundRouter from './protection-fund' +import recurringDepositRouter from './recurring-deposits' +import recurringWithdrawalRouter from './recurring-withdrawals' +import referralsRouter from './referrals' +import sessionsRouter from './sessions' +import settingsRouter from './settings' +import stellarRouter from './stellar' +import strategiesRouter from './strategies' +import streamRouter from './stream' +import subAccountsRouter from './sub-accounts' +import supportTicketsRouter from './support-tickets' +import telegramRouter from './telegram' +import transactionsRouter from './transactions' +import vaultRouter from './vault' +import webAuthnRouter from './webauthn' +import webhooksRouter from './webhooks' +import whatsappRouter from './whatsapp' +import withdrawRouter from './withdraw' + +export interface ApiRoute { + /** Path relative to the version prefix — e.g. `deposit` → `/api/v1/deposit`. */ + path: string + /** + * The handlers mounted for this path. A rate limiter may appear here, in + * which case it applies to EVERY route under the mount. + */ + handlers: RequestHandler[] +} + +/** + * Routers mounted under the versioned `/api/v1/*` prefix, and their legacy + * `/api/*` aliases. + * + * A limiter in `handlers` applies to every route under that mount. Per-route + * limiters (the `sensitiveRateLimiter` on a single deposit endpoint, say) are + * attached inside the route file itself — the tier matrix reads them off the + * route's own middleware chain. + */ +export const API_ROUTES: ApiRoute[] = [ + { path: 'network', handlers: [networkRouter] }, + { path: 'net-worth', handlers: [netWorthRouter] }, + { path: 'agent/decisions', handlers: [agentDecisionsRouter] }, + { path: 'agent', handlers: [internalRateLimiter, agentRouter] }, + { path: 'auth/webauthn', handlers: [authRateLimiter, webAuthnRouter] }, + { path: 'auth', handlers: [authRateLimiter, authRouter] }, + { path: 'webauthn', handlers: [authRateLimiter, webAuthnRouter] }, + { path: 'whatsapp', handlers: [webhookRateLimiter, whatsappRouter] }, + { path: 'telegram', handlers: [webhookRateLimiter, telegramRouter] }, + { path: 'portfolio', handlers: [portfolioRouter] }, + { path: 'transactions', handlers: [transactionsRouter] }, + { path: 'protocols', handlers: [protocolsRouter] }, + { path: 'deposit', handlers: [depositRouter] }, + { path: 'withdraw', handlers: [withdrawRouter] }, + // #532 — collateral loans. Mounted beside withdraw rather than inside + // portfolio: a loan is its own lifecycle with its own approval and rate + // limits, and it must not inherit portfolio's read-only scope set. + { path: 'loans', handlers: [loansRouter] }, + // #533 — protocol-risk protection fund + { path: 'protection-fund', handlers: [protectionFundRouter] }, + // #534 — user settings (display currency) + { path: 'settings', handlers: [settingsRouter] }, + { path: 'vault', handlers: [vaultRouter] }, + { path: 'analytics', handlers: [analyticsRouter] }, + { path: 'stellar', handlers: [stellarRouter] }, + { path: 'fiat', handlers: [fiatRouter] }, + { path: 'referrals', handlers: [referralsRouter] }, + { path: 'deposit/recurring', handlers: [recurringDepositRouter] }, + { path: 'recurring-withdrawals', handlers: [recurringWithdrawalRouter] }, + { path: 'alerts', handlers: [alertsRouter] }, + { path: 'strategies', handlers: [strategiesRouter] }, + { path: 'sub-accounts', handlers: [subAccountsRouter] }, + { path: 'support/tickets', handlers: [supportTicketsRouter] }, + { + path: 'admin/support/tickets', + handlers: [adminRateLimiter, adminSupportTicketsRouter], + }, + { path: 'assistant', handlers: [assistantRouter] }, + { path: 'approvals', handlers: [approvalsRouter] }, + { path: 'approval-policies', handlers: [approvalPoliciesRouter] }, + { path: 'keys', handlers: [keysRouter] }, + { path: 'sessions', handlers: [sessionsRouter] }, + { path: 'stream', handlers: [streamRouter] }, + { path: 'notifications', handlers: [notificationsRouter] }, + { path: 'liquidity-floor', handlers: [liquidityFloorRouter] }, + { + path: 'admin/notifications/dlq', + handlers: [adminRateLimiter, notificationDlqRouter], + }, + { path: 'admin', handlers: [adminRateLimiter, adminRouter] }, +] + +/** + * Routers mounted OUTSIDE the `/api/v1` version scheme. + * + * These are either operational endpoints (health, metrics) or the pre-version + * paths that predate the versioning policy. They are listed here so the tier + * matrix can describe them too — an endpoint that no one can find in the + * matrix is an endpoint nobody knows the budget of. + */ +export const EXTRA_MOUNTS: ApiRoute[] = [ + { path: '/health', handlers: [healthRouter] }, + { path: '/api/analytics', handlers: [analyticsRouter] }, + { path: '/api/stellar', handlers: [stellarRouter] }, + { path: '/api/webhooks', handlers: [webhooksRouter] }, + { path: '/metrics', handlers: [metricsRouter] }, +] diff --git a/tests/helpers/routeTable.ts b/tests/helpers/routeTable.ts new file mode 100644 index 00000000..ba1bd62d --- /dev/null +++ b/tests/helpers/routeTable.ts @@ -0,0 +1,118 @@ +/** + * Route-table introspection for the tier-matrix CI check (#614). + * + * Express 4 has no public "list my routes" API, so the check walks the + * router stack directly. This is the only place that knows how to do that, + * so the walk stays in one file and every consumer sees the same route + * shapes. + * + * The paths produced here are Express's own route syntax — `:id` for a + * parameter — and they are the paths the tier matrix in + * src/middleware/rateLimitTiers.ts is written against. + */ + +import { type RequestHandler } from 'express' +import { limiterTier } from '../../src/middleware/rateLimiter' +import type { RateLimitTierId } from '../../src/middleware/rateLimitTiers' + +export interface DiscoveredRoute { + method: string + /** The full path, e.g. `/api/v1/keys/:id/rotate`. */ + path: string + /** + * The tier the CODE applies to this route: the route's own limiter if it + * has one, otherwise the mount-level limiter, otherwise the app-wide + * baseline (`tiered`). + */ + tier: RateLimitTierId + /** Where the tier came from — useful when a test fails. */ + tierSource: 'route' | 'mount' | 'baseline' +} + +/** + * Turn an Express 4 path regexp back into a readable path. + * + * Only needed for `router.use(path, subRouter)` mounts, where Express does + * not keep the original path string on the layer. Route layers expose + * `layer.route.path` directly, so this is only the fallback. + */ +function pathFromRegexp(source: string): string { + let s = source + s = s.replace(/^\^/, '') + s = s.replace(/\\\/\?\(\?=\\\/\|\$\)$/, '') + s = s.replace(/\\\//g, '/') + s = s.replace(/\(\?:[^)]*\)/g, ':p') + s = s.replace(/\([^)]*\)/g, ':p') + return s || '/' +} + +/** Normalise a path for comparison: no trailing slash, except for the root. */ +export function normaliseRoutePath(path: string): string { + const p = path.split('?')[0] + if (p.length > 1 && p.endsWith('/')) return p.slice(0, -1) + return p || '/' +} + +function walkStack( + stack: any[], + prefix: string, + mountTier: RateLimitTierId | undefined, + out: DiscoveredRoute[] +): void { + for (const layer of stack) { + if (layer.route) { + const methods = Object.keys(layer.route.methods || {}) + for (const method of methods) { + let tier = mountTier + let tierSource: DiscoveredRoute['tierSource'] = mountTier + ? 'mount' + : 'baseline' + + for (const handle of layer.route.stack || []) { + const routeTier = limiterTier(handle.handle) + if (routeTier) { + tier = routeTier + tierSource = 'route' + } + } + + out.push({ + method: method.toUpperCase(), + path: normaliseRoutePath(prefix + (layer.route.path || '')), + tier: tier ?? 'tiered', + tierSource, + }) + } + } else if (layer.name === 'router' && layer.handle?.stack) { + const nested = pathFromRegexp(layer.regexp.source) + walkStack( + layer.handle.stack, + prefix + (nested === '/' ? '' : nested), + mountTier, + out + ) + } + } +} + +/** + * Walk a mounted router and return every route it serves, with the tier the + * code applies to it. + */ +export function discoverRoutes( + mountPath: string, + handlers: RequestHandler[] +): DiscoveredRoute[] { + let mountTier: RateLimitTierId | undefined + for (const handler of handlers) { + const tier = limiterTier(handler) + if (tier) mountTier = tier + } + + const router = handlers[handlers.length - 1] as unknown as { + stack?: any[] + } + const out: DiscoveredRoute[] = [] + walkStack(router.stack ?? [], mountPath, mountTier, out) + return out +} diff --git a/tests/integration/rateLimiter.integration.test.ts b/tests/integration/rateLimiter.integration.test.ts index 79fd3cf7..a16f26ca 100644 --- a/tests/integration/rateLimiter.integration.test.ts +++ b/tests/integration/rateLimiter.integration.test.ts @@ -66,6 +66,7 @@ describe('rate limiter – IETF rate-limit headers', () => { windowMs: 60000, max: 30, limiterType: 'test', + tier: 'global', }) const app = buildTestApp(limiter) const res = await request(app).get('/test') @@ -80,6 +81,7 @@ describe('rate limiter – IETF rate-limit headers', () => { windowMs: 60000, max: 1, limiterType: 'test', + tier: 'global', }) const app = buildTestApp(limiter) @@ -95,6 +97,7 @@ describe('rate limiter – IETF rate-limit headers', () => { windowMs: 60000, max: 1, limiterType: 'test', + tier: 'global', }) const app = buildTestApp(limiter) @@ -113,6 +116,7 @@ describe('rate limiter – IETF rate-limit headers', () => { windowMs: 60000, max: 1, limiterType: 'test', + tier: 'global', }) const app = buildTestApp(limiter) diff --git a/tests/integration/rateLimiterTiering.integration.test.ts b/tests/integration/rateLimiterTiering.integration.test.ts index 820b01c9..6321c133 100644 --- a/tests/integration/rateLimiterTiering.integration.test.ts +++ b/tests/integration/rateLimiterTiering.integration.test.ts @@ -341,6 +341,7 @@ describe('route grouping', () => { windowMs: 900000, max: 1, limiterType: 'test', + tier: 'global', }) const app = appWith(limiter) await request(app).get('/api/v1/portfolio') diff --git a/tests/unit/middleware/rateLimitTierMatrix.test.ts b/tests/unit/middleware/rateLimitTierMatrix.test.ts new file mode 100644 index 00000000..903f63cd --- /dev/null +++ b/tests/unit/middleware/rateLimitTierMatrix.test.ts @@ -0,0 +1,220 @@ +/** + * The rate-limit tier matrix must describe the API that actually ships (#614). + * + * This is the CI half of the issue: "every mounted route has an explicit tier + * entry". It fails when + * + * - a mounted route resolves to NO matrix entry (an undocumented budget), or + * - the matrix says a route is in one tier while the code mounts a limiter + * from another (the table is lying), or + * - a matrix entry no longer matches any real route (the table has rotted), + * - a limiter exists that the tier catalogue does not describe, or + * - docs/RATE_LIMITING.md is not the table this module generates. + * + * The failure mode is deliberately loud: a silent gap in the matrix is + * exactly the drift the matrix exists to prevent. + */ + +import fs from 'fs' +import path from 'path' +import { API_ROUTES, EXTRA_MOUNTS } from '../../../src/routes/registry' +import { discoverRoutes } from '../../helpers/routeTable' +import { + RATE_LIMIT_MATRIX, + RATE_LIMIT_TIERS, + RATE_LIMIT_TIER_IDS, + resolveRateLimitTier, + renderRateLimitMatrixMarkdown, + renderRateLimitTierCatalogMarkdown, +} from '../../../src/middleware/rateLimitTiers' +import { + RATE_LIMIT_TIER_TAG, + limiterTier, +} from '../../../src/middleware/rateLimiter' +import { APP_LEVEL_LIMITERS } from '../../../src/middleware/rateLimiterChain' + +const ROOT = path.join(__dirname, '../../..') +const DOC_PATH = path.join(ROOT, 'docs', 'RATE_LIMITING.md') + +/** Every route the server serves, with the tier the code applies to it. */ +const ROUTES = [ + ...API_ROUTES.flatMap((r) => discoverRoutes(`/api/v1/${r.path}`, r.handlers)), + ...EXTRA_MOUNTS.flatMap((r) => discoverRoutes(r.path, r.handlers)), +].sort( + (a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method) +) + +describe('rate-limit tier matrix (#614)', () => { + it('discovers the real route table', () => { + // A silent walk failure would make every other assertion in this file + // pass vacuously, so pin the shape of what was found. + expect(ROUTES.length).toBeGreaterThan(100) + expect(ROUTES.some((r) => r.path === '/api/v1/deposit')).toBe(true) + expect(ROUTES.some((r) => r.path === '/api/v1/keys/:id/rotate')).toBe(true) + }) + + it('gives every mounted route an explicit tier entry', () => { + const unmapped = ROUTES.filter( + (r) => resolveRateLimitTier(r.method, r.path) === undefined + ) + expect(unmapped.map((r) => `${r.method} ${r.path}`).join('\n')).toBe('') + }) + + it('agrees with the limiter the code actually mounts', () => { + const drifted = ROUTES.filter((r) => { + const entry = resolveRateLimitTier(r.method, r.path) + return entry !== undefined && entry.tier !== r.tier + }) + expect( + drifted + .map( + (r) => + `${r.method} ${r.path} — matrix says "${ + resolveRateLimitTier(r.method, r.path)?.tier + }", code mounts "${r.tier}" (${r.tierSource})` + ) + .join('\n') + ).toBe('') + }) + + it('has no matrix entry that no longer matches a real route', () => { + const dead = RATE_LIMIT_MATRIX.filter((entry) => { + return !ROUTES.some( + (r) => + r.method === (entry.method ?? r.method) && + resolveRateLimitTier(r.method, r.path) === entry + ) + }) + expect(dead.map((e) => `${e.method ?? 'ANY'} ${e.path}`).join('\n')).toBe( + '' + ) + }) + + it('covers every route with the app-wide global + tiered budgets', () => { + // The runtime half of the fail-open policy: no route is ever unlimited. + const appTiers = APP_LEVEL_LIMITERS.map((l) => limiterTier(l)) + expect(appTiers).toContain('global') + expect(appTiers).toContain('tiered') + }) + + it('describes every tier it uses, and uses every tier it describes', () => { + const usedTiers = new Set([ + ...APP_LEVEL_LIMITERS.map((l) => limiterTier(l)!).filter(Boolean), + ...ROUTES.map((r) => r.tier), + ]) + for (const tier of usedTiers) { + expect( + RATE_LIMIT_TIERS[tier as keyof typeof RATE_LIMIT_TIERS] + ).toBeDefined() + } + // A tier nothing routes to is a tier the matrix documents but the code + // never applies — usually a renamed or removed limiter. + for (const id of RATE_LIMIT_TIER_IDS) { + expect(usedTiers.has(id)).toBe(true) + } + }) + + it('tags every limiter the code mounts', () => { + // A limiter with no tier tag is a limiter the matrix cannot describe. + const limiters = [ + ...API_ROUTES.flatMap((r) => r.handlers), + ...EXTRA_MOUNTS.flatMap((r) => r.handlers), + ] + const untagged = limiters.filter( + (h) => typeof h === 'function' && limiterTier(h) === undefined + ) + // The routers themselves are handlers too, so only the ones that are + // actual limiters matter — those are the ones in `handlers` before the + // router. Handled by the mount-level tier in the route walk above. + expect(untagged.length).toBeGreaterThan(0) // routers are untagged + for (const handler of limiters) { + if (typeof handler !== 'function') continue + if ((handler as any)[RATE_LIMIT_TIER_TAG] !== undefined) { + expect(RATE_LIMIT_TIERS[limiterTier(handler)!]).toBeDefined() + } + } + }) + + it('documents the fail-open / fail-closed decision', () => { + // The policy is part of the contract, not prose in a comment. + const { + RATE_LIMIT_MATRIX_POLICY, + } = require('../../../src/middleware/rateLimitTiers') + expect(RATE_LIMIT_MATRIX_POLICY.runtime).toBe('fail-open') + expect(RATE_LIMIT_MATRIX_POLICY.ci).toBe('fail-closed') + expect(RATE_LIMIT_MATRIX_POLICY.rationale.length).toBeGreaterThan(20) + }) +}) + +describe('rate-limit tier resolution', () => { + it('maps an auth-type to its tier', () => { + expect(resolveRateLimitTier('POST', '/api/v1/auth/login')?.tier).toBe( + 'auth' + ) + expect( + resolveRateLimitTier('GET', '/api/v1/webauthn/credentials')?.tier + ).toBe('auth') + }) + + it('does not let an exact money-movement entry swallow a sibling router', () => { + // /api/v1/deposit is one money-moving endpoint; /api/v1/deposit/recurring + // is a separate router. A prefix match would put the recurring routes in + // the sensitive tier by accident. + expect(resolveRateLimitTier('POST', '/api/v1/deposit')?.tier).toBe( + 'sensitive' + ) + expect( + resolveRateLimitTier('POST', '/api/v1/deposit/recurring')?.tier + ).toBe('tiered') + }) + + it('applies a method-specific entry only to that method', () => { + expect(resolveRateLimitTier('POST', '/api/v1/keys')?.tier).toBe('sensitive') + expect(resolveRateLimitTier('GET', '/api/v1/keys')?.tier).toBe('tiered') + expect(resolveRateLimitTier('DELETE', '/api/v1/keys/:id')?.tier).toBe( + 'tiered' + ) + }) + + it('prefers an exact erasure entry over the admin prefix', () => { + expect( + resolveRateLimitTier('POST', '/api/v1/admin/erasure/requests')?.tier + ).toBe('erasure') + // The read-only list of erasure requests is an ordinary admin read. + expect( + resolveRateLimitTier('GET', '/api/v1/admin/erasure/requests')?.tier + ).toBe('admin') + }) + + it('does not match a prefix entry across a path-segment boundary', () => { + // /api/v1/keys must not capture /api/v1/keystore. + expect(resolveRateLimitTier('GET', '/api/v1/keystore')?.tier).toBe('tiered') + }) +}) + +describe('docs/RATE_LIMITING.md (#614)', () => { + const doc = fs.existsSync(DOC_PATH) ? fs.readFileSync(DOC_PATH, 'utf8') : '' + + it('exists', () => { + expect(fs.existsSync(DOC_PATH)).toBe(true) + }) + + it('contains the generated route → tier matrix', () => { + expect(doc).toContain(renderRateLimitMatrixMarkdown()) + }) + + it('contains the generated tier catalogue', () => { + expect(doc).toContain(renderRateLimitTierCatalogMarkdown()) + }) + + it('documents which auth type maps to which tier', () => { + for (const authType of ['session', 'api_key', 'admin', 'webhook']) { + expect(doc).toContain(authType) + } + }) + + it('documents the fail-open / fail-closed decision', () => { + expect(doc).toMatch(/fail-open/i) + expect(doc).toMatch(/fail-closed/i) + }) +})