@agent-cards/checkout 0.3.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ - Detect a merchant request abort or owning frame/page closure while approval is pending. The attachment holds an unknown outcome and blocks automatic retries; an expired request is never reported as an authorized handoff.
6
+ - Retire pending authorizations through the org-scoped cancellation endpoint when the merchant request disappears. Cancellation can win only before the processor-send boundary; started or completed replay remains unknown to this operation. A late creation response is drained so its authorization ID can be cancelled without exposing another approval link.
7
+ - Square token mode uses the vault's verified browser TLS relay. The relay keeps TLS termination and card plaintext on the cardholder device and Square. SDK 0.2.1 can complete a prompt approval but does not contain the native-timeout lifecycle fixes; upgrade to 0.3.1 for Square checkout.
8
+ - Square's native tokenization request still expires after about 10 seconds, including approval and token handoff. Delayed approval cannot complete that checkout; a later SCA challenge has its own lifetime after handoff. This release does not extend the deadline or retry a payment after timeout. Reconcile the merchant outcome and explicitly begin another checkout when required.
9
+
3
10
  ## 0.3.0
4
11
 
5
12
  ### Browser checkout lifecycle
package/README.md CHANGED
@@ -30,7 +30,7 @@ your agent ──drives──> merchant checkout
30
30
  npm i @agent-cards/checkout
31
31
  ```
32
32
 
33
- Upgrading from 0.2.x? Read the [0.3.0 migration notes](./CHANGELOG.md), especially
33
+ Upgrading from 0.2.x? Read the [migration notes](./CHANGELOG.md), especially
34
34
  the unknown-outcome, cancellation and browser-context requirements.
35
35
 
36
36
  ## Use it
@@ -79,8 +79,11 @@ Stripe's minor units, so the approval screen, the notifications and every
79
79
  read show one amount. Tokenization requests carry no amount, so there the
80
80
  pair is shown and reported (`amountAuthority: 'display_only'`), not enforced.
81
81
 
82
- Then let your agent click "Pay" like it always does. `attachToCdp` holds the
83
- request open until the cardholder approves, so the checkout simply continues.
82
+ Then let your agent click "Pay" like it always does. `attachToCdp` pauses the
83
+ request for approval and resumes it only while the merchant request remains
84
+ live. Merchant timeouts still apply: Square's observed tokenization deadline
85
+ is about 10 seconds for approval and token handoff, so delayed approval cannot
86
+ complete that checkout.
84
87
 
85
88
  Playwright:
86
89
 
@@ -389,6 +392,8 @@ do not reuse that token in a new attachment as a workaround. Configuration and
389
392
  unsupported-mode failures require fixing the integration. Bank flows requiring
390
393
  another confirmation and other stored-token chains remain unverified.
391
394
 
395
+ Square saved-card checkout requires SDK 0.3.1 for merchant-request lifetime handling. Its observed native tokenization request expires after about 10 seconds, including approval-page loading, unlocking, approval, relay and token handoff. An approval that exceeds this window cannot finish that checkout. A subsequent SCA challenge has its own lifetime after the token handoff. This SDK does not pause Square's timers or automatically retry an expired checkout. If the merchant request aborts or its frame closes, the attachment blocks further card requests and tries to retire a pre-replay approval. A started replay or unconfirmed cancellation remains unknown. General delayed human approval is not supported by this Square flow.
396
+
392
397
  Lost authorization polling, local approval timeouts, or interrupted browser
393
398
  handoffs produce `outcome_unknown` and block automatic retry. The thrown
394
399
  `PaymentOutcomeUnknownError` carries `authorizationId` when creation was
package/dist/cdp.js CHANGED
@@ -284,6 +284,22 @@ function failureSummary(error) {
284
284
  return `${error.name}: ${error.code ?? `http_${error.status}`}`;
285
285
  return error instanceof Error ? error.name : 'CheckoutError';
286
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
+ }
287
303
  /**
288
304
  * Take over card tokenization for a page.
289
305
  *
@@ -313,6 +329,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
313
329
  let quietUntil = 0;
314
330
  // One outstanding approval at a time; see the note above isApprovalOutcome.
315
331
  let awaitingApproval = false;
332
+ let activeRequest = null;
316
333
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
317
334
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
318
335
  let lastSubmitted = null;
@@ -323,6 +340,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
323
340
  const key = sessionId ?? '__root__';
324
341
  if (armed.has(key))
325
342
  return;
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(() => { });
326
347
  await cdp.send('Fetch.enable', {
327
348
  patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
328
349
  }, sessionId);
@@ -333,6 +354,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
333
354
  armed.add(key);
334
355
  };
335
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
+ }
336
369
  if (method === 'Target.attachedToTarget') {
337
370
  const child = params.sessionId;
338
371
  try {
@@ -351,7 +384,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
351
384
  }
352
385
  if (method !== 'Fetch.requestPaused')
353
386
  return;
354
- const { requestId, request, resourceType } = params;
387
+ const { requestId, request, resourceType, networkId, frameId } = params;
355
388
  if (!opts.vault.isCardRequest(request.url, request.method)) {
356
389
  if (guards.matches(request.url, request.method)) {
357
390
  lifecycle.unsupported();
@@ -374,6 +407,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
374
407
  // requests can never both clear the check above and raise two prompts for
375
408
  // one checkout.
376
409
  awaitingApproval = true;
410
+ const attempt = merchantAttempt(lifecycle);
377
411
  let handoffStarted = false;
378
412
  try {
379
413
  const body = pausedBody(request);
@@ -395,6 +429,11 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
395
429
  }
396
430
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
397
431
  lifecycle.begin();
432
+ if (typeof networkId !== 'string' || !networkId) {
433
+ attempt.stop();
434
+ attempt.assertLive();
435
+ }
436
+ activeRequest = { attempt, networkId, sessionId, frameId };
398
437
  const replay = await opts.vault.authorize({
399
438
  user: opts.user,
400
439
  merchant: opts.merchant,
@@ -404,10 +443,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
404
443
  cardId: opts.cardId,
405
444
  timeoutMs: opts.timeoutMs,
406
445
  signal: lifecycle.abort.signal,
446
+ merchantSignal: attempt.signal,
407
447
  onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
408
- onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
448
+ onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
449
+ lifecycle.approvalUrl(url);
450
+ return opts.onApprovalUrl?.(url);
451
+ } },
409
452
  request: { url: request.url, method: request.method, headers: request.headers, body },
410
453
  });
454
+ attempt.assertLive();
411
455
  if (lifecycle.isCancelled())
412
456
  throw new Error('checkout cancelled locally after approval');
413
457
  lifecycle.prepareHandoff(replay, request.url);
@@ -427,6 +471,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
427
471
  responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
428
472
  body: Buffer.from(page.body).toString('base64'),
429
473
  }, sessionId);
474
+ attempt.assertLive();
430
475
  lastSubmitted = { url: request.url, body, at: Date.now() };
431
476
  // Named for what it is: a device-attested submission with no
432
477
  // processor evidence, never an `authorized` event.
@@ -444,6 +489,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
444
489
  requestId,
445
490
  postData,
446
491
  }, sessionId);
492
+ attempt.assertLive();
447
493
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
448
494
  }
449
495
  else {
@@ -460,11 +506,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
460
506
  responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
461
507
  body: Buffer.from(replay.body).toString('base64'),
462
508
  }, sessionId);
509
+ attempt.assertLive();
463
510
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
464
511
  }
465
512
  lifecycle.handedOff(replay);
466
513
  }
467
514
  catch (err) {
515
+ // Ignore the failure event caused by our own safe decline/error abort.
516
+ if (activeRequest?.attempt === attempt)
517
+ activeRequest = null;
468
518
  lifecycle.failed(err, handoffStarted);
469
519
  if (isTerminal(err))
470
520
  terminal = err;
@@ -474,6 +524,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
474
524
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
475
525
  }
476
526
  finally {
527
+ if (activeRequest?.attempt === attempt)
528
+ activeRequest = null;
477
529
  awaitingApproval = false;
478
530
  lifecycle.end();
479
531
  }
@@ -520,6 +572,17 @@ export async function attachToPlaywright(page, opts) {
520
572
  let quietUntil = 0;
521
573
  // One outstanding approval at a time; see the note above isApprovalOutcome.
522
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
+ });
523
586
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
524
587
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
525
588
  let lastSubmitted = null;
@@ -547,6 +610,18 @@ export async function attachToPlaywright(page, opts) {
547
610
  }
548
611
  // Reserved before anything that could yield, matching attachToCdp.
549
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
+ };
550
625
  let handoffStarted = false;
551
626
  try {
552
627
  const body = request.postData() ?? '';
@@ -556,6 +631,8 @@ export async function attachToPlaywright(page, opts) {
556
631
  }
557
632
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
558
633
  lifecycle.begin();
634
+ activeRequest = { request, frames, attempt };
635
+ assertRequestLive();
559
636
  const replay = await opts.vault.authorize({
560
637
  user: opts.user,
561
638
  merchant: opts.merchant,
@@ -565,10 +642,15 @@ export async function attachToPlaywright(page, opts) {
565
642
  cardId: opts.cardId,
566
643
  timeoutMs: opts.timeoutMs,
567
644
  signal: lifecycle.abort.signal,
645
+ merchantSignal: attempt.signal,
568
646
  onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
569
- onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
647
+ onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
648
+ lifecycle.approvalUrl(url);
649
+ return opts.onApprovalUrl?.(url);
650
+ } },
570
651
  request: { url: request.url(), method: request.method(), headers: request.headers(), body },
571
652
  });
653
+ assertRequestLive();
572
654
  if (lifecycle.isCancelled())
573
655
  throw new Error('checkout cancelled locally after approval');
574
656
  lifecycle.prepareHandoff(replay, request.url());
@@ -579,6 +661,7 @@ export async function attachToPlaywright(page, opts) {
579
661
  const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
580
662
  // Inert on a navigation (never CORS-checked); one path for every fulfill.
581
663
  await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
664
+ assertRequestLive();
582
665
  lastSubmitted = { url: request.url(), body, at: Date.now() };
583
666
  opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
584
667
  }
@@ -589,6 +672,7 @@ export async function attachToPlaywright(page, opts) {
589
672
  const postData = cseBody(body, replay);
590
673
  handoffStarted = true;
591
674
  await route.continue({ postData });
675
+ assertRequestLive();
592
676
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
593
677
  }
594
678
  else {
@@ -597,20 +681,25 @@ export async function attachToPlaywright(page, opts) {
597
681
  // cross-origin answer is the same whichever adapter ran.
598
682
  const cors = corsDecision(request.url(), request.headers());
599
683
  await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
684
+ assertRequestLive();
600
685
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
601
686
  }
602
687
  lifecycle.handedOff(replay);
603
688
  }
604
689
  catch (err) {
690
+ if (activeRequest?.attempt === attempt)
691
+ activeRequest = null;
605
692
  lifecycle.failed(err, handoffStarted);
606
693
  if (isTerminal(err))
607
694
  terminal = err;
608
695
  else if (isApprovalOutcome(err))
609
696
  quietUntil = Date.now() + cooldownMs;
610
697
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
611
- await route.abort('aborted');
698
+ await route.abort('aborted').catch(() => { });
612
699
  }
613
700
  finally {
701
+ if (activeRequest?.attempt === attempt)
702
+ activeRequest = null;
614
703
  awaitingApproval = false;
615
704
  lifecycle.end();
616
705
  }
package/dist/client.d.ts CHANGED
@@ -135,6 +135,8 @@ export interface AuthorizeInput {
135
135
  timeoutMs?: number;
136
136
  /** Stops local polling; it does not revoke a pending approval or undo a payment. */
137
137
  signal?: AbortSignal;
138
+ /** Adapter-owned merchant request lifetime. A failed request retires a pending approval before replay when possible. */
139
+ merchantSignal?: AbortSignal;
138
140
  /** Called before onApprovalUrl; lets a runtime reconcile an interrupted authorization. */
139
141
  onAuthorizationCreated?: (authorizationId: string) => void;
140
142
  /** Called once with the URL to surface to the user, if you deliver it yourself. */
@@ -336,6 +338,14 @@ export declare class VaultClient {
336
338
  * response to replay into the browser. Your process never sees a card.
337
339
  */
338
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
+ }>;
339
349
  /**
340
350
  * POST the create, with two typed twists: a 502 `amount_unverifiable`
341
351
  * (Stripe did not answer the read-back) is retried on a short backoff
package/dist/client.js CHANGED
@@ -282,6 +282,8 @@ export class VaultClient {
282
282
  async authorize(input) {
283
283
  if (input.signal?.aborted)
284
284
  throw new CheckoutCancelledError();
285
+ if (input.merchantSignal?.aborted)
286
+ throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
285
287
  const rec = findRecognizer(input.request.url, this.registry);
286
288
  if (!rec)
287
289
  throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
@@ -341,9 +343,11 @@ export class VaultClient {
341
343
  },
342
344
  };
343
345
  try {
344
- created = await this.createAuthorization(payload, input.currency, operationSignal);
346
+ created = await this.createAuthorization(payload, input.currency, AbortSignal.any([operationSignal, AbortSignal.timeout(30_000)]), input.merchantSignal);
345
347
  }
346
348
  catch (error) {
349
+ if (error instanceof PaymentOutcomeUnknownError)
350
+ throw error;
347
351
  // A missing answer or generic 5xx can hide a committed row and a delivered approval link.
348
352
  // Only the documented pre-create read-back errors prove it is safe to retry.
349
353
  const safeReadFailure = error instanceof CheckoutApiError
@@ -357,125 +361,160 @@ export class VaultClient {
357
361
  if (!created || typeof created.id !== 'string' || !created.id)
358
362
  throw new PaymentOutcomeUnknownError(null, 'authorization_create_malformed');
359
363
  const authorizationId = created.id;
364
+ const stopSignal = input.merchantSignal
365
+ ? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
360
366
  try {
361
- Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
362
- }
363
- catch { /* observer only */ }
364
- try {
365
- Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
366
- }
367
- catch { /* approval delivery must not lose an existing authorization */ }
368
- while (Date.now() < deadline) {
369
- if (input.signal?.aborted)
370
- throw new PaymentOutcomeUnknownError(authorizationId, 'local_cancel');
371
- await interruptibleSleep(Math.min(this.pollIntervalMs, Math.max(0, deadline - Date.now())), input.signal);
372
- if (input.signal?.aborted)
373
- throw new PaymentOutcomeUnknownError(authorizationId, 'local_cancel');
374
- let s;
375
- const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
376
- const pollSignal = input.signal ? AbortSignal.any([input.signal, deadlineSignal]) : deadlineSignal;
377
367
  try {
378
- s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
368
+ Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
379
369
  }
380
- catch {
381
- throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_poll_failed');
382
- }
383
- if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
384
- throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
370
+ catch { /* observer only */ }
371
+ if (input.merchantSignal?.aborted)
372
+ throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
373
+ try {
374
+ Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
385
375
  }
386
- const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
387
- ? { amountAuthority: s.amount_authority }
388
- : {};
389
- if (s.status === 'submitted_on_device') {
390
- // The device attested that the processor's form left it; the stamp
391
- // is the whole fact and it is NOT an approval (see HostedFormReplay).
392
- // Only a hosted_form row may carry this status; a stamp without its
393
- // time is not one this SDK can act on. The device may already have paid,
394
- // so the adapter holds the attempt until the merchant is reconciled.
395
- const mode = typeof s.mode === 'string' ? s.mode : 'token';
396
- if (mode !== 'hosted_form')
397
- throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
398
- if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
399
- throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
376
+ catch { /* approval delivery must not lose an existing authorization */ }
377
+ while (Date.now() < deadline) {
378
+ if (stopSignal?.aborted)
379
+ throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
380
+ await interruptibleSleep(Math.min(this.pollIntervalMs, Math.max(0, deadline - Date.now())), stopSignal);
381
+ if (stopSignal?.aborted)
382
+ throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
383
+ let s;
384
+ const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
385
+ const pollSignal = stopSignal ? AbortSignal.any([stopSignal, deadlineSignal]) : deadlineSignal;
386
+ try {
387
+ s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
400
388
  }
401
- return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
402
- }
403
- if (s.status === 'approved') {
404
- const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
405
- if (approvedMode === 'hosted_form') {
406
- // Never: the API finishes a hosted form as submitted_on_device, and
407
- // its database refuses `approved` on that mode. An answer that says
408
- // otherwise is not one to act on as a payment.
409
- throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
389
+ catch {
390
+ throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'authorization_poll_failed');
410
391
  }
411
- if (approvedMode === 'cse') {
412
- const sub = s.substitutions;
413
- const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
414
- && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
415
- && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
416
- if (!fieldsOk)
417
- throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
418
- // `remove`: sibling keys the API says to drop with the swap (Adyen's
419
- // `brand`, stamped by adyen-web from the agent's dummy digits). Absent
420
- // on an older API; anything but a list of names is refused, since a
421
- // half-understood instruction would continue a body Adyen refuses.
422
- const removeRaw = sub.remove;
423
- if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
424
- throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
392
+ if (input.merchantSignal?.aborted)
393
+ throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
394
+ if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
395
+ throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
396
+ }
397
+ const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
398
+ ? { amountAuthority: s.amount_authority }
399
+ : {};
400
+ if (s.status === 'submitted_on_device') {
401
+ // The device attested that the processor's form left it; the stamp
402
+ // is the whole fact and it is NOT an approval (see HostedFormReplay).
403
+ // Only a hosted_form row may carry this status; a stamp without its
404
+ // time is not one this SDK can act on. The device may already have paid,
405
+ // so the adapter holds the attempt until the merchant is reconciled.
406
+ const mode = typeof s.mode === 'string' ? s.mode : 'token';
407
+ if (mode !== 'hosted_form')
408
+ throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
409
+ if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
410
+ throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
411
+ }
412
+ return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
413
+ }
414
+ if (s.status === 'approved') {
415
+ const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
416
+ if (approvedMode === 'hosted_form') {
417
+ // Never: the API finishes a hosted form as submitted_on_device, and
418
+ // its database refuses `approved` on that mode. An answer that says
419
+ // otherwise is not one to act on as a payment.
420
+ throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
421
+ }
422
+ if (approvedMode === 'cse') {
423
+ const sub = s.substitutions;
424
+ const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
425
+ && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
426
+ && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
427
+ if (!fieldsOk)
428
+ throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
429
+ // `remove`: sibling keys the API says to drop with the swap (Adyen's
430
+ // `brand`, stamped by adyen-web from the agent's dummy digits). Absent
431
+ // on an older API; anything but a list of names is refused, since a
432
+ // half-understood instruction would continue a body Adyen refuses.
433
+ const removeRaw = sub.remove;
434
+ if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
435
+ throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
436
+ }
437
+ return {
438
+ mode: 'cse',
439
+ authorizationId,
440
+ substitutions: {
441
+ encoding: 'json',
442
+ at: sub.at,
443
+ fields: { ...sub.fields },
444
+ ...(removeRaw ? { remove: [...removeRaw] } : {}),
445
+ },
446
+ ...amountAuthority,
447
+ };
448
+ }
449
+ if (approvedMode !== 'token')
450
+ throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
451
+ const response = s.response;
452
+ if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
453
+ || typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
454
+ throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
425
455
  }
426
456
  return {
427
- mode: 'cse',
457
+ mode: 'token',
428
458
  authorizationId,
429
- substitutions: {
430
- encoding: 'json',
431
- at: sub.at,
432
- fields: { ...sub.fields },
433
- ...(removeRaw ? { remove: [...removeRaw] } : {}),
434
- },
459
+ ...response,
460
+ amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
461
+ chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
462
+ chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
463
+ chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
435
464
  ...amountAuthority,
436
465
  };
437
466
  }
438
- if (approvedMode !== 'token')
439
- throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
440
- const response = s.response;
441
- if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
442
- || typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
443
- throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
444
- }
445
- return {
446
- mode: 'token',
447
- authorizationId,
448
- ...response,
449
- amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
450
- chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
451
- chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
452
- chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
453
- ...amountAuthority,
454
- };
455
- }
456
- if (s.status === 'declined') {
457
- // The pre-replay checks declined it: typed, with the numbers, so the
458
- // caller can say what happened rather than "the user said no".
459
- if (s.reason === 'amount_mismatch') {
460
- throw new AmountMismatchError(String(created.id), Number(s.expected_cents), Number(s.actual_cents), String(s.currency ?? input.currency ?? ''), s.actual_currency != null ? String(s.actual_currency) : undefined, 'pre_replay');
467
+ if (s.status === 'declined') {
468
+ // The pre-replay checks declined it: typed, with the numbers, so the
469
+ // caller can say what happened rather than "the user said no".
470
+ if (s.reason === 'amount_mismatch') {
471
+ throw new AmountMismatchError(String(created.id), Number(s.expected_cents), Number(s.actual_cents), String(s.currency ?? input.currency ?? ''), s.actual_currency != null ? String(s.actual_currency) : undefined, 'pre_replay');
472
+ }
473
+ if (s.reason === 'intent_not_confirmable')
474
+ throw new IntentNotConfirmableError(String(created.id));
475
+ if (s.reason === 'processor_refused') {
476
+ throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
477
+ }
478
+ throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
461
479
  }
462
- if (s.reason === 'intent_not_confirmable')
463
- throw new IntentNotConfirmableError(String(created.id));
464
- if (s.reason === 'processor_refused') {
465
- throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
480
+ if (s.status === 'expired') {
481
+ if (s.replay_attempted === false)
482
+ throw new ApprovalTimeoutError(timeoutMs);
483
+ throw new PaymentOutcomeUnknownError(authorizationId, 'expired_after_possible_replay');
466
484
  }
467
- throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
485
+ if (s.status !== 'awaiting_approval')
486
+ throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
468
487
  }
469
- if (s.status === 'expired') {
470
- if (s.replay_attempted === false)
471
- throw new ApprovalTimeoutError(timeoutMs);
472
- throw new PaymentOutcomeUnknownError(authorizationId, 'expired_after_possible_replay');
488
+ // A local deadline is not server-side expiry; the person may still use the approval link.
489
+ throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
490
+ }
491
+ catch (error) {
492
+ if (input.merchantSignal?.aborted)
493
+ throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
494
+ throw error;
495
+ }
496
+ finally {
497
+ if (input.merchantSignal?.aborted) {
498
+ // Drain a create acknowledgement even after the merchant aborts so its
499
+ // known ID can be retired. An unacknowledged create remains unknown.
500
+ // A started/finalized replay or failed cleanup never becomes a claimed
501
+ // cancellation, and this best-effort cleanup never retries a payment.
502
+ await this.cancelAuthorization(authorizationId).catch(() => { });
473
503
  }
474
- if (s.status !== 'awaiting_approval')
475
- throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
476
504
  }
477
- // A local deadline is not server-side expiry; the person may still use the approval link.
478
- throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
505
+ }
506
+ /** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
507
+ async cancelAuthorization(authorizationId) {
508
+ if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
509
+ throw new Error('Invalid authorization ID.');
510
+ const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/cancel`, {}, AbortSignal.timeout(5_000));
511
+ if (result?.id !== authorizationId || result.status !== 'declined'
512
+ || result.reason !== 'merchant_request_aborted' || result.cancelled !== true
513
+ || result.processor_request_started !== false) {
514
+ throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed');
515
+ }
516
+ return { id: authorizationId, status: 'declined', reason: 'merchant_request_aborted',
517
+ cancelled: true, processor_request_started: false };
479
518
  }
480
519
  /**
481
520
  * POST the create, with two typed twists: a 502 `amount_unverifiable`
@@ -484,10 +523,12 @@ export class VaultClient {
484
523
  * `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
485
524
  * adapters treat it as an answered request, not a dead page.
486
525
  */
487
- async createAuthorization(payload, currency, signal) {
526
+ async createAuthorization(payload, currency, signal, stopRetries) {
488
527
  for (let attempt = 0;; attempt++) {
528
+ if (stopRetries?.aborted)
529
+ throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
489
530
  try {
490
- return await this.post('/v2/checkout/authorizations', payload, signal);
531
+ return await this.post('/v2/checkout/authorizations', payload, signal, stopRetries);
491
532
  }
492
533
  catch (err) {
493
534
  if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
@@ -500,7 +541,7 @@ export class VaultClient {
500
541
  && (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
501
542
  if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
502
543
  throw err;
503
- await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], signal);
544
+ await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], stopRetries ? AbortSignal.any([stopRetries, ...(signal ? [signal] : [])]) : signal);
504
545
  if (signal?.aborted)
505
546
  throw signal.reason;
506
547
  }
@@ -545,9 +586,14 @@ export class VaultClient {
545
586
  return this.inflight;
546
587
  }
547
588
  /** Authenticated request that retries ONCE on a 401 with a fresh token. */
548
- async call(path, init = {}, retried = false) {
589
+ async call(path, init = {}, retried = false, stopNewRequests) {
549
590
  const signal = init.signal ?? AbortSignal.timeout(30_000);
550
591
  const token = await withSignal(this.accessToken(), signal);
592
+ // Auth can outlive the merchant request. Stop a create that has not left
593
+ // yet, while preserving the response of one already sent for cleanup.
594
+ if (stopNewRequests?.aborted)
595
+ throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
596
+ signal.throwIfAborted();
551
597
  const r = await withSignal(this.fetch(`${this.baseUrl}${path}`, {
552
598
  ...init,
553
599
  signal,
@@ -555,15 +601,17 @@ export class VaultClient {
555
601
  }), signal);
556
602
  // A token can be revoked or expire early; one forced refresh, then give up.
557
603
  if (r.status === 401 && !retried) {
604
+ if (stopNewRequests?.aborted)
605
+ throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
558
606
  await withSignal(this.accessToken(true), signal);
559
- return this.call(path, { ...init, signal }, true);
607
+ return this.call(path, { ...init, signal }, true, stopNewRequests);
560
608
  }
561
609
  if (!r.ok)
562
610
  throw new CheckoutApiError(r.status, path, await withSignal(r.text(), signal));
563
611
  return withSignal(r.json(), signal);
564
612
  }
565
- post(path, body, signal) {
566
- return this.call(path, { method: 'POST', body: JSON.stringify(body), signal });
613
+ post(path, body, signal, stopNewRequests) {
614
+ return this.call(path, { method: 'POST', body: JSON.stringify(body), signal }, false, stopNewRequests);
567
615
  }
568
616
  get(path, signal) {
569
617
  return this.call(path, { signal });
@@ -54,6 +54,7 @@ export declare class CheckoutLifecycle implements CheckoutController {
54
54
  private held;
55
55
  private active;
56
56
  private cancelled;
57
+ private merchantAborted;
57
58
  private unboundStripeToken;
58
59
  private reconciliation;
59
60
  readonly abort: AbortController;
@@ -68,6 +69,8 @@ export declare class CheckoutLifecycle implements CheckoutController {
68
69
  approvalUrl(approvalUrl: string): void;
69
70
  private notify;
70
71
  cancel(): void;
72
+ /** The exact browser request is gone; a late approval cannot reopen it. */
73
+ merchantRequestAborted(): void;
71
74
  unsupported(): void;
72
75
  prepareHandoff(replay: ReplayResponse, requestUrl: string): void;
73
76
  handedOff(replay: ReplayResponse): void;
package/dist/lifecycle.js CHANGED
@@ -6,6 +6,7 @@ export class CheckoutLifecycle {
6
6
  held = false;
7
7
  active = false;
8
8
  cancelled = false;
9
+ merchantAborted = false;
9
10
  unboundStripeToken = false;
10
11
  reconciliation = null;
11
12
  abort = new AbortController();
@@ -46,6 +47,12 @@ export class CheckoutLifecycle {
46
47
  this.abort.abort();
47
48
  this.set({ ...this.state, status: this.active || this.state.authorizationId ? 'outcome_unknown' : 'cancelled', reason: 'local_cancel' });
48
49
  }
50
+ /** The exact browser request is gone; a late approval cannot reopen it. */
51
+ merchantRequestAborted() {
52
+ this.merchantAborted = true;
53
+ this.held = true;
54
+ this.set({ ...this.state, status: 'outcome_unknown', reason: 'merchant_request_aborted' });
55
+ }
49
56
  unsupported() {
50
57
  this.held = true;
51
58
  if (this.state.authorizationId)
@@ -71,6 +78,8 @@ export class CheckoutLifecycle {
71
78
  }
72
79
  }
73
80
  handedOff(replay) {
81
+ if (this.merchantAborted)
82
+ return;
74
83
  const mismatch = (!replay.mode || replay.mode === 'token') && replay.amountVerified === false;
75
84
  this.held = !!this.options.requireMerchantResult || replay.mode === 'hosted_form' || mismatch || this.unboundStripeToken;
76
85
  this.set({ status: 'awaiting_merchant', authorizationId: replay.authorizationId, mode: replay.mode ?? 'token',
@@ -79,6 +88,10 @@ export class CheckoutLifecycle {
79
88
  failed(error, handoffStarted = false) {
80
89
  if (this.cancelled)
81
90
  return;
91
+ if (this.merchantAborted) {
92
+ this.merchantRequestAborted();
93
+ return;
94
+ }
82
95
  const authorizationId = error instanceof PaymentOutcomeUnknownError ? error.authorizationId : this.state.authorizationId;
83
96
  if (handoffStarted || error instanceof PaymentOutcomeUnknownError || error instanceof IntentNotConfirmableError) {
84
97
  this.held = true;
@@ -160,6 +173,7 @@ export class CheckoutLifecycle {
160
173
  if (this.unboundStripeToken)
161
174
  throw new Error('This attachment delivered an unbound Stripe token; reconcile the merchant order and use a separately validated checkout flow.');
162
175
  this.held = false;
176
+ this.merchantAborted = false;
163
177
  this.set({ status: 'idle', authorizationId: null });
164
178
  }
165
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Let browser agents pay with the user's own card, without your infrastructure ever touching card data.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,7 @@
23
23
  "_comment_build": "TypeScript is fetched rather than declared as a devDependency ON PURPOSE. This package ships zero dependencies, which is why pnpm writes no importer for it in the workspace lockfile; adding any dep here creates one, and an importer the lockfile has not been regenerated for fails every Vercel build with ERR_PNPM_OUTDATED_LOCKFILE. Pinned so the published output is reproducible.",
24
24
  "build": "npx -y -p typescript@5.9.3 tsc",
25
25
  "prepublishOnly": "pnpm build",
26
- "test": "node test.mjs && node --test lifecycle.test.mjs",
26
+ "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs",
27
27
  "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs"
28
28
  },
29
29
  "keywords": [