diff --git a/.github/scripts/verify-firebase-release.mjs b/.github/scripts/verify-firebase-release.mjs index 22c3e01..026f7bb 100644 --- a/.github/scripts/verify-firebase-release.mjs +++ b/.github/scripts/verify-firebase-release.mjs @@ -16,7 +16,7 @@ const FUNCTION_SPECS = Object.freeze({ }); const REGION = 'us-central1'; -const RUNTIME = 'nodejs20'; +const RUNTIME = 'nodejs22'; const FIRESTORE_RELEASE_REQUEST = 'cloud.firestore/(default)'; const FIRESTORE_RELEASE_CANONICAL = 'cloud.firestore'; const PROJECT_PATTERN = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a776a6..0d6160f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,37 @@ jobs: # firebase-admin so no emulator is needed. - run: npm run test:run + functions-node22: + name: Node 22 backend compatibility + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + - name: Install committed root dependencies + run: npm ci --legacy-peer-deps --ignore-scripts + - name: Install committed Functions dependencies + run: npm --prefix functions ci --ignore-scripts + - name: Run Node 22 backend tests with profile persistence + env: + REQUIRE_MEMBER_PROFILE_EMULATOR: '1' + run: >- + npx --no-install firebase emulators:exec + --project demo-functions-test + --only firestore,auth + "npm --prefix functions run test:run -- --runInBand" + commerce-command-journal: name: Commerce command journal emulator runs-on: ubuntu-latest diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a699c36..e9f2f89 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -112,6 +112,7 @@ jobs: for required_job in \ "Frontend lint + build" \ "Cloud Functions lint + test" \ + "Node 22 backend compatibility" \ "Commerce command journal emulator" \ "Test artifact scrubber" \ "Firestore security-rules tests" @@ -290,7 +291,7 @@ jobs: - name: Set up locked Node runtime uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: '20' + node-version: '22' cache: npm cache-dependency-path: | package-lock.json @@ -402,6 +403,7 @@ jobs: for required_job in \ "Frontend lint + build" \ "Cloud Functions lint + test" \ + "Node 22 backend compatibility" \ "Commerce command journal emulator" \ "Test artifact scrubber" \ "Firestore security-rules tests" diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 54b1067..457ec67 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -26,6 +26,18 @@ Published foundation mapping: Before creating or claiming anything from this catalog, search GitHub, confirm the predecessor is merged, then follow the timestamped claim protocol in the live issue. +## Current runtime prerequisite + +RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) prepares the initial profile backend for Node 22. Scope: Functions engine and lock metadata; exact staging guard/provider readback; a required sixth CI job; backend builder runtime; a fixed immutable redacted error stack; and officer handoff. Existing Node 20 checks, first-generation triggers, schema, auth, create-once semantics, two-Function scope, and #683 limits stay unchanged. No dependency versions or migrations change. #113/#133/#136, billing, hosted checks, and existing security advisories remain open; this is not a release or runner-matching activation. GitHub holds exact review/merge/CI evidence. + +Officer impact: no website or account behavior changes until a separately approved deployment. + +Officer documentation: `OFFICER_START_HERE.md`, `docs/officers/README.md`, `docs/officers/LOW_COST_COMMUNITY.md`, and `docs/officers/PUBLISH_AND_CHECK.md` distinguish runtime source preparation from hosted proof and explain the supported-runtime undo requirement. + +Deployment evidence: source/test work only; no website publication, `runmprc.com` change, Firebase deployment, provider configuration, or production behavior verification in #691. + +The 2026-09-27 production-dependency audit remains nonzero: root 5 findings (1 low, 2 moderate, 2 high: `browserslist`/`minimatch`); Functions 12 (11 moderate, 1 high: `sharp`). The lockfile differs only in the root engine field; no dependency finding is fixed or accepted as safe by this runtime update. + ## Backlog conventions ### Labels diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1877a4e..be5b582 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -7,6 +7,10 @@ This plan turns the target architecture into a dependency-ordered delivery program. It is deliberately staged: payment correctness and trust boundaries come before features, and a controlled pilot comes before a broad launch. +### Initial profile release runtime prerequisite + +RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) prepares Node 22 before the two-Function profile release under #136. Keep the five existing Node 20 checks and require a sixth, separate Node 22 backend/profile-emulator job in both protected-release CI checks. Match manifest, lock metadata, deployment builder, and provider runtime readback. Preserve CommonJS, first-generation triggers, create-once behavior, schema, and #683 resource limits; no backfill is needed. A fixed immutable error stack is the only backend logic adaptation. Node 22 source and passing synthetic tests do not resolve billing, #113 estate disposition, #133 authority, hosted behavior, or existing dependency advisories. Do not merge or deploy runner matching as part of this runtime prerequisite. + ## 1. Program outcome MPRC can open a race or merchandise item for sale only when the platform can: diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 05d7fb1..2e125cb 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -10,6 +10,8 @@ Attach the exact wording, public link, or approved photo when you have it. The AI should ask questions if anything is unclear. +**Backend maintenance, 2026-09-27:** [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) prepares a supported server runtime, Node 22, in source. It does not publish a website, deploy Functions, or add billing. The first backend release is still on hold. Follow the [low-cost review](./docs/officers/LOW_COST_COMMUNITY.md) and ask a maintainer for separate hosted proof; no officer terminal work is needed. + ## Choose the closest task | What you need | Open this short guide | diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 3e81aac..bde293f 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -8,6 +8,14 @@ This runbook explains how to operate the website, Firebase services, and Stripe Club officers and backup maintainers should start with [OFFICER_START_HERE.md](./OFFICER_START_HERE.md). It converts this technical runbook into short request, approval, verification, access, and emergency steps without terminal commands. +### Node 22 profile release prerequisite — hosted behavior NOT AVAILABLE YET + +RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) changes source runtime selection, not a hosted service. The protected backend builder uses Node 22; frontend and baseline CI remain Node 20. Both pre-approval and post-approval checks require all six named jobs, including `Node 22 backend compatibility`. That job requires local Auth and Firestore and runs the profile create-once persistence test; it does not prove hosted Auth or App Check. + +Before any approved #136 release, the platform maintainer must obtain exact-main CI, the named environment approval, private billing/cost-control decisions, #113/#133 evidence, and a compatible rollback or roll-forward. Deploy only the two profile Functions and the separately reviewed Rules scope. Read back `nodejs22` on both Functions plus min 0/max 2/256 MB/30 seconds; then perform the approved synthetic hosted checks. Do not use Node 20 as an assumed fallback after Google's scheduled October 30, 2026 decommissioning. No package versions, trigger generation, data format, or migration changes are included here. Officers use the [low-cost review](./docs/officers/LOW_COST_COMMUNITY.md), not terminal commands. + +Read-only check on 2026-09-27: `runmprc@gmail.com` could access zero billing accounts; staging billing remained disabled, and Functions, Cloud Build, and Artifact Registry APIs remained disabled. No billing, IAM, API, hosted runtime, website, or production behavior was changed by #691. + ## 1. Ownership roster Complete this table in the private operations system before live payments. Do not put personal phone numbers or recovery codes in this public repository. diff --git a/SECURITY.md b/SECURITY.md index 9905856..149b3ba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,6 +10,12 @@ This is both the repository security policy and the current engineering risk reg The public informational website can continue operating with normal care. **Live race or merchandise payments should remain disabled until every P0 launch blocker below is closed and verified in a staging dress rehearsal.** The existing payment implementation is a useful prototype, but several correctness defects could mark unpaid transactions paid, lose payment callbacks, oversell race capacity, fail to reconcile late-payment links, or allow overly broad administrative access. +### Runtime prerequisite — source only, 2026-09-27 + +RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) targets Node.js 22 without changing first-generation triggers, permissions, profile schema, deployment scope, or resource limits. [Google schedules Node 20 decommissioning for October 30, 2026](https://docs.cloud.google.com/functions/docs/runtime-support). Both protected-release CI reads now require the separate Node 22 backend/profile-persistence job, and provider verification rejects either profile Function unless its runtime is exactly `nodejs22`. The original Node 20 baseline checks remain required. + +The dual-runtime run exposed a Node 22 error-stack accessor that remained writable despite `Object.freeze`. The unused Checkout observation projector now replaces that accessor with a fixed non-enumerable, non-writable data property before freezing; the regression test also rejects a stack-formatting hook. No raw provider value or stack frame is retained in that diagnostic. Existing malformed-input, proxy/getter, no-output, and redaction tests remain intact. This is runtime compatibility, not payment activation or a dependency-audit fix. #113/#133/#136, existing dependency findings, and hosted verification remain separate release gates. + ## 2. Reporting a vulnerability Do not disclose a suspected vulnerability, secret, customer record, payment reference, or exploit in a public GitHub issue. Until MPRC publishes a dedicated security address: diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 4617913..6594cb0 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -16,6 +16,8 @@ The owner requests a simpler, low-cost community experience and is willing to su COST-001 [#683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) explicitly binds both profile Functions to zero reserved instances, maximum two instances each, 256 MB memory and a 30-second execution timeout. The release verifier requires matching provider readback. It records the v1 API's documented omitted-zero encoding for minimum instances; missing nonzero settings and malformed evidence remain unverified. Source and tests do not make these settings live or guarantee a bill ceiling. Cold-start, saturation, uncertain-timeout/retry and public-page availability checks remain required before release. The [officer guide](./docs/officers/LOW_COST_COMMUNITY.md) records scope, cost-control limits, proof, and recovery. Node runtime maintenance and existing provider/release gates remain open; no schema, role, price, domain, or deployed service changes in this slice. +RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) prepares Node.js 22 in the Functions manifest and lock metadata, staging source guard, backend release builder, and provider verifier. First-generation triggers, names, schema, authorization, create-once semantics, and all four COST-001 limits are unchanged. Existing Node 20 CI remains the baseline; a separate required Node 22 job exercises the backend and synthetic profile persistence. The immutable redacted Checkout observation error now uses a fixed stack data property because Node 22 otherwise leaves a mutable stack setter on frozen errors. This changes no checkout decision or provider call. No dependency version, billing, IAM, API, deployment, or data migration changes are included; hosted runtime and service quality remain unverified. + ### Goals - Publish public club content, events, and merchandise. @@ -59,7 +61,7 @@ flowchart LR Web["React single-page app\nNetlify live; Pages domain conflict unresolved"] Auth["Firebase Authentication"] FS["Cloud Firestore"] - Fn["Firebase Cloud Functions\nNode.js 20"] + Fn["Firebase Cloud Functions\nNode.js 22 source; hosted runtime unverified"] Stripe["Stripe-hosted Checkout\nand Stripe API"] Hook["Stripe webhook endpoint"] Mail["Firestore Email extension\nand mail provider"] @@ -81,7 +83,7 @@ flowchart LR Web --> Sentry ``` -Text alternative: browsers can use the React app, Auth, Firestore, Functions, Stripe, email, Strava, and the separately bounded Sentry path; #139 source has no browser-to-Firebase-Analytics data path, but website publication and provider behavior are not proven here. +Text alternative: browsers can use the React app, Auth, Firestore, Functions, Stripe, email, Strava, and the separately bounded Sentry path; #139 source has no browser-to-Firebase-Analytics data path. Functions source targets Node 22, but website publication, hosted runtime, and provider behavior are not proven here. ### Current component inventory diff --git a/docs/officers/LOW_COST_COMMUNITY.md b/docs/officers/LOW_COST_COMMUNITY.md index 1f9e18e..aa4494a 100644 --- a/docs/officers/LOW_COST_COMMUNITY.md +++ b/docs/officers/LOW_COST_COMMUNITY.md @@ -4,7 +4,7 @@ **Approver:** Dave Liu for the current simplification request. Any release still uses the named platform/security reviewers. Regular club expenditure needs the club's finance approval; willingness to supply a personal card is not an unlimited club budget. -**Checked:** 2026-09-11. This is a scope and source-review guide, not proof that the new settings are live. +**Checked:** 2026-09-27. This is a scope and source-review guide, not proof that the new settings are live. ## What we are prioritizing @@ -31,11 +31,13 @@ The first profile backend remains exactly two server operations: create a missin These are resource settings, **not a maximum monthly bill**. Google can briefly exceed the instance limit during traffic surges. A busy HTTP operation can queue requests and return a temporary busy response. Persistent storage, deployment files, database use, and other services can still cost money. The profile remains create-once so retrying an uncertain result does not overwrite existing information or grant membership. -No budget, spending cap, artifact-cleanup policy, billing account, Function, or new host is configured by this source change. Billing was separately checked as disabled/unlinked on 2026-09-11. A personal card has been offered as fallback; card details belong only in Google's secure payment form. +No budget, spending cap, artifact-cleanup policy, billing account, Function, or new host is configured by this source change. Billing was separately checked as disabled/unlinked on 2026-09-27; the club account could access zero billing accounts. A personal card has been offered as fallback; card details belong only in Google's secure payment form. + +[Issue #691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) prepares Node 22, the program that runs the server code. Google schedules the old Node 20 runtime to be taken out of service on October 30, 2026. The update keeps the same two operations and resource limits. It adds automated compatibility and made-up profile checks, but does not deploy anything or fix every dependency warning. Hosted Node 22 behavior is **NOT AVAILABLE YET**. ## Review before a low-cost release -**Prerequisites:** issue #683 and its reviewed pull request; the exact release source and test results; a named release approver; a private billing/payment-owner decision; and a platform maintainer. The existing release dependencies still apply. Never put payment details, passwords, or member records in an issue. +**Prerequisites:** issues #683 and #691 and their reviewed pull requests; the exact release source and test results; a named release approver; a private billing/payment-owner decision; and a platform maintainer. The existing release dependencies still apply. Never put payment details, passwords, or member records in an issue. ```mermaid flowchart LR @@ -51,8 +53,8 @@ Text alternative: confirm the small scope, review source, verify provider contro 1. Confirm the public pages to keep and the two profile operations to deploy. 2. Confirm the release adds no payment, email, photo, or synchronization service. -3. Ask the maintainer for the exact merged source and passing tests. -4. Ask for separate provider evidence of all four resource settings on each Function. Google may leave out a zero minimum-instance value; the maintainer must show that this was decoded using Google's documented format. Other missing or malformed settings are not proof of safe defaults. +3. Ask the maintainer for the exact merged source and all six passing checks, including “Node 22 backend compatibility.” +4. Ask for separate provider evidence of Node 22 and all four resource settings on each Function. Google may leave out a zero minimum-instance value; the maintainer must show that this was decoded using Google's documented format. Other missing or malformed settings are not proof of safe defaults. 5. Ask for the agreed alert recipients and budget amount. Alerts do not stop charges. 6. Ask whether a service spending cap is available and configured. It does not guarantee a whole-project bill ceiling or instant shutdown. 7. Ask for deployment-file cleanup and storage-retention evidence. Do not delete database backups as a cost shortcut. @@ -66,12 +68,13 @@ Text alternative: confirm the small scope, review source, verify provider contro **Success proof:** a reviewed source/CI record plus separate provider readback and synthetic quality evidence. Source tests alone cannot prove lower bills or a good hosted experience. -**Undo:** before release, hold the change. After release, ask the platform maintainer to use the reviewed compatible rollback or safe roll-forward. Reverting the limits can raise spending exposure and requires review. Do not edit a profile, widen permissions, switch off billing, or delete resources as an improvised fix. +**Undo:** before release, hold the change. After release, ask the platform maintainer to use the reviewed compatible rollback or safe roll-forward on a supported runtime. Do not assume the old Node 20 version can be redeployed after its retirement date. Reverting the limits can raise spending exposure and requires review. Do not edit a profile, widen permissions, switch off billing, or delete resources as an improvised fix. **Escalation:** platform/security owner for reliability or access; treasurer/finance approver for recurring club costs; cardholder for payment authority. A specialist is still needed for provider setup, release, and rollback. This guide does not claim independent officer deployment is available. ## References +- [Google's server runtime support dates](https://docs.cloud.google.com/functions/docs/runtime-support) - [Firebase resource settings and saturation behavior](https://firebase.google.com/docs/functions/1st-gen/manage-functions-1st) - [Google maximum-instance caveat](https://docs.cloud.google.com/functions/docs/reference/rest/v1/projects.locations.functions) - [Google's zero-value response format](https://protobuf.dev/programming-guides/json/#presence-and-default-values) diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index 33287f3..0ae556e 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -642,8 +642,8 @@ Do not use this section until #133 records that both GitHub environments are pro 2. Copy the exact 40-character merged commit. Do not use a branch name. 3. Choose the fixed release plan. Do not type a project or Function name. 4. Confirm the environment's Firebase project is the approved one. -5. Confirm the required checks belong to the same exact commit. -6. Confirm the rollback or safe roll-forward commit. +5. Confirm all six required checks belong to the same exact commit, including “Node 22 backend compatibility.” This check is source proof, not a live backend check. +6. Confirm the rollback or safe roll-forward commit uses a supported server runtime. Do not assume Node 20 remains deployable after its scheduled October 30, 2026 retirement. 7. Confirm the named observer is available. 8. Ask the platform maintainer to request the manual release. 9. Record the release-run link. @@ -660,7 +660,7 @@ Do not use this section until #133 records that both GitHub environments are pro 5. Confirm short-lived cloud authentication succeeds. 6. Confirm Firestore Rules deploy first. 7. Confirm only `createMemberOnSignUp` and `ensureMemberProfile` deploy next. -8. Confirm both Functions are found by the verification step. +8. Confirm both Functions are found by the verification step with Node 22 and the four settings in the [low-cost guide](./LOW_COST_COMMUNITY.md). These settings are prepared in source by #683/#691; hosted behavior is NOT AVAILABLE YET until separately verified. 9. Stop if any backend step is missing, skipped, failed, partial, or mismatched. 10. Confirm the GitHub Pages publication job starts only after backend success. 11. Confirm the Pages artifact uses the same exact commit. diff --git a/docs/officers/README.md b/docs/officers/README.md index 4cbd54f..3fc272e 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -27,6 +27,8 @@ flowchart TD In words: approve the merge, request one exact release, and approve its protected environment separately; Firebase must finish before the Pages copy; then check every affected service. +**Runtime maintenance, 2026-09-27:** [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) prepares Node 22, the program that runs server code. This is source preparation only, not a deployed backend or new billing. The [low-cost guide](./LOW_COST_COMMUNITY.md) explains the required proof and supported-runtime recovery check. + ## Short guides 1. [Request a change](./REQUEST_A_CHANGE.md) diff --git a/functions/memberProfile.emulator.test.js b/functions/memberProfile.emulator.test.js index 94870c4..1e058c2 100644 --- a/functions/memberProfile.emulator.test.js +++ b/functions/memberProfile.emulator.test.js @@ -1,6 +1,11 @@ const admin = require('firebase-admin'); const { ensureMemberProfileDocument } = require('./memberProfile'); +if (process.env.REQUIRE_MEMBER_PROFILE_EMULATOR === '1' + && (!process.env.FIRESTORE_EMULATOR_HOST || !process.env.FIREBASE_AUTH_EMULATOR_HOST)) { + throw new Error('Member profile integration requires both local Auth and Firestore.'); +} + const describeWithEmulator = process.env.FIRESTORE_EMULATOR_HOST ? describe : describe.skip; diff --git a/functions/package-lock.json b/functions/package-lock.json index 22db337..e1101c5 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -16,7 +16,7 @@ "jest": "^29.7.0" }, "engines": { - "node": "20" + "node": "22" } }, "node_modules/@babel/code-frame": { diff --git a/functions/package.json b/functions/package.json index 406cc72..7eb21a0 100644 --- a/functions/package.json +++ b/functions/package.json @@ -15,7 +15,7 @@ "test:run": "jest" }, "engines": { - "node": "20" + "node": "22" }, "main": "index.js", "dependencies": { diff --git a/functions/profileRuntime.test.js b/functions/profileRuntime.test.js index 9c64823..dca87de 100644 --- a/functions/profileRuntime.test.js +++ b/functions/profileRuntime.test.js @@ -2,6 +2,22 @@ // will receive. Importing these modules never invokes Auth, Firestore or a user. const { onSignUp } = require('./signup'); const { ensureMemberProfile } = require('./ensureMemberProfile'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +test.each([ + {}, + { FIRESTORE_EMULATOR_HOST: '127.0.0.1:8081' }, + { FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099' }, +])('required profile integration cannot silently skip without both emulators %#', (hosts) => { + const result = spawnSync(process.execPath, ['memberProfile.emulator.test.js'], { + cwd: path.resolve(__dirname), + env: { GCLOUD_PROJECT: 'demo-functions-test', REQUIRE_MEMBER_PROFILE_EMULATOR: '1', ...hosts }, + encoding: 'utf8', timeout: 10000, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Member profile integration requires both local Auth and Firestore.'); +}); describe('minimal profile backend resource limits', () => { const originalProject = process.env.GCLOUD_PROJECT; diff --git a/functions/stripeCheckoutSessionProjection.js b/functions/stripeCheckoutSessionProjection.js index d446d08..5c60e42 100644 --- a/functions/stripeCheckoutSessionProjection.js +++ b/functions/stripeCheckoutSessionProjection.js @@ -78,6 +78,15 @@ class StripeCheckoutSessionProjectionError extends Error { writable: false, configurable: false, }); + // Node 22 exposes a stack setter even on a frozen Error. Keep this + // redacted error surface immutable without invoking a stack formatter. + delete this.stack; + SAFE_DEFINE_PROPERTY(this, 'stack', { + value: `StripeCheckoutSessionProjectionError: ${ERROR_MESSAGE}`, + enumerable: false, + writable: false, + configurable: false, + }); SAFE_FREEZE(this); } } diff --git a/functions/stripeCheckoutSessionProjection.test.js b/functions/stripeCheckoutSessionProjection.test.js index 70a3f85..18a9cf2 100644 --- a/functions/stripeCheckoutSessionProjection.test.js +++ b/functions/stripeCheckoutSessionProjection.test.js @@ -267,6 +267,30 @@ function project(overrides = {}) { } describe('server-only Stripe Checkout Session projection', () => { + test('error stack is fixed data, not a mutable runtime accessor or formatting hook', () => { + const original = Object.getOwnPropertyDescriptor(Error, 'prepareStackTrace'); + let formatterCalls = 0; + let error; + try { + Error.prepareStackTrace = () => { + formatterCalls += 1; + return SECRET_CANARY; + }; + error = new StripeCheckoutSessionProjectionError(); + expect(Reflect.set(error, 'stack', PERSONAL_DATA_CANARY)).toBe(false); + expect(Object.getOwnPropertyDescriptor(error, 'stack')).toEqual({ + value: 'StripeCheckoutSessionProjectionError: Stripe Checkout Session observation is invalid.', + enumerable: false, + writable: false, + configurable: false, + }); + expect(formatterCalls).toBe(0); + } finally { + if (original) Object.defineProperty(Error, 'prepareStackTrace', original); + else delete Error.prepareStackTrace; + } + }); + test('exports one frozen, versioned, unused projection API', () => { const api = require('./stripeCheckoutSessionProjection'); expect(checkoutSessionProjectionSchemaVersion === 1).toBe(true); diff --git a/scripts/firebase-profile-functions-staging-contract.js b/scripts/firebase-profile-functions-staging-contract.js index e0f9d56..6b5fd0f 100644 --- a/scripts/firebase-profile-functions-staging-contract.js +++ b/scripts/firebase-profile-functions-staging-contract.js @@ -123,6 +123,8 @@ function validateSourceContract({ functionIndex, packageJson, packageLock, + functionsPackageJson, + functionsPackageLock, }) { validateFunctionsConfig(firebaseConfig); if (!hasExactKeys(aliases, ['projects']) @@ -132,6 +134,8 @@ function validateSourceContract({ } validateFunctionExports(functionIndex); validatePackageContract(packageJson, packageLock); + if (functionsPackageJson?.engines?.node !== '22' + || functionsPackageLock?.packages?.['']?.engines?.node !== '22') reject(); return Object.freeze({ account: CLUB_ACCOUNT, @@ -183,6 +187,8 @@ function validateRepository(root = path.resolve(__dirname, '..')) { functionIndex: fs.readFileSync(path.join(root, 'functions/index.js'), 'utf8'), packageJson: readJson(root, 'package.json'), packageLock: readJson(root, 'package-lock.json'), + functionsPackageJson: readJson(root, 'functions/package.json'), + functionsPackageLock: readJson(root, 'functions/package-lock.json'), }); } diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index 8611c74..fed5ee8 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -177,6 +177,75 @@ function expectedFirestoreRulesJob() { }; } +function expectedNode22Job() { + return { + name: 'Node 22 backend compatibility', + 'runs-on': 'ubuntu-latest', + 'timeout-minutes': 10, + permissions: { contents: 'read' }, + steps: [ + { uses: 'actions/checkout@v6', with: { 'persist-credentials': false } }, + { uses: 'actions/setup-node@v6', with: { 'node-version': '22', cache: 'npm' } }, + { uses: 'actions/setup-java@v5', with: { distribution: 'temurin', 'java-version': '21' } }, + { name: 'Install committed root dependencies', run: 'npm ci --legacy-peer-deps --ignore-scripts' }, + { name: 'Install committed Functions dependencies', run: 'npm --prefix functions ci --ignore-scripts' }, + { name: 'Run Node 22 backend tests with profile persistence', + env: { REQUIRE_MEMBER_PROFILE_EMULATOR: '1' }, + run: 'npx --no-install firebase emulators:exec --project demo-functions-test --only firestore,auth ' + + '"npm --prefix functions run test:run -- --runInBand"' }, + ], + }; +} + +test('Node 22 compatibility is exact, credential-free and required on both release reads', () => { + assert.deepEqual(exactEmulatorJobErrors(ciWorkflow, 'functions-node22', expectedNode22Job()), []); + assert.deepEqual(node22ReleaseErrors(releaseWorkflow), []); +}); + +function node22ReleaseErrors(workflow) { + const errors = releaseErrors(workflow, 'Node 22 backend compatibility'); + const backend = parseWorkflow(workflow)?.jobs?.['deploy-backend']; + const setup = backend?.steps?.filter((step) => step.uses?.startsWith('actions/setup-node@')); + if (setup?.length !== 1 || setup[0].with?.['node-version'] !== '22') { + errors.push('backend release must use Node 22'); + } + return errors; +} + +test('Node 22 release guard rejects either missing or duplicate gate and runtime downgrade', () => { + for (const id of ['preflight', 'deploy-backend']) { + const original = jobBlock(releaseWorkflow, id); + const name = '"Node 22 backend compatibility"'; + for (const changed of [ + original.replace(name, '"Not the Node 22 check"'), + original.replace(name, `${name} ${name}`), + ]) { + assert.notDeepEqual(node22ReleaseErrors(releaseWorkflow.replace(original, changed)), []); + } + } + const backend = jobBlock(releaseWorkflow, 'deploy-backend'); + assert.notDeepEqual(node22ReleaseErrors(releaseWorkflow.replace(backend, + backend.replace("node-version: '22'", "node-version: '20'"))), []); +}); + +test('Node 22 gate rejects omission, skips, privilege growth and incorrect execution', () => { + const original = jobBlock(ciWorkflow, 'functions-node22'); + for (const changed of [ + '', original.replace("node-version: '22'", "node-version: '20'"), + original.replace(' runs-on:', ' if: false\n runs-on:'), + original.replace(' runs-on:', ' continue-on-error: true\n runs-on:'), + original.replace('contents: read', 'contents: write'), + original.replace('persist-credentials: false', 'persist-credentials: true'), + original.replace("REQUIRE_MEMBER_PROFILE_EMULATOR: '1'", "REQUIRE_MEMBER_PROFILE_EMULATOR: '0'"), + original.replace('demo-functions-test', 'production-project'), + original.replace('--only firestore,auth', '--only firestore'), + original.replace('--runInBand', '--runInBand --testPathIgnorePatterns=memberProfile'), + ]) { + assert.notDeepEqual(exactEmulatorJobErrors(ciWorkflow.replace(original, changed), + 'functions-node22', expectedNode22Job()), []); + } +}); + function ciErrors(workflow, functionsScripts = functionsPackageJson.scripts) { const errors = []; const block = jobBlock(workflow, JOB_ID); @@ -408,9 +477,9 @@ function functionsJobCredentialErrors(workflow) { return errors; } -function releaseErrors(workflow) { +function releaseErrors(workflow, jobName = JOB_NAME) { const errors = []; - const requiredName = `"${JOB_NAME}"`; + const requiredName = `"${jobName}"`; const preflight = jobBlock(workflow, 'preflight'); const backend = jobBlock(workflow, 'deploy-backend'); if ((preflight.match(new RegExp(requiredName, 'g')) ?? []).length !== 1) { @@ -686,22 +755,25 @@ test('lint baseline rejects parser errors and malformed result or baseline data' test('guard rejects missing, renamed, broadened, or unsafe CI wiring', () => { const commerceBlock = jobBlock(ciWorkflow, JOB_ID); + const mutateCommerce = (from, to) => ciWorkflow.replace( + commerceBlock, commerceBlock.replace(from, to), + ); const mutations = [ ciWorkflow.replace(` ${JOB_ID}:\n`, ' removed-journal-job:\n'), ciWorkflow.replace(`name: ${JOB_NAME}`, 'name: Generic emulator'), ciWorkflow.replace(`--project ${DEMO_PROJECT}`, '--project demo-other-test'), ciWorkflow.replace(`${OPT_IN}: '1'`, `${OPT_IN}: '0'`), - ciWorkflow.replace('--only firestore', '--only auth,firestore'), - ciWorkflow.replace('npx --no-install firebase', 'npx firebase-tools@latest'), + mutateCommerce('--only firestore', '--only auth,firestore'), + mutateCommerce('npx --no-install firebase', 'npx firebase-tools@latest'), ciWorkflow.replace(TEST_FILE, 'another.test.js'), - ciWorkflow.replace('timeout-minutes: 10', `continue-on-error: ${ALWAYS_TRUE}`), - ciWorkflow.replace( + mutateCommerce('timeout-minutes: 10', `continue-on-error: ${ALWAYS_TRUE}`), + mutateCommerce( ' run: >-\n npx --no-install firebase emulators:exec', ` if: ${NEVER_RUN}\n` + ' run: >-\n npx --no-install firebase emulators:exec', ), ciWorkflow.replace(TEST_FILE, `${TEST_FILE} || true`), - ciWorkflow.replace(`java-version: '${JAVA_VERSION}'`, "java-version: '17'"), + mutateCommerce(`java-version: '${JAVA_VERSION}'`, "java-version: '17'"), ciWorkflow.replace(commerceBlock, `${commerceBlock} - run: npm i -g firebase-tools\n`), ciWorkflow.replace( commerceBlock, diff --git a/tests/firebase-profile-functions-staging-contract.test.js b/tests/firebase-profile-functions-staging-contract.test.js index 7e469cc..e3c7e56 100644 --- a/tests/firebase-profile-functions-staging-contract.test.js +++ b/tests/firebase-profile-functions-staging-contract.test.js @@ -43,6 +43,8 @@ function sourceContract(overrides = {}) { functionIndex: read('functions/index.js'), packageJson: readJson('package.json'), packageLock: readJson('package-lock.json'), + functionsPackageJson: readJson('functions/package.json'), + functionsPackageLock: readJson('functions/package-lock.json'), ...overrides, }; } @@ -60,6 +62,22 @@ test('CI-001D3 accepts only the exact staging profile Function source contract', }); }); +test('runtime source and committed lock metadata both require exact Node 22', () => { + const { validateSourceContract } = loadContract(); + assert.equal(readJson('functions/package.json').engines.node, '22'); + assert.equal(readJson('functions/package-lock.json').packages[''].engines.node, '22'); + for (const runtime of ['20', '18', '24', '>=22', undefined]) { + const manifest = readJson('functions/package.json'); + manifest.engines.node = runtime; + assert.throws(() => validateSourceContract(sourceContract({ functionsPackageJson: manifest })), + /firebase_profile_functions_staging_contract_rejected/); + const lock = readJson('functions/package-lock.json'); + lock.packages[''].engines.node = runtime; + assert.throws(() => validateSourceContract(sourceContract({ functionsPackageLock: lock })), + /firebase_profile_functions_staging_contract_rejected/); + } +}); + test('CI-001D3 rejects a changed deploy alias or Functions codebase', () => { const { validateSourceContract } = loadContract(); const firebaseConfig = readJson('firebase.json'); diff --git a/tests/firebase-release-verification.test.js b/tests/firebase-release-verification.test.js index 359fa59..9268423 100644 --- a/tests/firebase-release-verification.test.js +++ b/tests/firebase-release-verification.test.js @@ -17,7 +17,7 @@ function functionState(project, id, overrides = {}) { name: `projects/${project}/locations/us-central1/functions/${id}`, status: 'ACTIVE', entryPoint: id, - runtime: 'nodejs20', + runtime: 'nodejs22', minInstances: 0, maxInstances: 2, availableMemoryMb: 256, @@ -233,6 +233,24 @@ test('rejects missing Function revision evidence', async () => { assert.ok(errors.includes('createMemberOnSignUp revision metadata is unreadable.')); }); +test('requires Node 22 readback for both profile Functions, not stale or missing runtime', async () => { + const { validateBackendState } = await import(SCRIPT_URL); + const project = 'demo-approved-project'; + for (const id of ['createMemberOnSignUp', 'ensureMemberProfile']) { + for (const runtime of ['nodejs20', 'nodejs18', 'nodejs24', undefined, null, '22']) { + const functions = { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp'), + ensureMemberProfile: functionState(project, 'ensureMemberProfile'), + }; + functions[id].runtime = runtime; + assert.deepEqual(validateBackendState({ project }, { + project, functions, + rules: rulesState(project, 'new', [{ name: 'firestore.rules', digest: 'expected' }]), + }, 'expected'), [`${id} has the wrong runtime or entry point.`]); + } + } +}); + test('rejects missing, malformed or different resource limits on either Function', async () => { const { validateBackendState } = await import(SCRIPT_URL); const project = 'demo-approved-project'; diff --git a/tests/test-artifact-safety.test.js b/tests/test-artifact-safety.test.js index 8b819da..abdf6db 100644 --- a/tests/test-artifact-safety.test.js +++ b/tests/test-artifact-safety.test.js @@ -71,19 +71,20 @@ const EXPECTED_RELEASE_JOB_LOOP = [ ' for required_job in \\', ' "Frontend lint + build" \\', ' "Cloud Functions lint + test" \\', + ' "Node 22 backend compatibility" \\', ' "Commerce command journal emulator" \\', ` "${JOB_NAME}" \\`, ' "Firestore security-rules tests"', ' do', ].join('\n'); const EXPECTED_RELEASE_STEP_DIGESTS = Object.freeze({ - preflight: '3dafc148dd6062bf6d1f6f9f5627f12a7a18b9baf490aff9e46dd716377e7112', - postApproval: '93ecd6977236c93d8bb4367dec18349906a36d333587bf0891bfbdfec590fcc5', + preflight: '6ce0935c8017751980bc4c090dbe2242d73a76152a7f8737642cb855077d1260', + postApproval: '1510e2172dd67d0bc6c624b160d5f286ad6b4af72c1da340854c88ac8101bbfe', }); const EXPECTED_RELEASE_CONTEXT_DIGESTS = Object.freeze({ header: 'd5242cf76b0e8710346728e6f6f34852f4180e22e7df4ac628bb633d5eafa9fe', - preflight: '55e3100f120b37c56813c149c34aeebcb9dc9095cfe9acdd5ac4a08f52e77c8f', - postApproval: 'db137fb23dda50944583e068701f0b6049b9b0e96f5c162e4c859f39ba16fd84', + preflight: '402f8f059bb066496101954cae88593b134478f91f482ad65ad3ed0bb1b46748', + postApproval: '2f6624d64a40222da0122a4d4bd71aacd679c5424467566e3b52f27d1bf06a34', }); let scannerModule;