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>
304 lines
11 KiB
Markdown
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 |
|