diff --git a/.github/workflows/web-staging-deploy.yml b/.github/workflows/web-staging-deploy.yml index 78f81172..2bd07b5f 100644 --- a/.github/workflows/web-staging-deploy.yml +++ b/.github/workflows/web-staging-deploy.yml @@ -32,6 +32,16 @@ jobs: cache: npm cache-dependency-path: web/package-lock.json + # web/ pulls `eyecons` straight from GitHub, so `npm ci` has to resolve + # that repo's own floating devDependencies from the live registry rather + # than from our lockfile. A peer conflict up there (eslint 10 against + # plugins that cap at 9) walks npm 10's arborist into a null node and it + # dies with "Cannot read properties of null (reading 'edgesOut')". npm 11 + # resolves the same tree fine. Node 22 still bundles npm 10, so pin the + # newer npm here until the git dependency is vendored away. + - name: Upgrade npm + run: npm install -g npm@11 + - name: Install dependencies run: npm ci diff --git a/web/package.json b/web/package.json index f96cf2c7..d7b28ecc 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,7 @@ "check:anchor-wasm": "node scripts/build-anchor-wasm.mjs --check", "build:doc-runtime": "node scripts/build-doc-runtime.mjs", "check:doc-runtime": "node scripts/build-doc-runtime.mjs --check", - "deploy:staging": "node scripts/deploy-browser-staging.mjs", + "deploy:staging": "tsx scripts/deploy-browser-staging.mjs", "check": "npm run generate:icons && svelte-check --tsconfig ./tsconfig.json", "test": "node scripts/run-tests.mjs", "test:unit": "node scripts/run-tests.mjs", diff --git a/web/scripts/deploy-browser-staging.mjs b/web/scripts/deploy-browser-staging.mjs index b6aeac85..16a3f973 100644 --- a/web/scripts/deploy-browser-staging.mjs +++ b/web/scripts/deploy-browser-staging.mjs @@ -5,6 +5,8 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { buildContentSecurityPolicy } from '../src/lib/hosted/csp.ts'; + const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const relayOrigin = 'https://relay-staging.attn.sh'; const webOrigin = 'https://staging.attn.sh'; @@ -65,39 +67,18 @@ async function verifyBuild(expectedRelayOrigin) { return entryPath; } -async function themePreflightSha256() { - // The worker's CSP allows the inline theme-preflight script by hash (see - // src/lib/hosted/csp.ts). Read the constant from its source of truth so the - // pinned policy below cannot drift from it again — hardcoding the old - // hash-less directive here is what made this verifier reject a healthy - // deploy. - const source = await readFile( - path.join(webRoot, 'src', 'lib', 'hosted', 'theme-preflight.ts'), - 'utf8', - ); - const match = source.match(/THEME_PREFLIGHT_SHA256 = '(sha256-[A-Za-z0-9+/=]+)'/u); - if (!match) throw new Error('THEME_PREFLIGHT_SHA256 not found in theme-preflight.ts'); - return match[1]; -} - async function verifyLiveDeployment(expectedWebOrigin, expectedRelayOrigin, expectedEntryPath) { - const preflightHash = await themePreflightSha256(); - const requiredCspDirectives = new Set([ - "default-src 'none'", - "base-uri 'none'", - `connect-src 'self' ${expectedRelayOrigin} ${expectedRelayOrigin.replace('https:', 'wss:')}`, - "font-src 'self' data:", - "form-action 'none'", - "frame-ancestors 'none'", - "frame-src 'self' blob: data:", - "img-src 'self' blob: data:", - "manifest-src 'self'", - "media-src 'self' blob: data:", - "object-src 'none'", - `script-src 'self' 'wasm-unsafe-eval' '${preflightHash}'`, - "style-src 'self' 'unsafe-inline'", - "worker-src 'self'", - ]); + // Derive the expected policy from the same function the worker serves it + // from, rather than restating the directives here. A hardcoded copy has now + // rejected a healthy deploy twice — once when the script-src hash landed, + // once when img-src gained `https:` for remote document images — and in both + // cases the deploy itself was fine and only this check was stale. + const requiredCspDirectives = new Set( + buildContentSecurityPolicy(expectedRelayOrigin) + .split(';') + .map((directive) => directive.trim()) + .filter(Boolean), + ); const deadline = Date.now() + 60_000; let lastFailure = 'deployment did not become readable'; while (Date.now() < deadline) {