@agent-cards/sdk 0.21.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 (103) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/PREFLIGHT.md +30 -0
  3. package/README.md +1070 -21
  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 +5 -1
  22. package/dist/cdp.js +841 -135
  23. package/dist/client.d.ts +403 -19
  24. package/dist/client.js +949 -98
  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/datatrans.d.ts +96 -0
  29. package/dist/datatrans.generated.d.ts +64 -0
  30. package/dist/datatrans.generated.js +426 -0
  31. package/dist/datatrans.js +178 -0
  32. package/dist/elavon-converge.d.ts +37 -0
  33. package/dist/elavon-converge.generated.d.ts +49 -0
  34. package/dist/elavon-converge.generated.js +439 -0
  35. package/dist/elavon-converge.js +84 -0
  36. package/dist/eprotect.d.ts +152 -0
  37. package/dist/eprotect.generated.d.ts +77 -0
  38. package/dist/eprotect.generated.js +498 -0
  39. package/dist/eprotect.js +222 -0
  40. package/dist/hosted-form.d.ts +3 -2
  41. package/dist/hosted-form.js +3 -2
  42. package/dist/index.d.ts +17 -3
  43. package/dist/index.js +6 -1
  44. package/dist/juspay-gate.d.ts +82 -0
  45. package/dist/juspay-gate.js +126 -0
  46. package/dist/juspay.generated.d.ts +89 -0
  47. package/dist/juspay.generated.js +705 -0
  48. package/dist/lifecycle.d.ts +57 -6
  49. package/dist/lifecycle.js +119 -8
  50. package/dist/merchant-card-endpoints.generated.d.ts +63 -0
  51. package/dist/merchant-card-endpoints.generated.js +1103 -0
  52. package/dist/merchant-hosted.d.ts +4 -1
  53. package/dist/merchant-hosted.js +5 -2
  54. package/dist/merchant-session-probe.d.ts +48 -0
  55. package/dist/merchant-session-probe.js +53 -0
  56. package/dist/merchant-session.generated.d.ts +69 -0
  57. package/dist/merchant-session.generated.js +919 -0
  58. package/dist/payrails.d.ts +133 -0
  59. package/dist/payrails.generated.d.ts +73 -0
  60. package/dist/payrails.generated.js +596 -0
  61. package/dist/payrails.js +305 -0
  62. package/dist/pelecard.d.ts +32 -0
  63. package/dist/pelecard.generated.d.ts +32 -0
  64. package/dist/pelecard.generated.js +283 -0
  65. package/dist/pelecard.js +42 -0
  66. package/dist/pre-claim.d.ts +72 -2
  67. package/dist/pre-claim.js +857 -5
  68. package/dist/preflight-catalog.json +2218 -287
  69. package/dist/preflight-schemas.json +179 -6
  70. package/dist/preflight.generated.d.ts +1 -1
  71. package/dist/preflight.generated.js +233 -4
  72. package/dist/preparation.d.ts +5 -2
  73. package/dist/preparation.js +36 -10
  74. package/dist/prepared-processor.d.ts +84 -5
  75. package/dist/prepared-processor.js +147 -6
  76. package/dist/primer.d.ts +53 -0
  77. package/dist/primer.generated.d.ts +38 -0
  78. package/dist/primer.generated.js +624 -0
  79. package/dist/primer.js +82 -0
  80. package/dist/registry.d.ts +100 -8
  81. package/dist/registry.js +85 -11
  82. package/dist/shiji-sts.d.ts +144 -0
  83. package/dist/shiji-sts.generated.d.ts +72 -0
  84. package/dist/shiji-sts.generated.js +913 -0
  85. package/dist/shiji-sts.js +222 -0
  86. package/dist/stripe-checkout.generated.d.ts +1 -1
  87. package/dist/stripe-checkout.generated.js +699 -9
  88. package/dist/stripe-setup.d.ts +16 -0
  89. package/dist/stripe-setup.js +61 -0
  90. package/dist/substitutions.generated.js +187 -29
  91. package/dist/verifone.d.ts +68 -0
  92. package/dist/verifone.generated.d.ts +51 -0
  93. package/dist/verifone.generated.js +1401 -0
  94. package/dist/verifone.js +119 -0
  95. package/dist/voice-types.d.ts +27 -0
  96. package/dist/voice.d.ts +6 -1
  97. package/dist/voice.js +6 -1
  98. package/dist/wspay.d.ts +41 -0
  99. package/dist/wspay.generated.d.ts +41 -0
  100. package/dist/wspay.generated.js +370 -0
  101. package/dist/wspay.js +65 -0
  102. package/examples/preflight/kernel-native/inventory.json +1 -1
  103. package/package.json +3 -3
package/dist/cdp.js CHANGED
@@ -1,26 +1,41 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
- import { AdyenTestPlatformRefusedError, AmountRequiredError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedCapabilityError, UnsupportedModeError, } from './client.js';
2
+ import { AdyenTestPlatformRefusedError, AmountRequiredError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, CheckoutPreparationError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedCapabilityError, UnsupportedModeError, } from './client.js';
3
3
  import { PreparationGate } from './preparation.js';
4
4
  import { StripeCheckoutGate } from './stripe-checkout.js';
5
5
  import { MercadoCheckoutGate } from './mercado-checkout.js';
6
6
  import { MERCADO_CHECKOUT_PATTERN } from './mercado-checkout.generated.js';
7
+ import { JuspayCheckoutGate } from './juspay-gate.js';
8
+ import { ACI_CHECKOUT_REUSED, AciGate, playwrightReplacesNavigationBody } from './aci.js';
9
+ import { EprotectGate } from './eprotect.js';
7
10
  import { dispatchPreClaim, eventUrl } from './pre-claim.js';
8
11
  import { GMO_TOKEN_V1_PATTERNS, gmoRequestShop, isGmoTokenV1Request, recognizesGmo } from './gmo.js';
12
+ import { DatatransRepeats } from './datatrans.js';
13
+ import { SHIJI_STS_COMPANION_WINDOW_MS, ShijiStsCompanions, ShijiStsError, isShijiStsHost, shijiStsTokenContinuation, } from './shiji-sts.js';
9
14
  import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
10
15
  import { cseBody } from './cse-body.js';
16
+ import { STRIPE_SETUP_INTENT_CONFIRM, setupConfirmPayingWith } from './stripe-setup.js';
11
17
  import { replayTemplatedUrl, reviewedMerchantProfileUrlPatterns } from './merchant-hosted.js';
18
+ import { verifoneTemplatedUrl } from './verifone.js';
12
19
  import { MerchantHandoffWatch } from './merchant-handoff.js';
13
20
  import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watch.js';
14
21
  import { hostedFormSubmittedPage } from './hosted-form.js';
22
+ import { amadeusSendWindowMs, armSendWindow, failsAsNetworkError } from './amadeus.js';
15
23
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
16
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';
17
27
  /**
18
- * Fetch.failRequest's errorReason for a paused request the CDP adapter will not let through: the
19
- * CDP form of refusedRequestFailure ('Failed' for a CyberSource Flex request, so the Microform field
20
- * reports it to the page; 'Aborted' for every other request, as before).
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.
21
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. */
22
37
  function cdpFailure(url) {
23
- return refusedRequestFailure(url) === 'failed' ? 'Failed' : 'Aborted';
38
+ return refusalFailure(url) === 'failed' ? 'Failed' : 'Aborted';
24
39
  }
25
40
  // Every URL that leaves this module through onEvent is redacted to origin +
26
41
  // path first. A paused PaymentIntent confirm can carry the client secret in
@@ -131,6 +146,11 @@ const APPROVAL_COOLDOWN_MS = 5_000;
131
146
  * re-post is right; latching the whole attachment is not, because the prior
132
147
  * row declines or expires and the same form is then a new question. The
133
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.
134
154
  */
135
155
  function isApprovalOutcome(err) {
136
156
  // Processor refusals are held by the lifecycle until explicit merchant
@@ -139,7 +159,7 @@ function isApprovalOutcome(err) {
139
159
  if (err instanceof ProcessorRefusedError)
140
160
  return false;
141
161
  if (err instanceof CheckoutApiError)
142
- return err.code === 'duplicate_submission';
162
+ return err.code === 'duplicate_submission' || err.isStaleFingerprint;
143
163
  return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
144
164
  }
145
165
  /**
@@ -154,7 +174,8 @@ function isApprovalOutcome(err) {
154
174
  *
155
175
  * Terminal means "will answer identically next time no matter who does what":
156
176
  * a misconfiguration or an unsupported PSP. Nothing a person does changes
157
- * those, so asking again is pure waste. A GMO request with no `amount`
177
+ * those, so asking again is pure waste. A GMO request, a Pelecard submission
178
+ * or a WSPay confirm with no `amount`
158
179
  * (AmountRequiredError) is one: the attachment's options do not change.
159
180
  *
160
181
  * Other errors may be retryable unless the lifecycle holds the attachment
@@ -172,6 +193,10 @@ function isTerminal(err) {
172
193
  // Refused at create for Adyen's test platform: the page's next request is refused the same way.
173
194
  if (err instanceof AdyenTestPlatformRefusedError)
174
195
  return err.stage === 'create';
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;
175
200
  return err instanceof CardEncryptedError || err instanceof UnsupportedModeError || err instanceof PreparationRequiredError
176
201
  || err instanceof AmountRequiredError || err instanceof UnsupportedCapabilityError;
177
202
  }
@@ -189,7 +214,7 @@ function cseContinuationBody(opts, body, replay, url, preparation) {
189
214
  return cseBody(body, replay, url, preparation);
190
215
  }
191
216
  catch (error) {
192
- if (replay.kind === 'merchant_hosted' && typeof opts.vault?.cancelAuthorization === 'function') {
217
+ if ((replay.kind === 'merchant_hosted' || replay.kind === 'verifone') && typeof opts.vault?.cancelAuthorization === 'function') {
193
218
  void Promise.resolve().then(() => opts.vault.cancelAuthorization(replay.authorizationId, 'merchant_never_retried')).catch(() => { });
194
219
  }
195
220
  throw error;
@@ -279,13 +304,121 @@ function watchPlaywrightHandoff(page, request, watch, answered) {
279
304
  watch.failed();
280
305
  } } };
281
306
  }
282
- /** The `authorized` event's detail for a cse continuation; a merchant-hosted one names its profile and templated URL. */
283
- function cseAuthorizedDetail(replay, url) {
284
- const base = { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) };
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) };
285
315
  return replay.kind === 'merchant_hosted'
286
316
  ? { ...base, kind: 'merchant_hosted', profile: replay.profile, url: replayTemplatedUrl(replay, url) }
287
317
  : base;
288
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
+ }
289
422
  /**
290
423
  * The paused request's body, or null when Chromium says there is one and did
291
424
  * not hand it over. `postData` is set only for a text body; a binary or a
@@ -305,6 +438,66 @@ function pausedBody(request) {
305
438
  return null;
306
439
  return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
307
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
+ }
308
501
  const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
309
502
  /**
310
503
  * The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
@@ -424,6 +617,96 @@ export function withCorsHeaders(headers, cors) {
424
617
  }
425
618
  return out;
426
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
+ }
427
710
  /** CDP's header shape: `{ name, value }` entries. */
428
711
  function headerEntries(headers) {
429
712
  return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
@@ -551,26 +834,13 @@ function failureSummary(error) {
551
834
  // Why the merchant's own total could not stand behind the payment, and at which step.
552
835
  if (error instanceof MerchantTotalError)
553
836
  return `${error.name}: ${error.code}${error.reasonCode ? ` (${error.reasonCode})` : ''}`;
837
+ if (error instanceof CardEndpointRefusedError)
838
+ return `${error.name}: ${error.reason}`;
554
839
  return error instanceof Error ? error.name : 'CheckoutError';
555
840
  }
556
841
  const STRIPE_PAYMENT_INTENT_CONFIRM = /^\/v1\/payment_intents\/pi_[A-Za-z0-9]+\/confirm$/;
557
- /**
558
- * A page that tokenized the card through an approval, then confirms the
559
- * payment in the browser with the token it got back (confirmCardPayment with
560
- * payment_method: 'pm_...', confirmPayment with a confirmation token). That
561
- * confirmation carries no card, and the hold after a Stripe tokenization
562
- * would refuse it. The API decides instead whether it is the payment the
563
- * cardholder approved (the approved amount and currency, on the same Stripe
564
- * account, paid with exactly the approved token).
565
- *
566
- * Synchronous on purpose: every paused request passes through here, and an
567
- * await would reorder it against the attachment's own events. Returns the
568
- * approval to check, with the lifecycle's one continuation claimed, or null
569
- * when this is not such a confirmation and the ordinary rules apply.
570
- */
571
842
  function claimStripeContinuation(opts, lifecycle, url, method, body) {
572
- if (body == null || typeof opts.vault.checkStripeContinuation !== 'function' || !lifecycle.isBlocked()
573
- || method.toUpperCase() !== 'POST')
843
+ if (body == null || !lifecycle.isBlocked() || method.toUpperCase() !== 'POST')
574
844
  return null;
575
845
  let parsed;
576
846
  try {
@@ -579,25 +849,57 @@ function claimStripeContinuation(opts, lifecycle, url, method, body) {
579
849
  catch {
580
850
  return null;
581
851
  }
582
- if (parsed.origin !== 'https://api.stripe.com' || !STRIPE_PAYMENT_INTENT_CONFIRM.test(parsed.pathname)
583
- || 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)
584
875
  return null;
585
- return lifecycle.claimStripeContinuation();
876
+ const authorizationId = lifecycle.claimStripeContinuation();
877
+ return authorizationId ? { authorizationId, kind: 'setup', body: send, rewritten: send !== body } : null;
586
878
  }
587
- /** Ask the API about a claimed continuation and settle the claim. True: continue the page's request untouched. */
588
- async function checkStripeContinuation(opts, lifecycle, authorizationId, request) {
589
- let paymentIntentId = null;
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;
590
887
  try {
591
- 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;
592
891
  }
593
892
  catch (error) {
594
893
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
595
894
  }
596
895
  // The checkout may have been cancelled or reconciled while the API answered.
597
- const proceed = lifecycle.stripeContinuationChecked(paymentIntentId !== null);
598
- if (proceed)
599
- opts.onEvent?.({ type: 'stripe_payment_continued', detail: { authorizationId, paymentIntentId } });
600
- else if (paymentIntentId !== null)
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)
601
903
  opts.onEvent?.({ type: 'blocked', detail: 'stripe_continuation_superseded' });
602
904
  return proceed;
603
905
  }
@@ -690,15 +992,20 @@ function luhn(digits) {
690
992
  return sum % 10 === 0;
691
993
  }
692
994
  /**
693
- * The card-placeholder runs in a body: every 13 to 19 digit run that passes
694
- * Luhn, sorted. The placeholder the agent typed (4242 4242 4242 4242, 4111
695
- * 1111 1111 1111) passes; a millisecond timestamp or an order number fails
696
- * nine times in ten, and when one passes it merely makes the match stricter.
697
- * A client-side-encrypted body (Adyen) carries no digits at all and yields
698
- * an empty list on both sides.
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.
699
1006
  */
700
1007
  function cardRuns(body) {
701
- 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();
702
1009
  }
703
1010
  /** Every key path in a JSON value, arrays flattened so an element's order or count does not count. */
704
1011
  function jsonKeyPaths(value, prefix, out) {
@@ -800,12 +1107,24 @@ function sameCheckoutRequest(first, retry) {
800
1107
  * answer is a token or ciphertext, and any request for the same purchase can
801
1108
  * carry it. No for a hosted form (a navigation: there is no request to
802
1109
  * answer once the page has moved on), a prepared checkout (single use, bound
803
- * to one native request by design), a native Stripe Checkout step, and a
804
- * Mercado Pago request whose claim is bound to one document read. Unknown
805
- * modes (a duck-typed vault without checkoutModeOf) are taken as `token`.
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`.
806
1125
  */
807
1126
  function survivesAbandonment(opts, url, method, navigation, bound) {
808
- if (navigation || bound)
1127
+ if (navigation || bound || isShijiStsHost(url))
809
1128
  return false;
810
1129
  const mode = typeof opts.vault?.checkoutModeOf === 'function' ? opts.vault.checkoutModeOf(url, method) : 'token';
811
1130
  return mode === 'token' || mode === 'cse';
@@ -836,7 +1155,12 @@ function merchantAttempt(lifecycle, survives) {
836
1155
  let rebound = false;
837
1156
  let delivering = false;
838
1157
  let bound = null;
839
- 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);
840
1164
  const attempt = {
841
1165
  signal: controller.signal,
842
1166
  /** The browser request that currently carries this attempt; null while the page has none. */
@@ -1059,6 +1383,23 @@ function pauseForBusiness(opts, lifecycle, attemptSignal, request, pageOrigin) {
1059
1383
  });
1060
1384
  return { replay, delivered: { resolve: deliveredResolve, reject: deliveredReject } };
1061
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
+ }
1062
1403
  /**
1063
1404
  * Take over card tokenization for a page.
1064
1405
  *
@@ -1090,6 +1431,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1090
1431
  const ownedSessions = new Set([pageSessionId]);
1091
1432
  const sessionTypes = new Map([[pageSessionId, 'page']]);
1092
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();
1093
1437
  const detachedSessions = new Set();
1094
1438
  const rejectedSessions = new Set();
1095
1439
  const workerOwners = new Map();
@@ -1174,6 +1518,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1174
1518
  clearTimeout(timer);
1175
1519
  }
1176
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);
1177
1525
  let preparationFrameId;
1178
1526
  const readDocumentUrl = async () => {
1179
1527
  const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
@@ -1194,6 +1542,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1194
1542
  const merchantTotal = new MerchantTotalWatch(opts.onEvent, opts.vault);
1195
1543
  let handoffReplay = null;
1196
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();
1197
1552
  const armed = new Set();
1198
1553
  const arming = new Map();
1199
1554
  // Set once a failure proves that retrying cannot help; see isTerminal.
@@ -1344,6 +1699,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1344
1699
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
1345
1700
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
1346
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 });
1347
1711
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
1348
1712
  // The endpoint and siblings of every merchant profile this build reviewed
1349
1713
  // (merchant-hosted.ts), which a VaultClient arms from the start: a profile's card
@@ -1351,13 +1715,21 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1351
1715
  // it, before or after any syncRegistry(). The gate continues untouched a request
1352
1716
  // that the vault names no profile for.
1353
1717
  const merchantPatterns = [...reviewedMerchantProfileUrlPatterns(),
1354
- ...(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() : [])];
1355
1722
  // GMO token v1's JSONP GET is armed only when the synced registry recognizes
1356
1723
  // GMO (an org the API served the dark entry to), so the gate can refuse it;
1357
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).
1358
1729
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
1359
1730
  ...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns,
1360
- ...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : [])])];
1731
+ ...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : []), ...juspayCheckout.urlPatterns(), ...cardEndpointPatterns(),
1732
+ ...(payrails ? PAYRAILS_GUARD_PATTERNS : [])])];
1361
1733
  const arm = async (sessionId, resume = false, worker = false) => {
1362
1734
  const key = sessionId ?? '__root__';
1363
1735
  if (armed.has(key))
@@ -1430,6 +1802,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1430
1802
  opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
1431
1803
  };
1432
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
+ }
1433
1813
  if ((method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') && targetContextKnown) {
1434
1814
  try {
1435
1815
  assertContextTarget(params?.targetInfo, merchantContext);
@@ -1465,6 +1845,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1465
1845
  mercadoCheckout.invalidate();
1466
1846
  // The root page owns every attached OOPIF; its loss ends child requests too.
1467
1847
  activeRequest?.attempt.stop();
1848
+ // No getCvvId can come from a closed page: drop the companion and its CVV ciphertext.
1849
+ shijiCompanions.release();
1468
1850
  // Nothing can read the merchant's answer to a continued merchant-hosted
1469
1851
  // request any more, nor the page's report of a retry: a retry is judged on
1470
1852
  // what was read, then the answer is an unknown outcome unless it arrived.
@@ -1530,6 +1912,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1530
1912
  cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((read) => merchantTotal.log.read(key, typeof read?.body === 'string'
1531
1913
  ? (read.base64Encoded ? Buffer.from(read.body, 'base64').toString('utf8') : read.body) : null, completedAt), () => merchantTotal.log.read(key, null, completedAt));
1532
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
+ }
1533
1925
  return;
1534
1926
  }
1535
1927
  if (method === 'Network.responseReceived') {
@@ -1543,12 +1935,21 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1543
1935
  if (handoffReplay)
1544
1936
  merchantTotal.answered(handoffReplay, opts.vault);
1545
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
+ }
1546
1946
  return;
1547
1947
  }
1548
1948
  if (method === 'Network.loadingFailed') {
1549
1949
  merchantTotal.log.failed(requestKey(sessionId, params.requestId));
1550
1950
  if (handoffNetwork && params.requestId === handoffNetwork.networkId && sessionId === handoffNetwork.sessionId)
1551
1951
  merchantHandoff.failed();
1952
+ payrailsAnswers.delete(requestKey(sessionId, params.requestId));
1552
1953
  if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId) {
1553
1954
  const dead = { requestId: activeRequest.requestId, sessionId: activeRequest.sessionId, request: activeRequest.request };
1554
1955
  // A request a dedicated worker sent keeps today's behaviour: its loss
@@ -1590,6 +1991,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1590
1991
  : method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
1591
1992
  : activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
1592
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
+ }
1593
2004
  if (method !== 'Page.frameDetached') {
1594
2005
  const detached = method === 'Target.detachedFromTarget' ? params.sessionId : sessionId;
1595
2006
  detachedSessions.add(detached);
@@ -1653,6 +2064,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1653
2064
  ownedSessions.add(child);
1654
2065
  sessionTypes.set(child, params.targetInfo.type);
1655
2066
  sessionParents.set(child, sessionId);
2067
+ if (typeof params.targetInfo.parentFrameId === 'string' && params.targetInfo.parentFrameId) {
2068
+ sessionParentFrames.set(child, params.targetInfo.parentFrameId);
2069
+ }
1656
2070
  if (params.targetInfo.type === 'worker') {
1657
2071
  const owner = workerOwners.get(sessionId) ?? sessionId;
1658
2072
  workerOwners.set(child, owner);
@@ -1688,8 +2102,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1688
2102
  // a pause hands work back to the claim flow below.
1689
2103
  const preClaimResult = dispatchPreClaim({
1690
2104
  // headers and body are read lazily, inside the gate that needs them (see PreClaimContext.request).
1691
- request: { url: request.url, method: request.method, get headers() { return request.headers; }, get body() { return pausedBody(request); } },
1692
- vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout,
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,
1693
2111
  readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
1694
2112
  effects: {
1695
2113
  continueRequest: (postData) => cdp.send('Fetch.continueRequest', { requestId, ...(postData !== undefined ? { postData: Buffer.from(postData).toString('base64') } : {}) }, sessionId),
@@ -1707,10 +2125,19 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1707
2125
  stripeAttachmentBusy: () => arming.has(sessionId ?? '__root__'),
1708
2126
  requestInactive: () => false,
1709
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
+ }),
1710
2132
  stripeStep: null,
1711
2133
  merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, (judge) => awaitRetryReport(sessionId, networkId, judge)) },
1712
2134
  // The card field iframe that sent a Flex request is the paused request's own frame.
1713
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) },
1714
2141
  });
1715
2142
  const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
1716
2143
  if (preClaim.kind !== 'pause')
@@ -1718,14 +2145,36 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1718
2145
  const stripeStep = preClaim.stripeStep;
1719
2146
  // The capture context and fields a CyberSource Flex create carries, and its retry shape.
1720
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;
1721
2152
  if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
1722
2153
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
1723
2154
  await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1724
2155
  return;
1725
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(() => { });
2173
+ return;
2174
+ }
1726
2175
  let preparation;
1727
2176
  try {
1728
- 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);
1729
2178
  }
1730
2179
  catch (error) {
1731
2180
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
@@ -1756,32 +2205,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1756
2205
  return;
1757
2206
  }
1758
2207
  }
1759
- // A request the catalog marks as routine without a card (a confirmation
1760
- // token for a saved method, a bank source, a Checkout Session confirm with
1761
- // the method hosted Checkout created) was never ours: it continues in
1762
- // every state, like a request the catalog does not recognize. One without
1763
- // a card anywhere else is judged after the holds below.
1764
- const continuationBody = pausedBody(request);
1765
- const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody) ?? null;
1766
- if (withoutCard === 'continue') {
1767
- if (preparation)
1768
- preparationGate.retireUnboundClaim();
1769
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1770
- return;
1771
- }
1772
2208
  // Not gated on awaitingApproval: the page can send this confirmation while
1773
2209
  // the token's own delivery is still being acknowledged. The lifecycle
1774
2210
  // opens the claim only once a Stripe tokenization is being handed over.
1775
- const continuationId = stripeStep || terminal ? null
2211
+ const continuation = stripeStep || terminal ? null
1776
2212
  : claimStripeContinuation(opts, lifecycle, request.url, request.method, continuationBody);
1777
- if (continuationId) {
2213
+ if (continuation) {
1778
2214
  if (preparation)
1779
2215
  preparationGate.retireUnboundClaim();
1780
- if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url, method: request.method, headers: request.headers, body: continuationBody }) && !setupStop.signal.aborted) {
1781
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
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(() => { });
1782
2222
  return;
1783
2223
  }
1784
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2224
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1785
2225
  return;
1786
2226
  }
1787
2227
  // Same stop condition as the Playwright adapter: once a failure proves
@@ -1804,27 +2244,33 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1804
2244
  return;
1805
2245
  }
1806
2246
  // A recognized request that carries no card is not a card request: a
1807
- // Stripe confirmation paying with a method the page created itself. It is
1808
- // judged only after the holds above, so a request that would reuse an
1809
- // approved token is still refused there (card-fields.js requestWithoutCard).
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).
1810
2251
  if (withoutCard === 'refuse') {
1811
2252
  if (preparation)
1812
2253
  preparationGate.retireUnboundClaim();
1813
2254
  opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
1814
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
2255
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1815
2256
  return;
1816
2257
  }
1817
2258
  // Reserve BEFORE the first await (the authorize call below yields), so two
1818
2259
  // requests can never both clear the check above and raise two prompts for
1819
2260
  // one checkout.
1820
2261
  awaitingApproval = true;
1821
- 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)));
1822
2266
  let handoffStarted = false;
1823
2267
  // The approval the device gave, and whether it reached a browser request.
1824
2268
  let approvedId = null;
1825
2269
  let deliveryBegun = false;
1826
2270
  // The business's pause, so resume resolves once the page has its answer.
1827
2271
  let businessPause = null;
2272
+ // A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
2273
+ let disarmSendWindow = null;
1828
2274
  try {
1829
2275
  const body = pausedBody(request);
1830
2276
  if (body === null) {
@@ -1845,7 +2291,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1845
2291
  void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
1846
2292
  return;
1847
2293
  }
1848
- 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 } : {}) } });
1849
2298
  lifecycle.begin();
1850
2299
  const approvalStartedAt = Date.now();
1851
2300
  const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
@@ -1856,6 +2305,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1856
2305
  attempt.bind({ requestId, sessionId, request });
1857
2306
  activeRequest = { attempt, requestId, networkId, sessionId, frameId, request,
1858
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);
1859
2318
  await awaitRequestSource(activeRequest);
1860
2319
  if (preparation)
1861
2320
  await preparationGate.assertDocument();
@@ -1863,7 +2322,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1863
2322
  // the identity a retry has to repeat (see sameCheckoutRequest).
1864
2323
  const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
1865
2324
  const pageOrigin = pageOriginFrom(documentUrl);
1866
- 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);
1867
2327
  attempt.assertLive();
1868
2328
  activeRequest.identity.documentUrl = documentKey(documentUrl);
1869
2329
  activeRequest.identity.pageAmount = pageAmount;
@@ -1879,6 +2339,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1879
2339
  const cardKey = requestKey(activeRequest?.sourceSessionId ?? sessionId ?? '', networkId ?? '');
1880
2340
  const cardPage = merchantTotal.priced(request.url)
1881
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;
1882
2349
  attempt.assertLive();
1883
2350
  // Decision 176: pause only. The business creates the authorization from the paused request and answers
1884
2351
  // through controller.resume; the replay it hands back goes down the same delivery path as a create's.
@@ -1909,10 +2376,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1909
2376
  lifecycle.approvalUrl(url);
1910
2377
  return opts.onApprovalUrl?.(url);
1911
2378
  } },
1912
- request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url, method: request.method, headers: request.headers, body,
1913
- ...(mercadoCheckout.requiresDocument(request.url, request.method)
1914
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}),
1915
- ...(flexPause ? flexPause.sidecar : {}) },
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 } : {}) },
1916
2385
  });
1917
2386
  attempt.assertLive();
1918
2387
  approvedId = replay.authorizationId;
@@ -1943,7 +2412,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1943
2412
  attempt.beginDelivery();
1944
2413
  lifecycle.prepareHandoff(replay, deliverRequest.url);
1945
2414
  handoffStarted = replay.mode !== 'cse';
1946
- 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') {
1947
2431
  // The device submitted the processor's own form; the processor
1948
2432
  // answered the device. The paused NAVIGATION is fulfilled with a page
1949
2433
  // that says exactly that (see hosted-form.ts for why not an abort and
@@ -1974,20 +2458,41 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1974
2458
  if (replay.kind === 'merchant_hosted')
1975
2459
  await merchantTotal.release(replay, opts.vault, attempt.signal);
1976
2460
  attempt.assertLive();
1977
- 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
+ }
1978
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
+ }
1979
2477
  if (replay.kind === 'merchant_hosted') {
1980
2478
  // Watched from before the continue, so an answer that races the command is still read.
1981
2479
  merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url));
1982
2480
  handoffNetwork = { sessionId: activeRequest?.sourceSessionId ?? deliverOn, networkId: activeRequest?.networkId ?? '' };
1983
2481
  handoffReplay = replay;
1984
2482
  }
1985
- await cdp.send('Fetch.continueRequest', {
1986
- requestId: deliverTo,
1987
- postData,
1988
- }, 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
+ }
1989
2494
  attempt.assertLive();
1990
- opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url) });
2495
+ opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url, continuation.shijiCvv) });
1991
2496
  }
1992
2497
  else {
1993
2498
  // The processor's answer, handed to the page as if the processor had
@@ -1995,7 +2500,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1995
2500
  // see corsHeadersFor for why the answer has to carry the headers. The
1996
2501
  // decision rides on the event, so a fulfill the page could not read
1997
2502
  // (no usable Origin on a cross-origin request) is visible in telemetry
1998
- // 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.
1999
2506
  const cors = corsDecision(deliverRequest.url, deliverRequest.headers);
2000
2507
  await cdp.send('Fetch.fulfillRequest', {
2001
2508
  requestId: deliverTo,
@@ -2003,10 +2510,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2003
2510
  responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
2004
2511
  body: Buffer.from(replay.body).toString('base64'),
2005
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);
2006
2515
  attempt.assertLive();
2007
- 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 } });
2008
2524
  }
2009
- lifecycle.handedOff(replay);
2525
+ if (handedOver)
2526
+ lifecycle.handedOff(replay);
2010
2527
  businessPause?.delivered.resolve();
2011
2528
  }
2012
2529
  catch (err) {
@@ -2045,14 +2562,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
2045
2562
  terminal = err;
2046
2563
  else if (isApprovalOutcome(err))
2047
2564
  quietUntil = Date.now() + cooldownMs;
2565
+ reportCardEndpointRefusal(opts, cardEndpoint, err);
2048
2566
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
2049
2567
  }
2050
2568
  await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: cdpFailure(request.url) }, dead.sessionId).catch(() => { });
2051
2569
  settlePause();
2052
2570
  }
2053
2571
  finally {
2572
+ disarmSendWindow?.();
2054
2573
  if (activeRequest?.attempt === attempt)
2055
2574
  activeRequest = null;
2575
+ aci.end();
2576
+ eprotect.end();
2056
2577
  awaitingApproval = false;
2057
2578
  lifecycle.end();
2058
2579
  if (preparation)
@@ -2144,6 +2665,11 @@ export async function attachToPlaywright(page, opts) {
2144
2665
  return undefined;
2145
2666
  }
2146
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);
2147
2673
  const readDocumentUrl = async () => {
2148
2674
  if (page.isClosed?.() || typeof page.url !== 'function')
2149
2675
  throw new Error('merchant_document_unavailable');
@@ -2181,6 +2707,12 @@ export async function attachToPlaywright(page, opts) {
2181
2707
  return false;
2182
2708
  }
2183
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 });
2184
2716
  page.on?.('request', (request) => {
2185
2717
  // A new document is on its way: the main frame's next navigation commits it.
2186
2718
  if (mainDocument(request))
@@ -2224,6 +2756,25 @@ export async function attachToPlaywright(page, opts) {
2224
2756
  }).catch(() => merchantTotal.log.failed(key));
2225
2757
  });
2226
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
+ }
2227
2778
  // Playwright's own routing, NOT a hand-rolled CDP session.
2228
2779
  //
2229
2780
  // A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
@@ -2261,13 +2812,28 @@ export async function attachToPlaywright(page, opts) {
2261
2812
  }
2262
2813
  });
2263
2814
  page.on?.('close', () => { if (!attachmentReady)
2264
- setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
2815
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); shijiCompanions.release(); });
2265
2816
  page.on?.('crash', () => { if (!attachmentReady)
2266
- setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
2817
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); shijiCompanions.release(); });
2267
2818
  page.on?.('framenavigated', (frame) => {
2268
2819
  // A CyberSource Flex card field iframe loaded: start its capture-context check now, so its
2269
2820
  // card request pauses without waiting on it (cybersource-flex.ts).
2270
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
+ }
2271
2837
  if (frame === page.mainFrame?.()) {
2272
2838
  if (documentRequest) {
2273
2839
  documentRequest = null;
@@ -2298,24 +2864,52 @@ export async function attachToPlaywright(page, opts) {
2298
2864
  page.on?.('framedetached', (frame) => {
2299
2865
  if (activeRequest?.frames.includes(frame))
2300
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:`));
2301
2870
  });
2302
2871
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
2303
2872
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
2304
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
+ } };
2305
2882
  const install = async (assertActive) => {
2306
2883
  if (page.isClosed?.()) {
2307
2884
  const failure = new CheckoutAttachmentError('closed');
2308
2885
  setupStop.abort(failure);
2309
2886
  throw failure;
2310
2887
  }
2311
- 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())
2312
2895
  // An armed merchant profile's endpoint or a sibling of it (merchant-hosted.ts), as attachToCdp arms it.
2313
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)
2314
2899
  // GMO token v1's JSONP GET, routed only for an org the API served GMO to (see attachToCdp).
2315
- || (isGmoTokenV1Request(url.toString()) && recognizesGmo(opts.vault)), async (route) => {
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) => {
2316
2907
  const request = route.request();
2317
2908
  // When this request paused, and the page it came from: the merchant's total is read as of now.
2318
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;
2319
2913
  const pausedPage = merchantTotal.pageOf(keyOf(request)) ?? `doc:${documentSerial}`;
2320
2914
  // One pre-claim gate registry decides and performs the mechanics through
2321
2915
  // Playwright's own routing; the accessors read the same state the inline
@@ -2327,14 +2921,34 @@ export async function attachToPlaywright(page, opts) {
2327
2921
  // headers() and postData() are read lazily, inside the gate that needs them (see
2328
2922
  // PreClaimContext.request): a Playwright request mock without headers() and no Stripe checkout
2329
2923
  // never invokes it, and a body Playwright cannot read stops a Mercado checkout the way it did inline.
2330
- request: { url: request.url(), method: request.method(), get headers() { return request.headers(); }, get body() { return request.postData(); } },
2331
- vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout,
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)),
2332
2946
  readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
2333
2947
  effects: {
2334
2948
  continueRequest: (postData) => postData !== undefined ? route.continue({ postData }) : route.fallback(),
2335
2949
  answer: (response) => route.fulfill({ ...response,
2336
2950
  headers: withCorsHeaders(response.headers, corsHeadersFor(request.url(), request.headers())) }),
2337
- abort: () => route.abort(refusedRequestFailure(request.url())),
2951
+ abort: () => route.abort(refusalFailure(request.url())),
2338
2952
  },
2339
2953
  attachmentReady: () => attachmentReady,
2340
2954
  interceptionAborted: () => setupStop.signal.aborted,
@@ -2344,6 +2958,19 @@ export async function attachToPlaywright(page, opts) {
2344
2958
  stripeAttachmentBusy: () => false,
2345
2959
  requestInactive: () => !!(page.isClosed?.() || request.failure?.()),
2346
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
+ },
2347
2974
  stripeStep: null,
2348
2975
  // This route arrives after every answer the page read before sending it; only an unfollowed 3xx is left to settle.
2349
2976
  merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, () => { handoffAnswer?.settle(); return false; }) },
@@ -2365,6 +2992,11 @@ export async function attachToPlaywright(page, opts) {
2365
2992
  }
2366
2993
  return judgeMicroformFrameUrls(flexRequest, frameUrlOf(issuing), frames.map(frameUrlOf), microformContexts);
2367
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}` },
2368
3000
  });
2369
3001
  const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
2370
3002
  if (preClaim.kind !== 'pause')
@@ -2372,17 +3004,28 @@ export async function attachToPlaywright(page, opts) {
2372
3004
  const stripeStep = preClaim.stripeStep;
2373
3005
  // The capture context and fields a CyberSource Flex create carries, and its retry shape.
2374
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;
2375
3011
  if (!attachmentReady || setupStop.signal.aborted) {
2376
3012
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
2377
- return route.abort(refusedRequestFailure(request.url()));
3013
+ return route.abort(refusalFailure(request.url()));
2378
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();
2379
3022
  let preparation;
2380
3023
  try {
2381
- 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());
2382
3025
  }
2383
3026
  catch (error) {
2384
3027
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
2385
- return route.abort(refusedRequestFailure(request.url()));
3028
+ return route.abort(refusalFailure(request.url()));
2386
3029
  }
2387
3030
  // The page asking again for the purchase an outstanding approval is
2388
3031
  // for, after its own request timed out: bind it to that approval (see
@@ -2405,30 +3048,24 @@ export async function attachToPlaywright(page, opts) {
2405
3048
  return;
2406
3049
  }
2407
3050
  }
2408
- // Same order as attachToCdp: a routine card-free request continues in every state.
2409
- const continuationBody = request.postData();
2410
- const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody) ?? null;
2411
- if (withoutCard === 'continue') {
2412
- if (preparation)
2413
- preparationGate.retireUnboundClaim();
2414
- return route.fallback();
2415
- }
2416
- const continuationId = stripeStep || terminal ? null
3051
+ const continuation = stripeStep || terminal ? null
2417
3052
  : claimStripeContinuation(opts, lifecycle, request.url(), request.method(), continuationBody);
2418
- if (continuationId) {
3053
+ if (continuation) {
2419
3054
  if (preparation)
2420
3055
  preparationGate.retireUnboundClaim();
2421
- if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url(), method: request.method(), headers: request.headers(), body: continuationBody }) && !setupStop.signal.aborted) {
2422
- return route.fallback();
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();
2423
3059
  }
2424
- return route.abort('aborted');
3060
+ return route.abort(refusalFailure(request.url()));
2425
3061
  }
2426
3062
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
2427
3063
  // the page's retry loop turn into a stream of doomed API calls. Every
2428
3064
  // abort in this adapter is 'aborted' (ERR_ABORTED), and a CyberSource
2429
3065
  // Flex request's is 'failed' (refusedRequestFailure), the same codes the
2430
3066
  // CDP adapter's Fetch.failRequest uses, so a refused request resolves
2431
- // identically whichever adapter is attached.
3067
+ // identically whichever adapter is attached. An Amadeus card request is
3068
+ // 'failed' (ERR_FAILED) in both too, see failsAsNetworkError (refusalFailure).
2432
3069
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
2433
3070
  let repeatedAuthorizationId = null;
2434
3071
  if (lastSubmitted) {
@@ -2445,7 +3082,7 @@ export async function attachToPlaywright(page, opts) {
2445
3082
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
2446
3083
  if (preparation)
2447
3084
  preparationGate.retireUnboundClaim();
2448
- const refused = await route.abort(refusedRequestFailure(request.url()));
3085
+ const refused = await route.abort(refusalFailure(request.url()));
2449
3086
  if (repeatedAuthorizationId)
2450
3087
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
2451
3088
  return refused;
@@ -2455,7 +3092,7 @@ export async function attachToPlaywright(page, opts) {
2455
3092
  if (preparation)
2456
3093
  preparationGate.retireUnboundClaim();
2457
3094
  opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
2458
- return route.abort('aborted');
3095
+ return route.abort(refusalFailure(request.url()));
2459
3096
  }
2460
3097
  // Reserved before anything that could yield, matching attachToCdp.
2461
3098
  awaitingApproval = true;
@@ -2466,7 +3103,9 @@ export async function attachToPlaywright(page, opts) {
2466
3103
  catch {
2467
3104
  navigation = false;
2468
3105
  }
2469
- 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())));
2470
3109
  const frames = [];
2471
3110
  try {
2472
3111
  for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
@@ -2487,28 +3126,40 @@ export async function attachToPlaywright(page, opts) {
2487
3126
  // The approval the device gave, and whether it reached a browser request.
2488
3127
  let approvedId = null;
2489
3128
  let deliveryBegun = false;
3129
+ // A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
3130
+ let disarmSendWindow = null;
2490
3131
  try {
2491
3132
  const body = request.postData() ?? '';
2492
3133
  if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
2493
3134
  opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
2494
- const refused = await route.abort(refusedRequestFailure(request.url()));
3135
+ const refused = await route.abort(refusalFailure(request.url()));
2495
3136
  if (lastSubmitted)
2496
3137
  void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
2497
3138
  return refused;
2498
3139
  }
2499
- 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()) } });
2500
3143
  lifecycle.begin();
2501
3144
  const approvalStartedAt = Date.now();
2502
3145
  const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
2503
3146
  attempt.bind({ route, request, frames });
2504
3147
  activeRequest = { request, frames, attempt,
2505
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);
2506
3156
  if (preparation)
2507
3157
  await preparationGate.assertDocument();
2508
3158
  // One bounded read serves the merchant origin and the retry identity; see attachToCdp.
2509
3159
  const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
2510
3160
  const pageOrigin = pageOriginFrom(documentUrl);
2511
- 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);
2512
3163
  assertRequestLive();
2513
3164
  activeRequest.identity.documentUrl = documentKey(documentUrl);
2514
3165
  activeRequest.identity.pageAmount = pageAmount;
@@ -2518,6 +3169,13 @@ export async function attachToPlaywright(page, opts) {
2518
3169
  const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
2519
3170
  ? opts.merchantOrigin : pageOrigin?.startsWith('https:') ? pageOrigin : opts.merchantOrigin);
2520
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();
2521
3179
  const replay = await opts.vault.authorize({
2522
3180
  user: opts.user,
2523
3181
  merchant: opts.merchant,
@@ -2542,10 +3200,12 @@ export async function attachToPlaywright(page, opts) {
2542
3200
  lifecycle.approvalUrl(url);
2543
3201
  return opts.onApprovalUrl?.(url);
2544
3202
  } },
2545
- request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url(), method: request.method(), headers: request.headers(), body,
2546
- ...(mercadoCheckout.requiresDocument(request.url(), request.method())
2547
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}),
2548
- ...(flexPause ? flexPause.sidecar : {}) },
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 } : {}) },
2549
3209
  });
2550
3210
  assertRequestLive();
2551
3211
  approvedId = replay.authorizationId;
@@ -2570,7 +3230,17 @@ export async function attachToPlaywright(page, opts) {
2570
3230
  attempt.beginDelivery();
2571
3231
  lifecycle.prepareHandoff(replay, deliverRequest.url());
2572
3232
  handoffStarted = replay.mode !== 'cse';
2573
- 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') {
2574
3244
  // Same as the CDP path: the paused navigation resolves to the
2575
3245
  // synthetic page, and a re-post of this form is refused.
2576
3246
  const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
@@ -2588,8 +3258,26 @@ export async function attachToPlaywright(page, opts) {
2588
3258
  if (replay.kind === 'merchant_hosted')
2589
3259
  await merchantTotal.release(replay, opts.vault, attempt.signal);
2590
3260
  assertRequestLive();
2591
- 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
+ }
2592
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
+ }
2593
3281
  if (replay.kind === 'merchant_hosted') {
2594
3282
  merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url()));
2595
3283
  // Watched from before the continue: 5xx, a failure or the page closing is an unknown outcome.
@@ -2597,9 +3285,16 @@ export async function attachToPlaywright(page, opts) {
2597
3285
  handoffAnswer?.stop();
2598
3286
  handoffAnswer = watchPlaywrightHandoff(page, deliverRequest, merchantHandoff, () => merchantTotal.answered(replay, opts.vault));
2599
3287
  }
2600
- 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
+ }
2601
3296
  assertRequestLive();
2602
- opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url()) });
3297
+ opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url(), continuation.shijiCvv) });
2603
3298
  }
2604
3299
  else {
2605
3300
  // Playwright adds these itself when a cross-origin fulfill carries
@@ -2607,10 +3302,17 @@ export async function attachToPlaywright(page, opts) {
2607
3302
  // cross-origin answer is the same whichever adapter ran.
2608
3303
  const cors = corsDecision(deliverRequest.url(), deliverRequest.headers());
2609
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);
2610
3306
  assertRequestLive();
2611
- 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 } });
2612
3313
  }
2613
- lifecycle.handedOff(replay);
3314
+ if (handedOver)
3315
+ lifecycle.handedOff(replay);
2614
3316
  }
2615
3317
  catch (err) {
2616
3318
  const dead = attempt.target?.route ?? route;
@@ -2635,13 +3337,17 @@ export async function attachToPlaywright(page, opts) {
2635
3337
  terminal = err;
2636
3338
  else if (isApprovalOutcome(err))
2637
3339
  quietUntil = Date.now() + cooldownMs;
3340
+ reportCardEndpointRefusal(opts, cardEndpoint, err);
2638
3341
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
2639
3342
  }
2640
- await dead.abort(refusedRequestFailure(request.url())).catch(() => { });
3343
+ await dead.abort(refusalFailure(request.url())).catch(() => { });
2641
3344
  }
2642
3345
  finally {
3346
+ disarmSendWindow?.();
2643
3347
  if (activeRequest?.attempt === attempt)
2644
3348
  activeRequest = null;
3349
+ aci.end();
3350
+ eprotect.end();
2645
3351
  awaitingApproval = false;
2646
3352
  lifecycle.end();
2647
3353
  if (preparation)