@agentsbloom/sdk 0.2.0 → 0.4.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 +95 -2
- package/index.js +1994 -1111
- package/lib/ap2.js +153 -38
- package/lib/outcomes.js +108 -0
- package/lib/shared-store.js +172 -0
- package/package.json +85 -81
- package/telemetry.js +24 -17
- package/assets/logo-mark.svg +0 -25
package/lib/ap2.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
|
+
import { createReplayCache } from './shared-store.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Hardened AP2 (Agent Payments Protocol) mandate verification.
|
|
@@ -130,37 +131,50 @@ export function ed25519PublicKeyFromDidKey(didKey) {
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
// --- Replay protection ---
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
const
|
|
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?.();
|
|
134
|
+
// Consumed mandate jtis. Local Map by default; when an Upstash Redis REST
|
|
135
|
+
// endpoint is configured (see lib/shared-store.js) the cache becomes
|
|
136
|
+
// cluster-wide so a mandate consumed on one instance cannot replay against
|
|
137
|
+
// another. `has()` is maybe-async (boolean locally, Promise when shared) -
|
|
138
|
+
// callers await it.
|
|
139
|
+
const seenMandateJtis = createReplayCache('agentsbloom:ap2:jti', 50_000);
|
|
147
140
|
|
|
148
141
|
/** Clears all replay-tracking state. Exposed for tests and shutdown(). */
|
|
149
142
|
export function resetAp2ReplayCache() {
|
|
150
143
|
seenMandateJtis.clear();
|
|
151
144
|
}
|
|
152
145
|
|
|
153
|
-
/**
|
|
154
|
-
export function stopAp2ReplayCleanup() {
|
|
155
|
-
clearInterval(replayCleanupInterval);
|
|
156
|
-
}
|
|
146
|
+
/** No-op retained for shutdown() compatibility; eviction is amortized. */
|
|
147
|
+
export function stopAp2ReplayCleanup() {}
|
|
157
148
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Normalizes an audience/merchantScope value for comparison. Clients that
|
|
151
|
+
* build URLs with `new URL(...).href` (the standard practice - the
|
|
152
|
+
* Test Console's dynamic target validation does exactly this) produce a
|
|
153
|
+
* trailing slash ("https://store.example.com/") while merchants configure
|
|
154
|
+
* the same audience without one. Comparing raw strings rejected those
|
|
155
|
+
* perfectly valid mandates, so both sides are normalized: URL-parsed when
|
|
156
|
+
* possible (lowercased host, trailing slashes stripped), plain
|
|
157
|
+
* slash-stripped otherwise (non-URL audience strings keep old behavior).
|
|
158
|
+
*
|
|
159
|
+
* @param {string} value
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
export function normalizeAudience(value) {
|
|
163
|
+
if (typeof value !== 'string') return String(value ?? '');
|
|
164
|
+
const trimmed = value.trim();
|
|
165
|
+
if (trimmed === '') return trimmed;
|
|
166
|
+
try {
|
|
167
|
+
const url = new URL(trimmed);
|
|
168
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
169
|
+
return `${url.protocol}//${url.host}${path}`;
|
|
170
|
+
} catch {
|
|
171
|
+
return trimmed.replace(/\/+$/, '');
|
|
162
172
|
}
|
|
163
|
-
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Audience comparison that tolerates URL-normalization differences. */
|
|
176
|
+
function audienceMatches(claimed, expected) {
|
|
177
|
+
return normalizeAudience(claimed) === normalizeAudience(expected);
|
|
164
178
|
}
|
|
165
179
|
|
|
166
180
|
// --- Signature verification helpers ---
|
|
@@ -195,6 +209,20 @@ function hashAlgForJwtAlg(alg) {
|
|
|
195
209
|
* @param {boolean} [options.requireJti=true] - reject mandates without a `jti` claim
|
|
196
210
|
* @param {string[]} [options.requestedCategories] - if set and the mandate's
|
|
197
211
|
* `intentMandate.allowedCategories` is present, the two must overlap
|
|
212
|
+
* @param {boolean} [options.allowSelfCertifying=true] - v4: when false,
|
|
213
|
+
* self-certifying did:key mandates are rejected and only the
|
|
214
|
+
* merchant-configured trusted key is accepted (production-grade posture:
|
|
215
|
+
* a self-certifying mandate proves only that the MINTER holds the key,
|
|
216
|
+
* not that any real wallet/payer authorized the spend)
|
|
217
|
+
* @param {string} [options.expectedCurrency] - v4: when set, the mandate's
|
|
218
|
+
* declared currency (intentMandate.currency or paymentMandate.currency)
|
|
219
|
+
* must match it, closing unit-mismatch budget attacks (a "100" cap in a
|
|
220
|
+
* low-value currency authorizing 100 in the merchant's currency)
|
|
221
|
+
* @param {boolean} [options.consumeJti=true] - second-pass review: when
|
|
222
|
+
* false, a valid mandate is verified and returned WITHOUT consuming its
|
|
223
|
+
* single-use jti. The middleware passes false for mandates presented on
|
|
224
|
+
* non-AP2 routes so a mandate riding along on an unrelated action does
|
|
225
|
+
* not burn itself; AP2 endpoints consume as before.
|
|
198
226
|
* @returns {{ valid: boolean, verified: boolean, protocol: 'AP2', reason?: string, mandates?: object }}
|
|
199
227
|
*/
|
|
200
228
|
export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
@@ -204,6 +232,9 @@ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
|
204
232
|
maxMandateLifetimeSec = 3600,
|
|
205
233
|
requireJti = true,
|
|
206
234
|
requestedCategories = null,
|
|
235
|
+
allowSelfCertifying = true,
|
|
236
|
+
expectedCurrency = null,
|
|
237
|
+
consumeJti = true,
|
|
207
238
|
} = options;
|
|
208
239
|
|
|
209
240
|
const mandateHeader = headers['x-ap2-mandate'] || headers['authorization'];
|
|
@@ -224,6 +255,21 @@ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
|
224
255
|
return { valid: false, reason: 'Invalid AP2 Mandate SD-JWT format', protocol: 'AP2' };
|
|
225
256
|
}
|
|
226
257
|
|
|
258
|
+
// --- JOSE header sanity (v4 medium pass) ---
|
|
259
|
+
// The typ claim, when present, must be a JWT/SD-JWT type. Anything else
|
|
260
|
+
// (e.g. a nested JWT or an attacker-chosen custom typ) is rejected rather
|
|
261
|
+
// than silently processed. Selective-disclosure processing remains a
|
|
262
|
+
// documented gap - this verifier handles the signed envelope only.
|
|
263
|
+
if (
|
|
264
|
+
header.typ !== undefined &&
|
|
265
|
+
!['jwt', 'sd-jwt'].includes(String(header.typ).toLowerCase())
|
|
266
|
+
) {
|
|
267
|
+
return { valid: false, reason: `Unsupported mandate typ: ${header.typ}`, protocol: 'AP2' };
|
|
268
|
+
}
|
|
269
|
+
if (!header.alg) {
|
|
270
|
+
return { valid: false, reason: 'Missing alg in mandate header', protocol: 'AP2' };
|
|
271
|
+
}
|
|
272
|
+
|
|
227
273
|
// --- Required claims present ---
|
|
228
274
|
if (!payload.iss) return { valid: false, reason: 'Missing issuer', protocol: 'AP2' };
|
|
229
275
|
if (!payload.aud) return { valid: false, reason: 'Missing audience', protocol: 'AP2' };
|
|
@@ -247,6 +293,20 @@ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
|
247
293
|
let publicKey = null;
|
|
248
294
|
let usingSelfCertifyingKey = false;
|
|
249
295
|
|
|
296
|
+
// v4: merchants can require an out-of-band-trusted issuer. A
|
|
297
|
+
// self-certifying did:key mandate is cryptographically sound but
|
|
298
|
+
// semantically self-vouched - anyone can mint one declaring any
|
|
299
|
+
// maxBudget - so production deployments that bind mandates to real
|
|
300
|
+
// wallets should set allowSelfCertifying: false.
|
|
301
|
+
const isDidKeyIssuer = String(payload.iss || '').startsWith('did:key:');
|
|
302
|
+
if (!allowSelfCertifying && isDidKeyIssuer) {
|
|
303
|
+
return {
|
|
304
|
+
valid: false,
|
|
305
|
+
reason: 'Self-certifying did:key mandates are disabled for this store; a merchant-trusted public key is required',
|
|
306
|
+
protocol: 'AP2',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
250
310
|
if (trustedPublicKey) {
|
|
251
311
|
try {
|
|
252
312
|
publicKey = trustedPublicKey instanceof crypto.KeyObject
|
|
@@ -258,7 +318,7 @@ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
|
258
318
|
if (!EXPLICIT_KEY_ALLOWED_ALGS.has(header.alg)) {
|
|
259
319
|
return { valid: false, reason: `Unsupported signature algorithm: ${header.alg}`, protocol: 'AP2' };
|
|
260
320
|
}
|
|
261
|
-
} else if (
|
|
321
|
+
} else if (isDidKeyIssuer) {
|
|
262
322
|
usingSelfCertifyingKey = true;
|
|
263
323
|
if (header.alg !== 'EdDSA') {
|
|
264
324
|
return { valid: false, reason: 'did:key issuers require the EdDSA algorithm', protocol: 'AP2' };
|
|
@@ -292,7 +352,7 @@ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
|
292
352
|
}
|
|
293
353
|
|
|
294
354
|
// --- Audience binding ---
|
|
295
|
-
if (expectedAudience && payload.aud
|
|
355
|
+
if (expectedAudience && !audienceMatches(payload.aud, expectedAudience)) {
|
|
296
356
|
return {
|
|
297
357
|
valid: false,
|
|
298
358
|
reason: `Mandate audience "${payload.aud}" does not match this store ("${expectedAudience}")`,
|
|
@@ -302,7 +362,7 @@ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
|
302
362
|
|
|
303
363
|
// --- merchantScope binding (if the mandate declares one, it must match too) ---
|
|
304
364
|
const merchantScope = payload.intentMandate?.merchantScope;
|
|
305
|
-
if (expectedAudience && merchantScope && merchantScope
|
|
365
|
+
if (expectedAudience && merchantScope && !audienceMatches(merchantScope, expectedAudience)) {
|
|
306
366
|
return {
|
|
307
367
|
valid: false,
|
|
308
368
|
reason: `Mandate merchantScope "${merchantScope}" does not match this store ("${expectedAudience}")`,
|
|
@@ -310,12 +370,18 @@ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
|
310
370
|
};
|
|
311
371
|
}
|
|
312
372
|
|
|
313
|
-
// --- Replay protection ---
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
373
|
+
// --- Replay protection + final validations ---
|
|
374
|
+
// v4: the jti is now consumed with an ATOMIC claim() as the FINAL step,
|
|
375
|
+
// after every other validation passes. This preserves the 2026-08 audit
|
|
376
|
+
// semantics (an over-budget/category/audience attempt never burns the
|
|
377
|
+
// mandate - the corrected retry works) while closing the check-then-act
|
|
378
|
+
// race where two concurrent requests could both observe "not seen"
|
|
379
|
+
// before either recorded the jti. With claim(), exactly one concurrent
|
|
380
|
+
// request wins the mandate; the loser is rejected as a replay.
|
|
381
|
+
return finishVerification();
|
|
382
|
+
|
|
383
|
+
/** Everything after signature/audience verification. Maybe-async. */
|
|
384
|
+
function finishVerification() {
|
|
319
385
|
|
|
320
386
|
// --- Category enforcement (only if both the mandate and caller supply it) ---
|
|
321
387
|
const allowedCategories = payload.intentMandate?.allowedCategories;
|
|
@@ -340,13 +406,62 @@ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
|
|
|
340
406
|
}
|
|
341
407
|
}
|
|
342
408
|
|
|
343
|
-
|
|
344
|
-
|
|
409
|
+
// --- Currency binding (v4 medium pass) ---
|
|
410
|
+
// A budget number without a currency is unitless and forgeable by
|
|
411
|
+
// relabeling: a "100" cap minted in a low-value currency must not
|
|
412
|
+
// authorize "100" in the merchant's currency. When both sides declare a
|
|
413
|
+
// currency they must agree; merchants can also pin one via expectedCurrency.
|
|
414
|
+
const mandateCurrency = payload.intentMandate?.currency ?? payload.paymentMandate?.currency;
|
|
415
|
+
const normalizeCurrency = (value) => String(value || '').trim().toUpperCase();
|
|
416
|
+
if (mandateCurrency && expectedCurrency && normalizeCurrency(mandateCurrency) !== normalizeCurrency(expectedCurrency)) {
|
|
417
|
+
return {
|
|
418
|
+
valid: false,
|
|
419
|
+
reason: `Mandate currency "${mandateCurrency}" does not match this store's expected currency "${expectedCurrency}"`,
|
|
420
|
+
protocol: 'AP2',
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (
|
|
424
|
+
mandateCurrency &&
|
|
425
|
+
body?.currency &&
|
|
426
|
+
normalizeCurrency(mandateCurrency) !== normalizeCurrency(body.currency)
|
|
427
|
+
) {
|
|
428
|
+
return {
|
|
429
|
+
valid: false,
|
|
430
|
+
reason: `Order currency "${body.currency}" does not match the mandate's currency "${mandateCurrency}"`,
|
|
431
|
+
protocol: 'AP2',
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const replayRejection = () => ({
|
|
436
|
+
valid: false,
|
|
437
|
+
reason: 'Mandate has already been used (replay detected)',
|
|
345
438
|
protocol: 'AP2',
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// --- Consume the jti: every validation above passed, so winning the
|
|
442
|
+
// atomic claim is the single successful use of this mandate. When
|
|
443
|
+
// consumeJti is false (non-AP2 routes), skip consumption entirely. ---
|
|
444
|
+
if (payload.jti && consumeJti) {
|
|
445
|
+
const claimed = seenMandateJtis.claim(payload.jti, payload.exp * 1000);
|
|
446
|
+
if (typeof claimed === 'boolean') {
|
|
447
|
+
if (!claimed) return replayRejection();
|
|
448
|
+
} else {
|
|
449
|
+
return claimed.then((won) => (won ? buildSuccess() : replayRejection()));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return buildSuccess();
|
|
454
|
+
|
|
455
|
+
function buildSuccess() {
|
|
456
|
+
return {
|
|
457
|
+
valid: true,
|
|
458
|
+
protocol: 'AP2',
|
|
459
|
+
verified: true,
|
|
460
|
+
selfCertifying: usingSelfCertifyingKey,
|
|
461
|
+
mandates: payload,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
350
465
|
}
|
|
351
466
|
|
|
352
467
|
/**
|
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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared replay-cache store for multi-instance deployments.
|
|
3
|
+
*
|
|
4
|
+
* The mandate-jti and signature-nonce replay caches were in-memory Maps:
|
|
5
|
+
* correct per process, but a second instance (or a restart mid-attack)
|
|
6
|
+
* forgets every consumed nonce, so a captured mandate/signature replays
|
|
7
|
+
* cleanly against the other instance. When an Upstash Redis REST endpoint
|
|
8
|
+
* is configured (UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN), the
|
|
9
|
+
* caches become cluster-wide through plain fetch - no new dependency.
|
|
10
|
+
*
|
|
11
|
+
* Semantics (deliberately conservative):
|
|
12
|
+
* - Reads check the local Map first, then the shared store.
|
|
13
|
+
* - Writes go to both; shared writes are fire-and-forget with TTL, and
|
|
14
|
+
* shared-store OUTAGES degrade to local-only behavior (logged once)
|
|
15
|
+
* rather than failing requests - same fail-open-with-logging posture
|
|
16
|
+
* as the rest of the bookkeeping.
|
|
17
|
+
* - `has()` returns a boolean in local-only mode and a Promise<boolean>
|
|
18
|
+
* when the shared store is configured. Callers must treat it as
|
|
19
|
+
* maybe-async (`const hit = await has(k)` works for both).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const UPSTASH_URL = () => process.env.UPSTASH_REDIS_REST_URL || '';
|
|
23
|
+
const UPSTASH_TOKEN = () => process.env.UPSTASH_REDIS_REST_TOKEN || '';
|
|
24
|
+
|
|
25
|
+
export function isSharedStoreConfigured() {
|
|
26
|
+
return Boolean(UPSTASH_URL() && UPSTASH_TOKEN());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let outageLogged = false;
|
|
30
|
+
|
|
31
|
+
async function sharedRequest(path, body) {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`${UPSTASH_URL().replace(/\/$/, '')}${path}`, {
|
|
34
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
35
|
+
headers: { Authorization: `Bearer ${UPSTASH_TOKEN()}` },
|
|
36
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
37
|
+
signal: AbortSignal.timeout(3000),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
40
|
+
return await res.json();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (!outageLogged) {
|
|
43
|
+
console.warn(`🌸 AgentsBloom: shared replay store unreachable (${err.message}); replay protection is local-only for this instance.`);
|
|
44
|
+
outageLogged = true;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Returns the parsed value or null. Never throws. */
|
|
50
|
+
export async function sharedGetJSON(key) {
|
|
51
|
+
if (!isSharedStoreConfigured()) return null;
|
|
52
|
+
const result = await sharedRequest(`/get/${encodeURIComponent(key)}`);
|
|
53
|
+
const value = result?.result;
|
|
54
|
+
return value === null || value === undefined ? null : JSON.parse(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fire-and-forget set with TTL seconds. Never throws. */
|
|
58
|
+
export async function sharedSetJSON(key, value, ttlSec) {
|
|
59
|
+
if (!isSharedStoreConfigured()) return;
|
|
60
|
+
await sharedRequest(`/set/${encodeURIComponent(key)}?EX=${Math.max(1, Math.floor(ttlSec))}`, value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A bounded local Map + optional shared-store write-through, exposing a
|
|
65
|
+
* replay-cache interface: `has(key) -> boolean | Promise<boolean>` and
|
|
66
|
+
* `set(key, expiresAtMs) -> void`.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} namespace - key prefix in the shared store
|
|
69
|
+
* @param {number} maxLocalSize - bound for the in-memory Map
|
|
70
|
+
*/
|
|
71
|
+
export function createReplayCache(namespace, maxLocalSize = 50_000) {
|
|
72
|
+
const local = new Map(); // key -> expiresAtMs
|
|
73
|
+
|
|
74
|
+
const evict = (now) => {
|
|
75
|
+
for (const [key, expiresAtMs] of local) {
|
|
76
|
+
if (expiresAtMs < now) local.delete(key);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
/** Local-first; falls through to the shared store when configured. */
|
|
82
|
+
has(key) {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const expiresAtMs = local.get(key);
|
|
85
|
+
if (expiresAtMs !== undefined) {
|
|
86
|
+
if (expiresAtMs >= now) return true;
|
|
87
|
+
local.delete(key);
|
|
88
|
+
}
|
|
89
|
+
if (!isSharedStoreConfigured()) {
|
|
90
|
+
// Bounded like the previous plain-Map implementation.
|
|
91
|
+
if (local.size >= maxLocalSize) {
|
|
92
|
+
evict(now);
|
|
93
|
+
if (local.size >= maxLocalSize) return false;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return sharedGetJSON(`${namespace}:${key}`).then((value) => value !== null);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/** Write-through; the shared write is fire-and-forget. */
|
|
101
|
+
set(key, expiresAtMs) {
|
|
102
|
+
if (local.size >= maxLocalSize) {
|
|
103
|
+
evict(Date.now());
|
|
104
|
+
if (local.size >= maxLocalSize) return; // refuse rather than grow unbounded
|
|
105
|
+
}
|
|
106
|
+
local.set(key, expiresAtMs);
|
|
107
|
+
if (isSharedStoreConfigured()) {
|
|
108
|
+
const ttlSec = Math.max(1, Math.ceil((expiresAtMs - Date.now()) / 1000));
|
|
109
|
+
sharedSetJSON(`${namespace}:${key}`, { e: expiresAtMs }, ttlSec);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* v4: ATOMIC check-and-set for replay protection.
|
|
115
|
+
*
|
|
116
|
+
* The previous has()-then-set() sequence had a race window: two
|
|
117
|
+
* concurrent identical requests could both observe "not seen" before
|
|
118
|
+
* either recorded the nonce/jti, letting a captured signed request or
|
|
119
|
+
* mandate replay in parallel. claim() is a single atomic operation:
|
|
120
|
+
* locally it is a synchronous Map check-and-set; against the shared
|
|
121
|
+
* store it uses SET NX EX (set-if-not-exists), so only one of N
|
|
122
|
+
* concurrent claims across ALL instances wins.
|
|
123
|
+
*
|
|
124
|
+
* Returns true when this caller won the key (first use), false when
|
|
125
|
+
* the key was already claimed (replay). Returns a Promise<boolean>
|
|
126
|
+
* when a shared store is configured. If the shared store is
|
|
127
|
+
* unreachable, degrades to the local-only decision (same fail-open
|
|
128
|
+
* posture as the rest of the bookkeeping - documented).
|
|
129
|
+
*/
|
|
130
|
+
claim(key, expiresAtMs) {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
const expiresAt = local.get(key);
|
|
133
|
+
if (expiresAt !== undefined && expiresAt >= now) return false;
|
|
134
|
+
local.delete(key);
|
|
135
|
+
|
|
136
|
+
const claimLocally = () => {
|
|
137
|
+
if (local.size >= maxLocalSize) {
|
|
138
|
+
evict(Date.now());
|
|
139
|
+
if (local.size >= maxLocalSize) {
|
|
140
|
+
// Cannot track locally; treat as claimed-but-untracked so we
|
|
141
|
+
// never fail OPEN on capacity pressure.
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
local.set(key, expiresAtMs);
|
|
146
|
+
return true;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (!isSharedStoreConfigured()) {
|
|
150
|
+
return claimLocally();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ttlSec = Math.max(1, Math.ceil((expiresAtMs - now) / 1000));
|
|
154
|
+
return sharedRequest(`/set/${encodeURIComponent(`${namespace}:${key}`)}?NX&EX=${ttlSec}`, { e: expiresAtMs })
|
|
155
|
+
.then((result) => {
|
|
156
|
+
// Upstash SET NX returns { result: 'OK' } when the key was set,
|
|
157
|
+
// { result: null } when it already existed, and null on outage.
|
|
158
|
+
if (result === null) return claimLocally(); // outage: degrade to local
|
|
159
|
+
if (result?.result === 'OK') {
|
|
160
|
+
local.set(key, expiresAtMs);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/** Test hook. */
|
|
168
|
+
clear() {
|
|
169
|
+
local.clear();
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|