chore(work): decompose security-headers-rate-limit-sbom epic

Generated 1 epic + 11 stories under docs/work/epics/ from the approved
PRD docs/work/prds/security-headers-rate-limit-sbom.prd.md. Stories
cover rate-limit type primitives + implementations + ESLint rule +
withRateLimit wrapper, auth.signIn rate-limit backfill, security
headers core module + framework adapters, app wiring across web-next /
web-tanstack / cms, SBOM CI workflow, and documentation. Ready for
`pnpm work dispatch --execute`.
This commit is contained in:
2026-05-20 10:14:54 +02:00
parent 352a7c64c1
commit a633561c82
13 changed files with 762 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
{
"updated_at": "2026-05-19T22:09:30.480Z",
"updated_at": "2026-05-20T08:14:56.249Z",
"epics": {
"binder-wrap-helper": {
"status": "done",
@@ -566,9 +566,202 @@
"blocks": []
}
}
},
"security-headers-rate-limit-sbom": {
"status": "in-progress",
"title": "Security headers + rate-limit primitive + SBOM in CI — Epic C of ADR-025",
"prd": "docs/work/prds/security-headers-rate-limit-sbom.prd.md",
"stories": {
"01-rate-limit-type-primitives": {
"status": "in-progress",
"title": "Rate-limit type primitives and manifest field",
"ac_total": 1,
"ac_completed": 0,
"depends_on": [],
"blocks": []
},
"02-rate-limit-implementations": {
"status": "todo",
"title": "Rate-limit implementations — Noop, InMemory, Recording",
"ac_total": 2,
"ac_completed": 0,
"depends_on": [
"01-rate-limit-type-primitives"
],
"blocks": []
},
"03-no-undeclared-rate-limit-eslint-rule": {
"status": "todo",
"title": "`no-undeclared-rate-limit` ESLint rule",
"ac_total": 1,
"ac_completed": 0,
"depends_on": [
"01-rate-limit-type-primitives"
],
"blocks": [
"05-auth-signin-rate-limit-backfill"
]
},
"04-with-rate-limit-wrapper-and-conformance": {
"status": "todo",
"title": "`withRateLimit` wrapper and conformance extensions",
"ac_total": 1,
"ac_completed": 0,
"depends_on": [
"01-rate-limit-type-primitives",
"02-rate-limit-implementations"
],
"blocks": [
"05-auth-signin-rate-limit-backfill"
]
},
"05-auth-signin-rate-limit-backfill": {
"status": "todo",
"title": "auth.signIn rate-limit backfill",
"ac_total": 1,
"ac_completed": 0,
"depends_on": [],
"blocks": []
},
"06-security-headers-core-module": {
"status": "todo",
"title": "Security headers core module",
"ac_total": 1,
"ac_completed": 0,
"depends_on": [],
"blocks": [
"07-security-header-adapters"
]
},
"07-security-header-adapters": {
"status": "todo",
"title": "Per-framework security header adapters",
"ac_total": 2,
"ac_completed": 0,
"depends_on": [
"06-security-headers-core-module"
],
"blocks": [
"08-app-wiring-web-next",
"09-app-wiring-web-tanstack-and-cms"
]
},
"08-app-wiring-web-next": {
"status": "todo",
"title": "App wiring: web-next",
"ac_total": 1,
"ac_completed": 0,
"depends_on": [
"07-security-header-adapters"
],
"blocks": []
},
"09-app-wiring-web-tanstack-and-cms": {
"status": "todo",
"title": "App wiring: web-tanstack and cms",
"ac_total": 2,
"ac_completed": 0,
"depends_on": [
"07-security-header-adapters"
],
"blocks": []
},
"10-sbom-ci-workflow": {
"status": "todo",
"title": "SBOM CI workflow and ADR-023 amendment",
"ac_total": 1,
"ac_completed": 0,
"depends_on": [],
"blocks": []
},
"11-documentation": {
"status": "todo",
"title": "Documentation and conformance reference updates",
"ac_total": 2,
"ac_completed": 0,
"depends_on": [],
"blocks": []
}
}
}
},
"ready": [],
"blocked": [],
"ready": [
{
"epic": "security-headers-rate-limit-sbom",
"story": "01-rate-limit-type-primitives",
"title": "Rate-limit type primitives and manifest field"
},
{
"epic": "security-headers-rate-limit-sbom",
"story": "05-auth-signin-rate-limit-backfill",
"title": "auth.signIn rate-limit backfill"
},
{
"epic": "security-headers-rate-limit-sbom",
"story": "06-security-headers-core-module",
"title": "Security headers core module"
},
{
"epic": "security-headers-rate-limit-sbom",
"story": "10-sbom-ci-workflow",
"title": "SBOM CI workflow and ADR-023 amendment"
},
{
"epic": "security-headers-rate-limit-sbom",
"story": "11-documentation",
"title": "Documentation and conformance reference updates"
}
],
"blocked": [
{
"epic": "security-headers-rate-limit-sbom",
"story": "02-rate-limit-implementations",
"title": "Rate-limit implementations — Noop, InMemory, Recording",
"waiting_on": [
"security-headers-rate-limit-sbom/01-rate-limit-type-primitives"
]
},
{
"epic": "security-headers-rate-limit-sbom",
"story": "03-no-undeclared-rate-limit-eslint-rule",
"title": "`no-undeclared-rate-limit` ESLint rule",
"waiting_on": [
"security-headers-rate-limit-sbom/01-rate-limit-type-primitives"
]
},
{
"epic": "security-headers-rate-limit-sbom",
"story": "04-with-rate-limit-wrapper-and-conformance",
"title": "`withRateLimit` wrapper and conformance extensions",
"waiting_on": [
"security-headers-rate-limit-sbom/01-rate-limit-type-primitives",
"security-headers-rate-limit-sbom/02-rate-limit-implementations"
]
},
{
"epic": "security-headers-rate-limit-sbom",
"story": "07-security-header-adapters",
"title": "Per-framework security header adapters",
"waiting_on": [
"security-headers-rate-limit-sbom/06-security-headers-core-module"
]
},
{
"epic": "security-headers-rate-limit-sbom",
"story": "08-app-wiring-web-next",
"title": "App wiring: web-next",
"waiting_on": [
"security-headers-rate-limit-sbom/07-security-header-adapters"
]
},
{
"epic": "security-headers-rate-limit-sbom",
"story": "09-app-wiring-web-tanstack-and-cms",
"title": "App wiring: web-tanstack and cms",
"waiting_on": [
"security-headers-rate-limit-sbom/07-security-header-adapters"
]
}
],
"needs_prd_ship": []
}

View File

@@ -0,0 +1,54 @@
---
id: 01-rate-limit-type-primitives
epic: security-headers-rate-limit-sbom
title: Rate-limit type primitives and manifest field
type: technical-story
status: in-progress
feature: core-shared
depends-on: []
blocks:
[
02-rate-limit-implementations,
03-no-undeclared-rate-limit-eslint-rule,
04-with-rate-limit-wrapper-and-conformance,
]
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Add the foundational TypeScript types for the rate-limit primitive — `IRateLimit`, `RateLimitBudget`, `RateLimitDecision`, and the `RateLimited` brand — to `core-shared`, and extend the feature manifest schema with `rateLimit?: RateLimitBudget[]` so every subsequent story has a stable, schema-valid type surface to build on.
## Why
Manifest-first ordering requires types before implementations and lint rules. The `RateLimited` brand must exist in `core-shared/conformance/brands.ts` before `assertFeatureConformance` can enforce it; `RateLimitBudget` must be a valid manifest field before any feature can declare rate-limit gates; `IRateLimit` must be exported before any implementation or wrapper can reference it. All three land together because they are mutually referential and individually incomplete.
## Done when
- `packages/core-shared/src/rate-limit/rate-limit.interface.ts` exports `IRateLimit`, `RateLimitBudget`, `RateLimitDecision`.
- `packages/core-shared/src/conformance/brands.ts` exports `RateLimited<F>` brand and `isRateLimited(fn): boolean` helper.
- `UseCaseManifest` in `packages/core-shared/src/conformance/define-feature.ts` gains `rateLimit?: RateLimitBudget[]` (absent defaults to `[]`).
- New types exported from the `core-shared` barrel.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
## In scope
- `IRateLimit` interface: `consume(budgetName, key, weight?): Promise<RateLimitDecision>` and `reset(budgetName, key): Promise<void>`.
- `RateLimitBudget` type: `{ name: string; window: string; budget: number }`.
- `RateLimitDecision` type: `{ allowed: boolean; remaining: number; resetAt: Date }`.
- `RateLimited<F>` brand in `conformance/brands.ts` following the `Captured`, `ConsentChecked` pattern.
- `isRateLimited(fn): boolean` brand-check helper.
- `rateLimit?: RateLimitBudget[]` field added to `UseCaseManifest` in `define-feature.ts`.
- Barrel export from `core-shared`.
## Out of scope
- Implementations (`NoopRateLimit`, `InMemoryRateLimit`, `RecordingRateLimit`) — Story 02.
- `withRateLimit` wrapper, `assertFeatureConformance` extension, `wireUseCase` extension — Story 04.
- ESLint rule — Story 03.
- Auth backfill — Story 05.
## Tasks
- [ ] Add `IRateLimit`, `RateLimitBudget`, `RateLimitDecision` types in `packages/core-shared/src/rate-limit/rate-limit.interface.ts`; add `RateLimited<F>` brand + `isRateLimited` helper in `packages/core-shared/src/conformance/brands.ts`; extend `UseCaseManifest` in `packages/core-shared/src/conformance/define-feature.ts` with `rateLimit?: RateLimitBudget[]`; export new types from the `core-shared` barrel; all gates pass.

View File

@@ -0,0 +1,49 @@
---
id: 02-rate-limit-implementations
epic: security-headers-rate-limit-sbom
title: Rate-limit implementations — Noop, InMemory, Recording
type: technical-story
status: todo
feature: core-shared
depends-on: [01-rate-limit-type-primitives]
blocks:
[
04-with-rate-limit-wrapper-and-conformance,
05-auth-signin-rate-limit-backfill,
]
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Provide three working `IRateLimit` implementations that cover dev, test, and production-delegate scenarios: `NoopRateLimit` (always-allow, zero overhead), `InMemoryRateLimit` (per-process fixed-window with check-at-read expiry), and `RecordingRateLimit` (test helper in `core-testing` that captures all calls for assertions).
## Why
The `withRateLimit` wrapper (Story 04) and the `auth.signIn` backfill (Story 05) both need concrete classes to wire. `NoopRateLimit` is also the required default in `BindContext` so apps without a wired rate-limit implementation boot safely. `RecordingRateLimit` is the testing primitive every future rate-limited use-case test will use; landing it here alongside the production impls keeps the testing toolkit coherent.
## Done when
- `packages/core-shared/src/rate-limit/noop-rate-limit.ts` exports `NoopRateLimit` with unit test: `consume` always resolves `{ allowed: true, remaining: Infinity, resetAt: epoch }`, `reset` is a no-op.
- `packages/core-shared/src/rate-limit/in-memory-rate-limit.ts` exports `InMemoryRateLimit` using check-at-read fixed-window expiry (no `setTimeout`); unit test: per-bucket tracking, decrement on `consume`, reset at window boundary (synthetic clock), explicit `reset` call restores budget to declared value.
- `packages/core-testing/src/rate-limit/recording-rate-limit.ts` exports `RecordingRateLimit` capturing `consume` + `reset` invocation arguments; sibling test verifies capture correctness.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
## In scope
- `NoopRateLimit` — always-allow impl, no state, no timers.
- `InMemoryRateLimit``Map`-backed, per-bucket `{ count, resetAt }`, check-at-read expiry, clock injection via constructor arg for testability.
- `RecordingRateLimit` — in `packages/core-testing`; exposes `consumeCalls` + `resetCalls` accessors for test assertions.
- Barrel exports from `core-shared` and `core-testing`.
## Out of scope
- Redis-backed or any distributed impl — deferred, consumer wires via ADR-022 library evaluation.
- Token-bucket algorithm — InMemoryRateLimit uses fixed-window for simplicity; documented as dev-only.
- `withRateLimit` wrapper — Story 04.
## Tasks
- [ ] Implement `NoopRateLimit` in `packages/core-shared/src/rate-limit/noop-rate-limit.ts` with sibling unit test (consume always allowed, reset no-op); implement `InMemoryRateLimit` in `packages/core-shared/src/rate-limit/in-memory-rate-limit.ts` with sibling unit test (per-bucket tracking, check-at-read expiry via injected clock, explicit reset restores budget); export both from `core-shared` barrel; all gates pass.
- [ ] Implement `RecordingRateLimit` in `packages/core-testing/src/rate-limit/recording-rate-limit.ts` capturing `consume` + `reset` call arguments verbatim with `consumeCalls` + `resetCalls` accessors; sibling test verifies capture correctness; export from `core-testing` barrel; all gates pass.

View File

@@ -0,0 +1,45 @@
---
id: 03-no-undeclared-rate-limit-eslint-rule
epic: security-headers-rate-limit-sbom
title: "`no-undeclared-rate-limit` ESLint rule"
type: technical-story
status: todo
feature: core-eslint
depends-on: [01-rate-limit-type-primitives]
blocks: [05-auth-signin-rate-limit-backfill]
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Add the `no-undeclared-rate-limit` ESLint rule (warn severity) that catches `rateLimit.consume("X", _)` calls where `"X"` is not declared in the manifest's `rateLimit` array, and declared budget names that are never consumed in the use-case body — giving AI agents and developers lint-time enforcement of rate-limit drift.
## Why
Without a lint rule, an agent could add a `consume("foo", ...)` call that has no corresponding manifest declaration, or declare a budget that silently goes unused. The ESLint rule closes this gap at the same latency as the existing `no-undeclared-audit` and `no-undeclared-consent-check` rules, making the rate-limit channel structurally consistent with the rest of the conformance system.
## Done when
- `packages/core-eslint/rules/no-undeclared-rate-limit.js` is registered at warn severity in `plugin.js` + `base.js`.
- `packages/core-eslint/rules/_manifest-ast.js` parser extracts the `rateLimit` field from a feature manifest.
- RuleTester fixtures cover: matching `budgetName` in call and manifest (pass), `budgetName` in call absent from manifest (warn), declared budget never consumed in use-case body (warn), non-use-case file (no-op).
- Prior-art shape mirrors `no-undeclared-audit.js` and `no-undeclared-consent-check.js`.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
## In scope
- `no-undeclared-rate-limit.js` rule implementation.
- `_manifest-ast.js` extension for `rateLimit` field extraction.
- Rule registration in `plugin.js` + `base.js` at warn severity.
- RuleTester fixtures (declared/undeclared/unused/non-use-case).
## Out of scope
- CLAUDE.md + `conformance-quickref.md` rule-count bump (12 → 13) — Story 11.
- `withRateLimit` wrapper — Story 04.
- Auth backfill applying the rule — Story 05.
## Tasks
- [ ] Add `no-undeclared-rate-limit` rule in `packages/core-eslint/rules/no-undeclared-rate-limit.js` (warn severity); extend `packages/core-eslint/rules/_manifest-ast.js` to extract the `rateLimit` field; register the rule in `plugin.js` + `base.js`; add RuleTester fixtures: declared budget + matching call (pass), undeclared budget name in call (warn), declared budget never consumed (warn), non-use-case file (no-op); all gates pass.

View File

@@ -0,0 +1,48 @@
---
id: 04-with-rate-limit-wrapper-and-conformance
epic: security-headers-rate-limit-sbom
title: "`withRateLimit` wrapper and conformance extensions"
type: technical-story
status: todo
feature: core-shared
depends-on: [01-rate-limit-type-primitives, 02-rate-limit-implementations]
blocks: [05-auth-signin-rate-limit-backfill]
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Ship the `withRateLimit` wrapper (attaches `RateLimited` brand at DI bind time), extend `wireUseCase` to compose it innermost when `rateLimit.length > 0`, extend `assertFeatureConformance` to require the brand when a manifest declares rate-limit budgets, and add `rateLimit?: IRateLimit` to `BindContext` defaulting to `NoopRateLimit` — completing the full conformance enforcement loop for rate-limit.
## Why
The brand enforcement loop must be complete before any feature can declare `rateLimit` in its manifest and claim conformance. A partial landing (e.g. wrapper without `assertFeatureConformance`, or `BindContext` without a default) leaves the conformance layer in an undefined state: either boot assertions silently skip the brand check, or apps fail to boot because `ctx.rateLimit` is undefined. All four changes land together as a single coherent slice.
## Done when
- `packages/core-shared/src/rate-limit/with-rate-limit.ts` exports `withRateLimit(rateLimit, factory)` attaching the `RateLimited` brand; unit tests assert brand is attached, factory passthrough is preserved, and wrapper composes correctly with other wrappers in the canonical order.
- `wireUseCase` in `packages/core-shared/src/conformance/wire-use-case.ts` accepts optional `rateLimit?: IRateLimit` and composes `withRateLimit` innermost (after `withConsent`) when `manifest.rateLimit.length > 0`; existing `wireUseCase` tests remain green.
- `assertFeatureConformance` in `packages/core-shared/src/conformance/assert-bindings.ts` requires the `RateLimited` brand when `manifest.useCases[name].rateLimit.length > 0`; synthetic fixture test asserts `ConformanceError` is thrown when the brand is absent.
- `packages/core-shared/src/di/bind-context.ts` gains `rateLimit?: IRateLimit`; the app aggregator in `apps/web-next/src/server/bind-production.ts` (and equivalents) defaults to `new NoopRateLimit()` when the consumer does not wire a backend.
- Canonical wrapper composition order confirmed: `withSpan → withCapture → withAudit → withAnalytics → withConsent → withRateLimit → factory(deps)`.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
## In scope
- `withRateLimit(rateLimit, factory)` wrapper in `core-shared/rate-limit/with-rate-limit.ts`.
- `wireUseCase` extension accepting `rateLimit?: IRateLimit` and composing `withRateLimit` innermost.
- `assertFeatureConformance` extension: require `RateLimited` brand when `rateLimit.length > 0`.
- Synthetic fixture test for the boot assertion failure case.
- `BindContext.rateLimit?: IRateLimit` — app aggregators default to `new NoopRateLimit()`.
- Mirror prior art: `with-capture.ts` / `with-consent.ts` wrapper pattern; `wire-use-case.ts` + `assert-bindings.ts` extension pattern.
## Out of scope
- `NoopRateLimit` implementation — Story 02 (required as dependency).
- Auth backfill — Story 05.
- ESLint rule — Story 03.
## Tasks
- [ ] Implement `withRateLimit(rateLimit, factory)` in `packages/core-shared/src/rate-limit/with-rate-limit.ts` attaching the `RateLimited` brand; extend `wireUseCase` to accept optional `rateLimit?: IRateLimit` and compose `withRateLimit` innermost when `manifest.rateLimit.length > 0`; extend `assertFeatureConformance` to require `RateLimited` brand when `rateLimit.length > 0` with a synthetic fixture test asserting `ConformanceError` on absent brand; add `rateLimit?: IRateLimit` to `BindContext` and default it to `new NoopRateLimit()` in app aggregators; unit tests for wrapper brand attachment and factory passthrough; all gates pass.

View File

@@ -0,0 +1,54 @@
---
id: 05-auth-signin-rate-limit-backfill
epic: security-headers-rate-limit-sbom
title: auth.signIn rate-limit backfill
type: technical-story
status: todo
feature: auth
depends-on:
[
01-rate-limit-type-primitives,
02-rate-limit-implementations,
03-no-undeclared-rate-limit-eslint-rule,
04-with-rate-limit-wrapper-and-conformance,
]
blocks: []
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Apply the rate-limit primitive to `auth.signIn` as the canonical reference example that every downstream consumer will copy — manifest declaration, dual `consume` calls in the use-case body with `TooManyRequestsError` throws, binder wiring, and extended tests — demonstrating end-to-end credential-stuffing defence that passes lint and conformance.
## Why
`auth.signIn` is the highest-risk write path in the template. Without rate-limit gates, credential-stuffing and account-enumeration windows stay open until a real consumer notices their auth logs. Backfilling this use case with the complete pattern (manifest → use-case body → binders → tests) also validates that all four preceding stories form a coherent system: if any type, impl, ESLint rule, or wrapper is mis-shaped, this story's conformance gate will surface it.
## Done when
- `packages/auth/src/feature.manifest.ts` `signIn` entry declares `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]`.
- `signInUseCase` deps include `rateLimit: IRateLimit`; body calls `rateLimit.consume("ip", \`signIn:ip:${input.clientIp}\`)` and `rateLimit.consume("account", \`signIn:account:${input.email}\`)`, throwing `TooManyRequestsError`on`!allowed`.
- `packages/auth/src/di/bind-production.ts` + `bind-dev-seed.ts` pass `ctx.rateLimit ?? new NoopRateLimit()` into signIn's `wireUseCase`.
- Existing signIn unit tests extended: `RecordingRateLimit` asserts both `consume` calls captured; `InMemoryRateLimit` at budget 1 asserts second call throws `TooManyRequestsError`.
- `no-undeclared-rate-limit` ESLint rule passes (no warnings) on both `"ip"` and `"account"` call sites.
- `assertFeatureConformance` boot assertion passes (signIn is `RateLimited` branded).
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
## In scope
- `feature.manifest.ts``rateLimit` declaration for `signIn`.
- `sign-in.use-case.ts``rateLimit: IRateLimit` in deps, dual `consume` + `TooManyRequestsError` throw.
- `TooManyRequestsError` class (if not already present in `auth/entities/errors/`) — add alongside other auth errors.
- `bind-production.ts` + `bind-dev-seed.ts` — wire `ctx.rateLimit ?? new NoopRateLimit()` into signIn.
- Unit tests: `RecordingRateLimit` dual-consume assertion; `InMemoryRateLimit` budget-1 rejection.
## Out of scope
- `auth.signUp` rate-limit — not declared in the PRD; consumer adds when needed.
- Rate-limit for any other auth use case — beyond the canonical example scope.
- Redis-backed wiring — consumer adds via ADR-022 library evaluation.
## Tasks
- [ ] Add `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]` to the `signIn` entry in `packages/auth/src/feature.manifest.ts`; add `rateLimit: IRateLimit` to `signInUseCase` deps; add `TooManyRequestsError` to auth error types if absent; insert dual `rateLimit.consume` calls with `TooManyRequestsError` throws in the use-case body; update `bind-production.ts` + `bind-dev-seed.ts` to pass `ctx.rateLimit ?? new NoopRateLimit()` into signIn's `wireUseCase`; extend signIn unit tests with `RecordingRateLimit` dual-consume assertion and `InMemoryRateLimit` budget-1 rejection; all gates pass.

View File

@@ -0,0 +1,48 @@
---
id: 06-security-headers-core-module
epic: security-headers-rate-limit-sbom
title: Security headers core module
type: technical-story
status: todo
feature: core-shared
depends-on: []
blocks: [07-security-header-adapters]
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Add the framework-agnostic security headers module to `core-shared/security``SecurityHeadersConfig` types, `generateNonce()` cryptographic helper, and `buildSecurityHeaders()` pure builder that returns all six headers with mode-aware CSP and URL validation for allowlisted origins.
## Why
The per-framework adapters (Story 07) and app-wiring stories (08, 09) all depend on this pure builder. Extracting the builder into `core-shared` (must-have) ensures all three template apps and any future framework adapters share a single, tested implementation of the header set, without duplicating CSP string construction or nonce generation logic.
## Done when
- `packages/core-shared/src/security/security-types.ts` exports `SecurityHeadersConfig` and `CspMode`.
- `packages/core-shared/src/security/nonce.ts` exports `generateNonce()` returning a cryptographically random base64-encoded 16-byte string.
- `packages/core-shared/src/security/build-security-headers.ts` exports `buildSecurityHeaders(opts: SecurityHeadersConfig): Record<string, string>` emitting all six headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy); prod CSP includes nonce in `script-src 'strict-dynamic' 'nonce-{NONCE}'`; dev CSP is permissive (`'unsafe-inline' 'unsafe-eval' ws: localhost:* 127.0.0.1:*`); `allowedConnectOrigins` entries validated via `URL` constructor, throwing `InvalidSecurityHeadersConfig` on malformed input.
- Unit tests: expected header set per mode, nonce threading into CSP `script-src`, dev vs prod CSP shape, `allowedConnectOrigins` / `allowedImgOrigins` / `allowedFontOrigins` applied to correct CSP directives, URL validation error on malformed origin, `generateNonce` randomness (two calls differ).
- Types exported from `core-shared` barrel.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
## In scope
- `security-types.ts``SecurityHeadersConfig`, `CspMode` types.
- `nonce.ts``generateNonce()` using `crypto.randomBytes(16).toString("base64")`.
- `build-security-headers.ts` — pure builder, six headers, prod/dev CSP, `allowedConnectOrigins` / `allowedImgOrigins` / `allowedFontOrigins` applied to CSP directives, URL validation with `InvalidSecurityHeadersConfig` error.
- Unit tests for all three files.
- Barrel export from `core-shared`.
## Out of scope
- Framework-specific adapter subpaths (`core-shared/security/next`, `core-shared/security/tanstack`) — Story 07.
- App middleware wiring — Stories 08 and 09.
- CSP report-uri collector endpoint — deferred, documented in guide.
- Storybook CSP — explicitly out of Epic C scope.
## Tasks
- [ ] Create `packages/core-shared/src/security/security-types.ts` with `SecurityHeadersConfig` + `CspMode` types; implement `packages/core-shared/src/security/nonce.ts` with `generateNonce()` using `crypto.randomBytes`; implement `packages/core-shared/src/security/build-security-headers.ts` emitting all six headers with prod/dev CSP mode, nonce threading into `script-src`, `allowedConnectOrigins` / `allowedImgOrigins` / `allowedFontOrigins` applied to CSP, URL validation throwing `InvalidSecurityHeadersConfig` on malformed origins; unit tests covering header set, CSP variants, nonce threading, allowlist CSP directives, URL validation error, nonce randomness; export from `core-shared` barrel; all gates pass.

View File

@@ -0,0 +1,45 @@
---
id: 07-security-header-adapters
epic: security-headers-rate-limit-sbom
title: Per-framework security header adapters
type: technical-story
status: todo
feature: core-shared
depends-on: [06-security-headers-core-module]
blocks: [08-app-wiring-web-next, 09-app-wiring-web-tanstack-and-cms]
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Ship the two framework-specific adapter subpaths — `core-shared/security/next` (Next.js middleware + `getNonce()` Server Component helper) and `core-shared/security/tanstack` (TanStack Start server middleware + request-context nonce extractor) — so the app-wiring stories can wire them end-to-end without touching the underlying header builder.
## Why
Adapters follow the `core-analytics/react` subpath pattern established in the codebase: framework-specific code lives in a subpath export so the core module remains importable without dragging in framework dependencies. Each adapter generates a per-request nonce, calls `buildSecurityHeaders`, sets all six headers on the response, and forwards the nonce via `x-nonce` for downstream Server Component access. Landing the adapters before app wiring keeps the integration commits thin.
## Done when
- `packages/core-shared/src/security/next/index.ts` exports a Next.js middleware function and `getNonce()` helper; adapter tests assert all six headers set on response, `x-nonce` present, `getNonce()` reads the value from `headers()`.
- `packages/core-shared/src/security/tanstack/index.ts` exports a TanStack Start server middleware and a nonce extractor for server context; adapter tests assert equivalent header + nonce behaviour.
- Both subpaths declared in `packages/core-shared/package.json` `exports` map.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
## In scope
- `core-shared/security/next` — middleware calling `generateNonce()` + `buildSecurityHeaders({ mode: process.env.NODE_ENV === "production" ? "production" : "development", nonce })`, setting headers on the response, writing nonce to `x-nonce` response header; `getNonce()` reading `x-nonce` from `headers()` for use in Server Components.
- `core-shared/security/tanstack` — equivalent using TanStack Start's server middleware API; nonce extractor for TanStack Server context.
- `package.json` subpath exports for both adapters.
- Adapter unit tests for each.
## Out of scope
- App middleware wiring, Sentry nonce init, layout nonce threading — Stories 08 and 09.
- CMS adapter — `apps/cms` uses the framework-agnostic `buildSecurityHeaders` directly (Story 09).
- Storybook CSP — explicitly out of Epic C scope.
## Tasks
- [ ] Implement `packages/core-shared/src/security/next/index.ts`: Next.js middleware calling `generateNonce()` + `buildSecurityHeaders`, setting all six headers and `x-nonce` on the response, plus `getNonce()` helper reading `x-nonce` from Next.js `headers()`; add subpath to `package.json` exports; adapter tests asserting all headers present, nonce in response headers, `getNonce()` reads it; all gates pass.
- [ ] Implement `packages/core-shared/src/security/tanstack/index.ts`: TanStack Start server middleware equivalent (generate nonce, set headers + `x-nonce`) plus request-context nonce extractor; add subpath to `package.json` exports; adapter tests asserting equivalent header + nonce behaviour; all gates pass.

View File

@@ -0,0 +1,47 @@
---
id: 08-app-wiring-web-next
epic: security-headers-rate-limit-sbom
title: "App wiring: web-next"
type: technical-story
status: todo
feature: web-next
depends-on: [07-security-header-adapters]
blocks: []
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Wire the security headers middleware end-to-end in `apps/web-next` — middleware chain, nonce-aware Sentry browser SDK init, and nonce threaded into the document head — producing a Next.js app that emits all six headers with per-request CSP nonces and no CSP violations in the browser console.
## Why
`apps/web-next` is the primary template app; getting it wired first validates the Next.js adapter in a real app context, including the Sentry nonce contract and the `<Script nonce={nonce}>` threading pattern that consumers will copy. The Sentry init integration is non-trivial enough (ADR-014 nonce contract, `replayIntegration` + `feedbackIntegration` both need the nonce) that it justifies its own story to get right before the parallel web-tanstack story begins.
## Done when
- `apps/web-next/middleware.ts` invokes the `core-shared/security/next` middleware and chains it with existing auth checks (security headers apply before auth redirects).
- `apps/web-next/instrumentation-client.ts` reads nonce via `getNonce()` and passes it to `Sentry.init({ integrations: [replayIntegration({ nonce }), feedbackIntegration({ nonce })] })`.
- `apps/web-next/app/layout.tsx` threads nonce from `getNonce()` into `<Script nonce={nonce}>` for any inline scripts in the document head.
- Middleware test asserts: all six headers present in response, CSP shape matches prod template for `NODE_ENV=production`, CSP shape is permissive for `NODE_ENV=development`, `x-nonce` header present.
- No CSP violations appear in browser console when running `pnpm dev` against `localhost:3000`.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
## In scope
- `apps/web-next/middleware.ts` — security headers middleware chained with existing auth middleware.
- `apps/web-next/instrumentation-client.ts` — nonce-aware `Sentry.init`.
- `apps/web-next/app/layout.tsx` — nonce threaded into `<Script>` tags.
- Middleware test: six headers + CSP shape per mode + `x-nonce` presence.
## Out of scope
- TanStack Start and CMS wiring — Story 09.
- CSP report-uri collector — deferred.
- Storybook CSP — explicitly out of Epic C scope.
- HSTS preload list submission — consumer/legal action.
## Tasks
- [ ] Wire `core-shared/security/next` middleware in `apps/web-next/middleware.ts` chained before existing auth checks; update `apps/web-next/instrumentation-client.ts` to read nonce via `getNonce()` and pass to `Sentry.init` replay + feedback integrations; thread nonce from `getNonce()` into `<Script nonce={nonce}>` in `apps/web-next/app/layout.tsx`; add middleware test asserting all six headers, prod/dev CSP shape, and `x-nonce` present in response; all gates pass.

View File

@@ -0,0 +1,44 @@
---
id: 09-app-wiring-web-tanstack-and-cms
epic: security-headers-rate-limit-sbom
title: "App wiring: web-tanstack and cms"
type: technical-story
status: todo
feature: web-tanstack
depends-on: [07-security-header-adapters]
blocks: []
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Wire the security headers middleware in `apps/web-tanstack` (TanStack Start server middleware + nonce-aware Sentry init) and `apps/cms` (Payload Express middleware) so all three template apps emit the six security headers by default.
## Why
Completing the three-app wiring fulfils the PRD success criterion that a consumer picking any template app gets compliant default headers without writing middleware. The two tasks are independent and can land in either order; grouping them in one story reflects that they share the same depends-on (Story 07) and both close the "all apps wired" milestone together.
## Done when
- `apps/web-tanstack/app.config.ts` registers the `core-shared/security/tanstack` server middleware.
- The web-tanstack client init file reads nonce from request context and passes it to `Sentry.init` replay + feedback integrations (mirroring the web-next pattern).
- `apps/cms` Payload config wires the `core-shared/security` Express middleware; CMS responses emit all six headers (no nonce needed — server-side only app).
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
## In scope
- `apps/web-tanstack/app.config.ts``core-shared/security/tanstack` server middleware registration.
- web-tanstack client init file — nonce-aware Sentry init (nonce from request context via tanstack adapter's extractor).
- `apps/cms` Payload config — Express middleware from `core-shared/security` (framework-agnostic builder; no nonce needed for CMS).
## Out of scope
- web-next wiring — Story 08.
- CSP report-uri collector — deferred.
- Storybook CSP — explicitly out of Epic C scope.
## Tasks
- [ ] Register `core-shared/security/tanstack` server middleware in `apps/web-tanstack/app.config.ts`; update the web-tanstack client init file with nonce-aware Sentry init reading nonce from request context via the tanstack adapter's nonce extractor; all gates pass.
- [ ] Wire `core-shared/security` Express middleware in `apps/cms` Payload config (no nonce needed — CMS is server-side only); all gates pass.

View File

@@ -0,0 +1,45 @@
---
id: 10-sbom-ci-workflow
epic: security-headers-rate-limit-sbom
title: SBOM CI workflow and ADR-023 amendment
type: technical-story
status: todo
feature: ci
depends-on: []
blocks: []
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Amend `.github/workflows/release-please.yml` to generate a CycloneDX SBOM and upload it as a GitHub release asset whenever release-please cuts a release, and capture the concrete workflow step as an amendment to ADR-023 §10.
## Why
Consumers pursuing SOC 2 / ISO 27001 / FedRAMP / EU CRA must answer "what's in version X" without inventory inspection. A CycloneDX SBOM attached to every GitHub release gives auditors a machine-readable, per-release artifact. `pnpm dlx` avoids adding `@cyclonedx/cyclonedx-npm` to the lockfile (CI-only tool per ADR-022). The Renovate-pinned SHA on `softprops/action-gh-release` follows the established ADR-023 pattern.
## Done when
- `.github/workflows/release-please.yml` has a conditional step that runs `pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-<tag>.cdx.json --output-format json` when `steps.release.outputs.releases_created == 'true'`.
- A `softprops/action-gh-release@<SHA>` step (Renovate-managed SHA per ADR-023) uploads the SBOM JSON file as a release asset with `tag_name: ${{ steps.release.outputs.tag_name }}`.
- `docs/decisions/adr-023-ci-security-and-supply-chain.md` contains a new amendment subsection capturing the SBOM step's concrete shape and rationale.
- Local validation: `pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-test.cdx.json` succeeds and produces valid CycloneDX JSON.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
## In scope
- `.github/workflows/release-please.yml` — conditional SBOM generation + upload steps.
- `softprops/action-gh-release@<SHA>` with Renovate-managed SHA (choose a recent stable release; Renovate will keep it current).
- `docs/decisions/adr-023-ci-security-and-supply-chain.md` — amendment subsection §10 SBOM.
## Out of scope
- Per-PR SBOM generation — release-only per PRD.
- SBOM signing / SLSA provenance attestation — bare CycloneDX only; attestation is a future PRD.
- Per-package SBOMs — root SBOM covers all workspace packages; industry practice for monorepos.
- `@cyclonedx/cyclonedx-npm` added to `package.json` — invoked via `pnpm dlx` only.
## Tasks
- [ ] Add conditional SBOM generation step (`pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-${{ steps.release.outputs.tag_name }}.cdx.json --output-format json`) and upload step (`softprops/action-gh-release@<SHA>` with `files:` pointing to the SBOM and `tag_name:` from release-please output) to `.github/workflows/release-please.yml`; add amendment subsection to `docs/decisions/adr-023-ci-security-and-supply-chain.md` documenting the concrete step shape and rationale; all gates pass.

View File

@@ -0,0 +1,54 @@
---
id: 11-documentation
epic: security-headers-rate-limit-sbom
title: Documentation and conformance reference updates
type: technical-story
status: todo
feature: core-shared
depends-on:
[
05-auth-signin-rate-limit-backfill,
08-app-wiring-web-next,
09-app-wiring-web-tanstack-and-cms,
10-sbom-ci-workflow,
]
blocks: []
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Write the consumer-facing cookbooks (`security-headers.md`, `rate-limiting.md`) and update the cross-cutting reference docs (glossary, CLAUDE.md, conformance quickref) so downstream consumers, AI agents, and compliance officers can discover and apply security headers, rate-limiting, and SBOM from a single reading session.
## Why
Implementations are complete after Story 10, but the institutional knowledge of how to use them, customize them, and understand their conformance contracts lives only in code. The cookbooks close that gap by walking concrete wiring steps, key-naming conventions, and verification workflows. The CLAUDE.md and conformance-quickref updates keep the conformance rule count accurate (12 → 13) and surface the `rateLimit` manifest field in the authoritative reference every agent reads at session start.
## Done when
- `docs/guides/security-headers.md` covers: per-framework middleware wiring (Next.js, TanStack, CMS), nonce threading for consumer-added inline scripts, CSP allowlist customization (`allowedConnectOrigins` etc.), Sentry nonce integration steps, securityheaders.com verification workflow.
- `docs/guides/rate-limiting.md` covers: manifest `rateLimit` field declaration, canonical key-naming convention `<feature>:<scope>:<key>`, multi-budget patterns, `InMemoryRateLimit` for dev/test, guidance on wiring a production backend via `BindContext.rateLimit`.
- `docs/glossary.md` has entries for: `IRateLimit`, `RateLimited` (brand), `withRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM` (CycloneDX context), `nonce` (CSP context).
- `CLAUDE.md` conformance rule count updated 12 → 13; `rateLimit?: RateLimitBudget[]` documented in the manifest field table.
- `docs/guides/conformance-quickref.md` updated to list `no-undeclared-rate-limit` as the 13th rule.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
## In scope
- `docs/guides/security-headers.md` (new file).
- `docs/guides/rate-limiting.md` (new file).
- `docs/glossary.md` — new entries only; no existing entry edits.
- `CLAUDE.md` — conformance rule count bump (12 → 13) + `rateLimit` manifest field row.
- `docs/guides/conformance-quickref.md` — add `no-undeclared-rate-limit` entry.
## Out of scope
- ADR-023 amendment — Story 10.
- Any new code changes — docs only; all implementation is done.
- Compliance fill-in docs (incident runbook, password policy) — Epic D.
## Tasks
- [ ] Write `docs/guides/security-headers.md` (per-framework wiring, nonce threading for consumer inline scripts, CSP allowlist customization, Sentry nonce integration, securityheaders.com verification) and `docs/guides/rate-limiting.md` (manifest field, key-naming convention `<feature>:<scope>:<key>`, multi-budget patterns, dev/staging/prod backend wiring); all gates pass.
- [ ] Add entries for `IRateLimit`, `RateLimited` brand, `withRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM`, `nonce` (CSP context) to `docs/glossary.md`; update `CLAUDE.md` conformance rule count 12 → 13 and add `rateLimit?: RateLimitBudget[]` to the manifest field documentation; add `no-undeclared-rate-limit` as the 13th rule in `docs/guides/conformance-quickref.md`; all gates pass.

View File

@@ -0,0 +1,33 @@
---
id: security-headers-rate-limit-sbom
prd: docs/work/prds/security-headers-rate-limit-sbom.prd.md
title: Security headers + rate-limit primitive + SBOM in CI — Epic C of ADR-025
type: epic
status: in-progress
features:
[core-shared, core-testing, core-eslint, auth, web-next, web-tanstack, cms]
created: 2026-05-20T00:00:00Z
updated: 2026-05-20T08:14:55.907Z
---
## Goal
Ship three hardening primitives — framework-agnostic security header middleware, a manifest-declared rate-limit conformance channel, and per-release SBOM evidence — so downstream consumers get compliant default headers, lint-enforced rate-limit gates, and CycloneDX audit artifacts without inventing any of them.
## Why
Security scanners flag the absence of HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and CSP on every template app response. Auth endpoints (signIn, signUp) ship without rate-limit declarations, leaving credential-stuffing windows open until a consumer notices their auth logs. Consumers pursuing SOC 2 / ISO 27001 / FedRAMP must invent SBOM tooling and bolt it into their release flow. ADR-025 settled the strategy; this epic is the implementation.
## Stories
- [ ] [01 — Rate-limit type primitives and manifest field](01-rate-limit-type-primitives/_story.md)
- [ ] [02 — Rate-limit implementations: Noop, InMemory, Recording](02-rate-limit-implementations/_story.md)
- [ ] [03 — `no-undeclared-rate-limit` ESLint rule](03-no-undeclared-rate-limit-eslint-rule/_story.md)
- [ ] [04 — `withRateLimit` wrapper and conformance extensions](04-with-rate-limit-wrapper-and-conformance/_story.md)
- [ ] [05 — auth.signIn rate-limit backfill](05-auth-signin-rate-limit-backfill/_story.md)
- [ ] [06 — Security headers core module](06-security-headers-core-module/_story.md)
- [ ] [07 — Per-framework security header adapters](07-security-header-adapters/_story.md)
- [ ] [08 — App wiring: web-next](08-app-wiring-web-next/_story.md)
- [ ] [09 — App wiring: web-tanstack and cms](09-app-wiring-web-tanstack-and-cms/_story.md)
- [ ] [10 — SBOM CI workflow and ADR-023 amendment](10-sbom-ci-workflow/_story.md)
- [ ] [11 — Documentation and conformance reference updates](11-documentation/_story.md)