diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 7e3edbf..c280935 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 0000000..2219638 --- /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 323bb00..928c53d 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 0000000..b503e53 --- /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 96d82fb..e3212c7 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 0000000..77faf6e --- /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 20a0aa1..a179b7a 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 0000000..85f368c --- /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 0000000..ad5b92d --- /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 0000000..ba1bd62 --- /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 79fd3cf..a16f26c 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 820b01c..6321c13 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 0000000..903f63c --- /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) + }) +})