Files
agentic-dev/docs/guides/rate-limiting.md
Danijel Martinek 1fa11fec83 docs(security): add security-headers and rate-limiting cookbooks
Adds two new consumer-facing guides:
- docs/guides/security-headers.md: per-framework middleware wiring
  (Next.js, TanStack Start, Payload CMS), nonce threading for inline
  scripts, CSP allowlist customisation, Sentry nonce integration, and
  securityheaders.com verification workflow.
- docs/guides/rate-limiting.md: manifest rateLimit field declaration,
  canonical key-naming convention (<feature>:<scope>:<key>),
  multi-budget patterns, InMemoryRateLimit / NoopRateLimit for dev/test,
  and production backend wiring via BindContext.rateLimit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:39:56 +00:00

304 lines
11 KiB
Markdown

# Rate limiting cookbook
Rate limiting is declared in the feature manifest, enforced at the use-case level via `IRateLimit`, and verified at boot time by `assertFeatureConformance`. This guide covers the manifest declaration, key-naming convention, multi-budget patterns, and backend wiring for each environment.
---
## How it fits together
```
feature.manifest.ts
└── rateLimit: [{ name, window, budget }, ...]
wireUseCase({ rateLimit: ctx.rateLimit ?? new NoopRateLimit() })
└── withRateLimit(rateLimit, factory(deps)) ← attaches __rateLimited brand
assertFeatureConformance(container, manifest, symbols, ctx)
└── checks __rateLimited brand when manifest.rateLimit.length > 0
```
The conformance rule `no-undeclared-rate-limit` (ESLint, warn severity) verifies that every `rateLimit.consume("X", …)` call in a use-case file has a matching `{ name: "X" }` budget in the manifest, and that every declared budget is actually consumed.
---
## Manifest field declaration
Add `rateLimit` to the use-case entry inside `feature.manifest.ts`:
```ts
import { defineFeature } from "@repo/core-shared/conformance";
export const fooManifest = defineFeature({
name: "foo",
requiredCores: [],
useCases: {
submitOrder: {
mutates: true,
audits: [],
publishes: [],
consumes: [],
rateLimit: [
{ name: "ip", window: "1m", budget: 10 },
{ name: "user", window: "1h", budget: 100 },
],
},
},
realtimeChannels: [],
jobs: [],
} as const);
```
### `RateLimitBudget` fields
| Field | Type | Meaning |
| -------- | -------- | ---------------------------------------------------------------------- |
| `name` | `string` | Budget identifier; matches the first argument of `rateLimit.consume()` |
| `window` | `string` | Rolling window duration. Accepted units: `ms`, `s`, `m`, `h`, `d` |
| `budget` | `number` | Maximum requests (weight units) allowed within the window |
Window string examples: `"500ms"`, `"30s"`, `"5m"`, `"2h"`, `"1d"`.
Omitting `rateLimit` (or setting it to an empty array) means the use case is not rate-limited. `wireUseCase` still wraps it in `NoopRateLimit` so the slot type is always satisfied.
---
## Key-naming convention
Keys partition the budget across distinct entities. Use the pattern:
```
<feature>:<scope>:<discriminator>
```
| Segment | Example | Meaning |
| ----------------- | ------------- | -------------------------------------------- |
| `<feature>` | `signIn` | Use-case or feature slug (camelCase) |
| `<scope>` | `ip` | Budget name — matches `rateLimit[].name` |
| `<discriminator>` | `203.0.113.5` | Per-entity value (IP address, user ID, etc.) |
Canonical example from `auth/sign-in.use-case.ts`:
```ts
const { allowed: ipAllowed } = await rateLimit.consume(
"ip",
`signIn:ip:${input.clientIp ?? ""}`,
);
if (!ipAllowed) throw new TooManyRequestsError("Too many sign-in attempts");
const { allowed: accountAllowed } = await rateLimit.consume(
"account",
`signIn:account:${input.username}`,
);
if (!accountAllowed)
throw new TooManyRequestsError("Too many sign-in attempts");
```
**Do not** use bare IPs or usernames as keys — include the feature and scope prefix so buckets from different use cases never collide in shared backends.
---
## Multi-budget patterns
### IP + account (credential-stuffing defence)
Two independent budgets: one throttles by source IP, the other by target account. A single attacker cycling IPs can still be blocked by the account budget; many attackers hitting one account are caught by the per-IP budget.
```ts
// Manifest
rateLimit: [
{ name: "ip", window: "1m", budget: 5 },
{ name: "account", window: "1h", budget: 10 },
],
```
```ts
// Use case body — check IP first (cheaper lookup)
const { allowed: ipOk } = await rateLimit.consume(
"ip",
`signIn:ip:${input.clientIp ?? ""}`,
);
if (!ipOk) throw new TooManyRequestsError("Too many sign-in attempts");
const { allowed: accountOk } = await rateLimit.consume(
"account",
`signIn:account:${input.username}`,
);
if (!accountOk) throw new TooManyRequestsError("Too many sign-in attempts");
```
### Per-user action quota
One budget limits how many times a single authenticated user can trigger an action per day:
```ts
// Manifest
rateLimit: [
{ name: "user", window: "1d", budget: 50 },
],
```
```ts
// Use case body
const { allowed } = await rateLimit.consume(
"user",
`exportReport:user:${input.userId}`,
);
if (!allowed) throw new TooManyRequestsError("Daily export limit reached");
```
### Weighted consume
Pass a `weight` argument to consume multiple budget units in one call (e.g., bulk operations):
```ts
// Costs 5 budget units instead of 1
const { allowed } = await rateLimit.consume(
"user",
`sendEmails:user:${input.userId}`,
input.recipients.length,
);
```
The weight defaults to `1` when omitted.
---
## Throwing on rate-limit exceeded
Throw `TooManyRequestsError` from `@repo/<feature>/entities/errors`. The tRPC error middleware maps this to HTTP 429 via the feature's `xProcedure`:
```ts
import { TooManyRequestsError } from "../../entities/errors/auth";
if (!allowed) throw new TooManyRequestsError("Too many sign-in attempts");
```
Declare `TooManyRequestsError` in the feature's error file and register it in `integrations/api/procedures.ts`:
```ts
// packages/<feature>/src/integrations/api/procedures.ts
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { TooManyRequestsError } from "../../entities/errors/<feature>";
export const featureProcedure = t.procedure.use(
defineErrorMiddleware([
[TooManyRequestsError, "TOO_MANY_REQUESTS"],
// …other error → tRPC code mappings
]),
);
```
---
## Wiring a rate-limit backend
### Dev / test — `InMemoryRateLimit`
`InMemoryRateLimit` is a single-process, Map-backed implementation suitable for local development and unit tests. Buckets live only in memory — they reset on process restart.
```ts
import { InMemoryRateLimit } from "@repo/core-shared/rate-limit";
const rateLimit = new InMemoryRateLimit([
{ name: "ip", window: "1m", budget: 5 },
{ name: "account", window: "1h", budget: 10 },
]);
```
Pass the same budget declarations as the manifest so dev behaviour matches production.
### Default (no backend) — `NoopRateLimit`
`NoopRateLimit` always allows every request (`allowed: true`, `remaining: Infinity`). It is the default when `ctx.rateLimit` is absent, so features boot cleanly without a rate-limit backend wired:
```ts
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
const rateLimit = ctx.rateLimit ?? new NoopRateLimit();
```
Use `NoopRateLimit` in unit tests that exercise the use-case logic but do not need to test throttling behaviour. Use `RecordingRateLimit` from `@repo/core-testing/rate-limit` in tests that must assert on `consume` / `reset` call counts.
### Production — external backend via `IRateLimit`
Wire a production backend by implementing `IRateLimit` and passing the instance through `ctx.rateLimit` in the app's `bindAll` aggregator:
```ts
// apps/web-next/src/server/bind-production.ts (excerpt)
import { RedisRateLimit } from "@repo/<your-adapter>/rate-limit"; // your implementation
const rateLimit = new RedisRateLimit(redisClient, {
/* budget table loaded from manifest or config */
});
const ctx: BindProductionContext = {
config: resolvedConfig,
tracer,
logger,
queue,
rateLimit, // passed to all feature binders
};
bindProductionAuth(ctx);
// …other features
```
Every feature binder receives the same `IRateLimit` instance via `ctx.rateLimit`. Feature binders that declare `rateLimit` budgets in their manifest pass the instance to the use-case factory:
```ts
// packages/auth/src/di/bind-production.ts (excerpt)
const wrappedSignIn = wireUseCase({
container: authContainer,
symbol: AUTH_SYMBOLS.ISignInUseCase,
factory: signInUseCase,
deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()],
feature: "auth",
layer: "use-case",
name: "signIn",
tracer,
logger,
rateLimit: ctx.rateLimit ?? new NoopRateLimit(), // for brand attachment
});
```
`wireUseCase` wraps the factory output with `withRateLimit(rateLimit, fn)` and attaches the `__rateLimited` brand, which `assertFeatureConformance` checks at boot.
### Environment strategy summary
| Environment | Recommended backend | How to wire |
| ---------------- | ----------------------------------------------------- | ----------------------------------------------------- |
| Unit tests | `NoopRateLimit` | Inject directly: `signInUseCase(repo, auth, noop)(…)` |
| Rate-limit tests | `RecordingRateLimit` | Inject directly; assert `.calls` |
| `pnpm dev` | `NoopRateLimit` | Default in `bindAllDevSeed` via `ctx.rateLimit` |
| Staging / prod | `InMemoryRateLimit` or external Redis/Upstash adapter | Set `ctx.rateLimit` in `bindAllProduction` |
---
## Conformance gate
The ESLint rule `conformance/no-undeclared-rate-limit` (warn) fires when:
- A use-case file calls `rateLimit.consume("X", …)` but `"X"` is not in `feature.manifest.ts`**undeclared budget**
- A manifest entry declares `{ name: "X" }` but the use-case body never calls `rateLimit.consume("X", …)`**unused declaration**
Fix by keeping the `rateLimit` array in the manifest in sync with the `rateLimit.consume()` calls in the factory body.
The boot-time assertion (`assertFeatureConformance`) also requires the `__rateLimited` brand when `rateLimit.length > 0` — the dev server refuses to start if the brand is missing.
---
## API surface quick-reference
| Export | Package path | Purpose |
| -------------------- | ------------------------------- | -------------------------------------------------------- |
| `IRateLimit` | `@repo/core-shared/rate-limit` | Protocol interface for all backends |
| `RateLimitBudget` | `@repo/core-shared/rate-limit` | Manifest budget descriptor `{ name, window, budget }` |
| `RateLimitDecision` | `@repo/core-shared/rate-limit` | Result of `consume()`: `{ allowed, remaining, resetAt }` |
| `InMemoryRateLimit` | `@repo/core-shared/rate-limit` | Single-process Map-backed implementation |
| `NoopRateLimit` | `@repo/core-shared/rate-limit` | Always-allow stub (dev default) |
| `withRateLimit` | `@repo/core-shared/rate-limit` | DI wrapper; attaches `__rateLimited` brand |
| `RecordingRateLimit` | `@repo/core-testing/rate-limit` | Test helper; records `consume` / `reset` calls |
| `RateLimited<F>` | `@repo/core-shared/conformance` | Phantom brand type; confirms rate-limit wrapping |