---
id: security-headers-rate-limit-sbom
title: Security headers + rate-limit primitive + SBOM in CI — Epic C of ADR-025
type: prd
status: approved
author: danijel
created: 2026-05-19T11:05:39Z
updated: 2026-05-19T11:09:21.438Z
---

## Problem

Three load-bearing security primitives the playbook §5, §6, §13 require are missing from the template:

- **Security headers** — no app in the template ships HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, or CSP by default. The playbook calls these "mandatory"; security scanners (securityheaders.com, Mozilla Observatory) flag the absence on first scan. Every consumer reinvents the middleware, and getting CSP right with nonces + Sentry replay + Tailwind inline styles is non-trivial enough that most consumers ship `'unsafe-inline'` and call it done.
- **Rate-limit primitive** — no template surface for "this endpoint is rate-limited." Auth use cases (signIn, signUp) ship without rate-limit declarations; credential-stuffing and account-enumeration windows stay open until a real consumer notices their auth logs. The playbook §5 says "all authentication endpoints rate-limited per IP and per account" — declaratively impossible today.
- **SBOM** — no Software Bill of Materials generated on release. Consumers pursuing SOC 2 / ISO 27001 / FedRAMP / EU CRA evidence have to invent SBOM tooling and bolt it into their release flow. Audit-prep tax compounds across every consumer.

ADR-025 settled the strategy: framework-agnostic header middleware in `core-shared/security`, rate-limit as fourth conformance channel in `core-shared/rate-limit`, SBOM via `cyclonedx-npm` in `release-please.yml`. This PRD is the implementation seed for Epic C.

## Goal

Ship the three hardening primitives so a downstream consumer gets compliant default headers, manifest-declared rate-limit gates, and per-release SBOM evidence without inventing any of them. Template apps (`web-next`, `web-tanstack`, `cms`) wire the middleware end-to-end; auth feature ships rate-limit as the canonical reference example; release-please attaches a CycloneDX SBOM to every tagged release.

## In scope

### Security headers middleware in `core-shared/security`

- New module `core-shared/security/` exporting framework-agnostic header builder:
  - `buildSecurityHeaders(opts: SecurityHeadersConfig): Record<string, string>` — returns header name → value map
  - `SecurityHeadersConfig` type: `{ mode: "production" | "development", nonce?: string, allowedConnectOrigins?, allowedImgOrigins?, allowedFontOrigins?, csp?: { reportUri?: string, reportOnly?: boolean } }`
  - `generateNonce(): string` — cryptographically random 16-byte base64-encoded
  - Six headers emitted: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy
- Per-framework adapter subpaths (matches `core-analytics/react` pattern):
  - `core-shared/security/next` — Next.js middleware function + `getNonce()` helper reading from `headers()`
  - `core-shared/security/tanstack` — TanStack Start server middleware analog + request-context nonce extractor
- CSP defaults by mode (auto-switched from `NODE_ENV`):
  - **Production** — strict CSP with per-request nonce: `default-src 'self'; script-src 'self' 'strict-dynamic' 'nonce-{NONCE}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' {ALLOWED_ORIGINS}; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; upgrade-insecure-requests;`
  - **Development** — permissive: `default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: localhost:* 127.0.0.1:*;`
- App wiring across all three template apps:
  - `apps/web-next` — `middleware.ts` at app root invoking `core-shared/security/next`
  - `apps/web-tanstack` — `app.config.ts` server middleware
  - `apps/cms` — Payload `express` config middleware
- Sentry browser SDK nonce-aware init in `apps/web-next/instrumentation-client.ts` + `apps/web-tanstack` equivalent (reads `getNonce()` and threads it into `Sentry.init({ ... })` + replay/feedback integrations)

### Rate-limit primitive in `core-shared/rate-limit`

- Scaffolded inline in `core-shared` (small surface; doesn't justify a new optional core)
- `IRateLimit` interface — `consume(budgetName, key, weight?): Promise<RateLimitDecision>` + `reset(budgetName, key): Promise<void>` + `RateLimitDecision = { allowed, remaining, resetAt }`
- Three impls:
  - `NoopRateLimit` — always-allow default
  - `InMemoryRateLimit` — per-process Map-backed; dev/single-replica enforcement
  - `RecordingRateLimit` (lives in `core-testing`) — captures all calls for assertions
- `withRateLimit(rateLimit, factory)` wrapper attaching `RateLimited` brand at DI bind time
- `RateLimited` brand definition added to `core-shared/conformance/brands.ts`
- New per-use-case manifest field `rateLimit: RateLimitBudget[]` where `RateLimitBudget = { name: string, window: string, budget: number }`
- New ESLint rule `no-undeclared-rate-limit` (warn severity):
  - Warn on `rateLimit.consume("X", _)` literal `budgetName` not declared in manifest
  - Warn on declared budget never consumed
- `assertFeatureConformance` extended: require `RateLimited` brand when `rateLimit.length > 0`
- Brand composition order updated to innermost: `withSpan → withCapture → withAudit → withAnalytics → withConsent → withRateLimit → factory(deps)`
- `BindContext` gains `rateLimit?: IRateLimit` (defaults to `NoopRateLimit` when consumer doesn't wire one)
- `auth.signIn` backfilled as canonical reference example:
  - Manifest: `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]`
  - Use case body: dual `consume("ip", ...)` + `consume("account", ...)` calls

### SBOM generation in CI

- Amendment to ADR-023 §10 (SBOM bullet) — adds the concrete workflow step
- Step added to `.github/workflows/release-please.yml`:
  - Runs only when release-please cuts a release (`steps.release.outputs.releases_created == 'true'`)
  - Invokes `pnpm dlx @cyclonedx/cyclonedx-npm` to generate `sbom-<tag>.cdx.json` covering the entire workspace via `pnpm-lock.yaml`
  - Attaches SBOM as a GitHub release asset via `softprops/action-gh-release` (Renovate-pinned SHA per ADR-023)

### Documentation

- `docs/guides/security-headers.md` — full cookbook (per-framework wiring, nonce threading for consumer-added inline scripts, CSP allowlist customization, Sentry nonce integration, securityheaders.com verification)
- `docs/guides/rate-limiting.md` — cookbook (manifest field, naming convention `<feature>:<scope>:<key>`, multi-budget patterns, dev/staging/prod backend wiring guidance)
- `docs/glossary.md` — entries for `RateLimited` brand, `IRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM` (CycloneDX terminology)
- `CLAUDE.md` + `conformance-quickref.md` — rule count bump (12 → 13) + new manifest field documentation
- `docs/decisions/adr-023-ci-security-and-supply-chain.md` — amendment subsection capturing the SBOM workflow step

## Out of scope

- **Compliance fill-in docs (incident runbook, password policy templates, etc.)** — Epic D
- **Vendor-specific rate-limit backend** (Redis/Upstash/Cloudflare adapters) — consumer wires via ADR-022 library evaluation
- **Tracking IPs for rate-limit keys via request context** — caller-provided keys per Q5 of grill; consumer threads request context into use case input or builds key from controller
- **Distributed/multi-replica InMemoryRateLimit semantics** — per-process Map is dev-only; documented as such
- **Per-PR SBOM generation** — SBOM is a release artifact, per-release only (Q8 of grill)
- **SBOM signing / attestation** — bare SBOM only; SLSA-style provenance attestation is a future PRD if a consumer asks
- **CSP report-uri endpoint scaffolding** — CSP supports `report-uri` / `report-to` for violation collection but the template doesn't ship a collector. Config exposes the option; consumer wires their own collector
- **Nonce-aware policy for consumer-added inline scripts** — template documents the contract (`getNonce()` helper available; consumer threads via `<Script nonce={...}>`) but doesn't enforce
- **CSP for the Storybook app** — Storybook's renderer needs `'unsafe-eval'` for some addons; out of scope
- **Frame-ancestors override for embeddable widgets** — default `'none'`; consumer overrides when they ship embeddable surfaces
- **HSTS preload list submission** — `preload` directive emitted but submission to hstspreload.org is consumer/legal action

## Constraints

- **ADR-025** — Epic C strategy settled there.
- **ADR-014** — Sentry browser SDK init must respect new nonce contract; can't break existing instrumentation flow.
- **ADR-017** — observability PII boundary stays untouched; CSP changes don't affect Sentry's PII scrubbing.
- **ADR-021** — release-please's workflow shape is canonical for the SBOM step.
- **ADR-022** — no new third-party runtime dependencies for security headers, rate-limit, or SBOM generation. `@cyclonedx/cyclonedx-npm` invoked via `pnpm dlx` (no install). Rate-limit reference impls (`NoopRateLimit`, `InMemoryRateLimit`) use no external libs.
- **ADR-023** — SBOM step is an amendment to §10; CI workflow changes follow the established SHA-pin pattern.
- **ADR-024** + Epic B `requiresConsent`\*\* — rate-limit wrapper composes innermost (after consent); the order is canonical and tested.
- **Generator-first** — `withRateLimit` wrapper, `RateLimited` brand, ESLint rule additions follow the established conformance scaffold pattern.
- **Manifest-first ordering** — manifest schema + types land first; ESLint rule second; wrapper third; backfill (`auth.signIn`) fourth; app wiring last.
- **`core-shared` boundary** — rate-limit + security live in `core-shared` (must-have, available to every consumer). No new optional cores.
- **Conventional Commits** — every slice = one green commit.

## Success criteria

- `pnpm dev` on any app emits all six security headers with mode=development CSP; browser dev tools show `Content-Security-Policy` and the other 5 headers on every response.
- `pnpm build && pnpm start` (production mode) on any app emits mode=production CSP with a unique nonce per request, and Sentry browser SDK initializes successfully (no CSP violations in browser console).
- `securityheaders.com` scan of a `pnpm start`-served app scores A or A+.
- `core-shared/security/next` exports a working Next.js middleware that integrates with the app's existing middleware chain (auth checks etc.).
- `core-shared/security/tanstack` exports a working TanStack Start adapter.
- `IRateLimit` interface has three working impls (Noop, InMemory, Recording) with passing tests.
- `auth.signIn` manifest declares `rateLimit: [{ name: "ip", ... }, { name: "account", ... }]`; use case body invokes both `consume` calls; binding fails `assertFeatureConformance` if `withRateLimit` is omitted.
- `no-undeclared-rate-limit` ESLint rule fires on `rateLimit.consume("foo", ...)` where `"foo"` is not declared in the manifest; passes on matching calls.
- A merged release-please PR cuts a tag AND the release-please workflow uploads a CycloneDX SBOM as a release asset; the asset opens in any CycloneDX viewer.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff && pnpm compliance:emit-all --check` all green at every commit boundary.
- `docs/guides/security-headers.md` + `docs/guides/rate-limiting.md` cover consumer wiring end-to-end including the Sentry nonce thread and the canonical key-naming convention.
- CLAUDE.md + `conformance-quickref.md` reflect 13 conformance ESLint rules.

## User stories

1. As a **downstream consumer**, I want six security headers shipped by default in all three template apps so a securityheaders.com scan immediately scores A/A+ without my writing middleware.
2. As a **downstream consumer running Next.js**, I want a per-request CSP nonce so I can use Next.js's `<Script>` component without falling back to `'unsafe-inline'`.
3. As a **downstream consumer running TanStack Start**, I want the same nonce contract as Next.js so my dual-framework deployment behaves consistently.
4. As a **downstream consumer**, I want CSP to auto-switch between strict (production) and permissive (development) so my dev tooling (HMR, inline-style libraries) keeps working without manual config.
5. As a **downstream consumer customizing CSP**, I want `allowedConnectOrigins` + `allowedImgOrigins` config so I can allowlist my Sentry DSN host, analytics backend, CDN, etc. without forking the builder.
6. As a **downstream consumer shipping auth**, I want `rateLimit: [{ name: "ip" }, { name: "account" }]` on signIn so credential stuffing fails fast at lint time if I forget to wire it.
7. As a **downstream consumer**, I want `IRateLimit` to support multiple named budgets per use case so I can express "5/min per IP AND 10/hour per account" without homegrown key concatenation.
8. As an **AI agent** modifying signIn's use case body, I want `no-undeclared-rate-limit` to fire when I call `rateLimit.consume("foo", ...)` without declaring `"foo"` in manifest, so rate-limit drift is caught at lint time.
9. As an **AI agent** scaffolding a new auth/write/export use case, I want manifest to default to `rateLimit: []` so I'm prompted to think about rate-limit during manifest-first ordering.
10. As a **downstream consumer running InMemoryRateLimit in dev**, I want predictable single-process enforcement so I can test 429 responses locally without spinning up Redis.
11. As an **SRE rolling out production**, I want a Redis-backed `IRateLimit` impl I can swap in via `BindContext.rateLimit` so my multi-replica deployment shares state.
12. As a **compliance officer pursuing SOC 2**, I want a CycloneDX SBOM attached to every GitHub release so my auditor can answer "what's in version X" without inventory inspection.
13. As a **downstream consumer running serverless**, I want SBOM generation to happen at release time only (not per-PR) so my CI minutes don't balloon.
14. As an **AI agent** sequencing the dispatch, I want Epic C stories independent of Epic A/B so I can interleave with the dependency-ordered chain.

## Implementation decisions

### Module surface

- **`@repo/core-shared` additions** (must-have package, no new optional core):
  - New module `core-shared/security/` containing:
    - `security-types.ts` — `SecurityHeadersConfig`, `CspMode`, `CspDirective` types
    - `build-security-headers.ts` — pure builder function
    - `nonce.ts` — `generateNonce()` cryptographic helper
    - `next/index.ts` — Next.js middleware adapter + `getNonce()` from `headers()`
    - `tanstack/index.ts` — TanStack Start adapter
  - New module `core-shared/rate-limit/` containing:
    - `rate-limit.interface.ts` — `IRateLimit`, `RateLimitBudget`, `RateLimitDecision` types
    - `noop-rate-limit.ts` + sibling test
    - `in-memory-rate-limit.ts` + sibling test
    - `with-rate-limit.ts` — wrapper attaching `RateLimited` brand
  - Extension to `core-shared/conformance/brands.ts`:
    - `RateLimited<F> = F & { readonly __rateLimited: true }` type
    - Helper `isRateLimited(fn): boolean`
  - Extension to `core-shared/conformance/wire-use-case.ts`:
    - Accept optional `rateLimit: IRateLimit` arg; compose `withRateLimit` innermost when manifest declares `rateLimit.length > 0`
  - Extension to `core-shared/conformance/assert-bindings.ts`:
    - Require `RateLimited` brand when `manifest.useCases[name].rateLimit.length > 0`
  - Extension to `core-shared/conformance/define-feature.ts`:
    - Type `UseCaseManifest.rateLimit?: RateLimitBudget[]`
  - Extension to `core-shared/di/bind-context.ts`:
    - `BindContext.rateLimit?: IRateLimit` (defaults to `NoopRateLimit` at app aggregator level)
- **`@repo/core-testing` additions**:
  - `recording-rate-limit.ts` — captures `consume` + `reset` invocations
- **`@repo/core-eslint` additions**:
  - New rule `no-undeclared-rate-limit.js` (warn severity)
  - `_manifest-ast.js` parser gains `rateLimit` field extraction
  - `plugin.js` + `base.js` register the rule at warn
- **`packages/auth/` modifications**:
  - `feature.manifest.ts` — add `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]` to `signIn`
  - `application/use-cases/sign-in.use-case.ts` — body invokes both `rateLimit.consume` calls; deps signature gains `rateLimit: IRateLimit`
  - `di/bind-production.ts` + `bind-dev-seed.ts` — pass `ctx.rateLimit` (or `new NoopRateLimit()` fallback) into `wireUseCase` for signIn
- **`apps/web-next` modifications**:
  - `middleware.ts` — invokes `core-shared/security/next` middleware; chains with existing auth checks
  - `instrumentation-client.ts` — reads nonce via `getNonce()`, passes to `Sentry.init({ ..., transportOptions, integrations })`
  - `app/layout.tsx` — threads nonce into `<Script>` tags (Sentry, analytics, any inline scripts)
- **`apps/web-tanstack` modifications**:
  - `app.config.ts` — register server middleware from `core-shared/security/tanstack`
  - Equivalent client init file (`src/client.tsx` or framework convention) — nonce-aware Sentry init
- **`apps/cms` modifications**:
  - Payload config — wire Express middleware from `core-shared/security` (cms is server-side only; no nonce concern)
- **`.github/workflows/release-please.yml` modifications**:
  - Conditional SBOM generation step after release-creation
  - SBOM upload to GitHub release via `softprops/action-gh-release`
- **`docs/decisions/adr-023-ci-security-and-supply-chain.md`**:
  - Amendment subsection capturing the SBOM workflow step concrete shape
- **`docs/guides/`**:
  - New: `security-headers.md`
  - New: `rate-limiting.md`
- **`docs/glossary.md`**:
  - Entries: `IRateLimit`, `RateLimited` brand, `withRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM`, `nonce` (CSP context)
- **`CLAUDE.md` + `docs/guides/conformance-quickref.md`**:
  - Rule count bump: 12 → 13
  - New manifest field `rateLimit` documented in conformance table

### Type primitive contracts (inlined where decision-encoding tight)

```ts
// core-shared/security/security-types.ts
export type CspMode = "production" | "development";

export type SecurityHeadersConfig = {
  mode: CspMode;
  nonce?: string;
  allowedConnectOrigins?: string[];
  allowedImgOrigins?: string[];
  allowedFontOrigins?: string[];
  csp?: {
    reportUri?: string;
    reportOnly?: boolean;
  };
};

// core-shared/rate-limit/rate-limit.interface.ts
export type RateLimitBudget = {
  name: string;
  window: string; // ISO 8601 duration, e.g. "P1M" or shorthand "1m" / "1h"
  budget: number;
};

export type RateLimitDecision = {
  allowed: boolean;
  remaining: number;
  resetAt: Date;
};

export interface IRateLimit {
  consume(
    budgetName: string,
    key: string,
    weight?: number,
  ): Promise<RateLimitDecision>;
  reset(budgetName: string, key: string): Promise<void>;
}
```

```ts
// auth.signIn — canonical example
// feature.manifest.ts entry:
signIn: {
  mutates: false,
  audits: [],
  publishes: [],
  consumes: [],
  analyticsEvents: [],
  requiresConsent: ["essential"],
  rateLimit: [
    { name: "ip", window: "1m", budget: 5 },
    { name: "account", window: "1h", budget: 10 },
  ],
}

// use case body:
export const signInUseCase = (deps) => async (input) => {
  const ipDecision = await deps.rateLimit.consume("ip", `signIn:ip:${input.clientIp}`);
  if (!ipDecision.allowed) throw new TooManyRequestsError("ip");
  const accountDecision = await deps.rateLimit.consume("account", `signIn:account:${input.email}`);
  if (!accountDecision.allowed) throw new TooManyRequestsError("account");
  // ... rest of business logic
};
```

### CSP defaults

**Production CSP (template emits with nonce):**

```
default-src 'self';
script-src 'self' 'strict-dynamic' 'nonce-{NONCE}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' {ALLOWED_CONNECT_ORIGINS};
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
upgrade-insecure-requests;
```

`'strict-dynamic'` + nonce: nonce-tagged scripts can load further scripts dynamically without enumeration. Required for Sentry's lazy-loaded modules. `'unsafe-inline'` in `style-src` is a known compromise (Tailwind, CSS-in-JS, framework runtime inject inline styles); production CSP eliminates `'unsafe-inline'` for `script-src` (the actually-dangerous one).

**Development CSP:**

```
default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: localhost:* 127.0.0.1:*;
```

Allows Next.js HMR (websocket), React Refresh (unsafe-eval), and inline scripts. Auto-emitted when `mode === "development"`.

### Sentry nonce integration

Per-framework integration:

- **Next.js** (`apps/web-next/instrumentation-client.ts`):
  ```ts
  const nonce = (await headers()).get("x-nonce") ?? undefined;
  Sentry.init({
    dsn: ...,
    integrations: [
      Sentry.replayIntegration({ ..., nonce }),
      Sentry.feedbackIntegration({ ..., nonce }),
    ],
  });
  ```
- **TanStack Start** — equivalent via request-context extraction
- **Layout / Document head** — `<Script nonce={nonce}>` for any inline scripts the app ships

The `getNonce()` helper exported from `core-shared/security/next` abstracts the framework-specific extraction (`headers().get("x-nonce")` in Next.js Server Components).

### Rate-limit wrapper composition

Composition order (innermost → outermost):

```
withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ withRateLimit ⟶ factory(deps)
```

Rate-limit innermost because:

- Rate-limit fires after every other gate (audit, span, capture). The body running rate-limit `consume` calls is the actual work; outer layers wrap the work, including the rate-limit check.
- Spans should include rate-limit decision latency for observability — if `withSpan` were inside `withRateLimit`, the span would miss the rate-limit decision.

### CI workflow amendment (ADR-023 §10)

`.github/workflows/release-please.yml` gains a step after the release-please action emits `releases_created`:

```yaml
- name: Generate SBOM
  if: ${{ steps.release.outputs.releases_created == 'true' }}
  run: pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-${{ steps.release.outputs.tag_name }}.cdx.json --output-format json
- name: Upload SBOM to GitHub release
  if: ${{ steps.release.outputs.releases_created == 'true' }}
  uses: softprops/action-gh-release@<SHA>
  with:
    files: sbom-${{ steps.release.outputs.tag_name }}.cdx.json
    tag_name: ${{ steps.release.outputs.tag_name }}
```

`<SHA>` is Renovate-managed per ADR-023. `pnpm dlx` avoids adding cyclonedx-npm to the lockfile (it's a CI-only tool, not a runtime dep).

Per-package SBOMs aren't generated — release-please cuts multiple per-package tags simultaneously, but the root SBOM covers all workspace packages and is the canonical evidence per industry practice.

### Conformance impact

- ESLint rule count: 12 → 13 (`no-undeclared-rate-limit`)
- Manifest field: `rateLimit?: RateLimitBudget[]` (optional, defaults to `[]`)
- New brand: `RateLimited`
- Boot assertion: extended to require `RateLimited` brand when `rateLimit.length > 0`
- New manifest channel doesn't require a new optional core (`core-shared/rate-limit` is must-have)

## Testing decisions

- **`core-shared/security`**:
  - Unit tests on `buildSecurityHeaders`: returns expected header set per mode, nonce threaded into CSP when provided, optional config fields applied to CSP allowlists, dev vs prod CSP shape
  - Unit test on `generateNonce`: returns base64-encoded 16-byte strings, two calls return different values
  - Adapter tests for `core-shared/security/next`: middleware sets headers + injects nonce into response headers + makes nonce available to `getNonce()` helper
  - Adapter tests for `core-shared/security/tanstack`: equivalent
- **`core-shared/rate-limit`**:
  - `NoopRateLimit` test: `consume` always returns `{ allowed: true }`, `reset` is no-op
  - `InMemoryRateLimit` test: tracks per-bucket counts correctly, decrements on consume, resets at window boundary, resets to budget on explicit `reset` call, concurrent consumes are safe (single-threaded JS — straightforward)
  - `RecordingRateLimit` test: captures invocation arguments verbatim
  - `withRateLimit` wrapper test: `RateLimited` brand attached, factory invocation passthrough preserved, composes with other wrappers
- **`assertFeatureConformance` extension**:
  - Synthetic manifest fixture with `rateLimit: [{ name: "ip", ... }]` but no `withRateLimit` at bind: fails with `ConformanceError` naming the missing brand
- **`no-undeclared-rate-limit` ESLint rule**:
  - RuleTester fixtures parallel to `no-undeclared-audit.test.js`
  - Passes when call's `budgetName` matches declared
  - Fires on undeclared `budgetName`
  - Fires on declared-but-unused (warn)
  - No-op on non-use-case files
- **`auth.signIn` integration**:
  - Existing signIn test extended: with `RecordingRateLimit`, assert dual-consume captured; with `InMemoryRateLimit` set to budget 1, second call fails with `TooManyRequestsError`
- **App integration**:
  - `apps/web-next/middleware.test.ts` (or equivalent): assertion that all six headers present in response, CSP shape correct for `NODE_ENV=production` + `NODE_ENV=development`, nonce header present in response
  - Manual verification: `pnpm build && pnpm start`, hit `localhost:3000`, browser dev tools shows all headers + functioning Sentry replay (no CSP violations)
- **SBOM workflow**:
  - Verify locally: `pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-test.cdx.json` succeeds and produces valid CycloneDX JSON
  - End-to-end verification: merge a release-please PR, confirm SBOM asset appears on the resulting GitHub release
- **Coverage**: all new modules join L0 vitest thresholds per their `coverage.bands`. L1 `pnpm coverage:diff` gates every slice.
- **Prior art to mirror**:
  - Wrapper + brand pattern: `core-shared/instrumentation/with-capture.{ts,test.ts}` + `core-shared/conformance/wire-use-case.{ts,test.ts}`
  - ESLint rule shape: `packages/core-eslint/rules/no-undeclared-audit.{js,test.js}`
  - Three-impl interface pattern: `core-shared/jobs/{job-queue.interface.ts, payload-job-queue.ts, in-memory-job-queue.ts}` + `core-testing/instrumentation/recording-job-queue.ts`
  - Per-framework adapter subpath: `core-analytics/react` from ADR-024 (ships React provider as subpath)

## Open questions

- **Q1: Should `InMemoryRateLimit` honor TTL via `setTimeout` or check-at-read for bucket expiry?** — Recommended: **check-at-read**. setTimeout floods the event loop with timers in busy apps. Check-at-read on every `consume` call evaluates `now() > resetAt` and resets if expired. Single test case: assert bucket resets after `window` elapses with synthetic clock injection.
- **Q2: Does `withRateLimit` short-circuit the use case body on `!allowed`, or does the body decide?** — Recommended: **body decides**. The wrapper attaches the brand only; the use case body calls `consume(...)` explicitly and decides whether to throw `TooManyRequestsError` or degrade gracefully. Mirrors the audit pattern (`auditLog.record` doesn't decide flow control).
- **Q3: Should the security headers builder validate that `allowedConnectOrigins` entries are well-formed URLs?** — Recommended: **yes, with `URL` constructor parse**. Throws `InvalidSecurityHeadersConfig` at app boot if an allowlisted origin is malformed. Catches typos early. Documented in `docs/guides/security-headers.md`.
- **Q4: For TanStack Start nonce: where does the app store/retrieve the nonce — request context, async-local-storage, or response header?** — Recommended: **request context via TanStack's existing mechanisms**. Use `useRouterState()` or context provider for client-side access; server middleware reads from `event.node.req.headers["x-nonce"]` after setting it. Document the framework-specific extraction in `docs/guides/security-headers.md`.
- **Q5: How does CSP interact with `apps/storybook`?** — Recommended: **out of Epic C scope** (called out in "Out of scope"). Storybook's renderer needs `'unsafe-eval'`; shipping strict CSP there breaks docs. Document the omission.

## Out of scope (deferred)

- **Compliance fill-in docs (incident runbook, password policy, etc.)** — Epic D
- **Redis-backed `IRateLimit` reference impl** — consumer wires via ADR-022 library evaluation (`@upstash/ratelimit` or similar would need a trace)
- **CSP report-uri collector endpoint** — consumer-side; could be added later
- **SBOM signing / SLSA attestation** — bare CycloneDX SBOM only; signed attestations are a future PRD
- **Per-PR SBOM generation** — release-only per Q8 of grill
- **`apps/storybook` CSP** — Storybook tooling requires `'unsafe-eval'`; explicit deferral
- **`IRateLimit` v2: token-bucket algorithm vs fixed-window** — InMemoryRateLimit uses fixed-window for simplicity; consumer's production backend likely uses token-bucket; documented as such
- **CSP nonce reuse strategy** — every request gets a fresh nonce; no reuse across requests. Don't optimize prematurely.
- **HSTS preload list submission** — `preload` directive emitted but hstspreload.org submission is consumer/legal action

## Further notes

- **Builds on**: ADR-014 (Sentry observability — nonce integration), ADR-017 (PII boundary — CSP shouldn't affect server-side scrubbing), ADR-021 (release-please — SBOM step extends), ADR-022 (library evaluation — no new traces needed), ADR-023 (CI security — SBOM amendment), ADR-024 (analytics — confirms wrapper composition order), ADR-025 (strategy umbrella).
- **Pairs with**: Epic A PRD (`compliance-manifests-pii-retention-subprocessors.prd.md` — no direct dependency but Epic C's `auth.signIn` rate-limit backfill follows Epic A's backfill pattern), Epic B PRD (`dsr-consent-and-cookie-banner.prd.md` — wrapper composition order extended consistently), Epic D PRD (`compliance-docs-scaffolds.prd.md` — references Epic C's middleware + rate-limit + SBOM in security checklist documents).
- **Sequencing within Epic C**: type primitives (`SecurityHeadersConfig`, `RateLimitBudget`, `IRateLimit`) → manifest schema + ESLint rule → wrapper + brand + `wireUseCase` extension → `auth.signIn` backfill → security headers builder + per-framework adapters → app integration (web-next middleware + Sentry nonce, web-tanstack same, cms middleware) → SBOM workflow step + ADR-023 amendment → docs.
- **Dispatch independence**: Epic C has no dependencies on Epic A's deliverables or Epic B's; the dispatch loop can interleave Epic C stories with Epic B's whenever an Epic B story is blocked.
- **Stakeholders**: template authors (most affected — adds manifest field + brand + ESLint rule + ADR-023 amendment), downstream consumers (positively affected — gain compliant headers + rate-limit + SBOM out of box), AI agents operating in feature code (positively affected — declarative rate-limit gates with lint-time enforcement), compliance officers (positively affected — SBOM evidence per release; audit-grade headers), SREs (positively affected — IRateLimit interface for production wiring).
