@agent-cards/checkout 0.17.0 → 0.19.0

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