diff --git a/docs/guides/rate-limiting.md b/docs/guides/rate-limiting.md new file mode 100644 index 0000000..24061cc --- /dev/null +++ b/docs/guides/rate-limiting.md @@ -0,0 +1,303 @@ +# 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: + +``` +:: +``` + +| Segment | Example | Meaning | +| ----------------- | ------------- | -------------------------------------------- | +| `` | `signIn` | Use-case or feature slug (camelCase) | +| `` | `ip` | Budget name — matches `rateLimit[].name` | +| `` | `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//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//src/integrations/api/procedures.ts +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; +import { TooManyRequestsError } from "../../entities/errors/"; + +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//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` | `@repo/core-shared/conformance` | Phantom brand type; confirms rate-limit wrapping | diff --git a/docs/guides/security-headers.md b/docs/guides/security-headers.md new file mode 100644 index 0000000..143c75e --- /dev/null +++ b/docs/guides/security-headers.md @@ -0,0 +1,378 @@ +# Security headers cookbook + +Every response from every app emits six security headers and a per-request CSP nonce. This guide walks the wiring for each framework, shows how consumer code threads the nonce into inline scripts, explains CSP allowlist customisation, and covers Sentry nonce integration and verification. + +--- + +## The six headers + +`buildSecurityHeaders(opts: SecurityHeadersConfig)` from `@repo/core-shared/security` always emits: + +| Header | Value | +| --------------------------- | -------------------------------------------------- | +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` | +| `X-Frame-Options` | `DENY` | +| `X-Content-Type-Options` | `nosniff` | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | +| `Content-Security-Policy` | mode-dependent (see [CSP modes](#csp-modes) below) | + +`x-nonce` is also forwarded as an internal request header so server components can retrieve the nonce without another round trip. + +--- + +## CSP modes + +### `prod` — strict-dynamic + nonce + +``` +default-src 'self'; +script-src 'strict-dynamic' 'nonce-{NONCE}'; +style-src 'self' 'unsafe-inline'; +img-src 'self' data: {ALLOWED_IMG_ORIGINS}; +font-src 'self' {ALLOWED_FONT_ORIGINS}; +connect-src 'self' {ALLOWED_CONNECT_ORIGINS}; +frame-ancestors 'none'; +base-uri 'self'; +form-action 'self'; +object-src 'none'; +``` + +`strict-dynamic` allows scripts loaded by a nonce-bearing script to run without listing each origin explicitly. + +### `dev` — permissive for local tooling + +``` +default-src 'self'; +script-src 'unsafe-inline' 'unsafe-eval'; +style-src 'self' 'unsafe-inline'; +img-src 'self' data: {ALLOWED_IMG_ORIGINS}; +font-src 'self' {ALLOWED_FONT_ORIGINS}; +connect-src 'self' ws: localhost:* 127.0.0.1:* {ALLOWED_CONNECT_ORIGINS}; +frame-ancestors 'none'; +base-uri 'self'; +form-action 'self'; +object-src 'none'; +``` + +`unsafe-inline` / `unsafe-eval` permit Vite HMR and React DevTools. The `ws:` and `localhost:*` entries allow HMR websockets and local API calls. These never appear in `prod`. + +Both modes are selected automatically from `NODE_ENV`. No manual config is required. + +--- + +## Per-framework wiring + +### Next.js (`apps/web-next`) + +Create or update `middleware.ts` at the app root: + +```ts +// apps/web-next/middleware.ts +import { withSecurityHeaders } from "@repo/core-shared/security/next"; +import type { NextRequest } from "next/server"; + +export function middleware(request: NextRequest) { + return withSecurityHeaders(request); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; +``` + +`withSecurityHeaders` generates a fresh nonce per request, sets all six headers on the response, and forwards the nonce in both the downstream request headers (`x-nonce`) and the response headers so server components can read it. + +### TanStack Start (`apps/web-tanstack`) + +Security headers are applied via a Nitro/H3 server hook in `app.config.ts`: + +```ts +// apps/web-tanstack/app.config.ts +import { defineConfig } from "@tanstack/start/config"; +import { withSecurityHeaders } from "@repo/core-shared/security/tanstack"; + +interface H3SecurityEvent { + node: { + req: { headers: Record }; + res: { setHeader: (name: string, value: string) => void }; + }; +} + +function applySecurityHeaders(event: H3SecurityEvent): void { + const { nonce, headers } = withSecurityHeaders(); + for (const [k, v] of Object.entries(headers)) { + event.node.res.setHeader(k, v); + } + event.node.req.headers["x-nonce"] = nonce; +} + +export default defineConfig({ + server: { hooks: { request: applySecurityHeaders } }, +}); +``` + +`withSecurityHeaders()` returns `{ nonce, headers }`. The hook applies the headers to the response and stores the nonce on the request so `getNonce(req)` can read it from any server loader. + +### Payload CMS (`apps/cms`) + +The CMS is server-rendered without client-side JavaScript hydration, so no nonce is needed: + +```ts +// apps/cms/middleware.ts +import { buildSecurityHeaders } from "@repo/core-shared/security"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export function middleware(_request: NextRequest): NextResponse { + const mode = process.env.NODE_ENV === "production" ? "prod" : "dev"; + const secHeaders = buildSecurityHeaders({ mode }); + + const response = NextResponse.next(); + for (const [name, value] of Object.entries(secHeaders)) { + response.headers.set(name, value); + } + return response; +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; +``` + +--- + +## Nonce threading for inline scripts + +### Reading the nonce on the server + +**Next.js** — `getNonce()` reads the `x-nonce` header injected by `withSecurityHeaders`: + +```ts +import { getNonce } from "@repo/core-shared/security/next"; + +// Inside a Server Component or route handler: +const nonce = await getNonce(); +``` + +**TanStack Start** — `getNonce(req)` reads from the H3 request: + +```ts +import { getNonce } from "@repo/core-shared/security/tanstack"; +import { getEvent } from "vinxi/http"; + +// Inside a loader: +const nonce = getNonce(getEvent().node.req); +``` + +### Exposing the nonce to the browser + +Expose the nonce via a `` tag so client-side code can read it without re-fetching: + +**Next.js root layout:** + +```tsx +// apps/web-next/src/app/layout.tsx +import { getNonce } from "@repo/core-shared/security/next"; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + const nonce = await getNonce(); + return ( + + + + + {children} + + ); +} +``` + +**TanStack Start root route:** + +```tsx +// apps/web-tanstack/src/routes/__root.tsx +import { getNonce } from "@repo/core-shared/security/tanstack"; +import { createRootRoute, Outlet } from "@tanstack/react-router"; + +export const Route = createRootRoute({ + loader: async () => { + try { + const { getEvent } = await import("vinxi/http"); + return { nonce: getNonce(getEvent().node.req) }; + } catch { + return { nonce: "" }; // client-side navigation — nonce already in DOM + } + }, + component: () => { + const { nonce } = Route.useLoaderData(); + return ( + <> + + + + ); + }, +}); +``` + +### Using the nonce in your inline scripts + +Pass the nonce as the `nonce` attribute on any `