@agent-cards/checkout 0.2.1 → 0.3.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,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
@@ -270,6 +271,35 @@ function headerEntries(headers) {
270
271
  * that drifts from the registry is exactly the bug this adapter used to have.
271
272
  */
272
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
+ }
287
+ /** Separate from explicit cancellation: the client can drain a late create ID. */
288
+ function merchantAttempt(lifecycle) {
289
+ const controller = new AbortController();
290
+ const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'merchant_request_aborted');
291
+ return {
292
+ signal: controller.signal,
293
+ stop() {
294
+ if (controller.signal.aborted)
295
+ return;
296
+ lifecycle.merchantRequestAborted();
297
+ controller.abort(error());
298
+ },
299
+ assertLive() { if (controller.signal.aborted)
300
+ throw error(); },
301
+ };
302
+ }
273
303
  /**
274
304
  * Take over card tokenization for a page.
275
305
  *
@@ -288,6 +318,9 @@ const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
288
318
  * armed identically.
289
319
  */
290
320
  export async function attachToCdp(cdp, pageSessionId, opts) {
321
+ opts = safeOptions(opts);
322
+ const lifecycle = new CheckoutLifecycle(opts);
323
+ const guards = paymentEndpointGuards(opts.paymentEndpoints);
291
324
  const armed = new Set();
292
325
  // Set once a failure proves that retrying cannot help; see isTerminal.
293
326
  let terminal = null;
@@ -296,45 +329,77 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
296
329
  let quietUntil = 0;
297
330
  // One outstanding approval at a time; see the note above isApprovalOutcome.
298
331
  let awaitingApproval = false;
332
+ let activeRequest = null;
299
333
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
300
334
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
301
335
  let lastSubmitted = null;
302
336
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
303
- const urlPatterns = derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS;
337
+ const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns])];
304
338
  opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
305
339
  const arm = async (sessionId) => {
306
340
  const key = sessionId ?? '__root__';
307
341
  if (armed.has(key))
308
342
  return;
309
- armed.add(key);
343
+ // Fetch's interception ID differs from Network's ID. Enable failure events
344
+ // before intercepting and bind each attempt to both its ID and CDP session.
345
+ await cdp.send('Network.enable', {}, sessionId);
346
+ await cdp.send('Page.enable', {}, sessionId).catch(() => { });
310
347
  await cdp.send('Fetch.enable', {
311
348
  patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
312
- }, sessionId).catch(() => { });
349
+ }, sessionId);
313
350
  // Descend into this target's own children (iframes inside iframes).
314
351
  await cdp.send('Target.setAutoAttach', {
315
352
  autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
316
- }, sessionId).catch(() => { });
353
+ }, sessionId);
354
+ armed.add(key);
317
355
  };
318
356
  cdp.on(async (method, params, sessionId) => {
357
+ if (method === 'Network.loadingFailed') {
358
+ if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sessionId === sessionId)
359
+ activeRequest.attempt.stop();
360
+ return;
361
+ }
362
+ if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
363
+ if (activeRequest && (method === 'Target.detachedFromTarget' ? activeRequest.sessionId === params.sessionId
364
+ : method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
365
+ : activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
366
+ activeRequest.attempt.stop();
367
+ return;
368
+ }
319
369
  if (method === 'Target.attachedToTarget') {
320
370
  const child = params.sessionId;
321
- await arm(child);
371
+ try {
372
+ await arm(child);
373
+ }
374
+ catch {
375
+ terminal = new Error('browser_interception_unavailable');
376
+ lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
377
+ opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
378
+ // Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
379
+ return;
380
+ }
322
381
  // Child targets start paused when waitForDebuggerOnStart is set.
323
382
  await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
324
383
  return;
325
384
  }
326
385
  if (method !== 'Fetch.requestPaused')
327
386
  return;
328
- const { requestId, request, resourceType } = params;
387
+ const { requestId, request, resourceType, networkId, frameId } = params;
329
388
  if (!opts.vault.isCardRequest(request.url, request.method)) {
389
+ if (guards.matches(request.url, request.method)) {
390
+ lifecycle.unsupported();
391
+ opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
392
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
393
+ return;
394
+ }
330
395
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
331
396
  return;
332
397
  }
333
398
  // Same stop condition as the Playwright adapter: once a failure proves
334
399
  // 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) });
400
+ if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
401
+ const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
402
+ opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
338
403
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
339
404
  return;
340
405
  }
@@ -342,6 +407,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
342
407
  // requests can never both clear the check above and raise two prompts for
343
408
  // one checkout.
344
409
  awaitingApproval = true;
410
+ const attempt = merchantAttempt(lifecycle);
411
+ let handoffStarted = false;
345
412
  try {
346
413
  const body = pausedBody(request);
347
414
  if (body === null) {
@@ -361,15 +428,34 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
361
428
  return;
362
429
  }
363
430
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
431
+ lifecycle.begin();
432
+ if (typeof networkId !== 'string' || !networkId) {
433
+ attempt.stop();
434
+ attempt.assertLive();
435
+ }
436
+ activeRequest = { attempt, networkId, sessionId, frameId };
364
437
  const replay = await opts.vault.authorize({
365
438
  user: opts.user,
366
439
  merchant: opts.merchant,
367
440
  amount: opts.amount,
368
441
  amountCents: opts.amountCents,
369
442
  currency: opts.currency,
370
- onApprovalUrl: opts.onApprovalUrl,
443
+ cardId: opts.cardId,
444
+ timeoutMs: opts.timeoutMs,
445
+ signal: lifecycle.abort.signal,
446
+ merchantSignal: attempt.signal,
447
+ onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
448
+ onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
449
+ lifecycle.approvalUrl(url);
450
+ return opts.onApprovalUrl?.(url);
451
+ } },
371
452
  request: { url: request.url, method: request.method, headers: request.headers, body },
372
453
  });
454
+ attempt.assertLive();
455
+ if (lifecycle.isCancelled())
456
+ throw new Error('checkout cancelled locally after approval');
457
+ lifecycle.prepareHandoff(replay, request.url);
458
+ handoffStarted = replay.mode !== 'cse';
373
459
  if (replay.mode === 'hosted_form') {
374
460
  // The device submitted the processor's own form; the processor
375
461
  // answered the device. The paused NAVIGATION is fulfilled with a page
@@ -385,6 +471,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
385
471
  responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
386
472
  body: Buffer.from(page.body).toString('base64'),
387
473
  }, sessionId);
474
+ attempt.assertLive();
388
475
  lastSubmitted = { url: request.url, body, at: Date.now() };
389
476
  // Named for what it is: a device-attested submission with no
390
477
  // processor evidence, never an `authorized` event.
@@ -396,10 +483,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
396
483
  // and cookies, and only the four ciphertext fields swapped in. Only
397
484
  // postData rides on the command: no header override, ever (see
398
485
  // cseBody for why a recomputed Content-Length is refused by Chromium).
486
+ const postData = Buffer.from(cseBody(body, replay)).toString('base64');
487
+ handoffStarted = true;
399
488
  await cdp.send('Fetch.continueRequest', {
400
489
  requestId,
401
- postData: Buffer.from(cseBody(body, replay)).toString('base64'),
490
+ postData,
402
491
  }, sessionId);
492
+ attempt.assertLive();
403
493
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
404
494
  }
405
495
  else {
@@ -416,22 +506,32 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
416
506
  responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
417
507
  body: Buffer.from(replay.body).toString('base64'),
418
508
  }, sessionId);
509
+ attempt.assertLive();
419
510
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
420
511
  }
512
+ lifecycle.handedOff(replay);
421
513
  }
422
514
  catch (err) {
515
+ // Ignore the failure event caused by our own safe decline/error abort.
516
+ if (activeRequest?.attempt === attempt)
517
+ activeRequest = null;
518
+ lifecycle.failed(err, handoffStarted);
423
519
  if (isTerminal(err))
424
520
  terminal = err;
425
521
  else if (isApprovalOutcome(err))
426
522
  quietUntil = Date.now() + cooldownMs;
427
- opts.onEvent?.({ type: 'failed', detail: String(err) });
523
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
428
524
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
429
525
  }
430
526
  finally {
527
+ if (activeRequest?.attempt === attempt)
528
+ activeRequest = null;
431
529
  awaitingApproval = false;
530
+ lifecycle.end();
432
531
  }
433
532
  });
434
533
  await arm(pageSessionId);
534
+ return lifecycle;
435
535
  }
436
536
  /**
437
537
  * Playwright convenience wrapper — the path for cloud browsers that hand you a
@@ -445,6 +545,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
445
545
  * the note in the body for why a hand-rolled CDPSession does not work here.
446
546
  */
447
547
  export async function attachToPlaywright(page, opts) {
548
+ opts = safeOptions(opts);
549
+ // Routing cannot see requests owned by a service worker. Existing controlled
550
+ // contexts must be recreated with serviceWorkers: 'block' before checkout.
551
+ if (page.context?.().serviceWorkers?.().length) {
552
+ throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
553
+ }
554
+ const lifecycle = new CheckoutLifecycle(opts);
555
+ const guards = paymentEndpointGuards(opts.paymentEndpoints);
448
556
  // Playwright's own routing, NOT a hand-rolled CDP session.
449
557
  //
450
558
  // A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
@@ -464,14 +572,30 @@ export async function attachToPlaywright(page, opts) {
464
572
  let quietUntil = 0;
465
573
  // One outstanding approval at a time; see the note above isApprovalOutcome.
466
574
  let awaitingApproval = false;
575
+ let activeRequest = null;
576
+ page.on?.('requestfailed', (request) => {
577
+ if (activeRequest && activeRequest.request === request)
578
+ activeRequest.attempt.stop();
579
+ });
580
+ page.on?.('close', () => activeRequest?.attempt.stop());
581
+ page.on?.('crash', () => activeRequest?.attempt.stop());
582
+ page.on?.('framedetached', (frame) => {
583
+ if (activeRequest?.frames.includes(frame))
584
+ activeRequest.attempt.stop();
585
+ });
467
586
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
468
587
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
469
588
  let lastSubmitted = null;
470
- await page.route((url) => opts.vault.isCardRequest(url.toString()), async (route) => {
589
+ await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
471
590
  const request = route.request();
472
591
  // The matcher only sees the URL; a preflight or a GET must pass through
473
592
  // untouched or the browser's CORS check fails on our synthetic answer.
474
593
  if (!opts.vault.isCardRequest(request.url(), request.method())) {
594
+ if (guards.matches(request.url(), request.method())) {
595
+ lifecycle.unsupported();
596
+ opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
597
+ return route.abort('aborted');
598
+ }
475
599
  return route.fallback();
476
600
  }
477
601
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
@@ -479,13 +603,26 @@ export async function attachToPlaywright(page, opts) {
479
603
  // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
480
604
  // CDP adapter's Fetch.failRequest uses, so a refused navigation
481
605
  // 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) });
606
+ if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
607
+ const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
608
+ opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
485
609
  return route.abort('aborted');
486
610
  }
487
611
  // Reserved before anything that could yield, matching attachToCdp.
488
612
  awaitingApproval = true;
613
+ const attempt = merchantAttempt(lifecycle);
614
+ const frames = [];
615
+ try {
616
+ for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
617
+ frames.push(frame);
618
+ }
619
+ catch { /* requestfailed/page close still cover unavailable frame metadata */ }
620
+ const assertRequestLive = () => {
621
+ if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
622
+ attempt.stop();
623
+ attempt.assertLive();
624
+ };
625
+ let handoffStarted = false;
489
626
  try {
490
627
  const body = request.postData() ?? '';
491
628
  if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
@@ -493,21 +630,38 @@ export async function attachToPlaywright(page, opts) {
493
630
  return await route.abort('aborted');
494
631
  }
495
632
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
633
+ lifecycle.begin();
634
+ activeRequest = { request, frames, attempt };
635
+ assertRequestLive();
496
636
  const replay = await opts.vault.authorize({
497
637
  user: opts.user,
498
638
  merchant: opts.merchant,
499
639
  amount: opts.amount,
500
640
  amountCents: opts.amountCents,
501
641
  currency: opts.currency,
502
- onApprovalUrl: opts.onApprovalUrl,
642
+ cardId: opts.cardId,
643
+ timeoutMs: opts.timeoutMs,
644
+ signal: lifecycle.abort.signal,
645
+ merchantSignal: attempt.signal,
646
+ onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
647
+ onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
648
+ lifecycle.approvalUrl(url);
649
+ return opts.onApprovalUrl?.(url);
650
+ } },
503
651
  request: { url: request.url(), method: request.method(), headers: request.headers(), body },
504
652
  });
653
+ assertRequestLive();
654
+ if (lifecycle.isCancelled())
655
+ throw new Error('checkout cancelled locally after approval');
656
+ lifecycle.prepareHandoff(replay, request.url());
657
+ handoffStarted = replay.mode !== 'cse';
505
658
  if (replay.mode === 'hosted_form') {
506
659
  // Same as the CDP path: the paused navigation resolves to the
507
660
  // synthetic page, and a re-post of this form is refused.
508
661
  const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
509
662
  // Inert on a navigation (never CORS-checked); one path for every fulfill.
510
663
  await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
664
+ assertRequestLive();
511
665
  lastSubmitted = { url: request.url(), body, at: Date.now() };
512
666
  opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
513
667
  }
@@ -515,7 +669,10 @@ export async function attachToPlaywright(page, opts) {
515
669
  // Same as the CDP path: the request continues from this browser
516
670
  // with the ciphertext swapped in and no header override; Playwright
517
671
  // recomputes the length itself.
518
- await route.continue({ postData: cseBody(body, replay) });
672
+ const postData = cseBody(body, replay);
673
+ handoffStarted = true;
674
+ await route.continue({ postData });
675
+ assertRequestLive();
519
676
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
520
677
  }
521
678
  else {
@@ -524,19 +681,28 @@ export async function attachToPlaywright(page, opts) {
524
681
  // cross-origin answer is the same whichever adapter ran.
525
682
  const cors = corsDecision(request.url(), request.headers());
526
683
  await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
684
+ assertRequestLive();
527
685
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
528
686
  }
687
+ lifecycle.handedOff(replay);
529
688
  }
530
689
  catch (err) {
690
+ if (activeRequest?.attempt === attempt)
691
+ activeRequest = null;
692
+ lifecycle.failed(err, handoffStarted);
531
693
  if (isTerminal(err))
532
694
  terminal = err;
533
695
  else if (isApprovalOutcome(err))
534
696
  quietUntil = Date.now() + cooldownMs;
535
- opts.onEvent?.({ type: 'failed', detail: String(err) });
536
- await route.abort('aborted');
697
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
698
+ await route.abort('aborted').catch(() => { });
537
699
  }
538
700
  finally {
701
+ if (activeRequest?.attempt === attempt)
702
+ activeRequest = null;
539
703
  awaitingApproval = false;
704
+ lifecycle.end();
540
705
  }
541
706
  });
707
+ return lifecycle;
542
708
  }
package/dist/client.d.ts CHANGED
@@ -133,6 +133,12 @@ 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
+ /** Adapter-owned merchant request lifetime. A failed request retires a pending approval before replay when possible. */
139
+ merchantSignal?: AbortSignal;
140
+ /** Called before onApprovalUrl; lets a runtime reconcile an interrupted authorization. */
141
+ onAuthorizationCreated?: (authorizationId: string) => void;
136
142
  /** Called once with the URL to surface to the user, if you deliver it yourself. */
137
143
  onApprovalUrl?: (url: string) => void;
138
144
  }
@@ -141,10 +147,9 @@ export declare class CardEncryptedError extends Error {
141
147
  constructor(psp: string);
142
148
  }
143
149
  /**
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.
150
+ * The registry requests a mode this SDK build cannot finish, before creation.
151
+ * A response in an unexpected mode after creation has an unknown payment
152
+ * outcome instead and raises PaymentOutcomeUnknownError.
148
153
  */
149
154
  export declare class UnsupportedModeError extends Error {
150
155
  mode: string;
@@ -153,6 +158,15 @@ export declare class UnsupportedModeError extends Error {
153
158
  export declare class ApprovalTimeoutError extends Error {
154
159
  constructor(ms: number);
155
160
  }
161
+ export declare class CheckoutCancelledError extends Error {
162
+ constructor();
163
+ }
164
+ /** The payment may have reached the processor. Reconcile the merchant order before any new attempt. */
165
+ export declare class PaymentOutcomeUnknownError extends Error {
166
+ authorizationId: string | null;
167
+ reason: string;
168
+ constructor(authorizationId: string | null, reason: string);
169
+ }
156
170
  export declare class ApprovalDeclinedError extends Error {
157
171
  constructor(reason: string);
158
172
  }
@@ -324,6 +338,14 @@ export declare class VaultClient {
324
338
  * response to replay into the browser. Your process never sees a card.
325
339
  */
326
340
  authorize(input: AuthorizeInput): Promise<ReplayResponse>;
341
+ /** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
342
+ cancelAuthorization(authorizationId: string): Promise<{
343
+ id: string;
344
+ status: 'declined';
345
+ reason: 'merchant_request_aborted';
346
+ cancelled: true;
347
+ processor_request_started: false;
348
+ }>;
327
349
  /**
328
350
  * POST the create, with two typed twists: a 502 `amount_unverifiable`
329
351
  * (Stripe did not answer the read-back) is retried on a short backoff