@agentsbloom/sdk 0.2.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 +21 -0
- package/README.md +254 -0
- package/SECURITY.md +19 -0
- package/assets/logo-mark.svg +25 -0
- package/index.d.ts +124 -0
- package/index.js +1112 -0
- package/lib/ap2.js +422 -0
- package/package.json +81 -0
- package/telemetry.js +50 -0
package/lib/ap2.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hardened AP2 (Agent Payments Protocol) mandate verification.
|
|
5
|
+
*
|
|
6
|
+
* This module replaces the previous "valid but unverified" bypass in
|
|
7
|
+
* packages/sdk/index.js's verifyAP2Mandates: a mandate that could not be
|
|
8
|
+
* cryptographically verified was still returned as `{ valid: true,
|
|
9
|
+
* verified: false }` and the checkout gate only checked `!ap2MandateResult`
|
|
10
|
+
* / budget, never `.verified`. That meant any attacker could hand-craft an
|
|
11
|
+
* unsigned (or badly-signed) mandate claiming an arbitrary maxBudget and
|
|
12
|
+
* have it treated as authorization to check out.
|
|
13
|
+
*
|
|
14
|
+
* The rules enforced here:
|
|
15
|
+
* 1. If no mandate header is present at all, AP2 simply isn't in use for
|
|
16
|
+
* this request - that's still `{ valid: true, verified: false }` so
|
|
17
|
+
* non-AP2 traffic (REST/ACP) is never blocked by AP2 logic.
|
|
18
|
+
* 2. If a mandate header IS present, it MUST verify successfully or the
|
|
19
|
+
* request is rejected outright (`valid: false`). There is no
|
|
20
|
+
* "valid but unverified" middle ground once a mandate is presented.
|
|
21
|
+
* 3. Signature verification uses either (a) a merchant-configured
|
|
22
|
+
* trusted public key (out-of-band trust, any of ES256/384/512,
|
|
23
|
+
* RS256/384/512, EdDSA), or (b) self-certifying did:key issuers
|
|
24
|
+
* (Ed25519/EdDSA only - the public key IS the issuer identifier, so
|
|
25
|
+
* no pre-registration is needed for arbitrary agents/wallets).
|
|
26
|
+
* 4. Mandates must carry a unique `jti` and are checked against a
|
|
27
|
+
* bounded in-memory replay cache - a captured valid mandate cannot be
|
|
28
|
+
* replayed to trigger a second charge.
|
|
29
|
+
* 5. `aud` is checked against the caller-supplied expected audience
|
|
30
|
+
* (the merchant's own base URL) when one is provided, so a mandate
|
|
31
|
+
* signed for store A cannot be replayed against store B.
|
|
32
|
+
* 6. Mandate lifetime (`exp - iat`) is bounded, so a "valid forever"
|
|
33
|
+
* mandate cannot be crafted even with a real signature.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// --- base58btc (Bitcoin alphabet) - no external dependency ---
|
|
37
|
+
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
38
|
+
const BASE58_MAP = new Map(BASE58_ALPHABET.split('').map((c, i) => [c, i]));
|
|
39
|
+
|
|
40
|
+
/** @param {Buffer} buffer @returns {string} */
|
|
41
|
+
function base58Encode(buffer) {
|
|
42
|
+
if (buffer.length === 0) return '';
|
|
43
|
+
let value = 0n;
|
|
44
|
+
for (const byte of buffer) value = value * 256n + BigInt(byte);
|
|
45
|
+
|
|
46
|
+
let encoded = '';
|
|
47
|
+
while (value > 0n) {
|
|
48
|
+
const remainder = value % 58n;
|
|
49
|
+
value = value / 58n;
|
|
50
|
+
encoded = BASE58_ALPHABET[Number(remainder)] + encoded;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let leadingZeros = 0;
|
|
54
|
+
for (const byte of buffer) {
|
|
55
|
+
if (byte === 0) leadingZeros++;
|
|
56
|
+
else break;
|
|
57
|
+
}
|
|
58
|
+
return BASE58_ALPHABET[0].repeat(leadingZeros) + encoded;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string} str @returns {Buffer} */
|
|
62
|
+
function base58Decode(str) {
|
|
63
|
+
if (str.length === 0) return Buffer.alloc(0);
|
|
64
|
+
let value = 0n;
|
|
65
|
+
for (const char of str) {
|
|
66
|
+
const digit = BASE58_MAP.get(char);
|
|
67
|
+
if (digit === undefined) throw new Error(`Invalid base58 character: ${char}`);
|
|
68
|
+
value = value * 58n + BigInt(digit);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const bytes = [];
|
|
72
|
+
while (value > 0n) {
|
|
73
|
+
bytes.unshift(Number(value % 256n));
|
|
74
|
+
value = value / 256n;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let leadingZeros = 0;
|
|
78
|
+
for (const char of str) {
|
|
79
|
+
if (char === BASE58_ALPHABET[0]) leadingZeros++;
|
|
80
|
+
else break;
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat([Buffer.alloc(leadingZeros, 0), Buffer.from(bytes)]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Ed25519 multicodec (0xed) varint-encoded as [0xed, 0x01], per the did:key
|
|
86
|
+
// Ed25519 method spec (multicodec ed25519-pub prefix).
|
|
87
|
+
const ED25519_MULTICODEC_PREFIX = Buffer.from([0xed, 0x01]);
|
|
88
|
+
|
|
89
|
+
// Fixed 12-byte SPKI DER prefix for Ed25519 public keys (RFC 8410) - Ed25519
|
|
90
|
+
// has no algorithm parameters, so the DER encoding of any Ed25519 SPKI key
|
|
91
|
+
// is always this fixed prefix followed by the raw 32-byte public key.
|
|
92
|
+
const ED25519_SPKI_DER_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Derives a did:key identifier from an Ed25519 public key.
|
|
96
|
+
* @param {crypto.KeyObject|Buffer} publicKey - a Node KeyObject or raw 32-byte public key
|
|
97
|
+
* @returns {string} e.g. "did:key:z6Mk..."
|
|
98
|
+
*/
|
|
99
|
+
export function didKeyFromEd25519PublicKey(publicKey) {
|
|
100
|
+
const rawKey = Buffer.isBuffer(publicKey)
|
|
101
|
+
? publicKey
|
|
102
|
+
: publicKey.export({ type: 'spki', format: 'der' }).subarray(-32);
|
|
103
|
+
const prefixed = Buffer.concat([ED25519_MULTICODEC_PREFIX, rawKey]);
|
|
104
|
+
return `did:key:z${base58Encode(prefixed)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Derives an Ed25519 public KeyObject from a did:key identifier.
|
|
109
|
+
* @param {string} didKey - e.g. "did:key:z6Mk..."
|
|
110
|
+
* @returns {crypto.KeyObject|null} null if the identifier is not a
|
|
111
|
+
* well-formed Ed25519 did:key (caller treats this as "cannot verify")
|
|
112
|
+
*/
|
|
113
|
+
export function ed25519PublicKeyFromDidKey(didKey) {
|
|
114
|
+
if (typeof didKey !== 'string' || !didKey.startsWith('did:key:z')) return null;
|
|
115
|
+
try {
|
|
116
|
+
const multibase = didKey.slice('did:key:'.length);
|
|
117
|
+
const decoded = base58Decode(multibase.slice(1)); // drop leading 'z' multibase prefix
|
|
118
|
+
if (
|
|
119
|
+
decoded.length !== ED25519_MULTICODEC_PREFIX.length + 32 ||
|
|
120
|
+
!decoded.subarray(0, 2).equals(ED25519_MULTICODEC_PREFIX)
|
|
121
|
+
) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const rawKey = decoded.subarray(2);
|
|
125
|
+
const der = Buffer.concat([ED25519_SPKI_DER_PREFIX, rawKey]);
|
|
126
|
+
return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Replay protection ---
|
|
133
|
+
// A bounded, TTL-cleaned cache of mandate `jti`s that have already been
|
|
134
|
+
// consumed. Sized generously; entries are removed once the mandate they
|
|
135
|
+
// belonged to would have expired anyway, so the cache cannot grow forever
|
|
136
|
+
// even under sustained attack traffic.
|
|
137
|
+
const seenMandateJtis = new Map(); // jti -> expiresAtMs
|
|
138
|
+
const REPLAY_CACHE_MAX_SIZE = 50_000;
|
|
139
|
+
|
|
140
|
+
const replayCleanupInterval = setInterval(() => {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
for (const [jti, expiresAtMs] of seenMandateJtis.entries()) {
|
|
143
|
+
if (expiresAtMs < now) seenMandateJtis.delete(jti);
|
|
144
|
+
}
|
|
145
|
+
}, 60 * 1000);
|
|
146
|
+
replayCleanupInterval.unref?.();
|
|
147
|
+
|
|
148
|
+
/** Clears all replay-tracking state. Exposed for tests and shutdown(). */
|
|
149
|
+
export function resetAp2ReplayCache() {
|
|
150
|
+
seenMandateJtis.clear();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Stops the background cleanup timer. Called from index.js's shutdown(). */
|
|
154
|
+
export function stopAp2ReplayCleanup() {
|
|
155
|
+
clearInterval(replayCleanupInterval);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isReplay(jti, expMs) {
|
|
159
|
+
if (seenMandateJtis.has(jti)) return true;
|
|
160
|
+
if (seenMandateJtis.size < REPLAY_CACHE_MAX_SIZE) {
|
|
161
|
+
seenMandateJtis.set(jti, expMs);
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Signature verification helpers ---
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Algorithms accepted when the merchant has configured an explicit,
|
|
170
|
+
* out-of-band-trusted public key. The merchant already trusts this key by
|
|
171
|
+
* configuring it, so we support the same breadth of algorithms the
|
|
172
|
+
* original implementation advertised.
|
|
173
|
+
*/
|
|
174
|
+
const EXPLICIT_KEY_ALLOWED_ALGS = new Set(['EdDSA', 'ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512']);
|
|
175
|
+
|
|
176
|
+
function hashAlgForJwtAlg(alg) {
|
|
177
|
+
if (alg === 'EdDSA') return null; // Ed25519/Ed448 signature algorithm is built into the key
|
|
178
|
+
if (alg.includes('384')) return 'SHA384';
|
|
179
|
+
if (alg.includes('512')) return 'SHA512';
|
|
180
|
+
return 'SHA256';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Verifies an AP2 mandate SD-JWT presented in an incoming request.
|
|
185
|
+
*
|
|
186
|
+
* @param {Record<string, string>} [headers]
|
|
187
|
+
* @param {Record<string, unknown>} [body]
|
|
188
|
+
* @param {object} [options]
|
|
189
|
+
* @param {crypto.KeyObject|Buffer|string|null} [options.trustedPublicKey] - merchant-configured
|
|
190
|
+
* out-of-band-trusted key (any format accepted by crypto.createPublicKey, or an
|
|
191
|
+
* already-constructed KeyObject). When set, this key is used instead of deriving
|
|
192
|
+
* one from a did:key issuer, and the broader algorithm set is permitted.
|
|
193
|
+
* @param {string} [options.expectedAudience] - if set, `aud` must equal this value
|
|
194
|
+
* @param {number} [options.maxMandateLifetimeSec=3600] - maximum allowed `exp - iat`
|
|
195
|
+
* @param {boolean} [options.requireJti=true] - reject mandates without a `jti` claim
|
|
196
|
+
* @param {string[]} [options.requestedCategories] - if set and the mandate's
|
|
197
|
+
* `intentMandate.allowedCategories` is present, the two must overlap
|
|
198
|
+
* @returns {{ valid: boolean, verified: boolean, protocol: 'AP2', reason?: string, mandates?: object }}
|
|
199
|
+
*/
|
|
200
|
+
export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
201
|
+
const {
|
|
202
|
+
trustedPublicKey = null,
|
|
203
|
+
expectedAudience = null,
|
|
204
|
+
maxMandateLifetimeSec = 3600,
|
|
205
|
+
requireJti = true,
|
|
206
|
+
requestedCategories = null,
|
|
207
|
+
} = options;
|
|
208
|
+
|
|
209
|
+
const mandateHeader = headers['x-ap2-mandate'] || headers['authorization'];
|
|
210
|
+
if (!mandateHeader) {
|
|
211
|
+
// AP2 simply isn't in use for this request - not a security decision.
|
|
212
|
+
return { valid: true, protocol: 'AP2', verified: false, note: 'No AP2 Mandates attached' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let header, payload, signatureB64;
|
|
216
|
+
try {
|
|
217
|
+
const token = String(mandateHeader).replace(/^Bearer\s+/i, '');
|
|
218
|
+
const parts = token.split('.');
|
|
219
|
+
if (parts.length < 3) return { valid: false, reason: 'Invalid AP2 Mandate SD-JWT format', protocol: 'AP2' };
|
|
220
|
+
header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
|
|
221
|
+
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
222
|
+
signatureB64 = parts[2];
|
|
223
|
+
} catch {
|
|
224
|
+
return { valid: false, reason: 'Invalid AP2 Mandate SD-JWT format', protocol: 'AP2' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Required claims present ---
|
|
228
|
+
if (!payload.iss) return { valid: false, reason: 'Missing issuer', protocol: 'AP2' };
|
|
229
|
+
if (!payload.aud) return { valid: false, reason: 'Missing audience', protocol: 'AP2' };
|
|
230
|
+
if (typeof payload.iat !== 'number') return { valid: false, reason: 'Missing iat', protocol: 'AP2' };
|
|
231
|
+
if (typeof payload.exp !== 'number') return { valid: false, reason: 'Missing exp', protocol: 'AP2' };
|
|
232
|
+
if (requireJti && !payload.jti) {
|
|
233
|
+
return { valid: false, reason: 'Missing jti (required for replay protection)', protocol: 'AP2' };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- Temporal validity ---
|
|
237
|
+
const now = Math.floor(Date.now() / 1000);
|
|
238
|
+
if (payload.exp < now) return { valid: false, reason: 'Token expired', protocol: 'AP2' };
|
|
239
|
+
if (payload.iat > now) return { valid: false, reason: 'Token issued in the future', protocol: 'AP2' };
|
|
240
|
+
|
|
241
|
+
// --- Bounded mandate lifetime ---
|
|
242
|
+
if (payload.exp - payload.iat > maxMandateLifetimeSec) {
|
|
243
|
+
return { valid: false, reason: `Mandate lifetime exceeds maximum allowed (${maxMandateLifetimeSec}s)`, protocol: 'AP2' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Resolve verification key + algorithm policy ---
|
|
247
|
+
let publicKey = null;
|
|
248
|
+
let usingSelfCertifyingKey = false;
|
|
249
|
+
|
|
250
|
+
if (trustedPublicKey) {
|
|
251
|
+
try {
|
|
252
|
+
publicKey = trustedPublicKey instanceof crypto.KeyObject
|
|
253
|
+
? trustedPublicKey
|
|
254
|
+
: crypto.createPublicKey(trustedPublicKey);
|
|
255
|
+
} catch {
|
|
256
|
+
return { valid: false, reason: 'Configured trusted public key is invalid', protocol: 'AP2' };
|
|
257
|
+
}
|
|
258
|
+
if (!EXPLICIT_KEY_ALLOWED_ALGS.has(header.alg)) {
|
|
259
|
+
return { valid: false, reason: `Unsupported signature algorithm: ${header.alg}`, protocol: 'AP2' };
|
|
260
|
+
}
|
|
261
|
+
} else if (String(payload.iss).startsWith('did:key:')) {
|
|
262
|
+
usingSelfCertifyingKey = true;
|
|
263
|
+
if (header.alg !== 'EdDSA') {
|
|
264
|
+
return { valid: false, reason: 'did:key issuers require the EdDSA algorithm', protocol: 'AP2' };
|
|
265
|
+
}
|
|
266
|
+
publicKey = ed25519PublicKeyFromDidKey(payload.iss);
|
|
267
|
+
if (!publicKey) {
|
|
268
|
+
return { valid: false, reason: 'Issuer is not a valid Ed25519 did:key identifier', protocol: 'AP2' };
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
// No merchant-trusted key configured, and the issuer isn't a
|
|
272
|
+
// self-certifying did:key we can derive a key from - there is no way
|
|
273
|
+
// to verify this mandate, so it must be rejected rather than passed
|
|
274
|
+
// through as "valid but unverified".
|
|
275
|
+
return {
|
|
276
|
+
valid: false,
|
|
277
|
+
reason: 'Cannot verify mandate: issuer is not a did:key and no trusted public key is configured',
|
|
278
|
+
protocol: 'AP2',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- Signature verification (mandatory) ---
|
|
283
|
+
try {
|
|
284
|
+
const [encodedHeader, encodedPayload] = String(mandateHeader).replace(/^Bearer\s+/i, '').split('.');
|
|
285
|
+
const signingInput = Buffer.from(`${encodedHeader}.${encodedPayload}`);
|
|
286
|
+
const signature = Buffer.from(signatureB64, 'base64url');
|
|
287
|
+
const hashAlg = hashAlgForJwtAlg(header.alg);
|
|
288
|
+
const isValid = crypto.verify(hashAlg, signingInput, publicKey, signature);
|
|
289
|
+
if (!isValid) return { valid: false, reason: 'Invalid signature', protocol: 'AP2' };
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return { valid: false, reason: `Signature verification failed: ${err.message}`, protocol: 'AP2' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- Audience binding ---
|
|
295
|
+
if (expectedAudience && payload.aud !== expectedAudience) {
|
|
296
|
+
return {
|
|
297
|
+
valid: false,
|
|
298
|
+
reason: `Mandate audience "${payload.aud}" does not match this store ("${expectedAudience}")`,
|
|
299
|
+
protocol: 'AP2',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- merchantScope binding (if the mandate declares one, it must match too) ---
|
|
304
|
+
const merchantScope = payload.intentMandate?.merchantScope;
|
|
305
|
+
if (expectedAudience && merchantScope && merchantScope !== expectedAudience) {
|
|
306
|
+
return {
|
|
307
|
+
valid: false,
|
|
308
|
+
reason: `Mandate merchantScope "${merchantScope}" does not match this store ("${expectedAudience}")`,
|
|
309
|
+
protocol: 'AP2',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- Replay protection ---
|
|
314
|
+
if (payload.jti) {
|
|
315
|
+
if (isReplay(payload.jti, payload.exp * 1000)) {
|
|
316
|
+
return { valid: false, reason: 'Mandate has already been used (replay detected)', protocol: 'AP2' };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- Category enforcement (only if both the mandate and caller supply it) ---
|
|
321
|
+
const allowedCategories = payload.intentMandate?.allowedCategories;
|
|
322
|
+
if (Array.isArray(allowedCategories) && Array.isArray(requestedCategories) && requestedCategories.length > 0) {
|
|
323
|
+
const overlaps = requestedCategories.some((c) => allowedCategories.includes(c));
|
|
324
|
+
if (!overlaps) {
|
|
325
|
+
return {
|
|
326
|
+
valid: false,
|
|
327
|
+
reason: `Requested categories [${requestedCategories.join(', ')}] are not within the mandate's allowedCategories [${allowedCategories.join(', ')}]`,
|
|
328
|
+
protocol: 'AP2',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- Budget enforcement (checked here for the common case where the
|
|
334
|
+
// caller already knows the order total; the SDK middleware also
|
|
335
|
+
// re-checks this at the /ap2/checkout call site once the cart total is known) ---
|
|
336
|
+
const maxBudget = payload.intentMandate?.maxBudget ?? payload.paymentMandate?.maxBudget;
|
|
337
|
+
if (maxBudget !== undefined && body?.orderTotal !== undefined) {
|
|
338
|
+
if (Number(body.orderTotal) > Number(maxBudget)) {
|
|
339
|
+
return { valid: false, reason: 'Order total exceeds Intent Mandate maxBudget limit', protocol: 'AP2' };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
valid: true,
|
|
345
|
+
protocol: 'AP2',
|
|
346
|
+
verified: true,
|
|
347
|
+
selfCertifying: usingSelfCertifyingKey,
|
|
348
|
+
mandates: payload,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Builds and signs a complete AP2 Intent+Payment Mandate SD-JWT for
|
|
354
|
+
* agent-side tooling (test harnesses, demos, or a real agent's own wallet
|
|
355
|
+
* integration). The issuer is a did:key derived from the signing keypair,
|
|
356
|
+
* so the resulting mandate self-certifies without any prior key
|
|
357
|
+
* registration with the merchant.
|
|
358
|
+
*
|
|
359
|
+
* @param {object} options
|
|
360
|
+
* @param {string} options.audience - the merchant/store base URL this mandate authorizes (required)
|
|
361
|
+
* @param {number} options.maxBudget - spending cap enforced by the merchant on checkout
|
|
362
|
+
* @param {string} [options.currency='USD']
|
|
363
|
+
* @param {string[]} [options.allowedCategories] - optional category allowlist
|
|
364
|
+
* @param {string} [options.merchantScope] - defaults to `audience`
|
|
365
|
+
* @param {number} [options.lifetimeSec=3600] - mandate validity window
|
|
366
|
+
* @param {string} [options.paymentMethod='tokenized_card']
|
|
367
|
+
* @param {string} [options.subject='user_wallet_delegation']
|
|
368
|
+
* @param {crypto.KeyObject} [options.privateKey] - reuse an existing Ed25519 private key;
|
|
369
|
+
* a fresh ephemeral keypair is generated if omitted
|
|
370
|
+
* @returns {{ token: string, did: string, publicKey: crypto.KeyObject, privateKey: crypto.KeyObject }}
|
|
371
|
+
*/
|
|
372
|
+
export function createAp2Mandate(options = {}) {
|
|
373
|
+
const {
|
|
374
|
+
audience,
|
|
375
|
+
maxBudget,
|
|
376
|
+
currency = 'USD',
|
|
377
|
+
allowedCategories,
|
|
378
|
+
merchantScope,
|
|
379
|
+
lifetimeSec = 3600,
|
|
380
|
+
paymentMethod = 'tokenized_card',
|
|
381
|
+
subject = 'user_wallet_delegation',
|
|
382
|
+
privateKey: providedPrivateKey,
|
|
383
|
+
} = options;
|
|
384
|
+
|
|
385
|
+
if (!audience) throw new Error('createAp2Mandate requires an `audience` (the target store base URL)');
|
|
386
|
+
if (maxBudget === undefined) throw new Error('createAp2Mandate requires a `maxBudget`');
|
|
387
|
+
|
|
388
|
+
const { privateKey, publicKey } = providedPrivateKey
|
|
389
|
+
? { privateKey: providedPrivateKey, publicKey: crypto.createPublicKey(providedPrivateKey) }
|
|
390
|
+
: crypto.generateKeyPairSync('ed25519');
|
|
391
|
+
|
|
392
|
+
const did = didKeyFromEd25519PublicKey(publicKey);
|
|
393
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
394
|
+
const exp = iat + lifetimeSec;
|
|
395
|
+
|
|
396
|
+
const header = { alg: 'EdDSA', typ: 'JWT' };
|
|
397
|
+
const payload = {
|
|
398
|
+
iss: did,
|
|
399
|
+
aud: audience,
|
|
400
|
+
sub: subject,
|
|
401
|
+
iat,
|
|
402
|
+
exp,
|
|
403
|
+
jti: crypto.randomUUID(),
|
|
404
|
+
intentMandate: {
|
|
405
|
+
maxBudget,
|
|
406
|
+
currency,
|
|
407
|
+
merchantScope: merchantScope || audience,
|
|
408
|
+
...(allowedCategories ? { allowedCategories } : {}),
|
|
409
|
+
},
|
|
410
|
+
paymentMandate: {
|
|
411
|
+
paymentMethod,
|
|
412
|
+
currency,
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
|
|
417
|
+
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
418
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
419
|
+
const signature = crypto.sign(null, Buffer.from(signingInput), privateKey).toString('base64url');
|
|
420
|
+
|
|
421
|
+
return { token: `${signingInput}.${signature}`, did, publicKey, privateKey };
|
|
422
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentsbloom/sdk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Universal Node.js/Express SDK to convert any e-commerce store into an Agent-Ready API (UCP, ACP, AP2, WebMCP, Web Bot Auth).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"import": "./index.js",
|
|
12
|
+
"default": "./index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"index.d.ts",
|
|
18
|
+
"telemetry.js",
|
|
19
|
+
"lib/ap2.js",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"SECURITY.md",
|
|
23
|
+
"assets/logo-mark.svg"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": true,
|
|
26
|
+
"keywords": [
|
|
27
|
+
"agentsbloom",
|
|
28
|
+
"webmcp",
|
|
29
|
+
"ucp",
|
|
30
|
+
"acp",
|
|
31
|
+
"ap2",
|
|
32
|
+
"web-bot-auth",
|
|
33
|
+
"ai-agents"
|
|
34
|
+
],
|
|
35
|
+
"author": "AgentsBloom <contact@agentsbloom.com>",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/AgentsBloom/sdk.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://docs.agentsbloom.com",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/AgentsBloom/sdk/issues"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"express": "^4.0.0"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.1.0",
|
|
56
|
+
"@opentelemetry/api": "^1.9.0"
|
|
57
|
+
},
|
|
58
|
+
"optionalDependencies": {
|
|
59
|
+
"@opentelemetry/sdk-trace-node": "1.30.1",
|
|
60
|
+
"@opentelemetry/sdk-trace-base": "1.30.1",
|
|
61
|
+
"@opentelemetry/exporter-trace-otlp-http": "0.57.2",
|
|
62
|
+
"@opentelemetry/resources": "1.30.1",
|
|
63
|
+
"@opentelemetry/semantic-conventions": "1.29.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@opentelemetry/sdk-trace-node": "1.30.1",
|
|
67
|
+
"@opentelemetry/sdk-trace-base": "1.30.1",
|
|
68
|
+
"@opentelemetry/exporter-trace-otlp-http": "0.57.2",
|
|
69
|
+
"@opentelemetry/resources": "1.30.1",
|
|
70
|
+
"@opentelemetry/semantic-conventions": "1.29.0",
|
|
71
|
+
"fast-check": "^3.23.2"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"test": "node --test",
|
|
75
|
+
"lint": "node --check index.js && node --check telemetry.js && node --check lib/ap2.js && node --check scripts/check-package.mjs && node --check scripts/verify-consumer.mjs",
|
|
76
|
+
"check:package": "node scripts/check-package.mjs",
|
|
77
|
+
"verify:consumer": "node scripts/verify-consumer.mjs",
|
|
78
|
+
"release:check": "npm run lint && npm test && npm run check:package && npm run verify:consumer && npm pack --dry-run --ignore-scripts",
|
|
79
|
+
"prepublishOnly": "npm run release:check"
|
|
80
|
+
}
|
|
81
|
+
}
|
package/telemetry.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTLP trace exporter initialization for the AgentsBloom SDK.
|
|
3
|
+
*
|
|
4
|
+
* The OpenTelemetry SDK packages required to actually export spans
|
|
5
|
+
* (@opentelemetry/sdk-trace-node, sdk-trace-base, exporter-trace-otlp-http,
|
|
6
|
+
* resources, semantic-conventions) are loaded via dynamic import() so that
|
|
7
|
+
* a merchant application that never calls setupTelemetry() is never forced
|
|
8
|
+
* to install them. They are declared as optionalDependencies in
|
|
9
|
+
* package.json rather than dependencies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initialize an OTLP trace exporter/provider pair.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} options
|
|
16
|
+
* @param {string} options.otlpEndpoint - OTLP collector base URL (traces are posted to `${otlpEndpoint}/v1/traces`)
|
|
17
|
+
* @param {string} options.serviceName - Service name attached as the resource's service.name attribute
|
|
18
|
+
* @param {number} options.samplingRatio - Trace sampling ratio 0.0-1.0 (currently informational; not yet wired into a sampler)
|
|
19
|
+
* @param {string} options.apiKey - API key sent as a Bearer token in the exporter's Authorization header
|
|
20
|
+
* @returns {Promise<{ provider: import('@opentelemetry/sdk-trace-node').NodeTracerProvider, exporter: import('@opentelemetry/exporter-trace-otlp-http').OTLPTraceExporter } | null>}
|
|
21
|
+
* The initialized provider/exporter handle, or null if the optional OTel SDK packages are unavailable.
|
|
22
|
+
*/
|
|
23
|
+
let optionalDependencyWarningLogged = false;
|
|
24
|
+
|
|
25
|
+
export async function initExporter({ otlpEndpoint, serviceName, samplingRatio, apiKey } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const { NodeTracerProvider } = await import('@opentelemetry/sdk-trace-node');
|
|
28
|
+
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-base');
|
|
29
|
+
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http');
|
|
30
|
+
const { Resource } = await import('@opentelemetry/resources');
|
|
31
|
+
const { SemanticResourceAttributes } = await import('@opentelemetry/semantic-conventions');
|
|
32
|
+
|
|
33
|
+
const exporter = new OTLPTraceExporter({
|
|
34
|
+
url: `${otlpEndpoint}/v1/traces`,
|
|
35
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
36
|
+
});
|
|
37
|
+
const provider = new NodeTracerProvider({
|
|
38
|
+
resource: new Resource({ [SemanticResourceAttributes.SERVICE_NAME]: serviceName }),
|
|
39
|
+
});
|
|
40
|
+
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
|
|
41
|
+
provider.register();
|
|
42
|
+
return { provider, exporter };
|
|
43
|
+
} catch {
|
|
44
|
+
if (!optionalDependencyWarningLogged) {
|
|
45
|
+
optionalDependencyWarningLogged = true;
|
|
46
|
+
console.warn('🌸 AgentsBloom: OTel SDK packages unavailable, continuing without export.');
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|