@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.
- package/LICENSE +38 -0
- package/README.md +295 -2
- package/dist/adapters.d.ts +35 -0
- package/dist/adapters.js +23 -0
- package/dist/buy-tool.d.ts +175 -0
- package/dist/buy-tool.js +562 -0
- package/dist/client.d.ts +250 -0
- package/dist/client.js +412 -0
- package/dist/definition.d.ts +100 -0
- package/dist/definition.js +103 -0
- package/dist/handler.d.ts +48 -0
- package/dist/handler.js +190 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/message.d.ts +78 -0
- package/dist/message.js +76 -0
- package/dist/model-view.d.ts +62 -0
- package/dist/model-view.js +110 -0
- package/dist/verify.d.ts +41 -0
- package/dist/verify.js +137 -0
- package/examples/anthropic.mjs +55 -0
- package/examples/openrouter.mjs +53 -0
- package/examples/walkthrough.mjs +97 -0
- package/package.json +41 -4
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buy tool as a model sees it: one name, one description, and a strict
|
|
3
|
+
* JSON Schema with three required fields. The delivery address and the
|
|
4
|
+
* shopper's id are construction options, never model inputs, so a model can
|
|
5
|
+
* neither change where an order goes nor buy for someone else.
|
|
6
|
+
*/
|
|
7
|
+
export declare const BUY_TOOL_NAME = "buy";
|
|
8
|
+
/** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
|
|
9
|
+
export declare const BUY_TOOL_DESCRIPTION: string;
|
|
10
|
+
/** What the model sends: all three fields, every time. */
|
|
11
|
+
export interface BuyToolInput {
|
|
12
|
+
ask: string;
|
|
13
|
+
confirm: boolean;
|
|
14
|
+
new_order: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** A type, not an interface, so it fits the SDKs' own input schema types, which take any key. */
|
|
17
|
+
export type BuyToolInputSchema = {
|
|
18
|
+
type: 'object';
|
|
19
|
+
properties: {
|
|
20
|
+
ask: {
|
|
21
|
+
type: 'string';
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
24
|
+
confirm: {
|
|
25
|
+
type: 'boolean';
|
|
26
|
+
description: string;
|
|
27
|
+
};
|
|
28
|
+
new_order: {
|
|
29
|
+
type: 'boolean';
|
|
30
|
+
description: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
required: Array<keyof BuyToolInput>;
|
|
34
|
+
additionalProperties: false;
|
|
35
|
+
};
|
|
36
|
+
export interface BuyToolDefinition {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
input_schema: BuyToolInputSchema;
|
|
40
|
+
}
|
|
41
|
+
/** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
|
|
42
|
+
export declare function buyToolDefinition(): BuyToolDefinition;
|
|
43
|
+
/**
|
|
44
|
+
* The model's input, checked against the schema: an object, or its JSON text,
|
|
45
|
+
* with `ask` a string and `confirm` and `new_order` booleans, and nothing
|
|
46
|
+
* else. Throws a TypeError whose message tells the model what to send.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseBuyToolInput(value: unknown): BuyToolInput;
|
|
49
|
+
/** The buy tool in OpenAI's function-calling format, which the OpenAI SDK, OpenRouter's chat API and most other model APIs take. */
|
|
50
|
+
export interface OpenAIBuyTool {
|
|
51
|
+
type: 'function';
|
|
52
|
+
function: {
|
|
53
|
+
name: string;
|
|
54
|
+
description: string;
|
|
55
|
+
parameters: BuyToolInputSchema;
|
|
56
|
+
strict: true;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** A fresh copy of the tool in OpenAI's function-calling format: plain JSON, so an SDK that sends it as is sends exactly this. */
|
|
60
|
+
export declare function openAIBuyTool(): OpenAIBuyTool;
|
|
61
|
+
/** What a Standard Schema check returns: the model's input, checked, or why it does not fit. */
|
|
62
|
+
export type BuyToolInputCheck = {
|
|
63
|
+
readonly value: BuyToolInput;
|
|
64
|
+
readonly issues?: undefined;
|
|
65
|
+
} | {
|
|
66
|
+
readonly issues: ReadonlyArray<{
|
|
67
|
+
readonly message: string;
|
|
68
|
+
}>;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* The input schema as a Standard Schema that also hands over its JSON Schema,
|
|
72
|
+
* the form the Vercel AI SDK reads from a tool's `inputSchema`. The shape is
|
|
73
|
+
* the published Standard Schema one, so the package names no schema library.
|
|
74
|
+
*/
|
|
75
|
+
export interface BuyToolStandardSchema {
|
|
76
|
+
readonly '~standard': {
|
|
77
|
+
readonly version: 1;
|
|
78
|
+
readonly vendor: string;
|
|
79
|
+
readonly validate: (value: unknown) => BuyToolInputCheck;
|
|
80
|
+
readonly jsonSchema: {
|
|
81
|
+
readonly input: (options: {
|
|
82
|
+
readonly target: string;
|
|
83
|
+
}) => BuyToolInputSchema;
|
|
84
|
+
readonly output: (options: {
|
|
85
|
+
readonly target: string;
|
|
86
|
+
}) => BuyToolInputSchema;
|
|
87
|
+
};
|
|
88
|
+
/** Read by type inference only, never set. */
|
|
89
|
+
readonly types?: {
|
|
90
|
+
readonly input: BuyToolInput;
|
|
91
|
+
readonly output: BuyToolInput;
|
|
92
|
+
} | undefined;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The Standard Schema of the tool's input. `validate` checks the model's
|
|
97
|
+
* input as `parseBuyToolInput` does, and reports a refusal in the words
|
|
98
|
+
* `refusal` gives it, so the model reads what to send.
|
|
99
|
+
*/
|
|
100
|
+
export declare function buyToolStandardSchema(refusal: (error: unknown) => string): BuyToolStandardSchema;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buy tool as a model sees it: one name, one description, and a strict
|
|
3
|
+
* JSON Schema with three required fields. The delivery address and the
|
|
4
|
+
* shopper's id are construction options, never model inputs, so a model can
|
|
5
|
+
* neither change where an order goes nor buy for someone else.
|
|
6
|
+
*/
|
|
7
|
+
export const BUY_TOOL_NAME = 'buy';
|
|
8
|
+
/** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
|
|
9
|
+
export const BUY_TOOL_DESCRIPTION = [
|
|
10
|
+
'Buy what this shopper asks for at real stores.',
|
|
11
|
+
"Put the shopper's own words in `ask`.",
|
|
12
|
+
'Tell the shopper what `reply` says, in your own voice.',
|
|
13
|
+
'Show the shopper the cart before you confirm it.',
|
|
14
|
+
'Set `confirm` to true only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again.',
|
|
15
|
+
'Never write a link.',
|
|
16
|
+
'Each link reaches the shopper on its own.',
|
|
17
|
+
'When `link_sent` is true, tell the shopper a link is waiting for them.',
|
|
18
|
+
'Product links reach the shopper on their own, beside your reply: when `product_links_sent` is above zero and the shopper asks for links, tell them the links are there; never write or invent a URL.',
|
|
19
|
+
'A `placing` status means the order is going through, so never ask for another approval.',
|
|
20
|
+
'Tell the shopper they need not reply only when `shopper_need_not_reply` is true.',
|
|
21
|
+
'After `needs_card` with a cart, Agentcard keeps the cart, so set `confirm` to true once the shopper says they added the card.',
|
|
22
|
+
"After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
|
|
23
|
+
'Set `new_order` to true only when the shopper drops the cart.',
|
|
24
|
+
].join(' ');
|
|
25
|
+
/** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
|
|
26
|
+
export function buyToolDefinition() {
|
|
27
|
+
return {
|
|
28
|
+
name: BUY_TOOL_NAME,
|
|
29
|
+
description: BUY_TOOL_DESCRIPTION,
|
|
30
|
+
input_schema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
ask: { type: 'string', description: 'What the shopper said, in their own words.' },
|
|
34
|
+
confirm: {
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
description: 'True only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again. False otherwise.',
|
|
37
|
+
},
|
|
38
|
+
new_order: { type: 'boolean', description: 'True only when the shopper drops the cart to start a new order. False otherwise.' },
|
|
39
|
+
},
|
|
40
|
+
required: ['ask', 'confirm', 'new_order'],
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const FIELDS = new Set(['ask', 'confirm', 'new_order']);
|
|
46
|
+
/**
|
|
47
|
+
* The model's input, checked against the schema: an object, or its JSON text,
|
|
48
|
+
* with `ask` a string and `confirm` and `new_order` booleans, and nothing
|
|
49
|
+
* else. Throws a TypeError whose message tells the model what to send.
|
|
50
|
+
*/
|
|
51
|
+
export function parseBuyToolInput(value) {
|
|
52
|
+
let input = value;
|
|
53
|
+
if (typeof input === 'string') {
|
|
54
|
+
try {
|
|
55
|
+
input = JSON.parse(input);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new TypeError('The buy tool takes a JSON object with ask, confirm and new_order.');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
62
|
+
throw new TypeError('The buy tool takes an object with ask, confirm and new_order.');
|
|
63
|
+
}
|
|
64
|
+
const fields = input;
|
|
65
|
+
const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
|
|
66
|
+
if (extra !== undefined)
|
|
67
|
+
throw new TypeError(`The buy tool takes only ask, confirm and new_order, and ${extra} is not one of them.`);
|
|
68
|
+
if (typeof fields.ask !== 'string')
|
|
69
|
+
throw new TypeError("Set ask to the shopper's words, as a string.");
|
|
70
|
+
if (typeof fields.confirm !== 'boolean')
|
|
71
|
+
throw new TypeError('Set confirm to true or false.');
|
|
72
|
+
if (typeof fields.new_order !== 'boolean')
|
|
73
|
+
throw new TypeError('Set new_order to true or false.');
|
|
74
|
+
return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order };
|
|
75
|
+
}
|
|
76
|
+
/** A fresh copy of the tool in OpenAI's function-calling format: plain JSON, so an SDK that sends it as is sends exactly this. */
|
|
77
|
+
export function openAIBuyTool() {
|
|
78
|
+
const { name, description, input_schema } = buyToolDefinition();
|
|
79
|
+
return { type: 'function', function: { name, description, parameters: input_schema, strict: true } };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The Standard Schema of the tool's input. `validate` checks the model's
|
|
83
|
+
* input as `parseBuyToolInput` does, and reports a refusal in the words
|
|
84
|
+
* `refusal` gives it, so the model reads what to send.
|
|
85
|
+
*/
|
|
86
|
+
export function buyToolStandardSchema(refusal) {
|
|
87
|
+
const schema = () => buyToolDefinition().input_schema;
|
|
88
|
+
return {
|
|
89
|
+
'~standard': {
|
|
90
|
+
version: 1,
|
|
91
|
+
vendor: 'agentcard',
|
|
92
|
+
validate: (value) => {
|
|
93
|
+
try {
|
|
94
|
+
return { value: parseBuyToolInput(value) };
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
return { issues: [{ message: refusal(error) }] };
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
jsonSchema: { input: schema, output: schema },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import { type AgentcardEvent, type VerifyOptions } from './verify.js';
|
|
8
|
+
import { type PurchaseMessage } from './message.js';
|
|
9
|
+
/** The parts of a Node or Express request the handler reads. */
|
|
10
|
+
export interface WebhookRequest {
|
|
11
|
+
headers: Record<string, string | string[] | undefined>;
|
|
12
|
+
/** The bytes from `express.raw`, the text from `express.text`, or a body a JSON parser already parsed. */
|
|
13
|
+
body?: unknown;
|
|
14
|
+
/** The bytes a parser kept beside the parsed body: `express.json({ verify })`, NestJS and Firebase keep them here. */
|
|
15
|
+
rawBody?: unknown;
|
|
16
|
+
/** True once the request stream has been read to the end. */
|
|
17
|
+
readableEnded?: boolean;
|
|
18
|
+
on?(event: string, listener: (...args: any[]) => void): unknown;
|
|
19
|
+
}
|
|
20
|
+
/** The parts of a Node or Express response the handler writes. */
|
|
21
|
+
export interface WebhookResponse {
|
|
22
|
+
statusCode: number;
|
|
23
|
+
setHeader(name: string, value: string): unknown;
|
|
24
|
+
end(body?: string): unknown;
|
|
25
|
+
}
|
|
26
|
+
export interface PurchaseAgentWebhookOptions extends Pick<VerifyOptions, 'toleranceSeconds'> {
|
|
27
|
+
/** Receives every verified event that is not a purchase message, such as `merchant.connected`. */
|
|
28
|
+
onEvent?: (event: AgentcardEvent) => void | Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Receives what went wrong: an error thrown by `onMessage` or `onEvent`, whose
|
|
31
|
+
* delivery then answers 500, a purchase message the package cannot read, or a
|
|
32
|
+
* request whose raw body was already gone. The event is undefined when no
|
|
33
|
+
* event was read. A reporter that throws or rejects is ignored. Default:
|
|
34
|
+
* `console.error`.
|
|
35
|
+
*/
|
|
36
|
+
onError?: (error: unknown, event: AgentcardEvent | undefined) => void | Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A `(req, res)` handler for your Agentcard webhook endpoint. A delivery with a
|
|
40
|
+
* missing, malformed, stale or wrong signature gets a 400 and reaches no
|
|
41
|
+
* callback. A verified delivery runs `onMessage` for a purchase message, or
|
|
42
|
+
* `opts.onEvent` for any other event, and the handler answers when the
|
|
43
|
+
* callback is done: 200 when it returns, and 500 when it throws, so Agentcard
|
|
44
|
+
* delivers the event again. Agentcard waits 10 seconds for an answer, so keep
|
|
45
|
+
* the callback short. Agentcard delivers at least once, and again after a 500,
|
|
46
|
+
* so handle each `message.eventId` once.
|
|
47
|
+
*/
|
|
48
|
+
export declare function purchaseAgentWebhook(secret: string, onMessage: (message: PurchaseMessage, event: AgentcardEvent) => void | Promise<void>, opts?: PurchaseAgentWebhookOptions): (req: WebhookRequest, res: WebhookResponse) => Promise<void>;
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import { AgentcardSignatureError, InvalidEventBodyError, toleranceOf, verifyAgentcardWebhook, } from './verify.js';
|
|
8
|
+
import { readPurchaseMessage } from './message.js';
|
|
9
|
+
/** A signed Agentcard delivery is a few kilobytes; a body past this size did not come from Agentcard. */
|
|
10
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
11
|
+
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. ' +
|
|
12
|
+
"Mount the route with express.raw({ type: 'application/json' }) before any express.json(), or keep the bytes with " +
|
|
13
|
+
'express.json({ verify: (req, _res, buf) => { Object.assign(req, { rawBody: buf }); } }).';
|
|
14
|
+
class BodyTooLargeError extends Error {
|
|
15
|
+
}
|
|
16
|
+
function send(res, status, body) {
|
|
17
|
+
res.statusCode = status;
|
|
18
|
+
res.setHeader('content-type', 'application/json');
|
|
19
|
+
res.end(JSON.stringify(body));
|
|
20
|
+
}
|
|
21
|
+
function headerValue(value) {
|
|
22
|
+
return Array.isArray(value) ? value.join(',') : value;
|
|
23
|
+
}
|
|
24
|
+
function toBytes(chunk) {
|
|
25
|
+
if (chunk instanceof Uint8Array)
|
|
26
|
+
return chunk;
|
|
27
|
+
if (typeof chunk === 'string')
|
|
28
|
+
return new TextEncoder().encode(chunk);
|
|
29
|
+
throw new TypeError('The request stream yielded something other than bytes.');
|
|
30
|
+
}
|
|
31
|
+
/** Reads the request stream to its end. Past the size cap it keeps draining and keeps nothing. */
|
|
32
|
+
function readStream(req) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let size = 0;
|
|
36
|
+
let tooLarge = false;
|
|
37
|
+
let failed;
|
|
38
|
+
req.on('data', (chunk) => {
|
|
39
|
+
if (tooLarge || failed !== undefined)
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
const bytes = toBytes(chunk);
|
|
43
|
+
size += bytes.length;
|
|
44
|
+
if (size > MAX_BODY_BYTES) {
|
|
45
|
+
tooLarge = true;
|
|
46
|
+
chunks.length = 0;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
chunks.push(bytes);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
failed = err;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
req.on('end', () => {
|
|
56
|
+
if (failed !== undefined)
|
|
57
|
+
return reject(failed);
|
|
58
|
+
if (tooLarge)
|
|
59
|
+
return reject(new BodyTooLargeError());
|
|
60
|
+
const out = new Uint8Array(size);
|
|
61
|
+
let offset = 0;
|
|
62
|
+
for (const chunk of chunks) {
|
|
63
|
+
out.set(chunk, offset);
|
|
64
|
+
offset += chunk.length;
|
|
65
|
+
}
|
|
66
|
+
resolve(out);
|
|
67
|
+
});
|
|
68
|
+
req.on('error', reject);
|
|
69
|
+
// A client that disconnects mid-body ends the read; after 'end' this reject is a no-op.
|
|
70
|
+
req.on('close', () => reject(new Error('The request closed before its body ended.')));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function savedBody(req) {
|
|
74
|
+
if (typeof req.rawBody === 'string' || req.rawBody instanceof Uint8Array)
|
|
75
|
+
return req.rawBody;
|
|
76
|
+
if (typeof req.body === 'string' || req.body instanceof Uint8Array)
|
|
77
|
+
return req.body;
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The raw body, wherever it is: bytes a parser kept, bytes or text from a raw
|
|
82
|
+
* or text parser, or the unread stream. Undefined when a JSON parser already
|
|
83
|
+
* consumed the stream and kept no bytes. Every source is held to the same
|
|
84
|
+
* size cap, text counted in UTF-8 bytes.
|
|
85
|
+
*/
|
|
86
|
+
async function readRawBody(req) {
|
|
87
|
+
const saved = savedBody(req);
|
|
88
|
+
if (saved !== undefined) {
|
|
89
|
+
const size = typeof saved === 'string' ? new TextEncoder().encode(saved).length : saved.length;
|
|
90
|
+
if (size > MAX_BODY_BYTES)
|
|
91
|
+
throw new BodyTooLargeError();
|
|
92
|
+
return saved;
|
|
93
|
+
}
|
|
94
|
+
if (req.readableEnded === true || typeof req.on !== 'function')
|
|
95
|
+
return undefined;
|
|
96
|
+
return readStream(req);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A `(req, res)` handler for your Agentcard webhook endpoint. A delivery with a
|
|
100
|
+
* missing, malformed, stale or wrong signature gets a 400 and reaches no
|
|
101
|
+
* callback. A verified delivery runs `onMessage` for a purchase message, or
|
|
102
|
+
* `opts.onEvent` for any other event, and the handler answers when the
|
|
103
|
+
* callback is done: 200 when it returns, and 500 when it throws, so Agentcard
|
|
104
|
+
* delivers the event again. Agentcard waits 10 seconds for an answer, so keep
|
|
105
|
+
* the callback short. Agentcard delivers at least once, and again after a 500,
|
|
106
|
+
* so handle each `message.eventId` once.
|
|
107
|
+
*/
|
|
108
|
+
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
|
+
}
|
|
112
|
+
if (typeof onMessage !== 'function') {
|
|
113
|
+
throw new TypeError('Pass onMessage, the function that receives each purchase message.');
|
|
114
|
+
}
|
|
115
|
+
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. */
|
|
128
|
+
const dispatch = async (event) => {
|
|
129
|
+
let message;
|
|
130
|
+
try {
|
|
131
|
+
message = readPurchaseMessage(event);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
// A purchase message the package cannot read reads the same way on every delivery, so it is not sent again.
|
|
135
|
+
report(error, event);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
if (message)
|
|
140
|
+
await onMessage(message, event);
|
|
141
|
+
else if (opts.onEvent)
|
|
142
|
+
await opts.onEvent(event);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
report(error, event);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const handle = async (req, res) => {
|
|
151
|
+
let raw;
|
|
152
|
+
try {
|
|
153
|
+
raw = await readRawBody(req);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof BodyTooLargeError)
|
|
157
|
+
return send(res, 413, { error: 'body_too_large' });
|
|
158
|
+
return send(res, 400, { error: 'unreadable_body' });
|
|
159
|
+
}
|
|
160
|
+
if (raw === undefined) {
|
|
161
|
+
report(new Error(RAW_BODY_GONE), undefined);
|
|
162
|
+
return send(res, 500, { error: 'raw_body_unavailable' });
|
|
163
|
+
}
|
|
164
|
+
let event;
|
|
165
|
+
try {
|
|
166
|
+
event = verifyAgentcardWebhook(raw, headerValue(req.headers['agentcard-signature']), secret, verifyOpts);
|
|
167
|
+
}
|
|
168
|
+
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' });
|
|
175
|
+
}
|
|
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' });
|
|
180
|
+
};
|
|
181
|
+
// The handler never rejects: an unhandled rejection would end a plain Node server's process.
|
|
182
|
+
return async (req, res) => {
|
|
183
|
+
try {
|
|
184
|
+
await handle(req, res);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
report(error, undefined);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatureReason, type AgentcardEvent, type VerifyOptions, } from './verify.js';
|
|
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 BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, } from './client.js';
|
|
5
|
+
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
|
+
export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, } from './buy-tool.js';
|
|
7
|
+
export { forModel, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
|
|
8
|
+
export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { verifyAgentcardWebhook, AgentcardSignatureError, } from './verify.js';
|
|
2
|
+
export { readPurchaseMessage, } from './message.js';
|
|
3
|
+
export { purchaseAgentWebhook, } from './handler.js';
|
|
4
|
+
export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, } from './client.js';
|
|
5
|
+
export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
|
|
6
|
+
export { buyTool, } from './buy-tool.js';
|
|
7
|
+
export { forModel, stripLinks, } from './model-view.js';
|
|
8
|
+
export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `purchase.message`: one event type for every moment of a purchase that a
|
|
3
|
+
* shopper should hear about. Each message carries the text to send as is, a
|
|
4
|
+
* note for the company's agent, and the same values as facts.
|
|
5
|
+
*/
|
|
6
|
+
import { type AgentcardEvent } from './verify.js';
|
|
7
|
+
/**
|
|
8
|
+
* The moment a message is about:
|
|
9
|
+
* - `order_placed`: the shopper approved, and Agentcard placed the order.
|
|
10
|
+
* - `order_failed`: the order failed after the approval, and nothing more happens for that approval.
|
|
11
|
+
* - `order_not_placed_yet`: the shopper approved, and the order is not placed yet.
|
|
12
|
+
* - `approval_expiring`: the approval link expires soon; `text` carries the link.
|
|
13
|
+
* - `approval_expired`: the approval expired before the shopper answered, and nothing was charged.
|
|
14
|
+
* - `card_link_unfinished`: the shopper has not added a card from their card link; `text` carries the link again.
|
|
15
|
+
* - `card_link_expired`: the card link expired before the shopper added a card.
|
|
16
|
+
* - `cart_idle`: a cart sat without being sent for approval; `facts.nudgeNumber` counts the nudges.
|
|
17
|
+
*/
|
|
18
|
+
export type PurchaseMessageKind = 'order_placed' | 'order_failed' | 'order_not_placed_yet' | 'approval_expiring' | 'approval_expired' | 'card_link_unfinished' | 'card_link_expired' | 'cart_idle';
|
|
19
|
+
/** The message's values one by one, for a company that writes its own copy. A fact that does not apply is absent. */
|
|
20
|
+
export interface PurchaseFacts {
|
|
21
|
+
merchantName?: string;
|
|
22
|
+
/** The total in the currency's minor unit: 2306 is $23.06. */
|
|
23
|
+
totalCents?: number;
|
|
24
|
+
/** The three-letter currency code. */
|
|
25
|
+
currency?: string;
|
|
26
|
+
cardBrand?: string;
|
|
27
|
+
cardLast4?: string;
|
|
28
|
+
orderId?: string;
|
|
29
|
+
/** Why an order failed or was not placed, in words. */
|
|
30
|
+
reason?: string;
|
|
31
|
+
approvalUrl?: string;
|
|
32
|
+
/** When the approval link stops working, as an ISO 8601 timestamp. */
|
|
33
|
+
approvalExpiresAt?: string;
|
|
34
|
+
cardLinkUrl?: string;
|
|
35
|
+
/** Until when the order is retried, as an ISO 8601 timestamp. */
|
|
36
|
+
retryUntil?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Until when the message holds, as an ISO 8601 timestamp: the expiry of any link or window its text
|
|
39
|
+
* names. Absent when the message stays true. A message read after it is stale: drop it.
|
|
40
|
+
*/
|
|
41
|
+
validUntil?: string;
|
|
42
|
+
/** 1 for a cart's first nudge, 2 for its second. */
|
|
43
|
+
nudgeNumber?: number;
|
|
44
|
+
}
|
|
45
|
+
/** A `purchase.message` event, read. */
|
|
46
|
+
export interface PurchaseMessage {
|
|
47
|
+
/** The event id. Agentcard delivers at least once: handle each id once. */
|
|
48
|
+
eventId: string;
|
|
49
|
+
/**
|
|
50
|
+
* The moment the message is about: one of the kinds above, or a kind Agentcard adds later,
|
|
51
|
+
* read as its own name. Give a `switch` over it a default branch that sends `text`.
|
|
52
|
+
*/
|
|
53
|
+
kind: PurchaseMessageKind | (string & {});
|
|
54
|
+
/** The shopper's Agentcard user id. */
|
|
55
|
+
userId: string;
|
|
56
|
+
/** Your own id for the shopper, when you gave Agentcard one. */
|
|
57
|
+
externalUserId: string | null;
|
|
58
|
+
/** Ready to send to the shopper as is. */
|
|
59
|
+
text: string;
|
|
60
|
+
/**
|
|
61
|
+
* For your agent: the facts of what happened, marked as written by the system and not typed by
|
|
62
|
+
* the shopper. Empty when the message carries no note; send `text` then.
|
|
63
|
+
*/
|
|
64
|
+
note: string;
|
|
65
|
+
facts: PurchaseFacts;
|
|
66
|
+
/** The OAuth client id of the app the shopper connected through. */
|
|
67
|
+
clientId: string | null;
|
|
68
|
+
/** False for a sandbox event. */
|
|
69
|
+
livemode: boolean;
|
|
70
|
+
/** Unix seconds. */
|
|
71
|
+
created: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The message a `purchase.message` event carries, or null for any other event
|
|
75
|
+
* type. Pass the event `verifyAgentcardWebhook` returned. Throws a TypeError
|
|
76
|
+
* when a purchase message lacks its kind, user_id or text.
|
|
77
|
+
*/
|
|
78
|
+
export declare function readPurchaseMessage(event: AgentcardEvent): PurchaseMessage | null;
|
package/dist/message.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `purchase.message`: one event type for every moment of a purchase that a
|
|
3
|
+
* shopper should hear about. Each message carries the text to send as is, a
|
|
4
|
+
* note for the company's agent, and the same values as facts.
|
|
5
|
+
*/
|
|
6
|
+
import { isRecord } from './verify.js';
|
|
7
|
+
/** The event type every purchase message arrives as. */
|
|
8
|
+
const PURCHASE_MESSAGE_EVENT = 'purchase.message';
|
|
9
|
+
const STRING_FACTS = [
|
|
10
|
+
['merchant_name', 'merchantName'],
|
|
11
|
+
['currency', 'currency'],
|
|
12
|
+
['card_brand', 'cardBrand'],
|
|
13
|
+
['card_last4', 'cardLast4'],
|
|
14
|
+
['order_id', 'orderId'],
|
|
15
|
+
['reason', 'reason'],
|
|
16
|
+
['approval_url', 'approvalUrl'],
|
|
17
|
+
['approval_expires_at', 'approvalExpiresAt'],
|
|
18
|
+
['card_link_url', 'cardLinkUrl'],
|
|
19
|
+
['retry_until', 'retryUntil'],
|
|
20
|
+
['valid_until', 'validUntil'],
|
|
21
|
+
];
|
|
22
|
+
const NUMBER_FACTS = [
|
|
23
|
+
['total_cents', 'totalCents'],
|
|
24
|
+
['nudge_number', 'nudgeNumber'],
|
|
25
|
+
];
|
|
26
|
+
function readFacts(raw) {
|
|
27
|
+
const facts = {};
|
|
28
|
+
if (!isRecord(raw))
|
|
29
|
+
return facts;
|
|
30
|
+
for (const [wire, name] of STRING_FACTS) {
|
|
31
|
+
const value = raw[wire];
|
|
32
|
+
if (typeof value === 'string' && value !== '')
|
|
33
|
+
facts[name] = value;
|
|
34
|
+
}
|
|
35
|
+
for (const [wire, name] of NUMBER_FACTS) {
|
|
36
|
+
const value = raw[wire];
|
|
37
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
38
|
+
facts[name] = value;
|
|
39
|
+
}
|
|
40
|
+
return facts;
|
|
41
|
+
}
|
|
42
|
+
function requiredString(data, field) {
|
|
43
|
+
const value = data[field];
|
|
44
|
+
if (typeof value !== 'string' || value === '') {
|
|
45
|
+
throw new TypeError(`The purchase.message event has no ${field}. Every purchase message carries kind, user_id and text.`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The message a `purchase.message` event carries, or null for any other event
|
|
51
|
+
* type. Pass the event `verifyAgentcardWebhook` returned. Throws a TypeError
|
|
52
|
+
* when a purchase message lacks its kind, user_id or text.
|
|
53
|
+
*/
|
|
54
|
+
export function readPurchaseMessage(event) {
|
|
55
|
+
if (event.type !== PURCHASE_MESSAGE_EVENT)
|
|
56
|
+
return null;
|
|
57
|
+
const data = event.data;
|
|
58
|
+
const kind = requiredString(data, 'kind');
|
|
59
|
+
const userId = requiredString(data, 'user_id');
|
|
60
|
+
const text = requiredString(data, 'text');
|
|
61
|
+
// Without a note, the text alone still tells the shopper what happened.
|
|
62
|
+
const note = typeof data.note === 'string' ? data.note : '';
|
|
63
|
+
const connection = isRecord(data.connection) ? data.connection : {};
|
|
64
|
+
return {
|
|
65
|
+
eventId: event.id,
|
|
66
|
+
kind,
|
|
67
|
+
userId,
|
|
68
|
+
externalUserId: typeof data.external_user_id === 'string' && data.external_user_id !== '' ? data.external_user_id : null,
|
|
69
|
+
text,
|
|
70
|
+
note,
|
|
71
|
+
facts: readFacts(data.facts),
|
|
72
|
+
clientId: typeof connection.client_id === 'string' && connection.client_id !== '' ? connection.client_id : null,
|
|
73
|
+
livemode: event.livemode,
|
|
74
|
+
created: event.created,
|
|
75
|
+
};
|
|
76
|
+
}
|