@agent-cards/sdk 0.23.6 → 0.23.8

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
@@ -13,7 +13,7 @@ import { DatatransRepeats } from './datatrans.js';
13
13
  import { SHIJI_STS_COMPANION_WINDOW_MS, ShijiStsCompanions, ShijiStsError, isShijiStsHost, shijiStsTokenContinuation, } from './shiji-sts.js';
14
14
  import { isArmedEkashuUrl } from './ekashu.js';
15
15
  import { PAYPAL_HOSTED_FIELDS_PATTERNS, isPaypalHostedFieldsConfirm } from './paypal-hosted-fields.js';
16
- import { CYBERSOURCE_SECURE_ACCEPTANCE_PATTERNS, isCybersourceSecureAcceptanceUrl } from './cybersource-secure-acceptance.js';
16
+ import { CYBERSOURCE_SECURE_ACCEPTANCE_PATTERNS, CYBERSOURCE_SECURE_ACCEPTANCE_RECIPIENT, SecureAcceptanceFollowUpWatch, isCybersourceSecureAcceptanceUrl, secureAcceptanceFollowUp, secureAcceptanceFrameAnswer, secureAcceptanceReplyLast4, } from './cybersource-secure-acceptance.js';
17
17
  import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
18
18
  import { cseBody } from './cse-body.js';
19
19
  import { STRIPE_SETUP_INTENT_CONFIRM, setupConfirmPayingWith } from './stripe-setup.js';
@@ -22,7 +22,7 @@ import { verifoneTemplatedUrl } from './verifone.js';
22
22
  import { MerchantHandoffWatch } from './merchant-handoff.js';
23
23
  import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watch.js';
24
24
  import { hostedFormSubmittedPage } from './hosted-form.js';
25
- import { amadeusSendWindowMs, armSendWindow, failsAsNetworkError } from './amadeus.js';
25
+ import { armSendWindow, failsAsNetworkError, preparedSendWindowMs, sendWindowProcessor } from './amadeus.js';
26
26
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
27
27
  import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, loadedCardMicroformFromUrls, refusedRequestFailure, } from './cybersource-flex.js';
28
28
  import { CARD_ENDPOINT_REFUSED_STEP_ANSWER, CARD_ENDPOINT_WITHHELD_ANSWER, CardEndpointRefusedError, captureCardEndpointSession, cardEndpointAnswerHeaders, cardEndpointCookies, cardEndpointDetail, cardEndpointNavigationAnswer, cardEndpointPatterns, cardEndpointRouted, } from './card-endpoint.js';
@@ -678,6 +678,43 @@ function playwrightCookieParams(cookie) {
678
678
  function stoppedNavigationPage(profile, outcome, reason, authorizationId) {
679
679
  return cardEndpointStoppedPage({ outcome, reason, merchant: profile.merchant, authorizationId });
680
680
  }
681
+ /**
682
+ * Who a paused request's stopped page names, or null when the request is not a form navigation the
683
+ * adapters answer with one: a card endpoint profile whose page submits its form as a navigation names
684
+ * its merchant, and a Secure Acceptance token create the lane held (a frame navigation, pre-claim.ts)
685
+ * names CyberSource. Any other request is failed instead.
686
+ */
687
+ function stoppedPageRecipient(profile, secureAcceptance) {
688
+ if (secureAcceptance)
689
+ return CYBERSOURCE_SECURE_ACCEPTANCE_RECIPIENT;
690
+ return profile && profile.navigation !== null ? profile.merchant : null;
691
+ }
692
+ /**
693
+ * How a held Secure Acceptance frame post whose approval failed is answered. The card reached
694
+ * CyberSource once the device approved or the hand-off began, and CyberSource also answered a row the
695
+ * API declined as processor_refused, or one whose reply the SDK would not hand the frame (a reply that
696
+ * failed the rule, or one the API no longer serves): each reads `sent_answer_not_shown`. A reply that
697
+ * never came within the window reads `stopped_before_send` only when the API confirmed it retired the
698
+ * row before any processor request, and `sent_answer_not_shown` otherwise, since the device may have
699
+ * sent the card. Everything else reads as for a card endpoint (failedNavigationRefusal).
700
+ */
701
+ function secureAcceptanceFailedRefusal(error, sent, authorizationId) {
702
+ if (sent)
703
+ return { outcome: 'sent_answer_not_shown', reason: 'outcome_unknown', authorizationId };
704
+ if (error instanceof ProcessorRefusedError)
705
+ return { outcome: 'sent_answer_not_shown', reason: 'answer_withheld', authorizationId: error.authorizationId ?? authorizationId };
706
+ if (error instanceof PaymentOutcomeUnknownError) {
707
+ const id = error.authorizationId ?? authorizationId;
708
+ if (error.reason === 'secure_acceptance_reply_unconfirmed' || error.reason === 'response_expired') {
709
+ return { outcome: 'sent_answer_not_shown', reason: 'answer_withheld', authorizationId: id };
710
+ }
711
+ if (error.reason === 'secure_acceptance_reply_late') {
712
+ return error.processorRequestStarted === false ? { outcome: 'stopped_before_send', reason: 'not_approved', authorizationId: id }
713
+ : { outcome: 'sent_answer_not_shown', reason: 'outcome_unknown', authorizationId: id };
714
+ }
715
+ }
716
+ return failedNavigationRefusal(error, false, authorizationId);
717
+ }
681
718
  /** The API refused the create because this checkout already has a payment waiting or sent (409 merchant_checkout_in_use). */
682
719
  function checkoutInUse(error) {
683
720
  return error instanceof CheckoutApiError && error.code === 'merchant_checkout_in_use';
@@ -697,6 +734,18 @@ function heldNavigationRefusal(terminal, awaitingApproval, lifecycle) {
697
734
  const outcome = refusedNavigationOutcome({ awaitingApproval, status: state.status });
698
735
  return { outcome, reason: 'blocked', authorizationId: outcome === 'payment_in_progress' ? state.authorizationId : null };
699
736
  }
737
+ /**
738
+ * How a held Secure Acceptance frame post that no ready preparation binds is answered (the claim
739
+ * refused it: none was ready, it names another profile id, it carries no Origin): nothing reached the
740
+ * API for it and its card never left, so the page says so, unless another payment for this checkout is
741
+ * waiting or may exist (refusedNavigationOutcome), which the page then names.
742
+ */
743
+ function unboundNavigationRefusal(awaitingApproval, lifecycle) {
744
+ const state = lifecycle.getState();
745
+ const outcome = refusedNavigationOutcome({ awaitingApproval, status: state.status });
746
+ return outcome === 'payment_in_progress' ? { outcome, reason: 'blocked', authorizationId: state.authorizationId }
747
+ : { outcome, reason: 'not_approved', authorizationId: null };
748
+ }
700
749
  /**
701
750
  * How a card form navigation whose approval failed is answered, for `authorizationId`. Once
702
751
  * the device approved or the hand-off began (`sent`), the card reached the merchant. Before
@@ -1286,10 +1335,11 @@ function merchantAttempt(lifecycle, survives) {
1286
1335
  let rebound = false;
1287
1336
  let delivering = false;
1288
1337
  let bound = null;
1289
- // An Amadeus send window closes only once the API confirms the card never left the device and
1290
- // never can (amadeus.ts): a known non-payment, so the prepared checkout ends failed with that
1291
- // reason, and a fresh attachment can prepare again. Every other stop leaves the outcome unknown.
1292
- const error = () => reason === 'amadeus_send_window_closed'
1338
+ // A send window (an Amadeus SDK add's, a Secure Acceptance token create's) closes only once the API
1339
+ // confirms the card never left the device and never can (amadeus.ts): a known non-payment, so the
1340
+ // prepared checkout ends failed with that reason, and a fresh attachment can prepare again. Every
1341
+ // other stop leaves the outcome unknown.
1342
+ const error = () => reason === 'amadeus_send_window_closed' || reason === 'secure_acceptance_send_window_closed'
1293
1343
  ? new CheckoutPreparationError(lifecycle.getState().preparationId ?? null, reason)
1294
1344
  : new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
1295
1345
  const attempt = {
@@ -1515,13 +1565,15 @@ function pauseForBusiness(opts, lifecycle, attemptSignal, request, pageOrigin) {
1515
1565
  return { replay, delivered: { resolve: deliveredResolve, reject: deliveredReject } };
1516
1566
  }
1517
1567
  /**
1518
- * Arm the send window of a request paused under a preparation, when its processor sets one: only a
1519
- * prepared Amadeus SDK add does (amadeus.ts). Returns the disarm function, or null with no window.
1520
- * lifecycle.begin() cleared the authorization ID, so the one the window reads is this attempt's.
1568
+ * Arm the send window of a request paused under a preparation, when its processor sets one: a
1569
+ * prepared Amadeus SDK add and a prepared Secure Acceptance token create do (amadeus.ts
1570
+ * preparedSendWindowMs). Returns the disarm function, or null with no window. lifecycle.begin()
1571
+ * cleared the authorization ID, so the one the window reads is this attempt's.
1521
1572
  */
1522
1573
  function armWindowFor(opts, lifecycle, attempt, preparation, url, delivering) {
1523
- const ms = preparation ? amadeusSendWindowMs(preparation.psp, url) : null;
1524
- if (ms === null || !preparation)
1574
+ const ms = preparation ? preparedSendWindowMs(preparation.psp, url) : null;
1575
+ const processor = preparation ? sendWindowProcessor(preparation.psp) : null;
1576
+ if (ms === null || processor === null || !preparation)
1525
1577
  return null;
1526
1578
  return armSendWindow(ms, attempt, {
1527
1579
  authorizationId: () => lifecycle.getState().authorizationId,
@@ -1529,7 +1581,19 @@ function armWindowFor(opts, lifecycle, attempt, preparation, url, delivering) {
1529
1581
  cancel: typeof opts.vault?.cancelAuthorization === 'function' ? (id) => opts.vault.cancelAuthorization(id) : undefined,
1530
1582
  retirePreparation: typeof opts.vault?.retirePreparation === 'function' ? () => opts.vault.retirePreparation(preparation.id) : undefined,
1531
1583
  onEvent: opts.onEvent,
1532
- });
1584
+ }, processor);
1585
+ }
1586
+ /**
1587
+ * Begin the follow-up watch after a Secure Acceptance reply was delivered to the frame, when the
1588
+ * preparation's merchant has a reviewed entry that names its next request (secureAcceptanceFollowUp):
1589
+ * none on the test host. Read-only; see SecureAcceptanceFollowUpWatch.
1590
+ */
1591
+ function watchSecureAcceptanceFollowUp(watch, preparation, replay) {
1592
+ if (!preparation)
1593
+ return;
1594
+ const followUp = secureAcceptanceFollowUp(preparation);
1595
+ if (followUp)
1596
+ watch.begin(followUp, { authorizationId: replay.authorizationId, last4: secureAcceptanceReplyLast4(replay.body) });
1533
1597
  }
1534
1598
  /**
1535
1599
  * Take over card tokenization for a page.
@@ -1613,6 +1677,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1613
1677
  };
1614
1678
  let merchantContext;
1615
1679
  let targetContextKnown = false;
1680
+ // The page target's id, which is its main frame's id; read once the attachment is set up.
1681
+ let pageTargetId;
1616
1682
  const lifecycle = new CheckoutLifecycle(opts);
1617
1683
  const stripeCheckout = new StripeCheckoutGate(opts);
1618
1684
  const mercadoCheckout = new MercadoCheckoutGate();
@@ -1653,6 +1719,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1653
1719
  const juspayCheckout = new JuspayCheckoutGate(opts.vault);
1654
1720
  const aci = new AciGate(opts.vault);
1655
1721
  const eprotect = new EprotectGate(opts.vault);
1722
+ // The merchant's follow-up request after a delivered Secure Acceptance reply (cybersource-secure-acceptance.ts).
1723
+ const secureAcceptanceFollowUpWatch = new SecureAcceptanceFollowUpWatch(opts.onEvent);
1724
+ lifecycle.abort.signal.addEventListener('abort', () => secureAcceptanceFollowUpWatch.end(), { once: true });
1656
1725
  let preparationFrameId;
1657
1726
  const readDocumentUrl = async () => {
1658
1727
  const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
@@ -1697,15 +1766,34 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1697
1766
  return null;
1698
1767
  return { navigation: frame.parentId || sessionId !== pageSessionId ? 'frame' : 'top', url: typeof frame.url === 'string' ? frame.url : undefined };
1699
1768
  };
1769
+ /**
1770
+ * Which frame a paused document request navigates, as documentFrame reads it, without the frame tree where
1771
+ * the answer is already known: a navigation paused on an iframe target's session is a frame's, and one of
1772
+ * the page's main frame (whose id is the page target's id) is the page's own. Chromium does not answer
1773
+ * Page.getFrameTree on a session while that session's own top-level navigation is paused, so the read
1774
+ * would wait for a decision only this answer can make; any other frame's is read from the tree.
1775
+ */
1776
+ const pausedNavigation = async (sessionId, frameId) => {
1777
+ if (typeof frameId !== 'string' || !frameId)
1778
+ return null;
1779
+ if (sessionId && sessionId !== pageSessionId && sessionTypes.get(sessionId) === 'iframe')
1780
+ return 'frame';
1781
+ if (sessionId === pageSessionId && pageTargetId !== undefined && frameId === pageTargetId)
1782
+ return 'top';
1783
+ return (await documentFrame(sessionId, frameId))?.navigation ?? null;
1784
+ };
1700
1785
  /**
1701
1786
  * Refuse a paused request after the pre-claim gates: a card form navigation gets the
1702
1787
  * stopped page (card-endpoint-page.ts) that `refusal` describes, since an aborted page
1703
1788
  * load renders nothing and invites another click on Pay; any other request fails as
1704
- * cdpFailure says (a network error for CyberSource Flex and Amadeus, else an abort).
1789
+ * cdpFailure says (a network error for CyberSource Flex and Amadeus, else an abort). A
1790
+ * Secure Acceptance token create the lane held is a frame navigation, and its page names
1791
+ * CyberSource (stoppedPageRecipient).
1705
1792
  */
1706
- const refusePaused = async (requestId, sessionId, url, profile, refusal) => {
1707
- if (profile && profile.navigation !== null) {
1708
- const page = stoppedNavigationPage(profile, refusal.outcome, refusal.reason, refusal.authorizationId);
1793
+ const refusePaused = async (requestId, sessionId, url, profile, refusal, secureAcceptance = false) => {
1794
+ const merchant = stoppedPageRecipient(profile, secureAcceptance);
1795
+ if (merchant !== null) {
1796
+ const page = cardEndpointStoppedPage({ outcome: refusal.outcome, reason: refusal.reason, merchant, authorizationId: refusal.authorizationId });
1709
1797
  await cdp.send('Fetch.fulfillRequest', { requestId, responseCode: page.status, responseHeaders: headerEntries(page.headers),
1710
1798
  body: Buffer.from(page.body).toString('base64') }, sessionId).catch(() => { });
1711
1799
  return;
@@ -2071,6 +2159,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2071
2159
  // Read-only: the page this request came from (its session and document loader), and
2072
2160
  // the request itself when a priced merchant profile's rules read it.
2073
2161
  if (!sessionDetached(sessionId) && params.request) {
2162
+ // The merchant's follow-up after a delivered Secure Acceptance reply ends its watch (read-only).
2163
+ secureAcceptanceFollowUpWatch.saw(params.request.method, params.request.url);
2074
2164
  merchantTotal.sent(key, { method: params.request.method, url: params.request.url,
2075
2165
  ...(typeof params.request.postData === 'string' ? { requestBody: params.request.postData } : {}),
2076
2166
  ...(typeof params.loaderId === 'string' && params.loaderId ? { page: `${owner}:${params.loaderId}` } : {}) });
@@ -2317,7 +2407,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2317
2407
  replacesNavigationBody: async () => true,
2318
2408
  readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
2319
2409
  // Read only for a card endpoint whose page submits its form as a navigation.
2320
- navigation: async () => (resourceType === 'Document' ? (await documentFrame(sessionId, frameId))?.navigation ?? null : null),
2410
+ navigation: async () => (resourceType === 'Document' ? pausedNavigation(sessionId, frameId) : null),
2321
2411
  effects: {
2322
2412
  continueRequest: (postData) => cdp.send('Fetch.continueRequest', { requestId, ...(postData !== undefined ? { postData: Buffer.from(postData).toString('base64') } : {}) }, sessionId),
2323
2413
  answer: (response) => cdp.send('Fetch.fulfillRequest', { requestId, responseCode: response.status,
@@ -2360,9 +2450,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2360
2450
  // A Payrails card save the gate bound to a captured session: its key rides on the create.
2361
2451
  const payrailsSidecar = preClaim.payrails;
2362
2452
  const cardNavigation = preClaim.navigation;
2453
+ // A Secure Acceptance token create the lane held, a navigation of the merchant's hidden frame: every
2454
+ // refusal answers it with the stopped page naming CyberSource, and its approval with CyberSource's page.
2455
+ const secureAcceptance = preClaim.secureAcceptance === true;
2363
2456
  if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
2364
2457
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
2365
- await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
2458
+ await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle), secureAcceptance);
2366
2459
  return;
2367
2460
  }
2368
2461
  // A request the catalog marks as routine without a card (a confirmation
@@ -2389,7 +2482,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2389
2482
  }
2390
2483
  catch (error) {
2391
2484
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
2392
- await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
2485
+ // A Secure Acceptance frame post no ready preparation binds (none ready, another profile, no Origin)
2486
+ // ends on the stopped page: nothing reached the API, and the card never left.
2487
+ if (secureAcceptance)
2488
+ await refusePaused(requestId, sessionId, request.url, null, unboundNavigationRefusal(awaitingApproval, lifecycle), true);
2489
+ else
2490
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
2393
2491
  return;
2394
2492
  }
2395
2493
  // The page asking again for the purchase an outstanding approval is for:
@@ -2450,7 +2548,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2450
2548
  if (preparation)
2451
2549
  preparationGate.retireUnboundClaim();
2452
2550
  // A card form navigation never reads that nothing was sent while a payment for this checkout may exist.
2453
- await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
2551
+ await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle), secureAcceptance);
2454
2552
  if (repeatedAuthorizationId)
2455
2553
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
2456
2554
  return;
@@ -2717,6 +2815,27 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2717
2815
  attempt.assertLive();
2718
2816
  opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url, continuation.shijiCvv) });
2719
2817
  }
2818
+ else if (secureAcceptance) {
2819
+ // CyberSource's own reply page for the paused frame navigation, as the API stored it and authorize()
2820
+ // checked it, checked once more here so a replay from any path meets the same rule
2821
+ // (secureAcceptanceFrameAnswer): status 200, its one content type, and no CORS answer, which a
2822
+ // navigation never reads. Chrome commits it as CyberSource's own document in the merchant's hidden
2823
+ // frame, and its auto-post takes CyberSource's signed reply to the merchant's receipt page in this
2824
+ // browser's own session, as it would with no Agentcard in between.
2825
+ const answer = replay.mode === 'device_encrypt' ? null : secureAcceptanceFrameAnswer({ url: request.url, body }, replay);
2826
+ if (!answer)
2827
+ throw new PaymentOutcomeUnknownError(replay.authorizationId, 'secure_acceptance_reply_unconfirmed');
2828
+ await cdp.send('Fetch.fulfillRequest', {
2829
+ requestId: deliverTo,
2830
+ responseCode: answer.status,
2831
+ responseHeaders: headerEntries(answer.headers),
2832
+ body: Buffer.from(answer.body).toString('base64'),
2833
+ }, deliverOn);
2834
+ attempt.assertLive();
2835
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId,
2836
+ amountVerified: replay.mode === 'device_encrypt' ? null : replay.amountVerified ?? null, cors: 'none' } });
2837
+ watchSecureAcceptanceFollowUp(secureAcceptanceFollowUpWatch, preparation, replay);
2838
+ }
2720
2839
  else {
2721
2840
  // The processor's answer, handed to the page as if the processor had
2722
2841
  // sent it. Cross-origin, the browser then runs its CORS check on it:
@@ -2790,7 +2909,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2790
2909
  }
2791
2910
  // A card form navigation ends on the stopped page: its card reached the merchant once the
2792
2911
  // device approved (the merchant answered it) or the hand-off began; see failedNavigationRefusal.
2793
- await refusePaused(dead.requestId, dead.sessionId, request.url, cardEndpoint, failedNavigationRefusal(err, handoffStarted || approvedId !== null, approvedId ?? lifecycle.getState().authorizationId));
2912
+ // A Secure Acceptance frame post reads CyberSource's side too (secureAcceptanceFailedRefusal).
2913
+ const sent = handoffStarted || approvedId !== null, failedFor = approvedId ?? lifecycle.getState().authorizationId;
2914
+ await refusePaused(dead.requestId, dead.sessionId, request.url, cardEndpoint, secureAcceptance ? secureAcceptanceFailedRefusal(err, sent, failedFor) : failedNavigationRefusal(err, sent, failedFor), secureAcceptance);
2794
2915
  settlePause();
2795
2916
  }
2796
2917
  finally {
@@ -2815,6 +2936,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2815
2936
  }
2816
2937
  merchantContext = targetInfo.browserContextId;
2817
2938
  targetContextKnown = true;
2939
+ // A page target's id is its main frame's id: a navigation of that frame is the page's own (pausedNavigation).
2940
+ pageTargetId = typeof targetInfo.targetId === 'string' && targetInfo.targetId ? targetInfo.targetId : undefined;
2818
2941
  await cdp.send('Target.setDiscoverTargets', { discover: true });
2819
2942
  assertActive();
2820
2943
  const { targetInfos } = await cdp.send('Target.getTargets');
@@ -2895,6 +3018,9 @@ export async function attachToPlaywright(page, opts) {
2895
3018
  const aci = new AciGate(opts.vault);
2896
3019
  let navigationBodyReplaced;
2897
3020
  const eprotect = new EprotectGate(opts.vault);
3021
+ // The merchant's follow-up request after a delivered Secure Acceptance reply (cybersource-secure-acceptance.ts).
3022
+ const secureAcceptanceFollowUpWatch = new SecureAcceptanceFollowUpWatch(opts.onEvent);
3023
+ lifecycle.abort.signal.addEventListener('abort', () => secureAcceptanceFollowUpWatch.end(), { once: true });
2898
3024
  const readDocumentUrl = async () => {
2899
3025
  if (page.isClosed?.() || typeof page.url !== 'function')
2900
3026
  throw new Error('merchant_document_unavailable');
@@ -2966,6 +3092,13 @@ export async function attachToPlaywright(page, opts) {
2966
3092
  // A new document is on its way: the main frame's next navigation commits it.
2967
3093
  if (mainDocument(request))
2968
3094
  documentRequest = request;
3095
+ // The merchant's follow-up after a delivered Secure Acceptance reply ends its watch (read-only).
3096
+ if (secureAcceptanceFollowUpWatch.active) {
3097
+ try {
3098
+ secureAcceptanceFollowUpWatch.saw(request.method(), request.url());
3099
+ }
3100
+ catch { /* a request the page already dropped */ }
3101
+ }
2969
3102
  try {
2970
3103
  const body = typeof request.postData === 'function' ? request.postData() : null;
2971
3104
  merchantTotal.sent(keyOf(request), { method: request.method(), url: request.url(),
@@ -3303,11 +3436,16 @@ export async function attachToPlaywright(page, opts) {
3303
3436
  // A Payrails card save the gate bound to a captured session: its key rides on the create.
3304
3437
  const payrailsSidecar = preClaim.payrails;
3305
3438
  const cardNavigation = preClaim.navigation;
3439
+ // A Secure Acceptance token create the lane held, a navigation of the merchant's hidden frame (see attachToCdp).
3440
+ const secureAcceptance = preClaim.secureAcceptance === true;
3306
3441
  // A card form navigation is refused with the stopped page, anything else as refusalFailure says (see attachToCdp
3307
3442
  // refusePaused).
3308
- const refuseRoute = (target, refusal) => (cardEndpoint && cardEndpoint.navigation !== null
3309
- ? target.fulfill(stoppedNavigationPage(cardEndpoint, refusal.outcome, refusal.reason, refusal.authorizationId))
3310
- : target.abort(refusalFailure(request.url())));
3443
+ const refuseRoute = (target, refusal) => {
3444
+ const merchant = stoppedPageRecipient(cardEndpoint, secureAcceptance);
3445
+ return merchant !== null
3446
+ ? target.fulfill(cardEndpointStoppedPage({ outcome: refusal.outcome, reason: refusal.reason, merchant, authorizationId: refusal.authorizationId }))
3447
+ : target.abort(refusalFailure(request.url()));
3448
+ };
3311
3449
  if (!attachmentReady || setupStop.signal.aborted) {
3312
3450
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
3313
3451
  return refuseRoute(route, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
@@ -3325,6 +3463,9 @@ export async function attachToPlaywright(page, opts) {
3325
3463
  }
3326
3464
  catch (error) {
3327
3465
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
3466
+ // As in attachToCdp: a Secure Acceptance frame post no ready preparation binds ends on the stopped page.
3467
+ if (secureAcceptance)
3468
+ return refuseRoute(route, unboundNavigationRefusal(awaitingApproval, lifecycle));
3328
3469
  return route.abort(refusalFailure(request.url()));
3329
3470
  }
3330
3471
  // The page asking again for the purchase an outstanding approval is
@@ -3614,6 +3755,20 @@ export async function attachToPlaywright(page, opts) {
3614
3755
  assertRequestLive();
3615
3756
  opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url(), continuation.shijiCvv) });
3616
3757
  }
3758
+ else if (secureAcceptance) {
3759
+ // Same as the CDP path: CyberSource's own page, status 200 and its one content type, checked once
3760
+ // more, for the paused frame navigation. The SDK writes no CORS answer; Playwright appends its own to
3761
+ // any cross-origin fulfill whose request names an Origin (microsoft/playwright#12929), which a
3762
+ // navigation never reads.
3763
+ const answer = replay.mode === 'device_encrypt' ? null : secureAcceptanceFrameAnswer({ url: request.url(), body }, replay);
3764
+ if (!answer)
3765
+ throw new PaymentOutcomeUnknownError(replay.authorizationId, 'secure_acceptance_reply_unconfirmed');
3766
+ await deliverTo.fulfill({ status: answer.status, headers: answer.headers, body: answer.body });
3767
+ assertRequestLive();
3768
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId,
3769
+ amountVerified: replay.mode === 'device_encrypt' ? null : replay.amountVerified ?? null, cors: 'none' } });
3770
+ watchSecureAcceptanceFollowUp(secureAcceptanceFollowUpWatch, preparation, replay);
3771
+ }
3617
3772
  else {
3618
3773
  // Playwright adds these itself when a cross-origin fulfill carries
3619
3774
  // none; written here anyway (replacing a stale value) so a
@@ -3658,8 +3813,10 @@ export async function attachToPlaywright(page, opts) {
3658
3813
  reportCardEndpointRefusal(opts, cardEndpoint, err);
3659
3814
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
3660
3815
  }
3661
- // As in attachToCdp: a card form navigation ends on the stopped page (failedNavigationRefusal).
3662
- await Promise.resolve().then(() => refuseRoute(dead, failedNavigationRefusal(err, handoffStarted || approvedId !== null, approvedId ?? lifecycle.getState().authorizationId))).catch(() => { });
3816
+ // As in attachToCdp: a card form navigation ends on the stopped page (failedNavigationRefusal), and a
3817
+ // Secure Acceptance frame post reads CyberSource's side too (secureAcceptanceFailedRefusal).
3818
+ const sent = handoffStarted || approvedId !== null, failedFor = approvedId ?? lifecycle.getState().authorizationId;
3819
+ await Promise.resolve().then(() => refuseRoute(dead, secureAcceptance ? secureAcceptanceFailedRefusal(err, sent, failedFor) : failedNavigationRefusal(err, sent, failedFor))).catch(() => { });
3663
3820
  }
3664
3821
  finally {
3665
3822
  disarmSendWindow?.();
package/dist/client.d.ts CHANGED
@@ -176,6 +176,13 @@ export declare const SUPPORTED_REGISTRY_FEATURES: readonly string[];
176
176
  * `ekashu_hosted_payment_page`: NMI's hosted payment page, whose card-free entry this
177
177
  * build pauses for the cardholder's device and whose card page this build never lets
178
178
  * the agent's browser post to (pre-claim.ts, ekashu.ts); served on the same terms.
179
+ * `cybersource_sa_token_create`: CyberSource Secure Acceptance's token create
180
+ * (POST /silent/embedded/token/create), which this build pauses only as the
181
+ * merchant's hidden frame sends it, binds to a prepared checkout, and answers with
182
+ * CyberSource's own reply page once neither the API nor the cardholder's device
183
+ * finds a card in it (cybersource-secure-acceptance.ts). Served on the same terms,
184
+ * so every other company keeps the stop every build applies to a card posted to
185
+ * Secure Acceptance.
179
186
  */
180
187
  export declare const SUPPORTED_CAPABILITIES: readonly string[];
181
188
  /**
@@ -456,6 +463,19 @@ export interface PrepareCheckoutOptions {
456
463
  * processor.
457
464
  */
458
465
  paypageId?: string;
466
+ /**
467
+ * CyberSource Secure Acceptance only: the merchant's Secure Acceptance profile
468
+ * id, CyberSource's UUID shape (36 characters, hex in either case). It names
469
+ * the CyberSource merchant profile the card's token is created for, so it is
470
+ * fixed before the page sends anything: a token request naming another profile
471
+ * id never spends the approval. Required on the sandbox environment (your own
472
+ * test profile on testsecureacceptance.cybersource.com). On production leave it
473
+ * out for a merchant Agentcard reviewed: the SDK fills the one profile id its
474
+ * entry pins for the checkout page's origin, and refuses a checkout it can name
475
+ * none for (`secure_acceptance_profile_id_invalid`). Refused on every other
476
+ * processor.
477
+ */
478
+ secureAcceptanceProfileId?: string;
459
479
  /**
460
480
  * CyberSource Flex only: the cardholder's device tokenizes the card when it approves, before
461
481
  * Pay, under the capture context of the card fields the page already loaded, so the page's
@@ -525,6 +545,8 @@ export interface PreparedCheckout {
525
545
  readonly paymentPageUrl?: string;
526
546
  /** Worldpay eProtect only: the paypageId the API confirmed, the one merchant whose registration may spend this approval. */
527
547
  readonly paypageId?: string;
548
+ /** CyberSource Secure Acceptance only: the profile id the API confirmed, the one merchant profile whose token create may spend this approval. */
549
+ readonly secureAcceptanceProfileId?: string;
528
550
  /**
529
551
  * CyberSource Flex, tokenized before Pay: the capture context and fields the cardholder's device
530
552
  * tokenized the card under. Only a field request loaded under this context, with these fields,