diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index b47817a9..11f3c86b 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -54,7 +54,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 2 | CI-001 | Repair test gates and secure the deployment pipeline | P0 | L | partial: #103 baseline + #124 hosted Jest; #126 SPA gate and remaining gates open | SAFETY-001 | | 3 | SUPPLY-001 | Remove vulnerable dependency chains and stage SDK/build upgrades | P0 | L | partial: SUPPLY-001A tracked by #127; remaining upgrade/scanning slices open | CI-001 baseline | | 4 | SEC-001 | Replace the Firestore admin catch-all with resource-specific rules | P0 | M | source merged #123; Firebase live unproven | — | -| 5 | CONFIG-001 | Fail closed on server environment and commerce configuration | P0 | M | ready | CI-001A recommended | +| 5 | CONFIG-001 | Fail closed on server environment and commerce configuration | P0 | M | CONFIG-001A tracked in [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149); CONFIG-001B proposed; not deployed | CI-001A recommended | | 6 | AUTH-001 | Require verified email for member and privileged claims | P0 | M | partial: #98 merged; backend live unproven; parent open | SEC-001 recommended | | 7 | AUTH-002 | Replace the legacy static-key membership synchronization endpoint | P0 | M | ready | AUTH-001 | | 8 | OAUTH-001 | Make Strava token lifecycle server-only, transactional, and auditable | P0 | M | represented by live [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88); proposed/unclaimed | SAFETY-001/#99; SEC-001 and AUTH-003 for A; staged deferral/cutover with ABUSE-001A for C | @@ -274,13 +274,15 @@ Do not compensate by moving sensitive data into a different admin-readable docum ## CONFIG-001 — Fail closed on server environment and commerce configuration **Labels:** `priority:P0`, `type:security`, `type:reliability`, `area:firebase`, `area:stripe`, `size:M` -**Status:** Ready +**Status:** CONFIG-001A source/tests are tracked in live issue [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149), which remains the status source for review and merge evidence. CONFIG-001B remains proposed. No Firebase/Stripe configuration or live behavior is proven by source. **Depends on:** CI-001A recommended ### Problem Several Functions default a missing `SITE_ORIGIN` to the production domain; until this pass the webhook accepted a missing expected Stripe mode. `ENVIRONMENT_NAME` and `COMMERCE_ENABLED` are documented but not validated or enforced. A missing/mistyped deployment variable can therefore create unsafe URLs, mix live/test behavior, or leave no operational kill switch. +CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) owns only the typed invocation-time environment/origin/expected-mode boundary, server-key mode compatibility on the four key-bound Functions, removal of production-origin fallbacks, and fixed no-side-effect tests. Webhook and mail remain free of the Stripe API key. CONFIG-001B separately owns `COMMERCE_ENABLED`, global/per-domain switches, refund/incident continuity, and switch-race tests; do not combine the children. + ### Scope - Create one typed server-configuration module with an allowlisted environment name, required validated site origin, explicit expected Stripe livemode, commerce-enabled state, and safe emulator behavior. diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 1d962d47..fc3ce0c5 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -47,7 +47,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | -| CONFIG-001A | ready; CI-001A | Add typed server configuration requiring environment name, validated HTTPS/local origin, explicit Stripe mode, and environment/key compatibility; remove production fallbacks. | Missing/malformed/local-live tests fail before Firestore/Stripe/email. | +| CONFIG-001A | source/tests tracked in live [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149); CI-001A; not deployed/configured | Add typed server configuration requiring environment name, exact hosted/loopback origin, explicit Stripe mode, and key-bound environment/key compatibility; remove production fallbacks while keeping webhook/mail least-privilege. | Missing/malformed/local-live tests fail after auth/signature but before rate-limit/Firestore/Stripe/mail/ledger work; merge and provider evidence remain separate. | | CONFIG-001B | proposed; CONFIG-001A | Implement a server-owned global/per-domain commerce kill switch checked by every Session/refund/catalog command while webhook/reconciliation remain active. | Disabled-command tests prove no external call; webhook/reconcile tests still pass. | | PROMO-001 | source/tests complete in [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102); provider inventory and deployment remain owner action; PAY-001A for future enablement | Disable promotion entry and automatic tax in both Session creators; require a complete Stripe breakdown; quarantine unknown or nonzero discount, tax, or shipping adjustments. | Exact Session payload assertions and signed zero/partial/full/malformed/failed/expired adjustment tests pass; pre-change Session/provider inventory and live verification remain open. | | PAY-001A | ready; CI-001A | Add shared strict-object, bounds/depth, integer-cents/currency/date/URL/email primitives, safe errors, and redacted logging. | Prototype/deep/Unicode/money/unknown-key tests prove no external call. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 181cad69..e14b39b6 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -210,7 +210,7 @@ The first tranche deliberately combines one urgent safety repair with the most c 2. **PAY-003 — webhook event inbox and safe reducer:** PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) source merged through [PR #140](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/140) as `87bd1210575882cdd06214bcb98ed38ce00f71c2`, adding event replay protection, async-aware payment confirmation, expected total/currency validation, direct metadata lookup, terminal-state guards, and lifecycle tests. Firebase and Stripe live behavior remain unproven; PAY-003B/C remain open. 3. **SEC-001 — Firestore trust boundaries:** #100 merged through [PR #123](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/123) at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. Source and 295 Rules tests remove recursive browser-admin authority. Firebase deployment explicitly skipped, so deployed/live Rules remain unproven under #105. 4. **PROMO-001 — fail-safe monetary policy:** [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) disables promotion entry and automatic tax in both current creators, adds exact payload tests, and quarantines unknown or nonzero discount/tax/shipping adjustments on success, failure, and expiry. The private pre-change Session/provider inventory, protected deployment, provider readback, and live verification remain owner actions; no discount, tax, or shipping feature is authorized. -5. **Identity/config next:** AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) merged at `ce22c110`, proving source/tests for unverified-target rejection at the existing role-grant endpoints. Its Firebase deployment skipped, so backend-live status is unproven and the remaining AUTH-001 parent work stays open. Continue CONFIG-001 and the uncompleted AUTH-001 guards/Rules/refresh/audit scope without duplicating #98. +5. **Identity/config next:** AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) merged at `ce22c110`, proving source/tests for unverified-target rejection at the existing role-grant endpoints. Its Firebase deployment skipped, so backend-live status is unproven and the remaining AUTH-001 parent work stays open. CONFIG-001A is tracked in live child [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) for the typed environment/origin/Stripe-mode source boundary; CONFIG-001B retains the later commerce stop-switch matrix. Follow the live issues and do not duplicate either #98 or #149. 6. **Then:** PAY-001/PAY-002 so checkout creation, refunds, and every later handler share strict schemas and idempotent state. 7. **Then:** RACE-001 and MERCH-001 transactional reservations. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index cb9ba33f..2208c11d 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -91,6 +91,33 @@ COMMERCE_ENABLED ENVIRONMENT_NAME ``` +### Typed server configuration gate — source in #149, not deployed + +[CONFIG-001A / #149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) makes the current commerce and confirmation-mail Functions validate configuration at each invocation. Parsing happens at runtime, not while Firebase discovers Functions for deployment. The returned configuration is frozen and contains no Stripe key. + +| Environment | Accepted site origin | Expected Stripe mode | Accepted server-key mode | +| --- | --- | --- | --- | +| `local` | Exact loopback `http` or `https` origin | Test | Test | +| `test` | Canonical HTTPS `.test` origin | Test | Test | +| `staging` | `https://dev.runmprc.com` | Test | Test | +| `production` | `https://runmprc.com` | Live | Live | + +An origin is only the scheme, host, and optional non-default port. Credentials, paths, trailing slashes, queries, fragments, padded values, and default-port aliases are rejected. Test/live compatibility uses only the documented `sk_` or restricted `rk_` mode marker. The complete key is never returned, logged, stored in configuration, or copied into evidence. + +```mermaid +flowchart TD + Entry["Commerce or mail Function invoked"] --> First["Check caller, webhook signature, or whether mail work applies"] + First --> Parse{"Environment, origin, mode, and required key marker valid?"} + Parse -- "No" --> Stop["Fixed configuration error\nno rate-limit, business, mail, ledger, or Stripe write"] + Parse -- "Yes" --> Existing["Run existing validation and business logic"] +``` + +Text alternative: after identity, signature, or mail-eligibility checks, invalid server settings stop the invocation before any business, mail, event-ledger, or Stripe side effect. + +The webhook and confirmation-mail triggers validate environment, origin, and expected mode without receiving the Stripe API key. Only the two checkout creators and two admin action Functions bind and validate that key. `COMMERCE_ENABLED` and global/per-domain stop-switch behavior remain **NOT AVAILABLE YET** under CONFIG-001B. + +Source tests do not prove Firebase parameters, Secret Manager bindings, Stripe account mode, or production behavior. Before deployment, record only parameter names in public evidence. Store values and provider identifiers in the approved private continuity record. Use a protected staging release with made-up data; never discover configuration by trying a real payment or email. + `functions.config()` is deprecated and scheduled for decommissioning in March 2027. The legacy member-sync API key must be retired or migrated as its own security issue, not copied into a new generic secret without redesign. ### Secret rotation record diff --git a/SECURITY.md b/SECURITY.md index 00464cb1..86b7fd6c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -99,6 +99,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | 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. | +| Configuration part of RISK-001/RISK-010 | CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) source adds one invocation-time server configuration boundary. It requires an exact environment/origin/expected Stripe mode, validates test/live `sk_` or restricted `rk_` server-key compatibility only on the four key-bound Functions, removes production-origin fallbacks, and gives webhook/mail no Stripe API key. Fixed negative tests stop before rate-limit, Firestore, mail, event-ledger, or Stripe work; returned configuration and errors contain no complete key or unsafe supplied value. | Source review and merge do not configure Firebase or Stripe. Verify non-secret parameter names, Secret Manager bindings, exact staging/production modes, protected deployment, and provider readback privately. CONFIG-001B still owns the commerce stop-switch matrix; no live payment or email is authorized. | | Part of RISK-007 | AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) was merged through [PR #107](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/107) as `ce22c110f2132b157bd8a0d43b065585e0b43cb5`. Source and focused tests reject unverified targets at the two existing role-grant endpoints. | The workflow skipped Firebase because `FIREBASE_SERVICE_ACCOUNT` was absent, so backend-live behavior is unproven. Remaining AUTH-001 guards, Rules, verification mirror/refresh, demotion propagation, audit, and authoritative membership work stay open. | | RISK-013 | PROMO-001 [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) sets both current Checkout creators to promotion entry off and automatic tax off. Exact payload and signed event tests require an explicit all-zero breakdown; malformed or nonzero discount, tax, or shipping data creates durable review evidence while definitive failed/expired Sessions still cancel. | Privately inventory pre-change Sessions/provider promotion settings, keep the guard through protected staging/deploy, verify provider and live state separately, and implement an approved authoritative discount/tax/shipping contract before ever enabling it. | | Part of RISK-014 | SUPPLY-001A [#127](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/127) removes the unused direct `gpxparser` dependency and 50 lockfile entries without changing any retained package version, source URL, or integrity hash. A fresh Node 20 install lowers the root production audit from 33 findings (3 critical, 9 high) to 22 (1 critical, 8 high), and the optimized production asset hashes remain identical. | Remediate or time-bound the remaining root and Functions findings through the separately tested router, Firebase, Stripe, build-system, and scanning slices; do not use a forced audit upgrade. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index c769ee44..103a95a2 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -49,6 +49,8 @@ The issue sequence in [GITHUB_ISSUES.md](./GITHUB_ISSUES.md) closes these gaps i Never let a development build fall back to production Functions. Never share a webhook signing secret between endpoints or modes. A test-mode Event cannot mutate a production business record; the webhook processor must compare the incoming Stripe `livemode` flag to its environment configuration. +CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) implements this matrix as an invocation-time source guard for the current Functions. Configuration parsing returns only the environment name, canonical site origin, and expected livemode; it never returns or stores the complete Stripe key. The two checkout creators and two admin action Functions validate only the documented test/live marker on their bound `sk_` or restricted `rk_` server key. Webhook and confirmation-mail Functions validate the non-secret matrix without receiving that key. Invalid configuration returns a fixed failure before any local business or outside-provider side effect. Provider parameters, secrets, account mode, deployment, and live behavior still require separate private evidence. + ### Secret inventory | Name | Consumer | Storage | Notes | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index dd35c8f1..7ea15dd1 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -403,6 +403,8 @@ Optimized builds—including current Netlify previews and a locally served `buil `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. +CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) adds the source boundary for current commerce and confirmation-mail Functions: local is exact loopback, tests use HTTPS `.test`, staging is exactly `https://dev.runmprc.com`, and production is exactly `https://runmprc.com`. Test/live expected mode must match the server-key marker on the four key-bound Functions. Webhook and mail receive no Stripe API key, and invalid settings stop before rate-limit, business, mail, event-ledger, or Stripe writes. This source boundary does not create staging, configure Secret Manager or Stripe, deploy Firebase, enable commerce, or prove live behavior; CONFIG-001B and #105/#133/#136 retain those responsibilities. + ## 11. Security, privacy, and compliance posture Hosted Checkout reduces MPRC's card-data scope because payment details are entered on Stripe's domain, but it does not make the surrounding system automatically secure or compliant. MPRC still owns access control, application security, privacy disclosures, waiver evidence, fulfillment, refunds, disputes, incident response, vendor configuration, and data retention. diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index fadd2326..4c53faff 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -58,6 +58,34 @@ As of **2026-07-13**: - Git-triggered Netlify production builds are paused. Netlify build hooks are not controlled by that repository rule and remain unverified. - Live race signup, merchandise payments, and refunds remain unavailable. +### Commerce server safety gate — SOURCE ONLY, NOT DEPLOYED + +Issue [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) adds a source-code check for the server environment, website address, Stripe test/live mode, and server-key mode. It does not configure Firebase or Stripe. It does not make payments available. + +```mermaid +flowchart LR + Request["Signup, shop, admin, webhook, or mail work"] --> Identity["Check App Check, officer access, webhook signature, or whether mail work applies"] + Identity --> Config{"Server settings match?"} + Config -- "No" --> Stop["Stop before database, mail, or Stripe work"] + Config -- "Yes" --> Continue["Continue with the existing safety checks"] +``` + +In words: the server first checks identity, the webhook signature, or whether mail work applies. Wrong or missing settings then stop qualifying work before business data, email, or Stripe can change. + +If a member or officer sees **Server configuration is unavailable**: + +1. Stop. +2. Do not retry checkout, refund, or late registration. +3. Record the page and time without member or payment details. +4. Ask the platform owner to check the private environment record. +5. Never paste a key, setting value, screenshot of a console, or member details into an issue or AI tool. + +**Expected result:** no registration, order, refund, mail, or Stripe object is created while settings are invalid. + +**Undo:** revert the reviewed source change if it causes a false stop. Never restore a default production website address. + +**Escalation:** platform owner, then the finance owner if a payment might already exist in Stripe. + ## Before merge 1. Open the pull request. @@ -142,6 +170,7 @@ Stop and contact the platform owner if: - The commit is not merged into `main`. - The release request is more than 24 hours old. - The environment, project, or fixed scope is missing or wrong. +- The site reports **Server configuration is unavailable**. - Anyone asks for a service-account key, token, password, or recovery code. - Firebase is skipped, partial, failed, or unverified. - A website publication job starts before backend verification. diff --git a/functions/.env.example b/functions/.env.example index 9b75b76b..84119548 100644 --- a/functions/.env.example +++ b/functions/.env.example @@ -2,6 +2,12 @@ # Production values must be environment-scoped and validated. Do not copy this # file to production without following OPERATIONS_RUNBOOK.md. +# CONFIG-001A accepts only this explicit matrix: +# local = loopback origin + Stripe test mode +# test = HTTPS .test origin + Stripe test mode +# staging = https://dev.runmprc.com + Stripe test mode +# production = https://runmprc.com + Stripe live mode +# There is no production-origin fallback. ENVIRONMENT_NAME=local SITE_ORIGIN=http://localhost:3000 STRIPE_LIVEMODE_EXPECTED=false diff --git a/functions/adminOrderAction.js b/functions/adminOrderAction.js index dc37e7e7..f4c42785 100644 --- a/functions/adminOrderAction.js +++ b/functions/adminOrderAction.js @@ -8,6 +8,7 @@ const { auditEntry, Timestamp, } = require('./stripeHelpers'); +const { loadCallableServerConfig } = require('./serverConfig'); const ACTIONS = new Set([ 'mark_fulfilled', @@ -27,6 +28,7 @@ exports.adminOrderAction = functions .https.onCall(async (data, context) => { requireAppCheck(context); await requireAdmin(context); + loadCallableServerConfig({ requireStripeKey: true }); const { orderId, action, payload = {} } = data || {}; if (!orderId) { diff --git a/functions/adminRegistrationAction.js b/functions/adminRegistrationAction.js index df8c78d7..11a82993 100644 --- a/functions/adminRegistrationAction.js +++ b/functions/adminRegistrationAction.js @@ -10,6 +10,7 @@ const { auditEntry, Timestamp, } = require('./stripeHelpers'); +const { loadCallableServerConfig } = require('./serverConfig'); const ACTIONS = new Set([ 'refund_full', @@ -155,7 +156,7 @@ async function markComp({ eventId, registration, actor }) { } async function addLateRegistration({ - stripe, eventId, registration, actor, + stripe, eventId, registration, actor, siteOrigin, }) { if (!registration || !isValidEmail(registration.runner?.email)) { throw new functions.https.HttpsError('invalid-argument', 'registration.runner.email required'); @@ -246,7 +247,7 @@ async function addLateRegistration({ after_completion: { type: 'redirect', redirect: { - url: `${process.env.SITE_ORIGIN || 'https://runmprc.com'}/register/success?reg=${newRef.id}&token=${confirmationToken}`, + url: `${siteOrigin}/register/success?reg=${newRef.id}&token=${confirmationToken}`, }, }, }); @@ -261,6 +262,7 @@ exports.adminRegistrationAction = functions .https.onCall(async (data, context) => { requireAppCheck(context); await requireAdmin(context); + const serverConfig = loadCallableServerConfig({ requireStripeKey: true }); const { eventId, registrationId, action, payload = {}, @@ -284,7 +286,11 @@ exports.adminRegistrationAction = functions if (action === 'add_late_registration') { const stripe = getStripe(); return addLateRegistration({ - stripe, eventId, registration: payload.registration, actor, + stripe, + eventId, + registration: payload.registration, + actor, + siteOrigin: serverConfig.siteOrigin, }); } diff --git a/functions/adminRegistrationAction.test.js b/functions/adminRegistrationAction.test.js index ea39bddd..0b0e5033 100644 --- a/functions/adminRegistrationAction.test.js +++ b/functions/adminRegistrationAction.test.js @@ -1,10 +1,20 @@ +const mockFirestoreAccess = jest.fn(); +const mockStripeConstructor = jest.fn(); + +jest.mock('stripe', () => mockStripeConstructor); + jest.mock('firebase-admin', () => { const firestore = {}; firestore.FieldValue = { arrayUnion: (x) => x }; + const firestoreFunction = () => { + mockFirestoreAccess(); + return firestore; + }; + firestoreFunction.FieldValue = firestore.FieldValue; return { initializeApp: jest.fn(), apps: [{}], - firestore: Object.assign(() => firestore, { FieldValue: firestore.FieldValue }), + firestore: firestoreFunction, }; }); @@ -36,11 +46,50 @@ jest.mock('firebase-admin/firestore', () => ({ describe('adminRegistrationAction authorization', () => { beforeEach(() => { - process.env.STRIPE_SECRET_KEY = 'sk_test_x'; + process.env.ENVIRONMENT_NAME = 'test'; + process.env.SITE_ORIGIN = 'https://runmprc.test'; + process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; + process.env.STRIPE_SECRET_KEY = [ + 'sk', 'test', 'synthetic_admin_registration', + ].join('_'); + mockFirestoreAccess.mockClear(); + mockStripeConstructor.mockClear(); jest.resetModules(); }); + afterAll(() => { + delete process.env.ENVIRONMENT_NAME; + delete process.env.SITE_ORIGIN; + delete process.env.STRIPE_LIVEMODE_EXPECTED; + delete process.env.STRIPE_SECRET_KEY; + }); + + test('rejects invalid configuration before admin registration side effects', async () => { + delete process.env.SITE_ORIGIN; + const { adminRegistrationAction } = require('./adminRegistrationAction'); + + await expect(adminRegistrationAction({ + eventId: 'e1', + action: 'add_late_registration', + payload: { + registration: { + runner: { email: 'runner@example.test' }, + amountCents: 1000, + }, + }, + }, { + auth: { uid: 'admin-1', token: { role: 'admin' } }, + })).rejects.toMatchObject({ + code: 'failed-precondition', + message: 'Server configuration is unavailable', + }); + + expect(mockFirestoreAccess).not.toHaveBeenCalled(); + expect(mockStripeConstructor).not.toHaveBeenCalled(); + }); + test('rejects unauthenticated caller', async () => { + delete process.env.ENVIRONMENT_NAME; const { adminRegistrationAction } = require('./adminRegistrationAction'); await expect( adminRegistrationAction( @@ -51,6 +100,7 @@ describe('adminRegistrationAction authorization', () => { }); test('rejects non-admin caller', async () => { + delete process.env.ENVIRONMENT_NAME; const { adminRegistrationAction } = require('./adminRegistrationAction'); await expect( adminRegistrationAction( diff --git a/functions/checkoutPromotionPolicy.test.js b/functions/checkoutPromotionPolicy.test.js index 5136676f..97fe59ff 100644 --- a/functions/checkoutPromotionPolicy.test.js +++ b/functions/checkoutPromotionPolicy.test.js @@ -1,5 +1,8 @@ const mockCheckoutCreate = jest.fn(); const mockProductCreate = jest.fn(); +const mockGetStripe = jest.fn(); +const mockRateLimit = jest.fn(); +const mockFirestoreAccess = jest.fn(); jest.mock('firebase-functions', () => { class HttpsError extends Error { @@ -83,7 +86,10 @@ jest.mock('firebase-admin', () => { return { apps: [{}], - firestore: () => firestore, + firestore: () => { + mockFirestoreAccess(); + return firestore; + }, __clear: () => { store.clear(); registrationSequence = 0; @@ -95,10 +101,7 @@ jest.mock('firebase-admin', () => { }); jest.mock('./stripeHelpers', () => ({ - getStripe: () => ({ - checkout: { sessions: { create: mockCheckoutCreate } }, - products: { create: mockProductCreate }, - }), + getStripe: mockGetStripe, generateToken: () => 'synthetic-confirmation-token', resolveCallerRole: async () => null, requireAppCheck: () => {}, @@ -113,7 +116,7 @@ jest.mock('./stripeHelpers', () => ({ })); jest.mock('./rateLimit', () => ({ - checkRateLimit: async () => {}, + checkRateLimit: mockRateLimit, extractIp: () => 'synthetic-test-ip', })); @@ -126,15 +129,75 @@ describe('Checkout promotion policy', () => { admin.__clear(); mockCheckoutCreate.mockReset(); mockProductCreate.mockReset(); + mockGetStripe.mockReset(); + mockRateLimit.mockReset(); + mockFirestoreAccess.mockClear(); + mockGetStripe.mockReturnValue({ + checkout: { sessions: { create: mockCheckoutCreate } }, + products: { create: mockProductCreate }, + }); + mockRateLimit.mockResolvedValue(undefined); mockCheckoutCreate.mockImplementation(async (payload) => ({ id: payload.metadata.type === 'merch' ? 'cs_order_policy' : 'cs_registration_policy', url: 'https://checkout.stripe.test/synthetic-session', })); + process.env.ENVIRONMENT_NAME = 'test'; process.env.SITE_ORIGIN = 'https://runmprc.test'; + process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; + process.env.STRIPE_SECRET_KEY = [ + 'sk', 'test', 'synthetic_checkout_policy', + ].join('_'); }); afterAll(() => { + delete process.env.ENVIRONMENT_NAME; delete process.env.SITE_ORIGIN; + delete process.env.STRIPE_LIVEMODE_EXPECTED; + delete process.env.STRIPE_SECRET_KEY; + }); + + test.each([ + ['paid race checkout', createCheckoutSession, { + eventId: 'race-1', + runner: { + firstName: 'Test', + lastName: 'Runner', + email: 'runner@example.test', + }, + priceTier: 'nonMember', + acceptedWaiver: true, + }], + ['free volunteer checkout', createCheckoutSession, { + eventId: 'race-1', + runner: { + firstName: 'Test', + lastName: 'Volunteer', + email: 'volunteer@example.test', + }, + acceptedWaiver: true, + signupType: 'volunteer', + }], + ['merchandise checkout', createMerchCheckout, { + productSlug: 'hat', + buyer: { + firstName: 'Test', + lastName: 'Buyer', + email: 'buyer@example.test', + }, + }], + ])('rejects invalid configuration before %s side effects', async (_name, handler, data) => { + delete process.env.ENVIRONMENT_NAME; + + await expect(handler(data, { auth: null, rawRequest: {} })).rejects.toMatchObject({ + code: 'failed-precondition', + message: 'Server configuration is unavailable', + }); + + expect(mockRateLimit).not.toHaveBeenCalled(); + expect(mockFirestoreAccess).not.toHaveBeenCalled(); + expect(mockGetStripe).not.toHaveBeenCalled(); + expect(mockProductCreate).not.toHaveBeenCalled(); + expect(mockCheckoutCreate).not.toHaveBeenCalled(); }); test('race checkout sends the exact disabled-adjustment payload', async () => { diff --git a/functions/createCheckoutSession.js b/functions/createCheckoutSession.js index 3e812f62..c2e9e099 100644 --- a/functions/createCheckoutSession.js +++ b/functions/createCheckoutSession.js @@ -15,6 +15,7 @@ const { Timestamp, } = require('./stripeHelpers'); const { checkRateLimit, extractIp } = require('./rateLimit'); +const { loadCallableServerConfig } = require('./serverConfig'); const HOUR_MS = 60 * 60 * 1000; const CHECKOUT_PER_IP_PER_HOUR = 20; @@ -23,10 +24,6 @@ const CHECKOUT_PER_EMAIL_PER_HOUR = 10; const SUCCESS_PATH = '/register/success'; const CANCEL_PATH_PREFIX = '/events/'; -function resolveSiteOrigin() { - return process.env.SITE_ORIGIN || 'https://runmprc.com'; -} - function resolvePriceTier({ requestedTier, event, callerRole, now }) { const pricing = event.pricing || {}; const earlyBirdActive = isEarlyBirdActive(event, now); @@ -77,6 +74,7 @@ exports.createCheckoutSession = functions .runWith({ secrets: ['STRIPE_SECRET_KEY'] }) .https.onCall(async (data, context) => { requireAppCheck(context); + const serverConfig = loadCallableServerConfig({ requireStripeKey: true }); const { eventId, runner, @@ -233,7 +231,7 @@ exports.createCheckoutSession = functions const stripe = getStripe(); const productId = await ensureStripeProduct(stripe, eventRef, event); - const origin = resolveSiteOrigin(); + const origin = serverConfig.siteOrigin; const eventSlug = event.slug || eventId; const session = await stripe.checkout.sessions.create({ diff --git a/functions/createMerchCheckout.js b/functions/createMerchCheckout.js index 5833d17a..57efcfbe 100644 --- a/functions/createMerchCheckout.js +++ b/functions/createMerchCheckout.js @@ -10,15 +10,12 @@ const { Timestamp, } = require('./stripeHelpers'); const { checkRateLimit, extractIp } = require('./rateLimit'); +const { loadCallableServerConfig } = require('./serverConfig'); const HOUR_MS = 60 * 60 * 1000; const MERCH_PER_IP_PER_HOUR = 20; const MERCH_PER_EMAIL_PER_HOUR = 10; -function resolveSiteOrigin() { - return process.env.SITE_ORIGIN || 'https://runmprc.com'; -} - async function ensureStripeProduct(stripe, productRef, product) { if (product.stripeProductId) return product.stripeProductId; const created = await stripe.products.create({ @@ -37,6 +34,7 @@ exports.createMerchCheckout = functions .runWith({ secrets: ['STRIPE_SECRET_KEY'] }) .https.onCall(async (data, context) => { requireAppCheck(context); + const serverConfig = loadCallableServerConfig({ requireStripeKey: true }); const { productSlug, @@ -136,7 +134,7 @@ exports.createMerchCheckout = functions ...product, slug: productSlug, }); - const origin = resolveSiteOrigin(); + const origin = serverConfig.siteOrigin; const session = await stripe.checkout.sessions.create({ mode: 'payment', diff --git a/functions/sendConfirmationEmail.js b/functions/sendConfirmationEmail.js index 854ff960..996f592c 100644 --- a/functions/sendConfirmationEmail.js +++ b/functions/sendConfirmationEmail.js @@ -1,6 +1,7 @@ const functions = require('firebase-functions'); const admin = require('firebase-admin'); const { Timestamp } = require('firebase-admin/firestore'); +const { loadServerConfig } = require('./serverConfig'); /** * Firestore trigger: writes an email doc to the `mail` collection when a @@ -70,13 +71,13 @@ exports.sendConfirmationEmail = functions.firestore const email = after.runner?.email; if (!email) return; + const { siteOrigin } = loadServerConfig(); const eventSnap = await admin.firestore() .collection('events').doc(context.params.eventId).get(); if (!eventSnap.exists) return; const event = { id: eventSnap.id, ...eventSnap.data() }; - const siteOrigin = process.env.SITE_ORIGIN || 'https://runmprc.com'; const { html, text } = buildEmail({ reg: { id: context.params.regId, ...after }, event, @@ -107,13 +108,13 @@ exports.sendConfirmationEmailOnCreate = functions.firestore const email = data.runner?.email; if (!email) return; + const { siteOrigin } = loadServerConfig(); const eventSnap = await admin.firestore() .collection('events').doc(context.params.eventId).get(); if (!eventSnap.exists) return; const event = { id: eventSnap.id, ...eventSnap.data() }; - const siteOrigin = process.env.SITE_ORIGIN || 'https://runmprc.com'; const { html, text } = buildEmail({ reg: { id: context.params.regId, ...data }, event, diff --git a/functions/serverConfig.js b/functions/serverConfig.js new file mode 100644 index 00000000..45198c38 --- /dev/null +++ b/functions/serverConfig.js @@ -0,0 +1,188 @@ +const functions = require('firebase-functions'); + +const CONFIGURATION_ERROR_MESSAGE = 'Server configuration is unavailable'; +const ENVIRONMENT_NAMES = new Set(['local', 'test', 'staging', 'production']); +const STAGING_SITE_ORIGIN = 'https://dev.runmprc.com'; +const PRODUCTION_SITE_ORIGIN = 'https://runmprc.com'; + +/** + * @typedef {'local'|'test'|'staging'|'production'} EnvironmentName + */ + +/** + * @typedef {Object} ServerConfig + * @property {EnvironmentName} environmentName + * @property {string} siteOrigin + * @property {boolean} stripeLivemodeExpected + */ + +class ServerConfigError extends Error { + constructor(reason) { + super(CONFIGURATION_ERROR_MESSAGE); + Object.defineProperty(this, 'name', { + value: 'ServerConfigError', + enumerable: false, + }); + Object.defineProperty(this, 'reason', { + value: reason, + enumerable: false, + }); + Error.captureStackTrace?.(this, ServerConfigError); + } +} + +function reject(reason) { + throw new ServerConfigError(reason); +} + +function requiredExactString(environment, name, missingReason, invalidReason) { + const value = environment?.[name]; + if (typeof value !== 'string' || value.length === 0) reject(missingReason); + if (value.trim() !== value) reject(invalidReason); + return value; +} + +function parseCanonicalOrigin(rawOrigin) { + let parsed; + try { + parsed = new URL(rawOrigin); + } catch { + reject('site_origin_invalid'); + } + + if (parsed.origin !== rawOrigin + || parsed.username !== '' + || parsed.password !== '' + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '') { + reject('site_origin_invalid'); + } + return parsed; +} + +function isLoopbackHost(hostname) { + return hostname === 'localhost' + || hostname === '127.0.0.1' + || hostname === '[::1]' + || hostname === '::1'; +} + +function isValidDnsLabel(label) { + return label.length >= 1 + && label.length <= 63 + && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label); +} + +function isSyntheticTestHost(hostname) { + if (hostname.length > 253 || !hostname.endsWith('.test')) return false; + const syntheticName = hostname.slice(0, -'.test'.length); + return syntheticName.length > 0 + && syntheticName.split('.').every(isValidDnsLabel); +} + +function originMatchesEnvironment(environmentName, rawOrigin, parsedOrigin) { + if (environmentName === 'local') { + return (parsedOrigin.protocol === 'http:' || parsedOrigin.protocol === 'https:') + && isLoopbackHost(parsedOrigin.hostname); + } + if (environmentName === 'test') { + return parsedOrigin.protocol === 'https:' + && isSyntheticTestHost(parsedOrigin.hostname); + } + if (environmentName === 'staging') return rawOrigin === STAGING_SITE_ORIGIN; + return rawOrigin === PRODUCTION_SITE_ORIGIN; +} + +function stripeServerKeyMode(key) { + const modes = [ + ['sk_test_', false], + ['rk_test_', false], + ['sk_live_', true], + ['rk_live_', true], + ]; + const matched = modes.find(([prefix]) => key.startsWith(prefix)); + if (!matched || key.length === matched[0].length) return null; + return matched[1]; +} + +function validateStripeServerKey(environment, expectedLivemode) { + const key = environment?.STRIPE_SECRET_KEY; + if (typeof key !== 'string' || key.length === 0) reject('stripe_key_missing'); + if (key.trim() !== key) reject('stripe_key_invalid'); + const keyLivemode = stripeServerKeyMode(key); + if (keyLivemode === null) reject('stripe_key_invalid'); + if (keyLivemode !== expectedLivemode) reject('stripe_key_environment_mismatch'); +} + +/** + * Parse a supplied environment object without retaining secret material. + * + * @param {NodeJS.ProcessEnv|Object} environment + * @param {{requireStripeKey?: boolean}} options + * @returns {Readonly} + */ +function parseServerConfig(environment, { requireStripeKey = false } = {}) { + const environmentName = requiredExactString( + environment, + 'ENVIRONMENT_NAME', + 'environment_name_missing', + 'environment_name_invalid', + ); + if (!ENVIRONMENT_NAMES.has(environmentName)) reject('environment_name_invalid'); + + const siteOrigin = requiredExactString( + environment, + 'SITE_ORIGIN', + 'site_origin_missing', + 'site_origin_invalid', + ); + const parsedOrigin = parseCanonicalOrigin(siteOrigin); + if (!originMatchesEnvironment(environmentName, siteOrigin, parsedOrigin)) { + reject('site_origin_environment_mismatch'); + } + + const stripeMode = requiredExactString( + environment, + 'STRIPE_LIVEMODE_EXPECTED', + 'stripe_mode_missing', + 'stripe_mode_invalid', + ); + if (stripeMode !== 'true' && stripeMode !== 'false') reject('stripe_mode_invalid'); + const stripeLivemodeExpected = stripeMode === 'true'; + if (stripeLivemodeExpected !== (environmentName === 'production')) { + reject('stripe_mode_environment_mismatch'); + } + + if (requireStripeKey) validateStripeServerKey(environment, stripeLivemodeExpected); + + return Object.freeze({ + environmentName, + siteOrigin, + stripeLivemodeExpected, + }); +} + +function loadServerConfig(options) { + return parseServerConfig(process.env, options); +} + +function loadCallableServerConfig(options) { + try { + return loadServerConfig(options); + } catch (error) { + if (!(error instanceof ServerConfigError)) throw error; + throw new functions.https.HttpsError( + 'failed-precondition', + CONFIGURATION_ERROR_MESSAGE, + ); + } +} + +module.exports = { + CONFIGURATION_ERROR_MESSAGE, + ServerConfigError, + loadCallableServerConfig, + loadServerConfig, + parseServerConfig, +}; diff --git a/functions/serverConfig.test.js b/functions/serverConfig.test.js new file mode 100644 index 00000000..85f17e37 --- /dev/null +++ b/functions/serverConfig.test.js @@ -0,0 +1,255 @@ +const { + CONFIGURATION_ERROR_MESSAGE, + ServerConfigError, + parseServerConfig, +} = require('./serverConfig'); + +function serverKey(mode, type = 'sk') { + return `${type}_${mode}_synthetic_config_key`; +} + +const TEST_KEY = serverKey('test'); +const LIVE_KEY = serverKey('live'); + +function validEnvironment(overrides = {}) { + return { + ENVIRONMENT_NAME: 'test', + SITE_ORIGIN: 'https://runmprc.test', + STRIPE_LIVEMODE_EXPECTED: 'false', + STRIPE_SECRET_KEY: TEST_KEY, + ...overrides, + }; +} + +function captureConfigError(environment, options = {}) { + try { + parseServerConfig(environment, options); + } catch (error) { + return error; + } + throw new Error('Expected server configuration to be rejected'); +} + +describe('serverConfig', () => { + test.each([ + ['local loopback', { + ENVIRONMENT_NAME: 'local', + SITE_ORIGIN: 'http://localhost:3000', + STRIPE_LIVEMODE_EXPECTED: 'false', + STRIPE_SECRET_KEY: TEST_KEY, + }], + ['local IPv4 loopback', { + ENVIRONMENT_NAME: 'local', + SITE_ORIGIN: 'http://127.0.0.1:5001', + STRIPE_LIVEMODE_EXPECTED: 'false', + STRIPE_SECRET_KEY: TEST_KEY, + }], + ['local IPv6 loopback', { + ENVIRONMENT_NAME: 'local', + SITE_ORIGIN: 'http://[::1]:5001', + STRIPE_LIVEMODE_EXPECTED: 'false', + STRIPE_SECRET_KEY: TEST_KEY, + }], + ['test synthetic host', validEnvironment()], + ['staging club subdomain', { + ENVIRONMENT_NAME: 'staging', + SITE_ORIGIN: 'https://dev.runmprc.com', + STRIPE_LIVEMODE_EXPECTED: 'false', + STRIPE_SECRET_KEY: serverKey('test', 'rk'), + }], + ['production canonical origin', { + ENVIRONMENT_NAME: 'production', + SITE_ORIGIN: 'https://runmprc.com', + STRIPE_LIVEMODE_EXPECTED: 'true', + STRIPE_SECRET_KEY: LIVE_KEY, + }], + ['production restricted server key', { + ENVIRONMENT_NAME: 'production', + SITE_ORIGIN: 'https://runmprc.com', + STRIPE_LIVEMODE_EXPECTED: 'true', + STRIPE_SECRET_KEY: serverKey('live', 'rk'), + }], + ])('accepts %s without returning the Stripe key', (_name, environment) => { + const config = parseServerConfig(environment, { requireStripeKey: true }); + + expect(config).toEqual({ + environmentName: environment.ENVIRONMENT_NAME, + siteOrigin: environment.SITE_ORIGIN, + stripeLivemodeExpected: environment.STRIPE_LIVEMODE_EXPECTED === 'true', + }); + expect(Object.isFrozen(config)).toBe(true); + expect(JSON.stringify(config)).not.toContain(environment.STRIPE_SECRET_KEY); + }); + + test('base webhook and mail configuration does not require a Stripe API key', () => { + const environment = validEnvironment(); + delete environment.STRIPE_SECRET_KEY; + + expect(parseServerConfig(environment)).toEqual({ + environmentName: 'test', + siteOrigin: 'https://runmprc.test', + stripeLivemodeExpected: false, + }); + }); + + test.each([ + ['missing environment', { ENVIRONMENT_NAME: undefined }, 'environment_name_missing'], + ['unknown environment', { ENVIRONMENT_NAME: 'preview' }, 'environment_name_invalid'], + ['padded environment', { ENVIRONMENT_NAME: ' test ' }, 'environment_name_invalid'], + ['missing origin', { SITE_ORIGIN: undefined }, 'site_origin_missing'], + ['malformed origin', { SITE_ORIGIN: 'not a URL' }, 'site_origin_invalid'], + ['credential-bearing origin', { + SITE_ORIGIN: 'https://member:password@runmprc.test', + }, 'site_origin_invalid'], + ['origin path', { SITE_ORIGIN: 'https://runmprc.test/account' }, 'site_origin_invalid'], + ['origin query', { SITE_ORIGIN: 'https://runmprc.test?next=account' }, 'site_origin_invalid'], + ['origin fragment', { SITE_ORIGIN: 'https://runmprc.test#account' }, 'site_origin_invalid'], + ['origin trailing slash', { SITE_ORIGIN: 'https://runmprc.test/' }, 'site_origin_invalid'], + ['origin default port alias', { + SITE_ORIGIN: 'https://runmprc.test:443', + }, 'site_origin_invalid'], + ['origin uppercase alias', { + SITE_ORIGIN: 'https://RUNMPRC.test', + }, 'site_origin_invalid'], + ['local external host', { + ENVIRONMENT_NAME: 'local', + SITE_ORIGIN: 'http://example.test', + }, 'site_origin_environment_mismatch'], + ['deceptive local host', { + ENVIRONMENT_NAME: 'local', + SITE_ORIGIN: 'http://localhost.example.test', + }, 'site_origin_environment_mismatch'], + ['unspecified local address', { + ENVIRONMENT_NAME: 'local', + SITE_ORIGIN: 'http://0.0.0.0:3000', + }, 'site_origin_environment_mismatch'], + ['insecure test origin', { + SITE_ORIGIN: 'http://runmprc.test', + }, 'site_origin_environment_mismatch'], + ['test non-synthetic host', { + SITE_ORIGIN: 'https://example.com', + }, 'site_origin_environment_mismatch'], + ['test bare reserved suffix', { + SITE_ORIGIN: 'https://.test', + }, 'site_origin_environment_mismatch'], + ['test empty DNS label', { + SITE_ORIGIN: 'https://foo..test', + }, 'site_origin_environment_mismatch'], + ['test leading label hyphen', { + SITE_ORIGIN: 'https://-foo.test', + }, 'site_origin_environment_mismatch'], + ['test trailing label hyphen', { + SITE_ORIGIN: 'https://foo-.test', + }, 'site_origin_environment_mismatch'], + ['test invalid label character', { + SITE_ORIGIN: 'https://_.test', + }, 'site_origin_environment_mismatch'], + ['test overlong DNS label', { + SITE_ORIGIN: `https://${'a'.repeat(64)}.test`, + }, 'site_origin_environment_mismatch'], + ['test overlong DNS name', { + SITE_ORIGIN: `https://${`${'a'.repeat(63)}.`.repeat(4)}test`, + }, 'site_origin_environment_mismatch'], + ['insecure staging origin', { + ENVIRONMENT_NAME: 'staging', + SITE_ORIGIN: 'http://dev.runmprc.com', + }, 'site_origin_environment_mismatch'], + ['outside staging domain', { + ENVIRONMENT_NAME: 'staging', + SITE_ORIGIN: 'https://example.com', + }, 'site_origin_environment_mismatch'], + ['production origin in staging', { + ENVIRONMENT_NAME: 'staging', + SITE_ORIGIN: 'https://runmprc.com', + }, 'site_origin_environment_mismatch'], + ['unapproved club subdomain in staging', { + ENVIRONMENT_NAME: 'staging', + SITE_ORIGIN: 'https://staging.runmprc.com', + }, 'site_origin_environment_mismatch'], + ['noncanonical production origin', { + ENVIRONMENT_NAME: 'production', + SITE_ORIGIN: 'https://www.runmprc.com', + STRIPE_LIVEMODE_EXPECTED: 'true', + STRIPE_SECRET_KEY: LIVE_KEY, + }, 'site_origin_environment_mismatch'], + ['missing expected mode', { + STRIPE_LIVEMODE_EXPECTED: undefined, + }, 'stripe_mode_missing'], + ['malformed expected mode', { + STRIPE_LIVEMODE_EXPECTED: 'FALSE', + }, 'stripe_mode_invalid'], + ['live mode in test', { + STRIPE_LIVEMODE_EXPECTED: 'true', + STRIPE_SECRET_KEY: LIVE_KEY, + }, 'stripe_mode_environment_mismatch'], + ['test mode in production', { + ENVIRONMENT_NAME: 'production', + SITE_ORIGIN: 'https://runmprc.com', + STRIPE_LIVEMODE_EXPECTED: 'false', + }, 'stripe_mode_environment_mismatch'], + ])('rejects %s with a fixed safe error', (_name, overrides, reason) => { + const environment = validEnvironment(overrides); + Object.keys(environment).forEach((key) => { + if (environment[key] === undefined) delete environment[key]; + }); + + const error = captureConfigError(environment, { requireStripeKey: true }); + + expect(error).toBeInstanceOf(ServerConfigError); + expect(error.message).toBe(CONFIGURATION_ERROR_MESSAGE); + expect(error.reason).toBe(reason); + expect(error.stack).not.toContain(environment.STRIPE_SECRET_KEY); + }); + + test.each([ + ['missing key', undefined, 'stripe_key_missing'], + ['blank key', '', 'stripe_key_missing'], + ['padded key', ` ${TEST_KEY}`, 'stripe_key_invalid'], + ['unrecognized key', 'synthetic_unknown_key', 'stripe_key_invalid'], + ['publishable key', serverKey('test', 'pk'), 'stripe_key_invalid'], + ['webhook secret', ['whsec', 'synthetic_config_value'].join('_'), 'stripe_key_invalid'], + ['test prefix only', 'sk_test_', 'stripe_key_invalid'], + ['live prefix only', 'rk_live_', 'stripe_key_invalid'], + ['live key in test', LIVE_KEY, 'stripe_key_environment_mismatch'], + ['live restricted key in staging', serverKey('live', 'rk'), 'stripe_key_environment_mismatch'], + ])('rejects %s before exposing key material', (_name, key, reason) => { + const environment = validEnvironment({ STRIPE_SECRET_KEY: key }); + if (key === undefined) delete environment.STRIPE_SECRET_KEY; + + const error = captureConfigError(environment, { requireStripeKey: true }); + + expect(error).toBeInstanceOf(ServerConfigError); + expect(error.message).toBe(CONFIGURATION_ERROR_MESSAGE); + expect(error.reason).toBe(reason); + expect(error.stack).not.toContain(key || 'synthetic_unknown_key'); + }); + + test.each([serverKey('test'), serverKey('test', 'rk')])( + 'rejects test key %s in production', + (key) => { + const environment = { + ENVIRONMENT_NAME: 'production', + SITE_ORIGIN: 'https://runmprc.com', + STRIPE_LIVEMODE_EXPECTED: 'true', + STRIPE_SECRET_KEY: key, + }; + + const error = captureConfigError(environment, { requireStripeKey: true }); + + expect(error.reason).toBe('stripe_key_environment_mismatch'); + expect(error.message).toBe(CONFIGURATION_ERROR_MESSAGE); + expect(error.stack).not.toContain(key); + }, + ); + + test('does not serialize a supplied unsafe value or the complete key', () => { + const unsafeOrigin = 'https://member:password@runmprc.test/private?token=canary'; + const environment = validEnvironment({ SITE_ORIGIN: unsafeOrigin }); + + const error = captureConfigError(environment, { requireStripeKey: true }); + + expect(JSON.stringify(error)).toBe('{}'); + expect(error.stack).not.toContain(unsafeOrigin); + expect(error.stack).not.toContain(TEST_KEY); + }); +}); diff --git a/functions/serverConfigEntryPoints.test.js b/functions/serverConfigEntryPoints.test.js new file mode 100644 index 00000000..5dc1807f --- /dev/null +++ b/functions/serverConfigEntryPoints.test.js @@ -0,0 +1,230 @@ +const mockFirestoreAccess = jest.fn(); +const mockStripeConstructor = jest.fn(); +const mockMarkerUpdate = jest.fn(); +const mockEventGet = jest.fn(); +const mockMailAdd = jest.fn(); + +jest.mock('stripe', () => mockStripeConstructor); + +jest.mock('firebase-functions', () => { + class HttpsError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } + } + const https = { + HttpsError, + onCall: (handler) => handler, + onRequest: (handler) => handler, + }; + return { + firestore: { + document: () => ({ + onCreate: (handler) => handler, + onUpdate: (handler) => handler, + }), + }, + https, + runWith: () => ({ https }), + }; +}); + +jest.mock('firebase-admin', () => { + const firestore = { + FieldValue: { arrayUnion: (value) => value }, + collection: (name) => { + if (name === 'events') { + return { + doc: () => ({ get: mockEventGet }), + }; + } + if (name === 'mail') return { add: mockMailAdd }; + throw new Error(`Unexpected synthetic collection: ${name}`); + }, + }; + const firestoreFunction = () => { + mockFirestoreAccess(); + return firestore; + }; + firestoreFunction.FieldValue = firestore.FieldValue; + return { + apps: [{}], + firestore: firestoreFunction, + }; +}); + +jest.mock('firebase-admin/firestore', () => ({ + FieldValue: { arrayUnion: (value) => value }, + Timestamp: { now: () => ({ _seconds: 0 }) }, +})); + +function setValidTestConfig() { + process.env.ENVIRONMENT_NAME = 'test'; + process.env.SITE_ORIGIN = 'https://runmprc.test'; + process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; + process.env.STRIPE_SECRET_KEY = [ + 'sk', 'test', 'synthetic_entrypoint_key', + ].join('_'); +} + +function qualifyingUpdateChange() { + return { + before: { data: () => ({ status: 'pending' }) }, + after: { + data: () => ({ + status: 'paid', + runner: { email: 'runner@example.test' }, + }), + ref: { update: mockMarkerUpdate }, + }, + }; +} + +function qualifyingCreateSnapshot() { + return { + data: () => ({ + status: 'paid', + runner: { email: 'runner@example.test' }, + }), + ref: { update: mockMarkerUpdate }, + }; +} + +describe('server configuration entry-point guards', () => { + beforeEach(() => { + jest.resetModules(); + setValidTestConfig(); + mockFirestoreAccess.mockClear(); + mockStripeConstructor.mockReset(); + mockStripeConstructor.mockReturnValue({ synthetic: true }); + mockMarkerUpdate.mockReset(); + mockMarkerUpdate.mockResolvedValue(undefined); + mockEventGet.mockReset(); + mockEventGet.mockResolvedValue({ + exists: true, + id: 'race-1', + data: () => ({ + title: 'Synthetic Race', + slug: 'synthetic-race', + location: 'Synthetic Park', + }), + }); + mockMailAdd.mockReset(); + mockMailAdd.mockResolvedValue({ id: 'mail-synthetic-1' }); + }); + + afterAll(() => { + delete process.env.ENVIRONMENT_NAME; + delete process.env.SITE_ORIGIN; + delete process.env.STRIPE_LIVEMODE_EXPECTED; + delete process.env.STRIPE_SECRET_KEY; + }); + + test('admin order rejects invalid configuration before Firestore or Stripe', async () => { + delete process.env.STRIPE_SECRET_KEY; + const { adminOrderAction } = require('./adminOrderAction'); + + await expect(adminOrderAction({ + orderId: 'order-1', + action: 'refund_full', + }, { + auth: { uid: 'admin-1', token: { role: 'admin' } }, + })).rejects.toMatchObject({ + code: 'failed-precondition', + message: 'Server configuration is unavailable', + }); + + expect(mockFirestoreAccess).not.toHaveBeenCalled(); + expect(mockStripeConstructor).not.toHaveBeenCalled(); + }); + + test('qualifying registration update rejects invalid base config before mail work', async () => { + delete process.env.STRIPE_LIVEMODE_EXPECTED; + const { sendConfirmationEmail } = require('./sendConfirmationEmail'); + + await expect(sendConfirmationEmail( + qualifyingUpdateChange(), + { params: { eventId: 'race-1', regId: 'reg-1' } }, + )).rejects.toMatchObject({ + message: 'Server configuration is unavailable', + }); + + expect(mockFirestoreAccess).not.toHaveBeenCalled(); + expect(mockMarkerUpdate).not.toHaveBeenCalled(); + expect(mockStripeConstructor).not.toHaveBeenCalled(); + }); + + test('qualifying registration create rejects invalid base config before mail work', async () => { + delete process.env.SITE_ORIGIN; + const { sendConfirmationEmailOnCreate } = require('./sendConfirmationEmail'); + + await expect(sendConfirmationEmailOnCreate( + qualifyingCreateSnapshot(), + { params: { eventId: 'race-1', regId: 'reg-1' } }, + )).rejects.toMatchObject({ + message: 'Server configuration is unavailable', + }); + + expect(mockFirestoreAccess).not.toHaveBeenCalled(); + expect(mockMarkerUpdate).not.toHaveBeenCalled(); + expect(mockStripeConstructor).not.toHaveBeenCalled(); + }); + + test('irrelevant email trigger remains a no-op even when configuration is absent', async () => { + delete process.env.ENVIRONMENT_NAME; + const { sendConfirmationEmail } = require('./sendConfirmationEmail'); + const change = { + before: { data: () => ({ status: 'pending' }) }, + after: { data: () => ({ status: 'pending' }) }, + }; + + await expect(sendConfirmationEmail(change, { + params: { eventId: 'race-1', regId: 'reg-1' }, + })).resolves.toBeUndefined(); + + expect(mockFirestoreAccess).not.toHaveBeenCalled(); + }); + + test.each([ + ['update', 'sendConfirmationEmail', qualifyingUpdateChange], + ['create', 'sendConfirmationEmailOnCreate', qualifyingCreateSnapshot], + ])('valid config keeps the confirmation-email %s path working', async ( + _name, + exportName, + inputFactory, + ) => { + const emailFunctions = require('./sendConfirmationEmail'); + + await emailFunctions[exportName]( + inputFactory(), + { params: { eventId: 'race-1', regId: 'reg-1' } }, + ); + + expect(mockFirestoreAccess).toHaveBeenCalledTimes(2); + expect(mockEventGet).toHaveBeenCalledTimes(1); + expect(mockMailAdd).toHaveBeenCalledWith(expect.objectContaining({ + to: 'runner@example.test', + message: expect.objectContaining({ + text: expect.stringContaining( + 'https://runmprc.test/events/synthetic-race', + ), + }), + })); + expect(mockMarkerUpdate).toHaveBeenCalledTimes(1); + expect(mockStripeConstructor).not.toHaveBeenCalled(); + }); + + test('cached Stripe client never bypasses a later configuration failure', () => { + const { getStripe } = require('./stripeHelpers'); + + expect(getStripe()).toEqual({ synthetic: true }); + expect(getStripe()).toEqual({ synthetic: true }); + expect(mockStripeConstructor).toHaveBeenCalledTimes(1); + + delete process.env.ENVIRONMENT_NAME; + + expect(() => getStripe()).toThrow('Server configuration is unavailable'); + expect(mockStripeConstructor).toHaveBeenCalledTimes(1); + }); +}); diff --git a/functions/stripeHelpers.js b/functions/stripeHelpers.js index 43fb0c28..bbcc244f 100644 --- a/functions/stripeHelpers.js +++ b/functions/stripeHelpers.js @@ -2,6 +2,7 @@ const functions = require('firebase-functions'); const admin = require('firebase-admin'); const Stripe = require('stripe'); const crypto = require('crypto'); +const { loadCallableServerConfig } = require('./serverConfig'); const { Timestamp, FieldValue, @@ -11,14 +12,9 @@ const STRIPE_API_VERSION = '2023-10-16'; let _stripeClient = null; function getStripe() { + loadCallableServerConfig({ requireStripeKey: true }); if (_stripeClient) return _stripeClient; const secretKey = process.env.STRIPE_SECRET_KEY; - if (!secretKey) { - throw new functions.https.HttpsError( - 'failed-precondition', - 'Stripe secret key not configured', - ); - } _stripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION }); return _stripeClient; } diff --git a/functions/stripeWebhook.js b/functions/stripeWebhook.js index 7db2228f..d1bdf2fb 100644 --- a/functions/stripeWebhook.js +++ b/functions/stripeWebhook.js @@ -7,6 +7,11 @@ const { auditEntry, Timestamp, } = require('./stripeHelpers'); +const { + CONFIGURATION_ERROR_MESSAGE, + ServerConfigError, + loadServerConfig, +} = require('./serverConfig'); const LEDGER_RETENTION_DAYS = 90; const CHECKOUT_EVENT_TYPES = new Set([ @@ -52,15 +57,7 @@ function isSupportedEventType(type) { || DISPUTE_EVENT_TYPES.has(type); } -function validateExpectedLivemode(event) { - const configured = process.env.STRIPE_LIVEMODE_EXPECTED; - if (configured === undefined || configured === '') { - throw new Error('STRIPE_LIVEMODE_EXPECTED must be explicitly configured'); - } - if (configured !== 'true' && configured !== 'false') { - throw new Error('STRIPE_LIVEMODE_EXPECTED must be true or false'); - } - const expected = configured === 'true'; +function validateExpectedLivemode(event, expected) { if (event.livemode !== expected) { return { ok: false, expected, reason: 'livemode_mismatch' }; } @@ -1326,7 +1323,7 @@ function bindingMatchesTarget(snapshot, target) { return data.targetPath === target.ref.path && data.targetType === target.kind; } -async function processEvent(event) { +async function processEvent(event, expectedLivemode) { const db = admin.firestore(); const eventRef = db.collection('stripeEvents').doc(event.id); const existing = await eventRef.get(); @@ -1334,7 +1331,7 @@ async function processEvent(event) { return { duplicate: true, outcome: existing.data().outcome }; } - const modeValidation = validateExpectedLivemode(event); + const modeValidation = validateExpectedLivemode(event, expectedLivemode); const ownership = classifyMprcReference(event.data.object); const target = modeValidation.ok ? await resolveTarget(event) : null; if (target) await assertExclusiveProviderOwnership(event, target); @@ -1425,13 +1422,24 @@ exports.stripeWebhook = functions return; } + let webhookSecret; + try { + webhookSecret = getWebhookSecret(); + } catch { + console.error('Stripe webhook configuration unavailable', { + reason: 'webhook_secret_missing', + }); + response.status(500).send(CONFIGURATION_ERROR_MESSAGE); + return; + } + let event; try { const signature = request.get('stripe-signature'); event = Stripe.webhooks.constructEvent( request.rawBody, signature, - getWebhookSecret(), + webhookSecret, ); if (event.object !== 'event' || !isStripeId(event.id, 'evt_') @@ -1447,8 +1455,21 @@ exports.stripeWebhook = functions return; } + let serverConfig; + try { + serverConfig = loadServerConfig(); + } catch (error) { + console.error('Stripe webhook configuration unavailable', { + reason: error instanceof ServerConfigError + ? error.reason + : 'configuration_failed', + }); + response.status(500).send(CONFIGURATION_ERROR_MESSAGE); + return; + } + try { - const result = await processEvent(event); + const result = await processEvent(event, serverConfig.stripeLivemodeExpected); response.json({ received: true, ...result }); } catch { console.error('Stripe webhook handler error', { diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index 9e61409b..9cadb393 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -185,8 +185,11 @@ jest.mock('firebase-admin/firestore', () => { const admin = require('firebase-admin'); const WEBHOOK_SECRET = 'whsec_testsecret'; +process.env.ENVIRONMENT_NAME = 'test'; +process.env.SITE_ORIGIN = 'https://runmprc.test'; process.env.STRIPE_SECRET_KEY = 'sk_test_testing'; process.env.STRIPE_WEBHOOK_SECRET = WEBHOOK_SECRET; +process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; const { stripeWebhook } = require('./stripeWebhook'); @@ -331,7 +334,10 @@ describe('stripeWebhook', () => { beforeEach(() => { admin.__clear(); + process.env.ENVIRONMENT_NAME = 'test'; + process.env.SITE_ORIGIN = 'https://runmprc.test'; process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; + process.env.STRIPE_WEBHOOK_SECRET = WEBHOOK_SECRET; consoleError.mockClear(); }); @@ -346,15 +352,42 @@ describe('stripeWebhook', () => { )); expect(response.status).toHaveBeenCalledWith(500); + expect(response.send).toHaveBeenCalledWith('Server configuration is unavailable'); expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); expect(admin.__get('stripeEvents/evt_missing_mode_config')).toBeUndefined(); }); + test('reports a missing webhook secret as configuration failure before persistence', async () => { + delete process.env.STRIPE_WEBHOOK_SECRET; + seedRegistration(); + + const response = await deliver(stripeEvent( + 'evt_missing_webhook_secret', + 'checkout.session.completed', + registrationSession(), + )); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.send).toHaveBeenCalledWith('Server configuration is unavailable'); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_missing_webhook_secret')).toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith( + 'Stripe webhook configuration unavailable', + { reason: 'webhook_secret_missing' }, + ); + }); + afterAll(() => { consoleError.mockRestore(); + delete process.env.ENVIRONMENT_NAME; + delete process.env.SITE_ORIGIN; + delete process.env.STRIPE_LIVEMODE_EXPECTED; + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_WEBHOOK_SECRET; }); test('rejects a missing or invalid signature', async () => { + delete process.env.ENVIRONMENT_NAME; const event = stripeEvent( 'evt_bad_sig', 'checkout.session.completed', @@ -598,13 +631,13 @@ describe('stripeWebhook', () => { }); test('quarantines a configured livemode mismatch without changing the target', async () => { - process.env.STRIPE_LIVEMODE_EXPECTED = 'true'; seedRegistration(); const event = stripeEvent( 'evt_wrong_mode', 'checkout.session.completed', registrationSession(), ); + event.livemode = true; const response = await deliver(event); const registration = admin.__get('events/race-1/registrations/reg-1');