@agent-cards/sdk 0.22.0 → 0.23.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
@@ -11,6 +11,7 @@ import { dispatchPreClaim, eventUrl } from './pre-claim.js';
11
11
  import { GMO_TOKEN_V1_PATTERNS, gmoRequestShop, isGmoTokenV1Request, recognizesGmo } from './gmo.js';
12
12
  import { DatatransRepeats } from './datatrans.js';
13
13
  import { SHIJI_STS_COMPANION_WINDOW_MS, ShijiStsCompanions, ShijiStsError, isShijiStsHost, shijiStsTokenContinuation, } from './shiji-sts.js';
14
+ import { isArmedEkashuUrl } from './ekashu.js';
14
15
  import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
15
16
  import { cseBody } from './cse-body.js';
16
17
  import { STRIPE_SETUP_INTENT_CONFIRM, setupConfirmPayingWith } from './stripe-setup.js';
@@ -21,9 +22,11 @@ import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watc
21
22
  import { hostedFormSubmittedPage } from './hosted-form.js';
22
23
  import { amadeusSendWindowMs, armSendWindow, failsAsNetworkError } from './amadeus.js';
23
24
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
24
- import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, refusedRequestFailure } from './cybersource-flex.js';
25
- import { CARD_ENDPOINT_WITHHELD_ANSWER, CardEndpointRefusedError, captureCardEndpointSession, cardEndpointAnswerHeaders, cardEndpointCookies, cardEndpointDetail, cardEndpointFor, cardEndpointPatterns, } from './card-endpoint.js';
25
+ import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, loadedCardMicroformFromUrls, refusedRequestFailure, } from './cybersource-flex.js';
26
+ import { CARD_ENDPOINT_REFUSED_STEP_ANSWER, CARD_ENDPOINT_WITHHELD_ANSWER, CardEndpointRefusedError, captureCardEndpointSession, cardEndpointAnswerHeaders, cardEndpointCookies, cardEndpointDetail, cardEndpointFor, cardEndpointNavigationAnswer, cardEndpointPatterns, cardEndpointResendFor, } from './card-endpoint.js';
27
+ import { CardEndpointFactsWatch } from './card-endpoint-facts.js';
26
28
  import { PAYRAILS_GUARD_PATTERNS, PAYRAILS_INIT_BODY_LIMIT, PayrailsSessionCapture, isPayrailsHost, payrailsInitCandidate, recognizesPayrails, } from './payrails.js';
29
+ import { cardEndpointStoppedPage, refusedNavigationOutcome } from './card-endpoint-page.js';
27
30
  /**
28
31
  * How an adapter fails a paused request it will not let through: as a network error for a
29
32
  * CyberSource Flex request (refusedRequestFailure, so the Microform field reports it to the page)
@@ -620,10 +623,24 @@ export function withCorsHeaders(headers, cors) {
620
623
  /**
621
624
  * The request a card endpoint approval is asked for: `request` names the URL and
622
625
  * nothing else, and the profile travels with the captured session in
623
- * merchant_card_endpoint, the one member the API seals.
626
+ * merchant_card_endpoint, the one member the API seals, with the merchant's own cut
627
+ * record of the order for a profile that reads one.
624
628
  */
625
- function cardEndpointRequest(profile, session) {
626
- return { url: session.url, method: 'POST', headers: {}, body: '', merchant_card_endpoint: { profile: profile.id, session } };
629
+ function cardEndpointRequest(profile, session, facts = null) {
630
+ return { url: session.url, method: 'POST', headers: {}, body: '', merchant_card_endpoint: { profile: profile.id, session, ...(facts ? { facts } : {}) } };
631
+ }
632
+ /**
633
+ * The merchant's own record of the order for a paused card request of a profile that
634
+ * reads one (card-endpoint-facts.ts), or null for any other profile. A request with no
635
+ * record to send is refused before any create, naming no value.
636
+ */
637
+ async function cardEndpointFacts(watch, profile, url, pausedAt, signal) {
638
+ if (!profile.facts)
639
+ return null;
640
+ const captured = await watch.capture(profile, url, pausedAt, signal);
641
+ if (!captured.record)
642
+ throw new CardEndpointRefusedError(profile.id, 'facts_unavailable', captured.refusal);
643
+ return captured.record;
627
644
  }
628
645
  /** A card endpoint refusal as an event: the profile, the endpoint origin, the reason, and the fields it is about, never a value. */
629
646
  function reportCardEndpointRefusal(opts, profile, error) {
@@ -651,6 +668,75 @@ function playwrightCookieParams(cookie) {
651
668
  secure: cookie.secure, httpOnly: cookie.httpOnly,
652
669
  ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), ...(cookie.expires !== undefined ? { expires: cookie.expires } : {}) };
653
670
  }
671
+ /**
672
+ * The page a stopped card form navigation is answered with (card-endpoint-page.ts): the
673
+ * card never left the device, it reached the merchant and the answer cannot be shown, or
674
+ * a payment for this checkout is waiting or may have reached the merchant.
675
+ */
676
+ function stoppedNavigationPage(profile, outcome, reason, authorizationId) {
677
+ return cardEndpointStoppedPage({ outcome, reason, merchant: profile.merchant, authorizationId });
678
+ }
679
+ /** The API refused the create because this checkout already has a payment waiting or sent (409 merchant_checkout_in_use). */
680
+ function checkoutInUse(error) {
681
+ return error instanceof CheckoutApiError && error.code === 'merchant_checkout_in_use';
682
+ }
683
+ /**
684
+ * How a card form navigation the attachment refuses before any approval (not ready, held,
685
+ * busy, quiet, or after a failure that ends every later request) is answered. The API's
686
+ * refusal of this checkout as already paying keeps saying so on every later post. Otherwise
687
+ * the page reads the attachment (refusedNavigationOutcome): while an approval is outstanding,
688
+ * or once a payment may exist, it says a payment is in progress and names its authorization,
689
+ * never that nothing was sent.
690
+ */
691
+ function heldNavigationRefusal(terminal, awaitingApproval, lifecycle) {
692
+ if (checkoutInUse(terminal))
693
+ return { outcome: 'payment_in_progress', reason: 'checkout_in_use', authorizationId: null };
694
+ const state = lifecycle.getState();
695
+ const outcome = refusedNavigationOutcome({ awaitingApproval, status: state.status });
696
+ return { outcome, reason: 'blocked', authorizationId: outcome === 'payment_in_progress' ? state.authorizationId : null };
697
+ }
698
+ /**
699
+ * How a card form navigation whose approval failed is answered, for `authorizationId`. Once
700
+ * the device approved or the hand-off began (`sent`), the card reached the merchant. Before
701
+ * that it may have left only while the authorization stood: a PaymentOutcomeUnknownError
702
+ * whose authorization the API confirmed retired before any processor request
703
+ * (processorRequestStarted false) sent nothing, and any other one may have reached the
704
+ * merchant. The API's refusal of a checkout already paying says a payment is in progress.
705
+ * Every other failure stopped the card before it left.
706
+ */
707
+ function failedNavigationRefusal(error, sent, authorizationId) {
708
+ if (sent)
709
+ return { outcome: 'sent_answer_not_shown', reason: 'outcome_unknown', authorizationId };
710
+ if (error instanceof PaymentOutcomeUnknownError) {
711
+ return error.processorRequestStarted === false ? { outcome: 'stopped_before_send', reason: 'not_approved', authorizationId }
712
+ : { outcome: 'payment_in_progress', reason: 'outcome_unknown', authorizationId };
713
+ }
714
+ if (checkoutInUse(error))
715
+ return { outcome: 'payment_in_progress', reason: 'checkout_in_use', authorizationId };
716
+ return { outcome: 'stopped_before_send', reason: stoppedBeforeSendReason(error), authorizationId };
717
+ }
718
+ /**
719
+ * What a Playwright request navigates: 'top' for the page, 'frame' for an iframe, or null
720
+ * for a fetch, an XHR or a subresource, and for a request whose frame cannot be read.
721
+ */
722
+ function playwrightNavigation(request) {
723
+ try {
724
+ const navigating = typeof request.isNavigationRequest === 'function' ? request.isNavigationRequest() === true : request.resourceType?.() === 'document';
725
+ if (!navigating)
726
+ return null;
727
+ const frame = request.frame?.();
728
+ if (!frame)
729
+ return null;
730
+ return frame.parentFrame?.() ? 'frame' : 'top';
731
+ }
732
+ catch {
733
+ return null;
734
+ }
735
+ }
736
+ /** Why a card form navigation stopped before its card left, from the error that stopped it. */
737
+ function stoppedBeforeSendReason(error) {
738
+ return error instanceof CardEndpointRefusedError ? (error.reason === 'not_enabled' ? 'not_enabled' : 'request_unsupported') : 'not_approved';
739
+ }
654
740
  /**
655
741
  * Answer a paused card endpoint request with what the cardholder's device reported.
656
742
  * A withheld answer (it carried the card, or a stream the device cannot read) becomes
@@ -661,15 +747,24 @@ function playwrightCookieParams(cookie) {
661
747
  * merchant's status, the profile's answer
662
748
  * headers, the CORS answer the request needs, and the body. Resolves false for a
663
749
  * withheld answer and true when the merchant's answer was handed over.
750
+ *
751
+ * A profile whose page submits its form as a navigation is answered as a page load: a
752
+ * withheld answer, or a redirect that would make the browser post the form again, gets
753
+ * the stopped page (the card reached the merchant, the answer is not shown); a 301, 302
754
+ * or 303 is handed over as a redirect to its one https Location, which the browser
755
+ * follows in its own session with the merchant's cookies written first; any other
756
+ * answer is the page itself. No CORS answer rides on a page load.
664
757
  */
665
758
  async function deliverCardEndpointAnswer(opts, lifecycle, profile, replay, request, io) {
666
759
  // Only the request that named this profile is answered from its merchant's answer.
667
760
  if (!profile || replay.profile !== profile.id)
668
761
  throw new PaymentOutcomeUnknownError(replay.authorizationId, 'approved_mode_unsupported');
669
762
  const cors = corsDecision(request.url, request.headers);
763
+ const navigation = profile.navigation !== null;
670
764
  if (replay.withheld) {
671
- await io.fulfill({ status: CARD_ENDPOINT_WITHHELD_ANSWER.status, headers: withCorsHeaders({ ...CARD_ENDPOINT_WITHHELD_ANSWER.headers }, cors.headers),
672
- body: CARD_ENDPOINT_WITHHELD_ANSWER.body });
765
+ const fixed = replay.withheld === 'refused_step_in_response' ? CARD_ENDPOINT_REFUSED_STEP_ANSWER : CARD_ENDPOINT_WITHHELD_ANSWER;
766
+ await io.fulfill(navigation ? stoppedNavigationPage(profile, 'sent_answer_not_shown', 'answer_withheld', replay.authorizationId)
767
+ : { status: fixed.status, headers: withCorsHeaders({ ...fixed.headers }, cors.headers), body: fixed.body });
673
768
  io.assertLive();
674
769
  lifecycle.failed(new PaymentOutcomeUnknownError(replay.authorizationId, 'merchant_answer_withheld'), true);
675
770
  opts.onEvent?.({ type: 'merchant_answer_withheld', detail: { authorizationId: replay.authorizationId, ...cardEndpointDetail(profile), reason: replay.withheld } });
@@ -687,6 +782,15 @@ async function deliverCardEndpointAnswer(opts, lifecycle, profile, replay, reque
687
782
  }
688
783
  }
689
784
  const { cookies, skipped } = cardEndpointCookies(profile, request.url, replay.setCookies, Date.now(), jar);
785
+ // A navigation's answer is judged before any cookie is written: a redirect it cannot follow ends on the stopped page.
786
+ const verdict = navigation ? cardEndpointNavigationAnswer(profile, { status: replay.status, headers: replay.headers }, request.url) : { kind: 'page' };
787
+ if (verdict.kind === 'refused') {
788
+ await io.fulfill(stoppedNavigationPage(profile, 'sent_answer_not_shown', verdict.reason, replay.authorizationId));
789
+ io.assertLive();
790
+ lifecycle.failed(new PaymentOutcomeUnknownError(replay.authorizationId, 'merchant_redirect_refused'), true);
791
+ opts.onEvent?.({ type: 'merchant_answer_withheld', detail: { authorizationId: replay.authorizationId, ...cardEndpointDetail(profile), reason: verdict.reason } });
792
+ return false;
793
+ }
690
794
  let written = 0;
691
795
  let refused = 0;
692
796
  for (const cookie of cookies) {
@@ -701,10 +805,20 @@ async function deliverCardEndpointAnswer(opts, lifecycle, profile, replay, reque
701
805
  }
702
806
  }
703
807
  io.assertLive();
704
- await io.fulfill({ status: replay.status, headers: withCorsHeaders(cardEndpointAnswerHeaders(profile, replay.headers), cors.headers), body: replay.body });
808
+ if (verdict.kind === 'redirect') {
809
+ await io.fulfill({ status: verdict.status, headers: { location: verdict.location, 'cache-control': 'no-store' }, body: '' });
810
+ }
811
+ else if (navigation) {
812
+ const headers = cardEndpointAnswerHeaders(profile, replay.headers);
813
+ delete headers.location;
814
+ await io.fulfill({ status: replay.status, headers, body: replay.body });
815
+ }
816
+ else {
817
+ await io.fulfill({ status: replay.status, headers: withCorsHeaders(cardEndpointAnswerHeaders(profile, replay.headers), cors.headers), body: replay.body });
818
+ }
705
819
  io.assertLive();
706
820
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'card_endpoint', authorizationId: replay.authorizationId, ...cardEndpointDetail(profile),
707
- status: replay.status, cors: cors.outcome, cookies: { written, skipped: skipped + refused } } });
821
+ status: replay.status, ...(navigation ? { answered: verdict.kind } : { cors: cors.outcome }), cookies: { written, skipped: skipped + refused } } });
708
822
  return true;
709
823
  }
710
824
  /** CDP's header shape: `{ name, value }` entries. */
@@ -745,6 +859,21 @@ function pageOriginFrom(documentUrl) {
745
859
  return undefined;
746
860
  }
747
861
  }
862
+ /** The URL of the document the frame `frameId` holds, from the frame tree of the CDP session a request paused on. */
863
+ async function frameUrlIn(cdp, sessionId, frameId) {
864
+ if (typeof frameId !== 'string' || frameId === '')
865
+ throw new Error('merchant_document_unavailable');
866
+ const tree = await cdp.send('Page.getFrameTree', {}, sessionId);
867
+ const stack = tree?.frameTree ? [tree.frameTree] : [];
868
+ while (stack.length > 0) {
869
+ const node = stack.pop();
870
+ if (node?.frame?.id === frameId && typeof node.frame.url === 'string')
871
+ return node.frame.url;
872
+ for (const child of node?.childFrames ?? [])
873
+ stack.push(child);
874
+ }
875
+ throw new Error('merchant_document_unavailable');
876
+ }
748
877
  /**
749
878
  * The top-level document's URL, read once with a one-second bound when a card
750
879
  * request pauses: pageOriginFrom derives the merchant origin from it, and the
@@ -1530,7 +1659,58 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1530
1659
  preparationFrameId = tree.frameTree.frame.id;
1531
1660
  return tree.frameTree.frame.url;
1532
1661
  };
1533
- const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
1662
+ // prepare({ tokenizeBeforePay: true }) sends the card Microform whose field documents are committed
1663
+ // now. A field's commit can come before this attachment enabled its session, so no event reports
1664
+ // it; each iframe session's own frame tree, and the page's, is read once first (as rereadMicroforms
1665
+ // reads the pausing one), and every field found there is recorded (cybersource-flex.ts MicroformFrames).
1666
+ const readLoadedMicroform = async () => {
1667
+ const sessions = [pageSessionId, ...[...ownedSessions].filter(session => sessionTypes.get(session) === 'iframe' && !sessionDetached(session))];
1668
+ await Promise.all(sessions.map(session => rereadMicroforms(session, '').catch(() => undefined)));
1669
+ return microforms.loadedCard();
1670
+ };
1671
+ /**
1672
+ * The frame a paused document request navigates, read from the frame tree of the
1673
+ * session it paused on: 'top' for the page itself, 'frame' for an iframe (a frame with
1674
+ * a parent, or the root of an iframe's own session), with that frame's current
1675
+ * document URL, where the form was submitted. Null when the frame is not in the tree.
1676
+ */
1677
+ const documentFrame = async (sessionId, frameId) => {
1678
+ if (typeof frameId !== 'string' || !frameId)
1679
+ return null;
1680
+ const tree = await cdp.send('Page.getFrameTree', {}, sessionId);
1681
+ const find = (node, depth) => {
1682
+ if (!node || depth > 32)
1683
+ return null;
1684
+ if (node.frame?.id === frameId)
1685
+ return node.frame;
1686
+ for (const child of Array.isArray(node.childFrames) ? node.childFrames : []) {
1687
+ const hit = find(child, depth + 1);
1688
+ if (hit)
1689
+ return hit;
1690
+ }
1691
+ return null;
1692
+ };
1693
+ const frame = find(tree?.frameTree, 0);
1694
+ if (!frame)
1695
+ return null;
1696
+ return { navigation: frame.parentId || sessionId !== pageSessionId ? 'frame' : 'top', url: typeof frame.url === 'string' ? frame.url : undefined };
1697
+ };
1698
+ /**
1699
+ * Refuse a paused request after the pre-claim gates: a card form navigation gets the
1700
+ * stopped page (card-endpoint-page.ts) that `refusal` describes, since an aborted page
1701
+ * load renders nothing and invites another click on Pay; any other request fails as
1702
+ * cdpFailure says (a network error for CyberSource Flex and Amadeus, else an abort).
1703
+ */
1704
+ const refusePaused = async (requestId, sessionId, url, profile, refusal) => {
1705
+ if (profile && profile.navigation !== null) {
1706
+ const page = stoppedNavigationPage(profile, refusal.outcome, refusal.reason, refusal.authorizationId);
1707
+ await cdp.send('Fetch.fulfillRequest', { requestId, responseCode: page.status, responseHeaders: headerEntries(page.headers),
1708
+ body: Buffer.from(page.body).toString('base64') }, sessionId).catch(() => { });
1709
+ return;
1710
+ }
1711
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(url) }, sessionId).catch(() => { });
1712
+ };
1713
+ const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl, readLoadedMicroform);
1534
1714
  // A merchant-hosted request this attachment continued with the Vault's
1535
1715
  // ciphertext, and the Network identity its answer arrives under.
1536
1716
  const merchantHandoff = new MerchantHandoffWatch(lifecycle, opts.onEvent);
@@ -1540,6 +1720,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1540
1720
  // The merchant's own total (merchant-total-watch.ts): its checkout responses, recorded
1541
1721
  // read-only from attach on, and the approval a hand-off answers, for the charge report.
1542
1722
  const merchantTotal = new MerchantTotalWatch(opts.onEvent, opts.vault);
1723
+ // A reviewed card endpoint merchant's own record of the order (card-endpoint-facts.ts).
1724
+ const cardEndpointRecord = new CardEndpointFactsWatch();
1543
1725
  let handoffReplay = null;
1544
1726
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
1545
1727
  // Payrails sessions this page's own XHR and fetch answers carried, read only
@@ -1722,8 +1904,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1722
1904
  // GMO token v1's JSONP GET is armed only when the synced registry recognizes
1723
1905
  // GMO (an org the API served the dark entry to), so the gate can refuse it;
1724
1906
  // for every other org it is never intercepted, as before (see gmo.ts).
1725
- // Every reviewed card endpoint is armed in any status: one this build does not pay is
1726
- // paused only to be refused, so the placeholder card never reaches the merchant.
1907
+ // Every reviewed card endpoint is armed in any status: a card request to one this build
1908
+ // does not pay is paused only to be refused, so the placeholder card never reaches the
1909
+ // merchant, and a request with no card in it continues untouched.
1727
1910
  // Every Payrails host is intercepted only for an org the API served Payrails
1728
1911
  // to, so the gate can abort a card-form payment (see payrails.ts).
1729
1912
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
@@ -1884,6 +2067,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1884
2067
  merchantTotal.sent(key, { method: params.request.method, url: params.request.url,
1885
2068
  ...(typeof params.request.postData === 'string' ? { requestBody: params.request.postData } : {}),
1886
2069
  ...(typeof params.loaderId === 'string' && params.loaderId ? { page: `${owner}:${params.loaderId}` } : {}) });
2070
+ cardEndpointRecord.sent(key, { method: params.request.method, url: params.request.url, resourceType: params.type ?? null, networkId: params.requestId });
1887
2071
  }
1888
2072
  if (matchesActive) {
1889
2073
  try {
@@ -1906,6 +2090,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1906
2090
  if (method === 'Network.loadingFinished') {
1907
2091
  const key = requestKey(sessionId, params.requestId);
1908
2092
  requestSources.delete(key);
2093
+ // An out-of-process frame's document finishes on the frame's own session, under the
2094
+ // network id its request was reported with on the parent's (keyFor).
2095
+ const recordKey = cardEndpointRecord.keyFor(key, params.requestId);
2096
+ if (recordKey) {
2097
+ const completedAt = Date.now();
2098
+ // Its place among the answers that finished, before its text is read: texts can come back out of order.
2099
+ cardEndpointRecord.finished(recordKey);
2100
+ cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((read) => cardEndpointRecord.read(recordKey, typeof read?.body === 'string'
2101
+ ? (read.base64Encoded ? Buffer.from(read.body, 'base64').toString('utf8') : read.body) : null, completedAt), () => cardEndpointRecord.read(recordKey, null, completedAt));
2102
+ }
1909
2103
  if (merchantTotal.log.tracks(key)) {
1910
2104
  // When it finished, then its text: a merchant response the total or the charge is read from.
1911
2105
  const completedAt = Date.now();
@@ -1926,6 +2120,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1926
2120
  }
1927
2121
  if (method === 'Network.responseReceived') {
1928
2122
  merchantTotal.log.answered(requestKey(sessionId, params.requestId), params.response?.status);
2123
+ const recordKey = cardEndpointRecord.keyFor(requestKey(sessionId, params.requestId), params.requestId);
2124
+ if (recordKey)
2125
+ cardEndpointRecord.answered(recordKey, params.response?.status);
1929
2126
  // The merchant's answer to a continued merchant-hosted request: 5xx is an unknown outcome.
1930
2127
  if (handoffNetwork && params.requestId === handoffNetwork.networkId && sessionId === handoffNetwork.sessionId) {
1931
2128
  merchantHandoff.responded(params.response?.status);
@@ -1947,6 +2144,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1947
2144
  }
1948
2145
  if (method === 'Network.loadingFailed') {
1949
2146
  merchantTotal.log.failed(requestKey(sessionId, params.requestId));
2147
+ const recordKey = cardEndpointRecord.keyFor(requestKey(sessionId, params.requestId), params.requestId);
2148
+ if (recordKey)
2149
+ cardEndpointRecord.failed(recordKey);
1950
2150
  if (handoffNetwork && params.requestId === handoffNetwork.networkId && sessionId === handoffNetwork.sessionId)
1951
2151
  merchantHandoff.failed();
1952
2152
  payrailsAnswers.delete(requestKey(sessionId, params.requestId));
@@ -2109,6 +2309,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2109
2309
  // navigation (the ACI browser test's classic case, design gate G3).
2110
2310
  replacesNavigationBody: async () => true,
2111
2311
  readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
2312
+ // Read only for a card endpoint whose page submits its form as a navigation.
2313
+ navigation: async () => (resourceType === 'Document' ? (await documentFrame(sessionId, frameId))?.navigation ?? null : null),
2112
2314
  effects: {
2113
2315
  continueRequest: (postData) => cdp.send('Fetch.continueRequest', { requestId, ...(postData !== undefined ? { postData: Buffer.from(postData).toString('base64') } : {}) }, sessionId),
2114
2316
  answer: (response) => cdp.send('Fetch.fulfillRequest', { requestId, responseCode: response.status,
@@ -2145,13 +2347,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2145
2347
  const stripeStep = preClaim.stripeStep;
2146
2348
  // The capture context and fields a CyberSource Flex create carries, and its retry shape.
2147
2349
  const flexPause = preClaim.cybersourceFlex;
2148
- // A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts).
2350
+ // A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts), and
2351
+ // for one whose page submits its form as a navigation, which frame it navigates.
2149
2352
  const cardEndpoint = preClaim.cardEndpoint ?? null;
2150
2353
  // A Payrails card save the gate bound to a captured session: its key rides on the create.
2151
2354
  const payrailsSidecar = preClaim.payrails;
2355
+ const cardNavigation = preClaim.navigation;
2152
2356
  if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
2153
2357
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
2154
- await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
2358
+ await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
2155
2359
  return;
2156
2360
  }
2157
2361
  // A request the catalog marks as routine without a card (a confirmation
@@ -2238,7 +2442,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2238
2442
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
2239
2443
  if (preparation)
2240
2444
  preparationGate.retireUnboundClaim();
2241
- await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
2445
+ // A card form navigation never reads that nothing was sent while a payment for this checkout may exist.
2446
+ await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
2242
2447
  if (repeatedAuthorizationId)
2243
2448
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
2244
2449
  return;
@@ -2341,12 +2546,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2341
2546
  ? await merchantTotal.pageFor(cardKey, MERCHANT_PAGE_WAIT_MS, attempt.signal) : merchantTotal.pageOf(cardKey);
2342
2547
  // The card endpoint's session: the profile's own headers from the paused request,
2343
2548
  // the endpoint's cookie jar for a cookie profile (Chrome keeps it out of the
2344
- // paused headers, HttpOnly cookies included), and the checkout page's Referer.
2549
+ // paused headers, HttpOnly cookies included), and a Referer on the origin the
2550
+ // request is sent from: the profile's card vault frame when it names one, else
2551
+ // the checkout page.
2552
+ // A form inside an iframe is judged by the iframe's own document, where it was submitted, and
2553
+ // a checkout in a frame the merchant's page embeds by that frame's own document.
2554
+ const capturedOrigin = cardEndpoint?.navigation === 'frame'
2555
+ ? pageOriginFrom((await documentFrame(sessionId, frameId).catch(() => null))?.url)
2556
+ : cardEndpoint?.checkout_frame
2557
+ ? pageOriginFrom(await documentUrlOf(() => frameUrlIn(cdp, sessionId, frameId), attempt.signal)) : pageOrigin;
2345
2558
  const session = cardEndpoint ? await captureCardEndpointSession(cardEndpoint, {
2346
- url: request.url, headers: request.headers, body, pageOrigin,
2559
+ url: request.url, headers: request.headers, body, pageOrigin: capturedOrigin, navigation: cardNavigation,
2347
2560
  cookies: async () => (await cdp.send('Network.getCookies', { urls: [request.url] }, sessionId))?.cookies ?? [],
2348
2561
  }) : null;
2349
2562
  attempt.assertLive();
2563
+ // The merchant's own record of the order, as of the pause, for a profile that reads one.
2564
+ const cardRecord = cardEndpoint ? await cardEndpointFacts(cardEndpointRecord, cardEndpoint, request.url, pausedAt, attempt.signal) : null;
2565
+ attempt.assertLive();
2350
2566
  // Decision 176: pause only. The business creates the authorization from the paused request and answers
2351
2567
  // through controller.resume; the replay it hands back goes down the same delivery path as a create's.
2352
2568
  const pause = opts.pauseOnly ? pauseForBusiness(opts, lifecycle, attempt.signal, { url: request.url, method: request.method, headers: request.headers ?? {}, body }, pageOrigin) : null;
@@ -2376,7 +2592,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2376
2592
  lifecycle.approvalUrl(url);
2377
2593
  return opts.onApprovalUrl?.(url);
2378
2594
  } },
2379
- request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session)
2595
+ request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session, cardRecord)
2380
2596
  : { url: request.url, method: request.method, headers: request.headers, body,
2381
2597
  ...(mercadoCheckout.requiresDocument(request.url, request.method)
2382
2598
  ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}),
@@ -2565,7 +2781,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2565
2781
  reportCardEndpointRefusal(opts, cardEndpoint, err);
2566
2782
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
2567
2783
  }
2568
- await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: cdpFailure(request.url) }, dead.sessionId).catch(() => { });
2784
+ // A card form navigation ends on the stopped page: its card reached the merchant once the
2785
+ // device approved (the merchant answered it) or the hand-off began; see failedNavigationRefusal.
2786
+ await refusePaused(dead.requestId, dead.sessionId, request.url, cardEndpoint, failedNavigationRefusal(err, handoffStarted || approvedId !== null, approvedId ?? lifecycle.getState().authorizationId));
2569
2787
  settlePause();
2570
2788
  }
2571
2789
  finally {
@@ -2675,7 +2893,17 @@ export async function attachToPlaywright(page, opts) {
2675
2893
  throw new Error('merchant_document_unavailable');
2676
2894
  return page.url();
2677
2895
  };
2678
- const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
2896
+ // prepare({ tokenizeBeforePay: true }) sends the card Microform whose fields are on the page now.
2897
+ const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl, () => {
2898
+ let frames = [];
2899
+ try {
2900
+ frames = page.frames?.() ?? [];
2901
+ }
2902
+ catch {
2903
+ frames = [];
2904
+ }
2905
+ return loadedCardMicroformFromUrls(frames.map(frameUrlOf));
2906
+ });
2679
2907
  // A merchant-hosted request this attachment continued, and its answer; see watchPlaywrightHandoff.
2680
2908
  const merchantHandoff = new MerchantHandoffWatch(lifecycle, opts.onEvent);
2681
2909
  let handoffAnswer = null;
@@ -2686,6 +2914,8 @@ export async function attachToPlaywright(page, opts) {
2686
2914
  // but sends no document request, so it keeps the page id, as CDP's loaderId does. Nor does
2687
2915
  // a document request that commits nothing: one that failed, or one answered 204 or 205.
2688
2916
  const merchantTotal = new MerchantTotalWatch(opts.onEvent, opts.vault);
2917
+ // A reviewed card endpoint merchant's own record of the order (card-endpoint-facts.ts).
2918
+ const cardEndpointRecord = new CardEndpointFactsWatch();
2689
2919
  let documentSerial = 1;
2690
2920
  // The main frame's document request that no navigation has committed yet.
2691
2921
  let documentRequest = null;
@@ -2713,6 +2943,18 @@ export async function attachToPlaywright(page, opts) {
2713
2943
  // a new document (the handlers below).
2714
2944
  const shijiCompanions = new ShijiStsCompanions();
2715
2945
  lifecycle.abort.signal.addEventListener('abort', () => shijiCompanions.release(), { once: true });
2946
+ // An answer to a request the facts watch records began to arrive: the watch orders what the page sent after a
2947
+ // record by this moment, since Playwright reports the answer's end after the page's next request. Listened to only
2948
+ // once the page sent such a request, so a page that never meets a merchant with facts carries no extra listener.
2949
+ let factsAnswers = false;
2950
+ const factsAnswer = (response) => {
2951
+ try {
2952
+ const key = keyOf(response.request());
2953
+ if (cardEndpointRecord.tracks(key))
2954
+ cardEndpointRecord.answered(key, response.status());
2955
+ }
2956
+ catch { /* a response the page already dropped */ }
2957
+ };
2716
2958
  page.on?.('request', (request) => {
2717
2959
  // A new document is on its way: the main frame's next navigation commits it.
2718
2960
  if (mainDocument(request))
@@ -2723,6 +2965,16 @@ export async function attachToPlaywright(page, opts) {
2723
2965
  ...(typeof body === 'string' ? { requestBody: body } : {}), page: `doc:${documentSerial}` });
2724
2966
  }
2725
2967
  catch { /* a request the page already dropped */ }
2968
+ try {
2969
+ cardEndpointRecord.sent(keyOf(request), { method: request.method(), url: request.url(),
2970
+ resourceType: typeof request.resourceType === 'function' ? request.resourceType() : null });
2971
+ // From the first request the watch records, it also hears when each answer begins to arrive (factsAnswer).
2972
+ if (!factsAnswers && cardEndpointRecord.tracks(keyOf(request))) {
2973
+ factsAnswers = true;
2974
+ page.on?.('response', factsAnswer);
2975
+ }
2976
+ }
2977
+ catch { /* a request the page already dropped */ }
2726
2978
  });
2727
2979
  page.on?.('requestfinished', (request) => {
2728
2980
  // A document answered 204 or 205 loads nothing, so no navigation will commit it.
@@ -2733,6 +2985,27 @@ export async function attachToPlaywright(page, opts) {
2733
2985
  }).catch(() => { });
2734
2986
  }
2735
2987
  const key = keyOf(request);
2988
+ if (cardEndpointRecord.tracks(key)) {
2989
+ const finishedAt = Date.now();
2990
+ // Its place among the answers that finished, before its text is read: texts can come back out of order.
2991
+ cardEndpointRecord.finished(key);
2992
+ Promise.resolve().then(async () => {
2993
+ const response = await request.response();
2994
+ if (!response) {
2995
+ cardEndpointRecord.failed(key);
2996
+ return;
2997
+ }
2998
+ cardEndpointRecord.answered(key, response.status());
2999
+ let text = null;
3000
+ try {
3001
+ text = await response.text();
3002
+ }
3003
+ catch {
3004
+ text = null;
3005
+ }
3006
+ cardEndpointRecord.read(key, text, finishedAt);
3007
+ }).catch(() => cardEndpointRecord.failed(key));
3008
+ }
2736
3009
  if (!merchantTotal.log.tracks(key))
2737
3010
  return;
2738
3011
  const completedAt = Date.now();
@@ -2802,6 +3075,7 @@ export async function attachToPlaywright(page, opts) {
2802
3075
  if (request === documentRequest)
2803
3076
  documentRequest = null;
2804
3077
  merchantTotal.log.failed(keyOf(request));
3078
+ cardEndpointRecord.failed(keyOf(request));
2805
3079
  if (activeRequest && activeRequest.request === request && activeRequest.attempt.lose()) {
2806
3080
  // The page's own script gave up on its request; the cardholder's
2807
3081
  // approval did not (see merchantAttempt). Forget the request's identity
@@ -2899,11 +3173,17 @@ export async function attachToPlaywright(page, opts) {
2899
3173
  // GMO token v1's JSONP GET, routed only for an org the API served GMO to (see attachToCdp).
2900
3174
  || (isGmoTokenV1Request(url.toString()) && recognizesGmo(opts.vault))
2901
3175
  || juspayCheckout.matches(url.toString())
2902
- // Every reviewed card endpoint, in any status (the pre-claim gate refuses one this build does not pay).
3176
+ // Every reviewed card endpoint, in any status (the pre-claim gate holds only a request that carries a card,
3177
+ // and refuses one this build does not pay).
2903
3178
  || cardEndpointFor(url.toString(), 'POST') !== null
3179
+ // A request that sends the typed card to such a merchant again, refused in every status.
3180
+ || cardEndpointResendFor(url.toString(), 'POST') !== null
2904
3181
  || aci.matches(url.toString())
2905
3182
  // Every Payrails host, routed only for an org the API served Payrails to (see attachToCdp).
2906
- || (payrails !== null && isPayrailsHost(url.toString())), async (route) => {
3183
+ || (payrails !== null && isPayrailsHost(url.toString()))
3184
+ // Every request to an NMI host whose entry is served (ekashu.ts), as attachToCdp arms the
3185
+ // host: the card page's post and its 3-D Secure calls reach the NMI gate and fail.
3186
+ || isArmedEkashuUrl(opts.vault, url.toString()), async (route) => {
2907
3187
  const request = route.request();
2908
3188
  // When this request paused, and the page it came from: the merchant's total is read as of now.
2909
3189
  const pausedAt = Date.now();
@@ -2944,6 +3224,8 @@ export async function attachToPlaywright(page, opts) {
2944
3224
  // the body route.continue replaces on a navigation (aci.ts playwrightReplacesNavigationBody).
2945
3225
  replacesNavigationBody: () => (navigationBodyReplaced ??= playwrightReplacesNavigationBody(page)),
2946
3226
  readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
3227
+ // Read only for a card endpoint whose page submits its form as a navigation (see attachToCdp).
3228
+ navigation: async () => playwrightNavigation(request),
2947
3229
  effects: {
2948
3230
  continueRequest: (postData) => postData !== undefined ? route.continue({ postData }) : route.fallback(),
2949
3231
  answer: (response) => route.fulfill({ ...response,
@@ -3004,13 +3286,20 @@ export async function attachToPlaywright(page, opts) {
3004
3286
  const stripeStep = preClaim.stripeStep;
3005
3287
  // The capture context and fields a CyberSource Flex create carries, and its retry shape.
3006
3288
  const flexPause = preClaim.cybersourceFlex;
3007
- // A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts).
3289
+ // A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts), and
3290
+ // for one whose page submits its form as a navigation, which frame it navigates.
3008
3291
  const cardEndpoint = preClaim.cardEndpoint ?? null;
3009
3292
  // A Payrails card save the gate bound to a captured session: its key rides on the create.
3010
3293
  const payrailsSidecar = preClaim.payrails;
3294
+ const cardNavigation = preClaim.navigation;
3295
+ // A card form navigation is refused with the stopped page, anything else as refusalFailure says (see attachToCdp
3296
+ // refusePaused).
3297
+ const refuseRoute = (target, refusal) => (cardEndpoint && cardEndpoint.navigation !== null
3298
+ ? target.fulfill(stoppedNavigationPage(cardEndpoint, refusal.outcome, refusal.reason, refusal.authorizationId))
3299
+ : target.abort(refusalFailure(request.url())));
3011
3300
  if (!attachmentReady || setupStop.signal.aborted) {
3012
3301
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
3013
- return route.abort(refusalFailure(request.url()));
3302
+ return refuseRoute(route, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
3014
3303
  }
3015
3304
  // Same order as attachToCdp: a routine card-free request continues in
3016
3305
  // every state, before the preparation claim, so it neither spends a
@@ -3082,7 +3371,8 @@ export async function attachToPlaywright(page, opts) {
3082
3371
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
3083
3372
  if (preparation)
3084
3373
  preparationGate.retireUnboundClaim();
3085
- const refused = await route.abort(refusalFailure(request.url()));
3374
+ // As in attachToCdp: never that nothing was sent while a payment for this checkout may exist.
3375
+ const refused = await refuseRoute(route, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
3086
3376
  if (repeatedAuthorizationId)
3087
3377
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
3088
3378
  return refused;
@@ -3171,11 +3461,28 @@ export async function attachToPlaywright(page, opts) {
3171
3461
  assertRequestLive();
3172
3462
  // The card endpoint's session (see attachToCdp): request.headers() never carries
3173
3463
  // the cookie jar, so the endpoint's cookies are read from the context.
3464
+ // A form inside an iframe is judged by the iframe's own document, where it was submitted, and
3465
+ // a checkout in a frame the merchant's page embeds by that frame's own document.
3466
+ let capturedOrigin = pageOrigin;
3467
+ if (cardEndpoint?.navigation === 'frame') {
3468
+ try {
3469
+ capturedOrigin = pageOriginFrom(request.frame?.()?.url?.());
3470
+ }
3471
+ catch {
3472
+ capturedOrigin = undefined;
3473
+ }
3474
+ }
3475
+ else if (cardEndpoint?.checkout_frame) {
3476
+ capturedOrigin = pageOriginFrom(await documentUrlOf(async () => String(request.frame().url()), attempt.signal));
3477
+ }
3174
3478
  const session = cardEndpoint ? await captureCardEndpointSession(cardEndpoint, {
3175
- url: request.url(), headers: request.headers(), body, pageOrigin,
3479
+ url: request.url(), headers: request.headers(), body, pageOrigin: capturedOrigin, navigation: cardNavigation,
3176
3480
  cookies: async () => await page.context().cookies(request.url()),
3177
3481
  }) : null;
3178
3482
  assertRequestLive();
3483
+ // The merchant's own record of the order, as of the pause (see attachToCdp).
3484
+ const cardRecord = cardEndpoint ? await cardEndpointFacts(cardEndpointRecord, cardEndpoint, request.url(), pausedAt, attempt.signal) : null;
3485
+ assertRequestLive();
3179
3486
  const replay = await opts.vault.authorize({
3180
3487
  user: opts.user,
3181
3488
  merchant: opts.merchant,
@@ -3200,7 +3507,7 @@ export async function attachToPlaywright(page, opts) {
3200
3507
  lifecycle.approvalUrl(url);
3201
3508
  return opts.onApprovalUrl?.(url);
3202
3509
  } },
3203
- request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session)
3510
+ request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session, cardRecord)
3204
3511
  : { url: request.url(), method: request.method(), headers: request.headers(), body,
3205
3512
  ...(mercadoCheckout.requiresDocument(request.url(), request.method())
3206
3513
  ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}),
@@ -3340,7 +3647,8 @@ export async function attachToPlaywright(page, opts) {
3340
3647
  reportCardEndpointRefusal(opts, cardEndpoint, err);
3341
3648
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
3342
3649
  }
3343
- await dead.abort(refusalFailure(request.url())).catch(() => { });
3650
+ // As in attachToCdp: a card form navigation ends on the stopped page (failedNavigationRefusal).
3651
+ await Promise.resolve().then(() => refuseRoute(dead, failedNavigationRefusal(err, handoffStarted || approvedId !== null, approvedId ?? lifecycle.getState().authorizationId))).catch(() => { });
3344
3652
  }
3345
3653
  finally {
3346
3654
  disarmSendWindow?.();