@agentsbloom/sdk 0.2.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/LICENSE +21 -21
- package/README.md +184 -254
- package/SECURITY.md +24 -19
- package/index.d.ts +355 -124
- package/index.js +1582 -342
- package/lib/ap2.js +1017 -422
- package/lib/http-signatures.js +874 -0
- package/lib/money.js +283 -0
- package/lib/outcomes.js +108 -0
- package/lib/protocol.d.ts +229 -0
- package/lib/protocol.js +85 -0
- package/lib/shared-store.js +298 -0
- package/lib/signature-base.js +436 -0
- package/lib/structured-fields.js +398 -0
- package/package.json +95 -81
- package/telemetry.js +77 -50
- package/assets/logo-mark.svg +0 -25
package/lib/money.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact money handling for the AgentsBloom SDK.
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists
|
|
5
|
+
* -------------------
|
|
6
|
+
* Every monetary value in the previous implementation was an IEEE-754
|
|
7
|
+
* double, and the conversion to minor units was `Math.round(Number(x) * 100)`.
|
|
8
|
+
* That is wrong in a way that costs real money:
|
|
9
|
+
*
|
|
10
|
+
* 1.005 * 100 === 100.49999999999999 -> Math.round -> 100 ($1.00)
|
|
11
|
+
* 1.015 * 100 === 101.49999999999999 -> Math.round -> 101 ($1.01)
|
|
12
|
+
* 2.675 * 100 === 267.49999999999994 -> Math.round -> 267 ($2.67)
|
|
13
|
+
*
|
|
14
|
+
* A price that a human wrote as $1.005 is silently charged as $1.00, and the
|
|
15
|
+
* same wrong number is what gets hashed into a Cart Mandate. Budget
|
|
16
|
+
* comparisons had the same problem from the other direction: `orderTotal >
|
|
17
|
+
* maxBudget` on doubles can accept a total that is a fraction of a cent over
|
|
18
|
+
* the authorized cap, and accumulating `sum += q * u` across a large cart can
|
|
19
|
+
* exceed 2^53 and lose precision entirely.
|
|
20
|
+
*
|
|
21
|
+
* The fix is to never multiply a float. Values arrive as decimal strings or
|
|
22
|
+
* numbers, are converted to their exact shortest decimal representation, and
|
|
23
|
+
* are then shifted by the required number of places using integer (BigInt)
|
|
24
|
+
* arithmetic with explicit half-up rounding.
|
|
25
|
+
*
|
|
26
|
+
* Everything here is total: nothing throws for bad input, it returns `null`
|
|
27
|
+
* so callers make a deliberate fail-closed decision.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* ISO 4217 currencies whose minor unit is NOT two decimal places. Anything
|
|
32
|
+
* absent from this table is treated as exponent 2, which is correct for the
|
|
33
|
+
* overwhelming majority of codes.
|
|
34
|
+
*/
|
|
35
|
+
const CURRENCY_EXPONENTS = new Map(Object.entries({
|
|
36
|
+
// Zero-decimal currencies.
|
|
37
|
+
BIF: 0, CLP: 0, DJF: 0, GNF: 0, ISK: 0, JPY: 0, KMF: 0, KRW: 0, PYG: 0,
|
|
38
|
+
RWF: 0, UGX: 0, UYI: 0, VND: 0, VUV: 0, XAF: 0, XOF: 0, XPF: 0,
|
|
39
|
+
// Three-decimal currencies.
|
|
40
|
+
BHD: 3, IQD: 3, JOD: 3, KWD: 3, LYD: 3, OMR: 3, TND: 3,
|
|
41
|
+
// Four-decimal currencies.
|
|
42
|
+
CLF: 4, UYW: 4,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Internal comparison scale. Six places is more than any real currency needs
|
|
47
|
+
* (the deepest ISO 4217 minor unit is four), so scaling both sides of a
|
|
48
|
+
* budget comparison to micros is exact for every currency without needing to
|
|
49
|
+
* know which currency is in play.
|
|
50
|
+
*/
|
|
51
|
+
export const COMPARISON_SCALE = 6;
|
|
52
|
+
|
|
53
|
+
/** Longest accepted numeric input; guards BigInt work on hostile input. */
|
|
54
|
+
const MAX_NUMERIC_LENGTH = 32;
|
|
55
|
+
|
|
56
|
+
/** Hard ceiling on a single monetary amount (1 trillion major units). */
|
|
57
|
+
const MAX_MAJOR_UNITS = 1_000_000_000_000n;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the number of minor-unit decimal places for a currency code.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} currency
|
|
63
|
+
* @returns {number}
|
|
64
|
+
*/
|
|
65
|
+
export function currencyExponent(currency) {
|
|
66
|
+
const code = normalizeCurrencyCode(currency);
|
|
67
|
+
if (!code) return 2;
|
|
68
|
+
return CURRENCY_EXPONENTS.has(code) ? CURRENCY_EXPONENTS.get(code) : 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalizes a currency code to uppercase, or returns null when it is not a
|
|
73
|
+
* syntactically valid ISO 4217 alphabetic code.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} currency
|
|
76
|
+
* @returns {string|null}
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeCurrencyCode(currency) {
|
|
79
|
+
if (typeof currency !== 'string') return null;
|
|
80
|
+
const code = currency.trim().toUpperCase();
|
|
81
|
+
return /^[A-Z]{3}$/.test(code) ? code : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Converts a number or numeric string to its exact decimal representation,
|
|
86
|
+
* expanding exponent notation. Returns null for anything non-finite or
|
|
87
|
+
* malformed.
|
|
88
|
+
*
|
|
89
|
+
* `String(n)` gives JavaScript's shortest round-trip decimal, which is the
|
|
90
|
+
* value the developer actually wrote for any literal they could have typed —
|
|
91
|
+
* so this is a faithful, not lossy, starting point.
|
|
92
|
+
*
|
|
93
|
+
* @param {unknown} value
|
|
94
|
+
* @returns {string|null} e.g. '-12.345'
|
|
95
|
+
*/
|
|
96
|
+
export function toDecimalString(value) {
|
|
97
|
+
let text;
|
|
98
|
+
if (typeof value === 'number') {
|
|
99
|
+
if (!Number.isFinite(value)) return null;
|
|
100
|
+
text = String(value);
|
|
101
|
+
} else if (typeof value === 'bigint') {
|
|
102
|
+
text = value.toString();
|
|
103
|
+
} else if (typeof value === 'string') {
|
|
104
|
+
text = value.trim();
|
|
105
|
+
} else {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (text === '' || text.length > MAX_NUMERIC_LENGTH) return null;
|
|
110
|
+
|
|
111
|
+
const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d{1,3}))?$/.exec(text);
|
|
112
|
+
if (!match) return null;
|
|
113
|
+
const [, rawSign, rawInt = '', rawFrac = '', rawExp] = match;
|
|
114
|
+
if (rawInt === '' && rawFrac === '') return null;
|
|
115
|
+
|
|
116
|
+
const sign = rawSign === '-' ? '-' : '';
|
|
117
|
+
let digits = `${rawInt}${rawFrac}`;
|
|
118
|
+
// Decimal point position measured from the left of `digits`.
|
|
119
|
+
let pointIndex = rawInt.length;
|
|
120
|
+
|
|
121
|
+
if (rawExp !== undefined) {
|
|
122
|
+
pointIndex += Number(rawExp);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (pointIndex <= 0) {
|
|
126
|
+
digits = '0'.repeat(1 - pointIndex) + digits;
|
|
127
|
+
pointIndex = 1;
|
|
128
|
+
}
|
|
129
|
+
if (pointIndex > digits.length) {
|
|
130
|
+
digits += '0'.repeat(pointIndex - digits.length);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const intPart = digits.slice(0, pointIndex).replace(/^0+(?=\d)/, '');
|
|
134
|
+
const fracPart = digits.slice(pointIndex).replace(/0+$/, '');
|
|
135
|
+
return fracPart ? `${sign}${intPart}.${fracPart}` : `${sign}${intPart}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Converts a monetary value to an exact integer scaled by 10^scale, rounding
|
|
140
|
+
* half away from zero. Returns null when the value is not a finite number,
|
|
141
|
+
* is malformed, or exceeds the sanity ceiling.
|
|
142
|
+
*
|
|
143
|
+
* @param {unknown} value
|
|
144
|
+
* @param {number} scale - number of decimal places to keep
|
|
145
|
+
* @returns {bigint|null}
|
|
146
|
+
*/
|
|
147
|
+
export function toScaledBigInt(value, scale) {
|
|
148
|
+
if (!Number.isInteger(scale) || scale < 0 || scale > 12) return null;
|
|
149
|
+
const decimal = toDecimalString(value);
|
|
150
|
+
if (decimal === null) return null;
|
|
151
|
+
|
|
152
|
+
const negative = decimal.startsWith('-');
|
|
153
|
+
const unsigned = negative ? decimal.slice(1) : decimal;
|
|
154
|
+
const [intPart, fracPart = ''] = unsigned.split('.');
|
|
155
|
+
|
|
156
|
+
if (intPart.length > 16) return null;
|
|
157
|
+
|
|
158
|
+
const kept = fracPart.slice(0, scale).padEnd(scale, '0');
|
|
159
|
+
const dropped = fracPart.slice(scale);
|
|
160
|
+
|
|
161
|
+
let scaled = BigInt(`${intPart}${kept}` || '0');
|
|
162
|
+
// Half-up rounding on the discarded remainder: '5...' rounds away from zero.
|
|
163
|
+
if (dropped.length > 0 && dropped.charCodeAt(0) >= 0x35) {
|
|
164
|
+
scaled += 1n;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (scaled > MAX_MAJOR_UNITS * 10n ** BigInt(scale)) return null;
|
|
168
|
+
return negative ? -scaled : scaled;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Converts a monetary value to the exact minor units of a currency
|
|
173
|
+
* (cents for USD, yen for JPY, fils for KWD).
|
|
174
|
+
*
|
|
175
|
+
* @param {unknown} value
|
|
176
|
+
* @param {unknown} currency
|
|
177
|
+
* @returns {bigint|null}
|
|
178
|
+
*/
|
|
179
|
+
export function toMinorUnits(value, currency) {
|
|
180
|
+
return toScaledBigInt(value, currencyExponent(currency));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Converts a monetary value to hundredths of the major unit.
|
|
185
|
+
*
|
|
186
|
+
* Used by `canonicalCartHash`, whose wire format (`ac1_`, field `u`) is
|
|
187
|
+
* defined as hundredths regardless of the currency's real minor unit. That
|
|
188
|
+
* definition is fixed for cross-SDK byte compatibility — the agent SDK, the
|
|
189
|
+
* PHP core, and every merchant must agree — so this deliberately does NOT
|
|
190
|
+
* consult `currencyExponent`. Only the ROUNDING is corrected relative to the
|
|
191
|
+
* old `Math.round(x * 100)`.
|
|
192
|
+
*
|
|
193
|
+
* @param {unknown} value
|
|
194
|
+
* @returns {number|null} an exact integer, or null when unrepresentable
|
|
195
|
+
*/
|
|
196
|
+
export function toHundredths(value) {
|
|
197
|
+
const scaled = toScaledBigInt(value, 2);
|
|
198
|
+
if (scaled === null) return null;
|
|
199
|
+
if (scaled > BigInt(Number.MAX_SAFE_INTEGER) || scaled < -BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return Number(scaled);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Compares two monetary amounts exactly.
|
|
207
|
+
*
|
|
208
|
+
* Both sides are converted to micros with half-up rounding, so this is exact
|
|
209
|
+
* for every ISO 4217 currency and immune to the float artifacts that made
|
|
210
|
+
* `Number(orderTotal) > Number(maxBudget)` unreliable.
|
|
211
|
+
*
|
|
212
|
+
* @param {unknown} left
|
|
213
|
+
* @param {unknown} right
|
|
214
|
+
* @returns {-1|0|1|null} null when either side is not a valid amount
|
|
215
|
+
*/
|
|
216
|
+
export function compareAmounts(left, right) {
|
|
217
|
+
const a = toScaledBigInt(left, COMPARISON_SCALE);
|
|
218
|
+
const b = toScaledBigInt(right, COMPARISON_SCALE);
|
|
219
|
+
if (a === null || b === null) return null;
|
|
220
|
+
if (a < b) return -1;
|
|
221
|
+
if (a > b) return 1;
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Strict parse of a client- or handler-supplied monetary amount.
|
|
227
|
+
*
|
|
228
|
+
* This is the gate that closes the budget-enforcement bypass: the old code
|
|
229
|
+
* did `Number(orderTotal) > Number(maxBudget)`, and `Number('abc')` is `NaN`,
|
|
230
|
+
* for which every comparison is false. A malformed total therefore passed the
|
|
231
|
+
* budget check instead of failing it. Callers must treat `null` as "reject".
|
|
232
|
+
*
|
|
233
|
+
* @param {unknown} value
|
|
234
|
+
* @param {object} [options]
|
|
235
|
+
* @param {boolean} [options.allowZero=true]
|
|
236
|
+
* @param {boolean} [options.allowNegative=false]
|
|
237
|
+
* @returns {{ decimal: string, micros: bigint }|null}
|
|
238
|
+
*/
|
|
239
|
+
export function parseAmount(value, options = {}) {
|
|
240
|
+
const { allowZero = true, allowNegative = false } = options;
|
|
241
|
+
// Reject booleans and empty-ish values that Number() would happily coerce.
|
|
242
|
+
if (typeof value === 'boolean' || value === null || value === undefined) return null;
|
|
243
|
+
if (typeof value === 'string' && value.trim() === '') return null;
|
|
244
|
+
if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'bigint') return null;
|
|
245
|
+
|
|
246
|
+
const decimal = toDecimalString(value);
|
|
247
|
+
if (decimal === null) return null;
|
|
248
|
+
const micros = toScaledBigInt(decimal, COMPARISON_SCALE);
|
|
249
|
+
if (micros === null) return null;
|
|
250
|
+
if (!allowNegative && micros < 0n) return null;
|
|
251
|
+
if (!allowZero && micros === 0n) return null;
|
|
252
|
+
return { decimal, micros };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Formats a scaled integer back to a decimal string.
|
|
257
|
+
*
|
|
258
|
+
* @param {bigint} scaled
|
|
259
|
+
* @param {number} scale
|
|
260
|
+
* @returns {string}
|
|
261
|
+
*/
|
|
262
|
+
export function formatScaled(scaled, scale) {
|
|
263
|
+
const negative = scaled < 0n;
|
|
264
|
+
const digits = (negative ? -scaled : scaled).toString().padStart(scale + 1, '0');
|
|
265
|
+
const intPart = digits.slice(0, digits.length - scale);
|
|
266
|
+
const fracPart = scale > 0 ? digits.slice(digits.length - scale).replace(/0+$/, '') : '';
|
|
267
|
+
return `${negative ? '-' : ''}${intPart}${fracPart ? `.${fracPart}` : ''}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Sums a list of `{ quantity, unitHundredths }` line items exactly.
|
|
272
|
+
*
|
|
273
|
+
* @param {Array<{ quantity: number, unitHundredths: number }>} items
|
|
274
|
+
* @returns {number|null} total in hundredths, or null on overflow
|
|
275
|
+
*/
|
|
276
|
+
export function sumLineItems(items) {
|
|
277
|
+
let total = 0n;
|
|
278
|
+
for (const item of items) {
|
|
279
|
+
total += BigInt(item.quantity) * BigInt(item.unitHundredths);
|
|
280
|
+
if (total > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
|
281
|
+
}
|
|
282
|
+
return Number(total);
|
|
283
|
+
}
|
package/lib/outcomes.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payment-outcome reporting (migration 011 / POST /api/outcomes).
|
|
3
|
+
*
|
|
4
|
+
* AgentsBloom is the middleman: the merchant's gateway keys stay on THEIR
|
|
5
|
+
* servers, inside THEIR webhook handlers. That also means payment outcomes
|
|
6
|
+
* (paid / declined / chargeback) happen where we cannot see them - so this
|
|
7
|
+
* module lets the merchant's existing webhook handler report the outcome
|
|
8
|
+
* with one line. Those reports are what turn risk-core's decline-rate rules
|
|
9
|
+
* from theory into data, and what powers the dashboard's outcome analytics.
|
|
10
|
+
*
|
|
11
|
+
* Keys never leave the merchant: they only ever send order references,
|
|
12
|
+
* statuses, and optional amounts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_STATUSES = ['paid', 'declined', 'refunded', 'chargeback', 'disputed', 'canceled'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maps a Stripe webhook event to an outcome report body. Returns null for
|
|
19
|
+
* events that carry no outcome signal, so handlers can do:
|
|
20
|
+
*
|
|
21
|
+
* const report = stripeEventToOutcome(event);
|
|
22
|
+
* if (report) await reportPaymentOutcome(report);
|
|
23
|
+
*/
|
|
24
|
+
export function stripeEventToOutcome(event) {
|
|
25
|
+
const type = event?.type;
|
|
26
|
+
const wrap = (status, extra = {}) => {
|
|
27
|
+
const obj = event?.data?.object ?? {};
|
|
28
|
+
return {
|
|
29
|
+
orderRef:
|
|
30
|
+
obj.metadata?.orderRef ??
|
|
31
|
+
obj.client_reference_id ??
|
|
32
|
+
obj.id,
|
|
33
|
+
gateway: 'stripe',
|
|
34
|
+
status,
|
|
35
|
+
amount: typeof obj.amount_total === 'number'
|
|
36
|
+
? obj.amount_total / 100
|
|
37
|
+
: typeof obj.amount === 'number'
|
|
38
|
+
? obj.amount / 100
|
|
39
|
+
: undefined,
|
|
40
|
+
currency: typeof obj.currency === 'string' ? obj.currency : undefined,
|
|
41
|
+
reason: extra.reason ?? obj.failure_message ?? null,
|
|
42
|
+
occurredAt: event?.created ? new Date(event.created * 1000).toISOString() : undefined,
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
switch (type) {
|
|
46
|
+
case 'checkout.session.completed':
|
|
47
|
+
return wrap('paid');
|
|
48
|
+
case 'invoice.payment_failed':
|
|
49
|
+
case 'charge.failed': {
|
|
50
|
+
const rep = wrap('declined');
|
|
51
|
+
rep.reason = event?.data?.object?.failure_message ?? event?.data?.object?.failure_code ?? null;
|
|
52
|
+
return rep;
|
|
53
|
+
}
|
|
54
|
+
case 'charge.refunded':
|
|
55
|
+
return wrap('refunded');
|
|
56
|
+
case 'charge.dispute.created':
|
|
57
|
+
return wrap('chargeback');
|
|
58
|
+
default:
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Creates a reporter bound to your AgentsBloom account key.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} options
|
|
67
|
+
* @param {string} options.apiKey - partner API key (ag_live_... / ag_test_...)
|
|
68
|
+
* @param {string} [options.collectorUrl] - backend base URL
|
|
69
|
+
* (default https://api.agentsbloom.com)
|
|
70
|
+
* @param {(url:string, init:object)=>Promise<Response>} [options.fetchImpl]
|
|
71
|
+
*/
|
|
72
|
+
export function createOutcomeReporter({ apiKey, collectorUrl, fetchImpl } = {}) {
|
|
73
|
+
const base = String(collectorUrl || process.env.AGENTSBLOOM_API_URL || 'https://api.agentsbloom.com').replace(/\/$/, '');
|
|
74
|
+
const doFetch = fetchImpl ?? fetch;
|
|
75
|
+
|
|
76
|
+
async function report(body) {
|
|
77
|
+
if (!apiKey) throw new Error('createOutcomeReporter requires your AgentsBloom apiKey');
|
|
78
|
+
if (!body || !body.orderRef || !DEFAULT_STATUSES.includes(body.status)) {
|
|
79
|
+
throw new Error('reportPaymentOutcome requires orderRef and status (paid|declined|refunded|chargeback|disputed|canceled)');
|
|
80
|
+
}
|
|
81
|
+
const res = await doFetch(`${base}/api/outcomes`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
'content-type': 'application/json',
|
|
85
|
+
authorization: `Bearer ${apiKey}`,
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
gateway: 'stripe',
|
|
89
|
+
...body,
|
|
90
|
+
}),
|
|
91
|
+
signal: AbortSignal.timeout(10_000),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const text = await res.text().catch(() => '');
|
|
95
|
+
throw new Error(`outcome report failed: HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
96
|
+
}
|
|
97
|
+
return res.json();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Reports a raw Stripe webhook event (no-op for non-outcome events). */
|
|
101
|
+
async function captureStripeOutcome(stripeEvent) {
|
|
102
|
+
const body = stripeEventToOutcome(stripeEvent);
|
|
103
|
+
if (!body) return { skipped: true };
|
|
104
|
+
return report(body);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { report, captureStripeOutcome };
|
|
108
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@agentsbloom/sdk/protocol` — the dependency-free
|
|
3
|
+
* wire-protocol primitives shared by every Node consumer (this SDK, the hosted
|
|
4
|
+
* gateway, and any language port checking itself against the reference).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// RFC 8941 structured fields
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export class StructuredFieldError extends Error {
|
|
12
|
+
name: 'StructuredFieldError';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A Byte Sequence bare item (`:b64:`). */
|
|
16
|
+
export class ByteSequence {
|
|
17
|
+
constructor(bytes: Buffer);
|
|
18
|
+
bytes: Buffer;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A Token bare item (distinguishes `foo` from `"foo"`). */
|
|
22
|
+
export class Token {
|
|
23
|
+
constructor(value: string);
|
|
24
|
+
value: string;
|
|
25
|
+
toString(): string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type BareItem = string | number | boolean | ByteSequence | Token;
|
|
29
|
+
export type Parameters = Map<string, BareItem>;
|
|
30
|
+
|
|
31
|
+
export interface StructuredItem {
|
|
32
|
+
value: BareItem;
|
|
33
|
+
params: Parameters;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DictionaryMember {
|
|
37
|
+
value: BareItem | StructuredItem[];
|
|
38
|
+
items?: StructuredItem[];
|
|
39
|
+
params: Parameters;
|
|
40
|
+
isInnerList: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The EXACT source substring of this member's value. RFC 9421 §2.5 requires
|
|
43
|
+
* `@signature-params` to reproduce the parameters as RECEIVED, so
|
|
44
|
+
* re-serializing from the parse tree would reject signers whose parameter
|
|
45
|
+
* ordering differs from ours.
|
|
46
|
+
*/
|
|
47
|
+
raw: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseDictionary(input: string): Map<string, DictionaryMember>;
|
|
51
|
+
export function parseByteSequenceDictionary(input: string): Map<string, Buffer>;
|
|
52
|
+
export function serializeByteSequence(bytes: Buffer | Uint8Array): string;
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// RFC 9421 signature base
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/** RFC 9421-conformant, host-bound profile. */
|
|
59
|
+
export const PROFILE_STRICT: 'ab2';
|
|
60
|
+
/** Byte-exact reproduction of the pre-0.6 base. Cannot bind the authority. */
|
|
61
|
+
export const PROFILE_LEGACY: 'ab1';
|
|
62
|
+
export type SignatureProfile = 'ab2' | 'ab1';
|
|
63
|
+
|
|
64
|
+
export class SignatureBaseError extends Error {
|
|
65
|
+
name: 'SignatureBaseError';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A runtime-neutral view of the request being verified. */
|
|
69
|
+
export interface SignatureRequestContext {
|
|
70
|
+
method: string;
|
|
71
|
+
/** 'http:' or 'https:' */
|
|
72
|
+
scheme: string;
|
|
73
|
+
/** Raw Host / :authority value. */
|
|
74
|
+
authority: string;
|
|
75
|
+
/** Absolute path, no query. */
|
|
76
|
+
path: string;
|
|
77
|
+
/** Query string WITHOUT the leading '?'. */
|
|
78
|
+
query: string;
|
|
79
|
+
header(name: string): string | string[] | undefined;
|
|
80
|
+
/** `originalUrl` equivalent; used by the `ab1` profile only. */
|
|
81
|
+
legacyTarget?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface CoveredComponent {
|
|
85
|
+
name: string;
|
|
86
|
+
params: Parameters;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildSignatureBase(options: {
|
|
90
|
+
components: CoveredComponent[];
|
|
91
|
+
/** The EXACT `Signature-Input` dictionary-member value as received. */
|
|
92
|
+
signatureParamsRaw: string;
|
|
93
|
+
request: SignatureRequestContext;
|
|
94
|
+
profile?: SignatureProfile;
|
|
95
|
+
}): string;
|
|
96
|
+
|
|
97
|
+
export function requestContextFromExpress(
|
|
98
|
+
req: unknown,
|
|
99
|
+
options?: { forwardedProto?: string },
|
|
100
|
+
): SignatureRequestContext;
|
|
101
|
+
|
|
102
|
+
/** RFC 9421 §2.2.3: lowercase host, scheme's default port removed. */
|
|
103
|
+
export function normalizeAuthority(rawAuthority: string, scheme: string): string;
|
|
104
|
+
|
|
105
|
+
export function contentDigestHeader(
|
|
106
|
+
bodyBytes: Buffer,
|
|
107
|
+
algorithm?: 'sha-256' | 'sha-512',
|
|
108
|
+
): string;
|
|
109
|
+
|
|
110
|
+
export function verifyContentDigest(
|
|
111
|
+
headerValue: string | string[] | undefined,
|
|
112
|
+
bodyBytes: Buffer,
|
|
113
|
+
): { ok: true } | { ok: false; reason: string };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolves the exact body bytes a digest must cover. Throws when a signed
|
|
117
|
+
* request has a body but `req.rawBody` was not captured — hashing a
|
|
118
|
+
* re-serialization proves nothing about what the client sent.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveBodyBytes(
|
|
121
|
+
req: unknown,
|
|
122
|
+
options?: { allowReserializedBody?: boolean },
|
|
123
|
+
): Buffer;
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// RFC 9421 verification (JWKS-backed)
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export const SUPPORTED_SIGNATURE_ALGORITHMS: readonly string[];
|
|
130
|
+
export const STRICT_REQUIRED_COMPONENTS: readonly string[];
|
|
131
|
+
export const LEGACY_REQUIRED_COMPONENTS: readonly string[];
|
|
132
|
+
|
|
133
|
+
export interface JwksCache {
|
|
134
|
+
get(cacheKey: string): unknown;
|
|
135
|
+
put(cacheKey: string, entry: unknown): void;
|
|
136
|
+
clear(): void;
|
|
137
|
+
readonly size: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createJwksCache(options?: { maxEntries?: number }): JwksCache;
|
|
141
|
+
|
|
142
|
+
/** True for loopback, RFC 1918, link-local, CGNAT and reserved addresses. */
|
|
143
|
+
export function isBlockedSsrfHostname(hostname: string): boolean;
|
|
144
|
+
|
|
145
|
+
export type HttpSignatureVerification =
|
|
146
|
+
| { ok: true; keyid: string; identity: string; profile: SignatureProfile }
|
|
147
|
+
| { ok: false; status: number; code: string; publicMessage: string; detail: string };
|
|
148
|
+
|
|
149
|
+
export function verifyHttpMessageSignature(options: {
|
|
150
|
+
req: unknown;
|
|
151
|
+
requestContext: SignatureRequestContext;
|
|
152
|
+
signatureHeader: string;
|
|
153
|
+
signatureInputHeader: string;
|
|
154
|
+
jwks?: { keys: Array<Record<string, unknown>> } | null;
|
|
155
|
+
jwksUrl?: string | null;
|
|
156
|
+
jwksCache: JwksCache;
|
|
157
|
+
nonceCache: { claim(key: string, expiresAtMs: number): boolean | Promise<boolean> };
|
|
158
|
+
nonceNamespace: string;
|
|
159
|
+
maxAgeMs: number;
|
|
160
|
+
clockSkewMs: number;
|
|
161
|
+
requireAuthority?: boolean;
|
|
162
|
+
expectedAuthorities?: Set<string> | null;
|
|
163
|
+
acceptLegacyProfile?: boolean;
|
|
164
|
+
allowReserializedBody?: boolean;
|
|
165
|
+
fetchImpl?: typeof fetch;
|
|
166
|
+
nowMs?: number;
|
|
167
|
+
}): Promise<HttpSignatureVerification>;
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Exact-decimal money
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
export const COMPARISON_SCALE: 6;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Strict parse of a monetary amount. Returns null for everything `Number()`
|
|
177
|
+
* would silently coerce (`'abc'` -> NaN, `''` -> 0, booleans, arrays) — the
|
|
178
|
+
* exact inputs that let a malformed order total PASS a budget comparison.
|
|
179
|
+
*/
|
|
180
|
+
export function parseAmount(
|
|
181
|
+
value: unknown,
|
|
182
|
+
options?: { allowZero?: boolean; allowNegative?: boolean },
|
|
183
|
+
): { decimal: string; micros: bigint } | null;
|
|
184
|
+
|
|
185
|
+
/** Exact comparison. Returns null when either side is not a valid amount. */
|
|
186
|
+
export function compareAmounts(left: unknown, right: unknown): -1 | 0 | 1 | null;
|
|
187
|
+
|
|
188
|
+
export function toDecimalString(value: unknown): string | null;
|
|
189
|
+
export function toScaledBigInt(value: unknown, scale: number): bigint | null;
|
|
190
|
+
export function toMinorUnits(value: unknown, currency: unknown): bigint | null;
|
|
191
|
+
/** Hundredths of the major unit — the cart-hash wire format. */
|
|
192
|
+
export function toHundredths(value: unknown): number | null;
|
|
193
|
+
export function formatScaled(scaled: bigint, scale: number): string;
|
|
194
|
+
export function sumLineItems(
|
|
195
|
+
items: Array<{ quantity: number; unitHundredths: number }>,
|
|
196
|
+
): number | null;
|
|
197
|
+
export function currencyExponent(currency: unknown): number;
|
|
198
|
+
export function normalizeCurrencyCode(currency: unknown): string | null;
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Replay cache
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
export interface ReplayCache {
|
|
205
|
+
/**
|
|
206
|
+
* Atomic check-and-set. True when this caller won the key (first use), false
|
|
207
|
+
* on replay OR when a configured shared store is unreachable and
|
|
208
|
+
* `AGENTSBLOOM_REPLAY_FAIL_OPEN` is not set.
|
|
209
|
+
*/
|
|
210
|
+
claim(key: string, expiresAtMs: number): boolean | Promise<boolean>;
|
|
211
|
+
/** Advisory only; never gate a decision on it. */
|
|
212
|
+
has(key: string): boolean | Promise<boolean>;
|
|
213
|
+
set(key: string, expiresAtMs: number): void;
|
|
214
|
+
clear(): void;
|
|
215
|
+
stats(): {
|
|
216
|
+
localSize: number;
|
|
217
|
+
maxLocalSize: number;
|
|
218
|
+
capacityEvictions: number;
|
|
219
|
+
sharedFailures: number;
|
|
220
|
+
shared: boolean;
|
|
221
|
+
failOpen: boolean;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function createReplayCache(namespace: string, maxLocalSize?: number): ReplayCache;
|
|
226
|
+
export function isSharedStoreConfigured(): boolean;
|
|
227
|
+
export function isReplayFailOpenEnabled(): boolean;
|
|
228
|
+
export function sharedStoreOutageCount(): number;
|
|
229
|
+
export function resetSharedStoreDiagnostics(): void;
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@agentsbloom/sdk/protocol` — the wire-protocol primitives, published as a
|
|
3
|
+
* dependency-free subpath.
|
|
4
|
+
*
|
|
5
|
+
* Why this entry point exists
|
|
6
|
+
* --------------------------
|
|
7
|
+
*
|
|
8
|
+
* The RFC 9421 signature base, the RFC 8941 parser and the exact-decimal money
|
|
9
|
+
* helpers had been reimplemented per consumer: once in this SDK, once in
|
|
10
|
+
* `@agentsbloom/next`, once in `@agentsbloom/agent`, and once in the hosted
|
|
11
|
+
* gateway (`apps/backend-api/lib/rfc9421.js`). Every copy drifted, and the
|
|
12
|
+
* drift was not cosmetic — the gateway lowercased `@method`, folded the query
|
|
13
|
+
* into `@path`, and never covered `@authority`, so an agent signing the
|
|
14
|
+
* conformant base could not authenticate against it at all.
|
|
15
|
+
*
|
|
16
|
+
* Any Node consumer can now import the ONE implementation:
|
|
17
|
+
*
|
|
18
|
+
* import { buildSignatureBase, parseDictionary } from '@agentsbloom/sdk/protocol';
|
|
19
|
+
*
|
|
20
|
+
* The gateway deliberately avoided importing `@agentsbloom/sdk` because the
|
|
21
|
+
* main entry point pulls in OpenTelemetry and the MCP SDK. That concern does
|
|
22
|
+
* not apply here: every module re-exported below imports nothing but
|
|
23
|
+
* `node:crypto`, so this subpath adds no transitive dependencies and no
|
|
24
|
+
* startup cost.
|
|
25
|
+
*
|
|
26
|
+
* Runtimes without `node:crypto` (Edge, workers, browsers) should use the
|
|
27
|
+
* WebCrypto ports in `@agentsbloom/next`, which are kept byte-identical.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// --- RFC 8941 structured fields -------------------------------------------
|
|
31
|
+
export {
|
|
32
|
+
parseDictionary,
|
|
33
|
+
parseByteSequenceDictionary,
|
|
34
|
+
serializeByteSequence,
|
|
35
|
+
ByteSequence,
|
|
36
|
+
Token,
|
|
37
|
+
StructuredFieldError,
|
|
38
|
+
} from './structured-fields.js';
|
|
39
|
+
|
|
40
|
+
// --- RFC 9421 signature base ----------------------------------------------
|
|
41
|
+
export {
|
|
42
|
+
buildSignatureBase,
|
|
43
|
+
requestContextFromExpress,
|
|
44
|
+
normalizeAuthority,
|
|
45
|
+
contentDigestHeader,
|
|
46
|
+
verifyContentDigest,
|
|
47
|
+
resolveBodyBytes,
|
|
48
|
+
SignatureBaseError,
|
|
49
|
+
PROFILE_STRICT,
|
|
50
|
+
PROFILE_LEGACY,
|
|
51
|
+
} from './signature-base.js';
|
|
52
|
+
|
|
53
|
+
// --- RFC 9421 verification (JWKS-backed) ----------------------------------
|
|
54
|
+
export {
|
|
55
|
+
verifyHttpMessageSignature,
|
|
56
|
+
createJwksCache,
|
|
57
|
+
isBlockedSsrfHostname,
|
|
58
|
+
SUPPORTED_SIGNATURE_ALGORITHMS,
|
|
59
|
+
STRICT_REQUIRED_COMPONENTS,
|
|
60
|
+
LEGACY_REQUIRED_COMPONENTS,
|
|
61
|
+
} from './http-signatures.js';
|
|
62
|
+
|
|
63
|
+
// --- Exact-decimal money --------------------------------------------------
|
|
64
|
+
export {
|
|
65
|
+
parseAmount,
|
|
66
|
+
compareAmounts,
|
|
67
|
+
toDecimalString,
|
|
68
|
+
toScaledBigInt,
|
|
69
|
+
toMinorUnits,
|
|
70
|
+
toHundredths,
|
|
71
|
+
formatScaled,
|
|
72
|
+
sumLineItems,
|
|
73
|
+
currencyExponent,
|
|
74
|
+
normalizeCurrencyCode,
|
|
75
|
+
COMPARISON_SCALE,
|
|
76
|
+
} from './money.js';
|
|
77
|
+
|
|
78
|
+
// --- Bounded / cluster-wide replay cache ----------------------------------
|
|
79
|
+
export {
|
|
80
|
+
createReplayCache,
|
|
81
|
+
isSharedStoreConfigured,
|
|
82
|
+
isReplayFailOpenEnabled,
|
|
83
|
+
sharedStoreOutageCount,
|
|
84
|
+
resetSharedStoreDiagnostics,
|
|
85
|
+
} from './shared-store.js';
|