diff --git a/docs/work/_system/_state.json b/docs/work/_system/_state.json index 7efa8d2..f854119 100644 --- a/docs/work/_system/_state.json +++ b/docs/work/_system/_state.json @@ -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": [] } diff --git a/docs/work/epics/security-headers-rate-limit-sbom/01-rate-limit-type-primitives/_story.md b/docs/work/epics/security-headers-rate-limit-sbom/01-rate-limit-type-primitives/_story.md new file mode 100644 index 0000000..057fdd0 --- /dev/null +++ b/docs/work/epics/security-headers-rate-limit-sbom/01-rate-limit-type-primitives/_story.md @@ -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` 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` and `reset(budgetName, key): Promise`. +- `RateLimitBudget` type: `{ name: string; window: string; budget: number }`. +- `RateLimitDecision` type: `{ allowed: boolean; remaining: number; resetAt: Date }`. +- `RateLimited` 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` 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. diff --git a/docs/work/epics/security-headers-rate-limit-sbom/02-rate-limit-implementations/_story.md b/docs/work/epics/security-headers-rate-limit-sbom/02-rate-limit-implementations/_story.md new file mode 100644 index 0000000..1f567ab --- /dev/null +++ b/docs/work/epics/security-headers-rate-limit-sbom/02-rate-limit-implementations/_story.md @@ -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. diff --git a/docs/work/epics/security-headers-rate-limit-sbom/03-no-undeclared-rate-limit-eslint-rule/_story.md b/docs/work/epics/security-headers-rate-limit-sbom/03-no-undeclared-rate-limit-eslint-rule/_story.md new file mode 100644 index 0000000..631417e --- /dev/null +++ b/docs/work/epics/security-headers-rate-limit-sbom/03-no-undeclared-rate-limit-eslint-rule/_story.md @@ -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. diff --git a/docs/work/epics/security-headers-rate-limit-sbom/04-with-rate-limit-wrapper-and-conformance/_story.md b/docs/work/epics/security-headers-rate-limit-sbom/04-with-rate-limit-wrapper-and-conformance/_story.md new file mode 100644 index 0000000..31fb17b --- /dev/null +++ b/docs/work/epics/security-headers-rate-limit-sbom/04-with-rate-limit-wrapper-and-conformance/_story.md @@ -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. diff --git a/docs/work/epics/security-headers-rate-limit-sbom/05-auth-signin-rate-limit-backfill/_story.md b/docs/work/epics/security-headers-rate-limit-sbom/05-auth-signin-rate-limit-backfill/_story.md new file mode 100644 index 0000000..ee6e965 --- /dev/null +++ b/docs/work/epics/security-headers-rate-limit-sbom/05-auth-signin-rate-limit-backfill/_story.md @@ -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. diff --git a/docs/work/epics/security-headers-rate-limit-sbom/06-security-headers-core-module/_story.md b/docs/work/epics/security-headers-rate-limit-sbom/06-security-headers-core-module/_story.md new file mode 100644 index 0000000..c739d47 --- /dev/null +++ b/docs/work/epics/security-headers-rate-limit-sbom/06-security-headers-core-module/_story.md @@ -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` 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. diff --git a/docs/work/epics/security-headers-rate-limit-sbom/07-security-header-adapters/_story.md b/docs/work/epics/security-headers-rate-limit-sbom/07-security-header-adapters/_story.md new file mode 100644 index 0000000..1b8da57 --- /dev/null +++ b/docs/work/epics/security-headers-rate-limit-sbom/07-security-header-adapters/_story.md @@ -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. diff --git a/docs/work/epics/security-headers-rate-limit-sbom/08-app-wiring-web-next/_story.md b/docs/work/epics/security-headers-rate-limit-sbom/08-app-wiring-web-next/_story.md new file mode 100644 index 0000000..d58a41f --- /dev/null +++ b/docs/work/epics/security-headers-rate-limit-sbom/08-app-wiring-web-next/_story.md @@ -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 `