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>
11 KiB
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:
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:
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.
// Manifest
rateLimit: [
{ name: "ip", window: "1m", budget: 5 },
{ name: "account", window: "1h", budget: 10 },
],
// 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:
// Manifest
rateLimit: [
{ name: "user", window: "1d", budget: 50 },
],
// 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):
// 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:
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:
// 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.
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:
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:
// 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:
// 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 infeature.manifest.ts→ undeclared budget - A manifest entry declares
{ name: "X" }but the use-case body never callsrateLimit.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 |