import { fileURLToPath, URL } from 'node:url'; import { defineConfig, type Plugin } from 'vite'; import { crx } from '@crxjs/vite-plugin'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; import manifest from './manifest.json'; /** * Plan 04-02 CSP hardening (RESEARCH §Q1, Option β — refined #3). * * Surgically excises the `new Function("" + I)` CSP-unsafe literal from JSZip's * pre-bundled `dist/jszip.min.js` after Vite has rolled it into the SW chunk. * * Context: JSZip's `package.json` `"browser"` field maps `./lib/index` → * `./dist/jszip.min.js`, a pre-bundled CJS distribution that ships its own * internal CommonJS module registry (including the `setimmediate` polyfill in * slot 54). The pre-bundled form bypasses BOTH: * - `vite-plugin-node-polyfills` `exclude: ['setimmediate']` (which only * filters `node-stdlib-browser`-aliased polyfills); * - Our `resolve.alias.setimmediate` (which only intercepts bare-specifier * `require("setimmediate")` calls, not jszip's internal `r("setimmediate")` * against its own slot table). * * Alternative considered (Option α — force JSZip's unbundled `lib/index.js` * entry via `resolve.alias.jszip`): tested empirically 2026-05-21 against the * UAT harness; broke A30+ assertions because JSZip's async-write pipeline * (readable-stream-browser) was not transitively wired correctly through Vite's * resolver. Reverted. * * The runtime IS already CSP-safe under the current bundle: the SW chunk's * top-of-module `queueMicrotask`-based polyfill prelude lands BEFORE JSZip is * imported, so the setimmediate IIFE's `if(!s.setImmediate){...}` guard skips * the offending body at runtime. The `new Function` literal becomes statically * present but runtime-unreachable. This plugin removes the static literal so * the build-gate `tests/build/no-new-function-in-sw-chunk.test.ts` passes * AND no future static-analysis auditor (or tightened MV3 CSP) flags it. * * Replacement: `(I=new Function(""+I))` → `(I=function(){})` — a no-op IIFE. * Since the parent `typeof I!="function"&&` guard predicates the assignment on * I being a non-function, and our prelude ensures the entire IIFE body never * executes, the replacement is observably equivalent in our codepath. * * Why not the empty arrow `()=>{}`: keeps the same `function` keyword family * as the surrounding code (mangler-friendly + bytecount-parity). * * Scope: applies ONLY to chunks with `setImmediate`-pattern content (a guarded * check before the substring replacement avoids touching unrelated chunks). * * References: * - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §Q1 * - .planning/phases/04-harden-clean-up-optional/04-02-PLAN.md threat model * T-04-02-01 (Elevation of Privilege — `new Function` literal) * - node_modules/jszip/lib/utils.js:7 (the upstream `require("setimmediate")` site) * - node_modules/setimmediate/setImmediate.js (the upstream polyfill source) */ function stripSetimmediateNewFunction(): Plugin { // Exact substring from `setimmediate` package's setImmediate.js, present // verbatim in JSZip's pre-bundled output. The replacement is byte-shorter // (no `new Function("" + I)` constructor invocation); we accept the size // delta in favor of pinning a unique substring for the find-and-replace. // If this string changes in a future JSZip / setimmediate upgrade, the // build-gate test catches the regression (count goes back to 1). const NEEDLE = '(I=new Function(""+I))'; const REPLACEMENT = '(I=function(){})'; return { name: 'plan-04-02-strip-setimmediate-new-function', enforce: 'post', generateBundle(_options, bundle) { for (const fileName of Object.keys(bundle)) { const chunk = bundle[fileName]; if (chunk.type !== 'chunk') continue; if (!chunk.code.includes(NEEDLE)) continue; chunk.code = chunk.code.split(NEEDLE).join(REPLACEMENT); } }, }; } export default defineConfig({ plugins: [ crx({ manifest, contentScripts: { injectCss: false, }, }), nodePolyfills({ include: ['buffer'], // Plan 04-02 CSP hardening (RESEARCH §Q1 + Plan 01-12 deferred-items flip): // exclude the transitive `setimmediate` polyfill that vite-plugin-node-polyfills // bundles alongside `buffer`. NOTE: this only filters node-stdlib-browser-aliased // polyfills; JSZip's direct `require("setimmediate")` in its pre-bundled // dist/jszip.min.js bypasses this exclusion. See stripSetimmediateNewFunction() // plugin below for the post-bundle excision of the JSZip-side literal. exclude: ['setimmediate'], globals: { Buffer: true, global: false, process: false, }, protocolImports: false, }), // Plan 04-02 Wave 1 — post-bundle excision of the JSZip-internal // setimmediate polyfill's `new Function("" + I)` literal. See the plugin // definition + multi-paragraph rationale above (file top). stripSetimmediateNewFunction(), ], resolve: { alias: { ebml: 'ebml/lib/ebml.js', // Plan 04-02 CSP hardening (RESEARCH §Q1, refined): redirect the // upstream `setimmediate` npm package (which contains a CSP-unsafe // `new Function("" + I)` fallback for the never-called string-form // `setImmediate(string)`) to a local stub that installs a safe // `queueMicrotask`-based polyfill. JSZip's `require("setimmediate")` // at `node_modules/jszip/lib/utils.js:7` bypasses // vite-plugin-node-polyfills' `exclude` option, so the alias is the // canonical interception point. See src/shared/setimmediate-stub.ts // for the polyfill source + threat-mitigation rationale (T-04-02-01). // Pinned by tests/build/no-new-function-in-sw-chunk.test.ts (1 → 0). setimmediate: fileURLToPath(new URL('./src/shared/setimmediate-stub.ts', import.meta.url)), }, }, // `define` text-replaces token symbols at bundle time. We declare // __MOKOSH_UAT__ as `false` in the production config so the gated // hook-import branches in src/background/index.ts + src/offscreen/ // recorder.ts are static dead branches that Rollup tree-shakes. The // test-only build (vite.test.config.ts) overrides this to `true`. We // chose a dedicated token rather than gating on import.meta.env.MODE // because vitest also uses MODE='test' by default — gating on MODE // would activate the hooks during unit tests and overwrite the // vi.fn() chrome.* mocks the existing 83-test baseline relies on. // Reference: https://vite.dev/config/shared-options.html#define define: { __MOKOSH_UAT__: 'false', // Plan 01-12 Wave 5 (RESEARCH §12 + D-09 spirit-satisfaction): // Defensive token reserved for any future inline smoke-mode check. // Currently the smoke harness lives entirely in `smoke.sh` outside // Vite's input set — verified by `grep -rn 'smoke\|SMOKE\|data:text/html' src/` // returning empty. Activated by setting the env var `VITE_DEV=1` // before invoking `vite` / `vite build`; defaults to `false` so the // gated branch (if any future plan adds one) is statically tree- // shaken out of production. See scripts/README.md for the wider // dev-script isolation invariant. __VITE_DEV__: JSON.stringify(process.env.VITE_DEV === '1'), }, build: { // Plan 01-11: bump from default ES2020 to ES2022 so gated top-level // await (`if (__MOKOSH_UAT__) { await import(...); }` in // src/background/index.ts + src/offscreen/recorder.ts) compiles. // The extension targets MV3 (Chrome ≥88); top-level await landed in // Chrome 89 / Edge 89 / Firefox 89 / Safari 15 per MDN — comfortably // inside the MV3 compatibility envelope. // Reference: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/await#browser_compatibility // Reference: https://vite.dev/config/build-options.html#build-target target: 'es2022', rollupOptions: { input: { offscreen: 'src/offscreen/index.html', // Plan 01-10 D-17-onboarding: welcome page bundled into both // production and test builds. The page is reached via // chrome.runtime.getURL('src/welcome/welcome.html') from the // SW's openWelcomeIfFirstInstall helper; it requires the // manifest's web_accessible_resources block to be navigable. welcome: 'src/welcome/welcome.html', }, }, }, });