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