diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index ff447986..ff7a329e 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); 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 | +| 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 tracked in [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173); PAY-002B2C2 pre-send evidence/cutoff tracked in [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182); reconciliation/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). 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. +**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/tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). PAY-002B2C2 pre-POST evidence and retry-cutoff source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182). Verified 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 @@ -637,9 +637,11 @@ PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) own 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. +PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) owns only the unused pre-POST marker, deterministic audit, and fixed 23-hour automatic-retry cutoff. It preserves B2A, B2B, and C1 records byte-for-byte. Under the exact current lease/fence and exact attempt-1 plan, `recordInitialStripeSendEvidence` creates `providerAttempts/0000000001/sendEvidence/first` plus `auditEvents/commerce_provider_send_{commandKeyHash}_0000000001`, or creates neither. Both bind a command-bound digest of every immutable C1 plan field. The first atomic creation, and an exact retry, return fixed `send_permitted` only when a post-transaction trusted-time check remains strictly before the transaction-validated lease's captured expiry and the persisted deadline. That second check does not re-read lifecycle state. Lease/deadline equality, later time, clock rollback, paired missing/unreadable time, or a changed/replaced C1 plan returns fixed `reconciliation_required` or fails closed without advancing. These classifications are retry-safety evidence only: they do not authorize a caller, prove the configured account, report a Stripe outcome, make a request, advance to attempt `2`, or replay a result. -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. +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/C2 add no TTL. Deleting both C2 partners would look like first use, 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, #173, and #182 expand that same file. Hosted results remain synthetic source proof only. The current foundations are unused: they add 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 7875cc00..cb19d666 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -67,8 +67,8 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | 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-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-002B2C2 | source/tests tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182); PAY-002B2C1/#173 and CI-001B3/#167; unused/no runtime or deploy | Recompute the exact B1/C1 attempt-1 plan under the current lease/fence, then atomically create a separate pre-POST marker and deterministic audit. Bind both to a command-bound digest of every immutable C1 plan field. The persisted version-1 deadline is 23 hours after the marker. The first atomic creation, and an exact retry, return fixed `send_permitted` only after a post-transaction trusted-time check remains before the transaction-validated lease's captured expiry and deadline; this does not re-read lifecycle state. Equality, later time, rollback, missing/unreadable paired time, or plan replacement reconciles or fails closed. Never create attempt 2 or make a provider request. | Mock and #167-hosted emulator concurrency, post-transaction lease expiry, self-consistent plan replacement, commit-failure, lost-response, takeover, nanosecond cutoff, unknown-time, timeout/connection-loss/5xx ambiguity, conflict, malformed-partner, byte-preservation, redaction, static no-call/random, and explicit browser-denial tests. | +| PAY-002B2C3 | proposed; PAY-002B2C2/#182 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. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ec80b76e..e40894dd 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. 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. +**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. PAY-002B2C2 source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182). They add a separate atomic pre-send marker/audit, a command-bound digest of every immutable C1 plan field, and a fixed 23-hour cutoff. The first marker creation, and an exact attempt-1 retry, get the narrow `send_permitted` classification only after a post-transaction trusted-time check remains before the transaction-validated lease's captured expiry and the persisted deadline; that second check does not re-read lifecycle state. Equality, later time, rollback, missing/unreadable paired time, or plan replacement requires reconciliation or fails closed. These classifications are not caller authorization, configured-account proof, provider execution/outcome evidence, or response 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, #173, and #182 expand. 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-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. +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-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) owns only the unused separate pre-send marker/audit, exact-attempt retry classification, and persisted 23-hour cutoff. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; 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 none of #169/#173/#182 deploys or configures 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 a2842fa7..c79f0099 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-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 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; PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) supplies only unused pre-send evidence/audit and the exact persisted retry cutoff; B2C3 must still add verified reconciliation and attempt advancement; 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. PAY-002B2C2 is tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) for a separate atomic pre-send marker/audit, a complete command-bound C1-plan digest in both partners, a post-transaction lease/deadline freshness check, and persisted 23-hour attempt-1 cutoff. | 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 does not permit a send. C2's fixed `send_permitted` is only a retry-safety classification under the exact lease/fence/plan; it is not caller authorization, configured-account proof, a Stripe call/outcome, or response replay. C2 makes no provider request and never advances attempt `1`. A changed/replaced plan fails its stored digest. Lease/deadline equality, later time, rollback, or missing/unreadable paired time returns `reconciliation_required`. 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. | @@ -112,7 +112,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-014 | SUPPLY-001A [#127](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/127) removes the unused direct `gpxparser` dependency and 50 lockfile entries without changing any retained package version, source URL, or integrity hash. A fresh Node 20 install lowers the root production audit from 33 findings (3 critical, 9 high) to 22 (1 critical, 8 high), and the optimized production asset hashes remain identical. | Remediate or time-bound the remaining root and Functions findings through the separately tested router, Firebase, Stripe, build-system, and scanning slices; do not use a forced audit upgrade. | | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | | Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142) source routes the remaining render, Auth-email, members-only, and App Check browser diagnostics through five fixed outcome identifiers and prohibits direct console calls elsewhere in application/public runtime source. Synthetic tests use mocks/canaries and make no provider call. | #110/#111 still own any future approved telemetry purpose, consent, event/parameter schema, provider settings, and residual hosted boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, console history, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. Callback protected handoffs remain with ABUSE-001A, DATA-001A, and OAUTH-001C in #88. | -| Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. Static negative tests require that exact job at both protected-release CI rechecks. | #169 expands the same focused suite for lease/takeover/stale-fence/terminal races. A green demo-emulator job proves synthetic source behavior only; it does not deploy Firebase or prove Stripe/live behavior. Make lint non-mutating/fail-closed, configure and prove branch-required checks, and add the broader TEST-001 suite. | +| Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. Static negative tests require that exact job at both protected-release CI rechecks. | #169, #173, and #182 expand the same focused suite for lease/fence, immutable plan, pre-send evidence, concurrent retry, and cutoff behavior. A green demo-emulator job proves synthetic source behavior only; it does not deploy Firebase or prove Stripe/live behavior. Make lint non-mutating/fail-closed, configure and prove branch-required checks, and add the broader TEST-001 suite. | | RISK-026, RISK-036 | #135 merged through PR #138 as `9eafab1217aff7058c42240aaba72d7b93f8ed24`, replacing automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. Post-merge staging and synthetic production probes failed closed before authentication or mutation, and published neither Firebase nor Pages. | #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | | RISK-039 | The #118 source slice uses an empty authenticated callable request, bounded Firebase Auth identity fields, one transactional create-only helper shared with signup, constant responses, generic failures, and a UI that hides Edit until setup and the Rules-protected read succeed. Signup and recovery never change custom claims; browser profile creation remains denied. | Source review/merge is not deployment. Under #105, deploy the exact #100 Rules plus both `createMemberOnSignUp` and `ensureMemberProfile` before the website, prove App Check policy, use a synthetic staged account, verify rollback, then record website, Function, Rules, and live behavior separately. Never repair a real profile manually. | | RISK-040 | AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged as `46557c7`: account creation returns `accepted` or `unavailable` without exposing provider details. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) merged as `23bca8c8`: My Account makes no false “sent” claim, blocks rapid repeats, and applies the same 60-second browser cooldown after either outcome. Its protected release run `29252492614` stopped before build because the required public App Check key was absent, so neither frontend revision is published. AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks one byte-equivalent password-reset result after provider success or failure, an immediate repeat guard, and the same browser wait. Synthetic tests use mocked calls and canary values only. | Publish and verify each exact frontend revision separately. #118 profile source is merged but live behavior is unproven. Keep delivery, Spam, DNS, templates, and private Firebase email-enumeration-protection readback under #119. Identical browser copy does not prove the direct Firebase API suppresses account-state differences. An accepted verification request does not prove delivery, and browser cooldowns can reset; they are not server abuse protection. Incoming action-code recovery remains AUTH-MAIL-002C2 under #120. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index b0c8c3a7..a627885e 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -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 the complete PAY-002B2C1/B2C2/B2C3 chain 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 runtime-adopted 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,7 +274,7 @@ 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-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. +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. The complete runtime-adopted PAY-002B2C1/B2C2/B2C3 chain 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) @@ -289,10 +289,27 @@ The immutable plan binds provider `stripe`, attempt `1`, HTTP `POST`, environmen 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. +C1 has no first-send marker, safe-resend clock, Stripe call, provider response, ambiguity state, reconciliation evidence, or later-attempt transition. PAY-002B2C2 source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) for the separate pre-POST evidence/cutoff boundary described below. 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. +### Pre-POST send-evidence target (PAY-002B2C2) + +PAY-002B2C2 source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182). They preserve the B2A registration, B2B lifecycle, and C1 plan documents and audits byte-for-byte. With the exact active holder/fence and exact recomputed attempt-1 plan, one transaction may create only: + +```text +checkoutRequests/{commandKeyHash}/providerAttempts/0000000001/sendEvidence/first +auditEvents/commerce_provider_send_{commandKeyHash}_0000000001 +``` + +The marker records schema versions, the command hash, provider `stripe`, attempt `1`, a command-bound digest of every immutable C1 plan field, the originating fence, one trusted pre-POST time, and an immutable automatic-retry deadline exactly 23 hours later. The audit repeats that plan digest. Changing or replacing the C1 account, API version, endpoint, parameters, idempotency-key commitment, binding fence, or binding time after the marker therefore fails closed. The marker deliberately repeats no raw Stripe account, provider parameters, idempotency key, caller, command UUID, provider response, or business/member data. The audit says `provider_pre_send_recorded`, not “sent” or “completed,” because the transaction commits before a possible POST. + +If both partners are absent, the exact current holder may create them atomically. The first atomic marker creation, and an exact retry strictly before the persisted deadline, may return the fixed frozen classification `send_permitted`. The transaction validates the holder, fence, and leased state. After it returns, a second trusted-time check must still be strictly before the lease expiry captured by that transaction and the persisted deadline; a delayed transaction is downgraded before any caller may POST. This second clock check does not re-read lifecycle state. Deadline or lease equality, later time, clock rollback, or a paired missing/unreadable time returns fixed `reconciliation_required`. One missing partner, a plan-digest mismatch, future/extra identity fields, a conflicting exact plan, a missing originating lease audit, an expired/wrong holder or fence, and terminal lifecycle state fail closed without repair. The originating fence is tied to its deterministic lease audit; its pre-POST time must fall before that lease expires. A valid later takeover may reuse the same fixed attempt-1 plan only inside the original stored deadline. + +`send_permitted` proves only that this retry-safety marker exists and the exact attempt is still inside its conservative window. It does not authorize the caller, prove that a configured credential controls the account, claim that Stripe received/executed the request, classify a timeout/connection loss/`5xx`, or replay a result. The source cannot accept a caller-selected attempt, key, method, deadline, retry duration, or provider outcome. It cannot create attempt `2`, report reconciliation evidence, call Stripe/network code, or update an earlier record. B2C3 remains required for verified reconciliation and any safe later-attempt decision. + +#182 remains unused source. It adds no endpoint/index import, provider adapter/call, Firebase deployment, Stripe account/key/domain configuration, production read/write, website change, or live behavior. Its mock, Firestore emulator, and browser-denial tests use synthetic demo-only data. A green test does not prove Firebase or Stripe configuration. + ## 7. Persistence-first checkout saga External Stripe calls cannot be part of a Firestore transaction. Use this sequence: diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 43f2cbbb..765b2248 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -248,7 +248,8 @@ The first release may continue using `admin` in the UI, but server endpoints and | `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. | | `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`. | +| `checkoutRequests/{commandKeyHash}/providerAttempts/0000000001/sendEvidence/first` | PAY-002B2C2/#182 separate server-only pre-POST marker with a complete C1-plan digest, its originating fence, trusted time, and persisted 23-hour automatic-retry deadline. It is unused and does not say Stripe received or completed a request. | +| `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`; C2 pairs the pre-send marker with `commerce_provider_send_{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 | @@ -269,13 +270,15 @@ 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-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. +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. PAY-002B2C2/#182 owns only separate pre-send evidence and a conservative automatic-retry cutoff. B2C3 still owns verified reconciliation and safe attempt advancement. No TTL is safe yet: deleting both C2 partners would look like first use, 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 a command pair can be deleted. 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. +These commitments are pseudonymous equality evidence, not authorization, configured-account proof, a pre-POST marker, provider-execution proof, or response replay. PAY-002B2C2 source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182). Its unused transaction recomputes the exact B1 identity and C1 plan, requires the exact active lease holder/fence, and creates only the separate marker/audit pair. Both partners bind a command-bound digest of every immutable C1 plan field, so a later coherent account, API-version, endpoint, parameter, key-commitment, or binding change cannot reuse the marker. Version 1 persists a deadline exactly 23 hours after the pre-POST marker. The first atomic creation, and an exact attempt-1 retry, are classified `send_permitted` only when a post-transaction trusted-time check is still strictly before the transaction-validated lease's captured expiry and the stored deadline. The second check does not re-read lifecycle state. Equality, later time, rollback, or paired missing/unreadable time is classified `reconciliation_required`; no attempt advancement follows. -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. +The fixed C2 result is narrow retry-safety evidence. It is not caller authorization, configured-account proof, a claim that Stripe received the POST, a provider outcome, or response replay. Timeout, connection loss, `5xx`, a missing object reference, and incomplete search are deliberately not C2 inputs and cannot manufacture attempt `2`. B2C3 remains responsible for verified reconciliation evidence and safe later-attempt authorization. #173/#182 have no runtime/index import, Stripe/network call, Firebase deployment, 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, #173, and #182 expand that same suite. These are synthetic source checks only. The current journal source remains unused and makes no endpoint, Firebase deployment, Stripe/provider configuration, production data, website, or live/officer change. ## 7. Business invariants diff --git a/functions/commerceCommandJournal.emulator.test.js b/functions/commerceCommandJournal.emulator.test.js index 8ae185fa..76f0a6a0 100644 --- a/functions/commerceCommandJournal.emulator.test.js +++ b/functions/commerceCommandJournal.emulator.test.js @@ -15,6 +15,7 @@ const { bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, + recordInitialStripeSendEvidence, registerCommerceCommand, } = require('./commerceCommandJournal'); @@ -67,15 +68,23 @@ const COMMAND_IDS = Object.freeze({ providerExpiredBeforeBind: '468ace02-5791-4bdf-8ace-468ace025791', providerTerminalNoPlan: '579bdf13-6802-4ace-8bdf-579bdf136802', providerCommitFailure: '68ace024-7913-4bdf-8ace-68ace0247913', + providerSendConcurrent: '71ace024-7913-4bdf-8ace-68ace0247913', + providerSendBoundary: '72ace024-7913-4bdf-8ace-68ace0247913', + providerSendConflict: '73ace024-7913-4bdf-8ace-68ace0247913', + providerSendReplacement: '76ace024-7913-4bdf-8ace-68ace0247913', + providerSendCommitFailure: '74ace024-7913-4bdf-8ace-68ace0247913', + providerSendOrphan: '75ace024-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 OTHER_STRIPE_ACCOUNT_ID = 'acct_1SyntheticTest000000000002'; const STRIPE_API_VERSION = '2025-06-30.basil'; const STRIPE_ENDPOINT_PATH = '/v1/checkout/sessions'; const STRIPE_OPERATION = 'checkout_session_create'; +const PROVIDER_SEND_RETRY_WINDOW_MILLIS = 23 * 60 * 60 * 1000; function frozenRecord(entries) { const record = Object.create(null); @@ -277,6 +286,9 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { .doc('0000000001')); const providerAuditRef = trackRef(db.collection('auditEvents') .doc(`commerce_provider_attempt_${commandKeyHash}_0000000001`)); + const providerSendRef = trackRef(providerPlanRef.collection('sendEvidence').doc('first')); + const providerSendAuditRef = trackRef(db.collection('auditEvents') + .doc(`commerce_provider_send_${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. @@ -288,6 +300,8 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { auditRef, providerPlanRef, providerAuditRef, + providerSendRef, + providerSendAuditRef, }; } @@ -329,6 +343,17 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { }; } + async function readProviderSendEvidence(pair) { + const [evidenceSnapshot, auditSnapshot] = await Promise.all([ + pair.providerSendRef.get(), + pair.providerSendAuditRef.get(), + ]); + return { + evidence: snapshotEvidence(evidenceSnapshot), + audit: snapshotEvidence(auditSnapshot), + }; + } + async function readFoundationEvidence(pair, lifecycleRevision) { const [registration, lifecycle] = await Promise.all([ readPair(pair), @@ -399,6 +424,19 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { expect(Object.isFrozen(result)).toBe(true); } + function expectProviderSendResult(result, outcome) { + expect(result).toEqual({ + journalSchemaVersion: 1, + providerPlanSchemaVersion: 1, + providerSendEvidenceSchemaVersion: 1, + outcome, + state: outcome === 'send_permitted' + ? 'pre_send_recorded' + : 'provider_outcome_unknown', + }); + expect(Object.isFrozen(result)).toBe(true); + } + async function setupLeasedCommand(commandId, reference, leaseIndex) { const commandPayload = payload(reference); const leaseId = deterministicLeaseId(leaseIndex); @@ -416,6 +454,23 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { }; } + async function setupBoundProviderPlan(commandId, reference, leaseIndex) { + const setup = await setupLeasedCommand(commandId, reference, leaseIndex); + const args = providerPlanArgs( + db, + commandId, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters(reference), + ); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(args), + 'provider_plan_bound', + ); + return { ...setup, args }; + } + async function cleanupTrackedRefs() { if (!trackedRefs) return; const refs = [...trackedRefs.values()]; @@ -1919,4 +1974,283 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { expect(await readFoundationEvidence(lifecycle.pair, 2)) .toEqual(lifecycleFoundationBefore); }); + + test('24 identical pre-send calls create one pair and preserve every foundation byte', async () => { + const setup = await setupBoundProviderPlan( + COMMAND_IDS.providerSendConcurrent, + 'provider-send-concurrent', + 320, + ); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + const providerBefore = await readProviderEvidence(setup.pair); + + const results = await Promise.all( + Array.from({ length: CONCURRENT_CALLS }, () => retryWholeOperationAfterUnavailable( + () => recordInitialStripeSendEvidence(setup.args), + retryEvidence, + )), + ); + for (const result of results) expectProviderSendResult(result, 'send_permitted'); + + const send = await readProviderSendEvidence(setup.pair); + expect(send.evidence.exists).toBe(true); + expect(send.audit.exists).toBe(true); + expect(Object.keys(send.evidence.data).sort()).toEqual([ + 'automaticRetryDeadlineAt', + 'commandIdentityVersion', + 'commandKeyHash', + 'prePostFenceEpoch', + 'prePostRecordedAt', + 'provider', + 'providerAttempt', + 'providerPlanCommitment', + 'providerPlanSchemaVersion', + 'providerSendEvidenceSchemaVersion', + ].sort()); + expect(send.evidence.data).toMatchObject({ + providerSendEvidenceSchemaVersion: 1, + providerPlanSchemaVersion: 1, + commandIdentityVersion: 1, + commandKeyHash: setup.pair.commandKeyHash, + providerAttempt: 1, + provider: 'stripe', + providerPlanCommitment: expect.stringMatching(/^[0-9a-f]{64}$/), + prePostFenceEpoch: 1, + }); + expect(send.evidence.data.prePostRecordedAt.toMillis()).toBe(BASE_NOW_MILLIS); + expect(send.evidence.data.automaticRetryDeadlineAt.toMillis() + - send.evidence.data.prePostRecordedAt.toMillis()) + .toBe(PROVIDER_SEND_RETRY_WINDOW_MILLIS); + expect(send.audit.data).toMatchObject({ + providerSendAuditSchemaVersion: 1, + aggregateType: 'commerce_provider_send', + commandKeyHash: setup.pair.commandKeyHash, + providerAttempt: 1, + eventType: 'provider_pre_send_recorded', + provider: 'stripe', + environment: 'test', + stripeMode: 'test', + providerOperation: STRIPE_OPERATION, + providerPlanCommitment: send.evidence.data.providerPlanCommitment, + prePostFenceEpoch: 1, + }); + expect(send.audit.data.occurredAt.toMillis()).toBe(BASE_NOW_MILLIS); + expect(send.audit.data.automaticRetryDeadlineAt.toMillis()) + .toBe(send.evidence.data.automaticRetryDeadlineAt.toMillis()); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBefore); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBefore); + expect((await matchingAudits(setup.pair)).size).toBe(4); + const rendered = JSON.stringify({ results, send }); + expect(rendered).not.toContain(STRIPE_ACCOUNT_ID); + expect(rendered).not.toContain('provider-send-concurrent'); + expect(rendered).not.toContain(setup.leaseId); + }); + + test('lost response retries stay read-only and a conflicting plan cannot reuse evidence', async () => { + const setup = await setupBoundProviderPlan( + COMMAND_IDS.providerSendConflict, + 'provider-send-conflict', + 321, + ); + expectProviderSendResult( + await recordInitialStripeSendEvidence(setup.args), + 'send_permitted', + ); + const sendBefore = await readProviderSendEvidence(setup.pair); + const providerBefore = await readProviderEvidence(setup.pair); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + + const retries = await Promise.all(Array.from( + { length: 8 }, + () => recordInitialStripeSendEvidence(setup.args), + )); + for (const result of retries) expectProviderSendResult(result, 'send_permitted'); + const conflictingArgs = providerPlanArgs( + db, + COMMAND_IDS.providerSendConflict, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters('different-provider-send-parameters'), + ); + await expect(recordInitialStripeSendEvidence(conflictingArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'command_conflict', + }); + expect(await readProviderSendEvidence(setup.pair)).toEqual(sendBefore); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBefore); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBefore); + expect((await matchingAudits(setup.pair)).size).toBe(4); + }); + + test('a valid replacement parent plan cannot reuse the surviving child marker', async () => { + const setup = await setupBoundProviderPlan( + COMMAND_IDS.providerSendReplacement, + 'provider-send-replacement', + 326, + ); + expectProviderSendResult( + await recordInitialStripeSendEvidence(setup.args), + 'send_permitted', + ); + const sendBefore = await readProviderSendEvidence(setup.pair); + + await Promise.all([ + setup.pair.providerPlanRef.delete(), + setup.pair.providerAuditRef.delete(), + ]); + const replacementArgs = providerPlanArgs( + db, + COMMAND_IDS.providerSendReplacement, + setup.commandPayload, + setup.leaseId, + 1, + providerParameters('provider-send-replacement-plan-b'), + { + stripeAccountId: OTHER_STRIPE_ACCOUNT_ID, + stripeApiVersion: '2024-06-20', + }, + ); + expectProviderPlanResult( + await bindInitialStripeProviderPlan(replacementArgs), + 'provider_plan_bound', + ); + + await expect(recordInitialStripeSendEvidence(replacementArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderSendEvidence(setup.pair)).toEqual(sendBefore); + }); + + test('a fresh takeover permits deadline-minus-1ns and reconciles at equality and later', async () => { + const setup = await setupBoundProviderPlan( + COMMAND_IDS.providerSendBoundary, + 'provider-send-boundary', + 322, + ); + expectProviderSendResult( + await recordInitialStripeSendEvidence(setup.args), + 'send_permitted', + ); + const sendBefore = await readProviderSendEvidence(setup.pair); + const deadline = sendBefore.evidence.data.automaticRetryDeadlineAt; + const takeoverLeaseId = deterministicLeaseId(323); + setTrustedNow(deadline.toMillis() - 1000); + expectLeaseResult(await acquireCommerceCommandLease(acquireArgs( + db, + COMMAND_IDS.providerSendBoundary, + setup.commandPayload, + takeoverLeaseId, + )), 2); + const takeoverArgs = providerPlanArgs( + db, + COMMAND_IDS.providerSendBoundary, + setup.commandPayload, + takeoverLeaseId, + 2, + providerParameters('provider-send-boundary'), + ); + + const immediatelyBeforeDeadline = new admin.firestore.Timestamp( + deadline._seconds, + deadline._nanoseconds - 1, + ); + timestampNow + .mockImplementationOnce(() => immediatelyBeforeDeadline) + .mockImplementationOnce(() => immediatelyBeforeDeadline); + expectProviderSendResult( + await recordInitialStripeSendEvidence(takeoverArgs), + 'send_permitted', + ); + timestampNow.mockImplementationOnce(() => new admin.firestore.Timestamp( + deadline._seconds, + deadline._nanoseconds, + )); + expectProviderSendResult( + await recordInitialStripeSendEvidence(takeoverArgs), + 'reconciliation_required', + ); + timestampNow.mockImplementationOnce(() => new admin.firestore.Timestamp( + deadline._seconds, + deadline._nanoseconds + 1, + )); + expectProviderSendResult( + await recordInitialStripeSendEvidence(takeoverArgs), + 'reconciliation_required', + ); + expect(await readProviderSendEvidence(setup.pair)).toEqual(sendBefore); + expect((await matchingAudits(setup.pair)).size).toBe(5); + }); + + test('an injected commit failure leaves neither send partner', async () => { + const setup = await setupBoundProviderPlan( + COMMAND_IDS.providerSendCommitFailure, + 'provider-send-commit-failure', + 324, + ); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + const providerBefore = await readProviderEvidence(setup.pair); + const sendBefore = await readProviderSendEvidence(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 recordInitialStripeSendEvidence(setup.args).then( + () => null, + (reason) => reason, + ); + expectJournalError(error, 'journal_unavailable'); + commitCalls = commitFailure.mock.calls.length; + } finally { + commitFailure.mockRestore(); + } + expect(commitCalls).toBe(1); + expect(await readProviderSendEvidence(setup.pair)).toEqual(sendBefore); + 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(sendBefore.evidence.exists).toBe(false); + expect(sendBefore.audit.exists).toBe(false); + }); + + test('orphan identity evidence errors while paired missing time requires reconciliation', async () => { + const setup = await setupBoundProviderPlan( + COMMAND_IDS.providerSendOrphan, + 'provider-send-orphan', + 325, + ); + await setup.pair.providerSendAuditRef.create({ + providerSendAuditSchemaVersion: 1, + syntheticOrphan: true, + }); + const orphanBefore = await readProviderSendEvidence(setup.pair); + await expect(recordInitialStripeSendEvidence(setup.args)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderSendEvidence(setup.pair)).toEqual(orphanBefore); + + await setup.pair.providerSendAuditRef.delete(); + expectProviderSendResult( + await recordInitialStripeSendEvidence(setup.args), + 'send_permitted', + ); + const validEvidence = await setup.pair.providerSendRef.get(); + const validAudit = await setup.pair.providerSendAuditRef.get(); + const evidenceWithoutTime = { ...validEvidence.data() }; + const auditWithoutTime = { ...validAudit.data() }; + delete evidenceWithoutTime.prePostRecordedAt; + delete auditWithoutTime.occurredAt; + await setup.pair.providerSendRef.set(evidenceWithoutTime); + await setup.pair.providerSendAuditRef.set(auditWithoutTime); + const unknownBefore = await readProviderSendEvidence(setup.pair); + expectProviderSendResult( + await recordInitialStripeSendEvidence(setup.args), + 'reconciliation_required', + ); + expect(await readProviderSendEvidence(setup.pair)).toEqual(unknownBefore); + }); }); diff --git a/functions/commerceCommandJournal.js b/functions/commerceCommandJournal.js index 567a9b2e..1cbb7fc0 100644 --- a/functions/commerceCommandJournal.js +++ b/functions/commerceCommandJournal.js @@ -16,9 +16,12 @@ const lifecycleSchemaVersion = 1; const lifecycleAuditSchemaVersion = 2; const providerPlanSchemaVersion = 1; const providerPlanAuditSchemaVersion = 1; +const providerSendEvidenceSchemaVersion = 1; +const providerSendAuditSchemaVersion = 1; const MAXIMUM_ENDPOINT_SCHEMA_VERSION = 1000000; const MAXIMUM_LIFECYCLE_NUMBER = 9999999999; const LEASE_DURATION_SECONDS = 60; +const PROVIDER_SEND_RETRY_WINDOW_SECONDS = 23 * 60 * 60; const TRANSACTION_OPTIONS = Object.freeze({ maxAttempts: 10 }); const COMMAND_COLLECTION = 'checkoutRequests'; const AUDIT_COLLECTION = 'auditEvents'; @@ -26,6 +29,8 @@ const LIFECYCLE_COLLECTION = 'lifecycle'; const LIFECYCLE_DOCUMENT = 'current'; const PROVIDER_ATTEMPTS_COLLECTION = 'providerAttempts'; const INITIAL_PROVIDER_ATTEMPT_DOCUMENT = '0000000001'; +const SEND_EVIDENCE_COLLECTION = 'sendEvidence'; +const INITIAL_SEND_EVIDENCE_DOCUMENT = 'first'; const COMMAND_STATE = 'registered'; const COMMAND_REVISION = 1; const INITIAL_PROVIDER_ATTEMPT = 1; @@ -43,6 +48,7 @@ const HASH_DOMAINS = Object.freeze({ 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', + providerPlan: 'mprc.command-provider-plan-complete.v1', }); const ENVIRONMENTS = new Set(['local', 'test', 'staging', 'production']); const STRIPE_MODES = new Set(['test', 'live']); @@ -171,6 +177,33 @@ const PROVIDER_PLAN_AUDIT_FIELDS = Object.freeze([ 'boundFenceEpoch', 'occurredAt', ]); +const PROVIDER_SEND_FIELDS = Object.freeze([ + 'providerSendEvidenceSchemaVersion', + 'providerPlanSchemaVersion', + 'commandIdentityVersion', + 'commandKeyHash', + 'providerAttempt', + 'provider', + 'providerPlanCommitment', + 'prePostFenceEpoch', + 'prePostRecordedAt', + 'automaticRetryDeadlineAt', +]); +const PROVIDER_SEND_AUDIT_FIELDS = Object.freeze([ + 'providerSendAuditSchemaVersion', + 'aggregateType', + 'commandKeyHash', + 'providerAttempt', + 'eventType', + 'provider', + 'environment', + 'stripeMode', + 'providerOperation', + 'providerPlanCommitment', + 'prePostFenceEpoch', + 'automaticRetryDeadlineAt', + 'occurredAt', +]); const ERROR_MESSAGES = Object.freeze({ invalid_command_input: 'Commerce command journal input is invalid.', @@ -237,6 +270,57 @@ function readExactOwnDataObject(value, expectedFields, reason) { return result; } +function readExactObjectWithOptionalTimeFields( + value, + expectedFields, + optionalTimeFields, + reason, +) { + if (value === null || typeof value !== 'object' || isProxy(value)) reject(reason); + + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + reject(reason); + } + const expected = new Set(expectedFields); + const optional = new Set(optionalTimeFields); + if (prototype !== Object.prototype + || keys.length < expectedFields.length - optionalTimeFields.length + || keys.length > expectedFields.length) { + reject(reason); + } + + const result = Object.create(null); + for (const key of keys) { + if (typeof key !== 'string' || !expected.has(key)) reject(reason); + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + reject(reason); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + reject(reason); + } + result[key] = descriptor.value; + } + for (const field of expectedFields) { + if (!Object.prototype.hasOwnProperty.call(result, field)) { + if (!optional.has(field)) reject(reason); + result[field] = null; + } + } + return result; +} + function readTimestamp(value) { if (value === null || typeof value !== 'object' || isProxy(value)) return null; let prototype; @@ -322,15 +406,18 @@ function captureTrustedTimestamp() { return timestamp; } -function addLeaseDuration(timestamp) { +function addTimestampSeconds(timestamp, seconds) { const parts = readTimestamp(timestamp); - if (parts === null || parts.seconds > 253402300799 - LEASE_DURATION_SECONDS) { + if (!Number.isSafeInteger(seconds) + || seconds < 1 + || parts === null + || parts.seconds > 253402300799 - seconds) { reject('journal_unavailable'); } let expiresAt; try { expiresAt = new Timestamp( - parts.seconds + LEASE_DURATION_SECONDS, + parts.seconds + seconds, parts.nanoseconds, ); Object.freeze(expiresAt); @@ -340,6 +427,14 @@ function addLeaseDuration(timestamp) { return expiresAt; } +function addLeaseDuration(timestamp) { + return addTimestampSeconds(timestamp, LEASE_DURATION_SECONDS); +} + +function addProviderSendRetryWindow(timestamp) { + return addTimestampSeconds(timestamp, PROVIDER_SEND_RETRY_WINDOW_SECONDS); +} + function copyTimestamp(parts) { let timestamp; try { @@ -404,6 +499,30 @@ function createProviderCommitment(domain, commandKeyHash, valueName, value) { ]); } +function createCompleteProviderPlanCommitment(plan, boundAt) { + if (boundAt === null) reject('journal_record_invalid'); + return digest(HASH_DOMAINS.providerPlan, [ + ['version', '1'], + ['providerPlanSchemaVersion', String(plan.providerPlanSchemaVersion)], + ['commandIdentityVersion', String(plan.commandIdentityVersion)], + ['commandKeyHash', plan.commandKeyHash], + ['environment', plan.environment], + ['provider', plan.provider], + ['providerAttempt', String(plan.providerAttempt)], + ['providerOperation', plan.providerOperation], + ['stripeMode', plan.stripeMode], + ['stripeAccountFingerprint', plan.stripeAccountFingerprint], + ['stripeApiVersion', plan.stripeApiVersion], + ['httpMethod', plan.httpMethod], + ['endpointPath', plan.endpointPath], + ['parametersFingerprint', plan.parametersFingerprint], + ['idempotencyKeyFingerprint', plan.idempotencyKeyFingerprint], + ['boundFenceEpoch', String(plan.boundFenceEpoch)], + ['boundAtSeconds', String(boundAt.seconds)], + ['boundAtNanoseconds', String(boundAt.nanoseconds)], + ]); +} + function validStripeAccountId(value) { return typeof value === 'string' && SAFE_STRIPE_ACCOUNT_ID.test(value); } @@ -651,6 +770,13 @@ function providerPlanAuditDocumentId(commandKeyHash) { return `commerce_provider_attempt_${commandKeyHash}_${INITIAL_PROVIDER_ATTEMPT_DOCUMENT}`; } +function providerSendAuditDocumentId(commandKeyHash) { + if (typeof commandKeyHash !== 'string' || !LOWERCASE_SHA256.test(commandKeyHash)) { + reject('journal_record_invalid'); + } + return `commerce_provider_send_${commandKeyHash}_${INITIAL_PROVIDER_ATTEMPT_DOCUMENT}`; +} + function prepareIdentity(input, fields) { let values; let commandKey; @@ -884,6 +1010,69 @@ function prepareProviderPlanOperation(input) { }); } +function prepareProviderSendOperation(input) { + const prepared = prepareProviderPlanOperation(input); + + let providerSendRef; + let providerSendAuditRef; + try { + providerSendRef = prepared.providerPlanRef + .collection(SEND_EVIDENCE_COLLECTION) + .doc(INITIAL_SEND_EVIDENCE_DOCUMENT); + providerSendAuditRef = prepared.db.collection(AUDIT_COLLECTION).doc( + providerSendAuditDocumentId(prepared.commandKeyHash), + ); + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } + + const automaticRetryDeadlineAt = addProviderSendRetryWindow(prepared.occurredAt); + + return Object.freeze({ + ...prepared, + providerSendRef, + providerSendAuditRef, + automaticRetryDeadlineAt, + }); +} + +function createProviderSendPair(prepared, providerPlan) { + const providerPlanCommitment = createCompleteProviderPlanCommitment( + providerPlan.record, + providerPlan.boundAt, + ); + const record = Object.freeze({ + providerSendEvidenceSchemaVersion, + providerPlanSchemaVersion, + commandIdentityVersion, + commandKeyHash: prepared.commandKeyHash, + providerAttempt: INITIAL_PROVIDER_ATTEMPT, + provider: 'stripe', + providerPlanCommitment, + prePostFenceEpoch: prepared.expectedFenceEpoch, + prePostRecordedAt: prepared.occurredAt, + automaticRetryDeadlineAt: prepared.automaticRetryDeadlineAt, + }); + const audit = Object.freeze({ + providerSendAuditSchemaVersion, + aggregateType: 'commerce_provider_send', + commandKeyHash: prepared.commandKeyHash, + providerAttempt: INITIAL_PROVIDER_ATTEMPT, + eventType: 'provider_pre_send_recorded', + provider: 'stripe', + environment: prepared.expected.environment, + stripeMode: prepared.providerPlanRecord.stripeMode, + providerOperation: prepared.providerPlanRecord.providerOperation, + providerPlanCommitment, + prePostFenceEpoch: prepared.expectedFenceEpoch, + automaticRetryDeadlineAt: prepared.automaticRetryDeadlineAt, + occurredAt: prepared.occurredAt, + }); + + return Object.freeze({ record, audit }); +} + function readSnapshot(snapshot) { let exists; try { @@ -1400,7 +1589,158 @@ async function readProviderPlanContext(transaction, prepared) { ); } - return Object.freeze({ lifecycle, providerPlan, mayCreate }); + return Object.freeze({ + lifecycle, + providerPlan, + mayCreate, + readRequiredLifecycleAudit, + }); +} + +function parseProviderSendPair(recordValue, auditValue, prepared, context) { + const record = readExactObjectWithOptionalTimeFields( + recordValue, + PROVIDER_SEND_FIELDS, + ['prePostRecordedAt', 'automaticRetryDeadlineAt'], + 'journal_record_invalid', + ); + const audit = readExactObjectWithOptionalTimeFields( + auditValue, + PROVIDER_SEND_AUDIT_FIELDS, + ['occurredAt', 'automaticRetryDeadlineAt'], + 'journal_record_invalid', + ); + + if (record.providerSendEvidenceSchemaVersion !== providerSendEvidenceSchemaVersion + || record.providerPlanSchemaVersion !== providerPlanSchemaVersion + || record.commandIdentityVersion !== commandIdentityVersion + || record.commandKeyHash !== prepared.commandKeyHash + || record.providerAttempt !== INITIAL_PROVIDER_ATTEMPT + || record.provider !== 'stripe' + || typeof record.providerPlanCommitment !== 'string' + || !LOWERCASE_SHA256.test(record.providerPlanCommitment) + || !validLifecycleNumber(record.prePostFenceEpoch) + || record.prePostFenceEpoch < context.providerPlan.record.boundFenceEpoch + || record.prePostFenceEpoch > context.lifecycle.record.fenceEpoch + || audit.providerSendAuditSchemaVersion !== providerSendAuditSchemaVersion + || audit.aggregateType !== 'commerce_provider_send' + || audit.commandKeyHash !== record.commandKeyHash + || audit.providerAttempt !== record.providerAttempt + || audit.eventType !== 'provider_pre_send_recorded' + || audit.provider !== record.provider + || audit.environment !== context.providerPlan.record.environment + || audit.stripeMode !== context.providerPlan.record.stripeMode + || audit.providerOperation !== context.providerPlan.record.providerOperation + || audit.providerPlanCommitment !== record.providerPlanCommitment + || record.providerPlanCommitment !== createCompleteProviderPlanCommitment( + context.providerPlan.record, + context.providerPlan.boundAt, + ) + || audit.prePostFenceEpoch !== record.prePostFenceEpoch) { + reject('journal_record_invalid'); + } + + const prePostRecordedAt = readTimestamp(record.prePostRecordedAt); + const automaticRetryDeadlineAt = readTimestamp(record.automaticRetryDeadlineAt); + const auditOccurredAt = readTimestamp(audit.occurredAt); + const auditRetryDeadlineAt = readTimestamp(audit.automaticRetryDeadlineAt); + const timeUnknown = prePostRecordedAt === null + || automaticRetryDeadlineAt === null + || auditOccurredAt === null + || auditRetryDeadlineAt === null + || !timestampsEqual(prePostRecordedAt, auditOccurredAt) + || !timestampsEqual(automaticRetryDeadlineAt, auditRetryDeadlineAt) + || !timestampExactlySecondsAfter( + automaticRetryDeadlineAt, + prePostRecordedAt, + PROVIDER_SEND_RETRY_WINDOW_SECONDS, + ) + || compareTimestamps(prePostRecordedAt, context.providerPlan.boundAt) < 0; + if (timeUnknown) { + return Object.freeze({ + record, + audit, + timeUnknown: true, + prePostRecordedAt: null, + automaticRetryDeadlineAt: null, + }); + } + + return Object.freeze({ + record, + audit, + timeUnknown: false, + prePostRecordedAt, + automaticRetryDeadlineAt, + }); +} + +async function readProviderSendContext(transaction, prepared) { + const context = await readProviderPlanContext(transaction, prepared); + if (context.providerPlan === null) reject('journal_record_invalid'); + + const providerSendSnapshot = await transaction.get(prepared.providerSendRef); + const providerSendAuditSnapshot = await transaction.get(prepared.providerSendAuditRef); + const providerSendDocument = readSnapshot(providerSendSnapshot); + const providerSendAudit = readSnapshot(providerSendAuditSnapshot); + + if (providerSendDocument.exists !== providerSendAudit.exists) { + reject('journal_record_invalid'); + } + if (!providerSendDocument.exists) { + return Object.freeze({ ...context, providerSend: null }); + } + + const providerSend = parseProviderSendPair( + providerSendDocument.value, + providerSendAudit.value, + prepared, + context, + ); + const bindingAuditDocument = await context.readRequiredLifecycleAudit( + providerSend.record.prePostFenceEpoch + 1, + ); + const bindingAudit = validateProviderLeaseAudit( + bindingAuditDocument.value, + providerSend.record.prePostFenceEpoch, + prepared, + ); + let bindingPredecessorAudit = null; + if (providerSend.record.prePostFenceEpoch > 1) { + const predecessorDocument = await context.readRequiredLifecycleAudit( + providerSend.record.prePostFenceEpoch, + ); + bindingPredecessorAudit = validateProviderLeaseAudit( + predecessorDocument.value, + providerSend.record.prePostFenceEpoch - 1, + prepared, + ); + } + validateProviderLeaseChronology( + bindingAudit, + bindingPredecessorAudit, + context.lifecycle, + ); + if (providerSend.timeUnknown) { + return Object.freeze({ ...context, providerSend }); + } + if (compareTimestamps(providerSend.prePostRecordedAt, bindingAudit.occurredAt) < 0 + || compareTimestamps( + providerSend.prePostRecordedAt, + bindingAudit.leaseExpiresAt, + ) >= 0) { + return Object.freeze({ + ...context, + providerSend: Object.freeze({ + ...providerSend, + timeUnknown: true, + prePostRecordedAt: null, + automaticRetryDeadlineAt: null, + }), + }); + } + + return Object.freeze({ ...context, providerSend }); } function validateActiveProviderPlanLease(prepared, lifecycle) { @@ -1450,6 +1790,94 @@ async function bindInitialStripeProviderPlan(input) { } } +const PROVIDER_SEND_PERMITTED = Object.freeze({ + journalSchemaVersion, + providerPlanSchemaVersion, + providerSendEvidenceSchemaVersion, + outcome: 'send_permitted', + state: 'pre_send_recorded', +}); +const PROVIDER_RECONCILIATION_REQUIRED = Object.freeze({ + journalSchemaVersion, + providerPlanSchemaVersion, + providerSendEvidenceSchemaVersion, + outcome: 'reconciliation_required', + state: 'provider_outcome_unknown', +}); + +async function recordInitialStripeSendEvidence(input) { + const prepared = prepareProviderSendOperation(input); + const permittedTransactionResults = new Set(); + + try { + const result = await prepared.db.runTransaction(async (transaction) => { + const context = await readProviderSendContext(transaction, prepared); + const createPermittedResult = (prePostRecordedAt, automaticRetryDeadlineAt) => { + const permitted = Object.freeze({ + transactionCheckedAt: prepared.occurredAtParts, + leaseExpiresAt: context.lifecycle.leaseExpiresAt, + prePostRecordedAt, + automaticRetryDeadlineAt, + }); + permittedTransactionResults.add(permitted); + return permitted; + }; + if (context.providerSend === null) { + if (!context.mayCreate + || compareTimestamps( + prepared.occurredAtParts, + context.providerPlan.boundAt, + ) < 0) { + return PROVIDER_RECONCILIATION_REQUIRED; + } + const pair = createProviderSendPair(prepared, context.providerPlan); + const deadline = readTimestamp(pair.record.automaticRetryDeadlineAt); + if (deadline === null) reject('journal_unavailable'); + transaction.create(prepared.providerSendRef, pair.record); + transaction.create( + prepared.providerSendAuditRef, + pair.audit, + ); + return createPermittedResult(prepared.occurredAtParts, deadline); + } + + if (context.providerSend.timeUnknown + || !context.mayCreate + || compareTimestamps( + prepared.occurredAtParts, + context.providerSend.prePostRecordedAt, + ) < 0 + || compareTimestamps( + prepared.occurredAtParts, + context.providerSend.automaticRetryDeadlineAt, + ) >= 0) { + return PROVIDER_RECONCILIATION_REQUIRED; + } + return createPermittedResult( + context.providerSend.prePostRecordedAt, + context.providerSend.automaticRetryDeadlineAt, + ); + }, TRANSACTION_OPTIONS); + + if (result === PROVIDER_RECONCILIATION_REQUIRED) return result; + if (!permittedTransactionResults.has(result)) { + reject('journal_unavailable'); + } + const returnedAt = readTimestamp(captureTrustedTimestamp()); + if (returnedAt === null + || compareTimestamps(returnedAt, result.transactionCheckedAt) < 0 + || compareTimestamps(returnedAt, result.prePostRecordedAt) < 0 + || compareTimestamps(returnedAt, result.leaseExpiresAt) >= 0 + || compareTimestamps(returnedAt, result.automaticRetryDeadlineAt) >= 0) { + return PROVIDER_RECONCILIATION_REQUIRED; + } + return PROVIDER_SEND_PERMITTED; + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } +} + const REGISTERED_NEW = Object.freeze({ journalSchemaVersion, outcome: 'registered_new', @@ -1672,10 +2100,12 @@ Object.freeze(CommerceCommandJournalError); module.exports = Object.freeze({ journalSchemaVersion, lifecycleSchemaVersion, + providerSendEvidenceSchemaVersion, CommerceCommandJournalError, acquireCommerceCommandLease, bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, + recordInitialStripeSendEvidence, registerCommerceCommand, }); diff --git a/functions/commerceCommandJournal.test.js b/functions/commerceCommandJournal.test.js index 5e179684..ec73ff7e 100644 --- a/functions/commerceCommandJournal.test.js +++ b/functions/commerceCommandJournal.test.js @@ -12,11 +12,13 @@ const { const { journalSchemaVersion, lifecycleSchemaVersion, + providerSendEvidenceSchemaVersion, CommerceCommandJournalError, acquireCommerceCommandLease, bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, + recordInitialStripeSendEvidence, registerCommerceCommand, } = require('./commerceCommandJournal'); @@ -40,6 +42,7 @@ const GOLDEN_IDEMPOTENCY_KEY_FINGERPRINT = '104e34d2323cce2f25e0d5f391f9ebc3b581 const RAW_CALLER = 'private-runner@example.test'; const HOSTILE_CANARY = 'private-runner@example.test/token?secret=do-not-copy'; const NOW_MILLIS = 1800000000123; +const PROVIDER_SEND_RETRY_WINDOW_MILLIS = 23 * 60 * 60 * 1000; function frozenRecord(entries = []) { const value = Object.create(null); @@ -277,6 +280,18 @@ function providerPlanPaths(input) { }); } +function providerSendPaths(input) { + const paths = providerPlanPaths(input); + return Object.freeze({ + ...paths, + providerSendPath: `${paths.providerPlanPath}/sendEvidence/first`, + providerSendAuditPath: documentPath( + 'auditEvents', + `commerce_provider_send_${paths.commandKeyHash}_0000000001`, + ), + }); +} + function captureError(callback) { return Promise.resolve() .then(callback) @@ -2221,6 +2236,569 @@ describe('provider plan fail-closed record and input boundary', () => { }); }); +describe('pre-POST Stripe send evidence and retry cutoff', () => { + let nowMillis; + let timestampNow; + + beforeEach(() => { + nowMillis = NOW_MILLIS; + timestampNow = jest.spyOn(Timestamp, 'now') + .mockImplementation(() => Timestamp.fromMillis(nowMillis)); + }); + + afterEach(() => jest.restoreAllMocks()); + + async function createBoundPlan(mock, overrides = {}) { + await registerCommerceCommand(validInput(mock.db, overrides)); + nowMillis += 1000; + await acquireCommerceCommandLease(leaseInput(mock.db, overrides)); + nowMillis += 1000; + await bindInitialStripeProviderPlan(providerPlanInput(mock.db, overrides)); + } + + async function recordEvidence(mock, overrides = {}) { + nowMillis += 1000; + return recordInitialStripeSendEvidence(providerPlanInput(mock.db, overrides)); + } + + test('atomically records one pre-send pair after all reads and preserves B2A/B2B/C1 bytes', async () => { + const mock = createMockDb(); + await createBoundPlan(mock); + const input = providerPlanInput(mock.db); + const paths = providerSendPaths(input); + const preserved = cloneStore(mock.store); + const callsBefore = timestampNow.mock.calls.length; + + const result = await recordEvidence(mock); + + expect(result).toEqual({ + journalSchemaVersion: 1, + providerPlanSchemaVersion: 1, + providerSendEvidenceSchemaVersion: 1, + outcome: 'send_permitted', + state: 'pre_send_recorded', + }); + expect(Object.isFrozen(result)).toBe(true); + expect(timestampNow).toHaveBeenCalledTimes(callsBefore + 2); + const transactionRun = mock.callbackRuns.at(-1); + expect(transactionRun.transaction.get).toHaveBeenCalledTimes(8); + expect(transactionRun.transaction.create).toHaveBeenCalledTimes(2); + expect(Math.max(...transactionRun.transaction.get.mock.invocationCallOrder)) + .toBeLessThan(Math.min(...transactionRun.transaction.create.mock.invocationCallOrder)); + expect(transactionRun.writes.map((write) => write.ref.path)).toEqual([ + paths.providerSendPath, + paths.providerSendAuditPath, + ]); + + const evidence = mock.store.get(paths.providerSendPath); + const audit = mock.store.get(paths.providerSendAuditPath); + expect(evidence).toEqual({ + providerSendEvidenceSchemaVersion: 1, + providerPlanSchemaVersion: 1, + commandIdentityVersion: 1, + commandKeyHash: paths.commandKeyHash, + providerAttempt: 1, + provider: 'stripe', + providerPlanCommitment: expect.stringMatching(/^[0-9a-f]{64}$/), + prePostFenceEpoch: 1, + prePostRecordedAt: expect.any(Timestamp), + automaticRetryDeadlineAt: expect.any(Timestamp), + }); + expect(audit).toEqual({ + providerSendAuditSchemaVersion: 1, + aggregateType: 'commerce_provider_send', + commandKeyHash: paths.commandKeyHash, + providerAttempt: 1, + eventType: 'provider_pre_send_recorded', + provider: 'stripe', + environment: 'test', + stripeMode: 'test', + providerOperation: PROVIDER_OPERATION, + providerPlanCommitment: evidence.providerPlanCommitment, + prePostFenceEpoch: 1, + automaticRetryDeadlineAt: expect.any(Timestamp), + occurredAt: expect.any(Timestamp), + }); + expect(evidence.prePostRecordedAt).toBe(audit.occurredAt); + expect(evidence.automaticRetryDeadlineAt).toBe(audit.automaticRetryDeadlineAt); + expect(evidence.prePostRecordedAt.toMillis()).toBe(NOW_MILLIS + 3000); + expect(evidence.automaticRetryDeadlineAt.toMillis() + - evidence.prePostRecordedAt.toMillis()).toBe(PROVIDER_SEND_RETRY_WINDOW_MILLIS); + expect(Object.isFrozen(evidence)).toBe(true); + expect(Object.isFrozen(audit)).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, evidence, audit }); + for (const raw of [ + STRIPE_ACCOUNT_ID, + HOSTILE_CANARY, + rawStripeKey, + LEASE_ID, + RAW_CALLER, + COMMAND_ID, + ]) expect(rendered).not.toContain(raw); + }); + + test('transaction retries reuse one trusted timestamp, deadline, and immutable pair', async () => { + const foundation = createMockDb(); + await createBoundPlan(foundation); + const mock = createMockDb([...foundation.store], { callbackRuns: 4 }); + timestampNow.mockClear(); + nowMillis += 1000; + + const result = await recordInitialStripeSendEvidence(providerPlanInput(mock.db)); + + expect(result.outcome).toBe('send_permitted'); + expect(timestampNow).toHaveBeenCalledTimes(2); + const retries = mock.callbackRuns.slice(-4); + expect(retries).toHaveLength(4); + const evidence = retries.map(({ writes }) => writes[0].value); + const audits = retries.map(({ writes }) => writes[1].value); + expect(new Set(evidence.map((value) => value.prePostRecordedAt))).toHaveProperty('size', 1); + expect(new Set(evidence.map((value) => value.automaticRetryDeadlineAt))) + .toHaveProperty('size', 1); + expect(new Set(audits.map((value) => value.occurredAt))).toHaveProperty('size', 1); + expect(new Set(audits.map((value) => value.automaticRetryDeadlineAt))) + .toHaveProperty('size', 1); + }); + + test('exact and canonically equivalent retries are read-only while plan changes conflict', async () => { + const mock = createMockDb(); + await createBoundPlan(mock); + await recordEvidence(mock); + const paths = providerSendPaths(providerPlanInput(mock.db)); + const before = cloneStore(mock.store); + nowMillis += 1; + const equivalentParameters = validProviderParameters([ + ['synthetic_reference', HOSTILE_CANARY], + ['currency', 'usd'], + ['amount_total', 2500], + ]); + + await expect(recordInitialStripeSendEvidence(providerPlanInput(mock.db))) + .resolves.toMatchObject({ outcome: 'send_permitted' }); + await expect(recordInitialStripeSendEvidence(providerPlanInput(mock.db, { + providerParameters: equivalentParameters, + }))).resolves.toMatchObject({ outcome: 'send_permitted' }); + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(mock.db, { + stripeAccountId: OTHER_STRIPE_ACCOUNT_ID, + })), + 'command_conflict', + [STRIPE_ACCOUNT_ID, OTHER_STRIPE_ACCOUNT_ID], + ); + expect(mock.store).toEqual(before); + expect(mock.store.get(paths.providerSendPath)).toBe(before.get(paths.providerSendPath)); + expect(mock.store.get(paths.providerSendAuditPath)) + .toBe(before.get(paths.providerSendAuditPath)); + for (const { writes } of mock.callbackRuns.slice(-3)) expect(writes).toEqual([]); + }); + + test('a self-consistent C1 replacement cannot reuse earlier send evidence', async () => { + const original = createMockDb(); + await createBoundPlan(original); + await recordEvidence(original); + const originalPaths = providerSendPaths(providerPlanInput(original.db)); + + nowMillis = NOW_MILLIS; + const replacement = createMockDb(); + const replacementParameters = validProviderParameters([ + ['amount_total', 5100], + ['currency', 'usd'], + ['synthetic_reference', 'replacement-plan'], + ]); + const replacementOverrides = { + stripeAccountId: OTHER_STRIPE_ACCOUNT_ID, + stripeApiVersion: '2024-06-20', + providerParameters: replacementParameters, + }; + await registerCommerceCommand(validInput(replacement.db)); + nowMillis += 1000; + await acquireCommerceCommandLease(leaseInput(replacement.db)); + nowMillis += 1000; + await bindInitialStripeProviderPlan(providerPlanInput( + replacement.db, + replacementOverrides, + )); + const replacementPaths = providerPlanPaths(providerPlanInput( + replacement.db, + replacementOverrides, + )); + + const replacedStore = cloneStore(original.store); + replacedStore.set( + originalPaths.providerPlanPath, + replacement.store.get(replacementPaths.providerPlanPath), + ); + replacedStore.set( + originalPaths.providerPlanAuditPath, + replacement.store.get(replacementPaths.providerPlanAuditPath), + ); + const mock = createMockDb([...replacedStore]); + const before = cloneStore(mock.store); + nowMillis = NOW_MILLIS + 3001; + + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(mock.db, replacementOverrides)), + 'journal_record_invalid', + [STRIPE_ACCOUNT_ID, OTHER_STRIPE_ACCOUNT_ID], + ); + expect(mock.store).toEqual(before); + expect(mock.callbackRuns.at(-1).writes).toEqual([]); + }); + + test('post-transaction lease expiry downgrades a committed marker before send', async () => { + const mock = createMockDb(); + await createBoundPlan(mock); + const paths = providerSendPaths(providerPlanInput(mock.db)); + const checkedAt = Timestamp.fromMillis(NOW_MILLIS + 3000); + const leaseExpiry = Timestamp.fromMillis(NOW_MILLIS + 61000); + timestampNow + .mockImplementationOnce(() => checkedAt) + .mockImplementationOnce(() => leaseExpiry); + + await expect(recordInitialStripeSendEvidence(providerPlanInput(mock.db))) + .resolves.toMatchObject({ outcome: 'reconciliation_required' }); + expect(mock.store.has(paths.providerSendPath)).toBe(true); + expect(mock.store.has(paths.providerSendAuditPath)).toBe(true); + }); + + test('post-transaction deadline equality and clock rollback downgrade read-only retries', async () => { + const mock = createMockDb(); + await createBoundPlan(mock); + await recordEvidence(mock); + const paths = providerSendPaths(providerPlanInput(mock.db)); + const evidence = mock.store.get(paths.providerSendPath); + const deadline = evidence.automaticRetryDeadlineAt; + const immediatelyBeforeDeadline = new Timestamp( + deadline._seconds, + deadline._nanoseconds - 1, + ); + const beforeMarker = new Timestamp( + evidence.prePostRecordedAt._seconds, + evidence.prePostRecordedAt._nanoseconds - 1, + ); + + nowMillis = deadline.toMillis() - 1000; + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: OTHER_LEASE_ID })); + const before = cloneStore(mock.store); + const takeoverInput = providerPlanInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + }); + + timestampNow + .mockImplementationOnce(() => immediatelyBeforeDeadline) + .mockImplementationOnce(() => deadline); + await expect(recordInitialStripeSendEvidence(takeoverInput)) + .resolves.toMatchObject({ outcome: 'reconciliation_required' }); + + timestampNow + .mockImplementationOnce(() => immediatelyBeforeDeadline) + .mockImplementationOnce(() => beforeMarker); + await expect(recordInitialStripeSendEvidence(takeoverInput)) + .resolves.toMatchObject({ outcome: 'reconciliation_required' }); + expect(mock.store).toEqual(before); + }); + + test('permits only strictly before the persisted 23-hour deadline at nanosecond precision', async () => { + const mock = createMockDb(); + await createBoundPlan(mock); + await recordEvidence(mock); + const paths = providerSendPaths(providerPlanInput(mock.db)); + const evidence = mock.store.get(paths.providerSendPath); + const deadline = evidence.automaticRetryDeadlineAt; + const first = evidence.prePostRecordedAt; + + timestampNow.mockImplementationOnce(() => new Timestamp( + first._seconds, + first._nanoseconds - 1, + )); + await expect(recordInitialStripeSendEvidence(providerPlanInput(mock.db))) + .resolves.toEqual({ + journalSchemaVersion: 1, + providerPlanSchemaVersion: 1, + providerSendEvidenceSchemaVersion: 1, + outcome: 'reconciliation_required', + state: 'provider_outcome_unknown', + }); + + nowMillis = deadline.toMillis() - 1000; + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: OTHER_LEASE_ID })); + const takeoverInput = providerPlanInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + }); + const immediatelyBeforeDeadline = new Timestamp( + deadline._seconds, + deadline._nanoseconds - 1, + ); + timestampNow + .mockImplementationOnce(() => immediatelyBeforeDeadline) + .mockImplementationOnce(() => immediatelyBeforeDeadline); + await expect(recordInitialStripeSendEvidence(takeoverInput)) + .resolves.toMatchObject({ outcome: 'send_permitted' }); + timestampNow.mockImplementationOnce(() => new Timestamp( + deadline._seconds, + deadline._nanoseconds, + )); + await expect(recordInitialStripeSendEvidence(takeoverInput)) + .resolves.toMatchObject({ outcome: 'reconciliation_required' }); + timestampNow.mockImplementationOnce(() => new Timestamp( + deadline._seconds, + deadline._nanoseconds + 1, + )); + await expect(recordInitialStripeSendEvidence(takeoverInput)) + .resolves.toMatchObject({ outcome: 'reconciliation_required' }); + expect(mock.store.get(paths.providerSendPath)).toBe(evidence); + }); + + test('missing or unreadable paired times require reconciliation without mutation', async () => { + const foundation = createMockDb(); + await createBoundPlan(foundation); + await recordEvidence(foundation); + const paths = providerSendPaths(providerPlanInput(foundation.db)); + + for (const mutate of [ + (record, audit) => { + const nextRecord = { ...record }; + const nextAudit = { ...audit }; + delete nextRecord.prePostRecordedAt; + delete nextAudit.occurredAt; + return [Object.freeze(nextRecord), Object.freeze(nextAudit)]; + }, + (record, audit) => [Object.freeze({ + ...record, + automaticRetryDeadlineAt: { _seconds: 1, _nanoseconds: 0 }, + }), audit], + (record, audit) => [record, Object.freeze({ + ...audit, + occurredAt: Timestamp.fromMillis(record.prePostRecordedAt.toMillis() + 1), + })], + ]) { + const seed = cloneStore(foundation.store); + const [record, audit] = mutate( + seed.get(paths.providerSendPath), + seed.get(paths.providerSendAuditPath), + ); + seed.set(paths.providerSendPath, record); + seed.set(paths.providerSendAuditPath, audit); + const mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + nowMillis += 1; + + await expect(recordInitialStripeSendEvidence(providerPlanInput(mock.db))) + .resolves.toMatchObject({ outcome: 'reconciliation_required' }); + expect(mock.store).toEqual(before); + expect(mock.callbackRuns.at(-1).writes).toEqual([]); + } + }); + + test('orphan, future, extra, and missing C1 partners fail closed without repair', async () => { + const active = createMockDb(); + await registerCommerceCommand(validInput(active.db)); + nowMillis += 1000; + await acquireCommerceCommandLease(leaseInput(active.db)); + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(active.db)), + 'journal_record_invalid', + ); + + const foundation = createMockDb(); + await createBoundPlan(foundation); + await recordEvidence(foundation); + const paths = providerSendPaths(providerPlanInput(foundation.db)); + const corruptions = [ + (seed) => seed.delete(paths.providerSendAuditPath), + (seed) => seed.set(paths.providerSendPath, Object.freeze({ + ...seed.get(paths.providerSendPath), + providerSendEvidenceSchemaVersion: 2, + })), + (seed) => seed.set(paths.providerSendAuditPath, Object.freeze({ + ...seed.get(paths.providerSendAuditPath), + unexpected: HOSTILE_CANARY, + })), + (seed) => seed.delete(paths.providerPlanAuditPath), + ]; + + for (const corrupt of corruptions) { + const seed = cloneStore(foundation.store); + corrupt(seed); + const mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + nowMillis += 1; + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(mock.db)), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + expect(mock.callbackRuns.at(-1).transaction.create).not.toHaveBeenCalled(); + } + }); + + test('accessors, proxies, and a missing originating lease audit fail without traps or repair', async () => { + const foundation = createMockDb(); + await createBoundPlan(foundation); + await recordEvidence(foundation); + const paths = providerSendPaths(providerPlanInput(foundation.db)); + + let getterCalls = 0; + const accessor = { ...foundation.store.get(paths.providerSendPath) }; + Object.defineProperty(accessor, 'provider', { + enumerable: true, + get() { + getterCalls += 1; + return HOSTILE_CANARY; + }, + }); + let mock = createMockDb([...foundation.store]); + mock.store.set(paths.providerSendPath, accessor); + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(mock.db)), + 'journal_record_invalid', + ); + expect(getterCalls).toBe(0); + + let trapCalls = 0; + const proxy = new Proxy(foundation.store.get(paths.providerSendAuditPath), { + ownKeys() { + trapCalls += 1; + return []; + }, + }); + mock = createMockDb([...foundation.store]); + mock.store.set(paths.providerSendAuditPath, proxy); + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(mock.db)), + 'journal_record_invalid', + ); + expect(trapCalls).toBe(0); + + nowMillis = NOW_MILLIS + 61000; + await acquireCommerceCommandLease(leaseInput(foundation.db, { leaseId: OTHER_LEASE_ID })); + const missingHistory = cloneStore(foundation.store); + missingHistory.delete(paths.lifecycleAuditPath); + mock = createMockDb([...missingHistory]); + const before = cloneStore(mock.store); + nowMillis += 1; + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + })), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + }); + + test('wrong holder, wrong fence, expired lease, and terminal state never reveal the marker', async () => { + const mock = createMockDb(); + await createBoundPlan(mock); + await recordEvidence(mock); + const before = cloneStore(mock.store); + nowMillis += 1; + + for (const change of [ + { leaseId: OTHER_LEASE_ID }, + { expectedFenceEpoch: 2 }, + { leaseId: OTHER_LEASE_ID, expectedFenceEpoch: 2 }, + ]) { + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(mock.db, change)), + 'lease_stale', + ); + } + nowMillis = NOW_MILLIS + 61000; + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(mock.db)), + 'lease_stale', + ); + expect(mock.store).toEqual(before); + + const terminal = createMockDb(); + nowMillis = NOW_MILLIS; + await createBoundPlan(terminal); + nowMillis += 1000; + await completeCommerceCommand(completionInput(terminal.db)); + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(terminal.db)), + 'lease_stale', + ); + }); + + test('rejects ambiguous outcomes and caller-selected send controls without advancing', async () => { + const mock = createMockDb(); + await createBoundPlan(mock); + await recordEvidence(mock); + const before = cloneStore(mock.store); + const paths = providerSendPaths(providerPlanInput(mock.db)); + const transactionCount = mock.db.runTransaction.mock.calls.length; + for (const extra of [ + { providerOutcome: 'timeout' }, + { providerOutcome: 'connection_loss' }, + { providerOutcome: 'stripe_5xx' }, + { providerHttpStatus: 500 }, + { providerAttempt: 2 }, + { httpMethod: 'POST' }, + { stripeIdempotencyKey: HOSTILE_CANARY }, + { retryWindowSeconds: 86400 }, + { automaticRetryDeadlineAt: Timestamp.fromMillis(NOW_MILLIS) }, + ]) { + await expectSafeError( + () => recordInitialStripeSendEvidence({ ...providerPlanInput(mock.db), ...extra }), + 'invalid_command_input', + ); + } + expect(mock.db.runTransaction).toHaveBeenCalledTimes(transactionCount); + expect(mock.store).toEqual(before); + expect(mock.store.has(`${paths.commandPath}/providerAttempts/0000000002`)).toBe(false); + }); + + test('commit failure leaves no half-pair and forged transaction output is redacted', async () => { + const foundation = createMockDb(); + await createBoundPlan(foundation); + const paths = providerSendPaths(providerPlanInput(foundation.db)); + const commitFailure = createMockDb([...foundation.store], { + rejectCommit: new Error(`${HOSTILE_CANARY}/${STRIPE_ACCOUNT_ID}`), + }); + const before = cloneStore(commitFailure.store); + nowMillis += 1; + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(commitFailure.db)), + 'journal_unavailable', + [STRIPE_ACCOUNT_ID], + ); + expect(commitFailure.store).toEqual(before); + expect(commitFailure.store.has(paths.providerSendPath)).toBe(false); + expect(commitFailure.store.has(paths.providerSendAuditPath)).toBe(false); + + const forged = createMockDb([...foundation.store], { + returnedValue: Object.freeze({ outcome: HOSTILE_CANARY }), + }); + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(forged.db)), + 'journal_unavailable', + ); + }); + + test('a retry-window timestamp overflow fails before Firestore', async () => { + const mock = createMockDb(); + timestampNow.mockImplementationOnce(() => new Timestamp(253402300790, 0)); + await expectSafeError( + () => recordInitialStripeSendEvidence(providerPlanInput(mock.db)), + 'journal_unavailable', + ); + expect(mock.db.runTransaction).not.toHaveBeenCalled(); + }); +}); + describe('exact existing identity and partner validation', () => { beforeEach(() => { jest.spyOn(Timestamp, 'now').mockImplementation(() => Timestamp.fromMillis(NOW_MILLIS + 5000)); @@ -2472,10 +3050,11 @@ describe('fixed input and infrastructure failure boundary', () => { }); describe('closed source and export boundary', () => { - test('exports only the unused journal APIs and no provider-send permission', () => { + test('exports only the unused journal APIs and the narrow pre-send evidence gate', () => { const api = require('./commerceCommandJournal'); expect(journalSchemaVersion).toBe(1); expect(lifecycleSchemaVersion).toBe(1); + expect(providerSendEvidenceSchemaVersion).toBe(1); expect(Object.keys(api).sort()).toEqual([ 'CommerceCommandJournalError', 'acquireCommerceCommandLease', @@ -2484,28 +3063,49 @@ describe('closed source and export boundary', () => { 'failCommerceCommand', 'journalSchemaVersion', 'lifecycleSchemaVersion', + 'providerSendEvidenceSchemaVersion', + 'recordInitialStripeSendEvidence', 'registerCommerceCommand', ]); expect(Object.isFrozen(api)).toBe(true); expect(Object.isFrozen(CommerceCommandJournalError)).toBe(true); expect(Object.isFrozen(CommerceCommandJournalError.prototype)).toBe(true); expect(Object.keys(api).join(' ')).not.toMatch( - /execute|send|renew|release|reconcile|replay|advance|result/i, + /execute|renew|release|reconcile|replay|advance|result|outcome/i, ); }); test('has only the approved local, Firestore, and Node utility dependencies', () => { const source = fs.readFileSync(path.join(__dirname, 'commerceCommandJournal.js'), 'utf8'); - const imports = [...source.matchAll(/require\('([^']+)'\)/g)].map((match) => match[1]); + const dynamicImportPattern = /\bimport\b/; + const ambientSideEffectPattern = /\b(?:fetch|XMLHttpRequest|console|logger|process)\b/; + const importMatches = [...source.matchAll(/\brequire\s*\(\s*(['"])([^'"]+)\1\s*\)/g)]; + const imports = importMatches.map((match) => match[2]); expect(imports.sort()).toEqual([ './commerceCommandIdentity', 'firebase-admin/firestore', 'node:crypto', 'node:util', ]); + expect((source.match(/\brequire\b/g) || [])).toHaveLength(importMatches.length); + expect(source).not.toMatch(dynamicImportPattern); + for (const probe of ['import("stripe")', "import ('axios')"]) { + expect(probe).toMatch(dynamicImportPattern); + } + expect(source).toMatch( + /^const \{ createHash \} = require\((?:'|")node:crypto(?:'|")\);$/m, + ); expect(source).not.toMatch(/require\(['"]\.\/index|createCheckout|stripeWebhook/); - expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|console|logger)\s*[.(]/); - expect(source).not.toMatch(/Math\.random|randomUUID|process\.env|node:fs|node:http|node:https|child_process/); + expect(source).not.toMatch(ambientSideEffectPattern); + for (const probe of [ + 'console?.log("unsafe")', + 'console["log"]("unsafe")', + 'process["env"]', + 'globalThis["fetch"]("unsafe")', + ]) expect(probe).toMatch(ambientSideEffectPattern); + expect(source).not.toMatch( + /Math\.random|randomBytes|randomFill|randomInt|randomUUID|getRandomValues|process\.env|node:fs|node:http|node:https|child_process/, + ); expect(source).toContain('maxAttempts: 10'); expect((source.match(/Timestamp\.now\(\)/g) || [])).toHaveLength(1); expect(source.indexOf('Timestamp.now()')).toBeLessThan(source.indexOf('.runTransaction(')); diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index 543e8ff8..90cd5e0b 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -401,6 +401,37 @@ describe('server-only operational collections', () => { }); }); + describe.each([ + ['anonymous', undefined], + ['member', { uid: 'member-1', role: 'member' }], + ['browser admin', { uid: 'admin-1', role: 'admin' }], + ])('checkoutRequests provider send evidence — %s', (_label, auth) => { + const existingPath = 'checkoutRequests/synthetic-existing/providerAttempts/0000000001/sendEvidence/first'; + const newPath = 'checkoutRequests/synthetic-new/providerAttempts/0000000001/sendEvidence/first'; + const evidence = { + providerSendEvidenceSchemaVersion: 1, + providerAttempt: 1, + provider: 'stripe', + providerPlanCommitment: 'c'.repeat(64), + prePostFenceEpoch: 1, + }; + + test('CANNOT read, create, update, delete, list, or collection-group-list send evidence', async () => { + await seed(existingPath, evidence); + const client = await db(auth); + + await assertFails(client.doc(existingPath).get()); + await assertFails(client.doc(newPath).set(evidence)); + await assertFails(client.doc(existingPath).update({ providerAttempt: 2 })); + await assertFails(client.doc(existingPath).delete()); + await assertFails( + client.collection('checkoutRequests/synthetic-existing/providerAttempts/0000000001/sendEvidence') + .get(), + ); + await assertFails(client.collectionGroup('sendEvidence').get()); + }); + }); + describe.each([ ['promoCodes/PROMO1', 'promoCodes/PROMO2', { discountPercent: 10 }], ['ratelimits/checkout_ip__1.2.3.4', 'ratelimits/checkout_ip__5.6.7.8', { count: 5 }],