@agent-cards/checkout 0.17.0 → 0.19.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 +16 -0
- package/PREFLIGHT.md +4 -0
- package/README.md +106 -14
- package/dist/adyen-merchant-hosted.generated.d.ts +277 -0
- package/dist/adyen-merchant-hosted.generated.js +1902 -0
- package/dist/builtin-registry.generated.js +1 -1
- package/dist/card-fields.generated.d.ts +3 -0
- package/dist/card-fields.generated.js +46 -0
- package/dist/cdp.d.ts +6 -1
- package/dist/cdp.js +574 -220
- package/dist/client.d.ts +285 -5
- package/dist/client.js +590 -12
- package/dist/cse-body.d.ts +25 -0
- package/dist/cse-body.js +41 -0
- package/dist/fiserv.d.ts +65 -0
- package/dist/fiserv.generated.d.ts +73 -0
- package/dist/fiserv.generated.js +830 -0
- package/dist/fiserv.js +104 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -1
- package/dist/lifecycle.d.ts +38 -1
- package/dist/lifecycle.js +77 -5
- package/dist/merchant-handoff.d.ts +54 -0
- package/dist/merchant-handoff.js +100 -0
- package/dist/merchant-hosted.d.ts +140 -0
- package/dist/merchant-hosted.js +170 -0
- package/dist/merchant-total-watch.d.ts +115 -0
- package/dist/merchant-total-watch.js +268 -0
- package/dist/merchant-total.d.ts +257 -0
- package/dist/merchant-total.js +383 -0
- package/dist/pre-claim.d.ts +123 -0
- package/dist/pre-claim.js +386 -0
- package/dist/preflight-capabilities.generated.d.ts +1 -1
- package/dist/preflight-capabilities.generated.js +1 -1
- package/dist/preflight-catalog.json +133 -1
- package/dist/preflight-schemas.json +14 -2
- package/dist/preflight.generated.d.ts +1 -1
- package/dist/preflight.generated.js +15 -1
- package/dist/preparation.d.ts +13 -0
- package/dist/preparation.js +46 -9
- package/dist/prepared-processor.d.ts +36 -3
- package/dist/prepared-processor.js +53 -3
- package/dist/registry.d.ts +60 -0
- package/dist/registry.js +14 -0
- package/dist/stripe-checkout.generated.js +140 -20
- package/dist/substitutions.generated.d.ts +2 -1
- package/dist/substitutions.generated.js +758 -6
- package/examples/preflight/kernel-native/inventory.json +1 -1
- package/package.json +3 -3
package/dist/cdp.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
|
|
2
|
-
import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, ProcessorRefusedError, UnsupportedModeError,
|
|
2
|
+
import { AdyenTestPlatformRefusedError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedModeError, } from './client.js';
|
|
3
3
|
import { PreparationGate } from './preparation.js';
|
|
4
|
-
import {
|
|
5
|
-
import { StripeCheckoutGate, StripeCheckoutClaimConflictError, stripeCheckoutReadinessError } from './stripe-checkout.js';
|
|
4
|
+
import { StripeCheckoutGate } from './stripe-checkout.js';
|
|
6
5
|
import { MercadoCheckoutGate } from './mercado-checkout.js';
|
|
7
6
|
import { MERCADO_CHECKOUT_PATTERN } from './mercado-checkout.generated.js';
|
|
7
|
+
import { dispatchPreClaim, eventUrl } from './pre-claim.js';
|
|
8
8
|
import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
|
|
9
|
-
import {
|
|
9
|
+
import { cseBody } from './cse-body.js';
|
|
10
|
+
import { replayTemplatedUrl, reviewedMerchantProfileUrlPatterns } from './merchant-hosted.js';
|
|
11
|
+
import { MerchantHandoffWatch } from './merchant-handoff.js';
|
|
12
|
+
import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watch.js';
|
|
10
13
|
import { hostedFormSubmittedPage } from './hosted-form.js';
|
|
11
14
|
import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
|
|
12
15
|
// Every URL that leaves this module through onEvent is redacted to origin +
|
|
@@ -141,20 +144,121 @@ function isApprovalOutcome(err) {
|
|
|
141
144
|
function isTerminal(err) {
|
|
142
145
|
if (err instanceof CheckoutApiError)
|
|
143
146
|
return err.permanent;
|
|
144
|
-
|
|
147
|
+
// Refused at create for Adyen's test platform: the page's next request is refused the same way.
|
|
148
|
+
if (err instanceof AdyenTestPlatformRefusedError)
|
|
149
|
+
return err.stage === 'create';
|
|
150
|
+
return err instanceof CardEncryptedError || err instanceof UnsupportedModeError || err instanceof PreparationRequiredError;
|
|
145
151
|
}
|
|
146
152
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* request
|
|
153
|
-
*
|
|
154
|
-
* fulfilling.
|
|
153
|
+
* cseBody, retiring a merchant-hosted approval it refuses. The refusal (a live body
|
|
154
|
+
* that is not the one the API checked, a swap payment-core will not write) happens
|
|
155
|
+
* before anything leaves the browser, so the approval went to no request: it is
|
|
156
|
+
* retired as merchant_never_retried, the one runtime reason the API accepts on an
|
|
157
|
+
* approved cse row, and the API stops serving its ciphertext. Not awaited, so the
|
|
158
|
+
* page's request fails at once; a retirement that fails leaves the API's purge to
|
|
159
|
+
* drop the ciphertext.
|
|
155
160
|
*/
|
|
156
|
-
function
|
|
157
|
-
|
|
161
|
+
function cseContinuationBody(opts, body, replay, url, preparation) {
|
|
162
|
+
try {
|
|
163
|
+
return cseBody(body, replay, url, preparation);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (replay.kind === 'merchant_hosted' && typeof opts.vault?.cancelAuthorization === 'function') {
|
|
167
|
+
void Promise.resolve().then(() => opts.vault.cancelAuthorization(replay.authorizationId, 'merchant_never_retried')).catch(() => { });
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Chromium's own limit on the redirects one request follows. */
|
|
173
|
+
const MAX_REDIRECT_HOPS = 20;
|
|
174
|
+
/** The statuses a browser follows to the Location they name. */
|
|
175
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
176
|
+
/**
|
|
177
|
+
* Reports the merchant's answer to a continued merchant-hosted request to the
|
|
178
|
+
* hand-off watch, from the page's own response and requestfailed events. They
|
|
179
|
+
* arrive in the Playwright server's order, and Playwright routes a paused request
|
|
180
|
+
* only once Chromium's renderer reported it, so an answer the page read before it
|
|
181
|
+
* sent a retry is read here before that retry's route. request.response() is a
|
|
182
|
+
* round trip that the retry's route can overtake.
|
|
183
|
+
*
|
|
184
|
+
* A followed redirect is answered by its last hop, as CDP reports it. Playwright
|
|
185
|
+
* reports a hop's 3xx and then, in the same step, the next hop as a new request
|
|
186
|
+
* (redirectedTo()), so a 3xx waits, and settle() takes it as the final answer once
|
|
187
|
+
* any later event finds no next hop: the reply to that request's own response()
|
|
188
|
+
* call, another network event, a retry's route, or the page closing. `answered`
|
|
189
|
+
* runs once the final answer is known, after the watch has read it.
|
|
190
|
+
*/
|
|
191
|
+
function watchPlaywrightHandoff(page, request, watch, answered) {
|
|
192
|
+
let redirect = null;
|
|
193
|
+
let done = false;
|
|
194
|
+
const final = (status) => {
|
|
195
|
+
stop();
|
|
196
|
+
watch.responded(status);
|
|
197
|
+
try {
|
|
198
|
+
answered?.();
|
|
199
|
+
}
|
|
200
|
+
catch { /* the charge report never breaks the page */ }
|
|
201
|
+
};
|
|
202
|
+
const inChain = (candidate) => {
|
|
203
|
+
for (let hop = 0; candidate && hop <= MAX_REDIRECT_HOPS; hop++) {
|
|
204
|
+
if (candidate === request)
|
|
205
|
+
return true;
|
|
206
|
+
candidate = typeof candidate.redirectedFrom === 'function' ? candidate.redirectedFrom() : null;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
};
|
|
210
|
+
const onResponse = (response) => {
|
|
211
|
+
settle();
|
|
212
|
+
let from, status;
|
|
213
|
+
try {
|
|
214
|
+
from = response.request();
|
|
215
|
+
status = response.status();
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (done || !inChain(from))
|
|
221
|
+
return;
|
|
222
|
+
if (REDIRECT_STATUSES.has(status)) {
|
|
223
|
+
redirect = { request: from, status };
|
|
224
|
+
if (typeof from.response === 'function')
|
|
225
|
+
Promise.resolve().then(() => from.response()).then(settle, settle);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
final(status);
|
|
229
|
+
};
|
|
230
|
+
const onFailed = (failed) => { settle(); if (!done && inChain(failed)) {
|
|
231
|
+
stop();
|
|
232
|
+
watch.failed();
|
|
233
|
+
} };
|
|
234
|
+
const stop = () => { done = true; page.off?.('response', onResponse); page.off?.('requestfailed', onFailed); };
|
|
235
|
+
const settle = () => {
|
|
236
|
+
if (done || !redirect)
|
|
237
|
+
return;
|
|
238
|
+
let next = null;
|
|
239
|
+
try {
|
|
240
|
+
next = typeof redirect.request.redirectedTo === 'function' ? redirect.request.redirectedTo() : null;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
next = null;
|
|
244
|
+
}
|
|
245
|
+
if (next)
|
|
246
|
+
return;
|
|
247
|
+
final(redirect.status);
|
|
248
|
+
};
|
|
249
|
+
page.on?.('response', onResponse);
|
|
250
|
+
page.on?.('requestfailed', onFailed);
|
|
251
|
+
return { settle, stop, closed: () => { settle(); if (!done) {
|
|
252
|
+
stop();
|
|
253
|
+
watch.failed();
|
|
254
|
+
} } };
|
|
255
|
+
}
|
|
256
|
+
/** The `authorized` event's detail for a cse continuation; a merchant-hosted one names its profile and templated URL. */
|
|
257
|
+
function cseAuthorizedDetail(replay, url) {
|
|
258
|
+
const base = { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) };
|
|
259
|
+
return replay.kind === 'merchant_hosted'
|
|
260
|
+
? { ...base, kind: 'merchant_hosted', profile: replay.profile, url: replayTemplatedUrl(replay, url) }
|
|
261
|
+
: base;
|
|
158
262
|
}
|
|
159
263
|
/**
|
|
160
264
|
* The paused request's body, or null when Chromium says there is one and did
|
|
@@ -298,6 +402,17 @@ export function withCorsHeaders(headers, cors) {
|
|
|
298
402
|
function headerEntries(headers) {
|
|
299
403
|
return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
|
|
300
404
|
}
|
|
405
|
+
function readPayToInterceptMs(readClickedAt, approvalStartedAt) {
|
|
406
|
+
try {
|
|
407
|
+
const clickedAt = readClickedAt?.();
|
|
408
|
+
return typeof clickedAt === 'number' && Number.isFinite(clickedAt) && clickedAt <= approvalStartedAt
|
|
409
|
+
? Math.round(approvalStartedAt - clickedAt)
|
|
410
|
+
: undefined;
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
301
416
|
/**
|
|
302
417
|
* The origin of the top-level document the payment form is on, read when a
|
|
303
418
|
* card request pauses: the fact that names the merchant for the company's
|
|
@@ -404,8 +519,62 @@ function safeOptions(opts) {
|
|
|
404
519
|
function failureSummary(error) {
|
|
405
520
|
if (error instanceof CheckoutApiError)
|
|
406
521
|
return `${error.name}: ${error.code ?? `http_${error.status}`}`;
|
|
522
|
+
// The rule's code, as the API's own refusal carried it before the error was typed.
|
|
523
|
+
if (error instanceof AdyenTestPlatformRefusedError)
|
|
524
|
+
return `${error.name}: ${error.code}`;
|
|
525
|
+
// Why the merchant's own total could not stand behind the payment, and at which step.
|
|
526
|
+
if (error instanceof MerchantTotalError)
|
|
527
|
+
return `${error.name}: ${error.code}${error.reasonCode ? ` (${error.reasonCode})` : ''}`;
|
|
407
528
|
return error instanceof Error ? error.name : 'CheckoutError';
|
|
408
529
|
}
|
|
530
|
+
const STRIPE_PAYMENT_INTENT_CONFIRM = /^\/v1\/payment_intents\/pi_[A-Za-z0-9]+\/confirm$/;
|
|
531
|
+
/**
|
|
532
|
+
* A page that tokenized the card through an approval, then confirms the
|
|
533
|
+
* payment in the browser with the token it got back (confirmCardPayment with
|
|
534
|
+
* payment_method: 'pm_...', confirmPayment with a confirmation token). That
|
|
535
|
+
* confirmation carries no card, and the hold after a Stripe tokenization
|
|
536
|
+
* would refuse it. The API decides instead whether it is the payment the
|
|
537
|
+
* cardholder approved (the approved amount and currency, on the same Stripe
|
|
538
|
+
* account, paid with exactly the approved token).
|
|
539
|
+
*
|
|
540
|
+
* Synchronous on purpose: every paused request passes through here, and an
|
|
541
|
+
* await would reorder it against the attachment's own events. Returns the
|
|
542
|
+
* approval to check, with the lifecycle's one continuation claimed, or null
|
|
543
|
+
* when this is not such a confirmation and the ordinary rules apply.
|
|
544
|
+
*/
|
|
545
|
+
function claimStripeContinuation(opts, lifecycle, url, method, body) {
|
|
546
|
+
if (body == null || typeof opts.vault.checkStripeContinuation !== 'function' || !lifecycle.isBlocked()
|
|
547
|
+
|| method.toUpperCase() !== 'POST')
|
|
548
|
+
return null;
|
|
549
|
+
let parsed;
|
|
550
|
+
try {
|
|
551
|
+
parsed = new URL(url);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
if (parsed.origin !== 'https://api.stripe.com' || !STRIPE_PAYMENT_INTENT_CONFIRM.test(parsed.pathname)
|
|
557
|
+
|| opts.vault.withoutCard?.(url, body) !== 'refuse')
|
|
558
|
+
return null;
|
|
559
|
+
return lifecycle.claimStripeContinuation();
|
|
560
|
+
}
|
|
561
|
+
/** Ask the API about a claimed continuation and settle the claim. True: continue the page's request untouched. */
|
|
562
|
+
async function checkStripeContinuation(opts, lifecycle, authorizationId, request) {
|
|
563
|
+
let paymentIntentId = null;
|
|
564
|
+
try {
|
|
565
|
+
paymentIntentId = (await opts.vault.checkStripeContinuation(authorizationId, request)).paymentIntentId;
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
569
|
+
}
|
|
570
|
+
// The checkout may have been cancelled or reconciled while the API answered.
|
|
571
|
+
const proceed = lifecycle.stripeContinuationChecked(paymentIntentId !== null);
|
|
572
|
+
if (proceed)
|
|
573
|
+
opts.onEvent?.({ type: 'stripe_payment_continued', detail: { authorizationId, paymentIntentId } });
|
|
574
|
+
else if (paymentIntentId !== null)
|
|
575
|
+
opts.onEvent?.({ type: 'blocked', detail: 'stripe_continuation_superseded' });
|
|
576
|
+
return proceed;
|
|
577
|
+
}
|
|
409
578
|
/**
|
|
410
579
|
* How long an approval waits, after the cardholder gives it, for the page to
|
|
411
580
|
* issue the request it will answer.
|
|
@@ -754,7 +923,10 @@ async function retireUnusedApproval(opts, lifecycle, authorizationId) {
|
|
|
754
923
|
* than the recognizers (see cardUrlPatterns): each paused request is re-checked
|
|
755
924
|
* with `isCardRequest` below and continued untouched unless it is an exact
|
|
756
925
|
* match. Patterns are resolved once, at attach, so every nested target ends up
|
|
757
|
-
* armed identically.
|
|
926
|
+
* armed identically. Merchant profiles are the exception to syncing first: the
|
|
927
|
+
* endpoint of every profile this build reviewed is armed whatever the sync said,
|
|
928
|
+
* and each paused request is judged against the profiles armed when it pauses,
|
|
929
|
+
* so a sync after attach arms a profile here as it does in attachToPlaywright.
|
|
758
930
|
*/
|
|
759
931
|
export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
760
932
|
opts = safeOptions(opts);
|
|
@@ -825,6 +997,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
825
997
|
return tree.frameTree.frame.url;
|
|
826
998
|
};
|
|
827
999
|
const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
|
|
1000
|
+
// A merchant-hosted request this attachment continued with the Vault's
|
|
1001
|
+
// ciphertext, and the Network identity its answer arrives under.
|
|
1002
|
+
const merchantHandoff = new MerchantHandoffWatch(lifecycle, opts.onEvent);
|
|
1003
|
+
let handoffNetwork = null;
|
|
1004
|
+
// The page's retries of that request, each waiting for its own Network report; see awaitRetryReport.
|
|
1005
|
+
const retriesAwaitingReport = new Map();
|
|
1006
|
+
// The merchant's own total (merchant-total-watch.ts): its checkout responses, recorded
|
|
1007
|
+
// read-only from attach on, and the approval a hand-off answers, for the charge report.
|
|
1008
|
+
const merchantTotal = new MerchantTotalWatch(opts.onEvent, opts.vault);
|
|
1009
|
+
let handoffReplay = null;
|
|
828
1010
|
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
829
1011
|
const armed = new Set();
|
|
830
1012
|
const arming = new Map();
|
|
@@ -900,6 +1082,29 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
900
1082
|
request.attempt.assertLive();
|
|
901
1083
|
return true;
|
|
902
1084
|
};
|
|
1085
|
+
/**
|
|
1086
|
+
* Judges a retry of the handed-off merchant-hosted request in the page's order
|
|
1087
|
+
* (MerchantHandoffWatch.retried). Fetch pauses a request in the browser process,
|
|
1088
|
+
* while the renderer reports, in order on the page's session, the merchant's
|
|
1089
|
+
* answer and then the page's next request (Network.requestWillBeSent). A retry's
|
|
1090
|
+
* pause can arrive before an answer the page already read, so the retry is
|
|
1091
|
+
* judged at its own requestWillBeSent. False when that already arrived or cannot
|
|
1092
|
+
* come; otherwise the session going away or the attachment deadline ends the wait.
|
|
1093
|
+
*/
|
|
1094
|
+
const awaitRetryReport = (sessionId, networkId, judge) => {
|
|
1095
|
+
if (!sessionId || typeof networkId !== 'string' || !networkId || hasRetiredSummary(sessionId, networkId))
|
|
1096
|
+
return false;
|
|
1097
|
+
if ([...requestSources.values(), ...retiredSources.values()]
|
|
1098
|
+
.some(source => sourceOwner(source) === sessionId && source.networkId === networkId))
|
|
1099
|
+
return false;
|
|
1100
|
+
const key = requestKey(sessionId, networkId);
|
|
1101
|
+
let timer;
|
|
1102
|
+
const reported = () => { clearTimeout(timer); retriesAwaitingReport.delete(key); judge(); };
|
|
1103
|
+
timer = setTimeout(reported, setupTimeoutMs);
|
|
1104
|
+
timer.unref?.();
|
|
1105
|
+
retriesAwaitingReport.set(key, { sessionId, reported });
|
|
1106
|
+
return true;
|
|
1107
|
+
};
|
|
903
1108
|
/** A frame's Fetch event does not identify which dedicated worker sent it.
|
|
904
1109
|
* Require the exact Network event before asking the Vault whenever that frame
|
|
905
1110
|
* has had workers. A sibling's detach proves nothing about an unknown request;
|
|
@@ -954,8 +1159,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
954
1159
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
955
1160
|
let lastSubmitted = null;
|
|
956
1161
|
const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
|
|
1162
|
+
// The endpoint and siblings of every merchant profile this build reviewed
|
|
1163
|
+
// (merchant-hosted.ts), which a VaultClient arms from the start: a profile's card
|
|
1164
|
+
// request is paused here exactly as attachToPlaywright's per-request route catches
|
|
1165
|
+
// it, before or after any syncRegistry(). The gate continues untouched a request
|
|
1166
|
+
// that the vault names no profile for.
|
|
1167
|
+
const merchantPatterns = [...reviewedMerchantProfileUrlPatterns(),
|
|
1168
|
+
...(typeof opts.vault?.merchantProfileUrlPatterns === 'function' ? opts.vault.merchantProfileUrlPatterns() : [])];
|
|
957
1169
|
const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
|
|
958
|
-
...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN])];
|
|
1170
|
+
...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns])];
|
|
959
1171
|
const arm = async (sessionId, resume = false, worker = false) => {
|
|
960
1172
|
const key = sessionId ?? '__root__';
|
|
961
1173
|
if (armed.has(key))
|
|
@@ -1063,6 +1275,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1063
1275
|
mercadoCheckout.invalidate();
|
|
1064
1276
|
// The root page owns every attached OOPIF; its loss ends child requests too.
|
|
1065
1277
|
activeRequest?.attempt.stop();
|
|
1278
|
+
// Nothing can read the merchant's answer to a continued merchant-hosted
|
|
1279
|
+
// request any more, nor the page's report of a retry: a retry is judged on
|
|
1280
|
+
// what was read, then the answer is an unknown outcome unless it arrived.
|
|
1281
|
+
for (const waiting of [...retriesAwaitingReport.values()])
|
|
1282
|
+
waiting.reported();
|
|
1283
|
+
if (handoffNetwork)
|
|
1284
|
+
merchantHandoff.failed();
|
|
1066
1285
|
}
|
|
1067
1286
|
// The transport is browser-scoped. A second tab's request or child target
|
|
1068
1287
|
// must never be authorized with this page's merchant, amount or preparation.
|
|
@@ -1081,6 +1300,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1081
1300
|
retireSource(record);
|
|
1082
1301
|
else
|
|
1083
1302
|
requestSources.set(key, record);
|
|
1303
|
+
// Read-only: the page this request came from (its session and document loader), and
|
|
1304
|
+
// the request itself when a priced merchant profile's rules read it.
|
|
1305
|
+
if (!sessionDetached(sessionId) && params.request) {
|
|
1306
|
+
merchantTotal.sent(key, { method: params.request.method, url: params.request.url,
|
|
1307
|
+
...(typeof params.request.postData === 'string' ? { requestBody: params.request.postData } : {}),
|
|
1308
|
+
...(typeof params.loaderId === 'string' && params.loaderId ? { page: `${owner}:${params.loaderId}` } : {}) });
|
|
1309
|
+
}
|
|
1084
1310
|
if (matchesActive) {
|
|
1085
1311
|
try {
|
|
1086
1312
|
identifyRequest(activeRequest);
|
|
@@ -1088,15 +1314,39 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1088
1314
|
catch { /* stop already retires the exact attempt */ }
|
|
1089
1315
|
activeRequest.attributionChanged?.();
|
|
1090
1316
|
}
|
|
1317
|
+
retriesAwaitingReport.get(requestKey(owner, params.requestId))?.reported();
|
|
1091
1318
|
return;
|
|
1092
1319
|
}
|
|
1093
1320
|
if (sessionDetached(sessionId))
|
|
1094
1321
|
return;
|
|
1095
1322
|
if (method === 'Network.loadingFinished') {
|
|
1096
|
-
|
|
1323
|
+
const key = requestKey(sessionId, params.requestId);
|
|
1324
|
+
requestSources.delete(key);
|
|
1325
|
+
if (merchantTotal.log.tracks(key)) {
|
|
1326
|
+
// When it finished, then its text: a merchant response the total or the charge is read from.
|
|
1327
|
+
const completedAt = Date.now();
|
|
1328
|
+
cdp.send('Network.getResponseBody', { requestId: params.requestId }, sessionId).then((read) => merchantTotal.log.read(key, typeof read?.body === 'string'
|
|
1329
|
+
? (read.base64Encoded ? Buffer.from(read.body, 'base64').toString('utf8') : read.body) : null, completedAt), () => merchantTotal.log.read(key, null, completedAt));
|
|
1330
|
+
}
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
if (method === 'Network.responseReceived') {
|
|
1334
|
+
merchantTotal.log.answered(requestKey(sessionId, params.requestId), params.response?.status);
|
|
1335
|
+
// The merchant's answer to a continued merchant-hosted request: 5xx is an unknown outcome.
|
|
1336
|
+
if (handoffNetwork && params.requestId === handoffNetwork.networkId && sessionId === handoffNetwork.sessionId) {
|
|
1337
|
+
merchantHandoff.responded(params.response?.status);
|
|
1338
|
+
// What the merchant's confirmation says it charged, once it has been read: reported from
|
|
1339
|
+
// the hand-off's own answer, as the Playwright adapter does, whether or not the card
|
|
1340
|
+
// request itself is one the after-payment rules read (Dick's reads its checkout calls).
|
|
1341
|
+
if (handoffReplay)
|
|
1342
|
+
merchantTotal.answered(handoffReplay, opts.vault);
|
|
1343
|
+
}
|
|
1097
1344
|
return;
|
|
1098
1345
|
}
|
|
1099
1346
|
if (method === 'Network.loadingFailed') {
|
|
1347
|
+
merchantTotal.log.failed(requestKey(sessionId, params.requestId));
|
|
1348
|
+
if (handoffNetwork && params.requestId === handoffNetwork.networkId && sessionId === handoffNetwork.sessionId)
|
|
1349
|
+
merchantHandoff.failed();
|
|
1100
1350
|
if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId) {
|
|
1101
1351
|
const dead = { requestId: activeRequest.requestId, sessionId: activeRequest.sessionId, request: activeRequest.request };
|
|
1102
1352
|
// A request a dedicated worker sent keeps today's behaviour: its loss
|
|
@@ -1137,6 +1387,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1137
1387
|
if (method !== 'Page.frameDetached') {
|
|
1138
1388
|
const detached = method === 'Target.detachedFromTarget' ? params.sessionId : sessionId;
|
|
1139
1389
|
detachedSessions.add(detached);
|
|
1390
|
+
// A retry paused there is judged on what was read, and the target a
|
|
1391
|
+
// continued merchant-hosted request went out on is gone before its answer arrived.
|
|
1392
|
+
for (const waiting of [...retriesAwaitingReport.values()])
|
|
1393
|
+
if (sessionDetached(waiting.sessionId))
|
|
1394
|
+
waiting.reported();
|
|
1395
|
+
if (handoffNetwork && detached === handoffNetwork.sessionId)
|
|
1396
|
+
merchantHandoff.failed();
|
|
1140
1397
|
if (arming.has(detached))
|
|
1141
1398
|
failInterception(new CheckoutAttachmentError('closed'));
|
|
1142
1399
|
// Terminating a worker need not emit loadingFailed. Its nested workers
|
|
@@ -1209,105 +1466,41 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1209
1466
|
if (method !== 'Fetch.requestPaused')
|
|
1210
1467
|
return;
|
|
1211
1468
|
const { requestId, request, resourceType, networkId, frameId } = params;
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
if (braintree === 'invalid') {
|
|
1246
|
-
opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
|
|
1247
|
-
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
let stripeStep = null;
|
|
1251
|
-
let stripeStage = 'request_read', stripeUrl = '';
|
|
1252
|
-
try {
|
|
1253
|
-
if (stripeCheckout.isEnabled()) {
|
|
1254
|
-
stripeUrl = request.url;
|
|
1255
|
-
const nativeRequest = { url: stripeUrl, method: request.method,
|
|
1256
|
-
headers: request.headers, body: pausedBody(request) ?? '' };
|
|
1257
|
-
stripeStage = 'classification';
|
|
1258
|
-
stripeStep = stripeCheckout.claim(nativeRequest);
|
|
1259
|
-
}
|
|
1260
|
-
if (stripeStep) {
|
|
1261
|
-
stripeStage = 'readiness';
|
|
1262
|
-
if (!attachmentReady || arming.has(sessionId ?? '__root__'))
|
|
1263
|
-
throw stripeCheckoutReadinessError('attachment_not_ready');
|
|
1264
|
-
if (setupStop.signal.aborted)
|
|
1265
|
-
throw stripeCheckoutReadinessError('cancelled');
|
|
1266
|
-
if (terminal || lifecycle.isBlocked())
|
|
1267
|
-
throw stripeCheckoutReadinessError(lifecycle.isCancelled() ? 'cancelled' : 'checkout_inactive');
|
|
1268
|
-
if (awaitingApproval)
|
|
1269
|
-
throw stripeCheckoutReadinessError('approval_pending');
|
|
1270
|
-
stripeStage = 'document';
|
|
1271
|
-
stripeCheckout.assertDocument(await readDocumentUrl());
|
|
1272
|
-
stripeStage = 'claim';
|
|
1273
|
-
stripeCheckout.assertClaim(stripeStep);
|
|
1274
|
-
stripeStage = 'readiness';
|
|
1275
|
-
if (setupStop.signal.aborted)
|
|
1276
|
-
throw stripeCheckoutReadinessError('cancelled');
|
|
1277
|
-
if (lifecycle.isBlocked())
|
|
1278
|
-
throw stripeCheckoutReadinessError(lifecycle.isCancelled() ? 'cancelled' : 'checkout_inactive');
|
|
1279
|
-
if (stripeStep.phase === 'tokenization') {
|
|
1280
|
-
stripeStage = 'stub_response';
|
|
1281
|
-
const response = stripeStep.response;
|
|
1282
|
-
await cdp.send('Fetch.fulfillRequest', { requestId, responseCode: response.status,
|
|
1283
|
-
responseHeaders: Object.entries(withCorsHeaders(response.headers, corsHeadersFor(request.url, request.headers)))
|
|
1284
|
-
.map(([name, value]) => ({ name, value })),
|
|
1285
|
-
body: Buffer.from(response.body).toString('base64') }, sessionId);
|
|
1286
|
-
opts.onEvent?.({ type: 'checkout_prepared', detail: { processor: 'stripe' } });
|
|
1287
|
-
return;
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
catch (error) {
|
|
1292
|
-
const detail = stripeCheckout.describeRejection(error, stripeUrl, stripeStage, stripeStep?.phase);
|
|
1293
|
-
if (!(error instanceof StripeCheckoutClaimConflictError))
|
|
1294
|
-
stripeCheckout.invalidate();
|
|
1295
|
-
opts.onEvent?.({ type: 'checkout_blocked', detail });
|
|
1296
|
-
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
1297
|
-
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1298
|
-
return;
|
|
1299
|
-
}
|
|
1300
|
-
if (!stripeStep && !opts.vault.isCardRequest(request.url, request.method)) {
|
|
1301
|
-
if (guards.matches(request.url, request.method)) {
|
|
1302
|
-
preparationGate.invalidate('unsupported_checkout');
|
|
1303
|
-
lifecycle.unsupported();
|
|
1304
|
-
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
|
|
1305
|
-
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1306
|
-
return;
|
|
1307
|
-
}
|
|
1308
|
-
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
1469
|
+
// When this request paused: the merchant's total is read as of this moment.
|
|
1470
|
+
const pausedAt = Date.now();
|
|
1471
|
+
// One pre-claim gate registry decides and performs the mechanics through
|
|
1472
|
+
// these effects; the state accessors read the same closure variables the
|
|
1473
|
+
// inline gates did. It resolves synchronously for an ordinary card request,
|
|
1474
|
+
// so activeRequest is still set in the paused request's own microtask. Only
|
|
1475
|
+
// a pause hands work back to the claim flow below.
|
|
1476
|
+
const preClaimResult = dispatchPreClaim({
|
|
1477
|
+
// headers and body are read lazily, inside the gate that needs them (see PreClaimContext.request).
|
|
1478
|
+
request: { url: request.url, method: request.method, get headers() { return request.headers; }, get body() { return pausedBody(request); } },
|
|
1479
|
+
vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout,
|
|
1480
|
+
readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
|
|
1481
|
+
effects: {
|
|
1482
|
+
continueRequest: (postData) => cdp.send('Fetch.continueRequest', { requestId, ...(postData !== undefined ? { postData: Buffer.from(postData).toString('base64') } : {}) }, sessionId),
|
|
1483
|
+
answer: (response) => cdp.send('Fetch.fulfillRequest', { requestId, responseCode: response.status,
|
|
1484
|
+
responseHeaders: Object.entries(withCorsHeaders(response.headers, corsHeadersFor(request.url, request.headers)))
|
|
1485
|
+
.map(([name, value]) => ({ name, value })),
|
|
1486
|
+
body: Buffer.from(response.body).toString('base64') }, sessionId),
|
|
1487
|
+
abort: () => cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { }),
|
|
1488
|
+
},
|
|
1489
|
+
attachmentReady: () => attachmentReady,
|
|
1490
|
+
interceptionAborted: () => setupStop.signal.aborted,
|
|
1491
|
+
lifecycleAborted: () => lifecycle.abort.signal.aborted,
|
|
1492
|
+
terminal: () => !!terminal,
|
|
1493
|
+
documentClosed: () => false,
|
|
1494
|
+
stripeAttachmentBusy: () => arming.has(sessionId ?? '__root__'),
|
|
1495
|
+
requestInactive: () => false,
|
|
1496
|
+
awaitingApproval: () => awaitingApproval,
|
|
1497
|
+
stripeStep: null,
|
|
1498
|
+
merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, (judge) => awaitRetryReport(sessionId, networkId, judge)) },
|
|
1499
|
+
});
|
|
1500
|
+
const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
|
|
1501
|
+
if (preClaim.kind !== 'pause')
|
|
1309
1502
|
return;
|
|
1310
|
-
|
|
1503
|
+
const stripeStep = preClaim.stripeStep;
|
|
1311
1504
|
if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
|
|
1312
1505
|
opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
|
|
1313
1506
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
@@ -1340,20 +1533,68 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1340
1533
|
activeRequest.frameId = frameId;
|
|
1341
1534
|
activeRequest.request = request;
|
|
1342
1535
|
activeRequest.sourceSessionId = undefined;
|
|
1343
|
-
opts.onEvent?.({ type: 'card_request_paused', detail: { url:
|
|
1536
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url), ...(resourceType ? { resourceType } : {}),
|
|
1344
1537
|
authorizationId: lifecycle.getState().authorizationId, retry: true } });
|
|
1345
1538
|
activeRequest.attempt.bind({ requestId, sessionId, request });
|
|
1346
1539
|
return;
|
|
1347
1540
|
}
|
|
1348
1541
|
}
|
|
1542
|
+
// A request the catalog marks as routine without a card (a confirmation
|
|
1543
|
+
// token for a saved method, a bank source, a Checkout Session confirm with
|
|
1544
|
+
// the method hosted Checkout created) was never ours: it continues in
|
|
1545
|
+
// every state, like a request the catalog does not recognize. One without
|
|
1546
|
+
// a card anywhere else is judged after the holds below.
|
|
1547
|
+
const continuationBody = pausedBody(request);
|
|
1548
|
+
const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody) ?? null;
|
|
1549
|
+
if (withoutCard === 'continue') {
|
|
1550
|
+
if (preparation)
|
|
1551
|
+
preparationGate.retireUnboundClaim();
|
|
1552
|
+
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
// Not gated on awaitingApproval: the page can send this confirmation while
|
|
1556
|
+
// the token's own delivery is still being acknowledged. The lifecycle
|
|
1557
|
+
// opens the claim only once a Stripe tokenization is being handed over.
|
|
1558
|
+
const continuationId = stripeStep || terminal ? null
|
|
1559
|
+
: claimStripeContinuation(opts, lifecycle, request.url, request.method, continuationBody);
|
|
1560
|
+
if (continuationId) {
|
|
1561
|
+
if (preparation)
|
|
1562
|
+
preparationGate.retireUnboundClaim();
|
|
1563
|
+
if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url, method: request.method, headers: request.headers, body: continuationBody }) && !setupStop.signal.aborted) {
|
|
1564
|
+
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1349
1570
|
// Same stop condition as the Playwright adapter: once a failure proves
|
|
1350
1571
|
// retrying is pointless, fail the request without calling the API again.
|
|
1351
1572
|
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
1573
|
+
let repeatedAuthorizationId = null;
|
|
1574
|
+
if (lastSubmitted) {
|
|
1575
|
+
const body = pausedBody(request);
|
|
1576
|
+
if (body !== null && isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
|
|
1577
|
+
repeatedAuthorizationId = lastSubmitted.authorizationId;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1352
1580
|
const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
1353
1581
|
opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
|
|
1354
1582
|
if (preparation)
|
|
1355
1583
|
preparationGate.retireUnboundClaim();
|
|
1356
1584
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1585
|
+
if (repeatedAuthorizationId)
|
|
1586
|
+
void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
// A recognized request that carries no card is not a card request: a
|
|
1590
|
+
// Stripe confirmation paying with a method the page created itself. It is
|
|
1591
|
+
// judged only after the holds above, so a request that would reuse an
|
|
1592
|
+
// approved token is still refused there (card-fields.js requestWithoutCard).
|
|
1593
|
+
if (withoutCard === 'refuse') {
|
|
1594
|
+
if (preparation)
|
|
1595
|
+
preparationGate.retireUnboundClaim();
|
|
1596
|
+
opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
|
|
1597
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1357
1598
|
return;
|
|
1358
1599
|
}
|
|
1359
1600
|
// Reserve BEFORE the first await (the authorize call below yields), so two
|
|
@@ -1381,11 +1622,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1381
1622
|
if (isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
|
|
1382
1623
|
opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
|
|
1383
1624
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1625
|
+
if (lastSubmitted)
|
|
1626
|
+
void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
|
|
1384
1627
|
return;
|
|
1385
1628
|
}
|
|
1386
|
-
opts.onEvent?.({ type: 'card_request_paused', detail: { url:
|
|
1629
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
1387
1630
|
lifecycle.begin();
|
|
1388
1631
|
const approvalStartedAt = Date.now();
|
|
1632
|
+
const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
|
|
1389
1633
|
if (typeof networkId !== 'string' || !networkId) {
|
|
1390
1634
|
attempt.stop();
|
|
1391
1635
|
attempt.assertLive();
|
|
@@ -1410,6 +1654,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1410
1654
|
const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
|
|
1411
1655
|
? opts.merchantOrigin : pageOrigin?.startsWith('https:') ? pageOrigin : opts.merchantOrigin);
|
|
1412
1656
|
attempt.assertLive();
|
|
1657
|
+
// The card request's page is its own Network event's document, and Chromium can deliver
|
|
1658
|
+
// that event after the pause: for a profile the merchant's total prices, wait for it
|
|
1659
|
+
// (bounded), so a total tied to its page (Cinemark) is not refused for want of one.
|
|
1660
|
+
const cardKey = requestKey(activeRequest?.sourceSessionId ?? sessionId ?? '', networkId ?? '');
|
|
1661
|
+
const cardPage = merchantTotal.priced(request.url)
|
|
1662
|
+
? await merchantTotal.pageFor(cardKey, MERCHANT_PAGE_WAIT_MS, attempt.signal) : merchantTotal.pageOf(cardKey);
|
|
1663
|
+
attempt.assertLive();
|
|
1413
1664
|
const replay = await opts.vault.authorize({
|
|
1414
1665
|
user: opts.user,
|
|
1415
1666
|
merchant: opts.merchant,
|
|
@@ -1422,10 +1673,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1422
1673
|
merchantOrigin,
|
|
1423
1674
|
pageOrigin,
|
|
1424
1675
|
pageAmount,
|
|
1676
|
+
...(payToInterceptMs !== undefined ? { payToInterceptMs } : {}),
|
|
1425
1677
|
preparation,
|
|
1426
1678
|
timeoutMs: opts.timeoutMs,
|
|
1427
1679
|
signal: lifecycle.abort.signal,
|
|
1428
1680
|
merchantSignal: attempt.signal,
|
|
1681
|
+
// What the merchant's own checkout said its total was, as this browser recorded it
|
|
1682
|
+
// (read only for a reviewed profile priced by it): the page is the card request's.
|
|
1683
|
+
merchantTotal: merchantTotal.capture(pausedAt, cardPage),
|
|
1429
1684
|
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
1430
1685
|
onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
|
|
1431
1686
|
lifecycle.approvalUrl(url);
|
|
@@ -1480,7 +1735,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1480
1735
|
body: Buffer.from(page.body).toString('base64'),
|
|
1481
1736
|
}, deliverOn);
|
|
1482
1737
|
attempt.assertLive();
|
|
1483
|
-
lastSubmitted = { url: deliverRequest.url, body: deliverBody, at: Date.now() };
|
|
1738
|
+
lastSubmitted = { url: deliverRequest.url, body: deliverBody, at: Date.now(), authorizationId: replay.authorizationId };
|
|
1484
1739
|
// Named for what it is: a device-attested submission with no
|
|
1485
1740
|
// processor evidence, never an `authorized` event.
|
|
1486
1741
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
@@ -1490,15 +1745,25 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1490
1745
|
// still goes out from THIS browser, with its own session, risk data
|
|
1491
1746
|
// and cookies, and only the four ciphertext fields swapped in. Only
|
|
1492
1747
|
// postData rides on the command: no header override, ever (see
|
|
1493
|
-
//
|
|
1494
|
-
|
|
1748
|
+
// cse-body.ts for why a recomputed Content-Length is refused by Chromium).
|
|
1749
|
+
// A total the merchant moved while the cardholder decided holds the card here.
|
|
1750
|
+
if (replay.kind === 'merchant_hosted')
|
|
1751
|
+
await merchantTotal.release(replay, opts.vault, attempt.signal);
|
|
1752
|
+
attempt.assertLive();
|
|
1753
|
+
const postData = Buffer.from(cseContinuationBody(opts, deliverBody, replay, deliverRequest.url, preparation)).toString('base64');
|
|
1495
1754
|
handoffStarted = true;
|
|
1755
|
+
if (replay.kind === 'merchant_hosted') {
|
|
1756
|
+
// Watched from before the continue, so an answer that races the command is still read.
|
|
1757
|
+
merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url));
|
|
1758
|
+
handoffNetwork = { sessionId: activeRequest?.sourceSessionId ?? deliverOn, networkId: activeRequest?.networkId ?? '' };
|
|
1759
|
+
handoffReplay = replay;
|
|
1760
|
+
}
|
|
1496
1761
|
await cdp.send('Fetch.continueRequest', {
|
|
1497
1762
|
requestId: deliverTo,
|
|
1498
1763
|
postData,
|
|
1499
1764
|
}, deliverOn);
|
|
1500
1765
|
attempt.assertLive();
|
|
1501
|
-
opts.onEvent?.({ type: 'authorized', detail:
|
|
1766
|
+
opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url) });
|
|
1502
1767
|
}
|
|
1503
1768
|
else {
|
|
1504
1769
|
// The processor's answer, handed to the page as if the processor had
|
|
@@ -1635,6 +1900,79 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1635
1900
|
return page.url();
|
|
1636
1901
|
};
|
|
1637
1902
|
const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
|
|
1903
|
+
// A merchant-hosted request this attachment continued, and its answer; see watchPlaywrightHandoff.
|
|
1904
|
+
const merchantHandoff = new MerchantHandoffWatch(lifecycle, opts.onEvent);
|
|
1905
|
+
let handoffAnswer = null;
|
|
1906
|
+
// The merchant's own total, as attachToCdp records it. A page id is the main frame's
|
|
1907
|
+
// document: it changes when the main frame commits a new document, and a document request
|
|
1908
|
+
// belongs to the document it loads, so its page is read once the frame has committed it.
|
|
1909
|
+
// A same-document navigation (history.pushState, a hash change) fires framenavigated too
|
|
1910
|
+
// but sends no document request, so it keeps the page id, as CDP's loaderId does. Nor does
|
|
1911
|
+
// a document request that commits nothing: one that failed, or one answered 204 or 205.
|
|
1912
|
+
const merchantTotal = new MerchantTotalWatch(opts.onEvent, opts.vault);
|
|
1913
|
+
let documentSerial = 1;
|
|
1914
|
+
// The main frame's document request that no navigation has committed yet.
|
|
1915
|
+
let documentRequest = null;
|
|
1916
|
+
let requestSerial = 0;
|
|
1917
|
+
const requestKeys = new WeakMap();
|
|
1918
|
+
const keyOf = (request) => {
|
|
1919
|
+
let key = requestKeys.get(request);
|
|
1920
|
+
if (!key) {
|
|
1921
|
+
key = `pw:${++requestSerial}`;
|
|
1922
|
+
requestKeys.set(request, key);
|
|
1923
|
+
}
|
|
1924
|
+
return key;
|
|
1925
|
+
};
|
|
1926
|
+
const mainDocument = (request) => {
|
|
1927
|
+
try {
|
|
1928
|
+
return request.isNavigationRequest?.() === true && request.frame?.() === page.mainFrame?.();
|
|
1929
|
+
}
|
|
1930
|
+
catch {
|
|
1931
|
+
return false;
|
|
1932
|
+
}
|
|
1933
|
+
};
|
|
1934
|
+
page.on?.('request', (request) => {
|
|
1935
|
+
// A new document is on its way: the main frame's next navigation commits it.
|
|
1936
|
+
if (mainDocument(request))
|
|
1937
|
+
documentRequest = request;
|
|
1938
|
+
try {
|
|
1939
|
+
const body = typeof request.postData === 'function' ? request.postData() : null;
|
|
1940
|
+
merchantTotal.sent(keyOf(request), { method: request.method(), url: request.url(),
|
|
1941
|
+
...(typeof body === 'string' ? { requestBody: body } : {}), page: `doc:${documentSerial}` });
|
|
1942
|
+
}
|
|
1943
|
+
catch { /* a request the page already dropped */ }
|
|
1944
|
+
});
|
|
1945
|
+
page.on?.('requestfinished', (request) => {
|
|
1946
|
+
// A document answered 204 or 205 loads nothing, so no navigation will commit it.
|
|
1947
|
+
if (request === documentRequest) {
|
|
1948
|
+
Promise.resolve().then(() => request.response()).then((response) => {
|
|
1949
|
+
if (request === documentRequest && [204, 205].includes(response?.status?.()))
|
|
1950
|
+
documentRequest = null;
|
|
1951
|
+
}).catch(() => { });
|
|
1952
|
+
}
|
|
1953
|
+
const key = keyOf(request);
|
|
1954
|
+
if (!merchantTotal.log.tracks(key))
|
|
1955
|
+
return;
|
|
1956
|
+
const completedAt = Date.now();
|
|
1957
|
+
Promise.resolve().then(async () => {
|
|
1958
|
+
const response = await request.response();
|
|
1959
|
+
if (!response) {
|
|
1960
|
+
merchantTotal.log.failed(key);
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
merchantTotal.log.answered(key, response.status());
|
|
1964
|
+
if (mainDocument(request))
|
|
1965
|
+
merchantTotal.log.repage(key, `doc:${documentSerial}`);
|
|
1966
|
+
let text = null;
|
|
1967
|
+
try {
|
|
1968
|
+
text = await response.text();
|
|
1969
|
+
}
|
|
1970
|
+
catch {
|
|
1971
|
+
text = null;
|
|
1972
|
+
}
|
|
1973
|
+
merchantTotal.log.read(key, text, completedAt);
|
|
1974
|
+
}).catch(() => merchantTotal.log.failed(key));
|
|
1975
|
+
});
|
|
1638
1976
|
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
1639
1977
|
// Playwright's own routing, NOT a hand-rolled CDP session.
|
|
1640
1978
|
//
|
|
@@ -1659,6 +1997,10 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1659
1997
|
const retryWait = retryWaitMs(opts.merchantRetryWaitMs);
|
|
1660
1998
|
let activeRequest = null;
|
|
1661
1999
|
page.on?.('requestfailed', (request) => {
|
|
2000
|
+
// A document request that failed commits nothing either.
|
|
2001
|
+
if (request === documentRequest)
|
|
2002
|
+
documentRequest = null;
|
|
2003
|
+
merchantTotal.log.failed(keyOf(request));
|
|
1662
2004
|
if (activeRequest && activeRequest.request === request && activeRequest.attempt.lose()) {
|
|
1663
2005
|
// The page's own script gave up on its request; the cardholder's
|
|
1664
2006
|
// approval did not (see merchantAttempt). Forget the request's identity
|
|
@@ -1669,11 +2011,15 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1669
2011
|
}
|
|
1670
2012
|
});
|
|
1671
2013
|
page.on?.('close', () => { if (!attachmentReady)
|
|
1672
|
-
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); });
|
|
2014
|
+
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
|
|
1673
2015
|
page.on?.('crash', () => { if (!attachmentReady)
|
|
1674
|
-
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); });
|
|
2016
|
+
setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
|
|
1675
2017
|
page.on?.('framenavigated', (frame) => {
|
|
1676
2018
|
if (frame === page.mainFrame?.()) {
|
|
2019
|
+
if (documentRequest) {
|
|
2020
|
+
documentRequest = null;
|
|
2021
|
+
documentSerial += 1;
|
|
2022
|
+
}
|
|
1677
2023
|
preparationGate.invalidate('merchant_document_changed');
|
|
1678
2024
|
mercadoCheckout.invalidate();
|
|
1679
2025
|
if (stripeCheckout.isPrepared()) {
|
|
@@ -1709,97 +2055,48 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1709
2055
|
setupStop.abort(failure);
|
|
1710
2056
|
throw failure;
|
|
1711
2057
|
}
|
|
1712
|
-
await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString())
|
|
2058
|
+
await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString())
|
|
2059
|
+
// An armed merchant profile's endpoint or a sibling of it (merchant-hosted.ts), as attachToCdp arms it.
|
|
2060
|
+
|| (typeof opts.vault.merchantProfileOf === 'function' && opts.vault.merchantProfileOf(url.toString()) !== null), async (route) => {
|
|
1713
2061
|
const request = route.request();
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
stripeStage = 'classification';
|
|
1753
|
-
stripeStep = stripeCheckout.claim(nativeRequest);
|
|
1754
|
-
}
|
|
1755
|
-
if (stripeStep) {
|
|
1756
|
-
stripeStage = 'readiness';
|
|
1757
|
-
if (!attachmentReady)
|
|
1758
|
-
throw stripeCheckoutReadinessError('attachment_not_ready');
|
|
1759
|
-
if (setupStop.signal.aborted)
|
|
1760
|
-
throw stripeCheckoutReadinessError('cancelled');
|
|
1761
|
-
if (terminal || lifecycle.isBlocked())
|
|
1762
|
-
throw stripeCheckoutReadinessError(lifecycle.isCancelled() ? 'cancelled' : 'checkout_inactive');
|
|
1763
|
-
if (awaitingApproval)
|
|
1764
|
-
throw stripeCheckoutReadinessError('approval_pending');
|
|
1765
|
-
stripeStage = 'document';
|
|
1766
|
-
stripeCheckout.assertDocument(await readDocumentUrl());
|
|
1767
|
-
stripeStage = 'claim';
|
|
1768
|
-
stripeCheckout.assertClaim(stripeStep);
|
|
1769
|
-
stripeStage = 'readiness';
|
|
1770
|
-
if (setupStop.signal.aborted)
|
|
1771
|
-
throw stripeCheckoutReadinessError('cancelled');
|
|
1772
|
-
if (lifecycle.isBlocked())
|
|
1773
|
-
throw stripeCheckoutReadinessError(lifecycle.isCancelled() ? 'cancelled' : 'checkout_inactive');
|
|
1774
|
-
if (page.isClosed?.() || request.failure?.())
|
|
1775
|
-
throw stripeCheckoutReadinessError('request_inactive');
|
|
1776
|
-
if (stripeStep.phase === 'tokenization') {
|
|
1777
|
-
stripeStage = 'stub_response';
|
|
1778
|
-
const response = stripeStep.response;
|
|
1779
|
-
await route.fulfill({ ...response,
|
|
1780
|
-
headers: withCorsHeaders(response.headers, corsHeadersFor(request.url(), request.headers())) });
|
|
1781
|
-
opts.onEvent?.({ type: 'checkout_prepared', detail: { processor: 'stripe' } });
|
|
1782
|
-
return;
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
catch (error) {
|
|
1787
|
-
const detail = stripeCheckout.describeRejection(error, stripeUrl, stripeStage, stripeStep?.phase);
|
|
1788
|
-
if (!(error instanceof StripeCheckoutClaimConflictError))
|
|
1789
|
-
stripeCheckout.invalidate();
|
|
1790
|
-
opts.onEvent?.({ type: 'checkout_blocked', detail });
|
|
1791
|
-
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
1792
|
-
return route.abort('aborted');
|
|
1793
|
-
}
|
|
1794
|
-
if (!stripeStep && !opts.vault.isCardRequest(request.url(), request.method())) {
|
|
1795
|
-
if (guards.matches(request.url(), request.method())) {
|
|
1796
|
-
preparationGate.invalidate('unsupported_checkout');
|
|
1797
|
-
lifecycle.unsupported();
|
|
1798
|
-
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
|
|
1799
|
-
return route.abort('aborted');
|
|
1800
|
-
}
|
|
1801
|
-
return route.fallback();
|
|
1802
|
-
}
|
|
2062
|
+
// When this request paused, and the page it came from: the merchant's total is read as of now.
|
|
2063
|
+
const pausedAt = Date.now();
|
|
2064
|
+
const pausedPage = merchantTotal.pageOf(keyOf(request)) ?? `doc:${documentSerial}`;
|
|
2065
|
+
// One pre-claim gate registry decides and performs the mechanics through
|
|
2066
|
+
// Playwright's own routing; the accessors read the same state the inline
|
|
2067
|
+
// gates did. The matcher only sees the URL, so a preflight, a GET, or any
|
|
2068
|
+
// non-card body continues untouched (route.fallback) or the browser's CORS
|
|
2069
|
+
// check fails on a synthetic answer. It resolves synchronously for an
|
|
2070
|
+
// ordinary card request. Only a pause hands work back below.
|
|
2071
|
+
const preClaimResult = dispatchPreClaim({
|
|
2072
|
+
// headers() and postData() are read lazily, inside the gate that needs them (see
|
|
2073
|
+
// PreClaimContext.request): a Playwright request mock without headers() and no Stripe checkout
|
|
2074
|
+
// never invokes it, and a body Playwright cannot read stops a Mercado checkout the way it did inline.
|
|
2075
|
+
request: { url: request.url(), method: request.method(), get headers() { return request.headers(); }, get body() { return request.postData(); } },
|
|
2076
|
+
vault: opts.vault, guards, mercado: mercadoCheckout, stripe: stripeCheckout,
|
|
2077
|
+
readDocumentUrl, lifecycle, preparationGate, onEvent: opts.onEvent, failureSummary,
|
|
2078
|
+
effects: {
|
|
2079
|
+
continueRequest: (postData) => postData !== undefined ? route.continue({ postData }) : route.fallback(),
|
|
2080
|
+
answer: (response) => route.fulfill({ ...response,
|
|
2081
|
+
headers: withCorsHeaders(response.headers, corsHeadersFor(request.url(), request.headers())) }),
|
|
2082
|
+
abort: () => route.abort('aborted'),
|
|
2083
|
+
},
|
|
2084
|
+
attachmentReady: () => attachmentReady,
|
|
2085
|
+
interceptionAborted: () => setupStop.signal.aborted,
|
|
2086
|
+
lifecycleAborted: () => lifecycle.abort.signal.aborted,
|
|
2087
|
+
terminal: () => !!terminal,
|
|
2088
|
+
documentClosed: () => !!page.isClosed?.(),
|
|
2089
|
+
stripeAttachmentBusy: () => false,
|
|
2090
|
+
requestInactive: () => !!(page.isClosed?.() || request.failure?.()),
|
|
2091
|
+
awaitingApproval: () => awaitingApproval,
|
|
2092
|
+
stripeStep: null,
|
|
2093
|
+
// This route arrives after every answer the page read before sending it; only an unfollowed 3xx is left to settle.
|
|
2094
|
+
merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, () => { handoffAnswer?.settle(); return false; }) },
|
|
2095
|
+
});
|
|
2096
|
+
const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
|
|
2097
|
+
if (preClaim.kind !== 'pause')
|
|
2098
|
+
return;
|
|
2099
|
+
const stripeStep = preClaim.stripeStep;
|
|
1803
2100
|
if (!attachmentReady || setupStop.signal.aborted) {
|
|
1804
2101
|
opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
|
|
1805
2102
|
return route.abort('aborted');
|
|
@@ -1828,21 +2125,60 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1828
2125
|
catch { /* covered by requestfailed */ }
|
|
1829
2126
|
activeRequest.request = request;
|
|
1830
2127
|
activeRequest.frames = retryFrames;
|
|
1831
|
-
opts.onEvent?.({ type: 'card_request_paused', detail: { url:
|
|
2128
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url()), authorizationId: lifecycle.getState().authorizationId, retry: true } });
|
|
1832
2129
|
activeRequest.attempt.bind({ route, request, frames: retryFrames });
|
|
1833
2130
|
return;
|
|
1834
2131
|
}
|
|
1835
2132
|
}
|
|
2133
|
+
// Same order as attachToCdp: a routine card-free request continues in every state.
|
|
2134
|
+
const continuationBody = request.postData();
|
|
2135
|
+
const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody) ?? null;
|
|
2136
|
+
if (withoutCard === 'continue') {
|
|
2137
|
+
if (preparation)
|
|
2138
|
+
preparationGate.retireUnboundClaim();
|
|
2139
|
+
return route.fallback();
|
|
2140
|
+
}
|
|
2141
|
+
const continuationId = stripeStep || terminal ? null
|
|
2142
|
+
: claimStripeContinuation(opts, lifecycle, request.url(), request.method(), continuationBody);
|
|
2143
|
+
if (continuationId) {
|
|
2144
|
+
if (preparation)
|
|
2145
|
+
preparationGate.retireUnboundClaim();
|
|
2146
|
+
if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url(), method: request.method(), headers: request.headers(), body: continuationBody }) && !setupStop.signal.aborted) {
|
|
2147
|
+
return route.fallback();
|
|
2148
|
+
}
|
|
2149
|
+
return route.abort('aborted');
|
|
2150
|
+
}
|
|
1836
2151
|
// Fail closed and stay quiet: no card may reach the PSP, but neither may
|
|
1837
2152
|
// the page's retry loop turn into a stream of doomed API calls. Every
|
|
1838
2153
|
// abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
|
|
1839
2154
|
// CDP adapter's Fetch.failRequest uses, so a refused navigation
|
|
1840
2155
|
// resolves identically whichever adapter is attached.
|
|
1841
2156
|
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
2157
|
+
let repeatedAuthorizationId = null;
|
|
2158
|
+
if (lastSubmitted) {
|
|
2159
|
+
let body = null;
|
|
2160
|
+
try {
|
|
2161
|
+
body = request.postData() ?? '';
|
|
2162
|
+
}
|
|
2163
|
+
catch { /* The refusal still holds when the body cannot be read. */ }
|
|
2164
|
+
if (body !== null && isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
|
|
2165
|
+
repeatedAuthorizationId = lastSubmitted.authorizationId;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
1842
2168
|
const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
1843
2169
|
opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
|
|
1844
2170
|
if (preparation)
|
|
1845
2171
|
preparationGate.retireUnboundClaim();
|
|
2172
|
+
const refused = await route.abort('aborted');
|
|
2173
|
+
if (repeatedAuthorizationId)
|
|
2174
|
+
void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
|
|
2175
|
+
return refused;
|
|
2176
|
+
}
|
|
2177
|
+
// Same judgement as attachToCdp, after the same holds.
|
|
2178
|
+
if (withoutCard === 'refuse') {
|
|
2179
|
+
if (preparation)
|
|
2180
|
+
preparationGate.retireUnboundClaim();
|
|
2181
|
+
opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
|
|
1846
2182
|
return route.abort('aborted');
|
|
1847
2183
|
}
|
|
1848
2184
|
// Reserved before anything that could yield, matching attachToCdp.
|
|
@@ -1879,11 +2215,15 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1879
2215
|
const body = request.postData() ?? '';
|
|
1880
2216
|
if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
|
|
1881
2217
|
opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
|
|
1882
|
-
|
|
2218
|
+
const refused = await route.abort('aborted');
|
|
2219
|
+
if (lastSubmitted)
|
|
2220
|
+
void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
|
|
2221
|
+
return refused;
|
|
1883
2222
|
}
|
|
1884
|
-
opts.onEvent?.({ type: 'card_request_paused', detail: { url:
|
|
2223
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url()) } });
|
|
1885
2224
|
lifecycle.begin();
|
|
1886
2225
|
const approvalStartedAt = Date.now();
|
|
2226
|
+
const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
|
|
1887
2227
|
attempt.bind({ route, request, frames });
|
|
1888
2228
|
activeRequest = { request, frames, attempt,
|
|
1889
2229
|
identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined) };
|
|
@@ -1914,10 +2254,13 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1914
2254
|
merchantOrigin,
|
|
1915
2255
|
pageOrigin,
|
|
1916
2256
|
pageAmount,
|
|
2257
|
+
...(payToInterceptMs !== undefined ? { payToInterceptMs } : {}),
|
|
1917
2258
|
preparation,
|
|
1918
2259
|
timeoutMs: opts.timeoutMs,
|
|
1919
2260
|
signal: lifecycle.abort.signal,
|
|
1920
2261
|
merchantSignal: attempt.signal,
|
|
2262
|
+
// Same as the CDP path: the merchant's total as this browser recorded it.
|
|
2263
|
+
merchantTotal: merchantTotal.capture(pausedAt, pausedPage),
|
|
1921
2264
|
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
1922
2265
|
onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
|
|
1923
2266
|
lifecycle.approvalUrl(url);
|
|
@@ -1957,18 +2300,29 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1957
2300
|
// Inert on a navigation (never CORS-checked); one path for every fulfill.
|
|
1958
2301
|
await deliverTo.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(deliverRequest.url(), deliverRequest.headers())), body: synthetic.body });
|
|
1959
2302
|
assertRequestLive();
|
|
1960
|
-
lastSubmitted = { url: deliverRequest.url(), body: deliverBody, at: Date.now() };
|
|
2303
|
+
lastSubmitted = { url: deliverRequest.url(), body: deliverBody, at: Date.now(), authorizationId: replay.authorizationId };
|
|
1961
2304
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
1962
2305
|
}
|
|
1963
2306
|
else if (replay.mode === 'cse') {
|
|
1964
2307
|
// Same as the CDP path: the request continues from this browser
|
|
1965
2308
|
// with the ciphertext swapped in and no header override; Playwright
|
|
1966
2309
|
// recomputes the length itself.
|
|
1967
|
-
|
|
2310
|
+
// A total the merchant moved while the cardholder decided holds the card here.
|
|
2311
|
+
if (replay.kind === 'merchant_hosted')
|
|
2312
|
+
await merchantTotal.release(replay, opts.vault, attempt.signal);
|
|
2313
|
+
assertRequestLive();
|
|
2314
|
+
const postData = cseContinuationBody(opts, deliverBody, replay, deliverRequest.url(), preparation);
|
|
1968
2315
|
handoffStarted = true;
|
|
2316
|
+
if (replay.kind === 'merchant_hosted') {
|
|
2317
|
+
merchantHandoff.begin(replay.profile, replay.authorizationId, replayTemplatedUrl(replay, deliverRequest.url()));
|
|
2318
|
+
// Watched from before the continue: 5xx, a failure or the page closing is an unknown outcome.
|
|
2319
|
+
// Once the merchant's final answer arrived, what its confirmation says it charged is reported.
|
|
2320
|
+
handoffAnswer?.stop();
|
|
2321
|
+
handoffAnswer = watchPlaywrightHandoff(page, deliverRequest, merchantHandoff, () => merchantTotal.answered(replay, opts.vault));
|
|
2322
|
+
}
|
|
1969
2323
|
await deliverTo.continue({ postData });
|
|
1970
2324
|
assertRequestLive();
|
|
1971
|
-
opts.onEvent?.({ type: 'authorized', detail:
|
|
2325
|
+
opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url()) });
|
|
1972
2326
|
}
|
|
1973
2327
|
else {
|
|
1974
2328
|
// Playwright adds these itself when a cross-origin fulfill carries
|