@agent-cards/checkout 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +34 -5
- package/dist/cdp.js +162 -6
- package/dist/client.d.ts +60 -0
- package/dist/client.js +323 -113
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +17 -3
- package/dist/lifecycle.js +44 -2
- package/dist/preparation.d.ts +25 -0
- package/dist/preparation.js +150 -0
- package/package.json +3 -3
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;
|
|
@@ -6,18 +6,41 @@ export class CheckoutLifecycle {
|
|
|
6
6
|
held = false;
|
|
7
7
|
active = false;
|
|
8
8
|
cancelled = false;
|
|
9
|
+
merchantAborted = false;
|
|
9
10
|
unboundStripeToken = false;
|
|
10
11
|
reconciliation = null;
|
|
12
|
+
preparationHandler;
|
|
13
|
+
preparationUsed = false;
|
|
11
14
|
abort = new AbortController();
|
|
12
15
|
constructor(options) {
|
|
13
16
|
this.options = options;
|
|
14
17
|
}
|
|
15
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
|
+
}
|
|
16
39
|
isBlocked() { return this.held || this.cancelled; }
|
|
17
40
|
isCancelled() { return this.cancelled; }
|
|
18
41
|
begin() {
|
|
19
42
|
this.active = true;
|
|
20
|
-
this.set({ status: 'awaiting_approval', authorizationId: null });
|
|
43
|
+
this.set({ status: 'awaiting_approval', authorizationId: null, ...(this.state.preparationId ? { preparationId: this.state.preparationId } : {}) });
|
|
21
44
|
}
|
|
22
45
|
end() { this.active = false; }
|
|
23
46
|
set(state) {
|
|
@@ -46,6 +69,12 @@ export class CheckoutLifecycle {
|
|
|
46
69
|
this.abort.abort();
|
|
47
70
|
this.set({ ...this.state, status: this.active || this.state.authorizationId ? 'outcome_unknown' : 'cancelled', reason: 'local_cancel' });
|
|
48
71
|
}
|
|
72
|
+
/** The exact browser request is gone; a late approval cannot reopen it. */
|
|
73
|
+
merchantRequestAborted() {
|
|
74
|
+
this.merchantAborted = true;
|
|
75
|
+
this.held = true;
|
|
76
|
+
this.set({ ...this.state, status: 'outcome_unknown', reason: 'merchant_request_aborted' });
|
|
77
|
+
}
|
|
49
78
|
unsupported() {
|
|
50
79
|
this.held = true;
|
|
51
80
|
if (this.state.authorizationId)
|
|
@@ -71,14 +100,21 @@ export class CheckoutLifecycle {
|
|
|
71
100
|
}
|
|
72
101
|
}
|
|
73
102
|
handedOff(replay) {
|
|
103
|
+
if (this.merchantAborted)
|
|
104
|
+
return;
|
|
74
105
|
const mismatch = (!replay.mode || replay.mode === 'token') && replay.amountVerified === false;
|
|
75
106
|
this.held = !!this.options.requireMerchantResult || replay.mode === 'hosted_form' || mismatch || this.unboundStripeToken;
|
|
76
107
|
this.set({ status: 'awaiting_merchant', authorizationId: replay.authorizationId, mode: replay.mode ?? 'token',
|
|
108
|
+
...(this.state.preparationId ? { preparationId: this.state.preparationId } : {}),
|
|
77
109
|
...(mismatch ? { reason: 'charged_amount_mismatch' } : this.unboundStripeToken ? { reason: 'stripe_tokenization_unbound' } : {}) });
|
|
78
110
|
}
|
|
79
111
|
failed(error, handoffStarted = false) {
|
|
80
112
|
if (this.cancelled)
|
|
81
113
|
return;
|
|
114
|
+
if (this.merchantAborted) {
|
|
115
|
+
this.merchantRequestAborted();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
82
118
|
const authorizationId = error instanceof PaymentOutcomeUnknownError ? error.authorizationId : this.state.authorizationId;
|
|
83
119
|
if (handoffStarted || error instanceof PaymentOutcomeUnknownError || error instanceof IntentNotConfirmableError) {
|
|
84
120
|
this.held = true;
|
|
@@ -87,6 +123,9 @@ export class CheckoutLifecycle {
|
|
|
87
123
|
else if (error instanceof CheckoutCancelledError) {
|
|
88
124
|
this.cancel();
|
|
89
125
|
}
|
|
126
|
+
else if (error instanceof CheckoutPreparationError) {
|
|
127
|
+
this.preparationFailed(error);
|
|
128
|
+
}
|
|
90
129
|
else if (error instanceof ApprovalTimeoutError) {
|
|
91
130
|
this.set({ ...this.state, status: 'timed_out', reason: 'approval_expired' });
|
|
92
131
|
}
|
|
@@ -149,6 +188,8 @@ export class CheckoutLifecycle {
|
|
|
149
188
|
return this.getState();
|
|
150
189
|
}
|
|
151
190
|
retryAfterMerchantFailure(result) {
|
|
191
|
+
if (this.preparationUsed)
|
|
192
|
+
throw new Error('Prepared checkouts are single use. Reconcile this attempt and create a new attachment.');
|
|
152
193
|
if (this.active || this.reconciliation)
|
|
153
194
|
throw new Error('Cannot start another attempt while payment or reconciliation is in progress.');
|
|
154
195
|
if (this.cancelled)
|
|
@@ -160,6 +201,7 @@ export class CheckoutLifecycle {
|
|
|
160
201
|
if (this.unboundStripeToken)
|
|
161
202
|
throw new Error('This attachment delivered an unbound Stripe token; reconcile the merchant order and use a separately validated checkout flow.');
|
|
162
203
|
this.held = false;
|
|
204
|
+
this.merchantAborted = false;
|
|
163
205
|
this.set({ status: 'idle', authorizationId: null });
|
|
164
206
|
}
|
|
165
207
|
}
|
|
@@ -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",
|
|
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",
|