@blamejs/core 0.13.24 → 0.13.25
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 +2 -0
- package/lib/agent-idempotency.js +50 -10
- package/lib/agent-orchestrator.js +58 -5
- package/lib/agent-saga.js +8 -4
- package/lib/agent-tenant.js +56 -4
- package/lib/agent-trace.js +4 -3
- package/lib/vault/index.js +1 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.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.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
package/lib/agent-idempotency.js
CHANGED
|
@@ -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`.
|
|
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`)
|
|
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, {
|
|
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
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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(
|
|
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
|
-
|
|
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.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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).
|
|
55
|
-
*
|
|
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
|
|
91
|
-
* initialState, opts)
|
|
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)
|
package/lib/agent-tenant.js
CHANGED
|
@@ -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.
|
|
15
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
package/lib/agent-trace.js
CHANGED
|
@@ -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
|
|
18
|
-
* `traceparent` + `tracestate` into queue / event-bus
|
|
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/vault/index.js
CHANGED
|
@@ -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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:47754ab4-658d-4a84-ac31-adda479b6281",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-28T12:35:12.333Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.25",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.25",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.25",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.13.25",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|