@agent-cards/checkout 0.18.0 → 0.21.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 (88) hide show
  1. package/README.md +6 -669
  2. package/cdp.d.ts +1 -0
  3. package/cdp.js +2 -0
  4. package/index.d.ts +1 -0
  5. package/index.js +2 -0
  6. package/package.json +33 -33
  7. package/playwright.d.ts +1 -0
  8. package/playwright.js +2 -0
  9. package/preflight.d.ts +1 -0
  10. package/preflight.js +2 -0
  11. package/CHANGELOG.md +0 -124
  12. package/PREFLIGHT.md +0 -308
  13. package/dist/adyen.generated.d.ts +0 -24
  14. package/dist/adyen.generated.js +0 -64
  15. package/dist/attachment.d.ts +0 -11
  16. package/dist/attachment.js +0 -50
  17. package/dist/braintree.d.ts +0 -2
  18. package/dist/braintree.generated.d.ts +0 -10
  19. package/dist/braintree.generated.js +0 -302
  20. package/dist/braintree.js +0 -2
  21. package/dist/builtin-registry.generated.d.ts +0 -2
  22. package/dist/builtin-registry.generated.js +0 -1
  23. package/dist/card-fields.generated.d.ts +0 -3
  24. package/dist/card-fields.generated.js +0 -46
  25. package/dist/cdp.d.ts +0 -189
  26. package/dist/cdp.js +0 -2194
  27. package/dist/checkout-com.generated.d.ts +0 -4
  28. package/dist/checkout-com.generated.js +0 -183
  29. package/dist/client.d.ts +0 -618
  30. package/dist/client.js +0 -1251
  31. package/dist/hosted-form.d.ts +0 -44
  32. package/dist/hosted-form.js +0 -78
  33. package/dist/index.d.ts +0 -13
  34. package/dist/index.js +0 -6
  35. package/dist/lifecycle.d.ts +0 -165
  36. package/dist/lifecycle.js +0 -370
  37. package/dist/mercado-checkout.d.ts +0 -20
  38. package/dist/mercado-checkout.generated.d.ts +0 -52
  39. package/dist/mercado-checkout.generated.js +0 -198
  40. package/dist/mercado-checkout.js +0 -108
  41. package/dist/owned-shop.generated.d.ts +0 -24
  42. package/dist/owned-shop.generated.js +0 -108
  43. package/dist/paysafe.generated.d.ts +0 -12
  44. package/dist/paysafe.generated.js +0 -87
  45. package/dist/playwright.d.ts +0 -3
  46. package/dist/playwright.js +0 -3
  47. package/dist/preflight-capabilities.generated.d.ts +0 -1253
  48. package/dist/preflight-capabilities.generated.js +0 -1929
  49. package/dist/preflight-catalog.json +0 -4595
  50. package/dist/preflight-playwright.d.ts +0 -34
  51. package/dist/preflight-playwright.js +0 -355
  52. package/dist/preflight-schemas.json +0 -1110
  53. package/dist/preflight.d.ts +0 -1
  54. package/dist/preflight.generated.d.ts +0 -1965
  55. package/dist/preflight.generated.js +0 -556
  56. package/dist/preflight.js +0 -2
  57. package/dist/preparation.d.ts +0 -31
  58. package/dist/preparation.js +0 -164
  59. package/dist/prepared-processor.d.ts +0 -10
  60. package/dist/prepared-processor.js +0 -122
  61. package/dist/recurly.generated.d.ts +0 -1
  62. package/dist/recurly.generated.js +0 -87
  63. package/dist/registry.d.ts +0 -76
  64. package/dist/registry.js +0 -296
  65. package/dist/spreedly.generated.d.ts +0 -10
  66. package/dist/spreedly.generated.js +0 -332
  67. package/dist/stripe-checkout.d.ts +0 -81
  68. package/dist/stripe-checkout.generated.d.ts +0 -82
  69. package/dist/stripe-checkout.generated.js +0 -1004
  70. package/dist/stripe-checkout.js +0 -140
  71. package/dist/substitute.d.ts +0 -38
  72. package/dist/substitute.js +0 -23
  73. package/dist/substitutions.generated.d.ts +0 -10
  74. package/dist/substitutions.generated.js +0 -66
  75. package/examples/existing-browser.mjs +0 -63
  76. package/examples/preflight/classify-direct.mjs +0 -21
  77. package/examples/preflight/classify-kernel.mjs +0 -30
  78. package/examples/preflight/inspect-browser.mjs +0 -44
  79. package/examples/preflight/kernel-native/README.md +0 -112
  80. package/examples/preflight/kernel-native/documented-adapters.json +0 -113
  81. package/examples/preflight/kernel-native/inventory.json +0 -233
  82. package/examples/preflight/kernel-native/qualification.mjs +0 -182
  83. package/examples/preflight/kernel-profile.empty.json +0 -11
  84. package/examples/preflight/mollie-hosted.observations.json +0 -23
  85. package/examples/preflight/mollie-hosted.result.json +0 -103
  86. package/examples/preflight/stripe-script.direct.result.json +0 -92
  87. package/examples/preflight/stripe-script.observations.json +0 -16
  88. package/examples/preflight/stripe-script.result.json +0 -87
package/dist/cdp.js DELETED
@@ -1,2194 +0,0 @@
1
- import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
- import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, ProcessorRefusedError, UnsupportedModeError, redactUrl, } from './client.js';
3
- import { PreparationGate } from './preparation.js';
4
- import { classifyBraintreeRequest } from './braintree.js';
5
- import { StripeCheckoutGate, StripeCheckoutClaimConflictError, stripeCheckoutReadinessError } from './stripe-checkout.js';
6
- import { MercadoCheckoutGate } from './mercado-checkout.js';
7
- import { MERCADO_CHECKOUT_PATTERN } from './mercado-checkout.generated.js';
8
- import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
9
- import { substituteEncryptedFields } from './substitute.js';
10
- import { hostedFormSubmittedPage } from './hosted-form.js';
11
- import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
12
- // Every URL that leaves this module through onEvent is redacted to origin +
13
- // path first. A paused PaymentIntent confirm can carry the client secret in
14
- // its query string (Stripe.js puts it in the body; hand-rolled runtimes and
15
- // some SDKs put it in the URL), and onEvent is ordinary integrator telemetry:
16
- // logs, dashboards, crash reporters. None of those may receive a secret.
17
- /**
18
- * How long a hosted form that the cardholder already submitted stays refused
19
- * when the page posts it again.
20
- *
21
- * A hosted_form approval resolves the paused navigation with a synthetic page
22
- * (hostedFormSubmittedPage), and the merchant page may still carry the same
23
- * card form: an agent that clicks Pay again would pause an identical
24
- * navigation and, without this, raise a second prompt on the household's
25
- * device for a payment that already left it. The API's duplicate guard covers
26
- * a body the form regenerated (same supplier, session and amount inside 15
27
- * minutes: 409 duplicate_submission, which the adapters quiet the way they
28
- * quiet a decline, see isApprovalOutcome); this latch covers the
29
- * byte-identical re-post without a round trip. Same window as the
30
- * authorization's own TTL. Overridable per attach via `hostedFormRepeatQuietMs`.
31
- */
32
- const HOSTED_FORM_REPEAT_QUIET_MS = 15 * 60_000;
33
- /** The same form the device already submitted, posted again inside the quiet window. */
34
- function isRepeatOfSubmitted(last, url, body, quietMs) {
35
- return !!last && last.url === url && last.body === body && Date.now() - last.at < quietMs;
36
- }
37
- const HOSTED_FORM_REPEAT_REASON = 'already submitted on the cardholder\'s device';
38
- function targetRecord(value) {
39
- return value !== null && typeof value === 'object' && !Array.isArray(value);
40
- }
41
- function contextId(value) {
42
- return typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\u0000-\u0020\u007f]/.test(value);
43
- }
44
- /** Dedicated worker requests use their owning frame's Fetch interception.
45
- * Shared/service workers do not have that ownership guarantee. Refuse their
46
- * browser context, including newly discovered targets, before approving a card.
47
- * A different, explicitly identified context belongs to another checkout.
48
- */
49
- function assertContextTarget(target, merchantContext) {
50
- if (!targetRecord(target))
51
- throw new CheckoutAttachmentError('unavailable');
52
- if (contextId(target.browserContextId) && target.browserContextId !== merchantContext)
53
- return;
54
- if (!['page', 'iframe', 'worker', 'browser_ui'].includes(target.type)
55
- || (target.browserContextId !== undefined && !contextId(target.browserContextId))) {
56
- throw new CheckoutAttachmentError('unavailable');
57
- }
58
- }
59
- /**
60
- * How long to stop asking after a person declines or ignores an approval.
61
- *
62
- * This exists because the two failure shapes look identical at the route
63
- * handler and need opposite answers. A merchant page re-issues an aborted
64
- * tokenization within milliseconds, and re-prompting on each of those turns
65
- * one decline into a queue of notifications on someone's device. A genuinely
66
- * later checkout also arrives as a paused request, and that one deserves a
67
- * fresh prompt, so a permanent latch is wrong too.
68
- *
69
- * Elapsed time is what separates them, and nothing else available here does:
70
- * body, nonce and authorization id all differ between an automatic retry and a
71
- * new checkout. So a decline buys a short silence, not a closed door.
72
- *
73
- * Kept deliberately SHORT. A page's automatic retry lands in milliseconds — the
74
- * storm this exists for ran at roughly ten a second — so a few seconds absorbs
75
- * a burst with room to spare, while leaving almost no window in which a person
76
- * could start a genuinely new checkout and be turned away. Raise it only with
77
- * evidence of a slower retry loop; every second added is a second a real
78
- * checkout can be refused. Overridable per attach via `approvalCooldownMs`.
79
- */
80
- const APPROVAL_COOLDOWN_MS = 5_000;
81
- /**
82
- * One approval at a time, per attachment.
83
- *
84
- * The cooldown only arms once a failure RESOLVES, so two requests paused
85
- * before the first authorize() returns both sail past it and each raise their
86
- * own prompt. A checkout with several card iframes does exactly that. Nobody
87
- * should get two notifications asking them to approve the same purchase, so a
88
- * request arriving while one is outstanding is failed rather than queued: the
89
- * page's own retry brings it back, and by then there is an answer.
90
- */
91
- /**
92
- * This one authorization was answered; see APPROVAL_COOLDOWN_MS. An
93
- * AmountMismatchError is an ApprovalDeclinedError, so both amount checks land
94
- * here: the pre-replay one (the row IS declined) and the create-time one (no
95
- * row; the intent already disagreed). Either way the paused request is
96
- * aborted with nothing charged and the page's retry is quieted, NOT latched:
97
- * Stripe lets the merchant update an intent's amount until it is confirmed,
98
- * so the next request on this page is a new question and is judged afresh.
99
- * IntentNotConfirmableError lands here too (the row is declined).
100
- *
101
- * A 409 duplicate_submission is an answer of the same kind: the household
102
- * already has, or already answered, the prompt for this exact submission (a
103
- * hosted form the page posted again with a fresh nonce). Quieting the page's
104
- * re-post is right; latching the whole attachment is not, because the prior
105
- * row declines or expires and the same form is then a new question. The
106
- * quiet window absorbs the burst; the next request past it is judged afresh.
107
- */
108
- function isApprovalOutcome(err) {
109
- // Processor refusals are held by the lifecycle until explicit merchant
110
- // reconciliation and retry. A second timer would outlive that release and
111
- // reject the application's deliberate retry even after confirmed failure.
112
- if (err instanceof ProcessorRefusedError)
113
- return false;
114
- if (err instanceof CheckoutApiError)
115
- return err.code === 'duplicate_submission';
116
- return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
117
- }
118
- /**
119
- * Should this failure stop us intercepting for the rest of the page's life?
120
- *
121
- * It matters because a failed tokenization does not end the checkout: the
122
- * merchant's own page retries, we pause the retry, we fail it again, and the
123
- * loop runs as fast as the page will go. A real run against Shopify with a
124
- * stale user id produced ~10 authorization calls a SECOND for fifteen minutes
125
- * until the agent's host killed it, every one of them a request to our API that
126
- * could never have succeeded.
127
- *
128
- * Terminal means "will answer identically next time no matter who does what":
129
- * a misconfiguration or an unsupported PSP. Nothing a person does changes
130
- * those, so asking again is pure waste.
131
- *
132
- * Other errors may be retryable unless the lifecycle holds the attachment
133
- * for merchant reconciliation, as it does after a processor refusal.
134
- * A 5xx or a 429 clears on its own, and a
135
- * decline or a timeout is answered by the cooldown above rather than by
136
- * killing the page: the person said no to one authorization, not to every
137
- * checkout they will ever make in this session. A 409 duplicate_submission
138
- * is not `permanent` on the error itself (see CheckoutApiError) and lands in
139
- * the cooldown too.
140
- */
141
- function isTerminal(err) {
142
- if (err instanceof CheckoutApiError)
143
- return err.permanent;
144
- return err instanceof CardEncryptedError || err instanceof UnsupportedModeError;
145
- }
146
- /**
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.
155
- */
156
- function cseBody(body, replay) {
157
- return substituteEncryptedFields(body, replay.substitutions);
158
- }
159
- /**
160
- * The paused request's body, or null when Chromium says there is one and did
161
- * not hand it over. `postData` is set only for a text body; a binary or a
162
- * large one arrives as base64 `postDataEntries` instead, concatenated here
163
- * byte for byte (an entry without `bytes` is a file the browser streams, which
164
- * no runtime can read back). There is no CDP command to fetch it later:
165
- * `Fetch.getRequestPostData` does not exist (Chrome answers -32601), so a
166
- * body that is not on the event is a body this adapter cannot see.
167
- */
168
- function pausedBody(request) {
169
- if (typeof request.postData === 'string' && request.postData !== '')
170
- return request.postData;
171
- if (!request.hasPostData)
172
- return request.postData ?? '';
173
- const entries = request.postDataEntries ?? [];
174
- if (!entries.length || entries.some((e) => typeof e.bytes !== 'string'))
175
- return null;
176
- return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
177
- }
178
- const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
179
- /**
180
- * The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
181
- * request is same-origin (or carries no Origin, so no CORS check applies).
182
- *
183
- * The browser checks a fulfilled response exactly as it checks a real one. A
184
- * page that fetches a processor on another origin therefore needs
185
- * `access-control-allow-origin` on the synthetic answer, or its fetch rejects
186
- * with "Failed to fetch" and the page never sees the processor's reply, even
187
- * though the cardholder approved and the processor answered the vault.
188
- * Shopify never hit this on its current host: the checkout.pci.shopifyinc.com
189
- * card iframe posts to its own origin (its older deposit.<region>.shopifycs.com
190
- * host is called from the checkout.shopifycs.com frame, cross-origin, and gets
191
- * the answer like everyone else). Stripe hits it on every surface (Checkout on
192
- * checkout.stripe.com or a merchant domain, and Elements in the js.stripe.com
193
- * frame, all call api.stripe.com), and so does every other processor whose
194
- * card frame calls a separate API host. Observed live on
195
- * 2026-09-03: the vault replayed a Stripe PaymentMethod into a raw-CDP
196
- * runtime, the browser refused the answer for want of this header, and
197
- * Stripe Checkout showed "We are experiencing connection issues".
198
- *
199
- * The exact Origin is echoed rather than `*`: a credentialed request refuses
200
- * `*`, the echo satisfies both. The processor's own value can never reach
201
- * this adapter (a browser does not expose that header to the page that
202
- * replayed the call), so whatever the replay carries under these names is
203
- * replaced by the one value that is right for THIS request. Playwright adds
204
- * the same headers inside route.fulfill when a cross-origin fulfill carries
205
- * none (microsoft/playwright#12929), which is why attachToPlaywright never
206
- * needed this; it writes them itself anyway, replacing a stale value, so both
207
- * adapters answer the vault's replays identically.
208
- *
209
- * This widens nothing. A tokenization endpoint is built for anonymous
210
- * browsers and answers every origin (`access-control-allow-origin: *` on
211
- * Stripe's and Shopify's own replies), so the page that made the request
212
- * could always read the processor's answer to it; the replay is made exactly
213
- * as visible, to exactly that page. Whether a card goes anywhere at all is
214
- * decided by the cardholder on the approval screen, never by this header.
215
- *
216
- * Only what a browser serializes is ever echoed: one canonical http(s)
217
- * origin (`new URL(origin).origin === origin`), or the opaque `null` a
218
- * sandboxed or data: document sends, which Chrome matches against
219
- * `access-control-allow-origin: null` and which Playwright echoes too. That
220
- * refuses userinfo, a path, an explicit default port, several origins in one
221
- * value, or a control character that would break the fulfill after the
222
- * cardholder already approved. Anything refused simply gets no CORS answer,
223
- * which is what every fulfill got before this existed.
224
- */
225
- export function corsHeadersFor(url, requestHeaders) {
226
- return corsDecision(url, requestHeaders).headers;
227
- }
228
- /** The CORS answer and its reason: `none` when no usable Origin was sent (or the url is not http(s)), `same_origin` when no check applies. */
229
- export function corsDecision(url, requestHeaders) {
230
- const none = { headers: null, outcome: 'none' };
231
- const originEntry = Object.entries(requestHeaders ?? {}).find(([name]) => name.toLowerCase() === 'origin');
232
- const origin = typeof originEntry?.[1] === 'string' ? originEntry[1].trim() : '';
233
- if (!origin)
234
- return none;
235
- let target;
236
- try {
237
- target = new URL(url);
238
- }
239
- catch {
240
- return none;
241
- }
242
- if (target.protocol !== 'https:' && target.protocol !== 'http:')
243
- return none;
244
- if (origin !== 'null') {
245
- let originUrl;
246
- try {
247
- originUrl = new URL(origin);
248
- }
249
- catch {
250
- return none;
251
- }
252
- if (originUrl.protocol !== 'https:' && originUrl.protocol !== 'http:')
253
- return none;
254
- if (originUrl.origin !== origin)
255
- return none;
256
- if (target.origin === origin)
257
- return { headers: null, outcome: 'same_origin' };
258
- }
259
- return { headers: { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true' }, outcome: 'echoed' };
260
- }
261
- /**
262
- * `headers` with the CORS answer for this request written in; the object
263
- * itself when none is needed. Any header the answer names is replaced
264
- * whatever its case, so a name is never sent twice. The answer varies by
265
- * Origin, and a `vary` the replay already carries is extended rather than
266
- * replaced (or left alone when it already covers Origin or is `*`); a `vary`
267
- * the answer itself names is taken as given.
268
- */
269
- export function withCorsHeaders(headers, cors) {
270
- if (!cors)
271
- return headers;
272
- const replaced = new Set(Object.keys(cors).map((name) => name.toLowerCase()));
273
- const out = {};
274
- let varyName = null;
275
- for (const [name, value] of Object.entries(headers)) {
276
- const lower = name.toLowerCase();
277
- if (replaced.has(lower))
278
- continue;
279
- if (lower === 'vary')
280
- varyName = name;
281
- out[name] = value;
282
- }
283
- Object.assign(out, cors);
284
- if (replaced.has('vary'))
285
- return out;
286
- if (varyName === null) {
287
- out.vary = 'Origin';
288
- }
289
- else {
290
- const existing = String(out[varyName]);
291
- const members = existing.split(',').map((m) => m.trim().toLowerCase());
292
- if (!members.includes('origin') && !members.includes('*'))
293
- out[varyName] = `${existing}, Origin`;
294
- }
295
- return out;
296
- }
297
- /** CDP's header shape: `{ name, value }` entries. */
298
- function headerEntries(headers) {
299
- return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
300
- }
301
- function readPayToInterceptMs(readClickedAt, approvalStartedAt) {
302
- try {
303
- const clickedAt = readClickedAt?.();
304
- return typeof clickedAt === 'number' && Number.isFinite(clickedAt) && clickedAt <= approvalStartedAt
305
- ? Math.round(approvalStartedAt - clickedAt)
306
- : undefined;
307
- }
308
- catch {
309
- return undefined;
310
- }
311
- }
312
- /**
313
- * The origin of the top-level document the payment form is on, read when a
314
- * card request pauses: the fact that names the merchant for the company's
315
- * presets (the API's checkout_origin). An https origin, or http://localhost
316
- * for local checkout; anything else, or a page that cannot be read, yields
317
- * undefined and the authorization goes out without one (a merchant, category
318
- * or place rule then refuses it as unknown, never the request itself).
319
- */
320
- function pageOriginFrom(documentUrl) {
321
- if (typeof documentUrl !== 'string')
322
- return undefined;
323
- try {
324
- const url = new URL(documentUrl);
325
- if (url.username || url.password)
326
- return undefined;
327
- if (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))
328
- return url.origin;
329
- return undefined;
330
- }
331
- catch {
332
- return undefined;
333
- }
334
- }
335
- /**
336
- * The top-level document's URL, read once with a one-second bound when a card
337
- * request pauses: pageOriginFrom derives the merchant origin from it, and the
338
- * retry rule (sameCheckoutRequest) compares it. Undefined when the page
339
- * cannot be read, the attempt was stopped, or the read was late; the
340
- * authorization then goes out without a page origin and no retry can bind.
341
- */
342
- async function documentUrlOf(readDocumentUrl, signal) {
343
- let timer;
344
- let abort;
345
- try {
346
- const documentUrl = await Promise.race([
347
- Promise.resolve().then(readDocumentUrl).catch(() => undefined),
348
- new Promise((resolve) => {
349
- timer = setTimeout(resolve, 1_000);
350
- abort = () => resolve(undefined);
351
- signal.addEventListener('abort', abort, { once: true });
352
- if (signal.aborted)
353
- abort();
354
- }),
355
- ]);
356
- return typeof documentUrl === 'string' ? documentUrl : undefined;
357
- }
358
- catch {
359
- return undefined;
360
- }
361
- finally {
362
- if (timer)
363
- clearTimeout(timer);
364
- if (abort)
365
- signal.removeEventListener('abort', abort);
366
- }
367
- }
368
- /**
369
- * The page total, the lowest amount authority, through the integrator's own
370
- * reader (AttachOptions.pageAmount): an integer in the smallest unit with its
371
- * currency, or nothing. A read is a courtesy, never a wait: it gets one
372
- * second, and anything unreadable yields undefined so the authorization goes
373
- * out without a page total.
374
- */
375
- const PAGE_AMOUNT_READ_MS = 1_000;
376
- async function pageAmountOf(opts) {
377
- if (!opts.pageAmount)
378
- return undefined;
379
- let timer;
380
- try {
381
- const raw = await Promise.race([
382
- Promise.resolve().then(() => opts.pageAmount()).catch(() => undefined),
383
- new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), PAGE_AMOUNT_READ_MS); }),
384
- ]);
385
- if (!raw || typeof raw !== 'object')
386
- return undefined;
387
- const { amount, currency } = raw;
388
- if (!Number.isSafeInteger(amount) || amount < 0 || typeof currency !== 'string' || !/^[A-Za-z]{3}$/.test(currency))
389
- return undefined;
390
- return { amount: amount, currency: currency.toLowerCase() };
391
- }
392
- catch {
393
- return undefined;
394
- }
395
- finally {
396
- if (timer)
397
- clearTimeout(timer);
398
- }
399
- }
400
- /**
401
- * Last-resort patterns: the built-in recognizers' hosts, derived the same way
402
- * as everything else. Used only when the vault hands back nothing at all (a
403
- * duck-typed client from an older SDK, or an empty registry) — a hardcoded list
404
- * that drifts from the registry is exactly the bug this adapter used to have.
405
- */
406
- const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
407
- /** Observer exceptions and server error envelopes must not interrupt or leak a processor handoff. */
408
- function safeOptions(opts) {
409
- const observer = opts.onEvent;
410
- return { ...opts, onEvent: (event) => { try {
411
- Promise.resolve(observer?.(event)).catch(() => { });
412
- }
413
- catch { /* observer only */ } } };
414
- }
415
- function failureSummary(error) {
416
- if (error instanceof CheckoutApiError)
417
- return `${error.name}: ${error.code ?? `http_${error.status}`}`;
418
- return error instanceof Error ? error.name : 'CheckoutError';
419
- }
420
- const STRIPE_PAYMENT_INTENT_CONFIRM = /^\/v1\/payment_intents\/pi_[A-Za-z0-9]+\/confirm$/;
421
- /**
422
- * A page that tokenized the card through an approval, then confirms the
423
- * payment in the browser with the token it got back (confirmCardPayment with
424
- * payment_method: 'pm_...', confirmPayment with a confirmation token). That
425
- * confirmation carries no card, and the hold after a Stripe tokenization
426
- * would refuse it. The API decides instead whether it is the payment the
427
- * cardholder approved (the approved amount and currency, on the same Stripe
428
- * account, paid with exactly the approved token).
429
- *
430
- * Synchronous on purpose: every paused request passes through here, and an
431
- * await would reorder it against the attachment's own events. Returns the
432
- * approval to check, with the lifecycle's one continuation claimed, or null
433
- * when this is not such a confirmation and the ordinary rules apply.
434
- */
435
- function claimStripeContinuation(opts, lifecycle, url, method, body) {
436
- if (body == null || typeof opts.vault.checkStripeContinuation !== 'function' || !lifecycle.isBlocked()
437
- || method.toUpperCase() !== 'POST')
438
- return null;
439
- let parsed;
440
- try {
441
- parsed = new URL(url);
442
- }
443
- catch {
444
- return null;
445
- }
446
- if (parsed.origin !== 'https://api.stripe.com' || !STRIPE_PAYMENT_INTENT_CONFIRM.test(parsed.pathname)
447
- || opts.vault.withoutCard?.(url, body) !== 'refuse')
448
- return null;
449
- return lifecycle.claimStripeContinuation();
450
- }
451
- /** Ask the API about a claimed continuation and settle the claim. True: continue the page's request untouched. */
452
- async function checkStripeContinuation(opts, lifecycle, authorizationId, request) {
453
- let paymentIntentId = null;
454
- try {
455
- paymentIntentId = (await opts.vault.checkStripeContinuation(authorizationId, request)).paymentIntentId;
456
- }
457
- catch (error) {
458
- opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
459
- }
460
- // The checkout may have been cancelled or reconciled while the API answered.
461
- const proceed = lifecycle.stripeContinuationChecked(paymentIntentId !== null);
462
- if (proceed)
463
- opts.onEvent?.({ type: 'stripe_payment_continued', detail: { authorizationId, paymentIntentId } });
464
- else if (paymentIntentId !== null)
465
- opts.onEvent?.({ type: 'blocked', detail: 'stripe_continuation_superseded' });
466
- return proceed;
467
- }
468
- /**
469
- * How long an approval waits, after the cardholder gives it, for the page to
470
- * issue the request it will answer.
471
- *
472
- * This only matters when the page's own script abandoned its card request
473
- * while the person was deciding (Braintree's client gives up after 60
474
- * seconds, Square's after about 10) and did not ask again on its own. The
475
- * approval is real: the device sent the card to the processor and a token
476
- * came back, or encrypted it for the agent's browser. What is missing is a
477
- * request to hand it to, and the application supplies one by clicking Pay
478
- * again (the controller reads `ready_to_submit` with reason
479
- * `awaiting_merchant_retry` while this wait runs). Two minutes is a first
480
- * value, chosen rather than measured: long enough for an agent loop to read
481
- * the state and act, short enough that a minted token never sits usable for
482
- * the rest of the approval window. The approval window itself is the ceiling,
483
- * whatever this is set to. Overridable per attach via `merchantRetryWaitMs`;
484
- * the benchmark's retry gaps are what should replace it.
485
- */
486
- const MERCHANT_RETRY_WAIT_MS = 2 * 60_000;
487
- function retryWaitMs(value) {
488
- if (value === undefined)
489
- return MERCHANT_RETRY_WAIT_MS;
490
- if (!Number.isSafeInteger(value) || value <= 0 || value > 15 * 60_000) {
491
- throw new Error('merchantRetryWaitMs must be an integer between 1 and 900000.');
492
- }
493
- return value;
494
- }
495
- /** The page never asked again inside the retry wait; see merchantAttempt.awaitTarget. */
496
- class MerchantNeverRetried extends Error {
497
- authorizationId;
498
- constructor(authorizationId) {
499
- super('the merchant page never asked again');
500
- this.authorizationId = authorizationId;
501
- this.name = 'MerchantNeverRetried';
502
- }
503
- }
504
- /** A body field that names the purchase's amount or currency, wherever a processor puts it. */
505
- const AMOUNT_KEY = /^(amount|amount_?cents|total|sum|currency|currency_?code)$/i;
506
- /**
507
- * The amount and currency a body names, as `path=value` pairs: Adyen's
508
- * `amount.value` and `amount.currency`, Razorpay's and Nuvei's `amount`
509
- * and `currency`, Paysafe's `amount` and `currencyCode`, Tranzila's `sum`.
510
- * A pure tokenization (Braintree, Square, Shopify, a Stripe PaymentMethod)
511
- * names none and yields an empty list.
512
- */
513
- function bodyAmounts(value, prefix, parentKey, out) {
514
- if (Array.isArray(value)) {
515
- for (const item of value)
516
- bodyAmounts(item, `${prefix}[]`, parentKey, out);
517
- return;
518
- }
519
- if (value === null || typeof value !== 'object') {
520
- const key = prefix.split('.').pop() ?? '';
521
- if (AMOUNT_KEY.test(key) || (key === 'value' && AMOUNT_KEY.test(parentKey)))
522
- out.push(`${prefix}=${String(value)}`);
523
- return;
524
- }
525
- const own = prefix.split('.').pop() ?? '';
526
- for (const [key, child] of Object.entries(value))
527
- bodyAmounts(child, prefix ? `${prefix}.${key}` : key, own, out);
528
- }
529
- /** The document a request came from, as compared between a request and its retry. */
530
- function documentKey(url) {
531
- if (typeof url !== 'string')
532
- return undefined;
533
- try {
534
- const parsed = new URL(url);
535
- parsed.hash = '';
536
- return parsed.href;
537
- }
538
- catch {
539
- return undefined;
540
- }
541
- }
542
- /** Luhn, on a run of digits: the placeholder card the agent typed passes it, most other long numbers do not. */
543
- function luhn(digits) {
544
- let sum = 0;
545
- for (let i = 0; i < digits.length; i++) {
546
- let d = digits.charCodeAt(digits.length - 1 - i) - 48;
547
- if (i % 2 === 1) {
548
- d *= 2;
549
- if (d > 9)
550
- d -= 9;
551
- }
552
- sum += d;
553
- }
554
- return sum % 10 === 0;
555
- }
556
- /**
557
- * The card-placeholder runs in a body: every 13 to 19 digit run that passes
558
- * Luhn, sorted. The placeholder the agent typed (4242 4242 4242 4242, 4111
559
- * 1111 1111 1111) passes; a millisecond timestamp or an order number fails
560
- * nine times in ten, and when one passes it merely makes the match stricter.
561
- * A client-side-encrypted body (Adyen) carries no digits at all and yields
562
- * an empty list on both sides.
563
- */
564
- function cardRuns(body) {
565
- return (body.match(/\d{13,19}/g) ?? []).filter(luhn).sort();
566
- }
567
- /** Every key path in a JSON value, arrays flattened so an element's order or count does not count. */
568
- function jsonKeyPaths(value, prefix, out) {
569
- if (Array.isArray(value)) {
570
- for (const item of value)
571
- jsonKeyPaths(item, `${prefix}[]`, out);
572
- return;
573
- }
574
- if (value === null || typeof value !== 'object') {
575
- if (prefix)
576
- out.add(prefix);
577
- return;
578
- }
579
- for (const [key, child] of Object.entries(value))
580
- jsonKeyPaths(child, prefix ? `${prefix}.${key}` : key, out);
581
- }
582
- /**
583
- * The shape of a body plus its card placeholder, as a string two bodies can
584
- * be compared by. JSON: its sorted key paths and its card runs, so a body the
585
- * client regenerated (a new session id, a new nonce, a fresh encryption of
586
- * the same dummy card) still matches while a different operation, a saved
587
- * card or another card does not. Form-encoded: its sorted keys and its card
588
- * runs, so Stripe's per-attempt `time_on_page` and fraud ids do not break a
589
- * match but a different field set does. Anything else: the body itself.
590
- */
591
- function bodyShape(body) {
592
- const runs = cardRuns(body).join(',');
593
- const trimmed = body.trimStart();
594
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
595
- try {
596
- const parsed = JSON.parse(body);
597
- const paths = new Set();
598
- jsonKeyPaths(parsed, '', paths);
599
- const amounts = [];
600
- bodyAmounts(parsed, '', '', amounts);
601
- return { shape: `json:${[...paths].sort().join('|')};cards:${runs};amounts:${amounts.sort().join('|')}`, namesAmount: amounts.length > 0 };
602
- }
603
- catch { /* not JSON after all */ }
604
- }
605
- if (/^[^=&\s]+=[^&]*(&[^=&\s]+=[^&]*)*$/.test(body)) {
606
- const params = new URLSearchParams(body);
607
- const keys = [...new Set([...params.keys()])].sort();
608
- const amounts = [...params.entries()].filter(([key]) => AMOUNT_KEY.test(key)).map(([key, value]) => `${key}=${value}`).sort();
609
- return { shape: `form:${keys.join('|')};cards:${runs};amounts:${amounts.join('|')}`, namesAmount: amounts.length > 0 };
610
- }
611
- return { shape: `raw:${body}`, namesAmount: false };
612
- }
613
- /** The identity of a request from its parts; see CheckoutRequestIdentity. */
614
- function requestIdentity(opts, url, method, body, documentUrl, pageAmount) {
615
- const shaped = bodyShape(body);
616
- return { url, method, documentUrl, pageAmount, body: shaped.shape,
617
- amountEvidence: opts.amount != null || pageAmount !== undefined || shaped.namesAmount };
618
- }
619
- /**
620
- * May this request be answered from the approval the other one raised?
621
- *
622
- * The plan this implements says the rule starts strict and loosens per
623
- * processor with evidence, so it is strict: the same processor endpoint (the
624
- * full URL, query included), the same method, the same top-level document,
625
- * the same page total when the integrator reads one, and a body of the same
626
- * shape carrying the same card placeholder and the same amount when the
627
- * request names one. A request from another page, to another endpoint, for
628
- * another card, for another amount or with another set of fields is a new
629
- * question and is refused here, which leaves it to the ordinary "an approval
630
- * is already outstanding" path.
631
- *
632
- * An amount has to be in evidence somewhere: the integrator's hint (what the
633
- * cardholder was shown), the page total, or the request's own bytes. A
634
- * tokenization request names no amount, so on a checkout with no hint and no
635
- * page reader nothing could tell a retry for the approved cart from one for
636
- * a changed cart, and the approval is not reused. With a page reader, a
637
- * changed total refuses the retry; the hint is the integrator's statement of
638
- * the purchase, and the approval was given for it.
639
- */
640
- function sameCheckoutRequest(first, retry) {
641
- return first.amountEvidence && first.url === retry.url && first.method.toUpperCase() === retry.method.toUpperCase()
642
- && first.documentUrl !== undefined && first.documentUrl === retry.documentUrl
643
- && (first.pageAmount?.amount === retry.pageAmount?.amount && first.pageAmount?.currency === retry.pageAmount?.currency)
644
- && first.body === retry.body;
645
- }
646
- /**
647
- * Whether an approval may outlive the page's own request on this checkout.
648
- *
649
- * Yes for a plain tokenization or an encrypted-card request: the device's
650
- * answer is a token or ciphertext, and any request for the same purchase can
651
- * carry it. No for a hosted form (a navigation: there is no request to
652
- * answer once the page has moved on), a prepared checkout (single use, bound
653
- * to one native request by design), a native Stripe Checkout step, and a
654
- * Mercado Pago request whose claim is bound to one document read. Unknown
655
- * modes (a duck-typed vault without checkoutModeOf) are taken as `token`.
656
- */
657
- function survivesAbandonment(opts, url, method, navigation, bound) {
658
- if (navigation || bound)
659
- return false;
660
- const mode = typeof opts.vault?.checkoutModeOf === 'function' ? opts.vault.checkoutModeOf(url, method) : 'token';
661
- return mode === 'token' || mode === 'cse';
662
- }
663
- /**
664
- * One card request's attempt at an approval, and the browser request that
665
- * currently carries it. Separate from explicit cancellation: the client can
666
- * drain a late create ID.
667
- *
668
- * The request and the approval have different lives. The page's own script
669
- * can abandon its request (Braintree's client gives up after 60 seconds,
670
- * Square's after about 10) while the cardholder is still deciding on their
671
- * phone; nothing about the checkout changed, the page asked and stopped
672
- * listening. lose() records that: the approval keeps polling, the page's next
673
- * request for the same purchase binds through bind() and is answered from the
674
- * same authorization, and awaitTarget() is how the approval finds the request
675
- * to answer, waiting for a retry when none is live. stop() is for the page
676
- * itself going away (navigation of a prepared document, frame removal, close,
677
- * crash), a request lost once delivery began, or an attempt that cannot
678
- * survive abandonment at all: the approval cannot be reopened and the client
679
- * cancels it.
680
- */
681
- function merchantAttempt(lifecycle, survives) {
682
- const controller = new AbortController();
683
- let reason = 'merchant_request_aborted';
684
- let target = null;
685
- let lost = false;
686
- let rebound = false;
687
- let delivering = false;
688
- let bound = null;
689
- const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
690
- const attempt = {
691
- signal: controller.signal,
692
- /** The browser request that currently carries this attempt; null while the page has none. */
693
- get target() { return target; },
694
- /** The page abandoned its request and has not asked again yet. */
695
- get lost() { return lost; },
696
- bind(next) { target = next; rebound = lost; lost = false; bound?.(); },
697
- /** Delivery to the current target has begun; a loss from here on is an unknown outcome, never a retry. */
698
- beginDelivery() { delivering = true; },
699
- /** The page abandoned the current request. True when the approval survives it, false when the attempt stopped. */
700
- lose() {
701
- if (controller.signal.aborted)
702
- return false;
703
- if (!survives || delivering) {
704
- attempt.stop();
705
- return false;
706
- }
707
- target = null;
708
- lost = true;
709
- lifecycle.merchantRequestLost();
710
- return true;
711
- },
712
- stop(cause = 'merchant_request_aborted') {
713
- if (controller.signal.aborted)
714
- return;
715
- reason = cause;
716
- if (cause === 'merchant_request_aborted')
717
- lifecycle.merchantRequestAborted();
718
- else
719
- lifecycle.failed(error());
720
- controller.abort(error());
721
- },
722
- assertLive() { if (controller.signal.aborted)
723
- throw error(); },
724
- /**
725
- * The request to answer: the live one at once, or the page's retry,
726
- * waited for up to `waitMs`. Resolves `rebound` when the answer goes to
727
- * a request other than the one that raised the approval, so the caller
728
- * can re-run request attribution on it. Rejects with MerchantNeverRetried
729
- * when the wait ends with no request, and with the attempt's own error
730
- * when the page goes away meanwhile.
731
- */
732
- async awaitTarget(waitMs, stopSignals, onWaiting) {
733
- attempt.assertLive();
734
- if (target && !lost)
735
- return { target, rebound };
736
- onWaiting();
737
- const signals = [controller.signal, ...stopSignals];
738
- let timer;
739
- let onAbort = () => { };
740
- try {
741
- await new Promise((resolve, reject) => {
742
- bound = resolve;
743
- onAbort = () => { try {
744
- attempt.assertLive();
745
- reject(new Error('checkout cancelled locally before the merchant asked again'));
746
- }
747
- catch (stopped) {
748
- reject(stopped);
749
- } };
750
- for (const signal of signals)
751
- signal.addEventListener('abort', onAbort, { once: true });
752
- if (signals.some((signal) => signal.aborted))
753
- onAbort();
754
- timer = setTimeout(() => reject(new MerchantNeverRetried(lifecycle.getState().authorizationId)), waitMs);
755
- });
756
- }
757
- finally {
758
- bound = null;
759
- clearTimeout(timer);
760
- for (const signal of signals)
761
- signal.removeEventListener('abort', onAbort);
762
- }
763
- attempt.assertLive();
764
- if (!target)
765
- throw new MerchantNeverRetried(lifecycle.getState().authorizationId);
766
- return { target, rebound };
767
- },
768
- };
769
- return attempt;
770
- }
771
- /**
772
- * The retry wait, capped by what is left of the approval window: a token
773
- * must never sit usable past the moment the row itself would have expired.
774
- */
775
- function boundedRetryWait(waitMs, approvalStartedAt, timeoutMs) {
776
- const windowEnd = approvalStartedAt + (timeoutMs ?? 15 * 60_000);
777
- return Math.max(0, Math.min(waitMs, windowEnd - Date.now()));
778
- }
779
- /**
780
- * Retire an approval the page never asked for again, and tell the lifecycle
781
- * what became of it. A confirmed retirement ends as `declined` with reason
782
- * `merchant_never_retried` (the cardholder's screen says the merchant did
783
- * not finish and nothing was charged, and the next card request is a new
784
- * question); anything else is an unknown outcome the application must
785
- * reconcile, which holds the attachment. A vault without cancelAuthorization
786
- * (a duck-typed client) cannot retire anything and lands there too.
787
- */
788
- async function retireUnusedApproval(opts, lifecycle, authorizationId) {
789
- const cancel = typeof opts.vault?.cancelAuthorization === 'function' && authorizationId
790
- ? opts.vault.cancelAuthorization(authorizationId, 'merchant_never_retried') : Promise.reject(new Error('no authorization to retire'));
791
- try {
792
- await cancel;
793
- lifecycle.merchantNeverRetried(authorizationId);
794
- opts.onEvent?.({ type: 'failed', detail: 'merchant_never_retried' });
795
- }
796
- catch {
797
- lifecycle.failed(new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed'), true);
798
- opts.onEvent?.({ type: 'failed', detail: 'PaymentOutcomeUnknownError' });
799
- }
800
- }
801
- /**
802
- * Take over card tokenization for a page.
803
- *
804
- * IMPORTANT: card fields render in cross-origin iframes, which are separate CDP
805
- * targets. Enabling Fetch on the page session alone will never see the
806
- * tokenization request. This attaches recursively so every nested target is
807
- * armed, which is the whole reason this adapter exists.
808
- *
809
- * The patterns armed here come from the REGISTRY, not from a constant, so a PSP
810
- * the API knows about is paused without an SDK release — call
811
- * `vault.syncRegistry()` before attaching and every recognizer the server
812
- * serves is covered. They are a coarse pre-filter and are deliberately wider
813
- * than the recognizers (see cardUrlPatterns): each paused request is re-checked
814
- * with `isCardRequest` below and continued untouched unless it is an exact
815
- * match. Patterns are resolved once, at attach, so every nested target ends up
816
- * armed identically.
817
- */
818
- export async function attachToCdp(cdp, pageSessionId, opts) {
819
- opts = safeOptions(opts);
820
- const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
821
- const setupStop = new AbortController();
822
- let attachmentReady = false;
823
- let setupFailureReported = false;
824
- const ownedSessions = new Set([pageSessionId]);
825
- const sessionTypes = new Map([[pageSessionId, 'page']]);
826
- const sessionParents = new Map();
827
- const detachedSessions = new Set();
828
- const rejectedSessions = new Set();
829
- const workerOwners = new Map();
830
- const ownersWithWorkers = new Set();
831
- // Network IDs are scoped to their emitting session. Retired records are
832
- // separate from live requests, but still disqualify an indistinguishable
833
- // delayed Fetch event; deleting that evidence could approve a lost request.
834
- const requestSources = new Map();
835
- const retiredSources = new Map();
836
- const retiredCounts = new Map();
837
- // Keep exact evidence for the first 1,024 failures per owner. Later failures
838
- // enter a fixed 256 KiB Bloom filter with seven positions: a membership hit
839
- // refuses that Network ID, never the whole frame merely for reaching a cap.
840
- // Fetch lacks the emitter session, so compact evidence uses owner + Network
841
- // ID; exact records below still use session + ID. False positives may refuse
842
- // a request, but a failed identity is never forgotten or treated as live.
843
- // The filters are controller-local and monotonic. They are not rotated or
844
- // cleared on worker detach; arbitrary late Network/Fetch delivery is allowed.
845
- // A sufficiently long history can saturate a filter and reduce availability.
846
- const retiredSummaries = new Map();
847
- const summaryBytes = 256 * 1024;
848
- const summaryPositions = (id) => {
849
- const positions = [];
850
- for (let seed = 1; seed <= 7; seed++) {
851
- let hash = 0x811c9dc5 ^ Math.imul(seed, 0x9e3779b9);
852
- for (let i = 0; i < id.length; i++)
853
- hash = Math.imul(hash ^ id.charCodeAt(i), 0x01000193);
854
- hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b);
855
- hash = Math.imul(hash ^ (hash >>> 13), 0xc2b2ae35);
856
- positions.push((hash ^ (hash >>> 16)) & (summaryBytes * 8 - 1));
857
- }
858
- return positions;
859
- };
860
- const hasRetiredSummary = (owner, id) => {
861
- const bits = retiredSummaries.get(owner);
862
- return !!bits && summaryPositions(id).every(bit => (bits[bit >>> 3] & (1 << (bit & 7))) !== 0);
863
- };
864
- const requestKey = (sessionId, id) => JSON.stringify([sessionId, id]);
865
- const sessionDetached = (sessionId) => {
866
- while (sessionId) {
867
- if (detachedSessions.has(sessionId))
868
- return true;
869
- sessionId = sessionParents.get(sessionId);
870
- }
871
- return false;
872
- };
873
- let merchantContext;
874
- let targetContextKnown = false;
875
- const lifecycle = new CheckoutLifecycle(opts);
876
- const stripeCheckout = new StripeCheckoutGate(opts);
877
- const mercadoCheckout = new MercadoCheckoutGate();
878
- let preparationFrameId;
879
- const readDocumentUrl = async () => {
880
- const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
881
- if (typeof tree?.frameTree?.frame?.url !== 'string')
882
- throw new Error('merchant_document_unavailable');
883
- preparationFrameId = tree.frameTree.frame.id;
884
- return tree.frameTree.frame.url;
885
- };
886
- const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
887
- const guards = paymentEndpointGuards(opts.paymentEndpoints);
888
- const armed = new Set();
889
- const arming = new Map();
890
- // Set once a failure proves that retrying cannot help; see isTerminal.
891
- let terminal = null;
892
- // Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
893
- const cooldownMs = opts.approvalCooldownMs ?? APPROVAL_COOLDOWN_MS;
894
- let quietUntil = 0;
895
- // One outstanding approval at a time; see the note above isApprovalOutcome.
896
- let awaitingApproval = false;
897
- // How long an approval waits for the page to ask again; see MERCHANT_RETRY_WAIT_MS.
898
- const retryWait = retryWaitMs(opts.merchantRetryWaitMs);
899
- let activeRequest = null;
900
- const sourceOwner = (source) => workerOwners.get(source.sessionId) ?? source.sessionId;
901
- const retireSource = (source) => {
902
- const owner = sourceOwner(source), key = requestKey(source.sessionId, source.networkId);
903
- const count = (retiredCounts.get(owner) ?? 0) + (retiredSources.has(key) ? 0 : 1);
904
- // Compact overflow without dropping failed identities or turning unrelated
905
- // background failures into a permanent refusal of every request in a frame.
906
- if (count > 1024) {
907
- let bits = retiredSummaries.get(owner);
908
- if (!bits) {
909
- bits = new Uint8Array(summaryBytes);
910
- retiredSummaries.set(owner, bits);
911
- }
912
- for (const bit of summaryPositions(source.networkId))
913
- bits[bit >>> 3] |= 1 << (bit & 7);
914
- if (activeRequest?.sessionId === owner && hasRetiredSummary(owner, activeRequest.networkId)) {
915
- activeRequest.attempt.stop('browser_interception_unavailable');
916
- }
917
- return;
918
- }
919
- retiredCounts.set(owner, count);
920
- retiredSources.set(key, source);
921
- };
922
- const identifyRequest = (request) => {
923
- if (request.sessionId && hasRetiredSummary(request.sessionId, request.networkId)) {
924
- request.attempt.stop('browser_interception_unavailable');
925
- request.attempt.assertLive();
926
- }
927
- const matches = (source) => {
928
- const metadata = source.request;
929
- return sourceOwner(source) === request.sessionId
930
- && source.networkId === request.networkId && (!metadata
931
- || (metadata.url === request.request.url && metadata.method === request.request.method
932
- // A missing body is unknown, not evidence of a different request.
933
- && !(typeof metadata.postData === 'string' && typeof request.request.postData === 'string'
934
- && metadata.postData !== request.request.postData)));
935
- };
936
- const candidates = [...requestSources.values(), ...retiredSources.values()].filter(matches);
937
- if (!candidates.length)
938
- return false;
939
- // A failure can arrive before its Network request description. Do not
940
- // ignore that possible source and assign its Fetch to a live sibling.
941
- if (candidates.some(source => !source.request)) {
942
- if (request.sourceSessionId) {
943
- request.attempt.stop('browser_interception_unavailable');
944
- request.attempt.assertLive();
945
- }
946
- return false;
947
- }
948
- if (candidates.length !== 1) {
949
- request.attempt.stop('browser_interception_unavailable');
950
- request.attempt.assertLive();
951
- }
952
- const source = candidates[0];
953
- if (request.sourceSessionId && request.sourceSessionId !== source.sessionId) {
954
- request.attempt.stop('browser_interception_unavailable');
955
- }
956
- request.sourceSessionId = source.sessionId;
957
- if (sessionDetached(source.sessionId) || retiredSources.has(requestKey(source.sessionId, source.networkId)))
958
- request.attempt.stop();
959
- request.attempt.assertLive();
960
- return true;
961
- };
962
- /** A frame's Fetch event does not identify which dedicated worker sent it.
963
- * Require the exact Network event before asking the Vault whenever that frame
964
- * has had workers. A sibling's detach proves nothing about an unknown request;
965
- * missing attribution instead expires without releasing any card or token.
966
- * Keep detached ancestry so a late event cannot revive a lost worker request.
967
- */
968
- const awaitRequestSource = async (request) => {
969
- if (identifyRequest(request))
970
- return;
971
- if (!request.sessionId || !ownersWithWorkers.has(request.sessionId)) {
972
- if (request.sessionId && retiredSources.has(requestKey(request.sessionId, request.networkId))) {
973
- request.attempt.stop();
974
- request.attempt.assertLive();
975
- }
976
- request.sourceSessionId = request.sessionId;
977
- return;
978
- }
979
- let timer;
980
- let changed = () => { };
981
- const signals = [request.attempt.signal, lifecycle.abort.signal, setupStop.signal];
982
- try {
983
- await new Promise((resolve, reject) => {
984
- changed = () => {
985
- try {
986
- request.attempt.assertLive();
987
- if (lifecycle.isCancelled())
988
- throw new Error('checkout cancelled locally before approval');
989
- if (setupStop.signal.aborted)
990
- throw new PaymentOutcomeUnknownError(null, 'browser_interception_unavailable');
991
- if (identifyRequest(request))
992
- resolve();
993
- }
994
- catch (error) {
995
- reject(error);
996
- }
997
- };
998
- request.attributionChanged = changed;
999
- for (const signal of signals)
1000
- signal.addEventListener('abort', changed, { once: true });
1001
- timer = setTimeout(() => reject(new PaymentOutcomeUnknownError(null, 'browser_interception_unavailable')), setupTimeoutMs);
1002
- changed();
1003
- });
1004
- }
1005
- finally {
1006
- clearTimeout(timer);
1007
- for (const signal of signals)
1008
- signal.removeEventListener('abort', changed);
1009
- delete request.attributionChanged;
1010
- }
1011
- };
1012
- // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
1013
- const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
1014
- let lastSubmitted = null;
1015
- const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
1016
- const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
1017
- ...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN])];
1018
- const arm = async (sessionId, resume = false, worker = false) => {
1019
- const key = sessionId ?? '__root__';
1020
- if (armed.has(key))
1021
- return;
1022
- if (arming.has(key))
1023
- return arming.get(key);
1024
- // Fetch's interception ID differs from Network's ID. Enable failure events
1025
- // before intercepting and bind each attempt to both its ID and CDP session.
1026
- const pending = withinAttachmentDeadline(async (assertDeadline) => {
1027
- const assertActive = () => {
1028
- assertDeadline();
1029
- if (sessionId && detachedSessions.has(sessionId))
1030
- throw new CheckoutAttachmentError('closed');
1031
- };
1032
- await cdp.send('Network.enable', {}, sessionId);
1033
- assertActive();
1034
- // Worker Fetch does not exist in Chrome. Its requests pause on the owning
1035
- // frame, but its abort/finish events arrive on the worker Network session.
1036
- // Keep that session attached and paused until Network is listening, then
1037
- // resume it only after its nested workers can be attached recursively.
1038
- if (!worker) {
1039
- await cdp.send('Page.enable', {}, sessionId).catch(() => { });
1040
- assertActive();
1041
- await cdp.send('Fetch.enable', {
1042
- patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
1043
- }, sessionId);
1044
- assertActive();
1045
- }
1046
- // Descend into this target's own children (iframes inside iframes).
1047
- await cdp.send('Target.setAutoAttach', {
1048
- autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
1049
- }, sessionId);
1050
- assertActive();
1051
- // A child remains part of setup until Chrome acknowledges its resume.
1052
- // Deduplicate this command with arming, and bound the acknowledgement too.
1053
- if (resume) {
1054
- await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId);
1055
- assertActive();
1056
- }
1057
- }, setupTimeoutMs, setupStop.signal);
1058
- arming.set(key, pending);
1059
- try {
1060
- await pending;
1061
- armed.add(key);
1062
- }
1063
- catch (error) {
1064
- const failure = attachmentFailure(error);
1065
- setupStop.abort(failure);
1066
- throw failure;
1067
- }
1068
- finally {
1069
- arming.delete(key);
1070
- }
1071
- };
1072
- const failInterception = (error) => {
1073
- if (setupFailureReported)
1074
- return;
1075
- setupFailureReported = true;
1076
- setupStop.abort(attachmentFailure(error));
1077
- terminal = new Error('browser_interception_unavailable');
1078
- // A new target changes future interception, not the result of an earlier
1079
- // attempt. Preserve a settled result or handoff while still refusing new
1080
- // requests. Approval/preparation in progress must instead be retired.
1081
- const status = lifecycle.getState().status;
1082
- if (status === 'completed' || (!activeRequest && !['idle', 'awaiting_approval', 'ready_to_submit'].includes(status)))
1083
- return;
1084
- activeRequest?.attempt.stop();
1085
- preparationGate.invalidate('browser_interception_unavailable');
1086
- lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
1087
- opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
1088
- };
1089
- cdp.on(async (method, params, sessionId) => {
1090
- if ((method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') && targetContextKnown) {
1091
- try {
1092
- assertContextTarget(params?.targetInfo, merchantContext);
1093
- }
1094
- catch (error) {
1095
- failInterception(error);
1096
- }
1097
- return;
1098
- }
1099
- if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
1100
- preparationGate.invalidate('merchant_document_changed');
1101
- mercadoCheckout.invalidate();
1102
- if (stripeCheckout.isPrepared()) {
1103
- stripeCheckout.invalidate();
1104
- activeRequest?.attempt.stop();
1105
- }
1106
- if (preparationGate.isEngaged())
1107
- activeRequest?.attempt.stop();
1108
- // The page that abandoned its request has moved to another document:
1109
- // its approval cannot be answered there and is cancelled. A reload of
1110
- // the same document is not that; its re-issued request is the retry.
1111
- if (method === 'Page.frameNavigated' && activeRequest?.attempt.lost
1112
- && documentKey(params.frame?.url) !== activeRequest.identity.documentUrl)
1113
- activeRequest.attempt.stop();
1114
- return;
1115
- }
1116
- if ((method === 'Inspector.detached' && sessionId === pageSessionId)
1117
- || (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
1118
- detachedSessions.add(pageSessionId);
1119
- setupStop.abort(new CheckoutAttachmentError('closed'));
1120
- preparationGate.invalidate('merchant_document_closed');
1121
- stripeCheckout.invalidate();
1122
- mercadoCheckout.invalidate();
1123
- // The root page owns every attached OOPIF; its loss ends child requests too.
1124
- activeRequest?.attempt.stop();
1125
- }
1126
- // The transport is browser-scoped. A second tab's request or child target
1127
- // must never be authorized with this page's merchant, amount or preparation.
1128
- if (!sessionId || !ownedSessions.has(sessionId))
1129
- return;
1130
- if (method === 'Network.requestWillBeSent') {
1131
- const owner = workerOwners.get(sessionId) ?? sessionId;
1132
- const matchesActive = activeRequest?.sessionId === owner && activeRequest.networkId === params.requestId;
1133
- // Only identification may consume a late event from a detached target.
1134
- // Its Fetch events, children and commands remain permanently ignored.
1135
- if (sessionDetached(sessionId) && !matchesActive)
1136
- return;
1137
- const key = requestKey(sessionId, params.requestId);
1138
- const record = { sessionId, networkId: params.requestId, request: params.request };
1139
- if (sessionDetached(sessionId) || retiredSources.has(key) || hasRetiredSummary(owner, params.requestId))
1140
- retireSource(record);
1141
- else
1142
- requestSources.set(key, record);
1143
- if (matchesActive) {
1144
- try {
1145
- identifyRequest(activeRequest);
1146
- }
1147
- catch { /* stop already retires the exact attempt */ }
1148
- activeRequest.attributionChanged?.();
1149
- }
1150
- return;
1151
- }
1152
- if (sessionDetached(sessionId))
1153
- return;
1154
- if (method === 'Network.loadingFinished') {
1155
- requestSources.delete(requestKey(sessionId, params.requestId));
1156
- return;
1157
- }
1158
- if (method === 'Network.loadingFailed') {
1159
- if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId) {
1160
- const dead = { requestId: activeRequest.requestId, sessionId: activeRequest.sessionId, request: activeRequest.request };
1161
- // A request a dedicated worker sent keeps today's behaviour: its loss
1162
- // stops the attempt. A worker's retry pauses on the owning frame and
1163
- // is attributed only through the worker's own Network events, and
1164
- // binding an approval across that attribution is not something this
1165
- // change claims to get right; the observed failure is page script.
1166
- const fromWorker = sessionId !== activeRequest.sessionId;
1167
- if (fromWorker ? (activeRequest.attempt.stop(), false) : activeRequest.attempt.lose()) {
1168
- // The page's own script gave up on its request; the cardholder's
1169
- // approval did not (see merchantAttempt). Release the dead
1170
- // interception and forget the request's identity, so no later event
1171
- // on it can pass for the retry's.
1172
- activeRequest.networkId = '';
1173
- activeRequest.frameId = undefined;
1174
- activeRequest.sourceSessionId = undefined;
1175
- opts.onEvent?.({ type: 'merchant_request_lost', detail: { authorizationId: lifecycle.getState().authorizationId } });
1176
- cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: 'Aborted' }, dead.sessionId).catch(() => { });
1177
- }
1178
- }
1179
- const key = requestKey(sessionId, params.requestId);
1180
- retireSource(requestSources.get(key) ?? { sessionId, networkId: params.requestId });
1181
- requestSources.delete(key);
1182
- if (activeRequest?.sessionId === (workerOwners.get(sessionId) ?? sessionId) && activeRequest.networkId === params.requestId) {
1183
- try {
1184
- identifyRequest(activeRequest);
1185
- }
1186
- catch { /* its exact origin is unavailable */ }
1187
- activeRequest.attributionChanged?.();
1188
- }
1189
- return;
1190
- }
1191
- if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
1192
- if (activeRequest && (method === 'Target.detachedFromTarget' ? activeRequest.sessionId === params.sessionId
1193
- : method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
1194
- : activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
1195
- activeRequest.attempt.stop();
1196
- if (method !== 'Page.frameDetached') {
1197
- const detached = method === 'Target.detachedFromTarget' ? params.sessionId : sessionId;
1198
- detachedSessions.add(detached);
1199
- if (arming.has(detached))
1200
- failInterception(new CheckoutAttachmentError('closed'));
1201
- // Terminating a worker need not emit loadingFailed. Its nested workers
1202
- // also lose their request. An unknown request waits for attribution;
1203
- // detaching an unrelated sibling must never cancel the card approval.
1204
- const source = activeRequest?.sourceSessionId;
1205
- if (activeRequest && (sessionDetached(activeRequest.sessionId) || (source && sessionDetached(source))))
1206
- activeRequest.attempt.stop();
1207
- for (const [key, record] of requestSources)
1208
- if (sessionDetached(record.sessionId)) {
1209
- retireSource(record);
1210
- requestSources.delete(key);
1211
- }
1212
- }
1213
- return;
1214
- }
1215
- if (method === 'Target.attachedToTarget') {
1216
- const child = params.sessionId;
1217
- if (rejectedSessions.has(child))
1218
- return;
1219
- // A browser-root transport can report another context through an owned
1220
- // parent session. Release its debugger wait without enrolling it in this
1221
- // checkout. Remember the exclusion before awaiting cleanup: a duplicate
1222
- // event with omitted context metadata must never acquire ownership.
1223
- const childContext = params.targetInfo?.browserContextId;
1224
- if (contextId(childContext) && childContext !== merchantContext) {
1225
- if (typeof child !== 'string' || !child || child === pageSessionId
1226
- || ownedSessions.has(child) || detachedSessions.has(child))
1227
- return;
1228
- rejectedSessions.add(child);
1229
- // Owned setup may already have stopped. Foreign cleanup has its own
1230
- // deadline so rejection cannot freeze an unrelated frame or worker.
1231
- const cleanupStop = new AbortController();
1232
- const cleanup = (method, params, session) => withinAttachmentDeadline(async () => {
1233
- await cdp.send(method, params, session);
1234
- }, setupTimeoutMs, cleanupStop.signal);
1235
- await cleanup('Runtime.runIfWaitingForDebugger', {}, child).catch(() => cleanup('Target.detachFromTarget', { sessionId: child }, sessionId).catch(() => { }));
1236
- return;
1237
- }
1238
- if (setupStop.signal.aborted)
1239
- return;
1240
- // CDP may omit the context ID for ordinary children; keep those attached.
1241
- try {
1242
- if (typeof child !== 'string' || !child || !['page', 'iframe', 'worker'].includes(params.targetInfo?.type)) {
1243
- throw new CheckoutAttachmentError('unavailable');
1244
- }
1245
- if (child === pageSessionId || detachedSessions.has(child) || (ownedSessions.has(child)
1246
- && (sessionParents.get(child) !== sessionId || sessionTypes.get(child) !== params.targetInfo.type))) {
1247
- throw new CheckoutAttachmentError('unavailable');
1248
- }
1249
- ownedSessions.add(child);
1250
- sessionTypes.set(child, params.targetInfo.type);
1251
- sessionParents.set(child, sessionId);
1252
- if (params.targetInfo.type === 'worker') {
1253
- const owner = workerOwners.get(sessionId) ?? sessionId;
1254
- workerOwners.set(child, owner);
1255
- ownersWithWorkers.add(owner);
1256
- }
1257
- await arm(child, true, params.targetInfo.type === 'worker');
1258
- }
1259
- catch (error) {
1260
- // Shared child setup and sibling cancellation can reject several
1261
- // handlers together. Publish the terminal setup failure only once.
1262
- failInterception(error);
1263
- // Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
1264
- return;
1265
- }
1266
- return;
1267
- }
1268
- if (method !== 'Fetch.requestPaused')
1269
- return;
1270
- 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(() => { });
1368
- return;
1369
- }
1370
- if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
1371
- opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
1372
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1373
- return;
1374
- }
1375
- let preparation;
1376
- try {
1377
- preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request), request.headers);
1378
- }
1379
- catch (error) {
1380
- opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
1381
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1382
- return;
1383
- }
1384
- // The page asking again for the purchase an outstanding approval is for:
1385
- // its own request timed out while the cardholder decides (see
1386
- // merchantAttempt). Bind it to that approval instead of refusing it or
1387
- // raising a second prompt. A request that is not the same purchase falls
1388
- // through to the ordinary refusal below.
1389
- if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()
1390
- && typeof networkId === 'string' && networkId) {
1391
- const retryBody = pausedBody(request);
1392
- const retry = retryBody === null ? null : requestIdentity(opts, request.url, request.method, retryBody, documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
1393
- if (retry && activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
1394
- if (preparation)
1395
- preparationGate.retireUnboundClaim();
1396
- activeRequest.requestId = requestId;
1397
- activeRequest.networkId = networkId;
1398
- activeRequest.sessionId = sessionId;
1399
- activeRequest.frameId = frameId;
1400
- activeRequest.request = request;
1401
- activeRequest.sourceSessionId = undefined;
1402
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}),
1403
- authorizationId: lifecycle.getState().authorizationId, retry: true } });
1404
- activeRequest.attempt.bind({ requestId, sessionId, request });
1405
- return;
1406
- }
1407
- }
1408
- // A request the catalog marks as routine without a card (a confirmation
1409
- // token for a saved method, a bank source, a Checkout Session confirm with
1410
- // the method hosted Checkout created) was never ours: it continues in
1411
- // every state, like a request the catalog does not recognize. One without
1412
- // a card anywhere else is judged after the holds below.
1413
- const continuationBody = pausedBody(request);
1414
- const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url, continuationBody) ?? null;
1415
- if (withoutCard === 'continue') {
1416
- if (preparation)
1417
- preparationGate.retireUnboundClaim();
1418
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1419
- return;
1420
- }
1421
- // Not gated on awaitingApproval: the page can send this confirmation while
1422
- // the token's own delivery is still being acknowledged. The lifecycle
1423
- // opens the claim only once a Stripe tokenization is being handed over.
1424
- const continuationId = stripeStep || terminal ? null
1425
- : claimStripeContinuation(opts, lifecycle, request.url, request.method, continuationBody);
1426
- if (continuationId) {
1427
- if (preparation)
1428
- preparationGate.retireUnboundClaim();
1429
- if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url, method: request.method, headers: request.headers, body: continuationBody }) && !setupStop.signal.aborted) {
1430
- await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
1431
- return;
1432
- }
1433
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1434
- return;
1435
- }
1436
- // Same stop condition as the Playwright adapter: once a failure proves
1437
- // retrying is pointless, fail the request without calling the API again.
1438
- if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
1439
- let repeatedAuthorizationId = null;
1440
- if (lastSubmitted) {
1441
- const body = pausedBody(request);
1442
- if (body !== null && isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
1443
- repeatedAuthorizationId = lastSubmitted.authorizationId;
1444
- }
1445
- }
1446
- const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
1447
- opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
1448
- if (preparation)
1449
- preparationGate.retireUnboundClaim();
1450
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1451
- if (repeatedAuthorizationId)
1452
- void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
1453
- return;
1454
- }
1455
- // A recognized request that carries no card is not a card request: a
1456
- // Stripe confirmation paying with a method the page created itself. It is
1457
- // judged only after the holds above, so a request that would reuse an
1458
- // approved token is still refused there (card-fields.js requestWithoutCard).
1459
- if (withoutCard === 'refuse') {
1460
- if (preparation)
1461
- preparationGate.retireUnboundClaim();
1462
- opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
1463
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1464
- return;
1465
- }
1466
- // Reserve BEFORE the first await (the authorize call below yields), so two
1467
- // requests can never both clear the check above and raise two prompts for
1468
- // one checkout.
1469
- awaitingApproval = true;
1470
- const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url, request.method, resourceType === 'Document', !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url, request.method)));
1471
- let handoffStarted = false;
1472
- // The approval the device gave, and whether it reached a browser request.
1473
- let approvedId = null;
1474
- let deliveryBegun = false;
1475
- try {
1476
- const body = pausedBody(request);
1477
- if (body === null) {
1478
- // Fail closed, but only THIS request: an unreadable body says nothing
1479
- // about the page's configuration, so the next request is judged
1480
- // afresh (no latch, no cooldown).
1481
- opts.onEvent?.({ type: 'failed', detail: BODY_UNREADABLE_REASON });
1482
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1483
- return;
1484
- }
1485
- // The body is needed to tell a re-post of the form the cardholder
1486
- // already submitted from a new one, so this check sits after the read
1487
- // and before any API call: no second prompt, no round trip.
1488
- if (isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
1489
- opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
1490
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1491
- if (lastSubmitted)
1492
- void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
1493
- return;
1494
- }
1495
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
1496
- lifecycle.begin();
1497
- const approvalStartedAt = Date.now();
1498
- const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
1499
- if (typeof networkId !== 'string' || !networkId) {
1500
- attempt.stop();
1501
- attempt.assertLive();
1502
- }
1503
- attempt.bind({ requestId, sessionId, request });
1504
- activeRequest = { attempt, requestId, networkId, sessionId, frameId, request,
1505
- identity: requestIdentity(opts, request.url, request.method, body, undefined, undefined) };
1506
- await awaitRequestSource(activeRequest);
1507
- if (preparation)
1508
- await preparationGate.assertDocument();
1509
- // One bounded read of the document serves both the merchant origin and
1510
- // the identity a retry has to repeat (see sameCheckoutRequest).
1511
- const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
1512
- const pageOrigin = pageOriginFrom(documentUrl);
1513
- const pageAmount = await pageAmountOf(opts);
1514
- attempt.assertLive();
1515
- activeRequest.identity.documentUrl = documentKey(documentUrl);
1516
- activeRequest.identity.pageAmount = pageAmount;
1517
- activeRequest.identity.amountEvidence ||= pageAmount !== undefined;
1518
- if (stripeStep)
1519
- stripeCheckout.assertClaim(stripeStep);
1520
- const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
1521
- ? opts.merchantOrigin : pageOrigin?.startsWith('https:') ? pageOrigin : opts.merchantOrigin);
1522
- attempt.assertLive();
1523
- const replay = await opts.vault.authorize({
1524
- user: opts.user,
1525
- merchant: opts.merchant,
1526
- amount: opts.amount,
1527
- currency: opts.currency,
1528
- cardId: preparation?.cardId ?? opts.cardId,
1529
- executionMode: opts.executionMode,
1530
- grantId: opts.grantId,
1531
- stripeCheckoutEnvironment: opts.stripeCheckout?.environment,
1532
- merchantOrigin,
1533
- pageOrigin,
1534
- pageAmount,
1535
- ...(payToInterceptMs !== undefined ? { payToInterceptMs } : {}),
1536
- preparation,
1537
- timeoutMs: opts.timeoutMs,
1538
- signal: lifecycle.abort.signal,
1539
- merchantSignal: attempt.signal,
1540
- onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
1541
- onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
1542
- lifecycle.approvalUrl(url);
1543
- return opts.onApprovalUrl?.(url);
1544
- } },
1545
- request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url, method: request.method, headers: request.headers, body,
1546
- ...(mercadoCheckout.requiresDocument(request.url, request.method)
1547
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}) },
1548
- });
1549
- attempt.assertLive();
1550
- approvedId = replay.authorizationId;
1551
- if (lifecycle.isCancelled())
1552
- throw new Error('checkout cancelled locally after approval');
1553
- try {
1554
- await mercadoCheckout.arm(replay, request.url);
1555
- }
1556
- catch {
1557
- throw new PaymentOutcomeUnknownError(replay.authorizationId, 'mercado_checkout_metadata_invalid');
1558
- }
1559
- attempt.assertLive();
1560
- // The request to answer: the one that raised the approval while it is
1561
- // still paused, or the page's retry when the page gave up on the first
1562
- // (waited for, bounded; see merchantAttempt.awaitTarget). A retry is
1563
- // attributed to its frame or worker exactly as the first request was.
1564
- const delivery = await attempt.awaitTarget(boundedRetryWait(retryWait, approvalStartedAt, opts.timeoutMs), [lifecycle.abort.signal, setupStop.signal], () => {
1565
- lifecycle.awaitingMerchantRetry(replay.authorizationId);
1566
- opts.onEvent?.({ type: 'approval_awaiting_merchant_retry', detail: { authorizationId: replay.authorizationId } });
1567
- });
1568
- if (delivery.rebound) {
1569
- await awaitRequestSource(activeRequest);
1570
- attempt.assertLive();
1571
- }
1572
- const { requestId: deliverTo, sessionId: deliverOn, request: deliverRequest } = delivery.target;
1573
- const deliverBody = delivery.rebound ? pausedBody(deliverRequest) ?? body : body;
1574
- deliveryBegun = true;
1575
- attempt.beginDelivery();
1576
- lifecycle.prepareHandoff(replay, deliverRequest.url);
1577
- handoffStarted = replay.mode !== 'cse';
1578
- if (replay.mode === 'hosted_form') {
1579
- // The device submitted the processor's own form; the processor
1580
- // answered the device. The paused NAVIGATION is fulfilled with a page
1581
- // that says exactly that (see hosted-form.ts for why not an abort and
1582
- // why not a fake result page), and the same form is refused if the
1583
- // page posts it again.
1584
- const page = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
1585
- // A navigation response is never CORS-checked, so the CORS wrap is
1586
- // inert here; it is applied so every fulfill goes through one path.
1587
- await cdp.send('Fetch.fulfillRequest', {
1588
- requestId: deliverTo,
1589
- responseCode: page.status,
1590
- responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(deliverRequest.url, deliverRequest.headers))),
1591
- body: Buffer.from(page.body).toString('base64'),
1592
- }, deliverOn);
1593
- attempt.assertLive();
1594
- lastSubmitted = { url: deliverRequest.url, body: deliverBody, at: Date.now(), authorizationId: replay.authorizationId };
1595
- // Named for what it is: a device-attested submission with no
1596
- // processor evidence, never an `authorized` event.
1597
- opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
1598
- }
1599
- else if (replay.mode === 'cse') {
1600
- // The device encrypted the card for the processor; the request itself
1601
- // still goes out from THIS browser, with its own session, risk data
1602
- // and cookies, and only the four ciphertext fields swapped in. Only
1603
- // 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');
1606
- handoffStarted = true;
1607
- await cdp.send('Fetch.continueRequest', {
1608
- requestId: deliverTo,
1609
- postData,
1610
- }, deliverOn);
1611
- attempt.assertLive();
1612
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
1613
- }
1614
- else {
1615
- // The processor's answer, handed to the page as if the processor had
1616
- // sent it. Cross-origin, the browser then runs its CORS check on it:
1617
- // see corsHeadersFor for why the answer has to carry the headers. The
1618
- // decision rides on the event, so a fulfill the page could not read
1619
- // (no usable Origin on a cross-origin request) is visible in telemetry
1620
- // rather than only as the page's own "connection error".
1621
- const cors = corsDecision(deliverRequest.url, deliverRequest.headers);
1622
- await cdp.send('Fetch.fulfillRequest', {
1623
- requestId: deliverTo,
1624
- responseCode: replay.status,
1625
- responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
1626
- body: Buffer.from(replay.body).toString('base64'),
1627
- }, deliverOn);
1628
- attempt.assertLive();
1629
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
1630
- }
1631
- lifecycle.handedOff(replay);
1632
- }
1633
- catch (err) {
1634
- // Ignore the failure event caused by our own safe decline/error abort.
1635
- const dead = attempt.target ?? { requestId, sessionId };
1636
- if (activeRequest?.attempt === attempt)
1637
- activeRequest = null;
1638
- // Vault clients may normalize a merchantSignal abort. Preserve the
1639
- // adapter's more precise cause when ownership became ambiguous.
1640
- try {
1641
- attempt.assertLive();
1642
- }
1643
- catch (stopped) {
1644
- err = stopped;
1645
- }
1646
- if (err instanceof MerchantNeverRetried || (attempt.signal.aborted && approvedId && !deliveryBegun)) {
1647
- // The device approved and no request ever carried the answer: the
1648
- // page never asked again, or went away while the approval waited.
1649
- // Retire it (see retireUnusedApproval) and quiet the page's next
1650
- // request as after any other answered approval.
1651
- await retireUnusedApproval(opts, lifecycle, approvedId);
1652
- quietUntil = Date.now() + cooldownMs;
1653
- }
1654
- else {
1655
- // stop() already published the terminal state before aborting the Vault.
1656
- if (!attempt.signal.aborted)
1657
- lifecycle.failed(err, handoffStarted);
1658
- if (isTerminal(err))
1659
- terminal = err;
1660
- else if (isApprovalOutcome(err))
1661
- quietUntil = Date.now() + cooldownMs;
1662
- opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
1663
- }
1664
- await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: 'Aborted' }, dead.sessionId).catch(() => { });
1665
- }
1666
- finally {
1667
- if (activeRequest?.attempt === attempt)
1668
- activeRequest = null;
1669
- awaitingApproval = false;
1670
- lifecycle.end();
1671
- if (preparation)
1672
- preparationGate.retireUnboundClaim();
1673
- }
1674
- });
1675
- try {
1676
- await withinAttachmentDeadline(async (assertActive) => {
1677
- const { targetInfo } = await cdp.send('Target.getTargetInfo', {}, pageSessionId);
1678
- assertActive();
1679
- if (!targetRecord(targetInfo) || targetInfo.type !== 'page'
1680
- || (targetInfo.browserContextId !== undefined && !contextId(targetInfo.browserContextId))) {
1681
- throw new CheckoutAttachmentError('unavailable');
1682
- }
1683
- merchantContext = targetInfo.browserContextId;
1684
- targetContextKnown = true;
1685
- await cdp.send('Target.setDiscoverTargets', { discover: true });
1686
- assertActive();
1687
- const { targetInfos } = await cdp.send('Target.getTargets');
1688
- assertActive();
1689
- if (!Array.isArray(targetInfos))
1690
- throw new CheckoutAttachmentError('unavailable');
1691
- for (const target of targetInfos)
1692
- assertContextTarget(target, merchantContext);
1693
- await arm(pageSessionId);
1694
- // Existing OOPIFs report attachment before the parent's acknowledgement,
1695
- // but their Fetch setup finishes later. Do not invite the caller to press
1696
- // Pay while one of those already-running frames is still unprotected.
1697
- while (arming.size)
1698
- await Promise.all(arming.values());
1699
- assertActive();
1700
- }, setupTimeoutMs, setupStop.signal);
1701
- if (setupStop.signal.aborted)
1702
- throw attachmentFailure(setupStop.signal.reason);
1703
- attachmentReady = true;
1704
- opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
1705
- }
1706
- catch (error) {
1707
- const failure = attachmentFailure(error);
1708
- setupStop.abort(failure);
1709
- terminal = failure;
1710
- if (!setupFailureReported) {
1711
- setupFailureReported = true;
1712
- lifecycle.cancel();
1713
- opts.onEvent?.({ type: 'failed', detail: `checkout_attachment_${failure.reason}` });
1714
- }
1715
- throw failure;
1716
- }
1717
- return lifecycle;
1718
- }
1719
- /**
1720
- * Playwright convenience wrapper — the path for cloud browsers that hand you a
1721
- * CDP websocket (Kernel's `cdp_ws_url`, Browserbase, etc.):
1722
- *
1723
- * const browser = await chromium.connectOverCDP(kernelBrowser.cdp_ws_url);
1724
- * const page = await browser.contexts()[0].newPage();
1725
- * await attachToPlaywright(page, { vault, user, merchant, amount });
1726
- *
1727
- * Uses Playwright's own request routing, which already spans subframes — see
1728
- * the note in the body for why a hand-rolled CDPSession does not work here.
1729
- */
1730
- export async function attachToPlaywright(page, opts) {
1731
- opts = safeOptions(opts);
1732
- const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
1733
- const setupStop = new AbortController();
1734
- let attachmentReady = false;
1735
- // Routing cannot see requests owned by a service worker. Existing controlled
1736
- // contexts must be recreated with serviceWorkers: 'block' before checkout.
1737
- if (page.context?.().serviceWorkers?.().length) {
1738
- throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
1739
- }
1740
- const lifecycle = new CheckoutLifecycle(opts);
1741
- const stripeCheckout = new StripeCheckoutGate(opts);
1742
- const mercadoCheckout = new MercadoCheckoutGate();
1743
- const readDocumentUrl = async () => {
1744
- if (page.isClosed?.() || typeof page.url !== 'function')
1745
- throw new Error('merchant_document_unavailable');
1746
- return page.url();
1747
- };
1748
- const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
1749
- const guards = paymentEndpointGuards(opts.paymentEndpoints);
1750
- // Playwright's own routing, NOT a hand-rolled CDP session.
1751
- //
1752
- // A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
1753
- // send() takes no session id, so every command meant for an attached child
1754
- // target lands on the page instead — the nested card iframes are never armed
1755
- // and the tokenization request sails past. Card fields are cross-origin
1756
- // iframes essentially always, so that adapter was broken for the only case
1757
- // that matters.
1758
- //
1759
- // page.route already spans subframes, so Playwright does the target
1760
- // bookkeeping that attachToCdp has to do by hand for a raw connection.
1761
- // Set once a failure proves that retrying cannot help. The page is free to
1762
- // keep retrying; we simply stop asking the API and fail the request outright.
1763
- let terminal = null;
1764
- // Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
1765
- const cooldownMs = opts.approvalCooldownMs ?? APPROVAL_COOLDOWN_MS;
1766
- let quietUntil = 0;
1767
- // One outstanding approval at a time; see the note above isApprovalOutcome.
1768
- let awaitingApproval = false;
1769
- // How long an approval waits for the page to ask again; see MERCHANT_RETRY_WAIT_MS.
1770
- const retryWait = retryWaitMs(opts.merchantRetryWaitMs);
1771
- let activeRequest = null;
1772
- page.on?.('requestfailed', (request) => {
1773
- if (activeRequest && activeRequest.request === request && activeRequest.attempt.lose()) {
1774
- // The page's own script gave up on its request; the cardholder's
1775
- // approval did not (see merchantAttempt). Forget the request's identity
1776
- // so no later event on it can pass for the retry's.
1777
- activeRequest.request = null;
1778
- activeRequest.frames = [];
1779
- opts.onEvent?.({ type: 'merchant_request_lost', detail: { authorizationId: lifecycle.getState().authorizationId } });
1780
- }
1781
- });
1782
- page.on?.('close', () => { if (!attachmentReady)
1783
- setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); });
1784
- page.on?.('crash', () => { if (!attachmentReady)
1785
- setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); });
1786
- page.on?.('framenavigated', (frame) => {
1787
- if (frame === page.mainFrame?.()) {
1788
- preparationGate.invalidate('merchant_document_changed');
1789
- mercadoCheckout.invalidate();
1790
- if (stripeCheckout.isPrepared()) {
1791
- stripeCheckout.invalidate();
1792
- activeRequest?.attempt.stop();
1793
- }
1794
- if (preparationGate.isEngaged())
1795
- activeRequest?.attempt.stop();
1796
- // The page that abandoned its request has moved to another document:
1797
- // its approval cannot be answered there and is cancelled. A reload of
1798
- // the same document is not that; its re-issued request is the retry.
1799
- let url;
1800
- try {
1801
- url = typeof frame.url === 'function' ? frame.url() : undefined;
1802
- }
1803
- catch {
1804
- url = undefined;
1805
- }
1806
- if (activeRequest?.attempt.lost && documentKey(url) !== activeRequest.identity.documentUrl)
1807
- activeRequest.attempt.stop();
1808
- }
1809
- });
1810
- page.on?.('framedetached', (frame) => {
1811
- if (activeRequest?.frames.includes(frame))
1812
- activeRequest.attempt.stop();
1813
- });
1814
- // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
1815
- const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
1816
- let lastSubmitted = null;
1817
- const install = async (assertActive) => {
1818
- if (page.isClosed?.()) {
1819
- const failure = new CheckoutAttachmentError('closed');
1820
- setupStop.abort(failure);
1821
- throw failure;
1822
- }
1823
- await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString()), async (route) => {
1824
- 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
- }
1914
- if (!attachmentReady || setupStop.signal.aborted) {
1915
- opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
1916
- return route.abort('aborted');
1917
- }
1918
- let preparation;
1919
- try {
1920
- preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '', request.headers());
1921
- }
1922
- catch (error) {
1923
- opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
1924
- return route.abort('aborted');
1925
- }
1926
- // The page asking again for the purchase an outstanding approval is
1927
- // for, after its own request timed out: bind it to that approval (see
1928
- // merchantAttempt and the same check in attachToCdp).
1929
- if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()) {
1930
- const retry = requestIdentity(opts, request.url(), request.method(), request.postData() ?? '', documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
1931
- if (activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
1932
- if (preparation)
1933
- preparationGate.retireUnboundClaim();
1934
- const retryFrames = [];
1935
- try {
1936
- for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
1937
- retryFrames.push(frame);
1938
- }
1939
- catch { /* covered by requestfailed */ }
1940
- activeRequest.request = request;
1941
- activeRequest.frames = retryFrames;
1942
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()), authorizationId: lifecycle.getState().authorizationId, retry: true } });
1943
- activeRequest.attempt.bind({ route, request, frames: retryFrames });
1944
- return;
1945
- }
1946
- }
1947
- // Same order as attachToCdp: a routine card-free request continues in every state.
1948
- const continuationBody = request.postData();
1949
- const withoutCard = stripeStep ? null : opts.vault.withoutCard?.(request.url(), continuationBody) ?? null;
1950
- if (withoutCard === 'continue') {
1951
- if (preparation)
1952
- preparationGate.retireUnboundClaim();
1953
- return route.fallback();
1954
- }
1955
- const continuationId = stripeStep || terminal ? null
1956
- : claimStripeContinuation(opts, lifecycle, request.url(), request.method(), continuationBody);
1957
- if (continuationId) {
1958
- if (preparation)
1959
- preparationGate.retireUnboundClaim();
1960
- if (await checkStripeContinuation(opts, lifecycle, continuationId, { url: request.url(), method: request.method(), headers: request.headers(), body: continuationBody }) && !setupStop.signal.aborted) {
1961
- return route.fallback();
1962
- }
1963
- return route.abort('aborted');
1964
- }
1965
- // Fail closed and stay quiet: no card may reach the PSP, but neither may
1966
- // the page's retry loop turn into a stream of doomed API calls. Every
1967
- // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
1968
- // CDP adapter's Fetch.failRequest uses, so a refused navigation
1969
- // resolves identically whichever adapter is attached.
1970
- if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
1971
- let repeatedAuthorizationId = null;
1972
- if (lastSubmitted) {
1973
- let body = null;
1974
- try {
1975
- body = request.postData() ?? '';
1976
- }
1977
- catch { /* The refusal still holds when the body cannot be read. */ }
1978
- if (body !== null && isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
1979
- repeatedAuthorizationId = lastSubmitted.authorizationId;
1980
- }
1981
- }
1982
- const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
1983
- opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
1984
- if (preparation)
1985
- preparationGate.retireUnboundClaim();
1986
- const refused = await route.abort('aborted');
1987
- if (repeatedAuthorizationId)
1988
- void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
1989
- return refused;
1990
- }
1991
- // Same judgement as attachToCdp, after the same holds.
1992
- if (withoutCard === 'refuse') {
1993
- if (preparation)
1994
- preparationGate.retireUnboundClaim();
1995
- opts.onEvent?.({ type: 'blocked', detail: 'request_without_card' });
1996
- return route.abort('aborted');
1997
- }
1998
- // Reserved before anything that could yield, matching attachToCdp.
1999
- awaitingApproval = true;
2000
- let navigation = false;
2001
- try {
2002
- navigation = typeof request.resourceType === 'function' && request.resourceType() === 'document';
2003
- }
2004
- catch {
2005
- navigation = false;
2006
- }
2007
- const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url(), request.method(), navigation, !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url(), request.method())));
2008
- const frames = [];
2009
- try {
2010
- for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
2011
- frames.push(frame);
2012
- }
2013
- catch { /* requestfailed/page close still cover unavailable frame metadata */ }
2014
- // Judged on the request that currently carries the attempt: after a
2015
- // retry binds, the first request's failure is history, not a loss.
2016
- const assertRequestLive = () => {
2017
- const current = attempt.target;
2018
- if (current && (current.request.failure?.() || current.frames.some((frame) => frame.isDetached?.())))
2019
- attempt.lose();
2020
- if (page.isClosed?.())
2021
- attempt.stop();
2022
- attempt.assertLive();
2023
- };
2024
- let handoffStarted = false;
2025
- // The approval the device gave, and whether it reached a browser request.
2026
- let approvedId = null;
2027
- let deliveryBegun = false;
2028
- try {
2029
- const body = request.postData() ?? '';
2030
- if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
2031
- opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
2032
- const refused = await route.abort('aborted');
2033
- if (lastSubmitted)
2034
- void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
2035
- return refused;
2036
- }
2037
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
2038
- lifecycle.begin();
2039
- const approvalStartedAt = Date.now();
2040
- const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
2041
- attempt.bind({ route, request, frames });
2042
- activeRequest = { request, frames, attempt,
2043
- identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined) };
2044
- if (preparation)
2045
- await preparationGate.assertDocument();
2046
- // One bounded read serves the merchant origin and the retry identity; see attachToCdp.
2047
- const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
2048
- const pageOrigin = pageOriginFrom(documentUrl);
2049
- const pageAmount = await pageAmountOf(opts);
2050
- assertRequestLive();
2051
- activeRequest.identity.documentUrl = documentKey(documentUrl);
2052
- activeRequest.identity.pageAmount = pageAmount;
2053
- activeRequest.identity.amountEvidence ||= pageAmount !== undefined;
2054
- if (stripeStep)
2055
- stripeCheckout.assertClaim(stripeStep);
2056
- const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
2057
- ? opts.merchantOrigin : pageOrigin?.startsWith('https:') ? pageOrigin : opts.merchantOrigin);
2058
- assertRequestLive();
2059
- const replay = await opts.vault.authorize({
2060
- user: opts.user,
2061
- merchant: opts.merchant,
2062
- amount: opts.amount,
2063
- currency: opts.currency,
2064
- cardId: preparation?.cardId ?? opts.cardId,
2065
- executionMode: opts.executionMode,
2066
- grantId: opts.grantId,
2067
- stripeCheckoutEnvironment: opts.stripeCheckout?.environment,
2068
- merchantOrigin,
2069
- pageOrigin,
2070
- pageAmount,
2071
- ...(payToInterceptMs !== undefined ? { payToInterceptMs } : {}),
2072
- preparation,
2073
- timeoutMs: opts.timeoutMs,
2074
- signal: lifecycle.abort.signal,
2075
- merchantSignal: attempt.signal,
2076
- onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
2077
- onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
2078
- lifecycle.approvalUrl(url);
2079
- return opts.onApprovalUrl?.(url);
2080
- } },
2081
- request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url(), method: request.method(), headers: request.headers(), body,
2082
- ...(mercadoCheckout.requiresDocument(request.url(), request.method())
2083
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}) },
2084
- });
2085
- assertRequestLive();
2086
- approvedId = replay.authorizationId;
2087
- if (lifecycle.isCancelled())
2088
- throw new Error('checkout cancelled locally after approval');
2089
- try {
2090
- await mercadoCheckout.arm(replay, request.url());
2091
- }
2092
- catch {
2093
- throw new PaymentOutcomeUnknownError(replay.authorizationId, 'mercado_checkout_metadata_invalid');
2094
- }
2095
- assertRequestLive();
2096
- // The request to answer: the paused one, or the page's retry when the
2097
- // page gave up on the first (see attachToCdp for the same step).
2098
- const delivery = await attempt.awaitTarget(boundedRetryWait(retryWait, approvalStartedAt, opts.timeoutMs), [lifecycle.abort.signal, setupStop.signal], () => {
2099
- lifecycle.awaitingMerchantRetry(replay.authorizationId);
2100
- opts.onEvent?.({ type: 'approval_awaiting_merchant_retry', detail: { authorizationId: replay.authorizationId } });
2101
- });
2102
- const { route: deliverTo, request: deliverRequest } = delivery.target;
2103
- const deliverBody = delivery.rebound ? deliverRequest.postData() ?? '' : body;
2104
- deliveryBegun = true;
2105
- attempt.beginDelivery();
2106
- lifecycle.prepareHandoff(replay, deliverRequest.url());
2107
- handoffStarted = replay.mode !== 'cse';
2108
- if (replay.mode === 'hosted_form') {
2109
- // Same as the CDP path: the paused navigation resolves to the
2110
- // synthetic page, and a re-post of this form is refused.
2111
- const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
2112
- // Inert on a navigation (never CORS-checked); one path for every fulfill.
2113
- await deliverTo.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(deliverRequest.url(), deliverRequest.headers())), body: synthetic.body });
2114
- assertRequestLive();
2115
- lastSubmitted = { url: deliverRequest.url(), body: deliverBody, at: Date.now(), authorizationId: replay.authorizationId };
2116
- opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
2117
- }
2118
- else if (replay.mode === 'cse') {
2119
- // Same as the CDP path: the request continues from this browser
2120
- // with the ciphertext swapped in and no header override; Playwright
2121
- // recomputes the length itself.
2122
- const postData = cseBody(deliverBody, replay);
2123
- handoffStarted = true;
2124
- await deliverTo.continue({ postData });
2125
- assertRequestLive();
2126
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
2127
- }
2128
- else {
2129
- // Playwright adds these itself when a cross-origin fulfill carries
2130
- // none; written here anyway (replacing a stale value) so a
2131
- // cross-origin answer is the same whichever adapter ran.
2132
- const cors = corsDecision(deliverRequest.url(), deliverRequest.headers());
2133
- await deliverTo.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
2134
- assertRequestLive();
2135
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
2136
- }
2137
- lifecycle.handedOff(replay);
2138
- }
2139
- catch (err) {
2140
- const dead = attempt.target?.route ?? route;
2141
- if (activeRequest?.attempt === attempt)
2142
- activeRequest = null;
2143
- try {
2144
- attempt.assertLive();
2145
- }
2146
- catch (stopped) {
2147
- err = stopped;
2148
- }
2149
- if (err instanceof MerchantNeverRetried || (attempt.signal.aborted && approvedId && !deliveryBegun)) {
2150
- // The device approved and no request ever carried the answer; see
2151
- // the same branch in attachToCdp.
2152
- await retireUnusedApproval(opts, lifecycle, approvedId);
2153
- quietUntil = Date.now() + cooldownMs;
2154
- }
2155
- else {
2156
- if (!attempt.signal.aborted)
2157
- lifecycle.failed(err, handoffStarted);
2158
- if (isTerminal(err))
2159
- terminal = err;
2160
- else if (isApprovalOutcome(err))
2161
- quietUntil = Date.now() + cooldownMs;
2162
- opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
2163
- }
2164
- await dead.abort('aborted').catch(() => { });
2165
- }
2166
- finally {
2167
- if (activeRequest?.attempt === attempt)
2168
- activeRequest = null;
2169
- awaitingApproval = false;
2170
- lifecycle.end();
2171
- if (preparation)
2172
- preparationGate.retireUnboundClaim();
2173
- }
2174
- });
2175
- assertActive();
2176
- };
2177
- try {
2178
- await withinAttachmentDeadline(install, setupTimeoutMs, setupStop.signal);
2179
- if (setupStop.signal.aborted)
2180
- throw attachmentFailure(setupStop.signal.reason);
2181
- attachmentReady = true;
2182
- }
2183
- catch (error) {
2184
- const failure = attachmentFailure(error);
2185
- setupStop.abort(failure);
2186
- terminal = failure;
2187
- lifecycle.cancel();
2188
- opts.onEvent?.({ type: 'failed', detail: `checkout_attachment_${failure.reason}` });
2189
- // A route registration may finish after this rejection. Keep its handler
2190
- // inert for card traffic; removing it would reopen the failed checkout.
2191
- throw failure;
2192
- }
2193
- return lifecycle;
2194
- }