@agent-cards/checkout 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Add `controller.prepare({ psp: 'square', environment: 'production' | 'sandbox' })` to both browser adapters. The caller awaits cardholder consent and unlock before starting its first native Pay action. This requires the matching preparation API and Vault deployment.
6
+ - Bind one fresh request to the selected card, declared merchant, merchant origin, amount, currency and Square environment. Readiness expires after at most 30 seconds; preparation failure, expiry, cancellation, navigation and reuse fail closed. Binding creates no second approval link or SMS. Square token amounts remain display-only.
7
+ - Preserve native request deadlines and merchant-abort protections. The SDK never clicks Pay, changes Square timers or retries an abandoned prepared checkout. The post-submit relay and token handoff must still fit Square's native deadline; a disconnected or slow cardholder device can miss it.
8
+ - Recover a lost bind acknowledgement through preparation metadata for cancellation and reconciliation only. Started replay or unconfirmed cleanup remains unknown. A parent CDP page disconnect now also stops a pending request in a child iframe.
9
+ - Add preparation lifecycle tests and an isolated Chromium fixture that waits more than ten seconds before any card request, then permits one fresh request. These fixtures use no real processor and do not establish native Square or production checkout acceptance.
10
+
11
+ ## 0.3.1
12
+
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+
3
18
  ## 0.3.0
4
19
 
5
20
  ### 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. For human approval, use
86
+ `controller.prepare()` before the first Pay action as shown below.
84
87
 
85
88
  Playwright:
86
89
 
@@ -389,6 +392,29 @@ 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's observed native tokenization request expires after about 10 seconds. SDK 0.4.0 adds approval before submission, requiring the matching preparation API and Vault deployment. Start human approval before the caller's first Pay action:
396
+
397
+ ```ts
398
+ const checkout = await attachToPlaywright(page, {
399
+ vault, user: 'your-user-id', merchant: 'Example merchant',
400
+ amountCents: 100, currency: 'USD',
401
+ onApprovalUrl: deliverPrivatelyToCardholder,
402
+ });
403
+ const preparation = await checkout.prepare({
404
+ psp: 'square',
405
+ environment: 'production', // explicit; use 'sandbox' for Square Sandbox
406
+ });
407
+ // The cardholder has consented and unlocked the same approval document.
408
+ // No processor request or payment has started.
409
+ await page.getByRole('button', { name: 'Pay', exact: true }).click();
410
+ ```
411
+
412
+ `prepare()` is available on both Playwright and raw CDP controllers. It requires `amountCents` and `currency`, must precede the first recognized card request, and returns only when the cardholder's device is ready. It delivers the preparation URL through `onApprovalUrl` and `onUserAction`; binding the subsequent authorization sends no second approval link or SMS. The phone page must stay open. Its selected card, merchant origin, declared merchant, amount, currency and Square environment bind one fresh request. The amount remains `display_only`; a Square token does not enforce the merchant's eventual charge amount.
413
+
414
+ Readiness lasts up to 30 seconds (`preparation.expiresAt`) and appears as `ready_to_submit`, with `paymentStatus: 'not_started'`. Trigger the caller-owned Pay action immediately after the promise resolves. Expiry, navigation, cancellation, an early request or a changed checkout fails closed. A preparation and its attachment are single use; reconcile any bound authorization before creating a new attachment. The SDK never clicks Pay, reuses a stale request, pauses Square timers, or automatically retries a failed prepared checkout.
415
+
416
+ After Pay, Square's native deadline still covers fresh authorization binding, device replay, relay and token handoff. A disconnected/backgrounded phone or slow transport can still miss it. A subsequent SCA challenge has its own lifetime after token handoff. If the merchant request aborts or its frame closes, the attachment blocks further requests and tries to retire the pre-replay authorization; a started replay or unconfirmed cancellation remains unknown. Without `prepare()`, approval loading and human interaction still share the native deadline, so delayed approval cannot finish that request.
417
+
392
418
  Lost authorization polling, local approval timeouts, or interrupted browser
393
419
  handoffs produce `outcome_unknown` and block automatic retry. The thrown
394
420
  `PaymentOutcomeUnknownError` carries `authorizationId` when creation was
@@ -434,8 +460,11 @@ pnpm test:browser
434
460
  The browser fixtures never contact a payment service. The general suite uses
435
461
  `psp.invalid`; the Stripe continuation suite forces `api.stripe.com` through an
436
462
  allowlisted loopback proxy and a temporary self-signed TLS stub (requires the
437
- `openssl` CLI). All other proxy destinations are rejected. Both suites use an
438
- in-process Agentcard API fixture and loopback merchant pages. It proves nested-frame pause/resume, agent control during approval,
463
+ `openssl` CLI). All other proxy destinations are rejected. These suites use an
464
+ in-process Agentcard API fixture and loopback merchant pages. The preparation
465
+ fixture denies all external traffic, waits more than ten seconds before any
466
+ card request, and then checks one fresh request with an unchanged ten-second
467
+ fixture abort timer. It exercises SDK ordering, not the native Square SDK. It proves nested-frame pause/resume, agent control during approval,
439
468
  post-payment tasks in the same page, decline/expiry/cancel, unknown-outcome retry
440
469
  blocking, explicit unsupported endpoint behavior, and blocking an immediate real-browser
441
470
  Stripe token-to-intent fetch chain, including unrelated first intents, changed
package/dist/cdp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
2
  import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, UnsupportedModeError, redactUrl, } from './client.js';
3
+ import { PreparationGate } from './preparation.js';
3
4
  import { substituteEncryptedFields } from './substitute.js';
4
5
  import { hostedFormSubmittedPage } from './hosted-form.js';
5
6
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
@@ -284,6 +285,22 @@ function failureSummary(error) {
284
285
  return `${error.name}: ${error.code ?? `http_${error.status}`}`;
285
286
  return error instanceof Error ? error.name : 'CheckoutError';
286
287
  }
288
+ /** Separate from explicit cancellation: the client can drain a late create ID. */
289
+ function merchantAttempt(lifecycle) {
290
+ const controller = new AbortController();
291
+ const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'merchant_request_aborted');
292
+ return {
293
+ signal: controller.signal,
294
+ stop() {
295
+ if (controller.signal.aborted)
296
+ return;
297
+ lifecycle.merchantRequestAborted();
298
+ controller.abort(error());
299
+ },
300
+ assertLive() { if (controller.signal.aborted)
301
+ throw error(); },
302
+ };
303
+ }
287
304
  /**
288
305
  * Take over card tokenization for a page.
289
306
  *
@@ -304,6 +321,14 @@ function failureSummary(error) {
304
321
  export async function attachToCdp(cdp, pageSessionId, opts) {
305
322
  opts = safeOptions(opts);
306
323
  const lifecycle = new CheckoutLifecycle(opts);
324
+ let preparationFrameId;
325
+ const preparationGate = new PreparationGate(opts, lifecycle, async () => {
326
+ const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
327
+ if (typeof tree?.frameTree?.frame?.url !== 'string')
328
+ throw new Error('merchant_document_unavailable');
329
+ preparationFrameId = tree.frameTree.frame.id;
330
+ return tree.frameTree.frame.url;
331
+ });
307
332
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
308
333
  const armed = new Set();
309
334
  // Set once a failure proves that retrying cannot help; see isTerminal.
@@ -313,6 +338,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
313
338
  let quietUntil = 0;
314
339
  // One outstanding approval at a time; see the note above isApprovalOutcome.
315
340
  let awaitingApproval = false;
341
+ let activeRequest = null;
316
342
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
317
343
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
318
344
  let lastSubmitted = null;
@@ -323,6 +349,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
323
349
  const key = sessionId ?? '__root__';
324
350
  if (armed.has(key))
325
351
  return;
352
+ // Fetch's interception ID differs from Network's ID. Enable failure events
353
+ // before intercepting and bind each attempt to both its ID and CDP session.
354
+ await cdp.send('Network.enable', {}, sessionId);
355
+ await cdp.send('Page.enable', {}, sessionId).catch(() => { });
326
356
  await cdp.send('Fetch.enable', {
327
357
  patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
328
358
  }, sessionId);
@@ -333,6 +363,30 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
333
363
  armed.add(key);
334
364
  };
335
365
  cdp.on(async (method, params, sessionId) => {
366
+ if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
367
+ preparationGate.invalidate('merchant_document_changed');
368
+ if (preparationGate.isEngaged())
369
+ activeRequest?.attempt.stop();
370
+ return;
371
+ }
372
+ if ((method === 'Inspector.detached' && sessionId === pageSessionId)
373
+ || (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
374
+ preparationGate.invalidate('merchant_document_closed');
375
+ // The root page owns every attached OOPIF; its loss ends child requests too.
376
+ activeRequest?.attempt.stop();
377
+ }
378
+ if (method === 'Network.loadingFailed') {
379
+ if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sessionId === sessionId)
380
+ activeRequest.attempt.stop();
381
+ return;
382
+ }
383
+ if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
384
+ if (activeRequest && (method === 'Target.detachedFromTarget' ? activeRequest.sessionId === params.sessionId
385
+ : method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
386
+ : activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
387
+ activeRequest.attempt.stop();
388
+ return;
389
+ }
336
390
  if (method === 'Target.attachedToTarget') {
337
391
  const child = params.sessionId;
338
392
  try {
@@ -351,9 +405,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
351
405
  }
352
406
  if (method !== 'Fetch.requestPaused')
353
407
  return;
354
- const { requestId, request, resourceType } = params;
408
+ const { requestId, request, resourceType, networkId, frameId } = params;
355
409
  if (!opts.vault.isCardRequest(request.url, request.method)) {
356
410
  if (guards.matches(request.url, request.method)) {
411
+ preparationGate.invalidate('unsupported_checkout');
357
412
  lifecycle.unsupported();
358
413
  opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
359
414
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
@@ -362,11 +417,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
362
417
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
363
418
  return;
364
419
  }
420
+ let preparation;
421
+ try {
422
+ preparation = preparationGate.claim(request.url);
423
+ }
424
+ catch (error) {
425
+ opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
426
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
427
+ return;
428
+ }
365
429
  // Same stop condition as the Playwright adapter: once a failure proves
366
430
  // retrying is pointless, fail the request without calling the API again.
367
431
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
368
432
  const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
369
433
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
434
+ if (preparation)
435
+ preparationGate.retireUnboundClaim();
370
436
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
371
437
  return;
372
438
  }
@@ -374,6 +440,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
374
440
  // requests can never both clear the check above and raise two prompts for
375
441
  // one checkout.
376
442
  awaitingApproval = true;
443
+ const attempt = merchantAttempt(lifecycle);
377
444
  let handoffStarted = false;
378
445
  try {
379
446
  const body = pausedBody(request);
@@ -395,19 +462,33 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
395
462
  }
396
463
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
397
464
  lifecycle.begin();
465
+ if (typeof networkId !== 'string' || !networkId) {
466
+ attempt.stop();
467
+ attempt.assertLive();
468
+ }
469
+ activeRequest = { attempt, networkId, sessionId, frameId };
470
+ if (preparation)
471
+ await preparationGate.assertDocument();
472
+ attempt.assertLive();
398
473
  const replay = await opts.vault.authorize({
399
474
  user: opts.user,
400
475
  merchant: opts.merchant,
401
476
  amount: opts.amount,
402
477
  amountCents: opts.amountCents,
403
478
  currency: opts.currency,
404
- cardId: opts.cardId,
479
+ cardId: preparation?.cardId ?? opts.cardId,
480
+ preparation,
405
481
  timeoutMs: opts.timeoutMs,
406
482
  signal: lifecycle.abort.signal,
483
+ merchantSignal: attempt.signal,
407
484
  onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
408
- onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
485
+ onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
486
+ lifecycle.approvalUrl(url);
487
+ return opts.onApprovalUrl?.(url);
488
+ } },
409
489
  request: { url: request.url, method: request.method, headers: request.headers, body },
410
490
  });
491
+ attempt.assertLive();
411
492
  if (lifecycle.isCancelled())
412
493
  throw new Error('checkout cancelled locally after approval');
413
494
  lifecycle.prepareHandoff(replay, request.url);
@@ -427,6 +508,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
427
508
  responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
428
509
  body: Buffer.from(page.body).toString('base64'),
429
510
  }, sessionId);
511
+ attempt.assertLive();
430
512
  lastSubmitted = { url: request.url, body, at: Date.now() };
431
513
  // Named for what it is: a device-attested submission with no
432
514
  // processor evidence, never an `authorized` event.
@@ -444,6 +526,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
444
526
  requestId,
445
527
  postData,
446
528
  }, sessionId);
529
+ attempt.assertLive();
447
530
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
448
531
  }
449
532
  else {
@@ -460,11 +543,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
460
543
  responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
461
544
  body: Buffer.from(replay.body).toString('base64'),
462
545
  }, sessionId);
546
+ attempt.assertLive();
463
547
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
464
548
  }
465
549
  lifecycle.handedOff(replay);
466
550
  }
467
551
  catch (err) {
552
+ // Ignore the failure event caused by our own safe decline/error abort.
553
+ if (activeRequest?.attempt === attempt)
554
+ activeRequest = null;
468
555
  lifecycle.failed(err, handoffStarted);
469
556
  if (isTerminal(err))
470
557
  terminal = err;
@@ -474,8 +561,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
474
561
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
475
562
  }
476
563
  finally {
564
+ if (activeRequest?.attempt === attempt)
565
+ activeRequest = null;
477
566
  awaitingApproval = false;
478
567
  lifecycle.end();
568
+ if (preparation)
569
+ preparationGate.retireUnboundClaim();
479
570
  }
480
571
  });
481
572
  await arm(pageSessionId);
@@ -500,6 +591,11 @@ export async function attachToPlaywright(page, opts) {
500
591
  throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
501
592
  }
502
593
  const lifecycle = new CheckoutLifecycle(opts);
594
+ const preparationGate = new PreparationGate(opts, lifecycle, async () => {
595
+ if (page.isClosed?.() || typeof page.url !== 'function')
596
+ throw new Error('merchant_document_unavailable');
597
+ return page.url();
598
+ });
503
599
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
504
600
  // Playwright's own routing, NOT a hand-rolled CDP session.
505
601
  //
@@ -520,6 +616,24 @@ export async function attachToPlaywright(page, opts) {
520
616
  let quietUntil = 0;
521
617
  // One outstanding approval at a time; see the note above isApprovalOutcome.
522
618
  let awaitingApproval = false;
619
+ let activeRequest = null;
620
+ page.on?.('requestfailed', (request) => {
621
+ if (activeRequest && activeRequest.request === request)
622
+ activeRequest.attempt.stop();
623
+ });
624
+ page.on?.('close', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
625
+ page.on?.('crash', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
626
+ page.on?.('framenavigated', (frame) => {
627
+ if (frame === page.mainFrame?.()) {
628
+ preparationGate.invalidate('merchant_document_changed');
629
+ if (preparationGate.isEngaged())
630
+ activeRequest?.attempt.stop();
631
+ }
632
+ });
633
+ page.on?.('framedetached', (frame) => {
634
+ if (activeRequest?.frames.includes(frame))
635
+ activeRequest.attempt.stop();
636
+ });
523
637
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
524
638
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
525
639
  let lastSubmitted = null;
@@ -529,12 +643,21 @@ export async function attachToPlaywright(page, opts) {
529
643
  // untouched or the browser's CORS check fails on our synthetic answer.
530
644
  if (!opts.vault.isCardRequest(request.url(), request.method())) {
531
645
  if (guards.matches(request.url(), request.method())) {
646
+ preparationGate.invalidate('unsupported_checkout');
532
647
  lifecycle.unsupported();
533
648
  opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
534
649
  return route.abort('aborted');
535
650
  }
536
651
  return route.fallback();
537
652
  }
653
+ let preparation;
654
+ try {
655
+ preparation = preparationGate.claim(request.url());
656
+ }
657
+ catch (error) {
658
+ opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
659
+ return route.abort('aborted');
660
+ }
538
661
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
539
662
  // the page's retry loop turn into a stream of doomed API calls. Every
540
663
  // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
@@ -543,10 +666,24 @@ export async function attachToPlaywright(page, opts) {
543
666
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
544
667
  const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
545
668
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
669
+ if (preparation)
670
+ preparationGate.retireUnboundClaim();
546
671
  return route.abort('aborted');
547
672
  }
548
673
  // Reserved before anything that could yield, matching attachToCdp.
549
674
  awaitingApproval = true;
675
+ const attempt = merchantAttempt(lifecycle);
676
+ const frames = [];
677
+ try {
678
+ for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
679
+ frames.push(frame);
680
+ }
681
+ catch { /* requestfailed/page close still cover unavailable frame metadata */ }
682
+ const assertRequestLive = () => {
683
+ if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
684
+ attempt.stop();
685
+ attempt.assertLive();
686
+ };
550
687
  let handoffStarted = false;
551
688
  try {
552
689
  const body = request.postData() ?? '';
@@ -556,19 +693,29 @@ export async function attachToPlaywright(page, opts) {
556
693
  }
557
694
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
558
695
  lifecycle.begin();
696
+ activeRequest = { request, frames, attempt };
697
+ if (preparation)
698
+ await preparationGate.assertDocument();
699
+ assertRequestLive();
559
700
  const replay = await opts.vault.authorize({
560
701
  user: opts.user,
561
702
  merchant: opts.merchant,
562
703
  amount: opts.amount,
563
704
  amountCents: opts.amountCents,
564
705
  currency: opts.currency,
565
- cardId: opts.cardId,
706
+ cardId: preparation?.cardId ?? opts.cardId,
707
+ preparation,
566
708
  timeoutMs: opts.timeoutMs,
567
709
  signal: lifecycle.abort.signal,
710
+ merchantSignal: attempt.signal,
568
711
  onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
569
- onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
712
+ onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
713
+ lifecycle.approvalUrl(url);
714
+ return opts.onApprovalUrl?.(url);
715
+ } },
570
716
  request: { url: request.url(), method: request.method(), headers: request.headers(), body },
571
717
  });
718
+ assertRequestLive();
572
719
  if (lifecycle.isCancelled())
573
720
  throw new Error('checkout cancelled locally after approval');
574
721
  lifecycle.prepareHandoff(replay, request.url());
@@ -579,6 +726,7 @@ export async function attachToPlaywright(page, opts) {
579
726
  const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
580
727
  // Inert on a navigation (never CORS-checked); one path for every fulfill.
581
728
  await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
729
+ assertRequestLive();
582
730
  lastSubmitted = { url: request.url(), body, at: Date.now() };
583
731
  opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
584
732
  }
@@ -589,6 +737,7 @@ export async function attachToPlaywright(page, opts) {
589
737
  const postData = cseBody(body, replay);
590
738
  handoffStarted = true;
591
739
  await route.continue({ postData });
740
+ assertRequestLive();
592
741
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
593
742
  }
594
743
  else {
@@ -597,22 +746,29 @@ export async function attachToPlaywright(page, opts) {
597
746
  // cross-origin answer is the same whichever adapter ran.
598
747
  const cors = corsDecision(request.url(), request.headers());
599
748
  await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
749
+ assertRequestLive();
600
750
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
601
751
  }
602
752
  lifecycle.handedOff(replay);
603
753
  }
604
754
  catch (err) {
755
+ if (activeRequest?.attempt === attempt)
756
+ activeRequest = null;
605
757
  lifecycle.failed(err, handoffStarted);
606
758
  if (isTerminal(err))
607
759
  terminal = err;
608
760
  else if (isApprovalOutcome(err))
609
761
  quietUntil = Date.now() + cooldownMs;
610
762
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
611
- await route.abort('aborted');
763
+ await route.abort('aborted').catch(() => { });
612
764
  }
613
765
  finally {
766
+ if (activeRequest?.attempt === attempt)
767
+ activeRequest = null;
614
768
  awaitingApproval = false;
615
769
  lifecycle.end();
770
+ if (preparation)
771
+ preparationGate.retireUnboundClaim();
616
772
  }
617
773
  });
618
774
  return lifecycle;
package/dist/client.d.ts CHANGED
@@ -98,6 +98,46 @@ export interface HostedFormReplay {
98
98
  }
99
99
  /** What authorize() resolves with; branch on `mode` (absent means token). */
100
100
  export type ReplayResponse = TokenReplay | CseReplay | HostedFormReplay;
101
+ export interface PrepareCheckoutOptions {
102
+ psp: 'square';
103
+ /** The processor environment, independent of your Agentcard client's mode. */
104
+ environment: 'production' | 'sandbox';
105
+ signal?: AbortSignal;
106
+ }
107
+ export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
108
+ user: string;
109
+ merchant: string;
110
+ amountCents: number;
111
+ currency: string;
112
+ cardId?: string;
113
+ merchantOrigin: string;
114
+ checkoutKey: string;
115
+ timeoutMs?: number;
116
+ onPreparationCreated?: (id: string) => void;
117
+ onApprovalUrl?: (url: string) => void;
118
+ }
119
+ /** Real user consent and an unlocked device; no processor request or payment yet. */
120
+ export interface PreparedCheckout {
121
+ readonly id: string;
122
+ readonly status: 'ready';
123
+ readonly psp: 'square';
124
+ readonly environment: 'production' | 'sandbox';
125
+ readonly expiresAt: string;
126
+ readonly cardId: string;
127
+ readonly user: string;
128
+ readonly merchant: string;
129
+ readonly amountCents: number;
130
+ readonly currency: string;
131
+ readonly merchantOrigin: string;
132
+ readonly checkoutKey: string;
133
+ readonly paymentStatus: 'not_started';
134
+ readonly amountAuthority: 'display_only';
135
+ }
136
+ export declare class CheckoutPreparationError extends Error {
137
+ preparationId: string | null;
138
+ reason: string;
139
+ constructor(preparationId: string | null, reason: string);
140
+ }
101
141
  export interface AuthorizeInput {
102
142
  /** Your identifier for the person whose card should pay. */
103
143
  user: string;
@@ -135,10 +175,14 @@ export interface AuthorizeInput {
135
175
  timeoutMs?: number;
136
176
  /** Stops local polling; it does not revoke a pending approval or undo a payment. */
137
177
  signal?: AbortSignal;
178
+ /** Adapter-owned merchant request lifetime. A failed request retires a pending approval before replay when possible. */
179
+ merchantSignal?: AbortSignal;
138
180
  /** Called before onApprovalUrl; lets a runtime reconcile an interrupted authorization. */
139
181
  onAuthorizationCreated?: (authorizationId: string) => void;
140
182
  /** Called once with the URL to surface to the user, if you deliver it yourself. */
141
183
  onApprovalUrl?: (url: string) => void;
184
+ /** One-use preparation returned by this client. Never resumes an older request. */
185
+ preparation?: PreparedCheckout;
142
186
  }
143
187
  export declare class CardEncryptedError extends Error {
144
188
  psp: string;
@@ -313,6 +357,8 @@ export declare class VaultClient {
313
357
  private readonly pollIntervalMs;
314
358
  private readonly unverifiableRetryDelaysMs;
315
359
  private registry;
360
+ private readonly preparations;
361
+ private readonly usedPreparations;
316
362
  constructor(opts: VaultClientOptions);
317
363
  /** Refresh recognizers from the API so new PSPs work without a redeploy. */
318
364
  syncRegistry(): Promise<void>;
@@ -330,12 +376,26 @@ export declare class VaultClient {
330
376
  * these patterns pause.
331
377
  */
332
378
  cardUrlPatterns(): string[];
379
+ /** Wait for real device approval before the caller starts native tokenization. */
380
+ prepareCheckout(input: PrepareCheckoutInput): Promise<PreparedCheckout>;
381
+ /** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
382
+ cancelPreparation(id: string): Promise<void>;
333
383
  /**
334
384
  * Hand us a paused tokenization request. We ask the cardholder to approve,
335
385
  * their device supplies the card and calls the merchant, and you get back the
336
386
  * response to replay into the browser. Your process never sees a card.
337
387
  */
338
388
  authorize(input: AuthorizeInput): Promise<ReplayResponse>;
389
+ /** A lost bind acknowledgement must never resume the request. Recover metadata only for safe cleanup. */
390
+ private retireUncertainPreparation;
391
+ /** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
392
+ cancelAuthorization(authorizationId: string): Promise<{
393
+ id: string;
394
+ status: 'declined';
395
+ reason: 'merchant_request_aborted';
396
+ cancelled: true;
397
+ processor_request_started: false;
398
+ }>;
339
399
  /**
340
400
  * POST the create, with two typed twists: a 502 `amount_unverifiable`
341
401
  * (Stripe did not answer the read-back) is retried on a short backoff