@agent-cards/checkout 0.15.1 → 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 +38 -1
- package/dist/cdp.js +912 -75
- 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 +3 -3
package/dist/cdp.js
CHANGED
|
@@ -35,6 +35,27 @@ function isRepeatOfSubmitted(last, url, body, quietMs) {
|
|
|
35
35
|
return !!last && last.url === url && last.body === body && Date.now() - last.at < quietMs;
|
|
36
36
|
}
|
|
37
37
|
const HOSTED_FORM_REPEAT_REASON = 'already submitted on the cardholder\'s device';
|
|
38
|
+
function targetRecord(value) {
|
|
39
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
function contextId(value) {
|
|
42
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\u0000-\u0020\u007f]/.test(value);
|
|
43
|
+
}
|
|
44
|
+
/** Dedicated worker requests use their owning frame's Fetch interception.
|
|
45
|
+
* Shared/service workers do not have that ownership guarantee. Refuse their
|
|
46
|
+
* browser context, including newly discovered targets, before approving a card.
|
|
47
|
+
* A different, explicitly identified context belongs to another checkout.
|
|
48
|
+
*/
|
|
49
|
+
function assertContextTarget(target, merchantContext) {
|
|
50
|
+
if (!targetRecord(target))
|
|
51
|
+
throw new CheckoutAttachmentError('unavailable');
|
|
52
|
+
if (contextId(target.browserContextId) && target.browserContextId !== merchantContext)
|
|
53
|
+
return;
|
|
54
|
+
if (!['page', 'iframe', 'worker', 'browser_ui'].includes(target.type)
|
|
55
|
+
|| (target.browserContextId !== undefined && !contextId(target.browserContextId))) {
|
|
56
|
+
throw new CheckoutAttachmentError('unavailable');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
38
59
|
/**
|
|
39
60
|
* How long to stop asking after a person declines or ignores an approval.
|
|
40
61
|
*
|
|
@@ -285,7 +306,29 @@ function headerEntries(headers) {
|
|
|
285
306
|
* undefined and the authorization goes out without one (a merchant, category
|
|
286
307
|
* or place rule then refuses it as unknown, never the request itself).
|
|
287
308
|
*/
|
|
288
|
-
|
|
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) {
|
|
289
332
|
let timer;
|
|
290
333
|
let abort;
|
|
291
334
|
try {
|
|
@@ -299,14 +342,7 @@ async function pageOriginOf(readDocumentUrl, signal) {
|
|
|
299
342
|
abort();
|
|
300
343
|
}),
|
|
301
344
|
]);
|
|
302
|
-
|
|
303
|
-
return undefined;
|
|
304
|
-
const url = new URL(documentUrl);
|
|
305
|
-
if (url.username || url.password)
|
|
306
|
-
return undefined;
|
|
307
|
-
if (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))
|
|
308
|
-
return url.origin;
|
|
309
|
-
return undefined;
|
|
345
|
+
return typeof documentUrl === 'string' ? documentUrl : undefined;
|
|
310
346
|
}
|
|
311
347
|
catch {
|
|
312
348
|
return undefined;
|
|
@@ -370,21 +406,338 @@ function failureSummary(error) {
|
|
|
370
406
|
return `${error.name}: ${error.code ?? `http_${error.status}`}`;
|
|
371
407
|
return error instanceof Error ? error.name : 'CheckoutError';
|
|
372
408
|
}
|
|
373
|
-
/**
|
|
374
|
-
|
|
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) {
|
|
375
623
|
const controller = new AbortController();
|
|
376
|
-
|
|
377
|
-
|
|
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;
|
|
630
|
+
const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
|
|
631
|
+
const attempt = {
|
|
378
632
|
signal: controller.signal,
|
|
379
|
-
|
|
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
|
+
},
|
|
653
|
+
stop(cause = 'merchant_request_aborted') {
|
|
380
654
|
if (controller.signal.aborted)
|
|
381
655
|
return;
|
|
382
|
-
|
|
656
|
+
reason = cause;
|
|
657
|
+
if (cause === 'merchant_request_aborted')
|
|
658
|
+
lifecycle.merchantRequestAborted();
|
|
659
|
+
else
|
|
660
|
+
lifecycle.failed(error());
|
|
383
661
|
controller.abort(error());
|
|
384
662
|
},
|
|
385
663
|
assertLive() { if (controller.signal.aborted)
|
|
386
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
|
+
},
|
|
387
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
|
+
}
|
|
388
741
|
}
|
|
389
742
|
/**
|
|
390
743
|
* Take over card tokenization for a page.
|
|
@@ -409,6 +762,56 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
409
762
|
const setupStop = new AbortController();
|
|
410
763
|
let attachmentReady = false;
|
|
411
764
|
let setupFailureReported = false;
|
|
765
|
+
const ownedSessions = new Set([pageSessionId]);
|
|
766
|
+
const sessionTypes = new Map([[pageSessionId, 'page']]);
|
|
767
|
+
const sessionParents = new Map();
|
|
768
|
+
const detachedSessions = new Set();
|
|
769
|
+
const workerOwners = new Map();
|
|
770
|
+
const ownersWithWorkers = new Set();
|
|
771
|
+
// Network IDs are scoped to their emitting session. Retired records are
|
|
772
|
+
// separate from live requests, but still disqualify an indistinguishable
|
|
773
|
+
// delayed Fetch event; deleting that evidence could approve a lost request.
|
|
774
|
+
const requestSources = new Map();
|
|
775
|
+
const retiredSources = new Map();
|
|
776
|
+
const retiredCounts = new Map();
|
|
777
|
+
// Keep exact evidence for the first 1,024 failures per owner. Later failures
|
|
778
|
+
// enter a fixed 256 KiB Bloom filter with seven positions: a membership hit
|
|
779
|
+
// refuses that Network ID, never the whole frame merely for reaching a cap.
|
|
780
|
+
// Fetch lacks the emitter session, so compact evidence uses owner + Network
|
|
781
|
+
// ID; exact records below still use session + ID. False positives may refuse
|
|
782
|
+
// a request, but a failed identity is never forgotten or treated as live.
|
|
783
|
+
// The filters are controller-local and monotonic. They are not rotated or
|
|
784
|
+
// cleared on worker detach; arbitrary late Network/Fetch delivery is allowed.
|
|
785
|
+
// A sufficiently long history can saturate a filter and reduce availability.
|
|
786
|
+
const retiredSummaries = new Map();
|
|
787
|
+
const summaryBytes = 256 * 1024;
|
|
788
|
+
const summaryPositions = (id) => {
|
|
789
|
+
const positions = [];
|
|
790
|
+
for (let seed = 1; seed <= 7; seed++) {
|
|
791
|
+
let hash = 0x811c9dc5 ^ Math.imul(seed, 0x9e3779b9);
|
|
792
|
+
for (let i = 0; i < id.length; i++)
|
|
793
|
+
hash = Math.imul(hash ^ id.charCodeAt(i), 0x01000193);
|
|
794
|
+
hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b);
|
|
795
|
+
hash = Math.imul(hash ^ (hash >>> 13), 0xc2b2ae35);
|
|
796
|
+
positions.push((hash ^ (hash >>> 16)) & (summaryBytes * 8 - 1));
|
|
797
|
+
}
|
|
798
|
+
return positions;
|
|
799
|
+
};
|
|
800
|
+
const hasRetiredSummary = (owner, id) => {
|
|
801
|
+
const bits = retiredSummaries.get(owner);
|
|
802
|
+
return !!bits && summaryPositions(id).every(bit => (bits[bit >>> 3] & (1 << (bit & 7))) !== 0);
|
|
803
|
+
};
|
|
804
|
+
const requestKey = (sessionId, id) => JSON.stringify([sessionId, id]);
|
|
805
|
+
const sessionDetached = (sessionId) => {
|
|
806
|
+
while (sessionId) {
|
|
807
|
+
if (detachedSessions.has(sessionId))
|
|
808
|
+
return true;
|
|
809
|
+
sessionId = sessionParents.get(sessionId);
|
|
810
|
+
}
|
|
811
|
+
return false;
|
|
812
|
+
};
|
|
813
|
+
let merchantContext;
|
|
814
|
+
let targetContextKnown = false;
|
|
412
815
|
const lifecycle = new CheckoutLifecycle(opts);
|
|
413
816
|
const stripeCheckout = new StripeCheckoutGate(opts);
|
|
414
817
|
const mercadoCheckout = new MercadoCheckoutGate();
|
|
@@ -431,14 +834,128 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
431
834
|
let quietUntil = 0;
|
|
432
835
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
433
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);
|
|
434
839
|
let activeRequest = null;
|
|
840
|
+
const sourceOwner = (source) => workerOwners.get(source.sessionId) ?? source.sessionId;
|
|
841
|
+
const retireSource = (source) => {
|
|
842
|
+
const owner = sourceOwner(source), key = requestKey(source.sessionId, source.networkId);
|
|
843
|
+
const count = (retiredCounts.get(owner) ?? 0) + (retiredSources.has(key) ? 0 : 1);
|
|
844
|
+
// Compact overflow without dropping failed identities or turning unrelated
|
|
845
|
+
// background failures into a permanent refusal of every request in a frame.
|
|
846
|
+
if (count > 1024) {
|
|
847
|
+
let bits = retiredSummaries.get(owner);
|
|
848
|
+
if (!bits) {
|
|
849
|
+
bits = new Uint8Array(summaryBytes);
|
|
850
|
+
retiredSummaries.set(owner, bits);
|
|
851
|
+
}
|
|
852
|
+
for (const bit of summaryPositions(source.networkId))
|
|
853
|
+
bits[bit >>> 3] |= 1 << (bit & 7);
|
|
854
|
+
if (activeRequest?.sessionId === owner && hasRetiredSummary(owner, activeRequest.networkId)) {
|
|
855
|
+
activeRequest.attempt.stop('browser_interception_unavailable');
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
retiredCounts.set(owner, count);
|
|
860
|
+
retiredSources.set(key, source);
|
|
861
|
+
};
|
|
862
|
+
const identifyRequest = (request) => {
|
|
863
|
+
if (request.sessionId && hasRetiredSummary(request.sessionId, request.networkId)) {
|
|
864
|
+
request.attempt.stop('browser_interception_unavailable');
|
|
865
|
+
request.attempt.assertLive();
|
|
866
|
+
}
|
|
867
|
+
const matches = (source) => {
|
|
868
|
+
const metadata = source.request;
|
|
869
|
+
return sourceOwner(source) === request.sessionId
|
|
870
|
+
&& source.networkId === request.networkId && (!metadata
|
|
871
|
+
|| (metadata.url === request.request.url && metadata.method === request.request.method
|
|
872
|
+
// A missing body is unknown, not evidence of a different request.
|
|
873
|
+
&& !(typeof metadata.postData === 'string' && typeof request.request.postData === 'string'
|
|
874
|
+
&& metadata.postData !== request.request.postData)));
|
|
875
|
+
};
|
|
876
|
+
const candidates = [...requestSources.values(), ...retiredSources.values()].filter(matches);
|
|
877
|
+
if (!candidates.length)
|
|
878
|
+
return false;
|
|
879
|
+
// A failure can arrive before its Network request description. Do not
|
|
880
|
+
// ignore that possible source and assign its Fetch to a live sibling.
|
|
881
|
+
if (candidates.some(source => !source.request)) {
|
|
882
|
+
if (request.sourceSessionId) {
|
|
883
|
+
request.attempt.stop('browser_interception_unavailable');
|
|
884
|
+
request.attempt.assertLive();
|
|
885
|
+
}
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
if (candidates.length !== 1) {
|
|
889
|
+
request.attempt.stop('browser_interception_unavailable');
|
|
890
|
+
request.attempt.assertLive();
|
|
891
|
+
}
|
|
892
|
+
const source = candidates[0];
|
|
893
|
+
if (request.sourceSessionId && request.sourceSessionId !== source.sessionId) {
|
|
894
|
+
request.attempt.stop('browser_interception_unavailable');
|
|
895
|
+
}
|
|
896
|
+
request.sourceSessionId = source.sessionId;
|
|
897
|
+
if (sessionDetached(source.sessionId) || retiredSources.has(requestKey(source.sessionId, source.networkId)))
|
|
898
|
+
request.attempt.stop();
|
|
899
|
+
request.attempt.assertLive();
|
|
900
|
+
return true;
|
|
901
|
+
};
|
|
902
|
+
/** A frame's Fetch event does not identify which dedicated worker sent it.
|
|
903
|
+
* Require the exact Network event before asking the Vault whenever that frame
|
|
904
|
+
* has had workers. A sibling's detach proves nothing about an unknown request;
|
|
905
|
+
* missing attribution instead expires without releasing any card or token.
|
|
906
|
+
* Keep detached ancestry so a late event cannot revive a lost worker request.
|
|
907
|
+
*/
|
|
908
|
+
const awaitRequestSource = async (request) => {
|
|
909
|
+
if (identifyRequest(request))
|
|
910
|
+
return;
|
|
911
|
+
if (!request.sessionId || !ownersWithWorkers.has(request.sessionId)) {
|
|
912
|
+
if (request.sessionId && retiredSources.has(requestKey(request.sessionId, request.networkId))) {
|
|
913
|
+
request.attempt.stop();
|
|
914
|
+
request.attempt.assertLive();
|
|
915
|
+
}
|
|
916
|
+
request.sourceSessionId = request.sessionId;
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
let timer;
|
|
920
|
+
let changed = () => { };
|
|
921
|
+
const signals = [request.attempt.signal, lifecycle.abort.signal, setupStop.signal];
|
|
922
|
+
try {
|
|
923
|
+
await new Promise((resolve, reject) => {
|
|
924
|
+
changed = () => {
|
|
925
|
+
try {
|
|
926
|
+
request.attempt.assertLive();
|
|
927
|
+
if (lifecycle.isCancelled())
|
|
928
|
+
throw new Error('checkout cancelled locally before approval');
|
|
929
|
+
if (setupStop.signal.aborted)
|
|
930
|
+
throw new PaymentOutcomeUnknownError(null, 'browser_interception_unavailable');
|
|
931
|
+
if (identifyRequest(request))
|
|
932
|
+
resolve();
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
reject(error);
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
request.attributionChanged = changed;
|
|
939
|
+
for (const signal of signals)
|
|
940
|
+
signal.addEventListener('abort', changed, { once: true });
|
|
941
|
+
timer = setTimeout(() => reject(new PaymentOutcomeUnknownError(null, 'browser_interception_unavailable')), setupTimeoutMs);
|
|
942
|
+
changed();
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
finally {
|
|
946
|
+
clearTimeout(timer);
|
|
947
|
+
for (const signal of signals)
|
|
948
|
+
signal.removeEventListener('abort', changed);
|
|
949
|
+
delete request.attributionChanged;
|
|
950
|
+
}
|
|
951
|
+
};
|
|
435
952
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
436
953
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
437
954
|
let lastSubmitted = null;
|
|
438
955
|
const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
|
|
439
956
|
const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
|
|
440
957
|
...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN])];
|
|
441
|
-
const arm = async (sessionId, resume = false) => {
|
|
958
|
+
const arm = async (sessionId, resume = false, worker = false) => {
|
|
442
959
|
const key = sessionId ?? '__root__';
|
|
443
960
|
if (armed.has(key))
|
|
444
961
|
return;
|
|
@@ -446,15 +963,26 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
446
963
|
return arming.get(key);
|
|
447
964
|
// Fetch's interception ID differs from Network's ID. Enable failure events
|
|
448
965
|
// before intercepting and bind each attempt to both its ID and CDP session.
|
|
449
|
-
const pending = withinAttachmentDeadline(async (
|
|
966
|
+
const pending = withinAttachmentDeadline(async (assertDeadline) => {
|
|
967
|
+
const assertActive = () => {
|
|
968
|
+
assertDeadline();
|
|
969
|
+
if (sessionId && detachedSessions.has(sessionId))
|
|
970
|
+
throw new CheckoutAttachmentError('closed');
|
|
971
|
+
};
|
|
450
972
|
await cdp.send('Network.enable', {}, sessionId);
|
|
451
973
|
assertActive();
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
974
|
+
// Worker Fetch does not exist in Chrome. Its requests pause on the owning
|
|
975
|
+
// frame, but its abort/finish events arrive on the worker Network session.
|
|
976
|
+
// Keep that session attached and paused until Network is listening, then
|
|
977
|
+
// resume it only after its nested workers can be attached recursively.
|
|
978
|
+
if (!worker) {
|
|
979
|
+
await cdp.send('Page.enable', {}, sessionId).catch(() => { });
|
|
980
|
+
assertActive();
|
|
981
|
+
await cdp.send('Fetch.enable', {
|
|
982
|
+
patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
|
|
983
|
+
}, sessionId);
|
|
984
|
+
assertActive();
|
|
985
|
+
}
|
|
458
986
|
// Descend into this target's own children (iframes inside iframes).
|
|
459
987
|
await cdp.send('Target.setAutoAttach', {
|
|
460
988
|
autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
|
|
@@ -481,7 +1009,33 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
481
1009
|
arming.delete(key);
|
|
482
1010
|
}
|
|
483
1011
|
};
|
|
1012
|
+
const failInterception = (error) => {
|
|
1013
|
+
if (setupFailureReported)
|
|
1014
|
+
return;
|
|
1015
|
+
setupFailureReported = true;
|
|
1016
|
+
setupStop.abort(attachmentFailure(error));
|
|
1017
|
+
terminal = new Error('browser_interception_unavailable');
|
|
1018
|
+
// A new target changes future interception, not the result of an earlier
|
|
1019
|
+
// attempt. Preserve a settled result or handoff while still refusing new
|
|
1020
|
+
// requests. Approval/preparation in progress must instead be retired.
|
|
1021
|
+
const status = lifecycle.getState().status;
|
|
1022
|
+
if (status === 'completed' || (!activeRequest && !['idle', 'awaiting_approval', 'ready_to_submit'].includes(status)))
|
|
1023
|
+
return;
|
|
1024
|
+
activeRequest?.attempt.stop();
|
|
1025
|
+
preparationGate.invalidate('browser_interception_unavailable');
|
|
1026
|
+
lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
|
|
1027
|
+
opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
|
|
1028
|
+
};
|
|
484
1029
|
cdp.on(async (method, params, sessionId) => {
|
|
1030
|
+
if ((method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') && targetContextKnown) {
|
|
1031
|
+
try {
|
|
1032
|
+
assertContextTarget(params?.targetInfo, merchantContext);
|
|
1033
|
+
}
|
|
1034
|
+
catch (error) {
|
|
1035
|
+
failInterception(error);
|
|
1036
|
+
}
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
485
1039
|
if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
|
|
486
1040
|
preparationGate.invalidate('merchant_document_changed');
|
|
487
1041
|
mercadoCheckout.invalidate();
|
|
@@ -491,10 +1045,17 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
491
1045
|
}
|
|
492
1046
|
if (preparationGate.isEngaged())
|
|
493
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();
|
|
494
1054
|
return;
|
|
495
1055
|
}
|
|
496
1056
|
if ((method === 'Inspector.detached' && sessionId === pageSessionId)
|
|
497
1057
|
|| (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
|
|
1058
|
+
detachedSessions.add(pageSessionId);
|
|
498
1059
|
setupStop.abort(new CheckoutAttachmentError('closed'));
|
|
499
1060
|
preparationGate.invalidate('merchant_document_closed');
|
|
500
1061
|
stripeCheckout.invalidate();
|
|
@@ -502,9 +1063,69 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
502
1063
|
// The root page owns every attached OOPIF; its loss ends child requests too.
|
|
503
1064
|
activeRequest?.attempt.stop();
|
|
504
1065
|
}
|
|
1066
|
+
// The transport is browser-scoped. A second tab's request or child target
|
|
1067
|
+
// must never be authorized with this page's merchant, amount or preparation.
|
|
1068
|
+
if (!sessionId || !ownedSessions.has(sessionId))
|
|
1069
|
+
return;
|
|
1070
|
+
if (method === 'Network.requestWillBeSent') {
|
|
1071
|
+
const owner = workerOwners.get(sessionId) ?? sessionId;
|
|
1072
|
+
const matchesActive = activeRequest?.sessionId === owner && activeRequest.networkId === params.requestId;
|
|
1073
|
+
// Only identification may consume a late event from a detached target.
|
|
1074
|
+
// Its Fetch events, children and commands remain permanently ignored.
|
|
1075
|
+
if (sessionDetached(sessionId) && !matchesActive)
|
|
1076
|
+
return;
|
|
1077
|
+
const key = requestKey(sessionId, params.requestId);
|
|
1078
|
+
const record = { sessionId, networkId: params.requestId, request: params.request };
|
|
1079
|
+
if (sessionDetached(sessionId) || retiredSources.has(key) || hasRetiredSummary(owner, params.requestId))
|
|
1080
|
+
retireSource(record);
|
|
1081
|
+
else
|
|
1082
|
+
requestSources.set(key, record);
|
|
1083
|
+
if (matchesActive) {
|
|
1084
|
+
try {
|
|
1085
|
+
identifyRequest(activeRequest);
|
|
1086
|
+
}
|
|
1087
|
+
catch { /* stop already retires the exact attempt */ }
|
|
1088
|
+
activeRequest.attributionChanged?.();
|
|
1089
|
+
}
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (sessionDetached(sessionId))
|
|
1093
|
+
return;
|
|
1094
|
+
if (method === 'Network.loadingFinished') {
|
|
1095
|
+
requestSources.delete(requestKey(sessionId, params.requestId));
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
505
1098
|
if (method === 'Network.loadingFailed') {
|
|
506
|
-
if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.
|
|
507
|
-
activeRequest.
|
|
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
|
+
}
|
|
1119
|
+
const key = requestKey(sessionId, params.requestId);
|
|
1120
|
+
retireSource(requestSources.get(key) ?? { sessionId, networkId: params.requestId });
|
|
1121
|
+
requestSources.delete(key);
|
|
1122
|
+
if (activeRequest?.sessionId === (workerOwners.get(sessionId) ?? sessionId) && activeRequest.networkId === params.requestId) {
|
|
1123
|
+
try {
|
|
1124
|
+
identifyRequest(activeRequest);
|
|
1125
|
+
}
|
|
1126
|
+
catch { /* its exact origin is unavailable */ }
|
|
1127
|
+
activeRequest.attributionChanged?.();
|
|
1128
|
+
}
|
|
508
1129
|
return;
|
|
509
1130
|
}
|
|
510
1131
|
if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
|
|
@@ -512,6 +1133,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
512
1133
|
: method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
|
|
513
1134
|
: activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
|
|
514
1135
|
activeRequest.attempt.stop();
|
|
1136
|
+
if (method !== 'Page.frameDetached') {
|
|
1137
|
+
const detached = method === 'Target.detachedFromTarget' ? params.sessionId : sessionId;
|
|
1138
|
+
detachedSessions.add(detached);
|
|
1139
|
+
if (arming.has(detached))
|
|
1140
|
+
failInterception(new CheckoutAttachmentError('closed'));
|
|
1141
|
+
// Terminating a worker need not emit loadingFailed. Its nested workers
|
|
1142
|
+
// also lose their request. An unknown request waits for attribution;
|
|
1143
|
+
// detaching an unrelated sibling must never cancel the card approval.
|
|
1144
|
+
const source = activeRequest?.sourceSessionId;
|
|
1145
|
+
if (activeRequest && (sessionDetached(activeRequest.sessionId) || (source && sessionDetached(source))))
|
|
1146
|
+
activeRequest.attempt.stop();
|
|
1147
|
+
for (const [key, record] of requestSources)
|
|
1148
|
+
if (sessionDetached(record.sessionId)) {
|
|
1149
|
+
retireSource(record);
|
|
1150
|
+
requestSources.delete(key);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
515
1153
|
return;
|
|
516
1154
|
}
|
|
517
1155
|
if (method === 'Target.attachedToTarget') {
|
|
@@ -519,18 +1157,27 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
519
1157
|
return;
|
|
520
1158
|
const child = params.sessionId;
|
|
521
1159
|
try {
|
|
522
|
-
|
|
1160
|
+
if (typeof child !== 'string' || !child || !['page', 'iframe', 'worker'].includes(params.targetInfo?.type)) {
|
|
1161
|
+
throw new CheckoutAttachmentError('unavailable');
|
|
1162
|
+
}
|
|
1163
|
+
if (child === pageSessionId || detachedSessions.has(child) || (ownedSessions.has(child)
|
|
1164
|
+
&& (sessionParents.get(child) !== sessionId || sessionTypes.get(child) !== params.targetInfo.type))) {
|
|
1165
|
+
throw new CheckoutAttachmentError('unavailable');
|
|
1166
|
+
}
|
|
1167
|
+
ownedSessions.add(child);
|
|
1168
|
+
sessionTypes.set(child, params.targetInfo.type);
|
|
1169
|
+
sessionParents.set(child, sessionId);
|
|
1170
|
+
if (params.targetInfo.type === 'worker') {
|
|
1171
|
+
const owner = workerOwners.get(sessionId) ?? sessionId;
|
|
1172
|
+
workerOwners.set(child, owner);
|
|
1173
|
+
ownersWithWorkers.add(owner);
|
|
1174
|
+
}
|
|
1175
|
+
await arm(child, true, params.targetInfo.type === 'worker');
|
|
523
1176
|
}
|
|
524
1177
|
catch (error) {
|
|
525
1178
|
// Shared child setup and sibling cancellation can reject several
|
|
526
1179
|
// handlers together. Publish the terminal setup failure only once.
|
|
527
|
-
|
|
528
|
-
return;
|
|
529
|
-
setupFailureReported = true;
|
|
530
|
-
setupStop.abort(attachmentFailure(error));
|
|
531
|
-
terminal = new Error('browser_interception_unavailable');
|
|
532
|
-
lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
|
|
533
|
-
opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
|
|
1180
|
+
failInterception(error);
|
|
534
1181
|
// Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
|
|
535
1182
|
return;
|
|
536
1183
|
}
|
|
@@ -645,13 +1292,37 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
645
1292
|
}
|
|
646
1293
|
let preparation;
|
|
647
1294
|
try {
|
|
648
|
-
preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request));
|
|
1295
|
+
preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request), request.headers);
|
|
649
1296
|
}
|
|
650
1297
|
catch (error) {
|
|
651
1298
|
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
652
1299
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
653
1300
|
return;
|
|
654
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
|
+
}
|
|
655
1326
|
// Same stop condition as the Playwright adapter: once a failure proves
|
|
656
1327
|
// retrying is pointless, fail the request without calling the API again.
|
|
657
1328
|
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
@@ -666,8 +1337,11 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
666
1337
|
// requests can never both clear the check above and raise two prompts for
|
|
667
1338
|
// one checkout.
|
|
668
1339
|
awaitingApproval = true;
|
|
669
|
-
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)));
|
|
670
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;
|
|
671
1345
|
try {
|
|
672
1346
|
const body = pausedBody(request);
|
|
673
1347
|
if (body === null) {
|
|
@@ -688,16 +1362,26 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
688
1362
|
}
|
|
689
1363
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
690
1364
|
lifecycle.begin();
|
|
1365
|
+
const approvalStartedAt = Date.now();
|
|
691
1366
|
if (typeof networkId !== 'string' || !networkId) {
|
|
692
1367
|
attempt.stop();
|
|
693
1368
|
attempt.assertLive();
|
|
694
1369
|
}
|
|
695
|
-
|
|
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) };
|
|
1373
|
+
await awaitRequestSource(activeRequest);
|
|
696
1374
|
if (preparation)
|
|
697
1375
|
await preparationGate.assertDocument();
|
|
698
|
-
|
|
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);
|
|
699
1380
|
const pageAmount = await pageAmountOf(opts);
|
|
700
1381
|
attempt.assertLive();
|
|
1382
|
+
activeRequest.identity.documentUrl = documentKey(documentUrl);
|
|
1383
|
+
activeRequest.identity.pageAmount = pageAmount;
|
|
1384
|
+
activeRequest.identity.amountEvidence ||= pageAmount !== undefined;
|
|
701
1385
|
if (stripeStep)
|
|
702
1386
|
stripeCheckout.assertClaim(stripeStep);
|
|
703
1387
|
const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
|
|
@@ -729,6 +1413,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
729
1413
|
? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}) },
|
|
730
1414
|
});
|
|
731
1415
|
attempt.assertLive();
|
|
1416
|
+
approvedId = replay.authorizationId;
|
|
732
1417
|
if (lifecycle.isCancelled())
|
|
733
1418
|
throw new Error('checkout cancelled locally after approval');
|
|
734
1419
|
try {
|
|
@@ -738,7 +1423,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
738
1423
|
throw new PaymentOutcomeUnknownError(replay.authorizationId, 'mercado_checkout_metadata_invalid');
|
|
739
1424
|
}
|
|
740
1425
|
attempt.assertLive();
|
|
741
|
-
|
|
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);
|
|
742
1443
|
handoffStarted = replay.mode !== 'cse';
|
|
743
1444
|
if (replay.mode === 'hosted_form') {
|
|
744
1445
|
// The device submitted the processor's own form; the processor
|
|
@@ -750,13 +1451,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
750
1451
|
// A navigation response is never CORS-checked, so the CORS wrap is
|
|
751
1452
|
// inert here; it is applied so every fulfill goes through one path.
|
|
752
1453
|
await cdp.send('Fetch.fulfillRequest', {
|
|
753
|
-
requestId,
|
|
1454
|
+
requestId: deliverTo,
|
|
754
1455
|
responseCode: page.status,
|
|
755
|
-
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(
|
|
1456
|
+
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(deliverRequest.url, deliverRequest.headers))),
|
|
756
1457
|
body: Buffer.from(page.body).toString('base64'),
|
|
757
|
-
},
|
|
1458
|
+
}, deliverOn);
|
|
758
1459
|
attempt.assertLive();
|
|
759
|
-
lastSubmitted = { url:
|
|
1460
|
+
lastSubmitted = { url: deliverRequest.url, body: deliverBody, at: Date.now() };
|
|
760
1461
|
// Named for what it is: a device-attested submission with no
|
|
761
1462
|
// processor evidence, never an `authorized` event.
|
|
762
1463
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
@@ -767,12 +1468,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
767
1468
|
// and cookies, and only the four ciphertext fields swapped in. Only
|
|
768
1469
|
// postData rides on the command: no header override, ever (see
|
|
769
1470
|
// cseBody for why a recomputed Content-Length is refused by Chromium).
|
|
770
|
-
const postData = Buffer.from(cseBody(
|
|
1471
|
+
const postData = Buffer.from(cseBody(deliverBody, replay)).toString('base64');
|
|
771
1472
|
handoffStarted = true;
|
|
772
1473
|
await cdp.send('Fetch.continueRequest', {
|
|
773
|
-
requestId,
|
|
1474
|
+
requestId: deliverTo,
|
|
774
1475
|
postData,
|
|
775
|
-
},
|
|
1476
|
+
}, deliverOn);
|
|
776
1477
|
attempt.assertLive();
|
|
777
1478
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
778
1479
|
}
|
|
@@ -783,13 +1484,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
783
1484
|
// decision rides on the event, so a fulfill the page could not read
|
|
784
1485
|
// (no usable Origin on a cross-origin request) is visible in telemetry
|
|
785
1486
|
// rather than only as the page's own "connection error".
|
|
786
|
-
const cors = corsDecision(
|
|
1487
|
+
const cors = corsDecision(deliverRequest.url, deliverRequest.headers);
|
|
787
1488
|
await cdp.send('Fetch.fulfillRequest', {
|
|
788
|
-
requestId,
|
|
1489
|
+
requestId: deliverTo,
|
|
789
1490
|
responseCode: replay.status,
|
|
790
1491
|
responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
|
|
791
1492
|
body: Buffer.from(replay.body).toString('base64'),
|
|
792
|
-
},
|
|
1493
|
+
}, deliverOn);
|
|
793
1494
|
attempt.assertLive();
|
|
794
1495
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
795
1496
|
}
|
|
@@ -797,15 +1498,36 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
797
1498
|
}
|
|
798
1499
|
catch (err) {
|
|
799
1500
|
// Ignore the failure event caused by our own safe decline/error abort.
|
|
1501
|
+
const dead = attempt.target ?? { requestId, sessionId };
|
|
800
1502
|
if (activeRequest?.attempt === attempt)
|
|
801
1503
|
activeRequest = null;
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1504
|
+
// Vault clients may normalize a merchantSignal abort. Preserve the
|
|
1505
|
+
// adapter's more precise cause when ownership became ambiguous.
|
|
1506
|
+
try {
|
|
1507
|
+
attempt.assertLive();
|
|
1508
|
+
}
|
|
1509
|
+
catch (stopped) {
|
|
1510
|
+
err = stopped;
|
|
1511
|
+
}
|
|
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);
|
|
806
1518
|
quietUntil = Date.now() + cooldownMs;
|
|
807
|
-
|
|
808
|
-
|
|
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(() => { });
|
|
809
1531
|
}
|
|
810
1532
|
finally {
|
|
811
1533
|
if (activeRequest?.attempt === attempt)
|
|
@@ -817,7 +1539,31 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
817
1539
|
}
|
|
818
1540
|
});
|
|
819
1541
|
try {
|
|
820
|
-
await
|
|
1542
|
+
await withinAttachmentDeadline(async (assertActive) => {
|
|
1543
|
+
const { targetInfo } = await cdp.send('Target.getTargetInfo', {}, pageSessionId);
|
|
1544
|
+
assertActive();
|
|
1545
|
+
if (!targetRecord(targetInfo) || targetInfo.type !== 'page'
|
|
1546
|
+
|| (targetInfo.browserContextId !== undefined && !contextId(targetInfo.browserContextId))) {
|
|
1547
|
+
throw new CheckoutAttachmentError('unavailable');
|
|
1548
|
+
}
|
|
1549
|
+
merchantContext = targetInfo.browserContextId;
|
|
1550
|
+
targetContextKnown = true;
|
|
1551
|
+
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
1552
|
+
assertActive();
|
|
1553
|
+
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
1554
|
+
assertActive();
|
|
1555
|
+
if (!Array.isArray(targetInfos))
|
|
1556
|
+
throw new CheckoutAttachmentError('unavailable');
|
|
1557
|
+
for (const target of targetInfos)
|
|
1558
|
+
assertContextTarget(target, merchantContext);
|
|
1559
|
+
await arm(pageSessionId);
|
|
1560
|
+
// Existing OOPIFs report attachment before the parent's acknowledgement,
|
|
1561
|
+
// but their Fetch setup finishes later. Do not invite the caller to press
|
|
1562
|
+
// Pay while one of those already-running frames is still unprotected.
|
|
1563
|
+
while (arming.size)
|
|
1564
|
+
await Promise.all(arming.values());
|
|
1565
|
+
assertActive();
|
|
1566
|
+
}, setupTimeoutMs, setupStop.signal);
|
|
821
1567
|
if (setupStop.signal.aborted)
|
|
822
1568
|
throw attachmentFailure(setupStop.signal.reason);
|
|
823
1569
|
attachmentReady = true;
|
|
@@ -886,10 +1632,18 @@ export async function attachToPlaywright(page, opts) {
|
|
|
886
1632
|
let quietUntil = 0;
|
|
887
1633
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
888
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);
|
|
889
1637
|
let activeRequest = null;
|
|
890
1638
|
page.on?.('requestfailed', (request) => {
|
|
891
|
-
if (activeRequest && activeRequest.request === request)
|
|
892
|
-
|
|
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
|
+
}
|
|
893
1647
|
});
|
|
894
1648
|
page.on?.('close', () => { if (!attachmentReady)
|
|
895
1649
|
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); });
|
|
@@ -905,6 +1659,18 @@ export async function attachToPlaywright(page, opts) {
|
|
|
905
1659
|
}
|
|
906
1660
|
if (preparationGate.isEngaged())
|
|
907
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();
|
|
908
1674
|
}
|
|
909
1675
|
});
|
|
910
1676
|
page.on?.('framedetached', (frame) => {
|
|
@@ -1017,12 +1783,33 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1017
1783
|
}
|
|
1018
1784
|
let preparation;
|
|
1019
1785
|
try {
|
|
1020
|
-
preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '');
|
|
1786
|
+
preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '', request.headers());
|
|
1021
1787
|
}
|
|
1022
1788
|
catch (error) {
|
|
1023
1789
|
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
1024
1790
|
return route.abort('aborted');
|
|
1025
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
|
+
}
|
|
1026
1813
|
// Fail closed and stay quiet: no card may reach the PSP, but neither may
|
|
1027
1814
|
// the page's retry loop turn into a stream of doomed API calls. Every
|
|
1028
1815
|
// abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
|
|
@@ -1037,19 +1824,34 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1037
1824
|
}
|
|
1038
1825
|
// Reserved before anything that could yield, matching attachToCdp.
|
|
1039
1826
|
awaitingApproval = true;
|
|
1040
|
-
|
|
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())));
|
|
1041
1835
|
const frames = [];
|
|
1042
1836
|
try {
|
|
1043
1837
|
for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
|
|
1044
1838
|
frames.push(frame);
|
|
1045
1839
|
}
|
|
1046
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.
|
|
1047
1843
|
const assertRequestLive = () => {
|
|
1048
|
-
|
|
1844
|
+
const current = attempt.target;
|
|
1845
|
+
if (current && (current.request.failure?.() || current.frames.some((frame) => frame.isDetached?.())))
|
|
1846
|
+
attempt.lose();
|
|
1847
|
+
if (page.isClosed?.())
|
|
1049
1848
|
attempt.stop();
|
|
1050
1849
|
attempt.assertLive();
|
|
1051
1850
|
};
|
|
1052
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;
|
|
1053
1855
|
try {
|
|
1054
1856
|
const body = request.postData() ?? '';
|
|
1055
1857
|
if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
|
|
@@ -1058,12 +1860,20 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1058
1860
|
}
|
|
1059
1861
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
|
|
1060
1862
|
lifecycle.begin();
|
|
1061
|
-
|
|
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) };
|
|
1062
1867
|
if (preparation)
|
|
1063
1868
|
await preparationGate.assertDocument();
|
|
1064
|
-
|
|
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);
|
|
1065
1872
|
const pageAmount = await pageAmountOf(opts);
|
|
1066
1873
|
assertRequestLive();
|
|
1874
|
+
activeRequest.identity.documentUrl = documentKey(documentUrl);
|
|
1875
|
+
activeRequest.identity.pageAmount = pageAmount;
|
|
1876
|
+
activeRequest.identity.amountEvidence ||= pageAmount !== undefined;
|
|
1067
1877
|
if (stripeStep)
|
|
1068
1878
|
stripeCheckout.assertClaim(stripeStep);
|
|
1069
1879
|
const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
|
|
@@ -1095,6 +1905,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1095
1905
|
? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}) },
|
|
1096
1906
|
});
|
|
1097
1907
|
assertRequestLive();
|
|
1908
|
+
approvedId = replay.authorizationId;
|
|
1098
1909
|
if (lifecycle.isCancelled())
|
|
1099
1910
|
throw new Error('checkout cancelled locally after approval');
|
|
1100
1911
|
try {
|
|
@@ -1104,25 +1915,35 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1104
1915
|
throw new PaymentOutcomeUnknownError(replay.authorizationId, 'mercado_checkout_metadata_invalid');
|
|
1105
1916
|
}
|
|
1106
1917
|
assertRequestLive();
|
|
1107
|
-
|
|
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());
|
|
1108
1929
|
handoffStarted = replay.mode !== 'cse';
|
|
1109
1930
|
if (replay.mode === 'hosted_form') {
|
|
1110
1931
|
// Same as the CDP path: the paused navigation resolves to the
|
|
1111
1932
|
// synthetic page, and a re-post of this form is refused.
|
|
1112
1933
|
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
1113
1934
|
// Inert on a navigation (never CORS-checked); one path for every fulfill.
|
|
1114
|
-
await
|
|
1935
|
+
await deliverTo.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(deliverRequest.url(), deliverRequest.headers())), body: synthetic.body });
|
|
1115
1936
|
assertRequestLive();
|
|
1116
|
-
lastSubmitted = { url:
|
|
1937
|
+
lastSubmitted = { url: deliverRequest.url(), body: deliverBody, at: Date.now() };
|
|
1117
1938
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
1118
1939
|
}
|
|
1119
1940
|
else if (replay.mode === 'cse') {
|
|
1120
1941
|
// Same as the CDP path: the request continues from this browser
|
|
1121
1942
|
// with the ciphertext swapped in and no header override; Playwright
|
|
1122
1943
|
// recomputes the length itself.
|
|
1123
|
-
const postData = cseBody(
|
|
1944
|
+
const postData = cseBody(deliverBody, replay);
|
|
1124
1945
|
handoffStarted = true;
|
|
1125
|
-
await
|
|
1946
|
+
await deliverTo.continue({ postData });
|
|
1126
1947
|
assertRequestLive();
|
|
1127
1948
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
1128
1949
|
}
|
|
@@ -1130,23 +1951,39 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1130
1951
|
// Playwright adds these itself when a cross-origin fulfill carries
|
|
1131
1952
|
// none; written here anyway (replacing a stale value) so a
|
|
1132
1953
|
// cross-origin answer is the same whichever adapter ran.
|
|
1133
|
-
const cors = corsDecision(
|
|
1134
|
-
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 });
|
|
1135
1956
|
assertRequestLive();
|
|
1136
1957
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
1137
1958
|
}
|
|
1138
1959
|
lifecycle.handedOff(replay);
|
|
1139
1960
|
}
|
|
1140
1961
|
catch (err) {
|
|
1962
|
+
const dead = attempt.target?.route ?? route;
|
|
1141
1963
|
if (activeRequest?.attempt === attempt)
|
|
1142
1964
|
activeRequest = null;
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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);
|
|
1147
1975
|
quietUntil = Date.now() + cooldownMs;
|
|
1148
|
-
|
|
1149
|
-
|
|
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(() => { });
|
|
1150
1987
|
}
|
|
1151
1988
|
finally {
|
|
1152
1989
|
if (activeRequest?.attempt === attempt)
|