From d651768c9bb027f99e20912912a5b6d7b089d5ca Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:09:03 -0500 Subject: [PATCH 1/2] Pin npm 11 for the staging deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web/package.json depends on `eyecons` as a git dependency, so `npm ci` cannot install it from the lockfile alone — it clones the repo and resolves that project's own floating devDependencies against the live registry. A recent publish put eslint 10 in that tree alongside plugins whose peer ranges stop at 9, and npm 10's arborist walks the resulting graph into a null node: npm error Cannot read properties of null (reading 'edgesOut') Nothing in this repo moved; the registry did. Reproduced locally against an unchanged web/package-lock.json: npm 10.9.4 fails, npm 11.14.1 installs cleanly. CI stayed green through this because ci.yml runs Node 20 while the deploy job runs Node 22 for wrangler — different bundled npm, so the deploy is the only job that sees the bug. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW --- .github/workflows/web-staging-deploy.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From b2e2a0a71a9cfb5cde75b1ba0ffcd3df0ec0906e Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:24:30 -0500 Subject: [PATCH 2/2] Derive the deploy check's CSP from the policy it verifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging deploy succeeded and then failed its own propagation check: staging propagation verification timed out: live CSP does not match the pinned staging policy The worker served `img-src 'self' blob: data: https:` — the `https:` that "Allow approved remote document images" added — while the verifier still held a hand-copied `img-src 'self' blob: data:`. The deployment was healthy; only the copy was stale. This is the second time the duplicate list has rejected a good deploy; the first was the script-src preflight hash, which is why the hash alone was already being read from source. Take the whole policy from buildContentSecurityPolicy() instead, so the check cannot drift from the worker again. That makes the script import TypeScript, so it runs under tsx (already a devDependency, already used by the live-test scripts). Verified against the deployed staging worker: derived and live policies match on all 14 directives. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW --- web/package.json | 2 +- web/scripts/deploy-browser-staging.mjs | 45 ++++++++------------------ 2 files changed, 14 insertions(+), 33 deletions(-) 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) {