diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index b3ffc6a7..05af410c 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 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); PAY-002B2B lease/fence source/tests tracked in [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169); provider/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); PAY-002B2B lease/fence source/tests tracked in [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169); PAY-002B2C1 immutable initial-plan source/tests tracked in [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173); send/reconcile/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,7 +624,7 @@ 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). PAY-002B2A registered-only journal source/tests are tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). PAY-002B2B lease/fence source and tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). Provider-plan recovery, 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). PAY-002B2B lease/fence source and tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). PAY-002B2C1 immutable initial-plan source and tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). Pre-POST evidence, ambiguity/reconciliation, safe attempt advancement, runtime adoption, real legacy inventory/migration, and checkout sagas remain open. **Depends on:** CONFIG-001 and PAY-001 ### Current atomic boundary @@ -633,9 +633,13 @@ PAY-002A1 defines only a dependency-free version-1 target: separate payment, reg 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 [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the unused lease/fence source and tests. It preserves those B2A records exactly and stores mutable state separately at `checkoutRequests/{commandKeyHash}/lifecycle/current`. A fixed 60-second server lease fingerprints a trusted UUID v4 holder; a monotonic fence rejects stale or expired workers; each real change appends one deterministic audit. Terminal success stores only a command-bound server-only commitment, not a business/Stripe result, action proof, or replay value. The lease does not authorize the caller or permit a provider request. PAY-002B2C still 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. +PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the unused lease/fence source and tests. It preserves those B2A records exactly and stores mutable state separately at `checkoutRequests/{commandKeyHash}/lifecycle/current`. A fixed 60-second server lease fingerprints a trusted UUID v4 holder; a monotonic fence rejects stale or expired workers; each real change appends one deterministic audit. Terminal success stores only a command-bound server-only commitment, not a business/Stripe result, action proof, or replay value. The lease does not authorize the caller or permit a provider request. -B2A/B2B add 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 record is removed. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact demo-project/opt-in command-journal suite as a separate hosted release prerequisite; #169 expands that same file for lease and terminal races. Hosted results remain synthetic source proof only. #169's source is unused: it adds no endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, website change, or officer behavior. +PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) owns only the unused immutable initial-plan source and tests. While the exact current lease/fence is active, it binds attempt `1` to one Stripe account, mode, API version, the static `checkout_session_create` → `/v1/checkout/sessions` POST mapping, canonical parameter set, deterministic B1 key, and original binding fence. C1 rejects arbitrary or object-ID-bearing paths; later operations need an explicit reviewed mapping. Raw account, parameters, and key are never stored; command-bound commitments are equality evidence, not anonymization, configured-account proof, a send marker, or provider-execution proof. An exact active-lease retry is read-only, a valid takeover can observe but cannot rewrite the same plan, and conflicts or malformed partners fail closed. + +PAY-002B2C2 still owns pre-POST first-send evidence, exact same-attempt resend limits, and the old/unknown-send ambiguity cutoff without attempt advancement. PAY-002B2C3 owns verified reconciliation evidence and safe later-attempt authorization. PAY-002C/D, PAY-003B, RACE-001B, MERCH-001B, PAY-004, and PAY-005 depend on the completed B2C chain. + +B2A/B2B/C1 add 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 record is removed. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact demo-project/opt-in command-journal suite as a separate hosted release prerequisite; #169 and #173 expand that same file. Hosted results remain synthetic source proof only. #173's source is unused: it adds no endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, website change, or officer behavior. ### Problem diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 40adb011..699553c4 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -66,22 +66,24 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | 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-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 plus dedicated opt-in demo-emulator concurrent pair/conflict/orphan/failure tests; CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) adds the exact suite as a hosted release prerequisite. | | PAY-002B2B | source/tests tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169); PAY-002B2A/#165 and CI-001B3/#167; unused/no runtime or deploy | Preserve the immutable B2A root/audit and add separate `lifecycle/current` state with a fixed 60-second server lease, command-bound UUID-holder fingerprint, monotonic fence, terminal commitment/fixed final failure, and append-oriented transition audit. The lease grants no provider-send permission; the commitment is not result proof or replay. | Mock plus #167-hosted emulator races for acquire/recovery/busy/expiry/takeover/stale fence/terminal conflict, exact read-only retry, malformed partner, and immutable B2A records; explicit browser deny 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. | +| PAY-002B2C1 | source/tests tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173); PAY-002B2B/#169; unused/no runtime or deploy | While the exact current lease/fence is active, atomically bind one immutable attempt-1 Stripe account/mode/API-version/static `checkout_session_create` POST mapping/parameters/key plan and deterministic audit. Reject arbitrary or object-ID-bearing paths. Store only command-bound commitments; this is not account proof or send permission. | Mock plus #167-hosted emulator races for exact bind/recovery/conflict/expiry/takeover/malformed partners and byte-preserved B2A/B2B records; explicit browser-denial tests. | +| PAY-002B2C2 | proposed; PAY-002B2C1 | Persist pre-POST first-send evidence and a conservative code-owned resend cutoff; reuse only the exact plan/key, preserve timeout/network/5xx uncertainty, and require reconciliation after an old or unknown send time. Never advance the attempt. | Exact-resend/clock-boundary/unknown-send/timeout/5xx/concurrency tests with synthetic provider fixtures and no real request. | +| PAY-002B2C3 | proposed; PAY-002B2C2 and the merged PAY-003A/#101 evidence vocabulary | Append verified reconciliation evidence and permit a later attempt only after proven non-execution or an explicitly safe corrected/new logical operation. Keep every prior attempt immutable. PAY-003B may later consume this result; C3 does not wait on PAY-003B. | Closed evidence-reason matrix; unsafe timeout/5xx/old-key/missing-reference/partial-search advancement denied; synthetic provider/business reconciliation tests. | +| PAY-002C | proposed; PAY-001B, PAY-002B2C3, 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-002B2C3, 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-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-003B | proposed; PAY-002A1, PAY-002B2C3, 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-002B2C | 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-002B2C3 | 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-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. | +| MERCH-001B | proposed; A, PAY-002B2C3 | 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-002B2C3, 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. | @@ -89,7 +91,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-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-005A | proposed; PAY-001D, PAY-002A1, PAY-002B2C3, 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. | @@ -98,7 +100,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-002B2B | Build common privileged command middleware and append-only audit. Provider-calling commands additionally require PAY-002B2C. | 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-002B2C3. | 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 aa12b9be..ec80b76e 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. 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. PAY-002B2B source/tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). They keep those B2A records immutable and add a separate lifecycle record with a fixed 60-second server lease, UUID-holder fingerprint, monotonic fence, terminal commitment, and deterministic transition audit. A lease is not authorization or provider-send permission, and its terminal commitment is not business/provider proof or result replay. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact focused emulator file as a named hosted release prerequisite, which #169 expands. B2C and every runtime adopter remain open. Real migration, capacity, inventory, deployment, provider configuration, 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. PAY-002B2B source/tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). They keep those B2A records immutable and add a separate lifecycle record with a fixed 60-second server lease, UUID-holder fingerprint, monotonic fence, terminal commitment, and deterministic transition audit. PAY-002B2C1 source/tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). While an exact lease/fence is active, they bind immutable attempt-1 Stripe account/mode/version/endpoint/operation/parameters/key commitments and a deterministic audit without permitting a send. These commitments are not configured-account or provider-execution proof. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact focused emulator file as a named hosted release prerequisite, which #169 and #173 expand. B2C2 pre-POST/ambiguity handling, B2C3 reconciliation/advancement, and every runtime adopter remain open. 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-002B2A is tracked in [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for the immutable registered command/audit pair only. PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the separate unused lease/fence/terminal-commitment source and tests without send permission. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; 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 #169 does not deploy or configure Firebase/Stripe. +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 immutable registered command/audit pair only. PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the separate unused lease/fence/terminal-commitment source and tests without send permission. PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) owns only the unused lease-bound immutable initial plan and audit. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; PAY-002B2C2 must add pre-POST evidence and ambiguity cutoff; B2C3 must add verified reconciliation and safe attempt advancement; 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 #173 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 c26e343a..f9d70e43 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,8 +95,8 @@ These entries are implementation evidence, not a production risk-acceptance deci | Risks | Source result | Remaining closure evidence | | --- | --- | --- | -| Foundation only for parts of RISK-011, RISK-020, RISK-021 | PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). The source-only child defines versioned pure payment, registration, fulfillment, confirmed-refund, and per-dispute structural reducers; fixed cross-dimension review/rejection; and a deterministic synthetic legacy classifier/aggregate report with no IDs. Orders/registrations carry no singular canonical dispute state. Forbidden regressions fail closed, refunds/disputes do not overwrite payment state, and `paid_after_cancellation` remains paid plus cancelled plus review rather than becoming a payment enum. | This does not yet mitigate those risks in a runtime. An unchanged enum does not prove duplicate Event/command/evidence, authorization, amount monotonicity, provider state, or club policy. No endpoint imports the library and no 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 [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) supplies unused lease/fence/terminal-commitment source and tests without provider permission; PAY-002B2C must still add 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. PAY-002B2B source/tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) for a separate fixed-duration lease, holder fingerprint, monotonic fence, terminal commitment, and transition audit. The source does not add authorization, provider-send permission, result proof, or replay. 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 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 [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) supplies unused lease/fence/terminal-commitment source and tests without provider permission; PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) supplies only unused lease-bound attempt-1 plan commitments/audit; B2C2/B2C3 must still add pre-POST ambiguity/reconciliation 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. PAY-002B2C1 is tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) for an immutable lease-bound attempt-1 plan and deterministic audit whose account, canonical parameters, and B1 key are stored only as command-bound commitments. | Hashes and commitments are pseudonymous server-only equality evidence—not anonymization, authorization, configured-account proof, durable replay, or provider-execution proof. No endpoint imports the library. PAY-002B2A/#165 registers only a command/audit pair. PAY-002B2B/#169 adds a separate fixed-duration lease, holder fingerprint, monotonic fence, terminal commitment, and transition audit without provider permission. C1 adds no first-send time, Stripe call, safe-resend decision, or attempt advancement. PAY-002B2C2 must preserve ambiguous sends and stop old/unknown-send-time automatic POST retries; B2C3 must reconcile verified evidence and allow only safe attempt advancement. PAY-002C/D and PAY-005 must adopt the complete B2C chain 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. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs its dedicated exact demo-project/opt-in suite as a named hosted job and protected-release prerequisite. | Registration is neither authorization nor execution permission. #169 keeps the root and revision-1 audit immutable, places lifecycle state in the separate server-only subcollection, fails closed on malformed partners, and proves in synthetic tests that stale workers cannot finalize. The hosted emulator result is still synthetic source proof, not deployment or provider proof. 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. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 41a23461..b0c8c3a7 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-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. +Within PAY-002B2C2's conservative stored safe-send window, returning the same request reuses the exact B2C1 plan, parameters, and Stripe idempotency key. After that window—or when first-send time is unknown—the server must stop automatic POST retries; PAY-002B2C3 must 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 only under an explicit verified transition. ### Pure command identity and provider-key contract (PAY-002B1) @@ -238,7 +238,7 @@ 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-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-commitment state without provider-send permission, and PAY-002B2C owns the immutable provider plan, safe-send boundary, ambiguous outcomes, and reconciliation. +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. PAY-002B2C1 binds the immutable initial plan without send permission. Before a conservative B2C2 stored first-send deadline, retry may use only that exact plan, 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—B2C2 must stop automatic POST retries and B2C3 must reconcile stored provider references plus verified webhook/metadata or authorized operator evidence. B2C3 may authorize a later provider attempt only after separately proven non-execution or an explicitly safe corrected/new logical operation. PAY-002B2A owns first registration, and PAY-002B2B owns lease/fence and terminal-commitment state without provider-send permission. 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. @@ -260,7 +260,7 @@ auditEvents/commerce_command_{commandKeyHash}_0000000001 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. +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 the complete PAY-002B2C1/B2C2/B2C3 chain 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. @@ -274,14 +274,31 @@ The target uses a fixed 60-second server lease. Trusted server code supplies one The only terminal states are `succeeded` and `failed_final`. Success stores a write-once command-bound commitment to a later server-only business result; final failure stores no reason. The commitment is not a business or Stripe result, proof that an action happened, or a value that can replay a response. Every real lifecycle change appends one deterministic audit event. Recovery reads, busy observations, exact terminal retries, and rejected conflicts append nothing. -This lease is concurrency evidence only. It grants no authorization, inventory or capacity claim, price approval, execute/send permission, provider plan, safe POST retry, reconciliation decision, or result replay. PAY-002B2C and the domain-specific PAY-002C/D work remain required before any Stripe call can claim retry safety. #169's source is unused; no endpoint adopts it, and no Firebase deployment, Stripe/provider configuration, production read/write, website change, or live behavior is claimed. +This lease is concurrency evidence only. It grants no authorization, inventory or capacity claim, price approval, execute/send permission, provider plan, safe POST retry, reconciliation decision, or result replay. PAY-002B2C1/B2C2/B2C3 and the domain-specific PAY-002C/D work remain required before any Stripe call can claim retry safety. #169's source is unused; no endpoint adopts it, and no Firebase deployment, Stripe/provider configuration, production read/write, website change, or live behavior is claimed. + +### Immutable initial provider-plan target (PAY-002B2C1) + +PAY-002B2C1 source/tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). They preserve the B2A registration and B2B lifecycle documents and audits exactly. While the exact current holder/fence is leased and unexpired, one transaction may create only: + +```text +checkoutRequests/{commandKeyHash}/providerAttempts/0000000001 +auditEvents/commerce_provider_attempt_{commandKeyHash}_0000000001 +``` + +The immutable plan binds provider `stripe`, attempt `1`, HTTP `POST`, environment, test/live mode, API version, endpoint path, operation, and the original binding fence. C1 supports only the reviewed static pair `checkout_session_create` → `/v1/checkout/sessions`; it rejects arbitrary or object-ID-bearing paths. A later operation needs an explicit reviewed mapping rather than caller-selected path flexibility. The raw Stripe account ID, canonical provider parameters, and exact B1 idempotency key are not stored. Versioned length-framed commitments bind each value to the command hash. Those commitments are pseudonymous equality evidence only: they do not prove that a configured credential controls the account, that Stripe received a request, or that a result exists. + +An exact plan/audit retry requires the current active holder/fence and performs no write. The plan's stored binding fence and time must also fit the exact deterministic lease audit for that original fence, including its acquisition time and fixed 60-second expiry. Fence `1` must start exactly at the lifecycle's preserved creation time. A later binding fence also requires its exact immediate predecessor lease audit and cannot start before that predecessor expires. This is bounded corruption detection over the server-only append history, not cryptographic proof of every older audit event. After expiry, a worker must take over before it can observe the fixed existing classification; takeover never rewrites the original plan, binding fence, or timestamp. A conflicting plan, stale holder/fence, terminal lifecycle, malformed/orphan/future pair, missing required lease audit, or preseeded audit fails closed without repair. The fixed output contains only schema versions, `provider_plan_bound|provider_plan_existing`, and state `planned`; it exposes no plan, commitment, fence, timestamp, provider value, or send decision. + +C1 has no first-send marker, safe-resend clock, Stripe call, provider response, ambiguity state, reconciliation evidence, or later-attempt transition. PAY-002B2C2 must atomically persist pre-POST evidence, reuse only the exact plan/key inside a conservative source-owned window, and require reconciliation after an old or unknown send time. PAY-002B2C3 must append verified evidence and authorize a later attempt only after proven non-execution or an explicitly safe corrected/new logical operation. Timeout, connection loss, `5xx`, an old/pruned key, a missing object reference, or incomplete search never proves non-execution. + +#173 remains unused source. It adds no endpoint/index export, provider adapter, Firebase deployment, Stripe account/key/domain configuration, production read/write, website change, or 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-002B2A must register and compare them, and PAY-002B2B/C must complete their gates before any provider call. +2. Derive the PAY-002B1 command key and payload fingerprint; PAY-002B2A must register and compare them, PAY-002B2B must provide the current fence, PAY-002B2C1 must bind the immutable initial plan, and B2C2/B2C3 must complete their send/reconciliation 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. @@ -294,7 +311,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-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. +9. If the function loses its response after Stripe creates the Session, PAY-002B2C2 retries the exact B2C1 plan/key only inside its stored safe-send window. After the deadline—or when first-send time is unknown—it stops POSTing; B2C3 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. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 27704300..b0dbd998 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -247,7 +247,8 @@ The first release may continue using `admin` in the UI, but server endpoints and | `stripeEvents/{stripeEventId}` | Durable, non-PII webhook inbox/deduplication record with processing status and business reference | | `checkoutRequests/{commandKeyHash}` | Immutable server-only command registration. PAY-002B2A/#165 owns the exact `registered` revision-1 record. | | `checkoutRequests/{commandKeyHash}/lifecycle/current` | PAY-002B2B/#169 server-only lease, monotonic fence, and terminal commitment source. It is unused and is not provider-send permission. | -| `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`; B2B targets one deterministic event for each real lifecycle change. | +| `checkoutRequests/{commandKeyHash}/providerAttempts/0000000001` | PAY-002B2C1/#173 immutable lease-bound initial Stripe plan. It stores command-bound commitments, is unused, and is not account proof or provider-send permission. | +| `auditEvents/{eventId}` or bounded per-record audit subcollections | Append-oriented operational and security audit trail. B2A's first event is `commerce_command_{commandKeyHash}_0000000001`; B2B appends one deterministic event for each real lifecycle change; C1 binds the first plan with `commerce_provider_attempt_{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 | @@ -268,9 +269,13 @@ PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github B2A has no endpoint/index export, lease, fence, terminal commitment, result replay, provider plan/key/object, Stripe call, provider attempt transition, safe-send clock, or reconciliation behavior. PAY-002B2B source/tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). They keep the B2A root and revision-1 audit immutable and store mutable state at `checkoutRequests/{commandKeyHash}/lifecycle/current`. The source uses a fixed 60-second server lease, a command-bound fingerprint of a trusted UUID v4 holder, and a monotonic fence so stale or expired workers cannot finish. Each real lease or terminal change gets one deterministic audit event. -B2B terminal success stores only a command-bound commitment to a later server-only business result. That commitment is not the result itself, proof that Stripe or Firestore work happened, or response replay. The lease/fence is concurrency evidence, not authorization or provider-send permission. PAY-002B2C still 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. +B2B terminal success stores only a command-bound commitment to a later server-only business result. That commitment is not the result itself, proof that Stripe or Firestore work happened, or response replay. The lease/fence is concurrency evidence, not authorization or provider-send permission. PAY-002B2C1/#173 owns immutable initial-plan binding; B2C2/B2C3 still own 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. -CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact opt-in command-journal emulator suite as a named hosted release prerequisite; #169 expands that same suite. These are synthetic source checks only. #169's source remains unused and makes no endpoint, Firebase deployment, Stripe/provider configuration, production data, website, or live/officer change. +PAY-002B2C1 source/tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). With the exact current unexpired lease/fence, the unused journal can atomically create the immutable attempt-1 plan plus `auditEvents/commerce_provider_attempt_{commandKeyHash}_0000000001`. The first version accepts only the static `checkout_session_create` → `/v1/checkout/sessions` mapping, so an object ID or capability cannot enter the stored path. The plan also fixes Stripe mode, API version, original binding fence, and command-bound commitments to the account, canonical parameters, and deterministic B1 key. Raw account/parameters/key are absent. An existing plan is accepted only when its binding time fits the deterministic lifecycle audit for its original fence. An exact active-lease retry is read-only; a valid takeover may observe but cannot rewrite the plan; conflicts and malformed or missing partners fail closed. + +These commitments are pseudonymous equality evidence, not authorization, configured-account proof, a pre-POST marker, provider-execution proof, or response replay. PAY-002B2C2 remains responsible for first-send evidence, exact resend limits, and the old/unknown-send ambiguity cutoff. B2C3 remains responsible for verified reconciliation evidence and safe later-attempt authorization. #173 has no endpoint/index export and no Firebase deployment, Stripe/provider configuration, production data, website, or live/officer effect. + +CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact opt-in command-journal emulator suite as a named hosted release prerequisite; #169 and #173 expand that same suite. These are synthetic source checks only. #173's source remains unused and makes no endpoint, Firebase deployment, Stripe/provider configuration, production data, website, or live/officer change. ## 7. Business invariants @@ -362,7 +367,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-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. +If Stripe Session creation fails, the function releases the reservation in a compensating transaction. If the function crashes after Stripe creates the Session, PAY-002B2C2 retries only the exact B2C1 plan/key inside its stored safe-send window. After that deadline—or when first-send time is unknown—it stops POSTing, and B2C3 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 @@ -385,7 +390,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-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. +- A stable PAY-002B1 Stripe key identifies one logical provider generation. A lease takeover or HTTP retry is not a new generation. PAY-002B2C1 binds the immutable attempt-1 plan; B2C2 may retry only that exact plan/key inside its conservative stored send window; after that window or with unknown send time, it stops automatic POST, and B2C3 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 index 37b558f0..8ae185fa 100644 --- a/functions/commerceCommandJournal.emulator.test.js +++ b/functions/commerceCommandJournal.emulator.test.js @@ -1,4 +1,9 @@ const admin = require('firebase-admin'); +// Test-only pinned SDK boundary: fail visibly on an SDK upgrade rather than +// silently replacing the real post-callback Firestore commit proof. +const { + Transaction: FirestoreTransaction, +} = require('@google-cloud/firestore/build/src/transaction'); const { createCommandKey, @@ -7,6 +12,7 @@ const { const { CommerceCommandJournalError, acquireCommerceCommandLease, + bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, registerCommerceCommand, @@ -15,6 +21,7 @@ const { const PROJECT_ID = 'demo-pay002b2-test'; const APP_NAME = 'commerce-command-journal-emulator-test'; const CONCURRENT_CALLS = 24; +const MAXIMUM_WHOLE_OPERATION_CALLS = 3; const JOURNAL_EMULATOR_OPT_IN = 'REQUIRE_COMMERCE_COMMAND_JOURNAL_EMULATOR'; const describeWithEmulator = process.env[JOURNAL_EMULATOR_OPT_IN] === '1' ? describe @@ -43,11 +50,32 @@ const COMMAND_IDS = Object.freeze({ missingCurrentAudit: '23456789-abcd-4efa-8bcd-23456789abcd', malformedLifecycleShape: '3456789a-bcde-4fab-8cde-3456789abcde', malformedCurrentAudit: '456789ab-cdef-4abc-8def-456789abcdef', + providerSamePlan: '56789abc-def0-4bcd-8ef0-56789abcdef0', + providerConflict: '6789abcd-ef01-4cde-8f01-6789abcdef01', + providerLostResponse: '789abcde-f012-4def-8012-789abcdef012', + providerTakeover: '89abcdef-0123-4efa-8123-89abcdef0123', + providerWrongHolder: '9abcdef0-1234-4fab-8234-9abcdef01234', + providerTerminal: 'abcdef01-2345-4abc-8345-abcdef012345', + providerPreseedAudit: 'bcdef012-3456-4bcd-8456-bcdef0123456', + providerOrphanPlan: 'cdef0123-4567-4cde-8567-cdef01234567', + providerMalformedPlan: 'def01234-5678-4def-8678-def012345678', + providerFutureAudit: 'ef012345-6789-4efa-8789-ef0123456789', + providerMalformedRoot: 'f0123456-789a-4fab-889a-f0123456789a', + providerMalformedLifecycle: '13579bdf-2468-4ace-8bdf-13579bdf2468', + providerEarlierClock: '2468ace0-1357-4bdf-8ace-2468ace01357', + providerOrphanAudit: '3579bdf1-4680-4ace-8bdf-3579bdf14680', + providerExpiredBeforeBind: '468ace02-5791-4bdf-8ace-468ace025791', + providerTerminalNoPlan: '579bdf13-6802-4ace-8bdf-579bdf136802', + providerCommitFailure: '68ace024-7913-4bdf-8ace-68ace0247913', }); const BASE_NOW_MILLIS = 1800000000123; const LEASE_DURATION_MILLIS = 60000; const TERMINAL_REFERENCE_FINGERPRINT = 'd'.repeat(64); const OTHER_TERMINAL_REFERENCE_FINGERPRINT = 'e'.repeat(64); +const STRIPE_ACCOUNT_ID = 'acct_1SyntheticTest000000000001'; +const STRIPE_API_VERSION = '2025-06-30.basil'; +const STRIPE_ENDPOINT_PATH = '/v1/checkout/sessions'; +const STRIPE_OPERATION = 'checkout_session_create'; function frozenRecord(entries) { const record = Object.create(null); @@ -63,6 +91,14 @@ function payload(reference) { ]); } +function providerParameters(reference) { + return frozenRecord([ + ['amount_total', 2500], + ['currency', 'usd'], + ['synthetic_reference', reference], + ]); +} + function commandArgs(db, commandId, commandPayload) { return { db, @@ -105,6 +141,28 @@ function failArgs(db, commandId, commandPayload, leaseId, expectedFenceEpoch) { }; } +function providerPlanArgs( + db, + commandId, + commandPayload, + leaseId, + expectedFenceEpoch, + parameters, + overrides = {}, +) { + return { + ...acquireArgs(db, commandId, commandPayload, leaseId), + expectedFenceEpoch, + stripeAccountId: STRIPE_ACCOUNT_ID, + stripeMode: 'test', + stripeApiVersion: STRIPE_API_VERSION, + endpointPath: STRIPE_ENDPOINT_PATH, + providerOperation: STRIPE_OPERATION, + providerParameters: parameters, + ...overrides, + }; +} + function assertExactSafeEmulator() { if (process.env[JOURNAL_EMULATOR_OPT_IN] !== '1') { throw new Error('The commerce command journal emulator opt-in is required.'); @@ -174,12 +232,32 @@ function expectJournalError(error, reason) { expect(Object.isFrozen(error)).toBe(true); } +// The emulator can exhaust Firestore's fixed internal retry budget under an +// intentional same-document race. Retry only the exact caller operation, with +// the same closed-over immutable input and no delay or regenerated identity. +async function retryWholeOperationAfterUnavailable(operation, retryEvidence) { + retryEvidence.operations += 1; + for (let call = 1; call <= MAXIMUM_WHOLE_OPERATION_CALLS; call += 1) { + try { + return await operation(); + } catch (error) { + const mayRetry = error instanceof CommerceCommandJournalError + && error.reason === 'journal_unavailable' + && call < MAXIMUM_WHOLE_OPERATION_CALLS; + if (!mayRetry) throw error; + retryEvidence.unavailableRetries += 1; + } + } + throw new Error('The bounded whole-operation retry loop did not return or throw.'); +} + describeWithEmulator('commerce command journal Firestore transaction', () => { let app; let db; let trackedRefs = new Map(); let nowMillis; let timestampNow; + let retryEvidence; function trackRef(ref) { trackedRefs.set(ref.path, ref); @@ -195,11 +273,22 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { const { commandKeyHash } = commandIdentity(args); const commandRef = trackRef(db.collection('checkoutRequests').doc(commandKeyHash)); const lifecycleRef = trackRef(commandRef.collection('lifecycle').doc('current')); + const providerPlanRef = trackRef(commandRef.collection('providerAttempts') + .doc('0000000001')); + const providerAuditRef = trackRef(db.collection('auditEvents') + .doc(`commerce_provider_attempt_${commandKeyHash}_0000000001`)); const auditRef = auditRefFor(commandKeyHash, 1); // Track every revision this focused suite can create before a concurrent // assertion runs, so even an early failure cannot leak synthetic audits. for (const revision of [2, 3, 4]) auditRefFor(commandKeyHash, revision); - return { commandKeyHash, commandRef, lifecycleRef, auditRef }; + return { + commandKeyHash, + commandRef, + lifecycleRef, + auditRef, + providerPlanRef, + providerAuditRef, + }; } async function readPair(pair) { @@ -229,6 +318,29 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { }; } + async function readProviderEvidence(pair) { + const [planSnapshot, auditSnapshot] = await Promise.all([ + pair.providerPlanRef.get(), + pair.providerAuditRef.get(), + ]); + return { + plan: snapshotEvidence(planSnapshot), + audit: snapshotEvidence(auditSnapshot), + }; + } + + async function readFoundationEvidence(pair, lifecycleRevision) { + const [registration, lifecycle] = await Promise.all([ + readPair(pair), + readLifecycleEvidence(pair, lifecycleRevision), + ]); + return { + command: snapshotEvidence(registration.commandSnapshot), + registrationAudit: snapshotEvidence(registration.auditSnapshot), + lifecycle, + }; + } + async function matchingAudits(pair) { return db.collection('auditEvents') .where('commandKeyHash', '==', pair.commandKeyHash) @@ -277,6 +389,33 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { expect(Object.isFrozen(result)).toBe(true); } + function expectProviderPlanResult(result, outcome) { + expect(result).toEqual({ + journalSchemaVersion: 1, + providerPlanSchemaVersion: 1, + outcome, + state: 'planned', + }); + expect(Object.isFrozen(result)).toBe(true); + } + + async function setupLeasedCommand(commandId, reference, leaseIndex) { + const commandPayload = payload(reference); + const leaseId = deterministicLeaseId(leaseIndex); + const registrationArgs = commandArgs(db, commandId, commandPayload); + const leaseArgs = acquireArgs(db, commandId, commandPayload, leaseId); + const pair = pairFor(leaseArgs); + await registerCommerceCommand(registrationArgs); + expectLeaseResult(await acquireCommerceCommandLease(leaseArgs), 1); + return { + commandPayload, + leaseId, + registrationArgs, + leaseArgs, + pair, + }; + } + async function cleanupTrackedRefs() { if (!trackedRefs) return; const refs = [...trackedRefs.values()]; @@ -296,6 +435,7 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { beforeEach(() => { trackedRefs = new Map(); + retryEvidence = { operations: 0, unavailableRetries: 0 }; nowMillis = BASE_NOW_MILLIS; timestampNow = jest.spyOn(admin.firestore.Timestamp, 'now') .mockImplementation(() => admin.firestore.Timestamp.fromMillis(nowMillis)); @@ -303,6 +443,8 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { afterEach(async () => { timestampNow.mockRestore(); + expect(retryEvidence.unavailableRetries) + .toBeLessThanOrEqual(retryEvidence.operations * (MAXIMUM_WHOLE_OPERATION_CALLS - 1)); await cleanupTrackedRefs(); }); @@ -316,7 +458,10 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { const pair = pairFor(args); const results = await Promise.all( - Array.from({ length: CONCURRENT_CALLS }, () => registerCommerceCommand(args)), + Array.from({ length: CONCURRENT_CALLS }, () => retryWholeOperationAfterUnavailable( + () => registerCommerceCommand(args), + retryEvidence, + )), ); expect(results.filter((result) => result.outcome === 'registered_new')).toHaveLength(1); @@ -347,8 +492,14 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { const pair = pairFor(firstArgs); const calls = [ - ...Array.from({ length: 12 }, () => registerCommerceCommand(firstArgs)), - ...Array.from({ length: 12 }, () => registerCommerceCommand(secondArgs)), + ...Array.from({ length: 12 }, () => retryWholeOperationAfterUnavailable( + () => registerCommerceCommand(firstArgs), + retryEvidence, + )), + ...Array.from({ length: 12 }, () => retryWholeOperationAfterUnavailable( + () => registerCommerceCommand(secondArgs), + retryEvidence, + )), ]; const settled = await Promise.allSettled(calls); const fulfilled = settled.filter((result) => result.status === 'fulfilled'); @@ -500,7 +651,10 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { commandPayload, deterministicLeaseId(index + 1), )); - const results = await Promise.all(leaseArgs.map(acquireCommerceCommandLease)); + const results = await Promise.all(leaseArgs.map((args) => retryWholeOperationAfterUnavailable( + () => acquireCommerceCommandLease(args), + retryEvidence, + ))); const acquired = results.filter((result) => result.outcome === 'lease_acquired'); const busy = results.filter((result) => result.outcome === 'lease_busy'); @@ -559,7 +713,10 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { await registerCommerceCommand(registrationArgs); const results = await Promise.all( - Array.from({ length: CONCURRENT_CALLS }, () => acquireCommerceCommandLease(args)), + Array.from({ length: CONCURRENT_CALLS }, () => retryWholeOperationAfterUnavailable( + () => acquireCommerceCommandLease(args), + retryEvidence, + )), ); for (const result of results) { expectLeaseResult(result, 1); @@ -602,7 +759,10 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { ), })); const results = await Promise.all( - contenders.map(({ args }) => acquireCommerceCommandLease(args)), + contenders.map(({ args }) => retryWholeOperationAfterUnavailable( + () => acquireCommerceCommandLease(args), + retryEvidence, + )), ); const winningIndexes = results .map((result, index) => ({ result, index })) @@ -673,20 +833,26 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { setTrustedNow(BASE_NOW_MILLIS + 1); const settled = await Promise.allSettled([ - completeCommerceCommand(completeArgs( - db, - COMMAND_IDS.terminalRace, - commandPayload, - leaseId, - 1, - )), - failCommerceCommand(failArgs( - db, - COMMAND_IDS.terminalRace, - commandPayload, - leaseId, - 1, - )), + retryWholeOperationAfterUnavailable( + () => completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.terminalRace, + commandPayload, + leaseId, + 1, + )), + retryEvidence, + ), + retryWholeOperationAfterUnavailable( + () => failCommerceCommand(failArgs( + db, + COMMAND_IDS.terminalRace, + commandPayload, + leaseId, + 1, + )), + retryEvidence, + ), ]); const fulfilled = settled.filter((result) => result.status === 'fulfilled'); const rejected = settled.filter((result) => result.status === 'rejected'); @@ -732,22 +898,28 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { setTrustedNow(BASE_NOW_MILLIS + 1); const settled = await Promise.allSettled([ - completeCommerceCommand(completeArgs( - db, - COMMAND_IDS.commitmentRace, - commandPayload, - leaseId, - 1, - TERMINAL_REFERENCE_FINGERPRINT, - )), - completeCommerceCommand(completeArgs( - db, - COMMAND_IDS.commitmentRace, - commandPayload, - leaseId, - 1, - OTHER_TERMINAL_REFERENCE_FINGERPRINT, - )), + retryWholeOperationAfterUnavailable( + () => completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.commitmentRace, + commandPayload, + leaseId, + 1, + TERMINAL_REFERENCE_FINGERPRINT, + )), + retryEvidence, + ), + retryWholeOperationAfterUnavailable( + () => completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.commitmentRace, + commandPayload, + leaseId, + 1, + OTHER_TERMINAL_REFERENCE_FINGERPRINT, + )), + retryEvidence, + ), ]); const fulfilled = settled.filter((result) => result.status === 'fulfilled'); const rejected = settled.filter((result) => result.status === 'rejected'); @@ -1063,4 +1235,688 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { expect((await currentAuditRef.get()).exists).toBe(false); expect((await readAudit(missingAuditPair, 3)).exists).toBe(false); }); + + test('24 same-plan current-holder calls create one immutable provider pair', async () => { + const setup = await setupLeasedCommand( + COMMAND_IDS.providerSamePlan, + 'provider-same-plan', + 300, + ); + const args = providerPlanArgs( + db, + COMMAND_IDS.providerSamePlan, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters('provider-same-plan'), + ); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + + const results = await Promise.all( + Array.from({ length: CONCURRENT_CALLS }, () => retryWholeOperationAfterUnavailable( + () => bindInitialStripeProviderPlan(args), + retryEvidence, + )), + ); + expect(results.filter(({ outcome }) => outcome === 'provider_plan_bound')) + .toHaveLength(1); + expect(results.filter(({ outcome }) => outcome === 'provider_plan_existing')) + .toHaveLength(CONCURRENT_CALLS - 1); + for (const result of results) { + expectProviderPlanResult(result, result.outcome); + } + + const provider = await readProviderEvidence(setup.pair); + expect(provider.plan.exists).toBe(true); + expect(provider.audit.exists).toBe(true); + expect(Object.keys(provider.plan.data).sort()).toEqual([ + 'boundAt', + 'boundFenceEpoch', + 'commandIdentityVersion', + 'commandKeyHash', + 'endpointPath', + 'environment', + 'httpMethod', + 'idempotencyKeyFingerprint', + 'parametersFingerprint', + 'provider', + 'providerAttempt', + 'providerOperation', + 'providerPlanSchemaVersion', + 'stripeAccountFingerprint', + 'stripeApiVersion', + 'stripeMode', + ].sort()); + expect(provider.plan.data).toMatchObject({ + providerPlanSchemaVersion: 1, + commandIdentityVersion: 1, + commandKeyHash: setup.pair.commandKeyHash, + environment: 'test', + provider: 'stripe', + providerAttempt: 1, + providerOperation: STRIPE_OPERATION, + stripeMode: 'test', + stripeApiVersion: STRIPE_API_VERSION, + httpMethod: 'POST', + endpointPath: STRIPE_ENDPOINT_PATH, + stripeAccountFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/), + parametersFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/), + idempotencyKeyFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/), + boundFenceEpoch: 1, + }); + expect(provider.plan.data.boundAt.toMillis()).toBe(BASE_NOW_MILLIS); + expect(Object.keys(provider.audit.data).sort()).toEqual([ + 'aggregateType', + 'boundFenceEpoch', + 'commandKeyHash', + 'environment', + 'eventType', + 'occurredAt', + 'provider', + 'providerAttempt', + 'providerOperation', + 'providerPlanAuditSchemaVersion', + 'stripeMode', + ].sort()); + expect(provider.audit.data).toMatchObject({ + providerPlanAuditSchemaVersion: 1, + aggregateType: 'commerce_provider_attempt', + commandKeyHash: setup.pair.commandKeyHash, + providerAttempt: 1, + eventType: 'provider_plan_bound', + provider: 'stripe', + environment: 'test', + stripeMode: 'test', + providerOperation: STRIPE_OPERATION, + boundFenceEpoch: 1, + }); + expect(provider.audit.data.occurredAt.toMillis()).toBe(BASE_NOW_MILLIS); + const serialized = JSON.stringify(provider); + expect(serialized).not.toContain(STRIPE_ACCOUNT_ID); + expect(serialized).not.toContain('provider-same-plan'); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBefore); + expect((await matchingAudits(setup.pair)).size).toBe(3); + }); + + test('conflicting provider plans race to one immutable winner', async () => { + const setup = await setupLeasedCommand( + COMMAND_IDS.providerConflict, + 'provider-plan-conflict', + 301, + ); + const firstArgs = providerPlanArgs( + db, + COMMAND_IDS.providerConflict, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters('provider-plan-a'), + ); + const secondArgs = providerPlanArgs( + db, + COMMAND_IDS.providerConflict, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters('provider-plan-b'), + ); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + const settled = await Promise.allSettled([ + ...Array.from({ length: 12 }, () => retryWholeOperationAfterUnavailable( + () => bindInitialStripeProviderPlan(firstArgs), + retryEvidence, + )), + ...Array.from({ length: 12 }, () => retryWholeOperationAfterUnavailable( + () => bindInitialStripeProviderPlan(secondArgs), + retryEvidence, + )), + ]); + const fulfilled = settled.filter(({ status }) => status === 'fulfilled'); + const rejected = settled.filter(({ status }) => status === 'rejected'); + expect(fulfilled).toHaveLength(12); + expect(fulfilled.filter(({ value }) => value.outcome === 'provider_plan_bound')) + .toHaveLength(1); + expect(fulfilled.filter(({ value }) => value.outcome === 'provider_plan_existing')) + .toHaveLength(11); + for (const result of fulfilled) expectProviderPlanResult(result.value, result.value.outcome); + expect(rejected).toHaveLength(12); + for (const result of rejected) expectJournalError(result.reason, 'command_conflict'); + + const firstHalfWon = settled.slice(0, 12).every(({ status }) => status === 'fulfilled'); + const secondHalfWon = settled.slice(12).every(({ status }) => status === 'fulfilled'); + expect([firstHalfWon, secondHalfWon]).toEqual(expect.arrayContaining([true, false])); + const winningArgs = firstHalfWon ? firstArgs : secondArgs; + const losingArgs = firstHalfWon ? secondArgs : firstArgs; + const providerBeforeProbes = await readProviderEvidence(setup.pair); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(winningArgs), + 'provider_plan_existing', + ); + await expect(bindInitialStripeProviderPlan(losingArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'command_conflict', + }); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBeforeProbes); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBefore); + expect(JSON.stringify(providerBeforeProbes)).not.toContain('provider-plan-a'); + expect(JSON.stringify(providerBeforeProbes)).not.toContain('provider-plan-b'); + expect((await matchingAudits(setup.pair)).size).toBe(3); + }); + + test('a provider-plan retry after a lost response is byte-preserving', async () => { + const setup = await setupLeasedCommand( + COMMAND_IDS.providerLostResponse, + 'provider-lost-response', + 302, + ); + const args = providerPlanArgs( + db, + COMMAND_IDS.providerLostResponse, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters('provider-lost-response'), + ); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(args), + 'provider_plan_bound', + ); + const providerBeforeRetry = await readProviderEvidence(setup.pair); + const foundationBeforeRetry = await readFoundationEvidence(setup.pair, 2); + + const retries = await Promise.all( + Array.from({ length: 8 }, () => bindInitialStripeProviderPlan(args)), + ); + for (const result of retries) { + expectProviderPlanResult(result, 'provider_plan_existing'); + } + expect(await readProviderEvidence(setup.pair)).toEqual(providerBeforeRetry); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBeforeRetry); + expect((await matchingAudits(setup.pair)).size).toBe(3); + }); + + test('an injected real-emulator commit failure leaves every record unchanged', async () => { + const setup = await setupLeasedCommand( + COMMAND_IDS.providerCommitFailure, + 'provider-commit-failure', + 318, + ); + const args = providerPlanArgs( + db, + COMMAND_IDS.providerCommitFailure, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters('provider-commit-failure'), + ); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + const providerBefore = await readProviderEvidence(setup.pair); + const auditsBefore = await matchingAudits(setup.pair); + const commitFailure = jest.spyOn(FirestoreTransaction.prototype, 'commit') + .mockRejectedValueOnce(new Error('synthetic commit failure')); + let commitCalls = 0; + + try { + const error = await bindInitialStripeProviderPlan(args).then( + () => null, + (reason) => reason, + ); + expectJournalError(error, 'journal_unavailable'); + commitCalls = commitFailure.mock.calls.length; + } finally { + commitFailure.mockRestore(); + } + + expect(commitCalls).toBe(1); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBefore); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBefore); + expect((await matchingAudits(setup.pair)).size).toBe(auditsBefore.size); + expect(providerBefore.plan.exists).toBe(false); + expect(providerBefore.audit.exists).toBe(false); + }); + + test('an expired holder cannot bind or observe, while takeover reads without rewrite', async () => { + const setup = await setupLeasedCommand( + COMMAND_IDS.providerTakeover, + 'provider-takeover', + 303, + ); + const oldArgs = providerPlanArgs( + db, + COMMAND_IDS.providerTakeover, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters('provider-takeover'), + ); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(oldArgs), + 'provider_plan_bound', + ); + const providerBeforeExpiry = await readProviderEvidence(setup.pair); + const foundationBeforeExpiry = await readFoundationEvidence(setup.pair, 2); + + setTrustedNow(BASE_NOW_MILLIS + LEASE_DURATION_MILLIS); + await expect(bindInitialStripeProviderPlan(oldArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'lease_stale', + }); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBeforeExpiry); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBeforeExpiry); + + const nextLeaseId = deterministicLeaseId(304); + const nextLeaseArgs = acquireArgs( + db, + COMMAND_IDS.providerTakeover, + setup.commandPayload, + nextLeaseId, + ); + expectLeaseResult(await acquireCommerceCommandLease(nextLeaseArgs), 2); + const providerBeforeTakeoverRead = await readProviderEvidence(setup.pair); + const foundationBeforeTakeoverRead = await readFoundationEvidence(setup.pair, 3); + const nextArgs = providerPlanArgs( + db, + COMMAND_IDS.providerTakeover, + setup.commandPayload, + nextLeaseId, + 2, + providerParameters('provider-takeover'), + ); + await expect(bindInitialStripeProviderPlan(oldArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'lease_stale', + }); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(nextArgs), + 'provider_plan_existing', + ); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBeforeTakeoverRead); + expect(await readFoundationEvidence(setup.pair, 3)).toEqual(foundationBeforeTakeoverRead); + expect(providerBeforeTakeoverRead.plan.data.boundFenceEpoch).toBe(1); + expect(providerBeforeTakeoverRead.plan.data.boundAt.toMillis()).toBe(BASE_NOW_MILLIS); + expect((await matchingAudits(setup.pair)).size).toBe(4); + + setTrustedNow(BASE_NOW_MILLIS + (LEASE_DURATION_MILLIS * 2)); + const expired = await setupLeasedCommand( + COMMAND_IDS.providerExpiredBeforeBind, + 'provider-expired-before-bind', + 316, + ); + const expiredArgs = providerPlanArgs( + db, + COMMAND_IDS.providerExpiredBeforeBind, + expired.commandPayload, + expired.leaseId, + 1, + providerParameters('provider-expired-before-bind'), + ); + const expiredFoundationBefore = await readFoundationEvidence(expired.pair, 2); + const expiredProviderBefore = await readProviderEvidence(expired.pair); + setTrustedNow(BASE_NOW_MILLIS + (LEASE_DURATION_MILLIS * 3)); + await expect(bindInitialStripeProviderPlan(expiredArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'lease_stale', + }); + expect(await readProviderEvidence(expired.pair)).toEqual(expiredProviderBefore); + expect(await readFoundationEvidence(expired.pair, 2)).toEqual(expiredFoundationBefore); + expect(expiredProviderBefore.plan.exists).toBe(false); + expect(expiredProviderBefore.audit.exists).toBe(false); + }); + + test('wrong holder, wrong fence, and earlier captured time reject without a plan', async () => { + const setup = await setupLeasedCommand( + COMMAND_IDS.providerWrongHolder, + 'provider-wrong-holder', + 305, + ); + const validParameters = providerParameters('provider-wrong-holder'); + const wrongHolderArgs = providerPlanArgs( + db, + COMMAND_IDS.providerWrongHolder, + setup.commandPayload, + deterministicLeaseId(306), + 1, + validParameters, + ); + const wrongFenceArgs = providerPlanArgs( + db, + COMMAND_IDS.providerWrongHolder, + setup.commandPayload, + setup.leaseId, + 2, + validParameters, + ); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + const providerBefore = await readProviderEvidence(setup.pair); + const settled = await Promise.allSettled([ + bindInitialStripeProviderPlan(wrongHolderArgs), + bindInitialStripeProviderPlan(wrongFenceArgs), + ]); + for (const result of settled) { + expect(result.status).toBe('rejected'); + expectJournalError(result.reason, 'lease_stale'); + } + expect(await readProviderEvidence(setup.pair)).toEqual(providerBefore); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBefore); + expect(providerBefore.plan.exists).toBe(false); + expect(providerBefore.audit.exists).toBe(false); + + setTrustedNow(BASE_NOW_MILLIS + 1000); + const clockSetup = await setupLeasedCommand( + COMMAND_IDS.providerEarlierClock, + 'provider-earlier-clock', + 307, + ); + const clockArgs = providerPlanArgs( + db, + COMMAND_IDS.providerEarlierClock, + clockSetup.commandPayload, + clockSetup.leaseId, + 1, + providerParameters('provider-earlier-clock'), + ); + const clockFoundationBefore = await readFoundationEvidence(clockSetup.pair, 2); + const clockProviderBefore = await readProviderEvidence(clockSetup.pair); + setTrustedNow(BASE_NOW_MILLIS); + await expect(bindInitialStripeProviderPlan(clockArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'lease_stale', + }); + expect(await readProviderEvidence(clockSetup.pair)).toEqual(clockProviderBefore); + expect(await readFoundationEvidence(clockSetup.pair, 2)) + .toEqual(clockFoundationBefore); + }); + + test('a terminal lifecycle cannot create or observe a provider plan', async () => { + const setup = await setupLeasedCommand( + COMMAND_IDS.providerTerminal, + 'provider-terminal', + 308, + ); + const args = providerPlanArgs( + db, + COMMAND_IDS.providerTerminal, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters('provider-terminal'), + ); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(args), + 'provider_plan_bound', + ); + setTrustedNow(BASE_NOW_MILLIS + 1); + expectTerminalResult(await completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.providerTerminal, + setup.commandPayload, + setup.leaseId, + 1, + )), 'succeeded'); + const foundationBefore = await readFoundationEvidence(setup.pair, 3); + const providerBefore = await readProviderEvidence(setup.pair); + await expect(bindInitialStripeProviderPlan(args)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'lease_stale', + }); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBefore); + expect(await readFoundationEvidence(setup.pair, 3)).toEqual(foundationBefore); + expect(providerBefore.plan.exists).toBe(true); + expect(providerBefore.audit.exists).toBe(true); + expect((await matchingAudits(setup.pair)).size).toBe(4); + + const noPlan = await setupLeasedCommand( + COMMAND_IDS.providerTerminalNoPlan, + 'provider-terminal-no-plan', + 317, + ); + setTrustedNow(BASE_NOW_MILLIS + 2); + expectTerminalResult(await completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.providerTerminalNoPlan, + noPlan.commandPayload, + noPlan.leaseId, + 1, + )), 'succeeded'); + const noPlanArgs = providerPlanArgs( + db, + COMMAND_IDS.providerTerminalNoPlan, + noPlan.commandPayload, + noPlan.leaseId, + 1, + providerParameters('provider-terminal-no-plan'), + ); + const noPlanFoundationBefore = await readFoundationEvidence(noPlan.pair, 3); + const noPlanProviderBefore = await readProviderEvidence(noPlan.pair); + await expect(bindInitialStripeProviderPlan(noPlanArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'lease_stale', + }); + expect(await readProviderEvidence(noPlan.pair)).toEqual(noPlanProviderBefore); + expect(await readFoundationEvidence(noPlan.pair, 3)).toEqual(noPlanFoundationBefore); + expect(noPlanProviderBefore.plan.exists).toBe(false); + expect(noPlanProviderBefore.audit.exists).toBe(false); + }); + + test('preseeded or orphan provider partners reject without repair', async () => { + const preseed = await setupLeasedCommand( + COMMAND_IDS.providerPreseedAudit, + 'provider-preseed-audit', + 309, + ); + const preseedArgs = providerPlanArgs( + db, + COMMAND_IDS.providerPreseedAudit, + preseed.commandPayload, + preseed.leaseId, + 1, + providerParameters('provider-preseed-audit'), + ); + await preseed.pair.providerAuditRef.create({ syntheticPreseed: true }); + const preseedFoundationBefore = await readFoundationEvidence(preseed.pair, 2); + const preseedProviderBefore = await readProviderEvidence(preseed.pair); + await expect(bindInitialStripeProviderPlan(preseedArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderEvidence(preseed.pair)).toEqual(preseedProviderBefore); + expect(await readFoundationEvidence(preseed.pair, 2)).toEqual(preseedFoundationBefore); + expect(preseedProviderBefore.plan.exists).toBe(false); + expect(preseedProviderBefore.audit.exists).toBe(true); + + const orphan = await setupLeasedCommand( + COMMAND_IDS.providerOrphanPlan, + 'provider-orphan-plan', + 310, + ); + const orphanArgs = providerPlanArgs( + db, + COMMAND_IDS.providerOrphanPlan, + orphan.commandPayload, + orphan.leaseId, + 1, + providerParameters('provider-orphan-plan'), + ); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(orphanArgs), + 'provider_plan_bound', + ); + await orphan.pair.providerAuditRef.delete(); + const orphanFoundationBefore = await readFoundationEvidence(orphan.pair, 2); + const orphanProviderBefore = await readProviderEvidence(orphan.pair); + await expect(bindInitialStripeProviderPlan(orphanArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderEvidence(orphan.pair)).toEqual(orphanProviderBefore); + expect(await readFoundationEvidence(orphan.pair, 2)).toEqual(orphanFoundationBefore); + expect(orphanProviderBefore.plan.exists).toBe(true); + expect(orphanProviderBefore.audit.exists).toBe(false); + + const orphanAudit = await setupLeasedCommand( + COMMAND_IDS.providerOrphanAudit, + 'provider-orphan-audit', + 315, + ); + const orphanAuditArgs = providerPlanArgs( + db, + COMMAND_IDS.providerOrphanAudit, + orphanAudit.commandPayload, + orphanAudit.leaseId, + 1, + providerParameters('provider-orphan-audit'), + ); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(orphanAuditArgs), + 'provider_plan_bound', + ); + await orphanAudit.pair.providerPlanRef.delete(); + const orphanAuditFoundationBefore = await readFoundationEvidence(orphanAudit.pair, 2); + const orphanAuditProviderBefore = await readProviderEvidence(orphanAudit.pair); + await expect(bindInitialStripeProviderPlan(orphanAuditArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderEvidence(orphanAudit.pair)).toEqual(orphanAuditProviderBefore); + expect(await readFoundationEvidence(orphanAudit.pair, 2)) + .toEqual(orphanAuditFoundationBefore); + expect(orphanAuditProviderBefore.plan.exists).toBe(false); + expect(orphanAuditProviderBefore.audit.exists).toBe(true); + }); + + test('malformed and future provider records reject without rewrite', async () => { + const malformed = await setupLeasedCommand( + COMMAND_IDS.providerMalformedPlan, + 'provider-malformed-plan', + 311, + ); + const malformedArgs = providerPlanArgs( + db, + COMMAND_IDS.providerMalformedPlan, + malformed.commandPayload, + malformed.leaseId, + 1, + providerParameters('provider-malformed-plan'), + ); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(malformedArgs), + 'provider_plan_bound', + ); + const validPlan = await malformed.pair.providerPlanRef.get(); + await malformed.pair.providerPlanRef.set({ + ...validPlan.data(), + providerAttempt: 2, + }); + const malformedFoundationBefore = await readFoundationEvidence(malformed.pair, 2); + const malformedProviderBefore = await readProviderEvidence(malformed.pair); + await expect(bindInitialStripeProviderPlan(malformedArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderEvidence(malformed.pair)).toEqual(malformedProviderBefore); + expect(await readFoundationEvidence(malformed.pair, 2)) + .toEqual(malformedFoundationBefore); + + const future = await setupLeasedCommand( + COMMAND_IDS.providerFutureAudit, + 'provider-future-audit', + 312, + ); + const futureArgs = providerPlanArgs( + db, + COMMAND_IDS.providerFutureAudit, + future.commandPayload, + future.leaseId, + 1, + providerParameters('provider-future-audit'), + ); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(futureArgs), + 'provider_plan_bound', + ); + const validAudit = await future.pair.providerAuditRef.get(); + await future.pair.providerAuditRef.set({ + ...validAudit.data(), + providerPlanAuditSchemaVersion: 2, + }); + const futureFoundationBefore = await readFoundationEvidence(future.pair, 2); + const futureProviderBefore = await readProviderEvidence(future.pair); + await expect(bindInitialStripeProviderPlan(futureArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderEvidence(future.pair)).toEqual(futureProviderBefore); + expect(await readFoundationEvidence(future.pair, 2)).toEqual(futureFoundationBefore); + + await future.pair.providerAuditRef.set({ + ...validAudit.data(), + eventType: 'provider_request_sent', + }); + const malformedAuditFoundationBefore = await readFoundationEvidence(future.pair, 2); + const malformedAuditProviderBefore = await readProviderEvidence(future.pair); + await expect(bindInitialStripeProviderPlan(futureArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderEvidence(future.pair)).toEqual(malformedAuditProviderBefore); + expect(await readFoundationEvidence(future.pair, 2)) + .toEqual(malformedAuditFoundationBefore); + }); + + test('malformed B2A and B2B partners block plan creation without repair', async () => { + const root = await setupLeasedCommand( + COMMAND_IDS.providerMalformedRoot, + 'provider-malformed-root', + 313, + ); + const rootArgs = providerPlanArgs( + db, + COMMAND_IDS.providerMalformedRoot, + root.commandPayload, + root.leaseId, + 1, + providerParameters('provider-malformed-root'), + ); + const validRoot = await root.pair.commandRef.get(); + await root.pair.commandRef.set({ + ...validRoot.data(), + journalSchemaVersion: 2, + }); + const rootFoundationBefore = await readFoundationEvidence(root.pair, 2); + const rootProviderBefore = await readProviderEvidence(root.pair); + await expect(bindInitialStripeProviderPlan(rootArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderEvidence(root.pair)).toEqual(rootProviderBefore); + expect(await readFoundationEvidence(root.pair, 2)).toEqual(rootFoundationBefore); + + const lifecycle = await setupLeasedCommand( + COMMAND_IDS.providerMalformedLifecycle, + 'provider-malformed-lifecycle', + 314, + ); + const lifecycleArgs = providerPlanArgs( + db, + COMMAND_IDS.providerMalformedLifecycle, + lifecycle.commandPayload, + lifecycle.leaseId, + 1, + providerParameters('provider-malformed-lifecycle'), + ); + const validLifecycle = await lifecycle.pair.lifecycleRef.get(); + await lifecycle.pair.lifecycleRef.set({ + ...validLifecycle.data(), + commandRevision: 4, + }); + const lifecycleFoundationBefore = await readFoundationEvidence(lifecycle.pair, 2); + const lifecycleProviderBefore = await readProviderEvidence(lifecycle.pair); + await expect(bindInitialStripeProviderPlan(lifecycleArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderEvidence(lifecycle.pair)).toEqual(lifecycleProviderBefore); + expect(await readFoundationEvidence(lifecycle.pair, 2)) + .toEqual(lifecycleFoundationBefore); + }); }); diff --git a/functions/commerceCommandJournal.js b/functions/commerceCommandJournal.js index d90abf63..567a9b2e 100644 --- a/functions/commerceCommandJournal.js +++ b/functions/commerceCommandJournal.js @@ -7,12 +7,15 @@ const { CommandIdentityError, createCommandKey, createPayloadFingerprint, + createStripeIdempotencyKey, } = require('./commerceCommandIdentity'); const journalSchemaVersion = 1; const auditSchemaVersion = 1; const lifecycleSchemaVersion = 1; const lifecycleAuditSchemaVersion = 2; +const providerPlanSchemaVersion = 1; +const providerPlanAuditSchemaVersion = 1; const MAXIMUM_ENDPOINT_SCHEMA_VERSION = 1000000; const MAXIMUM_LIFECYCLE_NUMBER = 9999999999; const LEASE_DURATION_SECONDS = 60; @@ -21,18 +24,31 @@ const COMMAND_COLLECTION = 'checkoutRequests'; const AUDIT_COLLECTION = 'auditEvents'; const LIFECYCLE_COLLECTION = 'lifecycle'; const LIFECYCLE_DOCUMENT = 'current'; +const PROVIDER_ATTEMPTS_COLLECTION = 'providerAttempts'; +const INITIAL_PROVIDER_ATTEMPT_DOCUMENT = '0000000001'; const COMMAND_STATE = 'registered'; const COMMAND_REVISION = 1; +const INITIAL_PROVIDER_ATTEMPT = 1; +const PROVIDER_PARAMETERS_COMMAND_TYPE = 'stripe.provider.parameters'; const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/; const CANONICAL_UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const SAFE_COMMAND_TYPE = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; +const SAFE_STRIPE_ACCOUNT_ID = /^acct_[A-Za-z0-9]{16,64}$/; +const SAFE_STRIPE_API_VERSION = /^(20\d{2})-(0[1-9]|1[0-2])-([0-2]\d|3[01])(?:\.([a-z][a-z0-9]{0,31}))?$/; const LIFECYCLE_STATES = new Set(['leased', 'succeeded', 'failed_final']); const HASH_MAGIC = 'mprc-commerce-command-journal-sha256'; const HASH_DOMAINS = Object.freeze({ leaseOwner: 'mprc.command-lease-owner.v1', terminalCommitment: 'mprc.command-terminal-commitment.v1', + stripeAccount: 'mprc.command-provider-plan-stripe-account.v1', + stripeParameters: 'mprc.command-provider-plan-stripe-parameters.v1', + stripeIdempotencyKey: 'mprc.command-provider-plan-stripe-idempotency-key.v1', }); const ENVIRONMENTS = new Set(['local', 'test', 'staging', 'production']); +const STRIPE_MODES = new Set(['test', 'live']); +const STRIPE_ENDPOINT_BY_OPERATION = Object.freeze({ + checkout_session_create: '/v1/checkout/sessions', +}); const CALLER_SCOPE_KINDS = new Set([ 'firebase_uid', 'anonymous_principal', @@ -58,6 +74,16 @@ const FAIL_INPUT_FIELDS = Object.freeze([ ...ACQUIRE_INPUT_FIELDS, 'expectedFenceEpoch', ]); +const BIND_PROVIDER_PLAN_INPUT_FIELDS = Object.freeze([ + ...ACQUIRE_INPUT_FIELDS, + 'expectedFenceEpoch', + 'stripeAccountId', + 'stripeMode', + 'stripeApiVersion', + 'endpointPath', + 'providerOperation', + 'providerParameters', +]); const CALLER_SCOPE_FIELDS = Object.freeze(['kind', 'value']); const COMMAND_FIELDS = Object.freeze([ 'journalSchemaVersion', @@ -114,6 +140,37 @@ const LIFECYCLE_AUDIT_FIELDS = Object.freeze([ 'leaseExpiresAt', 'occurredAt', ]); +const PROVIDER_PLAN_FIELDS = Object.freeze([ + 'providerPlanSchemaVersion', + 'commandIdentityVersion', + 'commandKeyHash', + 'environment', + 'provider', + 'providerAttempt', + 'providerOperation', + 'stripeMode', + 'stripeAccountFingerprint', + 'stripeApiVersion', + 'httpMethod', + 'endpointPath', + 'parametersFingerprint', + 'idempotencyKeyFingerprint', + 'boundFenceEpoch', + 'boundAt', +]); +const PROVIDER_PLAN_AUDIT_FIELDS = Object.freeze([ + 'providerPlanAuditSchemaVersion', + 'aggregateType', + 'commandKeyHash', + 'providerAttempt', + 'eventType', + 'provider', + 'environment', + 'stripeMode', + 'providerOperation', + 'boundFenceEpoch', + 'occurredAt', +]); const ERROR_MESSAGES = Object.freeze({ invalid_command_input: 'Commerce command journal input is invalid.', @@ -339,6 +396,44 @@ function createTerminalCommitment(commandKeyHash, terminalReferenceFingerprint) ]); } +function createProviderCommitment(domain, commandKeyHash, valueName, value) { + return digest(domain, [ + ['version', String(providerPlanSchemaVersion)], + ['commandKeyHash', commandKeyHash], + [valueName, value], + ]); +} + +function validStripeAccountId(value) { + return typeof value === 'string' && SAFE_STRIPE_ACCOUNT_ID.test(value); +} + +function validStripeModeForEnvironment(stripeMode, environment) { + if (typeof stripeMode !== 'string' || !STRIPE_MODES.has(stripeMode)) return false; + return stripeMode === (environment === 'production' ? 'live' : 'test'); +} + +function validStripeApiVersion(value) { + if (typeof value !== 'string' || value.length > 64) return false; + const match = SAFE_STRIPE_API_VERSION.exec(value); + if (match === null) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysByMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return year >= 2011 && year <= 2099 && day <= daysByMonth[month - 1]; +} + +function validStripeOperationEndpoint(providerOperation, endpointPath) { + return typeof providerOperation === 'string' + && Object.prototype.hasOwnProperty.call( + STRIPE_ENDPOINT_BY_OPERATION, + providerOperation, + ) + && endpointPath === STRIPE_ENDPOINT_BY_OPERATION[providerOperation]; +} + function validLifecycleNumber(value) { return Number.isSafeInteger(value) && value >= 1 @@ -549,6 +644,13 @@ function auditDocumentId(commandKeyHash, revision) { return `commerce_command_${commandKeyHash}_${String(revision).padStart(10, '0')}`; } +function providerPlanAuditDocumentId(commandKeyHash) { + if (typeof commandKeyHash !== 'string' || !LOWERCASE_SHA256.test(commandKeyHash)) { + reject('journal_record_invalid'); + } + return `commerce_provider_attempt_${commandKeyHash}_${INITIAL_PROVIDER_ATTEMPT_DOCUMENT}`; +} + function prepareIdentity(input, fields) { let values; let commandKey; @@ -652,6 +754,136 @@ function prepareLifecycleOperation(input, fields, operation) { }); } +function prepareProviderPlanOperation(input) { + const identity = prepareIdentity(input, BIND_PROVIDER_PLAN_INPUT_FIELDS); + const { values } = identity; + + if (typeof values.leaseId !== 'string' + || !CANONICAL_UUID_V4.test(values.leaseId) + || values.leaseId === values.commandId + || !validLifecycleNumber(values.expectedFenceEpoch) + || !validStripeAccountId(values.stripeAccountId) + || !validStripeModeForEnvironment(values.stripeMode, values.environment) + || !validStripeApiVersion(values.stripeApiVersion) + || !validStripeOperationEndpoint(values.providerOperation, values.endpointPath)) { + reject('invalid_command_input'); + } + + let canonicalParametersFingerprint; + let stripeIdempotencyKey; + try { + canonicalParametersFingerprint = createPayloadFingerprint({ + commandType: PROVIDER_PARAMETERS_COMMAND_TYPE, + payload: values.providerParameters, + }).payloadFingerprint; + stripeIdempotencyKey = createStripeIdempotencyKey({ + stripeMode: values.stripeMode, + environment: values.environment, + providerOperation: values.providerOperation, + commandKeyHash: identity.commandKeyHash, + providerAttempt: INITIAL_PROVIDER_ATTEMPT, + }).stripeIdempotencyKey; + } catch (error) { + if (error instanceof CommandIdentityError) reject('invalid_command_input'); + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } + + let commandRef; + let registrationAuditRef; + let lifecycleRef; + let providerPlanRef; + let providerPlanAuditRef; + try { + commandRef = values.db.collection(COMMAND_COLLECTION).doc(identity.commandKeyHash); + registrationAuditRef = values.db.collection(AUDIT_COLLECTION).doc( + auditDocumentId(identity.commandKeyHash, COMMAND_REVISION), + ); + lifecycleRef = commandRef.collection(LIFECYCLE_COLLECTION).doc(LIFECYCLE_DOCUMENT); + providerPlanRef = commandRef.collection(PROVIDER_ATTEMPTS_COLLECTION).doc( + INITIAL_PROVIDER_ATTEMPT_DOCUMENT, + ); + providerPlanAuditRef = values.db.collection(AUDIT_COLLECTION).doc( + providerPlanAuditDocumentId(identity.commandKeyHash), + ); + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } + + const occurredAt = captureTrustedTimestamp(); + const leaseOwnerFingerprint = createLeaseOwnerFingerprint( + identity.commandKeyHash, + values.leaseId, + ); + const stripeAccountFingerprint = createProviderCommitment( + HASH_DOMAINS.stripeAccount, + identity.commandKeyHash, + 'stripeAccountId', + values.stripeAccountId, + ); + const parametersFingerprint = createProviderCommitment( + HASH_DOMAINS.stripeParameters, + identity.commandKeyHash, + 'canonicalParametersFingerprint', + canonicalParametersFingerprint, + ); + const idempotencyKeyFingerprint = createProviderCommitment( + HASH_DOMAINS.stripeIdempotencyKey, + identity.commandKeyHash, + 'stripeIdempotencyKey', + stripeIdempotencyKey, + ); + const providerPlanRecord = Object.freeze({ + providerPlanSchemaVersion, + commandIdentityVersion, + commandKeyHash: identity.commandKeyHash, + environment: values.environment, + provider: 'stripe', + providerAttempt: INITIAL_PROVIDER_ATTEMPT, + providerOperation: values.providerOperation, + stripeMode: values.stripeMode, + stripeAccountFingerprint, + stripeApiVersion: values.stripeApiVersion, + httpMethod: 'POST', + endpointPath: values.endpointPath, + parametersFingerprint, + idempotencyKeyFingerprint, + boundFenceEpoch: values.expectedFenceEpoch, + boundAt: occurredAt, + }); + const providerPlanAuditRecord = Object.freeze({ + providerPlanAuditSchemaVersion, + aggregateType: 'commerce_provider_attempt', + commandKeyHash: identity.commandKeyHash, + providerAttempt: INITIAL_PROVIDER_ATTEMPT, + eventType: 'provider_plan_bound', + provider: 'stripe', + environment: values.environment, + stripeMode: values.stripeMode, + providerOperation: values.providerOperation, + boundFenceEpoch: values.expectedFenceEpoch, + occurredAt, + }); + + return Object.freeze({ + db: values.db, + expected: identity.expected, + commandKeyHash: identity.commandKeyHash, + expectedFenceEpoch: values.expectedFenceEpoch, + leaseOwnerFingerprint, + occurredAt, + occurredAtParts: readTimestamp(occurredAt), + commandRef, + registrationAuditRef, + lifecycleRef, + providerPlanRef, + providerPlanAuditRef, + providerPlanRecord, + providerPlanAuditRecord, + }); +} + function readSnapshot(snapshot) { let exists; try { @@ -878,6 +1110,346 @@ function writeLifecycleTransition(transaction, prepared, context, lifecycleRecor transaction.create(context.nextAuditRef, auditRecord); } +function validateProviderLeaseAudit(value, expectedFenceEpoch, prepared) { + const audit = readExactOwnDataObject( + value, + LIFECYCLE_AUDIT_FIELDS, + 'journal_record_invalid', + ); + const leaseExpiresAt = readTimestamp(audit.leaseExpiresAt); + const occurredAt = readTimestamp(audit.occurredAt); + const expectedRevision = expectedFenceEpoch + 1; + const firstLease = expectedFenceEpoch === 1; + + if (!validLifecycleNumber(expectedFenceEpoch) + || !validLifecycleNumber(expectedRevision) + || audit.auditSchemaVersion !== lifecycleAuditSchemaVersion + || audit.aggregateType !== 'commerce_command' + || audit.commandKeyHash !== prepared.commandKeyHash + || audit.commandRevision !== expectedRevision + || audit.eventType !== (firstLease ? 'command_lease_acquired' : 'command_lease_taken_over') + || audit.fromState !== (firstLease ? 'registered' : 'leased') + || audit.toState !== 'leased' + || audit.environment !== prepared.expected.environment + || audit.callerScopeKind !== prepared.expected.callerScopeKind + || audit.commandType !== prepared.expected.commandType + || audit.fenceEpoch !== expectedFenceEpoch + || leaseExpiresAt === null + || occurredAt === null + || !timestampExactlySecondsAfter( + leaseExpiresAt, + occurredAt, + LEASE_DURATION_SECONDS, + )) { + reject('journal_record_invalid'); + } + return Object.freeze({ + fenceEpoch: expectedFenceEpoch, + leaseExpiresAt, + occurredAt, + }); +} + +function validateProviderLeaseChronology(leaseAudit, predecessorAudit, lifecycle) { + if (leaseAudit.fenceEpoch === 1) { + if (predecessorAudit !== null + || !timestampsEqual(leaseAudit.occurredAt, lifecycle.createdAt)) { + reject('journal_record_invalid'); + } + return; + } + + if (predecessorAudit === null + || predecessorAudit.fenceEpoch !== leaseAudit.fenceEpoch - 1 + || (predecessorAudit.fenceEpoch === 1 + ? !timestampsEqual(predecessorAudit.occurredAt, lifecycle.createdAt) + : compareTimestamps(predecessorAudit.occurredAt, lifecycle.createdAt) <= 0) + || compareTimestamps(leaseAudit.occurredAt, predecessorAudit.leaseExpiresAt) < 0) { + reject('journal_record_invalid'); + } +} + +function validateProviderPlanPair( + plan, + audit, + bindingAudit, + prepared, + registration, + lifecycle, +) { + const boundAt = readTimestamp(plan.boundAt); + const occurredAt = readTimestamp(audit.occurredAt); + + if (plan.providerPlanSchemaVersion !== providerPlanSchemaVersion + || plan.commandIdentityVersion !== commandIdentityVersion + || plan.commandKeyHash !== prepared.commandKeyHash + || plan.environment !== prepared.expected.environment + || plan.provider !== 'stripe' + || plan.providerAttempt !== INITIAL_PROVIDER_ATTEMPT + || !validStripeOperationEndpoint(plan.providerOperation, plan.endpointPath) + || !validStripeModeForEnvironment(plan.stripeMode, plan.environment) + || typeof plan.stripeAccountFingerprint !== 'string' + || !LOWERCASE_SHA256.test(plan.stripeAccountFingerprint) + || !validStripeApiVersion(plan.stripeApiVersion) + || plan.httpMethod !== 'POST' + || typeof plan.parametersFingerprint !== 'string' + || !LOWERCASE_SHA256.test(plan.parametersFingerprint) + || typeof plan.idempotencyKeyFingerprint !== 'string' + || !LOWERCASE_SHA256.test(plan.idempotencyKeyFingerprint) + || !validLifecycleNumber(plan.boundFenceEpoch) + || boundAt === null) { + reject('journal_record_invalid'); + } + + let storedStripeIdempotencyKey; + try { + storedStripeIdempotencyKey = createStripeIdempotencyKey({ + stripeMode: plan.stripeMode, + environment: plan.environment, + providerOperation: plan.providerOperation, + commandKeyHash: plan.commandKeyHash, + providerAttempt: plan.providerAttempt, + }).stripeIdempotencyKey; + } catch { + reject('journal_record_invalid'); + } + const storedKeyFingerprint = createProviderCommitment( + HASH_DOMAINS.stripeIdempotencyKey, + plan.commandKeyHash, + 'stripeIdempotencyKey', + storedStripeIdempotencyKey, + ); + + if (plan.idempotencyKeyFingerprint !== storedKeyFingerprint + || audit.providerPlanAuditSchemaVersion !== providerPlanAuditSchemaVersion + || audit.aggregateType !== 'commerce_provider_attempt' + || audit.commandKeyHash !== plan.commandKeyHash + || audit.providerAttempt !== plan.providerAttempt + || audit.eventType !== 'provider_plan_bound' + || audit.provider !== plan.provider + || audit.environment !== plan.environment + || audit.stripeMode !== plan.stripeMode + || audit.providerOperation !== plan.providerOperation + || audit.boundFenceEpoch !== plan.boundFenceEpoch + || !timestampsEqual(occurredAt, boundAt) + || compareTimestamps(bindingAudit.occurredAt, registration.createdAt) < 0 + || compareTimestamps(boundAt, registration.createdAt) < 0 + || plan.boundFenceEpoch > lifecycle.record.fenceEpoch + || compareTimestamps(boundAt, bindingAudit.occurredAt) < 0 + || compareTimestamps(boundAt, bindingAudit.leaseExpiresAt) >= 0) { + reject('journal_record_invalid'); + } + + if (plan.boundFenceEpoch === lifecycle.record.fenceEpoch) { + if (!timestampsEqual(bindingAudit.occurredAt, lifecycle.leaseAcquiredAt) + || !timestampsEqual(bindingAudit.leaseExpiresAt, lifecycle.leaseExpiresAt)) { + reject('journal_record_invalid'); + } + } else if (compareTimestamps(bindingAudit.leaseExpiresAt, lifecycle.leaseAcquiredAt) > 0) { + reject('journal_record_invalid'); + } + + const expected = prepared.providerPlanRecord; + if (plan.providerOperation !== expected.providerOperation + || plan.stripeMode !== expected.stripeMode + || plan.stripeAccountFingerprint !== expected.stripeAccountFingerprint + || plan.stripeApiVersion !== expected.stripeApiVersion + || plan.endpointPath !== expected.endpointPath + || plan.parametersFingerprint !== expected.parametersFingerprint + || plan.idempotencyKeyFingerprint !== expected.idempotencyKeyFingerprint) { + reject('command_conflict'); + } + + return Object.freeze({ record: plan, boundAt }); +} + +async function readProviderPlanContext(transaction, prepared) { + const commandSnapshot = await transaction.get(prepared.commandRef); + const registrationAuditSnapshot = await transaction.get(prepared.registrationAuditRef); + const lifecycleSnapshot = await transaction.get(prepared.lifecycleRef); + const command = readSnapshot(commandSnapshot); + const registrationAudit = readSnapshot(registrationAuditSnapshot); + const lifecycleDocument = readSnapshot(lifecycleSnapshot); + + let lifecycle = null; + let currentAudit = Object.freeze({ exists: false, value: null }); + const lifecycleAuditsByRevision = new Map(); + if (lifecycleDocument.exists) { + lifecycle = parseLifecycle(lifecycleDocument.value, prepared.expected); + const currentAuditRef = prepared.db.collection(AUDIT_COLLECTION).doc( + auditDocumentId(prepared.commandKeyHash, lifecycle.record.commandRevision), + ); + currentAudit = readSnapshot(await transaction.get(currentAuditRef)); + lifecycleAuditsByRevision.set(lifecycle.record.commandRevision, currentAudit); + } + + const providerPlanSnapshot = await transaction.get(prepared.providerPlanRef); + const providerPlanAuditSnapshot = await transaction.get(prepared.providerPlanAuditRef); + const providerPlanDocument = readSnapshot(providerPlanSnapshot); + const providerPlanAudit = readSnapshot(providerPlanAuditSnapshot); + + async function readRequiredLifecycleAudit(revision) { + if (!validLifecycleNumber(revision)) reject('journal_record_invalid'); + let audit = lifecycleAuditsByRevision.get(revision); + if (audit === undefined) { + const auditRef = prepared.db.collection(AUDIT_COLLECTION).doc( + auditDocumentId(prepared.commandKeyHash, revision), + ); + audit = readSnapshot(await transaction.get(auditRef)); + lifecycleAuditsByRevision.set(revision, audit); + } + if (!audit.exists) reject('journal_record_invalid'); + return audit; + } + + if (command.exists !== registrationAudit.exists) reject('journal_record_invalid'); + if (!command.exists) { + if (lifecycleDocument.exists || providerPlanDocument.exists || providerPlanAudit.exists) { + reject('journal_record_invalid'); + } + reject('command_not_registered'); + } + const registration = validateExistingPair( + command.value, + registrationAudit.value, + prepared.expected, + ); + + if (lifecycle === null) { + if (providerPlanDocument.exists || providerPlanAudit.exists) { + reject('journal_record_invalid'); + } + reject('lease_stale'); + } + if (compareTimestamps(lifecycle.createdAt, registration.createdAt) < 0 + || !currentAudit.exists) { + reject('journal_record_invalid'); + } + validateLifecycleAudit(currentAudit.value, lifecycle, prepared.expected); + const mayCreate = validateActiveProviderPlanLease(prepared, lifecycle); + const currentLeaseAudit = validateProviderLeaseAudit( + currentAudit.value, + lifecycle.record.fenceEpoch, + prepared, + ); + let currentPredecessorAudit = null; + if (lifecycle.record.fenceEpoch > 1) { + const predecessorDocument = await readRequiredLifecycleAudit( + lifecycle.record.fenceEpoch, + ); + currentPredecessorAudit = validateProviderLeaseAudit( + predecessorDocument.value, + lifecycle.record.fenceEpoch - 1, + prepared, + ); + } + // The current/binding lease and its immediate predecessor are a fixed read + // budget. Earlier server-only append history remains a trust boundary; a + // recursive scan would make transaction reads grow without a bound. + validateProviderLeaseChronology(currentLeaseAudit, currentPredecessorAudit, lifecycle); + + if (providerPlanDocument.exists !== providerPlanAudit.exists) { + reject('journal_record_invalid'); + } + let providerPlan = null; + if (providerPlanDocument.exists) { + const plan = readExactOwnDataObject( + providerPlanDocument.value, + PROVIDER_PLAN_FIELDS, + 'journal_record_invalid', + ); + const planAudit = readExactOwnDataObject( + providerPlanAudit.value, + PROVIDER_PLAN_AUDIT_FIELDS, + 'journal_record_invalid', + ); + if (!validLifecycleNumber(plan.boundFenceEpoch) + || !validLifecycleNumber(plan.boundFenceEpoch + 1) + || plan.boundFenceEpoch > lifecycle.record.fenceEpoch) { + reject('journal_record_invalid'); + } + let bindingAudit = currentLeaseAudit; + let bindingPredecessorAudit = currentPredecessorAudit; + if (lifecycle.record.fenceEpoch !== plan.boundFenceEpoch) { + const bindingAuditDocument = await readRequiredLifecycleAudit( + plan.boundFenceEpoch + 1, + ); + bindingAudit = validateProviderLeaseAudit( + bindingAuditDocument.value, + plan.boundFenceEpoch, + prepared, + ); + bindingPredecessorAudit = null; + if (plan.boundFenceEpoch > 1) { + const predecessorDocument = await readRequiredLifecycleAudit(plan.boundFenceEpoch); + bindingPredecessorAudit = validateProviderLeaseAudit( + predecessorDocument.value, + plan.boundFenceEpoch - 1, + prepared, + ); + } + } + validateProviderLeaseChronology(bindingAudit, bindingPredecessorAudit, lifecycle); + providerPlan = validateProviderPlanPair( + plan, + planAudit, + bindingAudit, + prepared, + registration, + lifecycle, + ); + } + + return Object.freeze({ lifecycle, providerPlan, mayCreate }); +} + +function validateActiveProviderPlanLease(prepared, lifecycle) { + if (lifecycle.record.state !== 'leased' + || lifecycle.record.leaseOwnerFingerprint !== prepared.leaseOwnerFingerprint + || lifecycle.record.fenceEpoch !== prepared.expectedFenceEpoch + || compareTimestamps(prepared.occurredAtParts, lifecycle.leaseExpiresAt) >= 0) { + reject('lease_stale'); + } + return compareTimestamps(prepared.occurredAtParts, lifecycle.leaseAcquiredAt) >= 0; +} + +const PROVIDER_PLAN_BOUND = Object.freeze({ + journalSchemaVersion, + providerPlanSchemaVersion, + outcome: 'provider_plan_bound', + state: 'planned', +}); +const PROVIDER_PLAN_EXISTING = Object.freeze({ + journalSchemaVersion, + providerPlanSchemaVersion, + outcome: 'provider_plan_existing', + state: 'planned', +}); + +async function bindInitialStripeProviderPlan(input) { + const prepared = prepareProviderPlanOperation(input); + + try { + const result = await prepared.db.runTransaction(async (transaction) => { + const context = await readProviderPlanContext(transaction, prepared); + if (context.providerPlan !== null) return PROVIDER_PLAN_EXISTING; + if (!context.mayCreate) reject('lease_stale'); + + transaction.create(prepared.providerPlanRef, prepared.providerPlanRecord); + transaction.create(prepared.providerPlanAuditRef, prepared.providerPlanAuditRecord); + return PROVIDER_PLAN_BOUND; + }, TRANSACTION_OPTIONS); + + if (result !== PROVIDER_PLAN_BOUND && result !== PROVIDER_PLAN_EXISTING) { + reject('journal_unavailable'); + } + return result; + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } +} + const REGISTERED_NEW = Object.freeze({ journalSchemaVersion, outcome: 'registered_new', @@ -1102,6 +1674,7 @@ module.exports = Object.freeze({ lifecycleSchemaVersion, CommerceCommandJournalError, acquireCommerceCommandLease, + bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, registerCommerceCommand, diff --git a/functions/commerceCommandJournal.test.js b/functions/commerceCommandJournal.test.js index 24ab1df9..5e179684 100644 --- a/functions/commerceCommandJournal.test.js +++ b/functions/commerceCommandJournal.test.js @@ -7,12 +7,14 @@ const { commandIdentityVersion, createCommandKey, createPayloadFingerprint, + createStripeIdempotencyKey, } = require('./commerceCommandIdentity'); const { journalSchemaVersion, lifecycleSchemaVersion, CommerceCommandJournalError, acquireCommerceCommandLease, + bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, registerCommerceCommand, @@ -22,10 +24,19 @@ const COMMAND_ID = '018f1f6a-9d2b-4c3d-8e5f-0123456789ab'; const OTHER_COMMAND_ID = '123e4567-e89b-42d3-a456-426614174000'; const LEASE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const OTHER_LEASE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const THIRD_LEASE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const TERMINAL_REFERENCE_FINGERPRINT = 'd'.repeat(64); const OTHER_TERMINAL_REFERENCE_FINGERPRINT = 'e'.repeat(64); +const STRIPE_ACCOUNT_ID = 'acct_1SyntheticTest000000000001'; +const OTHER_STRIPE_ACCOUNT_ID = 'acct_1SyntheticTest000000000002'; +const STRIPE_API_VERSION = '2025-06-30.basil'; +const STRIPE_ENDPOINT_PATH = '/v1/checkout/sessions'; +const PROVIDER_OPERATION = 'checkout_session_create'; const GOLDEN_LEASE_OWNER_FINGERPRINT = '5d99ad039ee873195d1a765728e5946e87a959bf6765ccc9ea775541b10bccf1'; const GOLDEN_TERMINAL_COMMITMENT = 'abf0fc9f071bb52ec4a0f5500e61f71b14066bf12bf27befca69ef90f7d118ab'; +const GOLDEN_STRIPE_ACCOUNT_FINGERPRINT = '4894d660135b684db293c373e6e31cf0ac3e9754cd15329b339325c539cac3a7'; +const GOLDEN_PARAMETERS_FINGERPRINT = '59e1672330d27a526ac0c684262b556187a983e4d09c4a6a089497e44babb6e0'; +const GOLDEN_IDEMPOTENCY_KEY_FINGERPRINT = '104e34d2323cce2f25e0d5f391f9ebc3b581fccc4492b5ffa5c811594a380973'; const RAW_CALLER = 'private-runner@example.test'; const HOSTILE_CANARY = 'private-runner@example.test/token?secret=do-not-copy'; const NOW_MILLIS = 1800000000123; @@ -220,6 +231,28 @@ function failureInput(db, overrides = {}) { }; } +function validProviderParameters(entries = null) { + return frozenRecord(entries || [ + ['amount_total', 2500], + ['currency', 'usd'], + ['synthetic_reference', HOSTILE_CANARY], + ]); +} + +function providerPlanInput(db, overrides = {}) { + return { + ...leaseInput(db), + expectedFenceEpoch: 1, + stripeAccountId: STRIPE_ACCOUNT_ID, + stripeMode: 'test', + stripeApiVersion: STRIPE_API_VERSION, + endpointPath: STRIPE_ENDPOINT_PATH, + providerOperation: PROVIDER_OPERATION, + providerParameters: validProviderParameters(), + ...overrides, + }; +} + function lifecyclePaths(input, revision = 2) { const identity = expectedIdentity(input); return Object.freeze({ @@ -232,6 +265,18 @@ function lifecyclePaths(input, revision = 2) { }); } +function providerPlanPaths(input) { + const identity = expectedIdentity(input); + return Object.freeze({ + ...lifecyclePaths(input), + providerPlanPath: `${identity.commandPath}/providerAttempts/0000000001`, + providerPlanAuditPath: documentPath( + 'auditEvents', + `commerce_provider_attempt_${identity.commandKeyHash}_0000000001`, + ), + }); +} + function captureError(callback) { return Promise.resolve() .then(callback) @@ -1162,6 +1207,1020 @@ describe('lifecycle fail-closed record and input boundary', () => { }); }); +describe('immutable initial Stripe provider plan', () => { + let nowMillis; + let timestampNow; + + beforeEach(() => { + nowMillis = NOW_MILLIS; + timestampNow = jest.spyOn(Timestamp, 'now') + .mockImplementation(() => Timestamp.fromMillis(nowMillis)); + }); + + afterEach(() => jest.restoreAllMocks()); + + async function createActiveLease(mock, overrides = {}) { + const registration = validInput(mock.db, overrides); + await registerCommerceCommand(registration); + nowMillis += 1000; + await acquireCommerceCommandLease(leaseInput(mock.db, overrides)); + } + + async function bindPlan(mock, overrides = {}) { + nowMillis += 1000; + return bindInitialStripeProviderPlan(providerPlanInput(mock.db, overrides)); + } + + test('creates the exact plan/audit pair after every read and preserves B2A/B2B bytes', async () => { + const mock = createMockDb(); + await createActiveLease(mock); + const input = providerPlanInput(mock.db); + const paths = providerPlanPaths(input); + const preserved = new Map([ + [paths.commandPath, mock.store.get(paths.commandPath)], + [paths.auditPath, mock.store.get(paths.auditPath)], + [paths.lifecyclePath, mock.store.get(paths.lifecyclePath)], + [paths.lifecycleAuditPath, mock.store.get(paths.lifecycleAuditPath)], + ]); + const callsBefore = timestampNow.mock.calls.length; + + const result = await bindPlan(mock); + + expect(result).toEqual({ + journalSchemaVersion: 1, + providerPlanSchemaVersion: 1, + outcome: 'provider_plan_bound', + state: 'planned', + }); + expect(Object.isFrozen(result)).toBe(true); + expect(timestampNow).toHaveBeenCalledTimes(callsBefore + 1); + const transactionRun = mock.callbackRuns.at(-1); + expect(transactionRun.transaction.get).toHaveBeenCalledTimes(6); + expect(transactionRun.transaction.create).toHaveBeenCalledTimes(2); + const latestReadOrder = Math.max(...transactionRun.transaction.get.mock.invocationCallOrder); + const earliestCreateOrder = Math.min( + ...transactionRun.transaction.create.mock.invocationCallOrder, + ); + expect(latestReadOrder).toBeLessThan(earliestCreateOrder); + expect(transactionRun.writes.map((write) => write.ref.path)).toEqual([ + paths.providerPlanPath, + paths.providerPlanAuditPath, + ]); + + const plan = mock.store.get(paths.providerPlanPath); + const audit = mock.store.get(paths.providerPlanAuditPath); + expect(plan).toEqual({ + providerPlanSchemaVersion: 1, + commandIdentityVersion: 1, + commandKeyHash: paths.commandKeyHash, + environment: 'test', + provider: 'stripe', + providerAttempt: 1, + providerOperation: PROVIDER_OPERATION, + stripeMode: 'test', + stripeAccountFingerprint: GOLDEN_STRIPE_ACCOUNT_FINGERPRINT, + stripeApiVersion: STRIPE_API_VERSION, + httpMethod: 'POST', + endpointPath: STRIPE_ENDPOINT_PATH, + parametersFingerprint: GOLDEN_PARAMETERS_FINGERPRINT, + idempotencyKeyFingerprint: GOLDEN_IDEMPOTENCY_KEY_FINGERPRINT, + boundFenceEpoch: 1, + boundAt: expect.any(Timestamp), + }); + expect(audit).toEqual({ + providerPlanAuditSchemaVersion: 1, + aggregateType: 'commerce_provider_attempt', + commandKeyHash: paths.commandKeyHash, + providerAttempt: 1, + eventType: 'provider_plan_bound', + provider: 'stripe', + environment: 'test', + stripeMode: 'test', + providerOperation: PROVIDER_OPERATION, + boundFenceEpoch: 1, + occurredAt: expect.any(Timestamp), + }); + expect(plan.boundAt).toBe(audit.occurredAt); + expect(plan.boundAt.toMillis()).toBe(NOW_MILLIS + 2000); + expect(Object.isFrozen(plan)).toBe(true); + expect(Object.isFrozen(audit)).toBe(true); + expect(Object.isFrozen(plan.boundAt)).toBe(true); + for (const [path_, value] of preserved) expect(mock.store.get(path_)).toBe(value); + + const rawStripeKey = createStripeIdempotencyKey({ + stripeMode: 'test', + environment: 'test', + providerOperation: PROVIDER_OPERATION, + commandKeyHash: paths.commandKeyHash, + providerAttempt: 1, + }).stripeIdempotencyKey; + const rendered = JSON.stringify({ result, plan, audit }); + for (const raw of [ + STRIPE_ACCOUNT_ID, + HOSTILE_CANARY, + rawStripeKey, + LEASE_ID, + RAW_CALLER, + COMMAND_ID, + ]) { + expect(rendered).not.toContain(raw); + } + expect(audit).not.toHaveProperty('stripeAccountFingerprint'); + expect(audit).not.toHaveProperty('parametersFingerprint'); + expect(audit).not.toHaveProperty('idempotencyKeyFingerprint'); + expect(result).not.toHaveProperty('shouldSend'); + expect(result).not.toHaveProperty('shouldExecute'); + }); + + test('exact lost-response retry and canonical parameter equivalence are read-only', async () => { + const mock = createMockDb(); + await createActiveLease(mock); + await bindPlan(mock); + const paths = providerPlanPaths(providerPlanInput(mock.db)); + const planBefore = mock.store.get(paths.providerPlanPath); + const auditBefore = mock.store.get(paths.providerPlanAuditPath); + const storeSize = mock.store.size; + nowMillis += 1; + const equivalentParameters = validProviderParameters([ + ['synthetic_reference', HOSTILE_CANARY], + ['currency', 'usd'], + ['amount_total', 2500], + ]); + + const exact = await bindInitialStripeProviderPlan(providerPlanInput(mock.db)); + const equivalent = await bindInitialStripeProviderPlan(providerPlanInput(mock.db, { + providerParameters: equivalentParameters, + })); + + for (const result of [exact, equivalent]) { + expect(result).toEqual({ + journalSchemaVersion: 1, + providerPlanSchemaVersion: 1, + outcome: 'provider_plan_existing', + state: 'planned', + }); + expect(Object.isFrozen(result)).toBe(true); + } + expect(mock.store.size).toBe(storeSize); + expect(mock.store.get(paths.providerPlanPath)).toBe(planBefore); + expect(mock.store.get(paths.providerPlanAuditPath)).toBe(auditBefore); + for (const { transaction, writes } of mock.callbackRuns.slice(-2)) { + expect(transaction.create).not.toHaveBeenCalled(); + expect(transaction.set).not.toHaveBeenCalled(); + expect(writes).toEqual([]); + } + }); + + test.each([ + ['account', { stripeAccountId: OTHER_STRIPE_ACCOUNT_ID }], + ['API version', { stripeApiVersion: '2024-06-20' }], + ['parameters', { + providerParameters: validProviderParameters([ + ['amount_total', 2501], + ['currency', 'usd'], + ['synthetic_reference', HOSTILE_CANARY], + ]), + }], + ])('rejects a valid but conflicting immutable %s without overwrite', async (_label, change) => { + const mock = createMockDb(); + await createActiveLease(mock); + await bindPlan(mock); + const paths = providerPlanPaths(providerPlanInput(mock.db)); + const before = cloneStore(mock.store); + nowMillis += 1; + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db, change)), + 'command_conflict', + [STRIPE_ACCOUNT_ID, OTHER_STRIPE_ACCOUNT_ID], + ); + + expect(mock.store).toEqual(before); + expect(mock.store.get(paths.providerPlanPath)).toBe(before.get(paths.providerPlanPath)); + }); + + test('transaction retries reuse one timestamp and deterministic prepared records', async () => { + const mock = createMockDb([], { callbackRuns: 4 }); + await createActiveLease(mock); + timestampNow.mockClear(); + + const result = await bindPlan(mock); + + expect(result.outcome).toBe('provider_plan_bound'); + expect(timestampNow).toHaveBeenCalledTimes(1); + expect(mock.db.runTransaction).toHaveBeenLastCalledWith( + expect.any(Function), + { maxAttempts: 10 }, + ); + const callbackRetries = mock.callbackRuns.slice(-4); + expect(callbackRetries).toHaveLength(4); + const plans = callbackRetries.map(({ writes }) => writes[0].value); + const audits = callbackRetries.map(({ writes }) => writes[1].value); + expect(new Set(plans.map((plan) => plan.boundAt)).size).toBe(1); + expect(new Set(plans.map((plan) => plan.stripeAccountFingerprint)).size).toBe(1); + expect(new Set(plans.map((plan) => plan.parametersFingerprint)).size).toBe(1); + expect(new Set(plans.map((plan) => plan.idempotencyKeyFingerprint)).size).toBe(1); + expect(new Set(audits.map((audit) => audit.occurredAt)).size).toBe(1); + }); + + test('all three domains are separate and the same raw context is command-bound', async () => { + const mock = createMockDb(); + await createActiveLease(mock); + await bindPlan(mock); + const firstPaths = providerPlanPaths(providerPlanInput(mock.db)); + const first = mock.store.get(firstPaths.providerPlanPath); + + nowMillis += 1; + await createActiveLease(mock, { commandId: OTHER_COMMAND_ID }); + await bindPlan(mock, { commandId: OTHER_COMMAND_ID }); + const secondPaths = providerPlanPaths(providerPlanInput(mock.db, { + commandId: OTHER_COMMAND_ID, + })); + const second = mock.store.get(secondPaths.providerPlanPath); + + expect(new Set([ + first.stripeAccountFingerprint, + first.parametersFingerprint, + first.idempotencyKeyFingerprint, + ])).toHaveProperty('size', 3); + expect(second.stripeAccountFingerprint).not.toBe(first.stripeAccountFingerprint); + expect(second.parametersFingerprint).not.toBe(first.parametersFingerprint); + expect(second.idempotencyKeyFingerprint).not.toBe(first.idempotencyKeyFingerprint); + }); + + test('wrong holder and fence independently reject before observing an existing plan', async () => { + const mock = createMockDb(); + await createActiveLease(mock); + await bindPlan(mock); + const before = cloneStore(mock.store); + nowMillis += 1; + + for (const change of [ + { leaseId: OTHER_LEASE_ID }, + { expectedFenceEpoch: 2 }, + { leaseId: OTHER_LEASE_ID, expectedFenceEpoch: 2 }, + { leaseId: OTHER_LEASE_ID, stripeApiVersion: '2024-06-20' }, + { expectedFenceEpoch: 2, stripeAccountId: OTHER_STRIPE_ACCOUNT_ID }, + ]) { + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db, change)), + 'lease_stale', + ); + } + expect(mock.store).toEqual(before); + }); + + test('expiry equality rejects both first bind and observation of an existing plan', async () => { + let mock = createMockDb(); + await createActiveLease(mock); + const paths = providerPlanPaths(providerPlanInput(mock.db)); + const before = cloneStore(mock.store); + nowMillis = NOW_MILLIS + 61000; + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'lease_stale', + ); + expect(mock.store).toEqual(before); + expect(mock.store.has(paths.providerPlanPath)).toBe(false); + + mock = createMockDb(); + nowMillis = NOW_MILLIS; + await createActiveLease(mock); + await bindPlan(mock); + const withPlan = cloneStore(mock.store); + nowMillis = NOW_MILLIS + 61000; + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'lease_stale', + ); + expect(mock.store).toEqual(withPlan); + }); + + test('a takeover holder observes the original plan without rewriting its fence or time', async () => { + const mock = createMockDb(); + await createActiveLease(mock); + await bindPlan(mock); + const paths = providerPlanPaths(providerPlanInput(mock.db)); + const planBefore = mock.store.get(paths.providerPlanPath); + const auditBefore = mock.store.get(paths.providerPlanAuditPath); + nowMillis = NOW_MILLIS + 61000; + const takeover = await acquireCommerceCommandLease(leaseInput(mock.db, { + leaseId: OTHER_LEASE_ID, + })); + expect(takeover).toMatchObject({ outcome: 'lease_acquired', fenceEpoch: 2 }); + nowMillis += 1; + + const result = await bindInitialStripeProviderPlan(providerPlanInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + })); + + expect(result.outcome).toBe('provider_plan_existing'); + expect(mock.store.get(paths.providerPlanPath)).toBe(planBefore); + expect(mock.store.get(paths.providerPlanAuditPath)).toBe(auditBefore); + expect(planBefore.boundFenceEpoch).toBe(1); + expect(planBefore.boundAt.toMillis()).toBe(NOW_MILLIS + 2000); + }); + + test('first binding at fence 2 validates its predecessor before both creates', async () => { + const mock = createMockDb(); + await createActiveLease(mock); + nowMillis = NOW_MILLIS + 61000; + await expect(acquireCommerceCommandLease(leaseInput(mock.db, { + leaseId: OTHER_LEASE_ID, + }))).resolves.toMatchObject({ outcome: 'lease_acquired', fenceEpoch: 2 }); + const paths = providerPlanPaths(providerPlanInput(mock.db)); + const preserved = cloneStore(mock.store); + nowMillis += 1; + + await expect(bindInitialStripeProviderPlan(providerPlanInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + }))).resolves.toMatchObject({ outcome: 'provider_plan_bound' }); + + const transactionRun = mock.callbackRuns.at(-1); + expect(transactionRun.transaction.get).toHaveBeenCalledTimes(7); + const latestReadOrder = Math.max(...transactionRun.transaction.get.mock.invocationCallOrder); + const earliestCreateOrder = Math.min( + ...transactionRun.transaction.create.mock.invocationCallOrder, + ); + expect(latestReadOrder).toBeLessThan(earliestCreateOrder); + expect(mock.store.get(paths.providerPlanPath)).toMatchObject({ + providerOperation: PROVIDER_OPERATION, + endpointPath: STRIPE_ENDPOINT_PATH, + boundFenceEpoch: 2, + }); + expect(mock.store.get(paths.providerPlanPath).boundAt.toMillis()) + .toBe(NOW_MILLIS + 61001); + for (const [path_, value] of preserved) expect(mock.store.get(path_)).toBe(value); + }); + + test('an earlier captured time may observe an exact plan but can never create one', async () => { + let mock = createMockDb(); + await createActiveLease(mock); + const paths = providerPlanPaths(providerPlanInput(mock.db)); + const before = cloneStore(mock.store); + nowMillis = NOW_MILLIS; + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'lease_stale', + ); + expect(mock.store).toEqual(before); + expect(mock.store.has(paths.providerPlanPath)).toBe(false); + + mock = createMockDb(); + nowMillis = NOW_MILLIS; + await createActiveLease(mock); + await bindPlan(mock); + const planBefore = mock.store.get(paths.providerPlanPath); + nowMillis = NOW_MILLIS; + + await expect(bindInitialStripeProviderPlan(providerPlanInput(mock.db))) + .resolves.toMatchObject({ outcome: 'provider_plan_existing' }); + expect(mock.store.get(paths.providerPlanPath)).toBe(planBefore); + }); + + test.each(['succeeded', 'failed_final'])( + 'terminal lifecycle %s cannot bind or observe a provider plan', + async (state) => { + const mock = createMockDb(); + await createActiveLease(mock); + await bindPlan(mock); + nowMillis += 1; + if (state === 'succeeded') { + await completeCommerceCommand(completionInput(mock.db)); + } else { + await failCommerceCommand(failureInput(mock.db)); + } + const before = cloneStore(mock.store); + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'lease_stale', + ); + expect(mock.store).toEqual(before); + }, + ); +}); + +describe('provider plan fail-closed record and input boundary', () => { + let nowMillis; + + beforeEach(() => { + nowMillis = NOW_MILLIS; + jest.spyOn(Timestamp, 'now') + .mockImplementation(() => Timestamp.fromMillis(nowMillis)); + }); + + afterEach(() => jest.restoreAllMocks()); + + async function createActiveLease(mock, overrides = {}) { + await registerCommerceCommand(validInput(mock.db, overrides)); + nowMillis += 1000; + await acquireCommerceCommandLease(leaseInput(mock.db, overrides)); + } + + async function createBoundPlan() { + const mock = createMockDb(); + await createActiveLease(mock); + nowMillis += 1000; + await bindInitialStripeProviderPlan(providerPlanInput(mock.db)); + return mock; + } + + async function createTakeoverWithoutPlan() { + const mock = createMockDb(); + await createActiveLease(mock); + nowMillis = NOW_MILLIS + 61000; + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: OTHER_LEASE_ID })); + return mock; + } + + test.each([ + ['short account', { stripeAccountId: 'acct_123456789012345' }], + ['long account', { stripeAccountId: `acct_${'a'.repeat(65)}` }], + ['account punctuation', { stripeAccountId: 'acct_123456789012345!' }], + ['account prefix', { stripeAccountId: 'ca_1234567890123456' }], + ['test/live mismatch', { stripeMode: 'live' }], + ['unknown mode', { stripeMode: 'sandbox' }], + ['pre-Stripe API year', { stripeApiVersion: '2010-12-31' }], + ['invalid leap day', { stripeApiVersion: '2025-02-29' }], + ['invalid month', { stripeApiVersion: '2025-13-01' }], + ['uppercase API train', { stripeApiVersion: '2025-06-30.Basil' }], + ['unbounded API train', { stripeApiVersion: `2025-06-30.${'a'.repeat(33)}` }], + ['endpoint scheme', { endpointPath: 'https://api.stripe.com/v1/refunds' }], + ['endpoint host', { endpointPath: '//api.stripe.com/v1/refunds' }], + ['endpoint query', { endpointPath: '/v1/refunds?expand=data' }], + ['endpoint fragment', { endpointPath: '/v1/refunds#private' }], + ['endpoint encoded slash', { endpointPath: '/v1/checkout%2fsessions' }], + ['endpoint encoded dot', { endpointPath: '/v1/%2e%2e/refunds' }], + ['endpoint traversal', { endpointPath: '/v1/../refunds' }], + ['endpoint current directory', { endpointPath: '/v1/./refunds' }], + ['endpoint double slash', { endpointPath: '/v1//refunds' }], + ['endpoint trailing slash', { endpointPath: '/v1/refunds/' }], + ['endpoint uppercase', { endpointPath: '/v1/Checkout/Sessions' }], + ['endpoint backslash', { endpointPath: '/v1/checkout\\sessions' }], + ['object-ID endpoint', { + endpointPath: '/v1/payment_intents/pi_synthetic000000000001/confirm', + }], + ['capability-shaped endpoint', { + endpointPath: '/v1/checkout/sessions/cs_test_capability000000000001', + }], + ['secret-shaped endpoint', { + endpointPath: '/v1/checkout/sessions/client_secret_synthetic000000000001', + }], + ['test-helper endpoint', { endpointPath: '/v1/test_helpers/refunds' }], + ['arbitrary refund endpoint', { endpointPath: '/v1/refunds' }], + ['operation and endpoint mismatch', { endpointPath: '/v1/payment_intents' }], + ['unmapped operation', { providerOperation: 'refund_create' }], + ['unmapped operation with fixed endpoint', { + providerOperation: 'payment_intent_create', + endpointPath: STRIPE_ENDPOINT_PATH, + }], + ['operation uppercase', { providerOperation: 'CheckoutSessionCreate' }], + ['operation slash', { providerOperation: 'checkout/session/create' }], + ['zero fence', { expectedFenceEpoch: 0 }], + ['future fence', { expectedFenceEpoch: 10000000000 }], + ['invalid lease', { leaseId: HOSTILE_CANARY }], + ['caller-selected attempt', { providerAttempt: 2 }], + ['caller-selected provider', { provider: 'stripe' }], + ['caller-selected method', { httpMethod: 'POST' }], + ])('rejects invalid provider-plan input before Firestore: %s', async (_label, change) => { + const mock = createMockDb(); + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db, change)), + 'invalid_command_input', + [STRIPE_ACCOUNT_ID], + ); + expect(mock.db.runTransaction).not.toHaveBeenCalled(); + }); + + test.each([ + ['minimum account', { stripeAccountId: `acct_${'a'.repeat(16)}` }], + ['maximum account', { stripeAccountId: `acct_${'A'.repeat(64)}` }], + ['first supported API date', { stripeApiVersion: '2011-01-01' }], + ['valid leap date', { stripeApiVersion: '2024-02-29' }], + ['bounded train', { stripeApiVersion: '2099-12-31.z9' }], + ])('accepts conservative boundary input: %s', async (_label, change) => { + const mock = createMockDb(); + await createActiveLease(mock); + nowMillis += 1; + await expect(bindInitialStripeProviderPlan(providerPlanInput(mock.db, change))) + .resolves.toMatchObject({ outcome: 'provider_plan_bound' }); + }); + + test('accepts live mode only for a production-scoped command', async () => { + const mock = createMockDb(); + await createActiveLease(mock, { environment: 'production' }); + nowMillis += 1; + await expect(bindInitialStripeProviderPlan(providerPlanInput(mock.db, { + environment: 'production', + stripeMode: 'live', + }))).resolves.toMatchObject({ outcome: 'provider_plan_bound' }); + + const invalid = createMockDb(); + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(invalid.db, { + environment: 'production', + stripeMode: 'test', + })), + 'invalid_command_input', + ); + expect(invalid.db.runTransaction).not.toHaveBeenCalled(); + }); + + test('rejects mutable, exotic, cyclic, accessor, proxy, and over-budget parameters', async () => { + const mutableNested = frozenRecord([['nested', { secret: HOSTILE_CANARY }]]); + const plainFrozen = Object.freeze({ secret: HOSTILE_CANARY }); + const symbol = Object.create(null); + symbol.safe = 1; + symbol[Symbol('hidden')] = HOSTILE_CANARY; + Object.freeze(symbol); + let getterCalls = 0; + const accessor = Object.create(null); + Object.defineProperty(accessor, 'secret', { + enumerable: true, + get() { + getterCalls += 1; + return HOSTILE_CANARY; + }, + }); + Object.freeze(accessor); + let trapCalls = 0; + const proxy = new Proxy(validProviderParameters(), { + ownKeys() { + trapCalls += 1; + return []; + }, + }); + const cyclic = Object.create(null); + cyclic.self = cyclic; + Object.freeze(cyclic); + let tooDeep = 'leaf'; + for (let index = 0; index < 8; index += 1) { + tooDeep = frozenRecord([['nested', tooDeep]]); + } + const tooMany = frozenRecord(Array.from( + { length: 101 }, + (_value, index) => [`field_${index}`, index], + )); + const tooLarge = frozenRecord([['value', 'x'.repeat(8193)]]); + + for (const providerParameters of [ + { secret: HOSTILE_CANARY }, + mutableNested, + plainFrozen, + symbol, + accessor, + proxy, + cyclic, + tooDeep, + tooMany, + tooLarge, + ]) { + const mock = createMockDb(); + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db, { + providerParameters, + })), + 'invalid_command_input', + ); + expect(mock.db.runTransaction).not.toHaveBeenCalled(); + } + expect(getterCalls).toBe(0); + expect(trapCalls).toBe(0); + }); + + test('rejects extra, missing, inherited, accessor, and proxied input roots', async () => { + const extraMock = createMockDb(); + const extra = { ...providerPlanInput(extraMock.db), shouldSend: true }; + const missingMock = createMockDb(); + const missing = providerPlanInput(missingMock.db); + delete missing.endpointPath; + const inheritedMock = createMockDb(); + const inherited = Object.assign( + Object.create({ inherited: HOSTILE_CANARY }), + providerPlanInput(inheritedMock.db), + ); + const accessorMock = createMockDb(); + const accessor = providerPlanInput(accessorMock.db); + let getterCalls = 0; + Object.defineProperty(accessor, 'stripeAccountId', { + enumerable: true, + get() { + getterCalls += 1; + return HOSTILE_CANARY; + }, + }); + const proxyMock = createMockDb(); + let trapCalls = 0; + const proxy = new Proxy(providerPlanInput(proxyMock.db), { + ownKeys() { + trapCalls += 1; + return []; + }, + }); + + for (const [value, mock] of [ + [extra, extraMock], + [missing, missingMock], + [inherited, inheritedMock], + [accessor, accessorMock], + [proxy, proxyMock], + ]) { + await expectSafeError( + () => bindInitialStripeProviderPlan(value), + 'invalid_command_input', + ); + expect(mock.db.runTransaction).not.toHaveBeenCalled(); + } + expect(getterCalls).toBe(0); + expect(trapCalls).toBe(0); + }); + + test('requires registration and lifecycle partners without repairing orphans', async () => { + let mock = createMockDb(); + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'command_not_registered', + ); + expect(mock.store.size).toBe(0); + + const bound = await createBoundPlan(); + const paths = providerPlanPaths(providerPlanInput(bound.db)); + for (const deletion of [ + paths.providerPlanPath, + paths.providerPlanAuditPath, + paths.lifecyclePath, + paths.lifecycleAuditPath, + paths.commandPath, + paths.auditPath, + ]) { + const seed = cloneStore(bound.store); + seed.delete(deletion); + mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'journal_record_invalid', + [paths.commandKeyHash], + ); + expect(mock.store).toEqual(before); + expect(mock.callbackRuns.at(-1).transaction.create).not.toHaveBeenCalled(); + } + }); + + test.each([ + ['schema', (plan) => ({ ...plan, providerPlanSchemaVersion: 2 })], + ['identity version', (plan) => ({ ...plan, commandIdentityVersion: 2 })], + ['command hash', (plan) => ({ ...plan, commandKeyHash: 'a'.repeat(64) })], + ['provider', (plan) => ({ ...plan, provider: 'other' })], + ['attempt', (plan) => ({ ...plan, providerAttempt: 2 })], + ['operation', (plan) => ({ ...plan, providerOperation: 'BadOperation' })], + ['unmapped lowercase operation', (plan) => ({ + ...plan, + providerOperation: 'refund_create', + })], + ['mode pairing', (plan) => ({ ...plan, stripeMode: 'live' })], + ['account commitment', (plan) => ({ + ...plan, + stripeAccountFingerprint: plan.stripeAccountFingerprint.toUpperCase(), + })], + ['API version', (plan) => ({ ...plan, stripeApiVersion: '2025-02-29' })], + ['HTTP method', (plan) => ({ ...plan, httpMethod: 'GET' })], + ['endpoint', (plan) => ({ ...plan, endpointPath: '/v1/Checkout/Sessions' })], + ['arbitrary lowercase endpoint', (plan) => ({ ...plan, endpointPath: '/v1/refunds' })], + ['object-ID endpoint', (plan) => ({ + ...plan, + endpointPath: '/v1/payment_intents/pi_synthetic000000000001/confirm', + })], + ['parameter commitment', (plan) => ({ ...plan, parametersFingerprint: 'bad' })], + ['key commitment', (plan) => ({ ...plan, idempotencyKeyFingerprint: 'a'.repeat(64) })], + ['zero fence', (plan) => ({ ...plan, boundFenceEpoch: 0 })], + ['non-Timestamp', (plan) => ({ ...plan, boundAt: { _seconds: 1, _nanoseconds: 0 } })], + ['extra field', (plan) => ({ ...plan, unexpected: HOSTILE_CANARY })], + ])('rejects malformed stored provider plan: %s', async (_label, mutate) => { + const bound = await createBoundPlan(); + const paths = providerPlanPaths(providerPlanInput(bound.db)); + const mock = createMockDb([...bound.store]); + mock.store.set( + paths.providerPlanPath, + Object.freeze(mutate(mock.store.get(paths.providerPlanPath))), + ); + const before = cloneStore(mock.store); + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'journal_record_invalid', + [paths.commandKeyHash, STRIPE_ACCOUNT_ID], + ); + expect(mock.store).toEqual(before); + expect(mock.callbackRuns.at(-1).transaction.create).not.toHaveBeenCalled(); + }); + + test.each([ + ['schema', (audit) => ({ ...audit, providerPlanAuditSchemaVersion: 2 })], + ['aggregate', (audit) => ({ ...audit, aggregateType: 'commerce_command' })], + ['command hash', (audit) => ({ ...audit, commandKeyHash: 'a'.repeat(64) })], + ['attempt', (audit) => ({ ...audit, providerAttempt: 2 })], + ['event', (audit) => ({ ...audit, eventType: 'provider_sent' })], + ['provider', (audit) => ({ ...audit, provider: 'other' })], + ['environment', (audit) => ({ ...audit, environment: 'staging' })], + ['mode', (audit) => ({ ...audit, stripeMode: 'live' })], + ['operation', (audit) => ({ ...audit, providerOperation: 'refund_create' })], + ['fence', (audit) => ({ ...audit, boundFenceEpoch: 2 })], + ['time', (audit) => ({ ...audit, occurredAt: Timestamp.fromMillis(NOW_MILLIS) })], + ['extra field', (audit) => ({ ...audit, unexpected: HOSTILE_CANARY })], + ])('rejects malformed stored provider-plan audit: %s', async (_label, mutate) => { + const bound = await createBoundPlan(); + const paths = providerPlanPaths(providerPlanInput(bound.db)); + const mock = createMockDb([...bound.store]); + mock.store.set( + paths.providerPlanAuditPath, + Object.freeze(mutate(mock.store.get(paths.providerPlanAuditPath))), + ); + const before = cloneStore(mock.store); + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'journal_record_invalid', + [paths.commandKeyHash], + ); + expect(mock.store).toEqual(before); + }); + + test('rejects stored provider-plan accessors and proxies without invoking traps', async () => { + const bound = await createBoundPlan(); + const paths = providerPlanPaths(providerPlanInput(bound.db)); + let getterCalls = 0; + const accessor = { ...bound.store.get(paths.providerPlanPath) }; + Object.defineProperty(accessor, 'endpointPath', { + enumerable: true, + get() { + getterCalls += 1; + return HOSTILE_CANARY; + }, + }); + let mock = createMockDb([...bound.store]); + mock.store.set(paths.providerPlanPath, accessor); + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'journal_record_invalid', + ); + expect(getterCalls).toBe(0); + + let trapCalls = 0; + const proxy = new Proxy(bound.store.get(paths.providerPlanAuditPath), { + ownKeys() { + trapCalls += 1; + return []; + }, + }); + mock = createMockDb([...bound.store]); + mock.store.set(paths.providerPlanAuditPath, proxy); + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'journal_record_invalid', + ); + expect(trapCalls).toBe(0); + }); + + test.each([ + ['before registration', NOW_MILLIS - 1], + ['before lease acquisition', NOW_MILLIS + 500], + ['at lease expiry', NOW_MILLIS + 61000], + ['after lease expiry', NOW_MILLIS + 61001], + ])('rejects impossible stored binding time %s', async (_label, boundAtMillis) => { + const bound = await createBoundPlan(); + const paths = providerPlanPaths(providerPlanInput(bound.db)); + const mock = createMockDb([...bound.store]); + const boundAt = Timestamp.fromMillis(boundAtMillis); + mock.store.set(paths.providerPlanPath, Object.freeze({ + ...mock.store.get(paths.providerPlanPath), + boundAt, + })); + mock.store.set(paths.providerPlanAuditPath, Object.freeze({ + ...mock.store.get(paths.providerPlanAuditPath), + occurredAt: Timestamp.fromMillis(boundAtMillis), + })); + const before = cloneStore(mock.store); + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db)), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + }); + + test('validates the original bound-fence audit after a takeover', async () => { + const bound = await createBoundPlan(); + const paths = providerPlanPaths(providerPlanInput(bound.db)); + nowMillis = NOW_MILLIS + 61000; + await acquireCommerceCommandLease(leaseInput(bound.db, { leaseId: OTHER_LEASE_ID })); + const validTakeoverStore = cloneStore(bound.store); + const originalAudit = validTakeoverStore.get(paths.lifecycleAuditPath); + + const corruptions = [ + ['missing', null], + ['fence', Object.freeze({ ...originalAudit, fenceEpoch: 2 })], + ['event', Object.freeze({ ...originalAudit, eventType: 'command_lease_taken_over' })], + ['shifted fence-1 acquisition', Object.freeze({ + ...originalAudit, + occurredAt: Timestamp.fromMillis(NOW_MILLIS + 500), + leaseExpiresAt: Timestamp.fromMillis(NOW_MILLIS + 60500), + })], + ['acquisition before registration', Object.freeze({ + ...originalAudit, + occurredAt: Timestamp.fromMillis(NOW_MILLIS - 1000), + leaseExpiresAt: Timestamp.fromMillis(NOW_MILLIS + 59000), + })], + ['binding before original acquisition', Object.freeze({ + ...originalAudit, + occurredAt: Timestamp.fromMillis(NOW_MILLIS + 3000), + leaseExpiresAt: Timestamp.fromMillis(NOW_MILLIS + 63000), + })], + ['bad expiry duration', Object.freeze({ + ...originalAudit, + leaseExpiresAt: Timestamp.fromMillis(NOW_MILLIS + 60000), + })], + ]; + + for (const [, corruption] of corruptions) { + const seed = cloneStore(validTakeoverStore); + if (corruption === null) seed.delete(paths.lifecycleAuditPath); + else seed.set(paths.lifecycleAuditPath, corruption); + const mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + nowMillis = NOW_MILLIS + 61001; + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + })), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + expect(mock.callbackRuns.at(-1).transaction.create).not.toHaveBeenCalled(); + } + }); + + test('first bind after takeover rejects a missing or impossible immediate predecessor', async () => { + const takeover = await createTakeoverWithoutPlan(); + const input = providerPlanInput(takeover.db); + const paths = providerPlanPaths(input); + const predecessorPath = lifecyclePaths(input, 2).lifecycleAuditPath; + const originalPredecessor = takeover.store.get(predecessorPath); + const corruptions = [ + ['missing', null], + ['wrong event', Object.freeze({ + ...originalPredecessor, + eventType: 'command_lease_taken_over', + })], + ['wrong fence', Object.freeze({ ...originalPredecessor, fenceEpoch: 2 })], + ['shifted fence-1 time', Object.freeze({ + ...originalPredecessor, + occurredAt: Timestamp.fromMillis(NOW_MILLIS + 500), + leaseExpiresAt: Timestamp.fromMillis(NOW_MILLIS + 60500), + })], + ['wrong lease duration', Object.freeze({ + ...originalPredecessor, + leaseExpiresAt: Timestamp.fromMillis(NOW_MILLIS + 60999), + })], + ]; + + for (const [, corruption] of corruptions) { + const seed = cloneStore(takeover.store); + if (corruption === null) seed.delete(predecessorPath); + else seed.set(predecessorPath, corruption); + const mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + nowMillis = NOW_MILLIS + 61001; + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + })), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + expect(mock.store.has(paths.providerPlanPath)).toBe(false); + expect(mock.store.has(paths.providerPlanAuditPath)).toBe(false); + expect(mock.callbackRuns.at(-1).transaction.create).not.toHaveBeenCalled(); + } + }); + + test('a self-consistent fence-3 record rejects an overlapping fence-2 predecessor', async () => { + const takeover = await createTakeoverWithoutPlan(); + nowMillis = NOW_MILLIS + 121000; + await acquireCommerceCommandLease(leaseInput(takeover.db, { leaseId: THIRD_LEASE_ID })); + const input = providerPlanInput(takeover.db); + const predecessorPath = lifecyclePaths(input, 3).lifecycleAuditPath; + const predecessor = takeover.store.get(predecessorPath); + takeover.store.set(predecessorPath, Object.freeze({ + ...predecessor, + occurredAt: Timestamp.fromMillis(NOW_MILLIS + 62000), + leaseExpiresAt: Timestamp.fromMillis(NOW_MILLIS + 122000), + })); + const before = cloneStore(takeover.store); + nowMillis += 1; + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(takeover.db, { + leaseId: THIRD_LEASE_ID, + expectedFenceEpoch: 3, + })), + 'journal_record_invalid', + ); + expect(takeover.store).toEqual(before); + expect(takeover.store.has(providerPlanPaths(input).providerPlanPath)).toBe(false); + expect(takeover.callbackRuns.at(-1).transaction.create).not.toHaveBeenCalled(); + }); + + test('a fence-2 plan observed at fence 3 still requires its predecessor audit', async () => { + const bound = await createTakeoverWithoutPlan(); + nowMillis += 1; + await bindInitialStripeProviderPlan(providerPlanInput(bound.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + })); + nowMillis = NOW_MILLIS + 121000; + await acquireCommerceCommandLease(leaseInput(bound.db, { leaseId: THIRD_LEASE_ID })); + const input = providerPlanInput(bound.db); + const predecessorPath = lifecyclePaths(input, 2).lifecycleAuditPath; + const seed = cloneStore(bound.store); + seed.delete(predecessorPath); + const mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + nowMillis += 1; + + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(mock.db, { + leaseId: THIRD_LEASE_ID, + expectedFenceEpoch: 3, + })), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + expect(mock.callbackRuns.at(-1).transaction.create).not.toHaveBeenCalled(); + }); + + test('commit failure and forged transaction output are fixed and redacted', async () => { + const active = createMockDb(); + await createActiveLease(active); + const commitFailure = createMockDb([...active.store], { + rejectCommit: new Error(`${HOSTILE_CANARY}/${STRIPE_ACCOUNT_ID}`), + }); + const before = cloneStore(commitFailure.store); + nowMillis += 1; + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(commitFailure.db)), + 'journal_unavailable', + [STRIPE_ACCOUNT_ID], + ); + expect(commitFailure.store).toEqual(before); + + const forged = createMockDb([...active.store], { + returnedValue: Object.freeze({ + outcome: HOSTILE_CANARY, + stripeAccountId: STRIPE_ACCOUNT_ID, + }), + }); + await expectSafeError( + () => bindInitialStripeProviderPlan(providerPlanInput(forged.db)), + 'journal_unavailable', + [STRIPE_ACCOUNT_ID], + ); + }); + + test('registration and lease recovery remain byte-preserving around plan binding', async () => { + const mock = await createBoundPlan(); + const input = providerPlanInput(mock.db); + const paths = providerPlanPaths(input); + const before = cloneStore(mock.store); + nowMillis += 1; + + await expect(registerCommerceCommand(validInput(mock.db))) + .resolves.toMatchObject({ outcome: 'registered_existing' }); + await expect(acquireCommerceCommandLease(leaseInput(mock.db))) + .resolves.toMatchObject({ outcome: 'lease_acquired', fenceEpoch: 1 }); + + expect(mock.store).toEqual(before); + for (const path_ of [ + paths.commandPath, + paths.auditPath, + paths.lifecyclePath, + paths.lifecycleAuditPath, + paths.providerPlanPath, + paths.providerPlanAuditPath, + ]) { + expect(mock.store.get(path_)).toBe(before.get(path_)); + } + }); +}); + describe('exact existing identity and partner validation', () => { beforeEach(() => { jest.spyOn(Timestamp, 'now').mockImplementation(() => Timestamp.fromMillis(NOW_MILLIS + 5000)); @@ -1413,13 +2472,14 @@ describe('fixed input and infrastructure failure boundary', () => { }); describe('closed source and export boundary', () => { - test('exports only the unused registration/lifecycle API and no provider-send permission', () => { + test('exports only the unused journal APIs and no provider-send permission', () => { const api = require('./commerceCommandJournal'); expect(journalSchemaVersion).toBe(1); expect(lifecycleSchemaVersion).toBe(1); expect(Object.keys(api).sort()).toEqual([ 'CommerceCommandJournalError', 'acquireCommerceCommandLease', + 'bindInitialStripeProviderPlan', 'completeCommerceCommand', 'failCommerceCommand', 'journalSchemaVersion', @@ -1430,7 +2490,7 @@ describe('closed source and export boundary', () => { expect(Object.isFrozen(CommerceCommandJournalError)).toBe(true); expect(Object.isFrozen(CommerceCommandJournalError.prototype)).toBe(true); expect(Object.keys(api).join(' ')).not.toMatch( - /execute|send|renew|release|reconcile|replay|provider/i, + /execute|send|renew|release|reconcile|replay|advance|result/i, ); }); diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index 2d0afa70..543e8ff8 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -376,6 +376,31 @@ describe('server-only operational collections', () => { }); }); + describe.each([ + ['anonymous', undefined], + ['member', { uid: 'member-1', role: 'member' }], + ['browser admin', { uid: 'admin-1', role: 'admin' }], + ])('checkoutRequests provider attempt — %s', (_label, auth) => { + const existingPath = 'checkoutRequests/synthetic-existing/providerAttempts/0000000001'; + const newPath = 'checkoutRequests/synthetic-new/providerAttempts/0000000001'; + const providerPlan = { + providerPlanSchemaVersion: 1, + provider: 'stripe', + providerAttempt: 1, + httpMethod: 'POST', + }; + + test('CANNOT read, create, update, or delete the initial provider plan', async () => { + await seed(existingPath, providerPlan); + const client = await db(auth); + + await assertFails(client.doc(existingPath).get()); + await assertFails(client.doc(newPath).set(providerPlan)); + await assertFails(client.doc(existingPath).update({ providerAttempt: 2 })); + await assertFails(client.doc(existingPath).delete()); + }); + }); + describe.each([ ['promoCodes/PROMO1', 'promoCodes/PROMO2', { discountPercent: 10 }], ['ratelimits/checkout_ip__1.2.3.4', 'ratelimits/checkout_ip__5.6.7.8', { count: 5 }],