@blamejs/core 0.13.40 → 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,8 @@ 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
+
11
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.
12
14
 
13
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.
@@ -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/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.40",
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:949239a7-6f00-4f14-9e96-43c3b5935fb9",
5
+ "serialNumber": "urn:uuid:857f3ba9-146d-4174-ba61-fed4e89f9c16",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-29T17:53:59.118Z",
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.40",
22
+ "bom-ref": "@blamejs/core@0.13.41",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.40",
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.40",
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.40",
57
+ "ref": "@blamejs/core@0.13.41",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]