@blamejs/core 0.13.24 → 0.13.26

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.26 (2026-05-28) — **`b.cryptoField.unsealRow` nulls a sealed column on unseal failure instead of returning the forged ciphertext.** When a sealed column failed to unseal — a DB-write attacker's forged `vault:<…>` payload, or a valid ciphertext copied into a different row so the AAD no longer matches — unsealRow recorded the failure on the audit chain but then kept the original attacker-crafted string in the field rather than nulling it, despite the documented contract that downstream sees 'no value'. A write-back guard discarded the intended null on the failure path. The column is now nulled on any unseal failure, so a forged or cross-row-copied value never reaches downstream code as if it were a real plaintext. Valid values round-trip unchanged and genuinely-unsealed pass-through values are still kept. This hardens every sealed-column reader, including the agent idempotency / orchestrator / tenant rows sealed in 0.13.25. **Fixed:** *Audit checkpoint docs name the actual signature algorithm* — `b.audit.checkpoint` / `b.audit.verifyCheckpoints` described the anchor signature as ML-DSA-87, but the checkpoint is signed with the configured `b.auditSign` algorithm — SLH-DSA-SHAKE-256f by default (ML-DSA-87 / ML-DSA-65 are opt-in). The docs and the verify-failure reason now refer to the post-quantum signature without naming a specific algorithm the operator may not be using. · *`b.storage.chunkScratch` example and assembly description corrected* — The `assemble()` example omitted the mandatory `chunkEncryptionKeys` argument (one sealed key per chunk, returned by `saveChunk`), so it would have thrown as written; it now collects and passes the keys. The prose no longer claims the primitive writes a final file with an 'atomic finalize' — `assemble()` concatenates the chunks in order and returns the assembled bytes for the caller to persist. **Security:** *Sealed columns are nulled on unseal failure (forged / cross-row ciphertext)* — `b.cryptoField.unsealRow` now nulls a sealed field when its value fails to unseal — a crafted `vault:`/`vault.aad:` payload written by a DB-write attacker, or a valid ciphertext copied to a different row (AAD mismatch). Previously the field kept the attacker-controlled string, so downstream code could read the forged ciphertext as if it were the plaintext. The audit emit (`system.crypto.unseal_failed`) is unchanged. Valid round-trips and not-actually-sealed pass-through values are unaffected. A regression test pins the forged-value, cross-row-copy, and pass-through cases.
12
+
13
+ - v0.13.25 (2026-05-28) — **Agent idempotency results and orchestrator/tenant registry rows are sealed at rest.** The b.agent.idempotency, b.agent.orchestrator, and b.agent.tenant primitives documented their stored rows as sealed at rest, but the values were written as plaintext JSON — a database dump could expose cached result payloads (which can carry mail-move / search data), the tenant ids that own each agent, and operator-supplied endpoint metadata. Those values are now sealed via b.cryptoField (XChaCha20-Poly1305 through the vault) before they reach the backing store and unsealed on read, when a vault is configured — which is the default in a booted app. Each ciphertext is AAD-bound to its row identity so a database-write attacker cannot copy a sealed value between rows. Reads of rows written before this release (plain JSON) continue to work unchanged, and a vault-less deployment stores rows as before. No API or call-site changes are required. **Fixed:** *`b.agent.saga` run() return/throw shape documented correctly* — The doc said `run()` resolves to `{ status: "failed", failedStep, lastCompensationError }` on failure and to a bare final state. In fact it resolves to `{ status: "completed", sagaId, state }` on success and rejects (throws) on step failure with an error carrying `failedStep`, `cause`, `compensationCause`, and `failedCompStepName`. The docs now describe the actual contract. · *`b.agent.idempotency` put() example uses the real option name* — The example passed `{ argsFingerprint: ... }`, which the function does not read; the fingerprint option is `requestFingerprint` (or `args`). The example now uses `requestFingerprint`. · *`b.agent.tenant.derivedKey` @since corrected to 0.9.26* — It was tagged `@since 0.9.25`, a version before the tenant module existed; it ships in 0.9.26 alongside `b.agent.tenant.create`. · *`b.agent.trace.injectIntoEnvelope` documented as single-argument* — The doc listed a second `currentSpan` argument the function ignores — it always injects the currently-active span's trace context. The doc now shows `injectIntoEnvelope(envelope)` and notes it should be called while the intended span is active. **Security:** *Agent idempotency / orchestrator / tenant rows sealed at rest* — `b.agent.idempotency` cached result blobs, `b.agent.orchestrator` registry rows (owning tenant id + endpoint metadata), and `b.agent.tenant` registry-row metadata are now sealed via `b.cryptoField` when a vault is configured (the default in a booted app via `b.start`). The fields were previously stored as plaintext despite the docs describing them as sealed, so a DB dump exposed cached payloads, agent↔tenant ownership, and endpoint detail. Each sealed value is AAD-bound to the row identity (the idempotency key hash / the agent name / the tenant id), so a sealed value cannot be copied between rows. Rows written before this release remain readable (a non-sealed value passes through unseal), and a vault-less deployment behaves as before. No call-site changes.
14
+
11
15
  - v0.13.24 (2026-05-28) — **`b.guard*` docs corrected: the compliance-posture opt key, the gate API, and validate return shapes.** Documentation corrections across the b.guard* family. The most consequential: the posture-selection option was documented as `compliance:` in many guards' @opts, but the working key is `compliancePosture:` — passing the documented `{ compliance: "hipaa" }` was silently ignored, so a compliance posture (e.g. HIPAA PII redaction) never activated. If you select a posture via the gate/validate/sanitize options, use `compliancePosture:`; `compliance:` had no effect. The guard docs now name the correct key uniformly. Also corrected: gate examples and prose that invoked the gate as a callable or via `.run` / `.inspect` (the gate is an object whose method is `.check(ctx)`), and validate() return shapes that listed `severities` / `summary` / `refusal` fields the function never returned (it returns `{ ok, issues }`). **Fixed:** *guard posture option is `compliancePosture:`, not `compliance:`* — Many `b.guard*` primitives documented the compliance-posture selector as `compliance: "hipaa"|"pci-dss"|"gdpr"|"soc2"` in their `@opts`, but the family resolver reads `compliancePosture:`. Passing `{ compliance: "hipaa" }` was accepted and silently ignored — the posture overlay (e.g. CSV `piiPolicy: "redact"` under HIPAA) never applied, leaving the default policy in force. The docs across the guard family now name `compliancePosture:` consistently (the key the resolver and `b.guardX.compliancePosture(name)` already used). Action: if you selected a posture with `compliance:`, switch to `compliancePosture:` — the posture was not taking effect before. · *guard gate is an object with `.check(ctx)`, not a callable* — Several guards' gate `@example`s and prose invoked the gate as a function (`g({...})`), via `.run(...)`, or via `.inspect(...)`, and a few described it as "an async function". `b.guardX.gate(opts)` returns an object whose async method is `.check(ctx)`; the examples and prose now use `.check`, so they run as written. · *guard validate() returns `{ ok, issues }`* — Several guards documented `validate()` as returning `{ ok, issues, severities }`, `{ ok, issues, summary }`, or `{ ok, issues, refusal? }`. The function returns `{ ok, issues }` (each issue carries its own `severity` / `kind`); the documented extra top-level fields were never present. The docs now state the actual shape.
12
16
 
13
17
  - v0.13.23 (2026-05-28) — **Documentation corrected to match actual behavior across several primitives.** A set of JSDoc / doc-comment corrections where the documented contract had drifted from what the code does. No behavior changes — the implementations already behaved as now documented; only the docs were wrong. The most operator-relevant is the JWT signer doc: an expiring token signed without an explicit jti receives an auto-minted 128-bit jti (so the replay-defense path has the jti it needs), which the sign-opts doc previously denied. Also corrected: did.resolve's unsupported-method error now names did:jwk (always supported); b.cose.verify is marked stable to match its stable sign sibling and the CWT / EAT / SCITT / mdoc verifiers built on it; b.linkHeader.serialize's doc now states every parameter value is double-quoted; b.auth.saml verifyResponse's documented return shape now lists inResponseTo and issuer (both always returned); and the rate-limit custom-backend contract drops a gc member the middleware never invoked. **Fixed:** *JWT signer doc now describes the auto-minted jti on expiring tokens* — `b.auth.jwt.sign`'s opts doc claimed that omitting `jti` adds no jti. In fact, when a token carries an `exp` and no operator-supplied `jti`, the signer auto-mints a random 128-bit `jti` so a replay-protected token always carries the identifier `verify`'s replay store requires. The doc now describes this; pass an explicit `jti` for a deterministic value. Behavior is unchanged. · *`b.did.resolve` unsupported-method error now names did:jwk* — The thrown error for an unsupported DID method listed only `did:key` and `did:web`, omitting `did:jwk`, which `resolve` fully supports. The message now reads `(did:key, did:jwk, and did:web only)`. · *`b.cose.verify` marked stable* — `b.cose.verify` carried `@status experimental` while its `b.cose.sign` sibling is stable and the CWT / EAT / SCITT / mdoc verifiers that depend on it are stable and shipped. The verifier is the same maturity as the rest of the COSE_Sign1 round-trip; its status now reflects that. · *`b.linkHeader.serialize` doc matches its quoting behavior* — The doc said parameters are token-encoded when they fit RFC 7230 token grammar and double-quoted otherwise. The serializer always double-quotes every value (valid under RFC 8288, and required for space-separated multi-rel and media-type values). The doc now states that. · *`b.auth.saml` verifyResponse documented return shape lists all fields* — The prose and the example each omitted a different field that `verifyResponse` always returns. The documented shape now lists all of `nameId`, `nameIdFormat`, `sessionIndex`, `attributes`, `audience`, `inResponseTo`, and `issuer`. · *rate-limit custom-backend contract is `{ take, reset }`* — The custom-backend opts doc listed a `gc` member that the middleware never reads or invokes (the runtime contract is `take` / `reset` / `close`, and the error message already said `{ take, reset }`). The documented shape now matches; an operator-supplied `gc` was always silently ignored. · *`b.mail.agent.create` doc no longer lists consumer as a method* — The created agent's method list named `consumer`, which is not a method on the returned object — the queue consumer is the sibling export `b.mail.agent.consumer`. The doc now says so.
@@ -12,10 +12,13 @@
12
12
  * generic agent-shaped surface:
13
13
  *
14
14
  * - **`instance.get(method, actorId, key)`** — returns cached
15
- * result envelope or `null`. Sealed columns unseal via
16
- * `b.cryptoField`.
15
+ * result envelope or `null`. The result blob unseals via
16
+ * `b.cryptoField` when a vault is configured.
17
17
  * - **`instance.put(method, actorId, key, result, opts?)`** —
18
- * serialize (`b.safeJson.stringify`) + seal + persist with TTL.
18
+ * serialize (`b.safeJson.stringify`), seal the result blob at rest
19
+ * via `b.cryptoField` (when a vault is configured — the default in
20
+ * a booted app; vault-less, the blob is stored as-is), persist
21
+ * with TTL.
19
22
  * Refuses if the same `(method, actorId, key)` already has a
20
23
  * cached entry whose `requestFingerprint` differs from the
21
24
  * supplied args fingerprint (defends key-reuse-different-args
@@ -67,11 +70,35 @@ var guardIdempotencyKey = require("./guard-idempotency-key");
67
70
  var agentAudit = require("./agent-audit");
68
71
 
69
72
  var audit = lazyRequire(function () { return require("./audit"); });
73
+ var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
74
+ var vault = lazyRequire(function () { return require("./vault"); });
70
75
 
71
76
  var AgentIdempotencyError = defineClass("AgentIdempotencyError", { alwaysPermanent: true });
72
77
 
73
78
  var DEFAULT_TTL_MS = C.TIME.hours(24);
74
79
  var MAX_RESULT_BYTES = C.BYTES.mib(1);
80
+
81
+ // At-rest sealing of the cached result. The result envelope can hold
82
+ // mail-move / search payloads, so the serialized blob is sealed via
83
+ // b.cryptoField before it reaches the backing store and unsealed on
84
+ // read — when a vault is configured (the default in a booted app via
85
+ // b.start). Without a vault there is no key, so the blob is stored
86
+ // as-is, the same vault-less mode the orchestrator's salted-FNV
87
+ // fallback supports. AAD binds each ciphertext to its keyHash (the row
88
+ // identity) so a DB-write attacker cannot copy a sealed result between
89
+ // rows. Rows written while vault-less (or before sealing landed) are
90
+ // plain JSON; unsealRow passes a non-`vault:` value through unchanged.
91
+ var SEAL_TABLE = "agent_idempotency";
92
+ var _sealTableRegistered = false;
93
+ function _ensureSealTable() {
94
+ if (_sealTableRegistered) return;
95
+ cryptoField().registerTable(SEAL_TABLE, {
96
+ sealedFields: ["resultBlob"],
97
+ aad: true,
98
+ rowIdField: "keyHash",
99
+ });
100
+ _sealTableRegistered = true;
101
+ }
75
102
  // Parse ceiling tracks the operator's configured maxResultBytes (set
76
103
  // per-instance via opts.maxResultBytes) — see _get. A static parse cap
77
104
  // would silently lose entries when operators raise the write cap.
@@ -99,7 +126,7 @@ var MAX_RESULT_BYTES = C.BYTES.mib(1);
99
126
  * var existing = await idem.get("move", "u1", "jmap-req-abc");
100
127
  * if (existing) return existing.result;
101
128
  * var result = await mailAgent.move(args);
102
- * await idem.put("move", "u1", "jmap-req-abc", result, { argsFingerprint: "..." });
129
+ * await idem.put("move", "u1", "jmap-req-abc", result, { requestFingerprint: "..." });
103
130
  */
104
131
  function create(opts) {
105
132
  opts = opts || {};
@@ -242,17 +269,22 @@ async function _get(store, method, actorId, key, auditImpl, ttlMs, maxResultByte
242
269
  { method: method, actorIdHash: _truncHash(_actorIdHash(actorId)) });
243
270
  return null;
244
271
  }
245
- // Deserialize the sealed result blob. v0.9.22 ships a simple
246
- // safeJson re-parse since the result was JSON-stringified at put().
247
- // v0.9.25 tenant integration will swap this for per-tenant sealRow
248
- // unseal when the row is sealed at rest.
272
+ // Unseal the result blob into a copy (when a vault is configured;
273
+ // vault-less or pre-sealing rows are plain JSON and used as-is). The
274
+ // original sealed `row` is preserved so the replay-count re-put below
275
+ // cannot round-trip plaintext back to the store.
276
+ var unsealed = row;
277
+ if (vault().isInitialized()) {
278
+ _ensureSealTable();
279
+ unsealed = cryptoField().unsealRow(SEAL_TABLE, row);
280
+ }
249
281
  var result;
250
282
  try {
251
283
  // Parse cap mirrors the operator's configured maxResultBytes (the
252
284
  // same cap put() enforced on write) — a static parse ceiling would
253
285
  // turn valid cached entries into permanent replay errors when the
254
286
  // operator raises the write cap.
255
- result = safeJson.parse(row.resultBlob, { maxBytes: maxResultBytes });
287
+ result = safeJson.parse(unsealed.resultBlob, { maxBytes: maxResultBytes });
256
288
  } catch (e) {
257
289
  throw new AgentIdempotencyError("agent-idempotency/corrupt-result",
258
290
  "get: cached result failed to parse — " + (e && e.message ? e.message : String(e)));
@@ -333,7 +365,15 @@ async function _put(store, method, actorId, key, result, putOpts, ttlMs, maxResu
333
365
  replayCount: existing ? (existing.replayCount || 0) : 0,
334
366
  expiresAt: now + ttlMs,
335
367
  };
336
- await store.put(method, actorId, hash, row);
368
+ // Seal the result blob at rest when a vault is configured (keyHash is
369
+ // populated above, so the AAD binding resolves). resultBlob stays the
370
+ // plaintext local for the audit byte-count below.
371
+ var sealedRow = row;
372
+ if (vault().isInitialized()) {
373
+ _ensureSealTable();
374
+ sealedRow = cryptoField().sealRow(SEAL_TABLE, row);
375
+ }
376
+ await store.put(method, actorId, hash, sealedRow);
337
377
  _safeAudit(auditImpl, "agent.idempotency.put", null, {
338
378
  method: method, actorIdHash: _truncHash(row.actorIdHash),
339
379
  resultBytes: Buffer.byteLength(resultBlob, "utf8"),
@@ -13,9 +13,10 @@
13
13
  *
14
14
  * - **Registry** (`register` / `lookup` / `unregister` / `list`)
15
15
  * — pluggable backend; in-memory default, durable via operator-
16
- * supplied `b.config.loadDbBacked` for restart-survival. Sealed
17
- * rows so tenant names + endpoint metadata don't leak in DB
18
- * dumps.
16
+ * supplied `b.config.loadDbBacked` for restart-survival. Rows are
17
+ * sealed at rest via `b.cryptoField` when a vault is configured
18
+ * (the default in a booted app), so tenant names + endpoint
19
+ * metadata don't leak in DB dumps.
19
20
  * - **Sharded topics** (`spawnConsumers`) — consistent-hash route
20
21
  * per-shard so each tenant's traffic owns one shard's ordering.
21
22
  * - **Leader-elected singletons** (`elect`) — composes `b.cluster`
@@ -61,9 +62,59 @@ var agentAudit = require("./agent-audit");
61
62
  var audit = lazyRequire(function () { return require("./audit"); });
62
63
  var cluster = lazyRequire(function () { return require("./cluster"); });
63
64
  var vault = lazyRequire(function () { return require("./vault"); });
65
+ var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
66
+ var safeJson = require("./safe-json");
64
67
 
65
68
  var AgentOrchestratorError = defineClass("AgentOrchestratorError", { alwaysPermanent: true });
66
69
 
70
+ // At-rest sealing of registry rows. The owning tenant id and the
71
+ // operator-supplied endpoint metadata are sealed via b.cryptoField
72
+ // before a row reaches the backend, so a DB dump does not leak which
73
+ // tenants own which agents or their endpoint detail — when a vault is
74
+ // configured (the default in a booted app via b.start). Without a vault
75
+ // there is no key, so rows are stored as-is (the same vault-less mode
76
+ // the salted-FNV shard fallback below supports). AAD binds each
77
+ // ciphertext to the agent `name` (the row identity). `metadata` is an
78
+ // object, so it is JSON-serialized before sealing and parsed back on
79
+ // read; `tenantId` may be null (sealRow leaves null fields untouched).
80
+ // Vault-less or pre-sealing rows carry plain values; unsealRow passes a
81
+ // non-sealed value through, so they still read.
82
+ var SEAL_TABLE = "agent_orchestrator_registry";
83
+ var _sealTableRegistered = false;
84
+ var SEAL_METADATA_MAX_BYTES = C.BYTES.mib(1);
85
+ function _ensureSealTable() {
86
+ if (_sealTableRegistered) return;
87
+ cryptoField().registerTable(SEAL_TABLE, {
88
+ sealedFields: ["tenantId", "metadata"],
89
+ aad: true,
90
+ rowIdField: "name",
91
+ });
92
+ _sealTableRegistered = true;
93
+ }
94
+ function _sealRegistryRow(row) {
95
+ if (!vault().isInitialized()) return row; // vault-less: store as-is (no key)
96
+ _ensureSealTable();
97
+ var pre = Object.assign({}, row);
98
+ if (pre.metadata !== undefined && pre.metadata !== null && typeof pre.metadata !== "string") {
99
+ pre.metadata = safeJson.stringify(pre.metadata);
100
+ }
101
+ return cryptoField().sealRow(SEAL_TABLE, pre);
102
+ }
103
+ function _unsealRegistryRow(row) {
104
+ if (!row) return row;
105
+ if (!vault().isInitialized()) return row; // vault-less: rows are plain
106
+ _ensureSealTable();
107
+ var out = cryptoField().unsealRow(SEAL_TABLE, row);
108
+ // New rows stored metadata as a sealed JSON string; legacy rows stored
109
+ // it as a plain object (which passes through unseal untouched). Only
110
+ // the string form needs parsing back to an object.
111
+ if (typeof out.metadata === "string") {
112
+ try { out.metadata = safeJson.parse(out.metadata, { maxBytes: SEAL_METADATA_MAX_BYTES }); }
113
+ catch (_e) { /* leave as-is — operator-stored raw string metadata */ }
114
+ }
115
+ return out;
116
+ }
117
+
67
118
  var DEFAULT_DRAIN_TIMEOUT_MS = C.TIME.minutes(2);
68
119
  var STREAM_ID_RAND_BYTES = 8; // allow:raw-byte-literal — stream-id random-suffix byte length, not a size cap
69
120
  var DEFAULT_PER_CONSUMER_STOP_MS = C.TIME.seconds(5);
@@ -278,7 +329,9 @@ async function _register(ctx, name, agent, regOpts) {
278
329
  registeredAt: Date.now(),
279
330
  metadata: regOpts.metadata || {},
280
331
  };
281
- await ctx.backend.set(name, row);
332
+ // Seal tenantId + metadata at rest (name is populated, so the AAD
333
+ // binding resolves). The plaintext `row` is kept for the audit below.
334
+ await ctx.backend.set(name, _sealRegistryRow(row));
282
335
  ctx.liveAgents.set(name, agent);
283
336
  _safeAudit(ctx, "agent.orchestrator.registered", regOpts.actor, {
284
337
  name: name, agentKind: regOpts.agentKind, tenantId: row.tenantId,
@@ -326,7 +379,7 @@ async function _lookup(ctx, name, args) {
326
379
  async function _list(ctx, args) {
327
380
  guardAgentRegistry.validate({ kind: "list" }, {});
328
381
  _checkPermission(ctx, args.actor, "agent-registry:read");
329
- var rows = await ctx.backend.list();
382
+ var rows = (await ctx.backend.list()).map(_unsealRegistryRow);
330
383
  return rows.filter(function (r) {
331
384
  if (args.kind && r.kind !== args.kind) return false;
332
385
  if (args.tenantId && r.tenantId !== args.tenantId) return false;
package/lib/agent-saga.js CHANGED
@@ -51,8 +51,10 @@
51
51
  *
52
52
  * Compensations that throw emit `agent.saga.compensation_failed`
53
53
  * audit at CRITICAL severity and halt further compensations
54
- * (operator alert; manual intervention needed). The saga returns
55
- * `{ status: "failed", failedStep, lastCompensationError }`.
54
+ * (operator alert; manual intervention needed). On step failure the
55
+ * saga REJECTS (throws) rather than resolving — the thrown error
56
+ * carries `failedStep`, `cause` (the originating step error),
57
+ * `compensationCause`, and `failedCompStepName`.
56
58
  *
57
59
  * ## No saga-level retry
58
60
  *
@@ -87,8 +89,10 @@ var SAGA_ID_RAND_BYTES = 8;
87
89
  * @status stable
88
90
  * @related b.agent.idempotency.create, b.outbox.enqueue
89
91
  *
90
- * Create a saga definition. Returns an instance with `run(ctx,
91
- * initialState, opts) Promise<finalState>`.
92
+ * Create a saga definition. Returns an instance whose `run(ctx,
93
+ * initialState, opts)` resolves to `{ status: "completed", sagaId,
94
+ * state }` on success and rejects (throws) on step failure with an
95
+ * error carrying the failed-step + compensation detail (see the intro).
92
96
  *
93
97
  * @opts
94
98
  * name: string, // required (audit label)
@@ -11,8 +11,9 @@
11
11
  * tends to leak across handlers, with one centralized scope:
12
12
  *
13
13
  * - **Registry** — `register(tenantId, config)` declares a tenant
14
- * boundary at boot. Sealed registry rows so tenant metadata
15
- * doesn't leak in DB dumps.
14
+ * boundary at boot. The row's metadata is sealed at rest via
15
+ * `b.cryptoField` when a vault is configured (the default in a
16
+ * booted app), so tenant metadata doesn't leak in DB dumps.
16
17
  * - **Cross-tenant gate** — `check(actor, agentTenantId)` refuses
17
18
  * calls where `actor.tenantId !== agentTenantId` unless the
18
19
  * actor holds the `framework.cross-tenant-admin` scope.
@@ -51,10 +52,12 @@
51
52
  */
52
53
 
53
54
  var lazyRequire = require("./lazy-require");
55
+ var C = require("./constants");
54
56
  var { defineClass } = require("./framework-error");
55
57
  var guardTenantId = require("./guard-tenant-id");
56
58
  var bCrypto = require("./crypto");
57
59
  var agentAudit = require("./agent-audit");
60
+ var safeJson = require("./safe-json");
58
61
 
59
62
  var audit = lazyRequire(function () { return require("./audit"); });
60
63
  var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
@@ -62,6 +65,52 @@ var vault = lazyRequire(function () { return require("./vault"); });
62
65
 
63
66
  var AgentTenantError = defineClass("AgentTenantError", { alwaysPermanent: true });
64
67
 
68
+ // At-rest sealing of the tenant registry row's metadata. The registry
69
+ // maps a tenantId to its config; the operator-supplied `metadata` is
70
+ // sealed via b.cryptoField before reaching the backend so a DB dump
71
+ // does not leak it — when a vault is configured (the default in a
72
+ // booted app via b.start). Without a vault there is no key, so the row
73
+ // is stored as-is. The registry is framework-owned coordination state,
74
+ // so it uses the singleton vault key (cryptoField.sealRow) — the
75
+ // per-tenant `sealRowForTenant` path below is for tenant DATA tables
76
+ // where cross-tenant cryptographic isolation matters. AAD binds the
77
+ // ciphertext to the tenantId (the row identity); `metadata` is an
78
+ // object, so it is JSON-serialized before sealing and parsed on read.
79
+ // Rows written before sealing landed carry a plain object; unsealRow
80
+ // passes a non-sealed value through, so they still read.
81
+ var SEAL_TABLE = "agent_tenant_registry";
82
+ var _sealTableRegistered = false;
83
+ var SEAL_METADATA_MAX_BYTES = C.BYTES.mib(1);
84
+ function _ensureSealTable() {
85
+ if (_sealTableRegistered) return;
86
+ cryptoField().registerTable(SEAL_TABLE, {
87
+ sealedFields: ["metadata"],
88
+ aad: true,
89
+ rowIdField: "tenantId",
90
+ });
91
+ _sealTableRegistered = true;
92
+ }
93
+ function _sealRegistryRow(row) {
94
+ if (!vault().isInitialized()) return row; // vault-less: store as-is (no key)
95
+ _ensureSealTable();
96
+ var pre = Object.assign({}, row);
97
+ if (pre.metadata !== undefined && pre.metadata !== null && typeof pre.metadata !== "string") {
98
+ pre.metadata = safeJson.stringify(pre.metadata);
99
+ }
100
+ return cryptoField().sealRow(SEAL_TABLE, pre);
101
+ }
102
+ function _unsealMetadata(row) {
103
+ if (!row) return row;
104
+ if (!vault().isInitialized()) return row; // vault-less: row is plain
105
+ _ensureSealTable();
106
+ var out = cryptoField().unsealRow(SEAL_TABLE, row);
107
+ if (typeof out.metadata === "string") {
108
+ try { out.metadata = safeJson.parse(out.metadata, { maxBytes: SEAL_METADATA_MAX_BYTES }); }
109
+ catch (_e) { /* legacy raw-string metadata — leave as-is */ }
110
+ }
111
+ return out;
112
+ }
113
+
65
114
  var CROSS_TENANT_ADMIN_SCOPE = "framework-cross-tenant-admin";
66
115
 
67
116
  // Per-tenant key derivation domain separators. NIST SP 800-108 r1 §5.1
@@ -146,7 +195,9 @@ async function _register(ctx, tenantId, regOpts) {
146
195
  metadata: regOpts.metadata || {},
147
196
  registeredAt: Date.now(),
148
197
  };
149
- await ctx.backend.set(tenantId, row);
198
+ // Seal metadata at rest (tenantId is populated, so the AAD binding
199
+ // resolves). The plaintext `row` is kept for the audit below.
200
+ await ctx.backend.set(tenantId, _sealRegistryRow(row));
150
201
  agentAudit.safeAudit(ctx.audit, "agent.tenant.registered", regOpts.actor, {
151
202
  tenantId: tenantId, posture: row.posture,
152
203
  });
@@ -213,6 +264,7 @@ async function _lookup(ctx, tenantId, args) {
213
264
  guardTenantId.validate(tenantId);
214
265
  var row = await ctx.backend.get(tenantId);
215
266
  if (!row) return null;
267
+ row = _unsealMetadata(row);
216
268
  return {
217
269
  tenantId: row.tenantId,
218
270
  posture: row.posture,
@@ -389,7 +441,7 @@ function _deriveTenantKeyBytes(tenantId, purpose) {
389
441
  /**
390
442
  * @primitive b.agent.tenant.derivedKey
391
443
  * @signature b.agent.tenant.derivedKey(tenantId, purpose)
392
- * @since 0.9.25
444
+ * @since 0.9.26
393
445
  * @status stable
394
446
  * @compliance hipaa, pci-dss, gdpr, soc2
395
447
  * @related b.agent.tenant.create, b.archive.wrap, b.vault
@@ -14,9 +14,10 @@
14
14
  * The substrate at v0.9.29 ships the integration surface:
15
15
  *
16
16
  * - `startSpan(name, opts)` — wrap an agent method call in a span
17
- * - `injectIntoEnvelope(envelope, currentSpan)` — inject W3C
18
- * `traceparent` + `tracestate` into queue / event-bus / sub-
19
- * agent envelopes so the consumer can continue the trace
17
+ * - `injectIntoEnvelope(envelope)` — inject the currently-active
18
+ * span's W3C `traceparent` + `tracestate` into queue / event-bus
19
+ * / sub-agent envelopes so the consumer can continue the trace
20
+ * (call inside the `startSpan` callback so the right span is live)
20
21
  * - `extractFromEnvelope(envelope)` — parse the envelope's
21
22
  * trace context (refused via `b.guardTraceContext` if
22
23
  * malformed)
package/lib/audit.js CHANGED
@@ -796,9 +796,10 @@ function _checkpointPayload(atMonotonicCounter, atRowHash, createdAt) {
796
796
  );
797
797
  }
798
798
 
799
- // Anchor the current chain tip with a fresh ML-DSA-87 signature. Inserts
800
- // a row into audit_checkpoints. Updates <dataDir>/audit.tip for boot-time
801
- // rollback detection.
799
+ // Anchor the current chain tip with a fresh post-quantum signature (the
800
+ // configured b.auditSign algorithm SLH-DSA-SHAKE-256f by default).
801
+ // Inserts a row into audit_checkpoints. Updates <dataDir>/audit.tip for
802
+ // boot-time rollback detection.
802
803
  //
803
804
  // opts:
804
805
  // skipIfUnchanged: bool — return null without inserting if the chain tip
@@ -810,8 +811,10 @@ function _checkpointPayload(atMonotonicCounter, atRowHash, createdAt) {
810
811
  * @compliance soc2, pci-dss, sox-404
811
812
  * @related b.audit.verifyCheckpoints, b.audit.verify
812
813
  *
813
- * Anchor the current chain tip with a fresh ML-DSA-87 (post-quantum)
814
- * signature. Inserts a row into `audit_checkpoints` and updates the
814
+ * Anchor the current chain tip with a fresh post-quantum signature (the
815
+ * configured `b.auditSign` algorithm SLH-DSA-SHAKE-256f by default,
816
+ * ML-DSA-87 / ML-DSA-65 optional). Inserts a row into `audit_checkpoints`
817
+ * and updates the
815
818
  * boot-time rollback-detection sidecar (single-node) or the cluster
816
819
  * audit-tip row (cluster mode, fencing-token guarded). Cluster mode
817
820
  * requires the caller hold leader status — `cluster.requireLeader()`
@@ -917,8 +920,8 @@ async function checkpoint(opts) {
917
920
  * @related b.audit.checkpoint, b.audit.verify
918
921
  *
919
922
  * Walk every checkpoint and verify (a) the public-key fingerprint
920
- * matches the current signing key, (b) the ML-DSA-87 signature over the
921
- * payload still verifies, (c) the audit_log row at the anchored counter
923
+ * matches the current signing key, (b) the post-quantum signature over
924
+ * the payload still verifies, (c) the audit_log row at the anchored counter
922
925
  * still has the recorded rowHash. Catches tampering that recomputed
923
926
  * chain hashes after holding the vault key, because the off-chain
924
927
  * signature anchor is unforgeable without the signing key.
@@ -967,7 +970,7 @@ async function verifyCheckpoints() {
967
970
  checkpointsVerified: i,
968
971
  breakAt: i,
969
972
  checkpointId: c._id,
970
- reason: "ML-DSA-87 signature failed",
973
+ reason: "post-quantum signature failed",
971
974
  };
972
975
  }
973
976
  // Also confirm the audit row at atMonotonicCounter still matches the
@@ -495,9 +495,14 @@ function unsealRow(table, row) {
495
495
  } catch (_e) { /* drop-silent */ }
496
496
  unsealed = null;
497
497
  }
498
- // If the value wasn't actually sealed, vault.unseal returns the input
499
- // unchanged keep the original.
500
- out[field] = unsealed !== undefined && unsealed !== null ? unsealed : out[field];
498
+ // Assign unconditionally. `unsealed` already carries the right value
499
+ // for every branch: the plaintext on success, the original value on
500
+ // the not-actually-sealed pass-through (set above), and `null` on an
501
+ // unseal failure. The failure case MUST null the column so downstream
502
+ // sees "no value" rather than the attacker-crafted `vault:<…>` string
503
+ // (a prior `... ? unsealed : out[field]` guard silently kept the
504
+ // forged ciphertext on failure, defeating the documented defense).
505
+ out[field] = unsealed;
501
506
  }
502
507
  }
503
508
 
package/lib/storage.js CHANGED
@@ -833,10 +833,10 @@ function _requireInit() {
833
833
  //
834
834
  // Resumable-chunked-upload primitive. Operators handling large file
835
835
  // uploads (multipart form / tus / S3-multipart-style flow) need to
836
- // persist incoming chunks during the upload window, then assemble
837
- // them into a final file when the upload completes. Without a
838
- // framework primitive every consumer ended up reinventing the
839
- // per-assembly directory layout + atomic finalize + GC of partial
836
+ // persist incoming chunks during the upload window, then concatenate
837
+ // them in order when the upload completes. Without a framework
838
+ // primitive every consumer ended up reinventing the per-assembly
839
+ // directory layout + ordered gap-checked assembly + GC of partial
840
840
  // assemblies that never completed.
841
841
  //
842
842
  // chunkScratch owns:
@@ -844,8 +844,8 @@ function _requireInit() {
844
844
  // storage backend just like saveFile)
845
845
  // - chunk persistence + retrieval with the framework envelope
846
846
  // - assembly metadata tracking createdAt/totalChunks/chunkHashes
847
- // - atomic concat into the final file (no consumer ever sees a
848
- // half-assembled file)
847
+ // - ordered, gap-checked concat returning the assembled bytes for
848
+ // the caller to persist (the primitive does not write a final file)
849
849
  // - GC of stale partial assemblies (operator opts in via gc())
850
850
  //
851
851
  // Backend is the same `b.storage` backend the operator already
@@ -919,10 +919,11 @@ function _validateChunkIndex(idx) {
919
919
  * @related b.storage.saveFile, b.storage.getFileBuffer
920
920
  *
921
921
  * Resumable-chunked-upload primitive. Persists incoming upload chunks
922
- * during the upload window + atomically assembles them into the
923
- * final file on completion. Owns per-assembly directory layout,
924
- * envelope-encrypted chunk persistence, atomic finalize, and GC of
925
- * partial assemblies.
922
+ * during the upload window, then concatenates them in order on
923
+ * completion and returns the assembled bytes for the caller to persist
924
+ * (the primitive does not itself write a final file). Owns per-assembly
925
+ * directory layout, envelope-encrypted chunk persistence, ordered
926
+ * gap-checked assembly, and GC of partial assemblies.
926
927
  *
927
928
  * Composes existing primitives: each chunk routes through
928
929
  * `b.storage.saveFile` (same XChaCha20-Poly1305 envelope as the
@@ -974,13 +975,16 @@ function _validateChunkIndex(idx) {
974
975
  * b.storage.init({ backend: "local", uploadDir: "./data/uploads" });
975
976
  * var cs = b.storage.chunkScratch({ rootKeyPrefix: "uploads/scratch" });
976
977
  *
977
- * // During upload — each PUT lands one chunk
978
- * await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 0, data: chunk0 });
979
- * await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 1, data: chunk1 });
980
- * await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 2, data: chunk2 });
978
+ * // During upload — each PUT lands one chunk. saveChunk returns the
979
+ * // chunk's sealed encryptionKey; collect them in order for assemble.
980
+ * var keys = [];
981
+ * keys[0] = (await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 0, data: chunk0 })).encryptionKey;
982
+ * keys[1] = (await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 1, data: chunk1 })).encryptionKey;
983
+ * keys[2] = (await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 2, data: chunk2 })).encryptionKey;
981
984
  *
982
- * // On completion — atomic assemble + cleanup
983
- * var assembled = await cs.assemble({ assemblyId: "upload-abc", expectedTotal: 3 });
985
+ * // On completion — concat the chunks (in order) into the assembled
986
+ * // buffer, then clean up. chunkEncryptionKeys is one key per chunk.
987
+ * var assembled = await cs.assemble({ assemblyId: "upload-abc", expectedTotal: 3, chunkEncryptionKeys: keys });
984
988
  * await cs.removeAssembly("upload-abc");
985
989
  *
986
990
  * // Periodic GC of partial uploads abandoned mid-stream
@@ -625,6 +625,7 @@ module.exports = {
625
625
  getKeysJson: getKeysJson,
626
626
  getCurrentPassphrase: getCurrentPassphrase,
627
627
  getMode: getMode,
628
+ isInitialized: function () { return initialized; },
628
629
  VaultError: VaultError,
629
630
  sealPemFile: sealPemFileModule.sealPemFile,
630
631
  SealPemFileError: sealPemFileModule.SealPemFileError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.24",
3
+ "version": "0.13.26",
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:be8cbd49-821e-4e0e-92d5-ea42e8aff583",
5
+ "serialNumber": "urn:uuid:4f626863-3efb-410c-9161-cb9eed647aea",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-28T11:47:50.484Z",
8
+ "timestamp": "2026-05-28T13:34:34.285Z",
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.24",
22
+ "bom-ref": "@blamejs/core@0.13.26",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.24",
25
+ "version": "0.13.26",
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.24",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.26",
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.24",
57
+ "ref": "@blamejs/core@0.13.26",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]