// Loads a release-candidate zip under the *store* extension id and checks the // things that only go wrong under that identity. // // Loading dist/ unpacked cannot catch a v1.2.1-class bug. The unpacked build // runs under the dev id pinned by the manifest `key`, and that id was in the // bundled OAuth metadata all along — only the store id was missing, so // sign-in was broken for every store user while every local test passed. // // The store id is a hash of the publisher's public key, and that key is // published inside every CRX the store serves (oauth/store-key.txt, verified // here against oauth/extension-ids.json before it is used). Stamping it into a // release zip's manifest makes Chrome compute the store id for a locally // loaded build, so the release candidate can be exercised under its real // identity before anything is uploaded. // // Usage: // node scripts/rc-load.mjs # newest zip in release/ // node scripts/rc-load.mjs release/x.zip # a specific one // node scripts/rc-load.mjs --interactive # headful, stays open to sign in // // Branded Chrome >= 137 ignores --load-extension, so the extension is loaded // over CDP with Extensions.loadUnpacked. The store copy of the extension // cannot be installed at the same time (same id), which is why this always // uses a throwaway profile. import { execFileSync } from 'node:child_process' import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { withChrome } from './cdp.mjs' import { extensionIdFromKey, redirectUriFor } from './check-oauth-metadata.mjs' /** Newest release zip by version, not by mtime — rebuilds must not reorder. */ export function latestReleaseZip(names) { const parsed = names .map((name) => ({ name, m: /^substandard-v(\d+)\.(\d+)\.(\d+)\.zip$/.exec(name) })) .filter((e) => e.m) .map(({ name, m }) => ({ name, v: [Number(m[1]), Number(m[2]), Number(m[3])] })) if (parsed.length === 0) return undefined parsed.sort((a, b) => b.v[0] - a.v[0] || b.v[1] - a.v[1] || b.v[2] - a.v[2]) return parsed[0].name } /** * What an `oauth-authorize` probe says about the bundled metadata. The * redirect_uri check in @atproto/oauth-client runs before any identity * lookup, so a deliberately unresolvable handle separates the two failures: * anything that is not "Invalid redirect_uri" means the metadata covered this * build's own id, which is the whole question. */ export function classifyAuthProbe(result) { const message = String(result?.settled?.__error ?? result?.threw ?? '') if (/invalid redirect_uri/i.test(message)) return 'invalid-redirect' if (result?.timedOut) return 'inconclusive' return 'ok' } function extract(zip, dest) { try { execFileSync('unzip', ['-oq', zip, '-d', dest], { stdio: 'pipe' }) return } catch { // no unzip; fall through to python3, as deploy-ext.sh does for zipping } execFileSync('python3', ['-c', 'import sys,zipfile;zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])', zip, dest]) } // Runs inside the extension's own service worker. Creating the offscreen // document and messaging it is exactly what startSignIn does; the bogus handle // stops before any consent window. const PROBE = `(async () => { try { await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['LOCAL_STORAGE'], justification: 'release candidate probe', }) } catch (err) { if (!/single offscreen document/i.test(String(err))) { return JSON.stringify({ id: chrome.runtime.id, offscreenError: String(err) }) } } const ask = chrome.runtime .sendMessage({ target: 'offscreen', type: 'oauth-authorize', handle: 'rc-probe.invalid' }) .then((settled) => ({ settled })) .catch((err) => ({ threw: String(err) })) const timed = new Promise((r) => setTimeout(() => r({ timedOut: true }), 15000)) return JSON.stringify({ id: chrome.runtime.id, authProbe: await Promise.race([ask, timed]) }) })()` async function main(argv) { const root = resolve(import.meta.dirname, '..') const interactive = argv.includes('--interactive') const zipArg = argv.find((a) => !a.startsWith('--')) const releaseDir = join(root, 'release') let zip = zipArg && resolve(zipArg) if (!zip) { const newest = existsSync(releaseDir) ? latestReleaseZip(readdirSync(releaseDir)) : undefined if (!newest) { console.error('rc-load: no release zip found — run scripts/deploy-ext.sh first') process.exit(1) } zip = join(releaseDir, newest) } if (!existsSync(zip)) { console.error(`rc-load: ${zip} does not exist`) process.exit(1) } const ids = JSON.parse(readFileSync(join(root, 'oauth/extension-ids.json'), 'utf8')) const storeKey = readFileSync(join(root, 'oauth/store-key.txt'), 'utf8').trim() const derived = extensionIdFromKey(storeKey) if (derived !== ids.store) { console.error( `rc-load: oauth/store-key.txt derives ${derived}, but extension-ids.json declares ${ids.store}.` + ' Re-extract the publisher key from the store CRX.', ) process.exit(1) } const work = mkdtempSync(join(tmpdir(), 'substandard-rc-')) const extDir = join(work, 'ext') const profile = join(work, 'profile') extract(zip, extDir) const manifestPath = join(extDir, 'manifest.json') const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) manifest.key = storeKey writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) console.log(`rc-load: ${zip}`) console.log(` version ${manifest.version}, stamped with the store identity ${ids.store}`) const problems = [] const probe = await withChrome( { extensionDir: extDir, profile, headless: !interactive }, async ({ inWorker, extensionId }) => { if (extensionId !== ids.store) { problems.push(`Chrome loaded this build as ${extensionId}, not the store id ${ids.store}`) } const raw = await inWorker(PROBE) const parsed = typeof raw === 'string' ? JSON.parse(raw) : undefined if (!parsed) problems.push(`the service worker did not answer the probe: ${raw}`) if (interactive) { console.log('\nrc-load: Chrome is open with the release candidate installed.') console.log(' Pin the toolbar icon and sign in for real; Ctrl-C here when done.') await new Promise(() => {}) } return parsed }, ) if (probe?.offscreenError) problems.push(`offscreen document failed: ${probe.offscreenError}`) else if (probe) { const verdict = classifyAuthProbe(probe.authProbe) if (verdict === 'invalid-redirect') { problems.push( `sign-in is broken in this artifact: authorization was refused with "Invalid redirect_uri".` + ` This build does not carry ${redirectUriFor(ids.store)} in its bundled` + ' oauth/client-metadata.json — rebuild from a tree that does.', ) } else if (verdict === 'inconclusive') { problems.push('the authorization probe timed out; run again, or with --interactive') } else { console.log(` sign-in reached identity resolution: ${probe.authProbe?.settled?.__error}`) } } rmSync(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) if (problems.length > 0) { console.error(`\nrc-load: FAILED (${problems.length} problem(s)):`) for (const p of problems) console.error(` - ${p}`) process.exit(1) } console.log(`\nrc-load: OK — ran as ${ids.store} and authorization got past its own metadata`) } if (process.argv[1] === import.meta.filename) await main(process.argv.slice(2))