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