@agent-cards/checkout 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Pay a Stripe Checkout Elements page with a vaulted card. A page built with `stripe.initCheckout` (Checkout Elements) sends the card inline in the Checkout Session confirm, `/v1/payment_pages/{cs_...}/confirm`, which the SDK never recognized, so the placeholder card reached Stripe and the purchase failed. The confirm now pauses for the cardholder's approval like a PaymentIntent confirm, and the phone sends the real card. The API refuses it unless the amount you pass equals the total the Session will charge (`expected_amount`). Hosted and embedded Checkout confirm the same endpoint with the payment method they created first; that confirm carries no card and continues untouched, as before, even after the approval. `syncRegistry()` asks for `features=checkout_sessions`: the API serves the Checkout Session endpoint only to an SDK that lets such a request through ahead of its holds.
6
+
7
+ - Let a page pay with the Stripe card token the cardholder just approved. A page that tokenizes first (`stripe.createPaymentMethod`, `createToken`, `createSource`, `createConfirmationToken`) and then confirms the payment in the browser with what came back (`confirmCardPayment` with `payment_method: 'pm_...'`, `confirmPayment` with the confirmation token) used to fail: the SDK held that card-free confirmation, because the token alone could pay any amount. The SDK now asks the API (`VaultClient.checkStripeContinuation`), which reads the payment from Stripe and allows it only for the approved amount and currency, on the same Stripe account, paid with exactly the approved token. Allowed, the page's own request continues untouched, the SDK emits `stripe_payment_continued`, and the controller reads `awaiting_merchant` with reason `stripe_payment_continued`. One confirmation continues per approval; later ones stay held, and a refused one is `blocked` with the API's code (at most three checks per approval). The matching API release is required; an older API refuses, which leaves the hold as it was.
8
+
9
+ - Pause Stripe confirmation tokens and card sources. A page that calls `stripe.createConfirmationToken` (collect the card, confirm on your own server) or the older `stripe.createSource` sends the card to `/v1/confirmation_tokens` or `/v1/sources`; the SDK never recognized either, so the placeholder card reached Stripe and the purchase failed. Both now pause for the cardholder's approval, and the page receives a real confirmation token or card source. A confirmation token for a saved method, or a bank source, carries no card and continues untouched. The matching API serves these endpoints only to an SDK that asks for `features=card_fields`, which this release does. They are not eligible for auto-approval yet.
10
+
11
+ - Refuse a Stripe confirmation that carries no card at once. A page that pays with a method it created itself confirms with `payment_method=pm_...`; the SDK used to pause that request and ask the cardholder to approve something their card could not complete. The SDK now reads the body after its holds: a Stripe request without `card[number]` or `payment_method_data[card][number]` is refused at once with a `blocked` event (`request_without_card`) and never reaches the API. A confirmation that follows an approved card token keeps its existing hold. `VaultClient.withoutCard(url, body)` exposes the judgement, and `syncRegistry()` asks for `features=card_fields` (an older API ignores it). The matching API refuses a Stripe request with no card as `400 card_fields_missing`.
12
+
13
+ - Approve a Paysafe Checkout 1.8.0 fresh-card checkout before Pay with `prepare({ psp: 'paysafe', environment: 'production' | 'sandbox' })`. Phone approval finishes before the hosted checkout starts its card request. One matching request consumes the approval; saved cards and other Paysafe APIs do not use preparation. Matching API, Vault and database releases are required. A token does not confirm merchant payment.
14
+
5
15
  - Keep the cardholder's approval when the merchant page gives up on its own card request. A page whose script times out its tokenization while the person is still deciding (Braintree after 60 seconds, Square after about 10) no longer cancels the approval: the SDK reports `merchant_request_lost`, answers the page's next request for the same purchase from the same authorization, and reads `ready_to_submit` with reason `awaiting_merchant_retry` when the approval lands before the page asks again, so one more Pay click completes the purchase with no second prompt. The wait for that request is bounded by `merchantRetryWaitMs` (two minutes by default, never past the approval window); an approval the page never asks for again is retired through the API as `merchant_never_retried` and the controller reads `declined` with that reason. Applies to `token` and `cse` checkouts; hosted forms, prepared checkouts and native Stripe Checkout keep cancelling. `VaultClient.cancelAuthorization` takes the reason and `checkoutModeOf` reads a request's mode. The matching API and Vault releases are required for the new reason; an older API answers its retirement as unknown, which the SDK holds.
6
16
 
7
17
  - Approve a Checkout.com guest card checkout before Pay with `prepare({ psp: 'checkout_com', environment: 'production' | 'sandbox' })`. Phone approval finishes before Flow starts its 12-second tokenization deadline. One fresh card request on the matching API or CAG host consumes the approval; saved-card, wallet and reusable-token requests are refused. Matching API, Vault and database releases are required. A token does not establish merchant payment completion.
package/README.md CHANGED
@@ -458,14 +458,22 @@ the original request's claim. Do not cancel that checkout in response to the dup
458
458
  authorization before another attempt. Neither disposition proves a payment outcome.
459
459
 
460
460
  With `requireMerchantResult`, subsequent card requests stay blocked after
461
- handoff. Stripe `/v1/payment_methods` and `/v1/tokens` handoffs always hold further
462
- recognized card requests, even when that option is false. A tokenization approval has no
463
- authoritative binding to a specific PaymentIntent, amount or currency. The first
464
- observed confirm cannot supply that binding. The SDK therefore blocks every
465
- follow-up confirm on that attachment, including the same token, an unrelated
466
- intent, changed amounts and retries. It reports `awaiting_merchant` with reason
461
+ handoff. Stripe tokenization handoffs (`/v1/payment_methods`, `/v1/tokens`,
462
+ `/v1/sources`, `/v1/confirmation_tokens`) always hold further recognized card
463
+ requests, even when that option is false. A tokenization approval has no
464
+ authoritative binding to a specific PaymentIntent, amount or currency, and the
465
+ page cannot supply one. The SDK reports `awaiting_merchant` with reason
467
466
  `stripe_tokenization_unbound`, while ordinary browser traffic stays available.
468
- There is no automatic token-to-intent continuation or merchant-continuation hook.
467
+
468
+ One follow-up can continue: the page's own card-free PaymentIntent confirm that
469
+ pays with the approved token (`confirmCardPayment` with `payment_method: 'pm_...'`,
470
+ `confirmPayment` with the confirmation token). The SDK asks the API, which reads
471
+ the payment from Stripe and allows it only for the approved amount and
472
+ currency, on the same Stripe account, paid with exactly the approved token.
473
+ Allowed, the request continues untouched, the SDK emits `stripe_payment_continued`
474
+ and the reason becomes `stripe_payment_continued`. One confirm continues per
475
+ approval; every other follow-up, including an unrelated intent, a changed
476
+ amount and a retry, stays blocked.
469
477
  Unrecognized merchant-server endpoints remain outside this guard unless listed
470
478
  in `paymentEndpoints`; this is not a guarantee against a merchant charging a
471
479
  saved token on its own server.
@@ -1 +1 @@
1
- export const BUILTIN_REGISTRY = [{ "psp": "spreedly", "match": /core\.spreedly\.com\/v1\/payment_methods\/restricted\.json/i, "hosts": ["^core\\.spreedly\\.com$"], "encoding": "json", "passthroughHeaders": [/^spreedly-environment-key$/i] }, { "psp": "shopify", "match": /(checkout\.pci\.shopifyinc\.com|deposit\.[a-z0-9-]+\.shopifycs\.com)\/sessions/i, "hosts": ["^checkout\\.pci\\.shopifyinc\\.com$", "^deposit\\.[a-z0-9-]+\\.shopifycs\\.com$"], "encoding": "json", "passthroughHeaders": [/^shopify-identification-signature$/i] }, { "psp": "stripe", "match": /api\.stripe\.com\/v1\/(payment_methods|tokens|setup_intents\/seti_\w+\/confirm|payment_intents\/pi_\w+\/confirm)/i, "hosts": ["^api\\.stripe\\.com$"], "encoding": "form", "passthroughHeaders": [/^authorization$/i, /^stripe-version$/i, /^x-stripe-client-user-agent$/i] }, { "psp": "braintree", "match": /payments(\.sandbox)?\.braintree-api\.com\/graphql/i, "hosts": ["^payments\\.braintree-api\\.com$", "^payments\\.sandbox\\.braintree-api\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i, /^braintree-version$/i] }, { "psp": "checkout_com", "match": /(api|card-acquisition-gateway)(\.sandbox)?\.checkout\.com\/tokens/i, "hosts": ["^api\\.checkout\\.com$", "^api\\.sandbox\\.checkout\\.com$", "^card-acquisition-gateway\\.checkout\\.com$", "^card-acquisition-gateway\\.sandbox\\.checkout\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i] }, { "psp": "adyen", "match": /(checkoutshopper-(test|live(-[a-z]+)?)\.adyen\.com|([a-z0-9-]+\.)*adyenpayments\.com)\/checkoutshopper\/v1\/sessions\/[A-Za-z0-9_-]+\/payments/i, "hosts": ["^checkoutshopper-(test|live(-[a-z]+)?)\\.adyen\\.com$", "^([a-z0-9-]+\\.)*adyenpayments\\.com$"], "encoding": "json", "passthroughHeaders": [], "clientSideEncrypted": true, "mode": "cse" }, { "psp": "tranzila", "match": /(direct\.tranzila\.com\/process\/n\/|directng\.tranzila\.com\/process\/?)/i, "hosts": ["^direct\\.tranzila\\.com$", "^directng\\.tranzila\\.com$"], "encoding": "form", "passthroughHeaders": [], "mode": "hosted_form" }, { "psp": "square", "match": /pci-connect\.squareup(?:sandbox)?\.com\/v2\/card-nonce/i, "hosts": ["^pci-connect\\.squareup\\.com$", "^pci-connect\\.squareupsandbox\\.com$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "authorize_net", "match": /(api2|apitest)\.authorize\.net\/xml\/v1\/request\.api/i, "hosts": ["^api2\\.authorize\\.net$", "^apitest\\.authorize\\.net$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "worldpay", "match": /(try\.)?access\.worldpay\.com\/(sessions\/card|verifiedTokens\/sessions)/i, "hosts": ["^access\\.worldpay\\.com$", "^try\\.access\\.worldpay\\.com$"], "encoding": "json", "passthroughHeaders": [/^accept$/i] }, { "psp": "nuvei", "match": /(secure|ppp-test)\.safecharge\.com\/ppp\/api\/v1\/(?:cardTokenization|clientPayment|websdk\/initPaymentWithCardTokenization)\.do/i, "hosts": ["^secure\\.safecharge\\.com$", "^ppp-test\\.safecharge\\.com$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "airwallex", "match": /(checkout(\.sandbox)?\.airwallex\.com|pci-api(\.sandbox)?\.airwallex\.com)\/api\/v1\/pa\/(payment_intents\/[^/?#]+\/confirm|payment_consents\/[^/?#]+\/verify|payment_methods\/create)(\?|$)/i, "hosts": ["^checkout\\.airwallex\\.com$", "^checkout\\.sandbox\\.airwallex\\.com$", "^pci-api\\.airwallex\\.com$", "^pci-api\\.sandbox\\.airwallex\\.com$"], "encoding": "json", "passthroughHeaders": [/^client-secret$/i, /^authorization$/i, /^x-auth-token$/i, /^x-on-behalf-of$/i, /^x-api-version$/i] }, { "psp": "rapyd", "match": /(api|sandboxapi)\.rapyd\.net\/v1\/(?:hosted\/collect\/card\/[^/?#]+\/payment_method|checkout\/checkout_[A-Za-z0-9]+)(?:[?#]|$)/i, "hosts": ["^api\\.rapyd\\.net$", "^sandboxapi\\.rapyd\\.net$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "dlocal", "match": /ppmcc(-sandbox)?\.dlocal\.com\/cvault\/credit-card\/temporal/i, "hosts": ["^ppmcc\\.dlocal\\.com$", "^ppmcc-sandbox\\.dlocal\\.com$"], "encoding": "json", "passthroughHeaders": [/^x-fields-api-key$/i, /^x-uow$/i, /^x-dlocal-infrav2$/i] }, { "psp": "ebanx", "match": /(?:(?:customer-frontier|customer|api-local-latam|api|sandbox-local-latam|sandbox)\.ebanx\.com|(?:api-diamond|api|sandbox)\.ebanxpay\.com)\/ws\/token/i, "hosts": ["^customer\\.ebanx\\.com$", "^customer-frontier\\.ebanx\\.com$", "^api\\.ebanx\\.com$", "^sandbox\\.ebanx\\.com$", "^api-local-latam\\.ebanx\\.com$", "^sandbox-local-latam\\.ebanx\\.com$", "^api-diamond\\.ebanxpay\\.com$", "^api\\.ebanxpay\\.com$", "^sandbox\\.ebanxpay\\.com$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "mercado_pago", "match": /api\.mercadopago\.com\/v1\/card_tokens(?![\w/-])/i, "hosts": ["^api\\.mercadopago\\.com$"], "encoding": "json", "passthroughHeaders": [/^x-product-id$/i] }, { "psp": "payu", "match": /(secure\.payu\.com|(merch-prod|secure)\.snd\.payu\.com)\/api\/front\/tokens/i, "hosts": ["^secure\\.payu\\.com$", "^(merch-prod|secure)\\.snd\\.payu\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i] }, { "psp": "razorpay", "match": /api\.razorpay\.com\/v1\/(?:standard_checkout\/)?payments\/create\/(?:ajax|checkout|fees)/i, "hosts": ["^api\\.razorpay\\.com$"], "encoding": "form", "passthroughHeaders": [/^x-razorpay-sessionid$/i, /^x-customer-access-token$/i] }, { "psp": "mollie", "match": /api\.cc\.mollie\.com\/v1\/card-tokens/i, "hosts": ["^api\\.cc\\.mollie\\.com$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "paysafe", "match": /(?:api(\.test)?\.paysafe\.com\/(?:paymenthub\/v1\/singleusepaymenthandles|js\/api\/v1\/tokenize)|hosted(\.test)?\.paysafe\.com\/checkout\/api\/v1\/tokenize)(?![\w/-])/i, "hosts": ["^api\\.paysafe\\.com$", "^api\\.test\\.paysafe\\.com$", "^hosted\\.paysafe\\.com$", "^hosted\\.test\\.paysafe\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i, /^x-paysafe-credentials$/i, /^correlationid$/i] }, { "psp": "recurly", "match": /api(\.eu)?\.recurly\.com\/js\/v1\/token(?![\w-])/i, "hosts": ["^api\\.recurly\\.com$", "^api\\.eu\\.recurly\\.com$"], "encoding": "form", "passthroughHeaders": [/^recurly-credential-checkout-hostname$/i] }, { "psp": "moneris", "match": /(www3|esqa|gateway|gatewayqa|gatewayt|gatewaydev)\.moneris\.com\/HPPtoken\/request\.php/i, "hosts": ["^www3\\.moneris\\.com$", "^esqa\\.moneris\\.com$", "^gateway\\.moneris\\.com$", "^gatewayqa\\.moneris\\.com$", "^gatewayt\\.moneris\\.com$", "^gatewaydev\\.moneris\\.com$"], "encoding": "form", "passthroughHeaders": [] }, { "psp": "bambora", "match": /(api\.na\.bambora\.com|api\.bam\.shift4api\.net)\/scripts\/tokenization\/tokens/i, "hosts": ["^api\\.na\\.bambora\\.com$", "^api\\.bam\\.shift4api\\.net$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "global_payments", "match": /(?:api\.heartlandportico\.com\/SecureSubmit\.v1\/api\/token|cert\.api2\.heartlandportico\.com\/Hps\.Exchange\.PosGateway\.Hpf\.v1\/api\/token|apis(?:\.sandbox)?(?:\.eu)?\.globalpay\.com\/ucp\/(?:merchants\/[^/]+\/)?payment-methods)/i, "hosts": ["^api\\.heartlandportico\\.com$", "^cert\\.api2\\.heartlandportico\\.com$", "^apis\\.globalpay\\.com$", "^apis\\.sandbox\\.globalpay\\.com$", "^apis\\.eu\\.globalpay\\.com$", "^apis\\.sandbox\\.eu\\.globalpay\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i, /^x-gp-version$/i] }];
1
+ export const BUILTIN_REGISTRY = [{ "psp": "spreedly", "match": /core\.spreedly\.com\/v1\/payment_methods\/restricted\.json/i, "hosts": ["^core\\.spreedly\\.com$"], "encoding": "json", "passthroughHeaders": [/^spreedly-environment-key$/i] }, { "psp": "shopify", "match": /(checkout\.pci\.shopifyinc\.com|deposit\.[a-z0-9-]+\.shopifycs\.com)\/sessions/i, "hosts": ["^checkout\\.pci\\.shopifyinc\\.com$", "^deposit\\.[a-z0-9-]+\\.shopifycs\\.com$"], "encoding": "json", "passthroughHeaders": [/^shopify-identification-signature$/i] }, { "psp": "stripe", "match": /api\.stripe\.com\/v1\/(payment_methods|tokens|sources|confirmation_tokens|setup_intents\/seti_\w+\/confirm|payment_intents\/pi_\w+\/confirm|payment_pages\/cs_(?:test|live)_\w+\/confirm)/i, "hosts": ["^api\\.stripe\\.com$"], "encoding": "form", "passthroughHeaders": [/^authorization$/i, /^stripe-version$/i, /^x-stripe-client-user-agent$/i], "cardFields": ["card[number]", "payment_method_data[card][number]"], "passWithoutCard": "api\\.stripe\\.com/v1/(sources|confirmation_tokens|payment_pages/cs_(?:test|live)_\\w+/confirm)" }, { "psp": "braintree", "match": /payments(\.sandbox)?\.braintree-api\.com\/graphql/i, "hosts": ["^payments\\.braintree-api\\.com$", "^payments\\.sandbox\\.braintree-api\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i, /^braintree-version$/i] }, { "psp": "checkout_com", "match": /(api|card-acquisition-gateway)(\.sandbox)?\.checkout\.com\/tokens/i, "hosts": ["^api\\.checkout\\.com$", "^api\\.sandbox\\.checkout\\.com$", "^card-acquisition-gateway\\.checkout\\.com$", "^card-acquisition-gateway\\.sandbox\\.checkout\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i] }, { "psp": "adyen", "match": /(checkoutshopper-(test|live(-[a-z]+)?)\.adyen\.com|([a-z0-9-]+\.)*adyenpayments\.com)\/checkoutshopper\/v1\/sessions\/[A-Za-z0-9_-]+\/payments/i, "hosts": ["^checkoutshopper-(test|live(-[a-z]+)?)\\.adyen\\.com$", "^([a-z0-9-]+\\.)*adyenpayments\\.com$"], "encoding": "json", "passthroughHeaders": [], "clientSideEncrypted": true, "mode": "cse" }, { "psp": "tranzila", "match": /(direct\.tranzila\.com\/process\/n\/|directng\.tranzila\.com\/process\/?)/i, "hosts": ["^direct\\.tranzila\\.com$", "^directng\\.tranzila\\.com$"], "encoding": "form", "passthroughHeaders": [], "mode": "hosted_form" }, { "psp": "square", "match": /pci-connect\.squareup(?:sandbox)?\.com\/v2\/card-nonce/i, "hosts": ["^pci-connect\\.squareup\\.com$", "^pci-connect\\.squareupsandbox\\.com$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "authorize_net", "match": /(api2|apitest)\.authorize\.net\/xml\/v1\/request\.api/i, "hosts": ["^api2\\.authorize\\.net$", "^apitest\\.authorize\\.net$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "worldpay", "match": /(try\.)?access\.worldpay\.com\/(sessions\/card|verifiedTokens\/sessions)/i, "hosts": ["^access\\.worldpay\\.com$", "^try\\.access\\.worldpay\\.com$"], "encoding": "json", "passthroughHeaders": [/^accept$/i] }, { "psp": "nuvei", "match": /(secure|ppp-test)\.safecharge\.com\/ppp\/api\/v1\/(?:cardTokenization|clientPayment|websdk\/initPaymentWithCardTokenization)\.do/i, "hosts": ["^secure\\.safecharge\\.com$", "^ppp-test\\.safecharge\\.com$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "airwallex", "match": /(checkout(\.sandbox)?\.airwallex\.com|pci-api(\.sandbox)?\.airwallex\.com)\/api\/v1\/pa\/(payment_intents\/[^/?#]+\/confirm|payment_consents\/[^/?#]+\/verify|payment_methods\/create)(\?|$)/i, "hosts": ["^checkout\\.airwallex\\.com$", "^checkout\\.sandbox\\.airwallex\\.com$", "^pci-api\\.airwallex\\.com$", "^pci-api\\.sandbox\\.airwallex\\.com$"], "encoding": "json", "passthroughHeaders": [/^client-secret$/i, /^authorization$/i, /^x-auth-token$/i, /^x-on-behalf-of$/i, /^x-api-version$/i] }, { "psp": "rapyd", "match": /(api|sandboxapi)\.rapyd\.net\/v1\/(?:hosted\/collect\/card\/[^/?#]+\/payment_method|checkout\/checkout_[A-Za-z0-9]+)(?:[?#]|$)/i, "hosts": ["^api\\.rapyd\\.net$", "^sandboxapi\\.rapyd\\.net$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "dlocal", "match": /ppmcc(-sandbox)?\.dlocal\.com\/cvault\/credit-card\/temporal/i, "hosts": ["^ppmcc\\.dlocal\\.com$", "^ppmcc-sandbox\\.dlocal\\.com$"], "encoding": "json", "passthroughHeaders": [/^x-fields-api-key$/i, /^x-uow$/i, /^x-dlocal-infrav2$/i] }, { "psp": "ebanx", "match": /(?:(?:customer-frontier|customer|api-local-latam|api|sandbox-local-latam|sandbox)\.ebanx\.com|(?:api-diamond|api|sandbox)\.ebanxpay\.com)\/ws\/token/i, "hosts": ["^customer\\.ebanx\\.com$", "^customer-frontier\\.ebanx\\.com$", "^api\\.ebanx\\.com$", "^sandbox\\.ebanx\\.com$", "^api-local-latam\\.ebanx\\.com$", "^sandbox-local-latam\\.ebanx\\.com$", "^api-diamond\\.ebanxpay\\.com$", "^api\\.ebanxpay\\.com$", "^sandbox\\.ebanxpay\\.com$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "mercado_pago", "match": /api\.mercadopago\.com\/v1\/card_tokens(?![\w/-])/i, "hosts": ["^api\\.mercadopago\\.com$"], "encoding": "json", "passthroughHeaders": [/^x-product-id$/i] }, { "psp": "payu", "match": /(secure\.payu\.com|(merch-prod|secure)\.snd\.payu\.com)\/api\/front\/tokens/i, "hosts": ["^secure\\.payu\\.com$", "^(merch-prod|secure)\\.snd\\.payu\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i] }, { "psp": "razorpay", "match": /api\.razorpay\.com\/v1\/(?:standard_checkout\/)?payments\/create\/(?:ajax|checkout|fees)/i, "hosts": ["^api\\.razorpay\\.com$"], "encoding": "form", "passthroughHeaders": [/^x-razorpay-sessionid$/i, /^x-customer-access-token$/i] }, { "psp": "mollie", "match": /api\.cc\.mollie\.com\/v1\/card-tokens/i, "hosts": ["^api\\.cc\\.mollie\\.com$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "paysafe", "match": /(?:api(\.test)?\.paysafe\.com\/(?:paymenthub\/v1\/singleusepaymenthandles|js\/api\/v1\/tokenize)|hosted(\.test)?\.paysafe\.com\/checkout\/api\/v1\/tokenize)(?![\w/-])/i, "hosts": ["^api\\.paysafe\\.com$", "^api\\.test\\.paysafe\\.com$", "^hosted\\.paysafe\\.com$", "^hosted\\.test\\.paysafe\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i, /^x-paysafe-credentials$/i, /^correlationid$/i] }, { "psp": "recurly", "match": /api(\.eu)?\.recurly\.com\/js\/v1\/token(?![\w-])/i, "hosts": ["^api\\.recurly\\.com$", "^api\\.eu\\.recurly\\.com$"], "encoding": "form", "passthroughHeaders": [/^recurly-credential-checkout-hostname$/i] }, { "psp": "moneris", "match": /(www3|esqa|gateway|gatewayqa|gatewayt|gatewaydev)\.moneris\.com\/HPPtoken\/request\.php/i, "hosts": ["^www3\\.moneris\\.com$", "^esqa\\.moneris\\.com$", "^gateway\\.moneris\\.com$", "^gatewayqa\\.moneris\\.com$", "^gatewayt\\.moneris\\.com$", "^gatewaydev\\.moneris\\.com$"], "encoding": "form", "passthroughHeaders": [] }, { "psp": "bambora", "match": /(api\.na\.bambora\.com|api\.bam\.shift4api\.net)\/scripts\/tokenization\/tokens/i, "hosts": ["^api\\.na\\.bambora\\.com$", "^api\\.bam\\.shift4api\\.net$"], "encoding": "json", "passthroughHeaders": [] }, { "psp": "global_payments", "match": /(?:api\.heartlandportico\.com\/SecureSubmit\.v1\/api\/token|cert\.api2\.heartlandportico\.com\/Hps\.Exchange\.PosGateway\.Hpf\.v1\/api\/token|apis(?:\.sandbox)?(?:\.eu)?\.globalpay\.com\/ucp\/(?:merchants\/[^/]+\/)?payment-methods)/i, "hosts": ["^api\\.heartlandportico\\.com$", "^cert\\.api2\\.heartlandportico\\.com$", "^apis\\.globalpay\\.com$", "^apis\\.sandbox\\.globalpay\\.com$", "^apis\\.eu\\.globalpay\\.com$", "^apis\\.sandbox\\.eu\\.globalpay\\.com$"], "encoding": "json", "passthroughHeaders": [/^authorization$/i, /^x-gp-version$/i] }];
@@ -0,0 +1,3 @@
1
+ declare function carriesCardFields(recognizer: any, body: any): boolean;
2
+ declare function requestWithoutCard(recognizer: any, url: any, body: any): "continue" | "refuse" | null;
3
+ export { carriesCardFields, requestWithoutCard };
@@ -0,0 +1,46 @@
1
+ // @ts-nocheck
2
+ // Generated from @agent-cards/payment-core. Do not edit.
3
+ // artifact-sha256: ac6967df3f29912d12c310418d06b6583529d5a4d5346c362ef0a7987fd4f2ab
4
+ // src/card-fields.js
5
+ function carriesCardFields(recognizer, body) {
6
+ const fields = recognizer && recognizer.cardFields;
7
+ if (!Array.isArray(fields) || fields.length === 0)
8
+ return true;
9
+ if (recognizer.encoding !== "form")
10
+ return true;
11
+ if (typeof body !== "string" || body === "")
12
+ return false;
13
+ for (const chunk of body.split("&")) {
14
+ const eq = chunk.indexOf("=");
15
+ if (eq <= 0 || eq === chunk.length - 1)
16
+ continue;
17
+ let key;
18
+ try {
19
+ key = decodeURIComponent(chunk.slice(0, eq).replace(/\+/g, " "));
20
+ }
21
+ catch {
22
+ continue;
23
+ }
24
+ if (fields.includes(key))
25
+ return true;
26
+ }
27
+ return false;
28
+ }
29
+ function requestWithoutCard(recognizer, url, body) {
30
+ if (carriesCardFields(recognizer, body))
31
+ return null;
32
+ const pass = recognizer && recognizer.passWithoutCard;
33
+ if (typeof pass === "string" && pass !== "") {
34
+ let parsed;
35
+ try {
36
+ parsed = new URL(url);
37
+ }
38
+ catch {
39
+ return "refuse";
40
+ }
41
+ if (new RegExp(`^(?:${pass})$`, "i").test(`${parsed.hostname.toLowerCase()}${parsed.pathname}`))
42
+ return "continue";
43
+ }
44
+ return "refuse";
45
+ }
46
+ export { carriesCardFields, requestWithoutCard };
package/dist/cdp.d.ts CHANGED
@@ -109,6 +109,8 @@ export interface AttachOptions extends LifecycleOptions, ExecutionMetadata {
109
109
  vault: VaultClient;
110
110
  user: string;
111
111
  merchant: string;
112
+ /** Return the caller's `Date.now()` at the pay click; the SDK reads it when the card request pauses. */
113
+ payClickedAt?: () => number | null | undefined;
112
114
  /**
113
115
  * Your hint at the amount, an integer in the currency's smallest unit or a
114
116
  * decimal string in normal units, with its ISO 4217 code. See
package/dist/cdp.js CHANGED
@@ -298,6 +298,17 @@ export function withCorsHeaders(headers, cors) {
298
298
  function headerEntries(headers) {
299
299
  return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
300
300
  }
301
+ function readPayToInterceptMs(readClickedAt, approvalStartedAt) {
302
+ try {
303
+ const clickedAt = readClickedAt?.();
304
+ return typeof clickedAt === 'number' && Number.isFinite(clickedAt) && clickedAt <= approvalStartedAt
305
+ ? Math.round(approvalStartedAt - clickedAt)
306
+ : undefined;
307
+ }
308
+ catch {
309
+ return undefined;
310
+ }
311
+ }
301
312
  /**
302
313
  * The origin of the top-level document the payment form is on, read when a
303
314
  * card request pauses: the fact that names the merchant for the company's
@@ -406,6 +417,54 @@ function failureSummary(error) {
406
417
  return `${error.name}: ${error.code ?? `http_${error.status}`}`;
407
418
  return error instanceof Error ? error.name : 'CheckoutError';
408
419
  }
420
+ const STRIPE_PAYMENT_INTENT_CONFIRM = /^\/v1\/payment_intents\/pi_[A-Za-z0-9]+\/confirm$/;
421
+ /**
422
+ * A page that tokenized the card through an approval, then confirms the
423
+ * payment in the browser with the token it got back (confirmCardPayment with
424
+ * payment_method: 'pm_...', confirmPayment with a confirmation token). That
425
+ * confirmation carries no card, and the hold after a Stripe tokenization
426
+ * would refuse it. The API decides instead whether it is the payment the
427
+ * cardholder approved (the approved amount and currency, on the same Stripe
428
+ * account, paid with exactly the approved token).
429
+ *
430
+ * Synchronous on purpose: every paused request passes through here, and an
431
+ * await would reorder it against the attachment's own events. Returns the
432
+ * approval to check, with the lifecycle's one continuation claimed, or null
433
+ * when this is not such a confirmation and the ordinary rules apply.
434
+ */
435
+ function claimStripeContinuation(opts, lifecycle, url, method, body) {
436
+ if (body == null || typeof opts.vault.checkStripeContinuation !== 'function' || !lifecycle.isBlocked()
437
+ || method.toUpperCase() !== 'POST')
438
+ return null;
439
+ let parsed;
440
+ try {
441
+ parsed = new URL(url);
442
+ }
443
+ catch {
444
+ return null;
445
+ }
446
+ if (parsed.origin !== 'https://api.stripe.com' || !STRIPE_PAYMENT_INTENT_CONFIRM.test(parsed.pathname)
447
+ || opts.vault.withoutCard?.(url, body) !== 'refuse')
448
+ return null;
449
+ return lifecycle.claimStripeContinuation();
450
+ }
451
+ /** Ask the API about a claimed continuation and settle the claim. True: continue the page's request untouched. */
452
+ async function checkStripeContinuation(opts, lifecycle, authorizationId, request) {
453
+ let paymentIntentId = null;
454
+ try {
455
+ paymentIntentId = (await opts.vault.checkStripeContinuation(authorizationId, request)).paymentIntentId;
456
+ }
457
+ catch (error) {
458
+ opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
459
+ }
460
+ // The checkout may have been cancelled or reconciled while the API answered.
461
+ const proceed = lifecycle.stripeContinuationChecked(paymentIntentId !== null);
462
+ if (proceed)
463
+ opts.onEvent?.({ type: 'stripe_payment_continued', detail: { authorizationId, paymentIntentId } });
464
+ else if (paymentIntentId !== null)
465
+ opts.onEvent?.({ type: 'blocked', detail: 'stripe_continuation_superseded' });
466
+ return proceed;
467
+ }
409
468
  /**
410
469
  * How long an approval waits, after the cardholder gives it, for the page to
411
470
  * issue the request it will answer.
@@ -766,6 +825,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
766
825
  const sessionTypes = new Map([[pageSessionId, 'page']]);
767
826
  const sessionParents = new Map();
768
827
  const detachedSessions = new Set();
828
+ const rejectedSessions = new Set();
769
829
  const workerOwners = new Map();
770
830
  const ownersWithWorkers = new Set();
771
831
  // Network IDs are scoped to their emitting session. Retired records are
@@ -1153,9 +1213,31 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1153
1213
  return;
1154
1214
  }
1155
1215
  if (method === 'Target.attachedToTarget') {
1216
+ const child = params.sessionId;
1217
+ if (rejectedSessions.has(child))
1218
+ return;
1219
+ // A browser-root transport can report another context through an owned
1220
+ // parent session. Release its debugger wait without enrolling it in this
1221
+ // checkout. Remember the exclusion before awaiting cleanup: a duplicate
1222
+ // event with omitted context metadata must never acquire ownership.
1223
+ const childContext = params.targetInfo?.browserContextId;
1224
+ if (contextId(childContext) && childContext !== merchantContext) {
1225
+ if (typeof child !== 'string' || !child || child === pageSessionId
1226
+ || ownedSessions.has(child) || detachedSessions.has(child))
1227
+ return;
1228
+ rejectedSessions.add(child);
1229
+ // Owned setup may already have stopped. Foreign cleanup has its own
1230
+ // deadline so rejection cannot freeze an unrelated frame or worker.
1231
+ const cleanupStop = new AbortController();
1232
+ const cleanup = (method, params, session) => withinAttachmentDeadline(async () => {
1233
+ await cdp.send(method, params, session);
1234
+ }, setupTimeoutMs, cleanupStop.signal);
1235
+ await cleanup('Runtime.runIfWaitingForDebugger', {}, child).catch(() => cleanup('Target.detachFromTarget', { sessionId: child }, sessionId).catch(() => { }));
1236
+ return;
1237
+ }
1156
1238
  if (setupStop.signal.aborted)
1157
1239
  return;
1158
- const child = params.sessionId;
1240
+ // CDP may omit the context ID for ordinary children; keep those attached.
1159
1241
  try {
1160
1242
  if (typeof child !== 'string' || !child || !['page', 'iframe', 'worker'].includes(params.targetInfo?.type)) {
1161
1243
  throw new CheckoutAttachmentError('unavailable');
@@ -1323,14 +1405,62 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1323
1405
  return;
1324
1406
  }
1325
1407
  }
1408
+ // A request the catalog marks as routine without a card (a confirmation
1409
+ // token for a saved method, a bank source, a Checkout Session confirm with
1410
+ // the method hosted Checkout created) was never ours: it continues in
1411
+ // every state, like a request the catalog does not recognize. One without
1412
+ // a card anywhere else is judged after the holds below.
1413
+ const continuationBody = pausedBody(request);
1414
+ const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody) ?? null;
1415
+ if (withoutCard === 'continue') {
1416
+ if (preparation)
1417
+ preparationGate.retireUnboundClaim();
1418
+ await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1419
+ return;
1420
+ }
1421
+ // Not gated on awaitingApproval: the page can send this confirmation while
1422
+ // the token's own delivery is still being acknowledged. The lifecycle
1423
+ // opens the claim only once a Stripe tokenization is being handed over.
1424
+ const continuationId = stripeStep || terminal ? null
1425
+ : claimStripeContinuation(opts, lifecycle, request.url, request.method, continuationBody);
1426
+ if (continuationId) {
1427
+ if (preparation)
1428
+ preparationGate.retireUnboundClaim();
1429
+ if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url, method: request.method, headers: request.headers, body: continuationBody }) && !setupStop.signal.aborted) {
1430
+ await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1431
+ return;
1432
+ }
1433
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1434
+ return;
1435
+ }
1326
1436
  // Same stop condition as the Playwright adapter: once a failure proves
1327
1437
  // retrying is pointless, fail the request without calling the API again.
1328
1438
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
1439
+ let repeatedAuthorizationId = null;
1440
+ if (lastSubmitted) {
1441
+ const body = pausedBody(request);
1442
+ if (body !== null && isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
1443
+ repeatedAuthorizationId = lastSubmitted.authorizationId;
1444
+ }
1445
+ }
1329
1446
  const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
1330
1447
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
1331
1448
  if (preparation)
1332
1449
  preparationGate.retireUnboundClaim();
1333
1450
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1451
+ if (repeatedAuthorizationId)
1452
+ void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
1453
+ return;
1454
+ }
1455
+ // A recognized request that carries no card is not a card request: a
1456
+ // Stripe confirmation paying with a method the page created itself. It is
1457
+ // judged only after the holds above, so a request that would reuse an
1458
+ // approved token is still refused there (card-fields.js requestWithoutCard).
1459
+ if (withoutCard === 'refuse') {
1460
+ if (preparation)
1461
+ preparationGate.retireUnboundClaim();
1462
+ opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
1463
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1334
1464
  return;
1335
1465
  }
1336
1466
  // Reserve BEFORE the first await (the authorize call below yields), so two
@@ -1358,11 +1488,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1358
1488
  if (isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
1359
1489
  opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
1360
1490
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1491
+ if (lastSubmitted)
1492
+ void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
1361
1493
  return;
1362
1494
  }
1363
1495
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
1364
1496
  lifecycle.begin();
1365
1497
  const approvalStartedAt = Date.now();
1498
+ const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
1366
1499
  if (typeof networkId !== 'string' || !networkId) {
1367
1500
  attempt.stop();
1368
1501
  attempt.assertLive();
@@ -1399,6 +1532,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1399
1532
  merchantOrigin,
1400
1533
  pageOrigin,
1401
1534
  pageAmount,
1535
+ ...(payToInterceptMs !== undefined ? { payToInterceptMs } : {}),
1402
1536
  preparation,
1403
1537
  timeoutMs: opts.timeoutMs,
1404
1538
  signal: lifecycle.abort.signal,
@@ -1457,7 +1591,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1457
1591
  body: Buffer.from(page.body).toString('base64'),
1458
1592
  }, deliverOn);
1459
1593
  attempt.assertLive();
1460
- lastSubmitted = { url: deliverRequest.url, body: deliverBody, at: Date.now() };
1594
+ lastSubmitted = { url: deliverRequest.url, body: deliverBody, at: Date.now(), authorizationId: replay.authorizationId };
1461
1595
  // Named for what it is: a device-attested submission with no
1462
1596
  // processor evidence, never an `authorized` event.
1463
1597
  opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
@@ -1810,16 +1944,55 @@ export async function attachToPlaywright(page, opts) {
1810
1944
  return;
1811
1945
  }
1812
1946
  }
1947
+ // Same order as attachToCdp: a routine card-free request continues in every state.
1948
+ const continuationBody = request.postData();
1949
+ const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody) ?? null;
1950
+ if (withoutCard === 'continue') {
1951
+ if (preparation)
1952
+ preparationGate.retireUnboundClaim();
1953
+ return route.fallback();
1954
+ }
1955
+ const continuationId = stripeStep || terminal ? null
1956
+ : claimStripeContinuation(opts, lifecycle, request.url(), request.method(), continuationBody);
1957
+ if (continuationId) {
1958
+ if (preparation)
1959
+ preparationGate.retireUnboundClaim();
1960
+ if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url(), method: request.method(), headers: request.headers(), body: continuationBody }) && !setupStop.signal.aborted) {
1961
+ return route.fallback();
1962
+ }
1963
+ return route.abort('aborted');
1964
+ }
1813
1965
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
1814
1966
  // the page's retry loop turn into a stream of doomed API calls. Every
1815
1967
  // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
1816
1968
  // CDP adapter's Fetch.failRequest uses, so a refused navigation
1817
1969
  // resolves identically whichever adapter is attached.
1818
1970
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
1971
+ let repeatedAuthorizationId = null;
1972
+ if (lastSubmitted) {
1973
+ let body = null;
1974
+ try {
1975
+ body = request.postData() ?? '';
1976
+ }
1977
+ catch { /* The refusal still holds when the body cannot be read. */ }
1978
+ if (body !== null && isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
1979
+ repeatedAuthorizationId = lastSubmitted.authorizationId;
1980
+ }
1981
+ }
1819
1982
  const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
1820
1983
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
1821
1984
  if (preparation)
1822
1985
  preparationGate.retireUnboundClaim();
1986
+ const refused = await route.abort('aborted');
1987
+ if (repeatedAuthorizationId)
1988
+ void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
1989
+ return refused;
1990
+ }
1991
+ // Same judgement as attachToCdp, after the same holds.
1992
+ if (withoutCard === 'refuse') {
1993
+ if (preparation)
1994
+ preparationGate.retireUnboundClaim();
1995
+ opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
1823
1996
  return route.abort('aborted');
1824
1997
  }
1825
1998
  // Reserved before anything that could yield, matching attachToCdp.
@@ -1856,11 +2029,15 @@ export async function attachToPlaywright(page, opts) {
1856
2029
  const body = request.postData() ?? '';
1857
2030
  if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
1858
2031
  opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
1859
- return await route.abort('aborted');
2032
+ const refused = await route.abort('aborted');
2033
+ if (lastSubmitted)
2034
+ void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
2035
+ return refused;
1860
2036
  }
1861
2037
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
1862
2038
  lifecycle.begin();
1863
2039
  const approvalStartedAt = Date.now();
2040
+ const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
1864
2041
  attempt.bind({ route, request, frames });
1865
2042
  activeRequest = { request, frames, attempt,
1866
2043
  identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined) };
@@ -1891,6 +2068,7 @@ export async function attachToPlaywright(page, opts) {
1891
2068
  merchantOrigin,
1892
2069
  pageOrigin,
1893
2070
  pageAmount,
2071
+ ...(payToInterceptMs !== undefined ? { payToInterceptMs } : {}),
1894
2072
  preparation,
1895
2073
  timeoutMs: opts.timeoutMs,
1896
2074
  signal: lifecycle.abort.signal,
@@ -1934,7 +2112,7 @@ export async function attachToPlaywright(page, opts) {
1934
2112
  // Inert on a navigation (never CORS-checked); one path for every fulfill.
1935
2113
  await deliverTo.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(deliverRequest.url(), deliverRequest.headers())), body: synthetic.body });
1936
2114
  assertRequestLive();
1937
- lastSubmitted = { url: deliverRequest.url(), body: deliverBody, at: Date.now() };
2115
+ lastSubmitted = { url: deliverRequest.url(), body: deliverBody, at: Date.now(), authorizationId: replay.authorizationId };
1938
2116
  opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
1939
2117
  }
1940
2118
  else if (replay.mode === 'cse') {
package/dist/client.d.ts CHANGED
@@ -15,6 +15,16 @@ export interface PausedRequest {
15
15
  * is never paused) and sent on every create.
16
16
  */
17
17
  export declare const SUPPORTED_MODES: readonly CheckoutMode[];
18
+ /**
19
+ * Registry features this SDK honours, asked for on syncRegistry next to the
20
+ * modes. `card_fields`: it reads a recognizer's cardFields and claims only a
21
+ * request whose body carries the card, so the API may serve it recognizers
22
+ * whose endpoints also run without a card. `checkout_sessions`: it lets a
23
+ * request the recognizer marks as routine without a card (passWithoutCard)
24
+ * through ahead of its holds, so the API may serve Stripe's Checkout Session
25
+ * confirm, which hosted Checkout sends after an approval.
26
+ */
27
+ export declare const SUPPORTED_REGISTRY_FEATURES: readonly string[];
18
28
  /**
19
29
  * What the amount on an authorization IS: held to a Stripe PaymentIntent
20
30
  * (read back at create and before the replay), the parked form's own sum
@@ -199,6 +209,8 @@ export interface AuthorizeInput extends ExecutionMetadata {
199
209
  amount: number;
200
210
  currency: string;
201
211
  };
212
+ /** Milliseconds from the caller's pay click until the SDK caught the card request, measured on one clock. */
213
+ payToInterceptMs?: number;
202
214
  /**
203
215
  * WHICH stored card should pay — a vault card id from
204
216
  * GET /api/v2/vault_cards. The approval page preselects it (the human can
@@ -459,6 +471,11 @@ export interface VaultClientOptions {
459
471
  /** Override the PSP registry (tests, or pinning). Defaults to the hosted list. */
460
472
  registry?: Recognizer[];
461
473
  fetchImpl?: typeof fetch;
474
+ /** Receives contained reporting failures that do not change checkout behavior. */
475
+ onEvent?: (event: {
476
+ type: string;
477
+ detail?: unknown;
478
+ }) => void;
462
479
  pollIntervalMs?: number;
463
480
  /**
464
481
  * Waits before retrying a create the API answered 502 `amount_unverifiable`
@@ -482,6 +499,16 @@ export declare class VaultClient {
482
499
  syncRegistry(): Promise<void>;
483
500
  /** True when this request is a card tokenization we can take over. */
484
501
  isCardRequest(url: string, method?: string): boolean;
502
+ /**
503
+ * What happens to a card request (by URL) whose body carries no card: null
504
+ * when it carries one, so it is a card request as usual; 'continue' when the
505
+ * recognizer marks the endpoint as one where such requests are routine and
506
+ * never ours; 'refuse' otherwise, such as a Stripe confirmation paying with
507
+ * a method this checkout never approved. The adapters ask only after their
508
+ * holds, so a request that would reuse an approved token is refused there
509
+ * first. A body that could not be read is not judged here.
510
+ */
511
+ withoutCard(url: string, body: string | null | undefined): 'continue' | 'refuse' | null;
485
512
  /**
486
513
  * How the card would reach the processor on this request (`token`, `cse`
487
514
  * or `hosted_form`; absent on the entry means `token`), or null when the
@@ -506,6 +533,8 @@ export declare class VaultClient {
506
533
  prepareCheckout(input: PrepareCheckoutInput): Promise<PreparedCheckout>;
507
534
  /** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
508
535
  cancelPreparation(id: string): Promise<void>;
536
+ observePreparation(id: string, guidance: 'presented_not_filled' | 'not_presented', reason?: string): Promise<void>;
537
+ reportDuplicateGuard(authorizationId: string): Promise<void>;
509
538
  /**
510
539
  * Hand us a paused tokenization request. We ask the cardholder to approve,
511
540
  * their device supplies the card and calls the merchant, and you get back the
@@ -539,6 +568,26 @@ export declare class VaultClient {
539
568
  cancelled: true;
540
569
  processor_request_started: boolean;
541
570
  }>;
571
+ /**
572
+ * Ask whether the page may pay with the Stripe card token an approval
573
+ * produced: a PaymentIntent confirm that carries no card and pays with
574
+ * exactly the approved payment method, card token, confirmation token or
575
+ * source. The API reads the payment from Stripe and answers only for the
576
+ * approved amount and currency on the same Stripe account; any other answer
577
+ * rejects with a CheckoutApiError whose code says why (for example
578
+ * `continuation_not_bound`, `amount_mismatch`). Nothing is charged here:
579
+ * the adapters continue the page's own request once this resolves.
580
+ */
581
+ checkStripeContinuation(authorizationId: string, request: {
582
+ url: string;
583
+ method: string;
584
+ headers: Record<string, string>;
585
+ body: string;
586
+ }): Promise<{
587
+ paymentIntentId: string;
588
+ amount: number;
589
+ currency: string;
590
+ }>;
542
591
  /**
543
592
  * POST the create, with two typed twists: a 502 `amount_unverifiable`
544
593
  * (Stripe did not answer the read-back) is retried on a short backoff
package/dist/client.js CHANGED
@@ -2,6 +2,7 @@ import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecogni
2
2
  import { isMercadoTokenRequest, parseMercadoCheckoutContext, validateMercadoProcessorContext } from './mercado-checkout.generated.js';
3
3
  import { matchesPreparedRequest, preparationMode, validPreparationEnvironment } from './prepared-processor.js';
4
4
  import { hasOwnedShopMarker, parseOwnedShopOrder, parseOwnedShopReceipt } from './owned-shop.generated.js';
5
+ import { requestWithoutCard } from './card-fields.generated.js';
5
6
  import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCheckoutMarker, parseStripeCheckoutContext, parseStripeCheckoutResponse, STRIPE_CHECKOUT_CONTEXT_HEADER, } from './stripe-checkout.generated.js';
6
7
  /**
7
8
  * The modes this SDK can finish. Asked for on syncRegistry (the API serves
@@ -9,6 +10,16 @@ import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCh
9
10
  * is never paused) and sent on every create.
10
11
  */
11
12
  export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
13
+ /**
14
+ * Registry features this SDK honours, asked for on syncRegistry next to the
15
+ * modes. `card_fields`: it reads a recognizer's cardFields and claims only a
16
+ * request whose body carries the card, so the API may serve it recognizers
17
+ * whose endpoints also run without a card. `checkout_sessions`: it lets a
18
+ * request the recognizer marks as routine without a card (passWithoutCard)
19
+ * through ahead of its holds, so the API may serve Stripe's Checkout Session
20
+ * confirm, which hosted Checkout sends after an approval.
21
+ */
22
+ export const SUPPORTED_REGISTRY_FEATURES = ['card_fields', 'checkout_sessions'];
12
23
  /** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
13
24
  export function validAmountInput(amount) {
14
25
  if (typeof amount === 'number')
@@ -395,7 +406,7 @@ export class VaultClient {
395
406
  // spread verbatim.
396
407
  let raw;
397
408
  try {
398
- raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}`);
409
+ raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}&features=${SUPPORTED_REGISTRY_FEATURES.join(',')}`);
399
410
  }
400
411
  catch {
401
412
  return;
@@ -412,6 +423,21 @@ export class VaultClient {
412
423
  isCardRequest(url, method = 'POST') {
413
424
  return method.toUpperCase() === 'POST' && findRecognizer(url, this.registry) !== null;
414
425
  }
426
+ /**
427
+ * What happens to a card request (by URL) whose body carries no card: null
428
+ * when it carries one, so it is a card request as usual; 'continue' when the
429
+ * recognizer marks the endpoint as one where such requests are routine and
430
+ * never ours; 'refuse' otherwise, such as a Stripe confirmation paying with
431
+ * a method this checkout never approved. The adapters ask only after their
432
+ * holds, so a request that would reuse an approved token is refused there
433
+ * first. A body that could not be read is not judged here.
434
+ */
435
+ withoutCard(url, body) {
436
+ if (body == null)
437
+ return null;
438
+ const rec = findRecognizer(url, this.registry);
439
+ return rec ? requestWithoutCard(rec, url, body) : null;
440
+ }
415
441
  /**
416
442
  * How the card would reach the processor on this request (`token`, `cse`
417
443
  * or `hosted_form`; absent on the entry means `token`), or null when the
@@ -534,6 +560,26 @@ export class VaultClient {
534
560
  if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
535
561
  throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
536
562
  }
563
+ async observePreparation(id, guidance, reason) {
564
+ try {
565
+ await this.post(`/v2/checkout/preparations/${id}/observe`, {
566
+ form_guidance: guidance, ...(reason ? { reason } : {}),
567
+ }, AbortSignal.timeout(5_000));
568
+ }
569
+ catch {
570
+ this.opts.onEvent?.({ type: 'telemetry_skipped', detail: 'form_guidance' });
571
+ }
572
+ }
573
+ async reportDuplicateGuard(authorizationId) {
574
+ try {
575
+ await this.post(`/v2/checkout/authorizations/${authorizationId}/duplicate-guard`, {
576
+ guard: 'hosted_form_repeat',
577
+ }, AbortSignal.timeout(5_000));
578
+ }
579
+ catch {
580
+ this.opts.onEvent?.({ type: 'telemetry_skipped', detail: 'duplicate_guard' });
581
+ }
582
+ }
537
583
  /**
538
584
  * Hand us a paused tokenization request. We ask the cardholder to approve,
539
585
  * their device supplies the card and calls the merchant, and you get back the
@@ -647,6 +693,9 @@ export class VaultClient {
647
693
  // snake_case on the wire; camelCase is this SDK's convention.
648
694
  ...(hasAmount ? { amount: input.amount, currency: input.currency } : {}),
649
695
  ...(input.pageAmount ? { page_amount: input.pageAmount.amount, page_currency: input.pageAmount.currency } : {}),
696
+ ...(Number.isInteger(input.payToInterceptMs) && input.payToInterceptMs >= 0
697
+ ? { pay_to_intercept_ms: input.payToInterceptMs }
698
+ : {}),
650
699
  psp: rec.psp,
651
700
  // The mode this request will be finished in. The API checks it against
652
701
  // the recognizer and refuses a disagreement before a row exists.
@@ -657,7 +706,7 @@ export class VaultClient {
657
706
  ...(input.merchantOrigin && !preparation ? { merchant_origin: input.merchantOrigin } : {}),
658
707
  // A prepared checkout already carries the page origin as merchant_origin.
659
708
  ...(preparation
660
- ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin }
709
+ ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin, form_guidance: 'filled' }
661
710
  : input.pageOrigin ? { checkout_origin: input.pageOrigin } : {}),
662
711
  request: {
663
712
  url: input.request.url,
@@ -996,6 +1045,32 @@ export class VaultClient {
996
1045
  return { id: authorizationId, status: 'declined', reason: recorded,
997
1046
  cancelled: true, processor_request_started: result.processor_request_started };
998
1047
  }
1048
+ /**
1049
+ * Ask whether the page may pay with the Stripe card token an approval
1050
+ * produced: a PaymentIntent confirm that carries no card and pays with
1051
+ * exactly the approved payment method, card token, confirmation token or
1052
+ * source. The API reads the payment from Stripe and answers only for the
1053
+ * approved amount and currency on the same Stripe account; any other answer
1054
+ * rejects with a CheckoutApiError whose code says why (for example
1055
+ * `continuation_not_bound`, `amount_mismatch`). Nothing is charged here:
1056
+ * the adapters continue the page's own request once this resolves.
1057
+ */
1058
+ async checkStripeContinuation(authorizationId, request) {
1059
+ if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
1060
+ throw new Error('Invalid authorization ID.');
1061
+ const rec = findRecognizer(request.url, this.registry);
1062
+ if (rec?.psp !== 'stripe')
1063
+ throw new Error('Only a Stripe request can continue an approved Stripe token.');
1064
+ const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/continuations`, {
1065
+ request: { url: request.url, method: request.method, headers: pickHeaders(request.headers, rec.passthroughHeaders), body: request.body },
1066
+ }, AbortSignal.timeout(15_000));
1067
+ const pi = result?.payment_intent;
1068
+ if (result?.object !== 'checkout_continuation' || result.continuation !== 'allowed' || result.authorization !== authorizationId
1069
+ || !pi || typeof pi.id !== 'string' || !Number.isSafeInteger(pi.amount) || typeof pi.currency !== 'string') {
1070
+ throw new Error('The continuation answer was not recognized.');
1071
+ }
1072
+ return { paymentIntentId: pi.id, amount: pi.amount, currency: pi.currency };
1073
+ }
999
1074
  /**
1000
1075
  * POST the create, with two typed twists: a 502 `amount_unverifiable`
1001
1076
  * (Stripe did not answer the read-back) is retried on a short backoff
@@ -61,6 +61,8 @@ export interface CheckoutController {
61
61
  status: 'failed';
62
62
  }): void;
63
63
  }
64
+ /** How many times one approval's card-free Stripe confirmation may be checked before it is simply held. */
65
+ export declare const MAX_STRIPE_CONTINUATION_CHECKS = 3;
64
66
  /** Shared by the raw CDP and Playwright transports. No browser ownership or payment execution lives here. */
65
67
  export declare class CheckoutLifecycle implements CheckoutController {
66
68
  private readonly options;
@@ -70,6 +72,9 @@ export declare class CheckoutLifecycle implements CheckoutController {
70
72
  private cancelled;
71
73
  private merchantAborted;
72
74
  private unboundStripeToken;
75
+ private stripeTokenPrepared;
76
+ private stripeContinuation;
77
+ private stripeContinuationChecks;
73
78
  private ownedShopOrderId;
74
79
  private reconciliation;
75
80
  private preparationHandler?;
@@ -121,6 +126,24 @@ export declare class CheckoutLifecycle implements CheckoutController {
121
126
  merchantNeverRetried(authorizationId: string | null): void;
122
127
  unsupported(): void;
123
128
  prepareHandoff(replay: ReplayResponse, requestUrl: string): void;
129
+ /**
130
+ * The approval a page's card-free Stripe confirmation may continue, or null.
131
+ * Only once a Stripe tokenization is being handed to the page, and while
132
+ * the checkout still awaits it (the page can send its confirmation before
133
+ * the browser acknowledges the delivery) or the merchant; for one check at
134
+ * a time and at most MAX_STRIPE_CONTINUATION_CHECKS checks. Reserves the check synchronously,
135
+ * so two confirmations can never both continue: the caller settles it with
136
+ * stripeContinuationChecked(). The hold stays on throughout.
137
+ */
138
+ claimStripeContinuation(): string | null;
139
+ /**
140
+ * Settle a claimed check. True only when the API allowed it and the checkout
141
+ * still awaits the merchant (nothing cancelled, aborted or reconciled it
142
+ * while the API answered): the page's confirmation continues, and no later
143
+ * one will.
144
+ */
145
+ stripeContinuationChecked(allowed: boolean): boolean;
146
+ private stripeContinuationOpen;
124
147
  handedOff(replay: ReplayResponse): void;
125
148
  failed(error: unknown, handoffStarted?: boolean): void;
126
149
  requestUserAction(reason: '3ds' | 'redirect' | 'other'): Promise<void>;
package/dist/lifecycle.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, CheckoutPreparationError, IntentNotConfirmableError, PaymentOutcomeUnknownError, ProcessorRefusedError } from './client.js';
2
2
  import { parseOwnedShopReceipt, OWNED_SHOP_SKU, OWNED_SHOP_AMOUNT_CENTS, OWNED_SHOP_CURRENCY } from './owned-shop.generated.js';
3
+ /** How many times one approval's card-free Stripe confirmation may be checked before it is simply held. */
4
+ export const MAX_STRIPE_CONTINUATION_CHECKS = 3;
3
5
  /** Shared by the raw CDP and Playwright transports. No browser ownership or payment execution lives here. */
4
6
  export class CheckoutLifecycle {
5
7
  options;
@@ -9,6 +11,10 @@ export class CheckoutLifecycle {
9
11
  cancelled = false;
10
12
  merchantAborted = false;
11
13
  unboundStripeToken = false;
14
+ // The page's own confirmation paying with that token: see claimStripeContinuation.
15
+ stripeTokenPrepared = false;
16
+ stripeContinuation = 'none';
17
+ stripeContinuationChecks = 0;
12
18
  ownedShopOrderId = null;
13
19
  reconciliation = null;
14
20
  preparationHandler;
@@ -141,16 +147,53 @@ export class CheckoutLifecycle {
141
147
  if (!replay.mode || replay.mode === 'token') {
142
148
  try {
143
149
  const url = new URL(requestUrl);
144
- if (url.origin === 'https://api.stripe.com' && ['/v1/payment_methods', '/v1/tokens'].includes(url.pathname)) {
150
+ if (url.origin === 'https://api.stripe.com' && ['/v1/payment_methods', '/v1/tokens', '/v1/sources', '/v1/confirmation_tokens'].includes(url.pathname)) {
145
151
  // Set before delivery: a lost browser acknowledgement must not reset
146
152
  // an already-issued token into a retryable authorization.
147
153
  this.unboundStripeToken = true;
154
+ this.stripeTokenPrepared = true;
148
155
  this.held = true;
149
156
  }
150
157
  }
151
158
  catch { /* unknown URL cannot establish a token binding */ }
152
159
  }
153
160
  }
161
+ /**
162
+ * The approval a page's card-free Stripe confirmation may continue, or null.
163
+ * Only once a Stripe tokenization is being handed to the page, and while
164
+ * the checkout still awaits it (the page can send its confirmation before
165
+ * the browser acknowledges the delivery) or the merchant; for one check at
166
+ * a time and at most MAX_STRIPE_CONTINUATION_CHECKS checks. Reserves the check synchronously,
167
+ * so two confirmations can never both continue: the caller settles it with
168
+ * stripeContinuationChecked(). The hold stays on throughout.
169
+ */
170
+ claimStripeContinuation() {
171
+ if (!this.stripeContinuationOpen() || this.stripeContinuation !== 'none'
172
+ || this.stripeContinuationChecks >= MAX_STRIPE_CONTINUATION_CHECKS)
173
+ return null;
174
+ this.stripeContinuation = 'checking';
175
+ this.stripeContinuationChecks++;
176
+ return this.state.authorizationId;
177
+ }
178
+ /**
179
+ * Settle a claimed check. True only when the API allowed it and the checkout
180
+ * still awaits the merchant (nothing cancelled, aborted or reconciled it
181
+ * while the API answered): the page's confirmation continues, and no later
182
+ * one will.
183
+ */
184
+ stripeContinuationChecked(allowed) {
185
+ if (this.stripeContinuation !== 'checking')
186
+ return false;
187
+ const proceed = allowed && this.stripeContinuationOpen();
188
+ this.stripeContinuation = proceed ? 'continued' : 'none';
189
+ if (proceed)
190
+ this.set({ ...this.state, reason: 'stripe_payment_continued' });
191
+ return proceed;
192
+ }
193
+ stripeContinuationOpen() {
194
+ return this.stripeTokenPrepared && !this.cancelled && !this.merchantAborted && !!this.state.authorizationId
195
+ && (this.state.status === 'awaiting_approval' || this.state.status === 'awaiting_merchant');
196
+ }
154
197
  handedOff(replay) {
155
198
  if (this.merchantAborted)
156
199
  return;
@@ -161,9 +204,13 @@ export class CheckoutLifecycle {
161
204
  }
162
205
  const mismatch = (!replay.mode || replay.mode === 'token') && replay.amountVerified === false;
163
206
  this.held = !!this.options.requireMerchantResult || replay.mode === 'hosted_form' || mismatch || this.unboundStripeToken;
207
+ if (mismatch)
208
+ this.stripeTokenPrepared = false;
164
209
  this.set({ status: 'awaiting_merchant', authorizationId: replay.authorizationId, mode: replay.mode ?? 'token',
165
210
  ...(this.state.preparationId ? { preparationId: this.state.preparationId } : {}),
166
- ...(mismatch ? { reason: 'charged_amount_mismatch' } : this.unboundStripeToken ? { reason: 'stripe_tokenization_unbound' } : {}) });
211
+ ...(mismatch ? { reason: 'charged_amount_mismatch' }
212
+ : this.stripeContinuation === 'continued' ? { reason: 'stripe_payment_continued' }
213
+ : this.unboundStripeToken ? { reason: 'stripe_tokenization_unbound' } : {}) });
167
214
  }
168
215
  failed(error, handoffStarted = false) {
169
216
  if (this.cancelled)
@@ -0,0 +1,12 @@
1
+ export type PaysafePreparationEnvironment = 'production' | 'sandbox';
2
+ export declare function paysafePreparationUrl(environment: string): string;
3
+ /** Exact strings refuse credentials, queries, fragments and URL normalization. */
4
+ export declare function paysafeEnvironment(url: string): PaysafePreparationEnvironment | undefined;
5
+ /** Checkout 1.8.0 starts a 40-second XHR when Pay is pressed. Prior approval may
6
+ * consume only its new-card request, including native clientInfo and headers.
7
+ * Saved cards, alternate APIs and 3DS-only requests require separate review.
8
+ * Source: hosted.paysafe.com/checkout/1.8.0/main.bundle.js, SHA-256
9
+ * e9e111ff03073cc936a47674e8e7c5db4a6d1050e1b65bf5e20723116fd5080c.
10
+ * The guard checks original bytes and never changes the request.
11
+ */
12
+ export declare function isPreparedPaysafeRequest(url: string, method: string, body: string | null, environment: string, headers?: Record<string, string>): boolean;
@@ -0,0 +1,87 @@
1
+ // Generated from @agent-cards/payment-core. Do not edit.
2
+ // source-sha256: 24c7940ae8974c0d24e5a365144c1b07ccc46863ae3094eb92b3c54c8307e837
3
+ import { readTokenizationJson } from './braintree.js';
4
+ const ENDPOINTS = {
5
+ production: 'https://hosted.paysafe.com/checkout/api/v1/tokenize',
6
+ sandbox: 'https://hosted.test.paysafe.com/checkout/api/v1/tokenize',
7
+ };
8
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ const keys = (value, allowed) => record(value) && Object.keys(value).every(key => allowed.includes(key));
10
+ const exact = (pattern, value) => typeof value === 'string' && pattern.exec(value)?.[0] === value;
11
+ const text = (value, max = 1024) => typeof value === 'string' && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
12
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
13
+ const uuid = (value) => exact(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i, value);
14
+ export function paysafePreparationUrl(environment) {
15
+ if (environment !== 'production' && environment !== 'sandbox')
16
+ throw new Error('unsupported_preparation_environment');
17
+ return ENDPOINTS[environment];
18
+ }
19
+ /** Exact strings refuse credentials, queries, fragments and URL normalization. */
20
+ export function paysafeEnvironment(url) {
21
+ return url === ENDPOINTS.production ? 'production' : url === ENDPOINTS.sandbox ? 'sandbox' : undefined;
22
+ }
23
+ const ADDRESS_FIELDS = ['city', 'country', 'zip', 'street', 'street2', 'state'];
24
+ function billing(value) {
25
+ return keys(value, ADDRESS_FIELDS) && Object.values(value).every(value => text(value))
26
+ && (!own(value, 'country') || exact(/^[A-Z]{2}$/, value.country));
27
+ }
28
+ function freshCard(value) {
29
+ if (!keys(value, ['card', 'clientInfo', 'billingAddress']) || !keys(value.card, ['cardNum', 'cardExpiry', 'cvv', 'holderName']))
30
+ return false;
31
+ const card = value.card;
32
+ if (!exact(/^[0-9]{12,19}$/, card.cardNum) || !exact(/^[0-9]{3,4}$/, card.cvv)
33
+ || !keys(card.cardExpiry, ['month', 'year']) || !exact(/^(?:0[1-9]|1[0-2])$/, card.cardExpiry.month)
34
+ || !exact(/^20[0-9]{2}$/, card.cardExpiry.year) || own(card, 'holderName') && !text(card.holderName)
35
+ || own(value, 'billingAddress') && !billing(value.billingAddress))
36
+ return false;
37
+ const info = value.clientInfo;
38
+ return keys(info, ['correlationId', 'invocationId', 'version', 'appName'])
39
+ && uuid(info.correlationId) && uuid(info.invocationId) && info.version === '1.8.0' && info.appName === 'paysafe.checkout';
40
+ }
41
+ const BROWSER_HEADERS = new Set(['accept', 'accept-language', 'accept-encoding', 'content-length', 'origin', 'referer',
42
+ 'user-agent', 'host', 'connection', 'cache-control', 'pragma', 'priority', 'dnt',
43
+ 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-user']);
44
+ function singleUseCredential(value) {
45
+ if (!exact(/^Basic [A-Za-z0-9+/]+={0,2}$/, value) || value.length > 1024)
46
+ return false;
47
+ try {
48
+ const encoded = value.slice(6), decoded = atob(encoded);
49
+ // Checkout receives public OT credentials, never merchant API credentials.
50
+ return btoa(decoded) === encoded && exact(/^OT-[0-9]{1,20}:[!-~]{1,512}$/, decoded);
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ function nativeHeaders(headers, correlationId) {
57
+ if (!record(headers))
58
+ return false;
59
+ const parsed = new Map();
60
+ let bytes = 0;
61
+ for (const [name, value] of Object.entries(headers)) {
62
+ const lower = name.toLowerCase();
63
+ if (parsed.size >= 64 || parsed.has(lower) || !exact(/^[A-Za-z0-9-]+$/, name) || !text(value, 8192)
64
+ || (bytes += name.length + value.length) > 32768)
65
+ return false;
66
+ if (!['content-type', 'correlationid', 'x-paysafe-credentials'].includes(lower) && !BROWSER_HEADERS.has(lower)
67
+ && !exact(/^sec-ch-ua(?:-[a-z0-9-]+)?$/, lower))
68
+ return false;
69
+ parsed.set(lower, value);
70
+ }
71
+ return exact(/^application\/json(?:;\s*charset=utf-8)?$/i, parsed.get('content-type'))
72
+ && parsed.get('correlationid') === correlationId && singleUseCredential(parsed.get('x-paysafe-credentials'));
73
+ }
74
+ /** Checkout 1.8.0 starts a 40-second XHR when Pay is pressed. Prior approval may
75
+ * consume only its new-card request, including native clientInfo and headers.
76
+ * Saved cards, alternate APIs and 3DS-only requests require separate review.
77
+ * Source: hosted.paysafe.com/checkout/1.8.0/main.bundle.js, SHA-256
78
+ * e9e111ff03073cc936a47674e8e7c5db4a6d1050e1b65bf5e20723116fd5080c.
79
+ * The guard checks original bytes and never changes the request.
80
+ */
81
+ export function isPreparedPaysafeRequest(url, method, body, environment, headers) {
82
+ if (method !== 'POST' || (environment !== 'production' && environment !== 'sandbox')
83
+ || paysafeEnvironment(url) !== environment)
84
+ return false;
85
+ const value = readTokenizationJson(body);
86
+ return freshCard(value) && record(value.clientInfo) && nativeHeaders(headers, value.clientInfo.correlationId);
87
+ }
@@ -124,7 +124,7 @@ export declare const CHECKOUT_PREFLIGHT_CAPABILITIES: {
124
124
  readonly requires: "reviewed_final_payment_and_continuation_evidence";
125
125
  }, {
126
126
  readonly id: "stripe.tokenization";
127
- readonly path_pattern: "^/v1/(?:payment_methods|tokens)$";
127
+ readonly path_pattern: "^/v1/(?:payment_methods|tokens|sources|confirmation_tokens)$";
128
128
  readonly effect: "token_or_method_creation";
129
129
  readonly amount_binding: "none";
130
130
  readonly psp: "stripe";
@@ -188,7 +188,7 @@ export const CHECKOUT_PREFLIGHT_CAPABILITIES = {
188
188
  },
189
189
  {
190
190
  "id": "stripe.tokenization",
191
- "path_pattern": "^/v1/(?:payment_methods|tokens)$",
191
+ "path_pattern": "^/v1/(?:payment_methods|tokens|sources|confirmation_tokens)$",
192
192
  "effect": "token_or_method_creation",
193
193
  "amount_binding": "none",
194
194
  "psp": "stripe",
@@ -2640,7 +2640,7 @@
2640
2640
  },
2641
2641
  {
2642
2642
  "id": "stripe.tokenization",
2643
- "path_pattern": "^/v1/(?:payment_methods|tokens)$",
2643
+ "path_pattern": "^/v1/(?:payment_methods|tokens|sources|confirmation_tokens)$",
2644
2644
  "effect": "token_or_method_creation",
2645
2645
  "amount_binding": "none",
2646
2646
  "psp": "stripe",
@@ -271,7 +271,7 @@ export declare function getCheckoutPreflightCatalog(): {
271
271
  readonly requires: "reviewed_final_payment_and_continuation_evidence";
272
272
  }, {
273
273
  readonly id: "stripe.tokenization";
274
- readonly path_pattern: "^/v1/(?:payment_methods|tokens)$";
274
+ readonly path_pattern: "^/v1/(?:payment_methods|tokens|sources|confirmation_tokens)$";
275
275
  readonly effect: "token_or_method_creation";
276
276
  readonly amount_binding: "none";
277
277
  readonly psp: "stripe";
@@ -22,4 +22,10 @@ export declare class PreparationGate {
22
22
  retireUnboundClaim(): void;
23
23
  /** A bound native request has its own cancellation/unknown-outcome machinery. */
24
24
  invalidate(reason: string): void;
25
+ /**
26
+ * Retire the server handle first, then report the form outcome. Neither
27
+ * call waits on the other: a stalled report cannot delay the cancel, and a
28
+ * stalled cancel cannot lose the report.
29
+ */
30
+ private cancelThenObserve;
25
31
  }
@@ -69,7 +69,7 @@ export class PreparationGate {
69
69
  if (prepared.psp !== options.psp || prepared.environment !== options.environment)
70
70
  throw new CheckoutPreparationError(prepared.id, 'checkout_changed');
71
71
  if (signal.aborted || this.state !== 'preparing') {
72
- void this.opts.vault.cancelPreparation(prepared.id).catch(() => { });
72
+ void this.cancelThenObserve(prepared.id, 'cancelled');
73
73
  throw new CheckoutPreparationError(prepared.id, 'cancelled');
74
74
  }
75
75
  await this.assertDocument();
@@ -135,7 +135,7 @@ export class PreparationGate {
135
135
  retireUnboundClaim() {
136
136
  if (this.state !== 'consumed' || !this.prepared || this.lifecycle.getState().authorizationId)
137
137
  return;
138
- void this.opts.vault.cancelPreparation(this.prepared.id).catch(() => { });
138
+ void this.cancelThenObserve(this.prepared.id, 'request_not_bound');
139
139
  // No bound ID means the adapter must not leave a spent handle appearing ready.
140
140
  if (this.lifecycle.getState().status === 'ready_to_submit')
141
141
  this.lifecycle.preparationFailed(new CheckoutPreparationError(this.prepared.id, 'request_not_bound'));
@@ -148,7 +148,17 @@ export class PreparationGate {
148
148
  clearTimeout(this.expiryTimer);
149
149
  this.stop.abort();
150
150
  if (this.prepared)
151
- void this.opts.vault.cancelPreparation(this.prepared.id).catch(() => { });
151
+ void this.cancelThenObserve(this.prepared.id, reason);
152
152
  this.lifecycle.preparationFailed(new CheckoutPreparationError(this.prepared?.id ?? null, reason));
153
153
  }
154
+ /**
155
+ * Retire the server handle first, then report the form outcome. Neither
156
+ * call waits on the other: a stalled report cannot delay the cancel, and a
157
+ * stalled cancel cannot lose the report.
158
+ */
159
+ async cancelThenObserve(id, reason) {
160
+ const cancellation = this.opts.vault.cancelPreparation(id).catch(() => { });
161
+ const observation = this.opts.vault.observePreparation?.(id, this.observedRequest ? 'presented_not_filled' : 'not_presented', reason);
162
+ await Promise.all([cancellation, observation?.catch(() => { })]);
163
+ }
154
164
  }
@@ -1,4 +1,4 @@
1
- export type PreparationProcessor = 'square' | 'braintree' | 'worldpay' | 'bambora' | 'mercado_pago' | 'recurly' | 'spreedly' | 'adyen' | 'checkout_com';
1
+ export type PreparationProcessor = 'square' | 'braintree' | 'worldpay' | 'bambora' | 'mercado_pago' | 'recurly' | 'spreedly' | 'adyen' | 'checkout_com' | 'paysafe';
2
2
  export type PreparationEnvironment = 'production' | 'sandbox' | 'shared';
3
3
  /** How the approved card reaches the processor: the device's own request (token), or ciphertext the device produces for this browser to send (cse, Adyen). */
4
4
  export type PreparationMode = 'token' | 'cse';
@@ -1,3 +1,4 @@
1
+ import { paysafePreparationUrl, isPreparedPaysafeRequest } from './paysafe.generated.js';
1
2
  import { adyenPreparationUrl, isPreparedAdyenRequest } from './adyen.generated.js';
2
3
  import { braintreeEnvironment, isPreparedBraintreeRequest, readTokenizationJson } from './braintree.js';
3
4
  import { isPreparedRecurlyRequest } from './recurly.generated.js';
@@ -10,11 +11,13 @@ export function preparationMode(psp) {
10
11
  export function validPreparationEnvironment(psp, environment) {
11
12
  if (psp === 'bambora' || psp === 'mercado_pago' || psp === 'recurly' || psp === 'spreedly')
12
13
  return environment === 'shared';
13
- return ['square', 'braintree', 'worldpay', 'adyen', 'checkout_com'].includes(psp) && ['production', 'sandbox'].includes(environment);
14
+ return ['square', 'braintree', 'worldpay', 'adyen', 'checkout_com', 'paysafe'].includes(psp) && ['production', 'sandbox'].includes(environment);
14
15
  }
15
16
  export function preparationEndpoint(psp, environment) {
16
17
  if (!validPreparationEnvironment(psp, environment))
17
18
  throw new Error('unsupported_preparation_processor');
19
+ if (psp === 'paysafe')
20
+ return paysafePreparationUrl(environment);
18
21
  if (psp === 'checkout_com')
19
22
  return checkoutComPreparationUrl(environment);
20
23
  if (psp === 'adyen')
@@ -79,6 +82,8 @@ function freshCardBody(psp, body) {
79
82
  export function matchesPreparedRequest(psp, environment, requestUrl, method, body, headers) {
80
83
  if (method.toUpperCase() !== 'POST' || !validPreparationEnvironment(psp, environment))
81
84
  return false;
85
+ if (psp === 'paysafe')
86
+ return isPreparedPaysafeRequest(requestUrl, method, body ?? null, environment, headers);
82
87
  if (psp === 'checkout_com')
83
88
  return isPreparedCheckoutComRequest(requestUrl, method, body ?? null, environment, headers);
84
89
  if (psp === 'adyen')
@@ -40,6 +40,21 @@ export interface Recognizer {
40
40
  * the call (signatures, client tokens). Matched case-insensitively.
41
41
  */
42
42
  passthroughHeaders: RegExp[];
43
+ /**
44
+ * Form keys that carry the card number. When present, a request on this
45
+ * recognizer's endpoints is a card request only if its body carries one of
46
+ * them: a Stripe confirmation that pays with a method the page already
47
+ * created (`payment_method=pm_...`) carries none, so there is nothing for
48
+ * the cardholder to approve. Served by the API and carried through
49
+ * syncRegistry() verbatim.
50
+ */
51
+ cardFields?: string[];
52
+ /**
53
+ * With cardFields: the endpoints (regex source over hostname + pathname,
54
+ * anchored) where a request without a card is routine and never ours, so
55
+ * it continues untouched; a request without a card anywhere else is refused.
56
+ */
57
+ passWithoutCard?: string;
43
58
  /**
44
59
  * True when the card is encrypted inside the page before the request leaves.
45
60
  * A digit swap is useless here; the vault must re-run the PSP's client-side
@@ -1,6 +1,6 @@
1
1
  // @ts-nocheck
2
2
  // Generated from @agent-cards/payment-core. Do not edit.
3
- // artifact-sha256: 0d2e18239c2a0102d3a473e339c8921aac7777e640ad44a2743a922204f2edfc
3
+ // artifact-sha256: 128bbf2359c38eda0d29a58c9b6f83123cf2d27d94365a5665e1569d605e9d2c
4
4
  // src/definitions.js
5
5
  function deepFreeze(value) {
6
6
  if (value && typeof value === "object" && !Object.isFrozen(value)) {
@@ -28,21 +28,42 @@ var PROCESSOR_DEFINITIONS = deepFreeze([
28
28
  },
29
29
  {
30
30
  psp: "stripe",
31
- // FOUR PAN-bearing surfaces, not two. `payment_methods` and `tokens` are the
32
- // LEGACY tokenizers. Modern Payment Element does not call either: it puts the
33
- // card inline in the intent confirmation as
34
- // `payment_method_data[card][number]`, so a merchant on the current SDK was
35
- // never paused at all. Observed live on Anthropic's own billing page
36
- // (platform.claude.com/settings/billing), which posts to
37
- // /v1/setup_intents/{seti_...}/confirm — that is the "save a card" flow;
38
- // /v1/payment_intents/{pi_...}/confirm is the same shape for "pay now".
39
- // The id segment is matched narrowly (seti_/pi_ + word chars) rather than
40
- // `.*` so the pattern cannot be widened by a crafted path.
41
- match: "api\\.stripe\\.com/v1/(payment_methods|tokens|setup_intents/seti_\\w+/confirm|payment_intents/pi_\\w+/confirm)",
31
+ // Every endpoint Stripe.js sends a card to, read from its own controller
32
+ // bundle: `tokens` (createToken), `payment_methods` (createPaymentMethod),
33
+ // `sources` (createSource, the older API), `confirmation_tokens`
34
+ // (createConfirmationToken: the page collects the card and confirms on its
35
+ // own server), and the intent confirmations that carry the card inline as
36
+ // `payment_method_data[card][number]` (confirmPayment, confirmSetup). The
37
+ // modern Payment Element mostly confirms inline or through a confirmation
38
+ // token, so matching the tokenizers alone never paused it. Observed live on
39
+ // Anthropic's own billing page (platform.claude.com/settings/billing),
40
+ // which posts to /v1/setup_intents/{seti_...}/confirm, the "save a card"
41
+ // flow; /v1/payment_intents/{pi_...}/confirm is the same shape for "pay
42
+ // now". Stripe Checkout Sessions confirm at
43
+ // /v1/payment_pages/{cs_...}/confirm: Checkout Elements (initCheckout)
44
+ // sends the card there inline, while hosted and embedded Checkout send only
45
+ // the payment method they created first. The id segment is matched
46
+ // narrowly (seti_/pi_/cs_ + word chars) rather than `.*` so the pattern
47
+ // cannot be widened by a crafted path. A client that does not check
48
+ // cardFields is served the pattern it knew before sources, confirmation
49
+ // tokens and Checkout Sessions joined (recognition.js recognizersForClient).
50
+ match: "api\\.stripe\\.com/v1/(payment_methods|tokens|sources|confirmation_tokens|setup_intents/seti_\\w+/confirm|payment_intents/pi_\\w+/confirm|payment_pages/cs_(?:test|live)_\\w+/confirm)",
42
51
  hosts: ["^api\\.stripe\\.com$"],
43
52
  encoding: "form",
44
53
  // Elements' own surface markers; without these Stripe rejects the surface.
45
- passthroughHeaders: ["^authorization$", "^stripe-version$", "^x-stripe-client-user-agent$"]
54
+ passthroughHeaders: ["^authorization$", "^stripe-version$", "^x-stripe-client-user-agent$"],
55
+ // The same confirmations also run WITHOUT a card: a page that created the
56
+ // method first confirms with `payment_method=pm_...`. Only a body that
57
+ // carries one of these fields is a card request (card-fields.js). Any
58
+ // other request on these endpoints has no card to approve, so it is
59
+ // refused (requestWithoutCard) instead of paused for the cardholder,
60
+ // unless passWithoutCard below names its endpoint.
61
+ cardFields: ["card[number]", "payment_method_data[card][number]"],
62
+ // A confirmation token that wraps a saved method, a source for a bank, or a
63
+ // Checkout Session confirm paying with the method hosted or embedded
64
+ // Checkout created first carries no card and was never ours: it continues
65
+ // untouched, as it did before these endpoints were recognized.
66
+ passWithoutCard: "api\\.stripe\\.com/v1/(sources|confirmation_tokens|payment_pages/cs_(?:test|live)_\\w+/confirm)"
46
67
  },
47
68
  {
48
69
  psp: "braintree",
@@ -498,6 +519,16 @@ function trustedClientRecognizer(pspOrEntry) {
498
519
  throw new Error("Refusing to pay: unknown payment processor.");
499
520
  return definition;
500
521
  }
522
+ // src/recognition.js
523
+ var PATTERN_WITHOUT_CARD_FIELDS = Object.freeze({
524
+ stripe: "api\\.stripe\\.com/v1/(payment_methods|tokens|setup_intents/seti_\\w+/confirm|payment_intents/pi_\\w+/confirm)"
525
+ });
526
+ var WITHOUT_CHECKOUT_SESSIONS = Object.freeze({
527
+ stripe: Object.freeze({
528
+ match: "api\\.stripe\\.com/v1/(payment_methods|tokens|sources|confirmation_tokens|setup_intents/seti_\\w+/confirm|payment_intents/pi_\\w+/confirm)",
529
+ passWithoutCard: "api\\.stripe\\.com/v1/(sources|confirmation_tokens)"
530
+ })
531
+ });
501
532
  // src/request-policy.js
502
533
  var HEADER_NEVER_FORWARD = /^(cookie|set-cookie|authorization2|proxy-authorization|host|content-length|origin|referer)$/i;
503
534
  var ALLOWED_MEDIA_TYPES = {
@@ -591,7 +622,7 @@ function validateStripeCheckoutCard(card, environment = "test") {
591
622
  reject();
592
623
  }
593
624
  else {
594
- if (!/^\d{13,19}(?![\s\S])/.test(card.number) || /^(\d)\1+(?![\s\S])/.test(card.number) || !/^\d{3,4}(?![\s\S])/.test(card.cvc) || SYNTHETIC_CARDS.includes(card.number))
625
+ if (!/^\d{12,19}(?![\s\S])/.test(card.number) || /^(\d)\1+(?![\s\S])/.test(card.number) || !/^\d{3,4}(?![\s\S])/.test(card.cvc) || SYNTHETIC_CARDS.includes(card.number))
595
626
  reject();
596
627
  let sum = 0, double = false;
597
628
  for (let i = card.number.length - 1; i >= 0; i--) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "catalog_version": "2026-09-15.1",
4
- "catalog_sha256": "e1bbae82b91134c8be5919fddd5d60ea56f6897b570f8ccb2f305cd5b0a1c085",
4
+ "catalog_sha256": "c605e3f370a2560e15d695e9ce80248f117076e4556f8dc7912ba1e54fb92315",
5
5
  "baseline_version": "kernel-native-docs-2026-09-11.1",
6
6
  "baseline_sha256": "d86722f49633d8223fccae54cd3d485df4375aee06aa156a273cc689b7c8507c",
7
7
  "native_build": null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Let browser agents pay with the user's own card, without your infrastructure ever touching card data.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "build": "node ../payment-core/scripts/build.mjs && node scripts/generate-payment-core.mjs && npx -y -p typescript@5.9.3 tsc && node scripts/generate-preflight-contract.mjs",
29
29
  "prepublishOnly": "pnpm build",
30
30
  "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs braintree.test.mjs autopilot.test.mjs stripe-checkout.test.mjs payment-core.test.mjs prepared-processor.test.mjs minimum-delay.test.mjs paysafe.test.mjs payu.test.mjs attachment.test.mjs worker-targets.test.mjs mercado-checkout.test.mjs mercado-polling.test.mjs && node --test preflight-package.test.mjs preflight-collector.test.mjs && node --test kernel-native-qualification.test.mjs && node --test ../vault/scripts/recurly-validation/watch-duty-sdk-result.test.mjs",
31
- "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs && node preparation-browser.test.mjs && node checkout-com-browser.test.mjs && node owned-shop-browser.test.mjs && node spreedly-browser.test.mjs && node worker-browser.test.mjs",
31
+ "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs && node preparation-browser.test.mjs && node checkout-com-browser.test.mjs && node paysafe-preparation-browser.test.mjs && node owned-shop-browser.test.mjs && node spreedly-browser.test.mjs && node worker-browser.test.mjs",
32
32
  "check:payment-core": "node scripts/generate-payment-core.mjs --check",
33
33
  "test:preflight": "node --test preflight-package.test.mjs preflight-collector.test.mjs kernel-native-qualification.test.mjs",
34
34
  "test:preflight:browser": "node preflight-browser.test.mjs",