From 3005fbbdb1191a6f6ac4b679286fdde24cdbde4f Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 09:18:37 -0700 Subject: [PATCH] feat(commerce): add command identity contract (#163) --- GITHUB_ISSUES.md | 6 +- GITHUB_ISSUE_SLICES.md | 19 +- IMPLEMENTATION_PLAN.md | 4 +- SECURITY.md | 3 +- STRIPE_COMMERCE_DESIGN.md | 28 +- SYSTEM_DESIGN.md | 8 +- functions/commerceCommandIdentity.js | 502 +++++++++++++ functions/commerceCommandIdentity.test.js | 847 ++++++++++++++++++++++ 8 files changed, 1393 insertions(+), 24 deletions(-) create mode 100644 functions/commerceCommandIdentity.js create mode 100644 functions/commerceCommandIdentity.test.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 4113125f..30481fb7 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -62,7 +62,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 10 | ABUSE-001 | Enforce native App Check and privacy-preserving abuse limits | P0 | L | partial: Enterprise browser source/tests tracked in [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159); provider config, native enforcement, rate limits, and live proof open | CI-001 baseline | | 11 | PAY-001 | Add strict request schemas and immutable monetary snapshots | P0 | L | partial: PAY-001A source/tests tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157); B/C/D and endpoint adoption open | ABUSE-001 interface agreed | | 12 | PROMO-001 | Disable unmodeled Stripe promotions until discounts are authoritative | P0 | S | source/tests complete under open GitHub issue #102; provider inventory and deployment remain owner action | PAY-001 for any future discount contract | -| 13 | PAY-002 | Implement idempotent payment commands and explicit state machines | P0 | L | partial: PAY-002A1 pure source/tests tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161); runtime adoption, migration, and B-D open | CONFIG-001, PAY-001 | +| 13 | PAY-002 | Implement idempotent payment commands and explicit state machines | P0 | L | partial: PAY-002A1 pure state source/tests tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161); PAY-002B1 pure command-identity source/tests tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163); journal/runtime/migration/sagas open | CONFIG-001, PAY-001 | | 14 | PAY-003 | Build idempotent, async-aware Stripe webhook ingestion | P0 | L | PAY-003A source merged in #101; PAY-003B/C remain open; not deployed | CONFIG-001, PAY-001/PAY-002 target contract | | 15 | RACE-001 | Add transactional race-capacity reservations | P0 | L | proposed | PAY-002, PAY-003 event contract | | 16 | MERCH-001 | Add SKU variants and transactional inventory reservations | P0 | L | proposed | PAY-002, PAY-003 event contract | @@ -624,14 +624,14 @@ The local webhook/creator patch is a safety default, not a complete discount fea ## PAY-002 — Implement idempotent payment commands and explicit state machines **Labels:** `priority:P0`, `type:reliability`, `type:security`, `area:stripe`, `area:firebase`, `size:L`, `needs-migration` -**Status:** Partial. PAY-002A1 pure source/tests are tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). Runtime adoption, real legacy inventory/migration, command identity, and checkout sagas remain open. +**Status:** Partial. PAY-002A1 pure state source/tests are tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). PAY-002B1 pure command-identity source/tests are tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). Firestore journal persistence, runtime adoption, real legacy inventory/migration, and checkout sagas remain open. **Depends on:** CONFIG-001 and PAY-001 ### Current atomic boundary PAY-002A1 defines only a dependency-free version-1 target: separate payment, registration, fulfillment, confirmed-refund, and per-dispute reducers; fixed cross-dimension validation; and a synthetic legacy classifier/redacted aggregate report. Orders/registrations have no canonical singular `disputeStatus`; each dispute is validated separately against paid payment context. Operational `fulfilled`, `transferred`, and `cancelled` legacy values require separate compatible payment evidence rather than manufacturing payment state. It does not import Firebase/Stripe, expose a Function, change the webhook, write a compatibility field, enumerate a real record, or deploy. -The next boundaries remain separate. A later inventory/migration child must use #113-approved data categories and a dry-run before writes. PAY-002B owns caller-scoped command/attempt identity and idempotency journals. PAY-002C/D own persistence-first race/shop sagas. PAY-003B/#106 owns webhook adoption and Stripe API-version adaptation. PAY-004/PAY-005 own cancellation and refund operations. +The next boundaries remain separate. A later inventory/migration child must use #113-approved data categories and a dry-run before writes. PAY-002B1 [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) owns only pure caller-scoped command keys, payload fingerprints, and deterministic Stripe keys with closed environment/mode pairing. A provider attempt is one logical generation, not an HTTP retry. PAY-002B2 owns Firestore leases/fencing, stored-result replay, conflict rejection, old/unknown-send-time POST blocking/reconciliation, provider-attempt recovery, and append-oriented audit. PAY-002C/D own persistence-first race/shop sagas. PAY-003B/#106 owns webhook adoption and Stripe API-version adaptation. PAY-004/PAY-005 own cancellation and refund operations. ### Problem diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index d9e1d742..a4beb2d1 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -62,22 +62,23 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | PAY-001D | proposed; PAY-001A, AUTH-003A | Apply bounded schemas to lookup/refund/cancel/substitute/late/comp/role/tracking/export commands. | Endpoint matrix with hostile/boundary/no-side-effect tests. | | PAY-002A1 | source/tests tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161); PAY-001A; no runtime adoption/migration/deploy | Build pure version-1 payment/registration/fulfillment/confirmed-refund/per-dispute reducers, cross-dimension validation, and a synthetic legacy classifier/redacted aggregate report. | Exhaust every state pair, retry/reorder/terminal/composite/legacy/hostile-input case; no Firebase/Stripe/logger call or record patch. | | PAY-002A2 | proposed; PAY-002A1, #113 owner-approved inventory | Use an owner-approved private read-only inventory for any real legacy/provider references (no mutation/export or copying into tests/issues), define one context-aware compatibility derivation, and use only synthetic emulator fixtures for the dry-run/idempotent migration plan. | Redacted counts/reasons for every approved fixture; repeat synthetic dry-run is identical; no production write until separately approved. | -| PAY-002B | proposed; PAY-002A1 | Build caller-scoped command journal, UUID/payload fingerprint, same-result replay, conflict rejection, lease/attempt/error/audit, and Stripe key scheme. | Concurrent duplicate/conflict and injected-failure emulator tests. | -| PAY-002C | proposed; PAY-001B, PAY-002B, RACE-001B | Convert race checkout to persistence-first record/hold plus deterministic Stripe Session saga. | One record/Session on retry at every failure boundary. | -| PAY-002D | proposed; PAY-001C, PAY-002B, MERCH-001B | Convert merchandise/catalog creation to persistence-first order/hold plus deterministic Stripe keys. | One order/Session/catalog object on retry; failure recovery tests. | +| PAY-002B1 | source/tests tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163); PAY-002A1; no journal/runtime/deploy | Define the pure caller-scoped command-key, UUID/payload-fingerprint, and deterministic Stripe-key contract. The command key excludes command type so B2 can reject cross-operation ID reuse; environment/mode pairs fail closed; provider attempt means one logical generation and survives lease/HTTP retry. | Canonical UUID, golden length-framed digests, environment/mode and dimension separation, hostile frozen payload, key-length/raw-value absence, immutability, and no-side-effect tests. | +| PAY-002B2 | proposed; PAY-002B1 | Build the server-only Firestore command journal with same-result replay, conflict rejection, leases/fencing, provider-attempt preservation, old/unknown-send-time POST stop/reconciliation, fixed errors, and append-oriented audit. | Concurrent duplicate/conflict, expired/stale lease, safe-window/ambiguous-old outcome, transaction-retry, and injected-failure emulator tests. | +| PAY-002C | proposed; PAY-001B, PAY-002B2, RACE-001B | Convert race checkout to persistence-first record/hold plus deterministic Stripe Session saga. | One record/Session on retry at every failure boundary. | +| PAY-002D | proposed; PAY-001C, PAY-002B2, MERCH-001B | Convert merchandise/catalog creation to persistence-first order/hold plus deterministic Stripe keys. | One order/Session/catalog object on retry; failure recovery tests. | ## Webhooks, capacity, inventory, and cancellation | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | | PAY-003A | source merged in [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) via [PR #140](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/140); not deployed | Review/land current ingress slice: signature/method, explicit livemode, payment mode, event ledger, reference resolution, money/adjustment checks, monotonic refunds, terminal guards, quarantine, generic errors, webhook-only secret. | Node 20 Functions lint and signed positive/negative/duplicate/out-of-order unit suite passed; protected deployment, Stripe destination rehearsal, and live verification remain open. | -| PAY-003B | proposed; PAY-002A1, PAY-002B-D, RACE-001B, MERCH-001B; PAY-002A2/#113 before any legacy/provider-path adoption | Inventory/validate the Stripe endpoint Event API version, move handling to strict versioned schemas/canonical reducers, and atomically finalize/release counters and outbox projections. Same-state Events must still apply monotonic amount/evidence changes after Event-ID dedupe. | Versioned/mismatch signed fixtures, emulator transactions for both domains, distinct duplicates, reorder, direct async failure, same-state higher partial-refund total, inquiry escalation, late win, and terminal cases. | +| PAY-003B | proposed; PAY-002A1, PAY-002B2/C/D, RACE-001B, MERCH-001B; PAY-002A2/#113 before any legacy/provider-path adoption | Inventory/validate the Stripe endpoint Event API version, move handling to strict versioned schemas/canonical reducers, and atomically finalize/release counters and outbox projections. Same-state Events must still apply monotonic amount/evidence changes after Event-ID dedupe. | Versioned/mismatch signed fixtures, emulator transactions for both domains, distinct duplicates, reorder, direct async failure, same-state higher partial-refund total, inquiry escalation, late win, and terminal cases. | | PAY-003C | owner_action; A/B, CI-001A | Add retry/quarantine worker/lease semantics, TTL/dead-letter monitoring, and Stripe CLI test-mode delivery rehearsal. | Redacted signed immediate/delayed/retry/rotation evidence and alerts. | | RACE-001A | proposed; PAY-002A1, PAY-002A2 for legacy/backfill adoption | Add capacity/hold schema, expiry/indexes, compatibility reader, and dry-run counter backfill. | Full/closed/legacy fixture report and idempotent rerun. | -| RACE-001B | proposed; RACE-001A, PAY-002B | Implement transaction-safe reserve/confirm/release across paid/free/comp/volunteer/admin paths. | Repeated concurrent last-seat, duplicate, expiry and nonnegative-counter tests. | +| RACE-001B | proposed; RACE-001A, PAY-002B2 | Implement transaction-safe reserve/confirm/release across paid/free/comp/volunteer/admin paths. | Repeated concurrent last-seat, duplicate, expiry and nonnegative-counter tests. | | MERCH-001A | proposed; PAY-001C, PAY-002A1, PAY-002A2 for legacy/backfill adoption | Add canonical SKU/variant and conventional `onHand`, `reserved`, `sold` schema plus catalog migration: `available = onHand - reserved`. | Legacy/size/color/zero-stock migration fixtures and invariant report. | -| MERCH-001B | proposed; A, PAY-002B | Implement deterministic multi-SKU reserve; on paid decrement `reserved` and `onHand`, increment `sold`; release only `reserved`; approved restock increments `onHand`. | Concurrent last-SKU/multi-line/retry/return tests; no negative stock. | -| PAY-004A | proposed; PAY-002B/C, RACE-001B | Implement one-record cancellation that expires Session/releases hold with already-paid/expired/network race recovery. | Retry and payment/cancel race tests; no resurrection. | +| MERCH-001B | proposed; A, PAY-002B2 | Implement deterministic multi-SKU reserve; on paid decrement `reserved` and `onHand`, increment `sold`; release only `reserved`; approved restock increments `onHand`. | Concurrent last-SKU/multi-line/retry/return tests; no negative stock. | +| PAY-004A | proposed; PAY-002B2/C, RACE-001B | Implement one-record cancellation that expires Session/releases hold with already-paid/expired/network race recovery. | Retry and payment/cancel race tests; no resurrection. | | PAY-004B | proposed; A, RACE/MERCH services | Implement bulk event/product closure as bounded idempotent jobs over active Sessions/holds. | Partial failure/resume/rerun report. | | PAY-004C | proposed; PAY-002C, PAY-003B | Replace late reusable Payment Links with one-off Checkout; inventory/deactivate/reconcile legacy links. | Retry/reuse/expiry/payment tests; owner performs Dashboard deactivation. | @@ -85,7 +86,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | -| PAY-005A | proposed; PAY-001D, PAY-002A1, PAY-002B, PAY-003B | Add idempotent refund commands, remaining-balance/concurrency validation, stable Stripe keys, and event-finalized totals. | Same/conflicting/concurrent partial/full/timeout and repeated `partially_refunded` status with increasing confirmed total tests. | +| PAY-005A | proposed; PAY-001D, PAY-002A1, PAY-002B2, PAY-003B | Add idempotent refund commands, remaining-balance/concurrency validation, stable Stripe keys, and event-finalized totals. | Same/conflicting/concurrent partial/full/timeout and repeated `partially_refunded` status with increasing confirmed total tests. | | PAY-005B | proposed; PAY-003B | Model per-dispute lifecycle for orders and registrations with finance alert references. | Duplicate/reordered warning lifecycle, inquiry escalation, created/updated/lost/won, rare lost-to-won late win, and `prevented` only under an explicitly validated upgraded webhook Event version. | | PAY-005C | proposed; A/B | Build read-only scheduled/operator reconciliation and mismatch/quarantine report. | Orphan/mismatch/refund/event/counter synthetic report. | | PAY-005D | proposed; C, AUTH-003B | Build separately authorized idempotent repair commands with reason and append audit. | Dry-run/apply/rerun and forbidden-capability tests. | @@ -94,7 +95,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | DATA-001A | proposed; PAY-002C/D | Replace confirmation query bearer tokens with verified Session/UID projection; hash/expire/scrub anonymous free-flow capabilities. | Cross-user/guess/replay/expiry/history/referrer tests. | | DATA-001B | blocked_owner_decision; LEGAL-001 | Define classification/purpose projections and remove PII from logs/monitoring/admin views. | Owner-approved matrix and projection/redaction snapshots. | | DATA-001C | blocked_owner_decision; B, RESILIENCE-001A | Add dry-run-first retention/minimization and authenticated access/deletion workflows. | Date/status/hold/cross-user/dry-run/apply/rerun tests. | -| ADMIN-001A | proposed; AUTH-003B, PAY-001A, PAY-002B | Build common privileged command middleware and append-only audit. | Capability/auth/schema/idempotency/audit tests. | +| ADMIN-001A | proposed; AUTH-003B, PAY-001A, PAY-002B2 | Build common privileged command middleware and append-only audit. | Capability/auth/schema/idempotency/audit tests. | | ADMIN-001B | proposed; A, RACE-001A | Move event/registration configuration and lifecycle operations to scoped APIs. | Field/state/capability tests and UI parity. | | ADMIN-001C | proposed; A, MERCH-001A | Move catalog/order/inventory/fulfillment operations to scoped APIs. | Field/state/capability/inventory tests and UI parity. | | ADMIN-001D | proposed; A, DATA-001B | Add purpose-specific minimum check-in/emergency/finance/fulfillment exports with re-auth/reason/bounds/formula defense/audit. | Column/PII/large/formula/capability snapshots. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 3bcde717..d238d762 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -125,7 +125,7 @@ Exit gate: - Concurrent last-seat and last-SKU tests prove no oversell. - Session creation is persistence-first, and known failures release holds once. -**Current work:** A PAY-003 webhook safety slice is implemented and unit-verified. PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for the pure versioned structural transition contract and synthetic legacy classification only. It does not prove duplicate Events/commands, evidence, authorization, provider state, or policy. Runtime adoption, command identity, real migration, capacity, inventory, provider/emulator integration, and production operations remain open. +**Current work:** A PAY-003 webhook safety slice is implemented and unit-verified. PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for the pure versioned structural transition contract and synthetic legacy classification only. PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) for the pure UUID, caller-scoped command-key, canonical payload-fingerprint, and deterministic Stripe-key contract only. Neither child proves duplicate Events/commands, authorization, provider state, or policy. Firestore journal/lease persistence, stored-result replay, runtime adoption, real migration, capacity, inventory, provider/emulator integration, and production operations remain open. ### Phase 3 — Complete lifecycle and customer safety @@ -213,7 +213,7 @@ The first tranche deliberately combines one urgent safety repair with the most c 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 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. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-002A1 is tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for pure separate state reducers and synthetic legacy classification. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; PAY-002B-D must add command identity and persistence-first sagas; PAY-003B must adopt the shared reducer. No current endpoint or record changes in A1. +6. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-002A1 is tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for pure separate state reducers and synthetic legacy classification. PAY-002B1 is tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) for pure command/fingerprint/Stripe-key derivation. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; PAY-002B2 must add the Firestore journal/leases/replay/audit; PAY-002C/D must add persistence-first sagas; PAY-003B must adopt the shared reducer. No current endpoint or record changes in A1/B1. 7. **Then:** RACE-001 and MERCH-001 transactional reservations. Why this order: SAFETY-001 removes immediate accidental-production and callback hazards without schema impact. PAY-003 closes the most dangerous “unpaid looks paid” and replay gaps while establishing test fixtures. The subsequent trust-boundary and command/state work completes the surrounding architecture; no live launch is authorized by the partial webhook patch alone. diff --git a/SECURITY.md b/SECURITY.md index a6fa360d..f9fbb098 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,7 +95,8 @@ These entries are implementation evidence, not a production risk-acceptance deci | Risks | Source result | Remaining closure evidence | | --- | --- | --- | -| Foundation only for parts of RISK-011, RISK-020, RISK-021 | PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). The source-only child defines versioned pure payment, registration, fulfillment, confirmed-refund, and per-dispute structural reducers; fixed cross-dimension review/rejection; and a deterministic synthetic legacy classifier/aggregate report with no IDs. Orders/registrations carry no singular canonical dispute state. Forbidden regressions fail closed, refunds/disputes do not overwrite payment state, and `paid_after_cancellation` remains paid plus cancelled plus review rather than becoming a payment enum. | This does not yet mitigate those risks in a runtime. An unchanged enum does not prove duplicate Event/command/evidence, authorization, amount monotonicity, provider state, or club policy. No endpoint imports the library and no record is read/written or migrated. PAY-002B must add command/attempt identity; PAY-002C/D persistence-first checkout; PAY-003B Event/version/evidence adoption including same-state amount changes and separate dispute records; PAY-004/PAY-005 cancellation/refund operations; MERCH-002/LEGAL-001 return/policy gates; #113 and a later migration child real inventory/dry-run. Firebase/Stripe deployment and live behavior remain unproven. | +| Foundation only for parts of RISK-011, RISK-020, RISK-021 | PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). The source-only child defines versioned pure payment, registration, fulfillment, confirmed-refund, and per-dispute structural reducers; fixed cross-dimension review/rejection; and a deterministic synthetic legacy classifier/aggregate report with no IDs. Orders/registrations carry no singular canonical dispute state. Forbidden regressions fail closed, refunds/disputes do not overwrite payment state, and `paid_after_cancellation` remains paid plus cancelled plus review rather than becoming a payment enum. | This does not yet mitigate those risks in a runtime. An unchanged enum does not prove duplicate Event/command/evidence, authorization, amount monotonicity, provider state, or club policy. No endpoint imports the library and no record is read/written or migrated. PAY-002B1 [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) tracks pure command/attempt key construction; PAY-002B2 must add durable comparison/replay/leases/fencing/recovery/audit; PAY-002C/D persistence-first checkout; PAY-003B Event/version/evidence adoption including same-state amount changes and separate dispute records; PAY-004/PAY-005 cancellation/refund operations; MERCH-002/LEGAL-001 return/policy gates; #113 and a later migration child real inventory/dry-run. Firebase/Stripe deployment and live behavior remain unproven. | +| Foundation only for retry/duplication parts of RISK-003 and RISK-020 | PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). The pure source child defines a canonical UUID v4 boundary, a domain-separated caller/environment command hash, a command-type plus canonical frozen-payload fingerprint, and a deterministic environment/mode/operation/command/provider-attempt Stripe key. Invalid production/test or non-production/live pairs fail. Raw caller, UUID, operation, and payload values never appear in outputs. | Hashes are pseudonymous server-only identifiers—not anonymization—and equality is neither authorization nor durable replay proof. No endpoint imports the library; no Firestore record, lease, audit event, Stripe object, or result exists. PAY-002B2 must transactionally compare fingerprints, fence workers, preserve ambiguous provider attempts/keys, stop old/unknown-send-time automatic POST retries, reconcile evidence, and replay safe stored result pointers. PAY-002C/D and PAY-005 must adopt it with endpoint schemas and failure-boundary tests. Deployment, provider configuration, and live behavior remain unproven. | | RISK-008 | ABUSE-001A1 is tracked in live [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159). The source-only slice replaces the browser reCAPTCHA v3 constructor with the pinned Firebase SDK's Enterprise provider and preserves local/test/capability-callback shutdown, missing-key behavior, token refresh, Analytics shutdown, and fixed redacted failure diagnostics. Synthetic tests use no real key or provider call. | No Enterprise key, allowed-domain policy, provider console setting, protected build variable, token exchange, staged metric, callable enforcement, website/Firebase publication, or live behavior is configured or proven. Release `29254280177` failed closed before build because the public site-key variable was absent. Complete #113/#133, ABUSE-001A2, #136/WEB-001, staged metrics, and provider readback before enforcement or a live claim. | | RISK-017 | PAY-001A is tracked in live [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). The source slice adds dependency-free strict-root-object, root-budget, bounded string/array, integer-cents, fixed-currency, calendar-date, canonical-URL, conservative email, fixed-error, and closed-enum safe-log primitives. Synthetic hostile-input tests prove deep immutable copies and reject prototypes, accessors, and unknown configured root keys. The libraries contain no network, Firebase, Stripe, logger, clock, or random dependency and make no such call themselves; a supplied array item parser must be trusted, pure, and synchronous. | This library is not imported by an endpoint and is not deployed. PAY-001B/C/D must define exact race, merchandise, lookup, refund, admin, nested-object, and custom-field schemas; apply smaller business limits before every write/provider call; store immutable schema/price snapshots; and add endpoint no-side-effect tests. Safe-log primitives do not replace approved observability purpose, retention, access, transport, or provider controls. | | RISK-001, RISK-002, RISK-034 | PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) source merged as `87bd1210575882cdd06214bcb98ed38ce00f71c2`, adding durable Stripe Event deduplication, transactional transition/processed marking, paid/amount/currency/Checkout-mode/reference-shape and explicitly configured livemode checks, async outcomes, monotonic refund/terminal guards, quarantine, generic signature failures, and a webhook-secret-only binding. | Firebase was not deployed. Complete CONFIG/PAY-001/PAY-002 contracts, metadata schema allowlist/migration, canonical reducers/reservations/outbox, retry/dead-letter/TTL/alerts, emulator integration, Stripe test-mode rehearsal, protected deploy, and reconcile. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 2de09421..8cbdc45c 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -51,6 +51,8 @@ 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. +PAY-002B1 applies the same closed pairing to Stripe idempotency keys: `production` requires `live`; `local`, `test`, and `staging` require `test`; every other pair fails. The environment remains bound into the idempotency-key digest rather than being treated as an unverified label. + ### 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. @@ -224,14 +226,28 @@ Launch with quantity `1` unless cart and multi-line inventory semantics are expl } ``` -Returning the same request returns the same active Session. If the Session expired, a versioned new attempt can be created against the same business record under an explicit transition. +Within PAY-002B2's conservative stored safe-send window, returning the same request reuses the same parameters and Stripe idempotency key. After that window—or when first-send time is unknown—the server must stop automatic POST retries and reconcile stored/provider/webhook evidence before returning a Session or allowing an explicit new attempt. If a Session is proven expired, a versioned new attempt can be created against the same business record under an explicit transition. + +### Pure command identity and provider-key contract (PAY-002B1) + +PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). It defines three pure values for later server commands: + +1. A command key hashes contract version, environment, server-derived caller-scope kind/value, and one canonical lowercase UUID v4. It deliberately excludes command type, so reusing the same caller command ID for a different operation reaches the same future journal record and can be rejected. +2. A separate payload fingerprint hashes contract version, trusted command type, and the endpoint-schema-validated canonical payload. Object key order is normalized; array order remains meaningful; only deeply frozen, accessor-free, proxy-free, bounded JSON-like data with integer numbers is accepted. +3. A Stripe idempotency key hashes expected provider mode, bounded provider operation, command key, and immutable provider-attempt number. It exposes no raw caller, UUID, payload, business/provider ID, or personal value and stays below Stripe's 255-character limit. + +Version 1 supports only the closed caller scopes `firebase_uid`, `anonymous_principal`, and the fixed `internal_system` principal. It does not support multi-tenant Firebase Auth. A future multi-tenant version must bind the verified tenant identity into the command key rather than treating a UID alone as globally unique. + +A Firestore worker-lease takeover is not a new provider attempt. Here, provider attempt means one logical provider generation, not an HTTP retry count. A network error, timeout, Stripe `5xx`, or unknown outcome never advances it. Before a conservative stored first-send deadline, retry uses the exact same parameters, attempt, and key. Stripe may prune a key after at least 24 hours; after the safe deadline—or when first-send time is unknown—PAY-002B2 must stop automatic POST retries and reconcile stored provider references plus verified webhook/metadata or authorized operator evidence. A later provider attempt is allowed only after separately proven non-execution or an explicitly safe corrected/new logical operation. PAY-002B2 owns that journal, timestamps, stored fingerprint comparison, leases/fencing, safe result replay, fixed failures, reconciliation, and append-oriented audit. + +Command hashes and fingerprints are pseudonymous server-only identifiers, not anonymization. They must not be returned to browsers or placed in logs, URLs, analytics, screenshots, or public evidence. This source contract performs no authorization, Firestore/Stripe call, clock/random operation, deployment, or provider configuration. No endpoint imports it yet, so it does not currently prevent a duplicate payment operation. ## 7. Persistence-first checkout saga External Stripe calls cannot be part of a Firestore transaction. Use this sequence: 1. Validate the request and read server-controlled catalog/event state. -2. Derive the idempotency-record key and payload fingerprint. +2. Derive the PAY-002B1 command key and payload fingerprint; PAY-002B2 must persist and compare them before any provider call. 3. In one Firestore transaction: - Reuse a matching prior request or reject a conflicting reuse. - Lock/read the event capacity counter or SKU variant. @@ -239,12 +255,12 @@ External Stripe calls cannot be part of a Firestore transaction. Use this sequen - Increment `reserved` exactly once when applicable. - Create the registration/order in `checkout_creating` with an immutable expected-price snapshot. - Store `capacityHeld` or `inventoryHeld` so release is idempotent. -4. Create the Stripe Checkout Session with an idempotency key such as `registration:{id}:session:{attempt}` or `order:{id}:session:{attempt}`. +4. Create the Stripe Checkout Session with the PAY-002B1 versioned mode/operation/command-hash/provider-attempt key. Do not put a raw registration, order, caller, or provider ID in that key. 5. Include `client_reference_id`, `metadata.type`, `metadata.schemaVersion`, and the opaque local ID(s). 6. Store Session ID, URL, expiry, and attempt state. 7. Return the URL. 8. If Stripe definitively rejects creation, run a compensating transaction that marks the attempt failed and releases the hold once. -9. If the function loses its response after Stripe creates the Session, a retry uses the same Stripe key, receives the same Session, and completes step 6. +9. If the function loses its response after Stripe creates the Session, PAY-002B2 retries the exact POST only inside its stored safe-send window. After the deadline—or when first-send time is unknown—it stops POSTing and reconciles stored provider references plus verified webhook/metadata evidence before completing step 6. Create the local business record before calling Stripe. That lets a very fast webhook resolve metadata directly and eliminates the current record-not-found race. @@ -381,7 +397,7 @@ stateDiagram-v2 processing --> failed ``` -Text alternative: one Checkout attempt moves forward from creating to open, then to processing or one terminal result. A raw state regression never starts a second attempt; PAY-002B must give that attempt its own command identity. +Text alternative: one Checkout attempt moves forward from creating to open, then to processing or one terminal result. A raw state regression never starts a second attempt; PAY-002B2 must persist the B1 identity and fence that attempt. ### Registration state @@ -409,7 +425,7 @@ Each Stripe dispute has its own state. An order or registration never stores one The server's Stripe client requests select `2023-10-16`, but [webhook endpoint Event versioning](https://docs.stripe.com/webhooks/versioning) is independent and the provider setting is unverified. Current webhook source neither accepts `prevented` nor validates `event.api_version`. PAY-003B must inventory and pin/validate the endpoint Event contract, then add fixtures before adopting any newer value. -A same-state observation is `unchanged`, not proven duplicate. A different Event can carry a higher confirmed partial-refund total or new dispute evidence without changing the enum. PAY-003's Event inbox and PAY-002B/PAY-005 command IDs—not this reducer—prove replay. Because Stripe can deliver the first observed Event out of order, the pure reducer can accept a structurally supported first observation while the provider adapter remains responsible for signature, object, version, ownership, amount, currency, environment, and evidence checks. +A same-state observation is `unchanged`, not proven duplicate. A different Event can carry a higher confirmed partial-refund total or new dispute evidence without changing the enum. PAY-003's Event inbox and persisted PAY-002B2/PAY-005 command records—not this reducer or a B1 digest alone—prove replay. Because Stripe can deliver the first observed Event out of order, the pure reducer can accept a structurally supported first observation while the provider adapter remains responsible for signature, object, version, ownership, amount, currency, environment, and evidence checks. Every allowed edge is only a structurally possible change. It never authorizes a caller or proves a waiver, refund decision, Session state, return/write-off decision, or provider fact. PAY-004, PAY-005, MERCH-002, LEGAL-001, scoped authorization, and provider validation must supply those gates before runtime adoption. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 2b526eed..3cbc3c3a 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -245,7 +245,7 @@ The first release may continue using `admin` in the UI, but server endpoints and | Path or field | Purpose | | --- | --- | | `stripeEvents/{stripeEventId}` | Durable, non-PII webhook inbox/deduplication record with processing status and business reference | -| `checkoutRequests/{idempotencyKeyHash}` | Stable client request mapping and payload fingerprint for retry-safe Session creation | +| `checkoutRequests/{commandKeyHash}` | Server-only caller/environment command mapping. PAY-002B1 defines the pure hash/fingerprint/provider-key contract; PAY-002B2 must add transactional replay, conflict, lease/fencing, attempt, and audit state. | | `auditEvents/{eventId}` or bounded per-record audit subcollections | Append-oriented operational and security audit trail without unbounded arrays | | `events/{id}.capacityCounters` | Transactionally maintained participant reservations, paid seats, and released seats | | `products/{id}/variants/{variantId}` | SKU, option values, price, on-hand, reserved, and sold counts | @@ -261,6 +261,8 @@ Large `auditLog` arrays on registration and order documents should be replaced b PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). Numeric local `stateSchemaVersion: 1` is distinct from string Stripe metadata `schemaVersion: "1"`, which only versions provider reference binding. The reducers and legacy classifier are a pure source/test target: they import no Firebase or Stripe code, make no call, and are not used by an endpoint. Current records, webhook behavior, compatibility writes, real migration, deployment, and live state remain unchanged until later PAY-002/PAY-003 children adopt the contract. +PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). It is also pure and unused: it separates a caller/environment/UUID command key from a command-type/payload fingerprint, then derives a deterministic Stripe key for one immutable provider attempt. Production/live and non-production/test are the only accepted environment/mode pairs. The command key intentionally excludes command type so PAY-002B2 can reject reuse of one caller command ID for another operation. Hashes are pseudonymous server-only identifiers—not anonymization—and must not enter browsers, logs, URLs, or analytics. The library contains no Firestore transaction, lease, clock, result, audit, Stripe call, or authorization decision. PAY-002B2 must persist the contract version, endpoint schema version, semantic command type, fingerprint, and safe recovery state before any saga can claim retry safety. + ## 7. Business invariants The following are correctness rules, not UI preferences: @@ -351,7 +353,7 @@ sequenceDiagram F-->>W: Confirmed, processing, failed, or support-required state ``` -If Stripe Session creation fails, the function releases the reservation in a compensating transaction. If the function crashes after Stripe creates the Session, retrying the same request returns the same Session through Stripe and local idempotency keys. A scheduled job releases abandoned reservations and reconciles records that missed a webhook. +If Stripe Session creation fails, the function releases the reservation in a compensating transaction. If the function crashes after Stripe creates the Session, PAY-002B2 retries the exact POST only inside its stored safe-send window. After that deadline—or when first-send time is unknown—it stops POSTing and reconciles stored/provider/webhook evidence because Stripe may have pruned the idempotency key. A scheduled job releases abandoned reservations and reconciles records that missed a webhook. ### 8.2 Free or volunteer registration @@ -374,7 +376,7 @@ The ingress verifies method, raw payload, signature, and secret. Relevant event Stripe and Firestore cannot share a distributed transaction. Checkout and refunds are therefore explicit sagas: - A Firestore transaction protects local uniqueness and scarce-resource counters. -- A stable Stripe idempotency key protects external creation. +- A stable PAY-002B1 Stripe key identifies one logical provider generation. A lease takeover or HTTP retry is not a new generation. PAY-002B2 may retry exact parameters/key only inside its conservative stored send window; after that window or with unknown send time, it stops automatic POST and reconciles because Stripe may have pruned the old key. - The business record stores the saga step and external ID. - A compensating transaction releases a reservation after a known failure. - A webhook advances successful external state. diff --git a/functions/commerceCommandIdentity.js b/functions/commerceCommandIdentity.js new file mode 100644 index 00000000..619d4e7a --- /dev/null +++ b/functions/commerceCommandIdentity.js @@ -0,0 +1,502 @@ +const { createHash } = require('node:crypto'); +const { types: { isProxy } } = require('node:util'); + +const commandIdentityVersion = 1; +const INTERNAL_SYSTEM_PRINCIPAL = 'mprc_internal_system'; + +const COMMAND_IDENTITY_LIMITS = Object.freeze({ + maximumCallerScopeLength: 128, + maximumFirebaseUidCharacters: 128, + maximumFirebaseUidBytes: 512, + maximumCommandTypeLength: 64, + maximumProviderOperationLength: 64, + maximumProviderAttempt: 1000000, + maximumPayloadDepth: 6, + maximumPayloadEntries: 100, + maximumPayloadArrayLength: 50, + maximumPayloadBytes: 32768, + maximumPayloadStringBytes: 8192, + maximumPayloadKeyBytes: 256, +}); + +const ENVIRONMENTS = new Set(['local', 'test', 'staging', 'production']); +const CALLER_SCOPE_KINDS = new Set([ + 'firebase_uid', + 'anonymous_principal', + 'internal_system', +]); +const STRIPE_MODES = new Set(['test', 'live']); +const DANGEROUS_PAYLOAD_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +const SAFE_SCOPE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; +const SAFE_SLUG = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; +const CANONICAL_UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/; +const SAFE_STRIPE_KEY = /^[a-z0-9_]+$/; +const HASH_MAGIC = 'mprc-commerce-command-identity-sha256'; +const HASH_DOMAINS = Object.freeze({ + commandKey: 'mprc.command-key.v1', + payloadFingerprint: 'mprc.payload-fingerprint.v1', + stripeIdempotencyKey: 'mprc.stripe-idempotency-key.v1', +}); + +const ERROR_MESSAGES = Object.freeze({ + invalid_argument_shape: 'Commerce command identity arguments are invalid.', + invalid_environment: 'Commerce command environment is invalid.', + invalid_caller_scope: 'Commerce command caller scope is invalid.', + invalid_command_id: 'Commerce command ID is invalid.', + invalid_command_type: 'Commerce command type is invalid.', + invalid_payload: 'Commerce command payload is invalid.', + payload_not_frozen: 'Commerce command payload must be deeply frozen.', + payload_too_deep: 'Commerce command payload exceeds the depth limit.', + payload_too_many_entries: 'Commerce command payload exceeds the entry limit.', + payload_too_large: 'Commerce command payload exceeds the byte limit.', + invalid_stripe_mode: 'Commerce command Stripe mode is invalid.', + invalid_provider_operation: 'Commerce provider operation is invalid.', + invalid_command_key_hash: 'Commerce command key hash is invalid.', + invalid_provider_attempt: 'Commerce provider attempt is invalid.', +}); + +class CommandIdentityError extends Error { + constructor(reason) { + const safeReason = Object.prototype.hasOwnProperty.call(ERROR_MESSAGES, reason) + ? reason + : 'invalid_argument_shape'; + super(ERROR_MESSAGES[safeReason]); + this.name = 'CommandIdentityError'; + this.code = 'invalid_command_identity'; + this.reason = safeReason; + Object.freeze(this); + } +} + +function fail(reason) { + throw new CommandIdentityError(reason); +} + +function readExactPlainDataObject(value, expectedKeys, reason = 'invalid_argument_shape') { + if (value === null || typeof value !== 'object' || isProxy(value)) fail(reason); + + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + fail(reason); + } + + if (prototype !== Object.prototype || keys.length !== expectedKeys.length) fail(reason); + const ownStringKeys = new Set(); + for (const key of keys) { + if (typeof key !== 'string' || ownStringKeys.has(key)) fail(reason); + ownStringKeys.add(key); + } + if (expectedKeys.some((key) => !ownStringKeys.has(key))) fail(reason); + + for (const key in value) { + if (!ownStringKeys.has(key)) fail(reason); + } + + const result = Object.create(null); + for (const key of expectedKeys) { + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + fail(reason); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + fail(reason); + } + result[key] = descriptor.value; + } + return result; +} + +function isBoundedSafeAscii(value, expression, maximumLength) { + return typeof value === 'string' + && value.length > 0 + && value.length <= maximumLength + && expression.test(value); +} + +function validateEnvironment(environment) { + if (typeof environment !== 'string' || !ENVIRONMENTS.has(environment)) { + fail('invalid_environment'); + } +} + +function validateCommandId(commandId) { + if (typeof commandId !== 'string' || !CANONICAL_UUID_V4.test(commandId)) { + fail('invalid_command_id'); + } +} + +function validateCallerScope(callerScope) { + const scope = readExactPlainDataObject( + callerScope, + ['kind', 'value'], + 'invalid_caller_scope', + ); + if (typeof scope.kind !== 'string' || !CALLER_SCOPE_KINDS.has(scope.kind)) { + fail('invalid_caller_scope'); + } + if (scope.kind === 'firebase_uid') { + const validUid = typeof scope.value === 'string' + && scope.value.length > 0 + && hasValidUnicode(scope.value) + && [...scope.value].length <= COMMAND_IDENTITY_LIMITS.maximumFirebaseUidCharacters + && Buffer.byteLength(scope.value, 'utf8') <= COMMAND_IDENTITY_LIMITS.maximumFirebaseUidBytes + && !hasUidControlCharacter(scope.value); + if (!validUid) fail('invalid_caller_scope'); + } else if (scope.kind === 'anonymous_principal') { + if (!isBoundedSafeAscii( + scope.value, + SAFE_SCOPE_VALUE, + COMMAND_IDENTITY_LIMITS.maximumCallerScopeLength, + )) { + fail('invalid_caller_scope'); + } + } else if (scope.value !== INTERNAL_SYSTEM_PRINCIPAL) { + fail('invalid_caller_scope'); + } + return scope; +} + +function hasUidControlCharacter(value) { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint <= 0x1F || (codePoint >= 0x7F && codePoint <= 0x9F)) return true; + } + return false; +} + +function validateSafeSlug(value, maximumLength, reason) { + if (!isBoundedSafeAscii(value, SAFE_SLUG, maximumLength)) fail(reason); +} + +function unsignedLength(length) { + const buffer = Buffer.allocUnsafe(4); + buffer.writeUInt32BE(length, 0); + return buffer; +} + +function asBytes(value) { + return Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8'); +} + +function updateLengthFramed(hash, value) { + const bytes = asBytes(value); + hash.update(unsignedLength(bytes.length)); + hash.update(bytes); +} + +function utf8Field(name, value) { + return Object.freeze({ name, type: 'utf8', value: Buffer.from(value, 'utf8') }); +} + +function bytesField(name, value) { + return Object.freeze({ name, type: 'bytes', value }); +} + +function digest(domain, fields) { + const hash = createHash('sha256'); + updateLengthFramed(hash, HASH_MAGIC); + updateLengthFramed(hash, domain); + for (const field of fields) { + updateLengthFramed(hash, field.name); + updateLengthFramed(hash, field.type); + updateLengthFramed(hash, field.value); + } + return hash.digest('hex'); +} + +function hasValidUnicode(value) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xD800 && code <= 0xDBFF) { + if (index + 1 >= value.length) return false; + const next = value.charCodeAt(index + 1); + if (next < 0xDC00 || next > 0xDFFF) return false; + index += 1; + } else if (code >= 0xDC00 && code <= 0xDFFF) { + return false; + } + } + return true; +} + +class CanonicalWriter { + constructor() { + this.chunks = []; + this.length = 0; + } + + push(buffer) { + if (this.length + buffer.length > COMMAND_IDENTITY_LIMITS.maximumPayloadBytes) { + fail('payload_too_large'); + } + this.chunks.push(buffer); + this.length += buffer.length; + } + + tag(tag) { + this.push(Buffer.from(tag, 'ascii')); + } + + count(value) { + this.push(unsignedLength(value)); + } + + text(value, maximumBytes) { + if (!hasValidUnicode(value)) fail('invalid_payload'); + if (value.length > maximumBytes) fail('payload_too_large'); + const bytes = Buffer.from(value, 'utf8'); + if (bytes.length > maximumBytes) fail('payload_too_large'); + this.count(bytes.length); + this.push(bytes); + } + + finish() { + return Buffer.concat(this.chunks, this.length); + } +} + +function inspectStructuredValue(value, expectedPrototype, reason = 'invalid_payload') { + if (isProxy(value)) fail(reason); + let prototype; + let keys; + let frozen; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + frozen = Object.isFrozen(value); + } catch { + fail(reason); + } + if (prototype !== expectedPrototype) fail(reason); + if (!frozen) fail('payload_not_frozen'); + + const ownStringKeys = new Set(); + for (const key of keys) { + if (typeof key !== 'string' || ownStringKeys.has(key)) fail(reason); + ownStringKeys.add(key); + } + for (const key in value) { + if (!ownStringKeys.has(key)) fail(reason); + } + return { keys, ownStringKeys }; +} + +function readPayloadDataDescriptor(value, key) { + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + fail('invalid_payload'); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + fail('invalid_payload'); + } + return descriptor.value; +} + +function addEntries(state, count) { + state.entries += count; + if (state.entries > COMMAND_IDENTITY_LIMITS.maximumPayloadEntries) { + fail('payload_too_many_entries'); + } +} + +function encodePayloadValue(value, writer, state, depth) { + if (depth > COMMAND_IDENTITY_LIMITS.maximumPayloadDepth) fail('payload_too_deep'); + + if (value === null) { + writer.tag('z'); + return; + } + + if (typeof value === 'boolean') { + writer.tag(value ? 't' : 'f'); + return; + } + + if (typeof value === 'string') { + writer.tag('s'); + writer.text(value, COMMAND_IDENTITY_LIMITS.maximumPayloadStringBytes); + return; + } + + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || Object.is(value, -0)) fail('invalid_payload'); + writer.tag('i'); + writer.text(String(value), 32); + return; + } + + if (typeof value !== 'object') fail('invalid_payload'); + if (isProxy(value)) fail('invalid_payload'); + if (state.active.has(value)) fail('invalid_payload'); + state.active.add(value); + try { + if (Array.isArray(value)) { + const { keys, ownStringKeys } = inspectStructuredValue(value, Array.prototype); + if (!ownStringKeys.has('length') || keys.length !== value.length + 1) { + fail('invalid_payload'); + } + if (value.length > COMMAND_IDENTITY_LIMITS.maximumPayloadArrayLength) { + fail('payload_too_many_entries'); + } + addEntries(state, value.length); + writer.tag('a'); + writer.count(value.length); + for (let index = 0; index < value.length; index += 1) { + const key = String(index); + if (!ownStringKeys.has(key)) fail('invalid_payload'); + encodePayloadValue( + readPayloadDataDescriptor(value, key), + writer, + state, + depth + 1, + ); + } + return; + } + + const { keys } = inspectStructuredValue(value, null); + if (keys.length > COMMAND_IDENTITY_LIMITS.maximumPayloadEntries) { + fail('payload_too_many_entries'); + } + const sortedKeys = [...keys].sort(); + addEntries(state, sortedKeys.length); + writer.tag('o'); + writer.count(sortedKeys.length); + for (const key of sortedKeys) { + if (DANGEROUS_PAYLOAD_KEYS.has(key) || !hasValidUnicode(key)) { + fail('invalid_payload'); + } + writer.tag('k'); + writer.text(key, COMMAND_IDENTITY_LIMITS.maximumPayloadKeyBytes); + encodePayloadValue( + readPayloadDataDescriptor(value, key), + writer, + state, + depth + 1, + ); + } + } finally { + state.active.delete(value); + } +} + +function canonicalPayload(payload) { + if (payload === null + || typeof payload !== 'object' + || isProxy(payload) + || Array.isArray(payload)) { + fail('invalid_payload'); + } + const writer = new CanonicalWriter(); + encodePayloadValue(payload, writer, { entries: 0, active: new WeakSet() }, 0); + return writer.finish(); +} + +function createCommandKey(input) { + const values = readExactPlainDataObject( + input, + ['environment', 'callerScope', 'commandId'], + ); + validateEnvironment(values.environment); + const scope = validateCallerScope(values.callerScope); + validateCommandId(values.commandId); + + const commandKeyHash = digest(HASH_DOMAINS.commandKey, [ + utf8Field('version', String(commandIdentityVersion)), + utf8Field('environment', values.environment), + utf8Field('caller-scope-kind', scope.kind), + utf8Field('caller-scope-value', scope.value), + utf8Field('command-id', values.commandId), + ]); + return Object.freeze({ version: commandIdentityVersion, commandKeyHash }); +} + +function createPayloadFingerprint(input) { + const values = readExactPlainDataObject(input, ['commandType', 'payload']); + validateSafeSlug( + values.commandType, + COMMAND_IDENTITY_LIMITS.maximumCommandTypeLength, + 'invalid_command_type', + ); + const encodedPayload = canonicalPayload(values.payload); + const payloadFingerprint = digest(HASH_DOMAINS.payloadFingerprint, [ + utf8Field('version', String(commandIdentityVersion)), + utf8Field('command-type', values.commandType), + bytesField('canonical-payload', encodedPayload), + ]); + return Object.freeze({ version: commandIdentityVersion, payloadFingerprint }); +} + +function createStripeIdempotencyKey(input) { + const values = readExactPlainDataObject(input, [ + 'stripeMode', + 'environment', + 'providerOperation', + 'commandKeyHash', + 'providerAttempt', + ]); + if (typeof values.stripeMode !== 'string' || !STRIPE_MODES.has(values.stripeMode)) { + fail('invalid_stripe_mode'); + } + validateEnvironment(values.environment); + const expectedMode = values.environment === 'production' ? 'live' : 'test'; + if (values.stripeMode !== expectedMode) fail('invalid_stripe_mode'); + validateSafeSlug( + values.providerOperation, + COMMAND_IDENTITY_LIMITS.maximumProviderOperationLength, + 'invalid_provider_operation', + ); + if (typeof values.commandKeyHash !== 'string' + || !LOWERCASE_SHA256.test(values.commandKeyHash)) { + fail('invalid_command_key_hash'); + } + if (!Number.isSafeInteger(values.providerAttempt) + || values.providerAttempt < 1 + || values.providerAttempt > COMMAND_IDENTITY_LIMITS.maximumProviderAttempt) { + fail('invalid_provider_attempt'); + } + + const keyDigest = digest(HASH_DOMAINS.stripeIdempotencyKey, [ + utf8Field('version', String(commandIdentityVersion)), + utf8Field('environment', values.environment), + utf8Field('stripe-mode', values.stripeMode), + utf8Field('provider-operation', values.providerOperation), + utf8Field('command-key-hash', values.commandKeyHash), + utf8Field('provider-attempt', String(values.providerAttempt)), + ]); + const stripeIdempotencyKey = `mprc_ci_v${commandIdentityVersion}_${values.stripeMode}_${keyDigest}`; + if (stripeIdempotencyKey.length >= 255 || !SAFE_STRIPE_KEY.test(stripeIdempotencyKey)) { + fail('invalid_provider_operation'); + } + return Object.freeze({ version: commandIdentityVersion, stripeIdempotencyKey }); +} + +Object.freeze(CommandIdentityError.prototype); +Object.freeze(CommandIdentityError); + +module.exports = Object.freeze({ + commandIdentityVersion, + INTERNAL_SYSTEM_PRINCIPAL, + COMMAND_IDENTITY_LIMITS, + CommandIdentityError, + createCommandKey, + createPayloadFingerprint, + createStripeIdempotencyKey, +}); diff --git a/functions/commerceCommandIdentity.test.js b/functions/commerceCommandIdentity.test.js new file mode 100644 index 00000000..bbe9466e --- /dev/null +++ b/functions/commerceCommandIdentity.test.js @@ -0,0 +1,847 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { inspect } = require('node:util'); + +const { + commandIdentityVersion, + INTERNAL_SYSTEM_PRINCIPAL, + COMMAND_IDENTITY_LIMITS, + CommandIdentityError, + createCommandKey, + createPayloadFingerprint, + createStripeIdempotencyKey, +} = require('./commerceCommandIdentity'); + +const COMMAND_ID = '018f1f6a-9d2b-4c3d-8e5f-0123456789ab'; +const OTHER_COMMAND_ID = '123e4567-e89b-42d3-a456-426614174000'; +const RAW_CALLER = 'private-runner@example.test'; +const HOSTILE_CANARY = 'private-runner@example.test/token?secret=do-not-copy'; +const GOLDEN_COMMAND_HASH = 'c00de203eff15a20861ca8503464f2f89cccc7f4a0649171f1eef3435ba057eb'; +const GOLDEN_PAYLOAD_HASH = 'b7876f30027ff2f25d4387eb276c7387bf7f784f0d1e7dcbab5906e8519f2650'; +const GOLDEN_STRIPE_KEY = 'mprc_ci_v1_test_4dd719b9bbb0f0f7a00a1bc08ff00cc1a92be1acb1f92e1151a3ce8ba3a85eff'; + +function frozenRecord(entries = []) { + const value = Object.create(null); + for (const [key, child] of entries) value[key] = child; + return Object.freeze(value); +} + +function commandArgs(overrides = {}) { + return { + environment: 'test', + callerScope: { kind: 'firebase_uid', value: 'uid_abc123' }, + commandId: COMMAND_ID, + ...overrides, + }; +} + +function stripeArgs(commandKeyHash, overrides = {}) { + return { + stripeMode: 'test', + environment: 'test', + providerOperation: 'checkout.session.create', + commandKeyHash, + providerAttempt: 1, + ...overrides, + }; +} + +function goldenPayload() { + return frozenRecord([ + ['amountCents', 2500], + ['currency', 'usd'], + ['target', frozenRecord([['eventId', 'race_2026']])], + ['lines', Object.freeze([ + frozenRecord([ + ['sku', 'shirt_s'], + ['quantity', 1], + ]), + ])], + ]); +} + +function captureError(callback) { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected callback to throw'); +} + +function expectSafeError(callback, reason, rawValue = HOSTILE_CANARY) { + const error = captureError(callback); + expect(error).toBeInstanceOf(CommandIdentityError); + expect(error).toMatchObject({ + name: 'CommandIdentityError', + code: 'invalid_command_identity', + reason, + }); + expect(Object.isFrozen(error)).toBe(true); + const serialized = [ + error.message, + error.stack, + String(error), + JSON.stringify(error), + inspect(error), + ].join('\n'); + if (rawValue) expect(serialized).not.toContain(rawValue); + expect(serialized).not.toContain(HOSTILE_CANARY); + return error; +} + +describe('versioned command identity wire contract', () => { + test('exports a closed frozen API and fixed bounds', () => { + const api = require('./commerceCommandIdentity'); + expect(commandIdentityVersion).toBe(1); + expect(INTERNAL_SYSTEM_PRINCIPAL).toBe('mprc_internal_system'); + expect(COMMAND_IDENTITY_LIMITS.maximumProviderAttempt).toBe(1000000); + expect(Object.isFrozen(api)).toBe(true); + expect(Object.isFrozen(COMMAND_IDENTITY_LIMITS)).toBe(true); + expect(Object.isFrozen(CommandIdentityError)).toBe(true); + expect(Object.isFrozen(CommandIdentityError.prototype)).toBe(true); + }); + + test('matches hard-coded golden command, payload, and Stripe outputs', () => { + const command = createCommandKey(commandArgs()); + const fingerprint = createPayloadFingerprint({ + commandType: 'race.checkout.create', + payload: goldenPayload(), + }); + const stripe = createStripeIdempotencyKey(stripeArgs(command.commandKeyHash)); + + expect(command).toEqual({ version: 1, commandKeyHash: GOLDEN_COMMAND_HASH }); + expect(fingerprint).toEqual({ version: 1, payloadFingerprint: GOLDEN_PAYLOAD_HASH }); + expect(stripe).toEqual({ version: 1, stripeIdempotencyKey: GOLDEN_STRIPE_KEY }); + for (const result of [command, fingerprint, stripe]) expect(Object.isFrozen(result)).toBe(true); + }); + + test('is stable and separates every command-key dimension', () => { + const baseline = createCommandKey(commandArgs()).commandKeyHash; + expect(createCommandKey(commandArgs()).commandKeyHash).toBe(baseline); + expect(new Set([ + baseline, + createCommandKey(commandArgs({ environment: 'local' })).commandKeyHash, + createCommandKey(commandArgs({ + callerScope: { kind: 'anonymous_principal', value: 'uid_abc123' }, + })).commandKeyHash, + createCommandKey(commandArgs({ + callerScope: { kind: 'firebase_uid', value: 'uid_xyz789' }, + })).commandKeyHash, + createCommandKey(commandArgs({ commandId: OTHER_COMMAND_ID })).commandKeyHash, + ])).toHaveProperty('size', 5); + }); + + test('length frames and field tags distinguish swapped and concatenation-like tuples', () => { + const first = createCommandKey(commandArgs({ + environment: 'test', + callerScope: { kind: 'firebase_uid', value: 'production' }, + })).commandKeyHash; + const swapped = createCommandKey(commandArgs({ + environment: 'production', + callerScope: { kind: 'firebase_uid', value: 'test' }, + })).commandKeyHash; + const shortLeft = createCommandKey(commandArgs({ + callerScope: { kind: 'firebase_uid', value: 'a:bc' }, + })).commandKeyHash; + const shortRight = createCommandKey(commandArgs({ + callerScope: { kind: 'firebase_uid', value: 'ab:c' }, + })).commandKeyHash; + expect(new Set([first, swapped, shortLeft, shortRight]).size).toBe(4); + }); +}); + +describe('canonical UUID and trusted caller scope', () => { + test.each([ + undefined, + null, + '', + ` ${COMMAND_ID}`, + `${COMMAND_ID} `, + COMMAND_ID.toUpperCase(), + `{${COMMAND_ID}}`, + `urn:uuid:${COMMAND_ID}`, + `${COMMAND_ID}\u0000`, + '00000000-0000-0000-0000-000000000000', + '018f1f6a-9d2b-3c3d-8e5f-0123456789ab', + '018f1f6a-9d2b-4c3d-0e5f-0123456789ab', + '018f1f6a-9d2b-4c3d-1e5f-0123456789ab', + '018f1f6a-9d2b-4c3d-fe5f-0123456789ab', + '018f1f6a-9d2b-4c3d-7e5f-0123456789ab', + '018f1f6a-9d2b-4c3d-ce5f-0123456789ab', + '018f1f6a‐9d2b-4c3d-8e5f-0123456789ab', + '018f1f6a-9d2b-4c3d-8e5f-0123456789ab', + 123, + ])('rejects noncanonical UUID case %# without reflecting it', (commandId) => { + expectSafeError( + () => createCommandKey(commandArgs({ commandId })), + 'invalid_command_id', + typeof commandId === 'string' ? commandId : HOSTILE_CANARY, + ); + }); + + test.each(['local', 'test', 'staging', 'production'])('accepts exact environment %s', (environment) => { + expect(createCommandKey(commandArgs({ environment })).commandKeyHash).toMatch(/^[0-9a-f]{64}$/); + }); + + test.each(['dev', 'prod', 'TEST', '', null, HOSTILE_CANARY])('rejects environment case %#', (environment) => { + expectSafeError( + () => createCommandKey(commandArgs({ environment })), + 'invalid_environment', + typeof environment === 'string' ? environment : HOSTILE_CANARY, + ); + }); + + test('accepts an email-looking verified Firebase UID but hashes it out of output', () => { + const result = createCommandKey(commandArgs({ + callerScope: { kind: 'firebase_uid', value: RAW_CALLER }, + })); + expect(JSON.stringify(result)).not.toContain(RAW_CALLER); + expect(result.commandKeyHash).toMatch(/^[0-9a-f]{64}$/); + }); + + test('accepts each closed caller kind and only the fixed internal principal', () => { + const scopes = [ + { kind: 'firebase_uid', value: 'custom uid/with+provider@example.test' }, + { kind: 'anonymous_principal', value: 'anon_01J0MPRC:server' }, + { kind: 'internal_system', value: INTERNAL_SYSTEM_PRINCIPAL }, + ]; + for (const callerScope of scopes) { + expect(createCommandKey(commandArgs({ callerScope })).commandKeyHash) + .toMatch(/^[0-9a-f]{64}$/); + } + expectSafeError( + () => createCommandKey(commandArgs({ + callerScope: { kind: 'internal_system', value: 'another_system' }, + })), + 'invalid_caller_scope', + 'another_system', + ); + }); + + test.each([ + { kind: 'email', value: 'uid_abc123' }, + { kind: 'phone', value: 'uid_abc123' }, + { kind: 'firebase_uid', value: '' }, + { kind: 'firebase_uid', value: 'uid\u0000canary' }, + { kind: 'firebase_uid', value: '\uD800' }, + { kind: 'anonymous_principal', value: RAW_CALLER }, + { kind: 'anonymous_principal', value: ' spaced' }, + { kind: 'browser_role', value: 'admin' }, + ])('rejects forbidden or malformed caller scope %#', (callerScope) => { + expectSafeError( + () => createCommandKey(commandArgs({ callerScope })), + 'invalid_caller_scope', + callerScope.value || HOSTILE_CANARY, + ); + }); +}); + +describe('canonical deeply frozen payload fingerprint', () => { + test('ignores object insertion order but preserves nested array order', () => { + const first = frozenRecord([ + ['z', frozenRecord([['second', 2], ['first', 1]])], + ['a', Object.freeze(['one', 'two'])], + ]); + const equivalent = frozenRecord([ + ['a', Object.freeze(['one', 'two'])], + ['z', frozenRecord([['first', 1], ['second', 2]])], + ]); + const reordered = frozenRecord([ + ['z', frozenRecord([['second', 2], ['first', 1]])], + ['a', Object.freeze(['two', 'one'])], + ]); + const one = createPayloadFingerprint({ commandType: 'race.checkout.create', payload: first }); + const two = createPayloadFingerprint({ commandType: 'race.checkout.create', payload: equivalent }); + const three = createPayloadFingerprint({ commandType: 'race.checkout.create', payload: reordered }); + expect(one.payloadFingerprint).toBe(two.payloadFingerprint); + expect(three.payloadFingerprint).not.toBe(one.payloadFingerprint); + }); + + test('changes for command type, JSON-like type, and every value', () => { + const inputs = [null, true, false, 0, 1, '1', Object.freeze([1]), frozenRecord([['v', 1]])] + .map((value) => frozenRecord([['value', value]])); + const fingerprints = inputs.map((payload) => createPayloadFingerprint({ + commandType: 'race.checkout.create', + payload, + }).payloadFingerprint); + expect(new Set(fingerprints).size).toBe(inputs.length); + const firstType = createPayloadFingerprint({ + commandType: 'race.checkout.create', + payload: frozenRecord([['value', 1]]), + }); + const secondType = createPayloadFingerprint({ + commandType: 'merch.checkout.create', + payload: frozenRecord([['value', 1]]), + }); + expect(firstType.payloadFingerprint).not.toBe(secondType.payloadFingerprint); + expect(createCommandKey(commandArgs()).commandKeyHash) + .toBe(createCommandKey(commandArgs()).commandKeyHash); + }); + + test('enforces the command-type grammar and bound without echoing input', () => { + expect(createPayloadFingerprint({ + commandType: 'a'.repeat(COMMAND_IDENTITY_LIMITS.maximumCommandTypeLength), + payload: frozenRecord(), + }).payloadFingerprint).toMatch(/^[0-9a-f]{64}$/); + for (const commandType of [ + '', + 'Race.Checkout', + 'race checkout', + 'race/checkout', + 'race..checkout', + `${'a'.repeat(COMMAND_IDENTITY_LIMITS.maximumCommandTypeLength)}b`, + HOSTILE_CANARY, + ]) { + expectSafeError( + () => createPayloadFingerprint({ commandType, payload: frozenRecord() }), + 'invalid_command_type', + commandType || HOSTILE_CANARY, + ); + } + }); + + test('accepts safe integer boundaries and rejects all other numeric forms', () => { + const valid = frozenRecord([ + ['minimum', Number.MIN_SAFE_INTEGER], + ['maximum', Number.MAX_SAFE_INTEGER], + ['zero', 0], + ]); + expect(createPayloadFingerprint({ commandType: 'refund.request', payload: valid }) + .payloadFingerprint).toMatch(/^[0-9a-f]{64}$/); + for (const value of [-0, 1.5, NaN, Infinity, -Infinity, Number.MAX_SAFE_INTEGER + 1]) { + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'refund.request', + payload: frozenRecord([['value', value]]), + }), + 'invalid_payload', + ); + } + }); + + test('keeps valid Unicode exact bytes distinct and rejects malformed Unicode', () => { + const composed = createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['value', 'caf\u00e9']]), + }); + const decomposed = createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['value', 'cafe\u0301']]), + }); + expect(composed.payloadFingerprint).not.toBe(decomposed.payloadFingerprint); + for (const payload of ['\uD800', '\uDC00', `safe${String.fromCharCode(0xD800)}tail`]) { + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['value', payload]]), + }), + 'invalid_payload', + ); + } + }); + + test('requires null-prototype records and recursive freezing', () => { + const mutableRoot = Object.create(null); + mutableRoot.value = 1; + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload: mutableRoot }), + 'payload_not_frozen', + ); + + const mutableNested = Object.create(null); + mutableNested.value = 1; + const frozenRoot = frozenRecord([['nested', mutableNested]]); + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload: frozenRoot }), + 'payload_not_frozen', + ); + + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: Object.freeze({ value: 1 }), + }), + 'invalid_payload', + ); + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: Object.freeze(Object.create({ polluted: true })), + }), + 'invalid_payload', + ); + }); + + test.each([null, true, 1, 'value', Object.freeze([frozenRecord([['value', 1]])])])( + 'rejects a non-record payload root: %p', + (payload) => { + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload }), + 'invalid_payload', + ); + }, + ); + + test('rejects live and revoked root or nested proxies before any reflection trap', () => { + let trapCalls = 0; + const liveProxy = new Proxy(frozenRecord([['value', 1]]), { + getPrototypeOf() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + ownKeys() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + getOwnPropertyDescriptor() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + get() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + }); + const revocable = Proxy.revocable(frozenRecord([['value', 1]]), {}); + revocable.revoke(); + + for (const candidate of [liveProxy, revocable.proxy]) { + for (const payload of [candidate, frozenRecord([['nested', candidate]])]) { + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload }), + 'invalid_payload', + ); + } + } + expect(trapCalls).toBe(0); + }); + + test('rejects cycles, symbols, dangerous keys, sparse arrays, and extra array keys', () => { + const cycle = Object.create(null); + cycle.self = cycle; + Object.freeze(cycle); + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload: cycle }), + 'invalid_payload', + ); + + const symbolRecord = Object.create(null); + symbolRecord.safe = true; + symbolRecord[Symbol('private')] = HOSTILE_CANARY; + Object.freeze(symbolRecord); + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload: symbolRecord }), + 'invalid_payload', + ); + + for (const key of ['__proto__', 'constructor', 'prototype']) { + const dangerous = Object.create(null); + Object.defineProperty(dangerous, key, { + value: HOSTILE_CANARY, + enumerable: true, + configurable: true, + writable: true, + }); + Object.freeze(dangerous); + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload: dangerous }), + 'invalid_payload', + ); + } + + const sparse = new Array(2); + sparse[0] = 'one'; + Object.freeze(sparse); + const extra = ['one']; + extra.extra = 'two'; + Object.freeze(extra); + for (const nestedArray of [sparse, extra]) { + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['items', nestedArray]]), + }), + 'invalid_payload', + ); + } + }); + + test('allows shared frozen aliases but rejects cycles', () => { + const shared = frozenRecord([['value', 7]]); + const aliased = frozenRecord([['left', shared], ['right', shared]]); + const copied = frozenRecord([ + ['left', frozenRecord([['value', 7]])], + ['right', frozenRecord([['value', 7]])], + ]); + expect(createPayloadFingerprint({ commandType: 'race.update', payload: aliased })) + .toEqual(createPayloadFingerprint({ commandType: 'race.update', payload: copied })); + }); + + test.each([ + new Date('2026-01-01T00:00:00Z'), + new Map([['value', 1]]), + new Set([1]), + Buffer.from('synthetic'), + new Uint8Array([1]), + new (class Example { constructor() { this.value = 1; } })(), + ])('rejects non-JSON object type case %# without inspecting contents', (nested) => { + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['value', nested]]), + }), + 'invalid_payload', + ); + }); + + test('rejects Object.prototype pollution before reading inherited values', () => { + Object.defineProperty(Object.prototype, 'temporaryCommandIdentityProbe', { + value: HOSTILE_CANARY, + enumerable: true, + configurable: true, + }); + try { + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['value', 1]]), + }), + 'invalid_argument_shape', + ); + } finally { + delete Object.prototype.temporaryCommandIdentityProbe; + } + }); + + test('rejects accessors and proxies without invoking hostile code', () => { + let getterCalls = 0; + const accessor = Object.create(null); + Object.defineProperty(accessor, 'secret', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + throw new Error(HOSTILE_CANARY); + }, + }); + Object.freeze(accessor); + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['nested', accessor]]), + }), + 'invalid_payload', + ); + expect(getterCalls).toBe(0); + + let trapCalls = 0; + const proxy = new Proxy(frozenRecord([['safe', true]]), { + getPrototypeOf() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + ownKeys() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + getOwnPropertyDescriptor() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + get() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + }); + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['nested', proxy]]), + }), + 'invalid_payload', + ); + expect(trapCalls).toBe(0); + }); + + test('enforces depth, entry, key, string, and total byte limits', () => { + let nested = 1; + for (let index = 1; index < COMMAND_IDENTITY_LIMITS.maximumPayloadDepth; index += 1) { + nested = Object.freeze([nested]); + } + const atDepth = frozenRecord([['nested', nested]]); + expect(createPayloadFingerprint({ commandType: 'race.update', payload: atDepth }) + .payloadFingerprint).toMatch(/^[0-9a-f]{64}$/); + const tooDeep = frozenRecord([['nested', Object.freeze([nested])]]); + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload: tooDeep }), + 'payload_too_deep', + ); + + const maximumEntries = frozenRecord(Array.from( + { length: COMMAND_IDENTITY_LIMITS.maximumPayloadEntries }, + (_unused, index) => [`value${index}`, null], + )); + expect(createPayloadFingerprint({ commandType: 'race.update', payload: maximumEntries }) + .payloadFingerprint).toMatch(/^[0-9a-f]{64}$/); + const tooMany = frozenRecord(Array.from( + { length: COMMAND_IDENTITY_LIMITS.maximumPayloadEntries + 1 }, + (_unused, index) => [`value${index}`, null], + )); + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload: tooMany }), + 'payload_too_many_entries', + ); + + const maximumArray = frozenRecord([['items', Object.freeze( + Array(COMMAND_IDENTITY_LIMITS.maximumPayloadArrayLength).fill(null), + )]]); + expect(createPayloadFingerprint({ commandType: 'race.update', payload: maximumArray }) + .payloadFingerprint).toMatch(/^[0-9a-f]{64}$/); + const oversizedArray = frozenRecord([['items', Object.freeze( + Array(COMMAND_IDENTITY_LIMITS.maximumPayloadArrayLength + 1).fill(null), + )]]); + expectSafeError( + () => createPayloadFingerprint({ commandType: 'race.update', payload: oversizedArray }), + 'payload_too_many_entries', + ); + + const maximumKey = 'k'.repeat(COMMAND_IDENTITY_LIMITS.maximumPayloadKeyBytes); + expect(createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([[maximumKey, true]]), + }).payloadFingerprint).toMatch(/^[0-9a-f]{64}$/); + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([[ + `${maximumKey}x`, + true, + ]]), + }), + 'payload_too_large', + ); + + const maximumString = 'x'.repeat(COMMAND_IDENTITY_LIMITS.maximumPayloadStringBytes); + expect(createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['value', maximumString]]), + }) + .payloadFingerprint).toMatch(/^[0-9a-f]{64}$/); + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['value', `${maximumString}x`]]), + }), + 'payload_too_large', + ); + expectSafeError( + () => createPayloadFingerprint({ + commandType: 'race.update', + payload: frozenRecord([['values', Object.freeze([ + maximumString, + maximumString, + maximumString, + maximumString, + ])]]), + }), + 'payload_too_large', + ); + }); +}); + +describe('deterministic Stripe idempotency key', () => { + const commandKeyHash = GOLDEN_COMMAND_HASH; + + test('is stable, safe, short, and contains no raw command material', () => { + const first = createStripeIdempotencyKey(stripeArgs(commandKeyHash)); + const second = createStripeIdempotencyKey(stripeArgs(commandKeyHash)); + expect(first).toEqual(second); + expect(first.stripeIdempotencyKey).toMatch(/^[a-z0-9_]+$/); + expect(first.stripeIdempotencyKey.length).toBeLessThan(255); + for (const raw of [COMMAND_ID, RAW_CALLER, HOSTILE_CANARY, 'checkout.session.create']) { + expect(first.stripeIdempotencyKey).not.toContain(raw); + } + }); + + test('separates environment, mode, operation, command, and logical provider attempt', () => { + const keys = [ + createStripeIdempotencyKey(stripeArgs(commandKeyHash)).stripeIdempotencyKey, + createStripeIdempotencyKey(stripeArgs(commandKeyHash, { + environment: 'local', + })).stripeIdempotencyKey, + createStripeIdempotencyKey(stripeArgs(commandKeyHash, { + environment: 'staging', + })).stripeIdempotencyKey, + createStripeIdempotencyKey(stripeArgs(commandKeyHash, { + environment: 'production', + stripeMode: 'live', + })).stripeIdempotencyKey, + createStripeIdempotencyKey(stripeArgs(commandKeyHash, { + providerOperation: 'refund.create', + })).stripeIdempotencyKey, + createStripeIdempotencyKey(stripeArgs('a'.repeat(64))).stripeIdempotencyKey, + createStripeIdempotencyKey(stripeArgs(commandKeyHash, { + providerAttempt: 2, + })).stripeIdempotencyKey, + ]; + expect(new Set(keys).size).toBe(keys.length); + }); + + test.each([ + ['production', 'test'], + ['local', 'live'], + ['test', 'live'], + ['staging', 'live'], + ])('rejects environment/mode mismatch %s/%s', (environment, stripeMode) => { + expectSafeError( + () => createStripeIdempotencyKey(stripeArgs(commandKeyHash, { + environment, + stripeMode, + })), + 'invalid_stripe_mode', + ); + }); + + test('accepts the fixed provider-attempt bounds and rejects invalid attempts', () => { + for (const providerAttempt of [1, COMMAND_IDENTITY_LIMITS.maximumProviderAttempt]) { + expect(createStripeIdempotencyKey(stripeArgs(commandKeyHash, { providerAttempt })) + .stripeIdempotencyKey).toMatch(/^[a-z0-9_]+$/); + } + for (const providerAttempt of [ + 0, + -0, + -1, + 1.5, + NaN, + Infinity, + 1n, + Object(1), + '1', + COMMAND_IDENTITY_LIMITS.maximumProviderAttempt + 1, + ]) { + expectSafeError( + () => createStripeIdempotencyKey(stripeArgs(commandKeyHash, { providerAttempt })), + 'invalid_provider_attempt', + ); + } + }); + + test.each([ + '', + 'Checkout.Create', + 'checkout create', + 'checkout/create', + 'checkout..create', + HOSTILE_CANARY, + ])('rejects unsafe provider operation case %#', (providerOperation) => { + expectSafeError( + () => createStripeIdempotencyKey(stripeArgs(commandKeyHash, { providerOperation })), + 'invalid_provider_operation', + providerOperation || HOSTILE_CANARY, + ); + }); + + test('accepts the maximum provider-operation length and rejects the next byte', () => { + const maximum = 'a'.repeat(COMMAND_IDENTITY_LIMITS.maximumProviderOperationLength); + expect(createStripeIdempotencyKey(stripeArgs(commandKeyHash, { + providerOperation: maximum, + })).stripeIdempotencyKey).toMatch(/^[a-z0-9_]+$/); + expectSafeError( + () => createStripeIdempotencyKey(stripeArgs(commandKeyHash, { + providerOperation: `${maximum}b`, + })), + 'invalid_provider_operation', + ); + }); + + test.each(['', 'A'.repeat(64), 'g'.repeat(64), 'a'.repeat(63), HOSTILE_CANARY, null])( + 'rejects noncanonical command hash case %#', + (commandKeyHashValue) => { + expectSafeError( + () => createStripeIdempotencyKey(stripeArgs(commandKeyHashValue)), + 'invalid_command_key_hash', + typeof commandKeyHashValue === 'string' ? commandKeyHashValue : HOSTILE_CANARY, + ); + }, + ); +}); + +describe('fail-closed shape and side-effect boundary', () => { + test('rejects missing, extra, accessor, symbol, custom-prototype, and proxy arguments', () => { + expectSafeError(() => createCommandKey({}), 'invalid_argument_shape'); + expectSafeError( + () => createCommandKey({ ...commandArgs(), extra: HOSTILE_CANARY }), + 'invalid_argument_shape', + ); + + let getterCalls = 0; + const accessor = commandArgs(); + Object.defineProperty(accessor, 'commandId', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error(HOSTILE_CANARY); + }, + }); + expectSafeError(() => createCommandKey(accessor), 'invalid_argument_shape'); + expect(getterCalls).toBe(0); + + const symbolInput = commandArgs(); + symbolInput[Symbol('secret')] = HOSTILE_CANARY; + expectSafeError(() => createCommandKey(symbolInput), 'invalid_argument_shape'); + expectSafeError( + () => createCommandKey(Object.assign(Object.create({ polluted: true }), commandArgs())), + 'invalid_argument_shape', + ); + + let trapCalls = 0; + const proxy = new Proxy(commandArgs(), { + ownKeys() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + get() { trapCalls += 1; throw new Error(HOSTILE_CANARY); }, + }); + expectSafeError(() => createCommandKey(proxy), 'invalid_argument_shape'); + expect(trapCalls).toBe(0); + }); + + test('does not mutate descriptors and is repeatable without console output', () => { + const payload = goldenPayload(); + const input = { commandType: 'race.checkout.create', payload }; + const beforeInput = Object.getOwnPropertyDescriptors(input); + const beforePayload = Object.getOwnPropertyDescriptors(payload); + const spies = ['log', 'info', 'warn', 'error', 'debug'] + .map((method) => jest.spyOn(console, method).mockImplementation(() => {})); + try { + const results = Array.from({ length: 10 }, () => createPayloadFingerprint(input)); + expect(new Set(results.map((result) => result.payloadFingerprint)).size).toBe(1); + expect(Object.getOwnPropertyDescriptors(input)).toEqual(beforeInput); + expect(Object.getOwnPropertyDescriptors(payload)).toEqual(beforePayload); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + + test('never serializes or inspects raw hostile values through errors', () => { + const error = expectSafeError( + () => createCommandKey(commandArgs({ commandId: HOSTILE_CANARY })), + 'invalid_command_id', + HOSTILE_CANARY, + ); + expect(Object.keys(error).sort()).toEqual(['code', 'name', 'reason']); + expect(JSON.parse(JSON.stringify(error))).toEqual({ + name: 'CommandIdentityError', + code: 'invalid_command_identity', + reason: 'invalid_command_id', + }); + }); + + test('imports only crypto/util and contains no forbidden runtime or side-effect primitive', () => { + const source = fs.readFileSync(path.join(__dirname, 'commerceCommandIdentity.js'), 'utf8'); + const imports = [...source.matchAll(/require\((['"])([^'"]+)\1\)/g)] + .map((match) => match[2]); + expect(imports).toEqual(['node:crypto', 'node:util']); + for (const pattern of [ + /require\(['"](?:firebase|stripe|fs|node:fs|http|https|net)/, + /process\s*\./, + /console\s*\./, + /Math\.random/, + /\bDate\s*\(/, + /\bfetch\s*\(/, + /setTimeout\s*\(/, + /setInterval\s*\(/, + ]) { + expect(source).not.toMatch(pattern); + } + }); + + test('import itself has no console output', () => { + const spies = ['log', 'info', 'warn', 'error', 'debug'] + .map((method) => jest.spyOn(console, method).mockImplementation(() => {})); + try { + jest.isolateModules(() => { + const isolated = require('./commerceCommandIdentity'); + expect(isolated.commandIdentityVersion).toBe(1); + }); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); +});