@agent-cards/sdk 0.20.0 → 0.22.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 (111) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/PREFLIGHT.md +34 -0
  3. package/README.md +1217 -15
  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 +283 -49
  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.d.ts +177 -0
  18. package/dist/card-endpoint.js +286 -0
  19. package/dist/card-fields.generated.d.ts +5 -3
  20. package/dist/card-fields.generated.js +284 -7
  21. package/dist/cdp.d.ts +6 -1
  22. package/dist/cdp.js +983 -147
  23. package/dist/client.d.ts +493 -13
  24. package/dist/client.js +1100 -82
  25. package/dist/creditguard.generated.d.ts +79 -0
  26. package/dist/creditguard.generated.js +662 -0
  27. package/dist/cse-body.js +11 -1
  28. package/dist/cybersource-flex.d.ts +183 -0
  29. package/dist/cybersource-flex.generated.d.ts +92 -0
  30. package/dist/cybersource-flex.generated.js +689 -0
  31. package/dist/cybersource-flex.js +275 -0
  32. package/dist/datatrans.d.ts +96 -0
  33. package/dist/datatrans.generated.d.ts +64 -0
  34. package/dist/datatrans.generated.js +426 -0
  35. package/dist/datatrans.js +178 -0
  36. package/dist/elavon-converge.d.ts +37 -0
  37. package/dist/elavon-converge.generated.d.ts +49 -0
  38. package/dist/elavon-converge.generated.js +439 -0
  39. package/dist/elavon-converge.js +84 -0
  40. package/dist/eprotect.d.ts +152 -0
  41. package/dist/eprotect.generated.d.ts +77 -0
  42. package/dist/eprotect.generated.js +498 -0
  43. package/dist/eprotect.js +222 -0
  44. package/dist/gmo.d.ts +63 -0
  45. package/dist/gmo.generated.d.ts +60 -0
  46. package/dist/gmo.generated.js +586 -0
  47. package/dist/gmo.js +119 -0
  48. package/dist/hosted-form.d.ts +3 -2
  49. package/dist/hosted-form.js +3 -2
  50. package/dist/index.d.ts +19 -3
  51. package/dist/index.js +6 -1
  52. package/dist/juspay-gate.d.ts +82 -0
  53. package/dist/juspay-gate.js +126 -0
  54. package/dist/juspay.generated.d.ts +89 -0
  55. package/dist/juspay.generated.js +705 -0
  56. package/dist/lifecycle.d.ts +62 -6
  57. package/dist/lifecycle.js +123 -8
  58. package/dist/merchant-card-endpoints.generated.d.ts +63 -0
  59. package/dist/merchant-card-endpoints.generated.js +1103 -0
  60. package/dist/merchant-hosted.d.ts +4 -1
  61. package/dist/merchant-hosted.js +5 -2
  62. package/dist/merchant-session-probe.d.ts +48 -0
  63. package/dist/merchant-session-probe.js +53 -0
  64. package/dist/merchant-session.generated.d.ts +69 -0
  65. package/dist/merchant-session.generated.js +919 -0
  66. package/dist/payrails.d.ts +133 -0
  67. package/dist/payrails.generated.d.ts +73 -0
  68. package/dist/payrails.generated.js +596 -0
  69. package/dist/payrails.js +305 -0
  70. package/dist/pelecard.d.ts +32 -0
  71. package/dist/pelecard.generated.d.ts +32 -0
  72. package/dist/pelecard.generated.js +283 -0
  73. package/dist/pelecard.js +42 -0
  74. package/dist/pre-claim.d.ts +84 -3
  75. package/dist/pre-claim.js +987 -9
  76. package/dist/preflight-catalog.json +2310 -263
  77. package/dist/preflight-schemas.json +187 -6
  78. package/dist/preflight.generated.d.ts +1 -1
  79. package/dist/preflight.generated.js +247 -4
  80. package/dist/preparation.d.ts +5 -2
  81. package/dist/preparation.js +36 -10
  82. package/dist/prepared-processor.d.ts +86 -5
  83. package/dist/prepared-processor.js +157 -6
  84. package/dist/primer.d.ts +53 -0
  85. package/dist/primer.generated.d.ts +38 -0
  86. package/dist/primer.generated.js +624 -0
  87. package/dist/primer.js +82 -0
  88. package/dist/registry.d.ts +114 -12
  89. package/dist/registry.js +85 -11
  90. package/dist/shiji-sts.d.ts +144 -0
  91. package/dist/shiji-sts.generated.d.ts +72 -0
  92. package/dist/shiji-sts.generated.js +913 -0
  93. package/dist/shiji-sts.js +222 -0
  94. package/dist/stripe-checkout.generated.d.ts +1 -1
  95. package/dist/stripe-checkout.generated.js +735 -10
  96. package/dist/stripe-setup.d.ts +16 -0
  97. package/dist/stripe-setup.js +61 -0
  98. package/dist/substitutions.generated.js +187 -29
  99. package/dist/verifone.d.ts +68 -0
  100. package/dist/verifone.generated.d.ts +51 -0
  101. package/dist/verifone.generated.js +1401 -0
  102. package/dist/verifone.js +119 -0
  103. package/dist/voice-types.d.ts +27 -0
  104. package/dist/voice.d.ts +6 -1
  105. package/dist/voice.js +6 -1
  106. package/dist/wspay.d.ts +41 -0
  107. package/dist/wspay.generated.d.ts +41 -0
  108. package/dist/wspay.generated.js +370 -0
  109. package/dist/wspay.js +65 -0
  110. package/examples/preflight/kernel-native/inventory.json +1 -1
  111. package/package.json +3 -3
package/dist/cdp.js CHANGED
@@ -1,17 +1,42 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
- import { AdyenTestPlatformRefusedError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, 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';
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';
8
14
  import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
9
15
  import { cseBody } from './cse-body.js';
16
+ import { STRIPE_SETUP_INTENT_CONFIRM, setupConfirmPayingWith } from './stripe-setup.js';
10
17
  import { replayTemplatedUrl, reviewedMerchantProfileUrlPatterns } from './merchant-hosted.js';
18
+ import { verifoneTemplatedUrl } from './verifone.js';
11
19
  import { MerchantHandoffWatch } from './merchant-handoff.js';
12
20
  import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watch.js';
13
21
  import { hostedFormSubmittedPage } from './hosted-form.js';
22
+ import { amadeusSendWindowMs, armSendWindow, failsAsNetworkError } from './amadeus.js';
14
23
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
24
+ import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, refusedRequestFailure } from './cybersource-flex.js';
25
+ import { CARD_ENDPOINT_WITHHELD_ANSWER, CardEndpointRefusedError, captureCardEndpointSession, cardEndpointAnswerHeaders, cardEndpointCookies, cardEndpointDetail, cardEndpointFor, cardEndpointPatterns, } from './card-endpoint.js';
26
+ import { PAYRAILS_GUARD_PATTERNS, PAYRAILS_INIT_BODY_LIMIT, PayrailsSessionCapture, isPayrailsHost, payrailsInitCandidate, recognizesPayrails, } from './payrails.js';
27
+ /**
28
+ * How an adapter fails a paused request it will not let through: as a network error for a
29
+ * CyberSource Flex request (refusedRequestFailure, so the Microform field reports it to the page)
30
+ * and for an Amadeus card request (failsAsNetworkError, so the SDK's tunnel reports it at once), as
31
+ * an abort for every other request, as before.
32
+ */
33
+ function refusalFailure(url) {
34
+ return refusedRequestFailure(url) === 'failed' || failsAsNetworkError(url) ? 'failed' : 'aborted';
35
+ }
36
+ /** Fetch.failRequest's errorReason for the same request: the CDP form of refusalFailure. */
37
+ function cdpFailure(url) {
38
+ return refusalFailure(url) === 'failed' ? 'Failed' : 'Aborted';
39
+ }
15
40
  // Every URL that leaves this module through onEvent is redacted to origin +
16
41
  // path first. A paused PaymentIntent confirm can carry the client secret in
17
42
  // its query string (Stripe.js puts it in the body; hand-rolled runtimes and
@@ -121,6 +146,11 @@ const APPROVAL_COOLDOWN_MS = 5_000;
121
146
  * re-post is right; latching the whole attachment is not, because the prior
122
147
  * row declines or expires and the same form is then a new question. The
123
148
  * quiet window absorbs the burst; the next request past it is judged afresh.
149
+ *
150
+ * A 400 for a stale Authorize.Net fingerprint (CheckoutApiError
151
+ * isStaleFingerprint) is answered the same way: no prompt was raised, and the
152
+ * form the page renders after a reload carries a fresh fingerprint, so the
153
+ * attachment is quieted rather than latched.
124
154
  */
125
155
  function isApprovalOutcome(err) {
126
156
  // Processor refusals are held by the lifecycle until explicit merchant
@@ -129,7 +159,7 @@ function isApprovalOutcome(err) {
129
159
  if (err instanceof ProcessorRefusedError)
130
160
  return false;
131
161
  if (err instanceof CheckoutApiError)
132
- return err.code === 'duplicate_submission';
162
+ return err.code === 'duplicate_submission' || err.isStaleFingerprint;
133
163
  return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
134
164
  }
135
165
  /**
@@ -144,7 +174,9 @@ function isApprovalOutcome(err) {
144
174
  *
145
175
  * Terminal means "will answer identically next time no matter who does what":
146
176
  * a misconfiguration or an unsupported PSP. Nothing a person does changes
147
- * those, so asking again is pure waste.
177
+ * those, so asking again is pure waste. A GMO request, a Pelecard submission
178
+ * or a WSPay confirm with no `amount`
179
+ * (AmountRequiredError) is one: the attachment's options do not change.
148
180
  *
149
181
  * Other errors may be retryable unless the lifecycle holds the attachment
150
182
  * for merchant reconciliation, as it does after a processor refusal.
@@ -161,7 +193,12 @@ function isTerminal(err) {
161
193
  // Refused at create for Adyen's test platform: the page's next request is refused the same way.
162
194
  if (err instanceof AdyenTestPlatformRefusedError)
163
195
  return err.stage === 'create';
164
- return err instanceof CardEncryptedError || err instanceof UnsupportedModeError || err instanceof PreparationRequiredError;
196
+ // A card endpoint refusal about the attachment (no amount, Autopilot, a profile this
197
+ // build does not pay) answers every request alike; one about the request does not.
198
+ if (err instanceof CardEndpointRefusedError)
199
+ return err.permanent;
200
+ return err instanceof CardEncryptedError || err instanceof UnsupportedModeError || err instanceof PreparationRequiredError
201
+ || err instanceof AmountRequiredError || err instanceof UnsupportedCapabilityError;
165
202
  }
166
203
  /**
167
204
  * cseBody, retiring a merchant-hosted approval it refuses. The refusal (a live body
@@ -177,7 +214,7 @@ function cseContinuationBody(opts, body, replay, url, preparation) {
177
214
  return cseBody(body, replay, url, preparation);
178
215
  }
179
216
  catch (error) {
180
- if (replay.kind === 'merchant_hosted' && typeof opts.vault?.cancelAuthorization === 'function') {
217
+ if ((replay.kind === 'merchant_hosted' || replay.kind === 'verifone') && typeof opts.vault?.cancelAuthorization === 'function') {
181
218
  void Promise.resolve().then(() => opts.vault.cancelAuthorization(replay.authorizationId, 'merchant_never_retried')).catch(() => { });
182
219
  }
183
220
  throw error;
@@ -267,13 +304,121 @@ function watchPlaywrightHandoff(page, request, watch, answered) {
267
304
  watch.failed();
268
305
  } } };
269
306
  }
270
- /** The `authorized` event's detail for a cse continuation; a merchant-hosted one names its profile and templated URL. */
271
- function cseAuthorizedDetail(replay, url) {
272
- const base = { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) };
307
+ /** The `authorized` event's detail for a cse continuation; a merchant-hosted or Verifone one names its profile and templated URL. */
308
+ function cseAuthorizedDetail(replay, url, shijiCvv = null) {
309
+ if (replay.kind === 'verifone') {
310
+ return { mode: 'cse', authorizationId: replay.authorizationId, fields: ['encryptedCard'], kind: 'verifone', profile: replay.profile,
311
+ url: verifoneTemplatedUrl(replay.profile, url) };
312
+ }
313
+ // A Shiji STS getTokenId carried pan and expires; its cvv waits for the companion getCvvId.
314
+ const base = { mode: 'cse', authorizationId: replay.authorizationId, fields: shijiCvv !== null ? ['pan', 'expires'] : Object.keys(replay.substitutions.fields) };
273
315
  return replay.kind === 'merchant_hosted'
274
316
  ? { ...base, kind: 'merchant_hosted', profile: replay.profile, url: replayTemplatedUrl(replay, url) }
275
317
  : base;
276
318
  }
319
+ /**
320
+ * The cse continuation for a paused request, by processor. A Shiji STS
321
+ * getTokenId takes the device's pan and expires at the body root and hands back
322
+ * the device's cvv, which that body does not carry: the adapter binds it to the
323
+ * getCvvId companion right before the continue (shiji-sts.ts). It is written
324
+ * only into `approved`, the request the approval was created from, byte for
325
+ * byte, so the accessToken and keySequence are the ones the API reviewed, and
326
+ * the cvv is bound to the reviewed getCvvId keys the approval served
327
+ * (`shijiCvvKeySequences`): no keys, no continuation. Every other processor
328
+ * takes cseContinuationBody (cse-body.ts cseBody), and `shijiCvv` and
329
+ * `shijiCvvKeySequences` are null.
330
+ */
331
+ function cseContinuation(opts, url, body, replay, approved, preparation) {
332
+ const shiji = shijiStsTokenContinuation(url, body, replay.substitutions, approved, replay.kind === 'merchant_hosted' || replay.kind === 'verifone' ? undefined : replay.shijiStsCompanion);
333
+ return shiji ? { body: shiji.body, shijiCvv: shiji.cvv, shijiCvvKeySequences: shiji.cvvKeySequences }
334
+ : { body: cseContinuationBody(opts, body, replay, url, preparation), shijiCvv: null, shijiCvvKeySequences: null };
335
+ }
336
+ /**
337
+ * The opaque page id a Shiji STS companion is bound to: the CDP session and
338
+ * frame the request paused in. The getCvvId must come from the same frame as
339
+ * the getTokenId it follows. Kept to the printable ASCII the binding accepts.
340
+ */
341
+ function cdpShijiPage(sessionId, frameId) {
342
+ return `${cdpShijiSession(sessionId)}${typeof frameId === 'string' && frameId ? frameId : 'none'}`.replace(/[^\x21-\x7e]/g, '_').slice(0, 256);
343
+ }
344
+ /** The start every cdpShijiPage id of one CDP session shares, so a detached target releases its binding. */
345
+ function cdpShijiSession(sessionId) {
346
+ return `cdp:${sessionId ?? 'root'}:`.replace(/[^\x21-\x7e]/g, '_');
347
+ }
348
+ /**
349
+ * The same for a Playwright request: its frame, numbered once per attachment
350
+ * process. A request with no frame (a service worker's) gets an id no other
351
+ * request shares, so it never binds.
352
+ */
353
+ const playwrightShijiFrames = new WeakMap();
354
+ let playwrightShijiSeq = 0;
355
+ function playwrightShijiPage(request) {
356
+ let frame = null;
357
+ try {
358
+ frame = request?.frame?.() ?? null;
359
+ }
360
+ catch {
361
+ frame = null;
362
+ }
363
+ if (!frame || typeof frame !== 'object')
364
+ return `pw:none:${++playwrightShijiSeq}`;
365
+ let id = playwrightShijiFrames.get(frame);
366
+ if (!id) {
367
+ id = `pw:frame:${++playwrightShijiSeq}`;
368
+ playwrightShijiFrames.set(frame, id);
369
+ }
370
+ return id;
371
+ }
372
+ /**
373
+ * The document a Playwright frame holds: its performance.timeOrigin, read in that frame. A new
374
+ * document in the frame (a reload, about:blank, a page that answered a link) has its own, while
375
+ * history.pushState, a hash change and a navigation the page cancels keep the document and so this
376
+ * value: Playwright reports all of them as framenavigated and names no document, so the adapter reads
377
+ * it. Null when the frame or its document cannot be read (no frame, detached, replaced mid-read);
378
+ * undefined when the read has not settled within the companion window: a frame with a navigation
379
+ * pending answers only once that navigation commits or is cancelled, and past the window no
380
+ * getCvvId could claim the binding anyway.
381
+ */
382
+ async function playwrightFrameDocument(frame) {
383
+ if (!frame || typeof frame !== 'object' || typeof frame.evaluate !== 'function')
384
+ return null;
385
+ let timer;
386
+ try {
387
+ const origin = await Promise.race([
388
+ Promise.resolve(frame.evaluate('performance.timeOrigin')),
389
+ new Promise((resolve) => { timer = setTimeout(() => resolve(UNSETTLED), SHIJI_STS_COMPANION_WINDOW_MS); }),
390
+ ]);
391
+ if (origin === UNSETTLED)
392
+ return undefined;
393
+ return typeof origin === 'number' && Number.isFinite(origin) ? String(origin) : null;
394
+ }
395
+ catch {
396
+ return null;
397
+ }
398
+ finally {
399
+ if (timer)
400
+ clearTimeout(timer);
401
+ }
402
+ }
403
+ const UNSETTLED = Symbol('unsettled');
404
+ /**
405
+ * The opaque page id a Shiji STS companion is bound to in Playwright: the request's frame
406
+ * (playwrightShijiPage) and the document that frame holds (playwrightFrameDocument), so a getCvvId
407
+ * from a document that replaced the getTokenId's never matches the binding. Null when the document
408
+ * cannot be read.
409
+ */
410
+ async function playwrightShijiDocumentPage(request) {
411
+ const page = playwrightShijiPage(request);
412
+ let frame = null;
413
+ try {
414
+ frame = request?.frame?.() ?? null;
415
+ }
416
+ catch {
417
+ frame = null;
418
+ }
419
+ const document = await playwrightFrameDocument(frame);
420
+ return typeof document === 'string' ? `${page}:doc:${document}` : null;
421
+ }
277
422
  /**
278
423
  * The paused request's body, or null when Chromium says there is one and did
279
424
  * not hand it over. `postData` is set only for a text body; a binary or a
@@ -293,6 +438,66 @@ function pausedBody(request) {
293
438
  return null;
294
439
  return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
295
440
  }
441
+ /** The frames of a Page.getFrameTree answer from its root down to `frameId`, or undefined when it is not there. */
442
+ function framePath(node, frameId, depth = 0) {
443
+ if (!node?.frame || depth > 64)
444
+ return undefined;
445
+ if (node.frame.id === frameId)
446
+ return [node.frame];
447
+ for (const child of Array.isArray(node.childFrames) ? node.childFrames : []) {
448
+ const path = framePath(child, frameId, depth + 1);
449
+ if (path)
450
+ return [node.frame, ...path];
451
+ }
452
+ return undefined;
453
+ }
454
+ /**
455
+ * The URLs of the frame a paused request came from and of every frame above it, sender first. A
456
+ * session answers only for its own frames (Page.getFrameTree), and a cross-origin frame is a session
457
+ * of its own, so at the root of one session the walk moves on to the session that attached it, from
458
+ * the frame the child named as its parent (the root's Frame.parentId, else the target's
459
+ * parentFrameId). Undefined unless the walk reaches the page's own root within one second in all: a
460
+ * frame that is not found, a session that does not answer and a parent frame nobody named each leave
461
+ * the chain unread. Asked only for a request the pre-claim gates attribute through its frames (see
462
+ * nuveiPreClaim in pre-claim.ts).
463
+ */
464
+ async function pausedFrameChain(cdp, sessionId, frameId, parentOf) {
465
+ if (typeof frameId !== 'string' || !frameId)
466
+ return undefined;
467
+ const deadline = Date.now() + 1_000;
468
+ const urls = [];
469
+ let session = sessionId, frame = frameId;
470
+ for (let hop = 0; hop < 16; hop++) {
471
+ let timer;
472
+ let tree;
473
+ try {
474
+ tree = await Promise.race([
475
+ cdp.send('Page.getFrameTree', {}, session),
476
+ new Promise(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - Date.now())); }),
477
+ ]);
478
+ }
479
+ catch {
480
+ return undefined;
481
+ }
482
+ finally {
483
+ clearTimeout(timer);
484
+ }
485
+ const path = framePath(tree?.frameTree, frame);
486
+ if (!path)
487
+ return undefined;
488
+ for (let i = path.length - 1; i >= 0; i--)
489
+ urls.push(typeof path[i].url === 'string' ? path[i].url : '');
490
+ const parent = session === undefined ? undefined : parentOf(session);
491
+ if (!parent)
492
+ return urls;
493
+ const next = typeof path[0].parentId === 'string' && path[0].parentId ? path[0].parentId : parent.frameId;
494
+ if (!next)
495
+ return undefined;
496
+ session = parent.sessionId;
497
+ frame = next;
498
+ }
499
+ return undefined;
500
+ }
296
501
  const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
297
502
  /**
298
503
  * The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
@@ -412,6 +617,96 @@ export function withCorsHeaders(headers, cors) {
412
617
  }
413
618
  return out;
414
619
  }
620
+ /**
621
+ * The request a card endpoint approval is asked for: `request` names the URL and
622
+ * nothing else, and the profile travels with the captured session in
623
+ * merchant_card_endpoint, the one member the API seals.
624
+ */
625
+ function cardEndpointRequest(profile, session) {
626
+ return { url: session.url, method: 'POST', headers: {}, body: '', merchant_card_endpoint: { profile: profile.id, session } };
627
+ }
628
+ /** A card endpoint refusal as an event: the profile, the endpoint origin, the reason, and the fields it is about, never a value. */
629
+ function reportCardEndpointRefusal(opts, profile, error) {
630
+ if (!profile || !(error instanceof CardEndpointRefusedError))
631
+ return;
632
+ opts.onEvent?.({ type: 'card_endpoint_refused', detail: { ...cardEndpointDetail(profile), reason: error.reason, ...(error.detail ? { refusal: error.detail } : {}) } });
633
+ }
634
+ /**
635
+ * Network.setCookie parameters: a URL and no domain for a host-only cookie, which Chrome
636
+ * keeps on the endpoint host alone, or the domain with its leading dot and no URL for a
637
+ * rotation of a domain cookie the browser holds there (CardEndpointSetCookie.domain).
638
+ */
639
+ function cdpCookieParams(cookie) {
640
+ return { name: cookie.name, value: cookie.value, ...(cookie.domain !== undefined ? { domain: `.${cookie.domain}` } : { url: `${cookie.origin}/` }),
641
+ path: cookie.path, secure: cookie.secure, httpOnly: cookie.httpOnly,
642
+ ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), ...(cookie.expires !== undefined ? { expires: cookie.expires } : {}) };
643
+ }
644
+ /**
645
+ * context.addCookies parameters at the cookie's path: the endpoint host with no leading
646
+ * dot, which Chromium keeps host-only, or the domain of a rotated domain cookie with its
647
+ * leading dot (CardEndpointSetCookie.domain).
648
+ */
649
+ function playwrightCookieParams(cookie) {
650
+ return { name: cookie.name, value: cookie.value, domain: cookie.domain !== undefined ? `.${cookie.domain}` : cookie.host, path: cookie.path,
651
+ secure: cookie.secure, httpOnly: cookie.httpOnly,
652
+ ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), ...(cookie.expires !== undefined ? { expires: cookie.expires } : {}) };
653
+ }
654
+ /**
655
+ * Answer a paused card endpoint request with what the cardholder's device reported.
656
+ * A withheld answer (it carried the card, or a stream the device cannot read) becomes
657
+ * the fixed error the merchant's page stops on, and the outcome is unknown: the
658
+ * merchant holds the card and its answer did not come back. Otherwise the merchant's
659
+ * cookies are written first (cardEndpointCookies, against the endpoint's jar as read
660
+ * now), so the page's next call carries them, and the request is answered with the
661
+ * merchant's status, the profile's answer
662
+ * headers, the CORS answer the request needs, and the body. Resolves false for a
663
+ * withheld answer and true when the merchant's answer was handed over.
664
+ */
665
+ async function deliverCardEndpointAnswer(opts, lifecycle, profile, replay, request, io) {
666
+ // Only the request that named this profile is answered from its merchant's answer.
667
+ if (!profile || replay.profile !== profile.id)
668
+ throw new PaymentOutcomeUnknownError(replay.authorizationId, 'approved_mode_unsupported');
669
+ const cors = corsDecision(request.url, request.headers);
670
+ if (replay.withheld) {
671
+ await io.fulfill({ status: CARD_ENDPOINT_WITHHELD_ANSWER.status, headers: withCorsHeaders({ ...CARD_ENDPOINT_WITHHELD_ANSWER.headers }, cors.headers),
672
+ body: CARD_ENDPOINT_WITHHELD_ANSWER.body });
673
+ io.assertLive();
674
+ lifecycle.failed(new PaymentOutcomeUnknownError(replay.authorizationId, 'merchant_answer_withheld'), true);
675
+ opts.onEvent?.({ type: 'merchant_answer_withheld', detail: { authorizationId: replay.authorizationId, ...cardEndpointDetail(profile), reason: replay.withheld } });
676
+ return false;
677
+ }
678
+ // The jar decides where a rotation lands, so it is read only when there is one to write.
679
+ let jar = [];
680
+ if (replay.setCookies.length > 0 && profile.response.headers.includes('set-cookie')) {
681
+ try {
682
+ const read = await io.readJar();
683
+ jar = Array.isArray(read) ? read.map((held) => ({ name: String(held?.name ?? ''), domain: String(held?.domain ?? '') })) : null;
684
+ }
685
+ catch {
686
+ jar = null;
687
+ }
688
+ }
689
+ const { cookies, skipped } = cardEndpointCookies(profile, request.url, replay.setCookies, Date.now(), jar);
690
+ let written = 0;
691
+ let refused = 0;
692
+ for (const cookie of cookies) {
693
+ // A cookie the browser refuses is counted, never fatal: the merchant already holds
694
+ // the card, and its answer still has to reach the page.
695
+ try {
696
+ await io.writeCookie(cookie);
697
+ written += 1;
698
+ }
699
+ catch {
700
+ refused += 1;
701
+ }
702
+ }
703
+ io.assertLive();
704
+ await io.fulfill({ status: replay.status, headers: withCorsHeaders(cardEndpointAnswerHeaders(profile, replay.headers), cors.headers), body: replay.body });
705
+ io.assertLive();
706
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'card_endpoint', authorizationId: replay.authorizationId, ...cardEndpointDetail(profile),
707
+ status: replay.status, cors: cors.outcome, cookies: { written, skipped: skipped + refused } } });
708
+ return true;
709
+ }
415
710
  /** CDP's header shape: `{ name, value }` entries. */
416
711
  function headerEntries(headers) {
417
712
  return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
@@ -539,26 +834,13 @@ function failureSummary(error) {
539
834
  // Why the merchant's own total could not stand behind the payment, and at which step.
540
835
  if (error instanceof MerchantTotalError)
541
836
  return `${error.name}: ${error.code}${error.reasonCode ? ` (${error.reasonCode})` : ''}`;
837
+ if (error instanceof CardEndpointRefusedError)
838
+ return `${error.name}: ${error.reason}`;
542
839
  return error instanceof Error ? error.name : 'CheckoutError';
543
840
  }
544
841
  const STRIPE_PAYMENT_INTENT_CONFIRM = /^\/v1\/payment_intents\/pi_[A-Za-z0-9]+\/confirm$/;
545
- /**
546
- * A page that tokenized the card through an approval, then confirms the
547
- * payment in the browser with the token it got back (confirmCardPayment with
548
- * payment_method: 'pm_...', confirmPayment with a confirmation token). That
549
- * confirmation carries no card, and the hold after a Stripe tokenization
550
- * would refuse it. The API decides instead whether it is the payment the
551
- * cardholder approved (the approved amount and currency, on the same Stripe
552
- * account, paid with exactly the approved token).
553
- *
554
- * Synchronous on purpose: every paused request passes through here, and an
555
- * await would reorder it against the attachment's own events. Returns the
556
- * approval to check, with the lifecycle's one continuation claimed, or null
557
- * when this is not such a confirmation and the ordinary rules apply.
558
- */
559
842
  function claimStripeContinuation(opts, lifecycle, url, method, body) {
560
- if (body == null || typeof opts.vault.checkStripeContinuation !== 'function' || !lifecycle.isBlocked()
561
- || method.toUpperCase() !== 'POST')
843
+ if (body == null || !lifecycle.isBlocked() || method.toUpperCase() !== 'POST')
562
844
  return null;
563
845
  let parsed;
564
846
  try {
@@ -567,25 +849,57 @@ function claimStripeContinuation(opts, lifecycle, url, method, body) {
567
849
  catch {
568
850
  return null;
569
851
  }
570
- if (parsed.origin !== 'https://api.stripe.com' || !STRIPE_PAYMENT_INTENT_CONFIRM.test(parsed.pathname)
571
- || opts.vault.withoutCard?.(url, body) !== 'refuse')
852
+ if (parsed.origin !== 'https://api.stripe.com')
853
+ return null;
854
+ if (STRIPE_PAYMENT_INTENT_CONFIRM.test(parsed.pathname)) {
855
+ if (typeof opts.vault.checkStripeContinuation !== 'function' || opts.vault.withoutCard?.(url, body) !== 'refuse')
856
+ return null;
857
+ const authorizationId = lifecycle.claimStripeContinuation();
858
+ return authorizationId ? { authorizationId, kind: 'payment', body, rewritten: false } : null;
859
+ }
860
+ if (!STRIPE_SETUP_INTENT_CONFIRM.test(parsed.pathname) || typeof opts.vault.checkStripeSetupContinuation !== 'function')
861
+ return null;
862
+ const withoutCard = opts.vault.withoutCard?.(url, body);
863
+ let send = null;
864
+ if (withoutCard === 'refuse')
865
+ send = body;
866
+ else if (withoutCard === null) {
867
+ // The card again, inline: only the approved payment method may replace it,
868
+ // and only when what is left carries no card at all.
869
+ const paymentMethod = lifecycle.approvedStripePaymentMethod();
870
+ send = paymentMethod ? setupConfirmPayingWith(body, paymentMethod) : null;
871
+ if (send !== null && opts.vault.withoutCard?.(url, send) !== 'refuse')
872
+ send = null;
873
+ }
874
+ if (send === null)
572
875
  return null;
573
- return lifecycle.claimStripeContinuation();
876
+ const authorizationId = lifecycle.claimStripeContinuation();
877
+ return authorizationId ? { authorizationId, kind: 'setup', body: send, rewritten: send !== body } : null;
574
878
  }
575
- /** Ask the API about a claimed continuation and settle the claim. True: continue the page's request untouched. */
576
- async function checkStripeContinuation(opts, lifecycle, authorizationId, request) {
577
- let paymentIntentId = null;
879
+ /**
880
+ * Ask the API about a claimed continuation, with the body it would continue
881
+ * with, and settle the claim. True: continue the page's request with that body
882
+ * (untouched unless the claim rewrote it).
883
+ */
884
+ async function checkStripeContinuation(opts, lifecycle, claim, request) {
885
+ const { authorizationId, kind } = claim;
886
+ let intentId = null;
578
887
  try {
579
- paymentIntentId = (await opts.vault.checkStripeContinuation(authorizationId, request)).paymentIntentId;
888
+ intentId = kind === 'setup'
889
+ ? (await opts.vault.checkStripeSetupContinuation(authorizationId, { ...request, body: claim.body })).setupIntentId
890
+ : (await opts.vault.checkStripeContinuation(authorizationId, { ...request, body: claim.body })).paymentIntentId;
580
891
  }
581
892
  catch (error) {
582
893
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
583
894
  }
584
895
  // The checkout may have been cancelled or reconciled while the API answered.
585
- const proceed = lifecycle.stripeContinuationChecked(paymentIntentId !== null);
586
- if (proceed)
587
- opts.onEvent?.({ type: 'stripe_payment_continued', detail: { authorizationId, paymentIntentId } });
588
- else if (paymentIntentId !== null)
896
+ const proceed = lifecycle.stripeContinuationChecked(intentId !== null, kind);
897
+ if (proceed) {
898
+ opts.onEvent?.(kind === 'setup'
899
+ ? { type: 'stripe_setup_continued', detail: { authorizationId, setupIntentId: intentId, rewritten: claim.rewritten } }
900
+ : { type: 'stripe_payment_continued', detail: { authorizationId, paymentIntentId: intentId } });
901
+ }
902
+ else if (intentId !== null)
589
903
  opts.onEvent?.({ type: 'blocked', detail: 'stripe_continuation_superseded' });
590
904
  return proceed;
591
905
  }
@@ -678,15 +992,20 @@ function luhn(digits) {
678
992
  return sum % 10 === 0;
679
993
  }
680
994
  /**
681
- * The card-placeholder runs in a body: every 13 to 19 digit run that passes
682
- * Luhn, sorted. The placeholder the agent typed (4242 4242 4242 4242, 4111
683
- * 1111 1111 1111) passes; a millisecond timestamp or an order number fails
684
- * nine times in ten, and when one passes it merely makes the match stricter.
685
- * A client-side-encrypted body (Adyen) carries no digits at all and yields
686
- * an empty list on both sides.
995
+ * The card-placeholder runs in a body: every run of 13 to 19 digits that
996
+ * passes Luhn, with any single spaces between its digits taken out, sorted.
997
+ * The placeholder the agent typed (4242 4242 4242 4242, 4111 1111 1111 1111)
998
+ * passes whether the page sends its digits alone or grouped by spaces the way
999
+ * the card input shows them, which is how WSPay's card frame sends it. A
1000
+ * millisecond timestamp or an order number fails nine times in ten, and when
1001
+ * one passes it merely makes the match stricter. A body with no single space
1002
+ * between two digits yields exactly the contiguous runs it always did,
1003
+ * because the optional space never matches there. A client-side-encrypted
1004
+ * body (Adyen) carries no digits at all and yields an empty list on both
1005
+ * sides.
687
1006
  */
688
1007
  function cardRuns(body) {
689
- return (body.match(/\d{13,19}/g) ?? []).filter(luhn).sort();
1008
+ return (body.match(/\d(?: ?\d){12,18}/g) ?? []).map((run) => run.replace(/ /g, '')).filter(luhn).sort();
690
1009
  }
691
1010
  /** Every key path in a JSON value, arrays flattened so an element's order or count does not count. */
692
1011
  function jsonKeyPaths(value, prefix, out) {
@@ -734,12 +1053,26 @@ function bodyShape(body) {
734
1053
  }
735
1054
  return { shape: `raw:${body}`, namesAmount: false };
736
1055
  }
737
- /** The identity of a request from its parts; see CheckoutRequestIdentity. */
738
- function requestIdentity(opts, url, method, body, documentUrl, pageAmount) {
739
- const shaped = bodyShape(body);
740
- return { url, method, documentUrl, pageAmount, body: shaped.shape,
1056
+ /**
1057
+ * The identity of a request from its parts; see CheckoutRequestIdentity. A CyberSource Flex
1058
+ * body is a fresh JWE every time the card field seals, and it names no amount, so its shape is
1059
+ * the one the Flex gate read from the field iframe (cybersource-flex.ts: microform type, fields,
1060
+ * the capture context's key id and the context itself), never the ciphertext.
1061
+ */
1062
+ function requestIdentity(opts, url, method, body, documentUrl, pageAmount, flexShape) {
1063
+ const shaped = flexShape !== undefined ? { shape: flexShape, namesAmount: false } : bodyShape(body);
1064
+ // A GMO token is minted for the one shop its request names, and the body's shape leaves that value
1065
+ // out, so the shop joins the identity: a retry that names another shop needs its own approval.
1066
+ const shop = gmoRequestShop(url, body);
1067
+ return { url, method, documentUrl, pageAmount, body: shop === undefined ? shaped.shape : `${shaped.shape};gmo_shop:${JSON.stringify(shop)}`,
741
1068
  amountEvidence: opts.amount != null || pageAmount !== undefined || shaped.namesAmount };
742
1069
  }
1070
+ /** A CDP frame or request URL with its fragment, which CDP reports apart (urlFragment). */
1071
+ function withFragment(value) {
1072
+ if (typeof value?.url !== 'string')
1073
+ return undefined;
1074
+ return value.url + (typeof value.urlFragment === 'string' ? value.urlFragment : '');
1075
+ }
743
1076
  /**
744
1077
  * May this request be answered from the approval the other one raised?
745
1078
  *
@@ -774,12 +1107,24 @@ function sameCheckoutRequest(first, retry) {
774
1107
  * answer is a token or ciphertext, and any request for the same purchase can
775
1108
  * carry it. No for a hosted form (a navigation: there is no request to
776
1109
  * answer once the page has moved on), a prepared checkout (single use, bound
777
- * to one native request by design), a native Stripe Checkout step, and a
778
- * Mercado Pago request whose claim is bound to one document read. Unknown
779
- * modes (a duck-typed vault without checkoutModeOf) are taken as `token`.
1110
+ * to one native request by design), a native Stripe Checkout step, a
1111
+ * Mercado Pago request whose claim is bound to one document read, a Juspay
1112
+ * /txns (the device pays that request's own order; a page that clicks Pay
1113
+ * again creates a new order, which the earlier answer would send to the
1114
+ * earlier order's bank check), a Payrails save (device_encrypt: the device
1115
+ * sends that one save itself, under the idempotency key the page gave it, so a
1116
+ * retry is a new question), and any request to a Shiji STS host. Shiji
1117
+ * encrypts every merchant's card under one static key, so the device's
1118
+ * ciphertext tokenizes the card in whichever STS session carries it, and a
1119
+ * retry the shape rule accepts (sameCheckoutRequest compares key paths, never
1120
+ * values) can name another Shiji merchant's accessToken and keySequence than
1121
+ * the request the API reviewed at create. No legitimate retry is lost:
1122
+ * sts.proxy's fetch sets no timeout, so the page never gives up on its own
1123
+ * getTokenId while the cardholder decides. Unknown modes (a duck-typed vault
1124
+ * without checkoutModeOf) are taken as `token`.
780
1125
  */
781
1126
  function survivesAbandonment(opts, url, method, navigation, bound) {
782
- if (navigation || bound)
1127
+ if (navigation || bound || isShijiStsHost(url))
783
1128
  return false;
784
1129
  const mode = typeof opts.vault?.checkoutModeOf === 'function' ? opts.vault.checkoutModeOf(url, method) : 'token';
785
1130
  return mode === 'token' || mode === 'cse';
@@ -810,7 +1155,12 @@ function merchantAttempt(lifecycle, survives) {
810
1155
  let rebound = false;
811
1156
  let delivering = false;
812
1157
  let bound = null;
813
- const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
1158
+ // An Amadeus send window closes only once the API confirms the card never left the device and
1159
+ // never can (amadeus.ts): a known non-payment, so the prepared checkout ends failed with that
1160
+ // reason, and a fresh attachment can prepare again. Every other stop leaves the outcome unknown.
1161
+ const error = () => reason === 'amadeus_send_window_closed'
1162
+ ? new CheckoutPreparationError(lifecycle.getState().preparationId ?? null, reason)
1163
+ : new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
814
1164
  const attempt = {
815
1165
  signal: controller.signal,
816
1166
  /** The browser request that currently carries this attempt; null while the page has none. */
@@ -1033,6 +1383,23 @@ function pauseForBusiness(opts, lifecycle, attemptSignal, request, pageOrigin) {
1033
1383
  });
1034
1384
  return { replay, delivered: { resolve: deliveredResolve, reject: deliveredReject } };
1035
1385
  }
1386
+ /**
1387
+ * Arm the send window of a request paused under a preparation, when its processor sets one: only a
1388
+ * prepared Amadeus SDK add does (amadeus.ts). Returns the disarm function, or null with no window.
1389
+ * lifecycle.begin() cleared the authorization ID, so the one the window reads is this attempt's.
1390
+ */
1391
+ function armWindowFor(opts, lifecycle, attempt, preparation, url, delivering) {
1392
+ const ms = preparation ? amadeusSendWindowMs(preparation.psp, url) : null;
1393
+ if (ms === null || !preparation)
1394
+ return null;
1395
+ return armSendWindow(ms, attempt, {
1396
+ authorizationId: () => lifecycle.getState().authorizationId,
1397
+ delivering,
1398
+ cancel: typeof opts.vault?.cancelAuthorization === 'function' ? (id) => opts.vault.cancelAuthorization(id) : undefined,
1399
+ retirePreparation: typeof opts.vault?.retirePreparation === 'function' ? () => opts.vault.retirePreparation(preparation.id) : undefined,
1400
+ onEvent: opts.onEvent,
1401
+ });
1402
+ }
1036
1403
  /**
1037
1404
  * Take over card tokenization for a page.
1038
1405
  *
@@ -1064,6 +1431,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1064
1431
  const ownedSessions = new Set([pageSessionId]);
1065
1432
  const sessionTypes = new Map([[pageSessionId, 'page']]);
1066
1433
  const sessionParents = new Map();
1434
+ // The frame in its parent session that a child target hangs from (TargetInfo.parentFrameId), so a
1435
+ // frame lookup can climb from a cross-origin frame into the document that frames it.
1436
+ const sessionParentFrames = new Map();
1067
1437
  const detachedSessions = new Set();
1068
1438
  const rejectedSessions = new Set();
1069
1439
  const workerOwners = new Map();
@@ -1115,6 +1485,43 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1115
1485
  const lifecycle = new CheckoutLifecycle(opts);
1116
1486
  const stripeCheckout = new StripeCheckoutGate(opts);
1117
1487
  const mercadoCheckout = new MercadoCheckoutGate();
1488
+ // CyberSource Flex card field iframes and their capture contexts, recorded from the CDP
1489
+ // events that report a frame's committed document with its fragment (cybersource-flex.ts).
1490
+ const microforms = new MicroformFrames();
1491
+ /**
1492
+ * One bounded read of a session's frame tree, for a Flex request whose frame is not on record:
1493
+ * records every frame it finds and resolves to the committed URL of `frameId`, or undefined
1494
+ * when that frame is not in the tree.
1495
+ */
1496
+ const rereadMicroforms = async (sessionId, frameId) => {
1497
+ if (!sessionId)
1498
+ return undefined;
1499
+ let timer;
1500
+ try {
1501
+ const tree = await Promise.race([cdp.send('Page.getFrameTree', {}, sessionId),
1502
+ new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('frame_tree_unavailable')), 1_000); })]);
1503
+ let issuing;
1504
+ const walk = (node, depth) => {
1505
+ if (!node?.frame || depth > 16)
1506
+ return;
1507
+ const url = withFragment(node.frame);
1508
+ microforms.observe(node.frame.id, url);
1509
+ if (node.frame.id === frameId)
1510
+ issuing = url;
1511
+ for (const child of Array.isArray(node.childFrames) ? node.childFrames : [])
1512
+ walk(child, depth + 1);
1513
+ };
1514
+ walk(tree?.frameTree, 0);
1515
+ return issuing;
1516
+ }
1517
+ finally {
1518
+ clearTimeout(timer);
1519
+ }
1520
+ };
1521
+ // Juspay's tokenize answer and the synthetic tokens it issued (juspay-gate.ts).
1522
+ const juspayCheckout = new JuspayCheckoutGate(opts.vault);
1523
+ const aci = new AciGate(opts.vault);
1524
+ const eprotect = new EprotectGate(opts.vault);
1118
1525
  let preparationFrameId;
1119
1526
  const readDocumentUrl = async () => {
1120
1527
  const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
@@ -1135,6 +1542,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1135
1542
  const merchantTotal = new MerchantTotalWatch(opts.onEvent, opts.vault);
1136
1543
  let handoffReplay = null;
1137
1544
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
1545
+ // Payrails sessions this page's own XHR and fetch answers carried, read only
1546
+ // when the synced registry pauses Payrails' save (an org the API served the
1547
+ // dark entry to); null otherwise, and no answer is read (see payrails.ts).
1548
+ const payrails = recognizesPayrails(opts.vault) ? new PayrailsSessionCapture() : null;
1549
+ // Answers that may carry a Payrails init payload, by session and Network ID,
1550
+ // read once they finish loading.
1551
+ const payrailsAnswers = new Set();
1138
1552
  const armed = new Set();
1139
1553
  const arming = new Map();
1140
1554
  // Set once a failure proves that retrying cannot help; see isTerminal.
@@ -1285,6 +1699,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1285
1699
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
1286
1700
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
1287
1701
  let lastSubmitted = null;
1702
+ // The approved Datatrans answers, per frameId: the repeat rule (datatrans.ts).
1703
+ const datatransRepeats = new DatatransRepeats();
1704
+ // The Shiji STS getCvvId companion of the getTokenId this attachment last continued (shiji-sts.ts).
1705
+ // A cancelled attachment drops it, and the device's CVV ciphertext with it, at once: no getCvvId
1706
+ // can use it any more (pre-claim.ts refuses every one once the lifecycle is aborted). The page,
1707
+ // frame or target going away drops it too (the detach handlers below), and so does the frame
1708
+ // committing a new document (Page.frameNavigated, at the top of the event handler).
1709
+ const shijiCompanions = new ShijiStsCompanions();
1710
+ lifecycle.abort.signal.addEventListener('abort', () => shijiCompanions.release(), { once: true });
1288
1711
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
1289
1712
  // The endpoint and siblings of every merchant profile this build reviewed
1290
1713
  // (merchant-hosted.ts), which a VaultClient arms from the start: a profile's card
@@ -1292,9 +1715,21 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1292
1715
  // it, before or after any syncRegistry(). The gate continues untouched a request
1293
1716
  // that the vault names no profile for.
1294
1717
  const merchantPatterns = [...reviewedMerchantProfileUrlPatterns(),
1295
- ...(typeof opts.vault?.merchantProfileUrlPatterns === 'function' ? opts.vault.merchantProfileUrlPatterns() : [])];
1718
+ ...(typeof opts.vault?.merchantProfileUrlPatterns === 'function' ? opts.vault.merchantProfileUrlPatterns() : []),
1719
+ // Every request to an armed Verifone merchant's origin (verifone.ts), armed from the profiles
1720
+ // the last syncRegistry() served: none before one, and none for a company Verifone is off for.
1721
+ ...(typeof opts.vault?.verifoneProfileUrlPatterns === 'function' ? opts.vault.verifoneProfileUrlPatterns() : [])];
1722
+ // GMO token v1's JSONP GET is armed only when the synced registry recognizes
1723
+ // GMO (an org the API served the dark entry to), so the gate can refuse it;
1724
+ // for every other org it is never intercepted, as before (see gmo.ts).
1725
+ // Every reviewed card endpoint is armed in any status: one this build does not pay is
1726
+ // paused only to be refused, so the placeholder card never reaches the merchant.
1727
+ // Every Payrails host is intercepted only for an org the API served Payrails
1728
+ // to, so the gate can abort a card-form payment (see payrails.ts).
1296
1729
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
1297
- ...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns])];
1730
+ ...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns,
1731
+ ...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : []), ...juspayCheckout.urlPatterns(), ...cardEndpointPatterns(),
1732
+ ...(payrails ? PAYRAILS_GUARD_PATTERNS : [])])];
1298
1733
  const arm = async (sessionId, resume = false, worker = false) => {
1299
1734
  const key = sessionId ?? '__root__';
1300
1735
  if (armed.has(key))
@@ -1367,6 +1802,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1367
1802
  opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
1368
1803
  };
1369
1804
  cdp.on(async (method, params, sessionId) => {
1805
+ // A frame that committed a new document drops the companion binding made for the getTokenId its
1806
+ // old document sent, with the device's CVV ciphertext: the document that replaced it never
1807
+ // obtained the approval, whatever accessToken and key it sends. A same-document navigation is
1808
+ // Page.navigatedWithinDocument and keeps the binding.
1809
+ if (method === 'Page.frameNavigated' && typeof params?.frame?.id === 'string') {
1810
+ const replaced = cdpShijiPage(sessionId, params.frame.id);
1811
+ shijiCompanions.releasePage((page) => page === replaced);
1812
+ }
1370
1813
  if ((method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') && targetContextKnown) {
1371
1814
  try {
1372
1815
  assertContextTarget(params?.targetInfo, merchantContext);
@@ -1402,6 +1845,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1402
1845
  mercadoCheckout.invalidate();
1403
1846
  // The root page owns every attached OOPIF; its loss ends child requests too.
1404
1847
  activeRequest?.attempt.stop();
1848
+ // No getCvvId can come from a closed page: drop the companion and its CVV ciphertext.
1849
+ shijiCompanions.release();
1405
1850
  // Nothing can read the merchant's answer to a continued merchant-hosted
1406
1851
  // request any more, nor the page's report of a retry: a retry is judged on
1407
1852
  // what was read, then the answer is an unknown outcome unless it arrived.
@@ -1421,6 +1866,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1421
1866
  // Its Fetch events, children and commands remain permanently ignored.
1422
1867
  if (sessionDetached(sessionId) && !matchesActive)
1423
1868
  return;
1869
+ // A frame's document request carries its URL fragment (for an out-of-process card field
1870
+ // iframe, on the PARENT session before the child target exists), but a navigation can be
1871
+ // stopped before it commits: it only starts the capture context's check. The frame is
1872
+ // recorded once its document commits (Page.frameNavigated below).
1873
+ if (params.type === 'Document')
1874
+ microforms.warm(withFragment(params.request));
1424
1875
  const key = requestKey(sessionId, params.requestId);
1425
1876
  const record = { sessionId, networkId: params.requestId, request: params.request };
1426
1877
  if (sessionDetached(sessionId) || retiredSources.has(key) || hasRetiredSummary(owner, params.requestId))
@@ -1446,6 +1897,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1446
1897
  }
1447
1898
  if (sessionDetached(sessionId))
1448
1899
  return;
1900
+ if (method === 'Page.frameNavigated') {
1901
+ // A child frame committed a document: an out-of-process iframe reports its own on its
1902
+ // session, an in-process one on its parent's (the page's top frame is handled above).
1903
+ microforms.observe(params.frame?.id, withFragment(params.frame));
1904
+ return;
1905
+ }
1449
1906
  if (method === 'Network.loadingFinished') {
1450
1907
  const key = requestKey(sessionId, params.requestId);
1451
1908
  requestSources.delete(key);
@@ -1455,6 +1912,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1455
1912
  cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((read) => merchantTotal.log.read(key, typeof read?.body === 'string'
1456
1913
  ? (read.base64Encoded ? Buffer.from(read.body, 'base64').toString('utf8') : read.body) : null, completedAt), () => merchantTotal.log.read(key, null, completedAt));
1457
1914
  }
1915
+ if (payrails && payrailsAnswers.delete(requestKey(sessionId, params.requestId))
1916
+ && !(Number(params.encodedDataLength) > PAYRAILS_INIT_BODY_LIMIT)) {
1917
+ payrails.reading(cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((answer) => {
1918
+ if (typeof answer?.body !== 'string')
1919
+ return;
1920
+ const body = answer.base64Encoded ? Buffer.from(answer.body, 'base64').toString('utf8') : answer.body;
1921
+ if (body.length <= PAYRAILS_INIT_BODY_LIMIT)
1922
+ payrails.observe(body);
1923
+ }));
1924
+ }
1458
1925
  return;
1459
1926
  }
1460
1927
  if (method === 'Network.responseReceived') {
@@ -1468,12 +1935,21 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1468
1935
  if (handoffReplay)
1469
1936
  merchantTotal.answered(handoffReplay, opts.vault);
1470
1937
  }
1938
+ // A 2xx XHR or fetch answer with a JSON type may carry a Payrails init
1939
+ // payload; its body is read once it finishes loading.
1940
+ if (payrails && payrailsInitCandidate(params?.type, params?.response?.status, params?.response?.mimeType)) {
1941
+ payrailsAnswers.add(requestKey(sessionId, params.requestId));
1942
+ // An answer that never finishes loading is forgotten with the oldest, not kept forever.
1943
+ if (payrailsAnswers.size > 256)
1944
+ payrailsAnswers.delete(payrailsAnswers.values().next().value);
1945
+ }
1471
1946
  return;
1472
1947
  }
1473
1948
  if (method === 'Network.loadingFailed') {
1474
1949
  merchantTotal.log.failed(requestKey(sessionId, params.requestId));
1475
1950
  if (handoffNetwork && params.requestId === handoffNetwork.networkId && sessionId === handoffNetwork.sessionId)
1476
1951
  merchantHandoff.failed();
1952
+ payrailsAnswers.delete(requestKey(sessionId, params.requestId));
1477
1953
  if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId) {
1478
1954
  const dead = { requestId: activeRequest.requestId, sessionId: activeRequest.sessionId, request: activeRequest.request };
1479
1955
  // A request a dedicated worker sent keeps today's behaviour: its loss
@@ -1507,10 +1983,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1507
1983
  return;
1508
1984
  }
1509
1985
  if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
1986
+ // A frame that moves to its own process ('swap') keeps its document; any other detach ends it.
1987
+ if (!(method === 'Page.frameDetached' && params.reason === 'swap')) {
1988
+ microforms.forget(method === 'Page.frameDetached' ? params.frameId : method === 'Target.detachedFromTarget' ? params.targetId : undefined);
1989
+ }
1510
1990
  if (activeRequest && (method === 'Target.detachedFromTarget' ? activeRequest.sessionId === params.sessionId
1511
1991
  : method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
1512
1992
  : activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
1513
1993
  activeRequest.attempt.stop();
1994
+ // The frame or target the bound getTokenId came from is gone, so its getCvvId cannot follow:
1995
+ // drop the companion binding and the CVV ciphertext it holds (a binding elsewhere stays).
1996
+ if (method === 'Page.frameDetached') {
1997
+ const gone = cdpShijiPage(sessionId, params.frameId);
1998
+ shijiCompanions.releasePage((page) => page === gone);
1999
+ }
2000
+ else {
2001
+ const gone = cdpShijiSession(method === 'Target.detachedFromTarget' ? params.sessionId : sessionId);
2002
+ shijiCompanions.releasePage((page) => page.startsWith(gone));
2003
+ }
1514
2004
  if (method !== 'Page.frameDetached') {
1515
2005
  const detached = method === 'Target.detachedFromTarget' ? params.sessionId : sessionId;
1516
2006
  detachedSessions.add(detached);
@@ -1574,11 +2064,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1574
2064
  ownedSessions.add(child);
1575
2065
  sessionTypes.set(child, params.targetInfo.type);
1576
2066
  sessionParents.set(child, sessionId);
2067
+ if (typeof params.targetInfo.parentFrameId === 'string' && params.targetInfo.parentFrameId) {
2068
+ sessionParentFrames.set(child, params.targetInfo.parentFrameId);
2069
+ }
1577
2070
  if (params.targetInfo.type === 'worker') {
1578
2071
  const owner = workerOwners.get(sessionId) ?? sessionId;
1579
2072
  workerOwners.set(child, owner);
1580
2073
  ownersWithWorkers.add(owner);
1581
2074
  }
2075
+ // An iframe target's id is its frame id, and its url keeps the fragment: the one source
2076
+ // for a card field iframe that rendered before this attachment (empty on a fresh one).
2077
+ if (params.targetInfo.type === 'iframe')
2078
+ microforms.observe(params.targetInfo.targetId, params.targetInfo.url);
1582
2079
  await arm(child, true, params.targetInfo.type === 'worker');
1583
2080
  }
1584
2081
  catch (error) {
@@ -1595,6 +2092,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1595
2092
  const { requestId, request, resourceType, networkId, frameId } = params;
1596
2093
  // When this request paused: the merchant's total is read as of this moment.
1597
2094
  const pausedAt = Date.now();
2095
+ // A paused document request has not committed either: it only starts the context's check.
2096
+ if (resourceType === 'Document')
2097
+ microforms.warm(withFragment(request));
1598
2098
  // One pre-claim gate registry decides and performs the mechanics through
1599
2099
  // these effects; the state accessors read the same closure variables the
1600
2100
  // inline gates did. It resolves synchronously for an ordinary card request,
@@ -1602,8 +2102,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1602
2102
  // a pause hands work back to the claim flow below.
1603
2103
  const preClaimResult = dispatchPreClaim({
1604
2104
  // headers and body are read lazily, inside the gate that needs them (see PreClaimContext.request).
1605
- request: { url: request.url, method: request.method, get headers() { return request.headers; }, get body() { return pausedBody(request); } },
1606
- vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout,
2105
+ request: { url: request.url, method: request.method, get headers() { return request.headers; }, get body() { return pausedBody(request); },
2106
+ navigation: resourceType === 'Document' },
2107
+ vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout, aci, eprotect,
2108
+ // This adapter drives Chromium, which sends the body Fetch.continueRequest replaces on a
2109
+ // navigation (the ACI browser test's classic case, design gate G3).
2110
+ replacesNavigationBody: async () => true,
1607
2111
  readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
1608
2112
  effects: {
1609
2113
  continueRequest: (postData) => cdp.send('Fetch.continueRequest', { requestId, ...(postData !== undefined ? { postData: Buffer.from(postData).toString('base64') } : {}) }, sessionId),
@@ -1611,7 +2115,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1611
2115
  responseHeaders: Object.entries(withCorsHeaders(response.headers, corsHeadersFor(request.url, request.headers)))
1612
2116
  .map(([name, value]) => ({ name, value })),
1613
2117
  body: Buffer.from(response.body).toString('base64') }, sessionId),
1614
- abort: () => cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { }),
2118
+ abort: () => cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { }),
1615
2119
  },
1616
2120
  attachmentReady: () => attachmentReady,
1617
2121
  interceptionAborted: () => setupStop.signal.aborted,
@@ -1621,25 +2125,60 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1621
2125
  stripeAttachmentBusy: () => arming.has(sessionId ?? '__root__'),
1622
2126
  requestInactive: () => false,
1623
2127
  awaitingApproval: () => awaitingApproval,
2128
+ initiatorFrames: () => pausedFrameChain(cdp, sessionId, frameId, child => {
2129
+ const parent = sessionParents.get(child);
2130
+ return parent === undefined ? undefined : { sessionId: parent, frameId: sessionParentFrames.get(child) };
2131
+ }),
1624
2132
  stripeStep: null,
1625
2133
  merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, (judge) => awaitRetryReport(sessionId, networkId, judge)) },
2134
+ // The card field iframe that sent a Flex request is the paused request's own frame.
2135
+ cybersourceFlex: { judge: (flexRequest) => microforms.judge(flexRequest, frameId, () => rereadMicroforms(sessionId, frameId)) },
2136
+ juspay: juspayCheckout,
2137
+ datatrans: datatransRepeats,
2138
+ frame: typeof frameId === 'string' && frameId ? frameId : undefined,
2139
+ payrails,
2140
+ shijiSts: { companions: shijiCompanions, page: () => cdpShijiPage(sessionId, frameId) },
1626
2141
  });
1627
2142
  const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
1628
2143
  if (preClaim.kind !== 'pause')
1629
2144
  return;
1630
2145
  const stripeStep = preClaim.stripeStep;
2146
+ // The capture context and fields a CyberSource Flex create carries, and its retry shape.
2147
+ const flexPause = preClaim.cybersourceFlex;
2148
+ // A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts).
2149
+ const cardEndpoint = preClaim.cardEndpoint ?? null;
2150
+ // A Payrails card save the gate bound to a captured session: its key rides on the create.
2151
+ const payrailsSidecar = preClaim.payrails;
1631
2152
  if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
1632
2153
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
1633
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2154
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
2155
+ return;
2156
+ }
2157
+ // A request the catalog marks as routine without a card (a confirmation
2158
+ // token for a saved method, a bank source, a Checkout Session confirm with
2159
+ // the method hosted Checkout created, every Amadeus action but the card
2160
+ // add and store) was never ours: it continues in every state, like a
2161
+ // request the catalog does not recognize. It is judged before the
2162
+ // preparation claim, so it neither spends a ready preparation nor counts
2163
+ // as the page's first card request: Amadeus's SDK sends its load to the
2164
+ // card URL as the payment step opens, before prepare(), and Air France's
2165
+ // Nuvei payment with a ccTempToken follows the prepared Amadeus add. One
2166
+ // without a card anywhere else is judged after the holds below, and so is
2167
+ // a Nuvei payment without a card once this attachment has handed Nuvei the
2168
+ // card (VaultClient.withoutCard).
2169
+ const continuationBody = pausedBody(request);
2170
+ const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody, lifecycle.cardHandoffs()) ?? null;
2171
+ if (withoutCard === 'continue') {
2172
+ await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1634
2173
  return;
1635
2174
  }
1636
2175
  let preparation;
1637
2176
  try {
1638
- preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request), request.headers);
2177
+ preparation = stripeStep ? undefined : preparationGate.claim(request.url, request.method, pausedBody(request), request.headers);
1639
2178
  }
1640
2179
  catch (error) {
1641
2180
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
1642
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2181
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1643
2182
  return;
1644
2183
  }
1645
2184
  // The page asking again for the purchase an outstanding approval is for:
@@ -1650,7 +2189,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1650
2189
  if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()
1651
2190
  && typeof networkId === 'string' && networkId) {
1652
2191
  const retryBody = pausedBody(request);
1653
- const retry = retryBody === null ? null : requestIdentity(opts, request.url, request.method, retryBody, documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
2192
+ const retry = retryBody === null ? null : requestIdentity(opts, request.url, request.method, retryBody, documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts), flexPause?.shape);
1654
2193
  if (retry && activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
1655
2194
  if (preparation)
1656
2195
  preparationGate.retireUnboundClaim();
@@ -1666,32 +2205,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1666
2205
  return;
1667
2206
  }
1668
2207
  }
1669
- // A request the catalog marks as routine without a card (a confirmation
1670
- // token for a saved method, a bank source, a Checkout Session confirm with
1671
- // the method hosted Checkout created) was never ours: it continues in
1672
- // every state, like a request the catalog does not recognize. One without
1673
- // a card anywhere else is judged after the holds below.
1674
- const continuationBody = pausedBody(request);
1675
- const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody) ?? null;
1676
- if (withoutCard === 'continue') {
1677
- if (preparation)
1678
- preparationGate.retireUnboundClaim();
1679
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1680
- return;
1681
- }
1682
2208
  // Not gated on awaitingApproval: the page can send this confirmation while
1683
2209
  // the token's own delivery is still being acknowledged. The lifecycle
1684
2210
  // opens the claim only once a Stripe tokenization is being handed over.
1685
- const continuationId = stripeStep || terminal ? null
2211
+ const continuation = stripeStep || terminal ? null
1686
2212
  : claimStripeContinuation(opts, lifecycle, request.url, request.method, continuationBody);
1687
- if (continuationId) {
2213
+ if (continuation) {
1688
2214
  if (preparation)
1689
2215
  preparationGate.retireUnboundClaim();
1690
- if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url, method: request.method, headers: request.headers, body: continuationBody }) && !setupStop.signal.aborted) {
1691
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
2216
+ if (await checkStripeContinuation(opts, lifecycle, continuation, { url: request.url, method: request.method, headers: request.headers }) && !setupStop.signal.aborted) {
2217
+ // A card save that carried the card again continues with the body the
2218
+ // API allowed, and postData alone rides the command (see cse-body.ts
2219
+ // for why no header, Content-Length included, is ever overridden).
2220
+ await cdp.send('Fetch.continueRequest', { requestId,
2221
+ ...(continuation.rewritten ? { postData: Buffer.from(continuation.body).toString('base64') } : {}) }, sessionId).catch(() => { });
1692
2222
  return;
1693
2223
  }
1694
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2224
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1695
2225
  return;
1696
2226
  }
1697
2227
  // Same stop condition as the Playwright adapter: once a failure proves
@@ -1708,33 +2238,39 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1708
2238
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
1709
2239
  if (preparation)
1710
2240
  preparationGate.retireUnboundClaim();
1711
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2241
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1712
2242
  if (repeatedAuthorizationId)
1713
2243
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
1714
2244
  return;
1715
2245
  }
1716
2246
  // A recognized request that carries no card is not a card request: a
1717
- // Stripe confirmation paying with a method the page created itself. It is
1718
- // judged only after the holds above, so a request that would reuse an
1719
- // approved token is still refused there (card-fields.js requestWithoutCard).
2247
+ // Stripe confirmation paying with a method the page created itself, or a
2248
+ // Nuvei payment with a token Nuvei may have minted from the approved card.
2249
+ // It is judged only after the holds above, so a request that would reuse
2250
+ // an approved token is still refused there (VaultClient.withoutCard).
1720
2251
  if (withoutCard === 'refuse') {
1721
2252
  if (preparation)
1722
2253
  preparationGate.retireUnboundClaim();
1723
2254
  opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
1724
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2255
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1725
2256
  return;
1726
2257
  }
1727
2258
  // Reserve BEFORE the first await (the authorize call below yields), so two
1728
2259
  // requests can never both clear the check above and raise two prompts for
1729
2260
  // one checkout.
1730
2261
  awaitingApproval = true;
1731
- const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url, request.method, resourceType === 'Document', !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url, request.method)));
2262
+ // A card endpoint approval never outlives its request: the device answers that
2263
+ // request, and a retry after the page gave up on it is refused, not bound.
2264
+ const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url, request.method, resourceType === 'Document', !!preparation || !!stripeStep || !!cardEndpoint || mercadoCheckout.requiresDocument(request.url, request.method)
2265
+ || juspayCheckout.bindsApproval(request.url, request.method)));
1732
2266
  let handoffStarted = false;
1733
2267
  // The approval the device gave, and whether it reached a browser request.
1734
2268
  let approvedId = null;
1735
2269
  let deliveryBegun = false;
1736
2270
  // The business's pause, so resume resolves once the page has its answer.
1737
2271
  let businessPause = null;
2272
+ // A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
2273
+ let disarmSendWindow = null;
1738
2274
  try {
1739
2275
  const body = pausedBody(request);
1740
2276
  if (body === null) {
@@ -1742,7 +2278,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1742
2278
  // about the page's configuration, so the next request is judged
1743
2279
  // afresh (no latch, no cooldown).
1744
2280
  opts.onEvent?.({ type: 'failed', detail: BODY_UNREADABLE_REASON });
1745
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2281
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1746
2282
  return;
1747
2283
  }
1748
2284
  // The body is needed to tell a re-post of the form the cardholder
@@ -1750,12 +2286,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1750
2286
  // and before any API call: no second prompt, no round trip.
1751
2287
  if (isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
1752
2288
  opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
1753
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2289
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1754
2290
  if (lastSubmitted)
1755
2291
  void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
1756
2292
  return;
1757
2293
  }
1758
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url), recognizer: recognizerNameOf(opts, request.url, request.method), ...(resourceType ? { resourceType } : {}) } });
2294
+ // A card endpoint's URL carries the merchant's cart and booking ids: its events
2295
+ // name the profile and the endpoint origin instead.
2296
+ opts.onEvent?.({ type: 'card_request_paused', detail: { ...(cardEndpoint ? cardEndpointDetail(cardEndpoint) : { url: eventUrl(opts.vault, request.url) }),
2297
+ recognizer: recognizerNameOf(opts, request.url, request.method), ...(resourceType ? { resourceType } : {}) } });
1759
2298
  lifecycle.begin();
1760
2299
  const approvalStartedAt = Date.now();
1761
2300
  const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
@@ -1765,7 +2304,17 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1765
2304
  }
1766
2305
  attempt.bind({ requestId, sessionId, request });
1767
2306
  activeRequest = { attempt, requestId, networkId, sessionId, frameId, request,
1768
- identity: requestIdentity(opts, request.url, request.method, body, undefined, undefined) };
2307
+ identity: requestIdentity(opts, request.url, request.method, body, undefined, undefined, flexPause?.shape) };
2308
+ // An ACI card-number request: the page's CVC and expiry requests for its checkout are held
2309
+ // until this approval ends (see AciGate). One whose checkout already has a card-number
2310
+ // request running or answered never begins; the pre-claim gate refuses it before this.
2311
+ if (!aci.begin(request.url, request.method, body, preparation?.merchantOrigin)) {
2312
+ throw new CheckoutPreparationError(preparation?.id ?? null, ACI_CHECKOUT_REUSED);
2313
+ }
2314
+ // A Worldpay eProtect registration: the vendor's give-up report for it ends this attempt as
2315
+ // the page abandoning its request (see EprotectGate.gaveUp).
2316
+ eprotect.begin(request.url, request.method, () => attempt.stop());
2317
+ disarmSendWindow = armWindowFor(opts, lifecycle, attempt, preparation, request.url, () => deliveryBegun);
1769
2318
  await awaitRequestSource(activeRequest);
1770
2319
  if (preparation)
1771
2320
  await preparationGate.assertDocument();
@@ -1773,7 +2322,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1773
2322
  // the identity a retry has to repeat (see sameCheckoutRequest).
1774
2323
  const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
1775
2324
  const pageOrigin = pageOriginFrom(documentUrl);
1776
- const pageAmount = await pageAmountOf(opts);
2325
+ // The agent's amount is a card endpoint's only authority: no page total is read.
2326
+ const pageAmount = cardEndpoint ? undefined : await pageAmountOf(opts);
1777
2327
  attempt.assertLive();
1778
2328
  activeRequest.identity.documentUrl = documentKey(documentUrl);
1779
2329
  activeRequest.identity.pageAmount = pageAmount;
@@ -1789,6 +2339,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1789
2339
  const cardKey = requestKey(activeRequest?.sourceSessionId ?? sessionId ?? '', networkId ?? '');
1790
2340
  const cardPage = merchantTotal.priced(request.url)
1791
2341
  ? await merchantTotal.pageFor(cardKey, MERCHANT_PAGE_WAIT_MS, attempt.signal) : merchantTotal.pageOf(cardKey);
2342
+ // The card endpoint's session: the profile's own headers from the paused request,
2343
+ // the endpoint's cookie jar for a cookie profile (Chrome keeps it out of the
2344
+ // paused headers, HttpOnly cookies included), and the checkout page's Referer.
2345
+ const session = cardEndpoint ? await captureCardEndpointSession(cardEndpoint, {
2346
+ url: request.url, headers: request.headers, body, pageOrigin,
2347
+ cookies: async () => (await cdp.send('Network.getCookies', { urls: [request.url] }, sessionId))?.cookies ?? [],
2348
+ }) : null;
1792
2349
  attempt.assertLive();
1793
2350
  // Decision 176: pause only. The business creates the authorization from the paused request and answers
1794
2351
  // through controller.resume; the replay it hands back goes down the same delivery path as a create's.
@@ -1819,9 +2376,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1819
2376
  lifecycle.approvalUrl(url);
1820
2377
  return opts.onApprovalUrl?.(url);
1821
2378
  } },
1822
- request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url, method: request.method, headers: request.headers, body,
1823
- ...(mercadoCheckout.requiresDocument(request.url, request.method)
1824
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}) },
2379
+ request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session)
2380
+ : { url: request.url, method: request.method, headers: request.headers, body,
2381
+ ...(mercadoCheckout.requiresDocument(request.url, request.method)
2382
+ ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}),
2383
+ ...(flexPause ? flexPause.sidecar : {}),
2384
+ ...(payrailsSidecar ? { payrails: payrailsSidecar } : {}) },
1825
2385
  });
1826
2386
  attempt.assertLive();
1827
2387
  approvedId = replay.authorizationId;
@@ -1852,7 +2412,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1852
2412
  attempt.beginDelivery();
1853
2413
  lifecycle.prepareHandoff(replay, deliverRequest.url);
1854
2414
  handoffStarted = replay.mode !== 'cse';
1855
- if (replay.mode === 'hosted_form') {
2415
+ // False only for a card endpoint answer the device withheld (see deliverCardEndpointAnswer).
2416
+ let handedOver = true;
2417
+ if (replay.mode === 'card_endpoint') {
2418
+ handedOver = await deliverCardEndpointAnswer(opts, lifecycle, cardEndpoint, replay, { url: deliverRequest.url, headers: deliverRequest.headers }, {
2419
+ readJar: async () => (await cdp.send('Network.getCookies', { urls: [deliverRequest.url] }, deliverOn))?.cookies,
2420
+ writeCookie: async (cookie) => {
2421
+ const written = await cdp.send('Network.setCookie', cdpCookieParams(cookie), deliverOn);
2422
+ if (written?.success === false)
2423
+ throw new Error('cookie_refused');
2424
+ },
2425
+ fulfill: (response) => cdp.send('Fetch.fulfillRequest', { requestId: deliverTo, responseCode: response.status,
2426
+ responseHeaders: headerEntries(response.headers), body: Buffer.from(response.body).toString('base64') }, deliverOn),
2427
+ assertLive: () => attempt.assertLive(),
2428
+ });
2429
+ }
2430
+ else if (replay.mode === 'hosted_form') {
1856
2431
  // The device submitted the processor's own form; the processor
1857
2432
  // answered the device. The paused NAVIGATION is fulfilled with a page
1858
2433
  // that says exactly that (see hosted-form.ts for why not an abort and
@@ -1883,20 +2458,41 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1883
2458
  if (replay.kind === 'merchant_hosted')
1884
2459
  await merchantTotal.release(replay, opts.vault, attempt.signal);
1885
2460
  attempt.assertLive();
1886
- const postData = Buffer.from(cseContinuationBody(opts, deliverBody, replay, deliverRequest.url, preparation)).toString('base64');
2461
+ // A Shiji STS getTokenId carries pan and expires only; the device's cvv
2462
+ // is bound to its getCvvId companion before the continue, so the
2463
+ // companion cannot arrive first, and dropped if the continue fails.
2464
+ // Both go only to the request the approval was created from, from the
2465
+ // frame that sent it (cseContinuation refuses any other body).
2466
+ const continuation = cseContinuation(opts, deliverRequest.url, deliverBody, replay, { url: request.url, body }, preparation);
2467
+ const postData = Buffer.from(continuation.body).toString('base64');
2468
+ if (continuation.shijiCvv !== null) {
2469
+ shijiCompanions.bind({ url: request.url, method: request.method, body }, cdpShijiPage(sessionId, frameId), continuation.shijiCvv, continuation.shijiCvvKeySequences ?? []);
2470
+ }
1887
2471
  handoffStarted = true;
2472
+ if (replay.kind === 'verifone') {
2473
+ // The merchant's own server answers a Verifone continuation, as a merchant-hosted one.
2474
+ merchantHandoff.begin(replay.profile, replay.authorizationId, verifoneTemplatedUrl(replay.profile, deliverRequest.url));
2475
+ handoffNetwork = { sessionId: activeRequest?.sourceSessionId ?? deliverOn, networkId: activeRequest?.networkId ?? '' };
2476
+ }
1888
2477
  if (replay.kind === 'merchant_hosted') {
1889
2478
  // Watched from before the continue, so an answer that races the command is still read.
1890
2479
  merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url));
1891
2480
  handoffNetwork = { sessionId: activeRequest?.sourceSessionId ?? deliverOn, networkId: activeRequest?.networkId ?? '' };
1892
2481
  handoffReplay = replay;
1893
2482
  }
1894
- await cdp.send('Fetch.continueRequest', {
1895
- requestId: deliverTo,
1896
- postData,
1897
- }, deliverOn);
2483
+ try {
2484
+ await cdp.send('Fetch.continueRequest', {
2485
+ requestId: deliverTo,
2486
+ postData,
2487
+ }, deliverOn);
2488
+ }
2489
+ catch (error) {
2490
+ if (continuation.shijiCvv !== null)
2491
+ shijiCompanions.release();
2492
+ throw error;
2493
+ }
1898
2494
  attempt.assertLive();
1899
- opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url) });
2495
+ opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url, continuation.shijiCvv) });
1900
2496
  }
1901
2497
  else {
1902
2498
  // The processor's answer, handed to the page as if the processor had
@@ -1904,7 +2500,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1904
2500
  // see corsHeadersFor for why the answer has to carry the headers. The
1905
2501
  // decision rides on the event, so a fulfill the page could not read
1906
2502
  // (no usable Origin on a cross-origin request) is visible in telemetry
1907
- // rather than only as the page's own "connection error".
2503
+ // rather than only as the page's own "connection error". A
2504
+ // device_encrypt answer (Payrails' 201, which the device obtained) is
2505
+ // handed over the same way.
1908
2506
  const cors = corsDecision(deliverRequest.url, deliverRequest.headers);
1909
2507
  await cdp.send('Fetch.fulfillRequest', {
1910
2508
  requestId: deliverTo,
@@ -1912,10 +2510,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1912
2510
  responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
1913
2511
  body: Buffer.from(replay.body).toString('base64'),
1914
2512
  }, deliverOn);
2513
+ // The frame that received it: the paused request's, or its retry's once rebound.
2514
+ datatransRepeats.remember(activeRequest?.frameId || undefined, deliverRequest.url, deliverBody, replay);
1915
2515
  attempt.assertLive();
1916
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
2516
+ // An ACI card-number request answered: its checkout's held CVC and expiry requests get the
2517
+ // same answer, and its /payment submission may continue (see AciGate).
2518
+ aci.answered(deliverRequest.url, { status: replay.status, headers: replay.headers, body: replay.body }, replay.authorizationId);
2519
+ // An eProtect registration answered: a give-up report for it from here on means the
2520
+ // vendor script stopped listening before the answer landed (see EprotectGate.gaveUp).
2521
+ eprotect.delivered(deliverRequest.url, () => lifecycle.merchantRequestAborted());
2522
+ opts.onEvent?.({ type: 'authorized', detail: { mode: replay.mode === 'device_encrypt' ? 'device_encrypt' : 'token', authorizationId: replay.authorizationId,
2523
+ amountVerified: replay.mode === 'device_encrypt' ? null : replay.amountVerified ?? null, cors: cors.outcome } });
1917
2524
  }
1918
- lifecycle.handedOff(replay);
2525
+ if (handedOver)
2526
+ lifecycle.handedOff(replay);
1919
2527
  businessPause?.delivered.resolve();
1920
2528
  }
1921
2529
  catch (err) {
@@ -1954,14 +2562,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1954
2562
  terminal = err;
1955
2563
  else if (isApprovalOutcome(err))
1956
2564
  quietUntil = Date.now() + cooldownMs;
2565
+ reportCardEndpointRefusal(opts, cardEndpoint, err);
1957
2566
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
1958
2567
  }
1959
- await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: 'Aborted' }, dead.sessionId).catch(() => { });
2568
+ await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: cdpFailure(request.url) }, dead.sessionId).catch(() => { });
1960
2569
  settlePause();
1961
2570
  }
1962
2571
  finally {
2572
+ disarmSendWindow?.();
1963
2573
  if (activeRequest?.attempt === attempt)
1964
2574
  activeRequest = null;
2575
+ aci.end();
2576
+ eprotect.end();
1965
2577
  awaitingApproval = false;
1966
2578
  lifecycle.end();
1967
2579
  if (preparation)
@@ -2041,6 +2653,23 @@ export async function attachToPlaywright(page, opts) {
2041
2653
  const lifecycle = new CheckoutLifecycle(opts);
2042
2654
  const stripeCheckout = new StripeCheckoutGate(opts);
2043
2655
  const mercadoCheckout = new MercadoCheckoutGate();
2656
+ // CyberSource Flex capture contexts, verified once each (cybersource-flex.ts). Playwright
2657
+ // keeps a frame's fragment in frame.url(), so the field iframes are read when a request pauses.
2658
+ const microformContexts = new CaptureContexts();
2659
+ const frameUrlOf = (frame) => {
2660
+ try {
2661
+ const url = frame?.url?.();
2662
+ return typeof url === 'string' ? url : undefined;
2663
+ }
2664
+ catch {
2665
+ return undefined;
2666
+ }
2667
+ };
2668
+ // Juspay's tokenize answer and the synthetic tokens it issued (juspay-gate.ts).
2669
+ const juspayCheckout = new JuspayCheckoutGate(opts.vault);
2670
+ const aci = new AciGate(opts.vault);
2671
+ let navigationBodyReplaced;
2672
+ const eprotect = new EprotectGate(opts.vault);
2044
2673
  const readDocumentUrl = async () => {
2045
2674
  if (page.isClosed?.() || typeof page.url !== 'function')
2046
2675
  throw new Error('merchant_document_unavailable');
@@ -2078,6 +2707,12 @@ export async function attachToPlaywright(page, opts) {
2078
2707
  return false;
2079
2708
  }
2080
2709
  };
2710
+ // The Shiji STS getCvvId companion of the getTokenId this attachment last continued (shiji-sts.ts).
2711
+ // Dropped, with the device's CVV ciphertext, as soon as no getCvvId can use it: a cancel, the page
2712
+ // closing or crashing, the frame the bound getTokenId came from detaching, or that frame committing
2713
+ // a new document (the handlers below).
2714
+ const shijiCompanions = new ShijiStsCompanions();
2715
+ lifecycle.abort.signal.addEventListener('abort', () => shijiCompanions.release(), { once: true });
2081
2716
  page.on?.('request', (request) => {
2082
2717
  // A new document is on its way: the main frame's next navigation commits it.
2083
2718
  if (mainDocument(request))
@@ -2121,6 +2756,25 @@ export async function attachToPlaywright(page, opts) {
2121
2756
  }).catch(() => merchantTotal.log.failed(key));
2122
2757
  });
2123
2758
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
2759
+ // Payrails sessions this page's own XHR and fetch answers carried, read only
2760
+ // when the synced registry pauses Payrails' save; see attachToCdp.
2761
+ const payrails = recognizesPayrails(opts.vault) ? new PayrailsSessionCapture() : null;
2762
+ if (payrails) {
2763
+ page.on?.('response', (response) => {
2764
+ try {
2765
+ const headers = response.headers?.() ?? {};
2766
+ if (!payrailsInitCandidate(response.request?.()?.resourceType?.(), response.status?.(), headers['content-type']))
2767
+ return;
2768
+ if (Number(headers['content-length']) > PAYRAILS_INIT_BODY_LIMIT)
2769
+ return;
2770
+ payrails.reading(Promise.resolve().then(() => response.text()).then((body) => {
2771
+ if (typeof body === 'string' && body.length <= PAYRAILS_INIT_BODY_LIMIT)
2772
+ payrails.observe(body);
2773
+ }));
2774
+ }
2775
+ catch { /* an answer that cannot be read carries no session */ }
2776
+ });
2777
+ }
2124
2778
  // Playwright's own routing, NOT a hand-rolled CDP session.
2125
2779
  //
2126
2780
  // A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
@@ -2158,10 +2812,28 @@ export async function attachToPlaywright(page, opts) {
2158
2812
  }
2159
2813
  });
2160
2814
  page.on?.('close', () => { if (!attachmentReady)
2161
- setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
2815
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); shijiCompanions.release(); });
2162
2816
  page.on?.('crash', () => { if (!attachmentReady)
2163
- setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
2817
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); shijiCompanions.release(); });
2164
2818
  page.on?.('framenavigated', (frame) => {
2819
+ // A CyberSource Flex card field iframe loaded: start its capture-context check now, so its
2820
+ // card request pauses without waiting on it (cybersource-flex.ts).
2821
+ microformContexts.warm(frameUrlOf(frame));
2822
+ // The bound getTokenId's frame navigated. history.pushState, a hash change and a navigation the
2823
+ // page cancels keep its document; a new document replaces it. Read which, and drop the binding and
2824
+ // the device's CVV ciphertext only when the frame holds another document now or cannot be read; a
2825
+ // read still waiting on a pending navigation when the window closes keeps it (the binding expires
2826
+ // with the window). A claim reads the document as well, so a getCvvId is judged the same way.
2827
+ const boundPage = shijiCompanions.boundPage();
2828
+ const shijiFrame = frame && typeof frame === 'object' ? playwrightShijiFrames.get(frame) : undefined;
2829
+ if (boundPage && shijiFrame && boundPage.startsWith(`${shijiFrame}:doc:`)) {
2830
+ void playwrightFrameDocument(frame).then((document) => {
2831
+ if (document === undefined)
2832
+ return;
2833
+ if (document === null || boundPage !== `${shijiFrame}:doc:${document}`)
2834
+ shijiCompanions.releasePage((page) => page === boundPage);
2835
+ });
2836
+ }
2165
2837
  if (frame === page.mainFrame?.()) {
2166
2838
  if (documentRequest) {
2167
2839
  documentRequest = null;
@@ -2192,22 +2864,52 @@ export async function attachToPlaywright(page, opts) {
2192
2864
  page.on?.('framedetached', (frame) => {
2193
2865
  if (activeRequest?.frames.includes(frame))
2194
2866
  activeRequest.attempt.stop();
2867
+ const gone = frame && typeof frame === 'object' ? playwrightShijiFrames.get(frame) : undefined;
2868
+ if (gone)
2869
+ shijiCompanions.releasePage((bound) => bound.startsWith(`${gone}:doc:`));
2195
2870
  });
2196
2871
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
2197
2872
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
2198
2873
  let lastSubmitted = null;
2874
+ // The approved Datatrans answers, per Frame: the repeat rule (datatrans.ts).
2875
+ const datatransRepeats = new DatatransRepeats();
2876
+ const frameOf = (request) => { try {
2877
+ return request.frame?.() ?? undefined;
2878
+ }
2879
+ catch {
2880
+ return undefined;
2881
+ } };
2199
2882
  const install = async (assertActive) => {
2200
2883
  if (page.isClosed?.()) {
2201
2884
  const failure = new CheckoutAttachmentError('closed');
2202
2885
  setupStop.abort(failure);
2203
2886
  throw failure;
2204
2887
  }
2205
- await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString())
2888
+ await page.route(
2889
+ // The matcher sees only the URL. ACI's /payment and /registration are not the recognizer's URL,
2890
+ // so the ACI gate routes them (never ACI's widget script, /bins or /v1/collect).
2891
+ // The matcher sees only the URL. Worldpay eProtect's registration is a GET, so its family and
2892
+ // the vendor's give-up reports are routed by the eProtect gate (never its scripts).
2893
+ (url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString())
2894
+ || eprotect.matches(url.toString())
2206
2895
  // An armed merchant profile's endpoint or a sibling of it (merchant-hosted.ts), as attachToCdp arms it.
2207
- || (typeof opts.vault.merchantProfileOf === 'function' && opts.vault.merchantProfileOf(url.toString()) !== null), async (route) => {
2896
+ || (typeof opts.vault.merchantProfileOf === 'function' && opts.vault.merchantProfileOf(url.toString()) !== null)
2897
+ // An armed Verifone merchant's origin (verifone.ts), as attachToCdp arms it.
2898
+ || (typeof opts.vault.verifoneProfileOf === 'function' && opts.vault.verifoneProfileOf(url.toString()) !== null)
2899
+ // GMO token v1's JSONP GET, routed only for an org the API served GMO to (see attachToCdp).
2900
+ || (isGmoTokenV1Request(url.toString()) && recognizesGmo(opts.vault))
2901
+ || juspayCheckout.matches(url.toString())
2902
+ // Every reviewed card endpoint, in any status (the pre-claim gate refuses one this build does not pay).
2903
+ || cardEndpointFor(url.toString(), 'POST') !== null
2904
+ || aci.matches(url.toString())
2905
+ // Every Payrails host, routed only for an org the API served Payrails to (see attachToCdp).
2906
+ || (payrails !== null && isPayrailsHost(url.toString())), async (route) => {
2208
2907
  const request = route.request();
2209
2908
  // When this request paused, and the page it came from: the merchant's total is read as of now.
2210
2909
  const pausedAt = Date.now();
2910
+ // The frame and document a Shiji STS request came from, read as it pauses: an approved
2911
+ // getTokenId's companion binding belongs to that document (playwrightShijiDocumentPage).
2912
+ const shijiDocumentPage = isShijiStsHost(request.url()) ? playwrightShijiDocumentPage(request) : null;
2211
2913
  const pausedPage = merchantTotal.pageOf(keyOf(request)) ?? `doc:${documentSerial}`;
2212
2914
  // One pre-claim gate registry decides and performs the mechanics through
2213
2915
  // Playwright's own routing; the accessors read the same state the inline
@@ -2219,14 +2921,34 @@ export async function attachToPlaywright(page, opts) {
2219
2921
  // headers() and postData() are read lazily, inside the gate that needs them (see
2220
2922
  // PreClaimContext.request): a Playwright request mock without headers() and no Stripe checkout
2221
2923
  // never invokes it, and a body Playwright cannot read stops a Mercado checkout the way it did inline.
2222
- request: { url: request.url(), method: request.method(), get headers() { return request.headers(); }, get body() { return request.postData(); } },
2223
- vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout,
2924
+ // postData() is null for no body, an empty one and a streamed upload alike; Payrails'
2925
+ // gate refuses a null or empty body on every adapter, its static host aside (see payrailsPreClaim).
2926
+ // At an armed Verifone merchant's origin a gate reads that null as '', a request with no body:
2927
+ // in Chromium, Playwright hands over the bytes of a Blob, a File, a FormData and a large JSON
2928
+ // body (measured on fetch, XMLHttpRequest and sendBeacon), so its null there is a request with no
2929
+ // body in practice; a body Chromium keeps no bytes for, such as a stream, is the one left, and
2930
+ // attachToCdp aborts it. Every other request's gate reads the null as it comes.
2931
+ request: { url: request.url(), method: request.method(), get headers() { return request.headers(); },
2932
+ get body() {
2933
+ const body = request.postData();
2934
+ return body === null && typeof opts.vault.verifoneProfileOf === 'function' && opts.vault.verifoneProfileOf(request.url()) !== null ? '' : body;
2935
+ },
2936
+ get navigation() { try {
2937
+ return request.isNavigationRequest?.() === true;
2938
+ }
2939
+ catch {
2940
+ return false;
2941
+ } } },
2942
+ vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout, aci, eprotect,
2943
+ // Asked once, and only for ACI's classic-mode /payment: whether this page's engine sends
2944
+ // the body route.continue replaces on a navigation (aci.ts playwrightReplacesNavigationBody).
2945
+ replacesNavigationBody: () => (navigationBodyReplaced ??= playwrightReplacesNavigationBody(page)),
2224
2946
  readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
2225
2947
  effects: {
2226
2948
  continueRequest: (postData) => postData !== undefined ? route.continue({ postData }) : route.fallback(),
2227
2949
  answer: (response) => route.fulfill({ ...response,
2228
2950
  headers: withCorsHeaders(response.headers, corsHeadersFor(request.url(), request.headers())) }),
2229
- abort: () => route.abort('aborted'),
2951
+ abort: () => route.abort(refusalFailure(request.url())),
2230
2952
  },
2231
2953
  attachmentReady: () => attachmentReady,
2232
2954
  interceptionAborted: () => setupStop.signal.aborted,
@@ -2236,31 +2958,80 @@ export async function attachToPlaywright(page, opts) {
2236
2958
  stripeAttachmentBusy: () => false,
2237
2959
  requestInactive: () => !!(page.isClosed?.() || request.failure?.()),
2238
2960
  awaitingApproval: () => awaitingApproval,
2961
+ // The request's frame, then each parent up to the page's main frame; a request with no frame
2962
+ // (a service worker's) has no chain.
2963
+ initiatorFrames: () => {
2964
+ const urls = [];
2965
+ try {
2966
+ for (let frame = request.frame(); frame && urls.length < 64; frame = frame.parentFrame())
2967
+ urls.push(frame.url());
2968
+ }
2969
+ catch {
2970
+ return undefined;
2971
+ }
2972
+ return urls.length ? urls : undefined;
2973
+ },
2239
2974
  stripeStep: null,
2240
2975
  // This route arrives after every answer the page read before sending it; only an unfollowed 3xx is left to settle.
2241
2976
  merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, () => { handoffAnswer?.settle(); return false; }) },
2977
+ // The card field iframe that sent a Flex request, and every frame beside it on the page.
2978
+ cybersourceFlex: { judge: (flexRequest) => {
2979
+ let issuing;
2980
+ try {
2981
+ issuing = request.frame?.();
2982
+ }
2983
+ catch {
2984
+ issuing = undefined;
2985
+ }
2986
+ let frames = [];
2987
+ try {
2988
+ frames = page.frames?.() ?? [];
2989
+ }
2990
+ catch {
2991
+ frames = [];
2992
+ }
2993
+ return judgeMicroformFrameUrls(flexRequest, frameUrlOf(issuing), frames.map(frameUrlOf), microformContexts);
2994
+ } },
2995
+ juspay: juspayCheckout,
2996
+ datatrans: datatransRepeats,
2997
+ frame: frameOf(request),
2998
+ payrails,
2999
+ shijiSts: { companions: shijiCompanions, page: async () => (await playwrightShijiDocumentPage(request)) ?? `pw:none:${++playwrightShijiSeq}` },
2242
3000
  });
2243
3001
  const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
2244
3002
  if (preClaim.kind !== 'pause')
2245
3003
  return;
2246
3004
  const stripeStep = preClaim.stripeStep;
3005
+ // The capture context and fields a CyberSource Flex create carries, and its retry shape.
3006
+ const flexPause = preClaim.cybersourceFlex;
3007
+ // A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts).
3008
+ const cardEndpoint = preClaim.cardEndpoint ?? null;
3009
+ // A Payrails card save the gate bound to a captured session: its key rides on the create.
3010
+ const payrailsSidecar = preClaim.payrails;
2247
3011
  if (!attachmentReady || setupStop.signal.aborted) {
2248
3012
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
2249
- return route.abort('aborted');
3013
+ return route.abort(refusalFailure(request.url()));
2250
3014
  }
3015
+ // Same order as attachToCdp: a routine card-free request continues in
3016
+ // every state, before the preparation claim, so it neither spends a
3017
+ // ready preparation nor counts as the page's first card request.
3018
+ const continuationBody = request.postData();
3019
+ const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody, lifecycle.cardHandoffs()) ?? null;
3020
+ if (withoutCard === 'continue')
3021
+ return route.fallback();
2251
3022
  let preparation;
2252
3023
  try {
2253
- preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '', request.headers());
3024
+ preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.method(), request.postData() ?? '', request.headers());
2254
3025
  }
2255
3026
  catch (error) {
2256
3027
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
2257
- return route.abort('aborted');
3028
+ return route.abort(refusalFailure(request.url()));
2258
3029
  }
2259
3030
  // The page asking again for the purchase an outstanding approval is
2260
3031
  // for, after its own request timed out: bind it to that approval (see
2261
3032
  // merchantAttempt and the same check in attachToCdp).
2262
3033
  if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()) {
2263
- const retry = requestIdentity(opts, request.url(), request.method(), request.postData() ?? '', documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
3034
+ const retry = requestIdentity(opts, request.url(), request.method(), request.postData() ?? '', documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts), flexPause?.shape);
2264
3035
  if (activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
2265
3036
  if (preparation)
2266
3037
  preparationGate.retireUnboundClaim();
@@ -2277,29 +3048,24 @@ export async function attachToPlaywright(page, opts) {
2277
3048
  return;
2278
3049
  }
2279
3050
  }
2280
- // Same order as attachToCdp: a routine card-free request continues in every state.
2281
- const continuationBody = request.postData();
2282
- const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody) ?? null;
2283
- if (withoutCard === 'continue') {
2284
- if (preparation)
2285
- preparationGate.retireUnboundClaim();
2286
- return route.fallback();
2287
- }
2288
- const continuationId = stripeStep || terminal ? null
3051
+ const continuation = stripeStep || terminal ? null
2289
3052
  : claimStripeContinuation(opts, lifecycle, request.url(), request.method(), continuationBody);
2290
- if (continuationId) {
3053
+ if (continuation) {
2291
3054
  if (preparation)
2292
3055
  preparationGate.retireUnboundClaim();
2293
- if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url(), method: request.method(), headers: request.headers(), body: continuationBody }) && !setupStop.signal.aborted) {
2294
- return route.fallback();
3056
+ if (await checkStripeContinuation(opts, lifecycle, continuation, { url: request.url(), method: request.method(), headers: request.headers() }) && !setupStop.signal.aborted) {
3057
+ // As in attachToCdp: a rewritten card save continues with the allowed body; Playwright recomputes the length.
3058
+ return continuation.rewritten ? route.continue({ postData: continuation.body }) : route.fallback();
2295
3059
  }
2296
- return route.abort('aborted');
3060
+ return route.abort(refusalFailure(request.url()));
2297
3061
  }
2298
3062
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
2299
3063
  // the page's retry loop turn into a stream of doomed API calls. Every
2300
- // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
2301
- // CDP adapter's Fetch.failRequest uses, so a refused navigation
2302
- // resolves identically whichever adapter is attached.
3064
+ // abort in this adapter is 'aborted' (ERR_ABORTED), and a CyberSource
3065
+ // Flex request's is 'failed' (refusedRequestFailure), the same codes the
3066
+ // CDP adapter's Fetch.failRequest uses, so a refused request resolves
3067
+ // identically whichever adapter is attached. An Amadeus card request is
3068
+ // 'failed' (ERR_FAILED) in both too, see failsAsNetworkError (refusalFailure).
2303
3069
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
2304
3070
  let repeatedAuthorizationId = null;
2305
3071
  if (lastSubmitted) {
@@ -2316,7 +3082,7 @@ export async function attachToPlaywright(page, opts) {
2316
3082
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
2317
3083
  if (preparation)
2318
3084
  preparationGate.retireUnboundClaim();
2319
- const refused = await route.abort('aborted');
3085
+ const refused = await route.abort(refusalFailure(request.url()));
2320
3086
  if (repeatedAuthorizationId)
2321
3087
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
2322
3088
  return refused;
@@ -2326,7 +3092,7 @@ export async function attachToPlaywright(page, opts) {
2326
3092
  if (preparation)
2327
3093
  preparationGate.retireUnboundClaim();
2328
3094
  opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
2329
- return route.abort('aborted');
3095
+ return route.abort(refusalFailure(request.url()));
2330
3096
  }
2331
3097
  // Reserved before anything that could yield, matching attachToCdp.
2332
3098
  awaitingApproval = true;
@@ -2337,7 +3103,9 @@ export async function attachToPlaywright(page, opts) {
2337
3103
  catch {
2338
3104
  navigation = false;
2339
3105
  }
2340
- const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url(), request.method(), navigation, !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url(), request.method())));
3106
+ // A card endpoint approval never outlives its request (see attachToCdp).
3107
+ const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url(), request.method(), navigation, !!preparation || !!stripeStep || !!cardEndpoint || mercadoCheckout.requiresDocument(request.url(), request.method())
3108
+ || juspayCheckout.bindsApproval(request.url(), request.method())));
2341
3109
  const frames = [];
2342
3110
  try {
2343
3111
  for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
@@ -2358,28 +3126,40 @@ export async function attachToPlaywright(page, opts) {
2358
3126
  // The approval the device gave, and whether it reached a browser request.
2359
3127
  let approvedId = null;
2360
3128
  let deliveryBegun = false;
3129
+ // A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
3130
+ let disarmSendWindow = null;
2361
3131
  try {
2362
3132
  const body = request.postData() ?? '';
2363
3133
  if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
2364
3134
  opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
2365
- const refused = await route.abort('aborted');
3135
+ const refused = await route.abort(refusalFailure(request.url()));
2366
3136
  if (lastSubmitted)
2367
3137
  void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
2368
3138
  return refused;
2369
3139
  }
2370
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url()), recognizer: recognizerNameOf(opts, request.url(), request.method()) } });
3140
+ // A card endpoint's events name the profile and the endpoint origin, never its URL (see attachToCdp).
3141
+ opts.onEvent?.({ type: 'card_request_paused', detail: { ...(cardEndpoint ? cardEndpointDetail(cardEndpoint) : { url: eventUrl(opts.vault, request.url()) }),
3142
+ recognizer: recognizerNameOf(opts, request.url(), request.method()) } });
2371
3143
  lifecycle.begin();
2372
3144
  const approvalStartedAt = Date.now();
2373
3145
  const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
2374
3146
  attempt.bind({ route, request, frames });
2375
3147
  activeRequest = { request, frames, attempt,
2376
- identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined) };
3148
+ identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined, flexPause?.shape) };
3149
+ // An ACI card-number request: see the same step in attachToCdp.
3150
+ if (!aci.begin(request.url(), request.method(), body, preparation?.merchantOrigin)) {
3151
+ throw new CheckoutPreparationError(preparation?.id ?? null, ACI_CHECKOUT_REUSED);
3152
+ }
3153
+ // A Worldpay eProtect registration: see the same step in attachToCdp.
3154
+ eprotect.begin(request.url(), request.method(), () => attempt.stop());
3155
+ disarmSendWindow = armWindowFor(opts, lifecycle, attempt, preparation, request.url(), () => deliveryBegun);
2377
3156
  if (preparation)
2378
3157
  await preparationGate.assertDocument();
2379
3158
  // One bounded read serves the merchant origin and the retry identity; see attachToCdp.
2380
3159
  const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
2381
3160
  const pageOrigin = pageOriginFrom(documentUrl);
2382
- const pageAmount = await pageAmountOf(opts);
3161
+ // The agent's amount is a card endpoint's only authority: no page total is read.
3162
+ const pageAmount = cardEndpoint ? undefined : await pageAmountOf(opts);
2383
3163
  assertRequestLive();
2384
3164
  activeRequest.identity.documentUrl = documentKey(documentUrl);
2385
3165
  activeRequest.identity.pageAmount = pageAmount;
@@ -2389,6 +3169,13 @@ export async function attachToPlaywright(page, opts) {
2389
3169
  const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
2390
3170
  ? opts.merchantOrigin : pageOrigin?.startsWith('https:') ? pageOrigin : opts.merchantOrigin);
2391
3171
  assertRequestLive();
3172
+ // The card endpoint's session (see attachToCdp): request.headers() never carries
3173
+ // the cookie jar, so the endpoint's cookies are read from the context.
3174
+ const session = cardEndpoint ? await captureCardEndpointSession(cardEndpoint, {
3175
+ url: request.url(), headers: request.headers(), body, pageOrigin,
3176
+ cookies: async () => await page.context().cookies(request.url()),
3177
+ }) : null;
3178
+ assertRequestLive();
2392
3179
  const replay = await opts.vault.authorize({
2393
3180
  user: opts.user,
2394
3181
  merchant: opts.merchant,
@@ -2413,9 +3200,12 @@ export async function attachToPlaywright(page, opts) {
2413
3200
  lifecycle.approvalUrl(url);
2414
3201
  return opts.onApprovalUrl?.(url);
2415
3202
  } },
2416
- request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url(), method: request.method(), headers: request.headers(), body,
2417
- ...(mercadoCheckout.requiresDocument(request.url(), request.method())
2418
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}) },
3203
+ request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session)
3204
+ : { url: request.url(), method: request.method(), headers: request.headers(), body,
3205
+ ...(mercadoCheckout.requiresDocument(request.url(), request.method())
3206
+ ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}),
3207
+ ...(flexPause ? flexPause.sidecar : {}),
3208
+ ...(payrailsSidecar ? { payrails: payrailsSidecar } : {}) },
2419
3209
  });
2420
3210
  assertRequestLive();
2421
3211
  approvedId = replay.authorizationId;
@@ -2440,7 +3230,17 @@ export async function attachToPlaywright(page, opts) {
2440
3230
  attempt.beginDelivery();
2441
3231
  lifecycle.prepareHandoff(replay, deliverRequest.url());
2442
3232
  handoffStarted = replay.mode !== 'cse';
2443
- if (replay.mode === 'hosted_form') {
3233
+ // False only for a card endpoint answer the device withheld (see deliverCardEndpointAnswer).
3234
+ let handedOver = true;
3235
+ if (replay.mode === 'card_endpoint') {
3236
+ handedOver = await deliverCardEndpointAnswer(opts, lifecycle, cardEndpoint, replay, { url: deliverRequest.url(), headers: deliverRequest.headers() }, {
3237
+ readJar: () => page.context().cookies(deliverRequest.url()),
3238
+ writeCookie: (cookie) => page.context().addCookies([playwrightCookieParams(cookie)]),
3239
+ fulfill: (response) => deliverTo.fulfill(response),
3240
+ assertLive: assertRequestLive,
3241
+ });
3242
+ }
3243
+ else if (replay.mode === 'hosted_form') {
2444
3244
  // Same as the CDP path: the paused navigation resolves to the
2445
3245
  // synthetic page, and a re-post of this form is refused.
2446
3246
  const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
@@ -2458,8 +3258,26 @@ export async function attachToPlaywright(page, opts) {
2458
3258
  if (replay.kind === 'merchant_hosted')
2459
3259
  await merchantTotal.release(replay, opts.vault, attempt.signal);
2460
3260
  assertRequestLive();
2461
- const postData = cseContinuationBody(opts, deliverBody, replay, deliverRequest.url(), preparation);
3261
+ // A Shiji STS getTokenId binds the device's cvv to its companion
3262
+ // first, exactly as in attachToCdp: only for the request the
3263
+ // approval was created from, and its frame.
3264
+ const continuation = cseContinuation(opts, deliverRequest.url(), deliverBody, replay, { url: request.url(), body }, preparation);
3265
+ if (continuation.shijiCvv !== null) {
3266
+ // The document the getTokenId paused in. One that cannot be read leaves the CVV nowhere to
3267
+ // go, so the card is not tokenized either.
3268
+ const boundPage = shijiDocumentPage ? await shijiDocumentPage : null;
3269
+ if (boundPage === null)
3270
+ throw new ShijiStsError('unsupported');
3271
+ assertRequestLive();
3272
+ shijiCompanions.bind({ url: request.url(), method: request.method(), body }, boundPage, continuation.shijiCvv, continuation.shijiCvvKeySequences ?? []);
3273
+ }
2462
3274
  handoffStarted = true;
3275
+ if (replay.kind === 'verifone') {
3276
+ // The merchant's own server answers a Verifone continuation, as a merchant-hosted one.
3277
+ merchantHandoff.begin(replay.profile, replay.authorizationId, verifoneTemplatedUrl(replay.profile, deliverRequest.url()));
3278
+ handoffAnswer?.stop();
3279
+ handoffAnswer = watchPlaywrightHandoff(page, deliverRequest, merchantHandoff);
3280
+ }
2463
3281
  if (replay.kind === 'merchant_hosted') {
2464
3282
  merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url()));
2465
3283
  // Watched from before the continue: 5xx, a failure or the page closing is an unknown outcome.
@@ -2467,9 +3285,16 @@ export async function attachToPlaywright(page, opts) {
2467
3285
  handoffAnswer?.stop();
2468
3286
  handoffAnswer = watchPlaywrightHandoff(page, deliverRequest, merchantHandoff, () => merchantTotal.answered(replay, opts.vault));
2469
3287
  }
2470
- await deliverTo.continue({ postData });
3288
+ try {
3289
+ await deliverTo.continue({ postData: continuation.body });
3290
+ }
3291
+ catch (error) {
3292
+ if (continuation.shijiCvv !== null)
3293
+ shijiCompanions.release();
3294
+ throw error;
3295
+ }
2471
3296
  assertRequestLive();
2472
- opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url()) });
3297
+ opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url(), continuation.shijiCvv) });
2473
3298
  }
2474
3299
  else {
2475
3300
  // Playwright adds these itself when a cross-origin fulfill carries
@@ -2477,10 +3302,17 @@ export async function attachToPlaywright(page, opts) {
2477
3302
  // cross-origin answer is the same whichever adapter ran.
2478
3303
  const cors = corsDecision(deliverRequest.url(), deliverRequest.headers());
2479
3304
  await deliverTo.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
3305
+ datatransRepeats.remember(frameOf(deliverRequest), deliverRequest.url(), deliverBody, replay);
2480
3306
  assertRequestLive();
2481
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
3307
+ // See the same step in attachToCdp.
3308
+ aci.answered(deliverRequest.url(), { status: replay.status, headers: replay.headers, body: replay.body }, replay.authorizationId);
3309
+ // See the same step in attachToCdp.
3310
+ eprotect.delivered(deliverRequest.url(), () => lifecycle.merchantRequestAborted());
3311
+ opts.onEvent?.({ type: 'authorized', detail: { mode: replay.mode === 'device_encrypt' ? 'device_encrypt' : 'token', authorizationId: replay.authorizationId,
3312
+ amountVerified: replay.mode === 'device_encrypt' ? null : replay.amountVerified ?? null, cors: cors.outcome } });
2482
3313
  }
2483
- lifecycle.handedOff(replay);
3314
+ if (handedOver)
3315
+ lifecycle.handedOff(replay);
2484
3316
  }
2485
3317
  catch (err) {
2486
3318
  const dead = attempt.target?.route ?? route;
@@ -2505,13 +3337,17 @@ export async function attachToPlaywright(page, opts) {
2505
3337
  terminal = err;
2506
3338
  else if (isApprovalOutcome(err))
2507
3339
  quietUntil = Date.now() + cooldownMs;
3340
+ reportCardEndpointRefusal(opts, cardEndpoint, err);
2508
3341
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
2509
3342
  }
2510
- await dead.abort('aborted').catch(() => { });
3343
+ await dead.abort(refusalFailure(request.url())).catch(() => { });
2511
3344
  }
2512
3345
  finally {
3346
+ disarmSendWindow?.();
2513
3347
  if (activeRequest?.attempt === attempt)
2514
3348
  activeRequest = null;
3349
+ aci.end();
3350
+ eprotect.end();
2515
3351
  awaitingApproval = false;
2516
3352
  lifecycle.end();
2517
3353
  if (preparation)