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