@agent-cards/sdk 0.23.19 → 0.23.21

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +61 -2
  2. package/PREFLIGHT.md +4 -0
  3. package/README.md +175 -7
  4. package/dist/amadeus.d.ts +4 -2
  5. package/dist/amadeus.js +16 -3
  6. package/dist/buy.d.ts +84 -2
  7. package/dist/buy.js +16 -1
  8. package/dist/card-endpoint-facts.d.ts +2 -1
  9. package/dist/card-endpoint-facts.js +18 -9
  10. package/dist/card-endpoint.d.ts +20 -4
  11. package/dist/card-endpoint.js +38 -4
  12. package/dist/card-fields.generated.js +1444 -51
  13. package/dist/cdp.d.ts +29 -0
  14. package/dist/cdp.js +444 -24
  15. package/dist/client.d.ts +134 -7
  16. package/dist/client.js +350 -25
  17. package/dist/comgate.d.ts +50 -0
  18. package/dist/comgate.js +80 -0
  19. package/dist/cybersource-secure-acceptance.generated.d.ts +1 -1
  20. package/dist/eprotect.generated.d.ts +1 -1
  21. package/dist/gmo.generated.js +7 -3
  22. package/dist/hipay.generated.d.ts +56 -0
  23. package/dist/hipay.generated.js +392 -0
  24. package/dist/hold.d.ts +180 -0
  25. package/dist/hold.js +270 -0
  26. package/dist/hosted-entry.d.ts +4 -0
  27. package/dist/hosted-entry.js +32 -0
  28. package/dist/index.d.ts +4 -1
  29. package/dist/index.js +1 -0
  30. package/dist/lifecycle.d.ts +10 -0
  31. package/dist/lifecycle.js +21 -0
  32. package/dist/merchant-card-endpoints.generated.js +102 -11
  33. package/dist/paddle-tokenex.generated.js +7 -3
  34. package/dist/paddle-vgs.generated.d.ts +3 -2
  35. package/dist/paddle-vgs.generated.js +9 -5
  36. package/dist/payrails.generated.js +7 -3
  37. package/dist/pre-claim.d.ts +20 -0
  38. package/dist/pre-claim.js +202 -22
  39. package/dist/preflight-catalog.json +58 -0
  40. package/dist/preflight-schemas.json +4 -0
  41. package/dist/preflight.generated.js +26 -2
  42. package/dist/prepared-processor.d.ts +6 -2
  43. package/dist/prepared-processor.js +18 -2
  44. package/dist/primer.generated.js +1487 -94
  45. package/dist/przelewy24.d.ts +47 -0
  46. package/dist/przelewy24.js +88 -0
  47. package/dist/quickpay.d.ts +44 -0
  48. package/dist/quickpay.js +65 -0
  49. package/dist/registry.d.ts +6 -5
  50. package/dist/registry.js +27 -7
  51. package/dist/shiji-sts.generated.js +7 -3
  52. package/dist/stripe-checkout.generated.js +744 -13
  53. package/dist/tokenex-iframe.d.ts +93 -0
  54. package/dist/tokenex-iframe.generated.d.ts +81 -0
  55. package/dist/tokenex-iframe.generated.js +403 -0
  56. package/dist/tokenex-iframe.js +195 -0
  57. package/dist/transbank.d.ts +45 -0
  58. package/dist/transbank.js +73 -0
  59. package/dist/twoc2p.d.ts +54 -0
  60. package/dist/twoc2p.generated.d.ts +33 -0
  61. package/dist/twoc2p.generated.js +1149 -0
  62. package/dist/twoc2p.js +85 -0
  63. package/dist/voice.d.ts +16 -13
  64. package/dist/voice.js +13 -10
  65. package/examples/preflight/kernel-native/inventory.json +1 -1
  66. package/package.json +3 -3
package/dist/cdp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
- import { AdyenTestPlatformRefusedError, AmountRequiredError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, CheckoutPreparationError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedCapabilityError, UnsupportedModeError, } from './client.js';
2
+ import { AdyenTestPlatformRefusedError, AmountRequiredError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, CheckoutPreparationError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedCapabilityError, UnsupportedModeError, redactUrl, } from './client.js';
3
3
  import { PreparationGate } from './preparation.js';
4
4
  import { preparedApprovalAnswersRetry } from './prepared-processor.js';
5
5
  import { StripeCheckoutGate } from './stripe-checkout.js';
@@ -8,15 +8,21 @@ import { MERCADO_CHECKOUT_PATTERN } from './mercado-checkout.generated.js';
8
8
  import { JuspayCheckoutGate } from './juspay-gate.js';
9
9
  import { ACI_CHECKOUT_REUSED, AciGate, playwrightReplacesNavigationBody } from './aci.js';
10
10
  import { EprotectGate, classifyEprotectRequest, eprotectScriptAnswer } from './eprotect.js';
11
- import { dispatchPreClaim, eventUrl, isArmedCreditguardCall } from './pre-claim.js';
11
+ import { dispatchPreClaim, eventUrl, isArmedCreditguardCall, routesHipayVault } from './pre-claim.js';
12
12
  import { GMO_TOKEN_V1_PATTERNS, gmoRequestShop, isGmoTokenV1Request, recognizesGmo } from './gmo.js';
13
13
  import { isPaddleVgsCardsUrl } from './paddle-vgs.generated.js';
14
14
  import { PADDLE_VGS_PAY_PATTERNS, PaddleVgsPayGate, isPaddleCheckoutService, recognizesPaddleVgs } from './paddle-vgs-pay.js';
15
15
  import { DatatransRepeats } from './datatrans.js';
16
+ import { TokenexIframeRepeats, isArmedTokenexIframeUrl } from './tokenex-iframe.js';
16
17
  import { SHIJI_STS_COMPANION_WINDOW_MS, ShijiStsCompanions, ShijiStsError, isShijiStsHost, shijiStsTokenContinuation, } from './shiji-sts.js';
17
18
  import { paddleTokenexTokenContinuation } from './paddle-tokenex.js';
19
+ import { twoC2pContinuation, twoC2pRetire } from './twoc2p.js';
18
20
  import { isArmedEkashuUrl } from './ekashu.js';
21
+ import { isArmedTransbankUrl } from './transbank.js';
19
22
  import { isArmedRedsysUrl } from './redsys.js';
23
+ import { isArmedPrzelewy24Url } from './przelewy24.js';
24
+ import { isArmedQuickpayUrl } from './quickpay.js';
25
+ import { COMGATE_CARD_PATTERNS, isArmedComgateUrl, servesComgate } from './comgate.js';
20
26
  import { PAYPAL_HOSTED_FIELDS_PATTERNS, isPaypalHostedFieldsConfirm } from './paypal-hosted-fields.js';
21
27
  import { EBANX_RESEND_PATTERNS, EbanxFollowUps, isEbanxResendUrl } from './ebanx.js';
22
28
  import { PAYPAL_CARD_FORM_PATTERNS, isPaypalCardFormUrl } from './paypal-card-forms.js';
@@ -30,6 +36,7 @@ import { MerchantHandoffWatch } from './merchant-handoff.js';
30
36
  import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watch.js';
31
37
  import { hostedFormSubmittedPage } from './hosted-form.js';
32
38
  import { armSendWindow, failsAsNetworkError, preparedSendWindowMs, sendWindowProcessor } from './amadeus.js';
39
+ import { HoldAnsweredError, HoldEndedError, HoldGate, HoldRefusedError, matchHoldOnlyRule } from './hold.js';
33
40
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
34
41
  import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, loadedCardMicroformFromUrls, refusedRequestFailure, } from './cybersource-flex.js';
35
42
  import { CARD_ENDPOINT_REFUSED_STEP_ANSWER, CARD_ENDPOINT_WITHHELD_ANSWER, CardEndpointRefusedError, captureCardEndpointSession, cardEndpointAnswerHeaders, cardEndpointCookies, cardEndpointDetail, cardEndpointNavigationAnswer, cardEndpointPatterns, cardEndpointRouted, } from './card-endpoint.js';
@@ -99,13 +106,22 @@ function isExtensionWorker(type, url) {
99
106
  * A different, explicitly identified context belongs to another checkout.
100
107
  * An extension's own worker owns no merchant request and does not count.
101
108
  */
102
- function assertContextTarget(target, merchantContext) {
109
+ function assertContextTarget(target, merchantContext, allow = {}) {
103
110
  if (!targetRecord(target))
104
111
  throw new CheckoutAttachmentError('unavailable');
105
112
  if (contextId(target.browserContextId) && target.browserContextId !== merchantContext)
106
113
  return;
107
114
  if (isExtensionWorker(target.type, target.url))
108
115
  return;
116
+ const knownContext = target.browserContextId === undefined || contextId(target.browserContextId);
117
+ // Every armed frame sends its requests past the site's service workers (Network.setBypassServiceWorker), so a
118
+ // worker in the merchant's context can no longer send a card request out of the interception's reach.
119
+ if (allow.serviceWorkers && target.type === 'service_worker' && knownContext)
120
+ return;
121
+ // A shared worker's own requests pause nowhere (Fetch does not reach them), so it is watched instead: its Network
122
+ // reports are read, and a card request seen there ends the checkout before anything is approved (sharedWorkers).
123
+ if (allow.sharedWorkers && target.type === 'shared_worker' && knownContext)
124
+ return;
109
125
  if (!['page', 'iframe', 'worker', 'browser_ui'].includes(target.type)
110
126
  || (target.browserContextId !== undefined && !contextId(target.browserContextId))) {
111
127
  throw new CheckoutAttachmentError('unavailable');
@@ -203,6 +219,9 @@ function isApprovalOutcome(err) {
203
219
  function isTerminal(err) {
204
220
  if (err instanceof CheckoutApiError)
205
221
  return err.permanent;
222
+ // A hold's decision is final for the attachment: every later card request fails at once.
223
+ if (err instanceof HoldEndedError || err instanceof HoldAnsweredError)
224
+ return true;
206
225
  // Refused at create for Adyen's test platform: the page's next request is refused the same way.
207
226
  if (err instanceof AdyenTestPlatformRefusedError)
208
227
  return err.stage === 'create';
@@ -350,6 +369,16 @@ function cseContinuation(opts, url, body, replay, approved, preparation) {
350
369
  const paddleTokenex = replay.kind === undefined ? paddleTokenexTokenContinuation(url, body, replay.substitutions, replay.paddleTokenexFacts, approved) : null;
351
370
  if (paddleTokenex)
352
371
  return { body: paddleTokenex.body, shijiCvv: null, shijiCvvKeySequenceSha256: null };
372
+ // 2C2P writes one card field under its pinned key, which the generic Adyen swap refuses as no JWE, so it is
373
+ // dispatched by the Payment URL to substituteTwoC2p (twoc2p.ts), only into the one Payment the approval was made for.
374
+ // A refusal there sent the field nowhere, so the approval is retired as merchant_never_retried, as a merchant-hosted
375
+ // one is (cseContinuationBody), and the API stops serving the field; a retirement the API does not confirm is
376
+ // reported as approval_retire_unconfirmed (twoc2p.ts twoC2pRetire).
377
+ const twoc2p = replay.kind === undefined
378
+ ? twoC2pContinuation(url, body, replay.substitutions, approved, twoC2pRetire(opts.vault, replay.authorizationId, opts.onEvent))
379
+ : null;
380
+ if (twoc2p)
381
+ return { body: twoc2p.body, shijiCvv: null, shijiCvvKeySequenceSha256: null };
353
382
  return { body: cseContinuationBody(opts, body, replay, url, preparation), shijiCvv: null, shijiCvvKeySequenceSha256: null };
354
383
  }
355
384
  /**
@@ -1388,7 +1417,7 @@ function merchantAttempt(lifecycle, survives) {
1388
1417
  // known non-payment, so the prepared checkout ends failed with that reason, and a fresh attachment can
1389
1418
  // prepare again. Every other stop leaves the outcome unknown.
1390
1419
  const error = () => reason === 'amadeus_send_window_closed' || reason === 'secure_acceptance_send_window_closed'
1391
- || reason === 'wix_payments_send_window_closed'
1420
+ || reason === 'wix_payments_send_window_closed' || reason === 'hipay_send_window_closed'
1392
1421
  ? new CheckoutPreparationError(lifecycle.getState().preparationId ?? null, reason)
1393
1422
  : new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
1394
1423
  const attempt = {
@@ -1549,6 +1578,35 @@ function replayOf(result, timeoutMs) {
1549
1578
  return { mode: 'token', authorizationId, status: response.status, headers: { ...response.headers }, body: response.body, amountVerified: null,
1550
1579
  ...(result.processor_context !== undefined ? { processorContext: result.processor_context } : {}), ...execution };
1551
1580
  }
1581
+ /** A CDP Fetch url pattern (`*` any run of characters, `?` one, `\\` escapes) against a URL. */
1582
+ function urlPatternMatches(pattern, url) {
1583
+ let re = '';
1584
+ for (let i = 0; i < pattern.length; i++) {
1585
+ const c = pattern[i];
1586
+ if (c === '\\' && i + 1 < pattern.length) {
1587
+ re += pattern[++i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1588
+ continue;
1589
+ }
1590
+ re += c === '*' ? '.*' : c === '?' ? '.' : c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1591
+ }
1592
+ try {
1593
+ return new RegExp(`^${re}$`).test(url);
1594
+ }
1595
+ catch {
1596
+ return false;
1597
+ }
1598
+ }
1599
+ /**
1600
+ * Wait for the hold's decision on a request; the request going away (its attempt stopped) ends the hold as lost,
1601
+ * which rejects the decision.
1602
+ */
1603
+ function holdUntilDecided(decision, signal, lost) {
1604
+ if (signal.aborted)
1605
+ lost();
1606
+ else
1607
+ signal.addEventListener('abort', lost, { once: true });
1608
+ return decision.finally(() => signal.removeEventListener('abort', lost));
1609
+ }
1552
1610
  /**
1553
1611
  * Worldpay eProtect's registration, resumed by the business. The read carries the registration as the API
1554
1612
  * stored it, bare JSON, and the paused request is the vendor's JSONP <script>, which runs its answer and reads
@@ -1688,12 +1746,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1688
1746
  opts = safeOptions(opts);
1689
1747
  if (opts.pauseOnly && typeof opts.onPaused !== 'function')
1690
1748
  throw new Error('pauseOnly needs onPaused: the business receives the paused request there and answers it with controller.resume.');
1749
+ if (opts.pauseOnly && opts.hold)
1750
+ throw new Error('hold and pauseOnly are two different pauses: attach with one of them.');
1691
1751
  const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
1692
1752
  const setupStop = new AbortController();
1693
1753
  let attachmentReady = false;
1694
1754
  let setupFailureReported = false;
1695
1755
  const ownedSessions = new Set([pageSessionId]);
1696
1756
  const sessionTypes = new Map([[pageSessionId, 'page']]);
1757
+ // Each window this attachment adopted (adoptOpenedWindow): its session, and its target's id, which is its main frame's.
1758
+ const windowTargets = new Map();
1697
1759
  const sessionParents = new Map();
1698
1760
  // The frame in its parent session that a child target hangs from (TargetInfo.parentFrameId), so a
1699
1761
  // frame lookup can climb from a cross-origin frame into the document that frames it.
@@ -1749,6 +1811,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1749
1811
  // The page target's id, which is its main frame's id; read once the attachment is set up.
1750
1812
  let pageTargetId;
1751
1813
  const lifecycle = new CheckoutLifecycle(opts);
1814
+ // Hold mode: the first card request is held with nothing created until controller.release or cancel (hold.ts).
1815
+ const holdGate = opts.hold ? new HoldGate(typeof opts.hold === 'object' ? opts.hold : {}, opts.vault?.sandbox === true, (type, detail) => opts.onEvent?.({ type, detail })) : null;
1816
+ if (holdGate) {
1817
+ lifecycle.setHold(holdGate);
1818
+ lifecycle.abort.signal.addEventListener('abort', () => { void holdGate.cancel('checkout_cancelled'); }, { once: true });
1819
+ }
1820
+ // The hold-only request (Bolt's tokenizer, a placeholder post) held now, by its Network id, so the page giving it up ends the hold.
1821
+ let holdOnlyNetworkId = null;
1822
+ const bypassServiceWorkers = (opts.serviceWorkers ?? (holdGate ? 'bypass' : 'refuse')) === 'bypass';
1823
+ const sharedWorkerMode = opts.sharedWorkers ?? (holdGate ? 'accept' : 'refuse');
1824
+ const observeSharedWorkers = sharedWorkerMode === 'observe';
1825
+ const targetAllowance = { serviceWorkers: bypassServiceWorkers, sharedWorkers: sharedWorkerMode !== 'refuse' };
1826
+ // Shared workers being watched: their target ids (claimed before the attach answers) and their sessions.
1827
+ const watchedWorkerTargets = new Set();
1828
+ const watchedWorkerSessions = new Set();
1829
+ // The watches still starting, by target id: a later report of the worker waits for its watch, and so does the attachment.
1830
+ const workerWatches = new Map();
1752
1831
  const stripeCheckout = new StripeCheckoutGate(opts);
1753
1832
  const mercadoCheckout = new MercadoCheckoutGate();
1754
1833
  // CyberSource Flex card field iframes and their capture contexts, recorded from the CDP
@@ -1801,6 +1880,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1801
1880
  preparationFrameId = tree.frameTree.frame.id;
1802
1881
  return tree.frameTree.frame.url;
1803
1882
  };
1883
+ // The URL the page's main frame last committed: its target's URL when the attachment is set up, then kept from
1884
+ // Page.frameNavigated and Page.navigatedWithinDocument, and from the page target's own reports (notePageTarget),
1885
+ // which name a document the page commits before this attachment has enabled its Page events. Chromium leaves
1886
+ // Page.getFrameTree unanswered on the page's session while that session's own top-level navigation is paused (a
1887
+ // hosted page entry the page sends this window to, by script or through the merchant's redirect), so
1888
+ // readDocumentUrl would wait out its bound and the create would name no checkout page. For that one paused request
1889
+ // the page is still the document it last committed.
1890
+ let committedTopUrl;
1891
+ /** A TargetInfo of the page's own target names the URL the page last committed: the target list, and each report. */
1892
+ const notePageTarget = (info) => {
1893
+ if (pageTargetId !== undefined && info?.targetId === pageTargetId && info?.type === 'page' && typeof info.url === 'string' && info.url) {
1894
+ committedTopUrl = info.url.split('#')[0];
1895
+ }
1896
+ };
1804
1897
  // prepare({ tokenizeBeforePay: true }) sends the card Microform whose field documents are committed
1805
1898
  // now. A field's commit can come before this attachment enabled its session, so no event reports
1806
1899
  // it; each iframe session's own frame tree, and the page's, is read once first (as rereadMicroforms
@@ -1835,7 +1928,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1835
1928
  const frame = find(tree?.frameTree, 0);
1836
1929
  if (!frame)
1837
1930
  return null;
1838
- return { navigation: frame.parentId || sessionId !== pageSessionId ? 'frame' : 'top', url: typeof frame.url === 'string' ? frame.url : undefined };
1931
+ return { navigation: frame.parentId || (sessionId !== pageSessionId && !(sessionId && windowTargets.has(sessionId))) ? 'frame' : 'top',
1932
+ url: typeof frame.url === 'string' ? frame.url : undefined };
1839
1933
  };
1840
1934
  /**
1841
1935
  * Which frame a paused document request navigates, as documentFrame reads it, without the frame tree where
@@ -1851,6 +1945,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1851
1945
  return 'frame';
1852
1946
  if (sessionId === pageSessionId && pageTargetId !== undefined && frameId === pageTargetId)
1853
1947
  return 'top';
1948
+ // An adopted window's main frame is that window's own top-level document, and its frame tree is as unanswered.
1949
+ if (sessionId && windowTargets.get(sessionId) === frameId)
1950
+ return 'top';
1854
1951
  return (await documentFrame(sessionId, frameId))?.navigation ?? null;
1855
1952
  };
1856
1953
  /**
@@ -2044,6 +2141,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2044
2141
  let lastSubmitted = null;
2045
2142
  // The approved Datatrans answers, per frameId: the repeat rule (datatrans.ts).
2046
2143
  const datatransRepeats = new DatatransRepeats();
2144
+ // The approved TokenEx iframe answers, per frameId (the card-number frame is an out-of-process iframe whose own
2145
+ // session pauses its post; its frameId names it): the repeat rule (tokenex-iframe.ts).
2146
+ const tokenexIframeRepeats = new TokenexIframeRepeats();
2047
2147
  // The card product each Worldline MyCheckout hosted checkout chose (worldline-mycheckout.ts).
2048
2148
  const worldlineProducts = new WorldlineMyCheckoutProducts();
2049
2149
  // The Shiji STS getCvvId companion of the getTokenId this attachment last continued (shiji-sts.ts).
@@ -2088,11 +2188,17 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2088
2188
  // EBANX's 3-D Secure calls and hosted confirm, which send the typed card again
2089
2189
  // after its token: the cardholder's device sends one the approved token request
2090
2190
  // covers, and any other is refused (see ebanx.ts).
2191
+ // ComGate's card hosts are armed only for an org the API served ComGate to, so the
2192
+ // gate can fail every card post there while the gateway link pauses (see comgate.ts).
2091
2193
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
2092
2194
  ...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns,
2093
2195
  ...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : []), ...juspayCheckout.urlPatterns(), ...cardEndpointPatterns(),
2094
2196
  ...(payrails ? PAYRAILS_GUARD_PATTERNS : []), ...PAYPAL_HOSTED_FIELDS_PATTERNS, ...PAYPAL_CARD_FORM_PATTERNS,
2095
- ...CYBERSOURCE_SECURE_ACCEPTANCE_PATTERNS, ...(recognizesPaddleVgs(opts.vault) ? PADDLE_VGS_PAY_PATTERNS : []), ...EBANX_RESEND_PATTERNS])];
2197
+ ...CYBERSOURCE_SECURE_ACCEPTANCE_PATTERNS, ...(recognizesPaddleVgs(opts.vault) ? PADDLE_VGS_PAY_PATTERNS : []), ...EBANX_RESEND_PATTERNS,
2198
+ ...(servesComgate(opts.vault) ? COMGATE_CARD_PATTERNS : [])])];
2199
+ // The hold-only rules' patterns (Bolt's tokenizer; every request a placeholder card can ride, by resource type).
2200
+ const fetchPatterns = [...urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
2201
+ ...(holdGate ? holdGate.patterns().map((p) => ({ ...p, requestStage: 'Request' })) : [])];
2096
2202
  const arm = async (sessionId, resume = false, worker = false) => {
2097
2203
  const key = sessionId ?? '__root__';
2098
2204
  if (armed.has(key))
@@ -2109,6 +2215,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2109
2215
  };
2110
2216
  await cdp.send('Network.enable', {}, sessionId);
2111
2217
  assertActive();
2218
+ // Requests skip the site's service workers, so every one stays in this frame's Fetch interception. Required:
2219
+ // a frame that cannot bypass them is not armed.
2220
+ if (bypassServiceWorkers && !worker) {
2221
+ await cdp.send('Network.setBypassServiceWorker', { bypass: true }, sessionId);
2222
+ assertActive();
2223
+ }
2112
2224
  // Worker Fetch does not exist in Chrome. Its requests pause on the owning
2113
2225
  // frame, but its abort/finish events arrive on the worker Network session.
2114
2226
  // Keep that session attached and paused until Network is listening, then
@@ -2116,9 +2228,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2116
2228
  if (!worker) {
2117
2229
  await cdp.send('Page.enable', {}, sessionId).catch(() => { });
2118
2230
  assertActive();
2119
- await cdp.send('Fetch.enable', {
2120
- patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
2121
- }, sessionId);
2231
+ await cdp.send('Fetch.enable', { patterns: fetchPatterns }, sessionId);
2122
2232
  assertActive();
2123
2233
  }
2124
2234
  // Descend into this target's own children (iframes inside iframes).
@@ -2147,6 +2257,56 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2147
2257
  arming.delete(key);
2148
2258
  }
2149
2259
  };
2260
+ /**
2261
+ * Watch a shared worker of the merchant's context (sharedWorkers 'observe'): attach to it from the browser root,
2262
+ * read its Network reports, and release it. Its requests cannot be paused, so a card request seen there ends the
2263
+ * checkout (see the Network.requestWillBeSent handler). A worker that cannot be watched ends it too.
2264
+ */
2265
+ const watchSharedWorker = async (target) => {
2266
+ if (sharedWorkerMode === 'refuse' || typeof target?.targetId !== 'string')
2267
+ return;
2268
+ // Chrome reports every existing target (Target.targetCreated) before it answers setDiscoverTargets, so that
2269
+ // report starts the watch: the attachment's own pass over the targets waits for it rather than passing it by.
2270
+ const starting = workerWatches.get(target.targetId);
2271
+ if (starting)
2272
+ return starting;
2273
+ if (watchedWorkerTargets.has(target.targetId))
2274
+ return;
2275
+ if (contextId(target.browserContextId) && target.browserContextId !== merchantContext)
2276
+ return;
2277
+ watchedWorkerTargets.add(target.targetId);
2278
+ if (!observeSharedWorkers) {
2279
+ opts.onEvent?.({ type: 'shared_worker_unwatched', detail: { scheme: String(target.url ?? '').split(':')[0] || null } });
2280
+ return;
2281
+ }
2282
+ const watch = (async () => {
2283
+ const { sessionId: workerSession } = await cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: true });
2284
+ if (typeof workerSession !== 'string' || !workerSession)
2285
+ throw new CheckoutAttachmentError('unavailable');
2286
+ watchedWorkerSessions.add(workerSession);
2287
+ rejectedSessions.add(workerSession);
2288
+ await cdp.send('Network.enable', {}, workerSession);
2289
+ await cdp.send('Runtime.runIfWaitingForDebugger', {}, workerSession).catch(() => { });
2290
+ opts.onEvent?.({ type: 'shared_worker_watched', detail: { scheme: String(target.url ?? '').split(':')[0] || null } });
2291
+ })().catch((error) => failInterception(error instanceof CheckoutAttachmentError ? error : new CheckoutAttachmentError('unavailable')))
2292
+ .finally(() => workerWatches.delete(target.targetId));
2293
+ workerWatches.set(target.targetId, watch);
2294
+ return watch;
2295
+ };
2296
+ /** A card request a watched shared worker sent: it could not be held, so nothing may be approved from here on. */
2297
+ const sharedWorkerCardRequest = (request) => {
2298
+ const url = typeof request?.url === 'string' ? request.url : '';
2299
+ const method = typeof request?.method === 'string' ? request.method : 'GET';
2300
+ if (!url)
2301
+ return false;
2302
+ if (typeof opts.vault?.isCardRequest === 'function' && opts.vault.isCardRequest(url, method))
2303
+ return true;
2304
+ if (!holdGate)
2305
+ return false;
2306
+ const body = typeof request.postData === 'string' ? request.postData : request.hasPostData ? null : '';
2307
+ // A body too large to ride the report is read as a card request only by its address (Bolt's tokenizer).
2308
+ return !!matchHoldOnlyRule({ url, method, body: body ?? '' }, holdGate.rules) || (body === null && !!matchHoldOnlyRule({ url, method, body: '' }, holdGate.rules.filter((r) => !r.body)));
2309
+ };
2150
2310
  const failInterception = (error) => {
2151
2311
  if (setupFailureReported)
2152
2312
  return;
@@ -2164,6 +2324,55 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2164
2324
  lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
2165
2325
  opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
2166
2326
  };
2327
+ /**
2328
+ * A window the merchant's page opened (window.open, or a form or link aimed at a new window), or one such a window
2329
+ * opened in turn, in the merchant's own browser context: it belongs to this checkout, so it is attached and armed
2330
+ * as the page is, and its requests reach the same gates. Chromium reports the window (Target.targetCreated, with its
2331
+ * openerId) as it starts, and the page session's auto-attach never covers a new page target. The attach races the
2332
+ * window's first request: a window that opens on the merchant's page and is sent on to a hosted page by script or
2333
+ * by the merchant's redirect is armed before that entry; a window opened straight onto a hosted page can load it
2334
+ * first, as every window did before. A window reported while this attachment still arms the page waits in
2335
+ * pendingWindows and is adopted once the page is armed, inside the setup deadline and before the attachment reports
2336
+ * ready, its opener judged then, so a window that window opened is adopted after it. A window that is gone before it
2337
+ * could be attached was never armed, as before; one attached but not armed fails the attachment, as a frame does.
2338
+ */
2339
+ const openedWindows = new Set();
2340
+ const pendingWindows = [];
2341
+ const adoptOpenedWindow = async (info, duringSetup = false) => {
2342
+ if (!info || info.type !== 'page' || typeof info.targetId !== 'string' || !info.targetId || info.targetId === pageTargetId
2343
+ || openedWindows.has(info.targetId) || info.browserContextId !== merchantContext)
2344
+ return;
2345
+ if (setupStop.signal.aborted)
2346
+ return;
2347
+ if (!attachmentReady && !duringSetup) {
2348
+ pendingWindows.push(info);
2349
+ return;
2350
+ }
2351
+ if (typeof info.openerId !== 'string' || !(info.openerId === pageTargetId || openedWindows.has(info.openerId)))
2352
+ return;
2353
+ openedWindows.add(info.targetId);
2354
+ let child;
2355
+ try {
2356
+ child = (await cdp.send('Target.attachToTarget', { targetId: info.targetId, flatten: true }))?.sessionId;
2357
+ }
2358
+ catch {
2359
+ return;
2360
+ }
2361
+ try {
2362
+ if (typeof child !== 'string' || !child || ownedSessions.has(child) || detachedSessions.has(child) || rejectedSessions.has(child)) {
2363
+ throw new CheckoutAttachmentError('unavailable');
2364
+ }
2365
+ ownedSessions.add(child);
2366
+ sessionTypes.set(child, 'page');
2367
+ sessionParents.set(child, pageSessionId);
2368
+ windowTargets.set(child, info.targetId);
2369
+ await arm(child);
2370
+ opts.onEvent?.({ type: 'window_armed', detail: {} });
2371
+ }
2372
+ catch (error) {
2373
+ failInterception(error);
2374
+ }
2375
+ };
2167
2376
  cdp.on(async (method, params, sessionId) => {
2168
2377
  // A frame that committed a new document drops the companion binding made for the getTokenId its
2169
2378
  // old document sent, with the device's CVV ciphertext: the document that replaced it never
@@ -2174,14 +2383,28 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2174
2383
  shijiCompanions.releasePage((page) => page === replaced);
2175
2384
  }
2176
2385
  if ((method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') && targetContextKnown) {
2386
+ // The page's own target reports each document it commits from discovery on, Page events or not.
2387
+ notePageTarget(params?.targetInfo);
2177
2388
  try {
2178
- assertContextTarget(params?.targetInfo, merchantContext);
2389
+ assertContextTarget(params?.targetInfo, merchantContext, targetAllowance);
2390
+ if (params?.targetInfo?.type === 'shared_worker')
2391
+ void watchSharedWorker(params.targetInfo);
2179
2392
  }
2180
2393
  catch (error) {
2181
2394
  failInterception(error);
2182
2395
  }
2396
+ if (method === 'Target.targetCreated')
2397
+ await adoptOpenedWindow(params?.targetInfo);
2183
2398
  return;
2184
2399
  }
2400
+ // The page's main frame committed a document, or moved within it: the URL a held top-level navigation leaves.
2401
+ if (sessionId === pageSessionId && method === 'Page.frameNavigated' && !params.frame?.parentId && typeof params.frame?.url === 'string') {
2402
+ committedTopUrl = params.frame.url;
2403
+ }
2404
+ if (sessionId === pageSessionId && method === 'Page.navigatedWithinDocument' && pageTargetId !== undefined
2405
+ && params?.frameId === pageTargetId && typeof params?.url === 'string') {
2406
+ committedTopUrl = params.url.split('#')[0];
2407
+ }
2185
2408
  if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
2186
2409
  preparationGate.invalidate('merchant_document_changed');
2187
2410
  mercadoCheckout.invalidate();
@@ -2203,6 +2426,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2203
2426
  || (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
2204
2427
  detachedSessions.add(pageSessionId);
2205
2428
  setupStop.abort(new CheckoutAttachmentError('closed'));
2429
+ holdGate?.close();
2206
2430
  preparationGate.invalidate('merchant_document_closed');
2207
2431
  stripeCheckout.invalidate();
2208
2432
  mercadoCheckout.invalidate();
@@ -2218,6 +2442,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2218
2442
  if (handoffNetwork)
2219
2443
  merchantHandoff.failed();
2220
2444
  }
2445
+ // A watched shared worker sent a card request: it went out unpaused, so nothing is approved from here on.
2446
+ if (method === 'Network.requestWillBeSent' && sessionId && watchedWorkerSessions.has(sessionId) && sharedWorkerCardRequest(params?.request)) {
2447
+ // Origin and path as redactUrl reads them, so a credential a processor writes into the path never shows.
2448
+ let endpoint = '';
2449
+ try {
2450
+ new URL(params.request.url);
2451
+ endpoint = redactUrl(params.request.url);
2452
+ }
2453
+ catch { /* unreadable */ }
2454
+ opts.onEvent?.({ type: 'card_request_unwatched', detail: { endpoint, from: 'shared_worker' } });
2455
+ void holdGate?.cancel('card_request_unwatched');
2456
+ failInterception(new CheckoutAttachmentError('unavailable'));
2457
+ return;
2458
+ }
2221
2459
  // The transport is browser-scoped. A second tab's request or child target
2222
2460
  // must never be authorized with this page's merchant, amount or preparation.
2223
2461
  if (!sessionId || !ownedSessions.has(sessionId))
@@ -2325,6 +2563,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2325
2563
  return;
2326
2564
  }
2327
2565
  if (method === 'Network.loadingFailed') {
2566
+ // The held request given up by the page (its own timeout) or failed by the hold itself: nothing was sent.
2567
+ if (holdGate && holdGate.state === 'held' && ((holdOnlyNetworkId && params.requestId === holdOnlyNetworkId)
2568
+ || (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId)))
2569
+ holdGate.lost();
2328
2570
  merchantTotal.log.failed(requestKey(sessionId, params.requestId));
2329
2571
  const recordKey = cardEndpointRecord.keyFor(requestKey(sessionId, params.requestId), params.requestId);
2330
2572
  if (recordKey)
@@ -2414,6 +2656,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2414
2656
  }
2415
2657
  if (method === 'Target.attachedToTarget') {
2416
2658
  const child = params.sessionId;
2659
+ if (params?.targetInfo?.type === 'shared_worker' && watchedWorkerTargets.has(params.targetInfo.targetId)) {
2660
+ if (typeof child === 'string' && child) {
2661
+ watchedWorkerSessions.add(child);
2662
+ rejectedSessions.add(child);
2663
+ }
2664
+ return;
2665
+ }
2417
2666
  if (rejectedSessions.has(child))
2418
2667
  return;
2419
2668
  // A browser-root transport can report another context through an owned
@@ -2437,6 +2686,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2437
2686
  }
2438
2687
  if (setupStop.signal.aborted)
2439
2688
  return;
2689
+ // A service worker the page's frames bypass: released from its debugger wait and left alone, never owned.
2690
+ if (bypassServiceWorkers && params.targetInfo?.type === 'service_worker' && typeof child === 'string' && child
2691
+ && child !== pageSessionId && !ownedSessions.has(child)) {
2692
+ rejectedSessions.add(child);
2693
+ await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
2694
+ return;
2695
+ }
2440
2696
  // CDP may omit the context ID for ordinary children; keep those attached.
2441
2697
  try {
2442
2698
  if (typeof child !== 'string' || !child || !['page', 'iframe', 'worker'].includes(params.targetInfo?.type)) {
@@ -2477,9 +2733,50 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2477
2733
  const { requestId, request, resourceType, networkId, frameId } = params;
2478
2734
  // When this request paused: the merchant's total is read as of this moment.
2479
2735
  const pausedAt = Date.now();
2736
+ // The page's own top-level navigation, paused: its frame tree cannot be read until this request is decided, so its
2737
+ // document is the one it last committed (committedTopUrl). Every other request reads the frame tree as before.
2738
+ const readPausedDocument = resourceType === 'Document' && sessionId === pageSessionId && pageTargetId !== undefined
2739
+ && frameId === pageTargetId && committedTopUrl !== undefined ? async () => committedTopUrl : readDocumentUrl;
2480
2740
  // A paused document request has not committed either: it only starts the context's check.
2481
2741
  if (resourceType === 'Document')
2482
2742
  microforms.warm(withFragment(request));
2743
+ // Hold mode: a card request no recogniser pays (Bolt's sealed tokenizer, a body carrying the placeholder card) is
2744
+ // held too, and can only be cancelled (or, in sandbox, a placeholder post sent on unchanged). Never recorded.
2745
+ if (holdGate) {
2746
+ // A request any of the SDK's own rules arms (a recogniser, a reviewed card endpoint, a guard, Stripe Checkout,
2747
+ // Mercado, a merchant profile...) goes down the SDK's own path; only what none of them covers is hold-only.
2748
+ const recognised = (typeof opts.vault?.isCardRequest === 'function' && opts.vault.isCardRequest(request.url, request.method))
2749
+ || urlPatterns.some((pattern) => urlPatternMatches(pattern, request.url));
2750
+ const holdRule = recognised ? null : matchHoldOnlyRule({ url: request.url, method: request.method, body: pausedBody(request) }, holdGate.rules);
2751
+ if (holdRule) {
2752
+ const failHeld = () => cdp.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId).catch(() => { });
2753
+ if (!attachmentReady || setupStop.signal.aborted || holdGate.refuse(holdRule.psp, request.url)) {
2754
+ await failHeld();
2755
+ return;
2756
+ }
2757
+ holdOnlyNetworkId = typeof networkId === 'string' && networkId ? networkId : null;
2758
+ let decision;
2759
+ try {
2760
+ decision = await holdGate.hold({ psp: holdRule.psp, url: request.url, kind: holdRule.kind, replayable: false, rule: holdRule });
2761
+ }
2762
+ catch {
2763
+ holdOnlyNetworkId = null;
2764
+ await failHeld();
2765
+ return;
2766
+ }
2767
+ finally { /* the decision is in */ }
2768
+ holdOnlyNetworkId = null;
2769
+ if (decision.kind === 'continue') {
2770
+ // Sandbox only (HoldGate.release): the post carries the documented placeholder test number and nothing else.
2771
+ const ok = await cdp.send('Fetch.continueRequest', { requestId }, sessionId).then(() => true, () => false);
2772
+ decision.settle(ok ? { outcome: 'continued' } : { outcome: 'failed', detail: 'continue_failed' });
2773
+ return;
2774
+ }
2775
+ await failHeld();
2776
+ decision.settle({ outcome: 'failed', detail: 'not_replayable' });
2777
+ return;
2778
+ }
2779
+ }
2483
2780
  // One pre-claim gate registry decides and performs the mechanics through
2484
2781
  // these effects; the state accessors read the same closure variables the
2485
2782
  // inline gates did. It resolves synchronously for an ordinary card request,
@@ -2522,6 +2819,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2522
2819
  cybersourceFlex: { judge: (flexRequest) => microforms.judge(flexRequest, frameId, () => rereadMicroforms(sessionId, frameId)) },
2523
2820
  juspay: juspayCheckout,
2524
2821
  datatrans: datatransRepeats,
2822
+ tokenexIframe: tokenexIframeRepeats,
2525
2823
  ebanx: ebanxFollowUps,
2526
2824
  frame: typeof frameId === 'string' && frameId ? frameId : undefined,
2527
2825
  payrails,
@@ -2593,7 +2891,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2593
2891
  if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()
2594
2892
  && typeof networkId === 'string' && networkId) {
2595
2893
  const retryBody = pausedBody(request);
2596
- const retry = retryBody === null ? null : requestIdentity(opts, request.url, request.method, retryBody, documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts), flexPause?.shape, request.headers);
2894
+ const retry = retryBody === null ? null : requestIdentity(opts, request.url, request.method, retryBody, documentKey(await documentUrlOf(readPausedDocument, lifecycle.abort.signal)), await pageAmountOf(opts), flexPause?.shape, request.headers);
2597
2895
  // A prepared approval is answered only in the card frame it was bound in: the same request from
2598
2896
  // another frame (a replacement for a removed one, or a second card frame) is refused below.
2599
2897
  if (retry && activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)
@@ -2670,6 +2968,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2670
2968
  await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
2671
2969
  return;
2672
2970
  }
2971
+ // Hold mode: one request held at a time, and none after a decision.
2972
+ if (holdGate && holdGate.refuse(recognizerNameOf(opts, request.url, request.method), request.url)) {
2973
+ if (preparation)
2974
+ preparationGate.retireUnboundClaim();
2975
+ await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
2976
+ return;
2977
+ }
2673
2978
  // Reserve BEFORE the first await (the authorize call below yields), so two
2674
2979
  // requests can never both clear the check above and raise two prompts for
2675
2980
  // one checkout.
@@ -2686,6 +2991,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2686
2991
  let ebanxRemembered = false;
2687
2992
  // The business's pause, so resume resolves once the page has its answer.
2688
2993
  let businessPause = null;
2994
+ // Hold mode: the person's release, settled with what came of it.
2995
+ let holdRelease = null;
2689
2996
  // A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
2690
2997
  let disarmSendWindow = null;
2691
2998
  try {
@@ -2738,7 +3045,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2738
3045
  await preparationGate.assertDocument();
2739
3046
  // One bounded read of the document serves both the merchant origin and
2740
3047
  // the identity a retry has to repeat (see sameCheckoutRequest).
2741
- const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
3048
+ const documentUrl = await documentUrlOf(readPausedDocument, attempt.signal);
2742
3049
  const pageOrigin = pageOriginFrom(documentUrl);
2743
3050
  // The agent's amount is a card endpoint's only authority: no page total is read.
2744
3051
  const pageAmount = cardEndpoint ? undefined : await pageAmountOf(opts);
@@ -2776,15 +3083,45 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2776
3083
  // The merchant's own record of the order, as of the pause, for a profile that reads one.
2777
3084
  const cardRecord = cardEndpoint ? await cardEndpointFacts(cardEndpointRecord, cardEndpoint, request.url, pausedAt, attempt.signal) : null;
2778
3085
  attempt.assertLive();
3086
+ // Hold mode: nothing is created until the person's total releases the request (hold.ts).
3087
+ if (holdGate) {
3088
+ if (holdGate.state !== 'watching') {
3089
+ // Another card request was held while this one was being read: this one is refused alone.
3090
+ holdGate.refuse(recognizerNameOf(opts, request.url, request.method), request.url);
3091
+ throw new HoldRefusedError();
3092
+ }
3093
+ const decision = await holdUntilDecided(holdGate.hold({ psp: recognizerNameOf(opts, request.url, request.method), url: request.url,
3094
+ kind: 'card_request', replayable: true }), attempt.signal, () => holdGate.lost());
3095
+ if (decision.kind !== 'release') {
3096
+ decision.settle({ outcome: 'failed', detail: 'not_releasable' });
3097
+ throw new HoldEndedError('not_releasable');
3098
+ }
3099
+ holdRelease = decision;
3100
+ attempt.assertLive();
3101
+ }
2779
3102
  // Decision 176: pause only. The business creates the authorization from the paused request and answers
2780
3103
  // through controller.resume; the replay it hands back goes down the same delivery path as a create's.
2781
3104
  const pause = opts.pauseOnly ? pauseForBusiness(opts, lifecycle, attempt.signal, { url: request.url, method: request.method, headers: request.headers ?? {}, body }, pageOrigin) : null;
2782
3105
  businessPause = pause;
2783
- const replay = pause ? eprotectResumedReplay(await pause.replay, request) : await opts.vault.authorize({
3106
+ // A sandbox hold never sends the request on: the approval is created at the released total and read, the
3107
+ // page's request fails the moment it exists, and the attempt ends with nothing charged.
3108
+ const holdSandbox = !!holdRelease && opts.vault.sandbox === true;
3109
+ const holdMerchantStop = holdSandbox ? new AbortController() : null;
3110
+ // The approval outlives the page's request, but not the checkout: cancelling the attachment while the approval is
3111
+ // open retires it at Agentcard (the client cancels an authorization whose merchant signal aborted).
3112
+ if (holdMerchantStop) {
3113
+ const retire = () => holdMerchantStop.abort();
3114
+ if (lifecycle.abort.signal.aborted)
3115
+ retire();
3116
+ else
3117
+ lifecycle.abort.signal.addEventListener('abort', retire, { once: true });
3118
+ }
3119
+ let holdRequestFailed = false;
3120
+ const authorization = pause ? null : opts.vault.authorize({
2784
3121
  user: opts.user,
2785
3122
  merchant: opts.merchant,
2786
- amount: opts.amount,
2787
- currency: opts.currency,
3123
+ amount: holdRelease ? holdRelease.amount : opts.amount,
3124
+ currency: holdRelease ? holdRelease.currency : opts.currency,
2788
3125
  cardId: preparation?.cardId ?? opts.cardId,
2789
3126
  executionMode: opts.executionMode,
2790
3127
  grantId: opts.grantId,
@@ -2796,11 +3133,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2796
3133
  preparation,
2797
3134
  timeoutMs: opts.timeoutMs,
2798
3135
  signal: lifecycle.abort.signal,
2799
- merchantSignal: attempt.signal,
3136
+ // A sandbox hold's approval outlives the page's request on purpose: that request is failed once it exists.
3137
+ merchantSignal: holdMerchantStop?.signal ?? attempt.signal,
2800
3138
  // What the merchant's own checkout said its total was, as this browser recorded it
2801
3139
  // (read only for a reviewed profile priced by it): the page is the card request's.
2802
3140
  merchantTotal: merchantTotal.capture(pausedAt, cardPage),
2803
- onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
3141
+ onAuthorizationCreated: (id) => {
3142
+ lifecycle.approvalCreated(id);
3143
+ if (holdSandbox && !holdRequestFailed) {
3144
+ holdRequestFailed = true;
3145
+ // The page's request is let go of before it is failed, so its failure report does not end this attempt:
3146
+ // the approval it raised is read to its end, and nothing is ever delivered to the page.
3147
+ if (activeRequest?.attempt === attempt)
3148
+ activeRequest = null;
3149
+ opts.onEvent?.({ type: 'card_request_not_sent', detail: { authorizationId: id } });
3150
+ void cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
3151
+ }
3152
+ },
2804
3153
  onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
2805
3154
  lifecycle.approvalUrl(url);
2806
3155
  return opts.onApprovalUrl?.(url);
@@ -2812,6 +3161,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2812
3161
  ...(flexPause ? flexPause.sidecar : {}),
2813
3162
  ...(payrailsSidecar ? { payrails: payrailsSidecar } : {}) },
2814
3163
  });
3164
+ if (holdSandbox && authorization) {
3165
+ const outcome = await authorization.then((r) => ({ outcome: 'approved_not_charged', authorizationId: r.authorizationId }), (err) => ({ outcome: err instanceof ApprovalDeclinedError ? 'declined' : err instanceof ApprovalTimeoutError ? 'expired' : 'failed',
3166
+ authorizationId: lifecycle.getState().authorizationId, detail: failureSummary(err) }));
3167
+ if (!holdRequestFailed)
3168
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
3169
+ holdRelease.settle(outcome);
3170
+ throw new HoldAnsweredError(outcome);
3171
+ }
3172
+ const replay = pause ? eprotectResumedReplay(await pause.replay, request) : await authorization;
2815
3173
  attempt.assertLive();
2816
3174
  approvedId = replay.authorizationId;
2817
3175
  if (lifecycle.isCancelled())
@@ -2979,6 +3337,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2979
3337
  }, deliverOn);
2980
3338
  // The frame that received it: the paused request's, or its retry's once rebound.
2981
3339
  datatransRepeats.remember(activeRequest?.frameId || undefined, deliverRequest.url, deliverBody, replay);
3340
+ tokenexIframeRepeats.remember(activeRequest?.frameId || undefined, deliverRequest.url, deliverBody, replay);
2982
3341
  attempt.assertLive();
2983
3342
  // An ACI card-number request answered: its checkout's held CVC and expiry requests get the
2984
3343
  // same answer, and its /payment submission may continue (see AciGate).
@@ -2992,8 +3351,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2992
3351
  if (handedOver)
2993
3352
  lifecycle.handedOff(replay);
2994
3353
  businessPause?.delivered.resolve();
3354
+ holdRelease?.settle({ outcome: 'authorized', authorizationId: replay.authorizationId });
2995
3355
  }
2996
3356
  catch (err) {
3357
+ if (err instanceof HoldRefusedError) {
3358
+ // Refused alone: the request another rule holds, and the checkout's state, are left as they are.
3359
+ if (activeRequest?.attempt === attempt)
3360
+ activeRequest = null;
3361
+ await refusePaused(attempt.target?.requestId ?? requestId, attempt.target?.sessionId ?? sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, false, lifecycle));
3362
+ return;
3363
+ }
3364
+ holdRelease?.settle(err instanceof HoldAnsweredError ? err.outcome : { outcome: err instanceof ApprovalDeclinedError ? 'declined' : err instanceof ApprovalTimeoutError ? 'expired' : 'failed',
3365
+ authorizationId: approvedId ?? lifecycle.getState().authorizationId, detail: failureSummary(err) });
2997
3366
  // Ignore the failure event caused by our own safe decline/error abort.
2998
3367
  const dead = attempt.target ?? { requestId, sessionId };
2999
3368
  // An EBANX token answer this handoff kept follow-ups for and did not finish handing over: nothing after it is
@@ -3067,6 +3436,11 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
3067
3436
  targetContextKnown = true;
3068
3437
  // A page target's id is its main frame's id: a navigation of that frame is the page's own (pausedNavigation).
3069
3438
  pageTargetId = typeof targetInfo.targetId === 'string' && targetInfo.targetId ? targetInfo.targetId : undefined;
3439
+ // The page's URL as its target reports it: the document a held top-level navigation leaves until the page commits
3440
+ // another one (the Page.frameNavigated branch above, and notePageTarget for a commit before the Page events are on).
3441
+ // No frame tree read: a navigation the page starts while this attachment arms would hold it.
3442
+ if (committedTopUrl === undefined && typeof targetInfo.url === 'string' && targetInfo.url)
3443
+ committedTopUrl = targetInfo.url.split('#')[0];
3070
3444
  await cdp.send('Target.setDiscoverTargets', { discover: true });
3071
3445
  assertActive();
3072
3446
  const { targetInfos } = await cdp.send('Target.getTargets');
@@ -3074,24 +3448,43 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
3074
3448
  if (!Array.isArray(targetInfos))
3075
3449
  throw new CheckoutAttachmentError('unavailable');
3076
3450
  for (const target of targetInfos)
3077
- assertContextTarget(target, merchantContext);
3451
+ assertContextTarget(target, merchantContext, targetAllowance);
3452
+ // The page may have committed another document since its TargetInfo was read; the list names the one it holds.
3453
+ for (const target of targetInfos)
3454
+ notePageTarget(target);
3455
+ for (const target of targetInfos)
3456
+ if (target?.type === 'shared_worker')
3457
+ await watchSharedWorker(target);
3078
3458
  await arm(pageSessionId);
3079
3459
  // Existing OOPIFs report attachment before the parent's acknowledgement,
3080
3460
  // but their Fetch setup finishes later. Do not invite the caller to press
3081
- // Pay while one of those already-running frames is still unprotected.
3082
- while (arming.size)
3083
- await Promise.all(arming.values());
3461
+ // Pay while one of those already-running frames is still unprotected, or
3462
+ // while a shared worker reported during setup is not yet watched.
3463
+ // A window the page opened while this attachment armed it is attached and armed here too, in the order Chromium
3464
+ // reported it (adoptOpenedWindow), and the frames and workers such a window brings are waited for as the page's
3465
+ // own are, all under the setup deadline. One that cannot be armed fails the attachment.
3466
+ while (arming.size || workerWatches.size || pendingWindows.length) {
3467
+ if (arming.size || workerWatches.size)
3468
+ await Promise.all([...arming.values(), ...workerWatches.values()]);
3469
+ else
3470
+ await adoptOpenedWindow(pendingWindows.shift(), true);
3471
+ assertActive();
3472
+ }
3084
3473
  assertActive();
3085
3474
  }, setupTimeoutMs, setupStop.signal);
3086
3475
  if (setupStop.signal.aborted)
3087
3476
  throw attachmentFailure(setupStop.signal.reason);
3088
3477
  attachmentReady = true;
3089
- opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
3478
+ // A window reported after that drain, before this line, is adopted the way a later window is.
3479
+ for (const info of pendingWindows.splice(0))
3480
+ void adoptOpenedWindow(info);
3481
+ opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns, ...(holdGate ? { hold: true, holdRules: holdGate.rules.map((r) => r.psp) } : {}), ...(bypassServiceWorkers ? { serviceWorkers: 'bypass' } : {}) } });
3090
3482
  }
3091
3483
  catch (error) {
3092
3484
  const failure = attachmentFailure(error);
3093
3485
  setupStop.abort(failure);
3094
3486
  terminal = failure;
3487
+ holdGate?.close();
3095
3488
  if (!setupFailureReported) {
3096
3489
  setupFailureReported = true;
3097
3490
  lifecycle.cancel();
@@ -3116,6 +3509,8 @@ export async function attachToPlaywright(page, opts) {
3116
3509
  opts = safeOptions(opts);
3117
3510
  if (opts.pauseOnly)
3118
3511
  throw new Error('pauseOnly is a raw CDP mode (attachToCdp); attachToPlaywright creates the authorization itself.');
3512
+ if (opts.hold)
3513
+ throw new Error('hold is a raw CDP mode (attachToCdp): it needs the frames\' own interception.');
3119
3514
  const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
3120
3515
  const setupStop = new AbortController();
3121
3516
  let attachmentReady = false;
@@ -3422,6 +3817,8 @@ export async function attachToPlaywright(page, opts) {
3422
3817
  let lastSubmitted = null;
3423
3818
  // The approved Datatrans answers, per Frame: the repeat rule (datatrans.ts).
3424
3819
  const datatransRepeats = new DatatransRepeats();
3820
+ // The approved TokenEx iframe answers, per Frame: the repeat rule (tokenex-iframe.ts).
3821
+ const tokenexIframeRepeats = new TokenexIframeRepeats();
3425
3822
  // The card product each Worldline MyCheckout hosted checkout chose (worldline-mycheckout.ts).
3426
3823
  const worldlineProducts = new WorldlineMyCheckoutProducts();
3427
3824
  const frameOf = (request) => { try {
@@ -3442,8 +3839,10 @@ export async function attachToPlaywright(page, opts) {
3442
3839
  // The matcher sees only the URL. Worldpay eProtect's registration is a GET, so its family and
3443
3840
  // the vendor's give-up reports are routed by the eProtect gate (never its scripts).
3444
3841
  // Gravy's session fields are a PUT, so a URL a PUT recognizer claims is routed too; the
3445
- // pre-claim gates then pause only the PUT and continue the URL's other methods.
3842
+ // pre-claim gates then pause only the PUT and continue the URL's other methods. A hosted page
3843
+ // entered with a plain GET (hosted-entry.ts, ComGate's gateway link) is routed by its exact link.
3446
3844
  (url) => opts.vault.isCardRequest(url.toString()) || opts.vault.isCardRequest(url.toString(), 'PUT')
3845
+ || opts.vault.isCardRequest(url.toString(), 'GET')
3447
3846
  || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString())
3448
3847
  || eprotect.matches(url.toString())
3449
3848
  // An armed merchant profile's endpoint or a sibling of it (merchant-hosted.ts), as attachToCdp arms it.
@@ -3461,9 +3860,16 @@ export async function attachToPlaywright(page, opts) {
3461
3860
  || aci.matches(url.toString())
3462
3861
  // Every Payrails host, routed only for an org the API served Payrails to (see attachToCdp).
3463
3862
  || (payrails !== null && isPayrailsHost(url.toString()))
3863
+ // Every request to Przelewy24's pay wall hosts once its entry is served (przelewy24.ts), as attachToCdp arms
3864
+ // the hosts: the card method's post, the card form's post and the Saferpay resolver reach the Przelewy24
3865
+ // gate and fail.
3866
+ || isArmedPrzelewy24Url(opts.vault, url.toString())
3464
3867
  // Every request to an NMI host whose entry is served (ekashu.ts), as attachToCdp arms the
3465
3868
  // host: the card page's post and its 3-D Secure calls reach the NMI gate and fail.
3466
3869
  || isArmedEkashuUrl(opts.vault, url.toString())
3870
+ // Every request to a Webpay host once its entry is served (transbank.ts), as attachToCdp arms the hosts: the
3871
+ // token's submit and Webpay's card steps reach the Webpay gate and fail, and the entry pauses.
3872
+ || isArmedTransbankUrl(opts.vault, url.toString())
3467
3873
  // A CreditGuard page's own calls beside its submit (its card lookup and its 3-D Secure calls), for an
3468
3874
  // org the API served CreditGuard to, as attachToCdp arms its hosts: the CreditGuard gate answers them
3469
3875
  // under a ready preparation and lets them go on with none (pre-claim.ts isArmedCreditguardCall).
@@ -3471,6 +3877,15 @@ export async function attachToPlaywright(page, opts) {
3471
3877
  // Every request to a Redsys host once its entry is served (redsys.ts), as attachToCdp arms the
3472
3878
  // hosts: the card page's post and Redsys's other card requests reach the Redsys gate and fail.
3473
3879
  || isArmedRedsysUrl(opts.vault, url.toString())
3880
+ // Every request to QuickPay's host once its entry is served (quickpay.ts), as attachToCdp arms the host:
3881
+ // the window's card post, its other card requests and its card lookup reach the QuickPay gate and fail.
3882
+ || isArmedQuickpayUrl(opts.vault, url.toString())
3883
+ // Every request to one of ComGate's card hosts once its entry is served (comgate.ts), as attachToCdp arms them:
3884
+ // the gateway's card post and every other card call there reach the ComGate gate and fail.
3885
+ || isArmedComgateUrl(opts.vault, url.toString())
3886
+ // Every request on the TokenEx iframe's /iframe/v3 path once its entry is served (tokenex-iframe.ts), as attachToCdp
3887
+ // arms its hosts: a card post the recognizer's exact URL does not match reaches the TokenEx gate and fails.
3888
+ || isArmedTokenexIframeUrl(opts.vault, url.toString())
3474
3889
  // PayPal's legacy Hosted Fields confirm, for every org, as attachToCdp arms it (paypal-hosted-fields.ts).
3475
3890
  || isPaypalHostedFieldsConfirm(url.toString())
3476
3891
  // PayPal's Card Fields confirm and GraphQL on www.paypal.com, for every org, as attachToCdp arms them (paypal-card-forms.ts).
@@ -3480,7 +3895,10 @@ export async function attachToPlaywright(page, opts) {
3480
3895
  // Paddle's checkout services, routed only for an org the API served Paddle VGS to (see attachToCdp).
3481
3896
  || (isPaddleCheckoutService(url.toString()) && recognizesPaddleVgs(opts.vault))
3482
3897
  // EBANX's 3-D Secure calls and hosted confirm, for every org, as attachToCdp arms them (ebanx.ts).
3483
- || isEbanxResendUrl(url.toString()), async (route) => {
3898
+ || isEbanxResendUrl(url.toString())
3899
+ // HiPay's two vault hosts, routed only for an org the API served HiPay to, as attachToCdp arms both hosts from the
3900
+ // recognizer: the frame's /update reaches the HiPay gate and fails (pre-claim.ts routesHipayVault).
3901
+ || routesHipayVault(opts.vault, url.toString()), async (route) => {
3484
3902
  const request = route.request();
3485
3903
  // When this request paused, and the page it came from: the merchant's total is read as of now.
3486
3904
  const pausedAt = Date.now();
@@ -3573,6 +3991,7 @@ export async function attachToPlaywright(page, opts) {
3573
3991
  } },
3574
3992
  juspay: juspayCheckout,
3575
3993
  datatrans: datatransRepeats,
3994
+ tokenexIframe: tokenexIframeRepeats,
3576
3995
  ebanx: ebanxFollowUps,
3577
3996
  frame: frameOf(request),
3578
3997
  payrails,
@@ -3959,6 +4378,7 @@ export async function attachToPlaywright(page, opts) {
3959
4378
  }
3960
4379
  await deliverTo.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
3961
4380
  datatransRepeats.remember(frameOf(deliverRequest), deliverRequest.url(), deliverBody, replay);
4381
+ tokenexIframeRepeats.remember(frameOf(deliverRequest), deliverRequest.url(), deliverBody, replay);
3962
4382
  assertRequestLive();
3963
4383
  // See the same step in attachToCdp.
3964
4384
  aci.answered(deliverRequest.url(), { status: replay.status, headers: replay.headers, body: replay.body }, replay.authorizationId);