From c4d09125f046f068c817bb40c5311e6976abdbb1 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 05:40:44 -0700 Subject: [PATCH] fix: enforce server-only commerce admission --- GITHUB_ISSUES.md | 12 +- GITHUB_ISSUE_SLICES.md | 5 +- IMPLEMENTATION_PLAN.md | 2 +- OPERATIONS_RUNBOOK.md | 33 +- SECURITY.md | 2 +- STRIPE_COMMERCE_DESIGN.md | 17 + SYSTEM_DESIGN.md | 13 +- docs/officers/EMERGENCY_AND_RECOVERY.md | 41 +- docs/officers/PUBLISH_AND_CHECK.md | 1 + functions/.env.example | 2 + functions/adminOrderAction.js | 18 +- functions/adminRegistrationAction.js | 40 +- functions/adminRegistrationAction.test.js | 2 + functions/checkoutPromotionPolicy.test.js | 236 +++++++++++ functions/commerceControl.js | 175 ++++++++ functions/commerceControl.test.js | 256 ++++++++++++ functions/commerceControlEntryPoints.test.js | 375 ++++++++++++++++++ functions/createCheckoutSession.js | 23 +- functions/createMerchCheckout.js | 23 +- functions/serverConfig.js | 29 +- functions/serverConfig.test.js | 50 +++ functions/serverConfigEntryPoints.test.js | 3 + functions/stripeHelpers.js | 5 +- functions/stripeWebhook.test.js | 24 ++ tests/firestore-rules/events.test.js | 2 + tests/firestore-rules/shop-and-locked.test.js | 29 ++ 26 files changed, 1371 insertions(+), 47 deletions(-) create mode 100644 functions/commerceControl.js create mode 100644 functions/commerceControl.test.js create mode 100644 functions/commerceControlEntryPoints.test.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 11f3c86b..48426968 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 | CONFIG-001A tracked in [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149); CONFIG-001B proposed; not deployed | CI-001A recommended | +| 5 | CONFIG-001 | Fail closed on server environment and commerce configuration | P0 | M | CONFIG-001A merged in #149/PR #150; CONFIG-001B1 tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151); B2 blocked; 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,14 +274,16 @@ 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:** 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. +**Status:** CONFIG-001A source/tests merged through [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) / PR #150 as `2c62b8dd`; Firebase and Stripe configuration remain unproven. CONFIG-001B1 source enforcement is tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151). CONFIG-001B2 remains blocked on a safe operator path, command/provider-object recovery, and protected deployment. **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. +The assessment found production-origin fallbacks, a webhook mode default, and an unenforced `COMMERCE_ENABLED`. CONFIG-001A removes the origin/mode/key fail-open source behavior. CONFIG-001B1 adds the read-only command-enforcement layer, but an audited no-deploy operator control and recovery drill still do not exist. Missing/mistyped deployment or runtime controls must never enable commerce. -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. +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 is split because source enforcement is not the same as an operable emergency procedure. B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) owns the exact deploy ceiling, fresh server-only runtime admission read, current command matrix, browser-deny proof, and honest in-flight boundary. B2 owns the protected audited writer, operator drill, existing Stripe-object recovery, deployment, and provider proof. Do not claim B1 alone is an officer-usable kill switch. ### Scope @@ -310,7 +312,7 @@ CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) ow ### Agent handoff -Implement as CONFIG-001A/001B from `GITHUB_ISSUE_SLICES.md`. Never print or pattern-match an entire secret; use provider-safe key-mode metadata/prefix only where documented. External parameter/Secret Manager changes need an authorized owner. +Implement only the live CONFIG child claimed in `GITHUB_ISSUE_SLICES.md`. Never print or pattern-match an entire secret; use provider-safe key-mode metadata/prefix only where documented. External parameters, runtime-control mutation, Secret Manager, deployment, and provider changes need their authorized owner issue. --- diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index fc3ce0c5..69e619e6 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -47,8 +47,9 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | -| 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. | +| CONFIG-001A | source/tests merged through [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) / PR #150 as `2c62b8dd`; 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-001B1 | source/tests tracked in live [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151); CONFIG-001A; not deployed/configured | Require an exact deploy-time ceiling; false denies new commerce before Firestore. Commands passing it and every refund use a fresh server-only global/domain/resource admission read. Keep webhook/mail independent; missing controls and resource flags mean disabled. | Paid/free/volunteer, merchandise, comp, late-registration, and refund allow/deny tests prove fixed failures and no command side effect; Rules tests deny every browser role; continuity and race-boundary tests pass. | +| CONFIG-001B2 | blocked owner/operator; B1, AUTH-003/ADMIN-001, PAY-002/PAY-004/PAY-005, CI-001 | Add a recent-auth capability-protected writer, append-only audit, active-provider-object recovery, protected deployment, and private disable/restore drill. | Named owners safely disable and restore synthetic staging commerce without code changes; in-flight and existing Stripe objects are reconciled; private provider/Firebase evidence is recorded. | | 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. | | PAY-001B | proposed; PAY-001A | Apply race/volunteer/free schemas, validate server event custom fields/options, and persist price/waiver snapshots. | Positive/hostile dynamic-field and snapshot tests. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index e14b39b6..03294cdd 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. 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. +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 merged through [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) / PR #150 as `2c62b8dd`, but remains undeployed. CONFIG-001B1 is tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) for fail-closed read-only admission; B2 retains the protected audited operator control, provider-object recovery, and drill. Follow the live issues and do not duplicate these slices. 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 2208c11d..957e21bc 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -14,7 +14,7 @@ Complete this table in the private operations system before live payments. Do no | Area | Primary role | Backup role | Required access | | --- | --- | --- | --- | -| Incident commander | Platform lead | Club president/designee | Checkout kill switch, status communication | +| Incident commander | Platform lead | Club president/designee | Commerce containment coordination and status communication; officer switch not available yet | | Stripe account and payouts | Treasurer | Finance backup | Stripe finance/admin with MFA | | Refunds and disputes | Finance admin | Treasurer | Stripe refunds/disputes; scoped MPRC finance capability | | Race operations | Race director | Registrar backup | Event/roster capability; no payout settings | @@ -30,15 +30,30 @@ Review access quarterly, when an officer changes, and after every incident. Remo ## 2. Emergency controls -Before launch, implement and test a checkout kill switch that does not require a code deployment. Recommended controls: +### Commerce pause — B1 source only, operator control NOT AVAILABLE YET -- `commerceEnabled=false` globally in server-controlled configuration. -- Per-event `checkoutEnabled=false` and per-product/variant non-sellable status. -- Server functions check the switch before reserving capacity/inventory or calling Stripe. -- The public site displays a neutral maintenance message and support path. -- Existing paid records and webhook/reconciliation processing continue while new Sessions are disabled. +CONFIG-001B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds the read-only server enforcement layer. It does not deploy that layer, create its runtime record, or provide a safe way for an officer to change it. -Changing only the UI is not a kill switch. Existing Checkout Sessions may remain payable; incident procedures must also expire active Sessions when required. +```mermaid +flowchart LR + Command["New signup, shop, comp, late add, or refund"] --> Typed["Check typed deploy settings"] + Typed --> Runtime["Read server-only runtime control fresh"] + Runtime --> Resource["Check event or product when required"] + Resource --> Decision{"This command admitted?"} + Decision -- "No" --> Stop["Stop before command side effects"] + Decision -- "Yes" --> Existing["Continue existing validation and command logic"] + Webhook["Signed Stripe webhook"] --> Continue["Continue payment evidence processing"] +``` + +Text alternative: new commerce and incident refunds use separate server decisions; a pause stops newly denied commands, while signed webhook processing continues without reading the pause control. + +The exact server-only `systemConfig/commerce` version-1 record contains a safe integer revision plus four booleans: global new commerce, race registration, merchandise checkout, and incident refunds. New race work also requires `events/{id}.checkoutEnabled=true`. New shop work also requires `products/{id}.checkoutEnabled=true`. Missing/malformed documents or missing resource fields mean disabled. Browser visitors, members, and browser admins cannot read/write this control or set those resource fields. + +`COMMERCE_ENABLED` is a deploy-time ceiling. It is not the no-deploy emergency switch. Keep it false during rollout. A false ceiling denies new commerce before a Firestore read. New-commerce requests that pass the ceiling, and every refund request, read the runtime record fresh without caching. + +Changing only the UI is never a kill switch. A request admitted immediately before a pause can finish, and an existing Stripe Session or Payment Link can remain payable. The current local Cancel action does not expire Stripe. PAY-002/PAY-004 must add command drain and provider-object expiry. No reconciliation job exists yet; PAY-005/#106 own it. + +CONFIG-001B2 must add a capability-protected, recent-auth, audited operator path and a private staging disable/restore drill. Until then, officers must use the stop-and-escalate steps below. Do not edit Firebase data, environment values, or Stripe objects from this guide. ## 3. Environment inventory @@ -114,7 +129,7 @@ flowchart TD 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. +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. CONFIG-001B1 requires exact `COMMERCE_ENABLED` only on those four command handlers and keeps webhook/mail independent. Its runtime enforcement remains source-only; the CONFIG-001B2 operator control and drill are **NOT AVAILABLE YET**. 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. diff --git a/SECURITY.md b/SECURITY.md index 86b7fd6c..b78249b7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -99,7 +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. | +| 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. | | 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 103a95a2..a40db597 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -51,6 +51,22 @@ Never let a development build fall back to production Functions. Never share a w 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. +### Commerce admission and incident continuity + +CONFIG-001B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds a source-only command admission matrix. It does not create an officer control or enable commerce. + +| Operation | Deploy ceiling | Runtime control | Resource control | Continues during new-commerce pause | +| --- | --- | --- | --- | --- | +| Race paid/free/volunteer, comp, late registration | `COMMERCE_ENABLED=true` | global + race on | event `checkoutEnabled=true` | No | +| Merchandise checkout | `COMMERCE_ENABLED=true` | global + merchandise on | product `checkoutEnabled=true` | No | +| Registration/order refund | valid typed config; ceiling may be false | incident refunds on | Existing record rules remain | Yes | +| Signed Stripe webhook | None from this switch | None | Existing verified transition rules | Yes | +| Confirmation mail | None from this switch | None | Existing eligibility rules | Yes | + +The runtime document is the exact versioned server-only `systemConfig/commerce` record. A false deploy ceiling denies new commerce before a Firestore read. New-commerce commands that pass that ceiling, and every refund command, read the runtime record fresh. Missing/malformed control or a missing resource flag means disabled; no browser role can read/write the control or set the resource flag. Unknown future command domains deny until explicitly integrated. + +The read is the admission point. It cannot cancel a Stripe request already in flight or make an existing Session/Payment Link unpayable. PAY-002/PAY-004 own command journals and provider-object expiry. The current local `cancel` action also does not expire Stripe. CONFIG-001B2 must add the protected audited writer and a private staging drill before this can be called an operational kill switch. + ### Secret inventory | Name | Consumer | Storage | Notes | @@ -58,6 +74,7 @@ CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) im | `STRIPE_SECRET_KEY` | Checkout, refund, reconciliation functions | Google Secret Manager, bound only to those functions | Prefer the narrowest key Stripe supports for the required API resources; never expose it to React. | | `STRIPE_WEBHOOK_SECRET` | Webhook ingress only | Google Secret Manager | Unique per event destination and environment. | | `STRIPE_LIVEMODE_EXPECTED` | Webhook/reconciler | Non-secret environment parameter | Explicit `true` or `false`; fail deployment/startup if absent. | +| `COMMERCE_ENABLED` | Current checkout/admin commerce commands | Non-secret environment parameter | Exact deploy-time ceiling; not the no-deploy runtime switch and never consulted by webhook/mail. | | `SITE_ORIGIN` | Checkout and email links | Validated environment parameter | Exact allowlisted HTTPS origin in hosted environments. | | `REACT_APP_RECAPTCHA_SITE_KEY` | Browser App Check | Build environment | Public site key; not a secret. Use a separate key per environment. | | `STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` | Strava functions | Secret Manager | Separate from Stripe and bound only to Strava functions. | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 7ea15dd1..ec817e75 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -160,6 +160,7 @@ flowchart TB subgraph Data["Data boundary"] Firestore["Firestore domain records"] + CommerceControl["Server-only commerce admission\nsystemConfig/commerce"] EventInbox["Stripe event inbox"] Audit["Append-oriented audit events"] Secrets["Google Secret Manager"] @@ -177,6 +178,8 @@ flowchart TB Web --> AdminAPI PublicAPI --> Firestore AdminAPI --> Firestore + PublicAPI --> CommerceControl + AdminAPI --> CommerceControl PublicAPI --> StripeAPI AdminAPI --> StripeAPI StripeAPI --> Checkout @@ -192,6 +195,8 @@ flowchart TB FirebaseAuth --> Claims ``` +Text alternative: checkout and refund commands read both domain records and the server-only commerce control; the signed webhook path does not depend on that control and continues processing payment evidence. + ## 5. Trust boundaries and authorization | Boundary | Trusted assertions | Never trust directly | @@ -247,6 +252,8 @@ The first release may continue using `admin` in the UI, but server endpoints and | `orders.paymentStatus` and `orders.fulfillmentStatus` | Separate money state from physical fulfillment state | | `registrations.paymentStatus` and `registrations.registrationStatus` | Separate payment lifecycle from attendance/transfer/cancellation lifecycle | | `retentionJobs/{jobId}` | Optional operational record of scheduled minimization/deletion work | +| `systemConfig/commerce` | Versioned global/domain command admission; browser read/write denied; protected writer is not available yet | +| `events/{id}.checkoutEnabled` and `products/{id}.checkoutEnabled` | Explicit server-owned resource admission; missing means disabled | Large `auditLog` arrays on registration and order documents should be replaced before they approach Firestore document-size and write-contention limits. New audit data should be append-oriented. @@ -403,7 +410,11 @@ 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. +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-001B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds a second source boundary for current commands. A false deploy ceiling denies new race, free/volunteer, merchandise, comp, and late-registration work before Firestore. Commands that pass it then require the fresh versioned `systemConfig/commerce` global/domain decision and an explicit resource flag. Registration/order refunds always read the separate incident-refund decision, so disabling new commerce does not silently disable response work. Missing or malformed controls mean disabled. Browser clients cannot read the global document or set resource admission fields. Webhook and confirmation mail deliberately have no edge to this control. + +The admission read is a clear linearization point, not a distributed lock. A command admitted on revision N can finish after revision N+1 disables new work, and an already-open Stripe Session or Payment Link can remain payable. PAY-002/PAY-004 own command journals, drain/expiry, and provider-object recovery. CONFIG-001B2 and #105/#133/#136 own the protected writer, drill, deployment, and provider evidence; none is made live by B1 source. ## 11. Security, privacy, and compliance posture diff --git a/docs/officers/EMERGENCY_AND_RECOVERY.md b/docs/officers/EMERGENCY_AND_RECOVERY.md index 3331bbc0..4fa35d15 100644 --- a/docs/officers/EMERGENCY_AND_RECOVERY.md +++ b/docs/officers/EMERGENCY_AND_RECOVERY.md @@ -31,11 +31,50 @@ | Account exists but verification email request failed | Ask the member to stop resending. Record only the time, public `/login` URL, and plain status. Do not ask for the email address, password, code, action link, mailbox access, or screenshot. | | Profile Save says permission is missing | Ask the member to stop, sign out, and wait. Open a new redacted incident through [Request a change](./REQUEST_A_CHANGE.md). Do not edit the database, delete the login, recreate the account, or grant a role. | | Private member information is visible | Contact platform and privacy owners immediately; they choose containment through an approved service procedure. Do not change permissions yourself. | -| Unexpected payment, refund, order, or signup | Contact treasurer and platform owners. There is no proven no-code payment kill switch. Do not test with real money. | +| Unexpected payment, refund, order, or signup | Contact treasurer and platform owners. The new source guard is not deployed and its officer control is NOT AVAILABLE YET. Do not test with real money. | | Password, secret, or recovery code exposed | Contact the owning service's two approved owners. They revoke/rotate through the service-specific procedure and record evidence. Do not rotate it from this generic guide. | 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. +### Pause new commerce — NOT AVAILABLE YET + +**Status:** [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds source checks only. There is no approved officer button or procedure for changing the server control. Do not edit Firebase, Stripe, or release settings yourself. + +**Purpose:** stop unsafe retries and reach the money and platform owners without making the incident larger. + +**Approver:** treasurer plus platform owner. A future control change also needs the protected approver named by CONFIG-001B2. + +**Prerequisites:** the public page, the time, and a plain description with names, emails, order details, payment details, and codes removed. + +```mermaid +flowchart LR + Seen["Unexpected signup, shop, or refund result"] --> Stop["Stop clicking and do not use real money"] + Stop --> Record["Record public page, time, and plain symptom"] + Record --> Owners["Contact treasurer and platform owner"] + Owners --> Wait["Wait for protected source, Firebase, and Stripe proof"] +``` + +In words: stop, record only safe public facts, contact both owners, and wait for separate system checks. + +1. Stop checkout, signup, late-add, comp, and refund attempts. +2. Do not retry with a real card or real member account. +3. Record the public page and time. +4. Record only the plain symptom. +5. Contact the treasurer. +6. Contact the platform owner. +7. Tell them whether the page showed **Commerce is temporarily unavailable**. +8. Wait for separate Firebase and Stripe checks. + +**Expected result:** no new officer-initiated command is attempted, no private or payment data is copied, and both owners coordinate containment. + +**Stop conditions:** anyone asks you to edit a database record, environment value, Stripe Session, Payment Link, payment state, or secret; anyone suggests a real payment test; either owner is unavailable; or the affected Stripe objects are unknown. + +**Success proof:** exact source commit, exact protected Firebase release, readback of the server control without its values in public evidence, a made-up staging command that is denied, signed webhook continuity, and separate Stripe confirmation about any already-open test objects. A green workflow alone is not proof. + +**Undo:** do not restore commerce because one page looks normal. The same owners must approve a protected restore after reconciliation and observation. Use a reviewed safe roll-forward; never revert to code without the server guard while the deploy ceiling is on. + +**Escalation:** treasurer plus platform owner; add the security owner for an unauthorized command and the privacy owner if member information appeared. + ### Account created but verification request failed **Status:** the truthful two-result screen in [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) is **SOURCE ONLY, NOT LIVE** until its exact website revision is published and verified. diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index 4c53faff..6bd39948 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -57,6 +57,7 @@ As of **2026-07-13**: - Future source stops writing that Pages domain claim. Only provider readback after #136/WEB-001 can prove it cleared. - 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. +- CONFIG-001B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds source enforcement for a server-only commerce pause. It is not in the fixed profile-recovery release plan, is not deployed, and has no approved officer control. A future reviewed plan must deploy the complete guarded Function set with the deploy ceiling and every runtime/resource flag off, then prove signed webhooks still work. Do not widen the current plan by hand. ### Commerce server safety gate — SOURCE ONLY, NOT DEPLOYED diff --git a/functions/.env.example b/functions/.env.example index 84119548..20b3c046 100644 --- a/functions/.env.example +++ b/functions/.env.example @@ -8,6 +8,8 @@ # staging = https://dev.runmprc.com + Stripe test mode # production = https://runmprc.com + Stripe live mode # There is no production-origin fallback. +# COMMERCE_ENABLED is a deploy-time ceiling for new commerce. Keep it false. +# The separate server-only runtime control also defaults to disabled. ENVIRONMENT_NAME=local SITE_ORIGIN=http://localhost:3000 STRIPE_LIVEMODE_EXPECTED=false diff --git a/functions/adminOrderAction.js b/functions/adminOrderAction.js index f4c42785..b68404f7 100644 --- a/functions/adminOrderAction.js +++ b/functions/adminOrderAction.js @@ -9,6 +9,10 @@ const { Timestamp, } = require('./stripeHelpers'); const { loadCallableServerConfig } = require('./serverConfig'); +const { + COMMERCE_OPERATIONS, + requireCommerceAdmission, +} = require('./commerceControl'); const ACTIONS = new Set([ 'mark_fulfilled', @@ -28,7 +32,10 @@ exports.adminOrderAction = functions .https.onCall(async (data, context) => { requireAppCheck(context); await requireAdmin(context); - loadCallableServerConfig({ requireStripeKey: true }); + const serverConfig = loadCallableServerConfig({ + requireStripeKey: true, + requireCommerceCeiling: true, + }); const { orderId, action, payload = {} } = data || {}; if (!orderId) { @@ -38,6 +45,15 @@ exports.adminOrderAction = functions throw new functions.https.HttpsError('invalid-argument', `Unknown action: ${action}`); } + const db = admin.firestore(); + if (action === 'refund_full' || action === 'refund_partial') { + await requireCommerceAdmission({ + db, + operation: COMMERCE_OPERATIONS.INCIDENT_REFUND, + deploymentEnabled: serverConfig.commerceEnabled, + }); + } + const ref = orderRef(orderId); const snap = await ref.get(); if (!snap.exists) { diff --git a/functions/adminRegistrationAction.js b/functions/adminRegistrationAction.js index 11a82993..3e1f7dfe 100644 --- a/functions/adminRegistrationAction.js +++ b/functions/adminRegistrationAction.js @@ -11,6 +11,10 @@ const { Timestamp, } = require('./stripeHelpers'); const { loadCallableServerConfig } = require('./serverConfig'); +const { + COMMERCE_OPERATIONS, + requireCommerceAdmission, +} = require('./commerceControl'); const ACTIONS = new Set([ 'refund_full', @@ -156,7 +160,7 @@ async function markComp({ eventId, registration, actor }) { } async function addLateRegistration({ - stripe, eventId, registration, actor, siteOrigin, + stripe, eventId, registration, actor, siteOrigin, eventSnap, }) { if (!registration || !isValidEmail(registration.runner?.email)) { throw new functions.https.HttpsError('invalid-argument', 'registration.runner.email required'); @@ -164,11 +168,6 @@ async function addLateRegistration({ const amountCents = Number(registration.amountCents) || 0; const priceTier = registration.priceTier || 'nonMember'; - const eventSnap = await admin.firestore() - .collection('events').doc(eventId).get(); - if (!eventSnap.exists) { - throw new functions.https.HttpsError('not-found', 'Event not found'); - } const event = eventSnap.data(); const newRef = admin.firestore() @@ -262,7 +261,10 @@ exports.adminRegistrationAction = functions .https.onCall(async (data, context) => { requireAppCheck(context); await requireAdmin(context); - const serverConfig = loadCallableServerConfig({ requireStripeKey: true }); + const serverConfig = loadCallableServerConfig({ + requireStripeKey: true, + requireCommerceCeiling: true, + }); const { eventId, registrationId, action, payload = {}, @@ -278,12 +280,27 @@ exports.adminRegistrationAction = functions uid: context.auth.uid, email: context.auth.token?.email || null, }; + const db = admin.firestore(); // Actions that don't need an existing registration doc if (action === 'mark_comp') { + const eventRef = db.collection('events').doc(eventId); + await requireCommerceAdmission({ + db, + operation: COMMERCE_OPERATIONS.RACE_REGISTRATION, + deploymentEnabled: serverConfig.commerceEnabled, + targetRef: eventRef, + }); return markComp({ eventId, registration: payload.registration, actor }); } if (action === 'add_late_registration') { + const eventRef = db.collection('events').doc(eventId); + const { targetSnapshot: eventSnap } = await requireCommerceAdmission({ + db, + operation: COMMERCE_OPERATIONS.RACE_REGISTRATION, + deploymentEnabled: serverConfig.commerceEnabled, + targetRef: eventRef, + }); const stripe = getStripe(); return addLateRegistration({ stripe, @@ -291,12 +308,21 @@ exports.adminRegistrationAction = functions registration: payload.registration, actor, siteOrigin: serverConfig.siteOrigin, + eventSnap, }); } if (!registrationId) { throw new functions.https.HttpsError('invalid-argument', 'registrationId required'); } + if (action === 'refund_full' || action === 'refund_partial') { + await requireCommerceAdmission({ + db, + operation: COMMERCE_OPERATIONS.INCIDENT_REFUND, + deploymentEnabled: serverConfig.commerceEnabled, + }); + } + const ref = regRef(eventId, registrationId); const snap = await ref.get(); if (!snap.exists) { diff --git a/functions/adminRegistrationAction.test.js b/functions/adminRegistrationAction.test.js index 0b0e5033..d9c32b66 100644 --- a/functions/adminRegistrationAction.test.js +++ b/functions/adminRegistrationAction.test.js @@ -49,6 +49,7 @@ describe('adminRegistrationAction authorization', () => { process.env.ENVIRONMENT_NAME = 'test'; process.env.SITE_ORIGIN = 'https://runmprc.test'; process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; + process.env.COMMERCE_ENABLED = 'true'; process.env.STRIPE_SECRET_KEY = [ 'sk', 'test', 'synthetic_admin_registration', ].join('_'); @@ -61,6 +62,7 @@ describe('adminRegistrationAction authorization', () => { delete process.env.ENVIRONMENT_NAME; delete process.env.SITE_ORIGIN; delete process.env.STRIPE_LIVEMODE_EXPECTED; + delete process.env.COMMERCE_ENABLED; delete process.env.STRIPE_SECRET_KEY; }); diff --git a/functions/checkoutPromotionPolicy.test.js b/functions/checkoutPromotionPolicy.test.js index 97fe59ff..f111513c 100644 --- a/functions/checkoutPromotionPolicy.test.js +++ b/functions/checkoutPromotionPolicy.test.js @@ -82,6 +82,7 @@ jest.mock('firebase-admin', () => { const firestore = { collection: (name) => new CollectionReference(name), + getAll: async (...refs) => refs.map((ref) => new Snapshot(ref)), }; return { @@ -96,6 +97,7 @@ jest.mock('firebase-admin', () => { orderSequence = 0; }, __seed: (path, data) => store.set(path, { ...data }), + __delete: (path) => store.delete(path), __get: (path) => store.get(path), }; }); @@ -127,6 +129,14 @@ const { createMerchCheckout } = require('./createMerchCheckout'); describe('Checkout promotion policy', () => { beforeEach(() => { admin.__clear(); + admin.__seed('systemConfig/commerce', { + schemaVersion: 1, + revision: 1, + newCommerceEnabled: true, + raceRegistrationEnabled: true, + merchandiseCheckoutEnabled: true, + incidentRefundsEnabled: true, + }); mockCheckoutCreate.mockReset(); mockProductCreate.mockReset(); mockGetStripe.mockReset(); @@ -144,6 +154,7 @@ describe('Checkout promotion policy', () => { process.env.ENVIRONMENT_NAME = 'test'; process.env.SITE_ORIGIN = 'https://runmprc.test'; process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; + process.env.COMMERCE_ENABLED = 'true'; process.env.STRIPE_SECRET_KEY = [ 'sk', 'test', 'synthetic_checkout_policy', ].join('_'); @@ -153,6 +164,7 @@ describe('Checkout promotion policy', () => { delete process.env.ENVIRONMENT_NAME; delete process.env.SITE_ORIGIN; delete process.env.STRIPE_LIVEMODE_EXPECTED; + delete process.env.COMMERCE_ENABLED; delete process.env.STRIPE_SECRET_KEY; }); @@ -200,8 +212,75 @@ describe('Checkout promotion policy', () => { expect(mockCheckoutCreate).not.toHaveBeenCalled(); }); + test('deployment ceiling blocks new race commerce before command side effects', async () => { + process.env.COMMERCE_ENABLED = 'false'; + admin.__seed('events/race-1', { + checkoutEnabled: true, + title: 'Synthetic Race', + status: 'open', + pricing: { nonMemberCents: 5000 }, + stripeProductId: 'prod_race_policy', + }); + + await expect(createCheckoutSession({ + eventId: 'race-1', + runner: { + firstName: 'Test', + lastName: 'Runner', + email: 'runner@example.test', + }, + priceTier: 'nonMember', + acceptedWaiver: true, + }, { auth: null, rawRequest: {} })).rejects.toMatchObject({ + code: 'failed-precondition', + message: 'Commerce is temporarily unavailable', + }); + + expect(mockRateLimit).not.toHaveBeenCalled(); + expect(mockGetStripe).not.toHaveBeenCalled(); + expect(mockProductCreate).not.toHaveBeenCalled(); + expect(mockCheckoutCreate).not.toHaveBeenCalled(); + }); + + test.each([ + ['missing', undefined], + ['malformed', { schemaVersion: 1, revision: 1 }], + ])('%s runtime control blocks merchandise before command side effects', async ( + _name, + runtimeControl, + ) => { + if (runtimeControl) { + admin.__seed('systemConfig/commerce', runtimeControl); + } else { + admin.__delete('systemConfig/commerce'); + } + admin.__seed('products/hat', { + checkoutEnabled: true, + title: 'Synthetic Hat', + status: 'active', + priceCents: 2000, + }); + + await expect(createMerchCheckout({ + productSlug: 'hat', + buyer: { + firstName: 'Test', + lastName: 'Buyer', + email: 'buyer@example.test', + }, + }, { auth: null, rawRequest: {} })).rejects.toMatchObject({ + code: 'failed-precondition', + message: 'Commerce is temporarily unavailable', + }); + + expect(mockRateLimit).not.toHaveBeenCalled(); + expect(mockGetStripe).not.toHaveBeenCalled(); + expect(mockCheckoutCreate).not.toHaveBeenCalled(); + }); + test('race checkout sends the exact disabled-adjustment payload', async () => { admin.__seed('events/race-1', { + checkoutEnabled: true, title: 'Synthetic Race', description: 'Synthetic test event', slug: 'synthetic-race', @@ -261,8 +340,78 @@ describe('Checkout promotion policy', () => { expect(mockProductCreate).not.toHaveBeenCalled(); }); + test('admitted volunteer signup remains free and does not call Stripe', async () => { + admin.__seed('events/race-1', { + checkoutEnabled: true, + title: 'Synthetic Race', + slug: 'synthetic-race', + status: 'open', + volunteerEnabled: true, + waiverVersion: 'synthetic-v1', + }); + + const result = await createCheckoutSession({ + eventId: 'race-1', + runner: { + firstName: 'Test', + lastName: 'Volunteer', + email: 'volunteer@example.test', + }, + acceptedWaiver: true, + signupType: 'volunteer', + }, { auth: null, rawRequest: {} }); + + expect(result).toMatchObject({ + free: true, + registrationId: 'reg-new-1', + }); + expect(admin.__get('events/race-1/registrations/reg-new-1')).toMatchObject({ + signupType: 'volunteer', + status: 'paid', + amountCents: 0, + }); + expect(mockGetStripe).not.toHaveBeenCalled(); + expect(mockProductCreate).not.toHaveBeenCalled(); + expect(mockCheckoutCreate).not.toHaveBeenCalled(); + }); + + test('admitted zero-price participant signup remains free and does not call Stripe', async () => { + admin.__seed('events/race-1', { + checkoutEnabled: true, + title: 'Synthetic Free Race', + slug: 'synthetic-free-race', + status: 'open', + pricing: { nonMemberCents: 0 }, + waiverVersion: 'synthetic-v1', + }); + + const result = await createCheckoutSession({ + eventId: 'race-1', + runner: { + firstName: 'Test', + lastName: 'Runner', + email: 'runner@example.test', + }, + priceTier: 'nonMember', + acceptedWaiver: true, + }, { auth: null, rawRequest: {} }); + + expect(result).toMatchObject({ + free: true, + registrationId: 'reg-new-1', + }); + expect(admin.__get('events/race-1/registrations/reg-new-1')).toMatchObject({ + signupType: 'participant', + status: 'paid', + amountCents: 0, + }); + expect(mockGetStripe).not.toHaveBeenCalled(); + expect(mockCheckoutCreate).not.toHaveBeenCalled(); + }); + test('merchandise checkout sends the exact disabled-adjustment payload', async () => { admin.__seed('products/hat', { + checkoutEnabled: true, title: 'Synthetic Hat', description: 'Synthetic test product', slug: 'hat', @@ -324,4 +473,91 @@ describe('Checkout promotion policy', () => { }); expect(mockProductCreate).not.toHaveBeenCalled(); }); + + test.each([ + ['paid race while the global switch is off', createCheckoutSession, { + eventId: 'race-1', + runner: { + firstName: 'Test', + lastName: 'Runner', + email: 'runner@example.test', + }, + priceTier: 'nonMember', + acceptedWaiver: true, + }, 'events/race-1', { + checkoutEnabled: true, + status: 'open', + pricing: { nonMemberCents: 5000 }, + }, { newCommerceEnabled: false }], + ['zero-price participant while the global switch is off', createCheckoutSession, { + eventId: 'race-1', + runner: { + firstName: 'Test', + lastName: 'Runner', + email: 'runner@example.test', + }, + priceTier: 'nonMember', + acceptedWaiver: true, + }, 'events/race-1', { + checkoutEnabled: true, + status: 'open', + pricing: { nonMemberCents: 0 }, + }, { newCommerceEnabled: false }], + ['free volunteer while the event switch is off', createCheckoutSession, { + eventId: 'race-1', + runner: { + firstName: 'Test', + lastName: 'Volunteer', + email: 'volunteer@example.test', + }, + acceptedWaiver: true, + signupType: 'volunteer', + }, 'events/race-1', { + checkoutEnabled: false, + status: 'open', + volunteerEnabled: true, + }, {}], + ['merchandise while the merchandise domain is off', createMerchCheckout, { + productSlug: 'hat', + buyer: { + firstName: 'Test', + lastName: 'Buyer', + email: 'buyer@example.test', + }, + }, 'products/hat', { + checkoutEnabled: true, + status: 'active', + priceCents: 2000, + }, { merchandiseCheckoutEnabled: false }], + ])('blocks %s before command side effects', async ( + _name, + handler, + data, + targetPath, + targetData, + controlPatch, + ) => { + admin.__seed(targetPath, targetData); + admin.__seed('systemConfig/commerce', { + schemaVersion: 1, + revision: 2, + newCommerceEnabled: true, + raceRegistrationEnabled: true, + merchandiseCheckoutEnabled: true, + incidentRefundsEnabled: true, + ...controlPatch, + }); + + await expect(handler(data, { auth: null, rawRequest: {} })).rejects.toMatchObject({ + code: 'failed-precondition', + message: 'Commerce is temporarily unavailable', + }); + + expect(mockRateLimit).not.toHaveBeenCalled(); + expect(mockGetStripe).not.toHaveBeenCalled(); + expect(mockProductCreate).not.toHaveBeenCalled(); + expect(mockCheckoutCreate).not.toHaveBeenCalled(); + expect(admin.__get('events/race-1/registrations/reg-new-1')).toBeUndefined(); + expect(admin.__get('orders/order-new-1')).toBeUndefined(); + }); }); diff --git a/functions/commerceControl.js b/functions/commerceControl.js new file mode 100644 index 00000000..3d31bd66 --- /dev/null +++ b/functions/commerceControl.js @@ -0,0 +1,175 @@ +const functions = require('firebase-functions'); + +const COMMERCE_CONTROL_MESSAGE = 'Commerce is temporarily unavailable'; +const COMMERCE_CONTROL_PATH = 'systemConfig/commerce'; +const COMMERCE_OPERATIONS = Object.freeze({ + RACE_REGISTRATION: 'race_registration', + MERCHANDISE_CHECKOUT: 'merchandise_checkout', + INCIDENT_REFUND: 'incident_refund', +}); +const CONTROL_FIELDS = Object.freeze([ + 'incidentRefundsEnabled', + 'merchandiseCheckoutEnabled', + 'newCommerceEnabled', + 'raceRegistrationEnabled', + 'revision', + 'schemaVersion', +]); + +class CommerceControlError extends Error { + constructor(reason) { + super(COMMERCE_CONTROL_MESSAGE); + Object.defineProperty(this, 'name', { + value: 'CommerceControlError', + enumerable: false, + }); + Object.defineProperty(this, 'reason', { + value: reason, + enumerable: false, + }); + Error.captureStackTrace?.(this, CommerceControlError); + } +} + +function reject(reason) { + throw new CommerceControlError(reason); +} + +/** + * Parse the exact versioned server-only runtime control document. + * + * @param {unknown} value + * @returns {Readonly<{ + * schemaVersion: 1, + * revision: number, + * newCommerceEnabled: boolean, + * raceRegistrationEnabled: boolean, + * merchandiseCheckoutEnabled: boolean, + * incidentRefundsEnabled: boolean, + * }>} + */ +function parseCommerceControl(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + reject('control_invalid'); + } + const keys = Object.keys(value).sort(); + if (keys.length !== CONTROL_FIELDS.length + || keys.some((key, index) => key !== CONTROL_FIELDS[index])) { + reject('control_fields_invalid'); + } + if (value.schemaVersion !== 1) reject('control_version_invalid'); + if (!Number.isSafeInteger(value.revision) || value.revision < 1) { + reject('control_revision_invalid'); + } + const booleanFields = [ + 'newCommerceEnabled', + 'raceRegistrationEnabled', + 'merchandiseCheckoutEnabled', + 'incidentRefundsEnabled', + ]; + if (booleanFields.some((field) => typeof value[field] !== 'boolean')) { + reject('control_switch_invalid'); + } + return Object.freeze({ + schemaVersion: 1, + revision: value.revision, + newCommerceEnabled: value.newCommerceEnabled, + raceRegistrationEnabled: value.raceRegistrationEnabled, + merchandiseCheckoutEnabled: value.merchandiseCheckoutEnabled, + incidentRefundsEnabled: value.incidentRefundsEnabled, + }); +} + +function controlDocumentReference(db) { + const [collectionName, documentId] = COMMERCE_CONTROL_PATH.split('/'); + return db.collection(collectionName).doc(documentId); +} + +function operationIsKnown(operation) { + return Object.values(COMMERCE_OPERATIONS).includes(operation); +} + +function operationAllowed({ operation, deploymentEnabled, control, targetSnapshot }) { + if (operation === COMMERCE_OPERATIONS.INCIDENT_REFUND) { + return control.incidentRefundsEnabled; + } + + if (deploymentEnabled !== true + || control.newCommerceEnabled !== true + || !targetSnapshot?.exists + || targetSnapshot.data()?.checkoutEnabled !== true) { + return false; + } + + if (operation === COMMERCE_OPERATIONS.RACE_REGISTRATION) { + return control.raceRegistrationEnabled; + } + return control.merchandiseCheckoutEnabled; +} + +/** + * Strongly read and apply the runtime policy for one current commerce command. + * The returned resource snapshot is the same snapshot admitted by the policy. + * + * @param {Object} input + * @param {FirebaseFirestore.Firestore} input.db + * @param {string} input.operation + * @param {boolean} input.deploymentEnabled + * @param {FirebaseFirestore.DocumentReference} [input.targetRef] + * @returns {Promise>} + */ +async function requireCommerceAdmission({ + db, + operation, + deploymentEnabled, + targetRef, +}) { + try { + if (!operationIsKnown(operation)) reject('operation_unknown'); + if (typeof deploymentEnabled !== 'boolean') reject('deployment_ceiling_invalid'); + const needsTarget = operation !== COMMERCE_OPERATIONS.INCIDENT_REFUND; + if (needsTarget !== Boolean(targetRef)) reject('target_invalid'); + if (needsTarget && deploymentEnabled !== true) reject('command_disabled'); + + const refs = [controlDocumentReference(db)]; + if (targetRef) refs.push(targetRef); + const [controlSnapshot, targetSnapshot] = await db.getAll(...refs); + if (!controlSnapshot?.exists) reject('control_missing'); + const control = parseCommerceControl(controlSnapshot.data()); + if (!operationAllowed({ + operation, + deploymentEnabled, + control, + targetSnapshot, + })) { + reject('command_disabled'); + } + + const admitted = { revision: control.revision }; + if (targetSnapshot) admitted.targetSnapshot = targetSnapshot; + return Object.freeze(admitted); + } catch (error) { + if (!(error instanceof CommerceControlError)) { + throw new functions.https.HttpsError( + 'failed-precondition', + COMMERCE_CONTROL_MESSAGE, + ); + } + throw new functions.https.HttpsError( + 'failed-precondition', + COMMERCE_CONTROL_MESSAGE, + ); + } +} + +module.exports = { + COMMERCE_CONTROL_MESSAGE, + COMMERCE_CONTROL_PATH, + COMMERCE_OPERATIONS, + CommerceControlError, + parseCommerceControl, + requireCommerceAdmission, +}; diff --git a/functions/commerceControl.test.js b/functions/commerceControl.test.js new file mode 100644 index 00000000..d6c274fc --- /dev/null +++ b/functions/commerceControl.test.js @@ -0,0 +1,256 @@ +jest.mock('firebase-functions', () => { + class HttpsError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } + } + return { https: { HttpsError } }; +}); + +const { + COMMERCE_CONTROL_MESSAGE, + COMMERCE_OPERATIONS, + CommerceControlError, + parseCommerceControl, + requireCommerceAdmission, +} = require('./commerceControl'); + +function validControl(overrides = {}) { + return { + schemaVersion: 1, + revision: 1, + newCommerceEnabled: true, + raceRegistrationEnabled: true, + merchandiseCheckoutEnabled: true, + incidentRefundsEnabled: true, + ...overrides, + }; +} + +function snapshot(data, exists = true) { + return { exists, data: () => data }; +} + +function mockDb(options = {}) { + const control = Object.prototype.hasOwnProperty.call(options, 'control') + ? options.control + : validControl(); + const target = Object.prototype.hasOwnProperty.call(options, 'target') + ? options.target + : { checkoutEnabled: true }; + const getAll = jest.fn().mockResolvedValue([ + snapshot(control, control !== undefined), + snapshot(target, target !== undefined), + ]); + return { + collection: jest.fn(() => ({ doc: jest.fn(() => ({ path: 'systemConfig/commerce' })) })), + getAll, + }; +} + +function captureParserError(value) { + try { + parseCommerceControl(value); + } catch (error) { + return error; + } + throw new Error('Expected commerce control rejection'); +} + +describe('commerceControl', () => { + test('returns a frozen minimal control snapshot', () => { + const input = validControl({ revision: 7 }); + const parsed = parseCommerceControl(input); + + expect(parsed).toEqual(input); + expect(Object.isFrozen(parsed)).toBe(true); + }); + + test.each([ + ['missing object', undefined, 'control_invalid'], + ['null object', null, 'control_invalid'], + ['array', [], 'control_invalid'], + ['missing field', (() => { + const value = validControl(); + delete value.newCommerceEnabled; + return value; + })(), 'control_fields_invalid'], + ['extra field', { ...validControl(), suppliedCanary: 'private-value' }, 'control_fields_invalid'], + ['unknown version', validControl({ schemaVersion: 2 }), 'control_version_invalid'], + ['zero revision', validControl({ revision: 0 }), 'control_revision_invalid'], + ['fractional revision', validControl({ revision: 1.5 }), 'control_revision_invalid'], + ['unsafe revision', validControl({ revision: Number.MAX_SAFE_INTEGER + 1 }), 'control_revision_invalid'], + ['string switch', validControl({ newCommerceEnabled: 'true' }), 'control_switch_invalid'], + ])('rejects %s without exposing supplied data', (_name, value, reason) => { + const error = captureParserError(value); + + expect(error).toBeInstanceOf(CommerceControlError); + expect(error.message).toBe(COMMERCE_CONTROL_MESSAGE); + expect(error.reason).toBe(reason); + expect(JSON.stringify(error)).toBe('{}'); + expect(error.stack).not.toContain('private-value'); + }); + + test.each([ + [COMMERCE_OPERATIONS.RACE_REGISTRATION, 'raceRegistrationEnabled'], + [COMMERCE_OPERATIONS.MERCHANDISE_CHECKOUT, 'merchandiseCheckoutEnabled'], + ])('admits %s only when every new-commerce layer is enabled', async (operation, domainField) => { + for (const [deploymentEnabled, controlPatch, targetData, admitted] of [ + [true, {}, { checkoutEnabled: true }, true], + [false, {}, { checkoutEnabled: true }, false], + [true, { newCommerceEnabled: false }, { checkoutEnabled: true }, false], + [true, { [domainField]: false }, { checkoutEnabled: true }, false], + [true, {}, { checkoutEnabled: false }, false], + [true, {}, {}, false], + ]) { + const db = mockDb({ control: validControl(controlPatch), target: targetData }); + const promise = requireCommerceAdmission({ + db, + operation, + deploymentEnabled, + targetRef: { path: 'synthetic/target' }, + }); + if (admitted) { + await expect(promise).resolves.toMatchObject({ revision: 1 }); + } else { + await expect(promise).rejects.toMatchObject({ + code: 'failed-precondition', + message: COMMERCE_CONTROL_MESSAGE, + }); + } + } + }); + + test('keeps incident refunds independent of the new-commerce ceiling', async () => { + const allowedDb = mockDb({ + control: validControl({ + newCommerceEnabled: false, + raceRegistrationEnabled: false, + merchandiseCheckoutEnabled: false, + incidentRefundsEnabled: true, + }), + }); + await expect(requireCommerceAdmission({ + db: allowedDb, + operation: COMMERCE_OPERATIONS.INCIDENT_REFUND, + deploymentEnabled: false, + })).resolves.toMatchObject({ revision: 1 }); + + const blockedDb = mockDb({ + control: validControl({ incidentRefundsEnabled: false }), + }); + await expect(requireCommerceAdmission({ + db: blockedDb, + operation: COMMERCE_OPERATIONS.INCIDENT_REFUND, + deploymentEnabled: true, + })).rejects.toMatchObject({ code: 'failed-precondition' }); + }); + + test('deployment ceiling denies new commerce before a runtime-control read', async () => { + const db = mockDb(); + + await expect(requireCommerceAdmission({ + db, + operation: COMMERCE_OPERATIONS.RACE_REGISTRATION, + deploymentEnabled: false, + targetRef: { path: 'events/race-1' }, + })).rejects.toMatchObject({ code: 'failed-precondition' }); + + expect(db.getAll).not.toHaveBeenCalled(); + }); + + test.each([ + ['unknown operation', { operation: 'future_command', deploymentEnabled: true }], + ['missing target', { + operation: COMMERCE_OPERATIONS.RACE_REGISTRATION, + deploymentEnabled: true, + }], + ['unexpected refund target', { + operation: COMMERCE_OPERATIONS.INCIDENT_REFUND, + deploymentEnabled: true, + targetRef: { path: 'synthetic/target' }, + }], + ])('fails closed for %s before reading Firestore', async (_name, input) => { + const db = mockDb(); + + await expect(requireCommerceAdmission({ db, ...input })).rejects.toMatchObject({ + code: 'failed-precondition', + message: COMMERCE_CONTROL_MESSAGE, + }); + expect(db.getAll).not.toHaveBeenCalled(); + }); + + test('reads a fresh revision on every invocation and never caches admission', async () => { + const db = mockDb(); + db.getAll + .mockResolvedValueOnce([ + snapshot(validControl({ revision: 4 })), + snapshot({ checkoutEnabled: true }), + ]) + .mockResolvedValueOnce([ + snapshot(validControl({ revision: 5, newCommerceEnabled: false })), + snapshot({ checkoutEnabled: true }), + ]); + const input = { + db, + operation: COMMERCE_OPERATIONS.RACE_REGISTRATION, + deploymentEnabled: true, + targetRef: { path: 'events/race-1' }, + }; + + await expect(requireCommerceAdmission(input)).resolves.toMatchObject({ revision: 4 }); + await expect(requireCommerceAdmission(input)).rejects.toMatchObject({ + code: 'failed-precondition', + }); + expect(db.getAll).toHaveBeenCalledTimes(2); + }); + + test('a disable committed before the admission read completes denies the command', async () => { + const db = mockDb(); + let currentControl = validControl({ revision: 8 }); + let releaseRead; + const readMayFinish = new Promise((resolve) => { + releaseRead = resolve; + }); + db.getAll.mockImplementation(async () => { + await readMayFinish; + return [ + snapshot(currentControl), + snapshot({ checkoutEnabled: true }), + ]; + }); + + const admission = requireCommerceAdmission({ + db, + operation: COMMERCE_OPERATIONS.MERCHANDISE_CHECKOUT, + deploymentEnabled: true, + targetRef: { path: 'products/hat' }, + }); + currentControl = validControl({ revision: 9, newCommerceEnabled: false }); + releaseRead(); + + await expect(admission).rejects.toMatchObject({ + code: 'failed-precondition', + message: COMMERCE_CONTROL_MESSAGE, + }); + expect(db.getAll).toHaveBeenCalledTimes(1); + }); + + test('fails generically for missing control or Firestore read failure', async () => { + const missingDb = mockDb({ control: undefined }); + await expect(requireCommerceAdmission({ + db: missingDb, + operation: COMMERCE_OPERATIONS.INCIDENT_REFUND, + deploymentEnabled: false, + })).rejects.toMatchObject({ message: COMMERCE_CONTROL_MESSAGE }); + + const failingDb = mockDb(); + failingDb.getAll.mockRejectedValue(new Error('synthetic provider detail')); + await expect(requireCommerceAdmission({ + db: failingDb, + operation: COMMERCE_OPERATIONS.INCIDENT_REFUND, + deploymentEnabled: false, + })).rejects.toMatchObject({ message: COMMERCE_CONTROL_MESSAGE }); + }); +}); diff --git a/functions/commerceControlEntryPoints.test.js b/functions/commerceControlEntryPoints.test.js new file mode 100644 index 00000000..b043f493 --- /dev/null +++ b/functions/commerceControlEntryPoints.test.js @@ -0,0 +1,375 @@ +const mockStripeConstructor = jest.fn(); +const mockRefundCreate = jest.fn(); +const mockProductCreate = jest.fn(); +const mockPriceCreate = jest.fn(); +const mockPaymentLinkCreate = jest.fn(); +const mockBusinessWrite = 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 { + https, + runWith: () => ({ https }), + }; +}); + +jest.mock('firebase-admin', () => { + const store = new Map(); + let autoId = 0; + + class Snapshot { + constructor(ref) { + this.ref = ref; + this.id = ref.id; + this.exists = store.has(ref.path); + } + + data() { + return store.get(this.ref.path); + } + } + + class DocumentReference { + constructor(path) { + this.path = path; + this.id = path.split('/').at(-1); + } + + collection(name) { + return new CollectionReference(`${this.path}/${name}`); + } + + async get() { + return new Snapshot(this); + } + + async set(data) { + mockBusinessWrite('set', this.path, data); + store.set(this.path, { ...data }); + } + + async update(data) { + mockBusinessWrite('update', this.path, data); + store.set(this.path, { ...(store.get(this.path) || {}), ...data }); + } + } + + class CollectionReference { + constructor(path) { + this.path = path; + } + + doc(id) { + let resolvedId = id; + if (!resolvedId) { + autoId += 1; + resolvedId = `auto-${autoId}`; + } + return new DocumentReference(`${this.path}/${resolvedId}`); + } + } + + const firestore = { + collection: (name) => new CollectionReference(name), + getAll: async (...refs) => refs.map((ref) => new Snapshot(ref)), + }; + const firestoreFunction = () => firestore; + firestoreFunction.FieldValue = { arrayUnion: (value) => [value] }; + + return { + apps: [{}], + firestore: firestoreFunction, + __clear: () => { + store.clear(); + autoId = 0; + }, + __seed: (path, data) => store.set(path, { ...data }), + __get: (path) => store.get(path), + }; +}); + +jest.mock('firebase-admin/firestore', () => ({ + FieldValue: { arrayUnion: (value) => [value] }, + Timestamp: { now: () => ({ _milliseconds: 1_800_000_000_000 }) }, +})); + +const admin = require('firebase-admin'); +const { adminRegistrationAction } = require('./adminRegistrationAction'); +const { adminOrderAction } = require('./adminOrderAction'); + +function control(overrides = {}) { + return { + schemaVersion: 1, + revision: 1, + newCommerceEnabled: true, + raceRegistrationEnabled: true, + merchandiseCheckoutEnabled: true, + incidentRefundsEnabled: true, + ...overrides, + }; +} + +function adminContext() { + return { + app: { appId: 'synthetic-app' }, + auth: { + uid: 'admin-1', + token: { role: 'admin', email: 'officer@example.test' }, + }, + }; +} + +function registrationInput(amountCents) { + return { + runner: { + firstName: 'Test', + lastName: 'Runner', + email: 'runner@example.test', + }, + amountCents, + priceTier: amountCents === 0 ? 'comp' : 'nonMember', + }; +} + +describe('commerce admission at admin entry points', () => { + beforeEach(() => { + admin.__clear(); + mockBusinessWrite.mockClear(); + mockRefundCreate.mockReset(); + mockProductCreate.mockReset(); + mockPriceCreate.mockReset(); + mockPaymentLinkCreate.mockReset(); + mockStripeConstructor.mockReset(); + mockStripeConstructor.mockReturnValue({ + refunds: { create: mockRefundCreate }, + products: { create: mockProductCreate }, + prices: { create: mockPriceCreate }, + paymentLinks: { create: mockPaymentLinkCreate }, + }); + mockRefundCreate.mockResolvedValue({ id: 're_synthetic' }); + mockProductCreate.mockResolvedValue({ id: 'prod_synthetic' }); + mockPriceCreate.mockResolvedValue({ id: 'price_synthetic' }); + mockPaymentLinkCreate.mockResolvedValue({ + id: 'plink_synthetic', + url: 'https://buy.stripe.test/synthetic', + }); + 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_commerce_control', + ].join('_'); + process.env.COMMERCE_ENABLED = 'true'; + process.env.ENFORCE_APP_CHECK = 'false'; + }); + + afterAll(() => { + 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.COMMERCE_ENABLED; + delete process.env.ENFORCE_APP_CHECK; + }); + + test.each([ + ['mark_comp', registrationInput(0)], + ['add_late_registration', registrationInput(0)], + ['add_late_registration', registrationInput(2500)], + ])('blocks admin %s before registration or Stripe work', async (action, registration) => { + admin.__seed('systemConfig/commerce', control({ newCommerceEnabled: false })); + admin.__seed('events/race-1', { + checkoutEnabled: true, + title: 'Synthetic Race', + slug: 'synthetic-race', + }); + + await expect(adminRegistrationAction({ + eventId: 'race-1', + action, + payload: { registration }, + }, adminContext())).rejects.toMatchObject({ + code: 'failed-precondition', + message: 'Commerce is temporarily unavailable', + }); + + expect(mockBusinessWrite).not.toHaveBeenCalled(); + expect(mockStripeConstructor).not.toHaveBeenCalled(); + expect(mockProductCreate).not.toHaveBeenCalled(); + expect(mockPriceCreate).not.toHaveBeenCalled(); + expect(mockPaymentLinkCreate).not.toHaveBeenCalled(); + }); + + test.each([ + ['mark_comp', registrationInput(0), 0], + ['add_late_registration', registrationInput(0), 0], + ['add_late_registration', registrationInput(2500), 1], + ])('keeps admitted admin %s behavior available', async ( + action, + registration, + expectedProviderCreates, + ) => { + admin.__seed('systemConfig/commerce', control()); + admin.__seed('events/race-1', { + checkoutEnabled: true, + title: 'Synthetic Race', + slug: 'synthetic-race', + waiverVersion: 'synthetic-v1', + }); + + await expect(adminRegistrationAction({ + eventId: 'race-1', + action, + payload: { registration }, + }, adminContext())).resolves.toMatchObject({ + ok: true, + registrationId: 'auto-1', + }); + + expect(admin.__get('events/race-1/registrations/auto-1')).toMatchObject({ + eventId: 'race-1', + amountCents: registration.amountCents, + }); + expect(mockProductCreate).toHaveBeenCalledTimes(expectedProviderCreates); + expect(mockPriceCreate).toHaveBeenCalledTimes(expectedProviderCreates); + expect(mockPaymentLinkCreate).toHaveBeenCalledTimes(expectedProviderCreates); + }); + + test.each([ + ['registration', adminRegistrationAction, { + eventId: 'race-1', + registrationId: 'reg-1', + action: 'refund_full', + }, 'events/race-1/registrations/reg-1'], + ['order', adminOrderAction, { + orderId: 'order-1', + action: 'refund_full', + }, 'orders/order-1'], + ])('blocks %s refund when incident refunds are off', async ( + _name, + handler, + data, + recordPath, + ) => { + admin.__seed('systemConfig/commerce', control({ incidentRefundsEnabled: false })); + admin.__seed(recordPath, { + status: 'paid', + amountCents: 2500, + stripePaymentIntentId: 'pi_synthetic', + }); + + await expect(handler(data, adminContext())).rejects.toMatchObject({ + code: 'failed-precondition', + message: 'Commerce is temporarily unavailable', + }); + + expect(mockRefundCreate).not.toHaveBeenCalled(); + expect(mockBusinessWrite).not.toHaveBeenCalled(); + }); + + test.each([ + ['registration', adminRegistrationAction, { + eventId: 'race-1', + registrationId: 'reg-1', + action: 'refund_full', + }, 'events/race-1/registrations/reg-1'], + ['order', adminOrderAction, { + orderId: 'order-1', + action: 'refund_full', + }, 'orders/order-1'], + ])('admits %s refund independently while new commerce is off', async ( + _name, + handler, + data, + recordPath, + ) => { + process.env.COMMERCE_ENABLED = 'false'; + admin.__seed('systemConfig/commerce', control({ + newCommerceEnabled: false, + raceRegistrationEnabled: false, + merchandiseCheckoutEnabled: false, + incidentRefundsEnabled: true, + })); + admin.__seed(recordPath, { + status: 'paid', + amountCents: 2500, + stripePaymentIntentId: 'pi_synthetic', + }); + + await expect(handler(data, adminContext())).resolves.toMatchObject({ + ok: true, + refundId: 're_synthetic', + }); + + expect(mockRefundCreate).toHaveBeenCalledWith({ + payment_intent: 'pi_synthetic', + }); + expect(mockBusinessWrite).toHaveBeenCalledTimes(1); + }); + + test('existing-record note bypasses an absent runtime commerce control', async () => { + process.env.COMMERCE_ENABLED = 'false'; + admin.__seed('events/race-1/registrations/reg-1', { + status: 'paid', + amountCents: 0, + }); + + await expect(adminRegistrationAction({ + eventId: 'race-1', + registrationId: 'reg-1', + action: 'add_note', + payload: { note: 'Synthetic note' }, + }, adminContext())).resolves.toEqual({ ok: true }); + + expect(mockBusinessWrite).toHaveBeenCalledTimes(1); + expect(mockRefundCreate).not.toHaveBeenCalled(); + }); + + test('order fulfillment work bypasses the new-commerce pause', async () => { + process.env.COMMERCE_ENABLED = 'false'; + admin.__seed('orders/order-1', { + status: 'paid', + amountCents: 2500, + }); + + await expect(adminOrderAction({ + orderId: 'order-1', + action: 'mark_fulfilled', + payload: { note: 'Synthetic fulfillment' }, + }, adminContext())).resolves.toEqual({ ok: true }); + + expect(admin.__get('orders/order-1')).toMatchObject({ status: 'fulfilled' }); + expect(mockBusinessWrite).toHaveBeenCalledTimes(1); + expect(mockRefundCreate).not.toHaveBeenCalled(); + }); + + test('App Check and admin authorization keep precedence over commerce state', async () => { + process.env.ENFORCE_APP_CHECK = 'true'; + + await expect(adminOrderAction({ + orderId: 'order-1', + action: 'refund_full', + }, { auth: null })).rejects.toMatchObject({ code: 'failed-precondition' }); + + process.env.ENFORCE_APP_CHECK = 'false'; + await expect(adminOrderAction({ + orderId: 'order-1', + action: 'refund_full', + }, { auth: null })).rejects.toMatchObject({ code: 'unauthenticated' }); + + expect(mockRefundCreate).not.toHaveBeenCalled(); + expect(mockBusinessWrite).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/createCheckoutSession.js b/functions/createCheckoutSession.js index c2e9e099..8a8d3d2b 100644 --- a/functions/createCheckoutSession.js +++ b/functions/createCheckoutSession.js @@ -16,6 +16,10 @@ const { } = require('./stripeHelpers'); const { checkRateLimit, extractIp } = require('./rateLimit'); const { loadCallableServerConfig } = require('./serverConfig'); +const { + COMMERCE_OPERATIONS, + requireCommerceAdmission, +} = require('./commerceControl'); const HOUR_MS = 60 * 60 * 1000; const CHECKOUT_PER_IP_PER_HOUR = 20; @@ -74,7 +78,10 @@ exports.createCheckoutSession = functions .runWith({ secrets: ['STRIPE_SECRET_KEY'] }) .https.onCall(async (data, context) => { requireAppCheck(context); - const serverConfig = loadCallableServerConfig({ requireStripeKey: true }); + const serverConfig = loadCallableServerConfig({ + requireStripeKey: true, + requireCommerceCeiling: true, + }); const { eventId, runner, @@ -88,6 +95,14 @@ exports.createCheckoutSession = functions if (!eventId || typeof eventId !== 'string') { throw new functions.https.HttpsError('invalid-argument', 'eventId is required'); } + const db = admin.firestore(); + const eventRef = db.collection('events').doc(eventId); + const { targetSnapshot: eventSnap } = await requireCommerceAdmission({ + db, + operation: COMMERCE_OPERATIONS.RACE_REGISTRATION, + deploymentEnabled: serverConfig.commerceEnabled, + targetRef: eventRef, + }); const runnerErrors = validateRunner(runner); if (runnerErrors.length) { throw new functions.https.HttpsError('invalid-argument', runnerErrors.join('; ')); @@ -115,12 +130,6 @@ exports.createCheckoutSession = functions }), ]); - const db = admin.firestore(); - const eventRef = db.collection('events').doc(eventId); - const eventSnap = await eventRef.get(); - if (!eventSnap.exists) { - throw new functions.https.HttpsError('not-found', 'Event not found'); - } const event = eventSnap.data(); const now = Date.now(); diff --git a/functions/createMerchCheckout.js b/functions/createMerchCheckout.js index 57efcfbe..e90937fa 100644 --- a/functions/createMerchCheckout.js +++ b/functions/createMerchCheckout.js @@ -11,6 +11,10 @@ const { } = require('./stripeHelpers'); const { checkRateLimit, extractIp } = require('./rateLimit'); const { loadCallableServerConfig } = require('./serverConfig'); +const { + COMMERCE_OPERATIONS, + requireCommerceAdmission, +} = require('./commerceControl'); const HOUR_MS = 60 * 60 * 1000; const MERCH_PER_IP_PER_HOUR = 20; @@ -34,7 +38,10 @@ exports.createMerchCheckout = functions .runWith({ secrets: ['STRIPE_SECRET_KEY'] }) .https.onCall(async (data, context) => { requireAppCheck(context); - const serverConfig = loadCallableServerConfig({ requireStripeKey: true }); + const serverConfig = loadCallableServerConfig({ + requireStripeKey: true, + requireCommerceCeiling: true, + }); const { productSlug, @@ -46,6 +53,14 @@ exports.createMerchCheckout = functions if (!productSlug || typeof productSlug !== 'string') { throw new functions.https.HttpsError('invalid-argument', 'productSlug required'); } + const db = admin.firestore(); + const productRef = db.collection('products').doc(productSlug); + const { targetSnapshot: productSnap } = await requireCommerceAdmission({ + db, + operation: COMMERCE_OPERATIONS.MERCHANDISE_CHECKOUT, + deploymentEnabled: serverConfig.commerceEnabled, + targetRef: productRef, + }); if (!buyer || !isValidEmail(buyer.email) || !buyer.firstName?.trim() || !buyer.lastName?.trim()) { throw new functions.https.HttpsError( @@ -70,12 +85,6 @@ exports.createMerchCheckout = functions }), ]); - const db = admin.firestore(); - const productRef = db.collection('products').doc(productSlug); - const productSnap = await productRef.get(); - if (!productSnap.exists) { - throw new functions.https.HttpsError('not-found', 'Product not found'); - } const product = productSnap.data(); if (product.status !== 'active') { throw new functions.https.HttpsError( diff --git a/functions/serverConfig.js b/functions/serverConfig.js index 45198c38..4b3fbd0b 100644 --- a/functions/serverConfig.js +++ b/functions/serverConfig.js @@ -14,6 +14,7 @@ const PRODUCTION_SITE_ORIGIN = 'https://runmprc.com'; * @property {EnvironmentName} environmentName * @property {string} siteOrigin * @property {boolean} stripeLivemodeExpected + * @property {boolean} [commerceEnabled] */ class ServerConfigError extends Error { @@ -115,14 +116,28 @@ function validateStripeServerKey(environment, expectedLivemode) { if (keyLivemode !== expectedLivemode) reject('stripe_key_environment_mismatch'); } +function parseCommerceCeiling(environment) { + const value = requiredExactString( + environment, + 'COMMERCE_ENABLED', + 'commerce_enabled_missing', + 'commerce_enabled_invalid', + ); + if (value !== 'true' && value !== 'false') reject('commerce_enabled_invalid'); + return value === 'true'; +} + /** * Parse a supplied environment object without retaining secret material. * * @param {NodeJS.ProcessEnv|Object} environment - * @param {{requireStripeKey?: boolean}} options + * @param {{requireStripeKey?: boolean, requireCommerceCeiling?: boolean}} options * @returns {Readonly} */ -function parseServerConfig(environment, { requireStripeKey = false } = {}) { +function parseServerConfig(environment, { + requireStripeKey = false, + requireCommerceCeiling = false, +} = {}) { const environmentName = requiredExactString( environment, 'ENVIRONMENT_NAME', @@ -156,11 +171,17 @@ function parseServerConfig(environment, { requireStripeKey = false } = {}) { if (requireStripeKey) validateStripeServerKey(environment, stripeLivemodeExpected); - return Object.freeze({ + const commerceEnabled = requireCommerceCeiling + ? parseCommerceCeiling(environment) + : undefined; + + const config = { environmentName, siteOrigin, stripeLivemodeExpected, - }); + }; + if (requireCommerceCeiling) config.commerceEnabled = commerceEnabled; + return Object.freeze(config); } function loadServerConfig(options) { diff --git a/functions/serverConfig.test.js b/functions/serverConfig.test.js index 85f17e37..58db3c1f 100644 --- a/functions/serverConfig.test.js +++ b/functions/serverConfig.test.js @@ -17,6 +17,7 @@ function validEnvironment(overrides = {}) { SITE_ORIGIN: 'https://runmprc.test', STRIPE_LIVEMODE_EXPECTED: 'false', STRIPE_SECRET_KEY: TEST_KEY, + COMMERCE_ENABLED: 'false', ...overrides, }; } @@ -92,6 +93,55 @@ describe('serverConfig', () => { }); }); + test.each([ + ['true', true], + ['false', false], + ])('parses the exact commerce deployment ceiling %s', (value, expected) => { + const config = parseServerConfig( + validEnvironment({ COMMERCE_ENABLED: value }), + { requireStripeKey: true, requireCommerceCeiling: true }, + ); + + expect(config.commerceEnabled).toBe(expected); + expect(Object.isFrozen(config)).toBe(true); + }); + + test.each([ + ['missing', undefined, 'commerce_enabled_missing'], + ['blank', '', 'commerce_enabled_missing'], + ['padded', ' true ', 'commerce_enabled_invalid'], + ['uppercase', 'TRUE', 'commerce_enabled_invalid'], + ['numeric-like', '1-synthetic-canary', 'commerce_enabled_invalid'], + ])('rejects %s required commerce ceiling with a fixed safe error', ( + _name, + value, + reason, + ) => { + const environment = validEnvironment({ COMMERCE_ENABLED: value }); + if (value === undefined) delete environment.COMMERCE_ENABLED; + + const error = captureConfigError(environment, { + requireStripeKey: true, + requireCommerceCeiling: true, + }); + + expect(error.message).toBe(CONFIGURATION_ERROR_MESSAGE); + expect(error.reason).toBe(reason); + expect(JSON.stringify(error)).toBe('{}'); + expect(error.stack).not.toContain(value || 'unsafe_commerce_value'); + }); + + test('webhook and mail base configuration does not require the commerce ceiling', () => { + const environment = validEnvironment(); + delete environment.COMMERCE_ENABLED; + + 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'], diff --git a/functions/serverConfigEntryPoints.test.js b/functions/serverConfigEntryPoints.test.js index 5dc1807f..1163ecac 100644 --- a/functions/serverConfigEntryPoints.test.js +++ b/functions/serverConfigEntryPoints.test.js @@ -63,6 +63,7 @@ function setValidTestConfig() { process.env.ENVIRONMENT_NAME = 'test'; process.env.SITE_ORIGIN = 'https://runmprc.test'; process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; + process.env.COMMERCE_ENABLED = 'true'; process.env.STRIPE_SECRET_KEY = [ 'sk', 'test', 'synthetic_entrypoint_key', ].join('_'); @@ -118,6 +119,7 @@ describe('server configuration entry-point guards', () => { delete process.env.ENVIRONMENT_NAME; delete process.env.SITE_ORIGIN; delete process.env.STRIPE_LIVEMODE_EXPECTED; + delete process.env.COMMERCE_ENABLED; delete process.env.STRIPE_SECRET_KEY; }); @@ -194,6 +196,7 @@ describe('server configuration entry-point guards', () => { exportName, inputFactory, ) => { + delete process.env.COMMERCE_ENABLED; const emailFunctions = require('./sendConfirmationEmail'); await emailFunctions[exportName]( diff --git a/functions/stripeHelpers.js b/functions/stripeHelpers.js index bbcc244f..124a20b5 100644 --- a/functions/stripeHelpers.js +++ b/functions/stripeHelpers.js @@ -12,7 +12,10 @@ const STRIPE_API_VERSION = '2023-10-16'; let _stripeClient = null; function getStripe() { - loadCallableServerConfig({ requireStripeKey: true }); + loadCallableServerConfig({ + requireStripeKey: true, + requireCommerceCeiling: true, + }); if (_stripeClient) return _stripeClient; const secretKey = process.env.STRIPE_SECRET_KEY; _stripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION }); diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index 9cadb393..3fbdaa81 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -334,6 +334,7 @@ describe('stripeWebhook', () => { beforeEach(() => { admin.__clear(); + delete process.env.COMMERCE_ENABLED; process.env.ENVIRONMENT_NAME = 'test'; process.env.SITE_ORIGIN = 'https://runmprc.test'; process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; @@ -384,6 +385,7 @@ describe('stripeWebhook', () => { delete process.env.STRIPE_LIVEMODE_EXPECTED; delete process.env.STRIPE_SECRET_KEY; delete process.env.STRIPE_WEBHOOK_SECRET; + delete process.env.COMMERCE_ENABLED; }); test('rejects a missing or invalid signature', async () => { @@ -445,6 +447,28 @@ describe('stripeWebhook', () => { })); }); + test('signed payment evidence continues while the new-commerce ceiling is off', async () => { + process.env.COMMERCE_ENABLED = 'false'; + seedRegistration(); + const event = stripeEvent( + 'evt_payment_during_pause', + 'checkout.session.completed', + registrationSession(), + ); + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + outcome: 'payment_confirmed', + })); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('paid'); + expect(admin.__get('stripeEvents/evt_payment_during_pause')).toMatchObject({ + status: 'processed', + outcome: 'payment_confirmed', + }); + }); + test('acknowledges and deduplicates unsupported event types', async () => { const event = stripeEvent('evt_unsupported', 'customer.created', { id: 'cus_1', diff --git a/tests/firestore-rules/events.test.js b/tests/firestore-rules/events.test.js index b52b1e9e..95ae2301 100644 --- a/tests/firestore-rules/events.test.js +++ b/tests/firestore-rules/events.test.js @@ -467,6 +467,7 @@ describe('events collection', () => { pricing: { memberCents: 2500, nonMemberCents: 3500 }, }], ['open status', { status: 'open' }], + ['checkout enablement', { checkoutEnabled: true }], ['members-only visibility', { visibility: 'members_only' }], ['registration window', { registrationOpensAt: NOW }], ['waiver configuration', { @@ -526,6 +527,7 @@ describe('events collection', () => { ['capacity', { capacity: 50 }], ['pricing', { pricing: { memberCents: 2500, nonMemberCents: 3500 } }], ['status', { status: 'open' }], + ['checkout enablement', { checkoutEnabled: true }], ['visibility', { visibility: 'members_only' }], ['registration window', { registrationOpensAt: NOW }], ['waiver configuration', { diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index 31cf3de1..df3616bb 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -200,6 +200,7 @@ describe('products collection', () => { ['mismatched creator', { createdBy: 'attacker' }], ['non-zero price', { priceCents: 1000 }], ['active status', { status: 'active', priceCents: 1000 }], + ['checkout enablement', { checkoutEnabled: true }], ['Stripe product ID', { stripeProductId: 'prod_test' }], ['Stripe price ID', { stripePriceId: 'price_test' }], ['inventory state', { inventory: { onHand: 10, reserved: 0 } }], @@ -217,6 +218,7 @@ describe('products collection', () => { ['creator', { createdBy: 'attacker' }], ['creation timestamp', { createdAt: new Date(0) }], ['availability status', { status: 'active' }], + ['checkout enablement', { checkoutEnabled: true }], ['Stripe product ID', { stripeProductId: 'prod_test' }], ['Stripe price ID', { stripePriceId: 'price_test' }], ['inventory state', { inventory: { onHand: 10, reserved: 0 } }], @@ -322,6 +324,33 @@ describe('orders collection', () => { }); describe('server-only operational collections', () => { + describe('systemConfig/commerce', () => { + const existingPath = 'systemConfig/commerce'; + const newPath = 'systemConfig/commerce-probe'; + const control = { + schemaVersion: 1, + revision: 1, + newCommerceEnabled: false, + raceRegistrationEnabled: false, + merchandiseCheckoutEnabled: false, + incidentRefundsEnabled: true, + }; + + test.each([ + ['anonymous', undefined], + ['member', { uid: 'member-1', role: 'member' }], + ['browser admin', { uid: 'admin-1', role: 'admin' }], + ])('%s CANNOT read or write the commerce control', async (_label, auth) => { + await seed(existingPath, control); + const client = await db(auth); + + await assertFails(client.doc(existingPath).get()); + await assertFails(client.doc(newPath).set(control)); + await assertFails(client.doc(existingPath).update({ newCommerceEnabled: true })); + await assertFails(client.doc(existingPath).delete()); + }); + }); + describe.each([ ['promoCodes/PROMO1', 'promoCodes/PROMO2', { discountPercent: 10 }], ['ratelimits/checkout_ip__1.2.3.4', 'ratelimits/checkout_ip__5.6.7.8', { count: 5 }],