@agent-cards/checkout 0.15.2 → 0.16.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 +4 -0
- package/README.md +14 -5
- package/dist/cdp.d.ts +8 -0
- package/dist/cdp.js +565 -58
- package/dist/checkout-com.generated.d.ts +4 -0
- package/dist/checkout-com.generated.js +183 -0
- package/dist/client.d.ts +32 -4
- package/dist/client.js +45 -9
- package/dist/index.d.ts +1 -1
- package/dist/lifecycle.d.ts +25 -0
- package/dist/lifecycle.js +34 -0
- package/dist/preparation.d.ts +1 -1
- package/dist/preparation.js +2 -2
- package/dist/prepared-processor.d.ts +2 -2
- package/dist/prepared-processor.js +7 -2
- package/package.json +2 -2
package/dist/cdp.js
CHANGED
|
@@ -306,7 +306,29 @@ function headerEntries(headers) {
|
|
|
306
306
|
* undefined and the authorization goes out without one (a merchant, category
|
|
307
307
|
* or place rule then refuses it as unknown, never the request itself).
|
|
308
308
|
*/
|
|
309
|
-
|
|
309
|
+
function pageOriginFrom(documentUrl) {
|
|
310
|
+
if (typeof documentUrl !== 'string')
|
|
311
|
+
return undefined;
|
|
312
|
+
try {
|
|
313
|
+
const url = new URL(documentUrl);
|
|
314
|
+
if (url.username || url.password)
|
|
315
|
+
return undefined;
|
|
316
|
+
if (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))
|
|
317
|
+
return url.origin;
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The top-level document's URL, read once with a one-second bound when a card
|
|
326
|
+
* request pauses: pageOriginFrom derives the merchant origin from it, and the
|
|
327
|
+
* retry rule (sameCheckoutRequest) compares it. Undefined when the page
|
|
328
|
+
* cannot be read, the attempt was stopped, or the read was late; the
|
|
329
|
+
* authorization then goes out without a page origin and no retry can bind.
|
|
330
|
+
*/
|
|
331
|
+
async function documentUrlOf(readDocumentUrl, signal) {
|
|
310
332
|
let timer;
|
|
311
333
|
let abort;
|
|
312
334
|
try {
|
|
@@ -320,14 +342,7 @@ async function pageOriginOf(readDocumentUrl, signal) {
|
|
|
320
342
|
abort();
|
|
321
343
|
}),
|
|
322
344
|
]);
|
|
323
|
-
|
|
324
|
-
return undefined;
|
|
325
|
-
const url = new URL(documentUrl);
|
|
326
|
-
if (url.username || url.password)
|
|
327
|
-
return undefined;
|
|
328
|
-
if (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))
|
|
329
|
-
return url.origin;
|
|
330
|
-
return undefined;
|
|
345
|
+
return typeof documentUrl === 'string' ? documentUrl : undefined;
|
|
331
346
|
}
|
|
332
347
|
catch {
|
|
333
348
|
return undefined;
|
|
@@ -391,13 +406,250 @@ function failureSummary(error) {
|
|
|
391
406
|
return `${error.name}: ${error.code ?? `http_${error.status}`}`;
|
|
392
407
|
return error instanceof Error ? error.name : 'CheckoutError';
|
|
393
408
|
}
|
|
394
|
-
/**
|
|
395
|
-
|
|
409
|
+
/**
|
|
410
|
+
* How long an approval waits, after the cardholder gives it, for the page to
|
|
411
|
+
* issue the request it will answer.
|
|
412
|
+
*
|
|
413
|
+
* This only matters when the page's own script abandoned its card request
|
|
414
|
+
* while the person was deciding (Braintree's client gives up after 60
|
|
415
|
+
* seconds, Square's after about 10) and did not ask again on its own. The
|
|
416
|
+
* approval is real: the device sent the card to the processor and a token
|
|
417
|
+
* came back, or encrypted it for the agent's browser. What is missing is a
|
|
418
|
+
* request to hand it to, and the application supplies one by clicking Pay
|
|
419
|
+
* again (the controller reads `ready_to_submit` with reason
|
|
420
|
+
* `awaiting_merchant_retry` while this wait runs). Two minutes is a first
|
|
421
|
+
* value, chosen rather than measured: long enough for an agent loop to read
|
|
422
|
+
* the state and act, short enough that a minted token never sits usable for
|
|
423
|
+
* the rest of the approval window. The approval window itself is the ceiling,
|
|
424
|
+
* whatever this is set to. Overridable per attach via `merchantRetryWaitMs`;
|
|
425
|
+
* the benchmark's retry gaps are what should replace it.
|
|
426
|
+
*/
|
|
427
|
+
const MERCHANT_RETRY_WAIT_MS = 2 * 60_000;
|
|
428
|
+
function retryWaitMs(value) {
|
|
429
|
+
if (value === undefined)
|
|
430
|
+
return MERCHANT_RETRY_WAIT_MS;
|
|
431
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > 15 * 60_000) {
|
|
432
|
+
throw new Error('merchantRetryWaitMs must be an integer between 1 and 900000.');
|
|
433
|
+
}
|
|
434
|
+
return value;
|
|
435
|
+
}
|
|
436
|
+
/** The page never asked again inside the retry wait; see merchantAttempt.awaitTarget. */
|
|
437
|
+
class MerchantNeverRetried extends Error {
|
|
438
|
+
authorizationId;
|
|
439
|
+
constructor(authorizationId) {
|
|
440
|
+
super('the merchant page never asked again');
|
|
441
|
+
this.authorizationId = authorizationId;
|
|
442
|
+
this.name = 'MerchantNeverRetried';
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** A body field that names the purchase's amount or currency, wherever a processor puts it. */
|
|
446
|
+
const AMOUNT_KEY = /^(amount|amount_?cents|total|sum|currency|currency_?code)$/i;
|
|
447
|
+
/**
|
|
448
|
+
* The amount and currency a body names, as `path=value` pairs: Adyen's
|
|
449
|
+
* `amount.value` and `amount.currency`, Razorpay's and Nuvei's `amount`
|
|
450
|
+
* and `currency`, Paysafe's `amount` and `currencyCode`, Tranzila's `sum`.
|
|
451
|
+
* A pure tokenization (Braintree, Square, Shopify, a Stripe PaymentMethod)
|
|
452
|
+
* names none and yields an empty list.
|
|
453
|
+
*/
|
|
454
|
+
function bodyAmounts(value, prefix, parentKey, out) {
|
|
455
|
+
if (Array.isArray(value)) {
|
|
456
|
+
for (const item of value)
|
|
457
|
+
bodyAmounts(item, `${prefix}[]`, parentKey, out);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (value === null || typeof value !== 'object') {
|
|
461
|
+
const key = prefix.split('.').pop() ?? '';
|
|
462
|
+
if (AMOUNT_KEY.test(key) || (key === 'value' && AMOUNT_KEY.test(parentKey)))
|
|
463
|
+
out.push(`${prefix}=${String(value)}`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const own = prefix.split('.').pop() ?? '';
|
|
467
|
+
for (const [key, child] of Object.entries(value))
|
|
468
|
+
bodyAmounts(child, prefix ? `${prefix}.${key}` : key, own, out);
|
|
469
|
+
}
|
|
470
|
+
/** The document a request came from, as compared between a request and its retry. */
|
|
471
|
+
function documentKey(url) {
|
|
472
|
+
if (typeof url !== 'string')
|
|
473
|
+
return undefined;
|
|
474
|
+
try {
|
|
475
|
+
const parsed = new URL(url);
|
|
476
|
+
parsed.hash = '';
|
|
477
|
+
return parsed.href;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** Luhn, on a run of digits: the placeholder card the agent typed passes it, most other long numbers do not. */
|
|
484
|
+
function luhn(digits) {
|
|
485
|
+
let sum = 0;
|
|
486
|
+
for (let i = 0; i < digits.length; i++) {
|
|
487
|
+
let d = digits.charCodeAt(digits.length - 1 - i) - 48;
|
|
488
|
+
if (i % 2 === 1) {
|
|
489
|
+
d *= 2;
|
|
490
|
+
if (d > 9)
|
|
491
|
+
d -= 9;
|
|
492
|
+
}
|
|
493
|
+
sum += d;
|
|
494
|
+
}
|
|
495
|
+
return sum % 10 === 0;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The card-placeholder runs in a body: every 13 to 19 digit run that passes
|
|
499
|
+
* Luhn, sorted. The placeholder the agent typed (4242 4242 4242 4242, 4111
|
|
500
|
+
* 1111 1111 1111) passes; a millisecond timestamp or an order number fails
|
|
501
|
+
* nine times in ten, and when one passes it merely makes the match stricter.
|
|
502
|
+
* A client-side-encrypted body (Adyen) carries no digits at all and yields
|
|
503
|
+
* an empty list on both sides.
|
|
504
|
+
*/
|
|
505
|
+
function cardRuns(body) {
|
|
506
|
+
return (body.match(/\d{13,19}/g) ?? []).filter(luhn).sort();
|
|
507
|
+
}
|
|
508
|
+
/** Every key path in a JSON value, arrays flattened so an element's order or count does not count. */
|
|
509
|
+
function jsonKeyPaths(value, prefix, out) {
|
|
510
|
+
if (Array.isArray(value)) {
|
|
511
|
+
for (const item of value)
|
|
512
|
+
jsonKeyPaths(item, `${prefix}[]`, out);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (value === null || typeof value !== 'object') {
|
|
516
|
+
if (prefix)
|
|
517
|
+
out.add(prefix);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
for (const [key, child] of Object.entries(value))
|
|
521
|
+
jsonKeyPaths(child, prefix ? `${prefix}.${key}` : key, out);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* The shape of a body plus its card placeholder, as a string two bodies can
|
|
525
|
+
* be compared by. JSON: its sorted key paths and its card runs, so a body the
|
|
526
|
+
* client regenerated (a new session id, a new nonce, a fresh encryption of
|
|
527
|
+
* the same dummy card) still matches while a different operation, a saved
|
|
528
|
+
* card or another card does not. Form-encoded: its sorted keys and its card
|
|
529
|
+
* runs, so Stripe's per-attempt `time_on_page` and fraud ids do not break a
|
|
530
|
+
* match but a different field set does. Anything else: the body itself.
|
|
531
|
+
*/
|
|
532
|
+
function bodyShape(body) {
|
|
533
|
+
const runs = cardRuns(body).join(',');
|
|
534
|
+
const trimmed = body.trimStart();
|
|
535
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
536
|
+
try {
|
|
537
|
+
const parsed = JSON.parse(body);
|
|
538
|
+
const paths = new Set();
|
|
539
|
+
jsonKeyPaths(parsed, '', paths);
|
|
540
|
+
const amounts = [];
|
|
541
|
+
bodyAmounts(parsed, '', '', amounts);
|
|
542
|
+
return { shape: `json:${[...paths].sort().join('|')};cards:${runs};amounts:${amounts.sort().join('|')}`, namesAmount: amounts.length > 0 };
|
|
543
|
+
}
|
|
544
|
+
catch { /* not JSON after all */ }
|
|
545
|
+
}
|
|
546
|
+
if (/^[^=&\s]+=[^&]*(&[^=&\s]+=[^&]*)*$/.test(body)) {
|
|
547
|
+
const params = new URLSearchParams(body);
|
|
548
|
+
const keys = [...new Set([...params.keys()])].sort();
|
|
549
|
+
const amounts = [...params.entries()].filter(([key]) => AMOUNT_KEY.test(key)).map(([key, value]) => `${key}=${value}`).sort();
|
|
550
|
+
return { shape: `form:${keys.join('|')};cards:${runs};amounts:${amounts.join('|')}`, namesAmount: amounts.length > 0 };
|
|
551
|
+
}
|
|
552
|
+
return { shape: `raw:${body}`, namesAmount: false };
|
|
553
|
+
}
|
|
554
|
+
/** The identity of a request from its parts; see CheckoutRequestIdentity. */
|
|
555
|
+
function requestIdentity(opts, url, method, body, documentUrl, pageAmount) {
|
|
556
|
+
const shaped = bodyShape(body);
|
|
557
|
+
return { url, method, documentUrl, pageAmount, body: shaped.shape,
|
|
558
|
+
amountEvidence: opts.amount != null || pageAmount !== undefined || shaped.namesAmount };
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* May this request be answered from the approval the other one raised?
|
|
562
|
+
*
|
|
563
|
+
* The plan this implements says the rule starts strict and loosens per
|
|
564
|
+
* processor with evidence, so it is strict: the same processor endpoint (the
|
|
565
|
+
* full URL, query included), the same method, the same top-level document,
|
|
566
|
+
* the same page total when the integrator reads one, and a body of the same
|
|
567
|
+
* shape carrying the same card placeholder and the same amount when the
|
|
568
|
+
* request names one. A request from another page, to another endpoint, for
|
|
569
|
+
* another card, for another amount or with another set of fields is a new
|
|
570
|
+
* question and is refused here, which leaves it to the ordinary "an approval
|
|
571
|
+
* is already outstanding" path.
|
|
572
|
+
*
|
|
573
|
+
* An amount has to be in evidence somewhere: the integrator's hint (what the
|
|
574
|
+
* cardholder was shown), the page total, or the request's own bytes. A
|
|
575
|
+
* tokenization request names no amount, so on a checkout with no hint and no
|
|
576
|
+
* page reader nothing could tell a retry for the approved cart from one for
|
|
577
|
+
* a changed cart, and the approval is not reused. With a page reader, a
|
|
578
|
+
* changed total refuses the retry; the hint is the integrator's statement of
|
|
579
|
+
* the purchase, and the approval was given for it.
|
|
580
|
+
*/
|
|
581
|
+
function sameCheckoutRequest(first, retry) {
|
|
582
|
+
return first.amountEvidence && first.url === retry.url && first.method.toUpperCase() === retry.method.toUpperCase()
|
|
583
|
+
&& first.documentUrl !== undefined && first.documentUrl === retry.documentUrl
|
|
584
|
+
&& (first.pageAmount?.amount === retry.pageAmount?.amount && first.pageAmount?.currency === retry.pageAmount?.currency)
|
|
585
|
+
&& first.body === retry.body;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Whether an approval may outlive the page's own request on this checkout.
|
|
589
|
+
*
|
|
590
|
+
* Yes for a plain tokenization or an encrypted-card request: the device's
|
|
591
|
+
* answer is a token or ciphertext, and any request for the same purchase can
|
|
592
|
+
* carry it. No for a hosted form (a navigation: there is no request to
|
|
593
|
+
* answer once the page has moved on), a prepared checkout (single use, bound
|
|
594
|
+
* to one native request by design), a native Stripe Checkout step, and a
|
|
595
|
+
* Mercado Pago request whose claim is bound to one document read. Unknown
|
|
596
|
+
* modes (a duck-typed vault without checkoutModeOf) are taken as `token`.
|
|
597
|
+
*/
|
|
598
|
+
function survivesAbandonment(opts, url, method, navigation, bound) {
|
|
599
|
+
if (navigation || bound)
|
|
600
|
+
return false;
|
|
601
|
+
const mode = typeof opts.vault?.checkoutModeOf === 'function' ? opts.vault.checkoutModeOf(url, method) : 'token';
|
|
602
|
+
return mode === 'token' || mode === 'cse';
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* One card request's attempt at an approval, and the browser request that
|
|
606
|
+
* currently carries it. Separate from explicit cancellation: the client can
|
|
607
|
+
* drain a late create ID.
|
|
608
|
+
*
|
|
609
|
+
* The request and the approval have different lives. The page's own script
|
|
610
|
+
* can abandon its request (Braintree's client gives up after 60 seconds,
|
|
611
|
+
* Square's after about 10) while the cardholder is still deciding on their
|
|
612
|
+
* phone; nothing about the checkout changed, the page asked and stopped
|
|
613
|
+
* listening. lose() records that: the approval keeps polling, the page's next
|
|
614
|
+
* request for the same purchase binds through bind() and is answered from the
|
|
615
|
+
* same authorization, and awaitTarget() is how the approval finds the request
|
|
616
|
+
* to answer, waiting for a retry when none is live. stop() is for the page
|
|
617
|
+
* itself going away (navigation of a prepared document, frame removal, close,
|
|
618
|
+
* crash), a request lost once delivery began, or an attempt that cannot
|
|
619
|
+
* survive abandonment at all: the approval cannot be reopened and the client
|
|
620
|
+
* cancels it.
|
|
621
|
+
*/
|
|
622
|
+
function merchantAttempt(lifecycle, survives) {
|
|
396
623
|
const controller = new AbortController();
|
|
397
624
|
let reason = 'merchant_request_aborted';
|
|
625
|
+
let target = null;
|
|
626
|
+
let lost = false;
|
|
627
|
+
let rebound = false;
|
|
628
|
+
let delivering = false;
|
|
629
|
+
let bound = null;
|
|
398
630
|
const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
|
|
399
|
-
|
|
631
|
+
const attempt = {
|
|
400
632
|
signal: controller.signal,
|
|
633
|
+
/** The browser request that currently carries this attempt; null while the page has none. */
|
|
634
|
+
get target() { return target; },
|
|
635
|
+
/** The page abandoned its request and has not asked again yet. */
|
|
636
|
+
get lost() { return lost; },
|
|
637
|
+
bind(next) { target = next; rebound = lost; lost = false; bound?.(); },
|
|
638
|
+
/** Delivery to the current target has begun; a loss from here on is an unknown outcome, never a retry. */
|
|
639
|
+
beginDelivery() { delivering = true; },
|
|
640
|
+
/** The page abandoned the current request. True when the approval survives it, false when the attempt stopped. */
|
|
641
|
+
lose() {
|
|
642
|
+
if (controller.signal.aborted)
|
|
643
|
+
return false;
|
|
644
|
+
if (!survives || delivering) {
|
|
645
|
+
attempt.stop();
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
target = null;
|
|
649
|
+
lost = true;
|
|
650
|
+
lifecycle.merchantRequestLost();
|
|
651
|
+
return true;
|
|
652
|
+
},
|
|
401
653
|
stop(cause = 'merchant_request_aborted') {
|
|
402
654
|
if (controller.signal.aborted)
|
|
403
655
|
return;
|
|
@@ -410,7 +662,82 @@ function merchantAttempt(lifecycle) {
|
|
|
410
662
|
},
|
|
411
663
|
assertLive() { if (controller.signal.aborted)
|
|
412
664
|
throw error(); },
|
|
665
|
+
/**
|
|
666
|
+
* The request to answer: the live one at once, or the page's retry,
|
|
667
|
+
* waited for up to `waitMs`. Resolves `rebound` when the answer goes to
|
|
668
|
+
* a request other than the one that raised the approval, so the caller
|
|
669
|
+
* can re-run request attribution on it. Rejects with MerchantNeverRetried
|
|
670
|
+
* when the wait ends with no request, and with the attempt's own error
|
|
671
|
+
* when the page goes away meanwhile.
|
|
672
|
+
*/
|
|
673
|
+
async awaitTarget(waitMs, stopSignals, onWaiting) {
|
|
674
|
+
attempt.assertLive();
|
|
675
|
+
if (target && !lost)
|
|
676
|
+
return { target, rebound };
|
|
677
|
+
onWaiting();
|
|
678
|
+
const signals = [controller.signal, ...stopSignals];
|
|
679
|
+
let timer;
|
|
680
|
+
let onAbort = () => { };
|
|
681
|
+
try {
|
|
682
|
+
await new Promise((resolve, reject) => {
|
|
683
|
+
bound = resolve;
|
|
684
|
+
onAbort = () => { try {
|
|
685
|
+
attempt.assertLive();
|
|
686
|
+
reject(new Error('checkout cancelled locally before the merchant asked again'));
|
|
687
|
+
}
|
|
688
|
+
catch (stopped) {
|
|
689
|
+
reject(stopped);
|
|
690
|
+
} };
|
|
691
|
+
for (const signal of signals)
|
|
692
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
693
|
+
if (signals.some((signal) => signal.aborted))
|
|
694
|
+
onAbort();
|
|
695
|
+
timer = setTimeout(() => reject(new MerchantNeverRetried(lifecycle.getState().authorizationId)), waitMs);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
finally {
|
|
699
|
+
bound = null;
|
|
700
|
+
clearTimeout(timer);
|
|
701
|
+
for (const signal of signals)
|
|
702
|
+
signal.removeEventListener('abort', onAbort);
|
|
703
|
+
}
|
|
704
|
+
attempt.assertLive();
|
|
705
|
+
if (!target)
|
|
706
|
+
throw new MerchantNeverRetried(lifecycle.getState().authorizationId);
|
|
707
|
+
return { target, rebound };
|
|
708
|
+
},
|
|
413
709
|
};
|
|
710
|
+
return attempt;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* The retry wait, capped by what is left of the approval window: a token
|
|
714
|
+
* must never sit usable past the moment the row itself would have expired.
|
|
715
|
+
*/
|
|
716
|
+
function boundedRetryWait(waitMs, approvalStartedAt, timeoutMs) {
|
|
717
|
+
const windowEnd = approvalStartedAt + (timeoutMs ?? 15 * 60_000);
|
|
718
|
+
return Math.max(0, Math.min(waitMs, windowEnd - Date.now()));
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Retire an approval the page never asked for again, and tell the lifecycle
|
|
722
|
+
* what became of it. A confirmed retirement ends as `declined` with reason
|
|
723
|
+
* `merchant_never_retried` (the cardholder's screen says the merchant did
|
|
724
|
+
* not finish and nothing was charged, and the next card request is a new
|
|
725
|
+
* question); anything else is an unknown outcome the application must
|
|
726
|
+
* reconcile, which holds the attachment. A vault without cancelAuthorization
|
|
727
|
+
* (a duck-typed client) cannot retire anything and lands there too.
|
|
728
|
+
*/
|
|
729
|
+
async function retireUnusedApproval(opts, lifecycle, authorizationId) {
|
|
730
|
+
const cancel = typeof opts.vault?.cancelAuthorization === 'function' && authorizationId
|
|
731
|
+
? opts.vault.cancelAuthorization(authorizationId, 'merchant_never_retried') : Promise.reject(new Error('no authorization to retire'));
|
|
732
|
+
try {
|
|
733
|
+
await cancel;
|
|
734
|
+
lifecycle.merchantNeverRetried(authorizationId);
|
|
735
|
+
opts.onEvent?.({ type: 'failed', detail: 'merchant_never_retried' });
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
lifecycle.failed(new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed'), true);
|
|
739
|
+
opts.onEvent?.({ type: 'failed', detail: 'PaymentOutcomeUnknownError' });
|
|
740
|
+
}
|
|
414
741
|
}
|
|
415
742
|
/**
|
|
416
743
|
* Take over card tokenization for a page.
|
|
@@ -507,6 +834,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
507
834
|
let quietUntil = 0;
|
|
508
835
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
509
836
|
let awaitingApproval = false;
|
|
837
|
+
// How long an approval waits for the page to ask again; see MERCHANT_RETRY_WAIT_MS.
|
|
838
|
+
const retryWait = retryWaitMs(opts.merchantRetryWaitMs);
|
|
510
839
|
let activeRequest = null;
|
|
511
840
|
const sourceOwner = (source) => workerOwners.get(source.sessionId) ?? source.sessionId;
|
|
512
841
|
const retireSource = (source) => {
|
|
@@ -716,6 +1045,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
716
1045
|
}
|
|
717
1046
|
if (preparationGate.isEngaged())
|
|
718
1047
|
activeRequest?.attempt.stop();
|
|
1048
|
+
// The page that abandoned its request has moved to another document:
|
|
1049
|
+
// its approval cannot be answered there and is cancelled. A reload of
|
|
1050
|
+
// the same document is not that; its re-issued request is the retry.
|
|
1051
|
+
if (method === 'Page.frameNavigated' && activeRequest?.attempt.lost
|
|
1052
|
+
&& documentKey(params.frame?.url) !== activeRequest.identity.documentUrl)
|
|
1053
|
+
activeRequest.attempt.stop();
|
|
719
1054
|
return;
|
|
720
1055
|
}
|
|
721
1056
|
if ((method === 'Inspector.detached' && sessionId === pageSessionId)
|
|
@@ -761,9 +1096,26 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
761
1096
|
return;
|
|
762
1097
|
}
|
|
763
1098
|
if (method === 'Network.loadingFailed') {
|
|
764
|
-
if (activeRequest && activeRequest.networkId === params.requestId
|
|
765
|
-
|
|
766
|
-
|
|
1099
|
+
if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId) {
|
|
1100
|
+
const dead = { requestId: activeRequest.requestId, sessionId: activeRequest.sessionId, request: activeRequest.request };
|
|
1101
|
+
// A request a dedicated worker sent keeps today's behaviour: its loss
|
|
1102
|
+
// stops the attempt. A worker's retry pauses on the owning frame and
|
|
1103
|
+
// is attributed only through the worker's own Network events, and
|
|
1104
|
+
// binding an approval across that attribution is not something this
|
|
1105
|
+
// change claims to get right; the observed failure is page script.
|
|
1106
|
+
const fromWorker = sessionId !== activeRequest.sessionId;
|
|
1107
|
+
if (fromWorker ? (activeRequest.attempt.stop(), false) : activeRequest.attempt.lose()) {
|
|
1108
|
+
// The page's own script gave up on its request; the cardholder's
|
|
1109
|
+
// approval did not (see merchantAttempt). Release the dead
|
|
1110
|
+
// interception and forget the request's identity, so no later event
|
|
1111
|
+
// on it can pass for the retry's.
|
|
1112
|
+
activeRequest.networkId = '';
|
|
1113
|
+
activeRequest.frameId = undefined;
|
|
1114
|
+
activeRequest.sourceSessionId = undefined;
|
|
1115
|
+
opts.onEvent?.({ type: 'merchant_request_lost', detail: { authorizationId: lifecycle.getState().authorizationId } });
|
|
1116
|
+
cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: 'Aborted' }, dead.sessionId).catch(() => { });
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
767
1119
|
const key = requestKey(sessionId, params.requestId);
|
|
768
1120
|
retireSource(requestSources.get(key) ?? { sessionId, networkId: params.requestId });
|
|
769
1121
|
requestSources.delete(key);
|
|
@@ -940,13 +1292,37 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
940
1292
|
}
|
|
941
1293
|
let preparation;
|
|
942
1294
|
try {
|
|
943
|
-
preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request));
|
|
1295
|
+
preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request), request.headers);
|
|
944
1296
|
}
|
|
945
1297
|
catch (error) {
|
|
946
1298
|
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
947
1299
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
948
1300
|
return;
|
|
949
1301
|
}
|
|
1302
|
+
// The page asking again for the purchase an outstanding approval is for:
|
|
1303
|
+
// its own request timed out while the cardholder decides (see
|
|
1304
|
+
// merchantAttempt). Bind it to that approval instead of refusing it or
|
|
1305
|
+
// raising a second prompt. A request that is not the same purchase falls
|
|
1306
|
+
// through to the ordinary refusal below.
|
|
1307
|
+
if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()
|
|
1308
|
+
&& typeof networkId === 'string' && networkId) {
|
|
1309
|
+
const retryBody = pausedBody(request);
|
|
1310
|
+
const retry = retryBody === null ? null : requestIdentity(opts, request.url, request.method, retryBody, documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
|
|
1311
|
+
if (retry && activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
|
|
1312
|
+
if (preparation)
|
|
1313
|
+
preparationGate.retireUnboundClaim();
|
|
1314
|
+
activeRequest.requestId = requestId;
|
|
1315
|
+
activeRequest.networkId = networkId;
|
|
1316
|
+
activeRequest.sessionId = sessionId;
|
|
1317
|
+
activeRequest.frameId = frameId;
|
|
1318
|
+
activeRequest.request = request;
|
|
1319
|
+
activeRequest.sourceSessionId = undefined;
|
|
1320
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}),
|
|
1321
|
+
authorizationId: lifecycle.getState().authorizationId, retry: true } });
|
|
1322
|
+
activeRequest.attempt.bind({ requestId, sessionId, request });
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
950
1326
|
// Same stop condition as the Playwright adapter: once a failure proves
|
|
951
1327
|
// retrying is pointless, fail the request without calling the API again.
|
|
952
1328
|
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
@@ -961,8 +1337,11 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
961
1337
|
// requests can never both clear the check above and raise two prompts for
|
|
962
1338
|
// one checkout.
|
|
963
1339
|
awaitingApproval = true;
|
|
964
|
-
const attempt = merchantAttempt(lifecycle);
|
|
1340
|
+
const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url, request.method, resourceType === 'Document', !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url, request.method)));
|
|
965
1341
|
let handoffStarted = false;
|
|
1342
|
+
// The approval the device gave, and whether it reached a browser request.
|
|
1343
|
+
let approvedId = null;
|
|
1344
|
+
let deliveryBegun = false;
|
|
966
1345
|
try {
|
|
967
1346
|
const body = pausedBody(request);
|
|
968
1347
|
if (body === null) {
|
|
@@ -983,17 +1362,26 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
983
1362
|
}
|
|
984
1363
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
985
1364
|
lifecycle.begin();
|
|
1365
|
+
const approvalStartedAt = Date.now();
|
|
986
1366
|
if (typeof networkId !== 'string' || !networkId) {
|
|
987
1367
|
attempt.stop();
|
|
988
1368
|
attempt.assertLive();
|
|
989
1369
|
}
|
|
990
|
-
|
|
1370
|
+
attempt.bind({ requestId, sessionId, request });
|
|
1371
|
+
activeRequest = { attempt, requestId, networkId, sessionId, frameId, request,
|
|
1372
|
+
identity: requestIdentity(opts, request.url, request.method, body, undefined, undefined) };
|
|
991
1373
|
await awaitRequestSource(activeRequest);
|
|
992
1374
|
if (preparation)
|
|
993
1375
|
await preparationGate.assertDocument();
|
|
994
|
-
|
|
1376
|
+
// One bounded read of the document serves both the merchant origin and
|
|
1377
|
+
// the identity a retry has to repeat (see sameCheckoutRequest).
|
|
1378
|
+
const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
|
|
1379
|
+
const pageOrigin = pageOriginFrom(documentUrl);
|
|
995
1380
|
const pageAmount = await pageAmountOf(opts);
|
|
996
1381
|
attempt.assertLive();
|
|
1382
|
+
activeRequest.identity.documentUrl = documentKey(documentUrl);
|
|
1383
|
+
activeRequest.identity.pageAmount = pageAmount;
|
|
1384
|
+
activeRequest.identity.amountEvidence ||= pageAmount !== undefined;
|
|
997
1385
|
if (stripeStep)
|
|
998
1386
|
stripeCheckout.assertClaim(stripeStep);
|
|
999
1387
|
const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
|
|
@@ -1025,6 +1413,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1025
1413
|
? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}) },
|
|
1026
1414
|
});
|
|
1027
1415
|
attempt.assertLive();
|
|
1416
|
+
approvedId = replay.authorizationId;
|
|
1028
1417
|
if (lifecycle.isCancelled())
|
|
1029
1418
|
throw new Error('checkout cancelled locally after approval');
|
|
1030
1419
|
try {
|
|
@@ -1034,7 +1423,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1034
1423
|
throw new PaymentOutcomeUnknownError(replay.authorizationId, 'mercado_checkout_metadata_invalid');
|
|
1035
1424
|
}
|
|
1036
1425
|
attempt.assertLive();
|
|
1037
|
-
|
|
1426
|
+
// The request to answer: the one that raised the approval while it is
|
|
1427
|
+
// still paused, or the page's retry when the page gave up on the first
|
|
1428
|
+
// (waited for, bounded; see merchantAttempt.awaitTarget). A retry is
|
|
1429
|
+
// attributed to its frame or worker exactly as the first request was.
|
|
1430
|
+
const delivery = await attempt.awaitTarget(boundedRetryWait(retryWait, approvalStartedAt, opts.timeoutMs), [lifecycle.abort.signal, setupStop.signal], () => {
|
|
1431
|
+
lifecycle.awaitingMerchantRetry(replay.authorizationId);
|
|
1432
|
+
opts.onEvent?.({ type: 'approval_awaiting_merchant_retry', detail: { authorizationId: replay.authorizationId } });
|
|
1433
|
+
});
|
|
1434
|
+
if (delivery.rebound) {
|
|
1435
|
+
await awaitRequestSource(activeRequest);
|
|
1436
|
+
attempt.assertLive();
|
|
1437
|
+
}
|
|
1438
|
+
const { requestId: deliverTo, sessionId: deliverOn, request: deliverRequest } = delivery.target;
|
|
1439
|
+
const deliverBody = delivery.rebound ? pausedBody(deliverRequest) ?? body : body;
|
|
1440
|
+
deliveryBegun = true;
|
|
1441
|
+
attempt.beginDelivery();
|
|
1442
|
+
lifecycle.prepareHandoff(replay, deliverRequest.url);
|
|
1038
1443
|
handoffStarted = replay.mode !== 'cse';
|
|
1039
1444
|
if (replay.mode === 'hosted_form') {
|
|
1040
1445
|
// The device submitted the processor's own form; the processor
|
|
@@ -1046,13 +1451,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1046
1451
|
// A navigation response is never CORS-checked, so the CORS wrap is
|
|
1047
1452
|
// inert here; it is applied so every fulfill goes through one path.
|
|
1048
1453
|
await cdp.send('Fetch.fulfillRequest', {
|
|
1049
|
-
requestId,
|
|
1454
|
+
requestId: deliverTo,
|
|
1050
1455
|
responseCode: page.status,
|
|
1051
|
-
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(
|
|
1456
|
+
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(deliverRequest.url, deliverRequest.headers))),
|
|
1052
1457
|
body: Buffer.from(page.body).toString('base64'),
|
|
1053
|
-
},
|
|
1458
|
+
}, deliverOn);
|
|
1054
1459
|
attempt.assertLive();
|
|
1055
|
-
lastSubmitted = { url:
|
|
1460
|
+
lastSubmitted = { url: deliverRequest.url, body: deliverBody, at: Date.now() };
|
|
1056
1461
|
// Named for what it is: a device-attested submission with no
|
|
1057
1462
|
// processor evidence, never an `authorized` event.
|
|
1058
1463
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
@@ -1063,12 +1468,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1063
1468
|
// and cookies, and only the four ciphertext fields swapped in. Only
|
|
1064
1469
|
// postData rides on the command: no header override, ever (see
|
|
1065
1470
|
// cseBody for why a recomputed Content-Length is refused by Chromium).
|
|
1066
|
-
const postData = Buffer.from(cseBody(
|
|
1471
|
+
const postData = Buffer.from(cseBody(deliverBody, replay)).toString('base64');
|
|
1067
1472
|
handoffStarted = true;
|
|
1068
1473
|
await cdp.send('Fetch.continueRequest', {
|
|
1069
|
-
requestId,
|
|
1474
|
+
requestId: deliverTo,
|
|
1070
1475
|
postData,
|
|
1071
|
-
},
|
|
1476
|
+
}, deliverOn);
|
|
1072
1477
|
attempt.assertLive();
|
|
1073
1478
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
1074
1479
|
}
|
|
@@ -1079,13 +1484,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1079
1484
|
// decision rides on the event, so a fulfill the page could not read
|
|
1080
1485
|
// (no usable Origin on a cross-origin request) is visible in telemetry
|
|
1081
1486
|
// rather than only as the page's own "connection error".
|
|
1082
|
-
const cors = corsDecision(
|
|
1487
|
+
const cors = corsDecision(deliverRequest.url, deliverRequest.headers);
|
|
1083
1488
|
await cdp.send('Fetch.fulfillRequest', {
|
|
1084
|
-
requestId,
|
|
1489
|
+
requestId: deliverTo,
|
|
1085
1490
|
responseCode: replay.status,
|
|
1086
1491
|
responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
|
|
1087
1492
|
body: Buffer.from(replay.body).toString('base64'),
|
|
1088
|
-
},
|
|
1493
|
+
}, deliverOn);
|
|
1089
1494
|
attempt.assertLive();
|
|
1090
1495
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
1091
1496
|
}
|
|
@@ -1093,6 +1498,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1093
1498
|
}
|
|
1094
1499
|
catch (err) {
|
|
1095
1500
|
// Ignore the failure event caused by our own safe decline/error abort.
|
|
1501
|
+
const dead = attempt.target ?? { requestId, sessionId };
|
|
1096
1502
|
if (activeRequest?.attempt === attempt)
|
|
1097
1503
|
activeRequest = null;
|
|
1098
1504
|
// Vault clients may normalize a merchantSignal abort. Preserve the
|
|
@@ -1103,15 +1509,25 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1103
1509
|
catch (stopped) {
|
|
1104
1510
|
err = stopped;
|
|
1105
1511
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1512
|
+
if (err instanceof MerchantNeverRetried || (attempt.signal.aborted && approvedId && !deliveryBegun)) {
|
|
1513
|
+
// The device approved and no request ever carried the answer: the
|
|
1514
|
+
// page never asked again, or went away while the approval waited.
|
|
1515
|
+
// Retire it (see retireUnusedApproval) and quiet the page's next
|
|
1516
|
+
// request as after any other answered approval.
|
|
1517
|
+
await retireUnusedApproval(opts, lifecycle, approvedId);
|
|
1112
1518
|
quietUntil = Date.now() + cooldownMs;
|
|
1113
|
-
|
|
1114
|
-
|
|
1519
|
+
}
|
|
1520
|
+
else {
|
|
1521
|
+
// stop() already published the terminal state before aborting the Vault.
|
|
1522
|
+
if (!attempt.signal.aborted)
|
|
1523
|
+
lifecycle.failed(err, handoffStarted);
|
|
1524
|
+
if (isTerminal(err))
|
|
1525
|
+
terminal = err;
|
|
1526
|
+
else if (isApprovalOutcome(err))
|
|
1527
|
+
quietUntil = Date.now() + cooldownMs;
|
|
1528
|
+
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
1529
|
+
}
|
|
1530
|
+
await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: 'Aborted' }, dead.sessionId).catch(() => { });
|
|
1115
1531
|
}
|
|
1116
1532
|
finally {
|
|
1117
1533
|
if (activeRequest?.attempt === attempt)
|
|
@@ -1216,10 +1632,18 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1216
1632
|
let quietUntil = 0;
|
|
1217
1633
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
1218
1634
|
let awaitingApproval = false;
|
|
1635
|
+
// How long an approval waits for the page to ask again; see MERCHANT_RETRY_WAIT_MS.
|
|
1636
|
+
const retryWait = retryWaitMs(opts.merchantRetryWaitMs);
|
|
1219
1637
|
let activeRequest = null;
|
|
1220
1638
|
page.on?.('requestfailed', (request) => {
|
|
1221
|
-
if (activeRequest && activeRequest.request === request)
|
|
1222
|
-
|
|
1639
|
+
if (activeRequest && activeRequest.request === request && activeRequest.attempt.lose()) {
|
|
1640
|
+
// The page's own script gave up on its request; the cardholder's
|
|
1641
|
+
// approval did not (see merchantAttempt). Forget the request's identity
|
|
1642
|
+
// so no later event on it can pass for the retry's.
|
|
1643
|
+
activeRequest.request = null;
|
|
1644
|
+
activeRequest.frames = [];
|
|
1645
|
+
opts.onEvent?.({ type: 'merchant_request_lost', detail: { authorizationId: lifecycle.getState().authorizationId } });
|
|
1646
|
+
}
|
|
1223
1647
|
});
|
|
1224
1648
|
page.on?.('close', () => { if (!attachmentReady)
|
|
1225
1649
|
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); });
|
|
@@ -1235,6 +1659,18 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1235
1659
|
}
|
|
1236
1660
|
if (preparationGate.isEngaged())
|
|
1237
1661
|
activeRequest?.attempt.stop();
|
|
1662
|
+
// The page that abandoned its request has moved to another document:
|
|
1663
|
+
// its approval cannot be answered there and is cancelled. A reload of
|
|
1664
|
+
// the same document is not that; its re-issued request is the retry.
|
|
1665
|
+
let url;
|
|
1666
|
+
try {
|
|
1667
|
+
url = typeof frame.url === 'function' ? frame.url() : undefined;
|
|
1668
|
+
}
|
|
1669
|
+
catch {
|
|
1670
|
+
url = undefined;
|
|
1671
|
+
}
|
|
1672
|
+
if (activeRequest?.attempt.lost && documentKey(url) !== activeRequest.identity.documentUrl)
|
|
1673
|
+
activeRequest.attempt.stop();
|
|
1238
1674
|
}
|
|
1239
1675
|
});
|
|
1240
1676
|
page.on?.('framedetached', (frame) => {
|
|
@@ -1347,12 +1783,33 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1347
1783
|
}
|
|
1348
1784
|
let preparation;
|
|
1349
1785
|
try {
|
|
1350
|
-
preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '');
|
|
1786
|
+
preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '', request.headers());
|
|
1351
1787
|
}
|
|
1352
1788
|
catch (error) {
|
|
1353
1789
|
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
1354
1790
|
return route.abort('aborted');
|
|
1355
1791
|
}
|
|
1792
|
+
// The page asking again for the purchase an outstanding approval is
|
|
1793
|
+
// for, after its own request timed out: bind it to that approval (see
|
|
1794
|
+
// merchantAttempt and the same check in attachToCdp).
|
|
1795
|
+
if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()) {
|
|
1796
|
+
const retry = requestIdentity(opts, request.url(), request.method(), request.postData() ?? '', documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
|
|
1797
|
+
if (activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
|
|
1798
|
+
if (preparation)
|
|
1799
|
+
preparationGate.retireUnboundClaim();
|
|
1800
|
+
const retryFrames = [];
|
|
1801
|
+
try {
|
|
1802
|
+
for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
|
|
1803
|
+
retryFrames.push(frame);
|
|
1804
|
+
}
|
|
1805
|
+
catch { /* covered by requestfailed */ }
|
|
1806
|
+
activeRequest.request = request;
|
|
1807
|
+
activeRequest.frames = retryFrames;
|
|
1808
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()), authorizationId: lifecycle.getState().authorizationId, retry: true } });
|
|
1809
|
+
activeRequest.attempt.bind({ route, request, frames: retryFrames });
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1356
1813
|
// Fail closed and stay quiet: no card may reach the PSP, but neither may
|
|
1357
1814
|
// the page's retry loop turn into a stream of doomed API calls. Every
|
|
1358
1815
|
// abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
|
|
@@ -1367,19 +1824,34 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1367
1824
|
}
|
|
1368
1825
|
// Reserved before anything that could yield, matching attachToCdp.
|
|
1369
1826
|
awaitingApproval = true;
|
|
1370
|
-
|
|
1827
|
+
let navigation = false;
|
|
1828
|
+
try {
|
|
1829
|
+
navigation = typeof request.resourceType === 'function' && request.resourceType() === 'document';
|
|
1830
|
+
}
|
|
1831
|
+
catch {
|
|
1832
|
+
navigation = false;
|
|
1833
|
+
}
|
|
1834
|
+
const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url(), request.method(), navigation, !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url(), request.method())));
|
|
1371
1835
|
const frames = [];
|
|
1372
1836
|
try {
|
|
1373
1837
|
for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
|
|
1374
1838
|
frames.push(frame);
|
|
1375
1839
|
}
|
|
1376
1840
|
catch { /* requestfailed/page close still cover unavailable frame metadata */ }
|
|
1841
|
+
// Judged on the request that currently carries the attempt: after a
|
|
1842
|
+
// retry binds, the first request's failure is history, not a loss.
|
|
1377
1843
|
const assertRequestLive = () => {
|
|
1378
|
-
|
|
1844
|
+
const current = attempt.target;
|
|
1845
|
+
if (current && (current.request.failure?.() || current.frames.some((frame) => frame.isDetached?.())))
|
|
1846
|
+
attempt.lose();
|
|
1847
|
+
if (page.isClosed?.())
|
|
1379
1848
|
attempt.stop();
|
|
1380
1849
|
attempt.assertLive();
|
|
1381
1850
|
};
|
|
1382
1851
|
let handoffStarted = false;
|
|
1852
|
+
// The approval the device gave, and whether it reached a browser request.
|
|
1853
|
+
let approvedId = null;
|
|
1854
|
+
let deliveryBegun = false;
|
|
1383
1855
|
try {
|
|
1384
1856
|
const body = request.postData() ?? '';
|
|
1385
1857
|
if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
|
|
@@ -1388,12 +1860,20 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1388
1860
|
}
|
|
1389
1861
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
|
|
1390
1862
|
lifecycle.begin();
|
|
1391
|
-
|
|
1863
|
+
const approvalStartedAt = Date.now();
|
|
1864
|
+
attempt.bind({ route, request, frames });
|
|
1865
|
+
activeRequest = { request, frames, attempt,
|
|
1866
|
+
identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined) };
|
|
1392
1867
|
if (preparation)
|
|
1393
1868
|
await preparationGate.assertDocument();
|
|
1394
|
-
|
|
1869
|
+
// One bounded read serves the merchant origin and the retry identity; see attachToCdp.
|
|
1870
|
+
const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
|
|
1871
|
+
const pageOrigin = pageOriginFrom(documentUrl);
|
|
1395
1872
|
const pageAmount = await pageAmountOf(opts);
|
|
1396
1873
|
assertRequestLive();
|
|
1874
|
+
activeRequest.identity.documentUrl = documentKey(documentUrl);
|
|
1875
|
+
activeRequest.identity.pageAmount = pageAmount;
|
|
1876
|
+
activeRequest.identity.amountEvidence ||= pageAmount !== undefined;
|
|
1397
1877
|
if (stripeStep)
|
|
1398
1878
|
stripeCheckout.assertClaim(stripeStep);
|
|
1399
1879
|
const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
|
|
@@ -1425,6 +1905,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1425
1905
|
? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}) },
|
|
1426
1906
|
});
|
|
1427
1907
|
assertRequestLive();
|
|
1908
|
+
approvedId = replay.authorizationId;
|
|
1428
1909
|
if (lifecycle.isCancelled())
|
|
1429
1910
|
throw new Error('checkout cancelled locally after approval');
|
|
1430
1911
|
try {
|
|
@@ -1434,25 +1915,35 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1434
1915
|
throw new PaymentOutcomeUnknownError(replay.authorizationId, 'mercado_checkout_metadata_invalid');
|
|
1435
1916
|
}
|
|
1436
1917
|
assertRequestLive();
|
|
1437
|
-
|
|
1918
|
+
// The request to answer: the paused one, or the page's retry when the
|
|
1919
|
+
// page gave up on the first (see attachToCdp for the same step).
|
|
1920
|
+
const delivery = await attempt.awaitTarget(boundedRetryWait(retryWait, approvalStartedAt, opts.timeoutMs), [lifecycle.abort.signal, setupStop.signal], () => {
|
|
1921
|
+
lifecycle.awaitingMerchantRetry(replay.authorizationId);
|
|
1922
|
+
opts.onEvent?.({ type: 'approval_awaiting_merchant_retry', detail: { authorizationId: replay.authorizationId } });
|
|
1923
|
+
});
|
|
1924
|
+
const { route: deliverTo, request: deliverRequest } = delivery.target;
|
|
1925
|
+
const deliverBody = delivery.rebound ? deliverRequest.postData() ?? '' : body;
|
|
1926
|
+
deliveryBegun = true;
|
|
1927
|
+
attempt.beginDelivery();
|
|
1928
|
+
lifecycle.prepareHandoff(replay, deliverRequest.url());
|
|
1438
1929
|
handoffStarted = replay.mode !== 'cse';
|
|
1439
1930
|
if (replay.mode === 'hosted_form') {
|
|
1440
1931
|
// Same as the CDP path: the paused navigation resolves to the
|
|
1441
1932
|
// synthetic page, and a re-post of this form is refused.
|
|
1442
1933
|
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
1443
1934
|
// Inert on a navigation (never CORS-checked); one path for every fulfill.
|
|
1444
|
-
await
|
|
1935
|
+
await deliverTo.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(deliverRequest.url(), deliverRequest.headers())), body: synthetic.body });
|
|
1445
1936
|
assertRequestLive();
|
|
1446
|
-
lastSubmitted = { url:
|
|
1937
|
+
lastSubmitted = { url: deliverRequest.url(), body: deliverBody, at: Date.now() };
|
|
1447
1938
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
1448
1939
|
}
|
|
1449
1940
|
else if (replay.mode === 'cse') {
|
|
1450
1941
|
// Same as the CDP path: the request continues from this browser
|
|
1451
1942
|
// with the ciphertext swapped in and no header override; Playwright
|
|
1452
1943
|
// recomputes the length itself.
|
|
1453
|
-
const postData = cseBody(
|
|
1944
|
+
const postData = cseBody(deliverBody, replay);
|
|
1454
1945
|
handoffStarted = true;
|
|
1455
|
-
await
|
|
1946
|
+
await deliverTo.continue({ postData });
|
|
1456
1947
|
assertRequestLive();
|
|
1457
1948
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
1458
1949
|
}
|
|
@@ -1460,23 +1951,39 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1460
1951
|
// Playwright adds these itself when a cross-origin fulfill carries
|
|
1461
1952
|
// none; written here anyway (replacing a stale value) so a
|
|
1462
1953
|
// cross-origin answer is the same whichever adapter ran.
|
|
1463
|
-
const cors = corsDecision(
|
|
1464
|
-
await
|
|
1954
|
+
const cors = corsDecision(deliverRequest.url(), deliverRequest.headers());
|
|
1955
|
+
await deliverTo.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
|
|
1465
1956
|
assertRequestLive();
|
|
1466
1957
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
1467
1958
|
}
|
|
1468
1959
|
lifecycle.handedOff(replay);
|
|
1469
1960
|
}
|
|
1470
1961
|
catch (err) {
|
|
1962
|
+
const dead = attempt.target?.route ?? route;
|
|
1471
1963
|
if (activeRequest?.attempt === attempt)
|
|
1472
1964
|
activeRequest = null;
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1965
|
+
try {
|
|
1966
|
+
attempt.assertLive();
|
|
1967
|
+
}
|
|
1968
|
+
catch (stopped) {
|
|
1969
|
+
err = stopped;
|
|
1970
|
+
}
|
|
1971
|
+
if (err instanceof MerchantNeverRetried || (attempt.signal.aborted && approvedId && !deliveryBegun)) {
|
|
1972
|
+
// The device approved and no request ever carried the answer; see
|
|
1973
|
+
// the same branch in attachToCdp.
|
|
1974
|
+
await retireUnusedApproval(opts, lifecycle, approvedId);
|
|
1477
1975
|
quietUntil = Date.now() + cooldownMs;
|
|
1478
|
-
|
|
1479
|
-
|
|
1976
|
+
}
|
|
1977
|
+
else {
|
|
1978
|
+
if (!attempt.signal.aborted)
|
|
1979
|
+
lifecycle.failed(err, handoffStarted);
|
|
1980
|
+
if (isTerminal(err))
|
|
1981
|
+
terminal = err;
|
|
1982
|
+
else if (isApprovalOutcome(err))
|
|
1983
|
+
quietUntil = Date.now() + cooldownMs;
|
|
1984
|
+
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
1985
|
+
}
|
|
1986
|
+
await dead.abort('aborted').catch(() => { });
|
|
1480
1987
|
}
|
|
1481
1988
|
finally {
|
|
1482
1989
|
if (activeRequest?.attempt === attempt)
|