From e0c459f1135287cf88af44ec02ab9c1615df3b40 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sun, 23 Aug 2026 13:14:29 -0700 Subject: [PATCH] feat: add fail-closed Firebase Hosting foundation (#663) --- .env.example | 9 +- .firebaserc | 2 +- .github/workflows/ci.yml | 19 +- .github/workflows/deploy.yml | 9 + AGENTS.md | 15 +- GITHUB_ISSUES.md | 6 +- GITHUB_ISSUE_SLICES.md | 2 +- IMPLEMENTATION_PLAN.md | 2 + OFFICER_START_HERE.md | 2 + OPERATIONS_RUNBOOK.md | 39 ++- README.md | 15 +- SECURITY.md | 5 +- SYSTEM_DESIGN.md | 8 +- docs/officers/ACCESS_CONTINUITY.md | 3 + docs/officers/EMERGENCY_AND_RECOVERY.md | 5 +- docs/officers/GLOSSARY.md | 1 + docs/officers/PUBLISH_AND_CHECK.md | 9 +- docs/officers/SYSTEM_MAPS.md | 4 +- firebase.json | 19 ++ package.json | 3 +- scripts/firebase-hosting-contract.js | 212 +++++++++++++++ scripts/netlify-release-build.js | 53 +++- .../firebase/FirebaseResources.test.ts | 219 ++++++++++++++++ src/services/firebase/FirebaseResources.ts | 133 +++++++++- tests/firebase-hosting-foundation.test.js | 241 ++++++++++++++++++ tests/release-workflow.test.js | 30 ++- tests/test-artifact-safety.test.js | 17 +- 27 files changed, 1033 insertions(+), 49 deletions(-) create mode 100644 scripts/firebase-hosting-contract.js create mode 100644 tests/firebase-hosting-foundation.test.js diff --git a/.env.example b/.env.example index 52c30b2b..065a0f76 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,13 @@ # Public React build configuration. REACT_APP_* values are embedded in the # browser bundle and MUST NOT contain secrets. -# The current FirebaseResources implementation contains the production web -# config directly; these names document the intended environment-specific -# migration and match existing local files. Firebase web config is public, but +# Development/test accept only `local` (or an absent selector) and use the +# demo-mprc-local emulators; the hosted fields below are ignored there. +# Optimized builds must select `staging` or `production` and supply the complete +# matching public web config. Never use CI's reserved synthetic staging values +# as evidence of an owned staging project. Firebase web config is public, but # every environment still needs correct Auth, Rules, and App Check controls. +REACT_APP_FIREBASE_ENVIRONMENT=local REACT_APP_FIREBASE_API_KEY= REACT_APP_FIREBASE_AUTH_DOMAIN= REACT_APP_FIREBASE_PROJECT_ID= diff --git a/.firebaserc b/.firebaserc index 9413418c..25dcb80d 100644 --- a/.firebaserc +++ b/.firebaserc @@ -1,5 +1,5 @@ { "projects": { - "default": "mid-peninsula-running-club" + "default": "demo-mprc-local" } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2728414b..7713d0bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,16 +38,33 @@ jobs: run: | ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/deploy.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/ci.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' - node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/test-artifact-safety.test.js tests/root-dependency-security.test.js + node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/firebase-hosting-foundation.test.js tests/test-artifact-safety.test.js tests/root-dependency-security.test.js - name: Build env: CI: 'true' # Lint runs as its own step above; the webpack eslint pass would # otherwise fail CI=true builds on style warnings. DISABLE_ESLINT_PLUGIN: 'true' + REACT_APP_FIREBASE_ENVIRONMENT: 'staging' + REACT_APP_FIREBASE_API_KEY: 'synthetic-ci-api-key-not-a-credential' + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mprc-staging-ci.firebaseapp.com' + REACT_APP_FIREBASE_PROJECT_ID: 'mprc-staging-ci' + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mprc-staging-ci.firebasestorage.app' + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '100000000001' + REACT_APP_FIREBASE_APP_ID: '1:100000000001:web:abcdef0123456789' REACT_APP_RECAPTCHA_SITE_KEY: ${{ secrets.REACT_APP_RECAPTCHA_SITE_KEY }} REACT_APP_SENTRY_DSN: ${{ secrets.REACT_APP_SENTRY_DSN }} run: npm run build + - name: Verify synthetic staging Firebase artifact + env: + REACT_APP_FIREBASE_ENVIRONMENT: 'staging' + REACT_APP_FIREBASE_API_KEY: 'synthetic-ci-api-key-not-a-credential' + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mprc-staging-ci.firebaseapp.com' + REACT_APP_FIREBASE_PROJECT_ID: 'mprc-staging-ci' + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mprc-staging-ci.firebasestorage.app' + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '100000000001' + REACT_APP_FIREBASE_APP_ID: '1:100000000001:web:abcdef0123456789' + run: node scripts/firebase-hosting-contract.js verify build test-artifact-scrubber: name: Test artifact scrubber diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f2cc1201..a699c362 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -178,12 +178,21 @@ jobs: CI: 'true' DISABLE_ESLINT_PLUGIN: 'true' SITE_ORIGIN: 'https://runmprc.com' + REACT_APP_FIREBASE_ENVIRONMENT: 'production' + REACT_APP_FIREBASE_API_KEY: 'AIzaSyD2u17HMhDPZ0Tn9D3H71fep1vZgT-njnw' + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mid-peninsula-running-club.firebaseapp.com' + REACT_APP_FIREBASE_PROJECT_ID: 'mid-peninsula-running-club' + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mid-peninsula-running-club.firebasestorage.app' + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '253289716314' + REACT_APP_FIREBASE_APP_ID: '1:253289716314:web:dcad9766d820044d7f9663' + REACT_APP_FIREBASE_MEASUREMENT_ID: 'G-ECN7TT0BGF' REACT_APP_RECAPTCHA_SITE_KEY: ${{ vars.REACT_APP_RECAPTCHA_SITE_KEY }} REACT_APP_SENTRY_DSN: ${{ vars.REACT_APP_SENTRY_DSN }} SOURCE_COMMIT: ${{ needs.preflight.outputs.source_commit }} run: | set -euo pipefail npm run build + node scripts/firebase-hosting-contract.js verify build printf '%s\n' "$SOURCE_COMMIT" > build/release-commit.txt - name: Reject server-credential material in the website artifact run: | diff --git a/AGENTS.md b/AGENTS.md index 3f931d8e..94b52c49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,16 +96,25 @@ npm --prefix functions run test:run -- --runInBand CI=true npm test -- --watchAll=false --runInBand npm run test:spa-navigation npm run test:rules -CI=true DISABLE_ESLINT_PLUGIN=true npx --no-install react-scripts build +CI=true DISABLE_ESLINT_PLUGIN=true \ +REACT_APP_FIREBASE_ENVIRONMENT=staging \ +REACT_APP_FIREBASE_API_KEY=synthetic-ci-api-key-not-a-credential \ +REACT_APP_FIREBASE_AUTH_DOMAIN=mprc-staging-ci.firebaseapp.com \ +REACT_APP_FIREBASE_PROJECT_ID=mprc-staging-ci \ +REACT_APP_FIREBASE_STORAGE_BUCKET=mprc-staging-ci.firebasestorage.app \ +REACT_APP_FIREBASE_MESSAGING_SENDER_ID=100000000001 \ +REACT_APP_FIREBASE_APP_ID=1:100000000001:web:abcdef0123456789 \ +npx --no-install react-scripts build +node scripts/firebase-hosting-contract.js verify build ``` -The Rules and commerce-emulator suites need Java 21. The direct `react-scripts build` command is preferred for a diagnostic build because normal `npm run build` regenerates the sitemap. Run dependency audits for security/dependency issues, but do not apply forced automatic upgrades. +The Rules and commerce-emulator suites need Java 21. The synthetic Firebase values above authorize no provider call or deployment. The direct `react-scripts build` command avoids regenerating the sitemap; the following verifier must pass before its output is trusted as a staging-shaped artifact. Normal `npm run build` validates its selected hosted environment and regenerates the sitemap. Run dependency audits for security/dependency issues, but do not apply forced automatic upgrades. The deterministic frontend Jest baseline is available through the command above. Hosted CI runs it as the blocking `Run frontend Jest tests` step under #124 and runs the standalone SPA suite under #126. #135 adds a tested manual, exact-commit, backend-first GitHub release gate and pauses Git-triggered Netlify production builds. This proves source shape only: protected environments/OIDC (#133), staged/live deployment (#136), fail-closed lint, required checks, Netlify publication, provider configuration, and production behavior remain separate work under #105 and WEB-001. For Firebase-backed local development, run `npm run emulators` first and wait until Auth, Firestore, and Functions are ready under `demo-mprc-local`. In a second terminal, run `npm start` and use `http://localhost:3000`. Stop if any browser Firebase request uses a non-loopback host. The emulator suite does not isolate Stripe, Strava, email, or other provider calls made by Functions; those flows remain forbidden until their test configuration and safe sink are separately proven. -Optimized builds and current deploy previews still use production Firebase configuration. Do not sign in, open private/admin pages, or exercise Firebase/provider behavior in a preview. Safe staging and provider isolation remain **NOT AVAILABLE YET** under #105/CONFIG. +Optimized builds must explicitly select `staging` or `production`. WEB-001A1 #663 provides and tests that source boundary, but it does not create an owned staging project or isolate outside providers. Do not sign in, open private/admin pages, or exercise Firebase/provider behavior in a preview until the protected staging and provider evidence required by #105/CONFIG exists. For Stripe lifecycle work, tests must include relevant negative and retry cases: invalid signature, duplicate/out-of-order Event, unpaid completion, amount/currency/environment mismatch, terminal-state protection, async success/failure, expiration, cancellation, and refund retry. Capacity/inventory work must include concurrent final-unit tests. diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index c4977f6b..5f11dd7a 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -77,7 +77,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 21 | DATA-002 | Create immutable, truthful waiver evidence | P1 | M | blocked_owner_decision | RACE-001, LEGAL-001 decision | | 22 | ADMIN-001 | Move sensitive admin operations behind scoped APIs and durable audit | P1 | L | proposed | SEC-001, AUTH-003, PAY-002 | | 23 | MERCH-002 | Configure shipping, tax, returns, and order communications | P1 | L | blocked_owner_decision | MERCH-001, LEGAL-001 | -| 24 | WEB-001 | Move to controlled hosting and add browser security policy | P1 | L | proposed | SAFETY-001, CI-001 | +| 24 | WEB-001 | Move to controlled hosting and add browser security policy | P1 | L | source foundation in progress through #460/#663; not deployed | SAFETY-001, CI-001 | | 25 | OBS-001 | Add payment observability, alerts, SLOs, and redaction | P1 | M | proposed | PAY-003, PAY-005 | | 26 | RESILIENCE-001 | Establish backup, restore, retention, and audited repair | P1 | L | proposed | DATA-001, PAY-005 | | 27 | TEST-001 | Build the Stripe/Firebase security integration and E2E suite | P0 | L | in progress through #177; most children proposed | core security/payment/data children | @@ -134,7 +134,7 @@ GitHub Pages routes unknown SPA paths through `public/404.html`. The original br ### Agent handoff -Do not redesign routing or hosting in this issue; WEB-001 owns that. Optimized previews still target production Firebase, so private preview behavior remains blocked on #105/CONFIG. Firebase emulators do not make Stripe, Strava, or email safe. ABUSE-001A must explicitly defer App Check enforcement on `lookupRegistration`, `lookupOrder`, and `stravaExchangeCode` while the initial capability guard is active. DATA-001A owns the two payment-confirmation handoffs; OAUTH-001C within live [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) owns the Strava exchange handoff. Do not weaken the guard. Do not include any real callback token in fixtures. #118 still owns the reported profile failure. +Do not redesign routing or hosting in this issue; WEB-001 owns that. WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) now makes ordinary optimized previews select a reserved synthetic staging configuration, but private preview behavior remains blocked because no owned staging project or provider isolation is verified under #105/CONFIG. Firebase emulators do not make Stripe, Strava, or email safe. ABUSE-001A must explicitly defer App Check enforcement on `lookupRegistration`, `lookupOrder`, and `stravaExchangeCode` while the initial capability guard is active. DATA-001A owns the two payment-confirmation handoffs; OAUTH-001C within live [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) owns the Strava exchange handoff. Do not weaken the guard. Do not include any real callback token in fixtures. #118 still owns the reported profile failure. --- @@ -468,7 +468,7 @@ Use OAUTH-001A/B/C as bounded outcomes within canonical tracker [#88](https://gi ## AUTH-003 — Introduce scoped admin capabilities, MFA, and recent authentication **Labels:** `priority:P1`, `type:security`, `area:auth`, `area:firebase`, `size:L`, `needs-migration`, `needs-external-config` -**Status:** Proposed +**Status:** In progress through tracker [#460](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/460). Source-only child WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) adds environment-explicit builds, artifact checks, and inert Firebase Hosting SPA configuration; no staging/production Hosting site, headers, release authority, deployment, domain, or cutover is verified. **Depends on:** AUTH-001, AUTH-002, SEC-001 ### Problem diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 1d037cfc..a632f1e5 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -138,7 +138,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | MERCH-002B | proposed; A, MERCH-001B | Apply server shipping/tax config and actual total snapshots/reconciliation. | Test-mode taxable/pickup/shipping/mismatch fixtures. | | MERCH-002C | proposed; A/B, ADMIN-001C | Add fulfillment/return/restock state and audited operator actions. | Forbidden transitions and stock adjustment tests. | | MERCH-002D | proposed; A–C, MAIL-001B | Add minimum customer communications/UI and address minimization. | Lifecycle/email/PII projection tests. | -| WEB-001A | owner_action; CI-001B2 | Provision isolated staging hosting, rewrites, canonical/sitemap behavior, deploy and rollback. | ADR and deep-route staging evidence. | +| WEB-001A | owner_action; source foundation tracked by [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663); CI-001B2 | #663 adds environment-explicit builds, executable artifact checks, a demo-only CLI default, and inert Hosting rewrites. An authorized owner must still provision isolated staging hosting, short-lived authority, canonical/sitemap behavior, deploy, readback, and rollback. | #663 source tests and synthetic Hosting-emulator deep routes are not a hosted stage; later ADR plus exact deployed staging/readback/rollback evidence is required. | | WEB-001B | proposed; A | Add CSP/HSTS/frame/referrer/permissions/MIME/cache policy. | Automated header/CSP and browser flow smoke. | | WEB-001C | partial source retirement tracked by [#492](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/492); remaining work proposed; A/B, PAY-001A | Keep the dormant legacy raw-HTML renderer/parser removed, then migrate any legacy stored content to the structured #83 contract and validate external URLs. | #492 no-manifest/no-lock/no-source guard plus later XSS/protocol/legacy-content migration fixtures. No route, data, Rules, hosting, or live change is proved by #492. | | WEB-001D | owner_action; A–C | Execute controlled DNS cutover/rollback and retire old deploy path without enabling commerce. | Dated owner-approved cutover evidence. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index af1f4a2a..192b1896 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -36,6 +36,8 @@ MPRC can open a race or merchandise item for sale only when the platform can: **WEB-002D completed release boundary:** [#659](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/659) completed one exact-artifact Netlify exception on 2026-08-14. Exact release merge `46e23647d8e0bf9fa3a574ea5c5f993be10a419d` published deploy `6a7ece87c5ca4d0007c1a3fc` from source `7496fe0881fb52908c4ff2f40f488df09c94c908`, tree `ccac4c189c195db8ab594e0eefe256ea9fa04996`, 62 files, and digest `e4c26e6f0fbcd086663d86238675f0be228fb649a00628c1c97d1166612f49c7`. Signed-out desktop route-focus and phone menu/route-focus checks passed. Repause merge `3138a00c1c48e1d5d1dcda0b44722b09a2194ff7` passed exact-main CI run `31783808994`; attempt `6a7ed0ddb00a46000818878d` remained unpublished and retained the verified deploy. The manifest is inactive, the temporary source/control/repause refs are absent, and rollback ref `codex/netlify-source-659-rollback` remains pinned to #623 source `c2d87d1f69f15e128a0bc9b1b9f915b7c8417aec`. #659 published only the reviewed #291 visible-focus, #490 phone-menu, and #657 route-focus behavior. It deployed no Firebase, Rules, Functions, or indexes; changed no outside-provider configuration, account, sign-in state, production data, payment, or connected directory behavior; and left directory availability literal `false`. Reusable hosting remains open under #460/#133/#136, and #507 still owns every connected-directory gate. +**WEB-001A1 Firebase Hosting source foundation:** [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) is the first bounded child of #460. It makes controlled optimized builds environment-explicit, keeps local/test on the demo emulators, requires complete staging-named public Firebase configuration, rejects production identities in staging, scans the executable synthetic CI artifact, changes the unqualified CLI default to `demo-mprc-local`, and adds the inert `build/` Hosting rewrite. It deliberately does not provision staging, configure short-lived authority or branch/environment protection, deploy Hosting/Firebase, automate `production-web`, add release readback/rollback/headers, alter Netlify/Pages, or touch DNS/TLS. Source/tests/merge and a synthetic build are not staging or production evidence. + ## 3. Dependency map ```mermaid diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index e0a72a93..b15f81b0 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -50,6 +50,8 @@ As of **2026-08-13**, a merge runs checks but does not start the GitHub release. As of **2026-08-14**, [#659](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/659) completed one accessibility-only Netlify release. Deploy `6a7ece87c5ca4d0007c1a3fc` is live from frozen source `7496fe0881fb52908c4ff2f40f488df09c94c908`; its exact 62-file marker and signed-out desktop/phone route-focus and menu checks passed. Repause merge `3138a00c1c48e1d5d1dcda0b44722b09a2194ff7` made the manifest inactive. Its attempt `6a7ed0ddb00a46000818878d` published nothing and retained the verified deploy. #623 deploy `6a7e072f8f346b0008510d29` is the recorded rollback, not current production. #659 changed no Firebase, provider configuration, account, sign-in state, production data, payment, or connected directory behavior. Officers do not run commands, sign in, enter data, or approve this as a reusable release. Follow the completed record in [Review, merge, release, and check a change](./docs/officers/PUBLISH_AND_CHECK.md). +As of **2026-08-23**, WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) adds source checks for an explicit staging-or-production website build and a Firebase Hosting SPA rewrite. This is **SOURCE ONLY, NOT LIVE**. There is no club-owned staging Hosting site, protected automatic Firebase Hosting release, custom domain, or officer publish control yet. Netlify still answers `runmprc.com`; a green #663 test or merged source change does not mean Firebase Hosting, Firebase backend services, DNS, GitHub Pages, Netlify, or the public website changed. + The optional profile-photo and officer People-finder functions are still **NOT AVAILABLE YET**. #621 makes the frontend default an inert preview: My Account shows the future photo and separate finder-choice controls disabled, while the People finder stays behind the administrator guard and shows its name field and Search button disabled. The preview reads no saved photo or setting, accepts or uploads no photo, searches no name, and saves nothing; it shows no people or sample results. #623 published exactly that disabled interface as deploy `6a7e072f8f346b0008510d29`. Officers inspect the protected layouts only in synthetic local artifacts. The completed signed-out production review proved only the exact revision, normal sign-in and administrator guards, and absence of a member-directory network request. Do not sign in to production, choose a real photo, enter a real name, or treat the preview as a directory. #623 changed no Firebase, provider configuration, account, sign-in, or production data. #507 must later prove the privacy, authorization, staging, backend-first deployment, and readback gates before a separate reviewed source change may connect it. Follow the preview and source-review procedure in [Events, shop, members, and money](./docs/officers/EVENTS_SHOP_MEMBERS.md). For the concise handbook, see [OFFICER_HANDBOOK.md](./OFFICER_HANDBOOK.md). The expanded task index is [docs/officers/README.md](./docs/officers/README.md). diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index a5da19f0..f26b4e13 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -66,7 +66,7 @@ Maintain an internal inventory with project/account IDs and console links: | Staging | **Create dedicated project** | Test/sandbox + unique endpoint | `dev.runmprc.com` | `staging` | Launch blocker | | Production | `mid-peninsula-running-club` (verify) | Live + unique endpoint | `runmprc.com` | `production` | Informational site only until gates close | -The repository currently includes only one `.firebaserc` project mapping. Do not infer staging isolation from the historical documentation; create and verify it. +The repository's only `.firebaserc` default is now `demo-mprc-local`, a deliberately non-deployable local namespace. It is an accidental-deploy brake, not staging. Every future deployment must still pass one exact privately approved project explicitly. Do not infer staging isolation from source or historical documentation; #113/#133 must create, own, map, and verify it. ## 4. Configuration and secrets @@ -77,13 +77,23 @@ Commit a `.env.example` containing names and safe descriptions, never real secre Expected public/environment-specific names include: ```text +REACT_APP_FIREBASE_ENVIRONMENT +REACT_APP_FIREBASE_API_KEY +REACT_APP_FIREBASE_AUTH_DOMAIN +REACT_APP_FIREBASE_PROJECT_ID +REACT_APP_FIREBASE_STORAGE_BUCKET +REACT_APP_FIREBASE_MESSAGING_SENDER_ID +REACT_APP_FIREBASE_APP_ID +REACT_APP_FIREBASE_MEASUREMENT_ID REACT_APP_RECAPTCHA_SITE_KEY REACT_APP_SENTRY_DSN REACT_APP_SENTRY_ENV REACT_APP_STRAVA_CLIENT_ID ``` -The Firebase web configuration is public application configuration. It should still be environment-specific and paired with Auth restrictions, Firestore rules, and App Check. +The Firebase web configuration is public application configuration. It must still be environment-specific and paired with Auth restrictions, Firestore rules, and App Check. WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) makes controlled optimized builds choose `staging` or `production`; staging project IDs must include a distinct `staging` segment and the complete artifact check rejects known production identities. Production accepts only the exact reviewed public values. These values are not server credentials, but do not paste provider-console screenshots, private links, tokens, or any server credential into source or an issue. + +`firebase.json` now has a source-only Hosting block for `build/` and missing-path SPA rewrites. Its predeploy chain rejects a Firebase CLI project that differs from the selected browser project, then rebuilds and scans executable JavaScript before upload. **Firebase Hosting publication is NOT AVAILABLE YET.** No staging site, channel, WIF identity, protected production branch, custom domain, release marker, rollback, or DNS/TLS change is created by #663. ### App Check release state — NOT AVAILABLE YET @@ -229,7 +239,7 @@ Expected result: the browser uses a fully synthetic Firebase configuration, Auth The CLI readiness messages prove the three processes listened during that run. Mocked frontend tests prove endpoint selection, not process readiness. If an emulator later stops, requests fail locally; do not change the project or disable the guard. -Optimized builds are different. Netlify previews and a locally served `build/` directory use `NODE_ENV=production` and currently target production Firebase. Use them only for public, read-only visual checks. Do not sign in or open account, member, admin, event-registration, shop-purchase, or provider flows. +Optimized builds are different. They must explicitly select `staging` or `production`; missing, local, incomplete, or cross-environment configuration stops startup. Ordinary Netlify deploy previews receive only the reserved synthetic staging configuration; the pinned release builder receives the exact reviewed public production web configuration. Both paths scan the built executable. A synthetic staging build proves artifact separation only—it is not an owned staging backend and does not isolate providers. Use previews and locally served artifacts only for public, read-only visual checks until protected staging is verified. Do not sign in or open account, member, admin, event-registration, shop-purchase, or provider flows. ### Forward Stripe events — NOT AVAILABLE YET for end-to-end use @@ -340,13 +350,30 @@ AUTH-001D1 [#213](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/213) add Passing this test proves only the reviewed website source. Browser role state never authorizes a database or Function request, and it does not grant, remove, refresh, or revoke a role. A protected release must deploy and read back the exact Rules and Functions first, publish the exact website to protected staging second, and then run the complete made-up-account matrix in [Access Continuity](./docs/officers/ACCESS_CONTINUITY.md#synthetic-role-boundary-drill--not-available-yet). Stop if any layer disagrees. Do not repair a result with a real account, profile edit, or Firebase Console change. -### Production build without changing generated sitemap during a diagnostic check +### Synthetic staging build without changing generated sitemap ```bash -CI=true DISABLE_ESLINT_PLUGIN=true npx --no-install react-scripts build +CI=true DISABLE_ESLINT_PLUGIN=true \ +REACT_APP_FIREBASE_ENVIRONMENT=staging \ +REACT_APP_FIREBASE_API_KEY=synthetic-ci-api-key-not-a-credential \ +REACT_APP_FIREBASE_AUTH_DOMAIN=mprc-staging-ci.firebaseapp.com \ +REACT_APP_FIREBASE_PROJECT_ID=mprc-staging-ci \ +REACT_APP_FIREBASE_STORAGE_BUCKET=mprc-staging-ci.firebasestorage.app \ +REACT_APP_FIREBASE_MESSAGING_SENDER_ID=100000000001 \ +REACT_APP_FIREBASE_APP_ID=1:100000000001:web:abcdef0123456789 \ +npx --no-install react-scripts build + +REACT_APP_FIREBASE_ENVIRONMENT=staging \ +REACT_APP_FIREBASE_API_KEY=synthetic-ci-api-key-not-a-credential \ +REACT_APP_FIREBASE_AUTH_DOMAIN=mprc-staging-ci.firebaseapp.com \ +REACT_APP_FIREBASE_PROJECT_ID=mprc-staging-ci \ +REACT_APP_FIREBASE_STORAGE_BUCKET=mprc-staging-ci.firebasestorage.app \ +REACT_APP_FIREBASE_MESSAGING_SENDER_ID=100000000001 \ +REACT_APP_FIREBASE_APP_ID=1:100000000001:web:abcdef0123456789 \ +node scripts/firebase-hosting-contract.js verify build ``` -The normal `npm run build` executes `prebuild` and may update `public/sitemap.xml`. Use the normal command only when generated sitemap changes are intended and reviewed. +These are reserved synthetic values and authorize no provider call or deployment. The first command is a diagnostic compile; the second proves executable JavaScript contains the synthetic staging project and no known production Firebase identity. It does not prove an owned staging project or safe provider behavior. The normal `npm run build` first validates the selected hosted environment and then regenerates `public/sitemap.xml`; use it only when that generated change is intended and reviewed. A direct diagnostic compile is never a release artifact by itself. ### Dependency review diff --git a/README.md b/README.md index c98aba4a..76941512 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ npm start Open only `http://localhost:3000`. Stop if Firebase traffic uses a non-loopback host. Use synthetic records only. This does **not** make checkout, refunds, email, Strava, or other outside-provider calls safe; follow [OPERATIONS_RUNBOOK.md](./OPERATIONS_RUNBOOK.md) before any provider test. -Do not use a Netlify preview or locally served optimized `build/` for sign-in, private pages, admin work, or Firebase testing. Those production-mode builds still target production Firebase until #105/CONFIG establishes staging. +Do not use a Netlify preview or locally served optimized `build/` for sign-in, private pages, admin work, or Firebase testing. WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) makes optimized builds choose `staging` or `production` explicitly, but it does not create or verify an owned staging project or isolate outside providers. ## Verification @@ -76,10 +76,19 @@ npm --prefix functions run test:run -- --runInBand CI=true npm test -- --watchAll=false --runInBand npm run test:spa-navigation npm run test:rules -CI=true DISABLE_ESLINT_PLUGIN=true npx --no-install react-scripts build +CI=true DISABLE_ESLINT_PLUGIN=true \ +REACT_APP_FIREBASE_ENVIRONMENT=staging \ +REACT_APP_FIREBASE_API_KEY=synthetic-ci-api-key-not-a-credential \ +REACT_APP_FIREBASE_AUTH_DOMAIN=mprc-staging-ci.firebaseapp.com \ +REACT_APP_FIREBASE_PROJECT_ID=mprc-staging-ci \ +REACT_APP_FIREBASE_STORAGE_BUCKET=mprc-staging-ci.firebasestorage.app \ +REACT_APP_FIREBASE_MESSAGING_SENDER_ID=100000000001 \ +REACT_APP_FIREBASE_APP_ID=1:100000000001:web:abcdef0123456789 \ +npx --no-install react-scripts build +node scripts/firebase-hosting-contract.js verify build ``` -Rules and commerce-emulator tests require Java 21. The repository pins Firebase CLI 15.24.0; use only that lockfile copy with Node 20 and explicit `demo-*` projects. The direct `react-scripts build` command is useful for a diagnostic compile because the normal `npm run build` runs the sitemap generator and may intentionally update `public/sitemap.xml`. Hosted CI runs the frontend Jest suite under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124), the SPA callback suite under [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126), and the release-gate source tests under [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135). [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133) still owns protected environment/OIDC configuration; [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136) owns the actual staged profile-recovery release. Required branch checks, scanning, staging, and hosting consolidation remain open under [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105) and their atomic children. +Rules and commerce-emulator tests require Java 21. The repository pins Firebase CLI 15.24.0; use only that lockfile copy with Node 20 and explicit `demo-*` projects. The synthetic values above authorize no provider call or deployment. The direct `react-scripts build` command avoids regenerating `public/sitemap.xml`; the following verifier proves that executable JavaScript selected the synthetic staging project and contains no known production Firebase identity. The normal `npm run build` validates its selected hosted environment and runs the sitemap generator. Hosted CI runs the frontend Jest suite under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124), the SPA callback suite under [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126), and the release-gate source tests under [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135). [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133) still owns protected environment/OIDC configuration; [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136) owns the actual staged profile-recovery release. Required branch checks, owned staging, protected publication, and hosting consolidation remain open under [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105) and their atomic children. These safety changes do not repair a missing member profile or prove deployed Firebase Rules/Functions. The reported profile-save failure remains [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118). diff --git a/SECURITY.md b/SECURITY.md index 0e4b96d6..52f42203 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,7 +47,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-007 | Repository source rejects unverified targets at the two existing role-grant endpoints. AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) adds exact verified-email checks to role-based Firestore Rules, and AUTH-001C [#209](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/209) tracks the matching gate at current Functions role consumers. Firebase deployment, verification-mirror refresh, revocation, authoritative membership, scoped capability/recent-auth controls, and replacement of the legacy static-key endpoint remain unproven. | Older deployed code or a remaining role-only server guard can let an unverified known-email account use member/admin authority. CSV export also retains separate minimization, recent-auth, and audit risks. | Deploy and verify the exact Rules/Functions through #105, complete the remaining AUTH-001 mirror/revocation work, require authoritative membership and scoped privileged access, harden roster export under #116, and retire the static-key endpoint. | | RISK-008 | `requireAppCheck` is controlled by optional `ENFORCE_APP_CHECK`; repository/deployment config does not prove it is true. Missing site key also disables client App Check. | Public callable functions may be scripted directly, enabling abuse and cloud/Stripe cost amplification. | Use Firebase runtime `enforceAppCheck: true` for sensitive callables, reCAPTCHA Enterprise, staged metrics, and no environment fail-open. | | RISK-009 | #99 source preserves and validates the complete same-origin Pages callback route, applies `strict-origin` before subresources, and suppresses initial callback App Check/telemetry startup; #126 adds that standalone suite as a blocking hosted CI step. #443 adds only the source/test Strava same-document handoff after the native and Router callback locations are clean. No production Stripe/Strava callback, provider-backed App Check token behavior, or live custom-domain handoff has been rehearsed, and the live custom domain uses a different host. | Hosting, provider, configuration, or release drift can still break return links or the App Check handoff even when source tests pass. Enforcing a callback callable without its matching released client handoff and staged provider evidence can also break confirmation or Strava exchange. | Keep the hosted callback gate blocking; keep `lookupRegistration`/`lookupOrder` deferred until DATA-001A; keep native enforcement for `stravaExchangeCode` deferred to ABUSE-001A2 after the exact #443 website source and provider-backed Enterprise token behavior are proven in protected staging; verify test-mode callbacks only after provider gates; consolidate hosting under WEB-001. | -| RISK-010 | Development/test now use synthetic loopback Auth, Firestore, and Functions, but optimized previews still target production Firebase and emulated Functions can call outside providers. | A maintainer can mistake a preview or Firebase emulator for complete staging and affect production/provider systems. | Prohibit private actions in previews; add dedicated staging/config under #105/CONFIG; prove test keys/sinks before provider flows. | +| RISK-010 | Development/test use synthetic loopback Auth, Firestore, and Functions. WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) makes controlled optimized builds select `staging` or `production` explicitly, requires staging-named public Firebase configuration, rejects known production identities before SDK use, scans CI's executable staging artifact, and makes the Hosting predeploy hook bind the selected browser project to Firebase CLI's exact project before rebuilding and scanning. No owned staging project or provider sandbox is configured, and emulated Functions can still call outside providers. | A bypassed build path, unowned staging project, or provider-capable emulator can still be mistaken for complete staging and affect production/provider systems. | Keep private actions out of previews; merge and preserve #663's fail-closed gate; complete #113/#133 staging ownership and short-lived authority; prove test keys, sinks, provider isolation, and deployed artifact readback before end-to-end flows. | | RISK-011 | Local cancellation only changes Firestore; it does not expire the active Stripe Session. The webhook can later move a cancelled record back to paid. | Customers can pay a supposedly cancelled registration/order; capacity/inventory and support state diverge. | Add explicit cancellation saga with Stripe Session expiry and allowed-state transitions. | | RISK-012 | Repository source now denies every admitted positive late-registration amount before identifier/token allocation, registration writes, Stripe construction, or Product/Price/Payment Link calls, and the Admin screen exposes only the exact-zero compatibility path. Firebase deployment is unproven, and legacy reusable Payment Links may still exist. | Older deployed code or an existing link can still create unreconciled or repeated charges/Sessions for one logical registration. The exact-zero local record is also marked paid without Stripe and is not payment, free, comp, or membership authority. | Keep paid late registration unavailable; inventory, deactivate, and reconcile legacy links privately; then replace them with one-off idempotent Checkout Sessions through the normal registration/payment lifecycle. | | RISK-013 | The assessment baseline permitted Stripe promotions while Firestore stored base price. Repository source now disables promotion entry and automatic tax, requires a complete adjustment breakdown, and quarantines unknown or nonzero discount/tax/shipping amounts; deployment and provider state are unproven. | An older open Session, provider drift, skipped backend release, or later guard regression could still make reporting, refunds, and local payment confirmation disagree. | Privately inventory pre-change Sessions/provider settings, release through the protected gate, verify Firebase and Stripe separately, and keep every adjustment disabled until an approved authoritative model exists. | @@ -87,6 +87,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-035 | The deterministic frontend Jest suite and standalone SPA callback suite run as separate blocking hosted CI steps. CI-001B4/#186 merged a non-mutating frontend lint gate as `bec7d5e365eacb418563a172029f241f660d9768`; exact PR and post-merge runs passed. CI-001B4A [#227](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/227) removes one reviewed `arrow-body-style` error, CI-001B4B [#239](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/239) removes one stale `AdminMembers` unknown-rule suppression record, and PAY-004C1 [#359](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/359) retires one `no-alert` warning plus two label-association errors with the unsafe reusable-link controls. Reviewed functional changes in [PR #391](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/391) and [PR #392](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/392) add one and two TypeScript files respectively while retiring two TSX errors each. CI-001B4C [#449](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/449) replaces the four remaining stale `react-hooks/exhaustive-deps` directives with ordinary same-line comments, without changing executable code or the gate. MEMBERS-CONTENT-001B [#492](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/492) removes one finding-free dormant JSX file. MEMBERS-DIRECTORY-001A [#505](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/505) adds four finding-free TypeScript/TSX files, MEMBERS-DIRECTORY-001B [#506](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/506) adds four more, WEB-SUGGESTIONS-001A [#618](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/618) adds one finding-free JSX page, and MEMBERS-DIRECTORY-001D [#621](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/621) adds one finding-free TypeScript availability module. The current baseline scans 120 files and still records 113 configured errors and 6 warnings after the lint process disables the repository's severity-masking `eslint-plugin-only-warn` hook. Branch protection, remaining lint-debt cleanup, and broader domain/integration coverage remain incomplete. | Continue reducing the reviewed finding baseline in focused changes, prove required branch checks, and add domain/integration coverage. Never regenerate the baseline merely to make CI green. | | RISK-036 | #135 adds a manual exact-commit source gate, fixed profile-recovery targets, backend-first order, missing-config failure, and ordinary Git-triggered Netlify production containment. WEB-UX-001A [#457](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/457) used a temporary exact-artifact exception. On 2026-08-01 an overbroad WEB-002A artifact was merged after a late blocker and published; exact rollback merge `1099ee8` restored source `ed1b0833`, and `dee7951` paused the manifest. Bounded replacement [#473](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/473) then published exact source `39ab8649` as deploy `6a6dc9ea588b0c0008036312`; its delta is only Shop and Events/Calendar failure containment, and its public checks passed. Final control `cb6a8f0` made the manifest inactive; Netlify attempt `6a6dcdd47bc81e000859a249` stopped unpublished and left that bounded deploy as #623's rollback. WEB-002C [#623](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/623) then completed one exact-artifact release: merge `9d5cc8612b4321172370bd949d307e7e4ac0ec7d` published frozen inert source `c2d87d1f69f15e128a0bc9b1b9f915b7c8417aec`, tree `411aa6ec9a9459f5d923030533ffc7c007fe6908`, 62 files, and digest `d837272a1e5efc1575809e87f532276b38d1a63f1dd79ec1aef0533f6da8afb1` as deploy `6a7e072f8f346b0008510d29`. Signed-out marker, route, guard, and no-connected-symbol/request checks passed. Repause merge `c8678c623afdd9becf77d596b71f36f26f04b746` made the manifest inactive; attempt `6a7e081e73fdd60009f7ba57` stopped unpublished and retained the verified deploy. The release source is absent and the rollback ref remains. Protected environments/OIDC, isolated staging, a reusable live-Netlify path, and provider-owned atomic rollback remain unverified. | Require a final blocker re-read and an executable delta from the live artifact before every release merge. Preserve the exact #473 rollback evidence, keep ordinary publication paused, and keep the reviewed Git rollback projection available. Treat #623 as a completed one-off, not a reusable control. Complete #133 and #136, provision isolated staging, protect the reusable Netlify release path under WEB-001, and rehearse provider rollback before broader production work. | | WEB-002D completed exact-artifact containment for RISK-036 | [#659](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/659) completed one accessibility-only release on 2026-08-14. Exact release merge `46e23647d8e0bf9fa3a574ea5c5f993be10a419d` published deploy `6a7ece87c5ca4d0007c1a3fc`; its marker and all 62 artifact paths matched frozen source `7496fe0881fb52908c4ff2f40f488df09c94c908`, tree `ccac4c189c195db8ab594e0eefe256ea9fa04996`, digest `e4c26e6f0fbcd086663d86238675f0be228fb649a00628c1c97d1166612f49c7`, and exact six-path diff digest `462eeb01e7a9858678802464f7dd4b76cd2fcb3c13be827efb4f98fa53ca809c`. Signed-out desktop and phone route-focus/menu checks passed. Repause merge `3138a00c1c48e1d5d1dcda0b44722b09a2194ff7` passed exact-main CI run `31783808994`; attempt `6a7ed0ddb00a46000818878d` stayed unpublished and retained the verified deploy. Literal-false directory availability, Firebase, Rules, Functions, indexes, outside-provider configuration, accounts, sign-in, production data, payments, and connected directory behavior were unchanged. | The manifest is inactive; source/control/repause refs are absent; rollback ref `codex/netlify-source-659-rollback` remains pinned to #623 source `c2d87d1f69f15e128a0bc9b1b9f915b7c8417aec`. Treat #659 as a completed one-off, not reusable authority. Keep ordinary publication paused, preserve #507 connected-directory gates, and preserve active #616 OAUTH-001A2L files and its RISK-024 wording byte-for-byte. | +| WEB-001A1 source foundation for RISK-010 and RISK-036 | [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) adds a dependency-free hosted-build contract, complete environment-explicit browser configuration, executable staging-artifact identity scan, a demo-only Firebase CLI default, and an inert Firebase Hosting SPA rewrite. Missing/unsupported hosted environments, incomplete or malformed staging fields, known production identities in staging, and non-exact production public configuration fail closed. | This is source and synthetic artifact proof only. It creates no staging/Hosting resource, WIF/IAM identity, protected branch/environment, automatic deployment, release marker, provider readback, rollback, header policy, custom domain, DNS/TLS change, Firebase deployment, website publication, or live protection. Complete #113/#133 and the remaining #460 children before any staging or production claim. | | RISK-037 | Account/registration deletion, export, retention, backup, and restore procedures are incomplete. | Approve retention matrix, automate minimization, support access/deletion requests, and test backup restoration. | | RISK-038 | Source-controlled secret scan is ad hoc; no continuous secret scanner, dependency update bot, SBOM, provenance, or branch protection is documented. | Add secret/dependency/code scanning, reviewed lockfile updates, protected environments/branches, and artifact provenance appropriate to project scale. | | RISK-039 | Some authenticated accounts can lack `members/{uid}` after the Firebase cutover; the account screen hid the read failure and exposed an update that could only fail. Manual database/account repair could corrupt roles or private data. | Use an authenticated create-once server bootstrap, keep browser creation denied, fail the UI closed, and prove backend-first deployment with synthetic accounts. | @@ -131,7 +132,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | RISK-001, RISK-002, RISK-034 | PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) source merged as `87bd1210575882cdd06214bcb98ed38ce00f71c2`, adding durable Stripe Event deduplication, transactional transition/processed marking, paid/amount/currency/Checkout-mode/reference-shape and explicitly configured livemode checks, async outcomes, monotonic refund/terminal guards, quarantine, generic signature failures, and a webhook-secret-only binding. | Firebase was not deployed. Complete CONFIG/PAY-001/PAY-002 contracts, metadata schema allowlist/migration, canonical reducers/reservations/outbox, retry/dead-letter/TTL/alerts, emulator integration, Stripe test-mode rehearsal, protected deploy, and reconcile. | | RISK-006, part of RISK-024/RISK-032 | #100 merged at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. OAuth secrets and server-owned financial/operational collections are denied; current catalog writes are restricted to inert drafts/display fields. | Firebase deployment skipped. Stage/deploy the exact Rules revision under #105, verify synthetic deny behavior, then move remaining catalog mutation to scoped APIs under ADMIN-001. | | RISK-009 | #99 preserves same-origin path, query, and fragment state through the Pages fallback, clears temporary state, rejects unsafe targets, and suppresses monitoring startup on initial capability callbacks. #126 adds the standalone suite as the named blocking `Run SPA callback safety tests` hosted step. | Keep exact hosted evidence on #126; verify exact website publication and later run approved test-mode Stripe/Strava callbacks; WEB-001 still owns controlled hosting. | -| RISK-010 | #99 uses a fully synthetic `demo-mprc-local` configuration, connects Functions/Auth/Firestore to loopback in development/test, routes the direct CSV Function URL locally, fails startup on connector errors, and completed a demo-only three-port CLI smoke. | Current optimized previews still target production; provider calls are not isolated by Firebase. Add protected staging/config and test-provider/sink evidence before end-to-end flows. | +| RISK-010 | #99 uses a fully synthetic `demo-mprc-local` configuration, connects Functions/Auth/Firestore to loopback in development/test, routes the direct CSV Function URL locally, fails startup on connector errors, and completed a demo-only three-port CLI smoke. WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) adds explicit hosted selection and synthetic staging executable-artifact proof with no production Firebase identity. | #663 is not a deployed staging environment and does not isolate provider calls made by Functions. Complete #113/#133, an owned staging Hosting site, protected short-lived deployment, provider test keys/sinks, exact marker/readback, and rollback evidence before end-to-end flows. | | Test-only foundation for RISK-010 and RISK-035 | TEST-001A1 is tracked in live [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175). The Functions Jest setup installs a fixed-error, loopback-only guard around supported Node outbound transports and rejects any supplied production-like project/origin/emulator host or live Stripe mode/key. Shared deterministic deeply frozen fixtures use reserved values, and the existing webhook suite adopts one synthetic signed-payload helper. | This is a test-process boundary, not a runtime sandbox. It does not intercept every possible native/add-on transport, isolate an emulated Function process, configure a test provider, scan emitted artifacts, deploy Firebase, or prove live behavior. TEST-001A2 [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177) separately adds the bounded artifact scrubber and named CI/release gate; later TEST-001 children must add emulator sagas and isolated staging evidence. | | Emitted-test-output part of RISK-035 | TEST-001A2 is tracked in live [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177). Its source adds a dependency-free read-only scanner for explicit narrow artifact roots, bounded deterministic traversal, fixed redacted failures, deliberate synthetic allow/deny tests, the exact `Test artifact scrubber` CI job, and that job in both protected-release CI rechecks. Current CI still uploads no test artifact. | This scanner covers a documented bounded set of recognizable patterns; green is not a general data-loss-prevention guarantee or proof that every unknown/obfuscated value is absent. It does not inspect source, runtime logs, website builds, provider responses, production data, or an operating-system sandbox, and it does not sanitize a rejected file. Every later report producer must invoke the scanner before upload, and exact hosted evidence remains separate from source and local-test evidence. | | Configuration part of RISK-001/RISK-010 | CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) merged through PR #150 as `2c62b8dd`, requiring an exact environment/origin/expected Stripe mode, key-mode compatibility on the four key-bound Functions, no production-origin fallback, and no Stripe API key on webhook/mail. CONFIG-001B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds exact `COMMERCE_ENABLED` parsing. A false ceiling denies new commerce before Firestore; otherwise new-commerce commands and every refund perform a fresh strict server-only global/domain/resource admission read. Missing controls/resource flags mean disabled; fixed errors expose no supplied value; Rules tests deny browser control; webhook/mail remain independent. | Neither source result configures or deploys Firebase/Stripe. B1 cannot stop already-admitted commands or existing Sessions/Payment Links and has no safe operator writer. CONFIG-001B2, PAY-002/PAY-004/PAY-005, and #105/#133/#136 retain protected control, expiry/reconciliation, staging, deployment, provider readback, and drill. No live payment or email is authorized. | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 14223946..a564506e 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -97,6 +97,8 @@ The former workflow automatically published Pages before attempting Firebase and WEB-002D [#659](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/659) completed one exact-artifact exception on 2026-08-14. Pinned preview `6a7ec998bf8fde00086d2bfe` matched control `137d8a8721339a6ca1079283cc34c1bd7cc2706c`. Exact release merge `46e23647d8e0bf9fa3a574ea5c5f993be10a419d` published deploy `6a7ece87c5ca4d0007c1a3fc` from source `7496fe0881fb52908c4ff2f40f488df09c94c908`, tree `ccac4c189c195db8ab594e0eefe256ea9fa04996`, 62 files, and artifact digest `e4c26e6f0fbcd086663d86238675f0be228fb649a00628c1c97d1166612f49c7`; the exact six-path predecessor diff digest was `462eeb01e7a9858678802464f7dd4b76cd2fcb3c13be827efb4f98fa53ca809c`. Signed-out desktop route-focus and phone menu/route-focus checks passed. Repause merge `3138a00c1c48e1d5d1dcda0b44722b09a2194ff7` passed exact-main CI `31783808994`; attempt `6a7ed0ddb00a46000818878d` stayed unpublished and retained the verified deploy. The manifest is inactive; temporary refs are absent; #623 deploy `6a7e072f8f346b0008510d29` is the rollback target. #659 changed no route set, content, Firebase surface, outside-provider configuration, account, sign-in state, production data, payment, or directory behavior; directory availability remains literal `false` and #507 still owns connection. +**WEB-001A1 Firebase Hosting foundation — SOURCE ONLY, NOT DEPLOYED:** [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) makes every controlled optimized browser build select `staging` or `production` explicitly. Local/test still use only `demo-mprc-local` and all three loopback emulators. A staging build requires a complete public Firebase web configuration whose project name has a `staging` boundary; both the browser startup boundary and build contract reject production-equal identities, and CI scans executable JavaScript for every selected staging field and absence of known production Firebase identities. Production requires the exact reviewed public web configuration. `firebase.json` now describes `build/`, a missing-path rewrite to `/index.html`, and a predeploy chain that binds the selected web configuration to Firebase CLI's exact `GCLOUD_PROJECT`, rebuilds, and scans the artifact before upload. `.firebaserc` defaults to the non-deployable demo namespace. This source does not create a Firebase Hosting site or channel, provision staging, authenticate to Google Cloud, protect `production-web`, publish a marker, change a custom domain, deploy Firebase, alter Netlify/Pages, or change `runmprc.com`. The current live topology and release diagram below therefore remain unchanged. + ```mermaid flowchart TD Merge["Merge to main"] --> CI["Exact main-push CI"] @@ -229,11 +231,11 @@ Text alternative: after the website hands off the action route, the app keeps on AUTH-MAIL-002C2 [#194](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/194) owns this verification-only source route. It ignores query-provided API keys and continuation URLs, suppresses Sentry and App Check startup while the initial capability is present, and returns only small non-identifying states. Its fixed `/account` exit performs a full clean-page load so App Check can start only after the private query is gone. Firebase configures one custom handler for verification, password reset, and email recovery. Therefore [#119](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/119) must not point the project-wide action URL at this partial route. Keep Firebase's default multi-mode handler or wait for separately reviewed coverage of every enabled mode. Website publication, provider configuration, action-link reachability, and the Firestore verification mirror remain unproven. -The direct-rewrite hosting path does not need browser storage. The existing GitHub Pages fallback from #99 briefly places the complete return route in tab-local `sessionStorage`, then its first root-page script reads and deletes that value before React starts. If the root page never loads, the value can remain until the tab closes. #194 accepts that already-merged residual only for Pages compatibility; it does not call this “memory only.” A future direct SPA rewrite should remove the bridge. The component itself never writes the code to storage or router state. +The direct-rewrite hosting path does not need browser storage. #663 adds that catch-all Hosting rewrite to source, but it is **NOT DEPLOYED** and does not remove the current Pages bridge. The existing GitHub Pages fallback from #99 briefly places the complete return route in tab-local `sessionStorage`, then its first root-page script reads and deletes that value before React starts. If the root page never loads, the value can remain until the tab closes. #194 accepts that already-merged residual only for Pages compatibility; it does not call this “memory only.” A verified Firebase Hosting cutover should remove the bridge only after callback and rollback proof. The component itself never writes the code to storage or router state. ## 4. Target deployment topology -The target keeps React, Firebase, and Stripe, but places stronger boundaries around them. Migrating the frontend from GitHub Pages to Firebase Hosting is recommended before live commerce because it supports controlled SPA rewrites, preview channels, and security headers. That hosting migration is not required to design or test the backend, and must be executed as its own issue. +The target keeps React, Firebase, and Stripe, but places stronger boundaries around them. Migrating the frontend from GitHub Pages/Netlify to Firebase Hosting is recommended before live commerce because it supports controlled SPA rewrites, preview channels, and security headers. #663 supplies only the fail-closed build selector and inert Hosting rewrite configuration. Staging project ownership, protected short-lived deployment authority, automatic exact-commit release, readback, rollback, headers, and DNS/TLS cutover remain separate work under #113/#133/#460. The migration is not required to design or test the backend and is not live merely because its source configuration exists. ```mermaid flowchart TB @@ -1739,7 +1741,7 @@ Text alternative: the local browser uses only the three loopback Firebase emulat #99 source selects a fully synthetic Firebase configuration for development/test, connects Auth, Firestore, and Functions to the loopback ports above, stops startup when connection setup fails, and leaves App Check, Analytics, and Sentry off locally. #139 additionally keeps Firebase Analytics off in every source environment and removes the direct Waiver SDK bypass pending an approved #110 policy. The Firebase CLI must still report all three emulators ready before the app is opened. Mocked endpoint tests alone do not prove listening processes, website publication, provider behavior, or deletion of earlier provider data/cookies. -Optimized builds—including current Netlify previews and a locally served `build/` directory—use `NODE_ENV=production` and still target production Firebase. They are restricted to public, read-only visual review until #105/CONFIG establishes a separate staging configuration. Do not sign in, open private/admin pages, or test Firebase/provider actions in those previews. +Optimized builds must select `staging` or `production` explicitly. WEB-001A1 #663 rejects missing, local, incomplete, or cross-environment Firebase web configuration, and its artifact check rejects known production identities from a staging executable. Ordinary Netlify deploy previews receive and scan only the reserved synthetic staging configuration; the pinned release builder receives and scans the exact reviewed public production web configuration. Source separation does not create an owned staging project or isolate outside providers, so previews remain restricted to public, read-only visual review until #105/CONFIG supplies protected staging evidence. Do not sign in, open private/admin pages, or test Firebase/provider actions in those previews. `SITE_ORIGIN`, Firebase project IDs, App Check keys, Stripe keys, webhook endpoints, Sentry environments, and email providers must be environment-scoped. Live and test webhook secrets are never interchangeable. The repository still needs an explicit staging project before end-to-end rollout. diff --git a/docs/officers/ACCESS_CONTINUITY.md b/docs/officers/ACCESS_CONTINUITY.md index 4b2cd3b2..40f95dec 100644 --- a/docs/officers/ACCESS_CONTINUITY.md +++ b/docs/officers/ACCESS_CONTINUITY.md @@ -38,6 +38,8 @@ Until those six steps are recorded, this guide does not claim incapacitation cov | Club email provider | Notices and password recovery | Communications owner plus backup | | Social/community accounts | Public communication and member groups | Named owner plus backup | +WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) adds fail-closed Firebase Hosting build source, not service ownership. Continuity still requires two individual limited-access Firebase/Google Cloud administrators, an approved staging project, short-lived release identity, protected reviewers, and a tested rollback. Do not create an unofficial personal project to make the source checks pass. + ## Release access to record privately Record these facts without copying a credential or private provider identifier: @@ -95,6 +97,7 @@ For each system, record only: 21. Compare its executable delta with the current live artifact immediately before merge. 22. Confirm reviewers reject release requests older than 24 hours and request the current `main` commit again. 23. **NOT AVAILABLE YET:** complete the synthetic role-boundary drill below after the reviewed database, Function, and website revisions are safely available in protected staging. +24. Confirm #663's source check is not recorded as a Firebase Hosting deploy. Until separate provider evidence exists, record staging Hosting, production Hosting, custom domain, release marker, and rollback as **NOT AVAILABLE YET**. ### Synthetic role-boundary drill — NOT AVAILABLE YET diff --git a/docs/officers/EMERGENCY_AND_RECOVERY.md b/docs/officers/EMERGENCY_AND_RECOVERY.md index a59c82aa..f6629c50 100644 --- a/docs/officers/EMERGENCY_AND_RECOVERY.md +++ b/docs/officers/EMERGENCY_AND_RECOVERY.md @@ -26,7 +26,7 @@ | Symptom | Safe first response | | --- | --- | | Wrong public text, image, or link | Ask the platform owner for one reviewed revert pull request. | -| Site unavailable or old after a merge | Ask the platform owner to check Netlify and GitHub Pages separately; do not change DNS. | +| Site unavailable or old after a merge | Ask the platform owner to check Netlify and GitHub Pages separately; do not change DNS. #663 Firebase Hosting source is not a live host. | | Login or member access is wrong | Stop role requests and contact the identity/platform owners. | | Account exists but a verification email request is unavailable | Ask the member to stop resending. Record only the time, route path `/login` or `/account`, and plain status. Omit all query/fragment text. Do not ask for the email address, password, code, action link, mailbox access, or screenshot. | | Verification link is unusable or temporarily unavailable | Ask the member to stop after one deliberate check and one retry. Record only the time, clean route `/auth/action`, and plain result. Never copy anything after `?` or `#`, and never ask for the link, code, email address, or screenshot. | @@ -38,6 +38,8 @@ Live commerce is not approved as of 2026-07-12. If the public site appears to accept a real payment, do not test it with a real card. +Firebase Hosting recovery is **NOT AVAILABLE YET**. WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) adds source configuration only. Do not switch DNS, create a personal Firebase project, run a deploy, or assume Firebase Hosting is a fallback during an incident. Use the verified Netlify/Pages evidence and contact the platform owner plus backup. + ### Unexpected “Something went wrong” page — SOURCE ONLY, NOT LIVE **Status: NOT AVAILABLE YET** @@ -315,6 +317,7 @@ Record: - What was checked on `runmprc.com`. - Whether Netlify identified the intended commit. - Whether Firebase actually deployed. +- Whether a Firebase Hosting site actually published the intended commit; source configuration or a green synthetic build is not publication proof. - Whether each outside provider was checked. - What follow-up prevents the same problem. diff --git a/docs/officers/GLOSSARY.md b/docs/officers/GLOSSARY.md index 4517a7e0..3fb1bc52 100644 --- a/docs/officers/GLOSSARY.md +++ b/docs/officers/GLOSSARY.md @@ -23,6 +23,7 @@ | Projection | A minimum server-only copy made for one approved task, such as finding an opted-in display name, without sending the full account record to the browser. | | Facial recognition | Automated processing that tries to identify or match a person from an image. The MPRC People finder does not do this and cannot accept a photo as a search. | | Firebase | The service used for login, database records, and backend Functions. | +| Firebase Hosting | Google's static website host. MPRC has source configuration for a future site, but no verified staging or production Firebase Hosting release yet. | | Firestore | The database inside Firebase. Saving an Admin form can change its records immediately. | | Function | A protected backend action, such as checking access or handling a payment event. | | Netlify | The service currently answering requests for `runmprc.com`. | diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index dd08780a..6abd358e 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -10,6 +10,8 @@ **Protected release status:** **NOT AVAILABLE YET.** Issue #135 provides the fail-closed source gate. Issue #133 must still configure protected `staging` and `production` environments, their named reviewers, and a short-lived cloud identity. Public browser build values must be named repository or organization variables because artifact preparation has no protected-environment access; #133/#136 must record and verify them separately. Do not add a long-lived Firebase key as a shortcut. +**Firebase Hosting status:** **SOURCE ONLY, NOT AVAILABLE YET.** WEB-001A1 [#663](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/663) requires controlled optimized builds to name staging or production, scans CI's synthetic staging JavaScript for the staging project and absence of production Firebase identities, and adds a missing-path SPA rewrite to source. It creates no Firebase Hosting site/channel, staging project, cloud authority, protected web-production branch, automatic publication, release marker, rollback, custom domain, or DNS/TLS change. Officers do not use its source configuration as a publish button. + **Live Netlify publication status:** a reusable protected release is **NOT AVAILABLE YET**. Ordinary Git-triggered production builds are paused by repository configuration. An overbroad #473 artifact was published and then rolled back on 2026-08-01; bounded #473 deploy `6a6dc9ea588b0c0008036312` is older history. #623 completed the inert-directory predecessor and remains the immediate rollback as deploy `6a7e072f8f346b0008510d29`, source `c2d87d1f69f15e128a0bc9b1b9f915b7c8417aec`. #659 completed one separate exact-artifact accessibility release. Deploy `6a7ece87c5ca4d0007c1a3fc`, source `7496fe0881fb52908c4ff2f40f488df09c94c908`, is production now. Its exact marker/artifact and signed-out route-focus/menu checks passed. The manifest is inactive, temporary refs are absent, and the rollback ref remains. Shop is the static catalog; Events and Calendar show a fixed retry-later notice; the directory remains inert. Event records remain unavailable because this did not deploy Firebase. GitHub Pages currently still claims the same custom domain; future source omits that claim, but #136/WEB-001 must publish and verify its removal. **Completed #659 exception:** WEB-002D [#659](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/659) completed one exact-artifact accessibility release on 2026-08-14. The frozen source contains only reviewed #291 visible focus, #490 phone-menu disclosure/close behavior, and #657 client-side route focus. Production is deploy `6a7ece87c5ca4d0007c1a3fc`; #623 deploy `6a7e072f8f346b0008510d29` is its rollback target. This is not an officer-operated or reusable control. The no-terminal section below is retained as the audit record. @@ -25,7 +27,9 @@ flowchart TD Request --> Preflight{"Commit and required checks valid?"} Preflight -- "No" --> Stop["Red failure — publish nothing"] Preflight -- "Yes" --> Prepare["Prepare credential-free website artifact"] - Prepare --> Release{"Approve protected environment?"} + Prepare --> WebConfig{"Explicit web environment and artifact identity valid?"} + WebConfig -- "No" --> Stop + WebConfig -- "Yes" --> Release{"Approve protected environment?"} Release -- "No" --> Stop Release -- "Yes" --> Gate{"Project, scope, and authority valid?"} Gate -- "No" --> Stop @@ -37,7 +41,7 @@ flowchart TD Pages --> Verify["Check Pages, Netlify, runmprc.com, and providers separately"] ``` -In words: merging does not release; a request checks one exact commit and may prepare a credential-free artifact; protected approval unlocks Firebase; only verified Firebase permits Pages publication. +In words: merging does not release; a request checks one exact commit and may prepare a credential-free artifact only when its web environment and artifact identity pass; protected approval unlocks Firebase; only verified Firebase permits Pages publication. #663 supplies the source web-environment check, not a live Firebase Hosting release. ## Current facts @@ -56,6 +60,7 @@ As of **2026-07-13**, with the internal tooling note below checked from source o - Source checked on 2026-08-01 pins every root brace-expansion dependency family to reviewed compatible releases 1.1.18, 2.1.4, and 5.0.9 after a new advisory and follow-up bypass fixes. This is internal dependency evidence only. Officers do not install packages, run audit commands, or resolve dependency warnings; the platform maintainer owns those tasks. The separate minimatch finding remains open. This source change does not publish a website, deploy Firebase, change a provider, or prove live behavior. - A production Pages publication job cannot start until Firebase deployment and Function verification succeed. - The `staging` option deliberately stops before deployment until #113/#133 name one exact approved staging Firebase project. A future staging release remains backend-only until a separate staging browser configuration and host exist. +- #663 source requires complete staging-named public browser configuration and gives CI a synthetic staging artifact scan. This does not satisfy the approved-project or hosted-staging requirements above. - `runmprc.com` is served by Netlify, not GitHub Pages. - GitHub Pages currently reports `runmprc.com` as its custom domain and redirects its normal address there. It is not an independently reachable copy today. - Future source stops writing that Pages domain claim. Only provider readback after #136/WEB-001 can prove it cleared. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 88b41ec4..f9cf4cdd 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -86,6 +86,8 @@ flowchart TD Rules --> Functions["Deploy and verify named Functions"] Functions --> Pages["Pages branch without Netlify's domain claim"] Main -. "Ordinary Git production build paused" .-> Netlify + Main --> HostingSource["#663 Firebase Hosting source checks"] + HostingSource -. "No owned project, authority, or deploy" .-> FutureHosting["Firebase Hosting — NOT AVAILABLE YET"] Main -. "Completed #659 exact release; manifest inactive" .-> WebGate{"Temporary authority active?"} WebGate -- "No" --> Stop WebGate -- "Yes" --> Netlify @@ -94,7 +96,7 @@ flowchart TD Dev["dev — legacy branch"] -. "do not use for new release work" .-> PR ``` -In words: merge, release request, and protected approval are separate; a missing or failed Firebase gate publishes nothing; ordinary merges cannot publish Netlify and the completed #659 exception is inactive; its deploy `6a7ece87c5ca4d0007c1a3fc` remains live while completed #623 deploy `6a7e072f8f346b0008510d29` is the immediate rollback and completed #473 deploy `6a6dc9ea588b0c0008036312` is older history; the future Pages branch must stop claiming the Netlify domain, and both hosts still need separate proof. +In words: merge, release request, and protected approval are separate; a missing or failed Firebase gate publishes nothing; #663 adds only source checks and points to no usable Firebase Hosting project; ordinary merges cannot publish Netlify and the completed #659 exception is inactive; its deploy `6a7ece87c5ca4d0007c1a3fc` remains live while completed #623 deploy `6a7e072f8f346b0008510d29` is the immediate rollback and completed #473 deploy `6a6dc9ea588b0c0008036312` is older history; the future Pages branch must stop claiming the Netlify domain, and every host still needs separate proof. ## Account and permission ownership diff --git a/firebase.json b/firebase.json index f9378881..0dae235e 100644 --- a/firebase.json +++ b/firebase.json @@ -16,6 +16,25 @@ "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, + "hosting": { + "public": "build", + "predeploy": [ + "node scripts/firebase-hosting-contract.js validate-deploy", + "npm run build", + "node scripts/firebase-hosting-contract.js verify build" + ], + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + }, "emulators": { "auth": { "port": 9099 diff --git a/package.json b/package.json index aecf583e..b588bbfb 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,14 @@ "start": "react-scripts start", "start:firebase": "react-scripts start", "sitemap": "node scripts/generate-sitemap.js", - "prebuild": "node scripts/generate-sitemap.js || true", + "prebuild": "node scripts/firebase-hosting-contract.js validate && node scripts/generate-sitemap.js", "build": "react-scripts build", "predeploy": "npm run build", "deploy": "gh-pages -d build", "emulators": "firebase emulators:start --project demo-mprc-local --only auth,firestore,functions", "lint:ci": "node .github/scripts/check-frontend-lint.cjs", "lint:fix": "eslint --fix --ext .js,.jsx .", + "test:firebase-hosting": "node --test tests/firebase-hosting-foundation.test.js", "test": "react-scripts test", "test:spa-navigation": "node --test tests/spa-navigation.test.js", "test:rules": "firebase emulators:exec --only firestore --project demo-rules-test \"npx --no-install jest --config tests/firestore-rules/jest.config.js\"", diff --git a/scripts/firebase-hosting-contract.js b/scripts/firebase-hosting-contract.js new file mode 100644 index 00000000..b039add2 --- /dev/null +++ b/scripts/firebase-hosting-contract.js @@ -0,0 +1,212 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const REJECTION = 'firebase_hosting_contract_rejected'; +const PRODUCTION_PROJECT_ID = 'mid-peninsula-running-club'; +const PRODUCTION_CONFIGURATION = Object.freeze({ + REACT_APP_FIREBASE_API_KEY: 'AIzaSyD2u17HMhDPZ0Tn9D3H71fep1vZgT-njnw', + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mid-peninsula-running-club.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: PRODUCTION_PROJECT_ID, + REACT_APP_FIREBASE_STORAGE_BUCKET: ( + 'mid-peninsula-running-club.firebasestorage.app' + ), + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '253289716314', + REACT_APP_FIREBASE_APP_ID: '1:253289716314:web:dcad9766d820044d7f9663', + REACT_APP_FIREBASE_MEASUREMENT_ID: 'G-ECN7TT0BGF', +}); +const PRODUCTION_IDENTIFIERS = Object.freeze( + Object.values(PRODUCTION_CONFIGURATION), +); +const STAGING_FIELDS = Object.freeze([ + 'REACT_APP_FIREBASE_API_KEY', + 'REACT_APP_FIREBASE_AUTH_DOMAIN', + 'REACT_APP_FIREBASE_PROJECT_ID', + 'REACT_APP_FIREBASE_STORAGE_BUCKET', + 'REACT_APP_FIREBASE_MESSAGING_SENDER_ID', + 'REACT_APP_FIREBASE_APP_ID', +]); +const MAX_EXECUTABLE_FILES = 10000; +const MAX_EXECUTABLE_BYTES = 32 * 1024 * 1024; +const MAX_TOTAL_EXECUTABLE_BYTES = 128 * 1024 * 1024; + +function reject() { + throw new Error(REJECTION); +} + +function requiredValue(environment, key) { + const value = environment?.[key]; + if (typeof value !== 'string' + || value.length === 0 + || value.length > 256 + || value.trim() !== value) { + reject(); + } + return value; +} + +function includesProductionIdentity(value) { + return PRODUCTION_IDENTIFIERS.some((identity) => value.includes(identity)); +} + +function validateStagingConfiguration(environment) { + const values = Object.fromEntries( + STAGING_FIELDS.map((key) => [key, requiredValue(environment, key)]), + ); + if (Object.values(values).some(includesProductionIdentity)) reject(); + + const projectId = values.REACT_APP_FIREBASE_PROJECT_ID; + const senderId = values.REACT_APP_FIREBASE_MESSAGING_SENDER_ID; + if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/u.test(projectId)) reject(); + if (!/(?:^|-)staging(?:-|$)/u.test(projectId)) reject(); + if (!/^[A-Za-z0-9_-]{20,128}$/u.test(values.REACT_APP_FIREBASE_API_KEY)) reject(); + if (values.REACT_APP_FIREBASE_AUTH_DOMAIN !== `${projectId}.firebaseapp.com`) reject(); + if (values.REACT_APP_FIREBASE_STORAGE_BUCKET !== `${projectId}.appspot.com` + && values.REACT_APP_FIREBASE_STORAGE_BUCKET + !== `${projectId}.firebasestorage.app`) reject(); + if (!/^[0-9]{6,20}$/u.test(senderId)) reject(); + if (!new RegExp(`^1:${senderId}:web:[A-Za-z0-9]{8,64}$`, 'u') + .test(values.REACT_APP_FIREBASE_APP_ID)) reject(); + if (environment.REACT_APP_FIREBASE_MEASUREMENT_ID !== undefined) { + const measurementId = requiredValue( + environment, + 'REACT_APP_FIREBASE_MEASUREMENT_ID', + ); + if (includesProductionIdentity(measurementId) + || !/^G-[A-Z0-9]{6,20}$/u.test(measurementId)) reject(); + } + + return Object.freeze({ + environment: 'staging', + projectId, + }); +} + +function validateBuildEnvironment(environment = process.env) { + const selected = environment?.REACT_APP_FIREBASE_ENVIRONMENT; + if (selected === 'production') { + Object.entries(PRODUCTION_CONFIGURATION).forEach(([key, expected]) => { + if (requiredValue(environment, key) !== expected) reject(); + }); + return Object.freeze({ + environment: 'production', + projectId: PRODUCTION_PROJECT_ID, + }); + } + if (selected === 'staging') return validateStagingConfiguration(environment); + return reject(); +} + +function validateDeployEnvironment(environment = process.env) { + const selected = validateBuildEnvironment(environment); + if (requiredValue(environment, 'GCLOUD_PROJECT') !== selected.projectId) { + reject(); + } + return selected; +} + +function executableFiles(root, relative = '', state = { count: 0 }) { + const directory = path.join(root, relative); + let entries; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return reject(); + } + const files = []; + entries.sort((left, right) => left.name.localeCompare(right.name)) + .forEach((entry) => { + const child = path.join(relative, entry.name); + if (entry.isSymbolicLink()) reject(); + if (entry.isDirectory()) { + files.push(...executableFiles(root, child, state)); + return; + } + if (!entry.isFile()) reject(); + if (!entry.name.endsWith('.js') || entry.name.endsWith('.js.map')) return; + state.count += 1; + if (state.count > MAX_EXECUTABLE_FILES) reject(); + files.push(child); + }); + return files; +} + +function verifyExecutableArtifact(directory, environment = process.env) { + if (typeof directory !== 'string' || directory.length === 0) reject(); + const selected = validateBuildEnvironment(environment); + const root = path.resolve(directory); + const files = executableFiles(root); + if (files.length === 0) reject(); + + const requiredIdentifiers = selected.environment === 'production' + ? PRODUCTION_IDENTIFIERS + : [ + ...STAGING_FIELDS.map((key) => requiredValue(environment, key)), + ...(environment.REACT_APP_FIREBASE_MEASUREMENT_ID === undefined + ? [] + : [requiredValue(environment, 'REACT_APP_FIREBASE_MEASUREMENT_ID')]), + ]; + const foundIdentifiers = new Set(); + let totalBytes = 0; + files.forEach((relative) => { + let stat; + let contents; + try { + stat = fs.statSync(path.join(root, relative)); + if (!stat.isFile() || stat.size > MAX_EXECUTABLE_BYTES) reject(); + totalBytes += stat.size; + if (totalBytes > MAX_TOTAL_EXECUTABLE_BYTES) reject(); + contents = fs.readFileSync(path.join(root, relative), 'utf8'); + } catch { + reject(); + } + requiredIdentifiers.forEach((identity) => { + if (contents.includes(identity)) foundIdentifiers.add(identity); + }); + if (selected.environment === 'staging' + && PRODUCTION_IDENTIFIERS.some((identity) => contents.includes(identity))) { + reject(); + } + }); + if (foundIdentifiers.size !== new Set(requiredIdentifiers).size) reject(); + + return Object.freeze({ + environment: selected.environment, + executableFileCount: files.length, + }); +} + +function runCli(args = process.argv.slice(2)) { + if (args.length === 1 && args[0] === 'validate') { + validateBuildEnvironment(); + process.stdout.write('firebase_hosting_contract_valid\n'); + return; + } + if (args.length === 1 && args[0] === 'validate-deploy') { + validateDeployEnvironment(); + process.stdout.write('firebase_hosting_deploy_contract_valid\n'); + return; + } + if (args.length === 2 && args[0] === 'verify') { + verifyExecutableArtifact(args[1]); + process.stdout.write('firebase_hosting_artifact_valid\n'); + return; + } + reject(); +} + +if (require.main === module) { + try { + runCli(); + } catch { + process.stderr.write(`${REJECTION}\n`); + process.exitCode = 1; + } +} + +module.exports = { + validateBuildEnvironment, + validateDeployEnvironment, + verifyExecutableArtifact, +}; diff --git a/scripts/netlify-release-build.js b/scripts/netlify-release-build.js index 80383522..65be6d79 100644 --- a/scripts/netlify-release-build.js +++ b/scripts/netlify-release-build.js @@ -10,6 +10,9 @@ const { authorizeProductionRelease, loadManifest, } = require('./netlify-release-policy'); +const { + verifyExecutableArtifact, +} = require('./firebase-hosting-contract'); const ROOT = path.resolve(__dirname, '..'); const OUTPUT = path.join(ROOT, 'build'); @@ -45,7 +48,34 @@ function safeBaseEnvironment(home, sourceEnvironment = process.env) { } function buildEnvironment(home, sourceEnvironment = process.env) { - return safeBaseEnvironment(home, sourceEnvironment); + return { + ...safeBaseEnvironment(home, sourceEnvironment), + REACT_APP_FIREBASE_ENVIRONMENT: 'production', + REACT_APP_FIREBASE_API_KEY: 'AIzaSyD2u17HMhDPZ0Tn9D3H71fep1vZgT-njnw', + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mid-peninsula-running-club.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: 'mid-peninsula-running-club', + REACT_APP_FIREBASE_STORAGE_BUCKET: ( + 'mid-peninsula-running-club.firebasestorage.app' + ), + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '253289716314', + REACT_APP_FIREBASE_APP_ID: '1:253289716314:web:dcad9766d820044d7f9663', + REACT_APP_FIREBASE_MEASUREMENT_ID: 'G-ECN7TT0BGF', + }; +} + +function previewBuildEnvironment(home, sourceEnvironment = process.env) { + return { + ...safeBaseEnvironment(home, sourceEnvironment), + REACT_APP_FIREBASE_ENVIRONMENT: 'staging', + REACT_APP_FIREBASE_API_KEY: 'synthetic-ci-api-key-not-a-credential', + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mprc-staging-ci.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: 'mprc-staging-ci', + REACT_APP_FIREBASE_STORAGE_BUCKET: ( + 'mprc-staging-ci.firebasestorage.app' + ), + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '100000000001', + REACT_APP_FIREBASE_APP_ID: '1:100000000001:web:abcdef0123456789', + }; } function run(command, args, options = {}) { @@ -148,7 +178,9 @@ function installAndBuild(source, temporaryDirectory) { ['build'], { cwd: source, env: environment }, ); - return path.join(source, 'build'); + const output = path.join(source, 'build'); + verifyExecutableArtifact(output, environment); + return output; } function walkFiles(directory, relative = '') { @@ -256,7 +288,21 @@ function main() { } if (!authorization.manifest.active || process.env.HEAD !== authorization.manifest.previewBranch) { - run('npm', ['run', 'build'], { cwd: ROOT }); + const previewDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'run-mprc-netlify-preview-'), + ); + const previewHome = path.join(previewDirectory, 'home'); + try { + fs.mkdirSync(previewHome); + const previewEnvironment = previewBuildEnvironment(previewHome); + run('npm', ['run', 'build'], { + cwd: ROOT, + env: previewEnvironment, + }); + verifyExecutableArtifact(OUTPUT, previewEnvironment); + } finally { + fs.rmSync(previewDirectory, { force: true, recursive: true }); + } return; } if (!SHA_PATTERN.test(process.env.COMMIT_REF)) { @@ -310,6 +356,7 @@ if (require.main === module) { module.exports = { buildEnvironment, fetchAndVerifySource, + previewBuildEnvironment, publishArtifact, releaseMarkerPayload, safeBaseEnvironment, diff --git a/src/services/firebase/FirebaseResources.test.ts b/src/services/firebase/FirebaseResources.test.ts index 1d524de2..df954a86 100644 --- a/src/services/firebase/FirebaseResources.test.ts +++ b/src/services/firebase/FirebaseResources.test.ts @@ -27,6 +27,27 @@ const mockReCaptchaEnterpriseProvider = jest.fn((siteKey: string) => ({ })); const mockReCaptchaV3Provider = jest.fn((siteKey: string) => ({ siteKey })); +const FIREBASE_BUILD_ENVIRONMENT_KEYS = [ + 'REACT_APP_FIREBASE_ENVIRONMENT', + 'REACT_APP_FIREBASE_API_KEY', + 'REACT_APP_FIREBASE_AUTH_DOMAIN', + 'REACT_APP_FIREBASE_PROJECT_ID', + 'REACT_APP_FIREBASE_STORAGE_BUCKET', + 'REACT_APP_FIREBASE_MESSAGING_SENDER_ID', + 'REACT_APP_FIREBASE_APP_ID', + 'REACT_APP_FIREBASE_MEASUREMENT_ID', +] as const; + +const PRODUCTION_FIREBASE_BUILD_CONFIG = { + REACT_APP_FIREBASE_API_KEY: 'AIzaSyD2u17HMhDPZ0Tn9D3H71fep1vZgT-njnw', + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mid-peninsula-running-club.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: 'mid-peninsula-running-club', + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mid-peninsula-running-club.firebasestorage.app', + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '253289716314', + REACT_APP_FIREBASE_APP_ID: '1:253289716314:web:dcad9766d820044d7f9663', + REACT_APP_FIREBASE_MEASUREMENT_ID: 'G-ECN7TT0BGF', +} as const; + jest.mock('firebase/app', () => ({ initializeApp: mockInitializeApp })); jest.mock('firebase/analytics', () => ({ getAnalytics: mockGetAnalytics, @@ -59,6 +80,52 @@ function setNodeEnv(value: string | undefined) { }); } +function createResourcesForExplicitEnvironment( + nodeEnv: string, + firebaseEnvironment: string | undefined, + firebaseConfig: Partial> = {}, +) { + const originalNodeEnv = process.env.NODE_ENV; + const originalSiteKey = process.env.REACT_APP_RECAPTCHA_SITE_KEY; + const originalValues = Object.fromEntries( + FIREBASE_BUILD_ENVIRONMENT_KEYS.map((key) => [key, process.env[key]]), + ); + setNodeEnv(nodeEnv); + process.env.REACT_APP_RECAPTCHA_SITE_KEY = 'configured-public-site-key'; + FIREBASE_BUILD_ENVIRONMENT_KEYS.forEach((key) => delete process.env[key]); + if (firebaseEnvironment !== undefined) { + process.env.REACT_APP_FIREBASE_ENVIRONMENT = firebaseEnvironment; + } + if (firebaseEnvironment === 'production') { + Object.entries(PRODUCTION_FIREBASE_BUILD_CONFIG).forEach(([key, value]) => { + process.env[key] = value; + }); + } + Object.entries(firebaseConfig).forEach(([key, value]) => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }); + + try { + let resources: import('./FirebaseResources').default; + jest.isolateModules(() => { + // eslint-disable-next-line global-require + const FirebaseResources = require('./FirebaseResources').default; + resources = FirebaseResources.getInstance(); + }); + return resources!; + } finally { + setNodeEnv(originalNodeEnv); + if (originalSiteKey === undefined) delete process.env.REACT_APP_RECAPTCHA_SITE_KEY; + else process.env.REACT_APP_RECAPTCHA_SITE_KEY = originalSiteKey; + FIREBASE_BUILD_ENVIRONMENT_KEYS.forEach((key) => { + const original = originalValues[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + }); + } +} + function createResourcesFor( nodeEnv: string, siteKey?: string, @@ -66,8 +133,19 @@ function createResourcesFor( ) { const originalNodeEnv = process.env.NODE_ENV; const originalSiteKey = process.env.REACT_APP_RECAPTCHA_SITE_KEY; + const originalFirebaseValues = Object.fromEntries( + FIREBASE_BUILD_ENVIRONMENT_KEYS.map((key) => [key, process.env[key]]), + ); const originalLocation = `${window.location.pathname}${window.location.search}${window.location.hash}`; setNodeEnv(nodeEnv); + if (nodeEnv === 'production') { + process.env.REACT_APP_FIREBASE_ENVIRONMENT = 'production'; + Object.entries(PRODUCTION_FIREBASE_BUILD_CONFIG).forEach(([key, value]) => { + process.env[key] = value; + }); + } else { + delete process.env.REACT_APP_FIREBASE_ENVIRONMENT; + } if (siteKey === undefined) delete process.env.REACT_APP_RECAPTCHA_SITE_KEY; else process.env.REACT_APP_RECAPTCHA_SITE_KEY = siteKey; window.history.replaceState(null, '', locationPath); @@ -83,6 +161,11 @@ function createResourcesFor( } finally { window.history.replaceState(null, '', originalLocation); setNodeEnv(originalNodeEnv); + FIREBASE_BUILD_ENVIRONMENT_KEYS.forEach((key) => { + const original = originalFirebaseValues[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + }); if (originalSiteKey === undefined) delete process.env.REACT_APP_RECAPTCHA_SITE_KEY; else process.env.REACT_APP_RECAPTCHA_SITE_KEY = originalSiteKey; } @@ -99,8 +182,19 @@ async function withFirebaseModuleFor( ) { const originalNodeEnv = process.env.NODE_ENV; const originalSiteKey = process.env.REACT_APP_RECAPTCHA_SITE_KEY; + const originalFirebaseValues = Object.fromEntries( + FIREBASE_BUILD_ENVIRONMENT_KEYS.map((key) => [key, process.env[key]]), + ); const originalLocation = `${window.location.pathname}${window.location.search}${window.location.hash}`; setNodeEnv(nodeEnv); + if (nodeEnv === 'production') { + process.env.REACT_APP_FIREBASE_ENVIRONMENT = 'production'; + Object.entries(PRODUCTION_FIREBASE_BUILD_CONFIG).forEach(([key, value]) => { + process.env[key] = value; + }); + } else { + delete process.env.REACT_APP_FIREBASE_ENVIRONMENT; + } if (siteKey === undefined) delete process.env.REACT_APP_RECAPTCHA_SITE_KEY; else process.env.REACT_APP_RECAPTCHA_SITE_KEY = siteKey; window.history.replaceState(initialHistoryState, '', initialLocation); @@ -115,6 +209,11 @@ async function withFirebaseModuleFor( } finally { window.history.replaceState(null, '', originalLocation); setNodeEnv(originalNodeEnv); + FIREBASE_BUILD_ENVIRONMENT_KEYS.forEach((key) => { + const original = originalFirebaseValues[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + }); if (originalSiteKey === undefined) delete process.env.REACT_APP_RECAPTCHA_SITE_KEY; else process.env.REACT_APP_RECAPTCHA_SITE_KEY = originalSiteKey; } @@ -275,6 +374,114 @@ describe('FirebaseResources environment isolation', () => { expect(mockIsAnalyticsSupported).not.toHaveBeenCalled(); }); + test('optimized startup rejects a missing Firebase environment', () => { + expect(() => createResourcesForExplicitEnvironment( + 'production', + undefined, + )).toThrow('Firebase environment configuration is unavailable; stop startup.'); + expect(mockInitializeApp).not.toHaveBeenCalled(); + }); + + test('local startup rejects a hosted Firebase environment', () => { + expect(() => createResourcesForExplicitEnvironment( + 'development', + 'production', + )).toThrow('Firebase environment configuration is unavailable; stop startup.'); + expect(mockInitializeApp).not.toHaveBeenCalled(); + }); + + test('optimized staging uses only its complete explicit Firebase configuration', () => { + const resources = createResourcesForExplicitEnvironment( + 'production', + 'staging', + { + REACT_APP_FIREBASE_API_KEY: 'synthetic-ci-api-key-not-a-credential', + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mprc-staging-ci.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: 'mprc-staging-ci', + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mprc-staging-ci.firebasestorage.app', + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '100000000001', + REACT_APP_FIREBASE_APP_ID: '1:100000000001:web:abcdef0123456789', + }, + ); + + expect(resources.app.options).toEqual({ + apiKey: 'synthetic-ci-api-key-not-a-credential', + authDomain: 'mprc-staging-ci.firebaseapp.com', + projectId: 'mprc-staging-ci', + storageBucket: 'mprc-staging-ci.firebasestorage.app', + messagingSenderId: '100000000001', + appId: '1:100000000001:web:abcdef0123456789', + }); + expect(JSON.stringify(resources.app.options)).not.toContain( + 'mid-peninsula-running-club', + ); + expect(mockConnectAuthEmulator).not.toHaveBeenCalled(); + expect(mockConnectFirestoreEmulator).not.toHaveBeenCalled(); + expect(mockConnectFunctionsEmulator).not.toHaveBeenCalled(); + }); + + test.each([ + ['missing app ID', { REACT_APP_FIREBASE_APP_ID: undefined }], + ['production API key', { + REACT_APP_FIREBASE_API_KEY: PRODUCTION_FIREBASE_BUILD_CONFIG.REACT_APP_FIREBASE_API_KEY, + }], + ['production project', { + REACT_APP_FIREBASE_PROJECT_ID: 'mid-peninsula-running-club', + }], + ['production auth domain', { + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mid-peninsula-running-club.firebaseapp.com', + }], + ['production storage bucket', { + REACT_APP_FIREBASE_STORAGE_BUCKET: ( + PRODUCTION_FIREBASE_BUILD_CONFIG.REACT_APP_FIREBASE_STORAGE_BUCKET + ), + }], + ['production sender and app IDs', { + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: ( + PRODUCTION_FIREBASE_BUILD_CONFIG.REACT_APP_FIREBASE_MESSAGING_SENDER_ID + ), + REACT_APP_FIREBASE_APP_ID: PRODUCTION_FIREBASE_BUILD_CONFIG.REACT_APP_FIREBASE_APP_ID, + }], + ['production measurement ID', { + REACT_APP_FIREBASE_MEASUREMENT_ID: ( + PRODUCTION_FIREBASE_BUILD_CONFIG.REACT_APP_FIREBASE_MEASUREMENT_ID + ), + }], + ])('optimized staging rejects %s before SDK initialization', (_name, override) => { + const config = { + REACT_APP_FIREBASE_API_KEY: 'synthetic-ci-api-key-not-a-credential', + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mprc-staging-ci.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: 'mprc-staging-ci', + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mprc-staging-ci.firebasestorage.app', + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '100000000001', + REACT_APP_FIREBASE_APP_ID: '1:100000000001:web:abcdef0123456789', + ...override, + } as Partial>; + + expect(() => createResourcesForExplicitEnvironment( + 'production', + 'staging', + config, + )).toThrow('Firebase environment configuration is unavailable; stop startup.'); + expect(mockInitializeApp).not.toHaveBeenCalled(); + }); + + test.each([ + ['missing measurement ID', { REACT_APP_FIREBASE_MEASUREMENT_ID: undefined }], + ['changed project', { + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mprc-production-other.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: 'mprc-production-other', + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mprc-production-other.firebasestorage.app', + }], + ])('optimized production rejects %s before SDK initialization', (_name, override) => { + expect(() => createResourcesForExplicitEnvironment( + 'production', + 'production', + override, + )).toThrow('Firebase environment configuration is unavailable; stop startup.'); + expect(mockInitializeApp).not.toHaveBeenCalled(); + }); + test('production uses only the Enterprise App Check provider without Analytics', async () => { const enterpriseProvider = { kind: 'enterprise', @@ -413,8 +620,15 @@ describe('FirebaseResources environment isolation', () => { test('remembers the initial Auth capability after the visible URL is scrubbed', () => { const originalNodeEnv = process.env.NODE_ENV; const originalSiteKey = process.env.REACT_APP_RECAPTCHA_SITE_KEY; + const originalFirebaseValues = Object.fromEntries( + FIREBASE_BUILD_ENVIRONMENT_KEYS.map((key) => [key, process.env[key]]), + ); const originalLocation = `${window.location.pathname}${window.location.search}${window.location.hash}`; setNodeEnv('production'); + process.env.REACT_APP_FIREBASE_ENVIRONMENT = 'production'; + Object.entries(PRODUCTION_FIREBASE_BUILD_CONFIG).forEach(([key, value]) => { + process.env[key] = value; + }); process.env.REACT_APP_RECAPTCHA_SITE_KEY = 'configured-public-site-key'; window.history.replaceState( null, @@ -439,6 +653,11 @@ describe('FirebaseResources environment isolation', () => { } finally { window.history.replaceState(null, '', originalLocation); setNodeEnv(originalNodeEnv); + FIREBASE_BUILD_ENVIRONMENT_KEYS.forEach((key) => { + const original = originalFirebaseValues[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + }); if (originalSiteKey === undefined) { delete process.env.REACT_APP_RECAPTCHA_SITE_KEY; } else { diff --git a/src/services/firebase/FirebaseResources.ts b/src/services/firebase/FirebaseResources.ts index 41d4b964..dd77c603 100644 --- a/src/services/firebase/FirebaseResources.ts +++ b/src/services/firebase/FirebaseResources.ts @@ -1,4 +1,4 @@ -import { FirebaseApp, initializeApp } from 'firebase/app'; +import { FirebaseApp, FirebaseOptions, initializeApp } from 'firebase/app'; import { AppCheck, getToken, initializeAppCheck, ReCaptchaEnterpriseProvider, } from 'firebase/app-check'; @@ -27,6 +27,9 @@ import { const isLocalRuntime = process.env.NODE_ENV !== 'production'; const LOCAL_FIREBASE_PROJECT_ID = 'demo-mprc-local'; const FUNCTION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; +const FIREBASE_ENVIRONMENT_ERROR = ( + 'Firebase environment configuration is unavailable; stop startup.' +); const STRAVA_APP_CHECK_PREPARATION_FAILED = ( 'Strava callback App Check preparation failed.' ); @@ -40,19 +43,123 @@ const LOCAL_FIREBASE_CONFIG = { appId: '1:000000000000:web:demo', } as const; -const PRODUCTION_FIREBASE_CONFIG = { - apiKey: 'AIzaSyD2u17HMhDPZ0Tn9D3H71fep1vZgT-njnw', - authDomain: 'mid-peninsula-running-club.firebaseapp.com', - projectId: 'mid-peninsula-running-club', - storageBucket: 'mid-peninsula-running-club.firebasestorage.app', - messagingSenderId: '253289716314', - appId: '1:253289716314:web:dcad9766d820044d7f9663', - measurementId: 'G-ECN7TT0BGF', -} as const; +type HostedFirebaseConfig = Required> & Pick; + +// Exact code-unit fixtures let staging reject a production-equal public field +// without placing a usable production Firebase identifier in its executable. +const PRODUCTION_FIREBASE_CONFIG_CODE_UNITS = Object.freeze({ + apiKey: [65, 73, 122, 97, 83, 121, 68, 50, 117, 49, 55, 72, 77, 104, + 68, 80, 90, 48, 84, 110, 57, 68, 51, 72, 55, 49, 102, 101, 112, 49, + 118, 90, 103, 84, 45, 110, 106, 110, 119], + authDomain: [109, 105, 100, 45, 112, 101, 110, 105, 110, 115, 117, 108, + 97, 45, 114, 117, 110, 110, 105, 110, 103, 45, 99, 108, 117, 98, 46, + 102, 105, 114, 101, 98, 97, 115, 101, 97, 112, 112, 46, 99, 111, 109], + projectId: [109, 105, 100, 45, 112, 101, 110, 105, 110, 115, 117, 108, + 97, 45, 114, 117, 110, 110, 105, 110, 103, 45, 99, 108, 117, 98], + storageBucket: [109, 105, 100, 45, 112, 101, 110, 105, 110, 115, 117, + 108, 97, 45, 114, 117, 110, 110, 105, 110, 103, 45, 99, 108, 117, 98, + 46, 102, 105, 114, 101, 98, 97, 115, 101, 115, 116, 111, 114, 97, 103, + 101, 46, 97, 112, 112], + messagingSenderId: [50, 53, 51, 50, 56, 57, 55, 49, 54, 51, 49, 52], + appId: [49, 58, 50, 53, 51, 50, 56, 57, 55, 49, 54, 51, 49, 52, 58, 119, + 101, 98, 58, 100, 99, 97, 100, 57, 55, 54, 54, 100, 56, 50, 48, 48, + 52, 52, 100, 55, 102, 57, 54, 54, 51], + measurementId: [71, 45, 69, 67, 78, 55, 84, 84, 48, 66, 71, 70], +} as const); + +function firebaseEnvironmentError(): never { + throw new Error(FIREBASE_ENVIRONMENT_ERROR); +} + +function requiredHostedValue(value: string | undefined): string { + if (!value + || value.length > 256 + || value.trim() !== value) return firebaseEnvironmentError(); + return value; +} + +function exactlyMatchesCodeUnits( + value: string | undefined, + expected: readonly number[], +): boolean { + if (value === undefined || value.length !== expected.length) return false; + return expected.every((codeUnit, index) => value.charCodeAt(index) === codeUnit); +} + +function isKnownProductionIdentity(value: string | undefined): boolean { + if (value === undefined) return false; + return Object.values(PRODUCTION_FIREBASE_CONFIG_CODE_UNITS) + .some((expected) => exactlyMatchesCodeUnits(value, expected)); +} + +function isExactProductionConfiguration(config: HostedFirebaseConfig): boolean { + return Object.entries(PRODUCTION_FIREBASE_CONFIG_CODE_UNITS) + .every(([key, expected]) => exactlyMatchesCodeUnits( + config[key as keyof HostedFirebaseConfig], + expected, + )); +} + +function hostedFirebaseConfig(environment: 'staging' | 'production'): FirebaseOptions { + const measurementId = process.env.REACT_APP_FIREBASE_MEASUREMENT_ID; + const config: HostedFirebaseConfig = { + apiKey: requiredHostedValue(process.env.REACT_APP_FIREBASE_API_KEY), + authDomain: requiredHostedValue(process.env.REACT_APP_FIREBASE_AUTH_DOMAIN), + projectId: requiredHostedValue(process.env.REACT_APP_FIREBASE_PROJECT_ID), + storageBucket: requiredHostedValue( + process.env.REACT_APP_FIREBASE_STORAGE_BUCKET, + ), + messagingSenderId: requiredHostedValue( + process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, + ), + appId: requiredHostedValue(process.env.REACT_APP_FIREBASE_APP_ID), + ...(measurementId ? { measurementId: requiredHostedValue(measurementId) } : {}), + }; + const { projectId, messagingSenderId } = config; + if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId) + || !/^[A-Za-z0-9_-]{20,128}$/.test(config.apiKey) + || config.authDomain !== `${projectId}.firebaseapp.com` + || ( + config.storageBucket !== `${projectId}.appspot.com` + && config.storageBucket !== `${projectId}.firebasestorage.app` + ) + || !/^[0-9]{6,20}$/.test(messagingSenderId) + || !new RegExp(`^1:${messagingSenderId}:web:[A-Za-z0-9]{8,64}$`) + .test(config.appId) + || (config.measurementId !== undefined + && !/^G-[A-Z0-9]{6,20}$/.test(config.measurementId)) + || (environment === 'production' && !isExactProductionConfiguration(config)) + || (environment === 'staging' && ( + !/(?:^|-)staging(?:-|$)/.test(projectId) + || Object.values(config).some(isKnownProductionIdentity) + ))) { + return firebaseEnvironmentError(); + } + return config; +} + +function selectedFirebaseConfig(): FirebaseOptions { + const selected = process.env.REACT_APP_FIREBASE_ENVIRONMENT; + if (isLocalRuntime) { + if (selected !== undefined && selected !== 'local') { + return firebaseEnvironmentError(); + } + return LOCAL_FIREBASE_CONFIG; + } + if (selected === 'production' || selected === 'staging') { + return hostedFirebaseConfig(selected); + } + return firebaseEnvironmentError(); +} -const FIREBASE_CONFIG = isLocalRuntime - ? LOCAL_FIREBASE_CONFIG - : PRODUCTION_FIREBASE_CONFIG; +const FIREBASE_CONFIG = selectedFirebaseConfig(); const initialPageHadUrlCapabilityCallbackState = typeof window !== 'undefined' && hasCapabilityCallbackState(window.location); const initialPagePathWasCapabilityCallback = typeof window !== 'undefined' diff --git a/tests/firebase-hosting-foundation.test.js b/tests/firebase-hosting-foundation.test.js new file mode 100644 index 00000000..1eb28ad1 --- /dev/null +++ b/tests/firebase-hosting-foundation.test.js @@ -0,0 +1,241 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const ROOT = path.resolve(__dirname, '..'); +const PRODUCTION_BUILD_ENVIRONMENT = Object.freeze({ + REACT_APP_FIREBASE_ENVIRONMENT: 'production', + REACT_APP_FIREBASE_API_KEY: 'AIzaSyD2u17HMhDPZ0Tn9D3H71fep1vZgT-njnw', + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mid-peninsula-running-club.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: 'mid-peninsula-running-club', + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mid-peninsula-running-club.firebasestorage.app', + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '253289716314', + REACT_APP_FIREBASE_APP_ID: '1:253289716314:web:dcad9766d820044d7f9663', + REACT_APP_FIREBASE_MEASUREMENT_ID: 'G-ECN7TT0BGF', +}); +const STAGING_BUILD_ENVIRONMENT = Object.freeze({ + REACT_APP_FIREBASE_ENVIRONMENT: 'staging', + REACT_APP_FIREBASE_API_KEY: 'synthetic-ci-api-key-not-a-credential', + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mprc-staging-ci.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: 'mprc-staging-ci', + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mprc-staging-ci.firebasestorage.app', + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: '100000000001', + REACT_APP_FIREBASE_APP_ID: '1:100000000001:web:abcdef0123456789', +}); + +function read(relativePath) { + return fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); +} + +test('WEB-001A1 keeps an unqualified Firebase CLI target in the local demo namespace', () => { + const aliases = JSON.parse(read('.firebaserc')); + + assert.equal(aliases.projects?.default, 'demo-mprc-local'); + assert.notEqual(aliases.projects?.default, 'mid-peninsula-running-club'); +}); + +test('WEB-001A1 configures Firebase Hosting as one static SPA without a live target', () => { + const config = JSON.parse(read('firebase.json')); + + assert.deepEqual(config.hosting, { + public: 'build', + predeploy: [ + 'node scripts/firebase-hosting-contract.js validate-deploy', + 'npm run build', + 'node scripts/firebase-hosting-contract.js verify build', + ], + ignore: [ + 'firebase.json', + '**/.*', + '**/node_modules/**', + ], + rewrites: [ + { + source: '**', + destination: '/index.html', + }, + ], + }); + assert.equal(config.targets, undefined); +}); + +test('WEB-001A1 controlled optimized builds validate an explicit Firebase environment', () => { + const packageJson = JSON.parse(read('package.json')); + const ci = read('.github/workflows/ci.yml'); + const release = read('.github/workflows/deploy.yml'); + const netlifyBuilder = read('scripts/netlify-release-build.js'); + + assert.match( + packageJson.scripts.prebuild, + /node scripts\/firebase-hosting-contract\.js validate/u, + ); + assert.match(ci, /REACT_APP_FIREBASE_ENVIRONMENT: 'staging'/u); + assert.match(ci, /node scripts\/firebase-hosting-contract\.js verify build/u); + assert.match(release, /REACT_APP_FIREBASE_ENVIRONMENT: 'production'/u); + assert.match( + netlifyBuilder, + /REACT_APP_FIREBASE_ENVIRONMENT: 'production'/u, + ); + assert.match( + netlifyBuilder, + /REACT_APP_FIREBASE_ENVIRONMENT: 'staging'/u, + ); +}); + +test('WEB-001A1 validates staging fields and rejects production identities', () => { + // eslint-disable-next-line global-require, import/no-unresolved + const contract = require('../scripts/firebase-hosting-contract'); + + assert.deepEqual(contract.validateBuildEnvironment(STAGING_BUILD_ENVIRONMENT), { + environment: 'staging', + projectId: 'mprc-staging-ci', + }); + assert.deepEqual(contract.validateBuildEnvironment( + PRODUCTION_BUILD_ENVIRONMENT, + ), { + environment: 'production', + projectId: 'mid-peninsula-running-club', + }); + + assert.throws( + () => contract.validateBuildEnvironment({}), + /firebase_hosting_contract_rejected/u, + ); + assert.throws( + () => contract.validateBuildEnvironment({ + ...PRODUCTION_BUILD_ENVIRONMENT, + REACT_APP_FIREBASE_APP_ID: '1:253289716314:web:wrongproductionapp', + }), + /firebase_hosting_contract_rejected/u, + ); + assert.throws( + () => contract.validateBuildEnvironment({ + ...STAGING_BUILD_ENVIRONMENT, + REACT_APP_FIREBASE_PROJECT_ID: 'mid-peninsula-running-club', + }), + /firebase_hosting_contract_rejected/u, + ); + assert.throws( + () => contract.validateBuildEnvironment({ + ...STAGING_BUILD_ENVIRONMENT, + REACT_APP_FIREBASE_APP_ID: undefined, + }), + /firebase_hosting_contract_rejected/u, + ); + assert.throws( + () => contract.validateBuildEnvironment({ + ...STAGING_BUILD_ENVIRONMENT, + REACT_APP_FIREBASE_API_KEY: PRODUCTION_BUILD_ENVIRONMENT.REACT_APP_FIREBASE_API_KEY, + }), + /firebase_hosting_contract_rejected/u, + ); + assert.throws( + () => contract.validateBuildEnvironment({ + ...STAGING_BUILD_ENVIRONMENT, + REACT_APP_FIREBASE_MEASUREMENT_ID: ( + PRODUCTION_BUILD_ENVIRONMENT.REACT_APP_FIREBASE_MEASUREMENT_ID + ), + }), + /firebase_hosting_contract_rejected/u, + ); + assert.throws( + () => contract.validateBuildEnvironment({ + ...STAGING_BUILD_ENVIRONMENT, + REACT_APP_FIREBASE_AUTH_DOMAIN: 'mprc-preview-ci.firebaseapp.com', + REACT_APP_FIREBASE_PROJECT_ID: 'mprc-preview-ci', + REACT_APP_FIREBASE_STORAGE_BUCKET: 'mprc-preview-ci.firebasestorage.app', + }), + /firebase_hosting_contract_rejected/u, + ); + assert.throws( + () => contract.validateBuildEnvironment({ + ...PRODUCTION_BUILD_ENVIRONMENT, + REACT_APP_FIREBASE_MEASUREMENT_ID: undefined, + }), + /firebase_hosting_contract_rejected/u, + ); +}); + +test('WEB-001A1 binds a Hosting deploy to the selected Firebase project', () => { + // eslint-disable-next-line global-require, import/no-unresolved + const contract = require('../scripts/firebase-hosting-contract'); + + assert.deepEqual(contract.validateDeployEnvironment({ + ...STAGING_BUILD_ENVIRONMENT, + GCLOUD_PROJECT: 'mprc-staging-ci', + }), { + environment: 'staging', + projectId: 'mprc-staging-ci', + }); + assert.throws( + () => contract.validateDeployEnvironment({ + ...STAGING_BUILD_ENVIRONMENT, + GCLOUD_PROJECT: 'mid-peninsula-running-club', + }), + /firebase_hosting_contract_rejected/u, + ); + assert.throws( + () => contract.validateDeployEnvironment(STAGING_BUILD_ENVIRONMENT), + /firebase_hosting_contract_rejected/u, + ); +}); + +test('WEB-001A1 CLI failures are fixed and do not echo supplied configuration', () => { + const canary = 'private-build-value-canary'; + const result = spawnSync( + process.execPath, + [path.join(ROOT, 'scripts', 'firebase-hosting-contract.js'), 'validate'], + { + encoding: 'utf8', + env: { + REACT_APP_FIREBASE_ENVIRONMENT: 'staging', + REACT_APP_FIREBASE_API_KEY: canary, + }, + }, + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, 'firebase_hosting_contract_rejected\n'); + assert.doesNotMatch(`${result.stdout}${result.stderr}`, new RegExp(canary, 'u')); +}); + +test('WEB-001A1 executable staging artifacts prove staging identity and reject production identity', (t) => { + // eslint-disable-next-line global-require, import/no-unresolved + const contract = require('../scripts/firebase-hosting-contract'); + const fixture = fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'mprc-hosting-')); + t.after(() => fs.rmSync(fixture, { force: true, recursive: true })); + fs.mkdirSync(path.join(fixture, 'static', 'js'), { recursive: true }); + fs.writeFileSync( + path.join(fixture, 'static', 'js', 'main.example.js'), + `const config=${JSON.stringify(STAGING_BUILD_ENVIRONMENT)};\n`, + 'utf8', + ); + fs.writeFileSync( + path.join(fixture, 'static', 'js', 'main.example.js.map'), + 'mid-peninsula-running-club', + 'utf8', + ); + + assert.deepEqual(contract.verifyExecutableArtifact( + fixture, + STAGING_BUILD_ENVIRONMENT, + ), { + environment: 'staging', + executableFileCount: 1, + }); + + fs.writeFileSync( + path.join(fixture, 'static', 'js', 'main.example.js'), + 'const project="mid-peninsula-running-club";\n', + 'utf8', + ); + assert.throws( + () => contract.verifyExecutableArtifact(fixture, STAGING_BUILD_ENVIRONMENT), + /firebase_hosting_contract_rejected/u, + ); +}); diff --git a/tests/release-workflow.test.js b/tests/release-workflow.test.js index e5b29029..ce4d66df 100644 --- a/tests/release-workflow.test.js +++ b/tests/release-workflow.test.js @@ -42,6 +42,7 @@ const { } = require('../scripts/netlify-release-policy'); const { buildEnvironment, + previewBuildEnvironment, releaseMarkerPayload, } = require('../scripts/netlify-release-build'); @@ -724,9 +725,10 @@ test('Netlify release builder isolates source, environment, and public proof', ( assert.match(netlifyBuild, /run-mprc-release\.json/); assert.match(netlifyBuild, /siteFilesSha256/); assert.match(netlifyBuild, /CREDENTIAL_MARKERS/); + assert.match(netlifyBuild, /verifyExecutableArtifact/); }); -test('Netlify pinned build receives no provider or React application variables', () => { +test('Netlify pinned build receives only reviewed public Firebase configuration', () => { const environment = buildEnvironment('/tmp/synthetic-home', { PATH: '/synthetic/bin', TMPDIR: '/tmp/synthetic', @@ -736,6 +738,15 @@ test('Netlify pinned build receives no provider or React application variables', FIREBASE_SERVICE_ACCOUNT: 'blocked', STRIPE_SECRET_KEY: 'blocked', }); + assert.equal(environment.REACT_APP_FIREBASE_ENVIRONMENT, 'production'); + assert.equal( + environment.REACT_APP_FIREBASE_PROJECT_ID, + 'mid-peninsula-running-club', + ); + assert.equal( + environment.REACT_APP_FIREBASE_AUTH_DOMAIN, + 'mid-peninsula-running-club.firebaseapp.com', + ); assert.equal(environment.REACT_APP_RECAPTCHA_SITE_KEY, undefined); assert.equal(environment.REACT_APP_SENTRY_ENV, undefined); assert.equal(environment.REACT_APP_UNREVIEWED_VALUE, undefined); @@ -745,6 +756,23 @@ test('Netlify pinned build receives no provider or React application variables', assert.equal(environment.HOME, '/tmp/synthetic-home'); }); +test('ordinary Netlify previews receive only synthetic staging Firebase configuration', () => { + const environment = previewBuildEnvironment('/tmp/synthetic-home', { + PATH: '/synthetic/bin', + REACT_APP_FIREBASE_PROJECT_ID: 'mid-peninsula-running-club', + REACT_APP_RECAPTCHA_SITE_KEY: 'blocked', + REACT_APP_SENTRY_DSN: 'blocked', + STRIPE_SECRET_KEY: 'blocked', + }); + + assert.equal(environment.REACT_APP_FIREBASE_ENVIRONMENT, 'staging'); + assert.equal(environment.REACT_APP_FIREBASE_PROJECT_ID, 'mprc-staging-ci'); + assert.equal(environment.REACT_APP_FIREBASE_MEASUREMENT_ID, undefined); + assert.equal(environment.REACT_APP_RECAPTCHA_SITE_KEY, undefined); + assert.equal(environment.REACT_APP_SENTRY_DSN, undefined); + assert.equal(environment.STRIPE_SECRET_KEY, undefined); +}); + test('Netlify manifest file rejects duplicate-key or noncanonical JSON', () => { const temporaryRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'mprc-netlify-manifest-'), diff --git a/tests/test-artifact-safety.test.js b/tests/test-artifact-safety.test.js index d5592c80..aee3a83b 100644 --- a/tests/test-artifact-safety.test.js +++ b/tests/test-artifact-safety.test.js @@ -13,6 +13,10 @@ const NETLIFY_RELEASE_BUILDER = path.join( REPOSITORY, 'scripts/netlify-release-build.js', ); +const FIREBASE_HOSTING_CONTRACT = path.join( + REPOSITORY, + 'scripts/firebase-hosting-contract.js', +); const CI_PATH = path.join(REPOSITORY, '.github/workflows/ci.yml'); const RELEASE_PATH = path.join(REPOSITORY, '.github/workflows/deploy.yml'); const ciWorkflow = fs.readFileSync(CI_PATH, 'utf8'); @@ -22,7 +26,7 @@ const temporaryDirectories = []; const JOB_ID = 'test-artifact-scrubber'; const JOB_NAME = 'Test artifact scrubber'; const TEST_COMMAND = 'node --test tests/test-artifact-safety.test.js'; -const FRONTEND_TEST_COMMAND = 'node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/test-artifact-safety.test.js tests/root-dependency-security.test.js'; +const FRONTEND_TEST_COMMAND = 'node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/firebase-hosting-foundation.test.js tests/test-artifact-safety.test.js tests/root-dependency-security.test.js'; const NEVER_RUN = ['$', '{{ false }}'].join(''); const ALWAYS_TRUE = ['$', '{{ true }}'].join(''); const BRACKET_SECRET = ['$', "{{ secrets['SYNTHETIC_CANARY'] }}"].join(''); @@ -154,6 +158,10 @@ function makeNetlifyReleaseBuilderFixture(loadManifestBody, fakeGit = null) { const script = path.join(root, 'scripts/netlify-release-build.js'); fs.mkdirSync(path.dirname(script), { recursive: true }); fs.copyFileSync(NETLIFY_RELEASE_BUILDER, script); + fs.copyFileSync( + FIREBASE_HOSTING_CONTRACT, + path.join(root, 'scripts/firebase-hosting-contract.js'), + ); writeArtifact( root, 'scripts/netlify-release-policy.js', @@ -1301,6 +1309,13 @@ test('frontend independently runs the exact workflow safety suite without a skip assert.notEqual(missingDependencyTest, ciWorkflow); assert.notDeepEqual(frontendValidationErrors(missingDependencyTest), []); + const missingHostingTest = ciWorkflow.replace( + ' tests/firebase-hosting-foundation.test.js', + '', + ); + assert.notEqual(missingHostingTest, ciWorkflow); + assert.notDeepEqual(frontendValidationErrors(missingHostingTest), []); + const swallowedValidation = ciWorkflow.replace( FRONTEND_TEST_COMMAND, `${FRONTEND_TEST_COMMAND} || true`,