@agent-cards/sdk 0.23.18 → 0.23.20

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 (49) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/PREFLIGHT.md +2 -0
  3. package/README.md +46 -7
  4. package/dist/aci.d.ts +0 -7
  5. package/dist/aci.js +0 -19
  6. package/dist/amadeus.d.ts +4 -2
  7. package/dist/amadeus.js +16 -3
  8. package/dist/buy.d.ts +84 -2
  9. package/dist/buy.js +16 -1
  10. package/dist/card-endpoint-facts.d.ts +2 -1
  11. package/dist/card-endpoint-facts.js +18 -9
  12. package/dist/card-endpoint.d.ts +20 -4
  13. package/dist/card-endpoint.js +38 -4
  14. package/dist/card-fields.generated.js +974 -45
  15. package/dist/cdp.d.ts +29 -0
  16. package/dist/cdp.js +302 -18
  17. package/dist/client.d.ts +43 -5
  18. package/dist/client.js +237 -43
  19. package/dist/datatrans.d.ts +2 -2
  20. package/dist/datatrans.generated.js +16 -11
  21. package/dist/datatrans.js +2 -2
  22. package/dist/hipay.generated.d.ts +56 -0
  23. package/dist/hipay.generated.js +389 -0
  24. package/dist/hold.d.ts +180 -0
  25. package/dist/hold.js +264 -0
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.js +1 -0
  28. package/dist/lifecycle.d.ts +10 -0
  29. package/dist/lifecycle.js +21 -0
  30. package/dist/merchant-card-endpoints.generated.js +102 -11
  31. package/dist/paddle-vgs.generated.d.ts +3 -2
  32. package/dist/paddle-vgs.generated.js +9 -5
  33. package/dist/pelecard.generated.d.ts +2 -1
  34. package/dist/pelecard.generated.js +14 -5
  35. package/dist/pre-claim.d.ts +14 -0
  36. package/dist/pre-claim.js +76 -3
  37. package/dist/prepared-processor.d.ts +6 -2
  38. package/dist/prepared-processor.js +18 -2
  39. package/dist/primer.generated.js +998 -69
  40. package/dist/quickpay.d.ts +44 -0
  41. package/dist/quickpay.js +65 -0
  42. package/dist/stripe-checkout.generated.js +320 -2
  43. package/dist/twoc2p.d.ts +54 -0
  44. package/dist/twoc2p.generated.d.ts +33 -0
  45. package/dist/twoc2p.generated.js +1145 -0
  46. package/dist/twoc2p.js +85 -0
  47. package/dist/voice.d.ts +16 -13
  48. package/dist/voice.js +13 -10
  49. package/package.json +3 -3
package/dist/cdp.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type VaultClient, type ExecutionMetadata } from './client.js';
2
2
  import { type StripeCheckoutOptions } from './stripe-checkout.js';
3
+ import { type HoldOptions } from './hold.js';
3
4
  import { type CheckoutController, type LifecycleOptions, type PaymentEndpointGuard, type PausedCheckout } from './lifecycle.js';
4
5
  /**
5
6
  * The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
@@ -112,6 +113,34 @@ export interface AttachOptions extends LifecycleOptions, ExecutionMetadata {
112
113
  pauseOnly?: boolean;
113
114
  /** With `pauseOnly`: the paused request and the controller to resume it on. Required when `pauseOnly` is set. */
114
115
  onPaused?: (paused: PausedCheckout) => void | Promise<void>;
116
+ /**
117
+ * Hold mode (CARD-REQUEST-HOLD.md): the first card request pauses and is HELD with nothing created, and
118
+ * `controller.hold` (CardRequestHold) answers it. `controller.release({ amount, currency, pageTotal })` creates the
119
+ * authorization only then, at the total the person confirmed, and only when the page shows it; a live client
120
+ * answers the page with the device's replay, a sandbox client never sends the request on. `controller.hold.cancel()`
121
+ * fails it. A request held past its processor's own wait is cancelled first. One request is held at a time; every
122
+ * other card request, and every one after a decision, fails at once. Besides the registry's processors, a hold
123
+ * catches Bolt's sealed tokenizer and, with `placeholderNumbers`, any request carrying the placeholder card (both
124
+ * cancel-only, except a sandbox placeholder post released with `continuePlaceholder`). Raw CDP only; not with
125
+ * `pauseOnly`. Service workers are bypassed in this mode unless `serviceWorkers: 'refuse'`.
126
+ */
127
+ hold?: boolean | HoldOptions;
128
+ /**
129
+ * A site service worker in the merchant's browser context. `refuse` (default): the attachment refuses the context,
130
+ * since a worker can send a card request outside the frames' interception. `bypass` (default in hold mode): every
131
+ * armed frame sends its requests past service workers (Network.setBypassServiceWorker), so the worker is accepted.
132
+ */
133
+ serviceWorkers?: 'refuse' | 'bypass';
134
+ /**
135
+ * A shared worker in the merchant's browser context (chewy.com runs three from blob: URLs). Its own requests pause
136
+ * nowhere: Fetch reaches no shared worker. `refuse` (default): the attachment refuses the context. `accept`
137
+ * (default in hold mode): the worker is accepted unwatched and a `shared_worker_unwatched` event names it; nothing
138
+ * it sends can be approved, and the browser only ever holds the placeholder card. Kernel answers an attach to such
139
+ * a worker with "Not allowed" and closes the connection, so `accept` never attaches. `observe`: the worker is
140
+ * attached and its Network reports read; a card request it sends ends the checkout and any hold, with nothing
141
+ * approved (a browser that allows the attach only).
142
+ */
143
+ sharedWorkers?: 'refuse' | 'accept' | 'observe';
115
144
  /** Explicit native hosted Stripe Checkout TEST integration; requires an Autopilot grant. */
116
145
  stripeCheckout?: StripeCheckoutOptions;
117
146
  /** Fallback when the browser cannot expose its top-level origin. Never an amount/payee authority. */
package/dist/cdp.js CHANGED
@@ -8,15 +8,17 @@ 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
16
  import { SHIJI_STS_COMPANION_WINDOW_MS, ShijiStsCompanions, ShijiStsError, isShijiStsHost, shijiStsTokenContinuation, } from './shiji-sts.js';
17
17
  import { paddleTokenexTokenContinuation } from './paddle-tokenex.js';
18
+ import { twoC2pContinuation, twoC2pRetire } from './twoc2p.js';
18
19
  import { isArmedEkashuUrl } from './ekashu.js';
19
20
  import { isArmedRedsysUrl } from './redsys.js';
21
+ import { isArmedQuickpayUrl } from './quickpay.js';
20
22
  import { PAYPAL_HOSTED_FIELDS_PATTERNS, isPaypalHostedFieldsConfirm } from './paypal-hosted-fields.js';
21
23
  import { EBANX_RESEND_PATTERNS, EbanxFollowUps, isEbanxResendUrl } from './ebanx.js';
22
24
  import { PAYPAL_CARD_FORM_PATTERNS, isPaypalCardFormUrl } from './paypal-card-forms.js';
@@ -30,6 +32,7 @@ import { MerchantHandoffWatch } from './merchant-handoff.js';
30
32
  import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watch.js';
31
33
  import { hostedFormSubmittedPage } from './hosted-form.js';
32
34
  import { armSendWindow, failsAsNetworkError, preparedSendWindowMs, sendWindowProcessor } from './amadeus.js';
35
+ import { HoldAnsweredError, HoldEndedError, HoldGate, HoldRefusedError, matchHoldOnlyRule } from './hold.js';
33
36
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
34
37
  import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, loadedCardMicroformFromUrls, refusedRequestFailure, } from './cybersource-flex.js';
35
38
  import { CARD_ENDPOINT_REFUSED_STEP_ANSWER, CARD_ENDPOINT_WITHHELD_ANSWER, CardEndpointRefusedError, captureCardEndpointSession, cardEndpointAnswerHeaders, cardEndpointCookies, cardEndpointDetail, cardEndpointNavigationAnswer, cardEndpointPatterns, cardEndpointRouted, } from './card-endpoint.js';
@@ -99,13 +102,22 @@ function isExtensionWorker(type, url) {
99
102
  * A different, explicitly identified context belongs to another checkout.
100
103
  * An extension's own worker owns no merchant request and does not count.
101
104
  */
102
- function assertContextTarget(target, merchantContext) {
105
+ function assertContextTarget(target, merchantContext, allow = {}) {
103
106
  if (!targetRecord(target))
104
107
  throw new CheckoutAttachmentError('unavailable');
105
108
  if (contextId(target.browserContextId) && target.browserContextId !== merchantContext)
106
109
  return;
107
110
  if (isExtensionWorker(target.type, target.url))
108
111
  return;
112
+ const knownContext = target.browserContextId === undefined || contextId(target.browserContextId);
113
+ // Every armed frame sends its requests past the site's service workers (Network.setBypassServiceWorker), so a
114
+ // worker in the merchant's context can no longer send a card request out of the interception's reach.
115
+ if (allow.serviceWorkers && target.type === 'service_worker' && knownContext)
116
+ return;
117
+ // A shared worker's own requests pause nowhere (Fetch does not reach them), so it is watched instead: its Network
118
+ // reports are read, and a card request seen there ends the checkout before anything is approved (sharedWorkers).
119
+ if (allow.sharedWorkers && target.type === 'shared_worker' && knownContext)
120
+ return;
109
121
  if (!['page', 'iframe', 'worker', 'browser_ui'].includes(target.type)
110
122
  || (target.browserContextId !== undefined && !contextId(target.browserContextId))) {
111
123
  throw new CheckoutAttachmentError('unavailable');
@@ -203,6 +215,9 @@ function isApprovalOutcome(err) {
203
215
  function isTerminal(err) {
204
216
  if (err instanceof CheckoutApiError)
205
217
  return err.permanent;
218
+ // A hold's decision is final for the attachment: every later card request fails at once.
219
+ if (err instanceof HoldEndedError || err instanceof HoldAnsweredError)
220
+ return true;
206
221
  // Refused at create for Adyen's test platform: the page's next request is refused the same way.
207
222
  if (err instanceof AdyenTestPlatformRefusedError)
208
223
  return err.stage === 'create';
@@ -350,6 +365,16 @@ function cseContinuation(opts, url, body, replay, approved, preparation) {
350
365
  const paddleTokenex = replay.kind === undefined ? paddleTokenexTokenContinuation(url, body, replay.substitutions, replay.paddleTokenexFacts, approved) : null;
351
366
  if (paddleTokenex)
352
367
  return { body: paddleTokenex.body, shijiCvv: null, shijiCvvKeySequenceSha256: null };
368
+ // 2C2P writes one card field under its pinned key, which the generic Adyen swap refuses as no JWE, so it is
369
+ // dispatched by the Payment URL to substituteTwoC2p (twoc2p.ts), only into the one Payment the approval was made for.
370
+ // A refusal there sent the field nowhere, so the approval is retired as merchant_never_retried, as a merchant-hosted
371
+ // one is (cseContinuationBody), and the API stops serving the field; a retirement the API does not confirm is
372
+ // reported as approval_retire_unconfirmed (twoc2p.ts twoC2pRetire).
373
+ const twoc2p = replay.kind === undefined
374
+ ? twoC2pContinuation(url, body, replay.substitutions, approved, twoC2pRetire(opts.vault, replay.authorizationId, opts.onEvent))
375
+ : null;
376
+ if (twoc2p)
377
+ return { body: twoc2p.body, shijiCvv: null, shijiCvvKeySequenceSha256: null };
353
378
  return { body: cseContinuationBody(opts, body, replay, url, preparation), shijiCvv: null, shijiCvvKeySequenceSha256: null };
354
379
  }
355
380
  /**
@@ -1388,7 +1413,7 @@ function merchantAttempt(lifecycle, survives) {
1388
1413
  // known non-payment, so the prepared checkout ends failed with that reason, and a fresh attachment can
1389
1414
  // prepare again. Every other stop leaves the outcome unknown.
1390
1415
  const error = () => reason === 'amadeus_send_window_closed' || reason === 'secure_acceptance_send_window_closed'
1391
- || reason === 'wix_payments_send_window_closed'
1416
+ || reason === 'wix_payments_send_window_closed' || reason === 'hipay_send_window_closed'
1392
1417
  ? new CheckoutPreparationError(lifecycle.getState().preparationId ?? null, reason)
1393
1418
  : new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
1394
1419
  const attempt = {
@@ -1549,6 +1574,35 @@ function replayOf(result, timeoutMs) {
1549
1574
  return { mode: 'token', authorizationId, status: response.status, headers: { ...response.headers }, body: response.body, amountVerified: null,
1550
1575
  ...(result.processor_context !== undefined ? { processorContext: result.processor_context } : {}), ...execution };
1551
1576
  }
1577
+ /** A CDP Fetch url pattern (`*` any run of characters, `?` one, `\\` escapes) against a URL. */
1578
+ function urlPatternMatches(pattern, url) {
1579
+ let re = '';
1580
+ for (let i = 0; i < pattern.length; i++) {
1581
+ const c = pattern[i];
1582
+ if (c === '\\' && i + 1 < pattern.length) {
1583
+ re += pattern[++i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1584
+ continue;
1585
+ }
1586
+ re += c === '*' ? '.*' : c === '?' ? '.' : c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1587
+ }
1588
+ try {
1589
+ return new RegExp(`^${re}$`).test(url);
1590
+ }
1591
+ catch {
1592
+ return false;
1593
+ }
1594
+ }
1595
+ /**
1596
+ * Wait for the hold's decision on a request; the request going away (its attempt stopped) ends the hold as lost,
1597
+ * which rejects the decision.
1598
+ */
1599
+ function holdUntilDecided(decision, signal, lost) {
1600
+ if (signal.aborted)
1601
+ lost();
1602
+ else
1603
+ signal.addEventListener('abort', lost, { once: true });
1604
+ return decision.finally(() => signal.removeEventListener('abort', lost));
1605
+ }
1552
1606
  /**
1553
1607
  * Worldpay eProtect's registration, resumed by the business. The read carries the registration as the API
1554
1608
  * stored it, bare JSON, and the paused request is the vendor's JSONP <script>, which runs its answer and reads
@@ -1688,6 +1742,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1688
1742
  opts = safeOptions(opts);
1689
1743
  if (opts.pauseOnly && typeof opts.onPaused !== 'function')
1690
1744
  throw new Error('pauseOnly needs onPaused: the business receives the paused request there and answers it with controller.resume.');
1745
+ if (opts.pauseOnly && opts.hold)
1746
+ throw new Error('hold and pauseOnly are two different pauses: attach with one of them.');
1691
1747
  const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
1692
1748
  const setupStop = new AbortController();
1693
1749
  let attachmentReady = false;
@@ -1749,6 +1805,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1749
1805
  // The page target's id, which is its main frame's id; read once the attachment is set up.
1750
1806
  let pageTargetId;
1751
1807
  const lifecycle = new CheckoutLifecycle(opts);
1808
+ // Hold mode: the first card request is held with nothing created until controller.release or cancel (hold.ts).
1809
+ const holdGate = opts.hold ? new HoldGate(typeof opts.hold === 'object' ? opts.hold : {}, opts.vault?.sandbox === true, (type, detail) => opts.onEvent?.({ type, detail })) : null;
1810
+ if (holdGate) {
1811
+ lifecycle.setHold(holdGate);
1812
+ lifecycle.abort.signal.addEventListener('abort', () => { void holdGate.cancel('checkout_cancelled'); }, { once: true });
1813
+ }
1814
+ // 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.
1815
+ let holdOnlyNetworkId = null;
1816
+ const bypassServiceWorkers = (opts.serviceWorkers ?? (holdGate ? 'bypass' : 'refuse')) === 'bypass';
1817
+ const sharedWorkerMode = opts.sharedWorkers ?? (holdGate ? 'accept' : 'refuse');
1818
+ const observeSharedWorkers = sharedWorkerMode === 'observe';
1819
+ const targetAllowance = { serviceWorkers: bypassServiceWorkers, sharedWorkers: sharedWorkerMode !== 'refuse' };
1820
+ // Shared workers being watched: their target ids (claimed before the attach answers) and their sessions.
1821
+ const watchedWorkerTargets = new Set();
1822
+ const watchedWorkerSessions = new Set();
1823
+ // The watches still starting, by target id: a later report of the worker waits for its watch, and so does the attachment.
1824
+ const workerWatches = new Map();
1752
1825
  const stripeCheckout = new StripeCheckoutGate(opts);
1753
1826
  const mercadoCheckout = new MercadoCheckoutGate();
1754
1827
  // CyberSource Flex card field iframes and their capture contexts, recorded from the CDP
@@ -2093,6 +2166,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2093
2166
  ...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : []), ...juspayCheckout.urlPatterns(), ...cardEndpointPatterns(),
2094
2167
  ...(payrails ? PAYRAILS_GUARD_PATTERNS : []), ...PAYPAL_HOSTED_FIELDS_PATTERNS, ...PAYPAL_CARD_FORM_PATTERNS,
2095
2168
  ...CYBERSOURCE_SECURE_ACCEPTANCE_PATTERNS, ...(recognizesPaddleVgs(opts.vault) ? PADDLE_VGS_PAY_PATTERNS : []), ...EBANX_RESEND_PATTERNS])];
2169
+ // The hold-only rules' patterns (Bolt's tokenizer; every request a placeholder card can ride, by resource type).
2170
+ const fetchPatterns = [...urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
2171
+ ...(holdGate ? holdGate.patterns().map((p) => ({ ...p, requestStage: 'Request' })) : [])];
2096
2172
  const arm = async (sessionId, resume = false, worker = false) => {
2097
2173
  const key = sessionId ?? '__root__';
2098
2174
  if (armed.has(key))
@@ -2109,6 +2185,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2109
2185
  };
2110
2186
  await cdp.send('Network.enable', {}, sessionId);
2111
2187
  assertActive();
2188
+ // Requests skip the site's service workers, so every one stays in this frame's Fetch interception. Required:
2189
+ // a frame that cannot bypass them is not armed.
2190
+ if (bypassServiceWorkers && !worker) {
2191
+ await cdp.send('Network.setBypassServiceWorker', { bypass: true }, sessionId);
2192
+ assertActive();
2193
+ }
2112
2194
  // Worker Fetch does not exist in Chrome. Its requests pause on the owning
2113
2195
  // frame, but its abort/finish events arrive on the worker Network session.
2114
2196
  // Keep that session attached and paused until Network is listening, then
@@ -2116,9 +2198,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2116
2198
  if (!worker) {
2117
2199
  await cdp.send('Page.enable', {}, sessionId).catch(() => { });
2118
2200
  assertActive();
2119
- await cdp.send('Fetch.enable', {
2120
- patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
2121
- }, sessionId);
2201
+ await cdp.send('Fetch.enable', { patterns: fetchPatterns }, sessionId);
2122
2202
  assertActive();
2123
2203
  }
2124
2204
  // Descend into this target's own children (iframes inside iframes).
@@ -2147,6 +2227,56 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2147
2227
  arming.delete(key);
2148
2228
  }
2149
2229
  };
2230
+ /**
2231
+ * Watch a shared worker of the merchant's context (sharedWorkers 'observe'): attach to it from the browser root,
2232
+ * read its Network reports, and release it. Its requests cannot be paused, so a card request seen there ends the
2233
+ * checkout (see the Network.requestWillBeSent handler). A worker that cannot be watched ends it too.
2234
+ */
2235
+ const watchSharedWorker = async (target) => {
2236
+ if (sharedWorkerMode === 'refuse' || typeof target?.targetId !== 'string')
2237
+ return;
2238
+ // Chrome reports every existing target (Target.targetCreated) before it answers setDiscoverTargets, so that
2239
+ // report starts the watch: the attachment's own pass over the targets waits for it rather than passing it by.
2240
+ const starting = workerWatches.get(target.targetId);
2241
+ if (starting)
2242
+ return starting;
2243
+ if (watchedWorkerTargets.has(target.targetId))
2244
+ return;
2245
+ if (contextId(target.browserContextId) && target.browserContextId !== merchantContext)
2246
+ return;
2247
+ watchedWorkerTargets.add(target.targetId);
2248
+ if (!observeSharedWorkers) {
2249
+ opts.onEvent?.({ type: 'shared_worker_unwatched', detail: { scheme: String(target.url ?? '').split(':')[0] || null } });
2250
+ return;
2251
+ }
2252
+ const watch = (async () => {
2253
+ const { sessionId: workerSession } = await cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: true });
2254
+ if (typeof workerSession !== 'string' || !workerSession)
2255
+ throw new CheckoutAttachmentError('unavailable');
2256
+ watchedWorkerSessions.add(workerSession);
2257
+ rejectedSessions.add(workerSession);
2258
+ await cdp.send('Network.enable', {}, workerSession);
2259
+ await cdp.send('Runtime.runIfWaitingForDebugger', {}, workerSession).catch(() => { });
2260
+ opts.onEvent?.({ type: 'shared_worker_watched', detail: { scheme: String(target.url ?? '').split(':')[0] || null } });
2261
+ })().catch((error) => failInterception(error instanceof CheckoutAttachmentError ? error : new CheckoutAttachmentError('unavailable')))
2262
+ .finally(() => workerWatches.delete(target.targetId));
2263
+ workerWatches.set(target.targetId, watch);
2264
+ return watch;
2265
+ };
2266
+ /** A card request a watched shared worker sent: it could not be held, so nothing may be approved from here on. */
2267
+ const sharedWorkerCardRequest = (request) => {
2268
+ const url = typeof request?.url === 'string' ? request.url : '';
2269
+ const method = typeof request?.method === 'string' ? request.method : 'GET';
2270
+ if (!url)
2271
+ return false;
2272
+ if (typeof opts.vault?.isCardRequest === 'function' && opts.vault.isCardRequest(url, method))
2273
+ return true;
2274
+ if (!holdGate)
2275
+ return false;
2276
+ const body = typeof request.postData === 'string' ? request.postData : request.hasPostData ? null : '';
2277
+ // A body too large to ride the report is read as a card request only by its address (Bolt's tokenizer).
2278
+ return !!matchHoldOnlyRule({ url, method, body: body ?? '' }, holdGate.rules) || (body === null && !!matchHoldOnlyRule({ url, method, body: '' }, holdGate.rules.filter((r) => !r.body)));
2279
+ };
2150
2280
  const failInterception = (error) => {
2151
2281
  if (setupFailureReported)
2152
2282
  return;
@@ -2175,7 +2305,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2175
2305
  }
2176
2306
  if ((method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') && targetContextKnown) {
2177
2307
  try {
2178
- assertContextTarget(params?.targetInfo, merchantContext);
2308
+ assertContextTarget(params?.targetInfo, merchantContext, targetAllowance);
2309
+ if (params?.targetInfo?.type === 'shared_worker')
2310
+ void watchSharedWorker(params.targetInfo);
2179
2311
  }
2180
2312
  catch (error) {
2181
2313
  failInterception(error);
@@ -2203,6 +2335,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2203
2335
  || (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
2204
2336
  detachedSessions.add(pageSessionId);
2205
2337
  setupStop.abort(new CheckoutAttachmentError('closed'));
2338
+ holdGate?.close();
2206
2339
  preparationGate.invalidate('merchant_document_closed');
2207
2340
  stripeCheckout.invalidate();
2208
2341
  mercadoCheckout.invalidate();
@@ -2218,6 +2351,19 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2218
2351
  if (handoffNetwork)
2219
2352
  merchantHandoff.failed();
2220
2353
  }
2354
+ // A watched shared worker sent a card request: it went out unpaused, so nothing is approved from here on.
2355
+ if (method === 'Network.requestWillBeSent' && sessionId && watchedWorkerSessions.has(sessionId) && sharedWorkerCardRequest(params?.request)) {
2356
+ let endpoint = '';
2357
+ try {
2358
+ const u = new URL(params.request.url);
2359
+ endpoint = `${u.origin}${u.pathname}`;
2360
+ }
2361
+ catch { /* unreadable */ }
2362
+ opts.onEvent?.({ type: 'card_request_unwatched', detail: { endpoint, from: 'shared_worker' } });
2363
+ void holdGate?.cancel('card_request_unwatched');
2364
+ failInterception(new CheckoutAttachmentError('unavailable'));
2365
+ return;
2366
+ }
2221
2367
  // The transport is browser-scoped. A second tab's request or child target
2222
2368
  // must never be authorized with this page's merchant, amount or preparation.
2223
2369
  if (!sessionId || !ownedSessions.has(sessionId))
@@ -2325,6 +2471,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2325
2471
  return;
2326
2472
  }
2327
2473
  if (method === 'Network.loadingFailed') {
2474
+ // The held request given up by the page (its own timeout) or failed by the hold itself: nothing was sent.
2475
+ if (holdGate && holdGate.state === 'held' && ((holdOnlyNetworkId && params.requestId === holdOnlyNetworkId)
2476
+ || (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId)))
2477
+ holdGate.lost();
2328
2478
  merchantTotal.log.failed(requestKey(sessionId, params.requestId));
2329
2479
  const recordKey = cardEndpointRecord.keyFor(requestKey(sessionId, params.requestId), params.requestId);
2330
2480
  if (recordKey)
@@ -2414,6 +2564,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2414
2564
  }
2415
2565
  if (method === 'Target.attachedToTarget') {
2416
2566
  const child = params.sessionId;
2567
+ if (params?.targetInfo?.type === 'shared_worker' && watchedWorkerTargets.has(params.targetInfo.targetId)) {
2568
+ if (typeof child === 'string' && child) {
2569
+ watchedWorkerSessions.add(child);
2570
+ rejectedSessions.add(child);
2571
+ }
2572
+ return;
2573
+ }
2417
2574
  if (rejectedSessions.has(child))
2418
2575
  return;
2419
2576
  // A browser-root transport can report another context through an owned
@@ -2437,6 +2594,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2437
2594
  }
2438
2595
  if (setupStop.signal.aborted)
2439
2596
  return;
2597
+ // A service worker the page's frames bypass: released from its debugger wait and left alone, never owned.
2598
+ if (bypassServiceWorkers && params.targetInfo?.type === 'service_worker' && typeof child === 'string' && child
2599
+ && child !== pageSessionId && !ownedSessions.has(child)) {
2600
+ rejectedSessions.add(child);
2601
+ await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
2602
+ return;
2603
+ }
2440
2604
  // CDP may omit the context ID for ordinary children; keep those attached.
2441
2605
  try {
2442
2606
  if (typeof child !== 'string' || !child || !['page', 'iframe', 'worker'].includes(params.targetInfo?.type)) {
@@ -2480,6 +2644,43 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2480
2644
  // A paused document request has not committed either: it only starts the context's check.
2481
2645
  if (resourceType === 'Document')
2482
2646
  microforms.warm(withFragment(request));
2647
+ // Hold mode: a card request no recogniser pays (Bolt's sealed tokenizer, a body carrying the placeholder card) is
2648
+ // held too, and can only be cancelled (or, in sandbox, a placeholder post sent on unchanged). Never recorded.
2649
+ if (holdGate) {
2650
+ // A request any of the SDK's own rules arms (a recogniser, a reviewed card endpoint, a guard, Stripe Checkout,
2651
+ // Mercado, a merchant profile...) goes down the SDK's own path; only what none of them covers is hold-only.
2652
+ const recognised = (typeof opts.vault?.isCardRequest === 'function' && opts.vault.isCardRequest(request.url, request.method))
2653
+ || urlPatterns.some((pattern) => urlPatternMatches(pattern, request.url));
2654
+ const holdRule = recognised ? null : matchHoldOnlyRule({ url: request.url, method: request.method, body: pausedBody(request) }, holdGate.rules);
2655
+ if (holdRule) {
2656
+ const failHeld = () => cdp.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId).catch(() => { });
2657
+ if (!attachmentReady || setupStop.signal.aborted || holdGate.refuse(holdRule.psp, request.url)) {
2658
+ await failHeld();
2659
+ return;
2660
+ }
2661
+ holdOnlyNetworkId = typeof networkId === 'string' && networkId ? networkId : null;
2662
+ let decision;
2663
+ try {
2664
+ decision = await holdGate.hold({ psp: holdRule.psp, url: request.url, kind: holdRule.kind, replayable: false, rule: holdRule });
2665
+ }
2666
+ catch {
2667
+ holdOnlyNetworkId = null;
2668
+ await failHeld();
2669
+ return;
2670
+ }
2671
+ finally { /* the decision is in */ }
2672
+ holdOnlyNetworkId = null;
2673
+ if (decision.kind === 'continue') {
2674
+ // Sandbox only (HoldGate.release): the post carries the documented placeholder test number and nothing else.
2675
+ const ok = await cdp.send('Fetch.continueRequest', { requestId }, sessionId).then(() => true, () => false);
2676
+ decision.settle(ok ? { outcome: 'continued' } : { outcome: 'failed', detail: 'continue_failed' });
2677
+ return;
2678
+ }
2679
+ await failHeld();
2680
+ decision.settle({ outcome: 'failed', detail: 'not_replayable' });
2681
+ return;
2682
+ }
2683
+ }
2483
2684
  // One pre-claim gate registry decides and performs the mechanics through
2484
2685
  // these effects; the state accessors read the same closure variables the
2485
2686
  // inline gates did. It resolves synchronously for an ordinary card request,
@@ -2670,6 +2871,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2670
2871
  await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
2671
2872
  return;
2672
2873
  }
2874
+ // Hold mode: one request held at a time, and none after a decision.
2875
+ if (holdGate && holdGate.refuse(recognizerNameOf(opts, request.url, request.method), request.url)) {
2876
+ if (preparation)
2877
+ preparationGate.retireUnboundClaim();
2878
+ await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
2879
+ return;
2880
+ }
2673
2881
  // Reserve BEFORE the first await (the authorize call below yields), so two
2674
2882
  // requests can never both clear the check above and raise two prompts for
2675
2883
  // one checkout.
@@ -2686,6 +2894,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2686
2894
  let ebanxRemembered = false;
2687
2895
  // The business's pause, so resume resolves once the page has its answer.
2688
2896
  let businessPause = null;
2897
+ // Hold mode: the person's release, settled with what came of it.
2898
+ let holdRelease = null;
2689
2899
  // A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
2690
2900
  let disarmSendWindow = null;
2691
2901
  try {
@@ -2776,15 +2986,45 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2776
2986
  // The merchant's own record of the order, as of the pause, for a profile that reads one.
2777
2987
  const cardRecord = cardEndpoint ? await cardEndpointFacts(cardEndpointRecord, cardEndpoint, request.url, pausedAt, attempt.signal) : null;
2778
2988
  attempt.assertLive();
2989
+ // Hold mode: nothing is created until the person's total releases the request (hold.ts).
2990
+ if (holdGate) {
2991
+ if (holdGate.state !== 'watching') {
2992
+ // Another card request was held while this one was being read: this one is refused alone.
2993
+ holdGate.refuse(recognizerNameOf(opts, request.url, request.method), request.url);
2994
+ throw new HoldRefusedError();
2995
+ }
2996
+ const decision = await holdUntilDecided(holdGate.hold({ psp: recognizerNameOf(opts, request.url, request.method), url: request.url,
2997
+ kind: 'card_request', replayable: true }), attempt.signal, () => holdGate.lost());
2998
+ if (decision.kind !== 'release') {
2999
+ decision.settle({ outcome: 'failed', detail: 'not_releasable' });
3000
+ throw new HoldEndedError('not_releasable');
3001
+ }
3002
+ holdRelease = decision;
3003
+ attempt.assertLive();
3004
+ }
2779
3005
  // Decision 176: pause only. The business creates the authorization from the paused request and answers
2780
3006
  // through controller.resume; the replay it hands back goes down the same delivery path as a create's.
2781
3007
  const pause = opts.pauseOnly ? pauseForBusiness(opts, lifecycle, attempt.signal, { url: request.url, method: request.method, headers: request.headers ?? {}, body }, pageOrigin) : null;
2782
3008
  businessPause = pause;
2783
- const replay = pause ? eprotectResumedReplay(await pause.replay, request) : await opts.vault.authorize({
3009
+ // A sandbox hold never sends the request on: the approval is created at the released total and read, the
3010
+ // page's request fails the moment it exists, and the attempt ends with nothing charged.
3011
+ const holdSandbox = !!holdRelease && opts.vault.sandbox === true;
3012
+ const holdMerchantStop = holdSandbox ? new AbortController() : null;
3013
+ // The approval outlives the page's request, but not the checkout: cancelling the attachment while the approval is
3014
+ // open retires it at Agentcard (the client cancels an authorization whose merchant signal aborted).
3015
+ if (holdMerchantStop) {
3016
+ const retire = () => holdMerchantStop.abort();
3017
+ if (lifecycle.abort.signal.aborted)
3018
+ retire();
3019
+ else
3020
+ lifecycle.abort.signal.addEventListener('abort', retire, { once: true });
3021
+ }
3022
+ let holdRequestFailed = false;
3023
+ const authorization = pause ? null : opts.vault.authorize({
2784
3024
  user: opts.user,
2785
3025
  merchant: opts.merchant,
2786
- amount: opts.amount,
2787
- currency: opts.currency,
3026
+ amount: holdRelease ? holdRelease.amount : opts.amount,
3027
+ currency: holdRelease ? holdRelease.currency : opts.currency,
2788
3028
  cardId: preparation?.cardId ?? opts.cardId,
2789
3029
  executionMode: opts.executionMode,
2790
3030
  grantId: opts.grantId,
@@ -2796,11 +3036,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2796
3036
  preparation,
2797
3037
  timeoutMs: opts.timeoutMs,
2798
3038
  signal: lifecycle.abort.signal,
2799
- merchantSignal: attempt.signal,
3039
+ // A sandbox hold's approval outlives the page's request on purpose: that request is failed once it exists.
3040
+ merchantSignal: holdMerchantStop?.signal ?? attempt.signal,
2800
3041
  // What the merchant's own checkout said its total was, as this browser recorded it
2801
3042
  // (read only for a reviewed profile priced by it): the page is the card request's.
2802
3043
  merchantTotal: merchantTotal.capture(pausedAt, cardPage),
2803
- onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
3044
+ onAuthorizationCreated: (id) => {
3045
+ lifecycle.approvalCreated(id);
3046
+ if (holdSandbox && !holdRequestFailed) {
3047
+ holdRequestFailed = true;
3048
+ // The page's request is let go of before it is failed, so its failure report does not end this attempt:
3049
+ // the approval it raised is read to its end, and nothing is ever delivered to the page.
3050
+ if (activeRequest?.attempt === attempt)
3051
+ activeRequest = null;
3052
+ opts.onEvent?.({ type: 'card_request_not_sent', detail: { authorizationId: id } });
3053
+ void cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
3054
+ }
3055
+ },
2804
3056
  onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
2805
3057
  lifecycle.approvalUrl(url);
2806
3058
  return opts.onApprovalUrl?.(url);
@@ -2812,6 +3064,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2812
3064
  ...(flexPause ? flexPause.sidecar : {}),
2813
3065
  ...(payrailsSidecar ? { payrails: payrailsSidecar } : {}) },
2814
3066
  });
3067
+ if (holdSandbox && authorization) {
3068
+ const outcome = await authorization.then((r) => ({ outcome: 'approved_not_charged', authorizationId: r.authorizationId }), (err) => ({ outcome: err instanceof ApprovalDeclinedError ? 'declined' : err instanceof ApprovalTimeoutError ? 'expired' : 'failed',
3069
+ authorizationId: lifecycle.getState().authorizationId, detail: failureSummary(err) }));
3070
+ if (!holdRequestFailed)
3071
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
3072
+ holdRelease.settle(outcome);
3073
+ throw new HoldAnsweredError(outcome);
3074
+ }
3075
+ const replay = pause ? eprotectResumedReplay(await pause.replay, request) : await authorization;
2815
3076
  attempt.assertLive();
2816
3077
  approvedId = replay.authorizationId;
2817
3078
  if (lifecycle.isCancelled())
@@ -2992,8 +3253,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2992
3253
  if (handedOver)
2993
3254
  lifecycle.handedOff(replay);
2994
3255
  businessPause?.delivered.resolve();
3256
+ holdRelease?.settle({ outcome: 'authorized', authorizationId: replay.authorizationId });
2995
3257
  }
2996
3258
  catch (err) {
3259
+ if (err instanceof HoldRefusedError) {
3260
+ // Refused alone: the request another rule holds, and the checkout's state, are left as they are.
3261
+ if (activeRequest?.attempt === attempt)
3262
+ activeRequest = null;
3263
+ await refusePaused(attempt.target?.requestId ?? requestId, attempt.target?.sessionId ?? sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, false, lifecycle));
3264
+ return;
3265
+ }
3266
+ holdRelease?.settle(err instanceof HoldAnsweredError ? err.outcome : { outcome: err instanceof ApprovalDeclinedError ? 'declined' : err instanceof ApprovalTimeoutError ? 'expired' : 'failed',
3267
+ authorizationId: approvedId ?? lifecycle.getState().authorizationId, detail: failureSummary(err) });
2997
3268
  // Ignore the failure event caused by our own safe decline/error abort.
2998
3269
  const dead = attempt.target ?? { requestId, sessionId };
2999
3270
  // An EBANX token answer this handoff kept follow-ups for and did not finish handing over: nothing after it is
@@ -3074,24 +3345,29 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
3074
3345
  if (!Array.isArray(targetInfos))
3075
3346
  throw new CheckoutAttachmentError('unavailable');
3076
3347
  for (const target of targetInfos)
3077
- assertContextTarget(target, merchantContext);
3348
+ assertContextTarget(target, merchantContext, targetAllowance);
3349
+ for (const target of targetInfos)
3350
+ if (target?.type === 'shared_worker')
3351
+ await watchSharedWorker(target);
3078
3352
  await arm(pageSessionId);
3079
3353
  // Existing OOPIFs report attachment before the parent's acknowledgement,
3080
3354
  // 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());
3355
+ // Pay while one of those already-running frames is still unprotected, or
3356
+ // while a shared worker reported during setup is not yet watched.
3357
+ while (arming.size || workerWatches.size)
3358
+ await Promise.all([...arming.values(), ...workerWatches.values()]);
3084
3359
  assertActive();
3085
3360
  }, setupTimeoutMs, setupStop.signal);
3086
3361
  if (setupStop.signal.aborted)
3087
3362
  throw attachmentFailure(setupStop.signal.reason);
3088
3363
  attachmentReady = true;
3089
- opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
3364
+ opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns, ...(holdGate ? { hold: true, holdRules: holdGate.rules.map((r) => r.psp) } : {}), ...(bypassServiceWorkers ? { serviceWorkers: 'bypass' } : {}) } });
3090
3365
  }
3091
3366
  catch (error) {
3092
3367
  const failure = attachmentFailure(error);
3093
3368
  setupStop.abort(failure);
3094
3369
  terminal = failure;
3370
+ holdGate?.close();
3095
3371
  if (!setupFailureReported) {
3096
3372
  setupFailureReported = true;
3097
3373
  lifecycle.cancel();
@@ -3116,6 +3392,8 @@ export async function attachToPlaywright(page, opts) {
3116
3392
  opts = safeOptions(opts);
3117
3393
  if (opts.pauseOnly)
3118
3394
  throw new Error('pauseOnly is a raw CDP mode (attachToCdp); attachToPlaywright creates the authorization itself.');
3395
+ if (opts.hold)
3396
+ throw new Error('hold is a raw CDP mode (attachToCdp): it needs the frames\' own interception.');
3119
3397
  const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
3120
3398
  const setupStop = new AbortController();
3121
3399
  let attachmentReady = false;
@@ -3471,6 +3749,9 @@ export async function attachToPlaywright(page, opts) {
3471
3749
  // Every request to a Redsys host once its entry is served (redsys.ts), as attachToCdp arms the
3472
3750
  // hosts: the card page's post and Redsys's other card requests reach the Redsys gate and fail.
3473
3751
  || isArmedRedsysUrl(opts.vault, url.toString())
3752
+ // Every request to QuickPay's host once its entry is served (quickpay.ts), as attachToCdp arms the host:
3753
+ // the window's card post, its other card requests and its card lookup reach the QuickPay gate and fail.
3754
+ || isArmedQuickpayUrl(opts.vault, url.toString())
3474
3755
  // PayPal's legacy Hosted Fields confirm, for every org, as attachToCdp arms it (paypal-hosted-fields.ts).
3475
3756
  || isPaypalHostedFieldsConfirm(url.toString())
3476
3757
  // PayPal's Card Fields confirm and GraphQL on www.paypal.com, for every org, as attachToCdp arms them (paypal-card-forms.ts).
@@ -3480,7 +3761,10 @@ export async function attachToPlaywright(page, opts) {
3480
3761
  // Paddle's checkout services, routed only for an org the API served Paddle VGS to (see attachToCdp).
3481
3762
  || (isPaddleCheckoutService(url.toString()) && recognizesPaddleVgs(opts.vault))
3482
3763
  // EBANX's 3-D Secure calls and hosted confirm, for every org, as attachToCdp arms them (ebanx.ts).
3483
- || isEbanxResendUrl(url.toString()), async (route) => {
3764
+ || isEbanxResendUrl(url.toString())
3765
+ // HiPay's two vault hosts, routed only for an org the API served HiPay to, as attachToCdp arms both hosts from the
3766
+ // recognizer: the frame's /update reaches the HiPay gate and fails (pre-claim.ts routesHipayVault).
3767
+ || routesHipayVault(opts.vault, url.toString()), async (route) => {
3484
3768
  const request = route.request();
3485
3769
  // When this request paused, and the page it came from: the merchant's total is read as of now.
3486
3770
  const pausedAt = Date.now();