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.
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 mapSecurityHeadersConfigtype:{ 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/reactpattern):core-shared/security/next— Next.js middleware function +getNonce()helper reading fromheaders()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:*;
- Production — strict CSP with per-request nonce:
- App wiring across all three template apps:
apps/web-next—middleware.tsat app root invokingcore-shared/security/nextapps/web-tanstack—app.config.tsserver middlewareapps/cms— Payloadexpressconfig middleware
- Sentry browser SDK nonce-aware init in
apps/web-next/instrumentation-client.ts+apps/web-tanstackequivalent (readsgetNonce()and threads it intoSentry.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) IRateLimitinterface —consume(budgetName, key, weight?): Promise<RateLimitDecision>+reset(budgetName, key): Promise<void>+RateLimitDecision = { allowed, remaining, resetAt }- Three impls:
NoopRateLimit— always-allow defaultInMemoryRateLimit— per-process Map-backed; dev/single-replica enforcementRecordingRateLimit(lives incore-testing) — captures all calls for assertions
withRateLimit(rateLimit, factory)wrapper attachingRateLimitedbrand at DI bind timeRateLimitedbrand definition added tocore-shared/conformance/brands.ts- New per-use-case manifest field
rateLimit: RateLimitBudget[]whereRateLimitBudget = { name: string, window: string, budget: number } - New ESLint rule
no-undeclared-rate-limit(warn severity):- Warn on
rateLimit.consume("X", _)literalbudgetNamenot declared in manifest - Warn on declared budget never consumed
- Warn on
assertFeatureConformanceextended: requireRateLimitedbrand whenrateLimit.length > 0- Brand composition order updated to innermost:
withSpan → withCapture → withAudit → withAnalytics → withConsent → withRateLimit → factory(deps) BindContextgainsrateLimit?: IRateLimit(defaults toNoopRateLimitwhen consumer doesn't wire one)auth.signInbackfilled 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
- Manifest:
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-npmto generatesbom-<tag>.cdx.jsoncovering the entire workspace viapnpm-lock.yaml - Attaches SBOM as a GitHub release asset via
softprops/action-gh-release(Renovate-pinned SHA per ADR-023)
- Runs only when release-please cuts a release (
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 forRateLimitedbrand,IRateLimit,SecurityHeadersConfig,buildSecurityHeaders,SBOM(CycloneDX terminology)CLAUDE.md+conformance-quickref.md— rule count bump (12 → 13) + new manifest field documentationdocs/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-tofor violation collection but the template doesn't ship a collector. Config exposes the option; consumer wires their own collector - Nonce-aware policy for consumer-added inline scripts — template documents the contract (
getNonce()helper available; consumer threads via<Script nonce={...}>) but doesn't enforce - CSP for the Storybook app — Storybook's renderer needs
'unsafe-eval'for some addons; out of scope - Frame-ancestors override for embeddable widgets — default
'none'; consumer overrides when they ship embeddable surfaces - HSTS preload list submission —
preloaddirective 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-npminvoked viapnpm dlx(no install). Rate-limit reference impls (NoopRateLimit,InMemoryRateLimit) use no external libs. - ADR-023 — SBOM step is an amendment to §10; CI workflow changes follow the established SHA-pin pattern.
- ADR-024 + Epic B
requiresConsent** — rate-limit wrapper composes innermost (after consent); the order is canonical and tested. - Generator-first —
withRateLimitwrapper,RateLimitedbrand, 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-sharedboundary — rate-limit + security live incore-shared(must-have, available to every consumer). No new optional cores.- Conventional Commits — every slice = one green commit.
Success criteria
pnpm devon any app emits all six security headers with mode=development CSP; browser dev tools showContent-Security-Policyand 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.comscan of apnpm start-served app scores A or A+.core-shared/security/nextexports a working Next.js middleware that integrates with the app's existing middleware chain (auth checks etc.).core-shared/security/tanstackexports a working TanStack Start adapter.IRateLimitinterface has three working impls (Noop, InMemory, Recording) with passing tests.auth.signInmanifest declaresrateLimit: [{ name: "ip", ... }, { name: "account", ... }]; use case body invokes bothconsumecalls; binding failsassertFeatureConformanceifwithRateLimitis omitted.no-undeclared-rate-limitESLint rule fires onrateLimit.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 --checkall green at every commit boundary.docs/guides/security-headers.md+docs/guides/rate-limiting.mdcover consumer wiring end-to-end including the Sentry nonce thread and the canonical key-naming convention.- CLAUDE.md +
conformance-quickref.mdreflect 13 conformance ESLint rules.
User stories
- 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.
- 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'. - As a downstream consumer running TanStack Start, I want the same nonce contract as Next.js so my dual-framework deployment behaves consistently.
- 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.
- As a downstream consumer customizing CSP, I want
allowedConnectOrigins+allowedImgOriginsconfig so I can allowlist my Sentry DSN host, analytics backend, CDN, etc. without forking the builder. - 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. - As a downstream consumer, I want
IRateLimitto support multiple named budgets per use case so I can express "5/min per IP AND 10/hour per account" without homegrown key concatenation. - As an AI agent modifying signIn's use case body, I want
no-undeclared-rate-limitto fire when I callrateLimit.consume("foo", ...)without declaring"foo"in manifest, so rate-limit drift is caught at lint time. - 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. - 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.
- As an SRE rolling out production, I want a Redis-backed
IRateLimitimpl I can swap in viaBindContext.rateLimitso my multi-replica deployment shares state. - 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.
- 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.
- 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-sharedadditions (must-have package, no new optional core):- New module
core-shared/security/containing:security-types.ts—SecurityHeadersConfig,CspMode,CspDirectivetypesbuild-security-headers.ts— pure builder functionnonce.ts—generateNonce()cryptographic helpernext/index.ts— Next.js middleware adapter +getNonce()fromheaders()tanstack/index.ts— TanStack Start adapter
- New module
core-shared/rate-limit/containing:rate-limit.interface.ts—IRateLimit,RateLimitBudget,RateLimitDecisiontypesnoop-rate-limit.ts+ sibling testin-memory-rate-limit.ts+ sibling testwith-rate-limit.ts— wrapper attachingRateLimitedbrand
- 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: IRateLimitarg; composewithRateLimitinnermost when manifest declaresrateLimit.length > 0
- Accept optional
- Extension to
core-shared/conformance/assert-bindings.ts:- Require
RateLimitedbrand whenmanifest.useCases[name].rateLimit.length > 0
- Require
- Extension to
core-shared/conformance/define-feature.ts:- Type
UseCaseManifest.rateLimit?: RateLimitBudget[]
- Type
- Extension to
core-shared/di/bind-context.ts:BindContext.rateLimit?: IRateLimit(defaults toNoopRateLimitat app aggregator level)
- New module
@repo/core-testingadditions:recording-rate-limit.ts— capturesconsume+resetinvocations
@repo/core-eslintadditions:- New rule
no-undeclared-rate-limit.js(warn severity) _manifest-ast.jsparser gainsrateLimitfield extractionplugin.js+base.jsregister the rule at warn
- New rule
packages/auth/modifications:feature.manifest.ts— addrateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]tosignInapplication/use-cases/sign-in.use-case.ts— body invokes bothrateLimit.consumecalls; deps signature gainsrateLimit: IRateLimitdi/bind-production.ts+bind-dev-seed.ts— passctx.rateLimit(ornew NoopRateLimit()fallback) intowireUseCasefor signIn
apps/web-nextmodifications:middleware.ts— invokescore-shared/security/nextmiddleware; chains with existing auth checksinstrumentation-client.ts— reads nonce viagetNonce(), passes toSentry.init({ ..., transportOptions, integrations })app/layout.tsx— threads nonce into<Script>tags (Sentry, analytics, any inline scripts)
apps/web-tanstackmodifications:app.config.ts— register server middleware fromcore-shared/security/tanstack- Equivalent client init file (
src/client.tsxor framework convention) — nonce-aware Sentry init
apps/cmsmodifications:- Payload config — wire Express middleware from
core-shared/security(cms is server-side only; no nonce concern)
- Payload config — wire Express middleware from
.github/workflows/release-please.ymlmodifications:- 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
- New:
docs/glossary.md:- Entries:
IRateLimit,RateLimitedbrand,withRateLimit,SecurityHeadersConfig,buildSecurityHeaders,SBOM,nonce(CSP context)
- Entries:
CLAUDE.md+docs/guides/conformance-quickref.md:- Rule count bump: 12 → 13
- New manifest field
rateLimitdocumented 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
consumecalls is the actual work; outer layers wrap the work, including the rate-limit check. - Spans should include rate-limit decision latency for observability — if
withSpanwere insidewithRateLimit, 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
RateLimitedbrand whenrateLimit.length > 0 - New manifest channel doesn't require a new optional core (
core-shared/rate-limitis 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 togetNonce()helper - Adapter tests for
core-shared/security/tanstack: equivalent
- Unit tests on
core-shared/rate-limit:NoopRateLimittest:consumealways returns{ allowed: true },resetis no-opInMemoryRateLimittest: tracks per-bucket counts correctly, decrements on consume, resets at window boundary, resets to budget on explicitresetcall, concurrent consumes are safe (single-threaded JS — straightforward)RecordingRateLimittest: captures invocation arguments verbatimwithRateLimitwrapper test:RateLimitedbrand attached, factory invocation passthrough preserved, composes with other wrappers
assertFeatureConformanceextension:- Synthetic manifest fixture with
rateLimit: [{ name: "ip", ... }]but nowithRateLimitat bind: fails withConformanceErrornaming the missing brand
- Synthetic manifest fixture with
no-undeclared-rate-limitESLint rule:- RuleTester fixtures parallel to
no-undeclared-audit.test.js - Passes when call's
budgetNamematches declared - Fires on undeclared
budgetName - Fires on declared-but-unused (warn)
- No-op on non-use-case files
- RuleTester fixtures parallel to
auth.signInintegration:- Existing signIn test extended: with
RecordingRateLimit, assert dual-consume captured; withInMemoryRateLimitset to budget 1, second call fails withTooManyRequestsError
- Existing signIn test extended: with
- App integration:
apps/web-next/middleware.test.ts(or equivalent): assertion that all six headers present in response, CSP shape correct forNODE_ENV=production+NODE_ENV=development, nonce header present in response- Manual verification:
pnpm build && pnpm start, hitlocalhost: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.jsonsucceeds and produces valid CycloneDX JSON - End-to-end verification: merge a release-please PR, confirm SBOM asset appears on the resulting GitHub release
- Verify locally:
- Coverage: all new modules join L0 vitest thresholds per their
coverage.bands. L1pnpm coverage:diffgates 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/reactfrom ADR-024 (ships React provider as subpath)
- Wrapper + brand pattern:
Open questions
- Q1: Should
InMemoryRateLimithonor TTL viasetTimeoutor check-at-read for bucket expiry? — Recommended: check-at-read. setTimeout floods the event loop with timers in busy apps. Check-at-read on everyconsumecall evaluatesnow() > resetAtand resets if expired. Single test case: assert bucket resets afterwindowelapses with synthetic clock injection. - Q2: Does
withRateLimitshort-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 callsconsume(...)explicitly and decides whether to throwTooManyRequestsErroror degrade gracefully. Mirrors the audit pattern (auditLog.recorddoesn't decide flow control). - Q3: Should the security headers builder validate that
allowedConnectOriginsentries are well-formed URLs? — Recommended: yes, withURLconstructor parse. ThrowsInvalidSecurityHeadersConfigat app boot if an allowlisted origin is malformed. Catches typos early. Documented indocs/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 fromevent.node.req.headers["x-nonce"]after setting it. Document the framework-specific extraction indocs/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
IRateLimitreference impl — consumer wires via ADR-022 library evaluation (@upstash/ratelimitor 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/storybookCSP — Storybook tooling requires'unsafe-eval'; explicit deferralIRateLimitv2: token-bucket algorithm vs fixed-window — InMemoryRateLimit uses fixed-window for simplicity; consumer's production backend likely uses token-bucket; documented as such- CSP nonce reuse strategy — every request gets a fresh nonce; no reuse across requests. Don't optimize prematurely.
- HSTS preload list submission —
preloaddirective 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'sauth.signInrate-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 +wireUseCaseextension →auth.signInbackfill → 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).