@agent-cards/checkout 0.3.1 → 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,13 @@
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
+
3
11
  ## 0.3.1
4
12
 
5
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.
package/README.md CHANGED
@@ -82,8 +82,8 @@ pair is shown and reported (`amountAuthority: 'display_only'`), not enforced.
82
82
  Then let your agent click "Pay" like it always does. `attachToCdp` pauses the
83
83
  request for approval and resumes it only while the merchant request remains
84
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.
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.
87
87
 
88
88
  Playwright:
89
89
 
@@ -392,7 +392,28 @@ do not reuse that token in a new attachment as a workaround. Configuration and
392
392
  unsupported-mode failures require fixing the integration. Bank flows requiring
393
393
  another confirmation and other stored-token chains remain unverified.
394
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.
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.
396
417
 
397
418
  Lost authorization polling, local approval timeouts, or interrupted browser
398
419
  handoffs produce `outcome_unknown` and block automatic retry. The thrown
@@ -439,8 +460,11 @@ pnpm test:browser
439
460
  The browser fixtures never contact a payment service. The general suite uses
440
461
  `psp.invalid`; the Stripe continuation suite forces `api.stripe.com` through an
441
462
  allowlisted loopback proxy and a temporary self-signed TLS stub (requires the
442
- `openssl` CLI). All other proxy destinations are rejected. Both suites use an
443
- 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,
444
468
  post-payment tasks in the same page, decline/expiry/cancel, unknown-outcome retry
445
469
  blocking, explicit unsupported endpoint behavior, and blocking an immediate real-browser
446
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';
@@ -320,6 +321,14 @@ function merchantAttempt(lifecycle) {
320
321
  export async function attachToCdp(cdp, pageSessionId, opts) {
321
322
  opts = safeOptions(opts);
322
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
+ });
323
332
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
324
333
  const armed = new Set();
325
334
  // Set once a failure proves that retrying cannot help; see isTerminal.
@@ -354,6 +363,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
354
363
  armed.add(key);
355
364
  };
356
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
+ }
357
378
  if (method === 'Network.loadingFailed') {
358
379
  if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sessionId === sessionId)
359
380
  activeRequest.attempt.stop();
@@ -387,6 +408,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
387
408
  const { requestId, request, resourceType, networkId, frameId } = params;
388
409
  if (!opts.vault.isCardRequest(request.url, request.method)) {
389
410
  if (guards.matches(request.url, request.method)) {
411
+ preparationGate.invalidate('unsupported_checkout');
390
412
  lifecycle.unsupported();
391
413
  opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
392
414
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
@@ -395,11 +417,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
395
417
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
396
418
  return;
397
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
+ }
398
429
  // Same stop condition as the Playwright adapter: once a failure proves
399
430
  // retrying is pointless, fail the request without calling the API again.
400
431
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
401
432
  const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
402
433
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
434
+ if (preparation)
435
+ preparationGate.retireUnboundClaim();
403
436
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
404
437
  return;
405
438
  }
@@ -434,18 +467,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
434
467
  attempt.assertLive();
435
468
  }
436
469
  activeRequest = { attempt, networkId, sessionId, frameId };
470
+ if (preparation)
471
+ await preparationGate.assertDocument();
472
+ attempt.assertLive();
437
473
  const replay = await opts.vault.authorize({
438
474
  user: opts.user,
439
475
  merchant: opts.merchant,
440
476
  amount: opts.amount,
441
477
  amountCents: opts.amountCents,
442
478
  currency: opts.currency,
443
- cardId: opts.cardId,
479
+ cardId: preparation?.cardId ?? opts.cardId,
480
+ preparation,
444
481
  timeoutMs: opts.timeoutMs,
445
482
  signal: lifecycle.abort.signal,
446
483
  merchantSignal: attempt.signal,
447
484
  onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
448
- onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
485
+ onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
449
486
  lifecycle.approvalUrl(url);
450
487
  return opts.onApprovalUrl?.(url);
451
488
  } },
@@ -528,6 +565,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
528
565
  activeRequest = null;
529
566
  awaitingApproval = false;
530
567
  lifecycle.end();
568
+ if (preparation)
569
+ preparationGate.retireUnboundClaim();
531
570
  }
532
571
  });
533
572
  await arm(pageSessionId);
@@ -552,6 +591,11 @@ export async function attachToPlaywright(page, opts) {
552
591
  throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
553
592
  }
554
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
+ });
555
599
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
556
600
  // Playwright's own routing, NOT a hand-rolled CDP session.
557
601
  //
@@ -577,8 +621,15 @@ export async function attachToPlaywright(page, opts) {
577
621
  if (activeRequest && activeRequest.request === request)
578
622
  activeRequest.attempt.stop();
579
623
  });
580
- page.on?.('close', () => activeRequest?.attempt.stop());
581
- page.on?.('crash', () => activeRequest?.attempt.stop());
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
+ });
582
633
  page.on?.('framedetached', (frame) => {
583
634
  if (activeRequest?.frames.includes(frame))
584
635
  activeRequest.attempt.stop();
@@ -592,12 +643,21 @@ export async function attachToPlaywright(page, opts) {
592
643
  // untouched or the browser's CORS check fails on our synthetic answer.
593
644
  if (!opts.vault.isCardRequest(request.url(), request.method())) {
594
645
  if (guards.matches(request.url(), request.method())) {
646
+ preparationGate.invalidate('unsupported_checkout');
595
647
  lifecycle.unsupported();
596
648
  opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
597
649
  return route.abort('aborted');
598
650
  }
599
651
  return route.fallback();
600
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
+ }
601
661
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
602
662
  // the page's retry loop turn into a stream of doomed API calls. Every
603
663
  // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
@@ -606,6 +666,8 @@ export async function attachToPlaywright(page, opts) {
606
666
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
607
667
  const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
608
668
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
669
+ if (preparation)
670
+ preparationGate.retireUnboundClaim();
609
671
  return route.abort('aborted');
610
672
  }
611
673
  // Reserved before anything that could yield, matching attachToCdp.
@@ -632,6 +694,8 @@ export async function attachToPlaywright(page, opts) {
632
694
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
633
695
  lifecycle.begin();
634
696
  activeRequest = { request, frames, attempt };
697
+ if (preparation)
698
+ await preparationGate.assertDocument();
635
699
  assertRequestLive();
636
700
  const replay = await opts.vault.authorize({
637
701
  user: opts.user,
@@ -639,12 +703,13 @@ export async function attachToPlaywright(page, opts) {
639
703
  amount: opts.amount,
640
704
  amountCents: opts.amountCents,
641
705
  currency: opts.currency,
642
- cardId: opts.cardId,
706
+ cardId: preparation?.cardId ?? opts.cardId,
707
+ preparation,
643
708
  timeoutMs: opts.timeoutMs,
644
709
  signal: lifecycle.abort.signal,
645
710
  merchantSignal: attempt.signal,
646
711
  onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
647
- onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
712
+ onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
648
713
  lifecycle.approvalUrl(url);
649
714
  return opts.onApprovalUrl?.(url);
650
715
  } },
@@ -702,6 +767,8 @@ export async function attachToPlaywright(page, opts) {
702
767
  activeRequest = null;
703
768
  awaitingApproval = false;
704
769
  lifecycle.end();
770
+ if (preparation)
771
+ preparationGate.retireUnboundClaim();
705
772
  }
706
773
  });
707
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;
@@ -141,6 +181,8 @@ export interface AuthorizeInput {
141
181
  onAuthorizationCreated?: (authorizationId: string) => void;
142
182
  /** Called once with the URL to surface to the user, if you deliver it yourself. */
143
183
  onApprovalUrl?: (url: string) => void;
184
+ /** One-use preparation returned by this client. Never resumes an older request. */
185
+ preparation?: PreparedCheckout;
144
186
  }
145
187
  export declare class CardEncryptedError extends Error {
146
188
  psp: string;
@@ -315,6 +357,8 @@ export declare class VaultClient {
315
357
  private readonly pollIntervalMs;
316
358
  private readonly unverifiableRetryDelaysMs;
317
359
  private registry;
360
+ private readonly preparations;
361
+ private readonly usedPreparations;
318
362
  constructor(opts: VaultClientOptions);
319
363
  /** Refresh recognizers from the API so new PSPs work without a redeploy. */
320
364
  syncRegistry(): Promise<void>;
@@ -332,12 +376,18 @@ export declare class VaultClient {
332
376
  * these patterns pause.
333
377
  */
334
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>;
335
383
  /**
336
384
  * Hand us a paused tokenization request. We ask the cardholder to approve,
337
385
  * their device supplies the card and calls the merchant, and you get back the
338
386
  * response to replay into the browser. Your process never sees a card.
339
387
  */
340
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;
341
391
  /** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
342
392
  cancelAuthorization(authorizationId: string): Promise<{
343
393
  id: string;
package/dist/client.js CHANGED
@@ -6,6 +6,16 @@ import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecogni
6
6
  */
7
7
  export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
8
8
  const AMOUNT_AUTHORITIES = ['stripe_payment_intent', 'hosted_form_sum', 'display_only'];
9
+ export class CheckoutPreparationError extends Error {
10
+ preparationId;
11
+ reason;
12
+ constructor(preparationId, reason) {
13
+ super(`Checkout preparation unavailable: ${reason}`);
14
+ this.preparationId = preparationId;
15
+ this.reason = reason;
16
+ this.name = 'CheckoutPreparationError';
17
+ }
18
+ }
9
19
  export class CardEncryptedError extends Error {
10
20
  psp;
11
21
  constructor(psp) {
@@ -223,6 +233,8 @@ export class VaultClient {
223
233
  pollIntervalMs;
224
234
  unverifiableRetryDelaysMs;
225
235
  registry;
236
+ preparations = new WeakSet();
237
+ usedPreparations = new WeakSet();
226
238
  constructor(opts) {
227
239
  this.opts = opts;
228
240
  this.baseUrl = (opts.baseUrl ?? 'https://api.agentcard.sh').replace(/\/$/, '');
@@ -274,12 +286,121 @@ export class VaultClient {
274
286
  cardUrlPatterns() {
275
287
  return deriveCardUrlPatterns(this.registry);
276
288
  }
289
+ /** Wait for real device approval before the caller starts native tokenization. */
290
+ async prepareCheckout(input) {
291
+ input = { ...input };
292
+ const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
293
+ if (input.psp !== 'square' || !['production', 'sandbox'].includes(input.environment))
294
+ throw fail('unsupported_processor');
295
+ if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
296
+ throw fail('amount_required');
297
+ const origin = new URL(input.merchantOrigin);
298
+ if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
299
+ throw fail('merchant_origin_invalid');
300
+ if (!input.checkoutKey || !input.user || !input.merchant)
301
+ throw fail('checkout_context_required');
302
+ const timeoutMs = input.timeoutMs ?? 15 * 60_000;
303
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
304
+ throw fail('timeout_invalid');
305
+ const signal = input.signal ? AbortSignal.any([input.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
306
+ if (signal.aborted)
307
+ throw fail('cancelled');
308
+ let id = null;
309
+ let ready = false;
310
+ try {
311
+ // Drain a sent creation even after caller cancellation to retire its ID.
312
+ // The separate stop signal prevents dispatch after a slow OAuth exchange.
313
+ const created = await this.post('/v2/checkout/preparations', {
314
+ user: input.user, merchant: input.merchant, amount_cents: input.amountCents, currency: input.currency.toLowerCase(),
315
+ ...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: 'token',
316
+ environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
317
+ }, AbortSignal.timeout(30_000), signal);
318
+ if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
319
+ throw fail('create_unconfirmed');
320
+ const preparationId = created.id;
321
+ id = preparationId;
322
+ try {
323
+ Promise.resolve(input.onPreparationCreated?.(preparationId)).catch(() => { });
324
+ }
325
+ catch { /* observer only */ }
326
+ if (signal.aborted)
327
+ throw fail('cancelled', id);
328
+ if (typeof created.approvalUrl !== 'string')
329
+ throw fail('approval_url_missing', id);
330
+ try {
331
+ Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
332
+ }
333
+ catch { /* observer only */ }
334
+ while (!signal.aborted) {
335
+ const state = await this.get(`/v2/checkout/preparations/${id}`, signal);
336
+ if (signal.aborted)
337
+ throw fail('cancelled', id);
338
+ if (state?.id !== id)
339
+ throw fail('status_unconfirmed', id);
340
+ if (state.status === 'ready') {
341
+ const expiry = Date.parse(state.ready_expires_at);
342
+ if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
343
+ || state.payment_status !== 'not_started' || state.amount_authority !== 'display_only'
344
+ || state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
345
+ || state.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
346
+ || state.psp !== 'square' || state.mode !== 'token' || state.environment !== input.environment
347
+ || state.checkout_key !== input.checkoutKey)
348
+ throw fail('ready_unconfirmed', id);
349
+ const prepared = Object.freeze({
350
+ id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, expiresAt: state.ready_expires_at,
351
+ cardId: state.card_id, user: input.user, merchant: input.merchant, amountCents: input.amountCents,
352
+ currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
353
+ paymentStatus: 'not_started', amountAuthority: 'display_only',
354
+ });
355
+ this.preparations.add(prepared);
356
+ ready = true;
357
+ return prepared;
358
+ }
359
+ if (state.status !== 'awaiting_approval')
360
+ throw fail(['cancelled', 'expired', 'bound'].includes(state.status) ? state.status : 'status_unconfirmed', id);
361
+ await interruptibleSleep(this.pollIntervalMs, signal);
362
+ }
363
+ throw fail('cancelled', id);
364
+ }
365
+ catch (error) {
366
+ if (error instanceof CheckoutPreparationError)
367
+ throw error;
368
+ throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
369
+ }
370
+ finally {
371
+ if (id && !ready)
372
+ await this.cancelPreparation(id).catch(() => { });
373
+ }
374
+ }
375
+ /** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
376
+ async cancelPreparation(id) {
377
+ if (!/^cprep_[A-Za-z0-9_-]{1,128}$/.test(id))
378
+ throw new CheckoutPreparationError(null, 'id_invalid');
379
+ const state = await this.post(`/v2/checkout/preparations/${id}/cancel`, {}, AbortSignal.timeout(5_000));
380
+ if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
381
+ throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
382
+ }
277
383
  /**
278
384
  * Hand us a paused tokenization request. We ask the cardholder to approve,
279
385
  * their device supplies the card and calls the merchant, and you get back the
280
386
  * response to replay into the browser. Your process never sees a card.
281
387
  */
282
388
  async authorize(input) {
389
+ const preparation = input.preparation;
390
+ if (preparation) {
391
+ if (!this.preparations.has(preparation) || this.usedPreparations.has(preparation))
392
+ throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
393
+ // Consume locally before any await, including OAuth, and never recycle it.
394
+ this.usedPreparations.add(preparation);
395
+ const url = new URL(input.request.url);
396
+ const host = preparation.environment === 'production' ? 'pci-connect.squareup.com' : 'pci-connect.squareupsandbox.com';
397
+ if (Date.parse(preparation.expiresAt) <= Date.now())
398
+ throw new CheckoutPreparationError(preparation.id, 'expired');
399
+ if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.amountCents !== preparation.amountCents
400
+ || input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
401
+ || url.origin !== `https://${host}` || url.pathname !== '/v2/card-nonce' || url.username || url.password)
402
+ throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
403
+ }
283
404
  if (input.signal?.aborted)
284
405
  throw new CheckoutCancelledError();
285
406
  if (input.merchantSignal?.aborted)
@@ -335,6 +456,7 @@ export class VaultClient {
335
456
  // the recognizer and refuses a disagreement before a row exists.
336
457
  mode,
337
458
  ...(input.cardId ? { cardId: input.cardId } : {}),
459
+ ...(preparation ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin } : {}),
338
460
  request: {
339
461
  url: input.request.url,
340
462
  method: input.request.method,
@@ -346,21 +468,35 @@ export class VaultClient {
346
468
  created = await this.createAuthorization(payload, input.currency, AbortSignal.any([operationSignal, AbortSignal.timeout(30_000)]), input.merchantSignal);
347
469
  }
348
470
  catch (error) {
349
- if (error instanceof PaymentOutcomeUnknownError)
471
+ if (error instanceof PaymentOutcomeUnknownError) {
472
+ if (preparation && !error.authorizationId)
473
+ throw new PaymentOutcomeUnknownError(await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated), error.reason);
350
474
  throw error;
475
+ }
476
+ if (preparation && error instanceof CheckoutApiError && error.code === 'preparation_bound') {
477
+ const id = typeof error.details.authorization_id === 'string' && /^cauth_[A-Za-z0-9_-]+$/.test(error.details.authorization_id) ? error.details.authorization_id : null;
478
+ if (id) {
479
+ try {
480
+ Promise.resolve(input.onAuthorizationCreated?.(id)).catch(() => { });
481
+ }
482
+ catch { /* observer only */ }
483
+ }
484
+ throw new PaymentOutcomeUnknownError(id, 'preparation_already_bound');
485
+ }
351
486
  // A missing answer or generic 5xx can hide a committed row and a delivered approval link.
352
487
  // Only the documented pre-create read-back errors prove it is safe to retry.
353
488
  const safeReadFailure = error instanceof CheckoutApiError
354
489
  && error.status === 502 && (error.code === 'amount_unverifiable' || error.code === 'cse_key_unavailable');
355
490
  if ((error instanceof CheckoutApiError && error.status >= 500 && !safeReadFailure)
356
491
  || (!(error instanceof CheckoutApiError) && !(error instanceof ApprovalDeclinedError))) {
357
- throw new PaymentOutcomeUnknownError(null, 'authorization_create_unanswered');
492
+ throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_unanswered');
358
493
  }
359
494
  throw error;
360
495
  }
361
496
  if (!created || typeof created.id !== 'string' || !created.id)
362
- throw new PaymentOutcomeUnknownError(null, 'authorization_create_malformed');
497
+ throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_malformed');
363
498
  const authorizationId = created.id;
499
+ let failed = false;
364
500
  const stopSignal = input.merchantSignal
365
501
  ? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
366
502
  try {
@@ -370,10 +506,12 @@ export class VaultClient {
370
506
  catch { /* observer only */ }
371
507
  if (input.merchantSignal?.aborted)
372
508
  throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
373
- try {
374
- Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
509
+ if (!preparation) {
510
+ try {
511
+ Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
512
+ }
513
+ catch { /* approval delivery must not lose an existing authorization */ }
375
514
  }
376
- catch { /* approval delivery must not lose an existing authorization */ }
377
515
  while (Date.now() < deadline) {
378
516
  if (stopSignal?.aborted)
379
517
  throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
@@ -489,12 +627,13 @@ export class VaultClient {
489
627
  throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
490
628
  }
491
629
  catch (error) {
630
+ failed = true;
492
631
  if (input.merchantSignal?.aborted)
493
632
  throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
494
633
  throw error;
495
634
  }
496
635
  finally {
497
- if (input.merchantSignal?.aborted) {
636
+ if (input.merchantSignal?.aborted || (preparation && failed)) {
498
637
  // Drain a create acknowledgement even after the merchant aborts so its
499
638
  // known ID can be retired. An unacknowledged create remains unknown.
500
639
  // A started/finalized replay or failed cleanup never becomes a claimed
@@ -503,6 +642,29 @@ export class VaultClient {
503
642
  }
504
643
  }
505
644
  }
645
+ /** A lost bind acknowledgement must never resume the request. Recover metadata only for safe cleanup. */
646
+ async retireUncertainPreparation(preparation, onCreated) {
647
+ // Race cancellation atomically against a create still arriving at the API.
648
+ // A bound preparation refuses cancellation; resolve its ID exactly once below.
649
+ await this.cancelPreparation(preparation.id).catch(() => { });
650
+ let state;
651
+ try {
652
+ state = await this.get(`/v2/checkout/preparations/${preparation.id}`, AbortSignal.timeout(3_000));
653
+ }
654
+ catch {
655
+ return null;
656
+ }
657
+ if (state?.id !== preparation.id || state.status !== 'bound' || typeof state.authorization_id !== 'string'
658
+ || !/^cauth_[A-Za-z0-9_-]{1,128}$/.test(state.authorization_id))
659
+ return null;
660
+ const id = state.authorization_id;
661
+ try {
662
+ Promise.resolve(onCreated?.(id)).catch(() => { });
663
+ }
664
+ catch { /* observer only */ }
665
+ await this.cancelAuthorization(id).catch(() => { });
666
+ return id;
667
+ }
506
668
  /** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
507
669
  async cancelAuthorization(authorizationId) {
508
670
  if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
@@ -537,7 +699,7 @@ export class VaultClient {
537
699
  }
538
700
  // Two 502s the API asks to be retried: Stripe did not answer the
539
701
  // amount read-back, or Adyen did not answer the public-key fetch.
540
- const retryable = err instanceof CheckoutApiError && err.status === 502
702
+ const retryable = !payload.preparation_id && err instanceof CheckoutApiError && err.status === 502
541
703
  && (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
542
704
  if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
543
705
  throw err;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, } from './client.js';
2
- export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, } from './client.js';
1
+ export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
+ export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, } from './client.js';
3
3
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
4
4
  export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
5
5
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, } from './client.js';
1
+ export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
2
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
3
3
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
4
4
  export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
@@ -1,4 +1,4 @@
1
- import { type ReplayResponse } from './client.js';
1
+ import { CheckoutPreparationError, type PrepareCheckoutOptions, type PreparedCheckout, type ReplayResponse } from './client.js';
2
2
  import type { CheckoutMode } from './registry.js';
3
3
  /** A processor approval is not an order. Only the merchant can confirm this result. */
4
4
  export type MerchantResult = {
@@ -13,8 +13,9 @@ export type MerchantResult = {
13
13
  reason: '3ds' | 'redirect' | 'other';
14
14
  };
15
15
  export interface CheckoutState {
16
- status: 'idle' | 'awaiting_approval' | 'awaiting_merchant' | 'requires_user_action' | 'completed' | 'declined' | 'timed_out' | 'cancelled' | 'unsupported' | 'outcome_unknown' | 'failed';
16
+ status: 'idle' | 'awaiting_approval' | 'ready_to_submit' | 'awaiting_merchant' | 'requires_user_action' | 'completed' | 'declined' | 'timed_out' | 'cancelled' | 'unsupported' | 'outcome_unknown' | 'failed';
17
17
  authorizationId: string | null;
18
+ preparationId?: string;
18
19
  mode?: CheckoutMode;
19
20
  orderId?: string;
20
21
  /** Stable SDK category; never includes a request body, processor response, or approval link. */
@@ -36,7 +37,9 @@ export interface LifecycleOptions {
36
37
  }
37
38
  export interface CheckoutController {
38
39
  getState(): Readonly<CheckoutState>;
39
- /** Stop this attachment locally. Does not revoke an approval link or cancel a processor payment. */
40
+ /** Await device consent before the caller starts the first native Pay action. One use per attachment. */
41
+ prepare(options: PrepareCheckoutOptions): Promise<PreparedCheckout>;
42
+ /** Stop locally and best-effort retire an unbound preparation. Does not cancel a processor payment. */
40
43
  cancel(): void;
41
44
  /** Ask the application's merchant resolver. A rejection records unknown; never automatically retries payment. */
42
45
  reconcile(): Promise<Readonly<CheckoutState>>;
@@ -57,9 +60,17 @@ export declare class CheckoutLifecycle implements CheckoutController {
57
60
  private merchantAborted;
58
61
  private unboundStripeToken;
59
62
  private reconciliation;
63
+ private preparationHandler?;
64
+ private preparationUsed;
60
65
  readonly abort: AbortController;
61
66
  constructor(options: LifecycleOptions);
62
67
  getState(): Readonly<CheckoutState>;
68
+ setPreparationHandler(handler: (options: PrepareCheckoutOptions) => Promise<PreparedCheckout>): void;
69
+ prepare(options: PrepareCheckoutOptions): Promise<PreparedCheckout>;
70
+ preparing(): void;
71
+ preparationCreated(preparationId: string): void;
72
+ prepared(preparation: PreparedCheckout): void;
73
+ preparationFailed(error: CheckoutPreparationError): void;
63
74
  isBlocked(): boolean;
64
75
  isCancelled(): boolean;
65
76
  begin(): void;
package/dist/lifecycle.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, IntentNotConfirmableError, PaymentOutcomeUnknownError } from './client.js';
1
+ import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, CheckoutPreparationError, IntentNotConfirmableError, PaymentOutcomeUnknownError } from './client.js';
2
2
  /** Shared by the raw CDP and Playwright transports. No browser ownership or payment execution lives here. */
3
3
  export class CheckoutLifecycle {
4
4
  options;
@@ -9,16 +9,38 @@ export class CheckoutLifecycle {
9
9
  merchantAborted = false;
10
10
  unboundStripeToken = false;
11
11
  reconciliation = null;
12
+ preparationHandler;
13
+ preparationUsed = false;
12
14
  abort = new AbortController();
13
15
  constructor(options) {
14
16
  this.options = options;
15
17
  }
16
18
  getState() { return { ...this.state }; }
19
+ setPreparationHandler(handler) { this.preparationHandler = handler; }
20
+ prepare(options) {
21
+ if (!this.preparationHandler)
22
+ return Promise.reject(new CheckoutPreparationError(null, 'transport_unavailable'));
23
+ return this.preparationHandler(options);
24
+ }
25
+ preparing() {
26
+ this.preparationUsed = true;
27
+ this.set({ status: 'awaiting_approval', authorizationId: null });
28
+ }
29
+ preparationCreated(preparationId) { this.set({ ...this.state, preparationId }); }
30
+ prepared(preparation) {
31
+ this.set({ status: 'ready_to_submit', authorizationId: null, preparationId: preparation.id });
32
+ }
33
+ preparationFailed(error) {
34
+ this.held = true;
35
+ if (this.cancelled)
36
+ return;
37
+ this.set({ ...this.state, status: error.reason === 'expired' ? 'timed_out' : error.reason === 'cancelled' ? 'cancelled' : 'failed', reason: error.reason });
38
+ }
17
39
  isBlocked() { return this.held || this.cancelled; }
18
40
  isCancelled() { return this.cancelled; }
19
41
  begin() {
20
42
  this.active = true;
21
- this.set({ status: 'awaiting_approval', authorizationId: null });
43
+ this.set({ status: 'awaiting_approval', authorizationId: null, ...(this.state.preparationId ? { preparationId: this.state.preparationId } : {}) });
22
44
  }
23
45
  end() { this.active = false; }
24
46
  set(state) {
@@ -83,6 +105,7 @@ export class CheckoutLifecycle {
83
105
  const mismatch = (!replay.mode || replay.mode === 'token') && replay.amountVerified === false;
84
106
  this.held = !!this.options.requireMerchantResult || replay.mode === 'hosted_form' || mismatch || this.unboundStripeToken;
85
107
  this.set({ status: 'awaiting_merchant', authorizationId: replay.authorizationId, mode: replay.mode ?? 'token',
108
+ ...(this.state.preparationId ? { preparationId: this.state.preparationId } : {}),
86
109
  ...(mismatch ? { reason: 'charged_amount_mismatch' } : this.unboundStripeToken ? { reason: 'stripe_tokenization_unbound' } : {}) });
87
110
  }
88
111
  failed(error, handoffStarted = false) {
@@ -100,6 +123,9 @@ export class CheckoutLifecycle {
100
123
  else if (error instanceof CheckoutCancelledError) {
101
124
  this.cancel();
102
125
  }
126
+ else if (error instanceof CheckoutPreparationError) {
127
+ this.preparationFailed(error);
128
+ }
103
129
  else if (error instanceof ApprovalTimeoutError) {
104
130
  this.set({ ...this.state, status: 'timed_out', reason: 'approval_expired' });
105
131
  }
@@ -162,6 +188,8 @@ export class CheckoutLifecycle {
162
188
  return this.getState();
163
189
  }
164
190
  retryAfterMerchantFailure(result) {
191
+ if (this.preparationUsed)
192
+ throw new Error('Prepared checkouts are single use. Reconcile this attempt and create a new attachment.');
165
193
  if (this.active || this.reconciliation)
166
194
  throw new Error('Cannot start another attempt while payment or reconciliation is in progress.');
167
195
  if (this.cancelled)
@@ -0,0 +1,25 @@
1
+ import { type PreparedCheckout } from './client.js';
2
+ import type { AttachOptions } from './cdp.js';
3
+ import type { CheckoutLifecycle } from './lifecycle.js';
4
+ /** A local, one-use rendezvous. It never starts or retries a merchant request. */
5
+ export declare class PreparationGate {
6
+ private readonly opts;
7
+ private readonly lifecycle;
8
+ private readonly readDocumentUrl;
9
+ private state;
10
+ private observedRequest;
11
+ private documentUrl;
12
+ private prepared?;
13
+ private stop;
14
+ private expiryTimer?;
15
+ constructor(opts: AttachOptions, lifecycle: CheckoutLifecycle, readDocumentUrl: () => Promise<string>);
16
+ private prepare;
17
+ /** Called for every recognized card mutation, before any await or local retry guard. */
18
+ claim(requestUrl: string): PreparedCheckout | undefined;
19
+ assertDocument(): Promise<void>;
20
+ private readDocument;
21
+ isEngaged(): boolean;
22
+ retireUnboundClaim(): void;
23
+ /** A bound native request has its own cancellation/unknown-outcome machinery. */
24
+ invalidate(reason: string): void;
25
+ }
@@ -0,0 +1,150 @@
1
+ import { CheckoutPreparationError } from './client.js';
2
+ /** A local, one-use rendezvous. It never starts or retries a merchant request. */
3
+ export class PreparationGate {
4
+ opts;
5
+ lifecycle;
6
+ readDocumentUrl;
7
+ state = 'unused';
8
+ observedRequest = false;
9
+ documentUrl = '';
10
+ prepared;
11
+ stop = new AbortController();
12
+ expiryTimer;
13
+ constructor(opts, lifecycle, readDocumentUrl) {
14
+ this.opts = opts;
15
+ this.lifecycle = lifecycle;
16
+ this.readDocumentUrl = readDocumentUrl;
17
+ lifecycle.setPreparationHandler(options => this.prepare(options));
18
+ lifecycle.abort.signal.addEventListener('abort', () => this.invalidate('cancelled'), { once: true });
19
+ }
20
+ async prepare(options) {
21
+ options = { ...options };
22
+ if (this.state !== 'unused' || this.observedRequest || this.lifecycle.getState().status !== 'idle' || this.lifecycle.isBlocked()) {
23
+ // A late opt-in cannot turn the next native retry into ordinary approval.
24
+ // Preserve any existing authorization's state for reconciliation.
25
+ if (this.state === 'unused')
26
+ this.state = 'failed';
27
+ throw new CheckoutPreparationError(this.prepared?.id ?? null, 'must_prepare_before_first_request');
28
+ }
29
+ // Reserve synchronously, including while origin discovery/OAuth is pending.
30
+ this.state = 'preparing';
31
+ this.lifecycle.preparing();
32
+ const signal = AbortSignal.any([this.stop.signal, this.lifecycle.abort.signal, ...(options?.signal ? [options.signal] : [])]);
33
+ const onAbort = () => this.invalidate('cancelled');
34
+ signal.addEventListener('abort', onAbort, { once: true });
35
+ try {
36
+ if (!options || options.psp !== 'square' || !['production', 'sandbox'].includes(options.environment))
37
+ throw new CheckoutPreparationError(null, 'unsupported_processor');
38
+ const tokenizer = options.environment === 'production' ? 'https://pci-connect.squareup.com/v2/card-nonce' : 'https://pci-connect.squareupsandbox.com/v2/card-nonce';
39
+ if (!this.opts.vault.isCardRequest(tokenizer, 'POST'))
40
+ throw new CheckoutPreparationError(null, 'processor_interception_unavailable');
41
+ if (!Number.isSafeInteger(this.opts.amountCents) || (this.opts.amountCents ?? 0) <= 0 || !/^[a-z]{3}$/i.test(this.opts.currency ?? ''))
42
+ throw new CheckoutPreparationError(null, 'amount_required');
43
+ if (signal.aborted)
44
+ throw new CheckoutPreparationError(null, 'cancelled');
45
+ this.documentUrl = await this.readDocument();
46
+ const page = new URL(this.documentUrl);
47
+ if (!(page.protocol === 'https:' || (page.protocol === 'http:' && page.hostname === 'localhost')) || page.username || page.password)
48
+ throw new CheckoutPreparationError(null, 'merchant_origin_invalid');
49
+ const prepared = await this.opts.vault.prepareCheckout({
50
+ ...options, user: this.opts.user, merchant: this.opts.merchant,
51
+ amountCents: this.opts.amountCents, currency: this.opts.currency, cardId: this.opts.cardId,
52
+ merchantOrigin: page.origin, checkoutKey: crypto.randomUUID(), timeoutMs: this.opts.timeoutMs, signal,
53
+ onPreparationCreated: id => this.lifecycle.preparationCreated(id),
54
+ onApprovalUrl: url => {
55
+ if (!signal.aborted) {
56
+ this.lifecycle.approvalUrl(url);
57
+ try {
58
+ Promise.resolve(this.opts.onApprovalUrl?.(url)).catch(() => { });
59
+ }
60
+ catch { /* observer only */ }
61
+ }
62
+ },
63
+ });
64
+ this.prepared = prepared;
65
+ if (signal.aborted || this.state !== 'preparing') {
66
+ void this.opts.vault.cancelPreparation(prepared.id).catch(() => { });
67
+ throw new CheckoutPreparationError(prepared.id, 'cancelled');
68
+ }
69
+ await this.assertDocument();
70
+ if (signal.aborted || this.state !== 'preparing')
71
+ throw new CheckoutPreparationError(prepared.id, 'cancelled');
72
+ const remaining = Date.parse(prepared.expiresAt) - Date.now();
73
+ if (!(remaining > 0))
74
+ throw new CheckoutPreparationError(prepared.id, 'expired');
75
+ this.state = 'ready';
76
+ this.expiryTimer = setTimeout(() => this.invalidate('expired'), remaining);
77
+ this.expiryTimer.unref?.();
78
+ this.lifecycle.prepared(prepared);
79
+ return prepared;
80
+ }
81
+ catch (error) {
82
+ const failure = error instanceof CheckoutPreparationError ? error : new CheckoutPreparationError(this.prepared?.id ?? null, 'preparation_unconfirmed');
83
+ this.invalidate(failure.reason);
84
+ throw failure;
85
+ }
86
+ // Keep the signal listener after ready: caller cancellation retires the handle too.
87
+ }
88
+ /** Called for every recognized card mutation, before any await or local retry guard. */
89
+ claim(requestUrl) {
90
+ this.observedRequest = true;
91
+ if (this.state === 'unused')
92
+ return undefined;
93
+ if (this.state !== 'ready' || !this.prepared) {
94
+ this.invalidate(this.state === 'preparing' ? 'submitted_before_ready' : 'already_used_or_unavailable');
95
+ throw new CheckoutPreparationError(this.prepared?.id ?? null, 'already_used_or_unavailable');
96
+ }
97
+ const prepared = this.prepared;
98
+ const request = new URL(requestUrl);
99
+ const origin = prepared.environment === 'production' ? 'https://pci-connect.squareup.com' : 'https://pci-connect.squareupsandbox.com';
100
+ if (Date.parse(prepared.expiresAt) <= Date.now() || request.origin !== origin || request.pathname !== '/v2/card-nonce' || request.username || request.password) {
101
+ const reason = Date.parse(prepared.expiresAt) <= Date.now() ? 'expired' : 'checkout_changed';
102
+ this.invalidate(reason);
103
+ throw new CheckoutPreparationError(prepared.id, reason);
104
+ }
105
+ this.state = 'consumed';
106
+ clearTimeout(this.expiryTimer);
107
+ return prepared;
108
+ }
109
+ async assertDocument() {
110
+ if (this.documentUrl && await this.readDocument() !== this.documentUrl)
111
+ throw new CheckoutPreparationError(this.prepared?.id ?? null, 'merchant_document_changed');
112
+ }
113
+ async readDocument() {
114
+ const signal = AbortSignal.any([this.stop.signal, this.lifecycle.abort.signal, AbortSignal.timeout(5_000)]);
115
+ const failure = () => new CheckoutPreparationError(this.prepared?.id ?? null, 'merchant_document_unavailable');
116
+ if (signal.aborted)
117
+ throw failure();
118
+ let aborted;
119
+ const stopped = new Promise((_, reject) => {
120
+ aborted = () => reject(failure());
121
+ signal.addEventListener('abort', aborted, { once: true });
122
+ });
123
+ try {
124
+ return await Promise.race([this.readDocumentUrl(), stopped]);
125
+ }
126
+ finally {
127
+ signal.removeEventListener('abort', aborted);
128
+ }
129
+ }
130
+ isEngaged() { return this.state !== 'unused'; }
131
+ retireUnboundClaim() {
132
+ if (this.state !== 'consumed' || !this.prepared || this.lifecycle.getState().authorizationId)
133
+ return;
134
+ void this.opts.vault.cancelPreparation(this.prepared.id).catch(() => { });
135
+ // No bound ID means the adapter must not leave a spent handle appearing ready.
136
+ if (this.lifecycle.getState().status === 'ready_to_submit')
137
+ this.lifecycle.preparationFailed(new CheckoutPreparationError(this.prepared.id, 'request_not_bound'));
138
+ }
139
+ /** A bound native request has its own cancellation/unknown-outcome machinery. */
140
+ invalidate(reason) {
141
+ if (this.state === 'unused' || this.state === 'consumed' || this.state === 'failed')
142
+ return;
143
+ this.state = 'failed';
144
+ clearTimeout(this.expiryTimer);
145
+ this.stop.abort();
146
+ if (this.prepared)
147
+ void this.opts.vault.cancelPreparation(this.prepared.id).catch(() => { });
148
+ this.lifecycle.preparationFailed(new CheckoutPreparationError(this.prepared?.id ?? null, reason));
149
+ }
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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,8 +23,8 @@
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 merchant-abort.test.mjs",
27
- "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs"
26
+ "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs",
27
+ "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs && node preparation-browser.test.mjs"
28
28
  },
29
29
  "keywords": [
30
30
  "payments",