Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/web-staging-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 13 additions & 32 deletions web/scripts/deploy-browser-staging.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
Loading