Files
agentic-dev-template/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

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 in feature.manifest.tsundeclared 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