@agent-cards/checkout 0.5.0 → 0.7.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
@@ -2,6 +2,7 @@ import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
2
  import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, ProcessorRefusedError, UnsupportedModeError, redactUrl, } from './client.js';
3
3
  import { PreparationGate } from './preparation.js';
4
4
  import { classifyBraintreeRequest } from './braintree.js';
5
+ import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
5
6
  import { substituteEncryptedFields } from './substitute.js';
6
7
  import { hostedFormSubmittedPage } from './hosted-form.js';
7
8
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
@@ -273,6 +274,59 @@ export function withCorsHeaders(headers, cors) {
273
274
  function headerEntries(headers) {
274
275
  return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
275
276
  }
277
+ /**
278
+ * The origin of the top-level document the payment form is on, read when a
279
+ * card request pauses: the fact that names the merchant for the company's
280
+ * presets (the API's checkout_origin). An https origin, or http://localhost
281
+ * for local checkout; anything else, or a page that cannot be read, yields
282
+ * undefined and the authorization goes out without one (a merchant, category
283
+ * or place rule then refuses it as unknown, never the request itself).
284
+ */
285
+ async function pageOriginOf(readDocumentUrl) {
286
+ try {
287
+ const url = new URL(await readDocumentUrl());
288
+ if (url.username || url.password)
289
+ return undefined;
290
+ if (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))
291
+ return url.origin;
292
+ return undefined;
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ }
298
+ /**
299
+ * The page total, the lowest amount authority, through the integrator's own
300
+ * reader (AttachOptions.pageAmount): an integer in the smallest unit with its
301
+ * currency, or nothing. A read is a courtesy, never a wait: it gets one
302
+ * second, and anything unreadable yields undefined so the authorization goes
303
+ * out without a page total.
304
+ */
305
+ const PAGE_AMOUNT_READ_MS = 1_000;
306
+ async function pageAmountOf(opts) {
307
+ if (!opts.pageAmount)
308
+ return undefined;
309
+ let timer;
310
+ try {
311
+ const raw = await Promise.race([
312
+ Promise.resolve().then(() => opts.pageAmount()).catch(() => undefined),
313
+ new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), PAGE_AMOUNT_READ_MS); }),
314
+ ]);
315
+ if (!raw || typeof raw !== 'object')
316
+ return undefined;
317
+ const { amount, currency } = raw;
318
+ if (!Number.isSafeInteger(amount) || amount < 0 || typeof currency !== 'string' || !/^[A-Za-z]{3}$/.test(currency))
319
+ return undefined;
320
+ return { amount: amount, currency: currency.toLowerCase() };
321
+ }
322
+ catch {
323
+ return undefined;
324
+ }
325
+ finally {
326
+ if (timer)
327
+ clearTimeout(timer);
328
+ }
329
+ }
276
330
  /**
277
331
  * Last-resort patterns: the built-in recognizers' hosts, derived the same way
278
332
  * as everything else. Used only when the vault hands back nothing at all (a
@@ -328,17 +382,23 @@ function merchantAttempt(lifecycle) {
328
382
  */
329
383
  export async function attachToCdp(cdp, pageSessionId, opts) {
330
384
  opts = safeOptions(opts);
385
+ const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
386
+ const setupStop = new AbortController();
387
+ let attachmentReady = false;
388
+ let setupFailureReported = false;
331
389
  const lifecycle = new CheckoutLifecycle(opts);
332
390
  let preparationFrameId;
333
- const preparationGate = new PreparationGate(opts, lifecycle, async () => {
391
+ const readDocumentUrl = async () => {
334
392
  const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
335
393
  if (typeof tree?.frameTree?.frame?.url !== 'string')
336
394
  throw new Error('merchant_document_unavailable');
337
395
  preparationFrameId = tree.frameTree.frame.id;
338
396
  return tree.frameTree.frame.url;
339
- });
397
+ };
398
+ const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
340
399
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
341
400
  const armed = new Set();
401
+ const arming = new Map();
342
402
  // Set once a failure proves that retrying cannot help; see isTerminal.
343
403
  let terminal = null;
344
404
  // Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
@@ -352,23 +412,48 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
352
412
  let lastSubmitted = null;
353
413
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
354
414
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns])];
355
- opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
356
- const arm = async (sessionId) => {
415
+ const arm = async (sessionId, resume = false) => {
357
416
  const key = sessionId ?? '__root__';
358
417
  if (armed.has(key))
359
418
  return;
419
+ if (arming.has(key))
420
+ return arming.get(key);
360
421
  // Fetch's interception ID differs from Network's ID. Enable failure events
361
422
  // before intercepting and bind each attempt to both its ID and CDP session.
362
- await cdp.send('Network.enable', {}, sessionId);
363
- await cdp.send('Page.enable', {}, sessionId).catch(() => { });
364
- await cdp.send('Fetch.enable', {
365
- patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
366
- }, sessionId);
367
- // Descend into this target's own children (iframes inside iframes).
368
- await cdp.send('Target.setAutoAttach', {
369
- autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
370
- }, sessionId);
371
- armed.add(key);
423
+ const pending = withinAttachmentDeadline(async (assertActive) => {
424
+ await cdp.send('Network.enable', {}, sessionId);
425
+ assertActive();
426
+ await cdp.send('Page.enable', {}, sessionId).catch(() => { });
427
+ assertActive();
428
+ await cdp.send('Fetch.enable', {
429
+ patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
430
+ }, sessionId);
431
+ assertActive();
432
+ // Descend into this target's own children (iframes inside iframes).
433
+ await cdp.send('Target.setAutoAttach', {
434
+ autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
435
+ }, sessionId);
436
+ assertActive();
437
+ // A child remains part of setup until Chrome acknowledges its resume.
438
+ // Deduplicate this command with arming, and bound the acknowledgement too.
439
+ if (resume) {
440
+ await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId);
441
+ assertActive();
442
+ }
443
+ }, setupTimeoutMs, setupStop.signal);
444
+ arming.set(key, pending);
445
+ try {
446
+ await pending;
447
+ armed.add(key);
448
+ }
449
+ catch (error) {
450
+ const failure = attachmentFailure(error);
451
+ setupStop.abort(failure);
452
+ throw failure;
453
+ }
454
+ finally {
455
+ arming.delete(key);
456
+ }
372
457
  };
373
458
  cdp.on(async (method, params, sessionId) => {
374
459
  if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
@@ -379,6 +464,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
379
464
  }
380
465
  if ((method === 'Inspector.detached' && sessionId === pageSessionId)
381
466
  || (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
467
+ setupStop.abort(new CheckoutAttachmentError('closed'));
382
468
  preparationGate.invalidate('merchant_document_closed');
383
469
  // The root page owns every attached OOPIF; its loss ends child requests too.
384
470
  activeRequest?.attempt.stop();
@@ -396,19 +482,25 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
396
482
  return;
397
483
  }
398
484
  if (method === 'Target.attachedToTarget') {
485
+ if (setupStop.signal.aborted)
486
+ return;
399
487
  const child = params.sessionId;
400
488
  try {
401
- await arm(child);
489
+ await arm(child, true);
402
490
  }
403
- catch {
491
+ catch (error) {
492
+ // Shared child setup and sibling cancellation can reject several
493
+ // handlers together. Publish the terminal setup failure only once.
494
+ if (setupFailureReported)
495
+ return;
496
+ setupFailureReported = true;
497
+ setupStop.abort(attachmentFailure(error));
404
498
  terminal = new Error('browser_interception_unavailable');
405
499
  lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
406
500
  opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
407
501
  // Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
408
502
  return;
409
503
  }
410
- // Child targets start paused when waitForDebuggerOnStart is set.
411
- await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
412
504
  return;
413
505
  }
414
506
  if (method !== 'Fetch.requestPaused')
@@ -438,9 +530,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
438
530
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
439
531
  return;
440
532
  }
533
+ if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
534
+ opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
535
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
536
+ return;
537
+ }
441
538
  let preparation;
442
539
  try {
443
- preparation = preparationGate.claim(request.url, braintree === 'tokenization' ? pausedBody(request) : undefined);
540
+ preparation = preparationGate.claim(request.url, pausedBody(request));
444
541
  }
445
542
  catch (error) {
446
543
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
@@ -490,14 +587,17 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
490
587
  activeRequest = { attempt, networkId, sessionId, frameId };
491
588
  if (preparation)
492
589
  await preparationGate.assertDocument();
590
+ const pageOrigin = await pageOriginOf(readDocumentUrl);
591
+ const pageAmount = await pageAmountOf(opts);
493
592
  attempt.assertLive();
494
593
  const replay = await opts.vault.authorize({
495
594
  user: opts.user,
496
595
  merchant: opts.merchant,
497
596
  amount: opts.amount,
498
- amountCents: opts.amountCents,
499
597
  currency: opts.currency,
500
598
  cardId: preparation?.cardId ?? opts.cardId,
599
+ pageOrigin,
600
+ pageAmount,
501
601
  preparation,
502
602
  timeoutMs: opts.timeoutMs,
503
603
  signal: lifecycle.abort.signal,
@@ -590,7 +690,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
590
690
  preparationGate.retireUnboundClaim();
591
691
  }
592
692
  });
593
- await arm(pageSessionId);
693
+ try {
694
+ await arm(pageSessionId);
695
+ if (setupStop.signal.aborted)
696
+ throw attachmentFailure(setupStop.signal.reason);
697
+ attachmentReady = true;
698
+ opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
699
+ }
700
+ catch (error) {
701
+ const failure = attachmentFailure(error);
702
+ setupStop.abort(failure);
703
+ terminal = failure;
704
+ if (!setupFailureReported) {
705
+ setupFailureReported = true;
706
+ lifecycle.cancel();
707
+ opts.onEvent?.({ type: 'failed', detail: `checkout_attachment_${failure.reason}` });
708
+ }
709
+ throw failure;
710
+ }
594
711
  return lifecycle;
595
712
  }
596
713
  /**
@@ -606,17 +723,21 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
606
723
  */
607
724
  export async function attachToPlaywright(page, opts) {
608
725
  opts = safeOptions(opts);
726
+ const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
727
+ const setupStop = new AbortController();
728
+ let attachmentReady = false;
609
729
  // Routing cannot see requests owned by a service worker. Existing controlled
610
730
  // contexts must be recreated with serviceWorkers: 'block' before checkout.
611
731
  if (page.context?.().serviceWorkers?.().length) {
612
732
  throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
613
733
  }
614
734
  const lifecycle = new CheckoutLifecycle(opts);
615
- const preparationGate = new PreparationGate(opts, lifecycle, async () => {
735
+ const readDocumentUrl = async () => {
616
736
  if (page.isClosed?.() || typeof page.url !== 'function')
617
737
  throw new Error('merchant_document_unavailable');
618
738
  return page.url();
619
- });
739
+ };
740
+ const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
620
741
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
621
742
  // Playwright's own routing, NOT a hand-rolled CDP session.
622
743
  //
@@ -642,8 +763,10 @@ export async function attachToPlaywright(page, opts) {
642
763
  if (activeRequest && activeRequest.request === request)
643
764
  activeRequest.attempt.stop();
644
765
  });
645
- page.on?.('close', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
646
- page.on?.('crash', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
766
+ page.on?.('close', () => { if (!attachmentReady)
767
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
768
+ page.on?.('crash', () => { if (!attachmentReady)
769
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
647
770
  page.on?.('framenavigated', (frame) => {
648
771
  if (frame === page.mainFrame?.()) {
649
772
  preparationGate.invalidate('merchant_document_changed');
@@ -658,147 +781,178 @@ export async function attachToPlaywright(page, opts) {
658
781
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
659
782
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
660
783
  let lastSubmitted = null;
661
- await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
662
- const request = route.request();
663
- const braintree = request.method().toUpperCase() === 'POST'
664
- ? classifyBraintreeRequest(request.url(), request.method(), request.postData() ?? '') : undefined;
665
- if (braintree === 'configuration')
666
- return route.fallback();
667
- if (braintree === 'invalid') {
668
- opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
669
- return route.abort('aborted');
784
+ const install = async (assertActive) => {
785
+ if (page.isClosed?.()) {
786
+ const failure = new CheckoutAttachmentError('closed');
787
+ setupStop.abort(failure);
788
+ throw failure;
670
789
  }
671
- // The matcher only sees the URL; a preflight or a GET must pass through
672
- // untouched or the browser's CORS check fails on our synthetic answer.
673
- if (!opts.vault.isCardRequest(request.url(), request.method())) {
674
- if (guards.matches(request.url(), request.method())) {
675
- preparationGate.invalidate('unsupported_checkout');
676
- lifecycle.unsupported();
677
- opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
790
+ await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
791
+ const request = route.request();
792
+ const braintree = request.method().toUpperCase() === 'POST'
793
+ ? classifyBraintreeRequest(request.url(), request.method(), request.postData() ?? '') : undefined;
794
+ if (braintree === 'configuration')
795
+ return route.fallback();
796
+ if (braintree === 'invalid') {
797
+ opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
678
798
  return route.abort('aborted');
679
799
  }
680
- return route.fallback();
681
- }
682
- let preparation;
683
- try {
684
- preparation = preparationGate.claim(request.url(), braintree === 'tokenization' ? request.postData() ?? '' : undefined);
685
- }
686
- catch (error) {
687
- opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
688
- return route.abort('aborted');
689
- }
690
- // Fail closed and stay quiet: no card may reach the PSP, but neither may
691
- // the page's retry loop turn into a stream of doomed API calls. Every
692
- // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
693
- // CDP adapter's Fetch.failRequest uses, so a refused navigation
694
- // resolves identically whichever adapter is attached.
695
- if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
696
- const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
697
- opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
698
- if (preparation)
699
- preparationGate.retireUnboundClaim();
700
- return route.abort('aborted');
701
- }
702
- // Reserved before anything that could yield, matching attachToCdp.
703
- awaitingApproval = true;
704
- const attempt = merchantAttempt(lifecycle);
705
- const frames = [];
706
- try {
707
- for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
708
- frames.push(frame);
709
- }
710
- catch { /* requestfailed/page close still cover unavailable frame metadata */ }
711
- const assertRequestLive = () => {
712
- if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
713
- attempt.stop();
714
- attempt.assertLive();
715
- };
716
- let handoffStarted = false;
717
- try {
718
- const body = request.postData() ?? '';
719
- if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
720
- opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
721
- return await route.abort('aborted');
800
+ // The matcher only sees the URL; a preflight or a GET must pass through
801
+ // untouched or the browser's CORS check fails on our synthetic answer.
802
+ if (!opts.vault.isCardRequest(request.url(), request.method())) {
803
+ if (guards.matches(request.url(), request.method())) {
804
+ preparationGate.invalidate('unsupported_checkout');
805
+ lifecycle.unsupported();
806
+ opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
807
+ return route.abort('aborted');
808
+ }
809
+ return route.fallback();
722
810
  }
723
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
724
- lifecycle.begin();
725
- activeRequest = { request, frames, attempt };
726
- if (preparation)
727
- await preparationGate.assertDocument();
728
- assertRequestLive();
729
- const replay = await opts.vault.authorize({
730
- user: opts.user,
731
- merchant: opts.merchant,
732
- amount: opts.amount,
733
- amountCents: opts.amountCents,
734
- currency: opts.currency,
735
- cardId: preparation?.cardId ?? opts.cardId,
736
- preparation,
737
- timeoutMs: opts.timeoutMs,
738
- signal: lifecycle.abort.signal,
739
- merchantSignal: attempt.signal,
740
- onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
741
- onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
742
- lifecycle.approvalUrl(url);
743
- return opts.onApprovalUrl?.(url);
744
- } },
745
- request: { url: request.url(), method: request.method(), headers: request.headers(), body },
746
- });
747
- assertRequestLive();
748
- if (lifecycle.isCancelled())
749
- throw new Error('checkout cancelled locally after approval');
750
- lifecycle.prepareHandoff(replay, request.url());
751
- handoffStarted = replay.mode !== 'cse';
752
- if (replay.mode === 'hosted_form') {
753
- // Same as the CDP path: the paused navigation resolves to the
754
- // synthetic page, and a re-post of this form is refused.
755
- const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
756
- // Inert on a navigation (never CORS-checked); one path for every fulfill.
757
- await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
758
- assertRequestLive();
759
- lastSubmitted = { url: request.url(), body, at: Date.now() };
760
- opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
811
+ if (!attachmentReady || setupStop.signal.aborted) {
812
+ opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
813
+ return route.abort('aborted');
761
814
  }
762
- else if (replay.mode === 'cse') {
763
- // Same as the CDP path: the request continues from this browser
764
- // with the ciphertext swapped in and no header override; Playwright
765
- // recomputes the length itself.
766
- const postData = cseBody(body, replay);
767
- handoffStarted = true;
768
- await route.continue({ postData });
769
- assertRequestLive();
770
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
815
+ let preparation;
816
+ try {
817
+ preparation = preparationGate.claim(request.url(), request.postData() ?? '');
771
818
  }
772
- else {
773
- // Playwright adds these itself when a cross-origin fulfill carries
774
- // none; written here anyway (replacing a stale value) so a
775
- // cross-origin answer is the same whichever adapter ran.
776
- const cors = corsDecision(request.url(), request.headers());
777
- await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
819
+ catch (error) {
820
+ opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
821
+ return route.abort('aborted');
822
+ }
823
+ // Fail closed and stay quiet: no card may reach the PSP, but neither may
824
+ // the page's retry loop turn into a stream of doomed API calls. Every
825
+ // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
826
+ // CDP adapter's Fetch.failRequest uses, so a refused navigation
827
+ // resolves identically whichever adapter is attached.
828
+ if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
829
+ const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
830
+ opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
831
+ if (preparation)
832
+ preparationGate.retireUnboundClaim();
833
+ return route.abort('aborted');
834
+ }
835
+ // Reserved before anything that could yield, matching attachToCdp.
836
+ awaitingApproval = true;
837
+ const attempt = merchantAttempt(lifecycle);
838
+ const frames = [];
839
+ try {
840
+ for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
841
+ frames.push(frame);
842
+ }
843
+ catch { /* requestfailed/page close still cover unavailable frame metadata */ }
844
+ const assertRequestLive = () => {
845
+ if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
846
+ attempt.stop();
847
+ attempt.assertLive();
848
+ };
849
+ let handoffStarted = false;
850
+ try {
851
+ const body = request.postData() ?? '';
852
+ if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
853
+ opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
854
+ return await route.abort('aborted');
855
+ }
856
+ opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
857
+ lifecycle.begin();
858
+ activeRequest = { request, frames, attempt };
859
+ if (preparation)
860
+ await preparationGate.assertDocument();
861
+ const pageOrigin = await pageOriginOf(readDocumentUrl);
862
+ const pageAmount = await pageAmountOf(opts);
778
863
  assertRequestLive();
779
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
864
+ const replay = await opts.vault.authorize({
865
+ user: opts.user,
866
+ merchant: opts.merchant,
867
+ amount: opts.amount,
868
+ currency: opts.currency,
869
+ cardId: preparation?.cardId ?? opts.cardId,
870
+ pageOrigin,
871
+ pageAmount,
872
+ preparation,
873
+ timeoutMs: opts.timeoutMs,
874
+ signal: lifecycle.abort.signal,
875
+ merchantSignal: attempt.signal,
876
+ onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
877
+ onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
878
+ lifecycle.approvalUrl(url);
879
+ return opts.onApprovalUrl?.(url);
880
+ } },
881
+ request: { url: request.url(), method: request.method(), headers: request.headers(), body },
882
+ });
883
+ assertRequestLive();
884
+ if (lifecycle.isCancelled())
885
+ throw new Error('checkout cancelled locally after approval');
886
+ lifecycle.prepareHandoff(replay, request.url());
887
+ handoffStarted = replay.mode !== 'cse';
888
+ if (replay.mode === 'hosted_form') {
889
+ // Same as the CDP path: the paused navigation resolves to the
890
+ // synthetic page, and a re-post of this form is refused.
891
+ const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
892
+ // Inert on a navigation (never CORS-checked); one path for every fulfill.
893
+ await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
894
+ assertRequestLive();
895
+ lastSubmitted = { url: request.url(), body, at: Date.now() };
896
+ opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
897
+ }
898
+ else if (replay.mode === 'cse') {
899
+ // Same as the CDP path: the request continues from this browser
900
+ // with the ciphertext swapped in and no header override; Playwright
901
+ // recomputes the length itself.
902
+ const postData = cseBody(body, replay);
903
+ handoffStarted = true;
904
+ await route.continue({ postData });
905
+ assertRequestLive();
906
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
907
+ }
908
+ else {
909
+ // Playwright adds these itself when a cross-origin fulfill carries
910
+ // none; written here anyway (replacing a stale value) so a
911
+ // cross-origin answer is the same whichever adapter ran.
912
+ const cors = corsDecision(request.url(), request.headers());
913
+ await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
914
+ assertRequestLive();
915
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
916
+ }
917
+ lifecycle.handedOff(replay);
780
918
  }
781
- lifecycle.handedOff(replay);
782
- }
783
- catch (err) {
784
- if (activeRequest?.attempt === attempt)
785
- activeRequest = null;
786
- lifecycle.failed(err, handoffStarted);
787
- if (isTerminal(err))
788
- terminal = err;
789
- else if (isApprovalOutcome(err))
790
- quietUntil = Date.now() + cooldownMs;
791
- opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
792
- await route.abort('aborted').catch(() => { });
793
- }
794
- finally {
795
- if (activeRequest?.attempt === attempt)
796
- activeRequest = null;
797
- awaitingApproval = false;
798
- lifecycle.end();
799
- if (preparation)
800
- preparationGate.retireUnboundClaim();
801
- }
802
- });
919
+ catch (err) {
920
+ if (activeRequest?.attempt === attempt)
921
+ activeRequest = null;
922
+ lifecycle.failed(err, handoffStarted);
923
+ if (isTerminal(err))
924
+ terminal = err;
925
+ else if (isApprovalOutcome(err))
926
+ quietUntil = Date.now() + cooldownMs;
927
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
928
+ await route.abort('aborted').catch(() => { });
929
+ }
930
+ finally {
931
+ if (activeRequest?.attempt === attempt)
932
+ activeRequest = null;
933
+ awaitingApproval = false;
934
+ lifecycle.end();
935
+ if (preparation)
936
+ preparationGate.retireUnboundClaim();
937
+ }
938
+ });
939
+ assertActive();
940
+ };
941
+ try {
942
+ await withinAttachmentDeadline(install, setupTimeoutMs, setupStop.signal);
943
+ if (setupStop.signal.aborted)
944
+ throw attachmentFailure(setupStop.signal.reason);
945
+ attachmentReady = true;
946
+ }
947
+ catch (error) {
948
+ const failure = attachmentFailure(error);
949
+ setupStop.abort(failure);
950
+ terminal = failure;
951
+ lifecycle.cancel();
952
+ opts.onEvent?.({ type: 'failed', detail: `checkout_attachment_${failure.reason}` });
953
+ // A route registration may finish after this rejection. Keep its handler
954
+ // inert for card traffic; removing it would reopen the failed checkout.
955
+ throw failure;
956
+ }
803
957
  return lifecycle;
804
958
  }