@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/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
- async function pageOriginOf(readDocumentUrl, signal) {
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
- if (typeof documentUrl !== 'string')
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
- /** Separate from explicit cancellation: the client can drain a late create ID. */
374
- function merchantAttempt(lifecycle) {
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
- const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'merchant_request_aborted');
377
- return {
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
- stop() {
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
- lifecycle.merchantRequestAborted();
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 (assertActive) => {
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
- await cdp.send('Page.enable', {}, sessionId).catch(() => { });
453
- assertActive();
454
- await cdp.send('Fetch.enable', {
455
- patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
456
- }, sessionId);
457
- assertActive();
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.sessionId === sessionId)
507
- activeRequest.attempt.stop();
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
- await arm(child, true);
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
- if (setupFailureReported)
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
- activeRequest = { attempt, networkId, sessionId, frameId };
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
- const pageOrigin = await pageOriginOf(readDocumentUrl, attempt.signal);
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
- lifecycle.prepareHandoff(replay, request.url);
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(request.url, request.headers))),
1456
+ responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(deliverRequest.url, deliverRequest.headers))),
756
1457
  body: Buffer.from(page.body).toString('base64'),
757
- }, sessionId);
1458
+ }, deliverOn);
758
1459
  attempt.assertLive();
759
- lastSubmitted = { url: request.url, body, at: Date.now() };
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(body, replay)).toString('base64');
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
- }, sessionId);
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(request.url, request.headers);
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
- }, sessionId);
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
- lifecycle.failed(err, handoffStarted);
803
- if (isTerminal(err))
804
- terminal = err;
805
- else if (isApprovalOutcome(err))
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
- opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
808
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
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 arm(pageSessionId);
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
- activeRequest.attempt.stop();
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
- const attempt = merchantAttempt(lifecycle);
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
- if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
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
- activeRequest = { request, frames, attempt };
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
- const pageOrigin = await pageOriginOf(readDocumentUrl, attempt.signal);
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
- lifecycle.prepareHandoff(replay, request.url());
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 route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
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: request.url(), body, at: Date.now() };
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(body, replay);
1944
+ const postData = cseBody(deliverBody, replay);
1124
1945
  handoffStarted = true;
1125
- await route.continue({ postData });
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(request.url(), request.headers());
1134
- await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
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
- lifecycle.failed(err, handoffStarted);
1144
- if (isTerminal(err))
1145
- terminal = err;
1146
- else if (isApprovalOutcome(err))
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
- opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
1149
- await route.abort('aborted').catch(() => { });
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)