diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 2c83473..a22e726 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -64,7 +64,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 complete in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157); race request/event-field adoption tracked in PAY-001B1 [#219](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/219); snapshots and remaining B/C/D work 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 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); PAY-002B2C3A pure closed reconciliation decision tracked in [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184); PAY-002B2C3B immutable candidate persistence tracked in [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206); PAY-002B2C3C fresh-lease later-attempt authorization tracked in [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226); PAY-002B2C4A immutable authorized attempt-2 plan tracked in [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232); PAY-002B2C4B attempt-2 pre-send evidence tracked in [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238); result/reconciliation/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); PAY-002B2C3A pure closed reconciliation decision tracked in [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184); PAY-002B2C3B immutable candidate persistence tracked in [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206); PAY-002B2C3C fresh-lease later-attempt authorization tracked in [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226); PAY-002B2C4A immutable authorized attempt-2 plan tracked in [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232); PAY-002B2C4B attempt-2 pre-send evidence tracked in [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238); PAY-002B2C4C1 pure unbound result-evidence policy tracked in [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246); trusted dispatch/result persistence/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 | @@ -639,7 +639,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/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). PAY-002B2C3A pure closed reconciliation-decision source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). PAY-002B2C3B immutable reconciliation-candidate persistence source/tests are tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206). PAY-002B2C3C fresh-lease later-attempt authorization source/tests are tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226). PAY-002B2C4A immutable authorized attempt-2 plan source/tests are tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232). PAY-002B2C4B attempt-2 pre-send source/tests are tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238). Attempt-2 result/reconciliation, 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). PAY-002B2C3A pure closed reconciliation-decision source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). PAY-002B2C3B immutable reconciliation-candidate persistence source/tests are tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206). PAY-002B2C3C fresh-lease later-attempt authorization source/tests are tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226). PAY-002B2C4A immutable authorized attempt-2 plan source/tests are tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232). PAY-002B2C4B attempt-2 pre-send source/tests are tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238). PAY-002B2C4C1 pure unbound result-evidence source/tests are tracked in live [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246). Trusted dispatch evidence, result persistence/reconciliation, runtime adoption, real legacy inventory/migration, and checkout sagas remain open. **Depends on:** CONFIG-001 and PAY-001 ### Current atomic boundary @@ -676,9 +676,11 @@ C4B preserves the existing version-1 plan-commitment bytes. A separate `provider After the transaction, a fresh trusted-time check returns fixed `send_permitted` / `pre_send_recorded` only while time has not rolled back and remains strictly before both the transaction-captured current lease expiry and stored deadline. Equality, later time, rollback, or unreadable paired time returns fixed `reconciliation_required` / `provider_outcome_unknown`; stale lease input fails as `lease_stale`. Changed input conflicts, and malformed/orphan/future/detached pairs fail closed. C4B never creates attempt `3`, calls Stripe, writes business state, replays a result, or enters an endpoint/index. Product/Price creation, Session expiry, refunds, and privileged provider operations require separate operation-specific boundaries. -C4B alone makes no complete runtime child ready. PAY-002C still waits for PAY-001B2 and RACE-001B. PAY-002D still waits for PAY-001C, MERCH-001B, and separate Product/Price boundaries. PAY-003B and later cancellation, refund, and provider-admin work retain their listed dependencies. +PAY-002B2C4C1 [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246) owns only the unused pure `classifyAuthorizedStripeCheckoutResultEvidence` policy. It accepts only a primitive, length-bounded, canonical JSON string encoding the exact 16-field reported attempt-2 Checkout Session assertion envelope. Deterministic reserialization must match the input. Its sole exact tuple returns fixed `unbound_result_candidate`; all 46 alternate enum values reconcile, and malformed/non-canonical serialized values or non-string objects throw one fixed redacted error before hostile property access. The input and output contain no raw Stripe object, Session ID/URL, money, timestamp, provider body, or personal data. -B2A/B2B/C1/C2/C3B/C3C/C4A/C4B add no TTL. Deleting paired plan, send, reconciliation, or authorization evidence could remove a durable duplicate barrier, so [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable 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, #182, #206, #226, #232, and #238 expand that same file. Hosted results remain synthetic source proof only. The current foundations, including C3A/C3B/C3C/C4A/C4B, are unused: they add no endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, website change, or officer behavior. +C4C1 has no dispatch or idempotency proof, raw-result projector, journal write, result persistence, provider call, endpoint/index import, or business transition. Its candidate still requires trusted dispatch evidence, persistence, and business validation. C4B/C4C1 alone make no complete runtime child ready. PAY-002C still waits for PAY-001B2, RACE-001B, and a trusted dispatch/result-persistence boundary. PAY-002D still waits for PAY-001C, MERCH-001B, separate Product/Price boundaries, and the same Checkout Session runtime result work. PAY-003B and later cancellation, refund, and provider-admin work retain their listed dependencies. + +B2A/B2B/C1/C2/C3B/C3C/C4A/C4B add no TTL. Deleting paired plan, send, reconciliation, or authorization evidence could remove a durable duplicate barrier, so [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable 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, #182, #206, #226, #232, and #238 expand that same file. Hosted results remain synthetic source proof only. The current foundations, including C3A/C3B/C3C/C4A/C4B/C4C1, 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 c3c4ade..5ae8f09 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -78,8 +78,9 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | PAY-002B2C3C | source/tests tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226); PAY-002B2C3B/#206; unused/no runtime or deploy | Under a fresh later-fence lease, validate the exact persisted C3B pair and matching closed transition before atomically creating one immutable attempt-2 authorization/audit pair. Command-bind the opaque transition commitment and attempt-2 key fingerprint. The fixed result still requires plan binding and cannot send, call Stripe, or change a business record. | Exact never-began and expired/unpaid mappings; unsafe-evidence/stale-lease/chronology denial; atomic pair, retry, lost-ack, orphan/malformed/future, redaction, browser-denial, and 24-way identical plus 12-v-12 conflict emulator tests. | | PAY-002B2C4A | source/tests tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232); PAY-002B2C3C/#226 and CI-001B3/#167; unused/no runtime or deploy | Under the exact C3C authorization and current active lease, atomically bind one immutable attempt-2 plan/audit pair. Version 1 preserves attempt-1 account, mode, API version, operation, endpoint, and canonical parameters. Only the internally derived attempt-2 key commitment, current fence/time, attempt number, and C3C provenance may differ. The fixed result requires separate C4B pre-send evidence. | Mock plus #167-hosted emulator equality/drift, lease-time, atomic-pair, exact-retry, later-lease observation, conflict, commit/lost-ack, orphan/malformed/future, complete prior-byte immutability, redaction, no-call/adoption, and browser-denial tests. | | PAY-002B2C4B | source/tests tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238); PAY-002B2C4A/#232 and CI-001B3/#167; unused/no runtime or deploy | Revalidate B1-C4A and the current lease before atomically recording attempt-2 Checkout Session pre-send evidence/audit. Preserve v1 bytes; bind every authorized C4A plan field and provenance with commitment version 2. Use one immutable 23-hour deadline and a second trusted-time check; fixed permission is retry-safety only. No Stripe call, result, attempt `3`, or business write. | Golden v1/v2 commitment tests; mock plus #167-hosted emulator transition/concurrency/conflict/later-lease/nanosecond/rollback/commit/lost-ack/malformed/provenance/immutability/redaction/no-call tests; explicit browser denial. | -| PAY-002C | proposed; PAY-001B2, PAY-002B2C4B/#238, 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-002B2C4B/#238 for Checkout Sessions, separate operation-specific Product/Price plan and pre-send gates, MERCH-001B | Convert merchandise/catalog creation to persistence-first order/hold plus deterministic Stripe keys. | One order/Session/catalog object on retry; failure recovery tests. | +| PAY-002B2C4C1 | source/tests tracked in live [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246); PAY-002B2C4B/#238; #184 vocabulary precedent; unused/no runtime or deploy | Accept only one primitive, length-bounded canonical JSON encoding of the reported 16-field attempt-2 Checkout Session assertion envelope. The sole exact tuple returns fixed `unbound_result_candidate`; all 46 alternate closed enum values reconcile. No dispatch/idempotency/result proof, raw Stripe parsing, journal, persistence, provider call, business transition, or replay. | Exact positive/46-alternate matrix; timeout/provider/paid/expired mismatches reconcile; non-string/proxy/accessor/re-branded-native/private-slot rejection; invalid/oversized/reordered/duplicate/missing/extra/alternate-encoding JSON rejection; pre/post-load prototype-pollution redaction; frozen singleton/error; static zero-adoption and removed-trust-vocabulary tests. | +| PAY-002C | proposed; PAY-001B2, PAY-002B2C4C1/#246, later trusted dispatch evidence, reviewed result projector/provider validation, and persistence, RACE-001B | Convert race checkout to persistence-first record/hold plus deterministic Stripe Session saga. C4C1 supplies only an unbound canonical serialized reported shape. | One record/Session on retry at every failure boundary; dispatch/result/business evidence survives lost acknowledgements. | +| PAY-002D | proposed; PAY-001C, PAY-002B2C4C1/#246 for Checkout Sessions, later trusted dispatch evidence, reviewed result projector/provider validation, and persistence, separate operation-specific Product/Price plan/pre-send/result gates, MERCH-001B | Convert merchandise/catalog creation to persistence-first order/hold plus deterministic Stripe keys. C4C1 supplies only an unbound canonical serialized reported Checkout Session shape. | One order/Session/catalog object on retry; failure recovery tests; no result accepted from C4C1 alone. | ## Webhooks, capacity, inventory, and cancellation diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 74a60ba..62411e9 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -127,6 +127,8 @@ Exit gate: **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. PAY-002B2C3A source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). That unused pure policy accepts only closed exact attempt-1 evidence and returns fixed frozen existing/candidate/reconcile classifications. Only complete trusted proof that execution never began, or an exact verified expired/unpaid Session with verified expiry and an eligible new logical generation, can be a candidate; a candidate is not authorization. Exact open/successful attempts stop advancement, while every transport/provider/key/reference/search/state ambiguity requires reconciliation. PAY-002B2C3B source/tests are tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206). The unused journal persists only an exact C3A candidate in one immutable record/audit pair after complete C1/C2 validation, the stored 23-hour cutoff, and current lease expiry. Exact retry is read-only; changed valid evidence conflicts; malformed/orphan/future/foundation-mismatched evidence fails closed. The fixed result still requires separate authorization. PAY-002B2C3C source/tests are tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226). Under a fresh later-fence lease, the unused journal accepts only the matching closed transition for an exact persisted safe candidate and creates one immutable attempt-2 authorization/audit pair. Its command-bound transition commitment is not business-state proof, and its fixed result still requires plan binding; no send or provider call is authorized. PAY-002B2C4A source/tests are tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232). Under the exact C3C authorization and current active lease, the unused journal creates one immutable attempt-2 plan/audit pair. Version 1 preserves the attempt-1 account, mode, API version, operation, endpoint, and canonical parameters; its fixed result still requires separate C4B pre-send evidence. PAY-002B2C4B source/tests are tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238). The unused journal binds the complete authorized C4A plan with a separate version-2 commitment, creates one attempt-2 pre-send marker/audit with a fixed 23-hour deadline, and returns fixed `send_permitted` only after a second trusted-time check remains before both the captured lease expiry and deadline. It makes no Stripe call and stores no result. 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, #182, #206, #226, #232, and #238 expand. Attempt-2 result/reconciliation and every runtime adopter remain open. Real migration, capacity, inventory, deployment, provider configuration, and production operations remain open. +PAY-002B2C4C1 source/tests are tracked in live [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246). The unused pure policy accepts only one length-bounded canonical JSON encoding of the 16-field attempt-2 Checkout Session result-evidence shape. Parsing creates the ordinary record inside the module, and deterministic reserialization must match exactly. Its sole match is `unbound_result_candidate`; it has no dispatch/idempotency/result proof and still requires trusted dispatch evidence, a reviewed raw-result projector/provider validator, persistence, and business validation. Attempt-2 result persistence/reconciliation and every runtime adopter remain open. + ### Phase 3 — Complete lifecycle and customer safety **Issues:** PAY-004, PAY-005, MAIL-001, DATA-001, DATA-002, MERCH-002 @@ -214,6 +216,7 @@ The first tranche deliberately combines one urgent safety repair with the most c 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. AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) merged at `a880177`, proving the exact verified-email second gate for role-based Firestore access while keeping UID-bound safe profile recovery. AUTH-001C [#209](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/209) merged at `85751dac`, proving the same second gate at current Functions role consumers. AUTH-001D1 [#213](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/213) tracks the matching fail-closed browser role projection; browser state remains guidance rather than authority. Firebase/website deployment is unproven, and verification-mirror refresh, revocation/audit, authoritative membership, capabilities/MFA/recent-auth, safe roster export, and legacy-sync retirement keep the AUTH-001 parent 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 complete in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-001B1 [#219](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/219) adds the compatible active-field/volunteer-tier browser projection plus exact bounded request, opaque-ID/callback, and selected event-answer validation in the current race endpoint; PAY-001B2 still owns immutable field/price/waiver snapshots, compatibility, and protected staging proof. 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-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) owns only the unused pure closed reconciliation decision. PAY-002B2C3B [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206) owns only immutable candidate evidence/audit persistence after the C2 cutoff and lease expiry; its result is not authorization. PAY-002B2C3C [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) owns only the fresh-lease immutable attempt-2 authorization/audit pair. PAY-002B2C4A [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) owns only the unused immutable attempt-2 plan/audit pair under the exact C3C authorization and current lease; version 1 preserves the attempt-1 provider plan fields and still requires separate C4B pre-send evidence. PAY-002B2C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) owns only the unused attempt-2 pre-send marker/audit, complete authorized-plan commitment version 2, fixed 23-hour deadline, and post-transaction lease/deadline clock gate for Checkout Session creation. PAY-001C/D still require endpoint adoption; PAY-002C/D must add persistence-first sagas and trusted business-transition integration; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and none of #169/#173/#182/#184/#206/#226/#232/#238 deploys or configures Firebase/Stripe. + PAY-002B2C4C1 [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246) owns only the unused pure unbound result-evidence classifier. It adds no journal, raw-result projector, Stripe call, dispatch proof, result persistence, or business transition. PAY-001C/D still require endpoint adoption; PAY-002C/D must add persistence-first sagas, trusted dispatch/result handling, and business-transition integration; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and none of #169/#173/#182/#184/#206/#226/#232/#238/#246 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/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 62127db..4792938 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -462,6 +462,35 @@ Stop if the emulator names a non-demo project, any provider credential is presen This is source and synthetic-test evidence only. Record these states separately: source changed; tests passed; code merged; website published; `runmprc.com` verified; Firebase deployed; Stripe/provider configured; production data changed; production behavior verified. #238 does not itself prove any deployment, provider setting, live behavior, or officer action. PAY-002C/D/PAY-003B still need trusted business, result, reconciliation, and runtime work. Never test this boundary with a real registration, order, Stripe object, customer, or member. +### Unbound Stripe attempt-2 result evidence — SOURCE ONLY, UNUSED + +PAY-002B2C4C1 [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246) adds one pure policy for a primitive, length-bounded, canonical JSON string encoding a reported 16-field attempt-2 Checkout Session assertion envelope. Deterministic reserialization must match exactly; parsing creates the ordinary record inside the module, so object provenance is never guessed from mutable prototypes. No endpoint or Functions index imports it. It does not parse a Stripe response, call Stripe, read or write Firestore, create a journal record, store a Session ID/URL, or change a registration, order, hold, or payment state. + +```mermaid +flowchart LR + R["Canonical serialized direct-response assertion"] --> P{"Exact closed C4C1 shape?"} + B["Reported C4B pre-send binding"] -. "Assertion only" .-> P + P -- "No" --> Q["reconciliation_required"] + P -- "Yes" --> U["unbound_result_candidate"] + U --> F["Future trusted dispatch evidence\nprojector/provider validation\npersistence and business validation"] +``` + +Text alternative: a matching canonical serialized envelope remains unbound and must stop until future trusted dispatch evidence, projector/provider validation, persistence, and business validation are available. + +Run only with Node 20 and repository lockfiles. These tests use synthetic closed enums and no Firebase emulator or provider credential: + +```bash +npm --prefix functions run test:run -- --runInBand commerceProviderResult.test.js +npm --prefix functions run lint +npm --prefix functions run test:run -- --runInBand +``` + +Expected result: the sole exact canonical serialized tuple returns the frozen `unbound_result_candidate`; all 46 single-enum alternatives return the same frozen `reconciliation_required`. Non-string values fail before property access. Empty, oversized, invalid, whitespace-bearing, reordered, duplicate, missing/extra, alternate-encoding, wrong-type, future, and Unicode-confusable serialized values produce one fixed redacted error. Re-branded native objects, private-slot class instances, and `Object.prototype` pollution present before module load or added later also fail without hostile invocation. Static checks prove no dispatch/idempotency/result binding and no journal, Stripe, Firebase, network, persistence, index, or runtime edge. + +Stop if any test needs a real Stripe object, key, Session ID/URL, money, member/business data, Firebase project, network call, or production service. Stop if the result says accepted, sent, succeeded, paid, executable, persistable, or business-valid; if any production file imports the policy; or if a diagram links C4B directly to a trusted provider result. + +This is source and synthetic-test evidence only. Record source changed, tests passed, code merged, website published, `runmprc.com` verified, Firebase deployed, Stripe configured, production data changed, and production behavior verified as separate states. #246 proves none of the external/live states and creates no officer task. A later reviewed child must add trusted dispatch evidence, raw-result projection/provider validation, persistence, and business attachment before any result may advance. + ### Target payment integration suite CI/staging must eventually include: diff --git a/SECURITY.md b/SECURITY.md index 4720b12..c01e59a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -99,6 +99,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Immediate resolved-result containment part of RISK-020 | PAY-005A2 is tracked in live [#204](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/204). Both existing refund Functions build one immutable expectation before Stripe from a bounded stored PaymentIntent, positive original cents, exact `usd`, action, and admitted partial cents. Only a plain succeeded Refund with a bounded ID, exact primitive PaymentIntent/currency binding, and permitted amount returns a frozen minimal projection. Partial cents must match exactly; explicit full accepts the positive full-remaining amount up to the original total. Invalid stored targets stop before Stripe. Thrown, malformed, mismatched, pending, action-required, failed, cancelled, unknown, or post-provider local-write failures return the same fixed ambiguous message and never return local success. A rejected Stripe result causes no local success write attempt; a later local-write error leaves the record state unknown. The audit uses only the validated refund ID and actual returned cents. | The provider call precedes response validation and the local write, so an unconfirmed provider result may already have moved money. A local write that reports an error may have committed or may remain unchanged, especially when its acknowledgement is lost. Do not retry either outcome; reconcile it. This slice adds no durable pre-send operation/ID, idempotency key, remaining-balance retrieval, cumulative amount, concurrency fence, webhook-finalized state, capability/MFA/recent-auth, approved policy, provider rehearsal, Firebase deployment, or live proof. Complete PAY-002B2C3B/C, PAY-005A, PAY-003B, AUTH-003, reconciliation, and protected release before authorizing production refunds. | | 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; PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) supplies only an unused pure closed evidence classifier; PAY-002B2C3B [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206) supplies only unused immutable candidate evidence/audit persistence after the cutoff and expired-lease gate; PAY-002B2C3C [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) supplies only an unused fresh-lease authorization record/audit for one later attempt; PAY-002B2C4A [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) supplies only the unused immutable attempt-2 plan/audit under that exact authorization and a current active lease. PAY-002B2C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) supplies only the unused attempt-2 pre-send pair, a complete authorized-plan commitment version 2, fixed 23-hour deadline, and post-transaction clock gate. C4B's fixed `send_permitted` is retry-safety classification only: it makes no Stripe request, records no result, and grants no caller or business authority; 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. PAY-002B2C3A is tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) for the pure exact attempt-1 reconciliation evidence policy. PAY-002B2C3B is tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206) for the deterministic candidate record/audit pair bound to complete C1/C2 commitments and an observed expired lease. PAY-002B2C3C is tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) for the deterministic later-attempt authorization/audit pair bound to that exact candidate, a matching closed transition commitment, and a fresh active lease. PAY-002B2C4A is tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) for the deterministic attempt-2 plan/audit pair bound to the exact C3C authorization and current active lease. PAY-002B2C4B is tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) for its atomic pre-send pair, explicit version-2 commitment over every C4A field and authorization provenance, immutable 23-hour deadline, and second trusted-time check. | Hashes and commitments are pseudonymous server-only equality evidence—not anonymization, caller authorization, configured-account proof, current business-state proof, durable response replay, or provider-execution proof. No endpoint imports these libraries. 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`. C3A returns only fixed `existing_attempt_found`, `new_attempt_candidate`, or `reconciliation_required` results from closed enum evidence. C3B can persist only an exact candidate at or after both the stored cutoff and current lease expiry; early/active candidates write nothing, exact retry is read-only, and changed/malformed/orphan/future evidence fails closed. C3C accepts only the two complete safe tuples under a later current lease; its opaque transition commitment is command-bound but is not proof of current business state. Its fixed `requires_plan_binding` result creates no attempt-2 plan, send marker, or provider call. C4A requires that exact authorization plus a current active lease and preserves attempt-1 account/mode/API/operation/endpoint/parameters in its immutable attempt-2 plan. Its fixed `requires_pre_send_evidence` result creates no send marker or provider call. C4B revalidates the complete chain and current lease, then returns fixed `send_permitted` only before both the captured lease expiry and stored deadline; equality, later time, rollback, or unreadable time reconciles. It creates no provider call, attempt `3`, business write, or result. Only the Checkout Session parts of PAY-002C/D may adopt C4A/C4B. Product/Price creation, Session expiry, refunds in PAY-005, and other provider operations need separate operation-specific plan, pre-send, result, and reconciliation boundaries. All runtime adoption also requires trusted business transactions, endpoint schemas, and failure-boundary tests. Deployment, provider configuration, and live behavior remain unproven. | +| Foundation only for attempt-2 result-shape parts of RISK-003 and RISK-020 | PAY-002B2C4C1 is tracked in live [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246). The unused pure `classifyAuthorizedStripeCheckoutResultEvidence` API accepts only a primitive, length-bounded, canonical JSON string encoding the reported exact 16-field closed assertion envelope for attempt `2` and `checkout_session_create`; deterministic reserialization must match. Parsing creates the ordinary record inside the module. Non-string objects fail before property access, and a hard-coded Node 20 baseline rejects `Object.prototype` pollution present before module load or introduced later. One complete reported direct-response shape returns fixed frozen `unbound_result_candidate`; all 46 alternate enum values reconcile, and malformed, hostile, oversized, or non-canonical serialized values throw one fixed redacted error. The API parses no Stripe object and echoes no supplied value. | The candidate is deliberately unbound. C4B proves only pre-send evidence, not dispatch. #246 contains no dispatch/idempotency/result binding, projector, journal write, result persistence, Session ID/URL, network/provider call, runtime import, or business transition. A later runtime boundary must use trusted dispatch evidence, a reviewed raw-result projector/provider validator, recoverable server-only persistence under approved retention, and business validation before any Session can be returned or state can advance. Source/tests/merge do not prove Firebase deployment, Stripe configuration, production data, website publication, or live behavior. | | 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 and RISK-018 | PAY-001A is complete in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). PAY-001B1 is tracked in live [#219](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/219): the website sends only the active field set and omits a volunteer tier; the race callable then adopts an exact bounded root/runner parser before Firestore and matches answers to the selected bounded server event/volunteer field schema before rate limits, role/capacity work, token creation, writes, or Stripe. Unknown fields, malformed prototypes/accessors/proxies, unsafe Unicode, oversized values, invalid participant/volunteer/tier combinations, non-literal waiver acceptance, malformed selected definitions, duplicate keys/options, missing/unknown/wrong-type answers, and invalid select choices return one fixed non-identifying failure. Opaque event IDs remain byte-identical and are encoded before callback URL use. | The browser projection is compatibility hygiene, never authority. This is source/test evidence, not deployment or production protection. PAY-001B2 still must persist immutable event-field, price, and waiver snapshots and prove compatibility in protected staging. PAY-001C/D must adopt exact merchandise and lookup/refund/admin schemas. Request IDs, persistence-first records, capacity holds, provider idempotency, provider configuration, and live behavior remain open. Safe validation/log primitives do not replace approved privacy purpose, retention, access, monitoring, or provider controls. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 4100f09..7d19a86 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -237,7 +237,7 @@ Launch with quantity `1` unless cart and multi-line inventory semantics are expl } ``` -Within PAY-002B2C2's conservative stored safe-send window, returning the same request reuses the exact B2C1 plan, parameters, and Stripe idempotency key. After that window—or when first-send time is unknown—the server must stop automatic POST retries. PAY-002B2C3A can classify only already-verified closed evidence; C3B must persist that proof, C3C must separately authorize/version a later attempt, C4A must bind its immutable plan, and C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) must record separate pre-send evidence before returning a new Session. If a Session is proven expired, a versioned new attempt can be created against the same business record only under an explicit verified transition. +Within PAY-002B2C2's conservative stored safe-send window, returning the same request reuses the exact B2C1 plan, parameters, and Stripe idempotency key. After that window—or when first-send time is unknown—the server must stop automatic POST retries. PAY-002B2C3A can classify only already-verified closed evidence; C3B must persist that proof, C3C must separately authorize/version a later attempt, C4A must bind its immutable plan, and C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) must record separate pre-send evidence. C4C1 [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246) can classify only a canonical serialized reported result shape as unbound; a later trusted dispatch observer, result projector/persistence boundary, and business transaction are still required before returning a Session. If a Session is proven expired, a versioned new attempt can be created against the same business record only under an explicit verified transition. ### Pure command identity and provider-key contract (PAY-002B1) @@ -462,16 +462,37 @@ After the transaction, a fresh trusted time is captured without re-reading lifec The fixed permitted output contains only schema versions, `send_permitted`, and `pre_send_recorded`. The fixed ambiguous output contains only schema versions, `reconciliation_required`, and `provider_outcome_unknown`. These are retry-safety classifications, not caller authorization, current business-state proof, Stripe-account control, proof that a request began, or a provider result. The pair stores no raw account, parameters, key, caller, UUID, transition value, provider/business/member ID, money, URL, response, secret, or personal data. -#238 is limited to `checkout_session_create` with `POST /v1/checkout/sessions`. It cannot create attempt `3`, call Stripe/network code, write a registration/order/hold, replay a result, or enter an endpoint/Functions index. Attempt-2 result and reconciliation work remains separate. Product/Price creation, Session expiry, refunds, and privileged provider operations still need their own operation-specific plan, pre-send, result, and reconciliation boundaries. +#238 is limited to `checkout_session_create` with `POST /v1/checkout/sessions`. It cannot create attempt `3`, call Stripe/network code, write a registration/order/hold, replay a result, or enter an endpoint/Functions index. PAY-002B2C4C1 [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246) owns only the separate unbound result-evidence policy below. Product/Price creation, Session expiry, refunds, and privileged provider operations still need their own operation-specific plan, pre-send, result, and reconciliation boundaries. This is source and synthetic-test design evidence only. Source changed, tests passed, code merged, Firebase deployed, Stripe configured, production data changed, website published, `runmprc.com` verified, and live behavior verified are separate states. #238 does not by itself prove any deployment, provider setting, production behavior, or officer action. +### Unbound attempt-2 result-evidence policy (PAY-002B2C4C1) + +PAY-002B2C4C1 source/tests are tracked in live [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246). The unused pure `classifyAuthorizedStripeCheckoutResultEvidence` API accepts only a primitive, length-bounded, canonical JSON string encoding the reported flat 16-field version-1 assertion envelope for provider `stripe`, attempt `2`, and operation `checkout_session_create`. Deterministic reserialization must match the input exactly. This creates the ordinary record inside the module instead of guessing object provenance from mutable JavaScript prototypes. It parses no Stripe object, makes no network call, reads no journal, and writes nothing. + +```mermaid +flowchart LR + Report["Canonical serialized direct-response evidence\nfrom a future adapter"] --> Policy{"Exact closed C4C1 shape?"} + Pre["Reported C4B pre-send binding"] -. "Assertion only" .-> Policy + Policy -- "No or ambiguous" --> Reconcile["reconciliation_required"] + Policy -- "Yes" --> Candidate["unbound_result_candidate"] + Candidate --> Stop["Stop: require trusted dispatch evidence,\npersistence, and business validation"] +``` + +Text alternative: a canonical serialized response and C4B-binding assertion may enter the pure policy; the sole matching shape is still unbound and stops before trusted dispatch evidence, persistence, and business validation. + +The sole unbound tuple reports an exact plan and C4B pre-send binding, complete direct-response evidence, response category `accepted`, an open Checkout Session shape, exact environment and parameter projections, a bounded opaque reference, a validated Checkout redirect, unpaid state, and future expiry. Every one of the 46 alternate closed enum values returns the same fixed `reconciliation_required` result. Non-string objects—including proxies, re-branded native objects, and private-slot class instances—fail before property access. Empty, oversized, invalid, whitespace-bearing, reordered, duplicate-key, missing/extra-key, alternate-encoding, future, or otherwise non-canonical serialized input throws one fixed redacted error. A hard-coded Node 20 `Object.prototype` baseline also rejects pollution present before module load or introduced later without invoking hostile accessors. + +The fixed positive output is only `unbound_result_candidate` with `requires_dispatch_evidence_persistence_and_business_validation`. The policy has no dispatch, idempotency, or result binding. It does not prove that a POST began, that the expected key was used, that Stripe returned anything, that a Session reference or URL may be stored or returned, or that payment/business state may advance. A later runtime-adjacent boundary must observe trusted dispatch evidence, validate/project the raw result, persist a recoverable reference without a Checkout URL, and attach the result through the relevant business transaction. + +#246 adds no journal record, Firestore/Rules change, endpoint/index import, Stripe SDK call, result projector, persistence, response replay, business write, package, workflow, provider setting, migration, production-data action, deployment, website change, or officer task. Source, tests, merge, Firebase deployment, Stripe configuration, production behavior, and website publication remain separate states. + ## 7. Persistence-first checkout saga External Stripe calls cannot be part of a Firestore transaction. Use this sequence: 1. Validate the request and read server-controlled catalog/event state. -2. Derive the PAY-002B1 command key and payload fingerprint; PAY-002B2A must register and compare them, PAY-002B2B must provide the current fence, PAY-002B2C1 must bind the immutable initial plan, B2C2 must record/freshness-check pre-send evidence, C3A/C3B/C3C must classify, persist, and authorize verified reconciliation evidence, and C4A/C4B must separately bind and pre-send-gate any later Checkout Session generation. +2. Derive the PAY-002B1 command key and payload fingerprint; PAY-002B2A must register and compare them, PAY-002B2B must provide the current fence, PAY-002B2C1 must bind the immutable initial plan, B2C2 must record/freshness-check pre-send evidence, C3A/C3B/C3C must classify, persist, and authorize verified reconciliation evidence, C4A/C4B must separately bind and pre-send-gate any later Checkout Session generation, and C4C1 may classify only an unbound reported result shape. 3. In one Firestore transaction: - Reuse a matching prior request or reject a conflicting reuse. - Lock/read the event capacity counter or SKU variant. @@ -484,7 +505,7 @@ External Stripe calls cannot be part of a Firestore transaction. Use this sequen 6. Store Session ID, URL, expiry, and attempt state. 7. Return the URL. 8. If Stripe definitively rejects creation, run a compensating transaction that marks the attempt failed and releases the hold once. -9. If the function loses its response after Stripe creates the Session, PAY-002B2C2 retries the exact B2C1 plan/key only inside its stored safe-send window. After the deadline—or when first-send time is unknown—it stops POSTing. C3A alone cannot retrieve or trust provider facts; C3B/C3C must persist verified evidence and authorize a later generation, C4A must bind its plan, and C4B/#238 must separately record fresh pre-send evidence before completing step 6. +9. If the function loses its response after Stripe creates the Session, PAY-002B2C2 retries the exact B2C1 plan/key only inside its stored safe-send window. After the deadline—or when first-send time is unknown—it stops POSTing. C3A alone cannot retrieve or trust provider facts; C3B/C3C must persist verified evidence and authorize a later generation, C4A must bind its plan, and C4B/#238 must separately record fresh pre-send evidence. C4C1/#246 can classify a reported result shape only; step 6 still requires a later trusted dispatch observer, result persistence, and business validation. Create the local business record before calling Stripe. That lets a very fast webhook resolve metadata directly and eliminates the current record-not-found race. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index d6c2097..b48b3ae 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -371,6 +371,21 @@ The marker time is captured once before the transaction; its deadline is exactly C4B stores no raw identity, account, parameters, key, transition value, money, URL, response, secret, or personal data. It is limited to `checkout_session_create` at `POST /v1/checkout/sessions`; it cannot create attempt `3`, call Stripe, write a business record, or enter an endpoint/index. Product/Price creation, Session expiry, refunds, and privileged provider actions require separate operation-specific plan, pre-send, result, and reconciliation boundaries. +PAY-002B2C4C1 source/tests are tracked in live [#246](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/246). The unused pure `classifyAuthorizedStripeCheckoutResultEvidence` API accepts only a primitive, length-bounded, canonical JSON string encoding the reported 16-field attempt-2 Checkout Session assertion envelope. Parsing creates the ordinary record inside the module; non-string objects fail before property access. Its sole matching output is `unbound_result_candidate` with `requires_dispatch_evidence_persistence_and_business_validation`; every other valid closed tuple reconciles and every malformed or non-canonical serialized value fails with one fixed redacted error. + +```mermaid +flowchart LR + Report["Canonical serialized direct-response evidence"] --> Policy{"Exact C4C1 shape?"} + Bind["Reported C4B pre-send binding"] -. "Assertion only" .-> Policy + Policy -- "No" --> Reconcile["reconciliation_required"] + Policy -- "Yes" --> Candidate["unbound_result_candidate"] + Candidate --> Future["Later trusted dispatch evidence\nresult persistence\nbusiness validation"] +``` + +Text alternative: canonical serialized reported evidence may produce only an unbound shape candidate, which must stop until later trusted dispatch evidence, result persistence, and business validation exist. + +C4C1 creates no persisted collection or audit row. It has no dispatch or idempotency proof, parses no raw Stripe object, stores no Session reference or URL, and has no journal, endpoint, index, provider, business-state, Rules, or deployment edge. C4B does not point directly to a trusted result: both the reported response and pre-send binding are untrusted assertions until a future adapter and runtime boundary validate them. + 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, #182, #206, #226, #232, and #238 expand that same suite. These are synthetic source checks only. Source change, tests, merge, Firebase deployment, Stripe configuration, production data, website publication, `runmprc.com` verification, and live behavior remain separate states. The current journal source remains unused and makes no endpoint, provider, production, website, or officer change. ## 7. Business invariants @@ -521,7 +536,7 @@ The ingress verifies method, raw payload, signature, and secret. Relevant event Stripe and Firestore cannot share a distributed transaction. Checkout and refunds are therefore explicit sagas: - A Firestore transaction protects local uniqueness and scarce-resource counters. -- A stable PAY-002B1 Stripe key identifies one logical provider generation. A lease takeover or HTTP retry is not a new generation. PAY-002B2C1 binds the immutable attempt-1 plan; B2C2 may retry only that exact plan/key inside its conservative stored send window; after that window or with unknown send time, it stops automatic POST. C3A classifies only closed verified evidence, C3B persists only the exact candidate after the stored cutoff and current lease expiry, C3C separately authorizes a later generation, C4A binds its immutable plan, and C4B/#238 records the separate attempt-2 pre-send pair and clock gate. None proves a provider result. +- A stable PAY-002B1 Stripe key identifies one logical provider generation. A lease takeover or HTTP retry is not a new generation. PAY-002B2C1 binds the immutable attempt-1 plan; B2C2 may retry only that exact plan/key inside its conservative stored send window; after that window or with unknown send time, it stops automatic POST. C3A classifies only closed verified evidence, C3B persists only the exact candidate after the stored cutoff and current lease expiry, C3C separately authorizes a later generation, C4A binds its immutable plan, and C4B/#238 records the separate attempt-2 pre-send pair and clock gate. C4C1/#246 can classify one canonical serialized reported shape only as unbound. None proves a provider result, dispatch, persistence, or business validity. - The business record stores the saga step and external ID. - A compensating transaction releases a reservation after a known failure. - A webhook advances successful external state. diff --git a/functions/commerceProviderResult.js b/functions/commerceProviderResult.js new file mode 100644 index 0000000..cb4cb75 --- /dev/null +++ b/functions/commerceProviderResult.js @@ -0,0 +1,466 @@ +const OBJECT_PROTOTYPE = Object.prototype; +const SAFE_CREATE = Object.create; +const SAFE_DEFINE_PROPERTY = Object.defineProperty; +const SAFE_FREEZE = Object.freeze; +const SAFE_FUNCTION_TO_STRING = Function.prototype.call.bind(Function.prototype.toString); +const SAFE_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const SAFE_GET_PROTOTYPE_OF = Object.getPrototypeOf; +const SAFE_HAS_OWN = Object.hasOwn; +const SAFE_JSON_PARSE = JSON.parse; +const SAFE_JSON_STRINGIFY = JSON.stringify; +const SAFE_OWN_KEYS = Reflect.ownKeys; +const SAFE_SET_HAS = Function.prototype.call.bind(Set.prototype.has); +const MAX_SERIALIZED_EVIDENCE_LENGTH = 1024; + +const EXPECTED_OBJECT_PROTOTYPE = SAFE_FREEZE([ + SAFE_FREEZE({ + key: 'constructor', + kind: 'data', + name: 'Object', + length: 1, + source: 'function Object() { [native code] }', + }), + SAFE_FREEZE({ + key: '__defineGetter__', + kind: 'data', + name: '__defineGetter__', + length: 2, + source: 'function __defineGetter__() { [native code] }', + }), + SAFE_FREEZE({ + key: '__defineSetter__', + kind: 'data', + name: '__defineSetter__', + length: 2, + source: 'function __defineSetter__() { [native code] }', + }), + SAFE_FREEZE({ + key: 'hasOwnProperty', + kind: 'data', + name: 'hasOwnProperty', + length: 1, + source: 'function hasOwnProperty() { [native code] }', + }), + SAFE_FREEZE({ + key: '__lookupGetter__', + kind: 'data', + name: '__lookupGetter__', + length: 1, + source: 'function __lookupGetter__() { [native code] }', + }), + SAFE_FREEZE({ + key: '__lookupSetter__', + kind: 'data', + name: '__lookupSetter__', + length: 1, + source: 'function __lookupSetter__() { [native code] }', + }), + SAFE_FREEZE({ + key: 'isPrototypeOf', + kind: 'data', + name: 'isPrototypeOf', + length: 1, + source: 'function isPrototypeOf() { [native code] }', + }), + SAFE_FREEZE({ + key: 'propertyIsEnumerable', + kind: 'data', + name: 'propertyIsEnumerable', + length: 1, + source: 'function propertyIsEnumerable() { [native code] }', + }), + SAFE_FREEZE({ + key: 'toString', + kind: 'data', + name: 'toString', + length: 0, + source: 'function toString() { [native code] }', + }), + SAFE_FREEZE({ + key: 'valueOf', + kind: 'data', + name: 'valueOf', + length: 0, + source: 'function valueOf() { [native code] }', + }), + SAFE_FREEZE({ + key: '__proto__', + kind: 'accessor', + getName: 'get __proto__', + getLength: 0, + getSource: 'function get __proto__() { [native code] }', + setName: 'set __proto__', + setLength: 1, + setSource: 'function set __proto__() { [native code] }', + }), + SAFE_FREEZE({ + key: 'toLocaleString', + kind: 'data', + name: 'toLocaleString', + length: 0, + source: 'function toLocaleString() { [native code] }', + }), +]); + +function matchesPristineDescriptor(descriptor, expected) { + if (!descriptor + || !SAFE_HAS_OWN(descriptor, 'enumerable') + || !SAFE_HAS_OWN(descriptor, 'configurable') + || descriptor.enumerable !== false + || descriptor.configurable !== true) { + return false; + } + + if (expected.kind === 'data') { + return SAFE_HAS_OWN(descriptor, 'value') + && SAFE_HAS_OWN(descriptor, 'writable') + && !SAFE_HAS_OWN(descriptor, 'get') + && !SAFE_HAS_OWN(descriptor, 'set') + && descriptor.writable === true + && typeof descriptor.value === 'function' + && SAFE_FUNCTION_TO_STRING(descriptor.value) === expected.source + && (expected.key !== 'constructor' || descriptor.value === Object); + } + + return !SAFE_HAS_OWN(descriptor, 'value') + && !SAFE_HAS_OWN(descriptor, 'writable') + && SAFE_HAS_OWN(descriptor, 'get') + && SAFE_HAS_OWN(descriptor, 'set') + && typeof descriptor.get === 'function' + && SAFE_FUNCTION_TO_STRING(descriptor.get) === expected.getSource + && typeof descriptor.set === 'function' + && SAFE_FUNCTION_TO_STRING(descriptor.set) === expected.setSource; +} + +function snapshotDescriptor(descriptor) { + if (!descriptor) return null; + const hasConfigurable = SAFE_HAS_OWN(descriptor, 'configurable'); + const hasEnumerable = SAFE_HAS_OWN(descriptor, 'enumerable'); + const hasValue = SAFE_HAS_OWN(descriptor, 'value'); + const hasWritable = SAFE_HAS_OWN(descriptor, 'writable'); + const hasGet = SAFE_HAS_OWN(descriptor, 'get'); + const hasSet = SAFE_HAS_OWN(descriptor, 'set'); + return SAFE_FREEZE({ + hasConfigurable, + configurable: hasConfigurable ? descriptor.configurable : undefined, + hasEnumerable, + enumerable: hasEnumerable ? descriptor.enumerable : undefined, + hasValue, + value: hasValue ? descriptor.value : undefined, + hasWritable, + writable: hasWritable ? descriptor.writable : undefined, + hasGet, + get: hasGet ? descriptor.get : undefined, + hasSet, + set: hasSet ? descriptor.set : undefined, + }); +} + +function sameDescriptor(descriptor, snapshot) { + if (descriptor === undefined || snapshot === null) return false; + + const hasConfigurable = SAFE_HAS_OWN(descriptor, 'configurable'); + const hasEnumerable = SAFE_HAS_OWN(descriptor, 'enumerable'); + const hasValue = SAFE_HAS_OWN(descriptor, 'value'); + const hasWritable = SAFE_HAS_OWN(descriptor, 'writable'); + const hasGet = SAFE_HAS_OWN(descriptor, 'get'); + const hasSet = SAFE_HAS_OWN(descriptor, 'set'); + + return hasConfigurable === snapshot.hasConfigurable + && (!hasConfigurable || descriptor.configurable === snapshot.configurable) + && hasEnumerable === snapshot.hasEnumerable + && (!hasEnumerable || descriptor.enumerable === snapshot.enumerable) + && hasValue === snapshot.hasValue + && (!hasValue || descriptor.value === snapshot.value) + && hasWritable === snapshot.hasWritable + && (!hasWritable || descriptor.writable === snapshot.writable) + && hasGet === snapshot.hasGet + && (!hasGet || descriptor.get === snapshot.get) + && hasSet === snapshot.hasSet + && (!hasSet || descriptor.set === snapshot.set); +} + +const OBJECT_PROTOTYPE_SNAPSHOT = SAFE_FREEZE(EXPECTED_OBJECT_PROTOTYPE.map( + (expected) => SAFE_FREEZE({ + key: expected.key, + descriptor: snapshotDescriptor( + SAFE_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, expected.key), + ), + }), +)); + +let OBJECT_PROTOTYPE_WAS_PRISTINE_AT_LOAD = false; +try { + const keysAtLoad = SAFE_OWN_KEYS(OBJECT_PROTOTYPE); + OBJECT_PROTOTYPE_WAS_PRISTINE_AT_LOAD = keysAtLoad.length === OBJECT_PROTOTYPE_SNAPSHOT.length + && keysAtLoad.every((key, index) => key === OBJECT_PROTOTYPE_SNAPSHOT[index].key) + && EXPECTED_OBJECT_PROTOTYPE.every((expected) => matchesPristineDescriptor( + SAFE_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, expected.key), + expected, + )); +} catch { + OBJECT_PROTOTYPE_WAS_PRISTINE_AT_LOAD = false; +} + +function hasPristineObjectPrototype() { + if (!OBJECT_PROTOTYPE_WAS_PRISTINE_AT_LOAD) return false; + + let keys; + try { + keys = SAFE_OWN_KEYS(OBJECT_PROTOTYPE); + } catch { + return false; + } + if (keys.length !== OBJECT_PROTOTYPE_SNAPSHOT.length) return false; + + for (let index = 0; index < OBJECT_PROTOTYPE_SNAPSHOT.length; index += 1) { + const snapshot = OBJECT_PROTOTYPE_SNAPSHOT[index]; + if (keys[index] !== snapshot.key) return false; + + let descriptor; + try { + descriptor = SAFE_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, snapshot.key); + } catch { + return false; + } + if (!sameDescriptor(descriptor, snapshot.descriptor)) return false; + } + return true; +} + +const providerResultPolicySchemaVersion = 1; +const PROVIDER_RESULT_ERROR_MESSAGE = 'Commerce provider result evidence is invalid.'; + +const EXPECTED_FIELDS = Object.freeze([ + 'providerResultPolicySchemaVersion', + 'provider', + 'providerAttempt', + 'providerOperation', + 'planBinding', + 'sendEvidenceBinding', + 'evidenceSource', + 'evidenceCompleteness', + 'responseEvidence', + 'providerObjectEvidence', + 'environmentEvidence', + 'parameterEvidence', + 'resultReferenceEvidence', + 'redirectEvidence', + 'paymentEvidence', + 'expiryEvidence', +]); + +const EXACT_BINDING_VALUES = Object.freeze(['exact', 'missing', 'conflicting']); +const PROVIDER_RESULT_POLICY_ENUMS = Object.freeze({ + planBinding: EXACT_BINDING_VALUES, + sendEvidenceBinding: EXACT_BINDING_VALUES, + evidenceSource: Object.freeze([ + 'reported_direct_response', + 'unverified_or_missing', + 'conflicting', + ]), + evidenceCompleteness: Object.freeze(['complete', 'partial', 'conflicting']), + responseEvidence: Object.freeze([ + 'none', + 'accepted', + 'timeout', + 'connection_lost', + 'conflict', + 'external_dependency_failure', + 'rate_limited', + 'server_failure', + 'other_failure', + 'unknown', + 'conflicting', + ]), + providerObjectEvidence: Object.freeze([ + 'none', + 'exact_open', + 'exact_complete', + 'exact_expired', + 'exact_unknown', + 'missing_reference', + 'not_found', + 'conflicting', + ]), + environmentEvidence: EXACT_BINDING_VALUES, + parameterEvidence: EXACT_BINDING_VALUES, + resultReferenceEvidence: Object.freeze([ + 'bounded_opaque', + 'missing', + 'invalid', + 'conflicting', + ]), + redirectEvidence: Object.freeze([ + 'validated_checkout', + 'missing', + 'invalid', + 'conflicting', + ]), + paymentEvidence: Object.freeze([ + 'none', + 'unpaid', + 'paid', + 'no_payment_required', + 'processing', + 'unknown', + 'conflicting', + ]), + expiryEvidence: Object.freeze([ + 'valid_future', + 'missing', + 'invalid', + 'expired', + 'unknown', + 'conflicting', + ]), +}); + +const ENUM_SETS = Object.freeze(Object.fromEntries( + Object.entries(PROVIDER_RESULT_POLICY_ENUMS) + .map(([field, values]) => [field, new Set(values)]), +)); +const ENUM_ENTRIES = Object.freeze(Object.entries(ENUM_SETS)); + +const RESULTS = Object.freeze({ + unboundResultCandidate: Object.freeze({ + providerResultPolicySchemaVersion, + classification: 'unbound_result_candidate', + state: 'requires_dispatch_evidence_persistence_and_business_validation', + }), + reconciliationRequired: Object.freeze({ + providerResultPolicySchemaVersion, + classification: 'reconciliation_required', + state: 'requires_reconciliation', + }), +}); + +function createFrozenDataDescriptor(value) { + const descriptor = SAFE_CREATE(null); + descriptor.value = value; + descriptor.enumerable = false; + descriptor.writable = false; + descriptor.configurable = false; + return SAFE_FREEZE(descriptor); +} + +const ERROR_NAME_DESCRIPTOR = createFrozenDataDescriptor('CommerceProviderResultError'); +const ERROR_MESSAGE_DESCRIPTOR = createFrozenDataDescriptor(PROVIDER_RESULT_ERROR_MESSAGE); +const ERROR_CODE_DESCRIPTOR = createFrozenDataDescriptor('invalid_provider_result_evidence'); + +class CommerceProviderResultError extends Error { + constructor() { + super(PROVIDER_RESULT_ERROR_MESSAGE); + SAFE_DEFINE_PROPERTY(this, 'name', ERROR_NAME_DESCRIPTOR); + SAFE_DEFINE_PROPERTY(this, 'message', ERROR_MESSAGE_DESCRIPTOR); + SAFE_DEFINE_PROPERTY(this, 'code', ERROR_CODE_DESCRIPTOR); + Error.captureStackTrace?.(this, CommerceProviderResultError); + SAFE_FREEZE(this); + } +} + +function fail() { + throw new CommerceProviderResultError(); +} + +function readExactEvidence(serializedValue) { + if (!hasPristineObjectPrototype() + || typeof serializedValue !== 'string' + || serializedValue.length === 0 + || serializedValue.length > MAX_SERIALIZED_EVIDENCE_LENGTH) { + fail(); + } + + let value; + let prototype; + let keys; + try { + value = SAFE_JSON_PARSE(serializedValue); + prototype = SAFE_GET_PROTOTYPE_OF(value); + keys = SAFE_OWN_KEYS(value); + } catch { + fail(); + } + + if (value === null + || typeof value !== 'object' + || prototype !== OBJECT_PROTOTYPE + || keys.length !== EXPECTED_FIELDS.length) { + fail(); + } + + for (let index = 0; index < EXPECTED_FIELDS.length; index += 1) { + if (keys[index] !== EXPECTED_FIELDS[index]) fail(); + } + + let canonicalValue; + try { + canonicalValue = SAFE_JSON_STRINGIFY(value, EXPECTED_FIELDS); + } catch { + fail(); + } + if (canonicalValue !== serializedValue) fail(); + + const evidence = SAFE_CREATE(null); + for (const field of EXPECTED_FIELDS) { + let descriptor; + try { + descriptor = SAFE_GET_OWN_PROPERTY_DESCRIPTOR(value, field); + } catch { + fail(); + } + if (!descriptor + || !SAFE_HAS_OWN(descriptor, 'enumerable') + || descriptor.enumerable !== true + || !SAFE_HAS_OWN(descriptor, 'value') + || SAFE_HAS_OWN(descriptor, 'get') + || SAFE_HAS_OWN(descriptor, 'set')) { + fail(); + } + evidence[field] = descriptor.value; + } + + if (evidence.providerResultPolicySchemaVersion !== providerResultPolicySchemaVersion + || evidence.provider !== 'stripe' + || evidence.providerAttempt !== 2 + || evidence.providerOperation !== 'checkout_session_create') { + fail(); + } + + for (const [field, allowedValues] of ENUM_ENTRIES) { + if (!SAFE_SET_HAS(allowedValues, evidence[field])) fail(); + } + + return evidence; +} + +function isUnboundResultCandidate(evidence) { + return evidence.planBinding === 'exact' + && evidence.sendEvidenceBinding === 'exact' + && evidence.evidenceSource === 'reported_direct_response' + && evidence.evidenceCompleteness === 'complete' + && evidence.responseEvidence === 'accepted' + && evidence.providerObjectEvidence === 'exact_open' + && evidence.environmentEvidence === 'exact' + && evidence.parameterEvidence === 'exact' + && evidence.resultReferenceEvidence === 'bounded_opaque' + && evidence.redirectEvidence === 'validated_checkout' + && evidence.paymentEvidence === 'unpaid' + && evidence.expiryEvidence === 'valid_future'; +} + +function classifyAuthorizedStripeCheckoutResultEvidence(input) { + const evidence = readExactEvidence(input); + if (isUnboundResultCandidate(evidence)) return RESULTS.unboundResultCandidate; + return RESULTS.reconciliationRequired; +} + +SAFE_FREEZE(CommerceProviderResultError.prototype); +SAFE_FREEZE(CommerceProviderResultError); + +module.exports = Object.freeze({ + providerResultPolicySchemaVersion, + PROVIDER_RESULT_POLICY_ENUMS, + CommerceProviderResultError, + classifyAuthorizedStripeCheckoutResultEvidence, +}); diff --git a/functions/commerceProviderResult.test.js b/functions/commerceProviderResult.test.js new file mode 100644 index 0000000..86d9c60 --- /dev/null +++ b/functions/commerceProviderResult.test.js @@ -0,0 +1,861 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const vm = require('node:vm'); +const { inspect } = require('node:util'); + +const { + providerResultPolicySchemaVersion, + PROVIDER_RESULT_POLICY_ENUMS, + CommerceProviderResultError, + classifyAuthorizedStripeCheckoutResultEvidence, +} = require('./commerceProviderResult'); + +const HOSTILE_CANARY = 'private-runner@example.test/token?secret=do-not-copy'; + +function evidence(overrides = {}) { + return { + providerResultPolicySchemaVersion: 1, + provider: 'stripe', + providerAttempt: 2, + providerOperation: 'checkout_session_create', + planBinding: 'exact', + sendEvidenceBinding: 'exact', + evidenceSource: 'reported_direct_response', + evidenceCompleteness: 'complete', + responseEvidence: 'accepted', + providerObjectEvidence: 'exact_open', + environmentEvidence: 'exact', + parameterEvidence: 'exact', + resultReferenceEvidence: 'bounded_opaque', + redirectEvidence: 'validated_checkout', + paymentEvidence: 'unpaid', + expiryEvidence: 'valid_future', + ...overrides, + }; +} + +function serializedEvidence(overrides = {}) { + return JSON.stringify(evidence(overrides)); +} + +function rebrandWithEvidence(root) { + Object.setPrototypeOf(root, Object.prototype); + for (const [field, value] of Object.entries(evidence())) { + Object.defineProperty(root, field, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + return root; +} + +function runPreloadPrototypeProbe(mutationSource) { + const modulePath = path.join(__dirname, 'commerceProviderResult.js'); + const script = ` + 'use strict'; + const mutationKeys = [ + 'temporaryPreloadPollution', + 'hasOwnProperty', + '__proto__', + 'value', + 'writable', + 'get', + 'set', + ]; + const originals = new Map(mutationKeys.map((key) => [ + key, + Object.getOwnPropertyDescriptor(Object.prototype, key), + ])); + const originalProto = originals.get('__proto__'); + let getterCalls = 0; + let api; + try { + ${mutationSource} + api = require(${JSON.stringify(modulePath)}); + } finally { + for (const key of originals.keys()) { + delete Object.prototype[key]; + } + for (const [key, descriptor] of originals) { + if (descriptor) { + Object.defineProperty(Object.prototype, key, descriptor); + } + } + } + let error; + try { + api.classifyAuthorizedStripeCheckoutResultEvidence( + ${JSON.stringify(serializedEvidence())}, + ); + } catch (caught) { + error = caught; + } + if (!error + || error.name !== 'CommerceProviderResultError' + || error.code !== 'invalid_provider_result_evidence' + || error.message !== 'Commerce provider result evidence is invalid.' + || getterCalls !== 0) { + process.exit(2); + } + process.stdout.write('fixed-redacted-rejection'); + `; + return spawnSync(process.execPath, ['-e', script], { + cwd: __dirname, + encoding: 'utf8', + }); +} + +function captureError(callback) { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected callback to throw'); +} + +function expectSafeError(callback, rawValue = HOSTILE_CANARY) { + const error = captureError(callback); + expect(error).toBeInstanceOf(CommerceProviderResultError); + expect(error).toMatchObject({ + name: 'CommerceProviderResultError', + code: 'invalid_provider_result_evidence', + message: 'Commerce provider result evidence is invalid.', + }); + expect(Object.isFrozen(error)).toBe(true); + + for (const [field, expectedValue] of Object.entries({ + name: 'CommerceProviderResultError', + code: 'invalid_provider_result_evidence', + message: 'Commerce provider result evidence is invalid.', + })) { + expect(Object.getOwnPropertyDescriptor(error, field)).toEqual({ + value: expectedValue, + enumerable: false, + writable: false, + configurable: false, + }); + } + + const rendered = [ + error.message, + String(error), + JSON.stringify(error), + inspect(error), + error.stack, + ].join('\n'); + if (rawValue) expect(rendered).not.toContain(String(rawValue)); + expect(rendered).not.toContain(HOSTILE_CANARY); + return error; +} + +function expectResult(input, classification, state) { + const result = classifyAuthorizedStripeCheckoutResultEvidence(JSON.stringify(input)); + expect(result).toEqual({ + providerResultPolicySchemaVersion: 1, + classification, + state, + }); + expect(Object.keys(result)).toEqual([ + 'providerResultPolicySchemaVersion', + 'classification', + 'state', + ]); + expect(Object.isFrozen(result)).toBe(true); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + return result; +} + +describe('unbound attempt-2 Checkout Session result evidence policy', () => { + test('exports one frozen versioned API and exact frozen enum catalog', () => { + const api = require('./commerceProviderResult'); + expect(providerResultPolicySchemaVersion).toBe(1); + expect(Object.isFrozen(api)).toBe(true); + expect(PROVIDER_RESULT_POLICY_ENUMS).toEqual({ + planBinding: ['exact', 'missing', 'conflicting'], + sendEvidenceBinding: ['exact', 'missing', 'conflicting'], + evidenceSource: [ + 'reported_direct_response', + 'unverified_or_missing', + 'conflicting', + ], + evidenceCompleteness: ['complete', 'partial', 'conflicting'], + responseEvidence: [ + 'none', + 'accepted', + 'timeout', + 'connection_lost', + 'conflict', + 'external_dependency_failure', + 'rate_limited', + 'server_failure', + 'other_failure', + 'unknown', + 'conflicting', + ], + providerObjectEvidence: [ + 'none', + 'exact_open', + 'exact_complete', + 'exact_expired', + 'exact_unknown', + 'missing_reference', + 'not_found', + 'conflicting', + ], + environmentEvidence: ['exact', 'missing', 'conflicting'], + parameterEvidence: ['exact', 'missing', 'conflicting'], + resultReferenceEvidence: ['bounded_opaque', 'missing', 'invalid', 'conflicting'], + redirectEvidence: ['validated_checkout', 'missing', 'invalid', 'conflicting'], + paymentEvidence: [ + 'none', + 'unpaid', + 'paid', + 'no_payment_required', + 'processing', + 'unknown', + 'conflicting', + ], + expiryEvidence: [ + 'valid_future', + 'missing', + 'invalid', + 'expired', + 'unknown', + 'conflicting', + ], + }); + expect(Object.isFrozen(PROVIDER_RESULT_POLICY_ENUMS)).toBe(true); + for (const values of Object.values(PROVIDER_RESULT_POLICY_ENUMS)) { + expect(Object.isFrozen(values)).toBe(true); + } + expect(Object.isFrozen(CommerceProviderResultError)).toBe(true); + expect(Object.isFrozen(CommerceProviderResultError.prototype)).toBe(true); + }); + + test('classifies only the sole complete reported response shape as an unbound candidate', () => { + const input = Object.freeze(evidence()); + const before = JSON.stringify(input); + const first = expectResult( + input, + 'unbound_result_candidate', + 'requires_dispatch_evidence_persistence_and_business_validation', + ); + const second = classifyAuthorizedStripeCheckoutResultEvidence(serializedEvidence()); + expect(second).toBe(first); + expect(JSON.stringify(input)).toBe(before); + }); + + test('all 46 single-enum alternates return one reconciliation singleton', () => { + const baseline = evidence(); + const results = new Set(); + let alternates = 0; + + for (const [field, values] of Object.entries(PROVIDER_RESULT_POLICY_ENUMS)) { + for (const value of values) { + if (value === baseline[field]) continue; + alternates += 1; + results.add(expectResult( + { ...baseline, [field]: value }, + 'reconciliation_required', + 'requires_reconciliation', + )); + } + } + + expect(alternates).toBe(46); + expect(results.size).toBe(1); + }); + + test('explicit failure, ambiguity, and non-open provider states always reconcile', () => { + const unsafe = [ + { planBinding: 'missing' }, + { sendEvidenceBinding: 'conflicting' }, + { evidenceSource: 'unverified_or_missing' }, + { evidenceCompleteness: 'partial' }, + { responseEvidence: 'timeout' }, + { responseEvidence: 'connection_lost' }, + { responseEvidence: 'conflict' }, + { responseEvidence: 'external_dependency_failure' }, + { responseEvidence: 'rate_limited' }, + { responseEvidence: 'server_failure' }, + { responseEvidence: 'other_failure' }, + { responseEvidence: 'unknown' }, + { providerObjectEvidence: 'exact_complete' }, + { providerObjectEvidence: 'exact_expired' }, + { providerObjectEvidence: 'exact_unknown' }, + { providerObjectEvidence: 'missing_reference' }, + { providerObjectEvidence: 'not_found' }, + { environmentEvidence: 'missing' }, + { environmentEvidence: 'conflicting' }, + { parameterEvidence: 'missing' }, + { parameterEvidence: 'conflicting' }, + { resultReferenceEvidence: 'invalid' }, + { redirectEvidence: 'invalid' }, + { paymentEvidence: 'paid' }, + { paymentEvidence: 'no_payment_required' }, + { paymentEvidence: 'processing' }, + { expiryEvidence: 'missing' }, + { expiryEvidence: 'invalid' }, + { expiryEvidence: 'expired' }, + { expiryEvidence: 'unknown' }, + ]; + + const results = unsafe.map((override) => expectResult( + evidence(override), + 'reconciliation_required', + 'requires_reconciliation', + )); + expect(new Set(results).size).toBe(1); + }); + + test('a classification exposes no authorization, replay, or business control', () => { + const results = [ + classifyAuthorizedStripeCheckoutResultEvidence(serializedEvidence()), + classifyAuthorizedStripeCheckoutResultEvidence(serializedEvidence({ + responseEvidence: 'timeout', + })), + ]; + for (const result of results) { + for (const forbidden of [ + 'send', + 'dispatch', + 'execute', + 'persist', + 'success', + 'providerAttempt', + 'sessionId', + 'url', + 'amount', + 'key', + 'commitment', + 'response', + 'businessTransition', + ]) { + expect(result).not.toHaveProperty(forbidden); + } + } + }); +}); + +describe('hostile and malformed result evidence fails with one fixed error', () => { + test.each([ + undefined, + null, + true, + 1, + 'stripe', + [], + new Date(0), + new Number(1), + new String('stripe'), + new Boolean(false), + ])('rejects a non-plain root case %#', (input) => { + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(input)); + }); + + test.each([ + ['Date', () => new Date(0)], + ['boxed Number', () => new Number(1)], + ['boxed Boolean', () => new Boolean(false)], + ['boxed BigInt', () => Object(1n)], + ['boxed Symbol', () => Object(Symbol('provider-result'))], + ['Map', () => new Map()], + ['Set', () => new Set()], + ['WeakMap', () => new WeakMap()], + ['WeakSet', () => new WeakSet()], + ['Promise', () => Promise.resolve()], + ['URL', () => new URL('https://example.test/provider-result')], + ['URLSearchParams', () => new URLSearchParams('state=unbound')], + ['Headers', () => new Headers()], + ['AbortController', () => new AbortController()], + ['WebAssembly.Module', () => new WebAssembly.Module( + new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]), + )], + ['WebAssembly.Memory', () => new WebAssembly.Memory({ initial: 1 })], + ['Intl.DateTimeFormat', () => new Intl.DateTimeFormat('en')], + ['Intl.NumberFormat', () => new Intl.NumberFormat('en')], + ['private-slot class instance', () => vm.runInNewContext( + 'new (class PrivateEvidence { #privateState = 1 })()', + )], + ])('rejects a re-branded %s with the exact 16 own data fields', (_name, makeRoot) => { + const input = rebrandWithEvidence(makeRoot()); + expect(Object.getPrototypeOf(input)).toBe(Object.prototype); + expect(Reflect.ownKeys(input)).toEqual(Object.keys(evidence())); + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(input)); + }); + + test('requires one bounded canonical JSON encoding', () => { + const canonical = serializedEvidence(); + const missing = evidence(); + delete missing.provider; + const { provider, ...rest } = evidence(); + const reordered = JSON.stringify({ provider, ...rest }); + const malformed = [ + '', + ' ', + '{', + 'null', + 'true', + '1', + '[]', + '{}', + JSON.stringify(evidence(), null, 2), + reordered, + JSON.stringify(missing), + `${canonical.slice(0, -1)},"extra":"${HOSTILE_CANARY}"}`, + canonical.replace( + '"provider":"stripe"', + '"provider":"stripe","provider":"stripe"', + ), + canonical.replace('"stripe"', '"\\u0073tripe"'), + canonical.replace(':1,', ':1.0,'), + 'x'.repeat(1025), + ]; + + expect(new Set(malformed).size).toBe(malformed.length); + for (const input of malformed) { + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(input)); + } + }); + + test('rejects every serialized missing field and every extra string field', () => { + for (const field of Object.keys(evidence())) { + const input = evidence(); + delete input[field]; + expectSafeError( + () => classifyAuthorizedStripeCheckoutResultEvidence(JSON.stringify(input)), + ); + } + + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence({ + ...evidence(), + [HOSTILE_CANARY]: 'extra', + })); + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(JSON.stringify({ + ...evidence(), + [HOSTILE_CANARY]: 'extra', + }))); + + for (const field of ['__proto__', 'constructor', 'prototype']) { + const input = evidence(); + Object.defineProperty(input, field, { + value: HOSTILE_CANARY, + enumerable: true, + configurable: true, + }); + expectSafeError( + () => classifyAuthorizedStripeCheckoutResultEvidence(JSON.stringify(input)), + ); + } + }); + + test('rejects inherited, custom, null, and polluted prototypes', () => { + const inherited = Object.create(evidence()); + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(inherited)); + + const custom = Object.assign(Object.create({ inherited: HOSTILE_CANARY }), evidence()); + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(custom)); + + const nullPrototype = Object.assign(Object.create(null), evidence()); + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(nullPrototype)); + + Object.defineProperty(Object.prototype, 'temporaryProviderResultPollution', { + value: HOSTILE_CANARY, + enumerable: true, + configurable: true, + }); + try { + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence( + serializedEvidence(), + )); + } finally { + delete Object.prototype.temporaryProviderResultPollution; + } + }); + + test('rejects shadowed enumerable and added non-enumerable Object.prototype fields', () => { + const input = serializedEvidence(); + let shadowedError; + Object.defineProperty(Object.prototype, 'provider', { + value: HOSTILE_CANARY, + enumerable: true, + configurable: true, + }); + try { + shadowedError = captureError( + () => classifyAuthorizedStripeCheckoutResultEvidence(input), + ); + } finally { + delete Object.prototype.provider; + } + expectSafeError(() => { throw shadowedError; }); + + let nonEnumerableError; + Object.defineProperty(Object.prototype, 'temporaryHiddenProviderResultPollution', { + value: HOSTILE_CANARY, + enumerable: false, + configurable: true, + }); + try { + nonEnumerableError = captureError( + () => classifyAuthorizedStripeCheckoutResultEvidence(input), + ); + } finally { + delete Object.prototype.temporaryHiddenProviderResultPollution; + } + expectSafeError(() => { throw nonEnumerableError; }); + }); + + test('rejects changed Object.prototype.hasOwnProperty without using it', () => { + const original = Object.getOwnPropertyDescriptor(Object.prototype, 'hasOwnProperty'); + const input = serializedEvidence(); + + let replacedError; + Object.defineProperty(Object.prototype, 'hasOwnProperty', { + value: null, + enumerable: false, + writable: true, + configurable: true, + }); + try { + replacedError = captureError( + () => classifyAuthorizedStripeCheckoutResultEvidence(input), + ); + } finally { + Object.defineProperty(Object.prototype, 'hasOwnProperty', original); + } + expectSafeError(() => { throw replacedError; }); + + let getterCalls = 0; + let accessorError; + Object.defineProperty(Object.prototype, 'hasOwnProperty', { + enumerable: false, + configurable: true, + get() { + getterCalls += 1; + throw new Error(HOSTILE_CANARY); + }, + }); + try { + accessorError = captureError( + () => classifyAuthorizedStripeCheckoutResultEvidence(input), + ); + } finally { + Object.defineProperty(Object.prototype, 'hasOwnProperty', original); + } + expect(getterCalls).toBe(0); + expectSafeError(() => { throw accessorError; }); + }); + + test.each(['value', 'writable', 'get', 'set'])( + 'rejects post-load Object.prototype.%s accessors without invoking them', + (field) => { + const original = Object.getOwnPropertyDescriptor(Object.prototype, field); + const input = serializedEvidence(); + let getterCalls = 0; + let error; + Object.defineProperty(Object.prototype, field, { + enumerable: false, + configurable: true, + get() { + getterCalls += 1; + throw new Error(HOSTILE_CANARY); + }, + }); + try { + error = captureError( + () => classifyAuthorizedStripeCheckoutResultEvidence(input), + ); + } finally { + if (original) { + Object.defineProperty(Object.prototype, field, original); + } else { + delete Object.prototype[field]; + } + } + expect(getterCalls).toBe(0); + expectSafeError(() => { throw error; }); + }, + ); + + test('rejects Object.prototype pollution present before module load', () => { + const probes = [ + runPreloadPrototypeProbe(` + Object.defineProperty(Object.prototype, 'temporaryPreloadPollution', { + value: ${JSON.stringify(HOSTILE_CANARY)}, + enumerable: false, + configurable: true, + }); + `), + runPreloadPrototypeProbe(` + Object.defineProperty(Object.prototype, 'hasOwnProperty', { + value: null, + enumerable: false, + writable: true, + configurable: true, + }); + `), + runPreloadPrototypeProbe(` + Object.defineProperty(Object.prototype, 'hasOwnProperty', { + enumerable: false, + configurable: true, + get() { + getterCalls += 1; + throw new Error(${JSON.stringify(HOSTILE_CANARY)}); + }, + }); + `), + runPreloadPrototypeProbe(` + const hostileGetter = new Proxy(function hostileGetter() {}, { + get() { + getterCalls += 1; + throw new Error(${JSON.stringify(HOSTILE_CANARY)}); + }, + }); + Object.defineProperty(Object.prototype, '__proto__', { + enumerable: false, + configurable: true, + get: hostileGetter, + set: originalProto.set, + }); + `), + runPreloadPrototypeProbe(` + Object.defineProperty(Object.prototype, 'value', { + enumerable: false, + configurable: true, + get() { + getterCalls += 1; + throw new Error(${JSON.stringify(HOSTILE_CANARY)}); + }, + }); + `), + runPreloadPrototypeProbe(` + Object.defineProperty(Object.prototype, 'get', { + enumerable: false, + configurable: true, + get() { + getterCalls += 1; + throw new Error(${JSON.stringify(HOSTILE_CANARY)}); + }, + }); + `), + runPreloadPrototypeProbe(` + Object.defineProperty(Object.prototype, 'writable', { + enumerable: false, + configurable: true, + get() { + getterCalls += 1; + throw new Error(${JSON.stringify(HOSTILE_CANARY)}); + }, + }); + `), + runPreloadPrototypeProbe(` + Object.defineProperty(Object.prototype, 'set', { + enumerable: false, + configurable: true, + get() { + getterCalls += 1; + throw new Error(${JSON.stringify(HOSTILE_CANARY)}); + }, + }); + `), + ]; + + for (const probe of probes) { + expect({ + status: probe.status, + signal: probe.signal, + stdout: probe.stdout, + stderr: probe.stderr, + }).toEqual({ + status: 0, + signal: null, + stdout: 'fixed-redacted-rejection', + stderr: '', + }); + } + }); + + test('rejects accessors without invoking them', () => { + let getterCalls = 0; + const input = evidence(); + Object.defineProperty(input, 'provider', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + throw new Error(HOSTILE_CANARY); + }, + }); + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(input)); + expect(getterCalls).toBe(0); + }); + + test('rejects proxies before invoking any hostile trap', () => { + let trapCalls = 0; + const handler = new Proxy({}, { + get() { + return () => { + trapCalls += 1; + throw new Error(HOSTILE_CANARY); + }; + }, + }); + const input = new Proxy(evidence(), handler); + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(input)); + expect(trapCalls).toBe(0); + }); + + test('rejects non-enumerable data fields without reading through them', () => { + const input = evidence(); + Object.defineProperty(input, 'provider', { + value: 'stripe', + enumerable: false, + configurable: true, + }); + expectSafeError(() => classifyAuthorizedStripeCheckoutResultEvidence(input)); + }); + + test.each([ + ['providerResultPolicySchemaVersion', 2], + ['providerResultPolicySchemaVersion', '1'], + ['provider', 'STRIPE'], + ['provider', 'str\u0131pe'], + ['providerAttempt', 1], + ['providerAttempt', 3], + ['providerAttempt', '2'], + ['providerOperation', 'checkout_session_creat\u0435'], + ['providerOperation', 'checkout_session_create '], + ['planBinding', 1], + ['sendEvidenceBinding', { state: 'exact' }], + ['evidenceSource', Symbol(HOSTILE_CANARY)], + ['evidenceCompleteness', true], + ['responseEvidence', 200], + ['providerObjectEvidence', 'cs_test_private'], + ['environmentEvidence', false], + ['parameterEvidence', ['exact']], + ['resultReferenceEvidence', 'opaque-id'], + ['redirectEvidence', 'https://private.example.test'], + ['paymentEvidence', 'PAID'], + ['expiryEvidence', new Date(0)], + ])('rejects a wrong fixed identity or field type for %s', (field, value) => { + expectSafeError( + () => classifyAuthorizedStripeCheckoutResultEvidence(serializedEvidence({ + [field]: value, + })), + typeof value === 'string' && value.length >= 5 ? value : HOSTILE_CANARY, + ); + }); + + test('rejects an unknown or future value in every enum dimension', () => { + for (const field of Object.keys(PROVIDER_RESULT_POLICY_ENUMS)) { + const value = `future_${field}_${HOSTILE_CANARY}`; + expectSafeError( + () => classifyAuthorizedStripeCheckoutResultEvidence(serializedEvidence({ + [field]: value, + })), + value, + ); + } + }); + + test('all validation errors are byte-equivalent and never contain supplied input', () => { + const errors = [ + captureError(() => classifyAuthorizedStripeCheckoutResultEvidence(null)), + captureError(() => classifyAuthorizedStripeCheckoutResultEvidence(serializedEvidence({ + evidenceSource: HOSTILE_CANARY, + }))), + captureError(() => classifyAuthorizedStripeCheckoutResultEvidence(JSON.stringify({ + ...evidence(), + extra: HOSTILE_CANARY, + }))), + ]; + const publicShapes = errors.map((error) => JSON.stringify({ + name: error.name, + code: error.code, + message: error.message, + enumerable: JSON.stringify(error), + })); + expect(new Set(publicShapes).size).toBe(1); + expect(publicShapes[0]).not.toContain(HOSTILE_CANARY); + }); +}); + +describe('immutability, redaction, and zero-side-effect boundary', () => { + test('callers cannot mutate canonical outputs, enum arrays, or error fields', () => { + const result = classifyAuthorizedStripeCheckoutResultEvidence(serializedEvidence()); + expect(() => { result.classification = HOSTILE_CANARY; }).toThrow(TypeError); + expect(() => { result.extra = HOSTILE_CANARY; }).toThrow(TypeError); + expect(() => { PROVIDER_RESULT_POLICY_ENUMS.planBinding.push(HOSTILE_CANARY); }) + .toThrow(TypeError); + + const error = captureError(() => classifyAuthorizedStripeCheckoutResultEvidence(null)); + expect(() => { error.message = HOSTILE_CANARY; }).toThrow(TypeError); + expect(() => { error.code = HOSTILE_CANARY; }).toThrow(TypeError); + }); + + test('source has no provider, persistence, trust, or runtime side-effect edge', () => { + const source = fs.readFileSync( + path.join(__dirname, 'commerceProviderResult.js'), + 'utf8', + ); + const requires = [...source.matchAll(/require\((['"])([^'"]+)\1\)/g)] + .map((match) => match[2]); + expect(requires).toEqual([]); + + for (const forbidden of [ + 'firebase', + "require('stripe')", + 'process.env', + 'console.', + 'Math.random', + 'Date.', + 'new Date', + 'fetch(', + 'node:http', + 'node:https', + 'node:net', + 'node:dns', + 'node:fs', + 'child_process', + 'import(', + 'commerceCommandJournal', + 'providerAttempts/', + 'dispatchBinding', + 'dispatchEvidence', + 'idempotencyEvidence', + 'resultBinding', + 'execution_started', + 'active_exact', + 'send_permitted', + 'shouldExecute', + 'shouldAdvance', + 'session.id', + 'session.url', + ]) { + expect(source).not.toContain(forbidden); + } + }); + + test('no production source imports the unused policy module', () => { + const files = fs.readdirSync(__dirname) + .filter((fileName) => fileName.endsWith('.js')) + .filter((fileName) => !fileName.endsWith('.test.js')) + .filter((fileName) => fileName !== 'commerceProviderResult.js'); + const importers = []; + for (const fileName of files) { + const source = fs.readFileSync(path.join(__dirname, fileName), 'utf8'); + if (source.includes('commerceProviderResult')) importers.push(fileName); + } + expect(importers).toEqual([]); + + const indexSource = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8'); + expect(indexSource).not.toContain('commerceProviderResult'); + }); +});