@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.
@@ -1,172 +1,298 @@
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
- }
1
+ /**
2
+ * Shared replay-cache store for multi-instance deployments.
3
+ *
4
+ * The mandate-jti and signature-nonce replay caches are 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 is
8
+ * configured (UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN) the caches
9
+ * become cluster-wide through plain `fetch` no new dependency.
10
+ *
11
+ * ---------------------------------------------------------------------------
12
+ * Fail-closed by default (changed)
13
+ * ---------------------------------------------------------------------------
14
+ *
15
+ * This module used to swallow every shared-store error and silently fall back
16
+ * to the local Map. For a replay cache that guards payment authorization,
17
+ * "the coordination layer is down, so let it through" is the wrong default:
18
+ * an attacker who can degrade the store (or simply wait for an Upstash
19
+ * incident) gets cluster-wide replay for free, and the operator finds out
20
+ * from a single `console.warn` emitted once per process lifetime.
21
+ *
22
+ * So: when a shared store is CONFIGURED but unreachable, `claim()` now
23
+ * returns false the request is rejected as if the key were already claimed.
24
+ * Operators who prefer availability over replay protection can opt out with
25
+ * `AGENTSBLOOM_REPLAY_FAIL_OPEN=true`, which is loud about itself.
26
+ *
27
+ * When no shared store is configured at all, behavior is unchanged
28
+ * (local-only, bounded), because there is nothing to fail closed against.
29
+ *
30
+ * ---------------------------------------------------------------------------
31
+ * Semantics
32
+ * ---------------------------------------------------------------------------
33
+ * - `claim(key, expiresAtMs)` is the primitive: an atomic check-and-set.
34
+ * Locally it is a synchronous Map operation; against Upstash it is
35
+ * `SET key value NX EX ttl`, so exactly one of N concurrent claimers
36
+ * across ALL instances wins. Returns `boolean` in local-only mode and
37
+ * `Promise<boolean>` when a shared store is configured — callers must
38
+ * treat it as maybe-async (`await` works for both).
39
+ * - `has(key)` is advisory only. Never gate a decision on it; use `claim`.
40
+ */
41
+
42
+ import crypto from 'crypto';
43
+
44
+ const UPSTASH_URL = () => process.env.UPSTASH_REDIS_REST_URL || '';
45
+ const UPSTASH_TOKEN = () => process.env.UPSTASH_REDIS_REST_TOKEN || '';
46
+
47
+ /** Redis rejects very long keys and they bloat memory; hash beyond this. */
48
+ const MAX_RAW_KEY_LENGTH = 320;
49
+ const SHARED_REQUEST_TIMEOUT_MS = 3000;
50
+ /** Re-log a shared-store outage at most this often (was: once per process). */
51
+ const OUTAGE_LOG_INTERVAL_MS = 60 * 1000;
52
+
53
+ export function isSharedStoreConfigured() {
54
+ return Boolean(UPSTASH_URL() && UPSTASH_TOKEN());
55
+ }
56
+
57
+ /**
58
+ * True when the operator has explicitly chosen availability over replay
59
+ * protection during a shared-store outage.
60
+ */
61
+ export function isReplayFailOpenEnabled() {
62
+ return String(process.env.AGENTSBLOOM_REPLAY_FAIL_OPEN || '').toLowerCase() === 'true';
63
+ }
64
+
65
+ let lastOutageLogMs = 0;
66
+ let outageCount = 0;
67
+
68
+ function logOutage(message) {
69
+ outageCount += 1;
70
+ const now = Date.now();
71
+ if (now - lastOutageLogMs < OUTAGE_LOG_INTERVAL_MS) return;
72
+ lastOutageLogMs = now;
73
+ const posture = isReplayFailOpenEnabled()
74
+ ? 'AGENTSBLOOM_REPLAY_FAIL_OPEN=true, so replay protection is LOCAL-ONLY for this instance'
75
+ : 'replay-protected requests are being REJECTED until it recovers (set AGENTSBLOOM_REPLAY_FAIL_OPEN=true to trade replay protection for availability)';
76
+ console.error(
77
+ `🌸 AgentsBloom: shared replay store unreachable (${message}); ${posture}. `
78
+ + `Failures since start: ${outageCount}.`,
79
+ );
80
+ }
81
+
82
+ /** Test/diagnostic hook. */
83
+ export function sharedStoreOutageCount() {
84
+ return outageCount;
85
+ }
86
+
87
+ /** Test hook: resets outage bookkeeping. */
88
+ export function resetSharedStoreDiagnostics() {
89
+ outageCount = 0;
90
+ lastOutageLogMs = 0;
91
+ }
92
+
93
+ /**
94
+ * Bounds a cache key so an attacker-supplied identifier (a multi-megabyte
95
+ * `jti`, say) cannot become a multi-megabyte Redis key.
96
+ *
97
+ * @param {string} key
98
+ * @returns {string}
99
+ */
100
+ function boundKey(key) {
101
+ const text = String(key);
102
+ if (text.length <= MAX_RAW_KEY_LENGTH) return text;
103
+ return `h:${crypto.createHash('sha256').update(text).digest('base64url')}`;
104
+ }
105
+
106
+ /**
107
+ * Performs one Upstash REST call.
108
+ *
109
+ * @returns {Promise<{ ok: true, body: unknown } | { ok: false, error: string }>}
110
+ */
111
+ async function sharedRequest(path, body) {
112
+ try {
113
+ const response = await fetch(`${UPSTASH_URL().replace(/\/$/, '')}${path}`, {
114
+ method: body === undefined ? 'GET' : 'POST',
115
+ headers: {
116
+ Authorization: `Bearer ${UPSTASH_TOKEN()}`,
117
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
118
+ },
119
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
120
+ redirect: 'error',
121
+ signal: AbortSignal.timeout(SHARED_REQUEST_TIMEOUT_MS),
122
+ });
123
+ if (!response.ok) {
124
+ const error = `HTTP ${response.status}`;
125
+ logOutage(error);
126
+ return { ok: false, error };
127
+ }
128
+ return { ok: true, body: await response.json() };
129
+ } catch (err) {
130
+ const error = err?.message || String(err);
131
+ logOutage(error);
132
+ return { ok: false, error };
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Reads a JSON value from the shared store.
138
+ *
139
+ * @returns {Promise<{ ok: boolean, value: unknown }>} `ok:false` signals an
140
+ * outage so callers can distinguish "absent" from "unknown".
141
+ */
142
+ export async function sharedGetJSON(key) {
143
+ if (!isSharedStoreConfigured()) return { ok: true, value: null };
144
+ const result = await sharedRequest(`/get/${encodeURIComponent(boundKey(key))}`);
145
+ if (!result.ok) return { ok: false, value: null };
146
+ const raw = result.body?.result;
147
+ if (raw === null || raw === undefined) return { ok: true, value: null };
148
+ try {
149
+ // Previously this JSON.parse sat OUTSIDE the error handling, so one
150
+ // malformed stored value threw an unhandled rejection out of has().
151
+ return { ok: true, value: JSON.parse(raw) };
152
+ } catch {
153
+ return { ok: true, value: null };
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Writes a JSON value with a TTL. Best effort; reports whether it landed.
159
+ *
160
+ * @returns {Promise<boolean>}
161
+ */
162
+ export async function sharedSetJSON(key, value, ttlSec) {
163
+ if (!isSharedStoreConfigured()) return false;
164
+ const ttl = Math.max(1, Math.floor(ttlSec));
165
+ const result = await sharedRequest(
166
+ `/set/${encodeURIComponent(boundKey(key))}?EX=${ttl}`,
167
+ value,
168
+ );
169
+ return result.ok;
170
+ }
171
+
172
+ /**
173
+ * A bounded local Map plus optional cluster-wide coordination, exposing a
174
+ * replay-cache interface.
175
+ *
176
+ * @param {string} namespace - key prefix in the shared store
177
+ * @param {number} [maxLocalSize] - bound for the in-memory Map
178
+ */
179
+ export function createReplayCache(namespace, maxLocalSize = 50_000) {
180
+ /** @type {Map<string, number>} key -> expiresAtMs */
181
+ const local = new Map();
182
+ let capacityEvictions = 0;
183
+ let sharedFailures = 0;
184
+
185
+ const sweep = (now) => {
186
+ for (const [key, expiresAtMs] of local) {
187
+ if (expiresAtMs < now) local.delete(key);
188
+ }
189
+ };
190
+
191
+ /**
192
+ * Records a key locally, evicting the oldest-inserted entry if the map is
193
+ * full. TTLs are uniform in practice, so oldest-inserted is also nearest to
194
+ * expiry — the least-bad choice. The counter exists so an operator can see
195
+ * that they are running against the bound.
196
+ */
197
+ const recordLocally = (key, expiresAtMs) => {
198
+ if (local.size >= maxLocalSize) {
199
+ sweep(Date.now());
200
+ if (local.size >= maxLocalSize) {
201
+ const oldest = local.keys().next().value;
202
+ if (oldest !== undefined) {
203
+ local.delete(oldest);
204
+ capacityEvictions += 1;
205
+ }
206
+ }
207
+ }
208
+ local.set(key, expiresAtMs);
209
+ };
210
+
211
+ return {
212
+ /**
213
+ * Advisory presence check. Prefer `claim()` for anything that gates a
214
+ * decision: `has()` cannot be atomic across instances.
215
+ */
216
+ has(key) {
217
+ const now = Date.now();
218
+ const expiresAtMs = local.get(key);
219
+ if (expiresAtMs !== undefined) {
220
+ if (expiresAtMs >= now) return true;
221
+ local.delete(key);
222
+ }
223
+ if (!isSharedStoreConfigured()) return false;
224
+ return sharedGetJSON(`${namespace}:${key}`).then((result) => result.value !== null);
225
+ },
226
+
227
+ /** Write-through record. Prefer `claim()`. */
228
+ set(key, expiresAtMs) {
229
+ recordLocally(key, expiresAtMs);
230
+ if (isSharedStoreConfigured()) {
231
+ const ttlSec = Math.max(1, Math.ceil((expiresAtMs - Date.now()) / 1000));
232
+ void sharedSetJSON(`${namespace}:${key}`, { e: expiresAtMs }, ttlSec);
233
+ }
234
+ },
235
+
236
+ /**
237
+ * Atomic check-and-set. Returns true when this caller won the key (first
238
+ * use), false when it was already claimed (replay) OR when a configured
239
+ * shared store could not be reached and fail-open is not enabled.
240
+ *
241
+ * @param {string} key
242
+ * @param {number} expiresAtMs - absolute time the record may be dropped
243
+ * @returns {boolean|Promise<boolean>}
244
+ */
245
+ claim(key, expiresAtMs) {
246
+ const now = Date.now();
247
+ const existing = local.get(key);
248
+ if (existing !== undefined && existing >= now) return false;
249
+ local.delete(key);
250
+
251
+ if (!isSharedStoreConfigured()) {
252
+ recordLocally(key, expiresAtMs);
253
+ return true;
254
+ }
255
+
256
+ const ttlSec = Math.max(1, Math.ceil((expiresAtMs - now) / 1000));
257
+ const sharedKey = boundKey(`${namespace}:${key}`);
258
+ return sharedRequest(`/set/${encodeURIComponent(sharedKey)}?NX&EX=${ttlSec}`, { e: expiresAtMs })
259
+ .then((result) => {
260
+ if (!result.ok) {
261
+ sharedFailures += 1;
262
+ if (isReplayFailOpenEnabled()) {
263
+ // Explicit operator choice: degrade to per-instance protection.
264
+ recordLocally(key, expiresAtMs);
265
+ return true;
266
+ }
267
+ // Fail closed. A replay cache that cannot coordinate must not
268
+ // hand out authorizations it can no longer deduplicate.
269
+ return false;
270
+ }
271
+ // Upstash SET NX returns { result: 'OK' } when the key was set and
272
+ // { result: null } when it already existed.
273
+ if (result.body?.result === 'OK') {
274
+ recordLocally(key, expiresAtMs);
275
+ return true;
276
+ }
277
+ return false;
278
+ });
279
+ },
280
+
281
+ /** Clears LOCAL state only; consumed keys remain in the shared store. */
282
+ clear() {
283
+ local.clear();
284
+ },
285
+
286
+ /** Diagnostics for tests and operators. */
287
+ stats() {
288
+ return {
289
+ localSize: local.size,
290
+ maxLocalSize,
291
+ capacityEvictions,
292
+ sharedFailures,
293
+ shared: isSharedStoreConfigured(),
294
+ failOpen: isReplayFailOpenEnabled(),
295
+ };
296
+ },
297
+ };
298
+ }