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