@agent-cards/checkout 0.18.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 +8 -0
- package/PREFLIGHT.md +4 -0
- package/README.md +91 -7
- 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/cdp.d.ts +4 -1
- package/dist/cdp.js +416 -217
- package/dist/client.d.ts +236 -5
- package/dist/client.js +514 -11
- 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 +15 -1
- package/dist/lifecycle.js +28 -3
- 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-catalog.json +132 -0
- package/dist/preflight-schemas.json +14 -2
- package/dist/preflight.generated.js +15 -1
- package/dist/preparation.d.ts +7 -0
- package/dist/preparation.js +33 -6
- package/dist/prepared-processor.d.ts +36 -3
- package/dist/prepared-processor.js +53 -3
- package/dist/registry.d.ts +45 -0
- package/dist/registry.js +14 -0
- package/dist/stripe-checkout.generated.js +96 -7
- 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
|
|
@@ -415,6 +519,12 @@ function safeOptions(opts) {
|
|
|
415
519
|
function failureSummary(error) {
|
|
416
520
|
if (error instanceof CheckoutApiError)
|
|
417
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})` : ''}`;
|
|
418
528
|
return error instanceof Error ? error.name : 'CheckoutError';
|
|
419
529
|
}
|
|
420
530
|
const STRIPE_PAYMENT_INTENT_CONFIRM = /^\/v1\/payment_intents\/pi_[A-Za-z0-9]+\/confirm$/;
|
|
@@ -813,7 +923,10 @@ async function retireUnusedApproval(opts, lifecycle, authorizationId) {
|
|
|
813
923
|
* than the recognizers (see cardUrlPatterns): each paused request is re-checked
|
|
814
924
|
* with `isCardRequest` below and continued untouched unless it is an exact
|
|
815
925
|
* match. Patterns are resolved once, at attach, so every nested target ends up
|
|
816
|
-
* 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.
|
|
817
930
|
*/
|
|
818
931
|
export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
819
932
|
opts = safeOptions(opts);
|
|
@@ -884,6 +997,16 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
884
997
|
return tree.frameTree.frame.url;
|
|
885
998
|
};
|
|
886
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;
|
|
887
1010
|
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
888
1011
|
const armed = new Set();
|
|
889
1012
|
const arming = new Map();
|
|
@@ -959,6 +1082,29 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
959
1082
|
request.attempt.assertLive();
|
|
960
1083
|
return true;
|
|
961
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
|
+
};
|
|
962
1108
|
/** A frame's Fetch event does not identify which dedicated worker sent it.
|
|
963
1109
|
* Require the exact Network event before asking the Vault whenever that frame
|
|
964
1110
|
* has had workers. A sibling's detach proves nothing about an unknown request;
|
|
@@ -1013,8 +1159,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1013
1159
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
1014
1160
|
let lastSubmitted = null;
|
|
1015
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() : [])];
|
|
1016
1169
|
const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
|
|
1017
|
-
...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN])];
|
|
1170
|
+
...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns])];
|
|
1018
1171
|
const arm = async (sessionId, resume = false, worker = false) => {
|
|
1019
1172
|
const key = sessionId ?? '__root__';
|
|
1020
1173
|
if (armed.has(key))
|
|
@@ -1122,6 +1275,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1122
1275
|
mercadoCheckout.invalidate();
|
|
1123
1276
|
// The root page owns every attached OOPIF; its loss ends child requests too.
|
|
1124
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();
|
|
1125
1285
|
}
|
|
1126
1286
|
// The transport is browser-scoped. A second tab's request or child target
|
|
1127
1287
|
// must never be authorized with this page's merchant, amount or preparation.
|
|
@@ -1140,6 +1300,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1140
1300
|
retireSource(record);
|
|
1141
1301
|
else
|
|
1142
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
|
+
}
|
|
1143
1310
|
if (matchesActive) {
|
|
1144
1311
|
try {
|
|
1145
1312
|
identifyRequest(activeRequest);
|
|
@@ -1147,15 +1314,39 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1147
1314
|
catch { /* stop already retires the exact attempt */ }
|
|
1148
1315
|
activeRequest.attributionChanged?.();
|
|
1149
1316
|
}
|
|
1317
|
+
retriesAwaitingReport.get(requestKey(owner, params.requestId))?.reported();
|
|
1150
1318
|
return;
|
|
1151
1319
|
}
|
|
1152
1320
|
if (sessionDetached(sessionId))
|
|
1153
1321
|
return;
|
|
1154
1322
|
if (method === 'Network.loadingFinished') {
|
|
1155
|
-
|
|
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
|
+
}
|
|
1156
1344
|
return;
|
|
1157
1345
|
}
|
|
1158
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();
|
|
1159
1350
|
if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId) {
|
|
1160
1351
|
const dead = { requestId: activeRequest.requestId, sessionId: activeRequest.sessionId, request: activeRequest.request };
|
|
1161
1352
|
// A request a dedicated worker sent keeps today's behaviour: its loss
|
|
@@ -1196,6 +1387,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1196
1387
|
if (method !== 'Page.frameDetached') {
|
|
1197
1388
|
const detached = method === 'Target.detachedFromTarget' ? params.sessionId : sessionId;
|
|
1198
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();
|
|
1199
1397
|
if (arming.has(detached))
|
|
1200
1398
|
failInterception(new CheckoutAttachmentError('closed'));
|
|
1201
1399
|
// Terminating a worker need not emit loadingFailed. Its nested workers
|
|
@@ -1268,105 +1466,41 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1268
1466
|
if (method !== 'Fetch.requestPaused')
|
|
1269
1467
|
return;
|
|
1270
1468
|
const { requestId, request, resourceType, networkId, frameId } = params;
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
if (braintree === 'invalid') {
|
|
1305
|
-
opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
|
|
1306
|
-
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1307
|
-
return;
|
|
1308
|
-
}
|
|
1309
|
-
let stripeStep = null;
|
|
1310
|
-
let stripeStage = 'request_read', stripeUrl = '';
|
|
1311
|
-
try {
|
|
1312
|
-
if (stripeCheckout.isEnabled()) {
|
|
1313
|
-
stripeUrl = request.url;
|
|
1314
|
-
const nativeRequest = { url: stripeUrl, method: request.method,
|
|
1315
|
-
headers: request.headers, body: pausedBody(request) ?? '' };
|
|
1316
|
-
stripeStage = 'classification';
|
|
1317
|
-
stripeStep = stripeCheckout.claim(nativeRequest);
|
|
1318
|
-
}
|
|
1319
|
-
if (stripeStep) {
|
|
1320
|
-
stripeStage = 'readiness';
|
|
1321
|
-
if (!attachmentReady || arming.has(sessionId ?? '__root__'))
|
|
1322
|
-
throw stripeCheckoutReadinessError('attachment_not_ready');
|
|
1323
|
-
if (setupStop.signal.aborted)
|
|
1324
|
-
throw stripeCheckoutReadinessError('cancelled');
|
|
1325
|
-
if (terminal || lifecycle.isBlocked())
|
|
1326
|
-
throw stripeCheckoutReadinessError(lifecycle.isCancelled() ? 'cancelled' : 'checkout_inactive');
|
|
1327
|
-
if (awaitingApproval)
|
|
1328
|
-
throw stripeCheckoutReadinessError('approval_pending');
|
|
1329
|
-
stripeStage = 'document';
|
|
1330
|
-
stripeCheckout.assertDocument(await readDocumentUrl());
|
|
1331
|
-
stripeStage = 'claim';
|
|
1332
|
-
stripeCheckout.assertClaim(stripeStep);
|
|
1333
|
-
stripeStage = 'readiness';
|
|
1334
|
-
if (setupStop.signal.aborted)
|
|
1335
|
-
throw stripeCheckoutReadinessError('cancelled');
|
|
1336
|
-
if (lifecycle.isBlocked())
|
|
1337
|
-
throw stripeCheckoutReadinessError(lifecycle.isCancelled() ? 'cancelled' : 'checkout_inactive');
|
|
1338
|
-
if (stripeStep.phase === 'tokenization') {
|
|
1339
|
-
stripeStage = 'stub_response';
|
|
1340
|
-
const response = stripeStep.response;
|
|
1341
|
-
await cdp.send('Fetch.fulfillRequest', { requestId, responseCode: response.status,
|
|
1342
|
-
responseHeaders: Object.entries(withCorsHeaders(response.headers, corsHeadersFor(request.url, request.headers)))
|
|
1343
|
-
.map(([name, value]) => ({ name, value })),
|
|
1344
|
-
body: Buffer.from(response.body).toString('base64') }, sessionId);
|
|
1345
|
-
opts.onEvent?.({ type: 'checkout_prepared', detail: { processor: 'stripe' } });
|
|
1346
|
-
return;
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
catch (error) {
|
|
1351
|
-
const detail = stripeCheckout.describeRejection(error, stripeUrl, stripeStage, stripeStep?.phase);
|
|
1352
|
-
if (!(error instanceof StripeCheckoutClaimConflictError))
|
|
1353
|
-
stripeCheckout.invalidate();
|
|
1354
|
-
opts.onEvent?.({ type: 'checkout_blocked', detail });
|
|
1355
|
-
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
1356
|
-
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
if (!stripeStep && !opts.vault.isCardRequest(request.url, request.method)) {
|
|
1360
|
-
if (guards.matches(request.url, request.method)) {
|
|
1361
|
-
preparationGate.invalidate('unsupported_checkout');
|
|
1362
|
-
lifecycle.unsupported();
|
|
1363
|
-
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
|
|
1364
|
-
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
1365
|
-
return;
|
|
1366
|
-
}
|
|
1367
|
-
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')
|
|
1368
1502
|
return;
|
|
1369
|
-
|
|
1503
|
+
const stripeStep = preClaim.stripeStep;
|
|
1370
1504
|
if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
|
|
1371
1505
|
opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
|
|
1372
1506
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
@@ -1399,7 +1533,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1399
1533
|
activeRequest.frameId = frameId;
|
|
1400
1534
|
activeRequest.request = request;
|
|
1401
1535
|
activeRequest.sourceSessionId = undefined;
|
|
1402
|
-
opts.onEvent?.({ type: 'card_request_paused', detail: { url:
|
|
1536
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url), ...(resourceType ? { resourceType } : {}),
|
|
1403
1537
|
authorizationId: lifecycle.getState().authorizationId, retry: true } });
|
|
1404
1538
|
activeRequest.attempt.bind({ requestId, sessionId, request });
|
|
1405
1539
|
return;
|
|
@@ -1492,7 +1626,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1492
1626
|
void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
|
|
1493
1627
|
return;
|
|
1494
1628
|
}
|
|
1495
|
-
opts.onEvent?.({ type: 'card_request_paused', detail: { url:
|
|
1629
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
1496
1630
|
lifecycle.begin();
|
|
1497
1631
|
const approvalStartedAt = Date.now();
|
|
1498
1632
|
const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
|
|
@@ -1520,6 +1654,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1520
1654
|
const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
|
|
1521
1655
|
? opts.merchantOrigin : pageOrigin?.startsWith('https:') ? pageOrigin : opts.merchantOrigin);
|
|
1522
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();
|
|
1523
1664
|
const replay = await opts.vault.authorize({
|
|
1524
1665
|
user: opts.user,
|
|
1525
1666
|
merchant: opts.merchant,
|
|
@@ -1537,6 +1678,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1537
1678
|
timeoutMs: opts.timeoutMs,
|
|
1538
1679
|
signal: lifecycle.abort.signal,
|
|
1539
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),
|
|
1540
1684
|
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
1541
1685
|
onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
|
|
1542
1686
|
lifecycle.approvalUrl(url);
|
|
@@ -1601,15 +1745,25 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
1601
1745
|
// still goes out from THIS browser, with its own session, risk data
|
|
1602
1746
|
// and cookies, and only the four ciphertext fields swapped in. Only
|
|
1603
1747
|
// postData rides on the command: no header override, ever (see
|
|
1604
|
-
//
|
|
1605
|
-
|
|
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');
|
|
1606
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
|
+
}
|
|
1607
1761
|
await cdp.send('Fetch.continueRequest', {
|
|
1608
1762
|
requestId: deliverTo,
|
|
1609
1763
|
postData,
|
|
1610
1764
|
}, deliverOn);
|
|
1611
1765
|
attempt.assertLive();
|
|
1612
|
-
opts.onEvent?.({ type: 'authorized', detail:
|
|
1766
|
+
opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url) });
|
|
1613
1767
|
}
|
|
1614
1768
|
else {
|
|
1615
1769
|
// The processor's answer, handed to the page as if the processor had
|
|
@@ -1746,6 +1900,79 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1746
1900
|
return page.url();
|
|
1747
1901
|
};
|
|
1748
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
|
+
});
|
|
1749
1976
|
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
1750
1977
|
// Playwright's own routing, NOT a hand-rolled CDP session.
|
|
1751
1978
|
//
|
|
@@ -1770,6 +1997,10 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1770
1997
|
const retryWait = retryWaitMs(opts.merchantRetryWaitMs);
|
|
1771
1998
|
let activeRequest = null;
|
|
1772
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));
|
|
1773
2004
|
if (activeRequest && activeRequest.request === request && activeRequest.attempt.lose()) {
|
|
1774
2005
|
// The page's own script gave up on its request; the cardholder's
|
|
1775
2006
|
// approval did not (see merchantAttempt). Forget the request's identity
|
|
@@ -1780,11 +2011,15 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1780
2011
|
}
|
|
1781
2012
|
});
|
|
1782
2013
|
page.on?.('close', () => { if (!attachmentReady)
|
|
1783
|
-
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(); });
|
|
1784
2015
|
page.on?.('crash', () => { if (!attachmentReady)
|
|
1785
|
-
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(); });
|
|
1786
2017
|
page.on?.('framenavigated', (frame) => {
|
|
1787
2018
|
if (frame === page.mainFrame?.()) {
|
|
2019
|
+
if (documentRequest) {
|
|
2020
|
+
documentRequest = null;
|
|
2021
|
+
documentSerial += 1;
|
|
2022
|
+
}
|
|
1788
2023
|
preparationGate.invalidate('merchant_document_changed');
|
|
1789
2024
|
mercadoCheckout.invalidate();
|
|
1790
2025
|
if (stripeCheckout.isPrepared()) {
|
|
@@ -1820,97 +2055,48 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1820
2055
|
setupStop.abort(failure);
|
|
1821
2056
|
throw failure;
|
|
1822
2057
|
}
|
|
1823
|
-
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) => {
|
|
1824
2061
|
const request = route.request();
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
stripeStage = 'classification';
|
|
1864
|
-
stripeStep = stripeCheckout.claim(nativeRequest);
|
|
1865
|
-
}
|
|
1866
|
-
if (stripeStep) {
|
|
1867
|
-
stripeStage = 'readiness';
|
|
1868
|
-
if (!attachmentReady)
|
|
1869
|
-
throw stripeCheckoutReadinessError('attachment_not_ready');
|
|
1870
|
-
if (setupStop.signal.aborted)
|
|
1871
|
-
throw stripeCheckoutReadinessError('cancelled');
|
|
1872
|
-
if (terminal || lifecycle.isBlocked())
|
|
1873
|
-
throw stripeCheckoutReadinessError(lifecycle.isCancelled() ? 'cancelled' : 'checkout_inactive');
|
|
1874
|
-
if (awaitingApproval)
|
|
1875
|
-
throw stripeCheckoutReadinessError('approval_pending');
|
|
1876
|
-
stripeStage = 'document';
|
|
1877
|
-
stripeCheckout.assertDocument(await readDocumentUrl());
|
|
1878
|
-
stripeStage = 'claim';
|
|
1879
|
-
stripeCheckout.assertClaim(stripeStep);
|
|
1880
|
-
stripeStage = 'readiness';
|
|
1881
|
-
if (setupStop.signal.aborted)
|
|
1882
|
-
throw stripeCheckoutReadinessError('cancelled');
|
|
1883
|
-
if (lifecycle.isBlocked())
|
|
1884
|
-
throw stripeCheckoutReadinessError(lifecycle.isCancelled() ? 'cancelled' : 'checkout_inactive');
|
|
1885
|
-
if (page.isClosed?.() || request.failure?.())
|
|
1886
|
-
throw stripeCheckoutReadinessError('request_inactive');
|
|
1887
|
-
if (stripeStep.phase === 'tokenization') {
|
|
1888
|
-
stripeStage = 'stub_response';
|
|
1889
|
-
const response = stripeStep.response;
|
|
1890
|
-
await route.fulfill({ ...response,
|
|
1891
|
-
headers: withCorsHeaders(response.headers, corsHeadersFor(request.url(), request.headers())) });
|
|
1892
|
-
opts.onEvent?.({ type: 'checkout_prepared', detail: { processor: 'stripe' } });
|
|
1893
|
-
return;
|
|
1894
|
-
}
|
|
1895
|
-
}
|
|
1896
|
-
}
|
|
1897
|
-
catch (error) {
|
|
1898
|
-
const detail = stripeCheckout.describeRejection(error, stripeUrl, stripeStage, stripeStep?.phase);
|
|
1899
|
-
if (!(error instanceof StripeCheckoutClaimConflictError))
|
|
1900
|
-
stripeCheckout.invalidate();
|
|
1901
|
-
opts.onEvent?.({ type: 'checkout_blocked', detail });
|
|
1902
|
-
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
1903
|
-
return route.abort('aborted');
|
|
1904
|
-
}
|
|
1905
|
-
if (!stripeStep && !opts.vault.isCardRequest(request.url(), request.method())) {
|
|
1906
|
-
if (guards.matches(request.url(), request.method())) {
|
|
1907
|
-
preparationGate.invalidate('unsupported_checkout');
|
|
1908
|
-
lifecycle.unsupported();
|
|
1909
|
-
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
|
|
1910
|
-
return route.abort('aborted');
|
|
1911
|
-
}
|
|
1912
|
-
return route.fallback();
|
|
1913
|
-
}
|
|
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;
|
|
1914
2100
|
if (!attachmentReady || setupStop.signal.aborted) {
|
|
1915
2101
|
opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
|
|
1916
2102
|
return route.abort('aborted');
|
|
@@ -1939,7 +2125,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
1939
2125
|
catch { /* covered by requestfailed */ }
|
|
1940
2126
|
activeRequest.request = request;
|
|
1941
2127
|
activeRequest.frames = retryFrames;
|
|
1942
|
-
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 } });
|
|
1943
2129
|
activeRequest.attempt.bind({ route, request, frames: retryFrames });
|
|
1944
2130
|
return;
|
|
1945
2131
|
}
|
|
@@ -2034,7 +2220,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2034
2220
|
void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
|
|
2035
2221
|
return refused;
|
|
2036
2222
|
}
|
|
2037
|
-
opts.onEvent?.({ type: 'card_request_paused', detail: { url:
|
|
2223
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: eventUrl(opts.vault, request.url()) } });
|
|
2038
2224
|
lifecycle.begin();
|
|
2039
2225
|
const approvalStartedAt = Date.now();
|
|
2040
2226
|
const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
|
|
@@ -2073,6 +2259,8 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2073
2259
|
timeoutMs: opts.timeoutMs,
|
|
2074
2260
|
signal: lifecycle.abort.signal,
|
|
2075
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),
|
|
2076
2264
|
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
2077
2265
|
onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
|
|
2078
2266
|
lifecycle.approvalUrl(url);
|
|
@@ -2119,11 +2307,22 @@ export async function attachToPlaywright(page, opts) {
|
|
|
2119
2307
|
// Same as the CDP path: the request continues from this browser
|
|
2120
2308
|
// with the ciphertext swapped in and no header override; Playwright
|
|
2121
2309
|
// recomputes the length itself.
|
|
2122
|
-
|
|
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);
|
|
2123
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
|
+
}
|
|
2124
2323
|
await deliverTo.continue({ postData });
|
|
2125
2324
|
assertRequestLive();
|
|
2126
|
-
opts.onEvent?.({ type: 'authorized', detail:
|
|
2325
|
+
opts.onEvent?.({ type: 'authorized', detail: cseAuthorizedDetail(replay, deliverRequest.url()) });
|
|
2127
2326
|
}
|
|
2128
2327
|
else {
|
|
2129
2328
|
// Playwright adds these itself when a cross-origin fulfill carries
|