@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 +8 -0
- package/README.md +29 -5
- package/dist/cdp.js +73 -6
- package/dist/client.d.ts +50 -0
- package/dist/client.js +170 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +14 -3
- package/dist/lifecycle.js +30 -2
- package/dist/preparation.d.ts +25 -0
- package/dist/preparation.js +150 -0
- package/package.json +3 -3
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
|
|
86
|
-
|
|
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
|
|
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.
|
|
443
|
-
in-process Agentcard API fixture and loopback merchant pages.
|
|
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
|
-
|
|
374
|
-
|
|
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';
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
"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",
|