@blamejs/core 0.13.39 → 0.13.41

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.13.x
10
10
 
11
+ - v0.13.41 (2026-05-29) — **Agent registry reads can be tenant-scoped; compliance-erasure docs clarify actor is an audit field.** The agent orchestrator's registry reads (list and lookup) gated only on the flat agent-registry:read scope, so any holder could enumerate every tenant's agents and resolve a handle to one — even though the event bus already scopes subscribe and delivery by tenant. The orchestrator now mirrors that: with the new tenantScope option enabled, list returns only the actor's own tenant's agents and lookup refuses a cross-tenant name, unless the actor holds the framework cross-tenant-admin scope. Off by default, so single-tenant deployments are unaffected. Separately, the subject-erasure docs now state explicitly that the recorded actor is an audit field, not authentication — the caller must be authorized upstream. **Added:** *Tenant-scoped agent registry reads (opts.tenantScope)* — `b.agent.orchestrator.create({ tenantScope: true })` now scopes `list` and `lookup` to the calling actor's tenant: `list` filters out agents in other tenants and `lookup` returns null for a cross-tenant name, unless the actor holds the cross-tenant-admin scope (`b.agent.tenant.CROSS_TENANT_ADMIN_SCOPE`). This closes a cross-tenant metadata-enumeration and handle-acquisition path — `agent-registry:read` alone no longer exposes other tenants' agents — and mirrors the tenant scoping the event bus enforces on subscribe and delivery. The option defaults off; existing single-tenant orchestrators behave exactly as before. The `tenantId` argument to `list` remains a convenience filter, distinct from this authorization boundary. **Changed:** *Subject-erasure docs clarify the actor is an audit field, not authentication* — `b.subject.erase` and `b.subject.eraseHard` gate the deletion on operator acknowledgements and the legal-hold registry, not on caller identity. Their documentation now states explicitly that the recorded `actor` is an audit-record field, not authentication — the caller MUST be authenticated and authorized by the route before invoking. No behavior change; this removes an implicit assumption that could otherwise be read as the primitive authorizing the call.
12
+
13
+ - v0.13.40 (2026-05-29) — **Redis client stops leaking a socket and blocking exit after close; DB exit-handler registers once.** Two handle-lifecycle fixes. The Redis client's reconnect backoff used an untracked, non-unref'd timer: during a backoff window it alone could keep the event loop alive (a process that won't exit), and a reconnect scheduled before close() fired afterward and opened a fresh socket because the connect path didn't re-check the closing flag. The timer is now tracked, unref'd, cancelled in close(), and the connect path refuses to re-open once closing. Separately, the encrypted database registered its process-exit final-flush handler on every init(), so repeated init/close cycles (long test runs, hot reload) accumulated 'exit' listeners toward the MaxListenersExceeded warning; it now registers once for the process lifetime. **Fixed:** *Redis client cancels its reconnect timer on close and won't re-open a closed connection* — The reconnect backoff scheduled `setTimeout(reconnect, delay)` without keeping a handle, without `unref()`, and the reconnect path checked only `connected`/`connecting` — not `closing`. So a backoff window could by itself hold the process open (it won't exit), and a reconnect scheduled before `close()` would fire afterward and open a fresh socket with listeners — a leak after explicit close. The timer is now tracked and `unref()`'d (a backoff no longer blocks exit), cancelled in `close()`, and the connect path returns early once closing so no socket is opened after close. · *Encrypted DB registers its process-exit flush handler once, not per init()* — `b.db.init()` in encrypted mode added a `process.on("exit")` final-flush handler on every call. Across repeated init/close cycles — long test suites, hot reload, embedded re-inits — these accumulated and tripped Node's MaxListenersExceeded warning (and grew memory slightly). The handler is now registered once for the process lifetime, guarded by a module flag, and still flushes whichever encrypted DB is open at exit time.
14
+
11
15
  - v0.13.39 (2026-05-29) — **Dual-control approvals are atomic — no quorum bypass or double-consume under concurrency.** The dual-control approval store read a grant, mutated it in memory, and wrote it back with an await in between — a non-atomic read-modify-write. Under concurrent calls (two approvals, or a retried one) each could act on a stale snapshot: the same approver could be appended twice and reach the M-of-N quorum with a single human, or a single-use grant could be consumed twice. approve / consume / revoke / cancel now commit through a new atomic cache.update primitive, so the check and the mutation are one indivisible step. The new b.cache.update is available to application code too — the memory backend is atomic by single-thread, and the cluster backend uses a transaction with compare-and-set so a concurrent writer on another node cannot lose an update. **Added:** *b.cache.update(key, mutatorFn, opts?) — atomic read-modify-write* — Reads the current value, calls `mutatorFn(current | null)`, and commits the result in one operation so a concurrent writer cannot clobber the change — the lost-update race that makes a plain `get` → mutate → `set` unsafe for counters, sets, and quorum state. The memory backend is atomic by single-thread; the cluster backend runs a transaction with a compare-and-set (and retries on contention) so the guarantee holds across nodes. `mutatorFn` returns `{ value }` to commit, `{ abort: data }` to leave the entry untouched and surface `data`, or `{ delete: true }` to remove it; the call resolves to `{ updated, value }`, `{ updated, deleted }`, or `{ aborted }`. **Security:** *Dual-control quorum and single-use guarantees hold under concurrent approvals* — `b.dualControl` persisted each grant through a cache read → in-memory mutate → write-back. Because the read and the write were separate awaited steps, two concurrent `approve` calls (or a retried one behind a load balancer) could each read the same pre-approval snapshot, so the duplicate-approver guard passed twice and the same approver was counted toward the M-of-N quorum twice — reaching quorum with one human. The same shape let two concurrent `consume` calls each see an unconsumed grant and both run the destructive operation. `approve` / `consume` / `revoke` / `cancel` now perform the check-and-mutate atomically via `cache.update`, so exactly one concurrent caller wins each transition.
12
16
 
13
17
  - v0.13.38 (2026-05-29) — **Atomic cache tag invalidation, and a clusterStorage.transaction primitive for multi-statement framework writes.** The cluster cache stored a value and its tag index with separate statements, so two concurrent writes to the same key could interleave their tag updates and leave the index out of step with the value — a later tag-based invalidation would then miss the key, letting a stale (possibly authorization-bearing) value survive a wipe. The value and tag writes now commit as one atomic unit. The enabling piece is a new b.clusterStorage.transaction primitive that runs a multi-statement read-modify-write all-or-nothing against the active backend — the external DB's pooled transaction in cluster mode, and a serialized transaction on the shared SQLite connection in single-node mode (so no concurrent statement can interleave into an open transaction). **Added:** *b.clusterStorage.transaction(fn) — atomic multi-statement framework-state writes* — Runs `fn` inside one transaction against the active backend so a multi-statement read-modify-write commits all-or-nothing. `fn` receives a transaction handle with the same `execute` / `executeOne` / `executeAll` surface, scoped to the open transaction. Cluster mode uses the external DB's pooled transaction (with its deadlock retry); single-node mode serializes against other transactions and against `execute` on the shared SQLite connection, so a concurrent statement cannot interleave into an open transaction. Use the handle's methods inside `fn` — calling the module-level `execute` from within `fn` would wait on the very transaction it is running. **Fixed:** *Cluster cache tag invalidation can no longer miss a key under concurrent writes* — The cluster cache wrote a value (`INSERT ... ON CONFLICT DO UPDATE`) and then rewrote its tag index (`DELETE` prior tags, `INSERT` new ones) as separate statements. Two concurrent `set()`s on the same key could interleave those tag statements, leaving the tag index inconsistent with the value — so a later `invalidateTag` could miss the key and a stale value would survive the wipe. The value and tag writes now run inside a single `clusterStorage.transaction`, so a concurrent writer observes either the whole prior state or the whole new state, never a mix.
@@ -64,6 +64,7 @@ var cluster = lazyRequire(function () { return require("./cluster"); }
64
64
  var vault = lazyRequire(function () { return require("./vault"); });
65
65
  var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
66
66
  var safeJson = require("./safe-json");
67
+ var agentTenant = lazyRequire(function () { return require("./agent-tenant"); });
67
68
 
68
69
  var AgentOrchestratorError = defineClass("AgentOrchestratorError", { alwaysPermanent: true });
69
70
 
@@ -168,6 +169,12 @@ function create(opts) {
168
169
  cluster: clusterImpl,
169
170
  audit: auditImpl,
170
171
  permissions: permissions,
172
+ // When true, registry reads (list / lookup) are scoped to the actor's
173
+ // tenant — an actor only sees / resolves agents in its own tenant
174
+ // unless it holds the cross-tenant-admin scope. Mirrors the tenant
175
+ // scoping agent-event-bus enforces on subscribe / delivery. Off by
176
+ // default (single-tenant deployments are unaffected).
177
+ tenantScope: opts.tenantScope === true,
171
178
  spawnedConsumers: [],
172
179
  streams: new Map(),
173
180
  elections: new Map(),
@@ -358,6 +365,19 @@ async function _unregister(ctx, name, args) {
358
365
  async function _lookup(ctx, name, args) {
359
366
  guardAgentRegistry.validate({ kind: "lookup", name: name }, {});
360
367
  _checkPermission(ctx, args.actor, "agent-registry:read");
368
+ // Tenant-scope gate: the row's declared tenant gates access even to a
369
+ // live in-process ref, so an actor can't acquire a handle to another
370
+ // tenant's agent. Consult the backend row (it exists as a metadata
371
+ // declaration even where a live ref is hydrated).
372
+ if (ctx.tenantScope) {
373
+ var sealedRow = await ctx.backend.get(name);
374
+ var declRow = sealedRow ? _unsealRegistryRow(sealedRow) : null;
375
+ if (declRow && !_tenantAllows(ctx, args.actor, declRow.tenantId)) {
376
+ _safeAudit(ctx, "agent.orchestrator.lookup_denied", args.actor,
377
+ { name: name, reason: "cross-tenant" });
378
+ return null;
379
+ }
380
+ }
361
381
  // Live agent ref lives in-process; the backend row exists only as
362
382
  // a metadata declaration. In multi-process deployments each process
363
383
  // hydrates its own liveAgents map by calling register() locally.
@@ -383,6 +403,10 @@ async function _list(ctx, args) {
383
403
  return rows.filter(function (r) {
384
404
  if (args.kind && r.kind !== args.kind) return false;
385
405
  if (args.tenantId && r.tenantId !== args.tenantId) return false;
406
+ // Tenant-scope gate: drop rows the actor's tenant may not see, so
407
+ // enumeration can't disclose other tenants' agents. The args.tenantId
408
+ // above is a caller-supplied FILTER, not an authorization boundary.
409
+ if (!_tenantAllows(ctx, args.actor, r.tenantId)) return false;
386
410
  return true;
387
411
  }).map(function (r) {
388
412
  return {
@@ -753,6 +777,21 @@ function _checkPermission(ctx, actor, scope) {
753
777
  }
754
778
  }
755
779
 
780
+ // Tenant-scope gate for registry reads. Returns true when the actor may
781
+ // see / resolve an agent row in `rowTenantId`: scoping disabled, the actor
782
+ // holds the cross-tenant-admin scope, or the actor's tenant matches the
783
+ // row's. Mirrors agent-tenant's CROSS_TENANT_ADMIN_SCOPE check so registry
784
+ // enumeration can't leak agents (or hand out live refs) across tenants.
785
+ function _tenantAllows(ctx, actor, rowTenantId) {
786
+ if (!ctx.tenantScope) return true;
787
+ if (ctx.permissions && actor &&
788
+ ctx.permissions.check(actor, agentTenant().CROSS_TENANT_ADMIN_SCOPE)) {
789
+ return true;
790
+ }
791
+ var actorTenant = (actor && actor.tenantId) || null;
792
+ return actorTenant !== null && actorTenant === (rowTenantId || null);
793
+ }
794
+
756
795
  function _safeAudit(ctx, action, actor, metadata) {
757
796
  agentAudit.safeAudit(ctx.audit, action, actor, metadata);
758
797
  }
package/lib/db.js CHANGED
@@ -142,6 +142,12 @@ var storageProbeTimer = null; // periodic free-space probe handle
142
142
  var writesRefused = false; // true when free space < minFreeBytes
143
143
  var minFreeBytes = 0; // refuse growth writes below this (0 = guard off)
144
144
  var statfsProbe = null; // free-space reader (fs.statfsSync; injectable for tests)
145
+ // The process-exit final-flush handler is registered ONCE at first
146
+ // encrypted init. Re-registering per init() leaked an 'exit' listener on
147
+ // every init/close cycle (MaxListenersExceeded in long test runs / hot
148
+ // reload); the flag makes it idempotent. The handler reads live module
149
+ // state at exit time, so a later re-init is still covered.
150
+ var _exitHandlerRegistered = false;
145
151
  var dataDir = null;
146
152
  var initialized = false;
147
153
  var dataResidency = null; // operator's declared region config (validated by storage backends)
@@ -1436,10 +1442,16 @@ async function init(opts) {
1436
1442
 
1437
1443
  // Final encrypt on process exit. We don't try to unlink the plaintext
1438
1444
  // here — the SQLite handle may still be open, and the OS reclaims tmpfs
1439
- // on reboot anyway. close() does the orderly shutdown.
1440
- process.on("exit", function () {
1441
- try { encryptToDisk(); } catch (_e) { /* exit handler silent */ }
1442
- });
1445
+ // on reboot anyway. close() does the orderly shutdown. Registered ONCE
1446
+ // (guarded by the module flag) — re-registering per init() leaked an
1447
+ // 'exit' listener on every init/close cycle. The handler reads live
1448
+ // module state, so it still flushes whatever DB is open at exit.
1449
+ if (!_exitHandlerRegistered) {
1450
+ _exitHandlerRegistered = true;
1451
+ process.on("exit", function () {
1452
+ try { if (atRest === "encrypted") encryptToDisk(); } catch (_e) { /* exit handler — silent */ }
1453
+ });
1454
+ }
1443
1455
  }
1444
1456
 
1445
1457
  log("ready (mode: " + atRest + ", path: " + dbPath + ")");
@@ -182,6 +182,11 @@ function create(opts) {
182
182
  var connected = false;
183
183
  var connecting = false;
184
184
  var closing = false;
185
+ // Tracked + unref'd reconnect timer. Tracked so close() can cancel a
186
+ // pending backoff (otherwise a reconnect scheduled before close fires
187
+ // after it and opens a fresh socket); unref'd so a backoff window doesn't
188
+ // by itself keep the event loop alive (the process-won't-exit class).
189
+ var reconnectTimer = null;
185
190
  var rxBuffer = Buffer.alloc(0);
186
191
  // FIFO of in-flight commands awaiting a response
187
192
  var pending = [];
@@ -210,7 +215,11 @@ function create(opts) {
210
215
  }
211
216
  reconnectAttempt++;
212
217
  var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
213
- setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
218
+ reconnectTimer = setTimeout(function () {
219
+ reconnectTimer = null;
220
+ _connect().catch(function () { /* will reschedule */ });
221
+ }, delay);
222
+ if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
214
223
  }
215
224
 
216
225
  function _drainPending(err) {
@@ -290,6 +299,9 @@ function create(opts) {
290
299
  }
291
300
 
292
301
  async function _connect() {
302
+ // A reconnect timer scheduled before close() can still fire afterward;
303
+ // refuse to re-open once closing so it doesn't leak a fresh socket.
304
+ if (closing) return;
293
305
  if (connected) return;
294
306
  if (connecting) {
295
307
  // Wait until current connect attempt resolves
@@ -423,6 +435,7 @@ function create(opts) {
423
435
 
424
436
  async function close() {
425
437
  closing = true;
438
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
426
439
  var err = _err("CLOSED", "redis client closed");
427
440
  _drainPending(err);
428
441
  if (socket) {
package/lib/subject.js CHANGED
@@ -257,6 +257,11 @@ function rectify(subjectId, opts) {
257
257
  * override an active hold (FRCP Rule 26/37(e), GDPR Art 17(3)(e),
258
258
  * SEC Rule 17a-4, HIPAA §164.530(j)(2)).
259
259
  *
260
+ * Security: any `actor` recorded here is an audit-record field, NOT
261
+ * authentication. This primitive gates the deletion on acknowledgements
262
+ * and the legal-hold registry, not on caller identity — the caller MUST be
263
+ * authenticated and authorized by your route before invoking.
264
+ *
260
265
  * Returns `{ rowsDeleted, perTable }`. Use `b.subject.eraseHard` when
261
266
  * residual ciphertext in WAL / replicas / backups must also be made
262
267
  * undecryptable.
@@ -383,7 +388,9 @@ function erase(subjectId, opts) {
383
388
  * Art. 17 erasure shape the framework offers.
384
389
  *
385
390
  * Same legal-hold + acknowledgement gates as `b.subject.erase`.
386
- * Leader-only. Returns `{ rowsDeleted, perRowKeysDestroyed, perTable }`.
391
+ * Security: the `actor` is an audit-record field, not authentication —
392
+ * authorize the caller upstream. Leader-only.
393
+ * Returns `{ rowsDeleted, perRowKeysDestroyed, perTable }`.
387
394
  *
388
395
  * @opts
389
396
  * reason: string, // ticket reference recorded in the audit event
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.39",
3
+ "version": "0.13.41",
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:8c1e3aad-b831-4d99-b528-7ca57a3a6ba3",
5
+ "serialNumber": "urn:uuid:857f3ba9-146d-4174-ba61-fed4e89f9c16",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-29T17:14:11.141Z",
8
+ "timestamp": "2026-05-29T18:38:58.148Z",
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.13.39",
22
+ "bom-ref": "@blamejs/core@0.13.41",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.39",
25
+ "version": "0.13.41",
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.13.39",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.41",
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.13.39",
57
+ "ref": "@blamejs/core@0.13.41",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]