@agent-cards/checkout 0.15.1 → 0.15.2

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.
Files changed (3) hide show
  1. package/dist/cdp.d.ts +30 -1
  2. package/dist/cdp.js +353 -23
  3. package/package.json +3 -3
package/dist/cdp.d.ts CHANGED
@@ -67,11 +67,40 @@ export declare function withCorsHeaders(headers: Record<string, string>, cors: R
67
67
  /**
68
68
  * Browser-level, session-aware CDP connection. A page-scoped Playwright or
69
69
  * Puppeteer CDPSession is NOT this interface; use attachToPlaywright for those.
70
+ *
71
+ * Forward commands and events over a browser WebSocket with flattened sessions.
72
+ * An omitted sessionId means the browser root, never a default page session.
73
+ * Attachment requires Target.getTargetInfo on the supplied page session, then
74
+ * Target.setDiscoverTargets and Target.getTargets at the browser root. Forward
75
+ * root Target.targetCreated/targetInfoChanged events with no sessionId, and
76
+ * preserve the sessionId on child attachment, Fetch, and Network events.
77
+ * Page/iframe sessions must support Page, Fetch, Network, and Target; dedicated
78
+ * workers require Network, Target, and Runtime.runIfWaitingForDebugger.
79
+ * Unsupported or incomplete discovery fails with CheckoutAttachmentError
80
+ * before fetch_armed. TypeScript describes forwarding; attachment verifies
81
+ * the browser's runtime capabilities and returned metadata.
70
82
  */
71
83
  export interface CdpLike {
84
+ /** Read the merchant target on the page session; returns { targetInfo: CdpTargetInfo }. */
85
+ send(method: 'Target.getTargetInfo', params: Record<string, never>, sessionId: string): Promise<any>;
86
+ /** Enable root discovery events, including targets outside the page tree. */
87
+ send(method: 'Target.setDiscoverTargets', params: {
88
+ discover: boolean;
89
+ }, sessionId?: undefined): Promise<any>;
90
+ /** Read { targetInfos: CdpTargetInfo[] }; missing sessionId must remain browser-scoped. */
91
+ send(method: 'Target.getTargets', params?: Record<string, never>, sessionId?: undefined): Promise<any>;
92
+ /** Forward other CDP commands unchanged; unsupported commands must reject. */
72
93
  send(method: string, params?: any, sessionId?: string): Promise<any>;
94
+ /** Deliver browser-root events as well as events from flattened child sessions. */
73
95
  on(handler: (method: string, params: any, sessionId?: string) => void): void;
74
96
  }
97
+ /** Browser Target metadata used to establish which checkout owns a worker. */
98
+ export interface CdpTargetInfo {
99
+ targetId: string;
100
+ type: string;
101
+ /** Absent for the default browser context. */
102
+ browserContextId?: string;
103
+ }
75
104
  export interface AttachOptions extends LifecycleOptions, ExecutionMetadata {
76
105
  /** Explicit native hosted Stripe Checkout TEST integration; requires an Autopilot grant. */
77
106
  stripeCheckout?: StripeCheckoutOptions;
@@ -104,7 +133,7 @@ export interface AttachOptions extends LifecycleOptions, ExecutionMetadata {
104
133
  } | undefined>;
105
134
  cardId?: string;
106
135
  timeoutMs?: number;
107
- /** Browser interception setup deadline, separate from approval. Defaults to 30000 ms; maximum 300000 ms. */
136
+ /** Interception setup and request ownership deadline, separate from approval. Defaults to 30000 ms; maximum 300000 ms. */
108
137
  attachmentTimeoutMs?: number;
109
138
  /** Explicit payment endpoints to block if the registry cannot handle their method/format. Unlisted traffic is untouched. */
110
139
  paymentEndpoints?: readonly PaymentEndpointGuard[];
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
  *
@@ -373,13 +394,18 @@ function failureSummary(error) {
373
394
  /** Separate from explicit cancellation: the client can drain a late create ID. */
374
395
  function merchantAttempt(lifecycle) {
375
396
  const controller = new AbortController();
376
- const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'merchant_request_aborted');
397
+ let reason = 'merchant_request_aborted';
398
+ const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
377
399
  return {
378
400
  signal: controller.signal,
379
- stop() {
401
+ stop(cause = 'merchant_request_aborted') {
380
402
  if (controller.signal.aborted)
381
403
  return;
382
- lifecycle.merchantRequestAborted();
404
+ reason = cause;
405
+ if (cause === 'merchant_request_aborted')
406
+ lifecycle.merchantRequestAborted();
407
+ else
408
+ lifecycle.failed(error());
383
409
  controller.abort(error());
384
410
  },
385
411
  assertLive() { if (controller.signal.aborted)
@@ -409,6 +435,56 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
409
435
  const setupStop = new AbortController();
410
436
  let attachmentReady = false;
411
437
  let setupFailureReported = false;
438
+ const ownedSessions = new Set([pageSessionId]);
439
+ const sessionTypes = new Map([[pageSessionId, 'page']]);
440
+ const sessionParents = new Map();
441
+ const detachedSessions = new Set();
442
+ const workerOwners = new Map();
443
+ const ownersWithWorkers = new Set();
444
+ // Network IDs are scoped to their emitting session. Retired records are
445
+ // separate from live requests, but still disqualify an indistinguishable
446
+ // delayed Fetch event; deleting that evidence could approve a lost request.
447
+ const requestSources = new Map();
448
+ const retiredSources = new Map();
449
+ const retiredCounts = new Map();
450
+ // Keep exact evidence for the first 1,024 failures per owner. Later failures
451
+ // enter a fixed 256 KiB Bloom filter with seven positions: a membership hit
452
+ // refuses that Network ID, never the whole frame merely for reaching a cap.
453
+ // Fetch lacks the emitter session, so compact evidence uses owner + Network
454
+ // ID; exact records below still use session + ID. False positives may refuse
455
+ // a request, but a failed identity is never forgotten or treated as live.
456
+ // The filters are controller-local and monotonic. They are not rotated or
457
+ // cleared on worker detach; arbitrary late Network/Fetch delivery is allowed.
458
+ // A sufficiently long history can saturate a filter and reduce availability.
459
+ const retiredSummaries = new Map();
460
+ const summaryBytes = 256 * 1024;
461
+ const summaryPositions = (id) => {
462
+ const positions = [];
463
+ for (let seed = 1; seed <= 7; seed++) {
464
+ let hash = 0x811c9dc5 ^ Math.imul(seed, 0x9e3779b9);
465
+ for (let i = 0; i < id.length; i++)
466
+ hash = Math.imul(hash ^ id.charCodeAt(i), 0x01000193);
467
+ hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b);
468
+ hash = Math.imul(hash ^ (hash >>> 13), 0xc2b2ae35);
469
+ positions.push((hash ^ (hash >>> 16)) & (summaryBytes * 8 - 1));
470
+ }
471
+ return positions;
472
+ };
473
+ const hasRetiredSummary = (owner, id) => {
474
+ const bits = retiredSummaries.get(owner);
475
+ return !!bits && summaryPositions(id).every(bit => (bits[bit >>> 3] & (1 << (bit & 7))) !== 0);
476
+ };
477
+ const requestKey = (sessionId, id) => JSON.stringify([sessionId, id]);
478
+ const sessionDetached = (sessionId) => {
479
+ while (sessionId) {
480
+ if (detachedSessions.has(sessionId))
481
+ return true;
482
+ sessionId = sessionParents.get(sessionId);
483
+ }
484
+ return false;
485
+ };
486
+ let merchantContext;
487
+ let targetContextKnown = false;
412
488
  const lifecycle = new CheckoutLifecycle(opts);
413
489
  const stripeCheckout = new StripeCheckoutGate(opts);
414
490
  const mercadoCheckout = new MercadoCheckoutGate();
@@ -432,13 +508,125 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
432
508
  // One outstanding approval at a time; see the note above isApprovalOutcome.
433
509
  let awaitingApproval = false;
434
510
  let activeRequest = null;
511
+ const sourceOwner = (source) => workerOwners.get(source.sessionId) ?? source.sessionId;
512
+ const retireSource = (source) => {
513
+ const owner = sourceOwner(source), key = requestKey(source.sessionId, source.networkId);
514
+ const count = (retiredCounts.get(owner) ?? 0) + (retiredSources.has(key) ? 0 : 1);
515
+ // Compact overflow without dropping failed identities or turning unrelated
516
+ // background failures into a permanent refusal of every request in a frame.
517
+ if (count > 1024) {
518
+ let bits = retiredSummaries.get(owner);
519
+ if (!bits) {
520
+ bits = new Uint8Array(summaryBytes);
521
+ retiredSummaries.set(owner, bits);
522
+ }
523
+ for (const bit of summaryPositions(source.networkId))
524
+ bits[bit >>> 3] |= 1 << (bit & 7);
525
+ if (activeRequest?.sessionId === owner && hasRetiredSummary(owner, activeRequest.networkId)) {
526
+ activeRequest.attempt.stop('browser_interception_unavailable');
527
+ }
528
+ return;
529
+ }
530
+ retiredCounts.set(owner, count);
531
+ retiredSources.set(key, source);
532
+ };
533
+ const identifyRequest = (request) => {
534
+ if (request.sessionId && hasRetiredSummary(request.sessionId, request.networkId)) {
535
+ request.attempt.stop('browser_interception_unavailable');
536
+ request.attempt.assertLive();
537
+ }
538
+ const matches = (source) => {
539
+ const metadata = source.request;
540
+ return sourceOwner(source) === request.sessionId
541
+ && source.networkId === request.networkId && (!metadata
542
+ || (metadata.url === request.request.url && metadata.method === request.request.method
543
+ // A missing body is unknown, not evidence of a different request.
544
+ && !(typeof metadata.postData === 'string' && typeof request.request.postData === 'string'
545
+ && metadata.postData !== request.request.postData)));
546
+ };
547
+ const candidates = [...requestSources.values(), ...retiredSources.values()].filter(matches);
548
+ if (!candidates.length)
549
+ return false;
550
+ // A failure can arrive before its Network request description. Do not
551
+ // ignore that possible source and assign its Fetch to a live sibling.
552
+ if (candidates.some(source => !source.request)) {
553
+ if (request.sourceSessionId) {
554
+ request.attempt.stop('browser_interception_unavailable');
555
+ request.attempt.assertLive();
556
+ }
557
+ return false;
558
+ }
559
+ if (candidates.length !== 1) {
560
+ request.attempt.stop('browser_interception_unavailable');
561
+ request.attempt.assertLive();
562
+ }
563
+ const source = candidates[0];
564
+ if (request.sourceSessionId && request.sourceSessionId !== source.sessionId) {
565
+ request.attempt.stop('browser_interception_unavailable');
566
+ }
567
+ request.sourceSessionId = source.sessionId;
568
+ if (sessionDetached(source.sessionId) || retiredSources.has(requestKey(source.sessionId, source.networkId)))
569
+ request.attempt.stop();
570
+ request.attempt.assertLive();
571
+ return true;
572
+ };
573
+ /** A frame's Fetch event does not identify which dedicated worker sent it.
574
+ * Require the exact Network event before asking the Vault whenever that frame
575
+ * has had workers. A sibling's detach proves nothing about an unknown request;
576
+ * missing attribution instead expires without releasing any card or token.
577
+ * Keep detached ancestry so a late event cannot revive a lost worker request.
578
+ */
579
+ const awaitRequestSource = async (request) => {
580
+ if (identifyRequest(request))
581
+ return;
582
+ if (!request.sessionId || !ownersWithWorkers.has(request.sessionId)) {
583
+ if (request.sessionId && retiredSources.has(requestKey(request.sessionId, request.networkId))) {
584
+ request.attempt.stop();
585
+ request.attempt.assertLive();
586
+ }
587
+ request.sourceSessionId = request.sessionId;
588
+ return;
589
+ }
590
+ let timer;
591
+ let changed = () => { };
592
+ const signals = [request.attempt.signal, lifecycle.abort.signal, setupStop.signal];
593
+ try {
594
+ await new Promise((resolve, reject) => {
595
+ changed = () => {
596
+ try {
597
+ request.attempt.assertLive();
598
+ if (lifecycle.isCancelled())
599
+ throw new Error('checkout cancelled locally before approval');
600
+ if (setupStop.signal.aborted)
601
+ throw new PaymentOutcomeUnknownError(null, 'browser_interception_unavailable');
602
+ if (identifyRequest(request))
603
+ resolve();
604
+ }
605
+ catch (error) {
606
+ reject(error);
607
+ }
608
+ };
609
+ request.attributionChanged = changed;
610
+ for (const signal of signals)
611
+ signal.addEventListener('abort', changed, { once: true });
612
+ timer = setTimeout(() => reject(new PaymentOutcomeUnknownError(null, 'browser_interception_unavailable')), setupTimeoutMs);
613
+ changed();
614
+ });
615
+ }
616
+ finally {
617
+ clearTimeout(timer);
618
+ for (const signal of signals)
619
+ signal.removeEventListener('abort', changed);
620
+ delete request.attributionChanged;
621
+ }
622
+ };
435
623
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
436
624
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
437
625
  let lastSubmitted = null;
438
626
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
439
627
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
440
628
  ...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN])];
441
- const arm = async (sessionId, resume = false) => {
629
+ const arm = async (sessionId, resume = false, worker = false) => {
442
630
  const key = sessionId ?? '__root__';
443
631
  if (armed.has(key))
444
632
  return;
@@ -446,15 +634,26 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
446
634
  return arming.get(key);
447
635
  // Fetch's interception ID differs from Network's ID. Enable failure events
448
636
  // before intercepting and bind each attempt to both its ID and CDP session.
449
- const pending = withinAttachmentDeadline(async (assertActive) => {
637
+ const pending = withinAttachmentDeadline(async (assertDeadline) => {
638
+ const assertActive = () => {
639
+ assertDeadline();
640
+ if (sessionId && detachedSessions.has(sessionId))
641
+ throw new CheckoutAttachmentError('closed');
642
+ };
450
643
  await cdp.send('Network.enable', {}, sessionId);
451
644
  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();
645
+ // Worker Fetch does not exist in Chrome. Its requests pause on the owning
646
+ // frame, but its abort/finish events arrive on the worker Network session.
647
+ // Keep that session attached and paused until Network is listening, then
648
+ // resume it only after its nested workers can be attached recursively.
649
+ if (!worker) {
650
+ await cdp.send('Page.enable', {}, sessionId).catch(() => { });
651
+ assertActive();
652
+ await cdp.send('Fetch.enable', {
653
+ patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
654
+ }, sessionId);
655
+ assertActive();
656
+ }
458
657
  // Descend into this target's own children (iframes inside iframes).
459
658
  await cdp.send('Target.setAutoAttach', {
460
659
  autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
@@ -481,7 +680,33 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
481
680
  arming.delete(key);
482
681
  }
483
682
  };
683
+ const failInterception = (error) => {
684
+ if (setupFailureReported)
685
+ return;
686
+ setupFailureReported = true;
687
+ setupStop.abort(attachmentFailure(error));
688
+ terminal = new Error('browser_interception_unavailable');
689
+ // A new target changes future interception, not the result of an earlier
690
+ // attempt. Preserve a settled result or handoff while still refusing new
691
+ // requests. Approval/preparation in progress must instead be retired.
692
+ const status = lifecycle.getState().status;
693
+ if (status === 'completed' || (!activeRequest && !['idle', 'awaiting_approval', 'ready_to_submit'].includes(status)))
694
+ return;
695
+ activeRequest?.attempt.stop();
696
+ preparationGate.invalidate('browser_interception_unavailable');
697
+ lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
698
+ opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
699
+ };
484
700
  cdp.on(async (method, params, sessionId) => {
701
+ if ((method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') && targetContextKnown) {
702
+ try {
703
+ assertContextTarget(params?.targetInfo, merchantContext);
704
+ }
705
+ catch (error) {
706
+ failInterception(error);
707
+ }
708
+ return;
709
+ }
485
710
  if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
486
711
  preparationGate.invalidate('merchant_document_changed');
487
712
  mercadoCheckout.invalidate();
@@ -495,6 +720,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
495
720
  }
496
721
  if ((method === 'Inspector.detached' && sessionId === pageSessionId)
497
722
  || (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
723
+ detachedSessions.add(pageSessionId);
498
724
  setupStop.abort(new CheckoutAttachmentError('closed'));
499
725
  preparationGate.invalidate('merchant_document_closed');
500
726
  stripeCheckout.invalidate();
@@ -502,9 +728,52 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
502
728
  // The root page owns every attached OOPIF; its loss ends child requests too.
503
729
  activeRequest?.attempt.stop();
504
730
  }
731
+ // The transport is browser-scoped. A second tab's request or child target
732
+ // must never be authorized with this page's merchant, amount or preparation.
733
+ if (!sessionId || !ownedSessions.has(sessionId))
734
+ return;
735
+ if (method === 'Network.requestWillBeSent') {
736
+ const owner = workerOwners.get(sessionId) ?? sessionId;
737
+ const matchesActive = activeRequest?.sessionId === owner && activeRequest.networkId === params.requestId;
738
+ // Only identification may consume a late event from a detached target.
739
+ // Its Fetch events, children and commands remain permanently ignored.
740
+ if (sessionDetached(sessionId) && !matchesActive)
741
+ return;
742
+ const key = requestKey(sessionId, params.requestId);
743
+ const record = { sessionId, networkId: params.requestId, request: params.request };
744
+ if (sessionDetached(sessionId) || retiredSources.has(key) || hasRetiredSummary(owner, params.requestId))
745
+ retireSource(record);
746
+ else
747
+ requestSources.set(key, record);
748
+ if (matchesActive) {
749
+ try {
750
+ identifyRequest(activeRequest);
751
+ }
752
+ catch { /* stop already retires the exact attempt */ }
753
+ activeRequest.attributionChanged?.();
754
+ }
755
+ return;
756
+ }
757
+ if (sessionDetached(sessionId))
758
+ return;
759
+ if (method === 'Network.loadingFinished') {
760
+ requestSources.delete(requestKey(sessionId, params.requestId));
761
+ return;
762
+ }
505
763
  if (method === 'Network.loadingFailed') {
506
- if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sessionId === sessionId)
764
+ if (activeRequest && activeRequest.networkId === params.requestId
765
+ && activeRequest.sourceSessionId === sessionId)
507
766
  activeRequest.attempt.stop();
767
+ const key = requestKey(sessionId, params.requestId);
768
+ retireSource(requestSources.get(key) ?? { sessionId, networkId: params.requestId });
769
+ requestSources.delete(key);
770
+ if (activeRequest?.sessionId === (workerOwners.get(sessionId) ?? sessionId) && activeRequest.networkId === params.requestId) {
771
+ try {
772
+ identifyRequest(activeRequest);
773
+ }
774
+ catch { /* its exact origin is unavailable */ }
775
+ activeRequest.attributionChanged?.();
776
+ }
508
777
  return;
509
778
  }
510
779
  if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
@@ -512,6 +781,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
512
781
  : method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
513
782
  : activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
514
783
  activeRequest.attempt.stop();
784
+ if (method !== 'Page.frameDetached') {
785
+ const detached = method === 'Target.detachedFromTarget' ? params.sessionId : sessionId;
786
+ detachedSessions.add(detached);
787
+ if (arming.has(detached))
788
+ failInterception(new CheckoutAttachmentError('closed'));
789
+ // Terminating a worker need not emit loadingFailed. Its nested workers
790
+ // also lose their request. An unknown request waits for attribution;
791
+ // detaching an unrelated sibling must never cancel the card approval.
792
+ const source = activeRequest?.sourceSessionId;
793
+ if (activeRequest && (sessionDetached(activeRequest.sessionId) || (source && sessionDetached(source))))
794
+ activeRequest.attempt.stop();
795
+ for (const [key, record] of requestSources)
796
+ if (sessionDetached(record.sessionId)) {
797
+ retireSource(record);
798
+ requestSources.delete(key);
799
+ }
800
+ }
515
801
  return;
516
802
  }
517
803
  if (method === 'Target.attachedToTarget') {
@@ -519,18 +805,27 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
519
805
  return;
520
806
  const child = params.sessionId;
521
807
  try {
522
- await arm(child, true);
808
+ if (typeof child !== 'string' || !child || !['page', 'iframe', 'worker'].includes(params.targetInfo?.type)) {
809
+ throw new CheckoutAttachmentError('unavailable');
810
+ }
811
+ if (child === pageSessionId || detachedSessions.has(child) || (ownedSessions.has(child)
812
+ && (sessionParents.get(child) !== sessionId || sessionTypes.get(child) !== params.targetInfo.type))) {
813
+ throw new CheckoutAttachmentError('unavailable');
814
+ }
815
+ ownedSessions.add(child);
816
+ sessionTypes.set(child, params.targetInfo.type);
817
+ sessionParents.set(child, sessionId);
818
+ if (params.targetInfo.type === 'worker') {
819
+ const owner = workerOwners.get(sessionId) ?? sessionId;
820
+ workerOwners.set(child, owner);
821
+ ownersWithWorkers.add(owner);
822
+ }
823
+ await arm(child, true, params.targetInfo.type === 'worker');
523
824
  }
524
825
  catch (error) {
525
826
  // Shared child setup and sibling cancellation can reject several
526
827
  // 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' });
828
+ failInterception(error);
534
829
  // Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
535
830
  return;
536
831
  }
@@ -692,7 +987,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
692
987
  attempt.stop();
693
988
  attempt.assertLive();
694
989
  }
695
- activeRequest = { attempt, networkId, sessionId, frameId };
990
+ activeRequest = { attempt, networkId, sessionId, frameId, request };
991
+ await awaitRequestSource(activeRequest);
696
992
  if (preparation)
697
993
  await preparationGate.assertDocument();
698
994
  const pageOrigin = await pageOriginOf(readDocumentUrl, attempt.signal);
@@ -799,7 +1095,17 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
799
1095
  // Ignore the failure event caused by our own safe decline/error abort.
800
1096
  if (activeRequest?.attempt === attempt)
801
1097
  activeRequest = null;
802
- lifecycle.failed(err, handoffStarted);
1098
+ // Vault clients may normalize a merchantSignal abort. Preserve the
1099
+ // adapter's more precise cause when ownership became ambiguous.
1100
+ try {
1101
+ attempt.assertLive();
1102
+ }
1103
+ catch (stopped) {
1104
+ err = stopped;
1105
+ }
1106
+ // stop() already published the terminal state before aborting the Vault.
1107
+ if (!attempt.signal.aborted)
1108
+ lifecycle.failed(err, handoffStarted);
803
1109
  if (isTerminal(err))
804
1110
  terminal = err;
805
1111
  else if (isApprovalOutcome(err))
@@ -817,7 +1123,31 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
817
1123
  }
818
1124
  });
819
1125
  try {
820
- await arm(pageSessionId);
1126
+ await withinAttachmentDeadline(async (assertActive) => {
1127
+ const { targetInfo } = await cdp.send('Target.getTargetInfo', {}, pageSessionId);
1128
+ assertActive();
1129
+ if (!targetRecord(targetInfo) || targetInfo.type !== 'page'
1130
+ || (targetInfo.browserContextId !== undefined && !contextId(targetInfo.browserContextId))) {
1131
+ throw new CheckoutAttachmentError('unavailable');
1132
+ }
1133
+ merchantContext = targetInfo.browserContextId;
1134
+ targetContextKnown = true;
1135
+ await cdp.send('Target.setDiscoverTargets', { discover: true });
1136
+ assertActive();
1137
+ const { targetInfos } = await cdp.send('Target.getTargets');
1138
+ assertActive();
1139
+ if (!Array.isArray(targetInfos))
1140
+ throw new CheckoutAttachmentError('unavailable');
1141
+ for (const target of targetInfos)
1142
+ assertContextTarget(target, merchantContext);
1143
+ await arm(pageSessionId);
1144
+ // Existing OOPIFs report attachment before the parent's acknowledgement,
1145
+ // but their Fetch setup finishes later. Do not invite the caller to press
1146
+ // Pay while one of those already-running frames is still unprotected.
1147
+ while (arming.size)
1148
+ await Promise.all(arming.values());
1149
+ assertActive();
1150
+ }, setupTimeoutMs, setupStop.signal);
821
1151
  if (setupStop.signal.aborted)
822
1152
  throw attachmentFailure(setupStop.signal.reason);
823
1153
  attachmentReady = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "Let browser agents pay with the user's own card, without your infrastructure ever touching card data.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -27,8 +27,8 @@
27
27
  "_comment_build": "The public SDK keeps zero runtime dependencies. Its build vendors deterministic metadata and substitution artifacts from the internal payment core; TypeScript remains pinned.",
28
28
  "build": "node ../payment-core/scripts/build.mjs && node scripts/generate-payment-core.mjs && npx -y -p typescript@5.9.3 tsc && node scripts/generate-preflight-contract.mjs",
29
29
  "prepublishOnly": "pnpm build",
30
- "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs braintree.test.mjs autopilot.test.mjs stripe-checkout.test.mjs payment-core.test.mjs prepared-processor.test.mjs minimum-delay.test.mjs paysafe.test.mjs attachment.test.mjs mercado-checkout.test.mjs mercado-polling.test.mjs && node --test preflight-package.test.mjs preflight-collector.test.mjs && node --test kernel-native-qualification.test.mjs && node --test ../vault/scripts/recurly-validation/watch-duty-sdk-result.test.mjs",
31
- "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs && node preparation-browser.test.mjs && node owned-shop-browser.test.mjs && node spreedly-browser.test.mjs",
30
+ "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs braintree.test.mjs autopilot.test.mjs stripe-checkout.test.mjs payment-core.test.mjs prepared-processor.test.mjs minimum-delay.test.mjs paysafe.test.mjs attachment.test.mjs worker-targets.test.mjs mercado-checkout.test.mjs mercado-polling.test.mjs && node --test preflight-package.test.mjs preflight-collector.test.mjs && node --test kernel-native-qualification.test.mjs && node --test ../vault/scripts/recurly-validation/watch-duty-sdk-result.test.mjs",
31
+ "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs && node preparation-browser.test.mjs && node owned-shop-browser.test.mjs && node spreedly-browser.test.mjs && node worker-browser.test.mjs",
32
32
  "check:payment-core": "node scripts/generate-payment-core.mjs --check",
33
33
  "test:preflight": "node --test preflight-package.test.mjs preflight-collector.test.mjs kernel-native-qualification.test.mjs",
34
34
  "test:preflight:browser": "node preflight-browser.test.mjs",