From 3c311398e34745a4c0144ed6283de22615409dc8 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 09:53:06 -0700 Subject: [PATCH] feat(commerce): register durable command identity (#165) --- GITHUB_ISSUES.md | 8 +- GITHUB_ISSUE_SLICES.md | 20 +- IMPLEMENTATION_PLAN.md | 4 +- SECURITY.md | 5 +- STRIPE_COMMERCE_DESIGN.md | 36 +- SYSTEM_DESIGN.md | 14 +- .../commerceCommandJournal.emulator.test.js | 353 ++++++++++ functions/commerceCommandJournal.js | 413 +++++++++++ functions/commerceCommandJournal.test.js | 650 ++++++++++++++++++ 9 files changed, 1476 insertions(+), 27 deletions(-) create mode 100644 functions/commerceCommandJournal.emulator.test.js create mode 100644 functions/commerceCommandJournal.js create mode 100644 functions/commerceCommandJournal.test.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 30481fb7..d33288c2 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 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 | +| 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 tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163); registered-only journal pair tracked in PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165); 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,16 @@ 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 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. +**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). PAY-002B2A registered-only journal source/tests are tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). Leases, provider-plan recovery, 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-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. +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-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) owns only the atomic `checkoutRequests/{commandKeyHash}` registered record plus deterministic revision-1 `auditEvents` partner. Exact existing pairs are read-only; same-key command-type/schema/payload conflicts and malformed pairs reject. Environment and caller scope derive different B1 keys; there is no cross-scope UUID query. B2A adds no lease, execute/send permission, result replay, provider plan/attempt, or reconciliation. PAY-002B2B owns leases/fencing, stale-worker rejection, terminal pointers/fixed final failure, and transition audit without provider-send permission. PAY-002B2C owns immutable provider-plan binding, safe-send cutoff, ambiguous outcomes, reconciliation, and safe provider-attempt advancement. PAY-002C/D, PAY-003B, RACE-001B, MERCH-001B, PAY-004, and PAY-005 depend on B2C. + +B2A adds no TTL. Deletion could re-enable an old command, so [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before any journal pair is removed. #165's concurrent proof is dedicated local-emulator evidence; hosted Functions CI does not start its exact demo project or set the explicit suite opt-in, and the standard differently named demo run safely skips it. No #165 endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, or officer behavior exists. ### Problem diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index a4beb2d1..ac38b7b0 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -63,22 +63,24 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | 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-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. | +| PAY-002B2A | source/tests tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165); PAY-002B1; unused/no runtime or deploy | Atomically create one immutable `registered` command and deterministic revision-1 audit partner, return a fixed read-only exact-existing classification, and reject same-key type/schema/payload conflicts or malformed pairs. Environment/caller changes derive another B1 key; stored mismatches are corruption. No lease, execute/send permission, result replay, provider, or reconciliation API. | Mocked transaction and dedicated opt-in demo-emulator concurrent pair/conflict/orphan/failure tests; hosted Functions CI does not run that opt-in suite and the standard demo run safely skips it. | +| PAY-002B2B | proposed; PAY-002B2A | Add leases, monotonic fence epochs, stale-worker rejection, terminal server-only pointers/fixed final failure, and append-oriented transition audit without provider-send permission. | Concurrent lease/takeover/stale fence, terminal replay-pointer, fixed-failure, and transaction-retry emulator tests. | +| PAY-002B2C | proposed; PAY-002B2B | Bind an immutable Stripe account/sandbox/API-version/endpoint/operation/mode/parameters plan, attempt/key, and pre-POST first-send evidence; stop old/unknown automatic POST, record ambiguity, reconcile verified evidence, and advance only a proven-safe logical attempt. | Exact-plan/key reuse, timeout/5xx/unknown-send cutoff, conflicting-plan, old-key, reconciliation, and safe/unsafe attempt-advance emulator/provider-fixture tests. | +| PAY-002C | proposed; PAY-001B, PAY-002B2C, 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-002B2C, 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-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-003B | proposed; PAY-002A1, PAY-002B2C, PAY-002C/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-002B2 | 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-002B2C | 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-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. | +| MERCH-001B | proposed; A, PAY-002B2C | 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-002B2C, PAY-002C, 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. | @@ -86,7 +88,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-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-005A | proposed; PAY-001D, PAY-002A1, PAY-002B2C, 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. | @@ -95,7 +97,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-002B2 | Build common privileged command middleware and append-only audit. | Capability/auth/schema/idempotency/audit tests. | +| ADMIN-001A | proposed; AUTH-003B, PAY-001A, PAY-002B2B | Build common privileged command middleware and append-only audit. Provider-calling commands additionally require PAY-002B2C. | 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 d238d762..172b5168 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. 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. +**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. PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for one unused server-only atomic registered command/revision-1 audit pair and exact existing/conflict classification. B2A grants no authorization, lease, execute/send permission, result replay, provider attempt, or reconciliation; B2B/C and every runtime adopter remain open. Its concurrency proof uses a dedicated opt-in local emulator project; current hosted Functions CI does not run that opt-in suite, and the standard differently named demo run safely skips it. Real migration, capacity, inventory, deployment, provider configuration, 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-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. +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-002B2A is tracked in [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for the registered command/audit pair only. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; PAY-002B2B must add leases/fences/terminal pointers without send permission; PAY-002B2C must bind the provider plan and safe-send/reconciliation lifecycle; PAY-002C/D must then add persistence-first sagas; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and #165 does not deploy or configure Firebase/Stripe. 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 f9fbb098..c036371e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,8 +95,9 @@ 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-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. | +| 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 business record is read/written or migrated. PAY-002B1 [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) supplies pure command/attempt keys; PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) supplies only unused registration/audit persistence; PAY-002B2B/C must add lease/fence/terminal and provider-plan/recovery behavior; 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. PAY-002B2A/#165 registers only a command/audit pair; it does not add a lease, provider plan, send permission, or result replay. PAY-002B2B must add leases/fencing and terminal pointers; PAY-002B2C must preserve ambiguous provider attempts/keys, stop old/unknown-send-time automatic POST retries, reconcile evidence, and allow only safe attempt advancement. PAY-002C/D and PAY-005 must adopt B2C with endpoint schemas and failure-boundary tests. Deployment, provider configuration, and live behavior remain unproven. | +| Foundation only for registration/audit parts of RISK-003, RISK-020, and RISK-032 | PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). The unused server-only transaction creates one immutable `checkoutRequests/{commandKeyHash}` registered record and deterministic revision-1 `auditEvents` partner atomically. Exact retries are read-only. Same-key type/schema/payload conflicts and corrupt, orphan, future, or unexpected stored pairs fail closed without repair. Environment/caller-scope changes derive different B1 keys; a stored mismatch under one key is corruption, not a cross-scope lookup. Fixed results expose no raw identity, UUID, payload, hash, path, or Firestore detail. | Registration is neither authorization nor execution permission. There is no endpoint/index export, lease/fence, provider send, result replay, Stripe object, attempt transition, or reconciliation. Local emulator concurrency evidence is not a hosted gate: current Functions CI does not run the dedicated project/opt-in suite, and the standard differently named demo run safely skips it. No TTL is safe: [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before deletion. Source/tests/merge do not prove Firebase deployment, Stripe configuration, or live behavior. | | 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 8cbdc45c..776ac468 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -226,7 +226,7 @@ Launch with quantity `1` unless cart and multi-line inventory semantics are expl } ``` -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. +Within PAY-002B2C'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) @@ -238,16 +238,40 @@ PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github. 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. +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-002B2C 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-002B2A owns only first registration, PAY-002B2B owns lease/fence and terminal-pointer state without provider-send permission, and PAY-002B2C owns the immutable provider plan, safe-send boundary, ambiguous outcomes, and reconciliation. 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. +### Registered-only command journal (PAY-002B2A) + +PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). Its unused server-only transaction creates exactly one registered command and its deterministic first audit event, or creates neither: + +```text +checkoutRequests/{commandKeyHash} + journalSchemaVersion, commandIdentityVersion, endpointSchemaVersion + environment, callerScopeKind, commandType, payloadFingerprint + state=registered, revision=1, createdAt, updatedAt + +auditEvents/commerce_command_{commandKeyHash}_0000000001 + auditSchemaVersion=1, aggregateType=commerce_command, commandKeyHash + commandRevision=1, eventType=command_registered, fromState=null + toState=registered, environment, callerScopeKind, commandType, occurredAt +``` + +The command timestamps and audit timestamp are one trusted Firestore Timestamp captured before the transaction callback. All reads occur before writes. The callback may rerun and contains only Firestore reads/writes—no logger, clock, random generator, network call, or mutable application-state side effect. An absent pair becomes the pair atomically. An exact pair returns the fixed frozen `registered_existing` classification without a write. The result contains only `journalSchemaVersion: 1`, `outcome: registered_new|registered_existing`, and `state: registered`; it has no `shouldExecute` flag. A command-type, endpoint-schema-version, or payload-fingerprint mismatch under the same B1 key is a conflict. Because environment and caller scope are part of the B1 key, a different environment or caller scope derives a different document; B2A does not query for cross-scope UUID reuse. An environment or caller-kind mismatch stored under an existing key is malformed corruption. An orphan, malformed, future-version, unexpected-state, or timestamp-mismatched pair fails closed without repair. + +Neither document stores the raw caller, command UUID, payload, personal data, Stripe key/object, provider attempt, result, lease, or failure text. Registration is not authorization and is not execution permission. B2A has no endpoint or index export and no lease, fence, execute/send decision, result replay, provider request, provider plan, attempt advancement, clock-based recovery, or reconciliation transition. PAY-002B2B and PAY-002B2C remain required before any business or provider side effect can claim command retry safety. + +B2A adds no TTL. Deleting the pair can make an old command appear new. Before any later retention job removes it, the owner-approved policy in [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must define a server-only tombstone or equivalent durable duplicate barrier that preserves conflict detection without raw identity or payload data. Until then, deletion is not safe. + +The concurrency proof for #165 runs only against its dedicated local demo Firestore emulator. Current hosted Functions CI neither starts that exact demo project nor sets the suite's explicit opt-in flag, so hosted unit CI does not gate the transactional emulator cases. The repository's standard differently named demo project safely skips this isolated suite. Source, tests, review, merge, and hosted CI are separate from Firebase deployment, Stripe configuration, and live behavior. + ## 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 PAY-002B1 command key and payload fingerprint; PAY-002B2 must persist and compare them before any provider call. +2. Derive the PAY-002B1 command key and payload fingerprint; PAY-002B2A must register and compare them, and PAY-002B2B/C must complete their gates 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. @@ -260,7 +284,7 @@ External Stripe calls cannot be part of a Firestore transaction. Use this sequen 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, 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. +9. If the function loses its response after Stripe creates the Session, PAY-002B2C 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. @@ -397,7 +421,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-002B2 must persist the B1 identity and fence that attempt. +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-002B2A registers the B1 identity, while PAY-002B2B/C must fence and bind the provider attempt. ### Registration state @@ -425,7 +449,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 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. +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 later PAY-002B2B/C/PAY-005 command state—not this reducer, a B1 digest, or B2A registration 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 3cbc3c3a..ba154623 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -245,8 +245,8 @@ 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/{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 | +| `checkoutRequests/{commandKeyHash}` | Server-only command journal. PAY-002B2A/#165 adds only an immutable `registered` revision-1 record; PAY-002B2B/C must add lease/fence, terminal-pointer, provider-plan, safe-send, and reconciliation state. | +| `auditEvents/{eventId}` or bounded per-record audit subcollections | Append-oriented operational and security audit trail. B2A's first event is exactly `auditEvents/commerce_command_{commandKeyHash}_0000000001`. | | `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 | | `orders.paymentStatus` and `orders.fulfillmentStatus` | Separate money state from physical fulfillment state | @@ -261,7 +261,11 @@ 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. +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 a journal 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-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). Its unused server-only transaction writes the exact `checkoutRequests/{commandKeyHash}` registered record and `auditEvents/commerce_command_{commandKeyHash}_0000000001` event together with one trusted Timestamp, or writes neither. Exact retries are read-only; command-type, endpoint-version, or payload mismatch under the same B1 key conflicts; corrupt or incomplete pairs fail closed without repair. Environment and caller scope are already bound into the B1 document ID, so a different scope creates a different record rather than a cross-scope conflict. The fixed result grants no authorization or execute/send permission and contains no hash, path, raw identity, UUID, or payload. + +B2A has no endpoint/index export, lease, fence, terminal pointer, result replay, provider plan/key/object, Stripe call, provider attempt transition, safe-send clock, or reconciliation behavior. PAY-002B2B owns lease/fence and terminal-pointer state without provider-send permission. PAY-002B2C owns immutable provider-plan binding, pre-send evidence, conservative retry cutoff, ambiguous outcome, verified reconciliation, and safe attempt advancement. No TTL is safe yet: [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before a command pair can be deleted. The #165 concurrent transaction proof is dedicated local-emulator evidence; hosted Functions CI does not start its exact demo project or set its explicit opt-in flag, and the standard differently named demo run safely skips it. None of this deploys Firebase, configures Stripe, or changes live/officer behavior. ## 7. Business invariants @@ -353,7 +357,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, 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. +If Stripe Session creation fails, the function releases the reservation in a compensating transaction. If the function crashes after Stripe creates the Session, PAY-002B2C 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 @@ -376,7 +380,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 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. +- A stable PAY-002B1 Stripe key identifies one logical provider generation. A lease takeover or HTTP retry is not a new generation. PAY-002B2C 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/commerceCommandJournal.emulator.test.js b/functions/commerceCommandJournal.emulator.test.js new file mode 100644 index 00000000..a4328e6e --- /dev/null +++ b/functions/commerceCommandJournal.emulator.test.js @@ -0,0 +1,353 @@ +const admin = require('firebase-admin'); + +const { + createCommandKey, + createPayloadFingerprint, +} = require('./commerceCommandIdentity'); +const { + CommerceCommandJournalError, + registerCommerceCommand, +} = require('./commerceCommandJournal'); + +const PROJECT_ID = 'demo-pay002b2-test'; +const APP_NAME = 'commerce-command-journal-emulator-test'; +const CONCURRENT_CALLS = 24; +const JOURNAL_EMULATOR_OPT_IN = 'REQUIRE_COMMERCE_COMMAND_JOURNAL_EMULATOR'; +const describeWithEmulator = process.env[JOURNAL_EMULATOR_OPT_IN] === '1' + ? describe + : describe.skip; + +jest.setTimeout(30000); + +const COMMAND_IDS = Object.freeze({ + identical: '11111111-1111-4111-8111-111111111111', + conflict: '22222222-2222-4222-8222-222222222222', + orphanAudit: '33333333-3333-4333-8333-333333333333', + lostResponse: '44444444-4444-4444-8444-444444444444', + malformed: '55555555-5555-4555-8555-555555555555', + future: '66666666-6666-4666-8666-666666666666', +}); + +function frozenRecord(entries) { + const record = Object.create(null); + for (const [key, value] of entries) record[key] = value; + return Object.freeze(record); +} + +function payload(reference) { + return frozenRecord([ + ['amountCents', 2500], + ['currency', 'usd'], + ['syntheticReference', reference], + ]); +} + +function commandArgs(db, commandId, commandPayload) { + return { + db, + environment: 'test', + callerScope: { + kind: 'firebase_uid', + value: 'synthetic_emulator_runner', + }, + commandId, + commandType: 'race.checkout.create', + endpointSchemaVersion: 1, + payload: commandPayload, + }; +} + +function assertExactSafeEmulator() { + if (process.env[JOURNAL_EMULATOR_OPT_IN] !== '1') { + throw new Error('The commerce command journal emulator opt-in is required.'); + } + const emulatorHost = process.env.FIRESTORE_EMULATOR_HOST; + if (typeof emulatorHost !== 'string') { + throw new Error('The Firestore emulator host is required.'); + } + + let parsed; + try { + parsed = new URL(`http://${emulatorHost}`); + } catch { + throw new Error('The Firestore emulator host is invalid.'); + } + const loopbackHosts = new Set(['127.0.0.1', 'localhost', '[::1]']); + const port = Number(parsed.port); + if (!loopbackHosts.has(parsed.hostname) + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '' + || !Number.isInteger(port) + || port < 1 + || port > 65535) { + throw new Error('The Firestore emulator must use a loopback host and explicit port.'); + } + + if (process.env.GCLOUD_PROJECT !== PROJECT_ID) { + throw new Error(`The emulator project must be exactly ${PROJECT_ID}.`); + } + if (process.env.GOOGLE_CLOUD_PROJECT + && process.env.GOOGLE_CLOUD_PROJECT !== PROJECT_ID) { + throw new Error(`The Google Cloud project must be exactly ${PROJECT_ID}.`); + } +} + +function commandIdentity(args) { + return createCommandKey({ + environment: args.environment, + callerScope: args.callerScope, + commandId: args.commandId, + }); +} + +function fingerprint(args) { + return createPayloadFingerprint({ + commandType: args.commandType, + payload: args.payload, + }).payloadFingerprint; +} + +function snapshotEvidence(snapshot) { + return { + exists: snapshot.exists, + data: snapshot.data(), + createTime: snapshot.createTime && snapshot.createTime.toMillis(), + updateTime: snapshot.updateTime && snapshot.updateTime.toMillis(), + }; +} + +function expectJournalError(error, reason) { + expect(error).toBeInstanceOf(CommerceCommandJournalError); + expect(error).toMatchObject({ + code: 'commerce_command_journal_error', + reason, + }); + expect(Object.isFrozen(error)).toBe(true); +} + +describeWithEmulator('commerce command journal Firestore transaction', () => { + let app; + let db; + let trackedRefs = new Map(); + + function pairFor(args) { + const { commandKeyHash } = commandIdentity(args); + const commandRef = db.collection('checkoutRequests').doc(commandKeyHash); + const auditRef = db.collection('auditEvents') + .doc(`commerce_command_${commandKeyHash}_0000000001`); + trackedRefs.set(commandRef.path, commandRef); + trackedRefs.set(auditRef.path, auditRef); + return { commandKeyHash, commandRef, auditRef }; + } + + async function readPair(pair) { + const [commandSnapshot, auditSnapshot] = await Promise.all([ + pair.commandRef.get(), + pair.auditRef.get(), + ]); + return { commandSnapshot, auditSnapshot }; + } + + async function cleanupTrackedRefs() { + if (!trackedRefs) return; + const refs = [...trackedRefs.values()]; + trackedRefs.clear(); + if (refs.length === 0) return; + if (!db) throw new Error('The Firestore emulator database is unavailable.'); + const batch = db.batch(); + for (const ref of refs) batch.delete(ref); + await batch.commit(); + } + + beforeAll(() => { + assertExactSafeEmulator(); + app = admin.initializeApp({ projectId: PROJECT_ID }, APP_NAME); + db = app.firestore(); + }); + + beforeEach(() => { + trackedRefs = new Map(); + }); + + afterEach(async () => { + await cleanupTrackedRefs(); + }); + + afterAll(async () => { + if (trackedRefs) await cleanupTrackedRefs(); + if (app) await app.delete(); + }); + + test('24 identical concurrent calls create one command and one audit event', async () => { + const args = commandArgs(db, COMMAND_IDS.identical, payload('identical')); + const pair = pairFor(args); + + const results = await Promise.all( + Array.from({ length: CONCURRENT_CALLS }, () => registerCommerceCommand(args)), + ); + + expect(results.filter((result) => result.outcome === 'registered_new')).toHaveLength(1); + expect(results.filter((result) => result.outcome === 'registered_existing')) + .toHaveLength(CONCURRENT_CALLS - 1); + for (const result of results) { + expect(result).toEqual({ + journalSchemaVersion: 1, + outcome: expect.stringMatching(/^registered_(?:new|existing)$/), + state: 'registered', + }); + expect(Object.isFrozen(result)).toBe(true); + } + + const stored = await readPair(pair); + expect(stored.commandSnapshot.exists).toBe(true); + expect(stored.auditSnapshot.exists).toBe(true); + expect(stored.auditSnapshot.data().commandKeyHash).toBe(pair.commandKeyHash); + const matchingAuditEvents = await db.collection('auditEvents') + .where('commandKeyHash', '==', pair.commandKeyHash) + .get(); + expect(matchingAuditEvents.size).toBe(1); + }); + + test('a concurrent two-payload race commits one identity and never overwrites it', async () => { + const firstArgs = commandArgs(db, COMMAND_IDS.conflict, payload('race-a')); + const secondArgs = commandArgs(db, COMMAND_IDS.conflict, payload('race-b')); + const pair = pairFor(firstArgs); + + const calls = [ + ...Array.from({ length: 12 }, () => registerCommerceCommand(firstArgs)), + ...Array.from({ length: 12 }, () => registerCommerceCommand(secondArgs)), + ]; + const settled = await Promise.allSettled(calls); + const fulfilled = settled.filter((result) => result.status === 'fulfilled'); + const rejected = settled.filter((result) => result.status === 'rejected'); + + expect(fulfilled).toHaveLength(12); + expect(rejected).toHaveLength(12); + expect(fulfilled.filter((result) => result.value.outcome === 'registered_new')) + .toHaveLength(1); + expect(fulfilled.filter((result) => result.value.outcome === 'registered_existing')) + .toHaveLength(11); + for (const result of rejected) expectJournalError(result.reason, 'command_conflict'); + + const firstFingerprint = fingerprint(firstArgs); + const secondFingerprint = fingerprint(secondArgs); + const initialPair = await readPair(pair); + const storedFingerprint = initialPair.commandSnapshot.data().payloadFingerprint; + expect([firstFingerprint, secondFingerprint]).toContain(storedFingerprint); + const winningArgs = storedFingerprint === firstFingerprint ? firstArgs : secondArgs; + const losingArgs = storedFingerprint === firstFingerprint ? secondArgs : firstArgs; + + await expect(Promise.all( + Array.from({ length: 5 }, () => registerCommerceCommand(winningArgs)), + )).resolves.toEqual(Array.from({ length: 5 }, () => ({ + journalSchemaVersion: 1, + outcome: 'registered_existing', + state: 'registered', + }))); + await expect(registerCommerceCommand(losingArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'command_conflict', + }); + + const finalPair = await readPair(pair); + expect(snapshotEvidence(finalPair.commandSnapshot)) + .toEqual(snapshotEvidence(initialPair.commandSnapshot)); + expect(snapshotEvidence(finalPair.auditSnapshot)) + .toEqual(snapshotEvidence(initialPair.auditSnapshot)); + }); + + test('a preseeded deterministic audit makes registration fail without an orphan command', async () => { + const args = commandArgs(db, COMMAND_IDS.orphanAudit, payload('preseed-audit')); + const pair = pairFor(args); + const preseed = Object.freeze({ syntheticPreseed: true }); + await pair.auditRef.create(preseed); + const auditBefore = await pair.auditRef.get(); + + await expect(registerCommerceCommand(args)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + + const stored = await readPair(pair); + expect(stored.commandSnapshot.exists).toBe(false); + expect(snapshotEvidence(stored.auditSnapshot)).toEqual(snapshotEvidence(auditBefore)); + }); + + test('a retry after a lost response is existing-only and leaves both documents unchanged', async () => { + const args = commandArgs(db, COMMAND_IDS.lostResponse, payload('lost-response')); + const pair = pairFor(args); + + await registerCommerceCommand(args); + const beforeRetry = await readPair(pair); + const retries = await Promise.all( + Array.from({ length: 8 }, () => registerCommerceCommand(args)), + ); + expect(retries).toEqual(Array.from({ length: 8 }, () => ({ + journalSchemaVersion: 1, + outcome: 'registered_existing', + state: 'registered', + }))); + + const afterRetry = await readPair(pair); + expect(snapshotEvidence(afterRetry.commandSnapshot)) + .toEqual(snapshotEvidence(beforeRetry.commandSnapshot)); + expect(snapshotEvidence(afterRetry.auditSnapshot)) + .toEqual(snapshotEvidence(beforeRetry.auditSnapshot)); + }); + + test('a malformed timestamp pair rejects without repair', async () => { + const args = commandArgs(db, COMMAND_IDS.malformed, payload('malformed-pair')); + const pair = pairFor(args); + await registerCommerceCommand(args); + const valid = await readPair(pair); + const mismatchedTimestamp = admin.firestore.Timestamp.fromMillis( + valid.auditSnapshot.data().occurredAt.toMillis() + 1, + ); + await pair.auditRef.set({ + ...valid.auditSnapshot.data(), + occurredAt: mismatchedTimestamp, + }); + const malformed = await readPair(pair); + + await expect(registerCommerceCommand(args)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + + const afterAttempt = await readPair(pair); + expect(snapshotEvidence(afterAttempt.commandSnapshot)) + .toEqual(snapshotEvidence(malformed.commandSnapshot)); + expect(snapshotEvidence(afterAttempt.auditSnapshot)) + .toEqual(snapshotEvidence(malformed.auditSnapshot)); + }); + + test('future-version records reject without repair', async () => { + const args = commandArgs(db, COMMAND_IDS.future, payload('future-pair')); + const pair = pairFor(args); + await registerCommerceCommand(args); + const valid = await readPair(pair); + await Promise.all([ + pair.commandRef.set({ + ...valid.commandSnapshot.data(), + journalSchemaVersion: 2, + }), + pair.auditRef.set({ + ...valid.auditSnapshot.data(), + auditSchemaVersion: 2, + }), + ]); + const future = await readPair(pair); + + await expect(registerCommerceCommand(args)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + + const afterAttempt = await readPair(pair); + expect(snapshotEvidence(afterAttempt.commandSnapshot)) + .toEqual(snapshotEvidence(future.commandSnapshot)); + expect(snapshotEvidence(afterAttempt.auditSnapshot)) + .toEqual(snapshotEvidence(future.auditSnapshot)); + }); +}); diff --git a/functions/commerceCommandJournal.js b/functions/commerceCommandJournal.js new file mode 100644 index 00000000..33dda228 --- /dev/null +++ b/functions/commerceCommandJournal.js @@ -0,0 +1,413 @@ +const { types: { isProxy } } = require('node:util'); +const { Timestamp } = require('firebase-admin/firestore'); + +const { + commandIdentityVersion, + CommandIdentityError, + createCommandKey, + createPayloadFingerprint, +} = require('./commerceCommandIdentity'); + +const journalSchemaVersion = 1; +const auditSchemaVersion = 1; +const MAXIMUM_ENDPOINT_SCHEMA_VERSION = 1000000; +const COMMAND_COLLECTION = 'checkoutRequests'; +const AUDIT_COLLECTION = 'auditEvents'; +const COMMAND_STATE = 'registered'; +const COMMAND_REVISION = 1; +const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/; +const SAFE_COMMAND_TYPE = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; +const ENVIRONMENTS = new Set(['local', 'test', 'staging', 'production']); +const CALLER_SCOPE_KINDS = new Set([ + 'firebase_uid', + 'anonymous_principal', + 'internal_system', +]); + +const INPUT_FIELDS = Object.freeze([ + 'db', + 'environment', + 'callerScope', + 'commandId', + 'commandType', + 'endpointSchemaVersion', + 'payload', +]); +const CALLER_SCOPE_FIELDS = Object.freeze(['kind', 'value']); +const COMMAND_FIELDS = Object.freeze([ + 'journalSchemaVersion', + 'commandIdentityVersion', + 'endpointSchemaVersion', + 'environment', + 'callerScopeKind', + 'commandType', + 'payloadFingerprint', + 'state', + 'revision', + 'createdAt', + 'updatedAt', +]); +const AUDIT_FIELDS = Object.freeze([ + 'auditSchemaVersion', + 'aggregateType', + 'commandKeyHash', + 'commandRevision', + 'eventType', + 'fromState', + 'toState', + 'environment', + 'callerScopeKind', + 'commandType', + 'occurredAt', +]); + +const ERROR_MESSAGES = Object.freeze({ + invalid_command_input: 'Commerce command registration input is invalid.', + command_conflict: 'Commerce command registration conflicts with an existing command.', + journal_record_invalid: 'Commerce command registration record is invalid.', + journal_unavailable: 'Commerce command registration is unavailable.', +}); + +class CommerceCommandJournalError extends Error { + constructor(reason) { + const safeReason = Object.prototype.hasOwnProperty.call(ERROR_MESSAGES, reason) + ? reason + : 'journal_unavailable'; + super(ERROR_MESSAGES[safeReason]); + this.name = 'CommerceCommandJournalError'; + this.code = 'commerce_command_journal_error'; + this.reason = safeReason; + Object.freeze(this); + } +} + +function reject(reason) { + throw new CommerceCommandJournalError(reason); +} + +function readExactOwnDataObject(value, expectedFields, reason) { + if (value === null || typeof value !== 'object' || isProxy(value)) reject(reason); + + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + reject(reason); + } + if (prototype !== Object.prototype || keys.length !== expectedFields.length) reject(reason); + + const expected = new Set(expectedFields); + const result = Object.create(null); + for (const key of keys) { + if (typeof key !== 'string' || !expected.has(key)) reject(reason); + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + reject(reason); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + reject(reason); + } + result[key] = descriptor.value; + } + if (expectedFields.some((field) => !Object.prototype.hasOwnProperty.call(result, field))) { + reject(reason); + } + return result; +} + +function readTimestamp(value) { + if (value === null || typeof value !== 'object' || isProxy(value)) return null; + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + return null; + } + if (prototype !== Timestamp.prototype + || keys.length !== 2 + || !keys.includes('_seconds') + || !keys.includes('_nanoseconds')) { + return null; + } + + const values = Object.create(null); + for (const key of keys) { + if (key !== '_seconds' && key !== '_nanoseconds') return null; + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + return null; + } + values[key] = descriptor.value; + } + + const seconds = values._seconds; + const nanoseconds = values._nanoseconds; + if (!Number.isSafeInteger(seconds) + || seconds < -62135596800 + || seconds > 253402300799 + || !Number.isSafeInteger(nanoseconds) + || nanoseconds < 0 + || nanoseconds > 999999999) { + return null; + } + return Object.freeze({ seconds, nanoseconds }); +} + +function timestampsEqual(first, second) { + return first !== null + && second !== null + && first.seconds === second.seconds + && first.nanoseconds === second.nanoseconds; +} + +function validEndpointSchemaVersion(value) { + return Number.isSafeInteger(value) + && value >= 1 + && value <= MAXIMUM_ENDPOINT_SCHEMA_VERSION; +} + +function validCommandType(value) { + return typeof value === 'string' + && value.length >= 1 + && value.length <= 64 + && SAFE_COMMAND_TYPE.test(value); +} + +function validStoredCommand(record) { + return record.journalSchemaVersion === journalSchemaVersion + && record.commandIdentityVersion === commandIdentityVersion + && validEndpointSchemaVersion(record.endpointSchemaVersion) + && typeof record.environment === 'string' + && ENVIRONMENTS.has(record.environment) + && typeof record.callerScopeKind === 'string' + && CALLER_SCOPE_KINDS.has(record.callerScopeKind) + && validCommandType(record.commandType) + && typeof record.payloadFingerprint === 'string' + && LOWERCASE_SHA256.test(record.payloadFingerprint) + && record.state === COMMAND_STATE + && record.revision === COMMAND_REVISION; +} + +function validStoredAudit(record) { + return record.auditSchemaVersion === auditSchemaVersion + && record.aggregateType === 'commerce_command' + && typeof record.commandKeyHash === 'string' + && LOWERCASE_SHA256.test(record.commandKeyHash) + && record.commandRevision === COMMAND_REVISION + && record.eventType === 'command_registered' + && record.fromState === null + && record.toState === COMMAND_STATE + && typeof record.environment === 'string' + && ENVIRONMENTS.has(record.environment) + && typeof record.callerScopeKind === 'string' + && CALLER_SCOPE_KINDS.has(record.callerScopeKind) + && validCommandType(record.commandType); +} + +function validateExistingPair(commandValue, auditValue, expected) { + const command = readExactOwnDataObject( + commandValue, + COMMAND_FIELDS, + 'journal_record_invalid', + ); + const audit = readExactOwnDataObject( + auditValue, + AUDIT_FIELDS, + 'journal_record_invalid', + ); + const createdAt = readTimestamp(command.createdAt); + const updatedAt = readTimestamp(command.updatedAt); + const occurredAt = readTimestamp(audit.occurredAt); + + if (!validStoredCommand(command) + || !validStoredAudit(audit) + || !timestampsEqual(createdAt, updatedAt) + || !timestampsEqual(createdAt, occurredAt) + || audit.commandKeyHash !== expected.commandKeyHash + || audit.commandRevision !== command.revision + || audit.environment !== command.environment + || audit.callerScopeKind !== command.callerScopeKind + || audit.commandType !== command.commandType) { + reject('journal_record_invalid'); + } + + // Environment and caller scope are part of B1's document identity. A mismatch + // at this derived document is corruption, not a normal command reuse conflict. + if (command.environment !== expected.environment + || command.callerScopeKind !== expected.callerScopeKind) { + reject('journal_record_invalid'); + } + if (command.commandType !== expected.commandType + || command.endpointSchemaVersion !== expected.endpointSchemaVersion + || command.payloadFingerprint !== expected.payloadFingerprint) { + reject('command_conflict'); + } +} + +function readSnapshot(snapshot) { + let exists; + try { + exists = snapshot.exists; + } catch { + reject('journal_unavailable'); + } + if (exists === false) return Object.freeze({ exists: false, value: null }); + if (exists !== true) reject('journal_unavailable'); + + let value; + try { + value = snapshot.data(); + } catch { + reject('journal_unavailable'); + } + return Object.freeze({ exists: true, value }); +} + +const REGISTERED_NEW = Object.freeze({ + journalSchemaVersion, + outcome: 'registered_new', + state: COMMAND_STATE, +}); +const REGISTERED_EXISTING = Object.freeze({ + journalSchemaVersion, + outcome: 'registered_existing', + state: COMMAND_STATE, +}); + +async function registerCommerceCommand(input) { + let values; + let commandKey; + let fingerprint; + let callerScopeKind; + try { + values = readExactOwnDataObject(input, INPUT_FIELDS, 'invalid_command_input'); + if (!validEndpointSchemaVersion(values.endpointSchemaVersion)) { + reject('invalid_command_input'); + } + commandKey = createCommandKey({ + environment: values.environment, + callerScope: values.callerScope, + commandId: values.commandId, + }); + fingerprint = createPayloadFingerprint({ + commandType: values.commandType, + payload: values.payload, + }); + const scope = readExactOwnDataObject( + values.callerScope, + CALLER_SCOPE_FIELDS, + 'invalid_command_input', + ); + callerScopeKind = scope.kind; + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + if (error instanceof CommandIdentityError) reject('invalid_command_input'); + reject('journal_unavailable'); + } + + let occurredAt; + let commandRef; + let auditRef; + try { + occurredAt = Timestamp.now(); + if (readTimestamp(occurredAt) === null) reject('journal_unavailable'); + Object.freeze(occurredAt); + commandRef = values.db.collection(COMMAND_COLLECTION).doc(commandKey.commandKeyHash); + auditRef = values.db.collection(AUDIT_COLLECTION).doc( + `commerce_command_${commandKey.commandKeyHash}_0000000001`, + ); + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } + + const commandRecord = Object.freeze({ + journalSchemaVersion, + commandIdentityVersion, + endpointSchemaVersion: values.endpointSchemaVersion, + environment: values.environment, + callerScopeKind, + commandType: values.commandType, + payloadFingerprint: fingerprint.payloadFingerprint, + state: COMMAND_STATE, + revision: COMMAND_REVISION, + createdAt: occurredAt, + updatedAt: occurredAt, + }); + const auditRecord = Object.freeze({ + auditSchemaVersion, + aggregateType: 'commerce_command', + commandKeyHash: commandKey.commandKeyHash, + commandRevision: COMMAND_REVISION, + eventType: 'command_registered', + fromState: null, + toState: COMMAND_STATE, + environment: values.environment, + callerScopeKind, + commandType: values.commandType, + occurredAt, + }); + const expected = Object.freeze({ + commandKeyHash: commandKey.commandKeyHash, + endpointSchemaVersion: values.endpointSchemaVersion, + environment: values.environment, + callerScopeKind, + commandType: values.commandType, + payloadFingerprint: fingerprint.payloadFingerprint, + }); + + try { + const result = await values.db.runTransaction(async (transaction) => { + const commandSnapshot = await transaction.get(commandRef); + const auditSnapshot = await transaction.get(auditRef); + const command = readSnapshot(commandSnapshot); + const audit = readSnapshot(auditSnapshot); + + if (command.exists !== audit.exists) reject('journal_record_invalid'); + if (command.exists) { + validateExistingPair(command.value, audit.value, expected); + return REGISTERED_EXISTING; + } + + transaction.create(commandRef, commandRecord); + transaction.create(auditRef, auditRecord); + return REGISTERED_NEW; + }); + + if (result !== REGISTERED_NEW && result !== REGISTERED_EXISTING) { + reject('journal_unavailable'); + } + return result; + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } +} + +Object.freeze(CommerceCommandJournalError.prototype); +Object.freeze(CommerceCommandJournalError); + +module.exports = Object.freeze({ + journalSchemaVersion, + CommerceCommandJournalError, + registerCommerceCommand, +}); diff --git a/functions/commerceCommandJournal.test.js b/functions/commerceCommandJournal.test.js new file mode 100644 index 00000000..0382fa0b --- /dev/null +++ b/functions/commerceCommandJournal.test.js @@ -0,0 +1,650 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { inspect } = require('node:util'); +const { Timestamp } = require('firebase-admin/firestore'); + +const { + commandIdentityVersion, + createCommandKey, + createPayloadFingerprint, +} = require('./commerceCommandIdentity'); +const { + journalSchemaVersion, + CommerceCommandJournalError, + registerCommerceCommand, +} = require('./commerceCommandJournal'); + +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 NOW_MILLIS = 1800000000123; + +function frozenRecord(entries = []) { + const value = Object.create(null); + for (const [key, child] of entries) value[key] = child; + return Object.freeze(value); +} + +function validPayload(quantity = 1) { + return frozenRecord([ + ['amountCents', 2500], + ['currency', 'usd'], + ['line', frozenRecord([['sku', 'shirt_s'], ['quantity', quantity]])], + ]); +} + +function validInput(db, overrides = {}) { + return { + db, + environment: 'test', + callerScope: { kind: 'firebase_uid', value: RAW_CALLER }, + commandId: COMMAND_ID, + commandType: 'merch.checkout.create', + endpointSchemaVersion: 1, + payload: validPayload(), + ...overrides, + }; +} + +function documentPath(collectionName, documentId) { + return `${collectionName}/${documentId}`; +} + +function reference(collectionName, documentId) { + return Object.freeze({ + id: documentId, + path: documentPath(collectionName, documentId), + }); +} + +function snapshot(value) { + return Object.freeze({ + exists: value !== undefined, + data: jest.fn(() => value), + }); +} + +function cloneStore(store) { + return new Map([...store.entries()]); +} + +function createMockDb(seed = [], options = {}) { + const store = new Map(seed); + const callbackRuns = []; + const collectionCalls = []; + const db = { + collection: jest.fn((collectionName) => { + collectionCalls.push(collectionName); + return { + doc: jest.fn((documentId) => reference(collectionName, documentId)), + }; + }), + runTransaction: jest.fn(async (callback) => { + if (options.rejectBeforeCallback) throw options.rejectBeforeCallback; + const numberOfRuns = options.callbackRuns || 1; + let result; + let finalWrites = []; + + for (let index = 0; index < numberOfRuns; index += 1) { + const readView = cloneStore(store); + const writes = []; + const transaction = Object.freeze({ + get: jest.fn(async (ref) => snapshot(readView.get(ref.path))), + create: jest.fn((ref, value) => { + writes.push(Object.freeze({ ref, value })); + }), + }); + callbackRuns.push({ transaction, writes }); + result = await callback(transaction); + finalWrites = writes; + } + + if (options.rejectCommit) throw options.rejectCommit; + for (const write of finalWrites) { + if (store.has(write.ref.path)) throw new Error('mock create precondition failed'); + } + for (const write of finalWrites) store.set(write.ref.path, write.value); + if (Object.prototype.hasOwnProperty.call(options, 'returnedValue')) { + return options.returnedValue; + } + return result; + }), + }; + return Object.freeze({ db, store, callbackRuns, collectionCalls }); +} + +function expectedIdentity(input) { + const command = createCommandKey({ + environment: input.environment, + callerScope: input.callerScope, + commandId: input.commandId, + }); + const fingerprint = createPayloadFingerprint({ + commandType: input.commandType, + payload: input.payload, + }); + const auditId = `commerce_command_${command.commandKeyHash}_0000000001`; + return Object.freeze({ + commandKeyHash: command.commandKeyHash, + payloadFingerprint: fingerprint.payloadFingerprint, + commandPath: documentPath('checkoutRequests', command.commandKeyHash), + auditPath: documentPath('auditEvents', auditId), + }); +} + +function exactPair(input, timestamp = Timestamp.fromMillis(NOW_MILLIS)) { + const identity = expectedIdentity(input); + const command = { + journalSchemaVersion: 1, + commandIdentityVersion, + endpointSchemaVersion: input.endpointSchemaVersion, + environment: input.environment, + callerScopeKind: input.callerScope.kind, + commandType: input.commandType, + payloadFingerprint: identity.payloadFingerprint, + state: 'registered', + revision: 1, + createdAt: timestamp, + updatedAt: Timestamp.fromMillis(timestamp.toMillis()), + }; + const audit = { + auditSchemaVersion: 1, + aggregateType: 'commerce_command', + commandKeyHash: identity.commandKeyHash, + commandRevision: 1, + eventType: 'command_registered', + fromState: null, + toState: 'registered', + environment: input.environment, + callerScopeKind: input.callerScope.kind, + commandType: input.commandType, + occurredAt: Timestamp.fromMillis(timestamp.toMillis()), + }; + return Object.freeze({ + identity, + command, + audit, + seed: Object.freeze([ + [identity.commandPath, command], + [identity.auditPath, audit], + ]), + }); +} + +function captureError(callback) { + return Promise.resolve() + .then(callback) + .then(() => { + throw new Error('Expected callback to reject'); + }, (error) => error); +} + +async function expectSafeError(callback, reason, forbidden = []) { + const error = await captureError(callback); + expect(error).toBeInstanceOf(CommerceCommandJournalError); + expect(error).toMatchObject({ + name: 'CommerceCommandJournalError', + code: 'commerce_command_journal_error', + reason, + }); + expect(Object.isFrozen(error)).toBe(true); + expect(Reflect.ownKeys(error).sort()).toEqual([ + 'code', + 'message', + 'name', + 'reason', + 'stack', + ]); + const rendered = [ + error.message, + error.stack, + String(error), + JSON.stringify(error), + inspect(error), + ].join('\n'); + for (const value of [HOSTILE_CANARY, RAW_CALLER, COMMAND_ID, ...forbidden]) { + expect(rendered).not.toContain(value); + } + return error; +} + +describe('atomic commerce command registration', () => { + let timestampNow; + + beforeEach(() => { + timestampNow = jest.spyOn(Timestamp, 'now') + .mockImplementation(() => Timestamp.fromMillis(NOW_MILLIS)); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('creates the exact command and deterministic audit partner atomically', async () => { + const mock = createMockDb(); + const input = validInput(mock.db); + const identity = expectedIdentity(input); + + const result = await registerCommerceCommand(input); + + expect(result).toEqual({ + journalSchemaVersion: 1, + outcome: 'registered_new', + state: 'registered', + }); + expect(Object.isFrozen(result)).toBe(true); + expect(timestampNow).toHaveBeenCalledTimes(1); + expect(mock.collectionCalls).toEqual(['checkoutRequests', 'auditEvents']); + expect(mock.callbackRuns).toHaveLength(1); + const [{ transaction, writes }] = mock.callbackRuns; + expect(transaction.get).toHaveBeenCalledTimes(2); + expect(transaction.create).toHaveBeenCalledTimes(2); + expect(transaction.get.mock.invocationCallOrder[1]) + .toBeLessThan(transaction.create.mock.invocationCallOrder[0]); + expect(writes.map((write) => write.ref.path)).toEqual([ + identity.commandPath, + identity.auditPath, + ]); + + const command = mock.store.get(identity.commandPath); + const audit = mock.store.get(identity.auditPath); + expect(command).toEqual({ + journalSchemaVersion: 1, + commandIdentityVersion: 1, + endpointSchemaVersion: 1, + environment: 'test', + callerScopeKind: 'firebase_uid', + commandType: 'merch.checkout.create', + payloadFingerprint: identity.payloadFingerprint, + state: 'registered', + revision: 1, + createdAt: expect.any(Timestamp), + updatedAt: expect.any(Timestamp), + }); + expect(audit).toEqual({ + auditSchemaVersion: 1, + aggregateType: 'commerce_command', + commandKeyHash: identity.commandKeyHash, + commandRevision: 1, + eventType: 'command_registered', + fromState: null, + toState: 'registered', + environment: 'test', + callerScopeKind: 'firebase_uid', + commandType: 'merch.checkout.create', + occurredAt: expect.any(Timestamp), + }); + expect(command.createdAt).toBe(command.updatedAt); + expect(command.createdAt).toBe(audit.occurredAt); + expect(Object.isFrozen(command)).toBe(true); + expect(Object.isFrozen(audit)).toBe(true); + expect(Object.isFrozen(command.createdAt)).toBe(true); + expect(JSON.stringify(result)).not.toMatch(/command|payload|caller|uuid|path|hash|execute/i); + }); + + test('returns a fixed read-only existing outcome for the exact pair', async () => { + const placeholder = createMockDb(); + const input = validInput(placeholder.db); + const pair = exactPair(input); + const mock = createMockDb(pair.seed); + const beforeCommand = { ...pair.command }; + const beforeAudit = { ...pair.audit }; + + const result = await registerCommerceCommand(validInput(mock.db)); + + expect(result).toEqual({ + journalSchemaVersion: 1, + outcome: 'registered_existing', + state: 'registered', + }); + expect(mock.callbackRuns[0].transaction.create).not.toHaveBeenCalled(); + expect(mock.store.get(pair.identity.commandPath)).toBe(pair.command); + expect(mock.store.get(pair.identity.auditPath)).toBe(pair.audit); + expect(mock.store.get(pair.identity.commandPath)).toEqual(beforeCommand); + expect(mock.store.get(pair.identity.auditPath)).toEqual(beforeAudit); + }); + + test('repeated transaction callbacks reuse one timestamp and have no callback-local clock', async () => { + const mock = createMockDb([], { callbackRuns: 4 }); + const result = await registerCommerceCommand(validInput(mock.db)); + + expect(result.outcome).toBe('registered_new'); + expect(timestampNow).toHaveBeenCalledTimes(1); + expect(mock.callbackRuns).toHaveLength(4); + const timestamps = mock.callbackRuns.flatMap(({ writes }) => [ + writes[0].value.createdAt, + writes[0].value.updatedAt, + writes[1].value.occurredAt, + ]); + expect(new Set(timestamps).size).toBe(1); + expect(mock.store.size).toBe(2); + }); + + test('another command ID receives a separate pseudonymous journal path', async () => { + const mock = createMockDb(); + const first = validInput(mock.db); + const second = validInput(mock.db, { commandId: OTHER_COMMAND_ID }); + const firstIdentity = expectedIdentity(first); + const secondIdentity = expectedIdentity(second); + + await registerCommerceCommand(first); + await registerCommerceCommand(second); + + expect(firstIdentity.commandPath).not.toBe(secondIdentity.commandPath); + expect(mock.store.size).toBe(4); + }); + + test('stores and returns no raw caller, UUID, or payload canary', async () => { + const mock = createMockDb(); + const payload = frozenRecord([ + ['note', HOSTILE_CANARY], + ['quantity', 1], + ]); + + const result = await registerCommerceCommand(validInput(mock.db, { payload })); + const rendered = JSON.stringify({ + result, + documents: [...mock.store.values()], + }); + + expect(rendered).not.toContain(HOSTILE_CANARY); + expect(rendered).not.toContain(RAW_CALLER); + expect(rendered).not.toContain(COMMAND_ID); + }); +}); + +describe('exact existing identity and partner validation', () => { + beforeEach(() => { + jest.spyOn(Timestamp, 'now').mockImplementation(() => Timestamp.fromMillis(NOW_MILLIS + 5000)); + }); + + afterEach(() => jest.restoreAllMocks()); + + async function expectPairFailure(mutatePair, reason) { + const base = validInput(createMockDb().db); + const pair = exactPair(base); + mutatePair(pair.command, pair.audit, pair.identity); + const mock = createMockDb(pair.seed); + await expectSafeError( + () => registerCommerceCommand(validInput(mock.db)), + reason, + [pair.identity.commandKeyHash, pair.identity.payloadFingerprint], + ); + expect(mock.store.get(pair.identity.commandPath)).toBe(pair.command); + expect(mock.store.get(pair.identity.auditPath)).toBe(pair.audit); + expect(mock.callbackRuns[0].transaction.create).not.toHaveBeenCalled(); + } + + test.each([ + ['command type', (command, audit) => { + command.commandType = 'race.checkout.create'; + audit.commandType = 'race.checkout.create'; + }], + ['endpoint schema version', (command) => { command.endpointSchemaVersion = 2; }], + ['payload fingerprint', (command) => { command.payloadFingerprint = 'a'.repeat(64); }], + ])('rejects immutable %s reuse as a fixed conflict', async (_label, mutate) => { + await expectPairFailure(mutate, 'command_conflict'); + }); + + test.each([ + ['environment', (command, audit) => { + command.environment = 'staging'; + audit.environment = 'staging'; + }], + ['caller scope kind', (command, audit) => { + command.callerScopeKind = 'anonymous_principal'; + audit.callerScopeKind = 'anonymous_principal'; + }], + ])('treats a stored %s mismatch at a B1-derived path as corruption', async (_label, mutate) => { + await expectPairFailure(mutate, 'journal_record_invalid'); + }); + + test.each([ + ['journal schema version', (command) => { command.journalSchemaVersion = 2; }], + ['command identity version', (command) => { command.commandIdentityVersion = 2; }], + ['audit schema version', (_command, audit) => { audit.auditSchemaVersion = 2; }], + ['unexpected state', (command) => { command.state = 'executing'; }], + ['unexpected revision', (command) => { command.revision = 2; }], + ['unexpected event', (_command, audit) => { audit.eventType = 'provider_sent'; }], + ['uppercase hash', (_command, audit) => { + audit.commandKeyHash = audit.commandKeyHash.toUpperCase(); + }], + ['command/audit environment mismatch', (_command, audit) => { audit.environment = 'local'; }], + ['command/audit type mismatch', (_command, audit) => { + audit.commandType = 'race.checkout.create'; + }], + ['command/audit timestamp mismatch', (_command, audit) => { + audit.occurredAt = Timestamp.fromMillis(NOW_MILLIS + 1); + }], + ['created/updated timestamp mismatch', (command) => { + command.updatedAt = Timestamp.fromMillis(NOW_MILLIS + 1); + }], + ['non-Timestamp time', (command) => { command.createdAt = { _seconds: 1, _nanoseconds: 0 }; }], + ])('rejects malformed pair: %s', async (_label, mutate) => { + await expectPairFailure(mutate, 'journal_record_invalid'); + }); + + test('rejects either orphan direction and writes no automatic repair', async () => { + const base = validInput(createMockDb().db); + const pair = exactPair(base); + for (const seed of [ + [[pair.identity.commandPath, pair.command]], + [[pair.identity.auditPath, pair.audit]], + ]) { + const mock = createMockDb(seed); + await expectSafeError( + () => registerCommerceCommand(validInput(mock.db)), + 'journal_record_invalid', + [pair.identity.commandKeyHash], + ); + expect(mock.store.size).toBe(1); + expect(mock.callbackRuns[0].transaction.create).not.toHaveBeenCalled(); + } + }); + + test.each([ + ['extra field', (value) => { value.unexpected = true; }], + ['symbol field', (value) => { value[Symbol('hidden')] = true; }], + ['custom prototype', (value) => Object.setPrototypeOf(value, { inherited: true })], + ])('rejects hostile command root shape: %s', async (_label, mutate) => { + await expectPairFailure((command) => mutate(command), 'journal_record_invalid'); + }); + + test('rejects accessors and proxies without invoking their traps', async () => { + const base = validInput(createMockDb().db); + const pair = exactPair(base); + let getterCalls = 0; + Object.defineProperty(pair.command, 'state', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + throw new Error(HOSTILE_CANARY); + }, + }); + let mock = createMockDb(pair.seed); + await expectSafeError( + () => registerCommerceCommand(validInput(mock.db)), + 'journal_record_invalid', + [pair.identity.commandKeyHash], + ); + expect(getterCalls).toBe(0); + + const proxyPair = exactPair(base); + let trapCalls = 0; + const proxy = new Proxy(proxyPair.command, { + ownKeys() { + trapCalls += 1; + throw new Error(HOSTILE_CANARY); + }, + }); + mock = createMockDb([ + [proxyPair.identity.commandPath, proxy], + [proxyPair.identity.auditPath, proxyPair.audit], + ]); + await expectSafeError( + () => registerCommerceCommand(validInput(mock.db)), + 'journal_record_invalid', + [proxyPair.identity.commandKeyHash], + ); + expect(trapCalls).toBe(0); + }); + + test('rejects a revoked stored proxy with the same fixed record error', async () => { + const base = validInput(createMockDb().db); + const pair = exactPair(base); + const { proxy, revoke } = Proxy.revocable(pair.audit, {}); + revoke(); + const mock = createMockDb([ + [pair.identity.commandPath, pair.command], + [pair.identity.auditPath, proxy], + ]); + await expectSafeError( + () => registerCommerceCommand(validInput(mock.db)), + 'journal_record_invalid', + [pair.identity.commandKeyHash], + ); + }); +}); + +describe('fixed input and infrastructure failure boundary', () => { + beforeEach(() => { + jest.spyOn(Timestamp, 'now').mockImplementation(() => Timestamp.fromMillis(NOW_MILLIS)); + }); + + afterEach(() => jest.restoreAllMocks()); + + test.each([ + ['bad UUID', { commandId: HOSTILE_CANARY }], + ['bad environment', { environment: HOSTILE_CANARY }], + ['bad command type', { commandType: HOSTILE_CANARY }], + ['zero endpoint schema', { endpointSchemaVersion: 0 }], + ['huge endpoint schema', { endpointSchemaVersion: 1000001 }], + ['mutable payload', { payload: { canary: HOSTILE_CANARY } }], + ['bad caller', { callerScope: { kind: 'firebase_uid', value: `uid\u0000${HOSTILE_CANARY}` } }], + ])('maps B1/input failure to one safe error: %s', async (_label, overrides) => { + const mock = createMockDb(); + await expectSafeError( + () => registerCommerceCommand(validInput(mock.db, overrides)), + 'invalid_command_input', + ); + expect(mock.db.runTransaction).not.toHaveBeenCalled(); + }); + + test('rejects extra, missing, accessor, custom-prototype, and proxied input roots', async () => { + const mock = createMockDb(); + const extra = { ...validInput(mock.db), unexpected: HOSTILE_CANARY }; + const missing = validInput(mock.db); + delete missing.payload; + const inherited = Object.assign(Object.create({ inherited: HOSTILE_CANARY }), validInput(mock.db)); + let getterCalls = 0; + const accessor = validInput(mock.db); + Object.defineProperty(accessor, 'commandId', { + enumerable: true, + get() { + getterCalls += 1; + return HOSTILE_CANARY; + }, + }); + let trapCalls = 0; + const proxy = new Proxy(validInput(mock.db), { + ownKeys() { + trapCalls += 1; + return []; + }, + }); + for (const value of [extra, missing, inherited, accessor, proxy]) { + await expectSafeError(() => registerCommerceCommand(value), 'invalid_command_input'); + } + expect(getterCalls).toBe(0); + expect(trapCalls).toBe(0); + }); + + test.each(['before callback', 'at commit'])('redacts unknown Firestore failure %s', async (phase) => { + const providerFailure = new Error(`${HOSTILE_CANARY}/checkoutRequests/private-hash`); + const options = phase === 'before callback' + ? { rejectBeforeCallback: providerFailure } + : { rejectCommit: providerFailure }; + const mock = createMockDb([], options); + await expectSafeError( + () => registerCommerceCommand(validInput(mock.db)), + 'journal_unavailable', + ['checkoutRequests/private-hash'], + ); + expect(mock.store.size).toBe(0); + }); + + test('rejects an unexpected transaction return instead of reflecting it', async () => { + const mock = createMockDb([], { returnedValue: HOSTILE_CANARY }); + await expectSafeError( + () => registerCommerceCommand(validInput(mock.db)), + 'journal_unavailable', + ); + }); + + test('maps malformed db and timestamp provider behavior to unavailable', async () => { + const malformedDb = { runTransaction: jest.fn() }; + await expectSafeError( + () => registerCommerceCommand(validInput(malformedDb)), + 'journal_unavailable', + ); + + Timestamp.now.mockImplementationOnce(() => ({ + _seconds: 1, + _nanoseconds: 0, + canary: HOSTILE_CANARY, + })); + const mock = createMockDb(); + await expectSafeError( + () => registerCommerceCommand(validInput(mock.db)), + 'journal_unavailable', + ); + expect(mock.db.runTransaction).not.toHaveBeenCalled(); + }); +}); + +describe('closed source and export boundary', () => { + test('exports only registration primitives and grants no execute/send/lease/result permission', () => { + const api = require('./commerceCommandJournal'); + expect(journalSchemaVersion).toBe(1); + expect(Object.keys(api).sort()).toEqual([ + 'CommerceCommandJournalError', + 'journalSchemaVersion', + 'registerCommerceCommand', + ]); + expect(Object.isFrozen(api)).toBe(true); + expect(Object.isFrozen(CommerceCommandJournalError)).toBe(true); + expect(Object.isFrozen(CommerceCommandJournalError.prototype)).toBe(true); + expect(Object.keys(api).join(' ')).not.toMatch(/execute|send|lease|fence|result|replay|provider/i); + }); + + test('has only the approved local, Firestore, and Node utility dependencies', () => { + const source = fs.readFileSync(path.join(__dirname, 'commerceCommandJournal.js'), 'utf8'); + const imports = [...source.matchAll(/require\('([^']+)'\)/g)].map((match) => match[1]); + expect(imports.sort()).toEqual([ + './commerceCommandIdentity', + 'firebase-admin/firestore', + 'node:util', + ]); + expect(source).not.toMatch(/require\(['"]\.\/index|createCheckout|stripeWebhook/); + expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|console|logger)\s*[.(]/); + expect(source).not.toMatch(/Math\.random|randomUUID|process\.env|node:fs|node:http|node:https|child_process/); + expect((source.match(/Timestamp\.now\(\)/g) || [])).toHaveLength(1); + expect(source.indexOf('Timestamp.now()')).toBeLessThan(source.indexOf('.runTransaction(')); + }); + + test('is not imported by the Functions index or any runtime module', () => { + const runtimeFiles = fs.readdirSync(__dirname) + .filter((fileName) => fileName.endsWith('.js')) + .filter((fileName) => !fileName.endsWith('.test.js')) + .filter((fileName) => fileName !== 'commerceCommandJournal.js'); + + expect(runtimeFiles).toContain('index.js'); + for (const fileName of runtimeFiles) { + const source = fs.readFileSync(path.join(__dirname, fileName), 'utf8'); + expect({ fileName, importsJournal: source.includes('commerceCommandJournal') }) + .toEqual({ fileName, importsJournal: false }); + } + }); +});