Files
agentic-dev/docs/work/prds/security-headers-rate-limit-sbom.prd.md
Danijel Martinek ef9cfd243e docs: seed PRD for security headers + rate-limit + SBOM (ADR-025 Epic C)
Implementation seed for ADR-025 Epic C: six security headers middleware
with nonce-based CSP in core-shared/security (Next + TanStack adapters),
fourth conformance channel for rate-limit in core-shared/rate-limit
(IRateLimit + RateLimited brand + multi-budget manifest field +
no-undeclared-rate-limit ESLint rule), CycloneDX SBOM step in
release-please.yml. auth.signIn backfilled as canonical rate-limit
reference. ADR-023 amendment for SBOM captured. Status: approved —
ready for pnpm work decompose.
2026-05-19 13:09:20 +02:00

31 KiB

id, title, type, status, author, created, updated
id title type status author created updated
security-headers-rate-limit-sbom Security headers + rate-limit primitive + SBOM in CI — Epic C of ADR-025 prd approved danijel 2026-05-19T11:05:39Z 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-nextmiddleware.ts at app root invoking core-shared/security/next
    • apps/web-tanstackapp.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 submissionpreload 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-firstwithRateLimit 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.tsSecurityHeadersConfig, CspMode, CspDirective types
      • build-security-headers.ts — pure builder function
      • nonce.tsgenerateNonce() 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.tsIRateLimit, 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)

// 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>;
}
// 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):
    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:

- 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 submissionpreload 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).