@agent-cards/sdk 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +87 -2
  2. package/PREFLIGHT.md +32 -0
  3. package/README.md +1177 -23
  4. package/dist/aci.d.ts +173 -0
  5. package/dist/aci.generated.d.ts +87 -0
  6. package/dist/aci.generated.js +306 -0
  7. package/dist/aci.js +371 -0
  8. package/dist/adyen-merchant-hosted.generated.d.ts +3 -3
  9. package/dist/adyen-merchant-hosted.generated.js +302 -56
  10. package/dist/agentcard.d.ts +9 -2
  11. package/dist/agentcard.js +4 -2
  12. package/dist/amadeus-checkout.generated.d.ts +57 -0
  13. package/dist/amadeus-checkout.generated.js +549 -0
  14. package/dist/amadeus.d.ts +41 -0
  15. package/dist/amadeus.js +81 -0
  16. package/dist/builtin-registry.generated.js +1 -1
  17. package/dist/card-endpoint-facts.d.ts +96 -0
  18. package/dist/card-endpoint-facts.js +275 -0
  19. package/dist/card-endpoint-page.d.ts +54 -0
  20. package/dist/card-endpoint-page.js +69 -0
  21. package/dist/card-endpoint.d.ts +298 -0
  22. package/dist/card-endpoint.js +438 -0
  23. package/dist/card-fields.generated.d.ts +5 -3
  24. package/dist/card-fields.generated.js +284 -7
  25. package/dist/cdp.d.ts +5 -1
  26. package/dist/cdp.js +1155 -141
  27. package/dist/client.d.ts +456 -20
  28. package/dist/client.js +1072 -104
  29. package/dist/creditguard.generated.d.ts +79 -0
  30. package/dist/creditguard.generated.js +662 -0
  31. package/dist/cse-body.js +11 -1
  32. package/dist/cybersource-flex.d.ts +30 -0
  33. package/dist/cybersource-flex.generated.d.ts +27 -1
  34. package/dist/cybersource-flex.generated.js +42 -2
  35. package/dist/cybersource-flex.js +46 -0
  36. package/dist/datatrans.d.ts +96 -0
  37. package/dist/datatrans.generated.d.ts +64 -0
  38. package/dist/datatrans.generated.js +426 -0
  39. package/dist/datatrans.js +178 -0
  40. package/dist/ekashu.d.ts +35 -0
  41. package/dist/ekashu.js +55 -0
  42. package/dist/elavon-converge.d.ts +37 -0
  43. package/dist/elavon-converge.generated.d.ts +49 -0
  44. package/dist/elavon-converge.generated.js +439 -0
  45. package/dist/elavon-converge.js +84 -0
  46. package/dist/eprotect.d.ts +152 -0
  47. package/dist/eprotect.generated.d.ts +77 -0
  48. package/dist/eprotect.generated.js +498 -0
  49. package/dist/eprotect.js +222 -0
  50. package/dist/hosted-form.d.ts +3 -2
  51. package/dist/hosted-form.js +3 -2
  52. package/dist/index.d.ts +17 -3
  53. package/dist/index.js +6 -1
  54. package/dist/juspay-gate.d.ts +82 -0
  55. package/dist/juspay-gate.js +126 -0
  56. package/dist/juspay.generated.d.ts +89 -0
  57. package/dist/juspay.generated.js +705 -0
  58. package/dist/lifecycle.d.ts +60 -6
  59. package/dist/lifecycle.js +122 -8
  60. package/dist/merchant-card-endpoints.generated.d.ts +122 -0
  61. package/dist/merchant-card-endpoints.generated.js +4261 -0
  62. package/dist/merchant-hosted.d.ts +4 -1
  63. package/dist/merchant-hosted.js +5 -2
  64. package/dist/merchant-session-probe.d.ts +48 -0
  65. package/dist/merchant-session-probe.js +53 -0
  66. package/dist/merchant-session.generated.d.ts +69 -0
  67. package/dist/merchant-session.generated.js +919 -0
  68. package/dist/payrails.d.ts +133 -0
  69. package/dist/payrails.generated.d.ts +73 -0
  70. package/dist/payrails.generated.js +596 -0
  71. package/dist/payrails.js +305 -0
  72. package/dist/pelecard.d.ts +32 -0
  73. package/dist/pelecard.generated.d.ts +32 -0
  74. package/dist/pelecard.generated.js +283 -0
  75. package/dist/pelecard.js +42 -0
  76. package/dist/pre-claim.d.ts +81 -2
  77. package/dist/pre-claim.js +1023 -5
  78. package/dist/preflight-catalog.json +2267 -288
  79. package/dist/preflight-schemas.json +183 -6
  80. package/dist/preflight.generated.d.ts +1 -1
  81. package/dist/preflight.generated.js +243 -4
  82. package/dist/preparation.d.ts +12 -3
  83. package/dist/preparation.js +63 -12
  84. package/dist/prepared-processor.d.ts +84 -5
  85. package/dist/prepared-processor.js +147 -6
  86. package/dist/primer.d.ts +53 -0
  87. package/dist/primer.generated.d.ts +38 -0
  88. package/dist/primer.generated.js +624 -0
  89. package/dist/primer.js +82 -0
  90. package/dist/registry.d.ts +100 -8
  91. package/dist/registry.js +85 -11
  92. package/dist/shiji-sts.d.ts +144 -0
  93. package/dist/shiji-sts.generated.d.ts +72 -0
  94. package/dist/shiji-sts.generated.js +913 -0
  95. package/dist/shiji-sts.js +222 -0
  96. package/dist/stripe-checkout.generated.d.ts +1 -1
  97. package/dist/stripe-checkout.generated.js +764 -9
  98. package/dist/stripe-setup.d.ts +16 -0
  99. package/dist/stripe-setup.js +61 -0
  100. package/dist/substitutions.generated.js +187 -29
  101. package/dist/verifone.d.ts +68 -0
  102. package/dist/verifone.generated.d.ts +51 -0
  103. package/dist/verifone.generated.js +1413 -0
  104. package/dist/verifone.js +119 -0
  105. package/dist/voice-types.d.ts +27 -0
  106. package/dist/voice.d.ts +6 -1
  107. package/dist/voice.js +6 -1
  108. package/dist/wspay.d.ts +41 -0
  109. package/dist/wspay.generated.d.ts +41 -0
  110. package/dist/wspay.generated.js +370 -0
  111. package/dist/wspay.js +65 -0
  112. package/examples/preflight/kernel-native/inventory.json +1 -1
  113. package/package.json +3 -3
package/dist/cdp.js CHANGED
@@ -1,26 +1,44 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
- import { AdyenTestPlatformRefusedError, AmountRequiredError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, 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, } from './client.js';
3
3
  import { PreparationGate } from './preparation.js';
4
4
  import { StripeCheckoutGate } from './stripe-checkout.js';
5
5
  import { MercadoCheckoutGate } from './mercado-checkout.js';
6
6
  import { MERCADO_CHECKOUT_PATTERN } from './mercado-checkout.generated.js';
7
+ import { JuspayCheckoutGate } from './juspay-gate.js';
8
+ import { ACI_CHECKOUT_REUSED, AciGate, playwrightReplacesNavigationBody } from './aci.js';
9
+ import { EprotectGate } from './eprotect.js';
7
10
  import { dispatchPreClaim, eventUrl } from './pre-claim.js';
8
11
  import { GMO_TOKEN_V1_PATTERNS, gmoRequestShop, isGmoTokenV1Request, recognizesGmo } from './gmo.js';
12
+ import { DatatransRepeats } from './datatrans.js';
13
+ import { SHIJI_STS_COMPANION_WINDOW_MS, ShijiStsCompanions, ShijiStsError, isShijiStsHost, shijiStsTokenContinuation, } from './shiji-sts.js';
14
+ import { isArmedEkashuUrl } from './ekashu.js';
9
15
  import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
10
16
  import { cseBody } from './cse-body.js';
17
+ import { STRIPE_SETUP_INTENT_CONFIRM, setupConfirmPayingWith } from './stripe-setup.js';
11
18
  import { replayTemplatedUrl, reviewedMerchantProfileUrlPatterns } from './merchant-hosted.js';
19
+ import { verifoneTemplatedUrl } from './verifone.js';
12
20
  import { MerchantHandoffWatch } from './merchant-handoff.js';
13
21
  import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watch.js';
14
22
  import { hostedFormSubmittedPage } from './hosted-form.js';
23
+ import { amadeusSendWindowMs, armSendWindow, failsAsNetworkError } from './amadeus.js';
15
24
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
16
- import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, refusedRequestFailure } from './cybersource-flex.js';
25
+ import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, loadedCardMicroformFromUrls, refusedRequestFailure, } from './cybersource-flex.js';
26
+ import { CARD_ENDPOINT_REFUSED_STEP_ANSWER, CARD_ENDPOINT_WITHHELD_ANSWER, CardEndpointRefusedError, captureCardEndpointSession, cardEndpointAnswerHeaders, cardEndpointCookies, cardEndpointDetail, cardEndpointFor, cardEndpointNavigationAnswer, cardEndpointPatterns, cardEndpointResendFor, } from './card-endpoint.js';
27
+ import { CardEndpointFactsWatch } from './card-endpoint-facts.js';
28
+ import { PAYRAILS_GUARD_PATTERNS, PAYRAILS_INIT_BODY_LIMIT, PayrailsSessionCapture, isPayrailsHost, payrailsInitCandidate, recognizesPayrails, } from './payrails.js';
29
+ import { cardEndpointStoppedPage, refusedNavigationOutcome } from './card-endpoint-page.js';
17
30
  /**
18
- * Fetch.failRequest's errorReason for a paused request the CDP adapter will not let through: the
19
- * CDP form of refusedRequestFailure ('Failed' for a CyberSource Flex request, so the Microform field
20
- * reports it to the page; 'Aborted' for every other request, as before).
31
+ * How an adapter fails a paused request it will not let through: as a network error for a
32
+ * CyberSource Flex request (refusedRequestFailure, so the Microform field reports it to the page)
33
+ * and for an Amadeus card request (failsAsNetworkError, so the SDK's tunnel reports it at once), as
34
+ * an abort for every other request, as before.
21
35
  */
36
+ function refusalFailure(url) {
37
+ return refusedRequestFailure(url) === 'failed' || failsAsNetworkError(url) ? 'failed' : 'aborted';
38
+ }
39
+ /** Fetch.failRequest's errorReason for the same request: the CDP form of refusalFailure. */
22
40
  function cdpFailure(url) {
23
- return refusedRequestFailure(url) === 'failed' ? 'Failed' : 'Aborted';
41
+ return refusalFailure(url) === 'failed' ? 'Failed' : 'Aborted';
24
42
  }
25
43
  // Every URL that leaves this module through onEvent is redacted to origin +
26
44
  // path first. A paused PaymentIntent confirm can carry the client secret in
@@ -131,6 +149,11 @@ const APPROVAL_COOLDOWN_MS = 5_000;
131
149
  * re-post is right; latching the whole attachment is not, because the prior
132
150
  * row declines or expires and the same form is then a new question. The
133
151
  * quiet window absorbs the burst; the next request past it is judged afresh.
152
+ *
153
+ * A 400 for a stale Authorize.Net fingerprint (CheckoutApiError
154
+ * isStaleFingerprint) is answered the same way: no prompt was raised, and the
155
+ * form the page renders after a reload carries a fresh fingerprint, so the
156
+ * attachment is quieted rather than latched.
134
157
  */
135
158
  function isApprovalOutcome(err) {
136
159
  // Processor refusals are held by the lifecycle until explicit merchant
@@ -139,7 +162,7 @@ function isApprovalOutcome(err) {
139
162
  if (err instanceof ProcessorRefusedError)
140
163
  return false;
141
164
  if (err instanceof CheckoutApiError)
142
- return err.code === 'duplicate_submission';
165
+ return err.code === 'duplicate_submission' || err.isStaleFingerprint;
143
166
  return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
144
167
  }
145
168
  /**
@@ -154,7 +177,8 @@ function isApprovalOutcome(err) {
154
177
  *
155
178
  * Terminal means "will answer identically next time no matter who does what":
156
179
  * a misconfiguration or an unsupported PSP. Nothing a person does changes
157
- * those, so asking again is pure waste. A GMO request with no `amount`
180
+ * those, so asking again is pure waste. A GMO request, a Pelecard submission
181
+ * or a WSPay confirm with no `amount`
158
182
  * (AmountRequiredError) is one: the attachment's options do not change.
159
183
  *
160
184
  * Other errors may be retryable unless the lifecycle holds the attachment
@@ -172,6 +196,10 @@ function isTerminal(err) {
172
196
  // Refused at create for Adyen's test platform: the page's next request is refused the same way.
173
197
  if (err instanceof AdyenTestPlatformRefusedError)
174
198
  return err.stage === 'create';
199
+ // A card endpoint refusal about the attachment (no amount, Autopilot, a profile this
200
+ // build does not pay) answers every request alike; one about the request does not.
201
+ if (err instanceof CardEndpointRefusedError)
202
+ return err.permanent;
175
203
  return err instanceof CardEncryptedError || err instanceof UnsupportedModeError || err instanceof PreparationRequiredError
176
204
  || err instanceof AmountRequiredError || err instanceof UnsupportedCapabilityError;
177
205
  }
@@ -189,7 +217,7 @@ function cseContinuationBody(opts, body, replay, url, preparation) {
189
217
  return cseBody(body, replay, url, preparation);
190
218
  }
191
219
  catch (error) {
192
- if (replay.kind === 'merchant_hosted' && typeof opts.vault?.cancelAuthorization === 'function') {
220
+ if ((replay.kind === 'merchant_hosted' || replay.kind === 'verifone') && typeof opts.vault?.cancelAuthorization === 'function') {
193
221
  void Promise.resolve().then(() => opts.vault.cancelAuthorization(replay.authorizationId, 'merchant_never_retried')).catch(() => { });
194
222
  }
195
223
  throw error;
@@ -279,13 +307,121 @@ function watchPlaywrightHandoff(page, request, watch, answered) {
279
307
  watch.failed();
280
308
  } } };
281
309
  }
282
- /** The `authorized` event's detail for a cse continuation; a merchant-hosted one names its profile and templated URL. */
283
- function cseAuthorizedDetail(replay, url) {
284
- const base = { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) };
310
+ /** The `authorized` event's detail for a cse continuation; a merchant-hosted or Verifone one names its profile and templated URL. */
311
+ function cseAuthorizedDetail(replay, url, shijiCvv = null) {
312
+ if (replay.kind === 'verifone') {
313
+ return { mode: 'cse', authorizationId: replay.authorizationId, fields: ['encryptedCard'], kind: 'verifone', profile: replay.profile,
314
+ url: verifoneTemplatedUrl(replay.profile, url) };
315
+ }
316
+ // A Shiji STS getTokenId carried pan and expires; its cvv waits for the companion getCvvId.
317
+ const base = { mode: 'cse', authorizationId: replay.authorizationId, fields: shijiCvv !== null ? ['pan', 'expires'] : Object.keys(replay.substitutions.fields) };
285
318
  return replay.kind === 'merchant_hosted'
286
319
  ? { ...base, kind: 'merchant_hosted', profile: replay.profile, url: replayTemplatedUrl(replay, url) }
287
320
  : base;
288
321
  }
322
+ /**
323
+ * The cse continuation for a paused request, by processor. A Shiji STS
324
+ * getTokenId takes the device's pan and expires at the body root and hands back
325
+ * the device's cvv, which that body does not carry: the adapter binds it to the
326
+ * getCvvId companion right before the continue (shiji-sts.ts). It is written
327
+ * only into `approved`, the request the approval was created from, byte for
328
+ * byte, so the accessToken and keySequence are the ones the API reviewed, and
329
+ * the cvv is bound to the reviewed getCvvId keys the approval served
330
+ * (`shijiCvvKeySequences`): no keys, no continuation. Every other processor
331
+ * takes cseContinuationBody (cse-body.ts cseBody), and `shijiCvv` and
332
+ * `shijiCvvKeySequences` are null.
333
+ */
334
+ function cseContinuation(opts, url, body, replay, approved, preparation) {
335
+ const shiji = shijiStsTokenContinuation(url, body, replay.substitutions, approved, replay.kind === 'merchant_hosted' || replay.kind === 'verifone' ? undefined : replay.shijiStsCompanion);
336
+ return shiji ? { body: shiji.body, shijiCvv: shiji.cvv, shijiCvvKeySequences: shiji.cvvKeySequences }
337
+ : { body: cseContinuationBody(opts, body, replay, url, preparation), shijiCvv: null, shijiCvvKeySequences: null };
338
+ }
339
+ /**
340
+ * The opaque page id a Shiji STS companion is bound to: the CDP session and
341
+ * frame the request paused in. The getCvvId must come from the same frame as
342
+ * the getTokenId it follows. Kept to the printable ASCII the binding accepts.
343
+ */
344
+ function cdpShijiPage(sessionId, frameId) {
345
+ return `${cdpShijiSession(sessionId)}${typeof frameId === 'string' && frameId ? frameId : 'none'}`.replace(/[^\x21-\x7e]/g, '_').slice(0, 256);
346
+ }
347
+ /** The start every cdpShijiPage id of one CDP session shares, so a detached target releases its binding. */
348
+ function cdpShijiSession(sessionId) {
349
+ return `cdp:${sessionId ?? 'root'}:`.replace(/[^\x21-\x7e]/g, '_');
350
+ }
351
+ /**
352
+ * The same for a Playwright request: its frame, numbered once per attachment
353
+ * process. A request with no frame (a service worker's) gets an id no other
354
+ * request shares, so it never binds.
355
+ */
356
+ const playwrightShijiFrames = new WeakMap();
357
+ let playwrightShijiSeq = 0;
358
+ function playwrightShijiPage(request) {
359
+ let frame = null;
360
+ try {
361
+ frame = request?.frame?.() ?? null;
362
+ }
363
+ catch {
364
+ frame = null;
365
+ }
366
+ if (!frame || typeof frame !== 'object')
367
+ return `pw:none:${++playwrightShijiSeq}`;
368
+ let id = playwrightShijiFrames.get(frame);
369
+ if (!id) {
370
+ id = `pw:frame:${++playwrightShijiSeq}`;
371
+ playwrightShijiFrames.set(frame, id);
372
+ }
373
+ return id;
374
+ }
375
+ /**
376
+ * The document a Playwright frame holds: its performance.timeOrigin, read in that frame. A new
377
+ * document in the frame (a reload, about:blank, a page that answered a link) has its own, while
378
+ * history.pushState, a hash change and a navigation the page cancels keep the document and so this
379
+ * value: Playwright reports all of them as framenavigated and names no document, so the adapter reads
380
+ * it. Null when the frame or its document cannot be read (no frame, detached, replaced mid-read);
381
+ * undefined when the read has not settled within the companion window: a frame with a navigation
382
+ * pending answers only once that navigation commits or is cancelled, and past the window no
383
+ * getCvvId could claim the binding anyway.
384
+ */
385
+ async function playwrightFrameDocument(frame) {
386
+ if (!frame || typeof frame !== 'object' || typeof frame.evaluate !== 'function')
387
+ return null;
388
+ let timer;
389
+ try {
390
+ const origin = await Promise.race([
391
+ Promise.resolve(frame.evaluate('performance.timeOrigin')),
392
+ new Promise((resolve) => { timer = setTimeout(() => resolve(UNSETTLED), SHIJI_STS_COMPANION_WINDOW_MS); }),
393
+ ]);
394
+ if (origin === UNSETTLED)
395
+ return undefined;
396
+ return typeof origin === 'number' && Number.isFinite(origin) ? String(origin) : null;
397
+ }
398
+ catch {
399
+ return null;
400
+ }
401
+ finally {
402
+ if (timer)
403
+ clearTimeout(timer);
404
+ }
405
+ }
406
+ const UNSETTLED = Symbol('unsettled');
407
+ /**
408
+ * The opaque page id a Shiji STS companion is bound to in Playwright: the request's frame
409
+ * (playwrightShijiPage) and the document that frame holds (playwrightFrameDocument), so a getCvvId
410
+ * from a document that replaced the getTokenId's never matches the binding. Null when the document
411
+ * cannot be read.
412
+ */
413
+ async function playwrightShijiDocumentPage(request) {
414
+ const page = playwrightShijiPage(request);
415
+ let frame = null;
416
+ try {
417
+ frame = request?.frame?.() ?? null;
418
+ }
419
+ catch {
420
+ frame = null;
421
+ }
422
+ const document = await playwrightFrameDocument(frame);
423
+ return typeof document === 'string' ? `${page}:doc:${document}` : null;
424
+ }
289
425
  /**
290
426
  * The paused request's body, or null when Chromium says there is one and did
291
427
  * not hand it over. `postData` is set only for a text body; a binary or a
@@ -305,6 +441,66 @@ function pausedBody(request) {
305
441
  return null;
306
442
  return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
307
443
  }
444
+ /** The frames of a Page.getFrameTree answer from its root down to `frameId`, or undefined when it is not there. */
445
+ function framePath(node, frameId, depth = 0) {
446
+ if (!node?.frame || depth > 64)
447
+ return undefined;
448
+ if (node.frame.id === frameId)
449
+ return [node.frame];
450
+ for (const child of Array.isArray(node.childFrames) ? node.childFrames : []) {
451
+ const path = framePath(child, frameId, depth + 1);
452
+ if (path)
453
+ return [node.frame, ...path];
454
+ }
455
+ return undefined;
456
+ }
457
+ /**
458
+ * The URLs of the frame a paused request came from and of every frame above it, sender first. A
459
+ * session answers only for its own frames (Page.getFrameTree), and a cross-origin frame is a session
460
+ * of its own, so at the root of one session the walk moves on to the session that attached it, from
461
+ * the frame the child named as its parent (the root's Frame.parentId, else the target's
462
+ * parentFrameId). Undefined unless the walk reaches the page's own root within one second in all: a
463
+ * frame that is not found, a session that does not answer and a parent frame nobody named each leave
464
+ * the chain unread. Asked only for a request the pre-claim gates attribute through its frames (see
465
+ * nuveiPreClaim in pre-claim.ts).
466
+ */
467
+ async function pausedFrameChain(cdp, sessionId, frameId, parentOf) {
468
+ if (typeof frameId !== 'string' || !frameId)
469
+ return undefined;
470
+ const deadline = Date.now() + 1_000;
471
+ const urls = [];
472
+ let session = sessionId, frame = frameId;
473
+ for (let hop = 0; hop < 16; hop++) {
474
+ let timer;
475
+ let tree;
476
+ try {
477
+ tree = await Promise.race([
478
+ cdp.send('Page.getFrameTree', {}, session),
479
+ new Promise(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - Date.now())); }),
480
+ ]);
481
+ }
482
+ catch {
483
+ return undefined;
484
+ }
485
+ finally {
486
+ clearTimeout(timer);
487
+ }
488
+ const path = framePath(tree?.frameTree, frame);
489
+ if (!path)
490
+ return undefined;
491
+ for (let i = path.length - 1; i >= 0; i--)
492
+ urls.push(typeof path[i].url === 'string' ? path[i].url : '');
493
+ const parent = session === undefined ? undefined : parentOf(session);
494
+ if (!parent)
495
+ return urls;
496
+ const next = typeof path[0].parentId === 'string' && path[0].parentId ? path[0].parentId : parent.frameId;
497
+ if (!next)
498
+ return undefined;
499
+ session = parent.sessionId;
500
+ frame = next;
501
+ }
502
+ return undefined;
503
+ }
308
504
  const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
309
505
  /**
310
506
  * The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
@@ -424,6 +620,207 @@ export function withCorsHeaders(headers, cors) {
424
620
  }
425
621
  return out;
426
622
  }
623
+ /**
624
+ * The request a card endpoint approval is asked for: `request` names the URL and
625
+ * nothing else, and the profile travels with the captured session in
626
+ * merchant_card_endpoint, the one member the API seals, with the merchant's own cut
627
+ * record of the order for a profile that reads one.
628
+ */
629
+ function cardEndpointRequest(profile, session, facts = null) {
630
+ return { url: session.url, method: 'POST', headers: {}, body: '', merchant_card_endpoint: { profile: profile.id, session, ...(facts ? { facts } : {}) } };
631
+ }
632
+ /**
633
+ * The merchant's own record of the order for a paused card request of a profile that
634
+ * reads one (card-endpoint-facts.ts), or null for any other profile. A request with no
635
+ * record to send is refused before any create, naming no value.
636
+ */
637
+ async function cardEndpointFacts(watch, profile, url, pausedAt, signal) {
638
+ if (!profile.facts)
639
+ return null;
640
+ const captured = await watch.capture(profile, url, pausedAt, signal);
641
+ if (!captured.record)
642
+ throw new CardEndpointRefusedError(profile.id, 'facts_unavailable', captured.refusal);
643
+ return captured.record;
644
+ }
645
+ /** A card endpoint refusal as an event: the profile, the endpoint origin, the reason, and the fields it is about, never a value. */
646
+ function reportCardEndpointRefusal(opts, profile, error) {
647
+ if (!profile || !(error instanceof CardEndpointRefusedError))
648
+ return;
649
+ opts.onEvent?.({ type: 'card_endpoint_refused', detail: { ...cardEndpointDetail(profile), reason: error.reason, ...(error.detail ? { refusal: error.detail } : {}) } });
650
+ }
651
+ /**
652
+ * Network.setCookie parameters: a URL and no domain for a host-only cookie, which Chrome
653
+ * keeps on the endpoint host alone, or the domain with its leading dot and no URL for a
654
+ * rotation of a domain cookie the browser holds there (CardEndpointSetCookie.domain).
655
+ */
656
+ function cdpCookieParams(cookie) {
657
+ return { name: cookie.name, value: cookie.value, ...(cookie.domain !== undefined ? { domain: `.${cookie.domain}` } : { url: `${cookie.origin}/` }),
658
+ path: cookie.path, secure: cookie.secure, httpOnly: cookie.httpOnly,
659
+ ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), ...(cookie.expires !== undefined ? { expires: cookie.expires } : {}) };
660
+ }
661
+ /**
662
+ * context.addCookies parameters at the cookie's path: the endpoint host with no leading
663
+ * dot, which Chromium keeps host-only, or the domain of a rotated domain cookie with its
664
+ * leading dot (CardEndpointSetCookie.domain).
665
+ */
666
+ function playwrightCookieParams(cookie) {
667
+ return { name: cookie.name, value: cookie.value, domain: cookie.domain !== undefined ? `.${cookie.domain}` : cookie.host, path: cookie.path,
668
+ secure: cookie.secure, httpOnly: cookie.httpOnly,
669
+ ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), ...(cookie.expires !== undefined ? { expires: cookie.expires } : {}) };
670
+ }
671
+ /**
672
+ * The page a stopped card form navigation is answered with (card-endpoint-page.ts): the
673
+ * card never left the device, it reached the merchant and the answer cannot be shown, or
674
+ * a payment for this checkout is waiting or may have reached the merchant.
675
+ */
676
+ function stoppedNavigationPage(profile, outcome, reason, authorizationId) {
677
+ return cardEndpointStoppedPage({ outcome, reason, merchant: profile.merchant, authorizationId });
678
+ }
679
+ /** The API refused the create because this checkout already has a payment waiting or sent (409 merchant_checkout_in_use). */
680
+ function checkoutInUse(error) {
681
+ return error instanceof CheckoutApiError && error.code === 'merchant_checkout_in_use';
682
+ }
683
+ /**
684
+ * How a card form navigation the attachment refuses before any approval (not ready, held,
685
+ * busy, quiet, or after a failure that ends every later request) is answered. The API's
686
+ * refusal of this checkout as already paying keeps saying so on every later post. Otherwise
687
+ * the page reads the attachment (refusedNavigationOutcome): while an approval is outstanding,
688
+ * or once a payment may exist, it says a payment is in progress and names its authorization,
689
+ * never that nothing was sent.
690
+ */
691
+ function heldNavigationRefusal(terminal, awaitingApproval, lifecycle) {
692
+ if (checkoutInUse(terminal))
693
+ return { outcome: 'payment_in_progress', reason: 'checkout_in_use', authorizationId: null };
694
+ const state = lifecycle.getState();
695
+ const outcome = refusedNavigationOutcome({ awaitingApproval, status: state.status });
696
+ return { outcome, reason: 'blocked', authorizationId: outcome === 'payment_in_progress' ? state.authorizationId : null };
697
+ }
698
+ /**
699
+ * How a card form navigation whose approval failed is answered, for `authorizationId`. Once
700
+ * the device approved or the hand-off began (`sent`), the card reached the merchant. Before
701
+ * that it may have left only while the authorization stood: a PaymentOutcomeUnknownError
702
+ * whose authorization the API confirmed retired before any processor request
703
+ * (processorRequestStarted false) sent nothing, and any other one may have reached the
704
+ * merchant. The API's refusal of a checkout already paying says a payment is in progress.
705
+ * Every other failure stopped the card before it left.
706
+ */
707
+ function failedNavigationRefusal(error, sent, authorizationId) {
708
+ if (sent)
709
+ return { outcome: 'sent_answer_not_shown', reason: 'outcome_unknown', authorizationId };
710
+ if (error instanceof PaymentOutcomeUnknownError) {
711
+ return error.processorRequestStarted === false ? { outcome: 'stopped_before_send', reason: 'not_approved', authorizationId }
712
+ : { outcome: 'payment_in_progress', reason: 'outcome_unknown', authorizationId };
713
+ }
714
+ if (checkoutInUse(error))
715
+ return { outcome: 'payment_in_progress', reason: 'checkout_in_use', authorizationId };
716
+ return { outcome: 'stopped_before_send', reason: stoppedBeforeSendReason(error), authorizationId };
717
+ }
718
+ /**
719
+ * What a Playwright request navigates: 'top' for the page, 'frame' for an iframe, or null
720
+ * for a fetch, an XHR or a subresource, and for a request whose frame cannot be read.
721
+ */
722
+ function playwrightNavigation(request) {
723
+ try {
724
+ const navigating = typeof request.isNavigationRequest === 'function' ? request.isNavigationRequest() === true : request.resourceType?.() === 'document';
725
+ if (!navigating)
726
+ return null;
727
+ const frame = request.frame?.();
728
+ if (!frame)
729
+ return null;
730
+ return frame.parentFrame?.() ? 'frame' : 'top';
731
+ }
732
+ catch {
733
+ return null;
734
+ }
735
+ }
736
+ /** Why a card form navigation stopped before its card left, from the error that stopped it. */
737
+ function stoppedBeforeSendReason(error) {
738
+ return error instanceof CardEndpointRefusedError ? (error.reason === 'not_enabled' ? 'not_enabled' : 'request_unsupported') : 'not_approved';
739
+ }
740
+ /**
741
+ * Answer a paused card endpoint request with what the cardholder's device reported.
742
+ * A withheld answer (it carried the card, or a stream the device cannot read) becomes
743
+ * the fixed error the merchant's page stops on, and the outcome is unknown: the
744
+ * merchant holds the card and its answer did not come back. Otherwise the merchant's
745
+ * cookies are written first (cardEndpointCookies, against the endpoint's jar as read
746
+ * now), so the page's next call carries them, and the request is answered with the
747
+ * merchant's status, the profile's answer
748
+ * headers, the CORS answer the request needs, and the body. Resolves false for a
749
+ * withheld answer and true when the merchant's answer was handed over.
750
+ *
751
+ * A profile whose page submits its form as a navigation is answered as a page load: a
752
+ * withheld answer, or a redirect that would make the browser post the form again, gets
753
+ * the stopped page (the card reached the merchant, the answer is not shown); a 301, 302
754
+ * or 303 is handed over as a redirect to its one https Location, which the browser
755
+ * follows in its own session with the merchant's cookies written first; any other
756
+ * answer is the page itself. No CORS answer rides on a page load.
757
+ */
758
+ async function deliverCardEndpointAnswer(opts, lifecycle, profile, replay, request, io) {
759
+ // Only the request that named this profile is answered from its merchant's answer.
760
+ if (!profile || replay.profile !== profile.id)
761
+ throw new PaymentOutcomeUnknownError(replay.authorizationId, 'approved_mode_unsupported');
762
+ const cors = corsDecision(request.url, request.headers);
763
+ const navigation = profile.navigation !== null;
764
+ if (replay.withheld) {
765
+ const fixed = replay.withheld === 'refused_step_in_response' ? CARD_ENDPOINT_REFUSED_STEP_ANSWER : CARD_ENDPOINT_WITHHELD_ANSWER;
766
+ await io.fulfill(navigation ? stoppedNavigationPage(profile, 'sent_answer_not_shown', 'answer_withheld', replay.authorizationId)
767
+ : { status: fixed.status, headers: withCorsHeaders({ ...fixed.headers }, cors.headers), body: fixed.body });
768
+ io.assertLive();
769
+ lifecycle.failed(new PaymentOutcomeUnknownError(replay.authorizationId, 'merchant_answer_withheld'), true);
770
+ opts.onEvent?.({ type: 'merchant_answer_withheld', detail: { authorizationId: replay.authorizationId, ...cardEndpointDetail(profile), reason: replay.withheld } });
771
+ return false;
772
+ }
773
+ // The jar decides where a rotation lands, so it is read only when there is one to write.
774
+ let jar = [];
775
+ if (replay.setCookies.length > 0 && profile.response.headers.includes('set-cookie')) {
776
+ try {
777
+ const read = await io.readJar();
778
+ jar = Array.isArray(read) ? read.map((held) => ({ name: String(held?.name ?? ''), domain: String(held?.domain ?? '') })) : null;
779
+ }
780
+ catch {
781
+ jar = null;
782
+ }
783
+ }
784
+ const { cookies, skipped } = cardEndpointCookies(profile, request.url, replay.setCookies, Date.now(), jar);
785
+ // A navigation's answer is judged before any cookie is written: a redirect it cannot follow ends on the stopped page.
786
+ const verdict = navigation ? cardEndpointNavigationAnswer(profile, { status: replay.status, headers: replay.headers }, request.url) : { kind: 'page' };
787
+ if (verdict.kind === 'refused') {
788
+ await io.fulfill(stoppedNavigationPage(profile, 'sent_answer_not_shown', verdict.reason, replay.authorizationId));
789
+ io.assertLive();
790
+ lifecycle.failed(new PaymentOutcomeUnknownError(replay.authorizationId, 'merchant_redirect_refused'), true);
791
+ opts.onEvent?.({ type: 'merchant_answer_withheld', detail: { authorizationId: replay.authorizationId, ...cardEndpointDetail(profile), reason: verdict.reason } });
792
+ return false;
793
+ }
794
+ let written = 0;
795
+ let refused = 0;
796
+ for (const cookie of cookies) {
797
+ // A cookie the browser refuses is counted, never fatal: the merchant already holds
798
+ // the card, and its answer still has to reach the page.
799
+ try {
800
+ await io.writeCookie(cookie);
801
+ written += 1;
802
+ }
803
+ catch {
804
+ refused += 1;
805
+ }
806
+ }
807
+ io.assertLive();
808
+ if (verdict.kind === 'redirect') {
809
+ await io.fulfill({ status: verdict.status, headers: { location: verdict.location, 'cache-control': 'no-store' }, body: '' });
810
+ }
811
+ else if (navigation) {
812
+ const headers = cardEndpointAnswerHeaders(profile, replay.headers);
813
+ delete headers.location;
814
+ await io.fulfill({ status: replay.status, headers, body: replay.body });
815
+ }
816
+ else {
817
+ await io.fulfill({ status: replay.status, headers: withCorsHeaders(cardEndpointAnswerHeaders(profile, replay.headers), cors.headers), body: replay.body });
818
+ }
819
+ io.assertLive();
820
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'card_endpoint', authorizationId: replay.authorizationId, ...cardEndpointDetail(profile),
821
+ status: replay.status, ...(navigation ? { answered: verdict.kind } : { cors: cors.outcome }), cookies: { written, skipped: skipped + refused } } });
822
+ return true;
823
+ }
427
824
  /** CDP's header shape: `{ name, value }` entries. */
428
825
  function headerEntries(headers) {
429
826
  return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
@@ -462,6 +859,21 @@ function pageOriginFrom(documentUrl) {
462
859
  return undefined;
463
860
  }
464
861
  }
862
+ /** The URL of the document the frame `frameId` holds, from the frame tree of the CDP session a request paused on. */
863
+ async function frameUrlIn(cdp, sessionId, frameId) {
864
+ if (typeof frameId !== 'string' || frameId === '')
865
+ throw new Error('merchant_document_unavailable');
866
+ const tree = await cdp.send('Page.getFrameTree', {}, sessionId);
867
+ const stack = tree?.frameTree ? [tree.frameTree] : [];
868
+ while (stack.length > 0) {
869
+ const node = stack.pop();
870
+ if (node?.frame?.id === frameId && typeof node.frame.url === 'string')
871
+ return node.frame.url;
872
+ for (const child of node?.childFrames ?? [])
873
+ stack.push(child);
874
+ }
875
+ throw new Error('merchant_document_unavailable');
876
+ }
465
877
  /**
466
878
  * The top-level document's URL, read once with a one-second bound when a card
467
879
  * request pauses: pageOriginFrom derives the merchant origin from it, and the
@@ -551,26 +963,13 @@ function failureSummary(error) {
551
963
  // Why the merchant's own total could not stand behind the payment, and at which step.
552
964
  if (error instanceof MerchantTotalError)
553
965
  return `${error.name}: ${error.code}${error.reasonCode ? ` (${error.reasonCode})` : ''}`;
966
+ if (error instanceof CardEndpointRefusedError)
967
+ return `${error.name}: ${error.reason}`;
554
968
  return error instanceof Error ? error.name : 'CheckoutError';
555
969
  }
556
970
  const STRIPE_PAYMENT_INTENT_CONFIRM = /^\/v1\/payment_intents\/pi_[A-Za-z0-9]+\/confirm$/;
557
- /**
558
- * A page that tokenized the card through an approval, then confirms the
559
- * payment in the browser with the token it got back (confirmCardPayment with
560
- * payment_method: 'pm_...', confirmPayment with a confirmation token). That
561
- * confirmation carries no card, and the hold after a Stripe tokenization
562
- * would refuse it. The API decides instead whether it is the payment the
563
- * cardholder approved (the approved amount and currency, on the same Stripe
564
- * account, paid with exactly the approved token).
565
- *
566
- * Synchronous on purpose: every paused request passes through here, and an
567
- * await would reorder it against the attachment's own events. Returns the
568
- * approval to check, with the lifecycle's one continuation claimed, or null
569
- * when this is not such a confirmation and the ordinary rules apply.
570
- */
571
971
  function claimStripeContinuation(opts, lifecycle, url, method, body) {
572
- if (body == null || typeof opts.vault.checkStripeContinuation !== 'function' || !lifecycle.isBlocked()
573
- || method.toUpperCase() !== 'POST')
972
+ if (body == null || !lifecycle.isBlocked() || method.toUpperCase() !== 'POST')
574
973
  return null;
575
974
  let parsed;
576
975
  try {
@@ -579,25 +978,57 @@ function claimStripeContinuation(opts, lifecycle, url, method, body) {
579
978
  catch {
580
979
  return null;
581
980
  }
582
- if (parsed.origin !== 'https://api.stripe.com' || !STRIPE_PAYMENT_INTENT_CONFIRM.test(parsed.pathname)
583
- || opts.vault.withoutCard?.(url, body) !== 'refuse')
981
+ if (parsed.origin !== 'https://api.stripe.com')
982
+ return null;
983
+ if (STRIPE_PAYMENT_INTENT_CONFIRM.test(parsed.pathname)) {
984
+ if (typeof opts.vault.checkStripeContinuation !== 'function' || opts.vault.withoutCard?.(url, body) !== 'refuse')
985
+ return null;
986
+ const authorizationId = lifecycle.claimStripeContinuation();
987
+ return authorizationId ? { authorizationId, kind: 'payment', body, rewritten: false } : null;
988
+ }
989
+ if (!STRIPE_SETUP_INTENT_CONFIRM.test(parsed.pathname) || typeof opts.vault.checkStripeSetupContinuation !== 'function')
584
990
  return null;
585
- return lifecycle.claimStripeContinuation();
991
+ const withoutCard = opts.vault.withoutCard?.(url, body);
992
+ let send = null;
993
+ if (withoutCard === 'refuse')
994
+ send = body;
995
+ else if (withoutCard === null) {
996
+ // The card again, inline: only the approved payment method may replace it,
997
+ // and only when what is left carries no card at all.
998
+ const paymentMethod = lifecycle.approvedStripePaymentMethod();
999
+ send = paymentMethod ? setupConfirmPayingWith(body, paymentMethod) : null;
1000
+ if (send !== null && opts.vault.withoutCard?.(url, send) !== 'refuse')
1001
+ send = null;
1002
+ }
1003
+ if (send === null)
1004
+ return null;
1005
+ const authorizationId = lifecycle.claimStripeContinuation();
1006
+ return authorizationId ? { authorizationId, kind: 'setup', body: send, rewritten: send !== body } : null;
586
1007
  }
587
- /** Ask the API about a claimed continuation and settle the claim. True: continue the page's request untouched. */
588
- async function checkStripeContinuation(opts, lifecycle, authorizationId, request) {
589
- let paymentIntentId = null;
1008
+ /**
1009
+ * Ask the API about a claimed continuation, with the body it would continue
1010
+ * with, and settle the claim. True: continue the page's request with that body
1011
+ * (untouched unless the claim rewrote it).
1012
+ */
1013
+ async function checkStripeContinuation(opts, lifecycle, claim, request) {
1014
+ const { authorizationId, kind } = claim;
1015
+ let intentId = null;
590
1016
  try {
591
- paymentIntentId = (await opts.vault.checkStripeContinuation(authorizationId, request)).paymentIntentId;
1017
+ intentId = kind === 'setup'
1018
+ ? (await opts.vault.checkStripeSetupContinuation(authorizationId, { ...request, body: claim.body })).setupIntentId
1019
+ : (await opts.vault.checkStripeContinuation(authorizationId, { ...request, body: claim.body })).paymentIntentId;
592
1020
  }
593
1021
  catch (error) {
594
1022
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
595
1023
  }
596
1024
  // The checkout may have been cancelled or reconciled while the API answered.
597
- const proceed = lifecycle.stripeContinuationChecked(paymentIntentId !== null);
598
- if (proceed)
599
- opts.onEvent?.({ type: 'stripe_payment_continued', detail: { authorizationId, paymentIntentId } });
600
- else if (paymentIntentId !== null)
1025
+ const proceed = lifecycle.stripeContinuationChecked(intentId !== null, kind);
1026
+ if (proceed) {
1027
+ opts.onEvent?.(kind === 'setup'
1028
+ ? { type: 'stripe_setup_continued', detail: { authorizationId, setupIntentId: intentId, rewritten: claim.rewritten } }
1029
+ : { type: 'stripe_payment_continued', detail: { authorizationId, paymentIntentId: intentId } });
1030
+ }
1031
+ else if (intentId !== null)
601
1032
  opts.onEvent?.({ type: 'blocked', detail: 'stripe_continuation_superseded' });
602
1033
  return proceed;
603
1034
  }
@@ -690,15 +1121,20 @@ function luhn(digits) {
690
1121
  return sum % 10 === 0;
691
1122
  }
692
1123
  /**
693
- * The card-placeholder runs in a body: every 13 to 19 digit run that passes
694
- * Luhn, sorted. The placeholder the agent typed (4242 4242 4242 4242, 4111
695
- * 1111 1111 1111) passes; a millisecond timestamp or an order number fails
696
- * nine times in ten, and when one passes it merely makes the match stricter.
697
- * A client-side-encrypted body (Adyen) carries no digits at all and yields
698
- * an empty list on both sides.
1124
+ * The card-placeholder runs in a body: every run of 13 to 19 digits that
1125
+ * passes Luhn, with any single spaces between its digits taken out, sorted.
1126
+ * The placeholder the agent typed (4242 4242 4242 4242, 4111 1111 1111 1111)
1127
+ * passes whether the page sends its digits alone or grouped by spaces the way
1128
+ * the card input shows them, which is how WSPay's card frame sends it. A
1129
+ * millisecond timestamp or an order number fails nine times in ten, and when
1130
+ * one passes it merely makes the match stricter. A body with no single space
1131
+ * between two digits yields exactly the contiguous runs it always did,
1132
+ * because the optional space never matches there. A client-side-encrypted
1133
+ * body (Adyen) carries no digits at all and yields an empty list on both
1134
+ * sides.
699
1135
  */
700
1136
  function cardRuns(body) {
701
- return (body.match(/\d{13,19}/g) ?? []).filter(luhn).sort();
1137
+ return (body.match(/\d(?: ?\d){12,18}/g) ?? []).map((run) => run.replace(/ /g, '')).filter(luhn).sort();
702
1138
  }
703
1139
  /** Every key path in a JSON value, arrays flattened so an element's order or count does not count. */
704
1140
  function jsonKeyPaths(value, prefix, out) {
@@ -800,12 +1236,24 @@ function sameCheckoutRequest(first, retry) {
800
1236
  * answer is a token or ciphertext, and any request for the same purchase can
801
1237
  * carry it. No for a hosted form (a navigation: there is no request to
802
1238
  * answer once the page has moved on), a prepared checkout (single use, bound
803
- * to one native request by design), a native Stripe Checkout step, and a
804
- * Mercado Pago request whose claim is bound to one document read. Unknown
805
- * modes (a duck-typed vault without checkoutModeOf) are taken as `token`.
1239
+ * to one native request by design), a native Stripe Checkout step, a
1240
+ * Mercado Pago request whose claim is bound to one document read, a Juspay
1241
+ * /txns (the device pays that request's own order; a page that clicks Pay
1242
+ * again creates a new order, which the earlier answer would send to the
1243
+ * earlier order's bank check), a Payrails save (device_encrypt: the device
1244
+ * sends that one save itself, under the idempotency key the page gave it, so a
1245
+ * retry is a new question), and any request to a Shiji STS host. Shiji
1246
+ * encrypts every merchant's card under one static key, so the device's
1247
+ * ciphertext tokenizes the card in whichever STS session carries it, and a
1248
+ * retry the shape rule accepts (sameCheckoutRequest compares key paths, never
1249
+ * values) can name another Shiji merchant's accessToken and keySequence than
1250
+ * the request the API reviewed at create. No legitimate retry is lost:
1251
+ * sts.proxy's fetch sets no timeout, so the page never gives up on its own
1252
+ * getTokenId while the cardholder decides. Unknown modes (a duck-typed vault
1253
+ * without checkoutModeOf) are taken as `token`.
806
1254
  */
807
1255
  function survivesAbandonment(opts, url, method, navigation, bound) {
808
- if (navigation || bound)
1256
+ if (navigation || bound || isShijiStsHost(url))
809
1257
  return false;
810
1258
  const mode = typeof opts.vault?.checkoutModeOf === 'function' ? opts.vault.checkoutModeOf(url, method) : 'token';
811
1259
  return mode === 'token' || mode === 'cse';
@@ -836,7 +1284,12 @@ function merchantAttempt(lifecycle, survives) {
836
1284
  let rebound = false;
837
1285
  let delivering = false;
838
1286
  let bound = null;
839
- const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
1287
+ // An Amadeus send window closes only once the API confirms the card never left the device and
1288
+ // never can (amadeus.ts): a known non-payment, so the prepared checkout ends failed with that
1289
+ // reason, and a fresh attachment can prepare again. Every other stop leaves the outcome unknown.
1290
+ const error = () => reason === 'amadeus_send_window_closed'
1291
+ ? new CheckoutPreparationError(lifecycle.getState().preparationId ?? null, reason)
1292
+ : new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
840
1293
  const attempt = {
841
1294
  signal: controller.signal,
842
1295
  /** The browser request that currently carries this attempt; null while the page has none. */
@@ -1059,6 +1512,23 @@ function pauseForBusiness(opts, lifecycle, attemptSignal, request, pageOrigin) {
1059
1512
  });
1060
1513
  return { replay, delivered: { resolve: deliveredResolve, reject: deliveredReject } };
1061
1514
  }
1515
+ /**
1516
+ * Arm the send window of a request paused under a preparation, when its processor sets one: only a
1517
+ * prepared Amadeus SDK add does (amadeus.ts). Returns the disarm function, or null with no window.
1518
+ * lifecycle.begin() cleared the authorization ID, so the one the window reads is this attempt's.
1519
+ */
1520
+ function armWindowFor(opts, lifecycle, attempt, preparation, url, delivering) {
1521
+ const ms = preparation ? amadeusSendWindowMs(preparation.psp, url) : null;
1522
+ if (ms === null || !preparation)
1523
+ return null;
1524
+ return armSendWindow(ms, attempt, {
1525
+ authorizationId: () => lifecycle.getState().authorizationId,
1526
+ delivering,
1527
+ cancel: typeof opts.vault?.cancelAuthorization === 'function' ? (id) => opts.vault.cancelAuthorization(id) : undefined,
1528
+ retirePreparation: typeof opts.vault?.retirePreparation === 'function' ? () => opts.vault.retirePreparation(preparation.id) : undefined,
1529
+ onEvent: opts.onEvent,
1530
+ });
1531
+ }
1062
1532
  /**
1063
1533
  * Take over card tokenization for a page.
1064
1534
  *
@@ -1090,6 +1560,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1090
1560
  const ownedSessions = new Set([pageSessionId]);
1091
1561
  const sessionTypes = new Map([[pageSessionId, 'page']]);
1092
1562
  const sessionParents = new Map();
1563
+ // The frame in its parent session that a child target hangs from (TargetInfo.parentFrameId), so a
1564
+ // frame lookup can climb from a cross-origin frame into the document that frames it.
1565
+ const sessionParentFrames = new Map();
1093
1566
  const detachedSessions = new Set();
1094
1567
  const rejectedSessions = new Set();
1095
1568
  const workerOwners = new Map();
@@ -1174,6 +1647,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1174
1647
  clearTimeout(timer);
1175
1648
  }
1176
1649
  };
1650
+ // Juspay's tokenize answer and the synthetic tokens it issued (juspay-gate.ts).
1651
+ const juspayCheckout = new JuspayCheckoutGate(opts.vault);
1652
+ const aci = new AciGate(opts.vault);
1653
+ const eprotect = new EprotectGate(opts.vault);
1177
1654
  let preparationFrameId;
1178
1655
  const readDocumentUrl = async () => {
1179
1656
  const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
@@ -1182,7 +1659,58 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1182
1659
  preparationFrameId = tree.frameTree.frame.id;
1183
1660
  return tree.frameTree.frame.url;
1184
1661
  };
1185
- const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
1662
+ // prepare({ tokenizeBeforePay: true }) sends the card Microform whose field documents are committed
1663
+ // now. A field's commit can come before this attachment enabled its session, so no event reports
1664
+ // it; each iframe session's own frame tree, and the page's, is read once first (as rereadMicroforms
1665
+ // reads the pausing one), and every field found there is recorded (cybersource-flex.ts MicroformFrames).
1666
+ const readLoadedMicroform = async () => {
1667
+ const sessions = [pageSessionId, ...[...ownedSessions].filter(session => sessionTypes.get(session) === 'iframe' && !sessionDetached(session))];
1668
+ await Promise.all(sessions.map(session => rereadMicroforms(session, '').catch(() => undefined)));
1669
+ return microforms.loadedCard();
1670
+ };
1671
+ /**
1672
+ * The frame a paused document request navigates, read from the frame tree of the
1673
+ * session it paused on: 'top' for the page itself, 'frame' for an iframe (a frame with
1674
+ * a parent, or the root of an iframe's own session), with that frame's current
1675
+ * document URL, where the form was submitted. Null when the frame is not in the tree.
1676
+ */
1677
+ const documentFrame = async (sessionId, frameId) => {
1678
+ if (typeof frameId !== 'string' || !frameId)
1679
+ return null;
1680
+ const tree = await cdp.send('Page.getFrameTree', {}, sessionId);
1681
+ const find = (node, depth) => {
1682
+ if (!node || depth > 32)
1683
+ return null;
1684
+ if (node.frame?.id === frameId)
1685
+ return node.frame;
1686
+ for (const child of Array.isArray(node.childFrames) ? node.childFrames : []) {
1687
+ const hit = find(child, depth + 1);
1688
+ if (hit)
1689
+ return hit;
1690
+ }
1691
+ return null;
1692
+ };
1693
+ const frame = find(tree?.frameTree, 0);
1694
+ if (!frame)
1695
+ return null;
1696
+ return { navigation: frame.parentId || sessionId !== pageSessionId ? 'frame' : 'top', url: typeof frame.url === 'string' ? frame.url : undefined };
1697
+ };
1698
+ /**
1699
+ * Refuse a paused request after the pre-claim gates: a card form navigation gets the
1700
+ * stopped page (card-endpoint-page.ts) that `refusal` describes, since an aborted page
1701
+ * load renders nothing and invites another click on Pay; any other request fails as
1702
+ * cdpFailure says (a network error for CyberSource Flex and Amadeus, else an abort).
1703
+ */
1704
+ const refusePaused = async (requestId, sessionId, url, profile, refusal) => {
1705
+ if (profile && profile.navigation !== null) {
1706
+ const page = stoppedNavigationPage(profile, refusal.outcome, refusal.reason, refusal.authorizationId);
1707
+ await cdp.send('Fetch.fulfillRequest', { requestId, responseCode: page.status, responseHeaders: headerEntries(page.headers),
1708
+ body: Buffer.from(page.body).toString('base64') }, sessionId).catch(() => { });
1709
+ return;
1710
+ }
1711
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(url) }, sessionId).catch(() => { });
1712
+ };
1713
+ const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl, readLoadedMicroform);
1186
1714
  // A merchant-hosted request this attachment continued with the Vault's
1187
1715
  // ciphertext, and the Network identity its answer arrives under.
1188
1716
  const merchantHandoff = new MerchantHandoffWatch(lifecycle, opts.onEvent);
@@ -1192,8 +1720,17 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1192
1720
  // The merchant's own total (merchant-total-watch.ts): its checkout responses, recorded
1193
1721
  // read-only from attach on, and the approval a hand-off answers, for the charge report.
1194
1722
  const merchantTotal = new MerchantTotalWatch(opts.onEvent, opts.vault);
1723
+ // A reviewed card endpoint merchant's own record of the order (card-endpoint-facts.ts).
1724
+ const cardEndpointRecord = new CardEndpointFactsWatch();
1195
1725
  let handoffReplay = null;
1196
1726
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
1727
+ // Payrails sessions this page's own XHR and fetch answers carried, read only
1728
+ // when the synced registry pauses Payrails' save (an org the API served the
1729
+ // dark entry to); null otherwise, and no answer is read (see payrails.ts).
1730
+ const payrails = recognizesPayrails(opts.vault) ? new PayrailsSessionCapture() : null;
1731
+ // Answers that may carry a Payrails init payload, by session and Network ID,
1732
+ // read once they finish loading.
1733
+ const payrailsAnswers = new Set();
1197
1734
  const armed = new Set();
1198
1735
  const arming = new Map();
1199
1736
  // Set once a failure proves that retrying cannot help; see isTerminal.
@@ -1344,6 +1881,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1344
1881
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
1345
1882
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
1346
1883
  let lastSubmitted = null;
1884
+ // The approved Datatrans answers, per frameId: the repeat rule (datatrans.ts).
1885
+ const datatransRepeats = new DatatransRepeats();
1886
+ // The Shiji STS getCvvId companion of the getTokenId this attachment last continued (shiji-sts.ts).
1887
+ // A cancelled attachment drops it, and the device's CVV ciphertext with it, at once: no getCvvId
1888
+ // can use it any more (pre-claim.ts refuses every one once the lifecycle is aborted). The page,
1889
+ // frame or target going away drops it too (the detach handlers below), and so does the frame
1890
+ // committing a new document (Page.frameNavigated, at the top of the event handler).
1891
+ const shijiCompanions = new ShijiStsCompanions();
1892
+ lifecycle.abort.signal.addEventListener('abort', () => shijiCompanions.release(), { once: true });
1347
1893
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
1348
1894
  // The endpoint and siblings of every merchant profile this build reviewed
1349
1895
  // (merchant-hosted.ts), which a VaultClient arms from the start: a profile's card
@@ -1351,13 +1897,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1351
1897
  // it, before or after any syncRegistry(). The gate continues untouched a request
1352
1898
  // that the vault names no profile for.
1353
1899
  const merchantPatterns = [...reviewedMerchantProfileUrlPatterns(),
1354
- ...(typeof opts.vault?.merchantProfileUrlPatterns === 'function' ? opts.vault.merchantProfileUrlPatterns() : [])];
1900
+ ...(typeof opts.vault?.merchantProfileUrlPatterns === 'function' ? opts.vault.merchantProfileUrlPatterns() : []),
1901
+ // Every request to an armed Verifone merchant's origin (verifone.ts), armed from the profiles
1902
+ // the last syncRegistry() served: none before one, and none for a company Verifone is off for.
1903
+ ...(typeof opts.vault?.verifoneProfileUrlPatterns === 'function' ? opts.vault.verifoneProfileUrlPatterns() : [])];
1355
1904
  // GMO token v1's JSONP GET is armed only when the synced registry recognizes
1356
1905
  // GMO (an org the API served the dark entry to), so the gate can refuse it;
1357
1906
  // for every other org it is never intercepted, as before (see gmo.ts).
1907
+ // Every reviewed card endpoint is armed in any status: a card request to one this build
1908
+ // does not pay is paused only to be refused, so the placeholder card never reaches the
1909
+ // merchant, and a request with no card in it continues untouched.
1910
+ // Every Payrails host is intercepted only for an org the API served Payrails
1911
+ // to, so the gate can abort a card-form payment (see payrails.ts).
1358
1912
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
1359
1913
  ...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns,
1360
- ...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : [])])];
1914
+ ...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : []), ...juspayCheckout.urlPatterns(), ...cardEndpointPatterns(),
1915
+ ...(payrails ? PAYRAILS_GUARD_PATTERNS : [])])];
1361
1916
  const arm = async (sessionId, resume = false, worker = false) => {
1362
1917
  const key = sessionId ?? '__root__';
1363
1918
  if (armed.has(key))
@@ -1430,6 +1985,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1430
1985
  opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
1431
1986
  };
1432
1987
  cdp.on(async (method, params, sessionId) => {
1988
+ // A frame that committed a new document drops the companion binding made for the getTokenId its
1989
+ // old document sent, with the device's CVV ciphertext: the document that replaced it never
1990
+ // obtained the approval, whatever accessToken and key it sends. A same-document navigation is
1991
+ // Page.navigatedWithinDocument and keeps the binding.
1992
+ if (method === 'Page.frameNavigated' && typeof params?.frame?.id === 'string') {
1993
+ const replaced = cdpShijiPage(sessionId, params.frame.id);
1994
+ shijiCompanions.releasePage((page) => page === replaced);
1995
+ }
1433
1996
  if ((method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') && targetContextKnown) {
1434
1997
  try {
1435
1998
  assertContextTarget(params?.targetInfo, merchantContext);
@@ -1465,6 +2028,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1465
2028
  mercadoCheckout.invalidate();
1466
2029
  // The root page owns every attached OOPIF; its loss ends child requests too.
1467
2030
  activeRequest?.attempt.stop();
2031
+ // No getCvvId can come from a closed page: drop the companion and its CVV ciphertext.
2032
+ shijiCompanions.release();
1468
2033
  // Nothing can read the merchant's answer to a continued merchant-hosted
1469
2034
  // request any more, nor the page's report of a retry: a retry is judged on
1470
2035
  // what was read, then the answer is an unknown outcome unless it arrived.
@@ -1502,6 +2067,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1502
2067
  merchantTotal.sent(key, { method: params.request.method, url: params.request.url,
1503
2068
  ...(typeof params.request.postData === 'string' ? { requestBody: params.request.postData } : {}),
1504
2069
  ...(typeof params.loaderId === 'string' && params.loaderId ? { page: `${owner}:${params.loaderId}` } : {}) });
2070
+ cardEndpointRecord.sent(key, { method: params.request.method, url: params.request.url, resourceType: params.type ?? null, networkId: params.requestId });
1505
2071
  }
1506
2072
  if (matchesActive) {
1507
2073
  try {
@@ -1524,16 +2090,39 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1524
2090
  if (method === 'Network.loadingFinished') {
1525
2091
  const key = requestKey(sessionId, params.requestId);
1526
2092
  requestSources.delete(key);
2093
+ // An out-of-process frame's document finishes on the frame's own session, under the
2094
+ // network id its request was reported with on the parent's (keyFor).
2095
+ const recordKey = cardEndpointRecord.keyFor(key, params.requestId);
2096
+ if (recordKey) {
2097
+ const completedAt = Date.now();
2098
+ // Its place among the answers that finished, before its text is read: texts can come back out of order.
2099
+ cardEndpointRecord.finished(recordKey);
2100
+ cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((read) => cardEndpointRecord.read(recordKey, typeof read?.body === 'string'
2101
+ ? (read.base64Encoded ? Buffer.from(read.body, 'base64').toString('utf8') : read.body) : null, completedAt), () => cardEndpointRecord.read(recordKey, null, completedAt));
2102
+ }
1527
2103
  if (merchantTotal.log.tracks(key)) {
1528
2104
  // When it finished, then its text: a merchant response the total or the charge is read from.
1529
2105
  const completedAt = Date.now();
1530
2106
  cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((read) => merchantTotal.log.read(key, typeof read?.body === 'string'
1531
2107
  ? (read.base64Encoded ? Buffer.from(read.body, 'base64').toString('utf8') : read.body) : null, completedAt), () => merchantTotal.log.read(key, null, completedAt));
1532
2108
  }
2109
+ if (payrails && payrailsAnswers.delete(requestKey(sessionId, params.requestId))
2110
+ && !(Number(params.encodedDataLength) > PAYRAILS_INIT_BODY_LIMIT)) {
2111
+ payrails.reading(cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((answer) => {
2112
+ if (typeof answer?.body !== 'string')
2113
+ return;
2114
+ const body = answer.base64Encoded ? Buffer.from(answer.body, 'base64').toString('utf8') : answer.body;
2115
+ if (body.length <= PAYRAILS_INIT_BODY_LIMIT)
2116
+ payrails.observe(body);
2117
+ }));
2118
+ }
1533
2119
  return;
1534
2120
  }
1535
2121
  if (method === 'Network.responseReceived') {
1536
2122
  merchantTotal.log.answered(requestKey(sessionId, params.requestId), params.response?.status);
2123
+ const recordKey = cardEndpointRecord.keyFor(requestKey(sessionId, params.requestId), params.requestId);
2124
+ if (recordKey)
2125
+ cardEndpointRecord.answered(recordKey, params.response?.status);
1537
2126
  // The merchant's answer to a continued merchant-hosted request: 5xx is an unknown outcome.
1538
2127
  if (handoffNetwork && params.requestId === handoffNetwork.networkId && sessionId === handoffNetwork.sessionId) {
1539
2128
  merchantHandoff.responded(params.response?.status);
@@ -1543,12 +2132,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1543
2132
  if (handoffReplay)
1544
2133
  merchantTotal.answered(handoffReplay, opts.vault);
1545
2134
  }
2135
+ // A 2xx XHR or fetch answer with a JSON type may carry a Payrails init
2136
+ // payload; its body is read once it finishes loading.
2137
+ if (payrails && payrailsInitCandidate(params?.type, params?.response?.status, params?.response?.mimeType)) {
2138
+ payrailsAnswers.add(requestKey(sessionId, params.requestId));
2139
+ // An answer that never finishes loading is forgotten with the oldest, not kept forever.
2140
+ if (payrailsAnswers.size > 256)
2141
+ payrailsAnswers.delete(payrailsAnswers.values().next().value);
2142
+ }
1546
2143
  return;
1547
2144
  }
1548
2145
  if (method === 'Network.loadingFailed') {
1549
2146
  merchantTotal.log.failed(requestKey(sessionId, params.requestId));
2147
+ const recordKey = cardEndpointRecord.keyFor(requestKey(sessionId, params.requestId), params.requestId);
2148
+ if (recordKey)
2149
+ cardEndpointRecord.failed(recordKey);
1550
2150
  if (handoffNetwork && params.requestId === handoffNetwork.networkId && sessionId === handoffNetwork.sessionId)
1551
2151
  merchantHandoff.failed();
2152
+ payrailsAnswers.delete(requestKey(sessionId, params.requestId));
1552
2153
  if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId) {
1553
2154
  const dead = { requestId: activeRequest.requestId, sessionId: activeRequest.sessionId, request: activeRequest.request };
1554
2155
  // A request a dedicated worker sent keeps today's behaviour: its loss
@@ -1590,6 +2191,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1590
2191
  : method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
1591
2192
  : activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
1592
2193
  activeRequest.attempt.stop();
2194
+ // The frame or target the bound getTokenId came from is gone, so its getCvvId cannot follow:
2195
+ // drop the companion binding and the CVV ciphertext it holds (a binding elsewhere stays).
2196
+ if (method === 'Page.frameDetached') {
2197
+ const gone = cdpShijiPage(sessionId, params.frameId);
2198
+ shijiCompanions.releasePage((page) => page === gone);
2199
+ }
2200
+ else {
2201
+ const gone = cdpShijiSession(method === 'Target.detachedFromTarget' ? params.sessionId : sessionId);
2202
+ shijiCompanions.releasePage((page) => page.startsWith(gone));
2203
+ }
1593
2204
  if (method !== 'Page.frameDetached') {
1594
2205
  const detached = method === 'Target.detachedFromTarget' ? params.sessionId : sessionId;
1595
2206
  detachedSessions.add(detached);
@@ -1653,6 +2264,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1653
2264
  ownedSessions.add(child);
1654
2265
  sessionTypes.set(child, params.targetInfo.type);
1655
2266
  sessionParents.set(child, sessionId);
2267
+ if (typeof params.targetInfo.parentFrameId === 'string' && params.targetInfo.parentFrameId) {
2268
+ sessionParentFrames.set(child, params.targetInfo.parentFrameId);
2269
+ }
1656
2270
  if (params.targetInfo.type === 'worker') {
1657
2271
  const owner = workerOwners.get(sessionId) ?? sessionId;
1658
2272
  workerOwners.set(child, owner);
@@ -1688,9 +2302,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1688
2302
  // a pause hands work back to the claim flow below.
1689
2303
  const preClaimResult = dispatchPreClaim({
1690
2304
  // headers and body are read lazily, inside the gate that needs them (see PreClaimContext.request).
1691
- request: { url: request.url, method: request.method, get headers() { return request.headers; }, get body() { return pausedBody(request); } },
1692
- vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout,
2305
+ request: { url: request.url, method: request.method, get headers() { return request.headers; }, get body() { return pausedBody(request); },
2306
+ navigation: resourceType === 'Document' },
2307
+ vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout, aci, eprotect,
2308
+ // This adapter drives Chromium, which sends the body Fetch.continueRequest replaces on a
2309
+ // navigation (the ACI browser test's classic case, design gate G3).
2310
+ replacesNavigationBody: async () => true,
1693
2311
  readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
2312
+ // Read only for a card endpoint whose page submits its form as a navigation.
2313
+ navigation: async () => (resourceType === 'Document' ? (await documentFrame(sessionId, frameId))?.navigation ?? null : null),
1694
2314
  effects: {
1695
2315
  continueRequest: (postData) => cdp.send('Fetch.continueRequest', { requestId, ...(postData !== undefined ? { postData: Buffer.from(postData).toString('base64') } : {}) }, sessionId),
1696
2316
  answer: (response) => cdp.send('Fetch.fulfillRequest', { requestId, responseCode: response.status,
@@ -1707,10 +2327,19 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1707
2327
  stripeAttachmentBusy: () => arming.has(sessionId ?? '__root__'),
1708
2328
  requestInactive: () => false,
1709
2329
  awaitingApproval: () => awaitingApproval,
2330
+ initiatorFrames: () => pausedFrameChain(cdp, sessionId, frameId, child => {
2331
+ const parent = sessionParents.get(child);
2332
+ return parent === undefined ? undefined : { sessionId: parent, frameId: sessionParentFrames.get(child) };
2333
+ }),
1710
2334
  stripeStep: null,
1711
2335
  merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, (judge) => awaitRetryReport(sessionId, networkId, judge)) },
1712
2336
  // The card field iframe that sent a Flex request is the paused request's own frame.
1713
2337
  cybersourceFlex: { judge: (flexRequest) => microforms.judge(flexRequest, frameId, () => rereadMicroforms(sessionId, frameId)) },
2338
+ juspay: juspayCheckout,
2339
+ datatrans: datatransRepeats,
2340
+ frame: typeof frameId === 'string' && frameId ? frameId : undefined,
2341
+ payrails,
2342
+ shijiSts: { companions: shijiCompanions, page: () => cdpShijiPage(sessionId, frameId) },
1714
2343
  });
1715
2344
  const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
1716
2345
  if (preClaim.kind !== 'pause')
@@ -1718,14 +2347,38 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1718
2347
  const stripeStep = preClaim.stripeStep;
1719
2348
  // The capture context and fields a CyberSource Flex create carries, and its retry shape.
1720
2349
  const flexPause = preClaim.cybersourceFlex;
2350
+ // A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts), and
2351
+ // for one whose page submits its form as a navigation, which frame it navigates.
2352
+ const cardEndpoint = preClaim.cardEndpoint ?? null;
2353
+ // A Payrails card save the gate bound to a captured session: its key rides on the create.
2354
+ const payrailsSidecar = preClaim.payrails;
2355
+ const cardNavigation = preClaim.navigation;
1721
2356
  if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
1722
2357
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
1723
- await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
2358
+ await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
2359
+ return;
2360
+ }
2361
+ // A request the catalog marks as routine without a card (a confirmation
2362
+ // token for a saved method, a bank source, a Checkout Session confirm with
2363
+ // the method hosted Checkout created, every Amadeus action but the card
2364
+ // add and store) was never ours: it continues in every state, like a
2365
+ // request the catalog does not recognize. It is judged before the
2366
+ // preparation claim, so it neither spends a ready preparation nor counts
2367
+ // as the page's first card request: Amadeus's SDK sends its load to the
2368
+ // card URL as the payment step opens, before prepare(), and Air France's
2369
+ // Nuvei payment with a ccTempToken follows the prepared Amadeus add. One
2370
+ // without a card anywhere else is judged after the holds below, and so is
2371
+ // a Nuvei payment without a card once this attachment has handed Nuvei the
2372
+ // card (VaultClient.withoutCard).
2373
+ const continuationBody = pausedBody(request);
2374
+ const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody, lifecycle.cardHandoffs()) ?? null;
2375
+ if (withoutCard === 'continue') {
2376
+ await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1724
2377
  return;
1725
2378
  }
1726
2379
  let preparation;
1727
2380
  try {
1728
- preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request), request.headers);
2381
+ preparation = stripeStep ? undefined : preparationGate.claim(request.url, request.method, pausedBody(request), request.headers);
1729
2382
  }
1730
2383
  catch (error) {
1731
2384
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
@@ -1756,32 +2409,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1756
2409
  return;
1757
2410
  }
1758
2411
  }
1759
- // A request the catalog marks as routine without a card (a confirmation
1760
- // token for a saved method, a bank source, a Checkout Session confirm with
1761
- // the method hosted Checkout created) was never ours: it continues in
1762
- // every state, like a request the catalog does not recognize. One without
1763
- // a card anywhere else is judged after the holds below.
1764
- const continuationBody = pausedBody(request);
1765
- const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody) ?? null;
1766
- if (withoutCard === 'continue') {
1767
- if (preparation)
1768
- preparationGate.retireUnboundClaim();
1769
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1770
- return;
1771
- }
1772
2412
  // Not gated on awaitingApproval: the page can send this confirmation while
1773
2413
  // the token's own delivery is still being acknowledged. The lifecycle
1774
2414
  // opens the claim only once a Stripe tokenization is being handed over.
1775
- const continuationId = stripeStep || terminal ? null
2415
+ const continuation = stripeStep || terminal ? null
1776
2416
  : claimStripeContinuation(opts, lifecycle, request.url, request.method, continuationBody);
1777
- if (continuationId) {
2417
+ if (continuation) {
1778
2418
  if (preparation)
1779
2419
  preparationGate.retireUnboundClaim();
1780
- if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url, method: request.method, headers: request.headers, body: continuationBody }) && !setupStop.signal.aborted) {
1781
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
2420
+ if (await checkStripeContinuation(opts, lifecycle, continuation, { url: request.url, method: request.method, headers: request.headers }) && !setupStop.signal.aborted) {
2421
+ // A card save that carried the card again continues with the body the
2422
+ // API allowed, and postData alone rides the command (see cse-body.ts
2423
+ // for why no header, Content-Length included, is ever overridden).
2424
+ await cdp.send('Fetch.continueRequest', { requestId,
2425
+ ...(continuation.rewritten ? { postData: Buffer.from(continuation.body).toString('base64') } : {}) }, sessionId).catch(() => { });
1782
2426
  return;
1783
2427
  }
1784
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2428
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1785
2429
  return;
1786
2430
  }
1787
2431
  // Same stop condition as the Playwright adapter: once a failure proves
@@ -1798,33 +2442,40 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1798
2442
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
1799
2443
  if (preparation)
1800
2444
  preparationGate.retireUnboundClaim();
1801
- await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
2445
+ // A card form navigation never reads that nothing was sent while a payment for this checkout may exist.
2446
+ await refusePaused(requestId, sessionId, request.url, cardEndpoint, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
1802
2447
  if (repeatedAuthorizationId)
1803
2448
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
1804
2449
  return;
1805
2450
  }
1806
2451
  // A recognized request that carries no card is not a card request: a
1807
- // Stripe confirmation paying with a method the page created itself. It is
1808
- // judged only after the holds above, so a request that would reuse an
1809
- // approved token is still refused there (card-fields.js requestWithoutCard).
2452
+ // Stripe confirmation paying with a method the page created itself, or a
2453
+ // Nuvei payment with a token Nuvei may have minted from the approved card.
2454
+ // It is judged only after the holds above, so a request that would reuse
2455
+ // an approved token is still refused there (VaultClient.withoutCard).
1810
2456
  if (withoutCard === 'refuse') {
1811
2457
  if (preparation)
1812
2458
  preparationGate.retireUnboundClaim();
1813
2459
  opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
1814
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2460
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1815
2461
  return;
1816
2462
  }
1817
2463
  // Reserve BEFORE the first await (the authorize call below yields), so two
1818
2464
  // requests can never both clear the check above and raise two prompts for
1819
2465
  // one checkout.
1820
2466
  awaitingApproval = true;
1821
- const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url, request.method, resourceType === 'Document', !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url, request.method)));
2467
+ // A card endpoint approval never outlives its request: the device answers that
2468
+ // request, and a retry after the page gave up on it is refused, not bound.
2469
+ const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url, request.method, resourceType === 'Document', !!preparation || !!stripeStep || !!cardEndpoint || mercadoCheckout.requiresDocument(request.url, request.method)
2470
+ || juspayCheckout.bindsApproval(request.url, request.method)));
1822
2471
  let handoffStarted = false;
1823
2472
  // The approval the device gave, and whether it reached a browser request.
1824
2473
  let approvedId = null;
1825
2474
  let deliveryBegun = false;
1826
2475
  // The business's pause, so resume resolves once the page has its answer.
1827
2476
  let businessPause = null;
2477
+ // A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
2478
+ let disarmSendWindow = null;
1828
2479
  try {
1829
2480
  const body = pausedBody(request);
1830
2481
  if (body === null) {
@@ -1845,7 +2496,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1845
2496
  void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
1846
2497
  return;
1847
2498
  }
1848
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url), recognizer: recognizerNameOf(opts, request.url, request.method), ...(resourceType ? { resourceType } : {}) } });
2499
+ // A card endpoint's URL carries the merchant's cart and booking ids: its events
2500
+ // name the profile and the endpoint origin instead.
2501
+ opts.onEvent?.({ type: 'card_request_paused', detail: { ...(cardEndpoint ? cardEndpointDetail(cardEndpoint) : { url: eventUrl(opts.vault, request.url) }),
2502
+ recognizer: recognizerNameOf(opts, request.url, request.method), ...(resourceType ? { resourceType } : {}) } });
1849
2503
  lifecycle.begin();
1850
2504
  const approvalStartedAt = Date.now();
1851
2505
  const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
@@ -1856,6 +2510,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1856
2510
  attempt.bind({ requestId, sessionId, request });
1857
2511
  activeRequest = { attempt, requestId, networkId, sessionId, frameId, request,
1858
2512
  identity: requestIdentity(opts, request.url, request.method, body, undefined, undefined, flexPause?.shape) };
2513
+ // An ACI card-number request: the page's CVC and expiry requests for its checkout are held
2514
+ // until this approval ends (see AciGate). One whose checkout already has a card-number
2515
+ // request running or answered never begins; the pre-claim gate refuses it before this.
2516
+ if (!aci.begin(request.url, request.method, body, preparation?.merchantOrigin)) {
2517
+ throw new CheckoutPreparationError(preparation?.id ?? null, ACI_CHECKOUT_REUSED);
2518
+ }
2519
+ // A Worldpay eProtect registration: the vendor's give-up report for it ends this attempt as
2520
+ // the page abandoning its request (see EprotectGate.gaveUp).
2521
+ eprotect.begin(request.url, request.method, () => attempt.stop());
2522
+ disarmSendWindow = armWindowFor(opts, lifecycle, attempt, preparation, request.url, () => deliveryBegun);
1859
2523
  await awaitRequestSource(activeRequest);
1860
2524
  if (preparation)
1861
2525
  await preparationGate.assertDocument();
@@ -1863,7 +2527,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1863
2527
  // the identity a retry has to repeat (see sameCheckoutRequest).
1864
2528
  const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
1865
2529
  const pageOrigin = pageOriginFrom(documentUrl);
1866
- const pageAmount = await pageAmountOf(opts);
2530
+ // The agent's amount is a card endpoint's only authority: no page total is read.
2531
+ const pageAmount = cardEndpoint ? undefined : await pageAmountOf(opts);
1867
2532
  attempt.assertLive();
1868
2533
  activeRequest.identity.documentUrl = documentKey(documentUrl);
1869
2534
  activeRequest.identity.pageAmount = pageAmount;
@@ -1879,6 +2544,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1879
2544
  const cardKey = requestKey(activeRequest?.sourceSessionId ?? sessionId ?? '', networkId ?? '');
1880
2545
  const cardPage = merchantTotal.priced(request.url)
1881
2546
  ? await merchantTotal.pageFor(cardKey, MERCHANT_PAGE_WAIT_MS, attempt.signal) : merchantTotal.pageOf(cardKey);
2547
+ // The card endpoint's session: the profile's own headers from the paused request,
2548
+ // the endpoint's cookie jar for a cookie profile (Chrome keeps it out of the
2549
+ // paused headers, HttpOnly cookies included), and a Referer on the origin the
2550
+ // request is sent from: the profile's card vault frame when it names one, else
2551
+ // the checkout page.
2552
+ // A form inside an iframe is judged by the iframe's own document, where it was submitted, and
2553
+ // a checkout in a frame the merchant's page embeds by that frame's own document.
2554
+ const capturedOrigin = cardEndpoint?.navigation === 'frame'
2555
+ ? pageOriginFrom((await documentFrame(sessionId, frameId).catch(() => null))?.url)
2556
+ : cardEndpoint?.checkout_frame
2557
+ ? pageOriginFrom(await documentUrlOf(() => frameUrlIn(cdp, sessionId, frameId), attempt.signal)) : pageOrigin;
2558
+ const session = cardEndpoint ? await captureCardEndpointSession(cardEndpoint, {
2559
+ url: request.url, headers: request.headers, body, pageOrigin: capturedOrigin, navigation: cardNavigation,
2560
+ cookies: async () => (await cdp.send('Network.getCookies', { urls: [request.url] }, sessionId))?.cookies ?? [],
2561
+ }) : null;
2562
+ attempt.assertLive();
2563
+ // The merchant's own record of the order, as of the pause, for a profile that reads one.
2564
+ const cardRecord = cardEndpoint ? await cardEndpointFacts(cardEndpointRecord, cardEndpoint, request.url, pausedAt, attempt.signal) : null;
1882
2565
  attempt.assertLive();
1883
2566
  // Decision 176: pause only. The business creates the authorization from the paused request and answers
1884
2567
  // through controller.resume; the replay it hands back goes down the same delivery path as a create's.
@@ -1909,10 +2592,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1909
2592
  lifecycle.approvalUrl(url);
1910
2593
  return opts.onApprovalUrl?.(url);
1911
2594
  } },
1912
- request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url, method: request.method, headers: request.headers, body,
1913
- ...(mercadoCheckout.requiresDocument(request.url, request.method)
1914
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}),
1915
- ...(flexPause ? flexPause.sidecar : {}) },
2595
+ request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session, cardRecord)
2596
+ : { url: request.url, method: request.method, headers: request.headers, body,
2597
+ ...(mercadoCheckout.requiresDocument(request.url, request.method)
2598
+ ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}),
2599
+ ...(flexPause ? flexPause.sidecar : {}),
2600
+ ...(payrailsSidecar ? { payrails: payrailsSidecar } : {}) },
1916
2601
  });
1917
2602
  attempt.assertLive();
1918
2603
  approvedId = replay.authorizationId;
@@ -1943,7 +2628,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1943
2628
  attempt.beginDelivery();
1944
2629
  lifecycle.prepareHandoff(replay, deliverRequest.url);
1945
2630
  handoffStarted = replay.mode !== 'cse';
1946
- if (replay.mode === 'hosted_form') {
2631
+ // False only for a card endpoint answer the device withheld (see deliverCardEndpointAnswer).
2632
+ let handedOver = true;
2633
+ if (replay.mode === 'card_endpoint') {
2634
+ handedOver = await deliverCardEndpointAnswer(opts, lifecycle, cardEndpoint, replay, { url: deliverRequest.url, headers: deliverRequest.headers }, {
2635
+ readJar: async () => (await cdp.send('Network.getCookies', { urls: [deliverRequest.url] }, deliverOn))?.cookies,
2636
+ writeCookie: async (cookie) => {
2637
+ const written = await cdp.send('Network.setCookie', cdpCookieParams(cookie), deliverOn);
2638
+ if (written?.success === false)
2639
+ throw new Error('cookie_refused');
2640
+ },
2641
+ fulfill: (response) => cdp.send('Fetch.fulfillRequest', { requestId: deliverTo, responseCode: response.status,
2642
+ responseHeaders: headerEntries(response.headers), body: Buffer.from(response.body).toString('base64') }, deliverOn),
2643
+ assertLive: () => attempt.assertLive(),
2644
+ });
2645
+ }
2646
+ else if (replay.mode === 'hosted_form') {
1947
2647
  // The device submitted the processor's own form; the processor
1948
2648
  // answered the device. The paused NAVIGATION is fulfilled with a page
1949
2649
  // that says exactly that (see hosted-form.ts for why not an abort and
@@ -1974,20 +2674,41 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1974
2674
  if (replay.kind === 'merchant_hosted')
1975
2675
  await merchantTotal.release(replay, opts.vault, attempt.signal);
1976
2676
  attempt.assertLive();
1977
- const postData = Buffer.from(cseContinuationBody(opts, deliverBody, replay, deliverRequest.url, preparation)).toString('base64');
2677
+ // A Shiji STS getTokenId carries pan and expires only; the device's cvv
2678
+ // is bound to its getCvvId companion before the continue, so the
2679
+ // companion cannot arrive first, and dropped if the continue fails.
2680
+ // Both go only to the request the approval was created from, from the
2681
+ // frame that sent it (cseContinuation refuses any other body).
2682
+ const continuation = cseContinuation(opts, deliverRequest.url, deliverBody, replay, { url: request.url, body }, preparation);
2683
+ const postData = Buffer.from(continuation.body).toString('base64');
2684
+ if (continuation.shijiCvv !== null) {
2685
+ shijiCompanions.bind({ url: request.url, method: request.method, body }, cdpShijiPage(sessionId, frameId), continuation.shijiCvv, continuation.shijiCvvKeySequences ?? []);
2686
+ }
1978
2687
  handoffStarted = true;
2688
+ if (replay.kind === 'verifone') {
2689
+ // The merchant's own server answers a Verifone continuation, as a merchant-hosted one.
2690
+ merchantHandoff.begin(replay.profile, replay.authorizationId, verifoneTemplatedUrl(replay.profile, deliverRequest.url));
2691
+ handoffNetwork = { sessionId: activeRequest?.sourceSessionId ?? deliverOn, networkId: activeRequest?.networkId ?? '' };
2692
+ }
1979
2693
  if (replay.kind === 'merchant_hosted') {
1980
2694
  // Watched from before the continue, so an answer that races the command is still read.
1981
2695
  merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url));
1982
2696
  handoffNetwork = { sessionId: activeRequest?.sourceSessionId ?? deliverOn, networkId: activeRequest?.networkId ?? '' };
1983
2697
  handoffReplay = replay;
1984
2698
  }
1985
- await cdp.send('Fetch.continueRequest', {
1986
- requestId: deliverTo,
1987
- postData,
1988
- }, deliverOn);
2699
+ try {
2700
+ await cdp.send('Fetch.continueRequest', {
2701
+ requestId: deliverTo,
2702
+ postData,
2703
+ }, deliverOn);
2704
+ }
2705
+ catch (error) {
2706
+ if (continuation.shijiCvv !== null)
2707
+ shijiCompanions.release();
2708
+ throw error;
2709
+ }
1989
2710
  attempt.assertLive();
1990
- opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url) });
2711
+ opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url, continuation.shijiCvv) });
1991
2712
  }
1992
2713
  else {
1993
2714
  // The processor's answer, handed to the page as if the processor had
@@ -1995,7 +2716,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1995
2716
  // see corsHeadersFor for why the answer has to carry the headers. The
1996
2717
  // decision rides on the event, so a fulfill the page could not read
1997
2718
  // (no usable Origin on a cross-origin request) is visible in telemetry
1998
- // rather than only as the page's own "connection error".
2719
+ // rather than only as the page's own "connection error". A
2720
+ // device_encrypt answer (Payrails' 201, which the device obtained) is
2721
+ // handed over the same way.
1999
2722
  const cors = corsDecision(deliverRequest.url, deliverRequest.headers);
2000
2723
  await cdp.send('Fetch.fulfillRequest', {
2001
2724
  requestId: deliverTo,
@@ -2003,10 +2726,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2003
2726
  responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
2004
2727
  body: Buffer.from(replay.body).toString('base64'),
2005
2728
  }, deliverOn);
2729
+ // The frame that received it: the paused request's, or its retry's once rebound.
2730
+ datatransRepeats.remember(activeRequest?.frameId || undefined, deliverRequest.url, deliverBody, replay);
2006
2731
  attempt.assertLive();
2007
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
2732
+ // An ACI card-number request answered: its checkout's held CVC and expiry requests get the
2733
+ // same answer, and its /payment submission may continue (see AciGate).
2734
+ aci.answered(deliverRequest.url, { status: replay.status, headers: replay.headers, body: replay.body }, replay.authorizationId);
2735
+ // An eProtect registration answered: a give-up report for it from here on means the
2736
+ // vendor script stopped listening before the answer landed (see EprotectGate.gaveUp).
2737
+ eprotect.delivered(deliverRequest.url, () => lifecycle.merchantRequestAborted());
2738
+ opts.onEvent?.({ type: 'authorized', detail: { mode: replay.mode === 'device_encrypt' ? 'device_encrypt' : 'token', authorizationId: replay.authorizationId,
2739
+ amountVerified: replay.mode === 'device_encrypt' ? null : replay.amountVerified ?? null, cors: cors.outcome } });
2008
2740
  }
2009
- lifecycle.handedOff(replay);
2741
+ if (handedOver)
2742
+ lifecycle.handedOff(replay);
2010
2743
  businessPause?.delivered.resolve();
2011
2744
  }
2012
2745
  catch (err) {
@@ -2045,14 +2778,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2045
2778
  terminal = err;
2046
2779
  else if (isApprovalOutcome(err))
2047
2780
  quietUntil = Date.now() + cooldownMs;
2781
+ reportCardEndpointRefusal(opts, cardEndpoint, err);
2048
2782
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
2049
2783
  }
2050
- await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: cdpFailure(request.url) }, dead.sessionId).catch(() => { });
2784
+ // A card form navigation ends on the stopped page: its card reached the merchant once the
2785
+ // device approved (the merchant answered it) or the hand-off began; see failedNavigationRefusal.
2786
+ await refusePaused(dead.requestId, dead.sessionId, request.url, cardEndpoint, failedNavigationRefusal(err, handoffStarted || approvedId !== null, approvedId ?? lifecycle.getState().authorizationId));
2051
2787
  settlePause();
2052
2788
  }
2053
2789
  finally {
2790
+ disarmSendWindow?.();
2054
2791
  if (activeRequest?.attempt === attempt)
2055
2792
  activeRequest = null;
2793
+ aci.end();
2794
+ eprotect.end();
2056
2795
  awaitingApproval = false;
2057
2796
  lifecycle.end();
2058
2797
  if (preparation)
@@ -2144,12 +2883,27 @@ export async function attachToPlaywright(page, opts) {
2144
2883
  return undefined;
2145
2884
  }
2146
2885
  };
2886
+ // Juspay's tokenize answer and the synthetic tokens it issued (juspay-gate.ts).
2887
+ const juspayCheckout = new JuspayCheckoutGate(opts.vault);
2888
+ const aci = new AciGate(opts.vault);
2889
+ let navigationBodyReplaced;
2890
+ const eprotect = new EprotectGate(opts.vault);
2147
2891
  const readDocumentUrl = async () => {
2148
2892
  if (page.isClosed?.() || typeof page.url !== 'function')
2149
2893
  throw new Error('merchant_document_unavailable');
2150
2894
  return page.url();
2151
2895
  };
2152
- const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
2896
+ // prepare({ tokenizeBeforePay: true }) sends the card Microform whose fields are on the page now.
2897
+ const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl, () => {
2898
+ let frames = [];
2899
+ try {
2900
+ frames = page.frames?.() ?? [];
2901
+ }
2902
+ catch {
2903
+ frames = [];
2904
+ }
2905
+ return loadedCardMicroformFromUrls(frames.map(frameUrlOf));
2906
+ });
2153
2907
  // A merchant-hosted request this attachment continued, and its answer; see watchPlaywrightHandoff.
2154
2908
  const merchantHandoff = new MerchantHandoffWatch(lifecycle, opts.onEvent);
2155
2909
  let handoffAnswer = null;
@@ -2160,6 +2914,8 @@ export async function attachToPlaywright(page, opts) {
2160
2914
  // but sends no document request, so it keeps the page id, as CDP's loaderId does. Nor does
2161
2915
  // a document request that commits nothing: one that failed, or one answered 204 or 205.
2162
2916
  const merchantTotal = new MerchantTotalWatch(opts.onEvent, opts.vault);
2917
+ // A reviewed card endpoint merchant's own record of the order (card-endpoint-facts.ts).
2918
+ const cardEndpointRecord = new CardEndpointFactsWatch();
2163
2919
  let documentSerial = 1;
2164
2920
  // The main frame's document request that no navigation has committed yet.
2165
2921
  let documentRequest = null;
@@ -2181,6 +2937,24 @@ export async function attachToPlaywright(page, opts) {
2181
2937
  return false;
2182
2938
  }
2183
2939
  };
2940
+ // The Shiji STS getCvvId companion of the getTokenId this attachment last continued (shiji-sts.ts).
2941
+ // Dropped, with the device's CVV ciphertext, as soon as no getCvvId can use it: a cancel, the page
2942
+ // closing or crashing, the frame the bound getTokenId came from detaching, or that frame committing
2943
+ // a new document (the handlers below).
2944
+ const shijiCompanions = new ShijiStsCompanions();
2945
+ lifecycle.abort.signal.addEventListener('abort', () => shijiCompanions.release(), { once: true });
2946
+ // An answer to a request the facts watch records began to arrive: the watch orders what the page sent after a
2947
+ // record by this moment, since Playwright reports the answer's end after the page's next request. Listened to only
2948
+ // once the page sent such a request, so a page that never meets a merchant with facts carries no extra listener.
2949
+ let factsAnswers = false;
2950
+ const factsAnswer = (response) => {
2951
+ try {
2952
+ const key = keyOf(response.request());
2953
+ if (cardEndpointRecord.tracks(key))
2954
+ cardEndpointRecord.answered(key, response.status());
2955
+ }
2956
+ catch { /* a response the page already dropped */ }
2957
+ };
2184
2958
  page.on?.('request', (request) => {
2185
2959
  // A new document is on its way: the main frame's next navigation commits it.
2186
2960
  if (mainDocument(request))
@@ -2191,6 +2965,16 @@ export async function attachToPlaywright(page, opts) {
2191
2965
  ...(typeof body === 'string' ? { requestBody: body } : {}), page: `doc:${documentSerial}` });
2192
2966
  }
2193
2967
  catch { /* a request the page already dropped */ }
2968
+ try {
2969
+ cardEndpointRecord.sent(keyOf(request), { method: request.method(), url: request.url(),
2970
+ resourceType: typeof request.resourceType === 'function' ? request.resourceType() : null });
2971
+ // From the first request the watch records, it also hears when each answer begins to arrive (factsAnswer).
2972
+ if (!factsAnswers && cardEndpointRecord.tracks(keyOf(request))) {
2973
+ factsAnswers = true;
2974
+ page.on?.('response', factsAnswer);
2975
+ }
2976
+ }
2977
+ catch { /* a request the page already dropped */ }
2194
2978
  });
2195
2979
  page.on?.('requestfinished', (request) => {
2196
2980
  // A document answered 204 or 205 loads nothing, so no navigation will commit it.
@@ -2201,6 +2985,27 @@ export async function attachToPlaywright(page, opts) {
2201
2985
  }).catch(() => { });
2202
2986
  }
2203
2987
  const key = keyOf(request);
2988
+ if (cardEndpointRecord.tracks(key)) {
2989
+ const finishedAt = Date.now();
2990
+ // Its place among the answers that finished, before its text is read: texts can come back out of order.
2991
+ cardEndpointRecord.finished(key);
2992
+ Promise.resolve().then(async () => {
2993
+ const response = await request.response();
2994
+ if (!response) {
2995
+ cardEndpointRecord.failed(key);
2996
+ return;
2997
+ }
2998
+ cardEndpointRecord.answered(key, response.status());
2999
+ let text = null;
3000
+ try {
3001
+ text = await response.text();
3002
+ }
3003
+ catch {
3004
+ text = null;
3005
+ }
3006
+ cardEndpointRecord.read(key, text, finishedAt);
3007
+ }).catch(() => cardEndpointRecord.failed(key));
3008
+ }
2204
3009
  if (!merchantTotal.log.tracks(key))
2205
3010
  return;
2206
3011
  const completedAt = Date.now();
@@ -2224,6 +3029,25 @@ export async function attachToPlaywright(page, opts) {
2224
3029
  }).catch(() => merchantTotal.log.failed(key));
2225
3030
  });
2226
3031
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
3032
+ // Payrails sessions this page's own XHR and fetch answers carried, read only
3033
+ // when the synced registry pauses Payrails' save; see attachToCdp.
3034
+ const payrails = recognizesPayrails(opts.vault) ? new PayrailsSessionCapture() : null;
3035
+ if (payrails) {
3036
+ page.on?.('response', (response) => {
3037
+ try {
3038
+ const headers = response.headers?.() ?? {};
3039
+ if (!payrailsInitCandidate(response.request?.()?.resourceType?.(), response.status?.(), headers['content-type']))
3040
+ return;
3041
+ if (Number(headers['content-length']) > PAYRAILS_INIT_BODY_LIMIT)
3042
+ return;
3043
+ payrails.reading(Promise.resolve().then(() => response.text()).then((body) => {
3044
+ if (typeof body === 'string' && body.length <= PAYRAILS_INIT_BODY_LIMIT)
3045
+ payrails.observe(body);
3046
+ }));
3047
+ }
3048
+ catch { /* an answer that cannot be read carries no session */ }
3049
+ });
3050
+ }
2227
3051
  // Playwright's own routing, NOT a hand-rolled CDP session.
2228
3052
  //
2229
3053
  // A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
@@ -2251,6 +3075,7 @@ export async function attachToPlaywright(page, opts) {
2251
3075
  if (request === documentRequest)
2252
3076
  documentRequest = null;
2253
3077
  merchantTotal.log.failed(keyOf(request));
3078
+ cardEndpointRecord.failed(keyOf(request));
2254
3079
  if (activeRequest && activeRequest.request === request && activeRequest.attempt.lose()) {
2255
3080
  // The page's own script gave up on its request; the cardholder's
2256
3081
  // approval did not (see merchantAttempt). Forget the request's identity
@@ -2261,13 +3086,28 @@ export async function attachToPlaywright(page, opts) {
2261
3086
  }
2262
3087
  });
2263
3088
  page.on?.('close', () => { if (!attachmentReady)
2264
- setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
3089
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); shijiCompanions.release(); });
2265
3090
  page.on?.('crash', () => { if (!attachmentReady)
2266
- setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
3091
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); shijiCompanions.release(); });
2267
3092
  page.on?.('framenavigated', (frame) => {
2268
3093
  // A CyberSource Flex card field iframe loaded: start its capture-context check now, so its
2269
3094
  // card request pauses without waiting on it (cybersource-flex.ts).
2270
3095
  microformContexts.warm(frameUrlOf(frame));
3096
+ // The bound getTokenId's frame navigated. history.pushState, a hash change and a navigation the
3097
+ // page cancels keep its document; a new document replaces it. Read which, and drop the binding and
3098
+ // the device's CVV ciphertext only when the frame holds another document now or cannot be read; a
3099
+ // read still waiting on a pending navigation when the window closes keeps it (the binding expires
3100
+ // with the window). A claim reads the document as well, so a getCvvId is judged the same way.
3101
+ const boundPage = shijiCompanions.boundPage();
3102
+ const shijiFrame = frame && typeof frame === 'object' ? playwrightShijiFrames.get(frame) : undefined;
3103
+ if (boundPage && shijiFrame && boundPage.startsWith(`${shijiFrame}:doc:`)) {
3104
+ void playwrightFrameDocument(frame).then((document) => {
3105
+ if (document === undefined)
3106
+ return;
3107
+ if (document === null || boundPage !== `${shijiFrame}:doc:${document}`)
3108
+ shijiCompanions.releasePage((page) => page === boundPage);
3109
+ });
3110
+ }
2271
3111
  if (frame === page.mainFrame?.()) {
2272
3112
  if (documentRequest) {
2273
3113
  documentRequest = null;
@@ -2298,24 +3138,58 @@ export async function attachToPlaywright(page, opts) {
2298
3138
  page.on?.('framedetached', (frame) => {
2299
3139
  if (activeRequest?.frames.includes(frame))
2300
3140
  activeRequest.attempt.stop();
3141
+ const gone = frame && typeof frame === 'object' ? playwrightShijiFrames.get(frame) : undefined;
3142
+ if (gone)
3143
+ shijiCompanions.releasePage((bound) => bound.startsWith(`${gone}:doc:`));
2301
3144
  });
2302
3145
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
2303
3146
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
2304
3147
  let lastSubmitted = null;
3148
+ // The approved Datatrans answers, per Frame: the repeat rule (datatrans.ts).
3149
+ const datatransRepeats = new DatatransRepeats();
3150
+ const frameOf = (request) => { try {
3151
+ return request.frame?.() ?? undefined;
3152
+ }
3153
+ catch {
3154
+ return undefined;
3155
+ } };
2305
3156
  const install = async (assertActive) => {
2306
3157
  if (page.isClosed?.()) {
2307
3158
  const failure = new CheckoutAttachmentError('closed');
2308
3159
  setupStop.abort(failure);
2309
3160
  throw failure;
2310
3161
  }
2311
- await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString())
3162
+ await page.route(
3163
+ // The matcher sees only the URL. ACI's /payment and /registration are not the recognizer's URL,
3164
+ // so the ACI gate routes them (never ACI's widget script, /bins or /v1/collect).
3165
+ // The matcher sees only the URL. Worldpay eProtect's registration is a GET, so its family and
3166
+ // the vendor's give-up reports are routed by the eProtect gate (never its scripts).
3167
+ (url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString())
3168
+ || eprotect.matches(url.toString())
2312
3169
  // An armed merchant profile's endpoint or a sibling of it (merchant-hosted.ts), as attachToCdp arms it.
2313
3170
  || (typeof opts.vault.merchantProfileOf === 'function' && opts.vault.merchantProfileOf(url.toString()) !== null)
3171
+ // An armed Verifone merchant's origin (verifone.ts), as attachToCdp arms it.
3172
+ || (typeof opts.vault.verifoneProfileOf === 'function' && opts.vault.verifoneProfileOf(url.toString()) !== null)
2314
3173
  // GMO token v1's JSONP GET, routed only for an org the API served GMO to (see attachToCdp).
2315
- || (isGmoTokenV1Request(url.toString()) && recognizesGmo(opts.vault)), async (route) => {
3174
+ || (isGmoTokenV1Request(url.toString()) && recognizesGmo(opts.vault))
3175
+ || juspayCheckout.matches(url.toString())
3176
+ // Every reviewed card endpoint, in any status (the pre-claim gate holds only a request that carries a card,
3177
+ // and refuses one this build does not pay).
3178
+ || cardEndpointFor(url.toString(), 'POST') !== null
3179
+ // A request that sends the typed card to such a merchant again, refused in every status.
3180
+ || cardEndpointResendFor(url.toString(), 'POST') !== null
3181
+ || aci.matches(url.toString())
3182
+ // Every Payrails host, routed only for an org the API served Payrails to (see attachToCdp).
3183
+ || (payrails !== null && isPayrailsHost(url.toString()))
3184
+ // Every request to an NMI host whose entry is served (ekashu.ts), as attachToCdp arms the
3185
+ // host: the card page's post and its 3-D Secure calls reach the NMI gate and fail.
3186
+ || isArmedEkashuUrl(opts.vault, url.toString()), async (route) => {
2316
3187
  const request = route.request();
2317
3188
  // When this request paused, and the page it came from: the merchant's total is read as of now.
2318
3189
  const pausedAt = Date.now();
3190
+ // The frame and document a Shiji STS request came from, read as it pauses: an approved
3191
+ // getTokenId's companion binding belongs to that document (playwrightShijiDocumentPage).
3192
+ const shijiDocumentPage = isShijiStsHost(request.url()) ? playwrightShijiDocumentPage(request) : null;
2319
3193
  const pausedPage = merchantTotal.pageOf(keyOf(request)) ?? `doc:${documentSerial}`;
2320
3194
  // One pre-claim gate registry decides and performs the mechanics through
2321
3195
  // Playwright's own routing; the accessors read the same state the inline
@@ -2327,14 +3201,36 @@ export async function attachToPlaywright(page, opts) {
2327
3201
  // headers() and postData() are read lazily, inside the gate that needs them (see
2328
3202
  // PreClaimContext.request): a Playwright request mock without headers() and no Stripe checkout
2329
3203
  // never invokes it, and a body Playwright cannot read stops a Mercado checkout the way it did inline.
2330
- request: { url: request.url(), method: request.method(), get headers() { return request.headers(); }, get body() { return request.postData(); } },
2331
- vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout,
3204
+ // postData() is null for no body, an empty one and a streamed upload alike; Payrails'
3205
+ // gate refuses a null or empty body on every adapter, its static host aside (see payrailsPreClaim).
3206
+ // At an armed Verifone merchant's origin a gate reads that null as '', a request with no body:
3207
+ // in Chromium, Playwright hands over the bytes of a Blob, a File, a FormData and a large JSON
3208
+ // body (measured on fetch, XMLHttpRequest and sendBeacon), so its null there is a request with no
3209
+ // body in practice; a body Chromium keeps no bytes for, such as a stream, is the one left, and
3210
+ // attachToCdp aborts it. Every other request's gate reads the null as it comes.
3211
+ request: { url: request.url(), method: request.method(), get headers() { return request.headers(); },
3212
+ get body() {
3213
+ const body = request.postData();
3214
+ return body === null && typeof opts.vault.verifoneProfileOf === 'function' && opts.vault.verifoneProfileOf(request.url()) !== null ? '' : body;
3215
+ },
3216
+ get navigation() { try {
3217
+ return request.isNavigationRequest?.() === true;
3218
+ }
3219
+ catch {
3220
+ return false;
3221
+ } } },
3222
+ vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout, aci, eprotect,
3223
+ // Asked once, and only for ACI's classic-mode /payment: whether this page's engine sends
3224
+ // the body route.continue replaces on a navigation (aci.ts playwrightReplacesNavigationBody).
3225
+ replacesNavigationBody: () => (navigationBodyReplaced ??= playwrightReplacesNavigationBody(page)),
2332
3226
  readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
3227
+ // Read only for a card endpoint whose page submits its form as a navigation (see attachToCdp).
3228
+ navigation: async () => playwrightNavigation(request),
2333
3229
  effects: {
2334
3230
  continueRequest: (postData) => postData !== undefined ? route.continue({ postData }) : route.fallback(),
2335
3231
  answer: (response) => route.fulfill({ ...response,
2336
3232
  headers: withCorsHeaders(response.headers, corsHeadersFor(request.url(), request.headers())) }),
2337
- abort: () => route.abort(refusedRequestFailure(request.url())),
3233
+ abort: () => route.abort(refusalFailure(request.url())),
2338
3234
  },
2339
3235
  attachmentReady: () => attachmentReady,
2340
3236
  interceptionAborted: () => setupStop.signal.aborted,
@@ -2344,6 +3240,19 @@ export async function attachToPlaywright(page, opts) {
2344
3240
  stripeAttachmentBusy: () => false,
2345
3241
  requestInactive: () => !!(page.isClosed?.() || request.failure?.()),
2346
3242
  awaitingApproval: () => awaitingApproval,
3243
+ // The request's frame, then each parent up to the page's main frame; a request with no frame
3244
+ // (a service worker's) has no chain.
3245
+ initiatorFrames: () => {
3246
+ const urls = [];
3247
+ try {
3248
+ for (let frame = request.frame(); frame && urls.length < 64; frame = frame.parentFrame())
3249
+ urls.push(frame.url());
3250
+ }
3251
+ catch {
3252
+ return undefined;
3253
+ }
3254
+ return urls.length ? urls : undefined;
3255
+ },
2347
3256
  stripeStep: null,
2348
3257
  // This route arrives after every answer the page read before sending it; only an unfollowed 3xx is left to settle.
2349
3258
  merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, () => { handoffAnswer?.settle(); return false; }) },
@@ -2365,6 +3274,11 @@ export async function attachToPlaywright(page, opts) {
2365
3274
  }
2366
3275
  return judgeMicroformFrameUrls(flexRequest, frameUrlOf(issuing), frames.map(frameUrlOf), microformContexts);
2367
3276
  } },
3277
+ juspay: juspayCheckout,
3278
+ datatrans: datatransRepeats,
3279
+ frame: frameOf(request),
3280
+ payrails,
3281
+ shijiSts: { companions: shijiCompanions, page: async () => (await playwrightShijiDocumentPage(request)) ?? `pw:none:${++playwrightShijiSeq}` },
2368
3282
  });
2369
3283
  const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
2370
3284
  if (preClaim.kind !== 'pause')
@@ -2372,17 +3286,35 @@ export async function attachToPlaywright(page, opts) {
2372
3286
  const stripeStep = preClaim.stripeStep;
2373
3287
  // The capture context and fields a CyberSource Flex create carries, and its retry shape.
2374
3288
  const flexPause = preClaim.cybersourceFlex;
3289
+ // A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts), and
3290
+ // for one whose page submits its form as a navigation, which frame it navigates.
3291
+ const cardEndpoint = preClaim.cardEndpoint ?? null;
3292
+ // A Payrails card save the gate bound to a captured session: its key rides on the create.
3293
+ const payrailsSidecar = preClaim.payrails;
3294
+ const cardNavigation = preClaim.navigation;
3295
+ // A card form navigation is refused with the stopped page, anything else as refusalFailure says (see attachToCdp
3296
+ // refusePaused).
3297
+ const refuseRoute = (target, refusal) => (cardEndpoint && cardEndpoint.navigation !== null
3298
+ ? target.fulfill(stoppedNavigationPage(cardEndpoint, refusal.outcome, refusal.reason, refusal.authorizationId))
3299
+ : target.abort(refusalFailure(request.url())));
2375
3300
  if (!attachmentReady || setupStop.signal.aborted) {
2376
3301
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
2377
- return route.abort(refusedRequestFailure(request.url()));
3302
+ return refuseRoute(route, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
2378
3303
  }
3304
+ // Same order as attachToCdp: a routine card-free request continues in
3305
+ // every state, before the preparation claim, so it neither spends a
3306
+ // ready preparation nor counts as the page's first card request.
3307
+ const continuationBody = request.postData();
3308
+ const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody, lifecycle.cardHandoffs()) ?? null;
3309
+ if (withoutCard === 'continue')
3310
+ return route.fallback();
2379
3311
  let preparation;
2380
3312
  try {
2381
- preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '', request.headers());
3313
+ preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.method(), request.postData() ?? '', request.headers());
2382
3314
  }
2383
3315
  catch (error) {
2384
3316
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
2385
- return route.abort(refusedRequestFailure(request.url()));
3317
+ return route.abort(refusalFailure(request.url()));
2386
3318
  }
2387
3319
  // The page asking again for the purchase an outstanding approval is
2388
3320
  // for, after its own request timed out: bind it to that approval (see
@@ -2405,30 +3337,24 @@ export async function attachToPlaywright(page, opts) {
2405
3337
  return;
2406
3338
  }
2407
3339
  }
2408
- // Same order as attachToCdp: a routine card-free request continues in every state.
2409
- const continuationBody = request.postData();
2410
- const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody) ?? null;
2411
- if (withoutCard === 'continue') {
2412
- if (preparation)
2413
- preparationGate.retireUnboundClaim();
2414
- return route.fallback();
2415
- }
2416
- const continuationId = stripeStep || terminal ? null
3340
+ const continuation = stripeStep || terminal ? null
2417
3341
  : claimStripeContinuation(opts, lifecycle, request.url(), request.method(), continuationBody);
2418
- if (continuationId) {
3342
+ if (continuation) {
2419
3343
  if (preparation)
2420
3344
  preparationGate.retireUnboundClaim();
2421
- if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url(), method: request.method(), headers: request.headers(), body: continuationBody }) && !setupStop.signal.aborted) {
2422
- return route.fallback();
3345
+ if (await checkStripeContinuation(opts, lifecycle, continuation, { url: request.url(), method: request.method(), headers: request.headers() }) && !setupStop.signal.aborted) {
3346
+ // As in attachToCdp: a rewritten card save continues with the allowed body; Playwright recomputes the length.
3347
+ return continuation.rewritten ? route.continue({ postData: continuation.body }) : route.fallback();
2423
3348
  }
2424
- return route.abort('aborted');
3349
+ return route.abort(refusalFailure(request.url()));
2425
3350
  }
2426
3351
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
2427
3352
  // the page's retry loop turn into a stream of doomed API calls. Every
2428
3353
  // abort in this adapter is 'aborted' (ERR_ABORTED), and a CyberSource
2429
3354
  // Flex request's is 'failed' (refusedRequestFailure), the same codes the
2430
3355
  // CDP adapter's Fetch.failRequest uses, so a refused request resolves
2431
- // identically whichever adapter is attached.
3356
+ // identically whichever adapter is attached. An Amadeus card request is
3357
+ // 'failed' (ERR_FAILED) in both too, see failsAsNetworkError (refusalFailure).
2432
3358
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
2433
3359
  let repeatedAuthorizationId = null;
2434
3360
  if (lastSubmitted) {
@@ -2445,7 +3371,8 @@ export async function attachToPlaywright(page, opts) {
2445
3371
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
2446
3372
  if (preparation)
2447
3373
  preparationGate.retireUnboundClaim();
2448
- const refused = await route.abort(refusedRequestFailure(request.url()));
3374
+ // As in attachToCdp: never that nothing was sent while a payment for this checkout may exist.
3375
+ const refused = await refuseRoute(route, heldNavigationRefusal(terminal, awaitingApproval, lifecycle));
2449
3376
  if (repeatedAuthorizationId)
2450
3377
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
2451
3378
  return refused;
@@ -2455,7 +3382,7 @@ export async function attachToPlaywright(page, opts) {
2455
3382
  if (preparation)
2456
3383
  preparationGate.retireUnboundClaim();
2457
3384
  opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
2458
- return route.abort('aborted');
3385
+ return route.abort(refusalFailure(request.url()));
2459
3386
  }
2460
3387
  // Reserved before anything that could yield, matching attachToCdp.
2461
3388
  awaitingApproval = true;
@@ -2466,7 +3393,9 @@ export async function attachToPlaywright(page, opts) {
2466
3393
  catch {
2467
3394
  navigation = false;
2468
3395
  }
2469
- const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url(), request.method(), navigation, !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url(), request.method())));
3396
+ // A card endpoint approval never outlives its request (see attachToCdp).
3397
+ const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url(), request.method(), navigation, !!preparation || !!stripeStep || !!cardEndpoint || mercadoCheckout.requiresDocument(request.url(), request.method())
3398
+ || juspayCheckout.bindsApproval(request.url(), request.method())));
2470
3399
  const frames = [];
2471
3400
  try {
2472
3401
  for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
@@ -2487,28 +3416,40 @@ export async function attachToPlaywright(page, opts) {
2487
3416
  // The approval the device gave, and whether it reached a browser request.
2488
3417
  let approvedId = null;
2489
3418
  let deliveryBegun = false;
3419
+ // A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
3420
+ let disarmSendWindow = null;
2490
3421
  try {
2491
3422
  const body = request.postData() ?? '';
2492
3423
  if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
2493
3424
  opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
2494
- const refused = await route.abort(refusedRequestFailure(request.url()));
3425
+ const refused = await route.abort(refusalFailure(request.url()));
2495
3426
  if (lastSubmitted)
2496
3427
  void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
2497
3428
  return refused;
2498
3429
  }
2499
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url()), recognizer: recognizerNameOf(opts, request.url(), request.method()) } });
3430
+ // A card endpoint's events name the profile and the endpoint origin, never its URL (see attachToCdp).
3431
+ opts.onEvent?.({ type: 'card_request_paused', detail: { ...(cardEndpoint ? cardEndpointDetail(cardEndpoint) : { url: eventUrl(opts.vault, request.url()) }),
3432
+ recognizer: recognizerNameOf(opts, request.url(), request.method()) } });
2500
3433
  lifecycle.begin();
2501
3434
  const approvalStartedAt = Date.now();
2502
3435
  const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
2503
3436
  attempt.bind({ route, request, frames });
2504
3437
  activeRequest = { request, frames, attempt,
2505
3438
  identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined, flexPause?.shape) };
3439
+ // An ACI card-number request: see the same step in attachToCdp.
3440
+ if (!aci.begin(request.url(), request.method(), body, preparation?.merchantOrigin)) {
3441
+ throw new CheckoutPreparationError(preparation?.id ?? null, ACI_CHECKOUT_REUSED);
3442
+ }
3443
+ // A Worldpay eProtect registration: see the same step in attachToCdp.
3444
+ eprotect.begin(request.url(), request.method(), () => attempt.stop());
3445
+ disarmSendWindow = armWindowFor(opts, lifecycle, attempt, preparation, request.url(), () => deliveryBegun);
2506
3446
  if (preparation)
2507
3447
  await preparationGate.assertDocument();
2508
3448
  // One bounded read serves the merchant origin and the retry identity; see attachToCdp.
2509
3449
  const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
2510
3450
  const pageOrigin = pageOriginFrom(documentUrl);
2511
- const pageAmount = await pageAmountOf(opts);
3451
+ // The agent's amount is a card endpoint's only authority: no page total is read.
3452
+ const pageAmount = cardEndpoint ? undefined : await pageAmountOf(opts);
2512
3453
  assertRequestLive();
2513
3454
  activeRequest.identity.documentUrl = documentKey(documentUrl);
2514
3455
  activeRequest.identity.pageAmount = pageAmount;
@@ -2518,6 +3459,30 @@ export async function attachToPlaywright(page, opts) {
2518
3459
  const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
2519
3460
  ? opts.merchantOrigin : pageOrigin?.startsWith('https:') ? pageOrigin : opts.merchantOrigin);
2520
3461
  assertRequestLive();
3462
+ // The card endpoint's session (see attachToCdp): request.headers() never carries
3463
+ // the cookie jar, so the endpoint's cookies are read from the context.
3464
+ // A form inside an iframe is judged by the iframe's own document, where it was submitted, and
3465
+ // a checkout in a frame the merchant's page embeds by that frame's own document.
3466
+ let capturedOrigin = pageOrigin;
3467
+ if (cardEndpoint?.navigation === 'frame') {
3468
+ try {
3469
+ capturedOrigin = pageOriginFrom(request.frame?.()?.url?.());
3470
+ }
3471
+ catch {
3472
+ capturedOrigin = undefined;
3473
+ }
3474
+ }
3475
+ else if (cardEndpoint?.checkout_frame) {
3476
+ capturedOrigin = pageOriginFrom(await documentUrlOf(async () => String(request.frame().url()), attempt.signal));
3477
+ }
3478
+ const session = cardEndpoint ? await captureCardEndpointSession(cardEndpoint, {
3479
+ url: request.url(), headers: request.headers(), body, pageOrigin: capturedOrigin, navigation: cardNavigation,
3480
+ cookies: async () => await page.context().cookies(request.url()),
3481
+ }) : null;
3482
+ assertRequestLive();
3483
+ // The merchant's own record of the order, as of the pause (see attachToCdp).
3484
+ const cardRecord = cardEndpoint ? await cardEndpointFacts(cardEndpointRecord, cardEndpoint, request.url(), pausedAt, attempt.signal) : null;
3485
+ assertRequestLive();
2521
3486
  const replay = await opts.vault.authorize({
2522
3487
  user: opts.user,
2523
3488
  merchant: opts.merchant,
@@ -2542,10 +3507,12 @@ export async function attachToPlaywright(page, opts) {
2542
3507
  lifecycle.approvalUrl(url);
2543
3508
  return opts.onApprovalUrl?.(url);
2544
3509
  } },
2545
- request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url(), method: request.method(), headers: request.headers(), body,
2546
- ...(mercadoCheckout.requiresDocument(request.url(), request.method())
2547
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}),
2548
- ...(flexPause ? flexPause.sidecar : {}) },
3510
+ request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session, cardRecord)
3511
+ : { url: request.url(), method: request.method(), headers: request.headers(), body,
3512
+ ...(mercadoCheckout.requiresDocument(request.url(), request.method())
3513
+ ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}),
3514
+ ...(flexPause ? flexPause.sidecar : {}),
3515
+ ...(payrailsSidecar ? { payrails: payrailsSidecar } : {}) },
2549
3516
  });
2550
3517
  assertRequestLive();
2551
3518
  approvedId = replay.authorizationId;
@@ -2570,7 +3537,17 @@ export async function attachToPlaywright(page, opts) {
2570
3537
  attempt.beginDelivery();
2571
3538
  lifecycle.prepareHandoff(replay, deliverRequest.url());
2572
3539
  handoffStarted = replay.mode !== 'cse';
2573
- if (replay.mode === 'hosted_form') {
3540
+ // False only for a card endpoint answer the device withheld (see deliverCardEndpointAnswer).
3541
+ let handedOver = true;
3542
+ if (replay.mode === 'card_endpoint') {
3543
+ handedOver = await deliverCardEndpointAnswer(opts, lifecycle, cardEndpoint, replay, { url: deliverRequest.url(), headers: deliverRequest.headers() }, {
3544
+ readJar: () => page.context().cookies(deliverRequest.url()),
3545
+ writeCookie: (cookie) => page.context().addCookies([playwrightCookieParams(cookie)]),
3546
+ fulfill: (response) => deliverTo.fulfill(response),
3547
+ assertLive: assertRequestLive,
3548
+ });
3549
+ }
3550
+ else if (replay.mode === 'hosted_form') {
2574
3551
  // Same as the CDP path: the paused navigation resolves to the
2575
3552
  // synthetic page, and a re-post of this form is refused.
2576
3553
  const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
@@ -2588,8 +3565,26 @@ export async function attachToPlaywright(page, opts) {
2588
3565
  if (replay.kind === 'merchant_hosted')
2589
3566
  await merchantTotal.release(replay, opts.vault, attempt.signal);
2590
3567
  assertRequestLive();
2591
- const postData = cseContinuationBody(opts, deliverBody, replay, deliverRequest.url(), preparation);
3568
+ // A Shiji STS getTokenId binds the device's cvv to its companion
3569
+ // first, exactly as in attachToCdp: only for the request the
3570
+ // approval was created from, and its frame.
3571
+ const continuation = cseContinuation(opts, deliverRequest.url(), deliverBody, replay, { url: request.url(), body }, preparation);
3572
+ if (continuation.shijiCvv !== null) {
3573
+ // The document the getTokenId paused in. One that cannot be read leaves the CVV nowhere to
3574
+ // go, so the card is not tokenized either.
3575
+ const boundPage = shijiDocumentPage ? await shijiDocumentPage : null;
3576
+ if (boundPage === null)
3577
+ throw new ShijiStsError('unsupported');
3578
+ assertRequestLive();
3579
+ shijiCompanions.bind({ url: request.url(), method: request.method(), body }, boundPage, continuation.shijiCvv, continuation.shijiCvvKeySequences ?? []);
3580
+ }
2592
3581
  handoffStarted = true;
3582
+ if (replay.kind === 'verifone') {
3583
+ // The merchant's own server answers a Verifone continuation, as a merchant-hosted one.
3584
+ merchantHandoff.begin(replay.profile, replay.authorizationId, verifoneTemplatedUrl(replay.profile, deliverRequest.url()));
3585
+ handoffAnswer?.stop();
3586
+ handoffAnswer = watchPlaywrightHandoff(page, deliverRequest, merchantHandoff);
3587
+ }
2593
3588
  if (replay.kind === 'merchant_hosted') {
2594
3589
  merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url()));
2595
3590
  // Watched from before the continue: 5xx, a failure or the page closing is an unknown outcome.
@@ -2597,9 +3592,16 @@ export async function attachToPlaywright(page, opts) {
2597
3592
  handoffAnswer?.stop();
2598
3593
  handoffAnswer = watchPlaywrightHandoff(page, deliverRequest, merchantHandoff, () => merchantTotal.answered(replay, opts.vault));
2599
3594
  }
2600
- await deliverTo.continue({ postData });
3595
+ try {
3596
+ await deliverTo.continue({ postData: continuation.body });
3597
+ }
3598
+ catch (error) {
3599
+ if (continuation.shijiCvv !== null)
3600
+ shijiCompanions.release();
3601
+ throw error;
3602
+ }
2601
3603
  assertRequestLive();
2602
- opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url()) });
3604
+ opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url(), continuation.shijiCvv) });
2603
3605
  }
2604
3606
  else {
2605
3607
  // Playwright adds these itself when a cross-origin fulfill carries
@@ -2607,10 +3609,17 @@ export async function attachToPlaywright(page, opts) {
2607
3609
  // cross-origin answer is the same whichever adapter ran.
2608
3610
  const cors = corsDecision(deliverRequest.url(), deliverRequest.headers());
2609
3611
  await deliverTo.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
3612
+ datatransRepeats.remember(frameOf(deliverRequest), deliverRequest.url(), deliverBody, replay);
2610
3613
  assertRequestLive();
2611
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
3614
+ // See the same step in attachToCdp.
3615
+ aci.answered(deliverRequest.url(), { status: replay.status, headers: replay.headers, body: replay.body }, replay.authorizationId);
3616
+ // See the same step in attachToCdp.
3617
+ eprotect.delivered(deliverRequest.url(), () => lifecycle.merchantRequestAborted());
3618
+ opts.onEvent?.({ type: 'authorized', detail: { mode: replay.mode === 'device_encrypt' ? 'device_encrypt' : 'token', authorizationId: replay.authorizationId,
3619
+ amountVerified: replay.mode === 'device_encrypt' ? null : replay.amountVerified ?? null, cors: cors.outcome } });
2612
3620
  }
2613
- lifecycle.handedOff(replay);
3621
+ if (handedOver)
3622
+ lifecycle.handedOff(replay);
2614
3623
  }
2615
3624
  catch (err) {
2616
3625
  const dead = attempt.target?.route ?? route;
@@ -2635,13 +3644,18 @@ export async function attachToPlaywright(page, opts) {
2635
3644
  terminal = err;
2636
3645
  else if (isApprovalOutcome(err))
2637
3646
  quietUntil = Date.now() + cooldownMs;
3647
+ reportCardEndpointRefusal(opts, cardEndpoint, err);
2638
3648
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
2639
3649
  }
2640
- await dead.abort(refusedRequestFailure(request.url())).catch(() => { });
3650
+ // As in attachToCdp: a card form navigation ends on the stopped page (failedNavigationRefusal).
3651
+ await Promise.resolve().then(() => refuseRoute(dead, failedNavigationRefusal(err, handoffStarted || approvedId !== null, approvedId ?? lifecycle.getState().authorizationId))).catch(() => { });
2641
3652
  }
2642
3653
  finally {
3654
+ disarmSendWindow?.();
2643
3655
  if (activeRequest?.attempt === attempt)
2644
3656
  activeRequest = null;
3657
+ aci.end();
3658
+ eprotect.end();
2645
3659
  awaitingApproval = false;
2646
3660
  lifecycle.end();
2647
3661
  if (preparation)