@agentsbloom/sdk 0.4.0 → 0.5.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/index.d.ts +354 -216
- package/index.js +2350 -1993
- package/lib/ap2.js +1017 -537
- package/lib/http-signatures.js +874 -0
- package/lib/money.js +283 -0
- package/lib/outcomes.js +108 -108
- package/lib/protocol.d.ts +229 -0
- package/lib/protocol.js +85 -0
- package/lib/shared-store.js +298 -172
- package/lib/signature-base.js +436 -0
- package/lib/structured-fields.js +398 -0
- package/package.json +16 -6
- package/telemetry.js +77 -57
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 8941 Structured Field Values — the subset RFC 9421 actually needs.
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists
|
|
5
|
+
* --------------------
|
|
6
|
+
* The previous verifier parsed `Signature-Input` with a handful of
|
|
7
|
+
* unanchored regexes:
|
|
8
|
+
*
|
|
9
|
+
* rfcSignatureInput.match(/keyid="([^"]+)"/)
|
|
10
|
+
* rfcSignatureInput.match(/\(([^)]+)\)/)
|
|
11
|
+
* rfcSignatureInput.match(/(?:^|;)created=(\d+)(?:;|$)/)
|
|
12
|
+
*
|
|
13
|
+
* That is not a parser, and the gap between "what the regex extracted" and
|
|
14
|
+
* "what the client actually signed" is exploitable:
|
|
15
|
+
*
|
|
16
|
+
* 1. `keyid` was taken from the FIRST match anywhere in the header. A
|
|
17
|
+
* covered-component name is a quoted string, so a component list
|
|
18
|
+
* containing `"keyid=\"attacker\""`-shaped text could surface a keyid
|
|
19
|
+
* that was never a signature parameter — the key used to verify and
|
|
20
|
+
* the key named in the signed parameters could disagree.
|
|
21
|
+
* 2. Component *parameters* (`;sf`, `;bs`, `;req`, `;key`, `;name`) were
|
|
22
|
+
* silently discarded. `"content-digest";sf` and `"content-digest"` are
|
|
23
|
+
* DIFFERENT components with different canonical values, but both
|
|
24
|
+
* collapsed to the same base line, so a signature over one verified
|
|
25
|
+
* against the other.
|
|
26
|
+
* 3. Duplicate components, unbalanced quotes, and trailing garbage were
|
|
27
|
+
* all accepted.
|
|
28
|
+
*
|
|
29
|
+
* A conformant parser removes the whole class. Everything below fails
|
|
30
|
+
* closed: anything that is not unambiguously well-formed throws.
|
|
31
|
+
*
|
|
32
|
+
* Scope: Dictionaries (`Signature`, `Signature-Input`), Inner Lists, Items,
|
|
33
|
+
* Parameters, and the bare-item types those use. List and Date/Display-String
|
|
34
|
+
* types are not needed by RFC 9421 and are deliberately absent.
|
|
35
|
+
*
|
|
36
|
+
* @see https://www.rfc-editor.org/rfc/rfc8941
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Hard cap so a hostile header cannot drive superlinear work. */
|
|
40
|
+
const MAX_FIELD_LENGTH = 16 * 1024;
|
|
41
|
+
/** Hard cap on dictionary members / inner-list entries / parameters. */
|
|
42
|
+
const MAX_MEMBERS = 64;
|
|
43
|
+
|
|
44
|
+
export class StructuredFieldError extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'StructuredFieldError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fail(message) {
|
|
52
|
+
throw new StructuredFieldError(message);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Marker for a Byte Sequence bare item (`:b64:`). */
|
|
56
|
+
export class ByteSequence {
|
|
57
|
+
/** @param {Buffer} bytes */
|
|
58
|
+
constructor(bytes) {
|
|
59
|
+
this.bytes = bytes;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Marker for a Token bare item (distinguishes `foo` from `"foo"`). */
|
|
64
|
+
export class Token {
|
|
65
|
+
/** @param {string} value */
|
|
66
|
+
constructor(value) {
|
|
67
|
+
this.value = value;
|
|
68
|
+
}
|
|
69
|
+
toString() {
|
|
70
|
+
return this.value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class Parser {
|
|
75
|
+
/** @param {string} input */
|
|
76
|
+
constructor(input) {
|
|
77
|
+
this.input = input;
|
|
78
|
+
this.pos = 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get done() {
|
|
82
|
+
return this.pos >= this.input.length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
peek() {
|
|
86
|
+
return this.input[this.pos];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** RFC 8941 §4.2: discard leading SP characters only. */
|
|
90
|
+
skipSp() {
|
|
91
|
+
while (this.input[this.pos] === ' ') this.pos += 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** RFC 8941 §4.2: OWS is SP / HTAB. */
|
|
95
|
+
skipOws() {
|
|
96
|
+
while (this.input[this.pos] === ' ' || this.input[this.pos] === '\t') this.pos += 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
expect(char) {
|
|
100
|
+
if (this.input[this.pos] !== char) {
|
|
101
|
+
fail(`expected "${char}" at offset ${this.pos}`);
|
|
102
|
+
}
|
|
103
|
+
this.pos += 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** RFC 8941 §4.2.3.3 — parse a parameter/dictionary key. */
|
|
107
|
+
parseKey() {
|
|
108
|
+
const char = this.peek();
|
|
109
|
+
if (char !== '*' && !isLcAlpha(char)) {
|
|
110
|
+
fail(`invalid key start "${char ?? '(end of input)'}" at offset ${this.pos}`);
|
|
111
|
+
}
|
|
112
|
+
const start = this.pos;
|
|
113
|
+
while (!this.done) {
|
|
114
|
+
const c = this.input[this.pos];
|
|
115
|
+
if (isLcAlpha(c) || isDigit(c) || c === '_' || c === '-' || c === '.' || c === '*') {
|
|
116
|
+
this.pos += 1;
|
|
117
|
+
} else {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return this.input.slice(start, this.pos);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** RFC 8941 §4.2.3.2 — parse zero or more `;key[=value]` parameters. */
|
|
125
|
+
parseParameters() {
|
|
126
|
+
const params = new Map();
|
|
127
|
+
while (!this.done && this.peek() === ';') {
|
|
128
|
+
this.pos += 1;
|
|
129
|
+
this.skipSp();
|
|
130
|
+
const key = this.parseKey();
|
|
131
|
+
let value = true;
|
|
132
|
+
if (!this.done && this.peek() === '=') {
|
|
133
|
+
this.pos += 1;
|
|
134
|
+
value = this.parseBareItem();
|
|
135
|
+
}
|
|
136
|
+
if (params.size >= MAX_MEMBERS) fail('too many parameters');
|
|
137
|
+
// RFC 8941: a duplicate key overwrites. We reject instead — a
|
|
138
|
+
// duplicate in a signature parameter set is never legitimate and
|
|
139
|
+
// "last one wins" is exactly the kind of ambiguity that lets a
|
|
140
|
+
// verifier and a signer disagree about what was signed.
|
|
141
|
+
if (params.has(key)) fail(`duplicate parameter "${key}"`);
|
|
142
|
+
params.set(key, value);
|
|
143
|
+
}
|
|
144
|
+
return params;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** RFC 8941 §4.2.3 — parse an Item (bare item plus parameters). */
|
|
148
|
+
parseItem() {
|
|
149
|
+
const value = this.parseBareItem();
|
|
150
|
+
const params = this.parseParameters();
|
|
151
|
+
return { value, params };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** RFC 8941 §4.2.1.1 — parse an Inner List `( item item ... )`. */
|
|
155
|
+
parseInnerList() {
|
|
156
|
+
this.expect('(');
|
|
157
|
+
const items = [];
|
|
158
|
+
for (;;) {
|
|
159
|
+
this.skipSp();
|
|
160
|
+
if (this.done) fail('unterminated inner list');
|
|
161
|
+
if (this.peek() === ')') {
|
|
162
|
+
this.pos += 1;
|
|
163
|
+
const params = this.parseParameters();
|
|
164
|
+
return { items, params };
|
|
165
|
+
}
|
|
166
|
+
if (items.length >= MAX_MEMBERS) fail('too many inner-list members');
|
|
167
|
+
items.push(this.parseItem());
|
|
168
|
+
const next = this.peek();
|
|
169
|
+
if (next !== ' ' && next !== ')') {
|
|
170
|
+
fail(`expected SP or ")" after inner-list member at offset ${this.pos}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** RFC 8941 §4.2.1 — Item or Inner List. */
|
|
176
|
+
parseItemOrInnerList() {
|
|
177
|
+
return this.peek() === '(' ? this.parseInnerList() : this.parseItem();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** RFC 8941 §4.2.3.1 — parse a bare Item. */
|
|
181
|
+
parseBareItem() {
|
|
182
|
+
if (this.done) fail('unexpected end of input while reading a bare item');
|
|
183
|
+
const char = this.peek();
|
|
184
|
+
if (char === '-' || isDigit(char)) return this.parseNumber();
|
|
185
|
+
if (char === '"') return this.parseString();
|
|
186
|
+
if (char === ':') return this.parseByteSequence();
|
|
187
|
+
if (char === '?') return this.parseBoolean();
|
|
188
|
+
if (char === '*' || isAlpha(char)) return this.parseToken();
|
|
189
|
+
fail(`invalid bare-item start "${char}" at offset ${this.pos}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** RFC 8941 §4.2.4 — Integer or Decimal. */
|
|
193
|
+
parseNumber() {
|
|
194
|
+
let sign = 1n;
|
|
195
|
+
if (this.peek() === '-') {
|
|
196
|
+
sign = -1n;
|
|
197
|
+
this.pos += 1;
|
|
198
|
+
}
|
|
199
|
+
if (this.done || !isDigit(this.peek())) fail('number must contain at least one digit');
|
|
200
|
+
const start = this.pos;
|
|
201
|
+
let isDecimal = false;
|
|
202
|
+
let integerDigits = 0;
|
|
203
|
+
let fractionDigits = 0;
|
|
204
|
+
while (!this.done) {
|
|
205
|
+
const c = this.input[this.pos];
|
|
206
|
+
if (isDigit(c)) {
|
|
207
|
+
this.pos += 1;
|
|
208
|
+
if (isDecimal) fractionDigits += 1;
|
|
209
|
+
else integerDigits += 1;
|
|
210
|
+
} else if (c === '.' && !isDecimal) {
|
|
211
|
+
if (integerDigits > 12) fail('decimal has more than 12 integer digits');
|
|
212
|
+
isDecimal = true;
|
|
213
|
+
this.pos += 1;
|
|
214
|
+
} else {
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const text = this.input.slice(start, this.pos);
|
|
219
|
+
if (isDecimal) {
|
|
220
|
+
if (fractionDigits === 0) fail('decimal must have digits after the "."');
|
|
221
|
+
if (fractionDigits > 3) fail('decimal has more than 3 fractional digits');
|
|
222
|
+
return Number(sign === -1n ? `-${text}` : text);
|
|
223
|
+
}
|
|
224
|
+
if (integerDigits > 15) fail('integer has more than 15 digits');
|
|
225
|
+
// Integers stay exact: RFC 8941 bounds them to +/-999,999,999,999,999
|
|
226
|
+
// which fits a double, but BigInt keeps `created`/`expires` handling
|
|
227
|
+
// honest and lets callers decide how to narrow.
|
|
228
|
+
return Number(sign * BigInt(text));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** RFC 8941 §4.2.5 — String (`"..."`, backslash escapes for `\` and `"`). */
|
|
232
|
+
parseString() {
|
|
233
|
+
this.expect('"');
|
|
234
|
+
let out = '';
|
|
235
|
+
while (!this.done) {
|
|
236
|
+
const char = this.input[this.pos];
|
|
237
|
+
this.pos += 1;
|
|
238
|
+
if (char === '\\') {
|
|
239
|
+
if (this.done) fail('string ends with a trailing backslash');
|
|
240
|
+
const escaped = this.input[this.pos];
|
|
241
|
+
this.pos += 1;
|
|
242
|
+
if (escaped !== '"' && escaped !== '\\') {
|
|
243
|
+
fail(`invalid string escape "\\${escaped}"`);
|
|
244
|
+
}
|
|
245
|
+
out += escaped;
|
|
246
|
+
} else if (char === '"') {
|
|
247
|
+
return out;
|
|
248
|
+
} else {
|
|
249
|
+
const code = char.charCodeAt(0);
|
|
250
|
+
// Only printable ASCII is legal inside a Structured Field string.
|
|
251
|
+
if (code < 0x20 || code >= 0x7f) fail('string contains a non-printable character');
|
|
252
|
+
out += char;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
fail('unterminated string');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** RFC 8941 §4.2.6 — Token. */
|
|
259
|
+
parseToken() {
|
|
260
|
+
const start = this.pos;
|
|
261
|
+
this.pos += 1; // first char already validated as ALPHA / "*"
|
|
262
|
+
while (!this.done) {
|
|
263
|
+
const c = this.input[this.pos];
|
|
264
|
+
if (isTokenChar(c)) this.pos += 1;
|
|
265
|
+
else break;
|
|
266
|
+
}
|
|
267
|
+
return new Token(this.input.slice(start, this.pos));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** RFC 8941 §4.2.7 — Byte Sequence (`:<base64>:`). */
|
|
271
|
+
parseByteSequence() {
|
|
272
|
+
this.expect(':');
|
|
273
|
+
const end = this.input.indexOf(':', this.pos);
|
|
274
|
+
if (end === -1) fail('unterminated byte sequence');
|
|
275
|
+
const encoded = this.input.slice(this.pos, end);
|
|
276
|
+
this.pos = end + 1;
|
|
277
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
|
|
278
|
+
fail('byte sequence is not valid base64');
|
|
279
|
+
}
|
|
280
|
+
// Reject non-canonical base64 rather than letting Node silently skip
|
|
281
|
+
// invalid characters: `Buffer.from` is famously lenient, which would let
|
|
282
|
+
// two different header strings decode to the same signature bytes.
|
|
283
|
+
const bytes = Buffer.from(encoded, 'base64');
|
|
284
|
+
if (bytes.toString('base64') !== encoded) {
|
|
285
|
+
fail('byte sequence is not canonically encoded base64');
|
|
286
|
+
}
|
|
287
|
+
return new ByteSequence(bytes);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** RFC 8941 §4.2.8 — Boolean (`?0` / `?1`). */
|
|
291
|
+
parseBoolean() {
|
|
292
|
+
this.expect('?');
|
|
293
|
+
const char = this.peek();
|
|
294
|
+
if (char === '0') {
|
|
295
|
+
this.pos += 1;
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
if (char === '1') {
|
|
299
|
+
this.pos += 1;
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
fail('boolean must be ?0 or ?1');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isDigit(char) {
|
|
307
|
+
return char >= '0' && char <= '9';
|
|
308
|
+
}
|
|
309
|
+
function isLcAlpha(char) {
|
|
310
|
+
return char >= 'a' && char <= 'z';
|
|
311
|
+
}
|
|
312
|
+
function isAlpha(char) {
|
|
313
|
+
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z');
|
|
314
|
+
}
|
|
315
|
+
function isTokenChar(char) {
|
|
316
|
+
if (char === undefined) return false;
|
|
317
|
+
if (isAlpha(char) || isDigit(char)) return true;
|
|
318
|
+
return "!#$%&'*+-.^_`|~:/".includes(char);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Parses a Structured Field Dictionary (RFC 8941 §4.2.2).
|
|
323
|
+
*
|
|
324
|
+
* Each member's `raw` property is the EXACT source substring of that
|
|
325
|
+
* member's value. RFC 9421 §2.5 requires the `@signature-params` line of the
|
|
326
|
+
* signature base to reproduce the signature parameters as they were
|
|
327
|
+
* received, so re-serializing from the parse tree would be wrong: a signer
|
|
328
|
+
* that emitted, say, `alg="ed25519"` before `keyid=...` must still verify.
|
|
329
|
+
* We parse to validate and to read parameters; we use `raw` for the base.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} input - raw header field value
|
|
332
|
+
* @returns {Map<string, { value: unknown, params: Map<string, unknown>, items?: Array<{value: unknown, params: Map<string, unknown>}>, isInnerList: boolean, raw: string }>}
|
|
333
|
+
*/
|
|
334
|
+
export function parseDictionary(input) {
|
|
335
|
+
if (typeof input !== 'string') fail('structured field value must be a string');
|
|
336
|
+
if (input.length > MAX_FIELD_LENGTH) fail('structured field value is too long');
|
|
337
|
+
|
|
338
|
+
const parser = new Parser(input);
|
|
339
|
+
const dictionary = new Map();
|
|
340
|
+
|
|
341
|
+
parser.skipSp();
|
|
342
|
+
while (!parser.done) {
|
|
343
|
+
const key = parser.parseKey();
|
|
344
|
+
let member;
|
|
345
|
+
const valueStart = parser.pos;
|
|
346
|
+
if (!parser.done && parser.peek() === '=') {
|
|
347
|
+
parser.pos += 1;
|
|
348
|
+
const parsed = parser.parseItemOrInnerList();
|
|
349
|
+
member = Array.isArray(parsed.items)
|
|
350
|
+
? { value: parsed.items, items: parsed.items, params: parsed.params, isInnerList: true }
|
|
351
|
+
: { value: parsed.value, params: parsed.params, isInnerList: false };
|
|
352
|
+
} else {
|
|
353
|
+
// Bare key => boolean true with parameters (RFC 8941 §4.2.2).
|
|
354
|
+
member = { value: true, params: parser.parseParameters(), isInnerList: false };
|
|
355
|
+
}
|
|
356
|
+
// `raw` intentionally starts AFTER the "=" so it is exactly the value.
|
|
357
|
+
member.raw = input.slice(
|
|
358
|
+
parser.input[valueStart] === '=' ? valueStart + 1 : valueStart,
|
|
359
|
+
parser.pos,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (dictionary.size >= MAX_MEMBERS) fail('too many dictionary members');
|
|
363
|
+
if (dictionary.has(key)) fail(`duplicate dictionary key "${key}"`);
|
|
364
|
+
dictionary.set(key, member);
|
|
365
|
+
|
|
366
|
+
parser.skipOws();
|
|
367
|
+
if (parser.done) return dictionary;
|
|
368
|
+
parser.expect(',');
|
|
369
|
+
parser.skipOws();
|
|
370
|
+
if (parser.done) fail('dictionary ends with a trailing comma');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return dictionary;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Parses a `Content-Digest` / `Content-Encoding`-style dictionary of byte
|
|
378
|
+
* sequences and returns a plain `algorithm -> Buffer` map.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} input
|
|
381
|
+
* @returns {Map<string, Buffer>}
|
|
382
|
+
*/
|
|
383
|
+
export function parseByteSequenceDictionary(input) {
|
|
384
|
+
const dictionary = parseDictionary(input);
|
|
385
|
+
const out = new Map();
|
|
386
|
+
for (const [key, member] of dictionary) {
|
|
387
|
+
if (member.isInnerList || !(member.value instanceof ByteSequence)) {
|
|
388
|
+
fail(`dictionary member "${key}" must be a byte sequence`);
|
|
389
|
+
}
|
|
390
|
+
out.set(key.toLowerCase(), member.value.bytes);
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Serializes bytes as an RFC 8941 Byte Sequence (`:base64:`). */
|
|
396
|
+
export function serializeByteSequence(bytes) {
|
|
397
|
+
return `:${Buffer.from(bytes).toString('base64')}:`;
|
|
398
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentsbloom/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
"types": "./index.d.ts",
|
|
11
11
|
"import": "./index.js",
|
|
12
12
|
"default": "./index.js"
|
|
13
|
+
},
|
|
14
|
+
"./protocol": {
|
|
15
|
+
"types": "./lib/protocol.d.ts",
|
|
16
|
+
"import": "./lib/protocol.js",
|
|
17
|
+
"default": "./lib/protocol.js"
|
|
13
18
|
}
|
|
14
19
|
},
|
|
15
20
|
"files": [
|
|
@@ -17,8 +22,14 @@
|
|
|
17
22
|
"index.d.ts",
|
|
18
23
|
"telemetry.js",
|
|
19
24
|
"lib/ap2.js",
|
|
25
|
+
"lib/http-signatures.js",
|
|
26
|
+
"lib/money.js",
|
|
20
27
|
"lib/outcomes.js",
|
|
28
|
+
"lib/protocol.js",
|
|
29
|
+
"lib/protocol.d.ts",
|
|
21
30
|
"lib/shared-store.js",
|
|
31
|
+
"lib/signature-base.js",
|
|
32
|
+
"lib/structured-fields.js",
|
|
22
33
|
"README.md",
|
|
23
34
|
"LICENSE",
|
|
24
35
|
"SECURITY.md"
|
|
@@ -37,12 +48,11 @@
|
|
|
37
48
|
"license": "MIT",
|
|
38
49
|
"repository": {
|
|
39
50
|
"type": "git",
|
|
40
|
-
"url": "https://github.com/
|
|
41
|
-
"directory": "packages/sdk"
|
|
51
|
+
"url": "https://github.com/AgentsBloom/sdk.git"
|
|
42
52
|
},
|
|
43
|
-
"homepage": "https://github.com/
|
|
53
|
+
"homepage": "https://github.com/AgentsBloom/sdk#readme",
|
|
44
54
|
"bugs": {
|
|
45
|
-
"url": "https://github.com/
|
|
55
|
+
"url": "https://github.com/AgentsBloom/sdk/issues"
|
|
46
56
|
},
|
|
47
57
|
"engines": {
|
|
48
58
|
"node": ">=20"
|
|
@@ -76,7 +86,7 @@
|
|
|
76
86
|
},
|
|
77
87
|
"scripts": {
|
|
78
88
|
"test": "node --test \"test/*.test.js\"",
|
|
79
|
-
"lint": "node
|
|
89
|
+
"lint": "node scripts/lint.mjs",
|
|
80
90
|
"check:package": "node scripts/check-package.mjs",
|
|
81
91
|
"verify:consumer": "node scripts/verify-consumer.mjs",
|
|
82
92
|
"release:check": "npm run lint && npm test && npm run check:package && npm run verify:consumer && npm pack --dry-run --ignore-scripts",
|
package/telemetry.js
CHANGED
|
@@ -1,57 +1,77 @@
|
|
|
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
|
-
* Dependency-audit note (2026-08): upgraded to the OpenTelemetry JS 2.x line
|
|
12
|
-
* (sdk-trace-*@2.10, resources@2.10, exporter-trace-otlp-http@0.221) which
|
|
13
|
-
* fixes GHSA-8988-4f7v-96qf and friends. The 2.x API differences this file
|
|
14
|
-
* absorbs:
|
|
15
|
-
* - `Resource` class is gone -> `resourceFromAttributes()`
|
|
16
|
-
* - `SemanticResourceAttributes.SERVICE_NAME` is gone ->
|
|
17
|
-
* `ATTR_SERVICE_NAME` from @opentelemetry/semantic-conventions
|
|
18
|
-
* - `provider.register()` no longer accepts a global logger/meter; plain
|
|
19
|
-
* registration is still supported.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
let optionalDependencyWarningLogged = false;
|
|
23
|
-
|
|
24
|
-
export async function initExporter({ otlpEndpoint, serviceName, samplingRatio, apiKey } = {}) {
|
|
25
|
-
try {
|
|
26
|
-
const { NodeTracerProvider } = await import('@opentelemetry/sdk-trace-node');
|
|
27
|
-
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-base');
|
|
28
|
-
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http');
|
|
29
|
-
const { resourceFromAttributes } = await import('@opentelemetry/resources');
|
|
30
|
-
let serviceNameKey;
|
|
31
|
-
try {
|
|
32
|
-
({ ATTR_SERVICE_NAME: serviceNameKey } = await import('@opentelemetry/semantic-conventions'));
|
|
33
|
-
} catch {
|
|
34
|
-
// Very new/old semantic-conventions layouts fall back to the literal.
|
|
35
|
-
serviceNameKey = 'service.name';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const exporter = new OTLPTraceExporter({
|
|
39
|
-
url: `${otlpEndpoint}/v1/traces`,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
* Dependency-audit note (2026-08): upgraded to the OpenTelemetry JS 2.x line
|
|
12
|
+
* (sdk-trace-*@2.10, resources@2.10, exporter-trace-otlp-http@0.221) which
|
|
13
|
+
* fixes GHSA-8988-4f7v-96qf and friends. The 2.x API differences this file
|
|
14
|
+
* absorbs:
|
|
15
|
+
* - `Resource` class is gone -> `resourceFromAttributes()`
|
|
16
|
+
* - `SemanticResourceAttributes.SERVICE_NAME` is gone ->
|
|
17
|
+
* `ATTR_SERVICE_NAME` from @opentelemetry/semantic-conventions
|
|
18
|
+
* - `provider.register()` no longer accepts a global logger/meter; plain
|
|
19
|
+
* registration is still supported.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
let optionalDependencyWarningLogged = false;
|
|
23
|
+
|
|
24
|
+
export async function initExporter({ otlpEndpoint, serviceName, samplingRatio, apiKey } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
const { NodeTracerProvider } = await import('@opentelemetry/sdk-trace-node');
|
|
27
|
+
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-base');
|
|
28
|
+
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http');
|
|
29
|
+
const { resourceFromAttributes } = await import('@opentelemetry/resources');
|
|
30
|
+
let serviceNameKey;
|
|
31
|
+
try {
|
|
32
|
+
({ ATTR_SERVICE_NAME: serviceNameKey } = await import('@opentelemetry/semantic-conventions'));
|
|
33
|
+
} catch {
|
|
34
|
+
// Very new/old semantic-conventions layouts fall back to the literal.
|
|
35
|
+
serviceNameKey = 'service.name';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const exporter = new OTLPTraceExporter({
|
|
39
|
+
url: `${otlpEndpoint}/v1/traces`,
|
|
40
|
+
// Only send an Authorization header when there is actually a credential;
|
|
41
|
+
// `Bearer ` with an empty value is a malformed header, not "no auth".
|
|
42
|
+
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// `samplingRatio` was accepted, logged as "sampling: N%", and then never
|
|
46
|
+
// used — every deployment exported 100% of spans regardless of the value,
|
|
47
|
+
// which is a cost and a data-volume surprise rather than a security bug,
|
|
48
|
+
// but it made the option a lie. Wire it up for real.
|
|
49
|
+
const ratio = Number.isFinite(samplingRatio) ? Math.min(1, Math.max(0, samplingRatio)) : 1;
|
|
50
|
+
let sampler;
|
|
51
|
+
if (ratio < 1) {
|
|
52
|
+
try {
|
|
53
|
+
const { TraceIdRatioBasedSampler, ParentBasedSampler } = await import('@opentelemetry/sdk-trace-base');
|
|
54
|
+
// Parent-based so a sampled distributed trace stays intact end to end.
|
|
55
|
+
sampler = new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(ratio) });
|
|
56
|
+
} catch {
|
|
57
|
+
sampler = undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2.x: span processors are passed at construction via `spanProcessors`
|
|
62
|
+
// (addSpanProcessor was removed from the public provider API).
|
|
63
|
+
const provider = new NodeTracerProvider({
|
|
64
|
+
resource: resourceFromAttributes({ [serviceNameKey || 'service.name']: serviceName }),
|
|
65
|
+
spanProcessors: [new BatchSpanProcessor(exporter)],
|
|
66
|
+
...(sampler ? { sampler } : {}),
|
|
67
|
+
});
|
|
68
|
+
provider.register();
|
|
69
|
+
return { provider, exporter };
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (!optionalDependencyWarningLogged) {
|
|
72
|
+
optionalDependencyWarningLogged = true;
|
|
73
|
+
console.warn(`🌸 AgentsBloom: OTel SDK packages unavailable, continuing without export. (${err?.message || 'unknown reason'})`);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|