From 9f337fe125c9a8486180c23c00024c1bc54edaad Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 12 Oct 2026 22:45:22 -0700 Subject: [PATCH] Fix staging release reporting and add read-only verification --- GITHUB_ISSUES.md | 2 + OPERATIONS_RUNBOOK.md | 8 ++ docs/officers/STAGING_COMMUNITY.md | 17 +++ docs/officers/SYSTEM_MAPS.md | 15 +++ scripts/staging-community-release.js | 169 ++++++++++++++++++++---- tests/staging-community-release.test.js | 147 +++++++++++++++++++++ 6 files changed, 332 insertions(+), 26 deletions(-) diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 5dd6d10..fca9af4 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -168,6 +168,8 @@ Do not redesign routing or hosting in this issue; WEB-001 owns that. WEB-001A1 [ ## CI-001 — Repair test gates and secure the deployment pipeline +**CI-STAGING-VERIFY-001 #722:** one script-only child corrects the final community success output's undefined digest, adds closed redacted failure phases and fixed read-only native verification. Local reproduction proves the reporting bug, not the caught exception from every historical failure. Clean exact green current-main source, native ancestor provenance/exact archive, scoped owner/runtime/config/resource/invoker and unchanged profile inventory remain mandatory. No new deployment, provider/IAM/data/payment mutation, dependency, workflow or production authority. See the [officer review](./docs/officers/STAGING_COMMUNITY.md#check-an-uncertain-staging-release--722). This does not close #105/#133/#136 or full backend readiness; current command/unchanged-marker evidence must be recorded in #722 before claiming hosted verification. + **CI-001C2 owner decision, 2026-10-11:** [#697](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/697) applies Dave's direct approval to continue Firebase backend setup without a backup owner. Dave (`daliu`) is the sole release/recovery owner. Backup appointment and two-person ownership are no longer backend prerequisites; absence of backup remains documented risk. #113/#133/#136 retain their other inventory, authority, exact-source, staging, policy and rollback requirements. Jeff's #667 read-only staging role does not expand. This decision is not Firebase deployment or live payment approval. **COST-001 source child:** [#683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) binds only the two profile Functions to zero reserved instances, max two each, 256 MB and 30 seconds, with actual SDK metadata tests and matching fail-closed provider readback validation. It records the community-first minimal-service direction and officer review procedure without changing schema, privileges, deployment scope, billing, providers, or public pages. This is source preparation, not reduced-bill or hosted quality evidence; #113/#133/#136 retain release prerequisites, synthetic cold-start/burst/recovery proof and compatible rollback. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index fdbe3b4..40a1f4b 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,5 +1,13 @@ # MPRC Platform Operations Runbook +## Read-only community release verification — #722 + +The former success output used an undefined `digest` identifier; a provider-free regression reproduces `ReferenceError` and verifies serialization of `bundle.digest`. Historical generic failures remain their original reported status; this does not prove which exception every old run caught. New failures expose only a closed phase enum, never exception text, provider responses, tokens or signed URLs. + +Specialist command after clean exact current-main/six-job evidence: `node scripts/staging-community-release.js verify`. It retains existing staging-only owner/runtime/zero-key/MFA checks, requires ancestor deployed provenance and identical downloaded reviewed bundle, validates ACTIVE resource/security/invoker state and unchanged two-profile inventory. Provider transport accepts only fixed reads and the signed source-read URL request. It cannot deploy, enable MFA, update IAM/Hosting or read/write member records. No-argument deployment remains separate, fixed-target and fail-closed; any other/extra argument fails before credentials. No new dependency, workflow, service, permissions, scheduler, migration or runtime-bundle change. The archive stream is bounded to one million bytes; credentials are never sent to its signed URL. Recheck exact current-main CI before reporting success. Separately read the public staging marker; record native command/marker results in #722 before claiming hosted verification. Do not redeploy unchanged code to investigate an observation failure. + +Officers follow the [no-terminal review](./docs/officers/STAGING_COMMUNITY.md#check-an-uncertain-staging-release--722). Dave approves/escalates; no backup is appointed. Source/CI/provider inspection and any repair remain specialist-only. Failed readback means stop, not skip a guard. Production, real dues, financial actions and full backend acceptance remain unavailable through this tooling child. + **#719 fictional linking — STAGING VERIFIED 2026-10-12:** reuse the same service, six-permission runtime and min0/max2/256MB/30s limits. Dave/specialist supplies fresh disposable verified Auth accounts, pending requests/revisions, exact expiring v3 association capabilities and six-field disposable markers. Follow the [screen-only procedure](./docs/officers/STAGING_COMMUNITY.md#link-a-fictional-receipt--719); old fixtures are removed. Disable capabilities first, deny old successful replays, then remove only inventoried owned records using identity/update-time fences. No ordinary officer edits records, grants a role, deploys or runs a terminal. No migration/scheduler/provider/payment/production change. PR720 merged b6a8fd57d9add466da73df1d33b69e131e3fecbd after six exact PR/main jobs and every step passed. Native communityActions version 4 and staging Hosting match that source; the downloaded 91,793-byte archive matches SHA-256 47f11b8bec7f5ae58281c9029a015e9cc751a04e5ad3eafe3234441fa3a963db. Forty-four hosted cases (32 denial cases), atomic races, immutable retries, actual officer/member UI, offline retry, phone-size DOM layout, cancellation and natural expiry passed. Six disposable users and 47 exact owned documents were removed; native absence and late profiles were verified. Both capabilities were disabled first; browser sign-out, Apps-on-device OFF, normal networking/viewport, closed tab/receiver and memory disposal complete the test handoff. Profiles v2/v1/2843388, App Check/MFA and the six-permission min0/max2 runtime remain unchanged. The release command reported its existing generic post-deploy failure; independent native checks qualified the result without repeating deployment or bypassing a guard. Reporting cause remains unqualified. Production/runmprc.com and real payments were untouched. **Status:** Target operating procedure; several controls are not implemented yet diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index 8d64af5..8dedf49 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -8,6 +8,23 @@ **Prerequisites:** the platform specialist supplies the exact reviewed release, a disposable verified account and a visibly made-up free event. Use only the staging website. Do not use real runner details or payment information. +## Check an uncertain staging release — #722 + +**Purpose:** establish what is deployed without starting another build. **Approver and escalation:** Dave Liu, sole staging owner; no backup is appointed. **Prerequisites:** the specialist uses the merged, reviewed #722 source and its six green current-main checks. No member account, authenticator code or test fixture is needed. Production verification or release is **NOT AVAILABLE YET** through this check. + +1. Ask Dave/platform specialist to check the existing staging community release without deploying. +2. State that production, real records and payments must stay untouched. +3. Request the #722 evidence link, not a provider screenshot or credential. +4. Check that the result separately names the reviewed source and deployed source, archive match, resource settings, unchanged two profiles and unchanged staging website marker. +5. If any check fails, stop and ask the specialist to investigate the named safe failure phase. +6. Do not request another deployment merely to clear an uncertain result. + +**Expected result and success proof:** the specialist's read-only verifier checks existing staging runtime permissions/zero keys, club account identity, MFA configuration, exact Function settings/source ancestry, downloaded source bytes, callable access and unchanged profile versions/labels. It performs no deployment, Auth/IAM update, member-data action or Hosting publication. The specialist separately reads the public staging website marker; the verifier does not claim fresh UI/lifecycle or complete backend acceptance. Record current native command evidence in #722 before calling this procedure verified. Green CI and labels alone are insufficient. + +**Stop conditions:** failed source/authority/config/resource/provenance/access/archive/inventory check; production target; new service/billing/permission request; real data; uncertain deployment. A failure reports only a fixed phase name, not private provider details. **Undo:** no provider state was changed, so stop the read-only check; any repair or reviewed staging rollback is separate. **Specialist-only work:** source/CI/provider verification and any later approved repair. Officers never run a terminal, sign in to test accounts, handle secrets or edit records for this review. + +**Reporting correction:** #722 identifies and reproduces the old final success statement's undefined `digest` variable. It now uses the actual bundle digest. That deterministic reporting bug could throw after all provider checks passed; it does not retroactively prove the exception from every historical run. Historical #711/#716/#719 deployment receipts and independent hosted proofs remain dated evidence. The new read-only mode avoids paying for another build to inspect an already-deployed result. + ## What the actions mean ## Link a fictional receipt — #719 diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 4dffe57..ff26ae1 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -2,6 +2,21 @@ These diagrams show where pages, information, and deployments go. Each diagram includes a one-sentence text version. +## Uncertain staging release review — #722 + +```mermaid +flowchart LR + Officer["Request a read-only review; no credentials"] --> Specialist["Dave/platform specialist; exact green main"] + Specialist --> Verify["Fixed staging verification; no deployment"] + Verify --> Native["Resource, source archive, access and unchanged profiles"] + Specialist --> Marker["Separate public staging website marker check"] + Native --> Proof["Dated issue evidence; failure phase means stop"] + Marker --> Proof + Verify -. "No writes" .-> Mutations["Build, IAM/Auth/Hosting, member data, production/payments"] +``` + +Text alternative: an officer asks the specialist for a current read-only staging source/resource/access check and a separate website-marker check, with dated issue proof or a safe stop phase, without another deployment or any account, record or production/payment mutation. + ## Fictional account linking — #719, staging verified 2026-10-12 ```mermaid diff --git a/scripts/staging-community-release.js b/scripts/staging-community-release.js index bf7ed53..92d7b20 100644 --- a/scripts/staging-community-release.js +++ b/scripts/staging-community-release.js @@ -18,6 +18,26 @@ const ACCOUNT = 'runmprc@gmail.com'; const RUNTIME = `mprc-community-runtime@${PROJECT}.iam.gserviceaccount.com`; const FILES = ['communityOnly.js', 'communityActions.js', 'communityAdministration.js', 'communityDues.js', 'communityDuesAssociation.js', 'membershipManualEvidence.js', 'membershipCancellationPolicy.json', 'membershipAuthority.js', 'requestValidation.js', 'profileRuntime.js', 'package.json', 'package-lock.json']; const check = (value) => { if (!value) throw new Error('staging_community_release_failed'); }; +const PHASES = new Set(['arguments', 'source', 'runtime_authority', 'owner_identity', 'mfa', 'inventory', 'bundle', 'source_upload', 'function_update', 'operation_poll', 'function_readback', 'source_provenance', 'invoker_policy', 'source_archive', 'final_inventory', 'report']); +const failures = new WeakMap(); +function phaseFailure(phase, error) { + if (failures.has(error)) return error; + const safe = new Error('staging_community_release_failed'); + failures.set(safe, PHASES.has(phase) ? phase : 'unknown'); + return safe; // Never retain the caught error, its cause, message or response. +} +function failureMessage(error) { + return `staging_community_release_failed phase=${failures.get(error) || 'unknown'}`; +} +function validateInventory(inventory) { + check(Array.isArray(inventory?.functions) && inventory.functions.length >= 2 && inventory.functions.length <= 3 + && !inventory.nextPageToken && !inventory.unreachable?.length); + const names = inventory.functions.map((fn) => fn.entryPoint); + check(new Set(names).size === names.length && names.includes('createMemberOnSignUp') && names.includes('ensureMemberProfile')); + check(inventory.functions.every((fn) => ['createMemberOnSignUp', 'ensureMemberProfile', TARGET].includes(fn.entryPoint) + && fn.name === `projects/${PROJECT}/locations/${REGION}/functions/${fn.entryPoint}` + && typeof fn.versionId === 'string' && /^[1-9][0-9]*$/.test(fn.versionId))); +} // Owner tokens do not inherit the CLI's --project as their API quota project. // Keep every authenticated provider request billed/routed to staging explicitly. @@ -37,6 +57,18 @@ function jsonRequest(url, token, method = 'GET', body, fetchImpl = fetch) { })); } +function verificationRequest(url, token, method = 'GET', body, fetchImpl = fetch) { + const reads = [ + `https://identitytoolkit.googleapis.com/admin/v2/projects/${PROJECT}/config`, + `https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, + `${BASE}/functions/${TARGET}`, `${BASE}/functions/${TARGET}:getIamPolicy`, + ]; + check((reads.includes(url) && method === 'GET' && body === undefined) + || (url === `${BASE}/functions/${TARGET}:generateDownloadUrl` && method === 'POST' + && body !== null && typeof body === 'object' && !Array.isArray(body) && Object.keys(body).length === 0)); + return jsonRequest(url, token, method, body, fetchImpl); +} + function createBundle(root) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mprc-community-owned-')); try { @@ -88,12 +120,68 @@ function validateStagingMfa(config) { && Object.keys(provider).length === 2 && Object.keys(provider.totpProviderConfig).length === 1 && !config.mfa.enabledProviders?.length); } -async function release(root) { +// Resource/identity/archive verification only. The sole POST requests a signed +// read URL; it never updates a Function, IAM, Auth, Hosting or member data. +async function verifyDeployment({ token, digest, sourceSha, inventory, verifyProvenance, request = verificationRequest, fetchImpl = fetch }) { + let phase = 'inventory'; + try { + validateInventory(inventory); + phase = 'function_readback'; + const native = await request(`${BASE}/functions/${TARGET}`, token); + const expected = functionSpec(native.labels?.['source-sha'], digest, 'https://storage.googleapis.com/synthetic'); + validateHostedFunction(native, expected); + check(typeof native.versionId === 'string' && /^[1-9][0-9]*$/.test(native.versionId)); + phase = 'source_provenance'; + check(/^[a-f0-9]{40}$/.test(sourceSha)); + check(native.labels['source-sha'] === sourceSha + || (typeof verifyProvenance === 'function' && await verifyProvenance(native.labels['source-sha']) === true)); + phase = 'invoker_policy'; + const policy = await request(`${BASE}/functions/${TARGET}:getIamPolicy`, token); + check(policy.bindings?.length === 1 && policy.bindings[0].role === 'roles/cloudfunctions.invoker' + && policy.bindings[0].members?.length === 1 && policy.bindings[0].members[0] === 'allUsers' && !policy.bindings[0].condition); + phase = 'source_archive'; + const download = await request(`${BASE}/functions/${TARGET}:generateDownloadUrl`, token, 'POST', {}); + const url = new URL(download.downloadUrl); + check(url.protocol === 'https:' && url.hostname === 'storage.googleapis.com' && !url.username && !url.password && !url.port); + const archive = await fetchImpl(download.downloadUrl, { redirect: 'error', signal: AbortSignal.timeout(30000) }); + check(archive.ok && archive.body); + const reader = archive.body.getReader(); + const hash = createHash('sha256'); let bytes = 0; let complete = false; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) { complete = true; break; } + bytes += chunk.value.byteLength; + check(bytes <= 1_000_000); + hash.update(chunk.value); + } + } finally { + try { if (!complete) await reader.cancel(); } finally { reader.releaseLock(); } + } + check(bytes > 0 && hash.digest('hex') === digest); + phase = 'final_inventory'; + const final = await request(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, token); + validateInventory(final); check(final.functions.length === 3); + const finalNative = final.functions.find((fn) => fn.entryPoint === TARGET); + validateHostedFunction(finalNative, expected); + check(finalNative.versionId === native.versionId); + 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 && isDeepStrictEqual(unchanged.labels, original.labels)); + } + return { sourceSha: native.labels['source-sha'], digest, bytes, version: native.versionId }; + } catch (error) { throw phaseFailure(phase, error); } +} + +async function performRelease(root, { verifyOnly }, phase) { + const request = verifyOnly ? verificationRequest : jsonRequest; + phase('source'); const gh = (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim(); const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === ''); const source = { GITHUB_SHA: sha, GH_TOKEN: gh('auth', 'token') }; await verifyCurrentSource(source); + phase('runtime_authority'); const gcloud = (...args) => execFileSync('/opt/homebrew/bin/gcloud', [...args, `--account=${ACCOUNT}`, `--project=${PROJECT}`], { encoding: 'utf8' }).trim(); const role = JSON.parse(gcloud('iam', 'roles', 'describe', 'mprcCommunityRuntime', '--format=json')); const permissions = ['datastore.databases.get', 'datastore.entities.get', 'datastore.entities.create', 'datastore.entities.update', 'datastore.entities.list', 'firebaseauth.users.get']; @@ -103,51 +191,80 @@ async function release(root) { check(runtimeRoles.length === 1 && runtimeRoles[0].role === `projects/${PROJECT}/roles/mprcCommunityRuntime` && !runtimeRoles[0].condition); const keys = JSON.parse(gcloud('iam', 'service-accounts', 'keys', 'list', `--iam-account=${RUNTIME}`, '--managed-by=user', '--format=json')); check(keys.length === 0); + phase('owner_identity'); const token = gcloud('auth', 'print-access-token'); // Memory only, never logged. + const owner = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${token}` }, redirect: 'error', signal: AbortSignal.timeout(15000), + }); + check(owner.ok && (await owner.json()).email === ACCOUNT); + phase('mfa'); const authUrl = `https://identitytoolkit.googleapis.com/admin/v2/projects/${PROJECT}/config`; - const beforeAuth = await jsonRequest(authUrl, token); + const beforeAuth = await request(authUrl, token); check(beforeAuth.subtype === 'IDENTITY_PLATFORM' && beforeAuth.signIn?.email?.enabled === true); - if (beforeAuth.mfa?.state === 'DISABLED') { + if (!verifyOnly && beforeAuth.mfa?.state === 'DISABLED') { check(!beforeAuth.mfa.providerConfigs?.length && !beforeAuth.mfa.enabledProviders?.length); await verifyCurrentSource(source); - await jsonRequest(`${authUrl}?updateMask=mfa`, token, 'PATCH', { mfa: STAGING_MFA }); + await request(`${authUrl}?updateMask=mfa`, token, 'PATCH', { mfa: STAGING_MFA }); } - const afterAuth = await jsonRequest(authUrl, token); + const afterAuth = await request(authUrl, token); validateStagingMfa(afterAuth); check(isDeepStrictEqual(beforeAuth.signIn, afterAuth.signIn) && isDeepStrictEqual(beforeAuth.authorizedDomains, afterAuth.authorizedDomains)); - const inventory = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, token); - check(!inventory.nextPageToken && !inventory.unreachable?.length && inventory.functions?.length >= 2 && inventory.functions.length <= 3); - check(inventory.functions.every((fn) => ['createMemberOnSignUp', 'ensureMemberProfile', TARGET].includes(fn.entryPoint) && fn.name.startsWith(`${BASE.replace('https://cloudfunctions.googleapis.com/v1/', '')}/functions/`))); + phase('inventory'); + const inventory = await request(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, token); + validateInventory(inventory); + phase('bundle'); const bundle = createBundle(root); + if (verifyOnly) { + const result = await verifyDeployment({ token, digest: bundle.digest, sourceSha: sha, inventory, request, + verifyProvenance: async (nativeSha) => { + execFileSync('git', ['merge-base', '--is-ancestor', nativeSha, sha], { cwd: root }); + return true; + }, + }); + phase('source'); await verifyCurrentSource(source); + phase('report'); + process.stdout.write(JSON.stringify({ status: 'staging_community_verified_without_deploy', reviewedSha: sha, ...result }) + '\n'); + return; + } + phase('source'); await verifyCurrentSource(source); + phase('source_upload'); const uploaded = await jsonRequest(`${BASE}/functions:generateUploadUrl`, token, 'POST', {}); const expected = functionSpec(sha, bundle.digest, uploaded.uploadUrl); const response = await fetch(uploaded.uploadUrl, { method: 'PUT', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'application/zip', 'x-goog-content-length-range': '0,104857600' }, body: bundle.zip }); check(response.ok); + phase('source'); await verifyCurrentSource(source); const exists = inventory.functions.some((fn) => fn.name === expected.name); + phase('function_update'); const operation = await jsonRequest(exists ? `${BASE}/functions/${TARGET}?updateMask=${Object.keys(expected).filter((key) => key !== 'name').join(',')}` : `${BASE}/functions`, token, exists ? 'PATCH' : 'POST', expected); + phase('operation_poll'); await waitForOperation(operation, token, jsonRequest); + phase('function_readback'); validateHostedFunction(await jsonRequest(`${BASE}/functions/${TARGET}`, token), expected); + phase('invoker_policy'); const prior = await jsonRequest(`${BASE}/functions/${TARGET}:getIamPolicy`, token); - check((prior.bindings || []).every((binding) => binding.role === 'roles/cloudfunctions.invoker' && binding.members?.length === 1 && binding.members[0] === 'allUsers')); + check((prior.bindings || []).every((binding) => binding.role === 'roles/cloudfunctions.invoker' && binding.members?.length === 1 && binding.members[0] === 'allUsers' && !binding.condition)); await jsonRequest(`${BASE}/functions/${TARGET}:setIamPolicy`, token, 'POST', { policy: { version: 1, ...(prior.etag ? { etag: prior.etag } : {}), bindings: [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }] } }); - // Native source readback, not labels alone, must match the submitted bytes. - const download = await jsonRequest(`${BASE}/functions/${TARGET}: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') === bundle.digest); - const final = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/-/functions`, token); - 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 - && 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'); + await verifyDeployment({ token, digest: bundle.digest, sourceSha: sha, inventory }); + phase('source'); await verifyCurrentSource(source); + phase('report'); + process.stdout.write(JSON.stringify(releaseResult(sha, bundle)) + '\n'); +} +function releaseResult(sha, bundle) { + check(/^[a-f0-9]{40}$/.test(sha) && /^[a-f0-9]{64}$/.test(bundle.digest) + && Buffer.isBuffer(bundle.zip) && bundle.zip.length > 0 && bundle.zip.length < 1_000_000); + return { status: 'staging_community_deployed_verified', sha, digest: bundle.digest, bytes: bundle.zip.length }; +} +async function release(root, options) { + let phase = 'source'; + try { return await performRelease(root, options, (value) => { phase = value; }); } + catch (error) { throw phaseFailure(phase, error); } +} +async function run(args, root, driver = release) { + if (!(args.length === 0 || (args.length === 1 && args[0] === 'verify'))) throw phaseFailure('arguments'); + return driver(root, { verifyOnly: args.length === 1 }); } -if (require.main === module) release(path.resolve(__dirname, '..')).catch(() => { process.stderr.write('staging_community_release_failed\n'); process.exitCode = 1; }); -module.exports = { FILES, createBundle, functionSpec, validateHostedFunction, jsonRequest, validateStagingMfa }; +if (require.main === module) run(process.argv.slice(2), path.resolve(__dirname, '..')).catch((error) => { process.stderr.write(`${failureMessage(error)}\n`); process.exitCode = 1; }); +module.exports = { FILES, createBundle, functionSpec, validateHostedFunction, jsonRequest, verificationRequest, validateStagingMfa, verifyDeployment, failureMessage, releaseResult, run }; diff --git a/tests/staging-community-release.test.js b/tests/staging-community-release.test.js index e39cace..a792126 100644 --- a/tests/staging-community-release.test.js +++ b/tests/staging-community-release.test.js @@ -60,3 +60,150 @@ test('Hosting requires the identical deployed Function bundle, not merely a labe assert.throws(() => validateBundle(native, 'c'.repeat(64))); assert.throws(() => validateBundle({ ...native, maxInstances: 5 }, 'b'.repeat(64))); }); + +function verificationFixture() { + const bytes = Buffer.from('synthetic-reviewed-archive'); + const digest = require('node:crypto').createHash('sha256').update(bytes).digest('hex'); + const sourceSha = 'a'.repeat(40); + const native = { ...functionSpec(sourceSha, digest, 'https://storage.googleapis.com/synthetic'), status: 'ACTIVE', versionId: '4' }; + const profiles = ['createMemberOnSignUp', 'ensureMemberProfile'].map((entryPoint) => ({ + name: native.name.replace('communityActions', entryPoint), entryPoint, versionId: '1', labels: { 'source-sha': 'c'.repeat(40) }, + })); + const inventory = { functions: [...profiles, native] }; + const requests = []; const downloads = []; + const args = { + token: 'synthetic-token-only', digest, sourceSha, inventory, + request: async (url, token, method = 'GET', body) => { + requests.push({ url, token, method, body }); + if (url.endsWith(':getIamPolicy')) return { bindings: [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }] }; + if (url.endsWith(':generateDownloadUrl')) return { downloadUrl: 'https://storage.googleapis.com/synthetic-archive' }; + if (url.endsWith('/locations/-/functions')) return inventory; + return native; + }, + fetchImpl: async (url, options) => { downloads.push({ url, options }); return new Response(bytes); }, + }; + return { args, bytes, native, inventory, profiles, requests, downloads }; +} + +test('read-only native verification checks exact source bytes and leaves deployment/IAM/data untouched', async () => { + const { verifyDeployment } = require('../scripts/staging-community-release'); + const f = verificationFixture(); + const result = await verifyDeployment(f.args); + assert.deepEqual(result, { sourceSha: f.args.sourceSha, digest: f.args.digest, bytes: f.bytes.length, version: '4' }); + assert.equal(f.requests.length, 4); + assert.ok(f.requests.every(({ url, method, body }) => url.startsWith('https://cloudfunctions.googleapis.com/v1/projects/run-mprc-staging/') + && (method === 'GET' || (method === 'POST' && url.endsWith(':generateDownloadUrl') && JSON.stringify(body) === '{}')))); + assert.equal(f.downloads.length, 1); + assert.equal(f.downloads[0].options.headers, undefined); // Signed URL gets no bearer/quota headers. + assert.equal(f.downloads[0].options.redirect, 'error'); +}); + +test('verification accepts script/docs-only ancestry only with identical archive and explicit provenance proof', async () => { + const { verifyDeployment } = require('../scripts/staging-community-release'); + const f = verificationFixture(); f.args.sourceSha = 'b'.repeat(40); + await assert.rejects(verifyDeployment(f.args)); + f.args.verifyProvenance = async (nativeSha) => { assert.equal(nativeSha, 'a'.repeat(40)); return true; }; + assert.equal((await verifyDeployment(f.args)).sourceSha, 'a'.repeat(40)); + f.args.verifyProvenance = async () => false; + await assert.rejects(verifyDeployment(f.args)); +}); + +test('native verification denies resource/invoker/archive/inventory drift without repair writes', async (t) => { + const { verifyDeployment } = require('../scripts/staging-community-release'); + const cases = [ + ['inactive', f => { f.native.status = 'DEPLOY_IN_PROGRESS'; }], + ['runtime', f => { f.native.runtime = 'nodejs20'; }], + ['source label', f => { f.native.labels['source-sha'] = 'invalid'; }], + ['digest label', f => { f.native.labels['bundle-a'] = '0'.repeat(32); }], + ['missing invoker', f => { const read = f.args.request; f.args.request = async (...a) => a[0].endsWith(':getIamPolicy') ? {} : read(...a); }], + ['conditional invoker', f => { const read = f.args.request; f.args.request = async (...a) => a[0].endsWith(':getIamPolicy') ? { bindings: [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'], condition: {} }] } : read(...a); }], + ['extra binding', f => { const read = f.args.request; f.args.request = async (...a) => a[0].endsWith(':getIamPolicy') ? { bindings: [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }, { role: 'roles/owner', members: ['allUsers'] }] } : read(...a); }], + ['source URL', f => { const read = f.args.request; f.args.request = async (...a) => a[0].endsWith(':generateDownloadUrl') ? { downloadUrl: 'https://foreign.invalid/token-private' } : read(...a); }], + ['source userinfo', f => { const read = f.args.request; f.args.request = async (...a) => a[0].endsWith(':generateDownloadUrl') ? { downloadUrl: 'https://private@storage.googleapis.com/source' } : read(...a); }], + ['source port', f => { const read = f.args.request; f.args.request = async (...a) => a[0].endsWith(':generateDownloadUrl') ? { downloadUrl: 'https://storage.googleapis.com:444/source' } : read(...a); }], + ['archive mismatch', f => { f.args.fetchImpl = async () => new Response('other'); }], + ['archive failure', f => { f.args.fetchImpl = async () => new Response('private provider body', { status: 403 }); }], + ['archive oversized', f => { f.args.fetchImpl = async () => new Response(Buffer.alloc(1_000_001)); }], + ['duplicate inventory', f => { f.inventory.functions[0] = f.inventory.functions[1]; }], + ['paginated inventory', f => { f.inventory.nextPageToken = 'private-token'; }], + ['unreachable inventory', f => { f.inventory.unreachable = ['foreign']; }], + ['missing profile', f => { f.inventory.functions.shift(); }], + ['foreign profile', f => { f.profiles[0].name = f.profiles[0].name.replace('run-mprc-staging', 'production'); }], + ['profile changed during verification', f => { const read = f.args.request; f.args.request = async (...a) => a[0].endsWith('/locations/-/functions') ? { functions: f.inventory.functions.map(fn => fn.entryPoint === 'ensureMemberProfile' ? { ...fn, versionId: '2' } : fn) } : read(...a); }], + ['target changed during verification', f => { const read = f.args.request; f.args.request = async (...a) => a[0].endsWith('/locations/-/functions') ? { functions: f.inventory.functions.map(fn => fn.entryPoint === 'communityActions' ? { ...fn, versionId: '5' } : fn) } : read(...a); }], + ]; + for (const [name, change] of cases) await t.test(name, async () => { + const f = verificationFixture(); change(f); + await assert.rejects(verifyDeployment(f.args)); + assert.ok(f.requests.every(({ url, method }) => method === 'GET' || url.endsWith(':generateDownloadUrl'))); + }); +}); + +test('closed failure phases never echo exceptions, provider bodies, URLs or forged phase fields', async () => { + const { verifyDeployment, failureMessage } = require('../scripts/staging-community-release'); + const f = verificationFixture(); + f.args.request = async () => { throw new Error('Bearer private-token https://private.invalid/runner-record'); }; + await assert.rejects(verifyDeployment(f.args), error => { + assert.equal(failureMessage(error), 'staging_community_release_failed phase=function_readback'); + assert.equal(error.message, 'staging_community_release_failed'); + return true; + }); + assert.equal(failureMessage({ phase: 'private-url', message: 'private-secret' }), 'staging_community_release_failed phase=unknown'); +}); + +test('CLI routes only no-argument deployment or exact verify, rejecting other args before work', async () => { + const { run, failureMessage } = require('../scripts/staging-community-release'); + const calls = []; + const driver = async (root, options) => { calls.push({ root, options }); }; + await run([], '/synthetic', driver); + await run(['verify'], '/synthetic', driver); + assert.deepEqual(calls, [{ root: '/synthetic', options: { verifyOnly: false } }, { root: '/synthetic', options: { verifyOnly: true } }]); + for (const args of [['deploy'], ['production'], ['verify', '--force'], ['--project=production']]) { + await assert.rejects(run(args, '/synthetic', driver), error => failureMessage(error) === 'staging_community_release_failed phase=arguments'); + } + assert.equal(calls.length, 2); +}); + +test('completed deployment serializes the actual bundle digest instead of throwing an undefined-variable error', () => { + const { releaseResult } = require('../scripts/staging-community-release'); + const sha = 'a'.repeat(40); const bundle = { zip: Buffer.from('synthetic-archive'), digest: 'b'.repeat(64) }; + // Reproduce the old final statement without contacting any provider. + assert.throws(() => require('node:vm').runInNewContext( + "JSON.stringify({ status: 'staging_community_deployed_verified', sha, digest, bytes: bundle.zip.length })", + { sha, bundle }, + ), error => error.name === 'ReferenceError' && error.message === 'digest is not defined'); + assert.deepEqual(JSON.parse(JSON.stringify(releaseResult(sha, bundle))), { + status: 'staging_community_deployed_verified', sha, digest: bundle.digest, bytes: bundle.zip.length, + }); + assert.throws(() => releaseResult(sha, { ...bundle, digest: 'private-provider-message' })); + assert.throws(() => releaseResult(sha, { ...bundle, zip: Buffer.alloc(1_000_000) })); +}); + +test('verification transport refuses deployment/IAM/Auth/data/foreign requests before transport access', async () => { + const { verificationRequest } = require('../scripts/staging-community-release'); + let calls = 0; + const fetchImpl = async () => { calls += 1; return { ok: true, json: async () => ({}) }; }; + const base = 'https://cloudfunctions.googleapis.com/v1/projects/run-mprc-staging/locations/us-central1/functions/communityActions'; + for (const [url, method, body] of [ + [base, 'PATCH', {}], [base + ':setIamPolicy', 'POST', {}], + [base.replace('/communityActions', ':generateUploadUrl'), 'POST', {}], + ['https://identitytoolkit.googleapis.com/admin/v2/projects/run-mprc-staging/config?updateMask=mfa', 'PATCH', {}], + ['https://firestore.googleapis.com/v1/projects/run-mprc-staging/databases/(default)/documents/members/fictional', 'GET'], + [base.replace('run-mprc-staging', 'production'), 'GET'], + [base + ':generateDownloadUrl', 'POST', { injection: true }], + [base, 'GET', {}], + ]) assert.throws(() => verificationRequest(url, 'synthetic', method, body, fetchImpl)); + assert.equal(calls, 0); + await verificationRequest(base, 'synthetic', 'GET', undefined, fetchImpl); + await verificationRequest(base + ':getIamPolicy', 'synthetic', 'GET', undefined, fetchImpl); + await verificationRequest(base + ':generateDownloadUrl', 'synthetic', 'POST', {}, fetchImpl); + assert.equal(calls, 3); +}); + +test('actual CLI rejects extra verify arguments with only a safe phase and no credential work', () => { + const { spawnSync } = require('node:child_process'); + const result = spawnSync(process.execPath, [path.resolve(__dirname, '../scripts/staging-community-release.js'), 'verify', 'private-argument'], { encoding: 'utf8' }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, 'staging_community_release_failed phase=arguments\n'); +});