@agent-cards/checkout 0.2.0 → 0.3.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/dist/cdp.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
- import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, UnsupportedModeError, redactUrl, } from './client.js';
2
+ import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, UnsupportedModeError, redactUrl, } from './client.js';
3
3
  import { substituteEncryptedFields } from './substitute.js';
4
4
  import { hostedFormSubmittedPage } from './hosted-form.js';
5
+ import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
5
6
  // Every URL that leaves this module through onEvent is redacted to origin +
6
7
  // path first. A paused PaymentIntent confirm can carry the client secret in
7
8
  // its query string (Stripe.js puts it in the body; hand-rolled runtimes and
@@ -141,6 +142,128 @@ function pausedBody(request) {
141
142
  return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
142
143
  }
143
144
  const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
145
+ /**
146
+ * The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
147
+ * request is same-origin (or carries no Origin, so no CORS check applies).
148
+ *
149
+ * The browser checks a fulfilled response exactly as it checks a real one. A
150
+ * page that fetches a processor on another origin therefore needs
151
+ * `access-control-allow-origin` on the synthetic answer, or its fetch rejects
152
+ * with "Failed to fetch" and the page never sees the processor's reply, even
153
+ * though the cardholder approved and the processor answered the vault.
154
+ * Shopify never hit this on its current host: the checkout.pci.shopifyinc.com
155
+ * card iframe posts to its own origin (its older deposit.<region>.shopifycs.com
156
+ * host is called from the checkout.shopifycs.com frame, cross-origin, and gets
157
+ * the answer like everyone else). Stripe hits it on every surface (Checkout on
158
+ * checkout.stripe.com or a merchant domain, and Elements in the js.stripe.com
159
+ * frame, all call api.stripe.com), and so does every other processor whose
160
+ * card frame calls a separate API host. Observed live on
161
+ * 2026-09-03: the vault replayed a Stripe PaymentMethod into a raw-CDP
162
+ * runtime, the browser refused the answer for want of this header, and
163
+ * Stripe Checkout showed "We are experiencing connection issues".
164
+ *
165
+ * The exact Origin is echoed rather than `*`: a credentialed request refuses
166
+ * `*`, the echo satisfies both. The processor's own value can never reach
167
+ * this adapter (a browser does not expose that header to the page that
168
+ * replayed the call), so whatever the replay carries under these names is
169
+ * replaced by the one value that is right for THIS request. Playwright adds
170
+ * the same headers inside route.fulfill when a cross-origin fulfill carries
171
+ * none (microsoft/playwright#12929), which is why attachToPlaywright never
172
+ * needed this; it writes them itself anyway, replacing a stale value, so both
173
+ * adapters answer the vault's replays identically.
174
+ *
175
+ * This widens nothing. A tokenization endpoint is built for anonymous
176
+ * browsers and answers every origin (`access-control-allow-origin: *` on
177
+ * Stripe's and Shopify's own replies), so the page that made the request
178
+ * could always read the processor's answer to it; the replay is made exactly
179
+ * as visible, to exactly that page. Whether a card goes anywhere at all is
180
+ * decided by the cardholder on the approval screen, never by this header.
181
+ *
182
+ * Only what a browser serializes is ever echoed: one canonical http(s)
183
+ * origin (`new URL(origin).origin === origin`), or the opaque `null` a
184
+ * sandboxed or data: document sends, which Chrome matches against
185
+ * `access-control-allow-origin: null` and which Playwright echoes too. That
186
+ * refuses userinfo, a path, an explicit default port, several origins in one
187
+ * value, or a control character that would break the fulfill after the
188
+ * cardholder already approved. Anything refused simply gets no CORS answer,
189
+ * which is what every fulfill got before this existed.
190
+ */
191
+ export function corsHeadersFor(url, requestHeaders) {
192
+ return corsDecision(url, requestHeaders).headers;
193
+ }
194
+ /** 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. */
195
+ export function corsDecision(url, requestHeaders) {
196
+ const none = { headers: null, outcome: 'none' };
197
+ const originEntry = Object.entries(requestHeaders ?? {}).find(([name]) => name.toLowerCase() === 'origin');
198
+ const origin = typeof originEntry?.[1] === 'string' ? originEntry[1].trim() : '';
199
+ if (!origin)
200
+ return none;
201
+ let target;
202
+ try {
203
+ target = new URL(url);
204
+ }
205
+ catch {
206
+ return none;
207
+ }
208
+ if (target.protocol !== 'https:' && target.protocol !== 'http:')
209
+ return none;
210
+ if (origin !== 'null') {
211
+ let originUrl;
212
+ try {
213
+ originUrl = new URL(origin);
214
+ }
215
+ catch {
216
+ return none;
217
+ }
218
+ if (originUrl.protocol !== 'https:' && originUrl.protocol !== 'http:')
219
+ return none;
220
+ if (originUrl.origin !== origin)
221
+ return none;
222
+ if (target.origin === origin)
223
+ return { headers: null, outcome: 'same_origin' };
224
+ }
225
+ return { headers: { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true' }, outcome: 'echoed' };
226
+ }
227
+ /**
228
+ * `headers` with the CORS answer for this request written in; the object
229
+ * itself when none is needed. Any header the answer names is replaced
230
+ * whatever its case, so a name is never sent twice. The answer varies by
231
+ * Origin, and a `vary` the replay already carries is extended rather than
232
+ * replaced (or left alone when it already covers Origin or is `*`); a `vary`
233
+ * the answer itself names is taken as given.
234
+ */
235
+ export function withCorsHeaders(headers, cors) {
236
+ if (!cors)
237
+ return headers;
238
+ const replaced = new Set(Object.keys(cors).map((name) => name.toLowerCase()));
239
+ const out = {};
240
+ let varyName = null;
241
+ for (const [name, value] of Object.entries(headers)) {
242
+ const lower = name.toLowerCase();
243
+ if (replaced.has(lower))
244
+ continue;
245
+ if (lower === 'vary')
246
+ varyName = name;
247
+ out[name] = value;
248
+ }
249
+ Object.assign(out, cors);
250
+ if (replaced.has('vary'))
251
+ return out;
252
+ if (varyName === null) {
253
+ out.vary = 'Origin';
254
+ }
255
+ else {
256
+ const existing = String(out[varyName]);
257
+ const members = existing.split(',').map((m) => m.trim().toLowerCase());
258
+ if (!members.includes('origin') && !members.includes('*'))
259
+ out[varyName] = `${existing}, Origin`;
260
+ }
261
+ return out;
262
+ }
263
+ /** CDP's header shape: `{ name, value }` entries. */
264
+ function headerEntries(headers) {
265
+ return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
266
+ }
144
267
  /**
145
268
  * Last-resort patterns: the built-in recognizers' hosts, derived the same way
146
269
  * as everything else. Used only when the vault hands back nothing at all (a
@@ -148,6 +271,19 @@ const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no
148
271
  * that drifts from the registry is exactly the bug this adapter used to have.
149
272
  */
150
273
  const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
274
+ /** Observer exceptions and server error envelopes must not interrupt or leak a processor handoff. */
275
+ function safeOptions(opts) {
276
+ const observer = opts.onEvent;
277
+ return { ...opts, onEvent: (event) => { try {
278
+ Promise.resolve(observer?.(event)).catch(() => { });
279
+ }
280
+ catch { /* observer only */ } } };
281
+ }
282
+ function failureSummary(error) {
283
+ if (error instanceof CheckoutApiError)
284
+ return `${error.name}: ${error.code ?? `http_${error.status}`}`;
285
+ return error instanceof Error ? error.name : 'CheckoutError';
286
+ }
151
287
  /**
152
288
  * Take over card tokenization for a page.
153
289
  *
@@ -166,6 +302,9 @@ const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
166
302
  * armed identically.
167
303
  */
168
304
  export async function attachToCdp(cdp, pageSessionId, opts) {
305
+ opts = safeOptions(opts);
306
+ const lifecycle = new CheckoutLifecycle(opts);
307
+ const guards = paymentEndpointGuards(opts.paymentEndpoints);
169
308
  const armed = new Set();
170
309
  // Set once a failure proves that retrying cannot help; see isTerminal.
171
310
  let terminal = null;
@@ -178,25 +317,34 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
178
317
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
179
318
  let lastSubmitted = null;
180
319
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
181
- const urlPatterns = derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS;
320
+ const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns])];
182
321
  opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
183
322
  const arm = async (sessionId) => {
184
323
  const key = sessionId ?? '__root__';
185
324
  if (armed.has(key))
186
325
  return;
187
- armed.add(key);
188
326
  await cdp.send('Fetch.enable', {
189
327
  patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
190
- }, sessionId).catch(() => { });
328
+ }, sessionId);
191
329
  // Descend into this target's own children (iframes inside iframes).
192
330
  await cdp.send('Target.setAutoAttach', {
193
331
  autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
194
- }, sessionId).catch(() => { });
332
+ }, sessionId);
333
+ armed.add(key);
195
334
  };
196
335
  cdp.on(async (method, params, sessionId) => {
197
336
  if (method === 'Target.attachedToTarget') {
198
337
  const child = params.sessionId;
199
- await arm(child);
338
+ try {
339
+ await arm(child);
340
+ }
341
+ catch {
342
+ terminal = new Error('browser_interception_unavailable');
343
+ lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
344
+ opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
345
+ // Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
346
+ return;
347
+ }
200
348
  // Child targets start paused when waitForDebuggerOnStart is set.
201
349
  await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
202
350
  return;
@@ -205,14 +353,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
205
353
  return;
206
354
  const { requestId, request, resourceType } = params;
207
355
  if (!opts.vault.isCardRequest(request.url, request.method)) {
356
+ if (guards.matches(request.url, request.method)) {
357
+ lifecycle.unsupported();
358
+ opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
359
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
360
+ return;
361
+ }
208
362
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
209
363
  return;
210
364
  }
211
365
  // Same stop condition as the Playwright adapter: once a failure proves
212
366
  // retrying is pointless, fail the request without calling the API again.
213
- if (terminal || awaitingApproval || Date.now() < quietUntil) {
214
- const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
215
- opts.onEvent?.({ type: 'blocked', detail: String(why) });
367
+ if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
368
+ const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
369
+ opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
216
370
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
217
371
  return;
218
372
  }
@@ -220,6 +374,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
220
374
  // requests can never both clear the check above and raise two prompts for
221
375
  // one checkout.
222
376
  awaitingApproval = true;
377
+ let handoffStarted = false;
223
378
  try {
224
379
  const body = pausedBody(request);
225
380
  if (body === null) {
@@ -239,15 +394,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
239
394
  return;
240
395
  }
241
396
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
397
+ lifecycle.begin();
242
398
  const replay = await opts.vault.authorize({
243
399
  user: opts.user,
244
400
  merchant: opts.merchant,
245
401
  amount: opts.amount,
246
402
  amountCents: opts.amountCents,
247
403
  currency: opts.currency,
248
- onApprovalUrl: opts.onApprovalUrl,
404
+ cardId: opts.cardId,
405
+ timeoutMs: opts.timeoutMs,
406
+ signal: lifecycle.abort.signal,
407
+ onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
408
+ onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
249
409
  request: { url: request.url, method: request.method, headers: request.headers, body },
250
410
  });
411
+ if (lifecycle.isCancelled())
412
+ throw new Error('checkout cancelled locally after approval');
413
+ lifecycle.prepareHandoff(replay, request.url);
414
+ handoffStarted = replay.mode !== 'cse';
251
415
  if (replay.mode === 'hosted_form') {
252
416
  // The device submitted the processor's own form; the processor
253
417
  // answered the device. The paused NAVIGATION is fulfilled with a page
@@ -255,10 +419,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
255
419
  // why not a fake result page), and the same form is refused if the
256
420
  // page posts it again.
257
421
  const page = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
422
+ // A navigation response is never CORS-checked, so the CORS wrap is
423
+ // inert here; it is applied so every fulfill goes through one path.
258
424
  await cdp.send('Fetch.fulfillRequest', {
259
425
  requestId,
260
426
  responseCode: page.status,
261
- responseHeaders: Object.entries(page.headers).map(([name, value]) => ({ name, value })),
427
+ responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
262
428
  body: Buffer.from(page.body).toString('base64'),
263
429
  }, sessionId);
264
430
  lastSubmitted = { url: request.url, body, at: Date.now() };
@@ -272,35 +438,48 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
272
438
  // and cookies, and only the four ciphertext fields swapped in. Only
273
439
  // postData rides on the command: no header override, ever (see
274
440
  // cseBody for why a recomputed Content-Length is refused by Chromium).
441
+ const postData = Buffer.from(cseBody(body, replay)).toString('base64');
442
+ handoffStarted = true;
275
443
  await cdp.send('Fetch.continueRequest', {
276
444
  requestId,
277
- postData: Buffer.from(cseBody(body, replay)).toString('base64'),
445
+ postData,
278
446
  }, sessionId);
279
447
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
280
448
  }
281
449
  else {
450
+ // The processor's answer, handed to the page as if the processor had
451
+ // sent it. Cross-origin, the browser then runs its CORS check on it:
452
+ // see corsHeadersFor for why the answer has to carry the headers. The
453
+ // decision rides on the event, so a fulfill the page could not read
454
+ // (no usable Origin on a cross-origin request) is visible in telemetry
455
+ // rather than only as the page's own "connection error".
456
+ const cors = corsDecision(request.url, request.headers);
282
457
  await cdp.send('Fetch.fulfillRequest', {
283
458
  requestId,
284
459
  responseCode: replay.status,
285
- responseHeaders: Object.entries(replay.headers).map(([name, value]) => ({ name, value: String(value) })),
460
+ responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
286
461
  body: Buffer.from(replay.body).toString('base64'),
287
462
  }, sessionId);
288
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null } });
463
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
289
464
  }
465
+ lifecycle.handedOff(replay);
290
466
  }
291
467
  catch (err) {
468
+ lifecycle.failed(err, handoffStarted);
292
469
  if (isTerminal(err))
293
470
  terminal = err;
294
471
  else if (isApprovalOutcome(err))
295
472
  quietUntil = Date.now() + cooldownMs;
296
- opts.onEvent?.({ type: 'failed', detail: String(err) });
473
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
297
474
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
298
475
  }
299
476
  finally {
300
477
  awaitingApproval = false;
478
+ lifecycle.end();
301
479
  }
302
480
  });
303
481
  await arm(pageSessionId);
482
+ return lifecycle;
304
483
  }
305
484
  /**
306
485
  * Playwright convenience wrapper — the path for cloud browsers that hand you a
@@ -314,6 +493,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
314
493
  * the note in the body for why a hand-rolled CDPSession does not work here.
315
494
  */
316
495
  export async function attachToPlaywright(page, opts) {
496
+ opts = safeOptions(opts);
497
+ // Routing cannot see requests owned by a service worker. Existing controlled
498
+ // contexts must be recreated with serviceWorkers: 'block' before checkout.
499
+ if (page.context?.().serviceWorkers?.().length) {
500
+ throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
501
+ }
502
+ const lifecycle = new CheckoutLifecycle(opts);
503
+ const guards = paymentEndpointGuards(opts.paymentEndpoints);
317
504
  // Playwright's own routing, NOT a hand-rolled CDP session.
318
505
  //
319
506
  // A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
@@ -336,11 +523,16 @@ export async function attachToPlaywright(page, opts) {
336
523
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
337
524
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
338
525
  let lastSubmitted = null;
339
- await page.route((url) => opts.vault.isCardRequest(url.toString()), async (route) => {
526
+ await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
340
527
  const request = route.request();
341
528
  // The matcher only sees the URL; a preflight or a GET must pass through
342
529
  // untouched or the browser's CORS check fails on our synthetic answer.
343
530
  if (!opts.vault.isCardRequest(request.url(), request.method())) {
531
+ if (guards.matches(request.url(), request.method())) {
532
+ lifecycle.unsupported();
533
+ opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
534
+ return route.abort('aborted');
535
+ }
344
536
  return route.fallback();
345
537
  }
346
538
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
@@ -348,13 +540,14 @@ export async function attachToPlaywright(page, opts) {
348
540
  // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
349
541
  // CDP adapter's Fetch.failRequest uses, so a refused navigation
350
542
  // resolves identically whichever adapter is attached.
351
- if (terminal || awaitingApproval || Date.now() < quietUntil) {
352
- const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
353
- opts.onEvent?.({ type: 'blocked', detail: String(why) });
543
+ if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
544
+ const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
545
+ opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
354
546
  return route.abort('aborted');
355
547
  }
356
548
  // Reserved before anything that could yield, matching attachToCdp.
357
549
  awaitingApproval = true;
550
+ let handoffStarted = false;
358
551
  try {
359
552
  const body = request.postData() ?? '';
360
553
  if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
@@ -362,20 +555,30 @@ export async function attachToPlaywright(page, opts) {
362
555
  return await route.abort('aborted');
363
556
  }
364
557
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
558
+ lifecycle.begin();
365
559
  const replay = await opts.vault.authorize({
366
560
  user: opts.user,
367
561
  merchant: opts.merchant,
368
562
  amount: opts.amount,
369
563
  amountCents: opts.amountCents,
370
564
  currency: opts.currency,
371
- onApprovalUrl: opts.onApprovalUrl,
565
+ cardId: opts.cardId,
566
+ timeoutMs: opts.timeoutMs,
567
+ signal: lifecycle.abort.signal,
568
+ onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
569
+ onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
372
570
  request: { url: request.url(), method: request.method(), headers: request.headers(), body },
373
571
  });
572
+ if (lifecycle.isCancelled())
573
+ throw new Error('checkout cancelled locally after approval');
574
+ lifecycle.prepareHandoff(replay, request.url());
575
+ handoffStarted = replay.mode !== 'cse';
374
576
  if (replay.mode === 'hosted_form') {
375
577
  // Same as the CDP path: the paused navigation resolves to the
376
578
  // synthetic page, and a re-post of this form is refused.
377
579
  const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
378
- await route.fulfill({ status: synthetic.status, headers: synthetic.headers, body: synthetic.body });
580
+ // Inert on a navigation (never CORS-checked); one path for every fulfill.
581
+ await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
379
582
  lastSubmitted = { url: request.url(), body, at: Date.now() };
380
583
  opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
381
584
  }
@@ -383,24 +586,34 @@ export async function attachToPlaywright(page, opts) {
383
586
  // Same as the CDP path: the request continues from this browser
384
587
  // with the ciphertext swapped in and no header override; Playwright
385
588
  // recomputes the length itself.
386
- await route.continue({ postData: cseBody(body, replay) });
589
+ const postData = cseBody(body, replay);
590
+ handoffStarted = true;
591
+ await route.continue({ postData });
387
592
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
388
593
  }
389
594
  else {
390
- await route.fulfill({ status: replay.status, headers: replay.headers, body: replay.body });
391
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null } });
595
+ // Playwright adds these itself when a cross-origin fulfill carries
596
+ // none; written here anyway (replacing a stale value) so a
597
+ // cross-origin answer is the same whichever adapter ran.
598
+ const cors = corsDecision(request.url(), request.headers());
599
+ await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
600
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
392
601
  }
602
+ lifecycle.handedOff(replay);
393
603
  }
394
604
  catch (err) {
605
+ lifecycle.failed(err, handoffStarted);
395
606
  if (isTerminal(err))
396
607
  terminal = err;
397
608
  else if (isApprovalOutcome(err))
398
609
  quietUntil = Date.now() + cooldownMs;
399
- opts.onEvent?.({ type: 'failed', detail: String(err) });
610
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
400
611
  await route.abort('aborted');
401
612
  }
402
613
  finally {
403
614
  awaitingApproval = false;
615
+ lifecycle.end();
404
616
  }
405
617
  });
618
+ return lifecycle;
406
619
  }
package/dist/client.d.ts CHANGED
@@ -133,6 +133,10 @@ export interface AuthorizeInput {
133
133
  request: PausedRequest;
134
134
  /** Abort if the user has not approved within this many ms. Default 15 min. */
135
135
  timeoutMs?: number;
136
+ /** Stops local polling; it does not revoke a pending approval or undo a payment. */
137
+ signal?: AbortSignal;
138
+ /** Called before onApprovalUrl; lets a runtime reconcile an interrupted authorization. */
139
+ onAuthorizationCreated?: (authorizationId: string) => void;
136
140
  /** Called once with the URL to surface to the user, if you deliver it yourself. */
137
141
  onApprovalUrl?: (url: string) => void;
138
142
  }
@@ -141,10 +145,9 @@ export declare class CardEncryptedError extends Error {
141
145
  constructor(psp: string);
142
146
  }
143
147
  /**
144
- * The API approved an authorization in a mode this SDK build cannot finish.
145
- * Unreachable by construction (syncRegistry asks only for SUPPORTED_MODES and
146
- * every create names its mode), so it is treated as terminal: retrying the
147
- * same page could only raise more prompts for the same dead end.
148
+ * The registry requests a mode this SDK build cannot finish, before creation.
149
+ * A response in an unexpected mode after creation has an unknown payment
150
+ * outcome instead and raises PaymentOutcomeUnknownError.
148
151
  */
149
152
  export declare class UnsupportedModeError extends Error {
150
153
  mode: string;
@@ -153,6 +156,15 @@ export declare class UnsupportedModeError extends Error {
153
156
  export declare class ApprovalTimeoutError extends Error {
154
157
  constructor(ms: number);
155
158
  }
159
+ export declare class CheckoutCancelledError extends Error {
160
+ constructor();
161
+ }
162
+ /** The payment may have reached the processor. Reconcile the merchant order before any new attempt. */
163
+ export declare class PaymentOutcomeUnknownError extends Error {
164
+ authorizationId: string | null;
165
+ reason: string;
166
+ constructor(authorizationId: string | null, reason: string);
167
+ }
156
168
  export declare class ApprovalDeclinedError extends Error {
157
169
  constructor(reason: string);
158
170
  }