@agent-cards/checkout 0.4.1 → 0.6.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
@@ -1,6 +1,8 @@
1
1
  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
+ import { classifyBraintreeRequest } from './braintree.js';
5
+ import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
4
6
  import { substituteEncryptedFields } from './substitute.js';
5
7
  import { hostedFormSubmittedPage } from './hosted-form.js';
6
8
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
@@ -327,6 +329,10 @@ function merchantAttempt(lifecycle) {
327
329
  */
328
330
  export async function attachToCdp(cdp, pageSessionId, opts) {
329
331
  opts = safeOptions(opts);
332
+ const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
333
+ const setupStop = new AbortController();
334
+ let attachmentReady = false;
335
+ let setupFailureReported = false;
330
336
  const lifecycle = new CheckoutLifecycle(opts);
331
337
  let preparationFrameId;
332
338
  const preparationGate = new PreparationGate(opts, lifecycle, async () => {
@@ -338,6 +344,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
338
344
  });
339
345
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
340
346
  const armed = new Set();
347
+ const arming = new Map();
341
348
  // Set once a failure proves that retrying cannot help; see isTerminal.
342
349
  let terminal = null;
343
350
  // Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
@@ -351,23 +358,48 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
351
358
  let lastSubmitted = null;
352
359
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
353
360
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns])];
354
- opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
355
- const arm = async (sessionId) => {
361
+ const arm = async (sessionId, resume = false) => {
356
362
  const key = sessionId ?? '__root__';
357
363
  if (armed.has(key))
358
364
  return;
365
+ if (arming.has(key))
366
+ return arming.get(key);
359
367
  // Fetch's interception ID differs from Network's ID. Enable failure events
360
368
  // before intercepting and bind each attempt to both its ID and CDP session.
361
- await cdp.send('Network.enable', {}, sessionId);
362
- await cdp.send('Page.enable', {}, sessionId).catch(() => { });
363
- await cdp.send('Fetch.enable', {
364
- patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
365
- }, sessionId);
366
- // Descend into this target's own children (iframes inside iframes).
367
- await cdp.send('Target.setAutoAttach', {
368
- autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
369
- }, sessionId);
370
- armed.add(key);
369
+ const pending = withinAttachmentDeadline(async (assertActive) => {
370
+ await cdp.send('Network.enable', {}, sessionId);
371
+ assertActive();
372
+ await cdp.send('Page.enable', {}, sessionId).catch(() => { });
373
+ assertActive();
374
+ await cdp.send('Fetch.enable', {
375
+ patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
376
+ }, sessionId);
377
+ assertActive();
378
+ // Descend into this target's own children (iframes inside iframes).
379
+ await cdp.send('Target.setAutoAttach', {
380
+ autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
381
+ }, sessionId);
382
+ assertActive();
383
+ // A child remains part of setup until Chrome acknowledges its resume.
384
+ // Deduplicate this command with arming, and bound the acknowledgement too.
385
+ if (resume) {
386
+ await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId);
387
+ assertActive();
388
+ }
389
+ }, setupTimeoutMs, setupStop.signal);
390
+ arming.set(key, pending);
391
+ try {
392
+ await pending;
393
+ armed.add(key);
394
+ }
395
+ catch (error) {
396
+ const failure = attachmentFailure(error);
397
+ setupStop.abort(failure);
398
+ throw failure;
399
+ }
400
+ finally {
401
+ arming.delete(key);
402
+ }
371
403
  };
372
404
  cdp.on(async (method, params, sessionId) => {
373
405
  if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
@@ -378,6 +410,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
378
410
  }
379
411
  if ((method === 'Inspector.detached' && sessionId === pageSessionId)
380
412
  || (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
413
+ setupStop.abort(new CheckoutAttachmentError('closed'));
381
414
  preparationGate.invalidate('merchant_document_closed');
382
415
  // The root page owns every attached OOPIF; its loss ends child requests too.
383
416
  activeRequest?.attempt.stop();
@@ -395,24 +428,43 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
395
428
  return;
396
429
  }
397
430
  if (method === 'Target.attachedToTarget') {
431
+ if (setupStop.signal.aborted)
432
+ return;
398
433
  const child = params.sessionId;
399
434
  try {
400
- await arm(child);
435
+ await arm(child, true);
401
436
  }
402
- catch {
437
+ catch (error) {
438
+ // Shared child setup and sibling cancellation can reject several
439
+ // handlers together. Publish the terminal setup failure only once.
440
+ if (setupFailureReported)
441
+ return;
442
+ setupFailureReported = true;
443
+ setupStop.abort(attachmentFailure(error));
403
444
  terminal = new Error('browser_interception_unavailable');
404
445
  lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
405
446
  opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
406
447
  // Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
407
448
  return;
408
449
  }
409
- // Child targets start paused when waitForDebuggerOnStart is set.
410
- await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
411
450
  return;
412
451
  }
413
452
  if (method !== 'Fetch.requestPaused')
414
453
  return;
415
454
  const { requestId, request, resourceType, networkId, frameId } = params;
455
+ // Braintree shares /graphql between configuration and card mutations.
456
+ // Classify before URL-only recognition or claiming a prepared request.
457
+ const braintree = request.method.toUpperCase() === 'POST'
458
+ ? classifyBraintreeRequest(request.url, request.method, pausedBody(request)) : undefined;
459
+ if (braintree === 'configuration') {
460
+ await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
461
+ return;
462
+ }
463
+ if (braintree === 'invalid') {
464
+ opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
465
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
466
+ return;
467
+ }
416
468
  if (!opts.vault.isCardRequest(request.url, request.method)) {
417
469
  if (guards.matches(request.url, request.method)) {
418
470
  preparationGate.invalidate('unsupported_checkout');
@@ -424,9 +476,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
424
476
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
425
477
  return;
426
478
  }
479
+ if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
480
+ opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
481
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
482
+ return;
483
+ }
427
484
  let preparation;
428
485
  try {
429
- preparation = preparationGate.claim(request.url);
486
+ preparation = preparationGate.claim(request.url, pausedBody(request));
430
487
  }
431
488
  catch (error) {
432
489
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
@@ -576,7 +633,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
576
633
  preparationGate.retireUnboundClaim();
577
634
  }
578
635
  });
579
- await arm(pageSessionId);
636
+ try {
637
+ await arm(pageSessionId);
638
+ if (setupStop.signal.aborted)
639
+ throw attachmentFailure(setupStop.signal.reason);
640
+ attachmentReady = true;
641
+ opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
642
+ }
643
+ catch (error) {
644
+ const failure = attachmentFailure(error);
645
+ setupStop.abort(failure);
646
+ terminal = failure;
647
+ if (!setupFailureReported) {
648
+ setupFailureReported = true;
649
+ lifecycle.cancel();
650
+ opts.onEvent?.({ type: 'failed', detail: `checkout_attachment_${failure.reason}` });
651
+ }
652
+ throw failure;
653
+ }
580
654
  return lifecycle;
581
655
  }
582
656
  /**
@@ -592,6 +666,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
592
666
  */
593
667
  export async function attachToPlaywright(page, opts) {
594
668
  opts = safeOptions(opts);
669
+ const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
670
+ const setupStop = new AbortController();
671
+ let attachmentReady = false;
595
672
  // Routing cannot see requests owned by a service worker. Existing controlled
596
673
  // contexts must be recreated with serviceWorkers: 'block' before checkout.
597
674
  if (page.context?.().serviceWorkers?.().length) {
@@ -628,8 +705,10 @@ export async function attachToPlaywright(page, opts) {
628
705
  if (activeRequest && activeRequest.request === request)
629
706
  activeRequest.attempt.stop();
630
707
  });
631
- page.on?.('close', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
632
- page.on?.('crash', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
708
+ page.on?.('close', () => { if (!attachmentReady)
709
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
710
+ page.on?.('crash', () => { if (!attachmentReady)
711
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
633
712
  page.on?.('framenavigated', (frame) => {
634
713
  if (frame === page.mainFrame?.()) {
635
714
  preparationGate.invalidate('merchant_document_changed');
@@ -644,139 +723,175 @@ export async function attachToPlaywright(page, opts) {
644
723
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
645
724
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
646
725
  let lastSubmitted = null;
647
- await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
648
- const request = route.request();
649
- // The matcher only sees the URL; a preflight or a GET must pass through
650
- // untouched or the browser's CORS check fails on our synthetic answer.
651
- if (!opts.vault.isCardRequest(request.url(), request.method())) {
652
- if (guards.matches(request.url(), request.method())) {
653
- preparationGate.invalidate('unsupported_checkout');
654
- lifecycle.unsupported();
655
- opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
726
+ const install = async (assertActive) => {
727
+ if (page.isClosed?.()) {
728
+ const failure = new CheckoutAttachmentError('closed');
729
+ setupStop.abort(failure);
730
+ throw failure;
731
+ }
732
+ await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
733
+ const request = route.request();
734
+ const braintree = request.method().toUpperCase() === 'POST'
735
+ ? classifyBraintreeRequest(request.url(), request.method(), request.postData() ?? '') : undefined;
736
+ if (braintree === 'configuration')
737
+ return route.fallback();
738
+ if (braintree === 'invalid') {
739
+ opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
656
740
  return route.abort('aborted');
657
741
  }
658
- return route.fallback();
659
- }
660
- let preparation;
661
- try {
662
- preparation = preparationGate.claim(request.url());
663
- }
664
- catch (error) {
665
- opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
666
- return route.abort('aborted');
667
- }
668
- // Fail closed and stay quiet: no card may reach the PSP, but neither may
669
- // the page's retry loop turn into a stream of doomed API calls. Every
670
- // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
671
- // CDP adapter's Fetch.failRequest uses, so a refused navigation
672
- // resolves identically whichever adapter is attached.
673
- if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
674
- const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
675
- opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
676
- if (preparation)
677
- preparationGate.retireUnboundClaim();
678
- return route.abort('aborted');
679
- }
680
- // Reserved before anything that could yield, matching attachToCdp.
681
- awaitingApproval = true;
682
- const attempt = merchantAttempt(lifecycle);
683
- const frames = [];
684
- try {
685
- for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
686
- frames.push(frame);
687
- }
688
- catch { /* requestfailed/page close still cover unavailable frame metadata */ }
689
- const assertRequestLive = () => {
690
- if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
691
- attempt.stop();
692
- attempt.assertLive();
693
- };
694
- let handoffStarted = false;
695
- try {
696
- const body = request.postData() ?? '';
697
- if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
698
- opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
699
- return await route.abort('aborted');
742
+ // The matcher only sees the URL; a preflight or a GET must pass through
743
+ // untouched or the browser's CORS check fails on our synthetic answer.
744
+ if (!opts.vault.isCardRequest(request.url(), request.method())) {
745
+ if (guards.matches(request.url(), request.method())) {
746
+ preparationGate.invalidate('unsupported_checkout');
747
+ lifecycle.unsupported();
748
+ opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
749
+ return route.abort('aborted');
750
+ }
751
+ return route.fallback();
700
752
  }
701
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
702
- lifecycle.begin();
703
- activeRequest = { request, frames, attempt };
704
- if (preparation)
705
- await preparationGate.assertDocument();
706
- assertRequestLive();
707
- const replay = await opts.vault.authorize({
708
- user: opts.user,
709
- merchant: opts.merchant,
710
- amount: opts.amount,
711
- amountCents: opts.amountCents,
712
- currency: opts.currency,
713
- cardId: preparation?.cardId ?? opts.cardId,
714
- preparation,
715
- timeoutMs: opts.timeoutMs,
716
- signal: lifecycle.abort.signal,
717
- merchantSignal: attempt.signal,
718
- onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
719
- onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
720
- lifecycle.approvalUrl(url);
721
- return opts.onApprovalUrl?.(url);
722
- } },
723
- request: { url: request.url(), method: request.method(), headers: request.headers(), body },
724
- });
725
- assertRequestLive();
726
- if (lifecycle.isCancelled())
727
- throw new Error('checkout cancelled locally after approval');
728
- lifecycle.prepareHandoff(replay, request.url());
729
- handoffStarted = replay.mode !== 'cse';
730
- if (replay.mode === 'hosted_form') {
731
- // Same as the CDP path: the paused navigation resolves to the
732
- // synthetic page, and a re-post of this form is refused.
733
- const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
734
- // Inert on a navigation (never CORS-checked); one path for every fulfill.
735
- await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
736
- assertRequestLive();
737
- lastSubmitted = { url: request.url(), body, at: Date.now() };
738
- opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
753
+ if (!attachmentReady || setupStop.signal.aborted) {
754
+ opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
755
+ return route.abort('aborted');
739
756
  }
740
- else if (replay.mode === 'cse') {
741
- // Same as the CDP path: the request continues from this browser
742
- // with the ciphertext swapped in and no header override; Playwright
743
- // recomputes the length itself.
744
- const postData = cseBody(body, replay);
745
- handoffStarted = true;
746
- await route.continue({ postData });
747
- assertRequestLive();
748
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
757
+ let preparation;
758
+ try {
759
+ preparation = preparationGate.claim(request.url(), request.postData() ?? '');
749
760
  }
750
- else {
751
- // Playwright adds these itself when a cross-origin fulfill carries
752
- // none; written here anyway (replacing a stale value) so a
753
- // cross-origin answer is the same whichever adapter ran.
754
- const cors = corsDecision(request.url(), request.headers());
755
- await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
761
+ catch (error) {
762
+ opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
763
+ return route.abort('aborted');
764
+ }
765
+ // Fail closed and stay quiet: no card may reach the PSP, but neither may
766
+ // the page's retry loop turn into a stream of doomed API calls. Every
767
+ // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
768
+ // CDP adapter's Fetch.failRequest uses, so a refused navigation
769
+ // resolves identically whichever adapter is attached.
770
+ if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
771
+ const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
772
+ opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
773
+ if (preparation)
774
+ preparationGate.retireUnboundClaim();
775
+ return route.abort('aborted');
776
+ }
777
+ // Reserved before anything that could yield, matching attachToCdp.
778
+ awaitingApproval = true;
779
+ const attempt = merchantAttempt(lifecycle);
780
+ const frames = [];
781
+ try {
782
+ for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
783
+ frames.push(frame);
784
+ }
785
+ catch { /* requestfailed/page close still cover unavailable frame metadata */ }
786
+ const assertRequestLive = () => {
787
+ if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
788
+ attempt.stop();
789
+ attempt.assertLive();
790
+ };
791
+ let handoffStarted = false;
792
+ try {
793
+ const body = request.postData() ?? '';
794
+ if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
795
+ opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
796
+ return await route.abort('aborted');
797
+ }
798
+ opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
799
+ lifecycle.begin();
800
+ activeRequest = { request, frames, attempt };
801
+ if (preparation)
802
+ await preparationGate.assertDocument();
756
803
  assertRequestLive();
757
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
804
+ const replay = await opts.vault.authorize({
805
+ user: opts.user,
806
+ merchant: opts.merchant,
807
+ amount: opts.amount,
808
+ amountCents: opts.amountCents,
809
+ currency: opts.currency,
810
+ cardId: preparation?.cardId ?? opts.cardId,
811
+ preparation,
812
+ timeoutMs: opts.timeoutMs,
813
+ signal: lifecycle.abort.signal,
814
+ merchantSignal: attempt.signal,
815
+ onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
816
+ onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
817
+ lifecycle.approvalUrl(url);
818
+ return opts.onApprovalUrl?.(url);
819
+ } },
820
+ request: { url: request.url(), method: request.method(), headers: request.headers(), body },
821
+ });
822
+ assertRequestLive();
823
+ if (lifecycle.isCancelled())
824
+ throw new Error('checkout cancelled locally after approval');
825
+ lifecycle.prepareHandoff(replay, request.url());
826
+ handoffStarted = replay.mode !== 'cse';
827
+ if (replay.mode === 'hosted_form') {
828
+ // Same as the CDP path: the paused navigation resolves to the
829
+ // synthetic page, and a re-post of this form is refused.
830
+ const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
831
+ // Inert on a navigation (never CORS-checked); one path for every fulfill.
832
+ await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
833
+ assertRequestLive();
834
+ lastSubmitted = { url: request.url(), body, at: Date.now() };
835
+ opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
836
+ }
837
+ else if (replay.mode === 'cse') {
838
+ // Same as the CDP path: the request continues from this browser
839
+ // with the ciphertext swapped in and no header override; Playwright
840
+ // recomputes the length itself.
841
+ const postData = cseBody(body, replay);
842
+ handoffStarted = true;
843
+ await route.continue({ postData });
844
+ assertRequestLive();
845
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
846
+ }
847
+ else {
848
+ // Playwright adds these itself when a cross-origin fulfill carries
849
+ // none; written here anyway (replacing a stale value) so a
850
+ // cross-origin answer is the same whichever adapter ran.
851
+ const cors = corsDecision(request.url(), request.headers());
852
+ await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
853
+ assertRequestLive();
854
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
855
+ }
856
+ lifecycle.handedOff(replay);
758
857
  }
759
- lifecycle.handedOff(replay);
760
- }
761
- catch (err) {
762
- if (activeRequest?.attempt === attempt)
763
- activeRequest = null;
764
- lifecycle.failed(err, handoffStarted);
765
- if (isTerminal(err))
766
- terminal = err;
767
- else if (isApprovalOutcome(err))
768
- quietUntil = Date.now() + cooldownMs;
769
- opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
770
- await route.abort('aborted').catch(() => { });
771
- }
772
- finally {
773
- if (activeRequest?.attempt === attempt)
774
- activeRequest = null;
775
- awaitingApproval = false;
776
- lifecycle.end();
777
- if (preparation)
778
- preparationGate.retireUnboundClaim();
779
- }
780
- });
858
+ catch (err) {
859
+ if (activeRequest?.attempt === attempt)
860
+ activeRequest = null;
861
+ lifecycle.failed(err, handoffStarted);
862
+ if (isTerminal(err))
863
+ terminal = err;
864
+ else if (isApprovalOutcome(err))
865
+ quietUntil = Date.now() + cooldownMs;
866
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
867
+ await route.abort('aborted').catch(() => { });
868
+ }
869
+ finally {
870
+ if (activeRequest?.attempt === attempt)
871
+ activeRequest = null;
872
+ awaitingApproval = false;
873
+ lifecycle.end();
874
+ if (preparation)
875
+ preparationGate.retireUnboundClaim();
876
+ }
877
+ });
878
+ assertActive();
879
+ };
880
+ try {
881
+ await withinAttachmentDeadline(install, setupTimeoutMs, setupStop.signal);
882
+ if (setupStop.signal.aborted)
883
+ throw attachmentFailure(setupStop.signal.reason);
884
+ attachmentReady = true;
885
+ }
886
+ catch (error) {
887
+ const failure = attachmentFailure(error);
888
+ setupStop.abort(failure);
889
+ terminal = failure;
890
+ lifecycle.cancel();
891
+ opts.onEvent?.({ type: 'failed', detail: `checkout_attachment_${failure.reason}` });
892
+ // A route registration may finish after this rejection. Keep its handler
893
+ // inert for card traffic; removing it would reopen the failed checkout.
894
+ throw failure;
895
+ }
781
896
  return lifecycle;
782
897
  }
package/dist/client.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type CheckoutMode, type Recognizer } from './registry.js';
2
2
  import type { Substitutions } from './substitute.js';
3
+ import { type PreparationProcessor } from './prepared-processor.js';
3
4
  export interface PausedRequest {
4
5
  url: string;
5
6
  method: string;
@@ -99,9 +100,9 @@ export interface HostedFormReplay {
99
100
  /** What authorize() resolves with; branch on `mode` (absent means token). */
100
101
  export type ReplayResponse = TokenReplay | CseReplay | HostedFormReplay;
101
102
  export interface PrepareCheckoutOptions {
102
- psp: 'square';
103
+ psp: PreparationProcessor;
103
104
  /** The processor environment, independent of your Agentcard client's mode. */
104
- environment: 'production' | 'sandbox';
105
+ environment: 'production' | 'sandbox' | 'shared';
105
106
  signal?: AbortSignal;
106
107
  }
107
108
  export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
@@ -120,8 +121,8 @@ export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
120
121
  export interface PreparedCheckout {
121
122
  readonly id: string;
122
123
  readonly status: 'ready';
123
- readonly psp: 'square';
124
- readonly environment: 'production' | 'sandbox';
124
+ readonly psp: PreparationProcessor;
125
+ readonly environment: 'production' | 'sandbox' | 'shared';
125
126
  readonly expiresAt: string;
126
127
  readonly cardId: string;
127
128
  readonly user: string;
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
2
+ import { matchesPreparedRequest, validPreparationEnvironment } from './prepared-processor.js';
2
3
  /**
3
4
  * The modes this SDK can finish. Asked for on syncRegistry (the API serves
4
5
  * only recognizers in these modes, so a request this build cannot complete
@@ -314,7 +315,7 @@ export class VaultClient {
314
315
  async prepareCheckout(input) {
315
316
  input = { ...input };
316
317
  const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
317
- if (input.psp !== 'square' || !['production', 'sandbox'].includes(input.environment))
318
+ if (!validPreparationEnvironment(input.psp, input.environment))
318
319
  throw fail('unsupported_processor');
319
320
  if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
320
321
  throw fail('amount_required');
@@ -367,7 +368,7 @@ export class VaultClient {
367
368
  || state.payment_status !== 'not_started' || state.amount_authority !== 'display_only'
368
369
  || state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
369
370
  || state.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
370
- || state.psp !== 'square' || state.mode !== 'token' || state.environment !== input.environment
371
+ || state.psp !== input.psp || state.mode !== 'token' || state.environment !== input.environment
371
372
  || state.checkout_key !== input.checkoutKey)
372
373
  throw fail('ready_unconfirmed', id);
373
374
  const prepared = Object.freeze({
@@ -416,13 +417,11 @@ export class VaultClient {
416
417
  throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
417
418
  // Consume locally before any await, including OAuth, and never recycle it.
418
419
  this.usedPreparations.add(preparation);
419
- const url = new URL(input.request.url);
420
- const host = preparation.environment === 'production' ? 'pci-connect.squareup.com' : 'pci-connect.squareupsandbox.com';
421
420
  if (Date.parse(preparation.expiresAt) <= Date.now())
422
421
  throw new CheckoutPreparationError(preparation.id, 'expired');
423
422
  if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.amountCents !== preparation.amountCents
424
423
  || input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
425
- || url.origin !== `https://${host}` || url.pathname !== '/v2/card-nonce' || url.username || url.password)
424
+ || !matchesPreparedRequest(preparation.psp, preparation.environment, input.request.url, input.request.method ?? 'POST', input.request.body))
426
425
  throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
427
426
  }
428
427
  if (input.signal?.aborted)
@@ -437,6 +436,8 @@ export class VaultClient {
437
436
  // clientSideEncrypted entry (an older registry, a hand-built one) still
438
437
  // refuses here, exactly as before.
439
438
  const mode = rec.mode ?? 'token';
439
+ if (preparation && (rec.psp !== preparation.psp || mode !== 'token'))
440
+ throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
440
441
  if (rec.clientSideEncrypted && mode !== 'cse')
441
442
  throw new CardEncryptedError(rec.psp);
442
443
  if (!SUPPORTED_MODES.includes(mode))
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
2
  export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, RazorpayProcessorError, } from './client.js';
3
3
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
4
+ export { CheckoutAttachmentError } from './attachment.js';
4
5
  export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
5
6
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
6
7
  export type { Substitutions } from './substitute.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
2
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
3
+ export { CheckoutAttachmentError } from './attachment.js';
3
4
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
4
5
  export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
5
6
  export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';
@@ -15,7 +15,7 @@ export declare class PreparationGate {
15
15
  constructor(opts: AttachOptions, lifecycle: CheckoutLifecycle, readDocumentUrl: () => Promise<string>);
16
16
  private prepare;
17
17
  /** Called for every recognized card mutation, before any await or local retry guard. */
18
- claim(requestUrl: string): PreparedCheckout | undefined;
18
+ claim(requestUrl: string, requestBody?: string | null): PreparedCheckout | undefined;
19
19
  assertDocument(): Promise<void>;
20
20
  private readDocument;
21
21
  isEngaged(): boolean;