@blamejs/core 0.15.41 → 0.15.43

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/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.43 (2026-06-28) — **`b.auth.lockout`'s failed-attempt counter now accumulates with an atomic compare-and-set, so a brute-force attacker spreading failed logins across multiple nodes can no longer lose increments and stay under the lockout threshold.** b.auth.lockout tracked failed attempts with a cache read-modify-write: read the counter, increment it, write it back. On a multi-node deployment two concurrent failures for the same account on different nodes both read the same value, each added one, and one write clobbered the other — a lost update that let an attacker spreading attempts across nodes exceed maxAttempts without ever triggering the lockout, weakening brute-force protection on a cluster. The counter now runs the whole decision (window decay, increment, lockout-ladder) through the cache's atomic update() (compare-and-set, retried on the cluster backend under contention), so every failure is counted regardless of which node records it. The lockout's documented fail-open posture is preserved — a genuinely unreachable cache still allows the attempt and signals the error — but a cache backend that cannot perform an atomic update now surfaces loud at first use instead of silently disabling the lockout. **Security:** *Lockout failure counter is atomic across nodes (no lost increments)* — b.auth.lockout accumulated failed attempts with a non-atomic cache get -> increment -> set, so concurrent failures for one account across a multi-node deployment lost increments and an attacker could exceed maxAttempts without engaging the lockout. The counter now uses the cache's atomic compare-and-set update(), counting every failure across nodes, with the existing exponential lockout ladder and window-decay logic unchanged. The cache backing a lockout must support atomic update() (b.cache does; the create() check now requires it), and a backend that can't commit an atomic update surfaces loud at first use rather than silently disabling brute-force protection. A genuinely unreachable cache still fails open by design.
12
+
13
+ - v0.15.42 (2026-06-28) — **The agent orchestrator and tenant registries serialize registration per name, so two concurrent register() calls for the same name can no longer both create a row (duplicate-create / lost registration), and a new b.safeAsync.keyedSerializer exposes that per-key serialization.** b.agent.orchestrator and b.agent.tenant registered a name with a check-then-create: read the backend row for the name, throw a duplicate error if it exists, otherwise write the new row. Because the read and the write are separated by an await, two concurrent register() calls for the same name both observed absence and both wrote — a duplicate-create where the second silently clobbered the first and both callers saw success, violating the one-row-per-name invariant. register() (and unregister()) now run through a per-name in-process serializer, so concurrent calls for the same name apply one at a time and the second is correctly refused as a duplicate; distinct names still run concurrently. The serializer is exposed as b.safeAsync.keyedSerializer() for any read-modify-write or check-then-create that must not interleave per key. It is in-process only: a registry backend shared across processes still needs its own atomic create or unique constraint to refuse a cross-process duplicate. **Added:** *b.safeAsync.keyedSerializer — serialize async work per key* — b.safeAsync.keyedSerializer() returns a { run(key, fn) } that queues fn behind any in-flight or queued work for the same key and runs it once they settle, so a read-modify-write or a check-then-create on a shared store cannot interleave with another call for the same key in the same process. Distinct keys run concurrently, and the per-key chain is dropped once it drains. It is the serialization the agent registries now use, and the same primitive backs the lockout and bot-challenge per-key failure counters. **Fixed:** *Agent orchestrator + tenant registries serialize registration per name (no duplicate-create race)* — b.agent.orchestrator.register and b.agent.tenant.register did a check-then-create (await backend.get -> throw-if-exists -> await backend.set) with an await between the read and the write, so two concurrent registrations of the same name both passed the duplicate check and both wrote — the second silently clobbering the first while both callers saw success. Registration now serializes per name in-process, so concurrent calls for one name apply sequentially and the second is refused with the duplicate error; distinct names are unaffected. A backend shared across processes still needs its own uniqueness constraint to refuse a cross-process duplicate. **Detectors:** *Build guard: a registry check-then-create must serialize per key* — A codebase guard now fails the build if a primitive does an async check-then-create on a pluggable backend (await backend.get -> throw a /duplicate error -> await backend.set) without serializing per key, so the duplicate-create race fixed here cannot reappear at a new registry.
14
+
11
15
  - v0.15.41 (2026-06-28) — **Counters kept on a shared cache — byte quotas and the static server's bandwidth/concurrency caps — now accumulate with an atomic compare-and-set, so concurrent requests can no longer lose each other's charges and slip under the limit.** Several caps maintained a counter on a cache with a non-atomic read-modify-write: read the current value, add to it in memory, write it back. On a cache shared across nodes, two concurrent requests both read the same value, each added only its own contribution, and one write clobbered the other — a lost update that under-counted the cap and let a peer slip under it. b.network.byteQuota (and the b.middleware.dailyByteQuota that composes it) and the static server's per-actor/global bandwidth and per-actor concurrency caps all did this. They now accumulate through the cache's atomic update() (compare-and-set, with retry on the cluster backend under contention), so every concurrent charge is counted. A cache backing these caps must support atomic update(); b.cache provides it, and both primitives refuse a get/set-only cache at construction. The single-node in-memory paths were already safe (they accumulate synchronously). **Fixed:** *Byte quota on a shared cache counts concurrent requests atomically (no lost updates)* — b.network.byteQuota's cache backend accumulated bytes with a get → add → set that is not atomic, so concurrent requests from one peer on a multi-node deployment lost each other's byte charges and the rolling daily byte budget was under-enforced. Accounting now uses the cache's atomic compare-and-set update(), counting every concurrent request, and retries the cluster CAS under a contention burst rather than dropping a charge. A cache wired to a byte quota must support update() — b.cache does; a plain get/set cache is refused at create() (byte-quota/cache-no-atomic-update), and a backend that can't actually commit an atomic update surfaces loud on first use instead of silently disabling the quota. b.middleware.dailyByteQuota, which composes byteQuota, inherits the fix. · *Static server bandwidth + concurrency caps count concurrent requests atomically* — b.staticServe's per-actor and global bandwidth caps and its per-actor concurrency cap kept their counters on the cache with a non-atomic get → add → set, so concurrent downloads from one actor on a multi-replica deployment lost each other's charges and the caps were under-enforced (a bandwidth/concurrency limit bypass). The counters now accumulate through the cache's atomic update() with the same contention retry, and the quota cache is required to support update() at create(). **Detectors:** *Build guard: a cache-backed counter must use atomic update(), not get → set* — A codebase guard now fails the build if a primitive accumulates a counter on a cache with a get → mutate → set read-modify-write instead of the atomic update(), so the lost-update class fixed here cannot reappear at a new cap. Caches used for lookups or cache-aside (the value is replaced wholesale, not incremented), or whose writes are serialized in-process, are allowlisted with the reason they cannot lose an increment.
12
16
 
13
17
  - v0.15.40 (2026-06-27) — **The durable webhook dispatcher's retry poller now claims due deliveries with FOR UPDATE SKIP LOCKED on Postgres and MySQL, so two pollers running at once (multiple app nodes) can no longer both grab the same delivery and send the webhook twice in one cycle.** The webhook dispatcher's retry poller claimed due deliveries by flipping them pending->in-flight and then re-selecting the in-flight rows by id. On Postgres or MySQL under the default READ COMMITTED isolation, two pollers running concurrently could both re-select the same row: the loser's UPDATE matched zero rows (the winner had already flipped it), but its reselect-by-id still re-read the row the winner had just claimed, so both pollers attempted the same HTTP delivery in one cycle. The claim now row-locks the due rows with SELECT ... FOR UPDATE SKIP LOCKED on the row-locking backends, so concurrent pollers see disjoint sets and each delivery is claimed by exactly one poller; sqlite (a single writer) keeps the existing mark-then-reselect, which it serializes safely. This matches the claim used by the framework's transactional outbox and cluster queue. Receivers that already dedup on the X-Webhook-Delivery-Id header were protected from a duplicate POST; this closes the at-most-once-per-cycle gap at the dispatcher itself. **Fixed:** *Webhook retry pollers no longer double-deliver under concurrency on Postgres / MySQL* — b.webhook.dispatcher's processRetries() claimed due deliveries with a mark-then-reselect that had no row lock, so on Postgres / MySQL at READ COMMITTED two concurrent pollers (for example, the dispatcher running on more than one app node) could both hand back the same delivery and POST it twice in a single retry cycle. The claim now uses SELECT ... FOR UPDATE SKIP LOCKED on those backends so each due row is locked by exactly one poller and concurrent pollers claim disjoint sets; the rows a poller selects are exactly the rows it owns. sqlite keeps the mark-then-reselect path, which its single writer serializes. Operators running the dispatcher on a single node, or whose receivers dedup on X-Webhook-Delivery-Id, were not exposed to a duplicate delivery; no configuration change is required to pick up the fix. **Detectors:** *Build guard: a competing-consumer claim must use FOR UPDATE SKIP LOCKED* — A codebase guard now fails the build if a poller that claims due rows across workers — SELECT status='pending' inside a transaction, then flip the rows to in-flight — omits FOR UPDATE SKIP LOCKED on the row-locking backends. Without the row lock, two pollers under READ COMMITTED both claim the same row; the guard keeps any future poller from re-introducing the shape, with the transactional outbox and cluster queue as the reference claims.
@@ -60,6 +60,7 @@ var bCrypto = require("./crypto");
60
60
  var agentAudit = require("./agent-audit");
61
61
  var vaultAad = require("./vault-aad");
62
62
  var validateOpts = require("./validate-opts");
63
+ var safeAsync = require("./safe-async");
63
64
 
64
65
  var audit = lazyRequire(function () { return require("./audit"); });
65
66
  var cluster = lazyRequire(function () { return require("./cluster"); });
@@ -165,6 +166,11 @@ function create(opts) {
165
166
 
166
167
  var ctx = {
167
168
  backend: backend,
169
+ // Serializes register/unregister per name so a concurrent pair for the same
170
+ // name can't interleave the check-then-create (await get -> throw-if-exists
171
+ // -> await set) and both write — a duplicate-create / lost-registration. In
172
+ // process only; a shared persistent backend also needs its own uniqueness.
173
+ registrySerializer: safeAsync.keyedSerializer(),
168
174
  cluster: clusterImpl,
169
175
  audit: auditImpl,
170
176
  permissions: permissions,
@@ -235,9 +241,9 @@ function create(opts) {
235
241
  }
236
242
 
237
243
  return {
238
- register: function (name, agent, regOpts) { return _register(ctx, name, agent, regOpts || {}); },
244
+ register: function (name, agent, regOpts) { return ctx.registrySerializer.run(name, function () { return _register(ctx, name, agent, regOpts || {}); }); },
239
245
  hydrate: function (name, agent) { return _hydrate(ctx, name, agent); },
240
- unregister: function (name, args) { return _unregister(ctx, name, args || {}); },
246
+ unregister: function (name, args) { return ctx.registrySerializer.run(name, function () { return _unregister(ctx, name, args || {}); }); },
241
247
  lookup: function (name, args) { return _lookup(ctx, name, args || {}); },
242
248
  list: function (args) { return _list(ctx, args || {}); },
243
249
  spawnConsumers: function (args) { return _spawnConsumers(ctx, args || {}); },
@@ -60,6 +60,7 @@ var agentAudit = require("./agent-audit");
60
60
  var safeJson = require("./safe-json");
61
61
  var vaultAad = require("./vault-aad");
62
62
  var validateOpts = require("./validate-opts");
63
+ var safeAsync = require("./safe-async");
63
64
 
64
65
  var audit = lazyRequire(function () { return require("./audit"); });
65
66
  var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
@@ -159,13 +160,19 @@ function create(opts) {
159
160
  var permissions = opts.permissions || null;
160
161
  var ctx = {
161
162
  backend: backend, audit: auditImpl, permissions: permissions,
163
+ // Serializes register/unregister per tenantId so a concurrent pair for the
164
+ // same tenant can't interleave the check-then-create (await get ->
165
+ // throw-if-exists -> await set) and both write — a duplicate-create /
166
+ // lost-registration. In process only; a shared persistent backend also
167
+ // needs its own uniqueness constraint.
168
+ registrySerializer: safeAsync.keyedSerializer(),
162
169
  // Archived tenants — keys retained but no live config; restore
163
170
  // requires explicit operator opt-in.
164
171
  archive: new Map(),
165
172
  };
166
173
  return {
167
- register: function (tenantId, regOpts) { return _register(ctx, tenantId, regOpts || {}); },
168
- unregister: function (tenantId, args) { return _unregister(ctx, tenantId, args || {}); },
174
+ register: function (tenantId, regOpts) { return ctx.registrySerializer.run(tenantId, function () { return _register(ctx, tenantId, regOpts || {}); }); },
175
+ unregister: function (tenantId, args) { return ctx.registrySerializer.run(tenantId, function () { return _unregister(ctx, tenantId, args || {}); }); },
169
176
  lookup: function (tenantId, args) { return _lookup(ctx, tenantId, args || {}); },
170
177
  list: function (args) { return _list(ctx, args || {}); },
171
178
  check: function (actor, agentTenantId) { return _check(ctx, actor, agentTenantId); },
@@ -161,10 +161,11 @@ function create(opts) {
161
161
 
162
162
  if (!opts.cache || typeof opts.cache !== "object" ||
163
163
  typeof opts.cache.get !== "function" ||
164
- typeof opts.cache.set !== "function" ||
165
- typeof opts.cache.del !== "function") {
164
+ typeof opts.cache.del !== "function" ||
165
+ typeof opts.cache.update !== "function") {
166
166
  throw _err("BAD_OPT", "auth.lockout.create: opts.cache must be a b.cache " +
167
- "instance (or shape with get/set/del). Pass b.cache.create({...}).");
167
+ "instance (or shape with get/del/update the failure counter needs " +
168
+ "an atomic update). Pass b.cache.create({...}).");
168
169
  }
169
170
  _requireString("namespace", opts.namespace);
170
171
 
@@ -232,13 +233,6 @@ function create(opts) {
232
233
  }
233
234
  }
234
235
 
235
- async function _writeState(key, state, ttlMs) {
236
- try {
237
- await cache.set(_scopedKey(key), state, { ttlMs: ttlMs });
238
- } catch (_e) {
239
- _signalCacheError("set");
240
- }
241
- }
242
236
 
243
237
  async function _deleteState(key) {
244
238
  try {
@@ -262,114 +256,125 @@ function create(opts) {
262
256
 
263
257
  // ---- Public surface ----
264
258
 
265
- // Per-key serialization of the failure counter (read→increment→write on an
266
- // async store): concurrent recordFailure calls for the same key would lose
267
- // updates, letting parallel failures stay under the lockout threshold. A
268
- // per-key promise chain applies them sequentially in-process.
269
- var _recordChains = new Map();
270
- function recordFailure(key, callOpts) {
271
- var prev = _recordChains.get(key) || Promise.resolve();
272
- var run = prev.then(function () { return _doRecordFailure(key, callOpts); },
273
- function () { return _doRecordFailure(key, callOpts); });
274
- var tail = run.then(function () {}, function () {});
275
- _recordChains.set(key, tail);
276
- tail.then(function () { if (_recordChains.get(key) === tail) _recordChains.delete(key); });
277
- return run;
278
- }
279
-
280
- async function _doRecordFailure(key, callOpts) {
259
+ // The failure counter is a read-modify-write on a shared cache. A plain
260
+ // get -> increment -> set is not atomic, so concurrent recordFailure calls
261
+ // across nodes lose increments and a brute-force attacker spread across nodes
262
+ // stays under the lockout threshold. cache.update runs the whole decision
263
+ // under a compare-and-set (with retry on the cluster backend), so every
264
+ // failure is counted. The mutator is PURE (it may re-run on a CAS retry): it
265
+ // computes the next state, records the outcome for the post-commit audit /
266
+ // observability emits below, and captures any error it raises so a
267
+ // configuration fault surfaces instead of being read as a cache failure.
268
+ async function recordFailure(key, callOpts) {
281
269
  _requireKey(key);
282
270
  callOpts = callOpts || {};
283
271
  var now = clock();
284
- var state = await _readState(key);
272
+ var outcome = null;
273
+ var mutatorErr = null;
274
+ try {
275
+ await cache.update(_scopedKey(key), function (state) {
276
+ try {
277
+ state = state || null;
278
+ // Currently locked: the lockout itself counts — don't accumulate during
279
+ // the cooldown. Abort (no write); the verdict comes from the read state.
280
+ if (state && state.lockedUntil && state.lockedUntil > now) {
281
+ outcome = { kind: "during-lock", attempts: state.attempts || 0,
282
+ lockNumber: state.lockNumber || 0, lockedUntil: state.lockedUntil };
283
+ return { abort: true };
284
+ }
285
+ // Window decay: failures older than windowMs reset the counter.
286
+ // Lock-number persists so an attacker who sleeps off a lockout doesn't
287
+ // get a fresh ladder rung.
288
+ if (state && state.lastFailureAt && (now - state.lastFailureAt) > windowMs) {
289
+ state = { attempts: 0, lockNumber: state.lockNumber || 0,
290
+ firstFailureAt: null, lastFailureAt: null, lockedUntil: null };
291
+ }
292
+ var attempts = (state && state.attempts) || 0;
293
+ var lockNumber = (state && state.lockNumber) || 0;
294
+ attempts += 1;
295
+ var lockedUntil = null, newLock = false;
296
+ if (attempts >= maxAttempts) {
297
+ lockNumber += 1;
298
+ lockedUntil = now + _resolveDuration(lockoutDurations, lockNumber);
299
+ newLock = true;
300
+ attempts = 0; // counter resets — the lockout window IS the punishment
301
+ }
302
+ var newState = {
303
+ attempts: attempts,
304
+ lockNumber: lockNumber,
305
+ firstFailureAt: (state && state.firstFailureAt) || now,
306
+ lastFailureAt: now,
307
+ lockedUntil: lockedUntil,
308
+ };
309
+ outcome = { kind: "recorded", attempts: attempts, lockNumber: lockNumber,
310
+ lockedUntil: lockedUntil, newLock: newLock };
311
+ // Keep the state only as long as it is meaningful: a non-locked counter
312
+ // expires after the decay window (so check()/attempts() read clean once
313
+ // it has elapsed), and a lock persists until lockedUntil plus a window
314
+ // so the lockNumber survives the cooldown — for the operator's actual
315
+ // configured duration, however long. A DURATION (the cache resolves it
316
+ // against its own clock) so an injectable lockout clock never desyncs
317
+ // the cache's expiry.
318
+ return { value: newState, ttlMs: lockedUntil ? (lockedUntil - now + windowMs) : windowMs };
319
+ } catch (me) {
320
+ mutatorErr = me;
321
+ throw me;
322
+ }
323
+ }, { ttlMs: windowMs });
324
+ } catch (e) {
325
+ // A configuration fault raised by the mutator (e.g. a lockoutDurations
326
+ // function returning an invalid value) must surface — not be swallowed as
327
+ // a backend failure and silently disable the lockout.
328
+ if (mutatorErr) throw mutatorErr;
329
+ // A backend that can't actually commit an atomic update (a get/set-only
330
+ // backend throws UNSUPPORTED at call time) must surface LOUD — failing
331
+ // open here would silently disable lockout. Any other cache error keeps
332
+ // the documented fail-OPEN posture (allow the attempt, signal it).
333
+ if (e && e.code === "UNSUPPORTED") {
334
+ throw _err("CACHE_NO_ATOMIC_UPDATE",
335
+ "auth.lockout: the cache backend does not support atomic update() — the " +
336
+ "failure counter cannot be enforced across nodes on a get/set-only backend; " +
337
+ "use a cache whose backend implements update (the memory or cluster backend).");
338
+ }
339
+ _signalCacheError("update");
340
+ return { locked: false, attempts: 0 };
341
+ }
285
342
 
286
- // If currently locked, the lockout itself counts — don't accumulate
287
- // additional attempts during the cooldown. The caller saw locked=true
288
- // from check() OR from a prior failure; this branch handles a caller
289
- // that calls recordFailure() on a locked account anyway (e.g. they
290
- // skipped check()).
291
- if (state && state.lockedUntil && state.lockedUntil > now) {
343
+ if (outcome.kind === "during-lock") {
292
344
  _emitObs("auth.lockout.failure_during_lock", { namespace: namespace });
293
345
  if (auditFailures) {
294
346
  _emitAudit("auth.lockout.failure", key, "denied",
295
- { duringLock: true, attempts: state.attempts || 0,
296
- lockNumber: state.lockNumber || 0,
297
- lockedUntil: state.lockedUntil,
298
- reason: callOpts.reason || null },
347
+ { duringLock: true, attempts: outcome.attempts, lockNumber: outcome.lockNumber,
348
+ lockedUntil: outcome.lockedUntil, reason: callOpts.reason || null },
299
349
  callOpts.req);
300
350
  }
301
- return {
302
- locked: true,
303
- attempts: state.attempts || 0,
304
- lockedUntil: state.lockedUntil,
305
- };
351
+ return { locked: true, attempts: outcome.attempts, lockedUntil: outcome.lockedUntil };
306
352
  }
307
353
 
308
- // Window decay: failures older than windowMs reset the counter.
309
- // Lock-number persists across decay windows so an attacker who
310
- // sleeps off a lockout doesn't get a fresh ladder rung.
311
- if (state && state.lastFailureAt && (now - state.lastFailureAt) > windowMs) {
312
- state = {
313
- attempts: 0,
314
- lockNumber: state.lockNumber || 0,
315
- firstFailureAt: null,
316
- lastFailureAt: null,
317
- lockedUntil: null,
318
- };
319
- }
320
-
321
- var attempts = (state && state.attempts) || 0;
322
- var lockNumber = (state && state.lockNumber) || 0;
323
- attempts += 1;
324
-
325
- var lockedUntil = null;
326
- var newLock = false;
327
- if (attempts >= maxAttempts) {
328
- lockNumber += 1;
329
- var dur = _resolveDuration(lockoutDurations, lockNumber);
330
- lockedUntil = now + dur;
331
- newLock = true;
332
- attempts = 0; // counter resets — the lockout window IS the punishment
333
- }
334
-
335
- var newState = {
336
- attempts: attempts,
337
- lockNumber: lockNumber,
338
- firstFailureAt: (state && state.firstFailureAt) || now,
339
- lastFailureAt: now,
340
- lockedUntil: lockedUntil,
341
- };
342
-
343
- // TTL: keep the state alive long enough that a follower-up failure
344
- // after the window/lockout expires still finds the lockNumber. The
345
- // longer of (windowMs after last failure) or (lockedUntil + windowMs).
346
- var ttlMs = lockedUntil ? (lockedUntil - now + windowMs) : windowMs;
347
- await _writeState(key, newState, ttlMs);
348
-
349
354
  _emitObs("auth.lockout.failure", { namespace: namespace });
350
355
  if (auditFailures) {
351
356
  _emitAudit("auth.lockout.failure", key, "failure",
352
- { attempts: newState.attempts, lockNumber: lockNumber,
357
+ { attempts: outcome.attempts, lockNumber: outcome.lockNumber,
353
358
  reason: callOpts.reason || null },
354
359
  callOpts.req);
355
360
  }
356
361
 
357
- if (newLock) {
362
+ if (outcome.newLock) {
358
363
  _emitObs("auth.lockout.engaged", {
359
364
  namespace: namespace,
360
- lockNumber: String(lockNumber),
365
+ lockNumber: String(outcome.lockNumber),
361
366
  });
362
367
  if (auditEngaged) {
363
368
  _emitAudit("auth.lockout.engaged", key, "denied",
364
- { lockNumber: lockNumber, lockedUntil: lockedUntil,
365
- durationMs: lockedUntil - now,
369
+ { lockNumber: outcome.lockNumber, lockedUntil: outcome.lockedUntil,
370
+ durationMs: outcome.lockedUntil - now,
366
371
  reason: callOpts.reason || null },
367
372
  callOpts.req);
368
373
  }
369
- return { locked: true, attempts: 0, lockedUntil: lockedUntil };
374
+ return { locked: true, attempts: 0, lockedUntil: outcome.lockedUntil };
370
375
  }
371
376
 
372
- return { locked: false, attempts: attempts };
377
+ return { locked: false, attempts: outcome.attempts };
373
378
  }
374
379
 
375
380
  async function recordSuccess(key, callOpts) {
@@ -41,6 +41,7 @@ var lazyRequire = require("./lazy-require");
41
41
  var requestHelpers = require("./request-helpers");
42
42
  var validateOpts = require("./validate-opts");
43
43
  var numericBounds = require("./numeric-bounds");
44
+ var safeAsync = require("./safe-async");
44
45
  var { AuthBotChallengeError } = require("./framework-error");
45
46
 
46
47
  var observability = lazyRequire(function () { return require("./observability"); });
@@ -280,15 +281,9 @@ function create(opts) {
280
281
  // per-key promise chain applies advances for a key sequentially. (Cross-node
281
282
  // atomicity additionally needs an atomic store; this fixes the in-process
282
283
  // race the gate actually depends on.)
283
- var _advanceChains = new Map();
284
+ var _advanceSerializer = safeAsync.keyedSerializer();
284
285
  function _advanceFailure(key, req) {
285
- var prev = _advanceChains.get(key) || Promise.resolve();
286
- var run = prev.then(function () { return _doAdvanceFailure(key, req); },
287
- function () { return _doAdvanceFailure(key, req); });
288
- var tail = run.then(function () {}, function () {});
289
- _advanceChains.set(key, tail);
290
- tail.then(function () { if (_advanceChains.get(key) === tail) _advanceChains.delete(key); });
291
- return run;
286
+ return _advanceSerializer.run(key, function () { return _doAdvanceFailure(key, req); });
292
287
  }
293
288
 
294
289
  async function _doAdvanceFailure(key, req) {
package/lib/cache.js CHANGED
@@ -360,7 +360,8 @@ function _memoryBackend(cfg) {
360
360
  if (entry) { _untrack(key, entry); entries.delete(key); }
361
361
  return { updated: true, deleted: true };
362
362
  }
363
- var effExpires = (decision.expiresAt !== undefined) ? decision.expiresAt : expiresAt;
363
+ var effExpires = (decision.ttlMs !== undefined) ? now + decision.ttlMs
364
+ : (decision.expiresAt !== undefined ? decision.expiresAt : expiresAt);
364
365
  await set(key, decision.value, effExpires, meta);
365
366
  return { updated: true, value: decision.value };
366
367
  }
@@ -640,7 +641,8 @@ function _clusterBackend(cfg) {
640
641
  // The mutator may pin the entry's expiry to the value's own
641
642
  // lifetime (e.g. a grant whose expiresAt the mutator just read);
642
643
  // otherwise the caller-resolved ttl applies.
643
- var effExpires = (decision.expiresAt !== undefined) ? decision.expiresAt : expiresAt;
644
+ var effExpires = (decision.ttlMs !== undefined) ? now + decision.ttlMs
645
+ : (decision.expiresAt !== undefined ? decision.expiresAt : expiresAt);
644
646
  var storedExpires = (effExpires === Infinity) ? Number.MAX_SAFE_INTEGER : effExpires;
645
647
  if (oldRaw === null) {
646
648
  // Row was absent/expired — insert, but lose the race if another
@@ -1186,9 +1188,13 @@ function create(opts) {
1186
1188
  *
1187
1189
  * `mutatorFn` returns one of: `{ value }` to commit the new value,
1188
1190
  * `{ abort: data }` to leave the entry untouched and surface `data` to
1189
- * the caller, or `{ delete: true }` to remove the entry. The call
1190
- * resolves to `{ updated: true, value }`, `{ updated: true, deleted: true }`,
1191
- * or `{ aborted: data }`.
1191
+ * the caller, or `{ delete: true }` to remove the entry. A committing
1192
+ * decision may also set the written value's lifetime `{ value, ttlMs }`
1193
+ * (a duration the backend resolves against its own clock) or
1194
+ * `{ value, expiresAt }` (an absolute time) — when the new value's own
1195
+ * state decides how long it should live; otherwise the call `ttlMs`
1196
+ * applies. The call resolves to `{ updated: true, value }`,
1197
+ * `{ updated: true, deleted: true }`, or `{ aborted: data }`.
1192
1198
  *
1193
1199
  * @opts
1194
1200
  * ttlMs: number | Infinity, // lifetime of the written value; default the instance ttlMs
package/lib/safe-async.js CHANGED
@@ -1611,6 +1611,47 @@ function flushLoop(fn, intervalMs, opts) {
1611
1611
  // We re-export them here under safe-async-shaped names so call sites
1612
1612
  // reaching for async safety primitives find them in one place.
1613
1613
 
1614
+ /**
1615
+ * @primitive b.safeAsync.keyedSerializer
1616
+ * @signature b.safeAsync.keyedSerializer()
1617
+ * @since 0.15.42
1618
+ * @status stable
1619
+ * @related b.safeAsync.parallel
1620
+ *
1621
+ * Serializes async work per key: `run(key, fn)` queues `fn` behind any in-flight
1622
+ * or queued work for the same `key` and runs it once they settle, so a
1623
+ * read-modify-write or a check-then-create on a shared store cannot interleave
1624
+ * with another call for the same key in the same process. Different keys run
1625
+ * concurrently. The per-key chain is dropped once it drains, so the map does not
1626
+ * grow without bound.
1627
+ *
1628
+ * In-process only: it serializes calls within ONE process. A registry shared
1629
+ * across processes still needs its backend's own atomic create / unique
1630
+ * constraint to refuse a cross-process duplicate.
1631
+ *
1632
+ * @example
1633
+ * var reg = b.safeAsync.keyedSerializer();
1634
+ * // concurrent register("acme") calls apply one-at-a-time, so the second
1635
+ * // sees the first's row and is refused as a duplicate:
1636
+ * await reg.run("acme", function () { return register("acme", row); });
1637
+ */
1638
+ function keyedSerializer() {
1639
+ var chains = new Map();
1640
+ function run(key, fn) {
1641
+ var prev = chains.get(key) || Promise.resolve();
1642
+ // Run fn after prev settles (resolved OR rejected) so one failure does not
1643
+ // wedge the key's queue. Wrap so prev's settled value/reason is NOT leaked
1644
+ // into fn as an argument — fn runs as a thunk with its own closure, never
1645
+ // the previous task's result.
1646
+ var result = prev.then(function () { return fn(); }, function () { return fn(); });
1647
+ var tail = result.then(function () {}, function () {});
1648
+ chains.set(key, tail);
1649
+ tail.then(function () { if (chains.get(key) === tail) chains.delete(key); });
1650
+ return result;
1651
+ }
1652
+ return { run: run };
1653
+ }
1654
+
1614
1655
  var retryHelper = require("./retry");
1615
1656
 
1616
1657
  var asyncRetry = retryHelper.withRetry;
@@ -1632,6 +1673,7 @@ module.exports = {
1632
1673
  makeBatchDrain: makeBatchDrain,
1633
1674
  makeBatchingSink: makeBatchingSink,
1634
1675
  parallel: parallel,
1676
+ keyedSerializer: keyedSerializer,
1635
1677
  Mutex: Mutex,
1636
1678
  Semaphore: Semaphore,
1637
1679
  Once: Once,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.41",
3
+ "version": "0.15.43",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:b6890c56-a06c-431c-b27d-d91ce739441d",
5
+ "serialNumber": "urn:uuid:bcd0c33c-a657-4ed3-b17c-eb1691a77bc5",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-28T08:26:04.321Z",
8
+ "timestamp": "2026-06-28T11:16:40.494Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.15.41",
22
+ "bom-ref": "@blamejs/core@0.15.43",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.41",
25
+ "version": "0.15.43",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.15.41",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.43",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.15.41",
57
+ "ref": "@blamejs/core@0.15.43",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]