@agent-cards/commerce-sdk 0.0.0-stage → 0.1.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.
@@ -0,0 +1,250 @@
1
+ /** The fetch the client calls: Node 18 and later have one built in. */
2
+ export type FetchLike = (url: string, init: {
3
+ method: string;
4
+ headers: Record<string, string>;
5
+ body?: string;
6
+ signal?: AbortSignal;
7
+ }) => Promise<{
8
+ status: number;
9
+ ok: boolean;
10
+ text(): Promise<string>;
11
+ }>;
12
+ export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
13
+ /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
14
+ export declare const BUY_TURN_TIMEOUT_MS = 130000;
15
+ /** A company's client credentials, or one shopper's own access token. */
16
+ export type AgentcardClientOptions = {
17
+ baseUrl?: string;
18
+ fetch?: FetchLike;
19
+ } & ({
20
+ clientId: string;
21
+ clientSecret: string;
22
+ accessToken?: undefined;
23
+ } | {
24
+ accessToken: string;
25
+ clientId?: undefined;
26
+ clientSecret?: undefined;
27
+ });
28
+ /** Who a call is for. A company names the shopper by its own id or by Agentcard's user id. Under a shopper's own token, leave both out. */
29
+ export interface Shopper {
30
+ externalUserId?: string;
31
+ userId?: string;
32
+ }
33
+ /** A delivery address the company already holds for the shopper; the cart is built for it and the shopper is asked for none of it. */
34
+ export interface BuyDeliveryAddress {
35
+ street: string;
36
+ city: string;
37
+ state: string;
38
+ zip: string;
39
+ address2?: string;
40
+ phone?: string;
41
+ name?: string;
42
+ country?: string;
43
+ can_leave_at_door?: boolean;
44
+ }
45
+ /** One `POST /buy`, as sent. */
46
+ export interface BuyRequest {
47
+ ask?: string;
48
+ /** A cart hash from an earlier answer, or true for the cart Agentcard last answered with. */
49
+ confirm?: string | true;
50
+ /** Continue the shopper's newest open conversation instead of starting one. */
51
+ resume?: true;
52
+ /** Drop the cart on the table and start a new order. */
53
+ new_order?: true;
54
+ payment_source?: 'vault';
55
+ delivery_address?: BuyDeliveryAddress;
56
+ external_user_id?: string;
57
+ user_id?: string;
58
+ }
59
+ export type BuyStatus = 'needs_input' | 'needs_approval' | 'order_placed' | 'partially_placed' | 'declined';
60
+ export interface BuyStore {
61
+ id: string;
62
+ name?: string;
63
+ }
64
+ export interface BuyCartLine {
65
+ name: string;
66
+ qty: number;
67
+ priceCents?: number;
68
+ product_id: string | null;
69
+ /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
70
+ url: string | null;
71
+ }
72
+ /**
73
+ * A cart as Agentcard answers it. `totalCents` is the all-in figure the shopper sees; `hash` is what a confirm names.
74
+ * Every amount is in the minor unit of `currency`, the store's ISO 4217 code.
75
+ */
76
+ export interface BuyCart {
77
+ merchant: string;
78
+ merchant_name: string;
79
+ store: BuyStore | null;
80
+ items: BuyCartLine[];
81
+ serviceFeesCents: number;
82
+ tipCents: number;
83
+ totalCents: number;
84
+ /** The ISO 4217 code the store prices the cart in, or null when Agentcard sent none. */
85
+ currency: string | null;
86
+ approvedCeilingCents: number | null;
87
+ hash: string;
88
+ }
89
+ /** A product a search found. `priceCents` is in the minor unit of `currency`, the store's ISO 4217 code. */
90
+ export interface BuyCatalogItem {
91
+ id: string;
92
+ name: string | null;
93
+ priceCents: number | null;
94
+ /** The ISO 4217 code the store prices the product in, or null when Agentcard sent none. */
95
+ currency: string | null;
96
+ image_url: string | null;
97
+ store: BuyStore | null;
98
+ /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
99
+ url: string | null;
100
+ }
101
+ /** The products the last search found. */
102
+ export interface BuyCatalog {
103
+ merchant: string;
104
+ merchant_name: string;
105
+ items: BuyCatalogItem[];
106
+ }
107
+ export interface BuyPaymentSource {
108
+ source: string;
109
+ brand: string | null;
110
+ last4: string | null;
111
+ }
112
+ /** One turn's answer, every field read for its type; a field an older API does not send reads as null. */
113
+ export interface BuyTurn {
114
+ conversation_id: string;
115
+ /** A status Agentcard adds later reads as `needs_input`. */
116
+ status: BuyStatus;
117
+ reply: string;
118
+ messages: string[];
119
+ cart: BuyCart | null;
120
+ carts: BuyCart[];
121
+ catalog: BuyCatalog | null;
122
+ order_id: string | null;
123
+ payment_source: BuyPaymentSource | null;
124
+ decline_code: string | null;
125
+ approval_url: string | null;
126
+ charge_status: string | null;
127
+ error_code: string | null;
128
+ authorization_id: string | null;
129
+ merchant_total_cents: number | null;
130
+ merchant_currency: string | null;
131
+ add_card_url: string | null;
132
+ add_card_expires_at: string | null;
133
+ placement_announced: boolean | null;
134
+ approval_expires_at: string | null;
135
+ approval_in_progress: boolean | null;
136
+ /** The page where the shopper signs in to a store's own account before Agentcard can buy there. */
137
+ store_login_url: string | null;
138
+ store_login_expires_at: string | null;
139
+ /** The store that page signs in to. */
140
+ store_login_merchant_name: string | null;
141
+ /** The answer as Agentcard sent it. */
142
+ raw: Record<string, unknown>;
143
+ }
144
+ export interface BuyOrder {
145
+ order_id: string | null;
146
+ merchant: string;
147
+ merchant_name: string;
148
+ status: string;
149
+ total_cents: number;
150
+ placed_at: string | null;
151
+ }
152
+ export interface BuyLastCheckout {
153
+ at: string;
154
+ merchant: string;
155
+ merchant_name: string;
156
+ status: string;
157
+ code: string | null;
158
+ message: string | null;
159
+ order_id: string | null;
160
+ decline_code: string | null;
161
+ approval_url: string | null;
162
+ approval_expires_at: string | null;
163
+ placement_announced: boolean | null;
164
+ approval_in_progress: boolean | null;
165
+ /** The add-card link a checkout that found no card made, and when it stops working. */
166
+ add_card_url: string | null;
167
+ add_card_expires_at: string | null;
168
+ total_cents: number | null;
169
+ }
170
+ /** Where one conversation stands: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
171
+ export interface BuyConversation {
172
+ conversation_id: string;
173
+ status: 'open' | 'closed';
174
+ turn_in_progress: boolean;
175
+ last_checkout: BuyLastCheckout | null;
176
+ orders: BuyOrder[];
177
+ carts: BuyCart[];
178
+ raw: Record<string, unknown>;
179
+ }
180
+ /** A Vault link: the page where a shopper connects and adds a card. */
181
+ export interface VaultLink {
182
+ id: string;
183
+ url: string;
184
+ /** When the link stops working, as an ISO 8601 timestamp. */
185
+ expiresAt: string | null;
186
+ /** True when Agentcard returned a link it had already opened for this shopper. */
187
+ reused: boolean;
188
+ }
189
+ /**
190
+ * A refusal, or a failure to reach Agentcard. `code` is the API's own
191
+ * (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
192
+ * `user_id_mismatch`), one read from the shape of the answer
193
+ * (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
194
+ * `conversation_not_found`), or the client's (`unreachable`, `timeout`,
195
+ * `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
196
+ * Agentcard never answered.
197
+ */
198
+ export declare class AgentcardApiError extends Error {
199
+ readonly status: number;
200
+ readonly code: string;
201
+ /** The error body as Agentcard sent it. */
202
+ readonly body: Record<string, unknown>;
203
+ readonly conversationId: string | null;
204
+ /** The carts as they stand, when the refusal names them: a changed cart's fresh hash is here. */
205
+ readonly carts: BuyCart[];
206
+ constructor(status: number, code: string, message: string, body?: Record<string, unknown>, cause?: unknown);
207
+ }
208
+ /** A turn as Agentcard answers it. */
209
+ export declare function turnOf(body: Record<string, unknown>): BuyTurn;
210
+ /** The client every shopper's buy tool shares. Build one per set of credentials and keep it. */
211
+ export declare class AgentcardClient {
212
+ readonly baseUrl: string;
213
+ private readonly fetchFn;
214
+ private readonly credentials;
215
+ private readonly shopperToken;
216
+ private token;
217
+ private inflight;
218
+ private learnedSandbox;
219
+ constructor(options: AgentcardClientOptions);
220
+ /** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
221
+ get company(): boolean;
222
+ /** True for a sandbox client, as the token exchange said; null until the first exchange, and under a shopper's token. */
223
+ get sandbox(): boolean | null;
224
+ /** The access token the client sends: the shopper's own, or the company's, exchanged once and renewed a minute before it expires. */
225
+ bearer(): Promise<string>;
226
+ /**
227
+ * One purchase turn, sent once with a 130-second wait. A turn is a model run
228
+ * against live stores, so a lost answer is never sent again: read the
229
+ * conversation instead. Only a 401 is sent again, once, with a fresh token,
230
+ * because Agentcard refused it before anything ran.
231
+ */
232
+ buy(body: BuyRequest): Promise<BuyTurn>;
233
+ /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
234
+ conversation(conversationId: string, who?: Shopper): Promise<BuyConversation>;
235
+ /**
236
+ * The Vault link a shopper opens to connect to your company and add a card.
237
+ * Agentcard returns the link it already opened for this shopper while that
238
+ * link has time left, so a shopper who asks twice gets one link. Sent once.
239
+ */
240
+ mintVaultLink(input: {
241
+ externalUserId: string;
242
+ }): Promise<VaultLink>;
243
+ /** A company names the shopper exactly once; a shopper's own token already names them. */
244
+ private checkShopper;
245
+ private call;
246
+ private send;
247
+ /** After a 401: the token another call already renewed, or one new exchange, however many calls were refused at once. */
248
+ private renewAfter;
249
+ private exchange;
250
+ }
package/dist/client.js ADDED
@@ -0,0 +1,412 @@
1
+ /**
2
+ * The client the buy tool calls Agentcard with. A company builds one from its
3
+ * client credentials, and every shopper's tool shares the one access token it
4
+ * exchanges them for; or a tool runs on one shopper's own access token. The
5
+ * global fetch is the only transport, so the package keeps no dependencies.
6
+ *
7
+ * The answer fields and the error codes follow the buy module of
8
+ * @agent-cards/sdk (packages/checkout/src/buy.ts: turnOf, buyError), and the
9
+ * token cache follows its client (packages/checkout/src/client.ts accessToken).
10
+ */
11
+ import { isRecord } from './verify.js';
12
+ export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
13
+ /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
14
+ export const BUY_TURN_TIMEOUT_MS = 130_000;
15
+ /** The wait for every other call. */
16
+ const CALL_TIMEOUT_MS = 30_000;
17
+ /** A token is renewed this long before it expires, so it never expires mid-call. */
18
+ const RENEW_EARLY_MS = 60_000;
19
+ /**
20
+ * A refusal, or a failure to reach Agentcard. `code` is the API's own
21
+ * (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
22
+ * `user_id_mismatch`), one read from the shape of the answer
23
+ * (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
24
+ * `conversation_not_found`), or the client's (`unreachable`, `timeout`,
25
+ * `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
26
+ * Agentcard never answered.
27
+ */
28
+ export class AgentcardApiError extends Error {
29
+ status;
30
+ code;
31
+ /** The error body as Agentcard sent it. */
32
+ body;
33
+ conversationId;
34
+ /** The carts as they stand, when the refusal names them: a changed cart's fresh hash is here. */
35
+ carts;
36
+ constructor(status, code, message, body = {}, cause) {
37
+ super(message, cause === undefined ? undefined : { cause });
38
+ this.name = 'AgentcardApiError';
39
+ this.status = status;
40
+ this.code = code;
41
+ this.body = body;
42
+ this.conversationId = str(body.conversation_id);
43
+ this.carts = cartsOf(body.carts);
44
+ }
45
+ }
46
+ const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
47
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
48
+ const bool = (v) => (typeof v === 'boolean' ? v : null);
49
+ /**
50
+ * A product page's address: an absolute http or https URL, serialized the way
51
+ * every browser reads it, or null. A value that is no web page, such as a
52
+ * `javascript:` address, never reaches your code as a store's page, and
53
+ * neither does one with a user name before its host, which reads as one store
54
+ * and opens another.
55
+ */
56
+ function pageUrl(v) {
57
+ const text = str(v);
58
+ if (!text)
59
+ return null;
60
+ try {
61
+ const url = new URL(text);
62
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
63
+ return null;
64
+ return url.username === '' && url.password === '' ? url.href : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ function storeOf(v) {
71
+ if (!isRecord(v) || !str(v.id))
72
+ return null;
73
+ return { id: v.id, ...(str(v.name) ? { name: v.name } : {}) };
74
+ }
75
+ function cartOf(v) {
76
+ if (!isRecord(v) || !str(v.hash) || !str(v.merchant))
77
+ return null;
78
+ return {
79
+ merchant: v.merchant,
80
+ merchant_name: str(v.merchant_name) ?? v.merchant,
81
+ store: storeOf(v.store),
82
+ items: Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => ({
83
+ name: str(i.name) ?? '',
84
+ qty: num(i.qty) ?? 1,
85
+ ...(num(i.priceCents) !== null ? { priceCents: i.priceCents } : {}),
86
+ product_id: str(i.product_id),
87
+ url: pageUrl(i.url),
88
+ })) : [],
89
+ serviceFeesCents: num(v.serviceFeesCents) ?? 0,
90
+ tipCents: num(v.tipCents) ?? 0,
91
+ totalCents: num(v.totalCents) ?? 0,
92
+ currency: str(v.currency),
93
+ approvedCeilingCents: num(v.approvedCeilingCents),
94
+ hash: v.hash,
95
+ };
96
+ }
97
+ function cartsOf(v) {
98
+ return Array.isArray(v) ? v.map(cartOf).filter((c) => c !== null) : [];
99
+ }
100
+ function catalogOf(v) {
101
+ if (!isRecord(v))
102
+ return null;
103
+ const items = Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => ({
104
+ id: str(i.id) ?? '',
105
+ name: str(i.name),
106
+ priceCents: num(i.priceCents),
107
+ currency: str(i.currency),
108
+ image_url: str(i.image_url),
109
+ store: storeOf(i.store),
110
+ url: pageUrl(i.url),
111
+ })) : [];
112
+ return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items };
113
+ }
114
+ function paymentSourceOf(v) {
115
+ return isRecord(v) && str(v.source) ? { source: v.source, brand: str(v.brand), last4: str(v.last4) } : null;
116
+ }
117
+ const STATUSES = new Set(['needs_input', 'needs_approval', 'order_placed', 'partially_placed', 'declined']);
118
+ /** A turn as Agentcard answers it. */
119
+ export function turnOf(body) {
120
+ return {
121
+ conversation_id: str(body.conversation_id) ?? '',
122
+ status: typeof body.status === 'string' && STATUSES.has(body.status) ? body.status : 'needs_input',
123
+ reply: typeof body.reply === 'string' ? body.reply : '',
124
+ messages: Array.isArray(body.messages) ? body.messages.filter((m) => typeof m === 'string') : [],
125
+ cart: cartOf(body.cart),
126
+ carts: cartsOf(body.carts),
127
+ catalog: catalogOf(body.catalog),
128
+ order_id: str(body.order_id),
129
+ payment_source: paymentSourceOf(body.payment_source),
130
+ decline_code: str(body.decline_code),
131
+ approval_url: str(body.approval_url),
132
+ charge_status: str(body.charge_status),
133
+ error_code: str(body.error_code),
134
+ authorization_id: str(body.authorization_id),
135
+ merchant_total_cents: num(body.merchant_total_cents),
136
+ merchant_currency: str(body.merchant_currency),
137
+ add_card_url: str(body.add_card_url),
138
+ add_card_expires_at: str(body.add_card_expires_at),
139
+ placement_announced: bool(body.placement_announced),
140
+ approval_expires_at: str(body.approval_expires_at),
141
+ approval_in_progress: bool(body.approval_in_progress),
142
+ store_login_url: str(body.store_login_url),
143
+ store_login_expires_at: str(body.store_login_expires_at),
144
+ store_login_merchant_name: str(body.store_login_merchant_name),
145
+ raw: body,
146
+ };
147
+ }
148
+ function conversationOf(body) {
149
+ const last = isRecord(body.last_checkout) ? body.last_checkout : null;
150
+ return {
151
+ conversation_id: str(body.conversation_id) ?? '',
152
+ status: body.status === 'closed' ? 'closed' : 'open',
153
+ turn_in_progress: body.turn_in_progress === true,
154
+ last_checkout: last ? {
155
+ at: str(last.at) ?? '',
156
+ merchant: str(last.merchant) ?? '',
157
+ merchant_name: str(last.merchant_name) ?? str(last.merchant) ?? '',
158
+ status: str(last.status) ?? '',
159
+ code: str(last.code),
160
+ message: str(last.message),
161
+ order_id: str(last.order_id),
162
+ decline_code: str(last.decline_code),
163
+ approval_url: str(last.approval_url),
164
+ approval_expires_at: str(last.approval_expires_at),
165
+ placement_announced: bool(last.placement_announced),
166
+ approval_in_progress: bool(last.approval_in_progress),
167
+ add_card_url: str(last.add_card_url),
168
+ add_card_expires_at: str(last.add_card_expires_at),
169
+ total_cents: num(last.total_cents),
170
+ } : null,
171
+ orders: Array.isArray(body.orders) ? body.orders.filter(isRecord).map((o) => ({
172
+ order_id: str(o.order_id),
173
+ merchant: str(o.merchant) ?? '',
174
+ merchant_name: str(o.merchant_name) ?? str(o.merchant) ?? '',
175
+ status: str(o.status) ?? '',
176
+ total_cents: num(o.total_cents) ?? 0,
177
+ placed_at: str(o.placed_at),
178
+ })) : [],
179
+ carts: cartsOf(body.carts),
180
+ raw: body,
181
+ };
182
+ }
183
+ /** An error code as the API spells one: lower-case words joined by underscores. */
184
+ const CODE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
185
+ /**
186
+ * The refusal as the client reports it. Most refusals name their code; the
187
+ * ones the API names in prose only (a changed cart, nothing to confirm, a
188
+ * closed conversation) and a conversation not found get a code from the shape
189
+ * of the answer, so a caller branches on `code` and never on the message.
190
+ */
191
+ function errorOf(status, body) {
192
+ let code;
193
+ let message;
194
+ if (isRecord(body.error)) {
195
+ code = str(body.error.code);
196
+ message = str(body.error.message);
197
+ }
198
+ else {
199
+ const named = str(body.error);
200
+ code = str(body.code) ?? (named && CODE.test(named) ? named : null);
201
+ message = str(body.message) ?? (named && named !== code ? named : null) ?? str(body.error_description);
202
+ }
203
+ if (!code) {
204
+ const text = message ?? '';
205
+ if (status === 404)
206
+ code = 'conversation_not_found';
207
+ else if (status === 409 && Array.isArray(body.carts))
208
+ code = 'cart_changed';
209
+ else if (status === 409 && /nothing to confirm/i.test(text))
210
+ code = 'nothing_to_confirm';
211
+ else if (status === 409 && /closed/i.test(text))
212
+ code = 'conversation_closed';
213
+ else if (status === 401)
214
+ code = 'unauthorized';
215
+ else
216
+ code = `http_${status}`;
217
+ }
218
+ return new AgentcardApiError(status, code, message ?? `Agentcard answered ${status} (${code}).`, body);
219
+ }
220
+ function failureOf(error, timeoutMs) {
221
+ const name = typeof error === 'object' && error !== null ? error.name : undefined;
222
+ if (name === 'TimeoutError' || name === 'AbortError') {
223
+ return new AgentcardApiError(0, 'timeout', `Agentcard did not answer within ${Math.round(timeoutMs / 1000)} seconds.`, {}, error);
224
+ }
225
+ return new AgentcardApiError(0, 'unreachable', 'Agentcard could not be reached.', {}, error);
226
+ }
227
+ /** The answer's JSON object, or null when the text is not JSON or not an object. */
228
+ function parseJson(text) {
229
+ try {
230
+ const value = JSON.parse(text);
231
+ return isRecord(value) ? value : null;
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ /** The client every shopper's buy tool shares. Build one per set of credentials and keep it. */
238
+ export class AgentcardClient {
239
+ baseUrl;
240
+ fetchFn;
241
+ credentials;
242
+ shopperToken;
243
+ token = null;
244
+ inflight = null;
245
+ learnedSandbox = null;
246
+ constructor(options) {
247
+ const o = (options ?? {});
248
+ const hasCredentials = o.clientId !== undefined || o.clientSecret !== undefined;
249
+ if (hasCredentials === (o.accessToken !== undefined)) {
250
+ throw new TypeError('Pass your company client credentials (clientId and clientSecret), or one shopper\'s accessToken, and not both.');
251
+ }
252
+ if (hasCredentials && (!str(o.clientId) || !str(o.clientSecret))) {
253
+ throw new TypeError('Pass clientId and clientSecret: the client credentials of your Agentcard app.');
254
+ }
255
+ if (!hasCredentials && !str(o.accessToken))
256
+ throw new TypeError('Pass accessToken: the shopper\'s own Agentcard access token.');
257
+ const fetchFn = o.fetch ?? globalThis.fetch;
258
+ if (typeof fetchFn !== 'function')
259
+ throw new TypeError('Pass fetch: this runtime has no global fetch. Node 18 and later have one.');
260
+ this.fetchFn = fetchFn;
261
+ this.baseUrl = (str(o.baseUrl) ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
262
+ this.credentials = hasCredentials ? { clientId: o.clientId, clientSecret: o.clientSecret } : null;
263
+ this.shopperToken = hasCredentials ? null : o.accessToken;
264
+ }
265
+ /** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
266
+ get company() {
267
+ return this.credentials !== null;
268
+ }
269
+ /** True for a sandbox client, as the token exchange said; null until the first exchange, and under a shopper's token. */
270
+ get sandbox() {
271
+ return this.learnedSandbox;
272
+ }
273
+ /** The access token the client sends: the shopper's own, or the company's, exchanged once and renewed a minute before it expires. */
274
+ async bearer() {
275
+ if (this.shopperToken !== null)
276
+ return this.shopperToken;
277
+ if (this.token && Date.now() < this.token.expiresAt)
278
+ return this.token.value;
279
+ if (!this.inflight)
280
+ this.inflight = this.exchange().finally(() => { this.inflight = null; });
281
+ return this.inflight;
282
+ }
283
+ /**
284
+ * One purchase turn, sent once with a 130-second wait. A turn is a model run
285
+ * against live stores, so a lost answer is never sent again: read the
286
+ * conversation instead. Only a 401 is sent again, once, with a fresh token,
287
+ * because Agentcard refused it before anything ran.
288
+ */
289
+ async buy(body) {
290
+ if (!isRecord(body))
291
+ throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
292
+ this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
293
+ return turnOf(await this.call('POST', '/buy', body, BUY_TURN_TIMEOUT_MS));
294
+ }
295
+ /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
296
+ async conversation(conversationId, who = {}) {
297
+ if (!str(conversationId))
298
+ throw new TypeError('Pass the conversation_id a purchase answer named.');
299
+ this.checkShopper(who);
300
+ const query = who.externalUserId ? `?external_user_id=${encodeURIComponent(who.externalUserId)}`
301
+ : who.userId ? `?user_id=${encodeURIComponent(who.userId)}` : '';
302
+ return conversationOf(await this.call('GET', `/buy/conversations/${encodeURIComponent(conversationId)}${query}`, undefined, CALL_TIMEOUT_MS));
303
+ }
304
+ /**
305
+ * The Vault link a shopper opens to connect to your company and add a card.
306
+ * Agentcard returns the link it already opened for this shopper while that
307
+ * link has time left, so a shopper who asks twice gets one link. Sent once.
308
+ */
309
+ async mintVaultLink(input) {
310
+ if (!this.credentials)
311
+ throw new TypeError('A Vault link is made with your company credentials: build the client with clientId and clientSecret.');
312
+ const externalUserId = input?.externalUserId;
313
+ if (!str(externalUserId))
314
+ throw new TypeError('Pass externalUserId: your own id for the shopper.');
315
+ const answer = await this.call('POST', '/api/v2/vault_sessions', { external_user_id: externalUserId, reuse: true }, CALL_TIMEOUT_MS);
316
+ const url = str(answer.url);
317
+ if (!url)
318
+ throw new AgentcardApiError(200, 'invalid_answer', 'Agentcard answered the Vault link request without a link.', answer);
319
+ return { id: str(answer.id) ?? '', url, expiresAt: str(answer.expires_at), reused: answer.reused === true };
320
+ }
321
+ /** A company names the shopper exactly once; a shopper's own token already names them. */
322
+ checkShopper(who) {
323
+ const named = [who.externalUserId, who.userId].filter((v) => v !== undefined);
324
+ if (this.shopperToken !== null) {
325
+ if (named.length > 0)
326
+ throw new TypeError('A shopper\'s own access token already names the shopper. Leave externalUserId and userId out.');
327
+ return;
328
+ }
329
+ if (named.length !== 1 || !str(named[0])) {
330
+ throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
331
+ }
332
+ }
333
+ async call(method, path, body, timeoutMs) {
334
+ let token = await this.bearer();
335
+ for (let attempt = 0;; attempt++) {
336
+ const answer = await this.send(method, path, token, body, timeoutMs);
337
+ if (answer.status === 401 && attempt === 0 && this.credentials) {
338
+ token = await this.renewAfter(token);
339
+ continue;
340
+ }
341
+ if (answer.ok) {
342
+ // An answer the client cannot read is not an empty purchase. It is refused, and never sent again.
343
+ if (!answer.readable) {
344
+ throw new AgentcardApiError(answer.status, 'invalid_answer', `Agentcard answered ${answer.status} with a body that is not a JSON object.`);
345
+ }
346
+ return answer.body;
347
+ }
348
+ if (answer.status === 401 && !this.credentials) {
349
+ throw new AgentcardApiError(401, 'unauthorized', 'Agentcard refused the shopper\'s access token. Sign the shopper in again.', answer.body);
350
+ }
351
+ throw errorOf(answer.status, answer.body);
352
+ }
353
+ }
354
+ async send(method, path, token, body, timeoutMs) {
355
+ try {
356
+ const res = await this.fetchFn(`${this.baseUrl}${path}`, {
357
+ method,
358
+ headers: {
359
+ authorization: `Bearer ${token}`,
360
+ accept: 'application/json',
361
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
362
+ },
363
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
364
+ signal: AbortSignal.timeout(timeoutMs),
365
+ });
366
+ const parsed = parseJson(await res.text());
367
+ return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
368
+ }
369
+ catch (error) {
370
+ throw failureOf(error, timeoutMs);
371
+ }
372
+ }
373
+ /** After a 401: the token another call already renewed, or one new exchange, however many calls were refused at once. */
374
+ async renewAfter(refused) {
375
+ if (this.inflight)
376
+ return this.inflight;
377
+ if (this.token && this.token.value !== refused && Date.now() < this.token.expiresAt)
378
+ return this.token.value;
379
+ this.token = null;
380
+ return this.bearer();
381
+ }
382
+ async exchange() {
383
+ const { clientId, clientSecret } = this.credentials;
384
+ const form = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret });
385
+ let status;
386
+ let body;
387
+ try {
388
+ const res = await this.fetchFn(`${this.baseUrl}/api/v2/oauth/token`, {
389
+ method: 'POST',
390
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
391
+ body: form.toString(),
392
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
393
+ });
394
+ status = res.status;
395
+ body = parseJson(await res.text()) ?? {};
396
+ if (!res.ok || !str(body.access_token)) {
397
+ const reason = str(body.error);
398
+ throw new AgentcardApiError(status, 'oauth_failed', `Agentcard refused the client credentials (${status}${reason ? `, ${reason}` : ''}). Check the clientId and clientSecret you passed.`, body);
399
+ }
400
+ }
401
+ catch (error) {
402
+ throw error instanceof AgentcardApiError ? error : failureOf(error, CALL_TIMEOUT_MS);
403
+ }
404
+ if (body.sandbox === true || body.livemode === false)
405
+ this.learnedSandbox = true;
406
+ else if (body.sandbox === false || body.livemode === true)
407
+ this.learnedSandbox = false;
408
+ const ttlMs = Math.max(60_000, (num(body.expires_in) ?? 3600) * 1000 - RENEW_EARLY_MS);
409
+ this.token = { value: body.access_token, expiresAt: Date.now() + ttlMs };
410
+ return this.token.value;
411
+ }
412
+ }