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