Restore point: 52f0301 (chore: snapshot the unpublished CSP plugins monorepo rewrite).
Do not start this plan until the workspace baseline (typed-directives + core, current deps) is in place and this document is explicitly picked up.
Do not reopen these unless something is broken:
| Tool | Choice | Why |
|---|---|---|
| Node | 24 local (.node-version); CI 22 / 24 / 26; engines.node >=22.13 |
22 Maintenance LTS, 24 Active LTS, 26 Current (LTS 2026-10-28). |
| pnpm | 12.4.1 (packageManager pin) |
Rust CLI; named catalogs + injected workspace packages. catalogMode: strict only gates the default catalog — add deps with pnpm add <pkg> --save-catalog-name <name>. Install from latest-12 until npm latest moves. |
| CI orchestrator | GitHub Actions + pnpm/setup@v2 |
Installs packageManager. Node version comes from the job matrix (runtime: node@22/24/26), not from devEngines.runtime. |
| Monorepo runner | pnpm -r / --filter |
Default pipeline is four packages. Playground and e2e-tests/test-app are extra workspace members. Turbo/Nx/moon are extra moving parts. |
| Lint | OxLint + oxfmt (not type-aware yet) | Same oxc family as obuild. Type-aware lint needs TypeScript 7 / oxlint-tsgolint. |
| Markdown | markdownlint-cli2 | Linter. oxfmt can wrap Markdown via bundled Prettier, but that is not a lint replacement and native Markdown formatting is still unshipped — keep ignoring *.md in oxfmt. |
| Spelling | cspell | Root pnpm spell; CI runs it. |
| Release | Deferred (Monup is the likely replacement) | Do not wire npm/JSR publish until packages are ready. Do not reintroduce bumpp/changelogithub. |
| TypeScript | 6.0.x (not 7) | Last JS-based compiler; stableTypeOrdering + no baseUrl for TS 7. 7.0 is still tsgo. |
| Dual-publish | npm dist + JSR source (typed-directives first) |
See docs/publishing.md. |
Pin files: package.json#packageManager, package.json#devEngines, .node-version.
Keep this DAG: typed-directives → core → (shared, basic-fscache) → (cli | unplugin) → e2e.
Do not merge shared into core, merge cli into unplugin, add @csp-plugins/headers, or start packages/adapters/ until Area 5. CspDirectiveHeaders stays in typed-directives. CspPluginOptions currently lives in packages/shared/src/types.ts — Area 3 should edit that file or move the type then, not fork a second options interface. Area 5 emitters belong in packages/adapters/, not a package named headers.
pnpm build is the four-package baseline. pnpm build:all also compiles cli + unplugin so their export maps fail in CI; their product tests stay out of pnpm test. packages/e2e-tests/test-app is a workspace member (@csp-plugins/e2e-test-app).
Do this on the new product branch before first publish. Do not treat the cleanup commit as dropping these for good.
| Restore | How | Why |
|---|---|---|
| Unit-test coverage | Vitest coverage (@vitest/coverage-v8 or the then-current provider) + integrated CI reports (GitHub Actions / similar). Not Codecov. |
Core libraries need published coverage. |
| API docs | TypeDoc or equivalent, starting with @csp-plugins/typed-directives |
The pre-monorepo package shipped TypeDoc HTML; restore that surface. Keep the existing packages/typed-directives/CHANGELOG.md (csp-typed-directives 1.x history). |
| Contributor list | Automate inclusion (all-contributors or a GitHub-native equivalent) | No hand-maintained empty list. |
| Release automation | Monup (version → changelog → npm/JSR → GitHub). Local version/changelog, CI publish. |
Replaces the deleted copy-pasted scripts/release.ts / changelog.ts. |
A developer can add one plugin (or one CLI invocation) and get a strict Content-Security-Policy with hashes for inline script/style, optional SRI, and no 'unsafe-inline' by default — then drop the same declarative header list onto common hosts and servers without hand-writing nginx/Netlify/Vercel config.
@csp-plugins/typed-directivesalready maps typed directives toCspDirectiveHeaders.@csp-plugins/corealready parses HTML, hashes/nonces, injects<meta>, and can hash externals.- CLI already writes
csp-headers.json. Bundler plugins only write.csp-manifest. - Plugins do not transform HTML (
generateCspis unused). Manifest hashes are often raw base64. Auto-manifest still allows'unsafe-inline'.
Area 1 (hash contract)
← Area 2 (strict defaults in core)
← Area 3 (Vite/unplugin HTML + headers at build)
← Area 4 (CLI consumes same contract; drop unsafe-inline auto-manifest)
← Area 5 (host adapters: Netlify, Vercel, nginx, Express)
Areas 3 and 5 conflict if both invent header-file formats; Area 1 owns the canonical CspDirectiveHeaders + hash string contract.
- Hash string format (
sha256-<b64>vs raw b64) — Area 1. Do not “fix” this inside plugin or adapter PRs. CSPResult.headersandgenerateHeaders— Area 2.- Vite
transformIndexHtml/generateCsp— Area 3. CLI post-process stays valid as a fallback. csp-headers.jsonshape — Area 1/5. Adapters must consumeCspDirectiveHeaders, not invent a second JSON schema.
- Goal: Every hash that appears in a manifest, integrity attribute, or CSP source is
sha256|sha384|sha512-<base64>.getHeaders()is the only public header map. - Depends on: nothing (baseline)
- Out of scope: plugin HTML transform, host files, changing default directives
- Likely files:
packages/core/lib/crypto.ts,packages/shared/src/asset-tracker.ts,packages/core/lib/csp-processor.ts, unit tests in core and a new shared Vitest project (none exists yet) CspDirectiveHeadersstays in@csp-plugins/typed-directives. Area 1 owns the hash string contract, not a new headers package.- Public surface:
function generateHash(content: string, algorithm: ValidCrypto): Promise<`${ValidCrypto}-${string}`>
interface CSPResult {
builder: CspDirectives
headers: CspDirectiveHeaders
nonces: { script?: string; style?: string }
analysis: HTMLAnalysisResult
html?: string
}- Pseudo-code:
generateHashreturns${algorithm}-${b64}; callers stop prefixing a second time.CommonAssetTracker.trackAssetawaits hash generation (ortrackAssetbecomes async /generateManifestwaits on pending hashes). Never write a manifest with a racing undefined hash.- Integrity attributes use the same prefixed string.
- Tests: existing e2e TODOs in
csp-hash-validation.test.ts/csp-integrity-hashing.test.tsbecome assertions, not warnings. Core unit tests check prefix + no double-prefix. - Risks: old manifests without prefix; migrate by detecting missing
sha\d{3}-. - Conflicts with: Area 3 (plugin reads hashes), Area 5 (headers files)
- Goal: Default policy is
'self'+ hashes/nonces.'unsafe-inline'/'unsafe-eval'only behinddevelopment.allowUnsafeInline/allowUnsafeEval. - Depends on: Area 1
- Out of scope: bundler plugins, host adapters, SSR nonce sessions
- Likely files:
packages/core/lib/csp-processor.ts,packages/core/tests/* - Public surface: keep
CSPProcessorOptions; change defaults, not option names. - Pseudo-code:
- When
generateHeadersis true,result.headers = builder.getHeaders(). - When false,
result.headersis empty (existing test). - Default
script-src/style-srcstart as['self']plus computed hashes/nonces. - Do not add remote URLs to
script-srcwhen hashing; hashes/SRI are the allow mechanism (already the intended core behavior).
- When
- Tests: processor tests that currently expect
'unsafe-inline'in prod paths should fail and be rewritten. Dev-mode tests keep the escape hatch. - Risks: sites that relied on unsafe-inline via auto-manifest — that is Area 4.
- Conflicts with: Area 4 defaults
- Goal:
cspVitePlugin()with no extra CLI step injects the CSP meta tag into built HTML and writescsp-headers.jsonnext to output.generateCspdefaults true. - Depends on: Area 2
- Out of scope: Webpack/Rollup/esbuild/Nuxt parity (follow-up PRs), SSR nonces, host-specific files
- Likely files:
packages/unplugin/src/vite.ts,packages/unplugin/src/dev-server-integration.ts,packages/unplugin/src/index.ts(remove Hello Unplugin stub),packages/shared/src/types.ts(CspPluginOptions— do not invent a second options type), e2ecsp-browser-validation.test.ts - Public surface:
interface CspPluginOptions {
trackAssets?: boolean
generateCsp?: boolean // default true
emitHeadersFile?: boolean | string // default 'csp-headers.json'
cspProcessorOptions?: Partial<CSPProcessorOptions>
manifestDir?: string
devServer?: boolean
}- Pseudo-code:
generateBundle/closeBundle: track assets (await hashes).transformIndexHtml(enforce post):CSPProcessor.processHTML(html)→ replace HTML.- If
devServer, reuseDevServerIntegration.processHTMLon the HTML middleware. - Write
CspDirectiveHeadersJSON besideoutDir.
- Tests: browser e2e TODOs become: built
index.htmlhasmeta[http-equiv=Content-Security-Policy], Puppeteer reports zero violations for the test app without'unsafe-inline'. - Risks: Vite HTML transform order vs other plugins; hashed filenames vs HTML src.
- Conflicts with: Area 4 (CLI should no-op if HTML already processed), Area 5 (same headers JSON)
- Goal:
csp-cli dist/remains the post-build path for non-Vite tools. Auto-manifest no longer injects'unsafe-inline'. - Depends on: Area 2
- Out of scope: new host formats (Area 5)
- Likely files:
packages/cli/src/cli.ts, CLI README - Pseudo-code:
- Default baseDirectives:
'self'+ hashes from processor, not unsafe-inline. --headers-outputstill writesgetHeaders()JSON.- If HTML already has a CSP meta from Area 3, replace-or-skip per
metaTagOptions.replaceExisting.
- Default baseDirectives:
- Tests: scenario tests that assume unsafe-inline in auto-manifest must switch to hash-based expectations.
- Conflicts with: Area 2 defaults
- Goal: One declarative
CspDirectiveHeadersobject can be emitted as native config for common hosts. Least developer intervention:--emit netlify,vercelor auto-detect from repo files. - Depends on: Area 1 (header map), Area 3/4 (something actually produces the map)
- Out of scope: every host on earth; start with the set below. No new CSP semantics.
- Likely files: new
packages/adapters/(notpackages/headers/— that name collides withCspDirectiveHeaders), CLI flag--emit, small fixtures - Public surface:
interface HeaderAdapter {
id: 'json' | 'netlify' | 'cloudflare-pages' | 'vercel' | 'firebase' | 'nginx' | 'apache' | 'caddy' | 'express'
emit(headers: CspDirectiveHeaders, options?: { pathPrefix?: string }): string | Record<string, unknown>
}- Pseudo-code (emitters only; do not fetch or scan HTML here):
json → current csp-headers.json
netlify / cloudflare-pages → `_headers`:
/*
Content-Security-Policy: ...
Referrer-Policy: ...
Report-To: ...
vercel → vercel.json { headers: [{ source: '/(.*)', headers: [...] }] }
firebase → firebase.json hosting.headers
nginx → add_header ... always;
apache → Header always set ...
caddy → header { ... }
express → middleware (req, res, next) => { res.set(headers); next() }
- Empty header values (
Report-To: '') are omitted. - Tests: golden files per adapter from a fixed
CspDirectiveHeadersfixture. Round-trip JSON adapter. No live deploys. - Risks: Vercel/Netlify merge vs overwrite of existing
_headers/vercel.json— adapters should merge CSP-related keys, not clobber unrelated headers. - Conflicts with: none if they only consume
CspDirectiveHeaders
- SSR / per-request nonces (GitHub issue #1 on vite-plugin-csp). Static hashes are the zero-config path.
- Parsing URLs embedded in JS (framework-specific).
- Webpack/Rollup/esbuild/Nuxt HTML transform parity after Vite works.
- Reconcile published
csp-typed-directivesnpm package vs workspace package name. - GitHub default-branch switchover: new work lands on isolated
main;lateststays the published vite-plugin-csp / snapshot line until the GitHub default is switched.
CommonAssetTrackerhash generation is fire-and-forget; manifests can miss hashes.packages/unplugin/src/index.tsdefault export is still the unplugin starter stub.generateCspcurrently defaults false inpackages/shared/src/asset-tracker.ts; Area 3 wants Vite default true. Flip the contract in one place, not only the Vite wrapper.- Local rewrite history is not a fast-forward of
origin/latest(published vite-plugin-csp 1.1.2).