@agent-cards/sdk 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/PREFLIGHT.md +30 -0
- package/README.md +1070 -21
- 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 +5 -1
- package/dist/cdp.js +841 -135
- package/dist/client.d.ts +403 -19
- package/dist/client.js +949 -98
- package/dist/creditguard.generated.d.ts +79 -0
- package/dist/creditguard.generated.js +662 -0
- package/dist/cse-body.js +11 -1
- 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/hosted-form.d.ts +3 -2
- package/dist/hosted-form.js +3 -2
- package/dist/index.d.ts +17 -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 +57 -6
- package/dist/lifecycle.js +119 -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 +72 -2
- package/dist/pre-claim.js +857 -5
- package/dist/preflight-catalog.json +2218 -287
- package/dist/preflight-schemas.json +179 -6
- package/dist/preflight.generated.d.ts +1 -1
- package/dist/preflight.generated.js +233 -4
- package/dist/preparation.d.ts +5 -2
- package/dist/preparation.js +36 -10
- package/dist/prepared-processor.d.ts +84 -5
- package/dist/prepared-processor.js +147 -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 +100 -8
- 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 +699 -9
- 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,26 +1,41 @@
|
|
|
1
1
|
import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
|
|
2
|
-
import { AdyenTestPlatformRefusedError, AmountRequiredError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedCapabilityError, UnsupportedModeError, } from './client.js';
|
|
2
|
+
import { AdyenTestPlatformRefusedError, AmountRequiredError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, CheckoutPreparationError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedCapabilityError, UnsupportedModeError, } from './client.js';
|
|
3
3
|
import { PreparationGate } from './preparation.js';
|
|
4
4
|
import { StripeCheckoutGate } from './stripe-checkout.js';
|
|
5
5
|
import { MercadoCheckoutGate } from './mercado-checkout.js';
|
|
6
6
|
import { MERCADO_CHECKOUT_PATTERN } from './mercado-checkout.generated.js';
|
|
7
|
+
import { JuspayCheckoutGate } from './juspay-gate.js';
|
|
8
|
+
import { ACI_CHECKOUT_REUSED, AciGate, playwrightReplacesNavigationBody } from './aci.js';
|
|
9
|
+
import { EprotectGate } from './eprotect.js';
|
|
7
10
|
import { dispatchPreClaim, eventUrl } from './pre-claim.js';
|
|
8
11
|
import { GMO_TOKEN_V1_PATTERNS, gmoRequestShop, isGmoTokenV1Request, recognizesGmo } from './gmo.js';
|
|
12
|
+
import { DatatransRepeats } from './datatrans.js';
|
|
13
|
+
import { SHIJI_STS_COMPANION_WINDOW_MS, ShijiStsCompanions, ShijiStsError, isShijiStsHost, shijiStsTokenContinuation, } from './shiji-sts.js';
|
|
9
14
|
import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
|
|
10
15
|
import { cseBody } from './cse-body.js';
|
|
16
|
+
import { STRIPE_SETUP_INTENT_CONFIRM, setupConfirmPayingWith } from './stripe-setup.js';
|
|
11
17
|
import { replayTemplatedUrl, reviewedMerchantProfileUrlPatterns } from './merchant-hosted.js';
|
|
18
|
+
import { verifoneTemplatedUrl } from './verifone.js';
|
|
12
19
|
import { MerchantHandoffWatch } from './merchant-handoff.js';
|
|
13
20
|
import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watch.js';
|
|
14
21
|
import { hostedFormSubmittedPage } from './hosted-form.js';
|
|
22
|
+
import { amadeusSendWindowMs, armSendWindow, failsAsNetworkError } from './amadeus.js';
|
|
15
23
|
import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
|
|
16
24
|
import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, refusedRequestFailure } from './cybersource-flex.js';
|
|
25
|
+
import { CARD_ENDPOINT_WITHHELD_ANSWER, CardEndpointRefusedError, captureCardEndpointSession, cardEndpointAnswerHeaders, cardEndpointCookies, cardEndpointDetail, cardEndpointFor, cardEndpointPatterns, } from './card-endpoint.js';
|
|
26
|
+
import { PAYRAILS_GUARD_PATTERNS, PAYRAILS_INIT_BODY_LIMIT, PayrailsSessionCapture, isPayrailsHost, payrailsInitCandidate, recognizesPayrails, } from './payrails.js';
|
|
17
27
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
28
|
+
* How an adapter fails a paused request it will not let through: as a network error for a
|
|
29
|
+
* CyberSource Flex request (refusedRequestFailure, so the Microform field reports it to the page)
|
|
30
|
+
* and for an Amadeus card request (failsAsNetworkError, so the SDK's tunnel reports it at once), as
|
|
31
|
+
* an abort for every other request, as before.
|
|
21
32
|
*/
|
|
33
|
+
function refusalFailure(url) {
|
|
34
|
+
return refusedRequestFailure(url) === 'failed' || failsAsNetworkError(url) ? 'failed' : 'aborted';
|
|
35
|
+
}
|
|
36
|
+
/** Fetch.failRequest's errorReason for the same request: the CDP form of refusalFailure. */
|
|
22
37
|
function cdpFailure(url) {
|
|
23
|
-
return
|
|
38
|
+
return refusalFailure(url) === 'failed' ? 'Failed' : 'Aborted';
|
|
24
39
|
}
|
|
25
40
|
// Every URL that leaves this module through onEvent is redacted to origin +
|
|
26
41
|
// path first. A paused PaymentIntent confirm can carry the client secret in
|
|
@@ -131,6 +146,11 @@ const APPROVAL_COOLDOWN_MS = 5_000;
|
|
|
131
146
|
* re-post is right; latching the whole attachment is not, because the prior
|
|
132
147
|
* row declines or expires and the same form is then a new question. The
|
|
133
148
|
* quiet window absorbs the burst; the next request past it is judged afresh.
|
|
149
|
+
*
|
|
150
|
+
* A 400 for a stale Authorize.Net fingerprint (CheckoutApiError
|
|
151
|
+
* isStaleFingerprint) is answered the same way: no prompt was raised, and the
|
|
152
|
+
* form the page renders after a reload carries a fresh fingerprint, so the
|
|
153
|
+
* attachment is quieted rather than latched.
|
|
134
154
|
*/
|
|
135
155
|
function isApprovalOutcome(err) {
|
|
136
156
|
// Processor refusals are held by the lifecycle until explicit merchant
|
|
@@ -139,7 +159,7 @@ function isApprovalOutcome(err) {
|
|
|
139
159
|
if (err instanceof ProcessorRefusedError)
|
|
140
160
|
return false;
|
|
141
161
|
if (err instanceof CheckoutApiError)
|
|
142
|
-
return err.code === 'duplicate_submission';
|
|
162
|
+
return err.code === 'duplicate_submission' || err.isStaleFingerprint;
|
|
143
163
|
return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
|
|
144
164
|
}
|
|
145
165
|
/**
|
|
@@ -154,7 +174,8 @@ function isApprovalOutcome(err) {
|
|
|
154
174
|
*
|
|
155
175
|
* Terminal means "will answer identically next time no matter who does what":
|
|
156
176
|
* a misconfiguration or an unsupported PSP. Nothing a person does changes
|
|
157
|
-
* those, so asking again is pure waste. A GMO request
|
|
177
|
+
* those, so asking again is pure waste. A GMO request, a Pelecard submission
|
|
178
|
+
* or a WSPay confirm with no `amount`
|
|
158
179
|
* (AmountRequiredError) is one: the attachment's options do not change.
|
|
159
180
|
*
|
|
160
181
|
* Other errors may be retryable unless the lifecycle holds the attachment
|
|
@@ -172,6 +193,10 @@ function isTerminal(err) {
|
|
|
172
193
|
// Refused at create for Adyen's test platform: the page's next request is refused the same way.
|
|
173
194
|
if (err instanceof AdyenTestPlatformRefusedError)
|
|
174
195
|
return err.stage === 'create';
|
|
196
|
+
// A card endpoint refusal about the attachment (no amount, Autopilot, a profile this
|
|
197
|
+
// build does not pay) answers every request alike; one about the request does not.
|
|
198
|
+
if (err instanceof CardEndpointRefusedError)
|
|
199
|
+
return err.permanent;
|
|
175
200
|
return err instanceof CardEncryptedError || err instanceof UnsupportedModeError || err instanceof PreparationRequiredError
|
|
176
201
|
|| err instanceof AmountRequiredError || err instanceof UnsupportedCapabilityError;
|
|
177
202
|
}
|
|
@@ -189,7 +214,7 @@ function cseContinuationBody(opts, body, replay, url, preparation) {
|
|
|
189
214
|
return cseBody(body, replay, url, preparation);
|
|
190
215
|
}
|
|
191
216
|
catch (error) {
|
|
192
|
-
if (replay.kind === 'merchant_hosted' && typeof opts.vault?.cancelAuthorization === 'function') {
|
|
217
|
+
if ((replay.kind === 'merchant_hosted' || replay.kind === 'verifone') && typeof opts.vault?.cancelAuthorization === 'function') {
|
|
193
218
|
void Promise.resolve().then(() => opts.vault.cancelAuthorization(replay.authorizationId, 'merchant_never_retried')).catch(() => { });
|
|
194
219
|
}
|
|
195
220
|
throw error;
|
|
@@ -279,13 +304,121 @@ function watchPlaywrightHandoff(page, request, watch, answered) {
|
|
|
279
304
|
watch.failed();
|
|
280
305
|
} } };
|
|
281
306
|
}
|
|
282
|
-
/** The `authorized` event's detail for a cse continuation; a merchant-hosted one names its profile and templated URL. */
|
|
283
|
-
function cseAuthorizedDetail(replay, url) {
|
|
284
|
-
|
|
307
|
+
/** The `authorized` event's detail for a cse continuation; a merchant-hosted or Verifone one names its profile and templated URL. */
|
|
308
|
+
function cseAuthorizedDetail(replay, url, shijiCvv = null) {
|
|
309
|
+
if (replay.kind === 'verifone') {
|
|
310
|
+
return { mode: 'cse', authorizationId: replay.authorizationId, fields: ['encryptedCard'], kind: 'verifone', profile: replay.profile,
|
|
311
|
+
url: verifoneTemplatedUrl(replay.profile, url) };
|
|
312
|
+
}
|
|
313
|
+
// A Shiji STS getTokenId carried pan and expires; its cvv waits for the companion getCvvId.
|
|
314
|
+
const base = { mode: 'cse', authorizationId: replay.authorizationId, fields: shijiCvv !== null ? ['pan', 'expires'] : Object.keys(replay.substitutions.fields) };
|
|
285
315
|
return replay.kind === 'merchant_hosted'
|
|
286
316
|
? { ...base, kind: 'merchant_hosted', profile: replay.profile, url: replayTemplatedUrl(replay, url) }
|
|
287
317
|
: base;
|
|
288
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* The cse continuation for a paused request, by processor. A Shiji STS
|
|
321
|
+
* getTokenId takes the device's pan and expires at the body root and hands back
|
|
322
|
+
* the device's cvv, which that body does not carry: the adapter binds it to the
|
|
323
|
+
* getCvvId companion right before the continue (shiji-sts.ts). It is written
|
|
324
|
+
* only into `approved`, the request the approval was created from, byte for
|
|
325
|
+
* byte, so the accessToken and keySequence are the ones the API reviewed, and
|
|
326
|
+
* the cvv is bound to the reviewed getCvvId keys the approval served
|
|
327
|
+
* (`shijiCvvKeySequences`): no keys, no continuation. Every other processor
|
|
328
|
+
* takes cseContinuationBody (cse-body.ts cseBody), and `shijiCvv` and
|
|
329
|
+
* `shijiCvvKeySequences` are null.
|
|
330
|
+
*/
|
|
331
|
+
function cseContinuation(opts, url, body, replay, approved, preparation) {
|
|
332
|
+
const shiji = shijiStsTokenContinuation(url, body, replay.substitutions, approved, replay.kind === 'merchant_hosted' || replay.kind === 'verifone' ? undefined : replay.shijiStsCompanion);
|
|
333
|
+
return shiji ? { body: shiji.body, shijiCvv: shiji.cvv, shijiCvvKeySequences: shiji.cvvKeySequences }
|
|
334
|
+
: { body: cseContinuationBody(opts, body, replay, url, preparation), shijiCvv: null, shijiCvvKeySequences: null };
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The opaque page id a Shiji STS companion is bound to: the CDP session and
|
|
338
|
+
* frame the request paused in. The getCvvId must come from the same frame as
|
|
339
|
+
* the getTokenId it follows. Kept to the printable ASCII the binding accepts.
|
|
340
|
+
*/
|
|
341
|
+
function cdpShijiPage(sessionId, frameId) {
|
|
342
|
+
return `${cdpShijiSession(sessionId)}${typeof frameId === 'string' && frameId ? frameId : 'none'}`.replace(/[^\x21-\x7e]/g, '_').slice(0, 256);
|
|
343
|
+
}
|
|
344
|
+
/** The start every cdpShijiPage id of one CDP session shares, so a detached target releases its binding. */
|
|
345
|
+
function cdpShijiSession(sessionId) {
|
|
346
|
+
return `cdp:${sessionId ?? 'root'}:`.replace(/[^\x21-\x7e]/g, '_');
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The same for a Playwright request: its frame, numbered once per attachment
|
|
350
|
+
* process. A request with no frame (a service worker's) gets an id no other
|
|
351
|
+
* request shares, so it never binds.
|
|
352
|
+
*/
|
|
353
|
+
const playwrightShijiFrames = new WeakMap();
|
|
354
|
+
let playwrightShijiSeq = 0;
|
|
355
|
+
function playwrightShijiPage(request) {
|
|
356
|
+
let frame = null;
|
|
357
|
+
try {
|
|
358
|
+
frame = request?.frame?.() ?? null;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
frame = null;
|
|
362
|
+
}
|
|
363
|
+
if (!frame || typeof frame !== 'object')
|
|
364
|
+
return `pw:none:${++playwrightShijiSeq}`;
|
|
365
|
+
let id = playwrightShijiFrames.get(frame);
|
|
366
|
+
if (!id) {
|
|
367
|
+
id = `pw:frame:${++playwrightShijiSeq}`;
|
|
368
|
+
playwrightShijiFrames.set(frame, id);
|
|
369
|
+
}
|
|
370
|
+
return id;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The document a Playwright frame holds: its performance.timeOrigin, read in that frame. A new
|
|
374
|
+
* document in the frame (a reload, about:blank, a page that answered a link) has its own, while
|
|
375
|
+
* history.pushState, a hash change and a navigation the page cancels keep the document and so this
|
|
376
|
+
* value: Playwright reports all of them as framenavigated and names no document, so the adapter reads
|
|
377
|
+
* it. Null when the frame or its document cannot be read (no frame, detached, replaced mid-read);
|
|
378
|
+
* undefined when the read has not settled within the companion window: a frame with a navigation
|
|
379
|
+
* pending answers only once that navigation commits or is cancelled, and past the window no
|
|
380
|
+
* getCvvId could claim the binding anyway.
|
|
381
|
+
*/
|
|
382
|
+
async function playwrightFrameDocument(frame) {
|
|
383
|
+
if (!frame || typeof frame !== 'object' || typeof frame.evaluate !== 'function')
|
|
384
|
+
return null;
|
|
385
|
+
let timer;
|
|
386
|
+
try {
|
|
387
|
+
const origin = await Promise.race([
|
|
388
|
+
Promise.resolve(frame.evaluate('performance.timeOrigin')),
|
|
389
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(UNSETTLED), SHIJI_STS_COMPANION_WINDOW_MS); }),
|
|
390
|
+
]);
|
|
391
|
+
if (origin === UNSETTLED)
|
|
392
|
+
return undefined;
|
|
393
|
+
return typeof origin === 'number' && Number.isFinite(origin) ? String(origin) : null;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
if (timer)
|
|
400
|
+
clearTimeout(timer);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const UNSETTLED = Symbol('unsettled');
|
|
404
|
+
/**
|
|
405
|
+
* The opaque page id a Shiji STS companion is bound to in Playwright: the request's frame
|
|
406
|
+
* (playwrightShijiPage) and the document that frame holds (playwrightFrameDocument), so a getCvvId
|
|
407
|
+
* from a document that replaced the getTokenId's never matches the binding. Null when the document
|
|
408
|
+
* cannot be read.
|
|
409
|
+
*/
|
|
410
|
+
async function playwrightShijiDocumentPage(request) {
|
|
411
|
+
const page = playwrightShijiPage(request);
|
|
412
|
+
let frame = null;
|
|
413
|
+
try {
|
|
414
|
+
frame = request?.frame?.() ?? null;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
frame = null;
|
|
418
|
+
}
|
|
419
|
+
const document = await playwrightFrameDocument(frame);
|
|
420
|
+
return typeof document === 'string' ? `${page}:doc:${document}` : null;
|
|
421
|
+
}
|
|
289
422
|
/**
|
|
290
423
|
* The paused request's body, or null when Chromium says there is one and did
|
|
291
424
|
* not hand it over. `postData` is set only for a text body; a binary or a
|
|
@@ -305,6 +438,66 @@ function pausedBody(request) {
|
|
|
305
438
|
return null;
|
|
306
439
|
return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
|
|
307
440
|
}
|
|
441
|
+
/** The frames of a Page.getFrameTree answer from its root down to `frameId`, or undefined when it is not there. */
|
|
442
|
+
function framePath(node, frameId, depth = 0) {
|
|
443
|
+
if (!node?.frame || depth > 64)
|
|
444
|
+
return undefined;
|
|
445
|
+
if (node.frame.id === frameId)
|
|
446
|
+
return [node.frame];
|
|
447
|
+
for (const child of Array.isArray(node.childFrames) ? node.childFrames : []) {
|
|
448
|
+
const path = framePath(child, frameId, depth + 1);
|
|
449
|
+
if (path)
|
|
450
|
+
return [node.frame, ...path];
|
|
451
|
+
}
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The URLs of the frame a paused request came from and of every frame above it, sender first. A
|
|
456
|
+
* session answers only for its own frames (Page.getFrameTree), and a cross-origin frame is a session
|
|
457
|
+
* of its own, so at the root of one session the walk moves on to the session that attached it, from
|
|
458
|
+
* the frame the child named as its parent (the root's Frame.parentId, else the target's
|
|
459
|
+
* parentFrameId). Undefined unless the walk reaches the page's own root within one second in all: a
|
|
460
|
+
* frame that is not found, a session that does not answer and a parent frame nobody named each leave
|
|
461
|
+
* the chain unread. Asked only for a request the pre-claim gates attribute through its frames (see
|
|
462
|
+
* nuveiPreClaim in pre-claim.ts).
|
|
463
|
+
*/
|
|
464
|
+
async function pausedFrameChain(cdp, sessionId, frameId, parentOf) {
|
|
465
|
+
if (typeof frameId !== 'string' || !frameId)
|
|
466
|
+
return undefined;
|
|
467
|
+
const deadline = Date.now() + 1_000;
|
|
468
|
+
const urls = [];
|
|
469
|
+
let session = sessionId, frame = frameId;
|
|
470
|
+
for (let hop = 0; hop < 16; hop++) {
|
|
471
|
+
let timer;
|
|
472
|
+
let tree;
|
|
473
|
+
try {
|
|
474
|
+
tree = await Promise.race([
|
|
475
|
+
cdp.send('Page.getFrameTree', {}, session),
|
|
476
|
+
new Promise(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - Date.now())); }),
|
|
477
|
+
]);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
clearTimeout(timer);
|
|
484
|
+
}
|
|
485
|
+
const path = framePath(tree?.frameTree, frame);
|
|
486
|
+
if (!path)
|
|
487
|
+
return undefined;
|
|
488
|
+
for (let i = path.length - 1; i >= 0; i--)
|
|
489
|
+
urls.push(typeof path[i].url === 'string' ? path[i].url : '');
|
|
490
|
+
const parent = session === undefined ? undefined : parentOf(session);
|
|
491
|
+
if (!parent)
|
|
492
|
+
return urls;
|
|
493
|
+
const next = typeof path[0].parentId === 'string' && path[0].parentId ? path[0].parentId : parent.frameId;
|
|
494
|
+
if (!next)
|
|
495
|
+
return undefined;
|
|
496
|
+
session = parent.sessionId;
|
|
497
|
+
frame = next;
|
|
498
|
+
}
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
308
501
|
const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
|
|
309
502
|
/**
|
|
310
503
|
* The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
|
|
@@ -424,6 +617,96 @@ export function withCorsHeaders(headers, cors) {
|
|
|
424
617
|
}
|
|
425
618
|
return out;
|
|
426
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* The request a card endpoint approval is asked for: `request` names the URL and
|
|
622
|
+
* nothing else, and the profile travels with the captured session in
|
|
623
|
+
* merchant_card_endpoint, the one member the API seals.
|
|
624
|
+
*/
|
|
625
|
+
function cardEndpointRequest(profile, session) {
|
|
626
|
+
return { url: session.url, method: 'POST', headers: {}, body: '', merchant_card_endpoint: { profile: profile.id, session } };
|
|
627
|
+
}
|
|
628
|
+
/** A card endpoint refusal as an event: the profile, the endpoint origin, the reason, and the fields it is about, never a value. */
|
|
629
|
+
function reportCardEndpointRefusal(opts, profile, error) {
|
|
630
|
+
if (!profile || !(error instanceof CardEndpointRefusedError))
|
|
631
|
+
return;
|
|
632
|
+
opts.onEvent?.({ type: 'card_endpoint_refused', detail: { ...cardEndpointDetail(profile), reason: error.reason, ...(error.detail ? { refusal: error.detail } : {}) } });
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Network.setCookie parameters: a URL and no domain for a host-only cookie, which Chrome
|
|
636
|
+
* keeps on the endpoint host alone, or the domain with its leading dot and no URL for a
|
|
637
|
+
* rotation of a domain cookie the browser holds there (CardEndpointSetCookie.domain).
|
|
638
|
+
*/
|
|
639
|
+
function cdpCookieParams(cookie) {
|
|
640
|
+
return { name: cookie.name, value: cookie.value, ...(cookie.domain !== undefined ? { domain: `.${cookie.domain}` } : { url: `${cookie.origin}/` }),
|
|
641
|
+
path: cookie.path, secure: cookie.secure, httpOnly: cookie.httpOnly,
|
|
642
|
+
...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), ...(cookie.expires !== undefined ? { expires: cookie.expires } : {}) };
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* context.addCookies parameters at the cookie's path: the endpoint host with no leading
|
|
646
|
+
* dot, which Chromium keeps host-only, or the domain of a rotated domain cookie with its
|
|
647
|
+
* leading dot (CardEndpointSetCookie.domain).
|
|
648
|
+
*/
|
|
649
|
+
function playwrightCookieParams(cookie) {
|
|
650
|
+
return { name: cookie.name, value: cookie.value, domain: cookie.domain !== undefined ? `.${cookie.domain}` : cookie.host, path: cookie.path,
|
|
651
|
+
secure: cookie.secure, httpOnly: cookie.httpOnly,
|
|
652
|
+
...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), ...(cookie.expires !== undefined ? { expires: cookie.expires } : {}) };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Answer a paused card endpoint request with what the cardholder's device reported.
|
|
656
|
+
* A withheld answer (it carried the card, or a stream the device cannot read) becomes
|
|
657
|
+
* the fixed error the merchant's page stops on, and the outcome is unknown: the
|
|
658
|
+
* merchant holds the card and its answer did not come back. Otherwise the merchant's
|
|
659
|
+
* cookies are written first (cardEndpointCookies, against the endpoint's jar as read
|
|
660
|
+
* now), so the page's next call carries them, and the request is answered with the
|
|
661
|
+
* merchant's status, the profile's answer
|
|
662
|
+
* headers, the CORS answer the request needs, and the body. Resolves false for a
|
|
663
|
+
* withheld answer and true when the merchant's answer was handed over.
|
|
664
|
+
*/
|
|
665
|
+
async function deliverCardEndpointAnswer(opts, lifecycle, profile, replay, request, io) {
|
|
666
|
+
// Only the request that named this profile is answered from its merchant's answer.
|
|
667
|
+
if (!profile || replay.profile !== profile.id)
|
|
668
|
+
throw new PaymentOutcomeUnknownError(replay.authorizationId, 'approved_mode_unsupported');
|
|
669
|
+
const cors = corsDecision(request.url, request.headers);
|
|
670
|
+
if (replay.withheld) {
|
|
671
|
+
await io.fulfill({ status: CARD_ENDPOINT_WITHHELD_ANSWER.status, headers: withCorsHeaders({ ...CARD_ENDPOINT_WITHHELD_ANSWER.headers }, cors.headers),
|
|
672
|
+
body: CARD_ENDPOINT_WITHHELD_ANSWER.body });
|
|
673
|
+
io.assertLive();
|
|
674
|
+
lifecycle.failed(new PaymentOutcomeUnknownError(replay.authorizationId, 'merchant_answer_withheld'), true);
|
|
675
|
+
opts.onEvent?.({ type: 'merchant_answer_withheld', detail: { authorizationId: replay.authorizationId, ...cardEndpointDetail(profile), reason: replay.withheld } });
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
// The jar decides where a rotation lands, so it is read only when there is one to write.
|
|
679
|
+
let jar = [];
|
|
680
|
+
if (replay.setCookies.length > 0 && profile.response.headers.includes('set-cookie')) {
|
|
681
|
+
try {
|
|
682
|
+
const read = await io.readJar();
|
|
683
|
+
jar = Array.isArray(read) ? read.map((held) => ({ name: String(held?.name ?? ''), domain: String(held?.domain ?? '') })) : null;
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
jar = null;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const { cookies, skipped } = cardEndpointCookies(profile, request.url, replay.setCookies, Date.now(), jar);
|
|
690
|
+
let written = 0;
|
|
691
|
+
let refused = 0;
|
|
692
|
+
for (const cookie of cookies) {
|
|
693
|
+
// A cookie the browser refuses is counted, never fatal: the merchant already holds
|
|
694
|
+
// the card, and its answer still has to reach the page.
|
|
695
|
+
try {
|
|
696
|
+
await io.writeCookie(cookie);
|
|
697
|
+
written += 1;
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
refused += 1;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
io.assertLive();
|
|
704
|
+
await io.fulfill({ status: replay.status, headers: withCorsHeaders(cardEndpointAnswerHeaders(profile, replay.headers), cors.headers), body: replay.body });
|
|
705
|
+
io.assertLive();
|
|
706
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'card_endpoint', authorizationId: replay.authorizationId, ...cardEndpointDetail(profile),
|
|
707
|
+
status: replay.status, cors: cors.outcome, cookies: { written, skipped: skipped + refused } } });
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
427
710
|
/** CDP's header shape: `{ name, value }` entries. */
|
|
428
711
|
function headerEntries(headers) {
|
|
429
712
|
return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
|
|
@@ -551,26 +834,13 @@ function failureSummary(error) {
|
|
|
551
834
|
// Why the merchant's own total could not stand behind the payment, and at which step.
|
|
552
835
|
if (error instanceof MerchantTotalError)
|
|
553
836
|
return `${error.name}: ${error.code}${error.reasonCode ? ` (${error.reasonCode})` : ''}`;
|
|
837
|
+
if (error instanceof CardEndpointRefusedError)
|
|
838
|
+
return `${error.name}: ${error.reason}`;
|
|
554
839
|
return error instanceof Error ? error.name : 'CheckoutError';
|
|
555
840
|
}
|
|
556
841
|
const STRIPE_PAYMENT_INTENT_CONFIRM = /^\/v1\/payment_intents\/pi_[A-Za-z0-9]+\/confirm$/;
|
|
557
|
-
/**
|
|
558
|
-
* A page that tokenized the card through an approval, then confirms the
|
|
559
|
-
* payment in the browser with the token it got back (confirmCardPayment with
|
|
560
|
-
* payment_method: 'pm_...', confirmPayment with a confirmation token). That
|
|
561
|
-
* confirmation carries no card, and the hold after a Stripe tokenization
|
|
562
|
-
* would refuse it. The API decides instead whether it is the payment the
|
|
563
|
-
* cardholder approved (the approved amount and currency, on the same Stripe
|
|
564
|
-
* account, paid with exactly the approved token).
|
|
565
|
-
*
|
|
566
|
-
* Synchronous on purpose: every paused request passes through here, and an
|
|
567
|
-
* await would reorder it against the attachment's own events. Returns the
|
|
568
|
-
* approval to check, with the lifecycle's one continuation claimed, or null
|
|
569
|
-
* when this is not such a confirmation and the ordinary rules apply.
|
|
570
|
-
*/
|
|
571
842
|
function claimStripeContinuation(opts, lifecycle, url, method, body) {
|
|
572
|
-
if (body == null ||
|
|
573
|
-
|| method.toUpperCase() !== 'POST')
|
|
843
|
+
if (body == null || !lifecycle.isBlocked() || method.toUpperCase() !== 'POST')
|
|
574
844
|
return null;
|
|
575
845
|
let parsed;
|
|
576
846
|
try {
|
|
@@ -579,25 +849,57 @@ function claimStripeContinuation(opts, lifecycle, url, method, body) {
|
|
|
579
849
|
catch {
|
|
580
850
|
return null;
|
|
581
851
|
}
|
|
582
|
-
if (parsed.origin !== 'https://api.stripe.com'
|
|
583
|
-
|
|
852
|
+
if (parsed.origin !== 'https://api.stripe.com')
|
|
853
|
+
return null;
|
|
854
|
+
if (STRIPE_PAYMENT_INTENT_CONFIRM.test(parsed.pathname)) {
|
|
855
|
+
if (typeof opts.vault.checkStripeContinuation !== 'function' || opts.vault.withoutCard?.(url, body) !== 'refuse')
|
|
856
|
+
return null;
|
|
857
|
+
const authorizationId = lifecycle.claimStripeContinuation();
|
|
858
|
+
return authorizationId ? { authorizationId, kind: 'payment', body, rewritten: false } : null;
|
|
859
|
+
}
|
|
860
|
+
if (!STRIPE_SETUP_INTENT_CONFIRM.test(parsed.pathname) || typeof opts.vault.checkStripeSetupContinuation !== 'function')
|
|
861
|
+
return null;
|
|
862
|
+
const withoutCard = opts.vault.withoutCard?.(url, body);
|
|
863
|
+
let send = null;
|
|
864
|
+
if (withoutCard === 'refuse')
|
|
865
|
+
send = body;
|
|
866
|
+
else if (withoutCard === null) {
|
|
867
|
+
// The card again, inline: only the approved payment method may replace it,
|
|
868
|
+
// and only when what is left carries no card at all.
|
|
869
|
+
const paymentMethod = lifecycle.approvedStripePaymentMethod();
|
|
870
|
+
send = paymentMethod ? setupConfirmPayingWith(body, paymentMethod) : null;
|
|
871
|
+
if (send !== null && opts.vault.withoutCard?.(url, send) !== 'refuse')
|
|
872
|
+
send = null;
|
|
873
|
+
}
|
|
874
|
+
if (send === null)
|
|
584
875
|
return null;
|
|
585
|
-
|
|
876
|
+
const authorizationId = lifecycle.claimStripeContinuation();
|
|
877
|
+
return authorizationId ? { authorizationId, kind: 'setup', body: send, rewritten: send !== body } : null;
|
|
586
878
|
}
|
|
587
|
-
/**
|
|
588
|
-
|
|
589
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Ask the API about a claimed continuation, with the body it would continue
|
|
881
|
+
* with, and settle the claim. True: continue the page's request with that body
|
|
882
|
+
* (untouched unless the claim rewrote it).
|
|
883
|
+
*/
|
|
884
|
+
async function checkStripeContinuation(opts, lifecycle, claim, request) {
|
|
885
|
+
const { authorizationId, kind } = claim;
|
|
886
|
+
let intentId = null;
|
|
590
887
|
try {
|
|
591
|
-
|
|
888
|
+
intentId = kind === 'setup'
|
|
889
|
+
? (await opts.vault.checkStripeSetupContinuation(authorizationId, { ...request, body: claim.body })).setupIntentId
|
|
890
|
+
: (await opts.vault.checkStripeContinuation(authorizationId, { ...request, body: claim.body })).paymentIntentId;
|
|
592
891
|
}
|
|
593
892
|
catch (error) {
|
|
594
893
|
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
595
894
|
}
|
|
596
895
|
// The checkout may have been cancelled or reconciled while the API answered.
|
|
597
|
-
const proceed = lifecycle.stripeContinuationChecked(
|
|
598
|
-
if (proceed)
|
|
599
|
-
opts.onEvent?.(
|
|
600
|
-
|
|
896
|
+
const proceed = lifecycle.stripeContinuationChecked(intentId !== null, kind);
|
|
897
|
+
if (proceed) {
|
|
898
|
+
opts.onEvent?.(kind === 'setup'
|
|
899
|
+
? { type: 'stripe_setup_continued', detail: { authorizationId, setupIntentId: intentId, rewritten: claim.rewritten } }
|
|
900
|
+
: { type: 'stripe_payment_continued', detail: { authorizationId, paymentIntentId: intentId } });
|
|
901
|
+
}
|
|
902
|
+
else if (intentId !== null)
|
|
601
903
|
opts.onEvent?.({ type: 'blocked', detail: 'stripe_continuation_superseded' });
|
|
602
904
|
return proceed;
|
|
603
905
|
}
|
|
@@ -690,15 +992,20 @@ function luhn(digits) {
|
|
|
690
992
|
return sum % 10 === 0;
|
|
691
993
|
}
|
|
692
994
|
/**
|
|
693
|
-
* The card-placeholder runs in a body: every 13 to 19
|
|
694
|
-
* Luhn,
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* 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.
|
|
699
1006
|
*/
|
|
700
1007
|
function cardRuns(body) {
|
|
701
|
-
return (body.match(/\d{
|
|
1008
|
+
return (body.match(/\d(?: ?\d){12,18}/g) ?? []).map((run) => run.replace(/ /g, '')).filter(luhn).sort();
|
|
702
1009
|
}
|
|
703
1010
|
/** Every key path in a JSON value, arrays flattened so an element's order or count does not count. */
|
|
704
1011
|
function jsonKeyPaths(value, prefix, out) {
|
|
@@ -800,12 +1107,24 @@ function sameCheckoutRequest(first, retry) {
|
|
|
800
1107
|
* answer is a token or ciphertext, and any request for the same purchase can
|
|
801
1108
|
* carry it. No for a hosted form (a navigation: there is no request to
|
|
802
1109
|
* answer once the page has moved on), a prepared checkout (single use, bound
|
|
803
|
-
* to one native request by design), a native Stripe Checkout step,
|
|
804
|
-
* Mercado Pago request whose claim is bound to one document read
|
|
805
|
-
*
|
|
1110
|
+
* to one native request by design), a native Stripe Checkout step, a
|
|
1111
|
+
* Mercado Pago request whose claim is bound to one document read, a Juspay
|
|
1112
|
+
* /txns (the device pays that request's own order; a page that clicks Pay
|
|
1113
|
+
* again creates a new order, which the earlier answer would send to the
|
|
1114
|
+
* earlier order's bank check), a Payrails save (device_encrypt: the device
|
|
1115
|
+
* sends that one save itself, under the idempotency key the page gave it, so a
|
|
1116
|
+
* retry is a new question), and any request to a Shiji STS host. Shiji
|
|
1117
|
+
* encrypts every merchant's card under one static key, so the device's
|
|
1118
|
+
* ciphertext tokenizes the card in whichever STS session carries it, and a
|
|
1119
|
+
* retry the shape rule accepts (sameCheckoutRequest compares key paths, never
|
|
1120
|
+
* values) can name another Shiji merchant's accessToken and keySequence than
|
|
1121
|
+
* the request the API reviewed at create. No legitimate retry is lost:
|
|
1122
|
+
* sts.proxy's fetch sets no timeout, so the page never gives up on its own
|
|
1123
|
+
* getTokenId while the cardholder decides. Unknown modes (a duck-typed vault
|
|
1124
|
+
* without checkoutModeOf) are taken as `token`.
|
|
806
1125
|
*/
|
|
807
1126
|
function survivesAbandonment(opts, url, method, navigation, bound) {
|
|
808
|
-
if (navigation || bound)
|
|
1127
|
+
if (navigation || bound || isShijiStsHost(url))
|
|
809
1128
|
return false;
|
|
810
1129
|
const mode = typeof opts.vault?.checkoutModeOf === 'function' ? opts.vault.checkoutModeOf(url, method) : 'token';
|
|
811
1130
|
return mode === 'token' || mode === 'cse';
|
|
@@ -836,7 +1155,12 @@ function merchantAttempt(lifecycle, survives) {
|
|
|
836
1155
|
let rebound = false;
|
|
837
1156
|
let delivering = false;
|
|
838
1157
|
let bound = null;
|
|
839
|
-
|
|
1158
|
+
// An Amadeus send window closes only once the API confirms the card never left the device and
|
|
1159
|
+
// never can (amadeus.ts): a known non-payment, so the prepared checkout ends failed with that
|
|
1160
|
+
// reason, and a fresh attachment can prepare again. Every other stop leaves the outcome unknown.
|
|
1161
|
+
const error = () => reason === 'amadeus_send_window_closed'
|
|
1162
|
+
? new CheckoutPreparationError(lifecycle.getState().preparationId ?? null, reason)
|
|
1163
|
+
: new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
|
|
840
1164
|
const attempt = {
|
|
841
1165
|
signal: controller.signal,
|
|
842
1166
|
/** The browser request that currently carries this attempt; null while the page has none. */
|
|
@@ -1059,6 +1383,23 @@ function pauseForBusiness(opts, lifecycle, attemptSignal, request, pageOrigin) {
|
|
|
1059
1383
|
});
|
|
1060
1384
|
return { replay, delivered: { resolve: deliveredResolve, reject: deliveredReject } };
|
|
1061
1385
|
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Arm the send window of a request paused under a preparation, when its processor sets one: only a
|
|
1388
|
+
* prepared Amadeus SDK add does (amadeus.ts). Returns the disarm function, or null with no window.
|
|
1389
|
+
* lifecycle.begin() cleared the authorization ID, so the one the window reads is this attempt's.
|
|
1390
|
+
*/
|
|
1391
|
+
function armWindowFor(opts, lifecycle, attempt, preparation, url, delivering) {
|
|
1392
|
+
const ms = preparation ? amadeusSendWindowMs(preparation.psp, url) : null;
|
|
1393
|
+
if (ms === null || !preparation)
|
|
1394
|
+
return null;
|
|
1395
|
+
return armSendWindow(ms, attempt, {
|
|
1396
|
+
authorizationId: () => lifecycle.getState().authorizationId,
|
|
1397
|
+
delivering,
|
|
1398
|
+
cancel: typeof opts.vault?.cancelAuthorization === 'function' ? (id) => opts.vault.cancelAuthorization(id) : undefined,
|
|
1399
|
+
retirePreparation: typeof opts.vault?.retirePreparation === 'function' ? () => opts.vault.retirePreparation(preparation.id) : undefined,
|
|
1400
|
+
onEvent: opts.onEvent,
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1062
1403
|
/**
|
|
1063
1404
|
* Take over card tokenization for a page.
|
|
1064
1405
|
*
|
|
@@ -1090,6 +1431,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1090
1431
|
const ownedSessions = new Set([pageSessionId]);
|
|
1091
1432
|
const sessionTypes = new Map([[pageSessionId, 'page']]);
|
|
1092
1433
|
const sessionParents = new Map();
|
|
1434
|
+
// The frame in its parent session that a child target hangs from (TargetInfo.parentFrameId), so a
|
|
1435
|
+
// frame lookup can climb from a cross-origin frame into the document that frames it.
|
|
1436
|
+
const sessionParentFrames = new Map();
|
|
1093
1437
|
const detachedSessions = new Set();
|
|
1094
1438
|
const rejectedSessions = new Set();
|
|
1095
1439
|
const workerOwners = new Map();
|
|
@@ -1174,6 +1518,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1174
1518
|
clearTimeout(timer);
|
|
1175
1519
|
}
|
|
1176
1520
|
};
|
|
1521
|
+
// Juspay's tokenize answer and the synthetic tokens it issued (juspay-gate.ts).
|
|
1522
|
+
const juspayCheckout = new JuspayCheckoutGate(opts.vault);
|
|
1523
|
+
const aci = new AciGate(opts.vault);
|
|
1524
|
+
const eprotect = new EprotectGate(opts.vault);
|
|
1177
1525
|
let preparationFrameId;
|
|
1178
1526
|
const readDocumentUrl = async () => {
|
|
1179
1527
|
const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
|
|
@@ -1194,6 +1542,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1194
1542
|
const merchantTotal = new MerchantTotalWatch(opts.onEvent, opts.vault);
|
|
1195
1543
|
let handoffReplay = null;
|
|
1196
1544
|
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
1545
|
+
// Payrails sessions this page's own XHR and fetch answers carried, read only
|
|
1546
|
+
// when the synced registry pauses Payrails' save (an org the API served the
|
|
1547
|
+
// dark entry to); null otherwise, and no answer is read (see payrails.ts).
|
|
1548
|
+
const payrails = recognizesPayrails(opts.vault) ? new PayrailsSessionCapture() : null;
|
|
1549
|
+
// Answers that may carry a Payrails init payload, by session and Network ID,
|
|
1550
|
+
// read once they finish loading.
|
|
1551
|
+
const payrailsAnswers = new Set();
|
|
1197
1552
|
const armed = new Set();
|
|
1198
1553
|
const arming = new Map();
|
|
1199
1554
|
// Set once a failure proves that retrying cannot help; see isTerminal.
|
|
@@ -1344,6 +1699,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1344
1699
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
1345
1700
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
1346
1701
|
let lastSubmitted = null;
|
|
1702
|
+
// The approved Datatrans answers, per frameId: the repeat rule (datatrans.ts).
|
|
1703
|
+
const datatransRepeats = new DatatransRepeats();
|
|
1704
|
+
// The Shiji STS getCvvId companion of the getTokenId this attachment last continued (shiji-sts.ts).
|
|
1705
|
+
// A cancelled attachment drops it, and the device's CVV ciphertext with it, at once: no getCvvId
|
|
1706
|
+
// can use it any more (pre-claim.ts refuses every one once the lifecycle is aborted). The page,
|
|
1707
|
+
// frame or target going away drops it too (the detach handlers below), and so does the frame
|
|
1708
|
+
// committing a new document (Page.frameNavigated, at the top of the event handler).
|
|
1709
|
+
const shijiCompanions = new ShijiStsCompanions();
|
|
1710
|
+
lifecycle.abort.signal.addEventListener('abort', () => shijiCompanions.release(), { once: true });
|
|
1347
1711
|
const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
|
|
1348
1712
|
// The endpoint and siblings of every merchant profile this build reviewed
|
|
1349
1713
|
// (merchant-hosted.ts), which a VaultClient arms from the start: a profile's card
|
|
@@ -1351,13 +1715,21 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1351
1715
|
// it, before or after any syncRegistry(). The gate continues untouched a request
|
|
1352
1716
|
// that the vault names no profile for.
|
|
1353
1717
|
const merchantPatterns = [...reviewedMerchantProfileUrlPatterns(),
|
|
1354
|
-
...(typeof opts.vault?.merchantProfileUrlPatterns === 'function' ? opts.vault.merchantProfileUrlPatterns() : [])
|
|
1718
|
+
...(typeof opts.vault?.merchantProfileUrlPatterns === 'function' ? opts.vault.merchantProfileUrlPatterns() : []),
|
|
1719
|
+
// Every request to an armed Verifone merchant's origin (verifone.ts), armed from the profiles
|
|
1720
|
+
// the last syncRegistry() served: none before one, and none for a company Verifone is off for.
|
|
1721
|
+
...(typeof opts.vault?.verifoneProfileUrlPatterns === 'function' ? opts.vault.verifoneProfileUrlPatterns() : [])];
|
|
1355
1722
|
// GMO token v1's JSONP GET is armed only when the synced registry recognizes
|
|
1356
1723
|
// GMO (an org the API served the dark entry to), so the gate can refuse it;
|
|
1357
1724
|
// for every other org it is never intercepted, as before (see gmo.ts).
|
|
1725
|
+
// Every reviewed card endpoint is armed in any status: one this build does not pay is
|
|
1726
|
+
// paused only to be refused, so the placeholder card never reaches the merchant.
|
|
1727
|
+
// Every Payrails host is intercepted only for an org the API served Payrails
|
|
1728
|
+
// to, so the gate can abort a card-form payment (see payrails.ts).
|
|
1358
1729
|
const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
|
|
1359
1730
|
...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns,
|
|
1360
|
-
...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : [])
|
|
1731
|
+
...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : []), ...juspayCheckout.urlPatterns(), ...cardEndpointPatterns(),
|
|
1732
|
+
...(payrails ? PAYRAILS_GUARD_PATTERNS : [])])];
|
|
1361
1733
|
const arm = async (sessionId, resume = false, worker = false) => {
|
|
1362
1734
|
const key = sessionId ?? '__root__';
|
|
1363
1735
|
if (armed.has(key))
|
|
@@ -1430,6 +1802,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1430
1802
|
opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
|
|
1431
1803
|
};
|
|
1432
1804
|
cdp.on(async (method, params, sessionId) => {
|
|
1805
|
+
// A frame that committed a new document drops the companion binding made for the getTokenId its
|
|
1806
|
+
// old document sent, with the device's CVV ciphertext: the document that replaced it never
|
|
1807
|
+
// obtained the approval, whatever accessToken and key it sends. A same-document navigation is
|
|
1808
|
+
// Page.navigatedWithinDocument and keeps the binding.
|
|
1809
|
+
if (method === 'Page.frameNavigated' && typeof params?.frame?.id === 'string') {
|
|
1810
|
+
const replaced = cdpShijiPage(sessionId, params.frame.id);
|
|
1811
|
+
shijiCompanions.releasePage((page) => page === replaced);
|
|
1812
|
+
}
|
|
1433
1813
|
if ((method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') && targetContextKnown) {
|
|
1434
1814
|
try {
|
|
1435
1815
|
assertContextTarget(params?.targetInfo, merchantContext);
|
|
@@ -1465,6 +1845,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1465
1845
|
mercadoCheckout.invalidate();
|
|
1466
1846
|
// The root page owns every attached OOPIF; its loss ends child requests too.
|
|
1467
1847
|
activeRequest?.attempt.stop();
|
|
1848
|
+
// No getCvvId can come from a closed page: drop the companion and its CVV ciphertext.
|
|
1849
|
+
shijiCompanions.release();
|
|
1468
1850
|
// Nothing can read the merchant's answer to a continued merchant-hosted
|
|
1469
1851
|
// request any more, nor the page's report of a retry: a retry is judged on
|
|
1470
1852
|
// what was read, then the answer is an unknown outcome unless it arrived.
|
|
@@ -1530,6 +1912,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1530
1912
|
cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((read) => merchantTotal.log.read(key, typeof read?.body === 'string'
|
|
1531
1913
|
? (read.base64Encoded ? Buffer.from(read.body, 'base64').toString('utf8') : read.body) : null, completedAt), () => merchantTotal.log.read(key, null, completedAt));
|
|
1532
1914
|
}
|
|
1915
|
+
if (payrails && payrailsAnswers.delete(requestKey(sessionId, params.requestId))
|
|
1916
|
+
&& !(Number(params.encodedDataLength) > PAYRAILS_INIT_BODY_LIMIT)) {
|
|
1917
|
+
payrails.reading(cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((answer) => {
|
|
1918
|
+
if (typeof answer?.body !== 'string')
|
|
1919
|
+
return;
|
|
1920
|
+
const body = answer.base64Encoded ? Buffer.from(answer.body, 'base64').toString('utf8') : answer.body;
|
|
1921
|
+
if (body.length <= PAYRAILS_INIT_BODY_LIMIT)
|
|
1922
|
+
payrails.observe(body);
|
|
1923
|
+
}));
|
|
1924
|
+
}
|
|
1533
1925
|
return;
|
|
1534
1926
|
}
|
|
1535
1927
|
if (method === 'Network.responseReceived') {
|
|
@@ -1543,12 +1935,21 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1543
1935
|
if (handoffReplay)
|
|
1544
1936
|
merchantTotal.answered(handoffReplay, opts.vault);
|
|
1545
1937
|
}
|
|
1938
|
+
// A 2xx XHR or fetch answer with a JSON type may carry a Payrails init
|
|
1939
|
+
// payload; its body is read once it finishes loading.
|
|
1940
|
+
if (payrails && payrailsInitCandidate(params?.type, params?.response?.status, params?.response?.mimeType)) {
|
|
1941
|
+
payrailsAnswers.add(requestKey(sessionId, params.requestId));
|
|
1942
|
+
// An answer that never finishes loading is forgotten with the oldest, not kept forever.
|
|
1943
|
+
if (payrailsAnswers.size > 256)
|
|
1944
|
+
payrailsAnswers.delete(payrailsAnswers.values().next().value);
|
|
1945
|
+
}
|
|
1546
1946
|
return;
|
|
1547
1947
|
}
|
|
1548
1948
|
if (method === 'Network.loadingFailed') {
|
|
1549
1949
|
merchantTotal.log.failed(requestKey(sessionId, params.requestId));
|
|
1550
1950
|
if (handoffNetwork && params.requestId === handoffNetwork.networkId && sessionId === handoffNetwork.sessionId)
|
|
1551
1951
|
merchantHandoff.failed();
|
|
1952
|
+
payrailsAnswers.delete(requestKey(sessionId, params.requestId));
|
|
1552
1953
|
if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId) {
|
|
1553
1954
|
const dead = { requestId: activeRequest.requestId, sessionId: activeRequest.sessionId, request: activeRequest.request };
|
|
1554
1955
|
// A request a dedicated worker sent keeps today's behaviour: its loss
|
|
@@ -1590,6 +1991,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1590
1991
|
: method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
|
|
1591
1992
|
: activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
|
|
1592
1993
|
activeRequest.attempt.stop();
|
|
1994
|
+
// The frame or target the bound getTokenId came from is gone, so its getCvvId cannot follow:
|
|
1995
|
+
// drop the companion binding and the CVV ciphertext it holds (a binding elsewhere stays).
|
|
1996
|
+
if (method === 'Page.frameDetached') {
|
|
1997
|
+
const gone = cdpShijiPage(sessionId, params.frameId);
|
|
1998
|
+
shijiCompanions.releasePage((page) => page === gone);
|
|
1999
|
+
}
|
|
2000
|
+
else {
|
|
2001
|
+
const gone = cdpShijiSession(method === 'Target.detachedFromTarget' ? params.sessionId : sessionId);
|
|
2002
|
+
shijiCompanions.releasePage((page) => page.startsWith(gone));
|
|
2003
|
+
}
|
|
1593
2004
|
if (method !== 'Page.frameDetached') {
|
|
1594
2005
|
const detached = method === 'Target.detachedFromTarget' ? params.sessionId : sessionId;
|
|
1595
2006
|
detachedSessions.add(detached);
|
|
@@ -1653,6 +2064,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1653
2064
|
ownedSessions.add(child);
|
|
1654
2065
|
sessionTypes.set(child, params.targetInfo.type);
|
|
1655
2066
|
sessionParents.set(child, sessionId);
|
|
2067
|
+
if (typeof params.targetInfo.parentFrameId === 'string' && params.targetInfo.parentFrameId) {
|
|
2068
|
+
sessionParentFrames.set(child, params.targetInfo.parentFrameId);
|
|
2069
|
+
}
|
|
1656
2070
|
if (params.targetInfo.type === 'worker') {
|
|
1657
2071
|
const owner = workerOwners.get(sessionId) ?? sessionId;
|
|
1658
2072
|
workerOwners.set(child, owner);
|
|
@@ -1688,8 +2102,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1688
2102
|
// a pause hands work back to the claim flow below.
|
|
1689
2103
|
const preClaimResult = dispatchPreClaim({
|
|
1690
2104
|
// headers and body are read lazily, inside the gate that needs them (see PreClaimContext.request).
|
|
1691
|
-
request: { url: request.url, method: request.method, get headers() { return request.headers; }, get body() { return pausedBody(request); }
|
|
1692
|
-
|
|
2105
|
+
request: { url: request.url, method: request.method, get headers() { return request.headers; }, get body() { return pausedBody(request); },
|
|
2106
|
+
navigation: resourceType === 'Document' },
|
|
2107
|
+
vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout, aci, eprotect,
|
|
2108
|
+
// This adapter drives Chromium, which sends the body Fetch.continueRequest replaces on a
|
|
2109
|
+
// navigation (the ACI browser test's classic case, design gate G3).
|
|
2110
|
+
replacesNavigationBody: async () => true,
|
|
1693
2111
|
readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
|
|
1694
2112
|
effects: {
|
|
1695
2113
|
continueRequest: (postData) => cdp.send('Fetch.continueRequest', { requestId, ...(postData !== undefined ? { postData: Buffer.from(postData).toString('base64') } : {}) }, sessionId),
|
|
@@ -1707,10 +2125,19 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1707
2125
|
stripeAttachmentBusy: () => arming.has(sessionId ?? '__root__'),
|
|
1708
2126
|
requestInactive: () => false,
|
|
1709
2127
|
awaitingApproval: () => awaitingApproval,
|
|
2128
|
+
initiatorFrames: () => pausedFrameChain(cdp, sessionId, frameId, child => {
|
|
2129
|
+
const parent = sessionParents.get(child);
|
|
2130
|
+
return parent === undefined ? undefined : { sessionId: parent, frameId: sessionParentFrames.get(child) };
|
|
2131
|
+
}),
|
|
1710
2132
|
stripeStep: null,
|
|
1711
2133
|
merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, (judge) => awaitRetryReport(sessionId, networkId, judge)) },
|
|
1712
2134
|
// The card field iframe that sent a Flex request is the paused request's own frame.
|
|
1713
2135
|
cybersourceFlex: { judge: (flexRequest) => microforms.judge(flexRequest, frameId, () => rereadMicroforms(sessionId, frameId)) },
|
|
2136
|
+
juspay: juspayCheckout,
|
|
2137
|
+
datatrans: datatransRepeats,
|
|
2138
|
+
frame: typeof frameId === 'string' && frameId ? frameId : undefined,
|
|
2139
|
+
payrails,
|
|
2140
|
+
shijiSts: { companions: shijiCompanions, page: () => cdpShijiPage(sessionId, frameId) },
|
|
1714
2141
|
});
|
|
1715
2142
|
const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
|
|
1716
2143
|
if (preClaim.kind !== 'pause')
|
|
@@ -1718,14 +2145,36 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1718
2145
|
const stripeStep = preClaim.stripeStep;
|
|
1719
2146
|
// The capture context and fields a CyberSource Flex create carries, and its retry shape.
|
|
1720
2147
|
const flexPause = preClaim.cybersourceFlex;
|
|
2148
|
+
// A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts).
|
|
2149
|
+
const cardEndpoint = preClaim.cardEndpoint ?? null;
|
|
2150
|
+
// A Payrails card save the gate bound to a captured session: its key rides on the create.
|
|
2151
|
+
const payrailsSidecar = preClaim.payrails;
|
|
1721
2152
|
if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
|
|
1722
2153
|
opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
|
|
1723
2154
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
|
|
1724
2155
|
return;
|
|
1725
2156
|
}
|
|
2157
|
+
// A request the catalog marks as routine without a card (a confirmation
|
|
2158
|
+
// token for a saved method, a bank source, a Checkout Session confirm with
|
|
2159
|
+
// the method hosted Checkout created, every Amadeus action but the card
|
|
2160
|
+
// add and store) was never ours: it continues in every state, like a
|
|
2161
|
+
// request the catalog does not recognize. It is judged before the
|
|
2162
|
+
// preparation claim, so it neither spends a ready preparation nor counts
|
|
2163
|
+
// as the page's first card request: Amadeus's SDK sends its load to the
|
|
2164
|
+
// card URL as the payment step opens, before prepare(), and Air France's
|
|
2165
|
+
// Nuvei payment with a ccTempToken follows the prepared Amadeus add. One
|
|
2166
|
+
// without a card anywhere else is judged after the holds below, and so is
|
|
2167
|
+
// a Nuvei payment without a card once this attachment has handed Nuvei the
|
|
2168
|
+
// card (VaultClient.withoutCard).
|
|
2169
|
+
const continuationBody = pausedBody(request);
|
|
2170
|
+
const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody, lifecycle.cardHandoffs()) ?? null;
|
|
2171
|
+
if (withoutCard === 'continue') {
|
|
2172
|
+
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
1726
2175
|
let preparation;
|
|
1727
2176
|
try {
|
|
1728
|
-
preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request), request.headers);
|
|
2177
|
+
preparation = stripeStep ? undefined : preparationGate.claim(request.url, request.method, pausedBody(request), request.headers);
|
|
1729
2178
|
}
|
|
1730
2179
|
catch (error) {
|
|
1731
2180
|
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
@@ -1756,32 +2205,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1756
2205
|
return;
|
|
1757
2206
|
}
|
|
1758
2207
|
}
|
|
1759
|
-
// A request the catalog marks as routine without a card (a confirmation
|
|
1760
|
-
// token for a saved method, a bank source, a Checkout Session confirm with
|
|
1761
|
-
// the method hosted Checkout created) was never ours: it continues in
|
|
1762
|
-
// every state, like a request the catalog does not recognize. One without
|
|
1763
|
-
// a card anywhere else is judged after the holds below.
|
|
1764
|
-
const continuationBody = pausedBody(request);
|
|
1765
|
-
const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody) ?? null;
|
|
1766
|
-
if (withoutCard === 'continue') {
|
|
1767
|
-
if (preparation)
|
|
1768
|
-
preparationGate.retireUnboundClaim();
|
|
1769
|
-
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
1770
|
-
return;
|
|
1771
|
-
}
|
|
1772
2208
|
// Not gated on awaitingApproval: the page can send this confirmation while
|
|
1773
2209
|
// the token's own delivery is still being acknowledged. The lifecycle
|
|
1774
2210
|
// opens the claim only once a Stripe tokenization is being handed over.
|
|
1775
|
-
const
|
|
2211
|
+
const continuation = stripeStep || terminal ? null
|
|
1776
2212
|
: claimStripeContinuation(opts, lifecycle, request.url, request.method, continuationBody);
|
|
1777
|
-
if (
|
|
2213
|
+
if (continuation) {
|
|
1778
2214
|
if (preparation)
|
|
1779
2215
|
preparationGate.retireUnboundClaim();
|
|
1780
|
-
if (await checkStripeContinuation(opts, lifecycle,
|
|
1781
|
-
|
|
2216
|
+
if (await checkStripeContinuation(opts, lifecycle, continuation, { url: request.url, method: request.method, headers: request.headers }) && !setupStop.signal.aborted) {
|
|
2217
|
+
// A card save that carried the card again continues with the body the
|
|
2218
|
+
// API allowed, and postData alone rides the command (see cse-body.ts
|
|
2219
|
+
// for why no header, Content-Length included, is ever overridden).
|
|
2220
|
+
await cdp.send('Fetch.continueRequest', { requestId,
|
|
2221
|
+
...(continuation.rewritten ? { postData: Buffer.from(continuation.body).toString('base64') } : {}) }, sessionId).catch(() => { });
|
|
1782
2222
|
return;
|
|
1783
2223
|
}
|
|
1784
|
-
await cdp.send('Fetch.failRequest', { requestId, errorReason:
|
|
2224
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
|
|
1785
2225
|
return;
|
|
1786
2226
|
}
|
|
1787
2227
|
// Same stop condition as the Playwright adapter: once a failure proves
|
|
@@ -1804,27 +2244,33 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1804
2244
|
return;
|
|
1805
2245
|
}
|
|
1806
2246
|
// A recognized request that carries no card is not a card request: a
|
|
1807
|
-
// Stripe confirmation paying with a method the page created itself
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
2247
|
+
// Stripe confirmation paying with a method the page created itself, or a
|
|
2248
|
+
// Nuvei payment with a token Nuvei may have minted from the approved card.
|
|
2249
|
+
// It is judged only after the holds above, so a request that would reuse
|
|
2250
|
+
// an approved token is still refused there (VaultClient.withoutCard).
|
|
1810
2251
|
if (withoutCard === 'refuse') {
|
|
1811
2252
|
if (preparation)
|
|
1812
2253
|
preparationGate.retireUnboundClaim();
|
|
1813
2254
|
opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
|
|
1814
|
-
await cdp.send('Fetch.failRequest', { requestId, errorReason:
|
|
2255
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
|
|
1815
2256
|
return;
|
|
1816
2257
|
}
|
|
1817
2258
|
// Reserve BEFORE the first await (the authorize call below yields), so two
|
|
1818
2259
|
// requests can never both clear the check above and raise two prompts for
|
|
1819
2260
|
// one checkout.
|
|
1820
2261
|
awaitingApproval = true;
|
|
1821
|
-
|
|
2262
|
+
// A card endpoint approval never outlives its request: the device answers that
|
|
2263
|
+
// request, and a retry after the page gave up on it is refused, not bound.
|
|
2264
|
+
const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url, request.method, resourceType === 'Document', !!preparation || !!stripeStep || !!cardEndpoint || mercadoCheckout.requiresDocument(request.url, request.method)
|
|
2265
|
+
|| juspayCheckout.bindsApproval(request.url, request.method)));
|
|
1822
2266
|
let handoffStarted = false;
|
|
1823
2267
|
// The approval the device gave, and whether it reached a browser request.
|
|
1824
2268
|
let approvedId = null;
|
|
1825
2269
|
let deliveryBegun = false;
|
|
1826
2270
|
// The business's pause, so resume resolves once the page has its answer.
|
|
1827
2271
|
let businessPause = null;
|
|
2272
|
+
// A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
|
|
2273
|
+
let disarmSendWindow = null;
|
|
1828
2274
|
try {
|
|
1829
2275
|
const body = pausedBody(request);
|
|
1830
2276
|
if (body === null) {
|
|
@@ -1845,7 +2291,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1845
2291
|
void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
|
|
1846
2292
|
return;
|
|
1847
2293
|
}
|
|
1848
|
-
|
|
2294
|
+
// A card endpoint's URL carries the merchant's cart and booking ids: its events
|
|
2295
|
+
// name the profile and the endpoint origin instead.
|
|
2296
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { ...(cardEndpoint ? cardEndpointDetail(cardEndpoint) : { url: eventUrl(opts.vault, request.url) }),
|
|
2297
|
+
recognizer: recognizerNameOf(opts, request.url, request.method), ...(resourceType ? { resourceType } : {}) } });
|
|
1849
2298
|
lifecycle.begin();
|
|
1850
2299
|
const approvalStartedAt = Date.now();
|
|
1851
2300
|
const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
|
|
@@ -1856,6 +2305,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1856
2305
|
attempt.bind({ requestId, sessionId, request });
|
|
1857
2306
|
activeRequest = { attempt, requestId, networkId, sessionId, frameId, request,
|
|
1858
2307
|
identity: requestIdentity(opts, request.url, request.method, body, undefined, undefined, flexPause?.shape) };
|
|
2308
|
+
// An ACI card-number request: the page's CVC and expiry requests for its checkout are held
|
|
2309
|
+
// until this approval ends (see AciGate). One whose checkout already has a card-number
|
|
2310
|
+
// request running or answered never begins; the pre-claim gate refuses it before this.
|
|
2311
|
+
if (!aci.begin(request.url, request.method, body, preparation?.merchantOrigin)) {
|
|
2312
|
+
throw new CheckoutPreparationError(preparation?.id ?? null, ACI_CHECKOUT_REUSED);
|
|
2313
|
+
}
|
|
2314
|
+
// A Worldpay eProtect registration: the vendor's give-up report for it ends this attempt as
|
|
2315
|
+
// the page abandoning its request (see EprotectGate.gaveUp).
|
|
2316
|
+
eprotect.begin(request.url, request.method, () => attempt.stop());
|
|
2317
|
+
disarmSendWindow = armWindowFor(opts, lifecycle, attempt, preparation, request.url, () => deliveryBegun);
|
|
1859
2318
|
await awaitRequestSource(activeRequest);
|
|
1860
2319
|
if (preparation)
|
|
1861
2320
|
await preparationGate.assertDocument();
|
|
@@ -1863,7 +2322,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1863
2322
|
// the identity a retry has to repeat (see sameCheckoutRequest).
|
|
1864
2323
|
const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
|
|
1865
2324
|
const pageOrigin = pageOriginFrom(documentUrl);
|
|
1866
|
-
|
|
2325
|
+
// The agent's amount is a card endpoint's only authority: no page total is read.
|
|
2326
|
+
const pageAmount = cardEndpoint ? undefined : await pageAmountOf(opts);
|
|
1867
2327
|
attempt.assertLive();
|
|
1868
2328
|
activeRequest.identity.documentUrl = documentKey(documentUrl);
|
|
1869
2329
|
activeRequest.identity.pageAmount = pageAmount;
|
|
@@ -1879,6 +2339,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1879
2339
|
const cardKey = requestKey(activeRequest?.sourceSessionId ?? sessionId ?? '', networkId ?? '');
|
|
1880
2340
|
const cardPage = merchantTotal.priced(request.url)
|
|
1881
2341
|
? await merchantTotal.pageFor(cardKey, MERCHANT_PAGE_WAIT_MS, attempt.signal) : merchantTotal.pageOf(cardKey);
|
|
2342
|
+
// The card endpoint's session: the profile's own headers from the paused request,
|
|
2343
|
+
// the endpoint's cookie jar for a cookie profile (Chrome keeps it out of the
|
|
2344
|
+
// paused headers, HttpOnly cookies included), and the checkout page's Referer.
|
|
2345
|
+
const session = cardEndpoint ? await captureCardEndpointSession(cardEndpoint, {
|
|
2346
|
+
url: request.url, headers: request.headers, body, pageOrigin,
|
|
2347
|
+
cookies: async () => (await cdp.send('Network.getCookies', { urls: [request.url] }, sessionId))?.cookies ?? [],
|
|
2348
|
+
}) : null;
|
|
1882
2349
|
attempt.assertLive();
|
|
1883
2350
|
// Decision 176: pause only. The business creates the authorization from the paused request and answers
|
|
1884
2351
|
// through controller.resume; the replay it hands back goes down the same delivery path as a create's.
|
|
@@ -1909,10 +2376,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1909
2376
|
lifecycle.approvalUrl(url);
|
|
1910
2377
|
return opts.onApprovalUrl?.(url);
|
|
1911
2378
|
} },
|
|
1912
|
-
request: stripeStep?.phase === 'final' ? stripeStep.request :
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2379
|
+
request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session)
|
|
2380
|
+
: { url: request.url, method: request.method, headers: request.headers, body,
|
|
2381
|
+
...(mercadoCheckout.requiresDocument(request.url, request.method)
|
|
2382
|
+
? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}),
|
|
2383
|
+
...(flexPause ? flexPause.sidecar : {}),
|
|
2384
|
+
...(payrailsSidecar ? { payrails: payrailsSidecar } : {}) },
|
|
1916
2385
|
});
|
|
1917
2386
|
attempt.assertLive();
|
|
1918
2387
|
approvedId = replay.authorizationId;
|
|
@@ -1943,7 +2412,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1943
2412
|
attempt.beginDelivery();
|
|
1944
2413
|
lifecycle.prepareHandoff(replay, deliverRequest.url);
|
|
1945
2414
|
handoffStarted = replay.mode !== 'cse';
|
|
1946
|
-
|
|
2415
|
+
// False only for a card endpoint answer the device withheld (see deliverCardEndpointAnswer).
|
|
2416
|
+
let handedOver = true;
|
|
2417
|
+
if (replay.mode === 'card_endpoint') {
|
|
2418
|
+
handedOver = await deliverCardEndpointAnswer(opts, lifecycle, cardEndpoint, replay, { url: deliverRequest.url, headers: deliverRequest.headers }, {
|
|
2419
|
+
readJar: async () => (await cdp.send('Network.getCookies', { urls: [deliverRequest.url] }, deliverOn))?.cookies,
|
|
2420
|
+
writeCookie: async (cookie) => {
|
|
2421
|
+
const written = await cdp.send('Network.setCookie', cdpCookieParams(cookie), deliverOn);
|
|
2422
|
+
if (written?.success === false)
|
|
2423
|
+
throw new Error('cookie_refused');
|
|
2424
|
+
},
|
|
2425
|
+
fulfill: (response) => cdp.send('Fetch.fulfillRequest', { requestId: deliverTo, responseCode: response.status,
|
|
2426
|
+
responseHeaders: headerEntries(response.headers), body: Buffer.from(response.body).toString('base64') }, deliverOn),
|
|
2427
|
+
assertLive: () => attempt.assertLive(),
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
else if (replay.mode === 'hosted_form') {
|
|
1947
2431
|
// The device submitted the processor's own form; the processor
|
|
1948
2432
|
// answered the device. The paused NAVIGATION is fulfilled with a page
|
|
1949
2433
|
// that says exactly that (see hosted-form.ts for why not an abort and
|
|
@@ -1974,20 +2458,41 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1974
2458
|
if (replay.kind === 'merchant_hosted')
|
|
1975
2459
|
await merchantTotal.release(replay, opts.vault, attempt.signal);
|
|
1976
2460
|
attempt.assertLive();
|
|
1977
|
-
|
|
2461
|
+
// A Shiji STS getTokenId carries pan and expires only; the device's cvv
|
|
2462
|
+
// is bound to its getCvvId companion before the continue, so the
|
|
2463
|
+
// companion cannot arrive first, and dropped if the continue fails.
|
|
2464
|
+
// Both go only to the request the approval was created from, from the
|
|
2465
|
+
// frame that sent it (cseContinuation refuses any other body).
|
|
2466
|
+
const continuation = cseContinuation(opts, deliverRequest.url, deliverBody, replay, { url: request.url, body }, preparation);
|
|
2467
|
+
const postData = Buffer.from(continuation.body).toString('base64');
|
|
2468
|
+
if (continuation.shijiCvv !== null) {
|
|
2469
|
+
shijiCompanions.bind({ url: request.url, method: request.method, body }, cdpShijiPage(sessionId, frameId), continuation.shijiCvv, continuation.shijiCvvKeySequences ?? []);
|
|
2470
|
+
}
|
|
1978
2471
|
handoffStarted = true;
|
|
2472
|
+
if (replay.kind === 'verifone') {
|
|
2473
|
+
// The merchant's own server answers a Verifone continuation, as a merchant-hosted one.
|
|
2474
|
+
merchantHandoff.begin(replay.profile, replay.authorizationId, verifoneTemplatedUrl(replay.profile, deliverRequest.url));
|
|
2475
|
+
handoffNetwork = { sessionId: activeRequest?.sourceSessionId ?? deliverOn, networkId: activeRequest?.networkId ?? '' };
|
|
2476
|
+
}
|
|
1979
2477
|
if (replay.kind === 'merchant_hosted') {
|
|
1980
2478
|
// Watched from before the continue, so an answer that races the command is still read.
|
|
1981
2479
|
merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url));
|
|
1982
2480
|
handoffNetwork = { sessionId: activeRequest?.sourceSessionId ?? deliverOn, networkId: activeRequest?.networkId ?? '' };
|
|
1983
2481
|
handoffReplay = replay;
|
|
1984
2482
|
}
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2483
|
+
try {
|
|
2484
|
+
await cdp.send('Fetch.continueRequest', {
|
|
2485
|
+
requestId: deliverTo,
|
|
2486
|
+
postData,
|
|
2487
|
+
}, deliverOn);
|
|
2488
|
+
}
|
|
2489
|
+
catch (error) {
|
|
2490
|
+
if (continuation.shijiCvv !== null)
|
|
2491
|
+
shijiCompanions.release();
|
|
2492
|
+
throw error;
|
|
2493
|
+
}
|
|
1989
2494
|
attempt.assertLive();
|
|
1990
|
-
opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url) });
|
|
2495
|
+
opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url, continuation.shijiCvv) });
|
|
1991
2496
|
}
|
|
1992
2497
|
else {
|
|
1993
2498
|
// The processor's answer, handed to the page as if the processor had
|
|
@@ -1995,7 +2500,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1995
2500
|
// see corsHeadersFor for why the answer has to carry the headers. The
|
|
1996
2501
|
// decision rides on the event, so a fulfill the page could not read
|
|
1997
2502
|
// (no usable Origin on a cross-origin request) is visible in telemetry
|
|
1998
|
-
// rather than only as the page's own "connection error".
|
|
2503
|
+
// rather than only as the page's own "connection error". A
|
|
2504
|
+
// device_encrypt answer (Payrails' 201, which the device obtained) is
|
|
2505
|
+
// handed over the same way.
|
|
1999
2506
|
const cors = corsDecision(deliverRequest.url, deliverRequest.headers);
|
|
2000
2507
|
await cdp.send('Fetch.fulfillRequest', {
|
|
2001
2508
|
requestId: deliverTo,
|
|
@@ -2003,10 +2510,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
2003
2510
|
responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
|
|
2004
2511
|
body: Buffer.from(replay.body).toString('base64'),
|
|
2005
2512
|
}, deliverOn);
|
|
2513
|
+
// The frame that received it: the paused request's, or its retry's once rebound.
|
|
2514
|
+
datatransRepeats.remember(activeRequest?.frameId || undefined, deliverRequest.url, deliverBody, replay);
|
|
2006
2515
|
attempt.assertLive();
|
|
2007
|
-
|
|
2516
|
+
// An ACI card-number request answered: its checkout's held CVC and expiry requests get the
|
|
2517
|
+
// same answer, and its /payment submission may continue (see AciGate).
|
|
2518
|
+
aci.answered(deliverRequest.url, { status: replay.status, headers: replay.headers, body: replay.body }, replay.authorizationId);
|
|
2519
|
+
// An eProtect registration answered: a give-up report for it from here on means the
|
|
2520
|
+
// vendor script stopped listening before the answer landed (see EprotectGate.gaveUp).
|
|
2521
|
+
eprotect.delivered(deliverRequest.url, () => lifecycle.merchantRequestAborted());
|
|
2522
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: replay.mode === 'device_encrypt' ? 'device_encrypt' : 'token', authorizationId: replay.authorizationId,
|
|
2523
|
+
amountVerified: replay.mode === 'device_encrypt' ? null : replay.amountVerified ?? null, cors: cors.outcome } });
|
|
2008
2524
|
}
|
|
2009
|
-
|
|
2525
|
+
if (handedOver)
|
|
2526
|
+
lifecycle.handedOff(replay);
|
|
2010
2527
|
businessPause?.delivered.resolve();
|
|
2011
2528
|
}
|
|
2012
2529
|
catch (err) {
|
|
@@ -2045,14 +2562,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
2045
2562
|
terminal = err;
|
|
2046
2563
|
else if (isApprovalOutcome(err))
|
|
2047
2564
|
quietUntil = Date.now() + cooldownMs;
|
|
2565
|
+
reportCardEndpointRefusal(opts, cardEndpoint, err);
|
|
2048
2566
|
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
2049
2567
|
}
|
|
2050
2568
|
await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: cdpFailure(request.url) }, dead.sessionId).catch(() => { });
|
|
2051
2569
|
settlePause();
|
|
2052
2570
|
}
|
|
2053
2571
|
finally {
|
|
2572
|
+
disarmSendWindow?.();
|
|
2054
2573
|
if (activeRequest?.attempt === attempt)
|
|
2055
2574
|
activeRequest = null;
|
|
2575
|
+
aci.end();
|
|
2576
|
+
eprotect.end();
|
|
2056
2577
|
awaitingApproval = false;
|
|
2057
2578
|
lifecycle.end();
|
|
2058
2579
|
if (preparation)
|
|
@@ -2144,6 +2665,11 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2144
2665
|
return undefined;
|
|
2145
2666
|
}
|
|
2146
2667
|
};
|
|
2668
|
+
// Juspay's tokenize answer and the synthetic tokens it issued (juspay-gate.ts).
|
|
2669
|
+
const juspayCheckout = new JuspayCheckoutGate(opts.vault);
|
|
2670
|
+
const aci = new AciGate(opts.vault);
|
|
2671
|
+
let navigationBodyReplaced;
|
|
2672
|
+
const eprotect = new EprotectGate(opts.vault);
|
|
2147
2673
|
const readDocumentUrl = async () => {
|
|
2148
2674
|
if (page.isClosed?.() || typeof page.url !== 'function')
|
|
2149
2675
|
throw new Error('merchant_document_unavailable');
|
|
@@ -2181,6 +2707,12 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2181
2707
|
return false;
|
|
2182
2708
|
}
|
|
2183
2709
|
};
|
|
2710
|
+
// The Shiji STS getCvvId companion of the getTokenId this attachment last continued (shiji-sts.ts).
|
|
2711
|
+
// Dropped, with the device's CVV ciphertext, as soon as no getCvvId can use it: a cancel, the page
|
|
2712
|
+
// closing or crashing, the frame the bound getTokenId came from detaching, or that frame committing
|
|
2713
|
+
// a new document (the handlers below).
|
|
2714
|
+
const shijiCompanions = new ShijiStsCompanions();
|
|
2715
|
+
lifecycle.abort.signal.addEventListener('abort', () => shijiCompanions.release(), { once: true });
|
|
2184
2716
|
page.on?.('request', (request) => {
|
|
2185
2717
|
// A new document is on its way: the main frame's next navigation commits it.
|
|
2186
2718
|
if (mainDocument(request))
|
|
@@ -2224,6 +2756,25 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2224
2756
|
}).catch(() => merchantTotal.log.failed(key));
|
|
2225
2757
|
});
|
|
2226
2758
|
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
2759
|
+
// Payrails sessions this page's own XHR and fetch answers carried, read only
|
|
2760
|
+
// when the synced registry pauses Payrails' save; see attachToCdp.
|
|
2761
|
+
const payrails = recognizesPayrails(opts.vault) ? new PayrailsSessionCapture() : null;
|
|
2762
|
+
if (payrails) {
|
|
2763
|
+
page.on?.('response', (response) => {
|
|
2764
|
+
try {
|
|
2765
|
+
const headers = response.headers?.() ?? {};
|
|
2766
|
+
if (!payrailsInitCandidate(response.request?.()?.resourceType?.(), response.status?.(), headers['content-type']))
|
|
2767
|
+
return;
|
|
2768
|
+
if (Number(headers['content-length']) > PAYRAILS_INIT_BODY_LIMIT)
|
|
2769
|
+
return;
|
|
2770
|
+
payrails.reading(Promise.resolve().then(() => response.text()).then((body) => {
|
|
2771
|
+
if (typeof body === 'string' && body.length <= PAYRAILS_INIT_BODY_LIMIT)
|
|
2772
|
+
payrails.observe(body);
|
|
2773
|
+
}));
|
|
2774
|
+
}
|
|
2775
|
+
catch { /* an answer that cannot be read carries no session */ }
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2227
2778
|
// Playwright's own routing, NOT a hand-rolled CDP session.
|
|
2228
2779
|
//
|
|
2229
2780
|
// A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
|
|
@@ -2261,13 +2812,28 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2261
2812
|
}
|
|
2262
2813
|
});
|
|
2263
2814
|
page.on?.('close', () => { if (!attachmentReady)
|
|
2264
|
-
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
|
|
2815
|
+
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); shijiCompanions.release(); });
|
|
2265
2816
|
page.on?.('crash', () => { if (!attachmentReady)
|
|
2266
|
-
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
|
|
2817
|
+
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); shijiCompanions.release(); });
|
|
2267
2818
|
page.on?.('framenavigated', (frame) => {
|
|
2268
2819
|
// A CyberSource Flex card field iframe loaded: start its capture-context check now, so its
|
|
2269
2820
|
// card request pauses without waiting on it (cybersource-flex.ts).
|
|
2270
2821
|
microformContexts.warm(frameUrlOf(frame));
|
|
2822
|
+
// The bound getTokenId's frame navigated. history.pushState, a hash change and a navigation the
|
|
2823
|
+
// page cancels keep its document; a new document replaces it. Read which, and drop the binding and
|
|
2824
|
+
// the device's CVV ciphertext only when the frame holds another document now or cannot be read; a
|
|
2825
|
+
// read still waiting on a pending navigation when the window closes keeps it (the binding expires
|
|
2826
|
+
// with the window). A claim reads the document as well, so a getCvvId is judged the same way.
|
|
2827
|
+
const boundPage = shijiCompanions.boundPage();
|
|
2828
|
+
const shijiFrame = frame && typeof frame === 'object' ? playwrightShijiFrames.get(frame) : undefined;
|
|
2829
|
+
if (boundPage && shijiFrame && boundPage.startsWith(`${shijiFrame}:doc:`)) {
|
|
2830
|
+
void playwrightFrameDocument(frame).then((document) => {
|
|
2831
|
+
if (document === undefined)
|
|
2832
|
+
return;
|
|
2833
|
+
if (document === null || boundPage !== `${shijiFrame}:doc:${document}`)
|
|
2834
|
+
shijiCompanions.releasePage((page) => page === boundPage);
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2271
2837
|
if (frame === page.mainFrame?.()) {
|
|
2272
2838
|
if (documentRequest) {
|
|
2273
2839
|
documentRequest = null;
|
|
@@ -2298,24 +2864,52 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2298
2864
|
page.on?.('framedetached', (frame) => {
|
|
2299
2865
|
if (activeRequest?.frames.includes(frame))
|
|
2300
2866
|
activeRequest.attempt.stop();
|
|
2867
|
+
const gone = frame && typeof frame === 'object' ? playwrightShijiFrames.get(frame) : undefined;
|
|
2868
|
+
if (gone)
|
|
2869
|
+
shijiCompanions.releasePage((bound) => bound.startsWith(`${gone}:doc:`));
|
|
2301
2870
|
});
|
|
2302
2871
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
2303
2872
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
2304
2873
|
let lastSubmitted = null;
|
|
2874
|
+
// The approved Datatrans answers, per Frame: the repeat rule (datatrans.ts).
|
|
2875
|
+
const datatransRepeats = new DatatransRepeats();
|
|
2876
|
+
const frameOf = (request) => { try {
|
|
2877
|
+
return request.frame?.() ?? undefined;
|
|
2878
|
+
}
|
|
2879
|
+
catch {
|
|
2880
|
+
return undefined;
|
|
2881
|
+
} };
|
|
2305
2882
|
const install = async (assertActive) => {
|
|
2306
2883
|
if (page.isClosed?.()) {
|
|
2307
2884
|
const failure = new CheckoutAttachmentError('closed');
|
|
2308
2885
|
setupStop.abort(failure);
|
|
2309
2886
|
throw failure;
|
|
2310
2887
|
}
|
|
2311
|
-
await page.route(
|
|
2888
|
+
await page.route(
|
|
2889
|
+
// The matcher sees only the URL. ACI's /payment and /registration are not the recognizer's URL,
|
|
2890
|
+
// so the ACI gate routes them (never ACI's widget script, /bins or /v1/collect).
|
|
2891
|
+
// The matcher sees only the URL. Worldpay eProtect's registration is a GET, so its family and
|
|
2892
|
+
// the vendor's give-up reports are routed by the eProtect gate (never its scripts).
|
|
2893
|
+
(url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString())
|
|
2894
|
+
|| eprotect.matches(url.toString())
|
|
2312
2895
|
// An armed merchant profile's endpoint or a sibling of it (merchant-hosted.ts), as attachToCdp arms it.
|
|
2313
2896
|
|| (typeof opts.vault.merchantProfileOf === 'function' && opts.vault.merchantProfileOf(url.toString()) !== null)
|
|
2897
|
+
// An armed Verifone merchant's origin (verifone.ts), as attachToCdp arms it.
|
|
2898
|
+
|| (typeof opts.vault.verifoneProfileOf === 'function' && opts.vault.verifoneProfileOf(url.toString()) !== null)
|
|
2314
2899
|
// GMO token v1's JSONP GET, routed only for an org the API served GMO to (see attachToCdp).
|
|
2315
|
-
|| (isGmoTokenV1Request(url.toString()) && recognizesGmo(opts.vault))
|
|
2900
|
+
|| (isGmoTokenV1Request(url.toString()) && recognizesGmo(opts.vault))
|
|
2901
|
+
|| juspayCheckout.matches(url.toString())
|
|
2902
|
+
// Every reviewed card endpoint, in any status (the pre-claim gate refuses one this build does not pay).
|
|
2903
|
+
|| cardEndpointFor(url.toString(), 'POST') !== null
|
|
2904
|
+
|| aci.matches(url.toString())
|
|
2905
|
+
// Every Payrails host, routed only for an org the API served Payrails to (see attachToCdp).
|
|
2906
|
+
|| (payrails !== null && isPayrailsHost(url.toString())), async (route) => {
|
|
2316
2907
|
const request = route.request();
|
|
2317
2908
|
// When this request paused, and the page it came from: the merchant's total is read as of now.
|
|
2318
2909
|
const pausedAt = Date.now();
|
|
2910
|
+
// The frame and document a Shiji STS request came from, read as it pauses: an approved
|
|
2911
|
+
// getTokenId's companion binding belongs to that document (playwrightShijiDocumentPage).
|
|
2912
|
+
const shijiDocumentPage = isShijiStsHost(request.url()) ? playwrightShijiDocumentPage(request) : null;
|
|
2319
2913
|
const pausedPage = merchantTotal.pageOf(keyOf(request)) ?? `doc:${documentSerial}`;
|
|
2320
2914
|
// One pre-claim gate registry decides and performs the mechanics through
|
|
2321
2915
|
// Playwright's own routing; the accessors read the same state the inline
|
|
@@ -2327,14 +2921,34 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2327
2921
|
// headers() and postData() are read lazily, inside the gate that needs them (see
|
|
2328
2922
|
// PreClaimContext.request): a Playwright request mock without headers() and no Stripe checkout
|
|
2329
2923
|
// never invokes it, and a body Playwright cannot read stops a Mercado checkout the way it did inline.
|
|
2330
|
-
|
|
2331
|
-
|
|
2924
|
+
// postData() is null for no body, an empty one and a streamed upload alike; Payrails'
|
|
2925
|
+
// gate refuses a null or empty body on every adapter, its static host aside (see payrailsPreClaim).
|
|
2926
|
+
// At an armed Verifone merchant's origin a gate reads that null as '', a request with no body:
|
|
2927
|
+
// in Chromium, Playwright hands over the bytes of a Blob, a File, a FormData and a large JSON
|
|
2928
|
+
// body (measured on fetch, XMLHttpRequest and sendBeacon), so its null there is a request with no
|
|
2929
|
+
// body in practice; a body Chromium keeps no bytes for, such as a stream, is the one left, and
|
|
2930
|
+
// attachToCdp aborts it. Every other request's gate reads the null as it comes.
|
|
2931
|
+
request: { url: request.url(), method: request.method(), get headers() { return request.headers(); },
|
|
2932
|
+
get body() {
|
|
2933
|
+
const body = request.postData();
|
|
2934
|
+
return body === null && typeof opts.vault.verifoneProfileOf === 'function' && opts.vault.verifoneProfileOf(request.url()) !== null ? '' : body;
|
|
2935
|
+
},
|
|
2936
|
+
get navigation() { try {
|
|
2937
|
+
return request.isNavigationRequest?.() === true;
|
|
2938
|
+
}
|
|
2939
|
+
catch {
|
|
2940
|
+
return false;
|
|
2941
|
+
} } },
|
|
2942
|
+
vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout, aci, eprotect,
|
|
2943
|
+
// Asked once, and only for ACI's classic-mode /payment: whether this page's engine sends
|
|
2944
|
+
// the body route.continue replaces on a navigation (aci.ts playwrightReplacesNavigationBody).
|
|
2945
|
+
replacesNavigationBody: () => (navigationBodyReplaced ??= playwrightReplacesNavigationBody(page)),
|
|
2332
2946
|
readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
|
|
2333
2947
|
effects: {
|
|
2334
2948
|
continueRequest: (postData) => postData !== undefined ? route.continue({ postData }) : route.fallback(),
|
|
2335
2949
|
answer: (response) => route.fulfill({ ...response,
|
|
2336
2950
|
headers: withCorsHeaders(response.headers, corsHeadersFor(request.url(), request.headers())) }),
|
|
2337
|
-
abort: () => route.abort(
|
|
2951
|
+
abort: () => route.abort(refusalFailure(request.url())),
|
|
2338
2952
|
},
|
|
2339
2953
|
attachmentReady: () => attachmentReady,
|
|
2340
2954
|
interceptionAborted: () => setupStop.signal.aborted,
|
|
@@ -2344,6 +2958,19 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2344
2958
|
stripeAttachmentBusy: () => false,
|
|
2345
2959
|
requestInactive: () => !!(page.isClosed?.() || request.failure?.()),
|
|
2346
2960
|
awaitingApproval: () => awaitingApproval,
|
|
2961
|
+
// The request's frame, then each parent up to the page's main frame; a request with no frame
|
|
2962
|
+
// (a service worker's) has no chain.
|
|
2963
|
+
initiatorFrames: () => {
|
|
2964
|
+
const urls = [];
|
|
2965
|
+
try {
|
|
2966
|
+
for (let frame = request.frame(); frame && urls.length < 64; frame = frame.parentFrame())
|
|
2967
|
+
urls.push(frame.url());
|
|
2968
|
+
}
|
|
2969
|
+
catch {
|
|
2970
|
+
return undefined;
|
|
2971
|
+
}
|
|
2972
|
+
return urls.length ? urls : undefined;
|
|
2973
|
+
},
|
|
2347
2974
|
stripeStep: null,
|
|
2348
2975
|
// This route arrives after every answer the page read before sending it; only an unfollowed 3xx is left to settle.
|
|
2349
2976
|
merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, () => { handoffAnswer?.settle(); return false; }) },
|
|
@@ -2365,6 +2992,11 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2365
2992
|
}
|
|
2366
2993
|
return judgeMicroformFrameUrls(flexRequest, frameUrlOf(issuing), frames.map(frameUrlOf), microformContexts);
|
|
2367
2994
|
} },
|
|
2995
|
+
juspay: juspayCheckout,
|
|
2996
|
+
datatrans: datatransRepeats,
|
|
2997
|
+
frame: frameOf(request),
|
|
2998
|
+
payrails,
|
|
2999
|
+
shijiSts: { companions: shijiCompanions, page: async () => (await playwrightShijiDocumentPage(request)) ?? `pw:none:${++playwrightShijiSeq}` },
|
|
2368
3000
|
});
|
|
2369
3001
|
const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
|
|
2370
3002
|
if (preClaim.kind !== 'pause')
|
|
@@ -2372,17 +3004,28 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2372
3004
|
const stripeStep = preClaim.stripeStep;
|
|
2373
3005
|
// The capture context and fields a CyberSource Flex create carries, and its retry shape.
|
|
2374
3006
|
const flexPause = preClaim.cybersourceFlex;
|
|
3007
|
+
// A reviewed merchant's own card endpoint this build pays (see card-endpoint.ts).
|
|
3008
|
+
const cardEndpoint = preClaim.cardEndpoint ?? null;
|
|
3009
|
+
// A Payrails card save the gate bound to a captured session: its key rides on the create.
|
|
3010
|
+
const payrailsSidecar = preClaim.payrails;
|
|
2375
3011
|
if (!attachmentReady || setupStop.signal.aborted) {
|
|
2376
3012
|
opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
|
|
2377
|
-
return route.abort(
|
|
3013
|
+
return route.abort(refusalFailure(request.url()));
|
|
2378
3014
|
}
|
|
3015
|
+
// Same order as attachToCdp: a routine card-free request continues in
|
|
3016
|
+
// every state, before the preparation claim, so it neither spends a
|
|
3017
|
+
// ready preparation nor counts as the page's first card request.
|
|
3018
|
+
const continuationBody = request.postData();
|
|
3019
|
+
const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody, lifecycle.cardHandoffs()) ?? null;
|
|
3020
|
+
if (withoutCard === 'continue')
|
|
3021
|
+
return route.fallback();
|
|
2379
3022
|
let preparation;
|
|
2380
3023
|
try {
|
|
2381
|
-
preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '', request.headers());
|
|
3024
|
+
preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.method(), request.postData() ?? '', request.headers());
|
|
2382
3025
|
}
|
|
2383
3026
|
catch (error) {
|
|
2384
3027
|
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
2385
|
-
return route.abort(
|
|
3028
|
+
return route.abort(refusalFailure(request.url()));
|
|
2386
3029
|
}
|
|
2387
3030
|
// The page asking again for the purchase an outstanding approval is
|
|
2388
3031
|
// for, after its own request timed out: bind it to that approval (see
|
|
@@ -2405,30 +3048,24 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2405
3048
|
return;
|
|
2406
3049
|
}
|
|
2407
3050
|
}
|
|
2408
|
-
|
|
2409
|
-
const continuationBody = request.postData();
|
|
2410
|
-
const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody) ?? null;
|
|
2411
|
-
if (withoutCard === 'continue') {
|
|
2412
|
-
if (preparation)
|
|
2413
|
-
preparationGate.retireUnboundClaim();
|
|
2414
|
-
return route.fallback();
|
|
2415
|
-
}
|
|
2416
|
-
const continuationId = stripeStep || terminal ? null
|
|
3051
|
+
const continuation = stripeStep || terminal ? null
|
|
2417
3052
|
: claimStripeContinuation(opts, lifecycle, request.url(), request.method(), continuationBody);
|
|
2418
|
-
if (
|
|
3053
|
+
if (continuation) {
|
|
2419
3054
|
if (preparation)
|
|
2420
3055
|
preparationGate.retireUnboundClaim();
|
|
2421
|
-
if (await checkStripeContinuation(opts, lifecycle,
|
|
2422
|
-
|
|
3056
|
+
if (await checkStripeContinuation(opts, lifecycle, continuation, { url: request.url(), method: request.method(), headers: request.headers() }) && !setupStop.signal.aborted) {
|
|
3057
|
+
// As in attachToCdp: a rewritten card save continues with the allowed body; Playwright recomputes the length.
|
|
3058
|
+
return continuation.rewritten ? route.continue({ postData: continuation.body }) : route.fallback();
|
|
2423
3059
|
}
|
|
2424
|
-
return route.abort(
|
|
3060
|
+
return route.abort(refusalFailure(request.url()));
|
|
2425
3061
|
}
|
|
2426
3062
|
// Fail closed and stay quiet: no card may reach the PSP, but neither may
|
|
2427
3063
|
// the page's retry loop turn into a stream of doomed API calls. Every
|
|
2428
3064
|
// abort in this adapter is 'aborted' (ERR_ABORTED), and a CyberSource
|
|
2429
3065
|
// Flex request's is 'failed' (refusedRequestFailure), the same codes the
|
|
2430
3066
|
// CDP adapter's Fetch.failRequest uses, so a refused request resolves
|
|
2431
|
-
// identically whichever adapter is attached.
|
|
3067
|
+
// identically whichever adapter is attached. An Amadeus card request is
|
|
3068
|
+
// 'failed' (ERR_FAILED) in both too, see failsAsNetworkError (refusalFailure).
|
|
2432
3069
|
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
2433
3070
|
let repeatedAuthorizationId = null;
|
|
2434
3071
|
if (lastSubmitted) {
|
|
@@ -2445,7 +3082,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2445
3082
|
opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
|
|
2446
3083
|
if (preparation)
|
|
2447
3084
|
preparationGate.retireUnboundClaim();
|
|
2448
|
-
const refused = await route.abort(
|
|
3085
|
+
const refused = await route.abort(refusalFailure(request.url()));
|
|
2449
3086
|
if (repeatedAuthorizationId)
|
|
2450
3087
|
void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
|
|
2451
3088
|
return refused;
|
|
@@ -2455,7 +3092,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2455
3092
|
if (preparation)
|
|
2456
3093
|
preparationGate.retireUnboundClaim();
|
|
2457
3094
|
opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
|
|
2458
|
-
return route.abort(
|
|
3095
|
+
return route.abort(refusalFailure(request.url()));
|
|
2459
3096
|
}
|
|
2460
3097
|
// Reserved before anything that could yield, matching attachToCdp.
|
|
2461
3098
|
awaitingApproval = true;
|
|
@@ -2466,7 +3103,9 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2466
3103
|
catch {
|
|
2467
3104
|
navigation = false;
|
|
2468
3105
|
}
|
|
2469
|
-
|
|
3106
|
+
// A card endpoint approval never outlives its request (see attachToCdp).
|
|
3107
|
+
const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url(), request.method(), navigation, !!preparation || !!stripeStep || !!cardEndpoint || mercadoCheckout.requiresDocument(request.url(), request.method())
|
|
3108
|
+
|| juspayCheckout.bindsApproval(request.url(), request.method())));
|
|
2470
3109
|
const frames = [];
|
|
2471
3110
|
try {
|
|
2472
3111
|
for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
|
|
@@ -2487,28 +3126,40 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2487
3126
|
// The approval the device gave, and whether it reached a browser request.
|
|
2488
3127
|
let approvedId = null;
|
|
2489
3128
|
let deliveryBegun = false;
|
|
3129
|
+
// A prepared Amadeus SDK add must be sent within its window (see amadeus.ts).
|
|
3130
|
+
let disarmSendWindow = null;
|
|
2490
3131
|
try {
|
|
2491
3132
|
const body = request.postData() ?? '';
|
|
2492
3133
|
if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
|
|
2493
3134
|
opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
|
|
2494
|
-
const refused = await route.abort(
|
|
3135
|
+
const refused = await route.abort(refusalFailure(request.url()));
|
|
2495
3136
|
if (lastSubmitted)
|
|
2496
3137
|
void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
|
|
2497
3138
|
return refused;
|
|
2498
3139
|
}
|
|
2499
|
-
|
|
3140
|
+
// A card endpoint's events name the profile and the endpoint origin, never its URL (see attachToCdp).
|
|
3141
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { ...(cardEndpoint ? cardEndpointDetail(cardEndpoint) : { url: eventUrl(opts.vault, request.url()) }),
|
|
3142
|
+
recognizer: recognizerNameOf(opts, request.url(), request.method()) } });
|
|
2500
3143
|
lifecycle.begin();
|
|
2501
3144
|
const approvalStartedAt = Date.now();
|
|
2502
3145
|
const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
|
|
2503
3146
|
attempt.bind({ route, request, frames });
|
|
2504
3147
|
activeRequest = { request, frames, attempt,
|
|
2505
3148
|
identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined, flexPause?.shape) };
|
|
3149
|
+
// An ACI card-number request: see the same step in attachToCdp.
|
|
3150
|
+
if (!aci.begin(request.url(), request.method(), body, preparation?.merchantOrigin)) {
|
|
3151
|
+
throw new CheckoutPreparationError(preparation?.id ?? null, ACI_CHECKOUT_REUSED);
|
|
3152
|
+
}
|
|
3153
|
+
// A Worldpay eProtect registration: see the same step in attachToCdp.
|
|
3154
|
+
eprotect.begin(request.url(), request.method(), () => attempt.stop());
|
|
3155
|
+
disarmSendWindow = armWindowFor(opts, lifecycle, attempt, preparation, request.url(), () => deliveryBegun);
|
|
2506
3156
|
if (preparation)
|
|
2507
3157
|
await preparationGate.assertDocument();
|
|
2508
3158
|
// One bounded read serves the merchant origin and the retry identity; see attachToCdp.
|
|
2509
3159
|
const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
|
|
2510
3160
|
const pageOrigin = pageOriginFrom(documentUrl);
|
|
2511
|
-
|
|
3161
|
+
// The agent's amount is a card endpoint's only authority: no page total is read.
|
|
3162
|
+
const pageAmount = cardEndpoint ? undefined : await pageAmountOf(opts);
|
|
2512
3163
|
assertRequestLive();
|
|
2513
3164
|
activeRequest.identity.documentUrl = documentKey(documentUrl);
|
|
2514
3165
|
activeRequest.identity.pageAmount = pageAmount;
|
|
@@ -2518,6 +3169,13 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2518
3169
|
const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
|
|
2519
3170
|
? opts.merchantOrigin : pageOrigin?.startsWith('https:') ? pageOrigin : opts.merchantOrigin);
|
|
2520
3171
|
assertRequestLive();
|
|
3172
|
+
// The card endpoint's session (see attachToCdp): request.headers() never carries
|
|
3173
|
+
// the cookie jar, so the endpoint's cookies are read from the context.
|
|
3174
|
+
const session = cardEndpoint ? await captureCardEndpointSession(cardEndpoint, {
|
|
3175
|
+
url: request.url(), headers: request.headers(), body, pageOrigin,
|
|
3176
|
+
cookies: async () => await page.context().cookies(request.url()),
|
|
3177
|
+
}) : null;
|
|
3178
|
+
assertRequestLive();
|
|
2521
3179
|
const replay = await opts.vault.authorize({
|
|
2522
3180
|
user: opts.user,
|
|
2523
3181
|
merchant: opts.merchant,
|
|
@@ -2542,10 +3200,12 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2542
3200
|
lifecycle.approvalUrl(url);
|
|
2543
3201
|
return opts.onApprovalUrl?.(url);
|
|
2544
3202
|
} },
|
|
2545
|
-
request: stripeStep?.phase === 'final' ? stripeStep.request :
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
3203
|
+
request: stripeStep?.phase === 'final' ? stripeStep.request : cardEndpoint && session ? cardEndpointRequest(cardEndpoint, session)
|
|
3204
|
+
: { url: request.url(), method: request.method(), headers: request.headers(), body,
|
|
3205
|
+
...(mercadoCheckout.requiresDocument(request.url(), request.method())
|
|
3206
|
+
? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}),
|
|
3207
|
+
...(flexPause ? flexPause.sidecar : {}),
|
|
3208
|
+
...(payrailsSidecar ? { payrails: payrailsSidecar } : {}) },
|
|
2549
3209
|
});
|
|
2550
3210
|
assertRequestLive();
|
|
2551
3211
|
approvedId = replay.authorizationId;
|
|
@@ -2570,7 +3230,17 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2570
3230
|
attempt.beginDelivery();
|
|
2571
3231
|
lifecycle.prepareHandoff(replay, deliverRequest.url());
|
|
2572
3232
|
handoffStarted = replay.mode !== 'cse';
|
|
2573
|
-
|
|
3233
|
+
// False only for a card endpoint answer the device withheld (see deliverCardEndpointAnswer).
|
|
3234
|
+
let handedOver = true;
|
|
3235
|
+
if (replay.mode === 'card_endpoint') {
|
|
3236
|
+
handedOver = await deliverCardEndpointAnswer(opts, lifecycle, cardEndpoint, replay, { url: deliverRequest.url(), headers: deliverRequest.headers() }, {
|
|
3237
|
+
readJar: () => page.context().cookies(deliverRequest.url()),
|
|
3238
|
+
writeCookie: (cookie) => page.context().addCookies([playwrightCookieParams(cookie)]),
|
|
3239
|
+
fulfill: (response) => deliverTo.fulfill(response),
|
|
3240
|
+
assertLive: assertRequestLive,
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3243
|
+
else if (replay.mode === 'hosted_form') {
|
|
2574
3244
|
// Same as the CDP path: the paused navigation resolves to the
|
|
2575
3245
|
// synthetic page, and a re-post of this form is refused.
|
|
2576
3246
|
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
@@ -2588,8 +3258,26 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2588
3258
|
if (replay.kind === 'merchant_hosted')
|
|
2589
3259
|
await merchantTotal.release(replay, opts.vault, attempt.signal);
|
|
2590
3260
|
assertRequestLive();
|
|
2591
|
-
|
|
3261
|
+
// A Shiji STS getTokenId binds the device's cvv to its companion
|
|
3262
|
+
// first, exactly as in attachToCdp: only for the request the
|
|
3263
|
+
// approval was created from, and its frame.
|
|
3264
|
+
const continuation = cseContinuation(opts, deliverRequest.url(), deliverBody, replay, { url: request.url(), body }, preparation);
|
|
3265
|
+
if (continuation.shijiCvv !== null) {
|
|
3266
|
+
// The document the getTokenId paused in. One that cannot be read leaves the CVV nowhere to
|
|
3267
|
+
// go, so the card is not tokenized either.
|
|
3268
|
+
const boundPage = shijiDocumentPage ? await shijiDocumentPage : null;
|
|
3269
|
+
if (boundPage === null)
|
|
3270
|
+
throw new ShijiStsError('unsupported');
|
|
3271
|
+
assertRequestLive();
|
|
3272
|
+
shijiCompanions.bind({ url: request.url(), method: request.method(), body }, boundPage, continuation.shijiCvv, continuation.shijiCvvKeySequences ?? []);
|
|
3273
|
+
}
|
|
2592
3274
|
handoffStarted = true;
|
|
3275
|
+
if (replay.kind === 'verifone') {
|
|
3276
|
+
// The merchant's own server answers a Verifone continuation, as a merchant-hosted one.
|
|
3277
|
+
merchantHandoff.begin(replay.profile, replay.authorizationId, verifoneTemplatedUrl(replay.profile, deliverRequest.url()));
|
|
3278
|
+
handoffAnswer?.stop();
|
|
3279
|
+
handoffAnswer = watchPlaywrightHandoff(page, deliverRequest, merchantHandoff);
|
|
3280
|
+
}
|
|
2593
3281
|
if (replay.kind === 'merchant_hosted') {
|
|
2594
3282
|
merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url()));
|
|
2595
3283
|
// Watched from before the continue: 5xx, a failure or the page closing is an unknown outcome.
|
|
@@ -2597,9 +3285,16 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2597
3285
|
handoffAnswer?.stop();
|
|
2598
3286
|
handoffAnswer = watchPlaywrightHandoff(page, deliverRequest, merchantHandoff, () => merchantTotal.answered(replay, opts.vault));
|
|
2599
3287
|
}
|
|
2600
|
-
|
|
3288
|
+
try {
|
|
3289
|
+
await deliverTo.continue({ postData: continuation.body });
|
|
3290
|
+
}
|
|
3291
|
+
catch (error) {
|
|
3292
|
+
if (continuation.shijiCvv !== null)
|
|
3293
|
+
shijiCompanions.release();
|
|
3294
|
+
throw error;
|
|
3295
|
+
}
|
|
2601
3296
|
assertRequestLive();
|
|
2602
|
-
opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url()) });
|
|
3297
|
+
opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url(), continuation.shijiCvv) });
|
|
2603
3298
|
}
|
|
2604
3299
|
else {
|
|
2605
3300
|
// Playwright adds these itself when a cross-origin fulfill carries
|
|
@@ -2607,10 +3302,17 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2607
3302
|
// cross-origin answer is the same whichever adapter ran.
|
|
2608
3303
|
const cors = corsDecision(deliverRequest.url(), deliverRequest.headers());
|
|
2609
3304
|
await deliverTo.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
|
|
3305
|
+
datatransRepeats.remember(frameOf(deliverRequest), deliverRequest.url(), deliverBody, replay);
|
|
2610
3306
|
assertRequestLive();
|
|
2611
|
-
|
|
3307
|
+
// See the same step in attachToCdp.
|
|
3308
|
+
aci.answered(deliverRequest.url(), { status: replay.status, headers: replay.headers, body: replay.body }, replay.authorizationId);
|
|
3309
|
+
// See the same step in attachToCdp.
|
|
3310
|
+
eprotect.delivered(deliverRequest.url(), () => lifecycle.merchantRequestAborted());
|
|
3311
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: replay.mode === 'device_encrypt' ? 'device_encrypt' : 'token', authorizationId: replay.authorizationId,
|
|
3312
|
+
amountVerified: replay.mode === 'device_encrypt' ? null : replay.amountVerified ?? null, cors: cors.outcome } });
|
|
2612
3313
|
}
|
|
2613
|
-
|
|
3314
|
+
if (handedOver)
|
|
3315
|
+
lifecycle.handedOff(replay);
|
|
2614
3316
|
}
|
|
2615
3317
|
catch (err) {
|
|
2616
3318
|
const dead = attempt.target?.route ?? route;
|
|
@@ -2635,13 +3337,17 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2635
3337
|
terminal = err;
|
|
2636
3338
|
else if (isApprovalOutcome(err))
|
|
2637
3339
|
quietUntil = Date.now() + cooldownMs;
|
|
3340
|
+
reportCardEndpointRefusal(opts, cardEndpoint, err);
|
|
2638
3341
|
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
2639
3342
|
}
|
|
2640
|
-
await dead.abort(
|
|
3343
|
+
await dead.abort(refusalFailure(request.url())).catch(() => { });
|
|
2641
3344
|
}
|
|
2642
3345
|
finally {
|
|
3346
|
+
disarmSendWindow?.();
|
|
2643
3347
|
if (activeRequest?.attempt === attempt)
|
|
2644
3348
|
activeRequest = null;
|
|
3349
|
+
aci.end();
|
|
3350
|
+
eprotect.end();
|
|
2645
3351
|
awaitingApproval = false;
|
|
2646
3352
|
lifecycle.end();
|
|
2647
3353
|
if (preparation)
|