@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/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
- // A bounded, TTL-cleaned cache of mandate `jti`s that have already been
134
- // consumed. Sized generously; entries are removed once the mandate they
135
- // belonged to would have expired anyway, so the cache cannot grow forever
136
- // even under sustained attack traffic.
137
- const seenMandateJtis = new Map(); // jti -> expiresAtMs
138
- const REPLAY_CACHE_MAX_SIZE = 50_000;
139
-
140
- const replayCleanupInterval = setInterval(() => {
141
- const now = Date.now();
142
- for (const [jti, expiresAtMs] of seenMandateJtis.entries()) {
143
- if (expiresAtMs < now) seenMandateJtis.delete(jti);
144
- }
145
- }, 60 * 1000);
146
- replayCleanupInterval.unref?.();
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
- /** Stops the background cleanup timer. Called from index.js's shutdown(). */
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
- function isReplay(jti, expMs) {
159
- if (seenMandateJtis.has(jti)) return true;
160
- if (seenMandateJtis.size < REPLAY_CACHE_MAX_SIZE) {
161
- seenMandateJtis.set(jti, expMs);
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
- return false;
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 (String(payload.iss).startsWith('did:key:')) {
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 !== expectedAudience) {
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 !== expectedAudience) {
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
- if (payload.jti) {
315
- if (isReplay(payload.jti, payload.exp * 1000)) {
316
- return { valid: false, reason: 'Mandate has already been used (replay detected)', protocol: 'AP2' };
317
- }
318
- }
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
- return {
344
- valid: true,
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
- verified: true,
347
- selfCertifying: usingSelfCertifyingKey,
348
- mandates: payload,
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
  /**
@@ -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
+ }