@agent-cards/checkout 0.1.0 → 0.2.1

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/dist/cdp.js CHANGED
@@ -1,11 +1,275 @@
1
- const CARD_PATTERNS = [
2
- '*pci.shopifyinc.com/sessions*',
3
- '*shopifycs.com/sessions*',
4
- '*api.stripe.com/v1/payment_methods*',
5
- '*api.stripe.com/v1/tokens*',
6
- '*braintree-api.com/graphql*',
7
- '*checkout.com/tokens*',
8
- ];
1
+ import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
+ import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, UnsupportedModeError, redactUrl, } from './client.js';
3
+ import { substituteEncryptedFields } from './substitute.js';
4
+ import { hostedFormSubmittedPage } from './hosted-form.js';
5
+ // Every URL that leaves this module through onEvent is redacted to origin +
6
+ // path first. A paused PaymentIntent confirm can carry the client secret in
7
+ // its query string (Stripe.js puts it in the body; hand-rolled runtimes and
8
+ // some SDKs put it in the URL), and onEvent is ordinary integrator telemetry:
9
+ // logs, dashboards, crash reporters. None of those may receive a secret.
10
+ /**
11
+ * How long a hosted form that the cardholder already submitted stays refused
12
+ * when the page posts it again.
13
+ *
14
+ * A hosted_form approval resolves the paused navigation with a synthetic page
15
+ * (hostedFormSubmittedPage), and the merchant page may still carry the same
16
+ * card form: an agent that clicks Pay again would pause an identical
17
+ * navigation and, without this, raise a second prompt on the household's
18
+ * phone for a payment that already left it. The API's duplicate guard covers
19
+ * a body the form regenerated (same supplier, session and amount inside 15
20
+ * minutes: 409 duplicate_submission, which the adapters quiet the way they
21
+ * quiet a decline, see isApprovalOutcome); this latch covers the
22
+ * byte-identical re-post without a round trip. Same window as the
23
+ * authorization's own TTL. Overridable per attach via `hostedFormRepeatQuietMs`.
24
+ */
25
+ const HOSTED_FORM_REPEAT_QUIET_MS = 15 * 60_000;
26
+ /** The same form the device already submitted, posted again inside the quiet window. */
27
+ function isRepeatOfSubmitted(last, url, body, quietMs) {
28
+ return !!last && last.url === url && last.body === body && Date.now() - last.at < quietMs;
29
+ }
30
+ const HOSTED_FORM_REPEAT_REASON = 'already submitted on the cardholder\'s device';
31
+ /**
32
+ * How long to stop asking after a person declines or ignores an approval.
33
+ *
34
+ * This exists because the two failure shapes look identical at the route
35
+ * handler and need opposite answers. A merchant page re-issues an aborted
36
+ * tokenization within milliseconds, and re-prompting on each of those turns
37
+ * one decline into a queue of notifications on someone's phone. A genuinely
38
+ * later checkout also arrives as a paused request, and that one deserves a
39
+ * fresh prompt, so a permanent latch is wrong too.
40
+ *
41
+ * Elapsed time is what separates them, and nothing else available here does:
42
+ * body, nonce and authorization id all differ between an automatic retry and a
43
+ * new checkout. So a decline buys a short silence, not a closed door.
44
+ *
45
+ * Kept deliberately SHORT. A page's automatic retry lands in milliseconds — the
46
+ * storm this exists for ran at roughly ten a second — so a few seconds absorbs
47
+ * a burst with room to spare, while leaving almost no window in which a person
48
+ * could start a genuinely new checkout and be turned away. Raise it only with
49
+ * evidence of a slower retry loop; every second added is a second a real
50
+ * checkout can be refused. Overridable per attach via `approvalCooldownMs`.
51
+ */
52
+ const APPROVAL_COOLDOWN_MS = 5_000;
53
+ /**
54
+ * One approval at a time, per attachment.
55
+ *
56
+ * The cooldown only arms once a failure RESOLVES, so two requests paused
57
+ * before the first authorize() returns both sail past it and each raise their
58
+ * own prompt. A checkout with several card iframes does exactly that. Nobody
59
+ * should get two notifications asking them to approve the same purchase, so a
60
+ * request arriving while one is outstanding is failed rather than queued: the
61
+ * page's own retry brings it back, and by then there is an answer.
62
+ */
63
+ /**
64
+ * This one authorization was answered; see APPROVAL_COOLDOWN_MS. An
65
+ * AmountMismatchError is an ApprovalDeclinedError, so both amount checks land
66
+ * here: the pre-replay one (the row IS declined) and the create-time one (no
67
+ * row; the intent already disagreed). Either way the paused request is
68
+ * aborted with nothing charged and the page's retry is quieted, NOT latched:
69
+ * Stripe lets the merchant update an intent's amount until it is confirmed,
70
+ * so the next request on this page is a new question and is judged afresh.
71
+ * IntentNotConfirmableError lands here too (the row is declined).
72
+ *
73
+ * A 409 duplicate_submission is an answer of the same kind: the household
74
+ * already has, or already answered, the prompt for this exact submission (a
75
+ * hosted form the page posted again with a fresh nonce). Quieting the page's
76
+ * re-post is right; latching the whole attachment is not, because the prior
77
+ * row declines or expires and the same form is then a new question. The
78
+ * quiet window absorbs the burst; the next request past it is judged afresh.
79
+ */
80
+ function isApprovalOutcome(err) {
81
+ if (err instanceof CheckoutApiError)
82
+ return err.code === 'duplicate_submission';
83
+ return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
84
+ }
85
+ /**
86
+ * Should this failure stop us intercepting for the rest of the page's life?
87
+ *
88
+ * It matters because a failed tokenization does not end the checkout: the
89
+ * merchant's own page retries, we pause the retry, we fail it again, and the
90
+ * loop runs as fast as the page will go. A real run against Shopify with a
91
+ * stale user id produced ~10 authorization calls a SECOND for fifteen minutes
92
+ * until the agent's host killed it, every one of them a request to our API that
93
+ * could never have succeeded.
94
+ *
95
+ * Terminal means "will answer identically next time no matter who does what":
96
+ * a misconfiguration or an unsupported PSP. Nothing a person does changes
97
+ * those, so asking again is pure waste.
98
+ *
99
+ * Everything else stays retryable. A 5xx or a 429 clears on its own, and a
100
+ * decline or a timeout is answered by the cooldown above rather than by
101
+ * killing the page: the person said no to one authorization, not to every
102
+ * checkout they will ever make in this session. A 409 duplicate_submission
103
+ * is not `permanent` on the error itself (see CheckoutApiError) and lands in
104
+ * the cooldown too.
105
+ */
106
+ function isTerminal(err) {
107
+ if (err instanceof CheckoutApiError)
108
+ return err.permanent;
109
+ return err instanceof CardEncryptedError || err instanceof UnsupportedModeError;
110
+ }
111
+ /**
112
+ * The cse continuation: the paused body with the vault's ciphertext in place
113
+ * of the dummy blobs. It is sent as postData ALONE. Chromium recomputes
114
+ * Content-Length for a continued request itself and refuses a header override
115
+ * that names it (Fetch.continueRequest answers -32602 "Unsafe header"), and
116
+ * that refusal would land after the cardholder approved, failing a paid-for
117
+ * request. Leaving `headers` off the command keeps every other header the
118
+ * browser's own, untouched, which is the point of continuing rather than
119
+ * fulfilling.
120
+ */
121
+ function cseBody(body, replay) {
122
+ return substituteEncryptedFields(body, replay.substitutions);
123
+ }
124
+ /**
125
+ * The paused request's body, or null when Chromium says there is one and did
126
+ * not hand it over. `postData` is set only for a text body; a binary or a
127
+ * large one arrives as base64 `postDataEntries` instead, concatenated here
128
+ * byte for byte (an entry without `bytes` is a file the browser streams, which
129
+ * no runtime can read back). There is no CDP command to fetch it later:
130
+ * `Fetch.getRequestPostData` does not exist (Chrome answers -32601), so a
131
+ * body that is not on the event is a body this adapter cannot see.
132
+ */
133
+ function pausedBody(request) {
134
+ if (typeof request.postData === 'string' && request.postData !== '')
135
+ return request.postData;
136
+ if (!request.hasPostData)
137
+ return request.postData ?? '';
138
+ const entries = request.postDataEntries ?? [];
139
+ if (!entries.length || entries.some((e) => typeof e.bytes !== 'string'))
140
+ return null;
141
+ return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
142
+ }
143
+ const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
144
+ /**
145
+ * The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
146
+ * request is same-origin (or carries no Origin, so no CORS check applies).
147
+ *
148
+ * The browser checks a fulfilled response exactly as it checks a real one. A
149
+ * page that fetches a processor on another origin therefore needs
150
+ * `access-control-allow-origin` on the synthetic answer, or its fetch rejects
151
+ * with "Failed to fetch" and the page never sees the processor's reply, even
152
+ * though the cardholder approved and the processor answered the vault.
153
+ * Shopify never hit this on its current host: the checkout.pci.shopifyinc.com
154
+ * card iframe posts to its own origin (its older deposit.<region>.shopifycs.com
155
+ * host is called from the checkout.shopifycs.com frame, cross-origin, and gets
156
+ * the answer like everyone else). Stripe hits it on every surface (Checkout on
157
+ * checkout.stripe.com or a merchant domain, and Elements in the js.stripe.com
158
+ * frame, all call api.stripe.com), and so does every other processor whose
159
+ * card frame calls a separate API host. Observed live on
160
+ * 2026-09-03: the vault replayed a Stripe PaymentMethod into a raw-CDP
161
+ * runtime, the browser refused the answer for want of this header, and
162
+ * Stripe Checkout showed "We are experiencing connection issues".
163
+ *
164
+ * The exact Origin is echoed rather than `*`: a credentialed request refuses
165
+ * `*`, the echo satisfies both. The processor's own value can never reach
166
+ * this adapter (a browser does not expose that header to the page that
167
+ * replayed the call), so whatever the replay carries under these names is
168
+ * replaced by the one value that is right for THIS request. Playwright adds
169
+ * the same headers inside route.fulfill when a cross-origin fulfill carries
170
+ * none (microsoft/playwright#12929), which is why attachToPlaywright never
171
+ * needed this; it writes them itself anyway, replacing a stale value, so both
172
+ * adapters answer the vault's replays identically.
173
+ *
174
+ * This widens nothing. A tokenization endpoint is built for anonymous
175
+ * browsers and answers every origin (`access-control-allow-origin: *` on
176
+ * Stripe's and Shopify's own replies), so the page that made the request
177
+ * could always read the processor's answer to it; the replay is made exactly
178
+ * as visible, to exactly that page. Whether a card goes anywhere at all is
179
+ * decided by the cardholder on the approval screen, never by this header.
180
+ *
181
+ * Only what a browser serializes is ever echoed: one canonical http(s)
182
+ * origin (`new URL(origin).origin === origin`), or the opaque `null` a
183
+ * sandboxed or data: document sends, which Chrome matches against
184
+ * `access-control-allow-origin: null` and which Playwright echoes too. That
185
+ * refuses userinfo, a path, an explicit default port, several origins in one
186
+ * value, or a control character that would break the fulfill after the
187
+ * cardholder already approved. Anything refused simply gets no CORS answer,
188
+ * which is what every fulfill got before this existed.
189
+ */
190
+ export function corsHeadersFor(url, requestHeaders) {
191
+ return corsDecision(url, requestHeaders).headers;
192
+ }
193
+ /** 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. */
194
+ export function corsDecision(url, requestHeaders) {
195
+ const none = { headers: null, outcome: 'none' };
196
+ const originEntry = Object.entries(requestHeaders ?? {}).find(([name]) => name.toLowerCase() === 'origin');
197
+ const origin = typeof originEntry?.[1] === 'string' ? originEntry[1].trim() : '';
198
+ if (!origin)
199
+ return none;
200
+ let target;
201
+ try {
202
+ target = new URL(url);
203
+ }
204
+ catch {
205
+ return none;
206
+ }
207
+ if (target.protocol !== 'https:' && target.protocol !== 'http:')
208
+ return none;
209
+ if (origin !== 'null') {
210
+ let originUrl;
211
+ try {
212
+ originUrl = new URL(origin);
213
+ }
214
+ catch {
215
+ return none;
216
+ }
217
+ if (originUrl.protocol !== 'https:' && originUrl.protocol !== 'http:')
218
+ return none;
219
+ if (originUrl.origin !== origin)
220
+ return none;
221
+ if (target.origin === origin)
222
+ return { headers: null, outcome: 'same_origin' };
223
+ }
224
+ return { headers: { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true' }, outcome: 'echoed' };
225
+ }
226
+ /**
227
+ * `headers` with the CORS answer for this request written in; the object
228
+ * itself when none is needed. Any header the answer names is replaced
229
+ * whatever its case, so a name is never sent twice. The answer varies by
230
+ * Origin, and a `vary` the replay already carries is extended rather than
231
+ * replaced (or left alone when it already covers Origin or is `*`); a `vary`
232
+ * the answer itself names is taken as given.
233
+ */
234
+ export function withCorsHeaders(headers, cors) {
235
+ if (!cors)
236
+ return headers;
237
+ const replaced = new Set(Object.keys(cors).map((name) => name.toLowerCase()));
238
+ const out = {};
239
+ let varyName = null;
240
+ for (const [name, value] of Object.entries(headers)) {
241
+ const lower = name.toLowerCase();
242
+ if (replaced.has(lower))
243
+ continue;
244
+ if (lower === 'vary')
245
+ varyName = name;
246
+ out[name] = value;
247
+ }
248
+ Object.assign(out, cors);
249
+ if (replaced.has('vary'))
250
+ return out;
251
+ if (varyName === null) {
252
+ out.vary = 'Origin';
253
+ }
254
+ else {
255
+ const existing = String(out[varyName]);
256
+ const members = existing.split(',').map((m) => m.trim().toLowerCase());
257
+ if (!members.includes('origin') && !members.includes('*'))
258
+ out[varyName] = `${existing}, Origin`;
259
+ }
260
+ return out;
261
+ }
262
+ /** CDP's header shape: `{ name, value }` entries. */
263
+ function headerEntries(headers) {
264
+ return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
265
+ }
266
+ /**
267
+ * Last-resort patterns: the built-in recognizers' hosts, derived the same way
268
+ * as everything else. Used only when the vault hands back nothing at all (a
269
+ * duck-typed client from an older SDK, or an empty registry) — a hardcoded list
270
+ * that drifts from the registry is exactly the bug this adapter used to have.
271
+ */
272
+ const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
9
273
  /**
10
274
  * Take over card tokenization for a page.
11
275
  *
@@ -13,16 +277,38 @@ const CARD_PATTERNS = [
13
277
  * targets. Enabling Fetch on the page session alone will never see the
14
278
  * tokenization request. This attaches recursively so every nested target is
15
279
  * armed, which is the whole reason this adapter exists.
280
+ *
281
+ * The patterns armed here come from the REGISTRY, not from a constant, so a PSP
282
+ * the API knows about is paused without an SDK release — call
283
+ * `vault.syncRegistry()` before attaching and every recognizer the server
284
+ * serves is covered. They are a coarse pre-filter and are deliberately wider
285
+ * than the recognizers (see cardUrlPatterns): each paused request is re-checked
286
+ * with `isCardRequest` below and continued untouched unless it is an exact
287
+ * match. Patterns are resolved once, at attach, so every nested target ends up
288
+ * armed identically.
16
289
  */
17
290
  export async function attachToCdp(cdp, pageSessionId, opts) {
18
291
  const armed = new Set();
292
+ // Set once a failure proves that retrying cannot help; see isTerminal.
293
+ let terminal = null;
294
+ // Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
295
+ const cooldownMs = opts.approvalCooldownMs ?? APPROVAL_COOLDOWN_MS;
296
+ let quietUntil = 0;
297
+ // One outstanding approval at a time; see the note above isApprovalOutcome.
298
+ let awaitingApproval = false;
299
+ // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
300
+ const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
301
+ let lastSubmitted = null;
302
+ const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
303
+ const urlPatterns = derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS;
304
+ opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
19
305
  const arm = async (sessionId) => {
20
306
  const key = sessionId ?? '__root__';
21
307
  if (armed.has(key))
22
308
  return;
23
309
  armed.add(key);
24
310
  await cdp.send('Fetch.enable', {
25
- patterns: CARD_PATTERNS.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
311
+ patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
26
312
  }, sessionId).catch(() => { });
27
313
  // Descend into this target's own children (iframes inside iframes).
28
314
  await cdp.send('Target.setAutoAttach', {
@@ -39,36 +325,111 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
39
325
  }
40
326
  if (method !== 'Fetch.requestPaused')
41
327
  return;
42
- const { requestId, request } = params;
328
+ const { requestId, request, resourceType } = params;
43
329
  if (!opts.vault.isCardRequest(request.url, request.method)) {
44
330
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
45
331
  return;
46
332
  }
47
- let body = request.postData ?? '';
48
- if (!body && request.hasPostData) {
49
- body = (await cdp.send('Fetch.getRequestPostData', { requestId }, sessionId).catch(() => ({ postData: '' }))).postData ?? '';
333
+ // Same stop condition as the Playwright adapter: once a failure proves
334
+ // retrying is pointless, fail the request without calling the API again.
335
+ if (terminal || awaitingApproval || Date.now() < quietUntil) {
336
+ const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
337
+ opts.onEvent?.({ type: 'blocked', detail: String(why) });
338
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
339
+ return;
50
340
  }
51
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: request.url } });
341
+ // Reserve BEFORE the first await (the authorize call below yields), so two
342
+ // requests can never both clear the check above and raise two prompts for
343
+ // one checkout.
344
+ awaitingApproval = true;
52
345
  try {
346
+ const body = pausedBody(request);
347
+ if (body === null) {
348
+ // Fail closed, but only THIS request: an unreadable body says nothing
349
+ // about the page's configuration, so the next request is judged
350
+ // afresh (no latch, no cooldown).
351
+ opts.onEvent?.({ type: 'failed', detail: BODY_UNREADABLE_REASON });
352
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
353
+ return;
354
+ }
355
+ // The body is needed to tell a re-post of the form the cardholder
356
+ // already submitted from a new one, so this check sits after the read
357
+ // and before any API call: no second prompt, no round trip.
358
+ if (isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
359
+ opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
360
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
361
+ return;
362
+ }
363
+ opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
53
364
  const replay = await opts.vault.authorize({
54
365
  user: opts.user,
55
366
  merchant: opts.merchant,
56
367
  amount: opts.amount,
368
+ amountCents: opts.amountCents,
369
+ currency: opts.currency,
57
370
  onApprovalUrl: opts.onApprovalUrl,
58
371
  request: { url: request.url, method: request.method, headers: request.headers, body },
59
372
  });
60
- await cdp.send('Fetch.fulfillRequest', {
61
- requestId,
62
- responseCode: replay.status,
63
- responseHeaders: Object.entries(replay.headers).map(([name, value]) => ({ name, value: String(value) })),
64
- body: Buffer.from(replay.body).toString('base64'),
65
- }, sessionId);
66
- opts.onEvent?.({ type: 'authorized' });
373
+ if (replay.mode === 'hosted_form') {
374
+ // The device submitted the processor's own form; the processor
375
+ // answered the device. The paused NAVIGATION is fulfilled with a page
376
+ // that says exactly that (see hosted-form.ts for why not an abort and
377
+ // why not a fake result page), and the same form is refused if the
378
+ // page posts it again.
379
+ const page = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
380
+ // A navigation response is never CORS-checked, so the CORS wrap is
381
+ // inert here; it is applied so every fulfill goes through one path.
382
+ await cdp.send('Fetch.fulfillRequest', {
383
+ requestId,
384
+ responseCode: page.status,
385
+ responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
386
+ body: Buffer.from(page.body).toString('base64'),
387
+ }, sessionId);
388
+ lastSubmitted = { url: request.url, body, at: Date.now() };
389
+ // Named for what it is: a device-attested submission with no
390
+ // processor evidence, never an `authorized` event.
391
+ opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
392
+ }
393
+ else if (replay.mode === 'cse') {
394
+ // The device encrypted the card for the processor; the request itself
395
+ // still goes out from THIS browser, with its own session, risk data
396
+ // and cookies, and only the four ciphertext fields swapped in. Only
397
+ // postData rides on the command: no header override, ever (see
398
+ // cseBody for why a recomputed Content-Length is refused by Chromium).
399
+ await cdp.send('Fetch.continueRequest', {
400
+ requestId,
401
+ postData: Buffer.from(cseBody(body, replay)).toString('base64'),
402
+ }, sessionId);
403
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
404
+ }
405
+ else {
406
+ // The processor's answer, handed to the page as if the processor had
407
+ // sent it. Cross-origin, the browser then runs its CORS check on it:
408
+ // see corsHeadersFor for why the answer has to carry the headers. The
409
+ // decision rides on the event, so a fulfill the page could not read
410
+ // (no usable Origin on a cross-origin request) is visible in telemetry
411
+ // rather than only as the page's own "connection error".
412
+ const cors = corsDecision(request.url, request.headers);
413
+ await cdp.send('Fetch.fulfillRequest', {
414
+ requestId,
415
+ responseCode: replay.status,
416
+ responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
417
+ body: Buffer.from(replay.body).toString('base64'),
418
+ }, sessionId);
419
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
420
+ }
67
421
  }
68
422
  catch (err) {
423
+ if (isTerminal(err))
424
+ terminal = err;
425
+ else if (isApprovalOutcome(err))
426
+ quietUntil = Date.now() + cooldownMs;
69
427
  opts.onEvent?.({ type: 'failed', detail: String(err) });
70
428
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
71
429
  }
430
+ finally {
431
+ awaitingApproval = false;
432
+ }
72
433
  });
73
434
  await arm(pageSessionId);
74
435
  }
@@ -95,6 +456,17 @@ export async function attachToPlaywright(page, opts) {
95
456
  //
96
457
  // page.route already spans subframes, so Playwright does the target
97
458
  // bookkeeping that attachToCdp has to do by hand for a raw connection.
459
+ // Set once a failure proves that retrying cannot help. The page is free to
460
+ // keep retrying; we simply stop asking the API and fail the request outright.
461
+ let terminal = null;
462
+ // Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
463
+ const cooldownMs = opts.approvalCooldownMs ?? APPROVAL_COOLDOWN_MS;
464
+ let quietUntil = 0;
465
+ // One outstanding approval at a time; see the note above isApprovalOutcome.
466
+ let awaitingApproval = false;
467
+ // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
468
+ const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
469
+ let lastSubmitted = null;
98
470
  await page.route((url) => opts.vault.isCardRequest(url.toString()), async (route) => {
99
471
  const request = route.request();
100
472
  // The matcher only sees the URL; a preflight or a GET must pass through
@@ -102,22 +474,69 @@ export async function attachToPlaywright(page, opts) {
102
474
  if (!opts.vault.isCardRequest(request.url(), request.method())) {
103
475
  return route.fallback();
104
476
  }
105
- const body = request.postData() ?? '';
106
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: request.url() } });
477
+ // Fail closed and stay quiet: no card may reach the PSP, but neither may
478
+ // the page's retry loop turn into a stream of doomed API calls. Every
479
+ // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
480
+ // CDP adapter's Fetch.failRequest uses, so a refused navigation
481
+ // resolves identically whichever adapter is attached.
482
+ if (terminal || awaitingApproval || Date.now() < quietUntil) {
483
+ const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
484
+ opts.onEvent?.({ type: 'blocked', detail: String(why) });
485
+ return route.abort('aborted');
486
+ }
487
+ // Reserved before anything that could yield, matching attachToCdp.
488
+ awaitingApproval = true;
107
489
  try {
490
+ const body = request.postData() ?? '';
491
+ if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
492
+ opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
493
+ return await route.abort('aborted');
494
+ }
495
+ opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
108
496
  const replay = await opts.vault.authorize({
109
497
  user: opts.user,
110
498
  merchant: opts.merchant,
111
499
  amount: opts.amount,
500
+ amountCents: opts.amountCents,
501
+ currency: opts.currency,
112
502
  onApprovalUrl: opts.onApprovalUrl,
113
503
  request: { url: request.url(), method: request.method(), headers: request.headers(), body },
114
504
  });
115
- await route.fulfill({ status: replay.status, headers: replay.headers, body: replay.body });
116
- opts.onEvent?.({ type: 'authorized' });
505
+ if (replay.mode === 'hosted_form') {
506
+ // Same as the CDP path: the paused navigation resolves to the
507
+ // synthetic page, and a re-post of this form is refused.
508
+ const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
509
+ // Inert on a navigation (never CORS-checked); one path for every fulfill.
510
+ await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
511
+ lastSubmitted = { url: request.url(), body, at: Date.now() };
512
+ opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
513
+ }
514
+ else if (replay.mode === 'cse') {
515
+ // Same as the CDP path: the request continues from this browser
516
+ // with the ciphertext swapped in and no header override; Playwright
517
+ // recomputes the length itself.
518
+ await route.continue({ postData: cseBody(body, replay) });
519
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
520
+ }
521
+ else {
522
+ // Playwright adds these itself when a cross-origin fulfill carries
523
+ // none; written here anyway (replacing a stale value) so a
524
+ // cross-origin answer is the same whichever adapter ran.
525
+ const cors = corsDecision(request.url(), request.headers());
526
+ await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
527
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
528
+ }
117
529
  }
118
530
  catch (err) {
531
+ if (isTerminal(err))
532
+ terminal = err;
533
+ else if (isApprovalOutcome(err))
534
+ quietUntil = Date.now() + cooldownMs;
119
535
  opts.onEvent?.({ type: 'failed', detail: String(err) });
120
- await route.abort();
536
+ await route.abort('aborted');
537
+ }
538
+ finally {
539
+ awaitingApproval = false;
121
540
  }
122
541
  });
123
542
  }