@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/PREFLIGHT.md +4 -0
  3. package/README.md +91 -7
  4. package/dist/adyen-merchant-hosted.generated.d.ts +277 -0
  5. package/dist/adyen-merchant-hosted.generated.js +1902 -0
  6. package/dist/builtin-registry.generated.js +1 -1
  7. package/dist/cdp.d.ts +4 -1
  8. package/dist/cdp.js +416 -217
  9. package/dist/client.d.ts +236 -5
  10. package/dist/client.js +514 -11
  11. package/dist/cse-body.d.ts +25 -0
  12. package/dist/cse-body.js +41 -0
  13. package/dist/fiserv.d.ts +65 -0
  14. package/dist/fiserv.generated.d.ts +73 -0
  15. package/dist/fiserv.generated.js +830 -0
  16. package/dist/fiserv.js +104 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.js +5 -1
  19. package/dist/lifecycle.d.ts +15 -1
  20. package/dist/lifecycle.js +28 -3
  21. package/dist/merchant-handoff.d.ts +54 -0
  22. package/dist/merchant-handoff.js +100 -0
  23. package/dist/merchant-hosted.d.ts +140 -0
  24. package/dist/merchant-hosted.js +170 -0
  25. package/dist/merchant-total-watch.d.ts +115 -0
  26. package/dist/merchant-total-watch.js +268 -0
  27. package/dist/merchant-total.d.ts +257 -0
  28. package/dist/merchant-total.js +383 -0
  29. package/dist/pre-claim.d.ts +123 -0
  30. package/dist/pre-claim.js +386 -0
  31. package/dist/preflight-catalog.json +132 -0
  32. package/dist/preflight-schemas.json +14 -2
  33. package/dist/preflight.generated.js +15 -1
  34. package/dist/preparation.d.ts +7 -0
  35. package/dist/preparation.js +33 -6
  36. package/dist/prepared-processor.d.ts +36 -3
  37. package/dist/prepared-processor.js +53 -3
  38. package/dist/registry.d.ts +45 -0
  39. package/dist/registry.js +14 -0
  40. package/dist/stripe-checkout.generated.js +96 -7
  41. package/dist/substitutions.generated.d.ts +2 -1
  42. package/dist/substitutions.generated.js +758 -6
  43. package/examples/preflight/kernel-native/inventory.json +1 -1
  44. 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, redactUrl, } from './client.js';
2
+ import { AdyenTestPlatformRefusedError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedModeError, } from './client.js';
3
3
  import { PreparationGate } from './preparation.js';
4
- import { classifyBraintreeRequest } from './braintree.js';
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 { substituteEncryptedFields } from './substitute.js';
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
- return err instanceof CardEncryptedError || err instanceof UnsupportedModeError;
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
- * The cse continuation: the paused body with the vault's ciphertext in place
148
- * of the dummy blobs. It is sent as postData ALONE. Chromium recomputes
149
- * Content-Length for a continued request itself and refuses a header override
150
- * that names it (Fetch.continueRequest answers -32602 "Unsafe header"), and
151
- * that refusal would land after the cardholder approved, failing a paid-for
152
- * request. Leaving `headers` off the command keeps every other header the
153
- * browser's own, untouched, which is the point of continuing rather than
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 cseBody(body, replay) {
157
- return substituteEncryptedFields(body, replay.substitutions);
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
- requestSources.delete(requestKey(sessionId, params.requestId));
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
- if (mercadoCheckout.matches(request.url) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
1272
- try {
1273
- if (!attachmentReady || setupStop.signal.aborted || lifecycle.abort.signal.aborted || terminal)
1274
- throw new Error('checkout_interception_not_ready');
1275
- const body = pausedBody(request) ?? '';
1276
- if (await mercadoCheckout.configuration(request.url, request.method, body, readDocumentUrl)) {
1277
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId);
1278
- return;
1279
- }
1280
- const postData = mercadoCheckout.associationBody(request.url, request.method, body, await readDocumentUrl());
1281
- if (postData !== undefined) {
1282
- if (setupStop.signal.aborted || lifecycle.abort.signal.aborted)
1283
- throw new Error('checkout_interception_not_ready');
1284
- await cdp.send('Fetch.continueRequest', { requestId, postData: Buffer.from(postData).toString('base64') }, sessionId);
1285
- return;
1286
- }
1287
- }
1288
- catch {
1289
- mercadoCheckout.invalidate();
1290
- lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'mercado_checkout_continuation_stopped'));
1291
- opts.onEvent?.({ type: 'blocked', detail: 'mercado_checkout_continuation_stopped' });
1292
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1293
- return;
1294
- }
1295
- }
1296
- // Braintree shares /graphql between configuration and card mutations.
1297
- // Classify before URL-only recognition or claiming a prepared request.
1298
- const braintree = request.method.toUpperCase() === 'POST'
1299
- ? classifyBraintreeRequest(request.url, request.method, pausedBody(request)) : undefined;
1300
- if (braintree === 'configuration') {
1301
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1302
- return;
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: redactUrl(request.url), ...(resourceType ? { resourceType } : {}),
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: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
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
- // cseBody for why a recomputed Content-Length is refused by Chromium).
1605
- const postData = Buffer.from(cseBody(deliverBody, replay)).toString('base64');
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: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
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()), async (route) => {
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
- if (mercadoCheckout.matches(request.url()) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
1826
- try {
1827
- if (!attachmentReady || setupStop.signal.aborted || lifecycle.abort.signal.aborted || terminal)
1828
- throw new Error('checkout_interception_not_ready');
1829
- const body = request.postData() ?? '';
1830
- if (await mercadoCheckout.configuration(request.url(), request.method(), body, readDocumentUrl))
1831
- return route.fallback();
1832
- const postData = mercadoCheckout.associationBody(request.url(), request.method(), body, await readDocumentUrl());
1833
- if (postData !== undefined) {
1834
- if (setupStop.signal.aborted || lifecycle.abort.signal.aborted || page.isClosed?.())
1835
- throw new Error('checkout_interception_not_ready');
1836
- await route.continue({ postData });
1837
- return;
1838
- }
1839
- }
1840
- catch {
1841
- mercadoCheckout.invalidate();
1842
- lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'mercado_checkout_continuation_stopped'));
1843
- opts.onEvent?.({ type: 'blocked', detail: 'mercado_checkout_continuation_stopped' });
1844
- return route.abort('aborted');
1845
- }
1846
- }
1847
- const braintree = request.method().toUpperCase() === 'POST'
1848
- ? classifyBraintreeRequest(request.url(), request.method(), request.postData() ?? '') : undefined;
1849
- if (braintree === 'configuration')
1850
- return route.fallback();
1851
- if (braintree === 'invalid') {
1852
- opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
1853
- return route.abort('aborted');
1854
- }
1855
- // The matcher only sees the URL; a preflight or a GET must pass through
1856
- // untouched or the browser's CORS check fails on our synthetic answer.
1857
- let stripeStep = null;
1858
- let stripeStage = 'request_read', stripeUrl = '';
1859
- try {
1860
- if (stripeCheckout.isEnabled()) {
1861
- stripeUrl = request.url();
1862
- const nativeRequest = { url: stripeUrl, method: request.method(), headers: request.headers(), body: request.postData() ?? '' };
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: redactUrl(request.url()), authorizationId: lifecycle.getState().authorizationId, retry: true } });
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: redactUrl(request.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
- const postData = cseBody(deliverBody, replay);
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: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
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