From a00cf73d965cc95039666af48b0276fc72cfe6e9 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sun, 11 Oct 2026 16:58:10 -0700 Subject: [PATCH] Repair scoped staging Hosting credential and bundle verification --- docs/officers/STAGING_COMMUNITY.md | 2 ++ scripts/staging-community-hosting.js | 33 +++++++++++++++++++++---- scripts/staging-community-release.js | 4 ++- tests/staging-community-release.test.js | 7 ++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index fe73da5..e4ac0fd 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -43,6 +43,8 @@ Officer review after the specialist confirms deployment: **Undo:** stop the staging review. Ask Dave and the platform specialist to use the reviewed staging rollback or remove the one staged community service. Do not edit a member record, role, payment or production setting yourself. +The specialist's Hosting release uses Dave's existing club login for a short-lived credential kept in memory. It does not require a new saved Firebase CLI login or key file. A later documentation or release-script fix may reuse the Function only after its downloaded source exactly matches the reviewed bundle; it does not silently deploy more backend code. + **Escalation:** Dave/platform specialist for deployment, access, uncertain results or recovery. Future officer dues confirmation, event editing, paid registration/refunds, and production release require separate work. The specialist still provisions and removes test fixtures; officers do not use a terminal or change source. ## Cost and scope diff --git a/scripts/staging-community-hosting.js b/scripts/staging-community-hosting.js index e533d80..cc610aa 100644 --- a/scripts/staging-community-hosting.js +++ b/scripts/staging-community-hosting.js @@ -2,23 +2,44 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); +const { createHash } = require('node:crypto'); const { execFileSync } = require('node:child_process'); const { verifyCurrentSource } = require('./staging-profile-authority'); -const { functionSpec, validateHostedFunction, jsonRequest } = require('./staging-community-release'); +const { createBundle, functionSpec, validateHostedFunction, jsonRequest } = require('./staging-community-release'); const { verifyExecutableArtifact } = require('./firebase-hosting-contract'); const PROJECT = 'run-mprc-staging'; const ACCOUNT = 'runmprc@gmail.com'; const check = (value) => { if (!value) throw new Error('staging_community_hosting_failed'); }; +function validateBundle(native, digest) { + check(`${native.labels?.['bundle-a']}${native.labels?.['bundle-b']}` === digest); + validateHostedFunction(native, functionSpec(native.labels?.['source-sha'], digest, 'https://storage.googleapis.com/synthetic')); +} +async function ownerToken() { + const token = execFileSync('/opt/homebrew/bin/gcloud', ['auth', 'print-access-token', `--account=${ACCOUNT}`, `--project=${PROJECT}`], { encoding: 'utf8' }).trim(); + const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${token}` }, redirect: 'error', signal: AbortSignal.timeout(15000), + }); + check(response.ok && (await response.json()).email === ACCOUNT); + return token; // No ADC fallback, persistent login, credential file or printed token. +} async function publish() { const root = path.resolve(__dirname, '..'); check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === ''); const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); const source = { GITHUB_SHA: sha, GH_TOKEN: execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim() }; await verifyCurrentSource(source); - const token = execFileSync('/opt/homebrew/bin/gcloud', ['auth', 'print-access-token', `--account=${ACCOUNT}`, `--project=${PROJECT}`], { encoding: 'utf8' }).trim(); + const token = await ownerToken(); const native = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/us-central1/functions/communityActions`, token); - const digest = `${native.labels?.['bundle-a']}${native.labels?.['bundle-b']}`; - validateHostedFunction(native, functionSpec(sha, digest, 'https://storage.googleapis.com/synthetic')); + const digest = createBundle(root).digest; + validateBundle(native, digest); + // Script/docs-only commits do not require another paid Function build. Require + // ancestor provenance AND native source bytes identical to the green main bundle. + execFileSync('git', ['merge-base', '--is-ancestor', native.labels['source-sha'], sha], { cwd: root }); + const download = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/${native.name}:generateDownloadUrl`, token, 'POST', {}); + const archiveUrl = new URL(download.downloadUrl); + check(archiveUrl.protocol === 'https:' && archiveUrl.hostname === 'storage.googleapis.com'); + const archive = await fetch(download.downloadUrl, { redirect: 'error', signal: AbortSignal.timeout(30000) }); + check(archive.ok && createHash('sha256').update(Buffer.from(await archive.arrayBuffer())).digest('hex') === digest); const configResponse = await fetch(`https://${PROJECT}.web.app/__/firebase/init.json`, { redirect: 'error', signal: AbortSignal.timeout(15000) }); check(configResponse.ok); const config = await configResponse.json(); check(config.projectId === PROJECT && typeof config.appId === 'string'); @@ -47,7 +68,8 @@ async function publish() { fs.writeFileSync(filename, JSON.stringify({ hosting: { site: PROJECT, public: path.join(root, 'build'), ignore: ['firebase.json', '**/.*', '**/node_modules/**'], rewrites: [{ source: '**', destination: '/index.html' }] } })); await verifyCurrentSource(source); check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === ''); - execFileSync(path.join(root, 'node_modules/.bin/firebase'), ['deploy', '--only', 'hosting', '--config', filename, '--project', PROJECT, '--account', ACCOUNT, '--non-interactive'], { cwd: root, env, stdio: 'pipe', timeout: 180000, maxBuffer: 2_000_000 }); + const deployEnv = { ...env, FIREBASE_TOKEN: await ownerToken(), GOOGLE_CLOUD_QUOTA_PROJECT: PROJECT }; + execFileSync(path.join(root, 'node_modules/.bin/firebase'), ['deploy', '--only', 'hosting', '--config', filename, '--project', PROJECT, '--non-interactive'], { cwd: root, env: deployEnv, stdio: 'pipe', timeout: 180000, maxBuffer: 2_000_000 }); const response = await fetch(`https://${PROJECT}.web.app/community-release.json`, { cache: 'no-store', redirect: 'error' }); check(response.ok); const marker = await response.json(); check(marker.sourceSha === sha && marker.project === PROJECT && marker.functionBundle === digest); @@ -55,3 +77,4 @@ async function publish() { } finally { fs.rmSync(directory, { recursive: true }); } } if (require.main === module) publish().catch(() => { process.stderr.write('staging_community_hosting_failed\n'); process.exitCode = 1; }); +module.exports = { validateBundle }; diff --git a/scripts/staging-community-release.js b/scripts/staging-community-release.js index 2cc6516..d78911e 100644 --- a/scripts/staging-community-release.js +++ b/scripts/staging-community-release.js @@ -111,7 +111,9 @@ async function release(root) { check(final.functions.length === 3 && !final.nextPageToken && !final.unreachable?.length); for (const original of inventory.functions.filter((fn) => fn.entryPoint !== TARGET)) { const unchanged = final.functions.find((fn) => fn.name === original.name); - check(unchanged?.versionId === original.versionId && JSON.stringify(unchanged.labels) === JSON.stringify(original.labels)); + check(unchanged?.versionId === original.versionId + && Object.keys(unchanged.labels || {}).length === Object.keys(original.labels || {}).length + && Object.entries(original.labels || {}).every(([key, value]) => unchanged.labels[key] === value)); } process.stdout.write(JSON.stringify({ status: 'staging_community_deployed_verified', sha, digest, bytes: bundle.zip.length }) + '\n'); } diff --git a/tests/staging-community-release.test.js b/tests/staging-community-release.test.js index 94c7ae9..f39c495 100644 --- a/tests/staging-community-release.test.js +++ b/tests/staging-community-release.test.js @@ -47,3 +47,10 @@ test('profile release can inspect only the known isolated service, never deploy assert.throws(() => validateKnownCommunity({ ...known, serviceAccountEmail: 'default' })); assert.throws(() => profileSpec('communityActions', 'a'.repeat(40), 'b'.repeat(64), 'https://storage.googleapis.com/synthetic')); }); +test('Hosting requires the identical deployed Function bundle, not merely a label or newer commit', () => { + const { validateBundle } = require('../scripts/staging-community-hosting'); + const native = { ...functionSpec('a'.repeat(40), 'b'.repeat(64), 'https://storage.googleapis.com/synthetic'), status: 'ACTIVE' }; + assert.doesNotThrow(() => validateBundle(native, 'b'.repeat(64))); + assert.throws(() => validateBundle(native, 'c'.repeat(64))); + assert.throws(() => validateBundle({ ...native, maxInstances: 5 }, 'b'.repeat(64))); +});