@agent-cards/commerce-sdk 0.2.0 → 0.8.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/dist/handler.js CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * A Node or Express `(req, res)` handler for an Agentcard webhook endpoint. It
3
- * reads the raw bytes, checks the signature, hands the event to your callback,
4
- * and answers once the callback is done: 200 when it returns, 500 when it
5
- * throws, so Agentcard delivers the event again instead of losing it.
2
+ * Handlers for an Agentcard webhook endpoint, for a Node or Express `(req, res)`
3
+ * route and for a route that takes a web `Request`, such as a Next.js route
4
+ * handler. Each reads the raw bytes, checks the signature, hands the event to
5
+ * your callback, and answers once the callback is done: 200 when it returns,
6
+ * 500 when it throws, so Agentcard delivers the event again instead of losing it.
6
7
  */
7
8
  import { AgentcardSignatureError, InvalidEventBodyError, toleranceOf, verifyAgentcardWebhook, } from './verify.js';
8
9
  import { readPurchaseMessage } from './message.js';
10
+ import { isStale, readShopperMessage } from './shopper-message.js';
9
11
  /** A signed Agentcard delivery is a few kilobytes; a body past this size did not come from Agentcard. */
10
12
  const MAX_BODY_BYTES = 1024 * 1024;
11
13
  const RAW_BODY_GONE = 'The webhook request reached the handler with its body already parsed and no raw bytes kept, so the signature cannot be checked. ' +
@@ -95,6 +97,132 @@ async function readRawBody(req) {
95
97
  return undefined;
96
98
  return readStream(req);
97
99
  }
100
+ /** Hands an error to `onError`, or to `console.error`. A reporter that throws or rejects never takes the process down. */
101
+ function reporter(onError) {
102
+ return (error, event) => {
103
+ try {
104
+ const reported = onError ? onError(error, event) : console.error('Agentcard webhook:', error);
105
+ // A reporter that rejects must not leave an unhandled rejection, which ends a plain Node server's process.
106
+ if (reported && typeof reported.then === 'function')
107
+ reported.then(undefined, () => { });
108
+ }
109
+ catch {
110
+ // A failing reporter must never take the process down with it.
111
+ }
112
+ };
113
+ }
114
+ function checkSecret(secret) {
115
+ if (typeof secret !== 'string' || secret === '') {
116
+ throw new TypeError('Pass the webhook endpoint secret Agentcard returned when you created the endpoint.');
117
+ }
118
+ }
119
+ /** Verifies one raw delivery and runs its callbacks. The answer waits for them, so a callback that fails gets the event delivered again. */
120
+ async function answer(raw, signature, secret, verifyOpts, dispatch, report) {
121
+ let event;
122
+ try {
123
+ event = verifyAgentcardWebhook(raw, signature, secret, verifyOpts);
124
+ }
125
+ catch (error) {
126
+ if (error instanceof AgentcardSignatureError)
127
+ return { status: 400, body: { error: 'invalid_signature', reason: error.reason } };
128
+ if (error instanceof InvalidEventBodyError)
129
+ return { status: 400, body: { error: 'invalid_body' } };
130
+ report(error, undefined);
131
+ return { status: 500, body: { error: 'verification_failed' } };
132
+ }
133
+ if (await dispatch(event))
134
+ return { status: 200, body: { received: true } };
135
+ return { status: 500, body: { error: 'callback_failed' } };
136
+ }
137
+ /** The `(req, res)` handler around one delivery's answer. It never rejects: an unhandled rejection would end a plain Node server's process. */
138
+ function nodeHandler(handleRaw, report) {
139
+ const handle = async (req, res) => {
140
+ let raw;
141
+ try {
142
+ raw = await readRawBody(req);
143
+ }
144
+ catch (error) {
145
+ if (error instanceof BodyTooLargeError)
146
+ return send(res, 413, { error: 'body_too_large' });
147
+ return send(res, 400, { error: 'unreadable_body' });
148
+ }
149
+ if (raw === undefined) {
150
+ report(new Error(RAW_BODY_GONE), undefined);
151
+ return send(res, 500, { error: 'raw_body_unavailable' });
152
+ }
153
+ const outcome = await handleRaw(raw, headerValue(req.headers['agentcard-signature']));
154
+ send(res, outcome.status, outcome.body);
155
+ };
156
+ return async (req, res) => {
157
+ try {
158
+ await handle(req, res);
159
+ }
160
+ catch (error) {
161
+ report(error, undefined);
162
+ }
163
+ };
164
+ }
165
+ /**
166
+ * The body of a web request, read chunk by chunk. Past the size cap it stops reading, cancels the
167
+ * stream and throws, so a caller with no signature cannot make the server hold a large body.
168
+ */
169
+ async function readFetchBody(request) {
170
+ if (!request.body || typeof request.body.getReader !== 'function')
171
+ return new Uint8Array(await request.arrayBuffer());
172
+ const reader = request.body.getReader();
173
+ const chunks = [];
174
+ let size = 0;
175
+ for (;;) {
176
+ const { done, value } = await reader.read();
177
+ if (done)
178
+ break;
179
+ if (!value)
180
+ continue;
181
+ size += value.length;
182
+ if (size > MAX_BODY_BYTES) {
183
+ await reader.cancel().catch(() => { });
184
+ throw new BodyTooLargeError();
185
+ }
186
+ chunks.push(value);
187
+ }
188
+ const out = new Uint8Array(size);
189
+ let offset = 0;
190
+ for (const chunk of chunks) {
191
+ out.set(chunk, offset);
192
+ offset += chunk.length;
193
+ }
194
+ return out;
195
+ }
196
+ /** A web `Response` with a JSON body, built from the runtime's own `Response`. */
197
+ function webResponse(outcome) {
198
+ return new Response(JSON.stringify(outcome.body), { status: outcome.status, headers: { 'content-type': 'application/json' } });
199
+ }
200
+ /** The web `Request` handler around one delivery's answer. It never rejects. */
201
+ function fetchHandler(handleRaw, report) {
202
+ return async (request) => {
203
+ try {
204
+ const declared = Number(request.headers.get('content-length'));
205
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES)
206
+ return webResponse({ status: 413, body: { error: 'body_too_large' } });
207
+ let raw;
208
+ try {
209
+ raw = await readFetchBody(request);
210
+ }
211
+ catch (error) {
212
+ if (error instanceof BodyTooLargeError)
213
+ return webResponse({ status: 413, body: { error: 'body_too_large' } });
214
+ return webResponse({ status: 400, body: { error: 'unreadable_body' } });
215
+ }
216
+ if (raw.length > MAX_BODY_BYTES)
217
+ return webResponse({ status: 413, body: { error: 'body_too_large' } });
218
+ return webResponse(await handleRaw(raw, request.headers.get('agentcard-signature') ?? undefined));
219
+ }
220
+ catch (error) {
221
+ report(error, undefined);
222
+ return webResponse({ status: 500, body: { error: 'handler_failed' } });
223
+ }
224
+ };
225
+ }
98
226
  /**
99
227
  * A `(req, res)` handler for your Agentcard webhook endpoint. A delivery with a
100
228
  * missing, malformed, stale or wrong signature gets a 400 and reaches no
@@ -103,28 +231,15 @@ async function readRawBody(req) {
103
231
  * callback is done: 200 when it returns, and 500 when it throws, so Agentcard
104
232
  * delivers the event again. Agentcard waits 10 seconds for an answer, so keep
105
233
  * the callback short. Agentcard delivers at least once, and again after a 500,
106
- * so handle each `message.eventId` once.
234
+ * so handle each `message.eventId` once. `agentcardWebhook` does that for you.
107
235
  */
108
236
  export function purchaseAgentWebhook(secret, onMessage, opts = {}) {
109
- if (typeof secret !== 'string' || secret === '') {
110
- throw new TypeError('Pass the webhook endpoint secret Agentcard returned when you created the endpoint.');
111
- }
237
+ checkSecret(secret);
112
238
  if (typeof onMessage !== 'function') {
113
239
  throw new TypeError('Pass onMessage, the function that receives each purchase message.');
114
240
  }
115
241
  const verifyOpts = { toleranceSeconds: toleranceOf(opts) };
116
- const report = (error, event) => {
117
- try {
118
- const reported = opts.onError ? opts.onError(error, event) : console.error('Agentcard webhook:', error);
119
- // A reporter that rejects must not leave an unhandled rejection, which ends a plain Node server's process.
120
- if (reported && typeof reported.then === 'function')
121
- reported.then(undefined, () => { });
122
- }
123
- catch {
124
- // A failing reporter must never take the process down with it.
125
- }
126
- };
127
- /** Runs the callback for one verified event. True when the delivery is done, false when the callback threw. */
242
+ const report = reporter(opts.onError);
128
243
  const dispatch = async (event) => {
129
244
  let message;
130
245
  try {
@@ -147,44 +262,90 @@ export function purchaseAgentWebhook(secret, onMessage, opts = {}) {
147
262
  return false;
148
263
  }
149
264
  };
150
- const handle = async (req, res) => {
151
- let raw;
265
+ return nodeHandler((raw, signature) => answer(raw, signature, secret, verifyOpts, dispatch, report), report);
266
+ }
267
+ /** How many handled event ids the handler remembers, the oldest forgotten first. */
268
+ const HANDLED_EVENTS_KEPT = 10_000;
269
+ /**
270
+ * One handler for every Agentcard event your shoppers should hear about. It checks each delivery's
271
+ * signature, skips an event this process already handled, reads the message the event carries for
272
+ * a shopper
273
+ * (`readShopperMessage`), drops a message whose link or window has closed (`isStale`), and calls
274
+ * `onMessage` with your own id for the shopper and the message, Agentcard's own text included.
275
+ * Every other verified event goes to `onEvent`. The answer waits for the callback: 200 when it
276
+ * returns, 500 when it throws, and an event whose callback threw runs again on the next delivery.
277
+ * The handled events live in this process's memory: with more than one server, deduplicate on
278
+ * `message.eventId` in your own storage too.
279
+ */
280
+ export function agentcardWebhook(opts) {
281
+ if (opts === null || typeof opts !== 'object')
282
+ throw new TypeError('Pass { secret, onMessage }.');
283
+ checkSecret(opts.secret);
284
+ if (typeof opts.onMessage !== 'function') {
285
+ throw new TypeError('Pass onMessage, the function that receives each message for a shopper.');
286
+ }
287
+ const secret = opts.secret;
288
+ const verifyOpts = { toleranceSeconds: toleranceOf(opts) };
289
+ const report = reporter(opts.onError);
290
+ // The events this process finished, and the ones it is running. A redelivery of a finished event
291
+ // runs nothing, and one that lands while the first delivery still runs waits for its answer.
292
+ const finished = new Set();
293
+ const running = new Map();
294
+ const finish = (eventId) => {
295
+ finished.add(eventId);
296
+ if (finished.size > HANDLED_EVENTS_KEPT)
297
+ finished.delete(finished.values().next().value);
298
+ };
299
+ /** Runs the callback for one event. True when the delivery is done, false when the callback threw. */
300
+ const run = async (event) => {
301
+ let message;
152
302
  try {
153
- raw = await readRawBody(req);
303
+ message = readShopperMessage(event);
154
304
  }
155
305
  catch (error) {
156
- if (error instanceof BodyTooLargeError)
157
- return send(res, 413, { error: 'body_too_large' });
158
- return send(res, 400, { error: 'unreadable_body' });
306
+ // A message the package cannot read reads the same way on every delivery, so it is not sent again.
307
+ report(error, event);
308
+ finish(event.id);
309
+ return true;
159
310
  }
160
- if (raw === undefined) {
161
- report(new Error(RAW_BODY_GONE), undefined);
162
- return send(res, 500, { error: 'raw_body_unavailable' });
311
+ // A message whose link or window closed before it arrived reaches no callback, whichever one it would reach.
312
+ if (message && isStale(message)) {
313
+ finish(event.id);
314
+ return true;
163
315
  }
164
- let event;
165
316
  try {
166
- event = verifyAgentcardWebhook(raw, headerValue(req.headers['agentcard-signature']), secret, verifyOpts);
317
+ if (message?.externalUserId) {
318
+ await opts.onMessage(message.externalUserId, message, event);
319
+ }
320
+ else if (opts.onEvent) {
321
+ await opts.onEvent(event);
322
+ }
167
323
  }
168
324
  catch (error) {
169
- if (error instanceof AgentcardSignatureError)
170
- return send(res, 400, { error: 'invalid_signature', reason: error.reason });
171
- if (error instanceof InvalidEventBodyError)
172
- return send(res, 400, { error: 'invalid_body' });
173
- report(error, undefined);
174
- return send(res, 500, { error: 'verification_failed' });
325
+ // Not marked finished, so the next delivery of the event runs the callback again.
326
+ report(error, event);
327
+ return false;
175
328
  }
176
- // The answer waits for the callback, so a callback that fails gets the event delivered again.
177
- if (await dispatch(event))
178
- return send(res, 200, { received: true });
179
- return send(res, 500, { error: 'callback_failed' });
329
+ finish(event.id);
330
+ return true;
180
331
  };
181
- // The handler never rejects: an unhandled rejection would end a plain Node server's process.
182
- return async (req, res) => {
332
+ const dispatch = async (event) => {
333
+ if (finished.has(event.id))
334
+ return true;
335
+ const inFlight = running.get(event.id);
336
+ if (inFlight)
337
+ return inFlight;
338
+ const attempt = run(event);
339
+ running.set(event.id, attempt);
183
340
  try {
184
- await handle(req, res);
341
+ return await attempt;
185
342
  }
186
- catch (error) {
187
- report(error, undefined);
343
+ finally {
344
+ running.delete(event.id);
188
345
  }
189
346
  };
347
+ const handleRaw = (raw, signature) => answer(raw, signature, secret, verifyOpts, dispatch, report);
348
+ const handler = nodeHandler(handleRaw, report);
349
+ handler.fetch = fetchHandler(handleRaw, report);
350
+ return handler;
190
351
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatureReason, type AgentcardEvent, type VerifyOptions, } from './verify.js';
2
2
  export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
3
- export { purchaseAgentWebhook, type PurchaseAgentWebhookOptions, type WebhookRequest, type WebhookResponse, } from './handler.js';
4
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, } from './client.js';
3
+ export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
+ export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
5
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyStreamHandlers, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, } from './client.js';
5
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
6
7
  export { toMessages, messageStream, type MessageStream, type MessageStreamOptions, } from './messages.js';
7
8
  export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, } from './buy-tool.js';
8
- export { forModel, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
9
+ export { instantAck, ackFor, type AckForOptions } from './ack.js';
10
+ export { forModel, forModelError, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
9
11
  export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
12
+ export { listenToAgentcard, type AgentcardListener, type ListenOptions, type ListenCursorStore, type StreamFetch, } from './listen.js';
13
+ export { AGENTCARD_EVENT_TYPES, type AgentcardEventType, } from './events.js';
package/dist/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  export { verifyAgentcardWebhook, AgentcardSignatureError, } from './verify.js';
2
2
  export { readPurchaseMessage, } from './message.js';
3
- export { purchaseAgentWebhook, } from './handler.js';
3
+ export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, } from './shopper-message.js';
4
+ export { agentcardWebhook, purchaseAgentWebhook, } from './handler.js';
4
5
  export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, } from './client.js';
5
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
6
7
  export { toMessages, messageStream, } from './messages.js';
7
8
  export { buyTool, } from './buy-tool.js';
8
- export { forModel, stripLinks, } from './model-view.js';
9
+ export { instantAck, ackFor } from './ack.js';
10
+ export { forModel, forModelError, stripLinks, } from './model-view.js';
9
11
  export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
12
+ export { listenToAgentcard, } from './listen.js';
13
+ export { AGENTCARD_EVENT_TYPES, } from './events.js';
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Hear every Agentcard event without a webhook endpoint. Your server opens one connection to
3
+ * Agentcard with your company credentials, and Agentcard streams your company's events down it:
4
+ * no public URL, no webhook secret, no signature to check. Each event is the envelope a webhook
5
+ * endpoint would receive, with the same id.
6
+ *
7
+ * The listener reconnects on its own, resumes after the last event it handled, skips an event it
8
+ * already handled, and falls back to asking every few seconds when the stream is not available.
9
+ * Shopper messages go through the same reader as `agentcardWebhook` (`readShopperMessage`), so
10
+ * `onMessage` gets the same ready-to-send text.
11
+ */
12
+ import { type AgentcardEvent } from './verify.js';
13
+ import { type ShopperMessage } from './shopper-message.js';
14
+ import { type AgentcardEventType } from './events.js';
15
+ import { type AgentcardClient } from './client.js';
16
+ /** Where the listener keeps the cursor of the last event it handled, so a restarted server resumes there. */
17
+ export interface ListenCursorStore {
18
+ /** The cursor saved last, or null to start from now. */
19
+ load(): Promise<string | null> | string | null;
20
+ /** Called after each handled event, and at most once a minute while no event arrives. */
21
+ save(cursor: string): Promise<void> | void;
22
+ }
23
+ /** The fetch the listener streams with: the global fetch of Node 18 and later. */
24
+ export type StreamFetch = (url: string, init: {
25
+ method: 'GET';
26
+ headers: Record<string, string>;
27
+ signal: AbortSignal;
28
+ }) => Promise<{
29
+ status: number;
30
+ ok: boolean;
31
+ headers: {
32
+ get(name: string): string | null;
33
+ };
34
+ body: {
35
+ getReader(): {
36
+ read(): Promise<{
37
+ done: boolean;
38
+ value?: Uint8Array;
39
+ }>;
40
+ cancel(reason?: unknown): Promise<void>;
41
+ };
42
+ } | null;
43
+ text(): Promise<string>;
44
+ }>;
45
+ export interface ListenOptions {
46
+ /**
47
+ * Receives each event for one of your shoppers, under your own id for them, with the message
48
+ * ready to post into that shopper's chat: the same as `agentcardWebhook`'s `onMessage`. A message
49
+ * whose link or window closed before it arrived reaches no shopper: its event goes to `onEvent`.
50
+ */
51
+ onMessage?: (externalUserId: string, message: ShopperMessage, event: AgentcardEvent<AgentcardEventType>) => void | Promise<void>;
52
+ /** Receives every event `onMessage` does not take: every event, when you pass no `onMessage`. */
53
+ onEvent?: (event: AgentcardEvent<AgentcardEventType | (string & {})>) => void | Promise<void>;
54
+ /**
55
+ * Receives what went wrong: a callback that threw five times, after which the listener moves on,
56
+ * a lost connection it is about to retry, a cursor Agentcard no longer keeps. A reporter that
57
+ * throws is ignored. Default: `console.error`.
58
+ */
59
+ onError?: (error: unknown, event?: AgentcardEvent) => void | Promise<void>;
60
+ /** Only these events: event types, families such as `order.*`, or `*`. Default: every event. */
61
+ events?: string[];
62
+ /** Keeps the cursor across restarts. Default: in memory, so a new process starts from now. */
63
+ cursor?: ListenCursorStore;
64
+ /** `auto` streams and falls back to polling when the stream is not available. Default `auto`. */
65
+ transport?: 'auto' | 'stream' | 'poll';
66
+ /** How long polling waits between reads. Default 2000. */
67
+ pollIntervalMs?: number;
68
+ /** How long the stream may stay silent before the listener reconnects. Agentcard sends a heartbeat every 15 seconds. Default 45000. */
69
+ heartbeatTimeoutMs?: number;
70
+ /** How long one polling read may take before the listener gives up on it and reads again. Default 30000. */
71
+ pollTimeoutMs?: number;
72
+ /** The fetch to stream with. Default: the global fetch. */
73
+ fetch?: StreamFetch;
74
+ }
75
+ /** A running listener. */
76
+ export interface AgentcardListener {
77
+ /** Resolves once the listener first reaches Agentcard, or once it closes. */
78
+ readonly ready: Promise<void>;
79
+ /** How the listener hears Agentcard right now. */
80
+ readonly transport: 'stream' | 'poll';
81
+ /** The cursor of the last event handled, or null before the first connection. */
82
+ readonly cursor: string | null;
83
+ /** Stops listening. Resolves once the callback that is running, if any, has returned. */
84
+ close(): Promise<void>;
85
+ }
86
+ /**
87
+ * Listen to every event Agentcard sends your company, over one connection your server opens.
88
+ * Build `agentcard` with your company's client credentials. Returns at once; `ready` resolves once
89
+ * the listener reaches Agentcard. Each event runs its callback once, in order, and the next event
90
+ * waits for it: a callback that throws runs again, up to five times, before the listener reports
91
+ * it and moves on. Run one listener per company and mode: two listeners each hear every event.
92
+ */
93
+ export declare function listenToAgentcard(agentcard: AgentcardClient, opts: ListenOptions): AgentcardListener;