@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.
- package/dist/cdp.d.ts +30 -1
- package/dist/cdp.js +353 -23
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|