@agent-cards/checkout 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +183 -10
- package/dist/cdp.d.ts +10 -5
- package/dist/cdp.js +187 -21
- package/dist/client.d.ts +26 -4
- package/dist/client.js +242 -98
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +94 -0
- package/dist/lifecycle.js +207 -0
- package/examples/existing-browser.mjs +63 -0
- package/package.json +6 -3
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, IntentNotConfirmableError, PaymentOutcomeUnknownError } from './client.js';
|
|
2
|
+
/** Shared by the raw CDP and Playwright transports. No browser ownership or payment execution lives here. */
|
|
3
|
+
export class CheckoutLifecycle {
|
|
4
|
+
options;
|
|
5
|
+
state = { status: 'idle', authorizationId: null };
|
|
6
|
+
held = false;
|
|
7
|
+
active = false;
|
|
8
|
+
cancelled = false;
|
|
9
|
+
merchantAborted = false;
|
|
10
|
+
unboundStripeToken = false;
|
|
11
|
+
reconciliation = null;
|
|
12
|
+
abort = new AbortController();
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
getState() { return { ...this.state }; }
|
|
17
|
+
isBlocked() { return this.held || this.cancelled; }
|
|
18
|
+
isCancelled() { return this.cancelled; }
|
|
19
|
+
begin() {
|
|
20
|
+
this.active = true;
|
|
21
|
+
this.set({ status: 'awaiting_approval', authorizationId: null });
|
|
22
|
+
}
|
|
23
|
+
end() { this.active = false; }
|
|
24
|
+
set(state) {
|
|
25
|
+
this.state = state;
|
|
26
|
+
// Application telemetry must never interrupt a paused payment after it reaches the processor.
|
|
27
|
+
try {
|
|
28
|
+
Promise.resolve(this.options.onStateChange?.(this.getState())).catch(() => { });
|
|
29
|
+
}
|
|
30
|
+
catch { /* observer only */ }
|
|
31
|
+
}
|
|
32
|
+
approvalCreated(authorizationId) {
|
|
33
|
+
this.set({ ...this.state, authorizationId });
|
|
34
|
+
}
|
|
35
|
+
approvalUrl(approvalUrl) {
|
|
36
|
+
this.notify({ reason: 'approval', authorizationId: this.state.authorizationId, approvalUrl });
|
|
37
|
+
}
|
|
38
|
+
notify(action) {
|
|
39
|
+
try {
|
|
40
|
+
Promise.resolve(this.options.onUserAction?.(action)).catch(() => { });
|
|
41
|
+
}
|
|
42
|
+
catch { /* observer only */ }
|
|
43
|
+
}
|
|
44
|
+
cancel() {
|
|
45
|
+
this.cancelled = true;
|
|
46
|
+
this.held = true;
|
|
47
|
+
this.abort.abort();
|
|
48
|
+
this.set({ ...this.state, status: this.active || this.state.authorizationId ? 'outcome_unknown' : 'cancelled', reason: 'local_cancel' });
|
|
49
|
+
}
|
|
50
|
+
/** The exact browser request is gone; a late approval cannot reopen it. */
|
|
51
|
+
merchantRequestAborted() {
|
|
52
|
+
this.merchantAborted = true;
|
|
53
|
+
this.held = true;
|
|
54
|
+
this.set({ ...this.state, status: 'outcome_unknown', reason: 'merchant_request_aborted' });
|
|
55
|
+
}
|
|
56
|
+
unsupported() {
|
|
57
|
+
this.held = true;
|
|
58
|
+
if (this.state.authorizationId)
|
|
59
|
+
return; // do not hide an outstanding/completed payment behind a later unsupported request
|
|
60
|
+
this.set({ ...this.state, status: 'unsupported', reason: 'unrecognized_payment_endpoint' });
|
|
61
|
+
}
|
|
62
|
+
prepareHandoff(replay, requestUrl) {
|
|
63
|
+
// A tokenization approval has no authoritative PaymentIntent or amount
|
|
64
|
+
// binding. Neither the first observed confirm nor the returned opaque
|
|
65
|
+
// token can supply one. Hold even in compatibility mode: this attachment
|
|
66
|
+
// must never turn that token into an unreviewed payment continuation.
|
|
67
|
+
if (!replay.mode || replay.mode === 'token') {
|
|
68
|
+
try {
|
|
69
|
+
const url = new URL(requestUrl);
|
|
70
|
+
if (url.origin === 'https://api.stripe.com' && ['/v1/payment_methods', '/v1/tokens'].includes(url.pathname)) {
|
|
71
|
+
// Set before delivery: a lost browser acknowledgement must not reset
|
|
72
|
+
// an already-issued token into a retryable authorization.
|
|
73
|
+
this.unboundStripeToken = true;
|
|
74
|
+
this.held = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch { /* unknown URL cannot establish a token binding */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
handedOff(replay) {
|
|
81
|
+
if (this.merchantAborted)
|
|
82
|
+
return;
|
|
83
|
+
const mismatch = (!replay.mode || replay.mode === 'token') && replay.amountVerified === false;
|
|
84
|
+
this.held = !!this.options.requireMerchantResult || replay.mode === 'hosted_form' || mismatch || this.unboundStripeToken;
|
|
85
|
+
this.set({ status: 'awaiting_merchant', authorizationId: replay.authorizationId, mode: replay.mode ?? 'token',
|
|
86
|
+
...(mismatch ? { reason: 'charged_amount_mismatch' } : this.unboundStripeToken ? { reason: 'stripe_tokenization_unbound' } : {}) });
|
|
87
|
+
}
|
|
88
|
+
failed(error, handoffStarted = false) {
|
|
89
|
+
if (this.cancelled)
|
|
90
|
+
return;
|
|
91
|
+
if (this.merchantAborted) {
|
|
92
|
+
this.merchantRequestAborted();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const authorizationId = error instanceof PaymentOutcomeUnknownError ? error.authorizationId : this.state.authorizationId;
|
|
96
|
+
if (handoffStarted || error instanceof PaymentOutcomeUnknownError || error instanceof IntentNotConfirmableError) {
|
|
97
|
+
this.held = true;
|
|
98
|
+
this.set({ ...this.state, authorizationId, status: 'outcome_unknown', reason: error instanceof PaymentOutcomeUnknownError ? error.reason : error instanceof IntentNotConfirmableError ? 'intent_not_confirmable' : 'browser_handoff_failed' });
|
|
99
|
+
}
|
|
100
|
+
else if (error instanceof CheckoutCancelledError) {
|
|
101
|
+
this.cancel();
|
|
102
|
+
}
|
|
103
|
+
else if (error instanceof ApprovalTimeoutError) {
|
|
104
|
+
this.set({ ...this.state, status: 'timed_out', reason: 'approval_expired' });
|
|
105
|
+
}
|
|
106
|
+
else if (error instanceof ApprovalDeclinedError) {
|
|
107
|
+
this.set({ ...this.state, status: 'declined', reason: 'approval_declined' });
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
this.set({ ...this.state, status: 'failed', reason: 'checkout_failed' });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async requestUserAction(reason) {
|
|
114
|
+
if (this.state.status === 'completed')
|
|
115
|
+
return;
|
|
116
|
+
this.held = true;
|
|
117
|
+
this.set({ ...this.state, status: 'requires_user_action', reason });
|
|
118
|
+
// Hooks deliver a link/live view owned by the integrator; the SDK does not invent challenge URLs.
|
|
119
|
+
try {
|
|
120
|
+
await this.options.onUserAction?.({ reason, authorizationId: this.state.authorizationId });
|
|
121
|
+
}
|
|
122
|
+
catch { /* observer only */ }
|
|
123
|
+
}
|
|
124
|
+
reconcile() {
|
|
125
|
+
if (this.state.status === 'completed')
|
|
126
|
+
return Promise.resolve(this.getState());
|
|
127
|
+
if (this.reconciliation)
|
|
128
|
+
return this.reconciliation;
|
|
129
|
+
if (!this.options.resolveMerchantResult)
|
|
130
|
+
return Promise.resolve(this.getState());
|
|
131
|
+
if (this.active)
|
|
132
|
+
throw new Error('Wait for the paused request to finish before reconciling the merchant order.');
|
|
133
|
+
this.reconciliation = this.resolve().finally(() => { this.reconciliation = null; });
|
|
134
|
+
return this.reconciliation;
|
|
135
|
+
}
|
|
136
|
+
async resolve() {
|
|
137
|
+
let result;
|
|
138
|
+
try {
|
|
139
|
+
result = await this.options.resolveMerchantResult(this.getState());
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
result = { status: 'unknown' };
|
|
143
|
+
}
|
|
144
|
+
if (!result || typeof result !== 'object')
|
|
145
|
+
result = { status: 'unknown' };
|
|
146
|
+
if (result.status === 'completed' && typeof result.orderId === 'string' && result.orderId.length > 0) {
|
|
147
|
+
this.held = true;
|
|
148
|
+
this.set({ ...this.state, status: 'completed', orderId: result.orderId });
|
|
149
|
+
}
|
|
150
|
+
else if (result.status === 'failed') {
|
|
151
|
+
// Keep the guard armed until the application deliberately starts another attempt.
|
|
152
|
+
this.held = true;
|
|
153
|
+
this.set({ ...this.state, status: 'failed', reason: 'merchant_confirmed_failure' });
|
|
154
|
+
}
|
|
155
|
+
else if (result.status === 'requires_user_action') {
|
|
156
|
+
await this.requestUserAction(result.reason);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
this.held = true;
|
|
160
|
+
this.set({ ...this.state, status: result.status === 'pending' ? 'awaiting_merchant' : 'outcome_unknown', reason: 'merchant_not_confirmed' });
|
|
161
|
+
}
|
|
162
|
+
return this.getState();
|
|
163
|
+
}
|
|
164
|
+
retryAfterMerchantFailure(result) {
|
|
165
|
+
if (this.active || this.reconciliation)
|
|
166
|
+
throw new Error('Cannot start another attempt while payment or reconciliation is in progress.');
|
|
167
|
+
if (this.cancelled)
|
|
168
|
+
throw new Error('This attachment was cancelled; attach again after reconciling the merchant order.');
|
|
169
|
+
if (result?.status !== 'failed')
|
|
170
|
+
throw new Error('A merchant-confirmed failure is required before retrying.');
|
|
171
|
+
if (this.state.status === 'completed')
|
|
172
|
+
throw new Error('This order is already complete. Use a new attachment for another order.');
|
|
173
|
+
if (this.unboundStripeToken)
|
|
174
|
+
throw new Error('This attachment delivered an unbound Stripe token; reconcile the merchant order and use a separately validated checkout flow.');
|
|
175
|
+
this.held = false;
|
|
176
|
+
this.merchantAborted = false;
|
|
177
|
+
this.set({ status: 'idle', authorizationId: null });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function paymentEndpointGuards(input = []) {
|
|
181
|
+
const guards = input.map((guard) => {
|
|
182
|
+
const origin = new URL(guard.origin);
|
|
183
|
+
if (!['http:', 'https:'].includes(origin.protocol) || origin.origin !== guard.origin
|
|
184
|
+
|| !guard.pathname.startsWith('/') || /[?#*]/.test(guard.pathname)) {
|
|
185
|
+
throw new Error('Payment endpoint guards require a canonical http(s) origin and an exact path without wildcards or query strings.');
|
|
186
|
+
}
|
|
187
|
+
const methods = (guard.methods ?? ['POST', 'PUT', 'PATCH']).map((method) => method.toUpperCase());
|
|
188
|
+
if (!methods.length || methods.some((method) => !['POST', 'PUT', 'PATCH', 'DELETE'].includes(method))) {
|
|
189
|
+
throw new Error('Payment endpoint guards can only block explicit mutation methods.');
|
|
190
|
+
}
|
|
191
|
+
return { ...guard, methods };
|
|
192
|
+
});
|
|
193
|
+
return {
|
|
194
|
+
patterns: guards.map((guard) => `${guard.origin}${guard.pathname}*`),
|
|
195
|
+
matches(raw, method) {
|
|
196
|
+
let url;
|
|
197
|
+
try {
|
|
198
|
+
url = new URL(raw);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
return guards.some((guard) => url.origin === guard.origin && url.pathname === guard.pathname
|
|
204
|
+
&& (method === undefined || guard.methods.includes(method.toUpperCase())));
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Existing Kernel / Browserbase / custom Chromium session; does not create or close the provider's browser.
|
|
2
|
+
// Run: CHECKOUT_DRIVER=/absolute/path/to/instinct-driver.mjs node examples/existing-browser.mjs
|
|
3
|
+
// The driver owns the existing agent, its approved purchase and merchant-specific order verification.
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { chromium } from 'playwright-core';
|
|
6
|
+
import { VaultClient, attachToPlaywright } from '../dist/index.js';
|
|
7
|
+
|
|
8
|
+
const driverPath = process.env.CHECKOUT_DRIVER;
|
|
9
|
+
if (!driverPath?.startsWith('/')) throw new Error('CHECKOUT_DRIVER must name an absolute path to your integration module.');
|
|
10
|
+
const driver = await import(pathToFileURL(driverPath).href);
|
|
11
|
+
for (const method of ['prepareCheckout', 'submitCheckout', 'resolveMerchantResult', 'onUserAction', 'finishAfterPayment']) {
|
|
12
|
+
if (typeof driver[method] !== 'function') throw new Error(`Your driver must implement ${method}.`);
|
|
13
|
+
}
|
|
14
|
+
if (!process.env.CHECKOUT_CDP_URL) throw new Error('Set CHECKOUT_CDP_URL to your existing session connection URL. Never log it.');
|
|
15
|
+
for (const name of ['AGENTCARD_CLIENT_ID', 'AGENTCARD_CLIENT_SECRET']) {
|
|
16
|
+
if (!process.env[name]) throw new Error(`Set ${name} for your Agentcard organization.`);
|
|
17
|
+
}
|
|
18
|
+
const browser = await chromium.connectOverCDP(process.env.CHECKOUT_CDP_URL);
|
|
19
|
+
try {
|
|
20
|
+
const context = browser.contexts()[0];
|
|
21
|
+
if (!context) throw new Error('The provider returned no default browser context.');
|
|
22
|
+
const pages = context.pages();
|
|
23
|
+
const index = Number(process.env.CHECKOUT_PAGE_INDEX ?? 0);
|
|
24
|
+
const page = pages[index];
|
|
25
|
+
if (!page) throw new Error('CHECKOUT_PAGE_INDEX must select the existing agent checkout tab.');
|
|
26
|
+
|
|
27
|
+
const vault = new VaultClient({ clientId: process.env.AGENTCARD_CLIENT_ID, clientSecret: process.env.AGENTCARD_CLIENT_SECRET });
|
|
28
|
+
await vault.syncRegistry();
|
|
29
|
+
const checkout = await driver.prepareCheckout(page); // user, merchant, amountCents+currency, exact paymentEndpoints
|
|
30
|
+
const controller = await attachToPlaywright(page, {
|
|
31
|
+
...checkout,
|
|
32
|
+
vault,
|
|
33
|
+
requireMerchantResult: true,
|
|
34
|
+
onUserAction: action => driver.onUserAction(action, { page }),
|
|
35
|
+
resolveMerchantResult: state => driver.resolveMerchantResult({ page, state }),
|
|
36
|
+
// State has no PAN, replay body, approval capability, or provider connection URL.
|
|
37
|
+
onStateChange: state => console.log(JSON.stringify(state)),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Must return after dispatching the click; do not block on a navigation that awaits user approval.
|
|
41
|
+
await driver.submitCheckout(page);
|
|
42
|
+
const deadline = Date.now() + 15 * 60_000;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
let state = controller.getState();
|
|
45
|
+
if (['awaiting_merchant', 'requires_user_action', 'outcome_unknown'].includes(state.status)) {
|
|
46
|
+
state = await controller.reconcile();
|
|
47
|
+
}
|
|
48
|
+
if (state.status === 'completed' && state.orderId) {
|
|
49
|
+
await driver.finishAfterPayment({ page, orderId: state.orderId });
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (['declined', 'timed_out', 'cancelled', 'unsupported', 'failed'].includes(state.status)) break;
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
54
|
+
}
|
|
55
|
+
if (controller.getState().status !== 'completed') {
|
|
56
|
+
controller.cancel(); // local stop only; reconcile any still-valid approval before creating a new one
|
|
57
|
+
process.exitCode = 2;
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
// Disconnect this CDP client. The existing agent retains ownership of its provider session.
|
|
61
|
+
// Do not call page.close(), context.close(), or a provider session-delete API here.
|
|
62
|
+
await browser.close();
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-cards/checkout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Let browser agents pay with the user's own card, without your infrastructure ever touching card data.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
|
-
"README.md"
|
|
15
|
+
"README.md",
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"examples"
|
|
16
18
|
],
|
|
17
19
|
"publishConfig": {
|
|
18
20
|
"access": "public"
|
|
@@ -21,7 +23,8 @@
|
|
|
21
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.",
|
|
22
24
|
"build": "npx -y -p typescript@5.9.3 tsc",
|
|
23
25
|
"prepublishOnly": "pnpm build",
|
|
24
|
-
"test": "node test.mjs"
|
|
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"
|
|
25
28
|
},
|
|
26
29
|
"keywords": [
|
|
27
30
|
"payments",
|