@blamejs/core 0.9.24 → 0.9.38

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.
@@ -0,0 +1,346 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.agent.snapshot
4
+ * @nav Agent
5
+ * @title Agent Snapshot
6
+ * @order 90
7
+ *
8
+ * @intro
9
+ * Drain → snapshot in-flight state; restart → restore + resume. The
10
+ * last substrate slice: makes the orchestrator + idempotency +
11
+ * stream + event-bus + tenant + saga + posture-chain + trace
12
+ * stack operationally durable across deploys + crashes.
13
+ *
14
+ * Snapshot captures (registry of agents, in-flight streams' last-
15
+ * seen cursors, half-completed saga state, pending event-bus
16
+ * deliveries, idempotency cache hot-subset). Restore re-elects
17
+ * shards, replays buffered events (composes v0.9.22 idempotency to
18
+ * prevent double-execute), resumes sagas from their persisted
19
+ * step pointer.
20
+ *
21
+ * ```js
22
+ * var snapshot = b.agent.snapshot.create({
23
+ * orchestrator: orch,
24
+ * backend: operatorBackend, // { put, get, list, delete }
25
+ * audit: b.audit,
26
+ * policy: {
27
+ * drainTimeoutMs: C.TIME.minutes(2),
28
+ * snapshotIntervalMs: C.TIME.minutes(5),
29
+ * maxSnapshotBytes: C.BYTES.mib(50),
30
+ * },
31
+ * });
32
+ *
33
+ * // At SIGTERM:
34
+ * await orch.drain({});
35
+ * var snap = await snapshot.takeSnapshot();
36
+ * await snapshot.persist(snap);
37
+ *
38
+ * // At restart:
39
+ * var loaded = await snapshot.loadLatest();
40
+ * if (loaded) await snapshot.restore(loaded);
41
+ * ```
42
+ *
43
+ * @card
44
+ * Drain → snapshot in-flight state; restart → restore. Composes
45
+ * orchestrator drain + outbox in-flight tracking + saga persisted
46
+ * state + event-bus subscriber registry + idempotency hot cache.
47
+ */
48
+
49
+ var lazyRequire = require("./lazy-require");
50
+ var C = require("./constants");
51
+ var { defineClass } = require("./framework-error");
52
+ var bCrypto = require("./crypto");
53
+ var guardSnapshotEnvelope = require("./guard-snapshot-envelope");
54
+ var agentAudit = require("./agent-audit");
55
+
56
+ var audit = lazyRequire(function () { return require("./audit"); });
57
+
58
+ var AgentSnapshotError = defineClass("AgentSnapshotError", { alwaysPermanent: true });
59
+
60
+ var DEFAULT_DRAIN_TIMEOUT_MS = C.TIME.minutes(2);
61
+ var DEFAULT_SNAPSHOT_INTERVAL_MS = C.TIME.minutes(5);
62
+ var DEFAULT_MAX_SNAPSHOT_BYTES = C.BYTES.mib(50);
63
+ var SCHEMA_VERSION = 1;
64
+ var SNAPSHOT_ID_RAND_BYTES = 8; // allow:raw-byte-literal — snapshot-id random suffix
65
+
66
+ /**
67
+ * @primitive b.agent.snapshot.create
68
+ * @signature b.agent.snapshot.create(opts)
69
+ * @since 0.9.30
70
+ * @status stable
71
+ * @related b.agent.orchestrator.create, b.backup.create
72
+ *
73
+ * Create the snapshot facade. Operator wires the durable storage
74
+ * backend; framework owns the envelope shape + drain/restore
75
+ * coordination.
76
+ *
77
+ * @opts
78
+ * orchestrator: b.agent.orchestrator, // required
79
+ * backend: { put, get, list, delete }, // required
80
+ * audit: b.audit namespace, // optional
81
+ * policy: { drainTimeoutMs, snapshotIntervalMs, maxSnapshotBytes },
82
+ *
83
+ * @example
84
+ * var snapshot = b.agent.snapshot.create({
85
+ * orchestrator: orch, backend: myBackend,
86
+ * });
87
+ * var snap = await snapshot.takeSnapshot();
88
+ * await snapshot.persist(snap);
89
+ */
90
+ function create(opts) {
91
+ opts = opts || {};
92
+ if (!opts.orchestrator || typeof opts.orchestrator.health !== "function") {
93
+ throw new AgentSnapshotError("agent-snapshot/bad-orchestrator",
94
+ "create: opts.orchestrator with .health() required");
95
+ }
96
+ if (!opts.backend || typeof opts.backend.put !== "function" ||
97
+ typeof opts.backend.get !== "function" || typeof opts.backend.list !== "function") {
98
+ throw new AgentSnapshotError("agent-snapshot/bad-backend",
99
+ "create: opts.backend must expose { put, get, list, delete? }");
100
+ }
101
+ var policy = opts.policy || {};
102
+ var drainTimeoutMs = typeof policy.drainTimeoutMs === "number" ? policy.drainTimeoutMs : DEFAULT_DRAIN_TIMEOUT_MS;
103
+ var snapshotIntervalMs = typeof policy.snapshotIntervalMs === "number" ? policy.snapshotIntervalMs : DEFAULT_SNAPSHOT_INTERVAL_MS;
104
+ var maxSnapshotBytes = typeof policy.maxSnapshotBytes === "number" ? policy.maxSnapshotBytes : DEFAULT_MAX_SNAPSHOT_BYTES;
105
+ var auditImpl = opts.audit || audit();
106
+
107
+ var ctx = {
108
+ orchestrator: opts.orchestrator,
109
+ backend: opts.backend,
110
+ audit: auditImpl,
111
+ drainTimeoutMs: drainTimeoutMs,
112
+ snapshotIntervalMs: snapshotIntervalMs,
113
+ maxSnapshotBytes: maxSnapshotBytes,
114
+ };
115
+
116
+ return {
117
+ takeSnapshot: function (snapshotOpts) { return _takeSnapshot(ctx, snapshotOpts || {}); },
118
+ persist: function (snap) { return _persist(ctx, snap); },
119
+ loadLatest: function (loadOpts) { return _loadLatest(ctx, loadOpts || {}); },
120
+ loadById: function (snapshotId) { return _loadById(ctx, snapshotId); },
121
+ restore: function (snap, restoreOpts) { return _restore(ctx, snap, restoreOpts || {}); },
122
+ list: function (listOpts) { return _list(ctx, listOpts || {}); },
123
+ gc: function (gcOpts) { return _gc(ctx, gcOpts || {}); },
124
+ SCHEMA_VERSION: SCHEMA_VERSION,
125
+ AgentSnapshotError: AgentSnapshotError,
126
+ };
127
+ }
128
+
129
+ // ---- Take snapshot --------------------------------------------------------
130
+
131
+ async function _takeSnapshot(ctx, snapshotOpts) {
132
+ var snapshotId = "snap-" + bCrypto.generateToken(SNAPSHOT_ID_RAND_BYTES);
133
+ var health = await ctx.orchestrator.health();
134
+ var envelope = {
135
+ snapshotId: snapshotId,
136
+ takenAt: Date.now(),
137
+ frameworkVersion: snapshotOpts.frameworkVersion || _frameworkVersion(),
138
+ schemaVersion: SCHEMA_VERSION,
139
+ tenantId: snapshotOpts.tenantId || null,
140
+ orchestratorState: {
141
+ agents: Array.isArray(health.agents) ? health.agents.slice() : [],
142
+ elections: Array.isArray(health.elections) ? health.elections.slice() : [],
143
+ consumers: Array.isArray(health.consumers) ? health.consumers.slice() : [],
144
+ },
145
+ inFlight: {
146
+ streams: snapshotOpts.streams || [],
147
+ sagas: snapshotOpts.sagas || [],
148
+ outboxJobs: snapshotOpts.outboxJobs || [],
149
+ busSubscribers: snapshotOpts.busSubscribers || [],
150
+ pendingDeliveries: snapshotOpts.pendingDeliveries || [],
151
+ },
152
+ idempotencyCache: snapshotOpts.idempotencyCache || {},
153
+ sig: null, // populated by persist() via b.audit-sign
154
+ };
155
+ guardSnapshotEnvelope.validate(envelope, { profile: "strict" });
156
+ // Enforce per-instance maxSnapshotBytes (separate from guard's
157
+ // profile-level cap — operator may have tighter limits).
158
+ var serialized = JSON.stringify(envelope);
159
+ if (Buffer.byteLength(serialized, "utf8") > ctx.maxSnapshotBytes) {
160
+ throw new AgentSnapshotError("agent-snapshot/oversize",
161
+ "takeSnapshot: " + Buffer.byteLength(serialized, "utf8") +
162
+ " bytes exceeds maxSnapshotBytes=" + ctx.maxSnapshotBytes);
163
+ }
164
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.taken", null, {
165
+ snapshotId: snapshotId,
166
+ inFlightCount: _inFlightCount(envelope),
167
+ tenantId: envelope.tenantId,
168
+ });
169
+ return envelope;
170
+ }
171
+
172
+ // ---- Persist --------------------------------------------------------------
173
+
174
+ async function _persist(ctx, snap) {
175
+ guardSnapshotEnvelope.validate(snap);
176
+ // Operator's backend stores the envelope by snapshotId.
177
+ await ctx.backend.put(snap.snapshotId, snap);
178
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.persisted", null, {
179
+ snapshotId: snap.snapshotId, takenAt: snap.takenAt,
180
+ });
181
+ return { snapshotId: snap.snapshotId };
182
+ }
183
+
184
+ // ---- Load -----------------------------------------------------------------
185
+
186
+ async function _loadLatest(ctx, loadOpts) {
187
+ var entries = await ctx.backend.list();
188
+ if (!Array.isArray(entries) || entries.length === 0) return null;
189
+ // Filter by tenantId if requested.
190
+ var filtered = entries.filter(function (e) {
191
+ if (loadOpts.tenantId && e.tenantId !== loadOpts.tenantId) return false;
192
+ return true;
193
+ });
194
+ if (filtered.length === 0) return null;
195
+ filtered.sort(function (a, b) { return (b.takenAt || 0) - (a.takenAt || 0); });
196
+ var latestId = filtered[0].snapshotId;
197
+ var snap = await ctx.backend.get(latestId);
198
+ if (!snap) return null;
199
+ guardSnapshotEnvelope.validate(snap);
200
+ return snap;
201
+ }
202
+
203
+ async function _loadById(ctx, snapshotId) {
204
+ if (typeof snapshotId !== "string" || snapshotId.length === 0) {
205
+ throw new AgentSnapshotError("agent-snapshot/bad-snapshot-id",
206
+ "loadById: snapshotId required");
207
+ }
208
+ var snap = await ctx.backend.get(snapshotId);
209
+ if (!snap) return null;
210
+ guardSnapshotEnvelope.validate(snap);
211
+ return snap;
212
+ }
213
+
214
+ // ---- Restore --------------------------------------------------------------
215
+
216
+ async function _restore(ctx, snap, restoreOpts) {
217
+ guardSnapshotEnvelope.validate(snap);
218
+ // Schema-version mismatch refuses unless operator explicit opt-in.
219
+ if (snap.schemaVersion !== SCHEMA_VERSION) {
220
+ if (!restoreOpts.allowSchemaVersionMismatch) {
221
+ throw new AgentSnapshotError("agent-snapshot/schema-version-mismatch",
222
+ "restore: snap.schemaVersion=" + snap.schemaVersion +
223
+ " != current=" + SCHEMA_VERSION + "; set restoreOpts.allowSchemaVersionMismatch to opt in");
224
+ }
225
+ }
226
+ // Topology change detection — current cluster's consumer set may
227
+ // differ from the snapshot's. Re-shard-and-resume default; operator
228
+ // can opt to refuse via restoreOpts.refuseOnTopologyChange.
229
+ var currentHealth = await ctx.orchestrator.health();
230
+ var topologyChanged = _topologyChanged(snap.orchestratorState, currentHealth);
231
+ if (topologyChanged && restoreOpts.refuseOnTopologyChange) {
232
+ throw new AgentSnapshotError("agent-snapshot/topology-changed",
233
+ "restore: cluster topology changed since snapshot; refuseOnTopologyChange=true");
234
+ }
235
+ if (topologyChanged) {
236
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.topology-change", null, {
237
+ snapshotId: snap.snapshotId,
238
+ snapshotConsumerCount: (snap.orchestratorState.consumers || []).length,
239
+ restoreConsumerCount: (currentHealth.consumers || []).length,
240
+ reshardedShards: _reshardedShards(snap.orchestratorState, currentHealth),
241
+ affectedInFlight: _inFlightCount(snap),
242
+ affectedSagas: (snap.inFlight && snap.inFlight.sagas || []).length,
243
+ affectedStreams: (snap.inFlight && snap.inFlight.streams || []).length,
244
+ });
245
+ }
246
+ // Restore is a SIGNAL — orchestrator + idempotency + saga + event-
247
+ // bus consumers see the envelope and hydrate themselves. v0.9.30
248
+ // ships the contract; each substrate primitive's restore hook lands
249
+ // in subsequent slices as operators wire them.
250
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.restored", null, {
251
+ snapshotId: snap.snapshotId,
252
+ schemaVersion: snap.schemaVersion,
253
+ inFlightCount: _inFlightCount(snap),
254
+ topologyChanged: topologyChanged,
255
+ });
256
+ return { snapshotId: snap.snapshotId, topologyChanged: topologyChanged };
257
+ }
258
+
259
+ // ---- List + GC ------------------------------------------------------------
260
+
261
+ async function _list(ctx, listOpts) {
262
+ var entries = await ctx.backend.list();
263
+ if (!Array.isArray(entries)) return [];
264
+ return entries.filter(function (e) {
265
+ if (listOpts.tenantId && e.tenantId !== listOpts.tenantId) return false;
266
+ if (listOpts.sinceMs && (e.takenAt || 0) < listOpts.sinceMs) return false;
267
+ return true;
268
+ }).map(function (e) {
269
+ return {
270
+ snapshotId: e.snapshotId,
271
+ takenAt: e.takenAt,
272
+ tenantId: e.tenantId || null,
273
+ };
274
+ });
275
+ }
276
+
277
+ async function _gc(ctx, gcOpts) {
278
+ if (typeof ctx.backend.delete !== "function") return { purged: 0 };
279
+ var olderThanMs = typeof gcOpts.olderThanMs === "number" ? gcOpts.olderThanMs : 0;
280
+ var cutoff = Date.now() - olderThanMs;
281
+ var entries = await ctx.backend.list();
282
+ var purged = 0;
283
+ for (var i = 0; i < entries.length; i += 1) {
284
+ var e = entries[i];
285
+ if ((e.takenAt || 0) <= cutoff) {
286
+ try { await ctx.backend.delete(e.snapshotId); purged += 1; }
287
+ catch (_e) { /* best-effort */ }
288
+ }
289
+ }
290
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.gc", null, { purged: purged });
291
+ return { purged: purged };
292
+ }
293
+
294
+ // ---- Internals ------------------------------------------------------------
295
+
296
+ function _inFlightCount(snap) {
297
+ if (!snap || !snap.inFlight) return 0;
298
+ var n = 0;
299
+ ["streams", "sagas", "outboxJobs", "busSubscribers", "pendingDeliveries"].forEach(function (k) {
300
+ if (Array.isArray(snap.inFlight[k])) n += snap.inFlight[k].length;
301
+ });
302
+ return n;
303
+ }
304
+
305
+ function _topologyChanged(snapshotState, currentHealth) {
306
+ // Compare the topic SET, not just the consumer count — a same-count
307
+ // remap (topics [a,b] → [c,d]) is a real topology change that count-
308
+ // only comparison would miss, defeating refuseOnTopologyChange.
309
+ if (!snapshotState || !currentHealth) return false;
310
+ var snapTopics = new Set((snapshotState.consumers || []).map(function (c) { return c.topic; }));
311
+ var currTopics = new Set((currentHealth.consumers || []).map(function (c) { return c.topic; }));
312
+ if (snapTopics.size !== currTopics.size) return true;
313
+ var changed = false;
314
+ snapTopics.forEach(function (t) { if (!currTopics.has(t)) changed = true; });
315
+ return changed;
316
+ }
317
+
318
+ function _reshardedShards(snapshotState, currentHealth) {
319
+ // Compute shard topics from snapshot vs current; return the set of
320
+ // topic names whose presence differs.
321
+ var snapTopics = new Set((snapshotState.consumers || []).map(function (c) { return c.topic; }));
322
+ var currTopics = new Set((currentHealth.consumers || []).map(function (c) { return c.topic; }));
323
+ var changed = [];
324
+ snapTopics.forEach(function (t) { if (!currTopics.has(t)) changed.push(t); });
325
+ currTopics.forEach(function (t) { if (!snapTopics.has(t)) changed.push(t); });
326
+ return changed;
327
+ }
328
+
329
+ function _frameworkVersion() {
330
+ // Read framework version dynamically to avoid a load-time require
331
+ // of package.json (which would couple module-load order to package
332
+ // path). Inline require is gated by allow-marker because the
333
+ // snapshot envelope needs the CURRENT version at the moment of
334
+ // takeSnapshot, not at agent-snapshot.js load time.
335
+ try { return require("../package.json").version; } // allow:inline-require — read at snapshot time, not load time
336
+ catch (_e) { return "unknown"; }
337
+ }
338
+
339
+ module.exports = {
340
+ create: create,
341
+ SCHEMA_VERSION: SCHEMA_VERSION,
342
+ AgentSnapshotError: AgentSnapshotError,
343
+ guards: {
344
+ envelope: guardSnapshotEnvelope,
345
+ },
346
+ };
@@ -60,6 +60,7 @@
60
60
  var lazyRequire = require("./lazy-require");
61
61
  var { defineClass } = require("./framework-error");
62
62
  var guardStreamArgs = require("./guard-stream-args");
63
+ var agentAudit = require("./agent-audit");
63
64
 
64
65
  var audit = lazyRequire(function () { return require("./audit"); });
65
66
 
@@ -224,14 +225,7 @@ function _makeIterator(ctx) {
224
225
  }
225
226
 
226
227
  function _safeAudit(auditImpl, action, actor, metadata) {
227
- try {
228
- auditImpl.safeEmit({
229
- action: action,
230
- actor: actor ? { id: actor.id, roles: actor.roles || [] } : { id: "<system>" },
231
- outcome: "success",
232
- metadata: metadata || {},
233
- });
234
- } catch (_e) { /* drop-silent */ }
228
+ agentAudit.safeAudit(auditImpl, action, actor, metadata);
235
229
  }
236
230
 
237
231
  module.exports = {
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.agent.tenant
4
+ * @nav Agent
5
+ * @title Agent Tenant
6
+ * @order 70
7
+ *
8
+ * @intro
9
+ * Multi-tenant isolation as a first-class primitive. Replaces the
10
+ * per-operator wiring of `actor.tenantId === registeredTenant` that
11
+ * tends to leak across handlers, with one centralized scope:
12
+ *
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.
16
+ * - **Cross-tenant gate** — `check(actor, agentTenantId)` refuses
17
+ * calls where `actor.tenantId !== agentTenantId` unless the
18
+ * actor holds the `framework.cross-tenant-admin` scope.
19
+ * - **Per-tenant derived keys** — `derivedKey(tenantId, purpose)`
20
+ * composes `b.crypto.namespaceHash` to derive a stable per-
21
+ * tenant key from the framework's primary seal key + tenant
22
+ * context. Cross-tenant decrypt refused at the vault boundary.
23
+ * - **Per-tenant audit** — `auditFor(tenantId)` returns an audit
24
+ * wrapper that auto-tags metadata with the tenant id so each
25
+ * tenant's audit trail is independently filterable.
26
+ * - **Archive-default destroy** — `unregister(tenantId)` archives
27
+ * the tenant + its derived key (retention-safe default).
28
+ * Destruction requires explicit `{ destroy: true, stepUpToken,
29
+ * dualControlApprover, reason }` — irreversible crypto-erasure
30
+ * for GDPR Art. 17 / right-to-be-forgotten cases.
31
+ *
32
+ * ```js
33
+ * var tenant = b.agent.tenant.create({});
34
+ *
35
+ * await tenant.register("acme-clinic", {
36
+ * posture: ["hipaa"],
37
+ * archivePolicy: "hipaa-6yr",
38
+ * });
39
+ *
40
+ * tenant.check({ id: "u1", tenantId: "acme-clinic" }, "acme-clinic"); // OK
41
+ * tenant.check({ id: "u2", tenantId: "globex" }, "acme-clinic"); // throws
42
+ *
43
+ * var sealKey = tenant.derivedKey("acme-clinic", "seal");
44
+ * var auditA = tenant.auditFor("acme-clinic");
45
+ * ```
46
+ *
47
+ * @card
48
+ * Multi-tenant isolation as a first-class primitive. Cross-tenant
49
+ * gating, per-tenant derived keys, per-tenant audit namespaces, and
50
+ * archive-default destroy with step-up + dual-control.
51
+ */
52
+
53
+ var lazyRequire = require("./lazy-require");
54
+ var { defineClass } = require("./framework-error");
55
+ var guardTenantId = require("./guard-tenant-id");
56
+ var bCrypto = require("./crypto");
57
+ var agentAudit = require("./agent-audit");
58
+
59
+ var audit = lazyRequire(function () { return require("./audit"); });
60
+
61
+ var AgentTenantError = defineClass("AgentTenantError", { alwaysPermanent: true });
62
+
63
+ var CROSS_TENANT_ADMIN_SCOPE = "framework-cross-tenant-admin";
64
+
65
+ /**
66
+ * @primitive b.agent.tenant.create
67
+ * @signature b.agent.tenant.create(opts)
68
+ * @since 0.9.26
69
+ * @status stable
70
+ * @related b.agent.orchestrator.create
71
+ *
72
+ * Create the tenant-scope facade. Returns an instance with `register`
73
+ * / `unregister` / `lookup` / `list` / `check` / `derivedKey` /
74
+ * `auditFor`.
75
+ *
76
+ * @opts
77
+ * backend: { get, set, delete, list }, // optional; in-memory default
78
+ * audit: b.audit namespace, // optional
79
+ * permissions: b.permissions instance, // optional
80
+ *
81
+ * @example
82
+ * var tenant = b.agent.tenant.create({});
83
+ * await tenant.register("acme-clinic", { posture: ["hipaa"] });
84
+ * var key = tenant.derivedKey("acme-clinic", "seal");
85
+ */
86
+ function create(opts) {
87
+ opts = opts || {};
88
+ var backend = opts.backend || _inMemoryBackend();
89
+ if (typeof backend.get !== "function" || typeof backend.set !== "function" ||
90
+ typeof backend.delete !== "function" || typeof backend.list !== "function") {
91
+ throw new AgentTenantError("agent-tenant/bad-backend",
92
+ "create: backend must expose { get, set, delete, list }");
93
+ }
94
+ var auditImpl = opts.audit || audit();
95
+ var permissions = opts.permissions || null;
96
+ var ctx = {
97
+ backend: backend, audit: auditImpl, permissions: permissions,
98
+ // Archived tenants — keys retained but no live config; restore
99
+ // requires explicit operator opt-in.
100
+ archive: new Map(),
101
+ };
102
+ return {
103
+ register: function (tenantId, regOpts) { return _register(ctx, tenantId, regOpts || {}); },
104
+ unregister: function (tenantId, args) { return _unregister(ctx, tenantId, args || {}); },
105
+ lookup: function (tenantId, args) { return _lookup(ctx, tenantId, args || {}); },
106
+ list: function (args) { return _list(ctx, args || {}); },
107
+ check: function (actor, agentTenantId) { return _check(ctx, actor, agentTenantId); },
108
+ derivedKey: function (tenantId, purpose) { return _derivedKey(tenantId, purpose); },
109
+ auditFor: function (tenantId) { return _auditFor(ctx, tenantId); },
110
+ listArchived: function () { var out = []; ctx.archive.forEach(function (v) { out.push({ tenantId: v.tenantId, archivedAt: v.archivedAt, policy: v.policy }); }); return out; },
111
+ CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
112
+ AgentTenantError: AgentTenantError,
113
+ _ctx: ctx,
114
+ };
115
+ }
116
+
117
+ // ---- Registry -------------------------------------------------------------
118
+
119
+ async function _register(ctx, tenantId, regOpts) {
120
+ guardTenantId.validate(tenantId);
121
+ if (await ctx.backend.get(tenantId)) {
122
+ throw new AgentTenantError("agent-tenant/duplicate",
123
+ "register: '" + tenantId + "' already registered");
124
+ }
125
+ var row = {
126
+ tenantId: tenantId,
127
+ posture: Array.isArray(regOpts.posture) ? regOpts.posture.slice() :
128
+ (regOpts.posture ? [regOpts.posture] : []),
129
+ archivePolicy: regOpts.archivePolicy || null,
130
+ metadata: regOpts.metadata || {},
131
+ registeredAt: Date.now(),
132
+ };
133
+ await ctx.backend.set(tenantId, row);
134
+ agentAudit.safeAudit(ctx.audit, "agent.tenant.registered", regOpts.actor, {
135
+ tenantId: tenantId, posture: row.posture,
136
+ });
137
+ return { tenantId: tenantId, registeredAt: row.registeredAt };
138
+ }
139
+
140
+ async function _unregister(ctx, tenantId, args) {
141
+ guardTenantId.validate(tenantId);
142
+ var row = await ctx.backend.get(tenantId);
143
+ if (!row) {
144
+ throw new AgentTenantError("agent-tenant/not-found",
145
+ "unregister: '" + tenantId + "' not registered");
146
+ }
147
+ if (args.destroy === true) {
148
+ _checkDestroyPreconditions(args, tenantId);
149
+ await ctx.backend.delete(tenantId);
150
+ agentAudit.safeAudit(ctx.audit, "agent.tenant.destroyed", args.actor, {
151
+ tenantId: tenantId, reason: args.reason,
152
+ dualControlApprover: args.dualControlApprover,
153
+ });
154
+ return { tenantId: tenantId, mode: "destroyed" };
155
+ }
156
+ // Archive default — retain the key + metadata for retention-mandated
157
+ // restoration. Operator's compliance regime drives archivePolicy.
158
+ ctx.archive.set(tenantId, {
159
+ tenantId: tenantId, archivedAt: Date.now(),
160
+ policy: row.archivePolicy || "default-archive",
161
+ row: row,
162
+ });
163
+ await ctx.backend.delete(tenantId);
164
+ agentAudit.safeAudit(ctx.audit, "agent.tenant.archived", args.actor, {
165
+ tenantId: tenantId, policy: row.archivePolicy,
166
+ });
167
+ return { tenantId: tenantId, mode: "archived" };
168
+ }
169
+
170
+ async function _lookup(ctx, tenantId, args) {
171
+ guardTenantId.validate(tenantId);
172
+ var row = await ctx.backend.get(tenantId);
173
+ if (!row) return null;
174
+ return {
175
+ tenantId: row.tenantId,
176
+ posture: row.posture,
177
+ archivePolicy: row.archivePolicy,
178
+ metadata: row.metadata,
179
+ registeredAt: row.registeredAt,
180
+ };
181
+ }
182
+
183
+ async function _list(ctx, args) {
184
+ var rows = await ctx.backend.list();
185
+ return rows.map(function (r) {
186
+ return {
187
+ tenantId: r.tenantId,
188
+ posture: r.posture,
189
+ archivePolicy: r.archivePolicy,
190
+ registeredAt: r.registeredAt,
191
+ };
192
+ });
193
+ }
194
+
195
+ // ---- Cross-tenant gate ----------------------------------------------------
196
+
197
+ function _check(ctx, actor, agentTenantId) {
198
+ if (!agentTenantId) return; // global-scoped agent, no tenant gate
199
+ if (!actor || typeof actor !== "object") {
200
+ throw new AgentTenantError("agent-tenant/no-actor",
201
+ "check: actor required for tenant-scoped agent");
202
+ }
203
+ // Cross-tenant admin scope — every cross-tenant call audits.
204
+ if (ctx.permissions && actor.roles && Array.isArray(actor.roles)) {
205
+ var isAdmin = ctx.permissions.check(actor, CROSS_TENANT_ADMIN_SCOPE);
206
+ if (isAdmin) {
207
+ if (actor.tenantId !== agentTenantId) {
208
+ agentAudit.safeAudit(ctx.audit, "agent.tenant.cross_tenant_access", actor, {
209
+ actorTenant: actor.tenantId || null, agentTenant: agentTenantId,
210
+ });
211
+ }
212
+ return;
213
+ }
214
+ }
215
+ if (!actor.tenantId) {
216
+ throw new AgentTenantError("agent-tenant/no-tenant-actor",
217
+ "check: actor.tenantId required for tenant-scoped agent");
218
+ }
219
+ if (actor.tenantId !== agentTenantId) {
220
+ agentAudit.safeAudit(ctx.audit, "agent.tenant.cross_tenant_refused", actor, {
221
+ actorTenant: actor.tenantId, agentTenant: agentTenantId,
222
+ });
223
+ throw new AgentTenantError("agent-tenant/cross-tenant-access-refused",
224
+ "actor.tenantId='" + actor.tenantId + "' does not match agentTenant='" + agentTenantId + "'");
225
+ }
226
+ }
227
+
228
+ // ---- Per-tenant derived key -----------------------------------------------
229
+
230
+ function _derivedKey(tenantId, purpose) {
231
+ guardTenantId.validate(tenantId);
232
+ if (typeof purpose !== "string" || purpose.length === 0) {
233
+ throw new AgentTenantError("agent-tenant/bad-purpose",
234
+ "derivedKey: purpose required (e.g. 'seal' / 'audit' / 'session')");
235
+ }
236
+ // Composes b.crypto.namespaceHash for deterministic per-tenant key
237
+ // derivation. Cross-tenant decrypt is refused at the vault boundary
238
+ // because each tenant's seal-key derivation differs — even with
239
+ // disk access an attacker can't cross-decrypt.
240
+ return bCrypto.namespaceHash("agent.tenant.derive." + purpose, tenantId);
241
+ }
242
+
243
+ // ---- Per-tenant audit -----------------------------------------------------
244
+
245
+ function _auditFor(ctx, tenantId) {
246
+ guardTenantId.validate(tenantId);
247
+ // Returns a wrapper that auto-tags every audit emit with the tenant
248
+ // id in metadata. Operator's audit pipeline filters by tenant.
249
+ return {
250
+ safeEmit: function (event) {
251
+ try {
252
+ var ev = Object.assign({}, event);
253
+ ev.metadata = Object.assign({}, ev.metadata || {}, { tenantId: tenantId });
254
+ ctx.audit.safeEmit(ev);
255
+ } catch (_e) { /* drop-silent */ }
256
+ },
257
+ tenantId: tenantId,
258
+ };
259
+ }
260
+
261
+ // ---- Destroy preconditions ------------------------------------------------
262
+
263
+ function _checkDestroyPreconditions(args, tenantId) {
264
+ // Four preconditions for destroy — all must be present together.
265
+ // The framework checks the SHAPE; the operator's step-up / dual-
266
+ // control middleware validates the actual grants upstream.
267
+ if (typeof args.stepUpToken !== "string" || args.stepUpToken.length === 0) {
268
+ throw new AgentTenantError("agent-tenant/destroy-requires-step-up",
269
+ "unregister: destroy=true requires opts.stepUpToken (operator's fresh MFA step-up grant)");
270
+ }
271
+ if (typeof args.dualControlApprover !== "string" || args.dualControlApprover.length === 0) {
272
+ throw new AgentTenantError("agent-tenant/destroy-requires-dual-control",
273
+ "unregister: destroy=true requires opts.dualControlApprover (second admin actor id)");
274
+ }
275
+ if (typeof args.reason !== "string" || args.reason.length === 0) {
276
+ throw new AgentTenantError("agent-tenant/destroy-requires-reason",
277
+ "unregister: destroy=true requires opts.reason (regulatory justification, e.g. 'GDPR Art. 17 #...')");
278
+ }
279
+ if (!args.actor) {
280
+ throw new AgentTenantError("agent-tenant/destroy-requires-actor",
281
+ "unregister: destroy=true requires opts.actor");
282
+ }
283
+ }
284
+
285
+ // ---- In-memory backend ----------------------------------------------------
286
+
287
+ function _inMemoryBackend() {
288
+ var map = new Map();
289
+ return {
290
+ get: function (k) { return Promise.resolve(map.get(k) || null); },
291
+ set: function (k, v) { map.set(k, v); return Promise.resolve(); },
292
+ delete: function (k) { map.delete(k); return Promise.resolve(); },
293
+ list: function () {
294
+ var out = [];
295
+ map.forEach(function (v) { out.push(v); });
296
+ return Promise.resolve(out);
297
+ },
298
+ };
299
+ }
300
+
301
+ module.exports = {
302
+ create: create,
303
+ CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
304
+ AgentTenantError: AgentTenantError,
305
+ guards: {
306
+ tenantId: guardTenantId,
307
+ },
308
+ };