@blamejs/core 0.9.23 → 0.9.28
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 +5 -0
- package/index.js +18 -1
- package/lib/agent-audit.js +45 -0
- package/lib/agent-event-bus.js +336 -0
- package/lib/agent-idempotency.js +2 -8
- package/lib/agent-orchestrator.js +2 -8
- package/lib/agent-posture-chain.js +208 -0
- package/lib/agent-saga.js +191 -0
- package/lib/agent-stream.js +237 -0
- package/lib/agent-tenant.js +308 -0
- package/lib/guard-event-bus-payload.js +217 -0
- package/lib/guard-event-bus-topic.js +150 -0
- package/lib/guard-posture-chain.js +201 -0
- package/lib/guard-saga-config.js +157 -0
- package/lib/guard-stream-args.js +166 -0
- package/lib/guard-tenant-id.js +138 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardEventBusPayload
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Event Bus Payload
|
|
6
|
+
* @order 439
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Payload-shape validator for `b.agent.eventBus` events. Bus owners
|
|
10
|
+
* declare a schema per topic (flat key→type map); operators
|
|
11
|
+
* publishing events validate against the schema before pubsub
|
|
12
|
+
* dispatch; subscribers re-validate at delivery in case the payload
|
|
13
|
+
* was tampered in-flight.
|
|
14
|
+
*
|
|
15
|
+
* Per-topic schema is a flat object:
|
|
16
|
+
*
|
|
17
|
+
* ```js
|
|
18
|
+
* bus.registerTopic("mail.scan.malware-detected", {
|
|
19
|
+
* schema: {
|
|
20
|
+
* source: "string",
|
|
21
|
+
* confidence: "number",
|
|
22
|
+
* detectedAt: "isoDateTime",
|
|
23
|
+
* sampleId: "string",
|
|
24
|
+
* },
|
|
25
|
+
* ...
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Types: `string` / `number` / `boolean` / `integer` / `isoDateTime`
|
|
30
|
+
* / `array` / `object`. Optional fields suffix-marked with `?`
|
|
31
|
+
* (e.g. `"reason?": "string"`).
|
|
32
|
+
*
|
|
33
|
+
* Payload byte cap (default 64 KiB) — events are metadata, NOT bulk
|
|
34
|
+
* data; publishers move bulk data through `b.objectStore` /
|
|
35
|
+
* `b.mailStore` and reference IDs in events.
|
|
36
|
+
*
|
|
37
|
+
* @card
|
|
38
|
+
* Validates `b.agent.eventBus` event payloads against per-topic
|
|
39
|
+
* schemas. Bounded byte cap, flat-shape type checks.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
var { defineClass } = require("./framework-error");
|
|
43
|
+
|
|
44
|
+
var GuardEventBusPayloadError = defineClass("GuardEventBusPayloadError", { alwaysPermanent: true });
|
|
45
|
+
|
|
46
|
+
var DEFAULT_PROFILE = "strict";
|
|
47
|
+
|
|
48
|
+
var PROFILES = Object.freeze({
|
|
49
|
+
strict: { maxBytes: 65536 }, // allow:raw-byte-literal — 64 KiB metadata cap
|
|
50
|
+
balanced: { maxBytes: 262144 }, // allow:raw-byte-literal — 256 KiB
|
|
51
|
+
permissive: { maxBytes: 1048576 }, // allow:raw-byte-literal — 1 MiB
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
55
|
+
hipaa: "strict",
|
|
56
|
+
"pci-dss": "strict",
|
|
57
|
+
gdpr: "strict",
|
|
58
|
+
soc2: "strict",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
var ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/; // allow:regex-no-length-cap — value length-bounded by maxBytes payload cap
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @primitive b.guardEventBusPayload.validate
|
|
65
|
+
* @signature b.guardEventBusPayload.validate(payload, schema, opts?)
|
|
66
|
+
* @since 0.9.25
|
|
67
|
+
* @status stable
|
|
68
|
+
* @related b.agent.eventBus.create
|
|
69
|
+
*
|
|
70
|
+
* Validate an event payload against its declared schema. Returns
|
|
71
|
+
* the payload on success; throws on type mismatch / missing required
|
|
72
|
+
* field / oversize.
|
|
73
|
+
*
|
|
74
|
+
* @opts
|
|
75
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
76
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* b.guardEventBusPayload.validate(
|
|
80
|
+
* { source: "1.2.3.4", confidence: 0.95 },
|
|
81
|
+
* { source: "string", confidence: "number" }
|
|
82
|
+
* );
|
|
83
|
+
*/
|
|
84
|
+
function validate(payload, schema, opts) {
|
|
85
|
+
opts = opts || {};
|
|
86
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
87
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
88
|
+
throw new GuardEventBusPayloadError("event-bus-payload/bad-input",
|
|
89
|
+
"guardEventBusPayload.validate: payload must be a plain object");
|
|
90
|
+
}
|
|
91
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
92
|
+
throw new GuardEventBusPayloadError("event-bus-payload/bad-schema",
|
|
93
|
+
"guardEventBusPayload.validate: schema must be a plain object");
|
|
94
|
+
}
|
|
95
|
+
var serialized;
|
|
96
|
+
try { serialized = JSON.stringify(payload); }
|
|
97
|
+
catch (e) {
|
|
98
|
+
throw new GuardEventBusPayloadError("event-bus-payload/unserializable",
|
|
99
|
+
"guardEventBusPayload.validate: payload not JSON-serializable: " +
|
|
100
|
+
(e && e.message ? e.message : String(e)));
|
|
101
|
+
}
|
|
102
|
+
if (Buffer.byteLength(serialized, "utf8") > profile.maxBytes) {
|
|
103
|
+
throw new GuardEventBusPayloadError("event-bus-payload/oversize",
|
|
104
|
+
"guardEventBusPayload.validate: " + Buffer.byteLength(serialized, "utf8") +
|
|
105
|
+
" bytes exceeds maxBytes=" + profile.maxBytes +
|
|
106
|
+
" (events are metadata; reference bulk data via objectStore IDs)");
|
|
107
|
+
}
|
|
108
|
+
// Walk schema; check each field's type + presence.
|
|
109
|
+
var schemaKeys = Object.keys(schema);
|
|
110
|
+
for (var i = 0; i < schemaKeys.length; i += 1) {
|
|
111
|
+
var key = schemaKeys[i];
|
|
112
|
+
var optional = key.charAt(key.length - 1) === "?";
|
|
113
|
+
var fieldName = optional ? key.slice(0, -1) : key;
|
|
114
|
+
var expectedType = schema[key];
|
|
115
|
+
var actual = payload[fieldName];
|
|
116
|
+
if (typeof actual === "undefined" || actual === null) {
|
|
117
|
+
if (!optional) {
|
|
118
|
+
throw new GuardEventBusPayloadError("event-bus-payload/missing-field",
|
|
119
|
+
"guardEventBusPayload.validate: required field '" + fieldName + "' missing");
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
_checkType(actual, expectedType, fieldName);
|
|
124
|
+
}
|
|
125
|
+
// Reject unknown keys — schema must be exhaustive.
|
|
126
|
+
var payloadKeys = Object.keys(payload);
|
|
127
|
+
for (var p = 0; p < payloadKeys.length; p += 1) {
|
|
128
|
+
var pk = payloadKeys[p];
|
|
129
|
+
if (!Object.prototype.hasOwnProperty.call(schema, pk) &&
|
|
130
|
+
!Object.prototype.hasOwnProperty.call(schema, pk + "?")) {
|
|
131
|
+
throw new GuardEventBusPayloadError("event-bus-payload/unknown-field",
|
|
132
|
+
"guardEventBusPayload.validate: unknown field '" + pk + "' not in schema");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return payload;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @primitive b.guardEventBusPayload.compliancePosture
|
|
140
|
+
* @signature b.guardEventBusPayload.compliancePosture(posture)
|
|
141
|
+
* @since 0.9.25
|
|
142
|
+
* @status stable
|
|
143
|
+
*
|
|
144
|
+
* Return the effective profile for a given compliance posture name.
|
|
145
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
146
|
+
* here instead of silently falling through to the default profile.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* b.guardEventBusPayload.compliancePosture("hipaa"); // → "strict"
|
|
150
|
+
*/
|
|
151
|
+
function compliancePosture(posture) {
|
|
152
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _checkType(value, type, fieldName) {
|
|
156
|
+
if (type === "string" && typeof value !== "string") {
|
|
157
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
158
|
+
"field '" + fieldName + "' expected string, got " + typeof value);
|
|
159
|
+
}
|
|
160
|
+
if (type === "number" && (typeof value !== "number" || !isFinite(value))) {
|
|
161
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
162
|
+
"field '" + fieldName + "' expected finite number, got " +
|
|
163
|
+
(typeof value === "number" ? "non-finite number" : typeof value));
|
|
164
|
+
}
|
|
165
|
+
if (type === "boolean" && typeof value !== "boolean") {
|
|
166
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
167
|
+
"field '" + fieldName + "' expected boolean, got " + typeof value);
|
|
168
|
+
}
|
|
169
|
+
if (type === "integer" && !Number.isInteger(value)) {
|
|
170
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
171
|
+
"field '" + fieldName + "' expected integer");
|
|
172
|
+
}
|
|
173
|
+
if (type === "isoDateTime") {
|
|
174
|
+
// Length-bound the value before regex test so a hostile input can't
|
|
175
|
+
// burn regex-engine CPU. RFC 3339 ISO-8601 dateTime is bounded by
|
|
176
|
+
// ~40 chars even with fractional seconds + numeric offset; cap at 64
|
|
177
|
+
// for safety. The payload-level maxBytes cap also bounds the field.
|
|
178
|
+
if (typeof value !== "string" || value.length > 64 || !ISO_DATETIME_RE.test(value)) { // allow:raw-byte-literal — ISO-8601 dateTime max length
|
|
179
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
180
|
+
"field '" + fieldName + "' expected ISO-8601 dateTime string");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (type === "array" && !Array.isArray(value)) {
|
|
184
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
185
|
+
"field '" + fieldName + "' expected array");
|
|
186
|
+
}
|
|
187
|
+
if (type === "object") {
|
|
188
|
+
// Plain object check: rule out null first (typeof null === "object"
|
|
189
|
+
// pre-ES6 quirk), then array, then any non-object type.
|
|
190
|
+
if (value === null || Array.isArray(value) || typeof value !== "object") {
|
|
191
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
192
|
+
"field '" + fieldName + "' expected plain object");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _resolveProfile(opts) {
|
|
198
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
199
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
200
|
+
}
|
|
201
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
202
|
+
if (!PROFILES[p]) {
|
|
203
|
+
throw new GuardEventBusPayloadError("event-bus-payload/bad-profile",
|
|
204
|
+
"guardEventBusPayload: unknown profile '" + p + "'");
|
|
205
|
+
}
|
|
206
|
+
return p;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
validate: validate,
|
|
211
|
+
compliancePosture: compliancePosture,
|
|
212
|
+
PROFILES: PROFILES,
|
|
213
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
214
|
+
GuardEventBusPayloadError: GuardEventBusPayloadError,
|
|
215
|
+
NAME: "eventBusPayload",
|
|
216
|
+
KIND: "event-bus-payload",
|
|
217
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardEventBusTopic
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Event Bus Topic
|
|
6
|
+
* @order 438
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Topic name validator for `b.agent.eventBus.registerTopic` /
|
|
10
|
+
* `publish` / `subscribe`. Refuses:
|
|
11
|
+
*
|
|
12
|
+
* - dot-count < 3 (operators must use `<domain>.<source>.<event>`
|
|
13
|
+
* shape so topic names are greppable + namespace-prefixed —
|
|
14
|
+
* `mail.scan.malware-detected` not `malware`)
|
|
15
|
+
* - non-ASCII (NFC + ASCII-only — operator-greppable across
|
|
16
|
+
* audit logs + JMAP wire + cross-process)
|
|
17
|
+
* - oversized (default 128 bytes — events are metadata, not bulk
|
|
18
|
+
* data; long names defeat the greppability rationale)
|
|
19
|
+
* - reserved `framework.*` prefix from operator code
|
|
20
|
+
* - path-traversal shapes (`..` / `/` / `\` / NUL / C0)
|
|
21
|
+
*
|
|
22
|
+
* @card
|
|
23
|
+
* Validates `b.agent.eventBus` topic names. Dot-count, ASCII,
|
|
24
|
+
* reserved-prefix, path-traversal refusal.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
var { defineClass } = require("./framework-error");
|
|
28
|
+
|
|
29
|
+
var GuardEventBusTopicError = defineClass("GuardEventBusTopicError", { alwaysPermanent: true });
|
|
30
|
+
|
|
31
|
+
var DEFAULT_PROFILE = "strict";
|
|
32
|
+
|
|
33
|
+
var PROFILES = Object.freeze({
|
|
34
|
+
strict: { maxBytes: 128, minDots: 2 }, // allow:raw-byte-literal
|
|
35
|
+
balanced: { maxBytes: 256, minDots: 2 }, // allow:raw-byte-literal
|
|
36
|
+
permissive: { maxBytes: 512, minDots: 1 }, // allow:raw-byte-literal
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
40
|
+
hipaa: "strict",
|
|
41
|
+
"pci-dss": "strict",
|
|
42
|
+
gdpr: "strict",
|
|
43
|
+
soc2: "strict",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
var RESERVED_PREFIXES = Object.freeze(["framework.", "FRAMEWORK."]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @primitive b.guardEventBusTopic.validate
|
|
50
|
+
* @signature b.guardEventBusTopic.validate(name, opts?)
|
|
51
|
+
* @since 0.9.25
|
|
52
|
+
* @status stable
|
|
53
|
+
* @related b.agent.eventBus.create
|
|
54
|
+
*
|
|
55
|
+
* Validate an event-bus topic name. Returns the name on success;
|
|
56
|
+
* throws `GuardEventBusTopicError` on refusal.
|
|
57
|
+
*
|
|
58
|
+
* @opts
|
|
59
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
60
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* b.guardEventBusTopic.validate("mail.scan.malware-detected");
|
|
64
|
+
*/
|
|
65
|
+
function validate(name, opts) {
|
|
66
|
+
opts = opts || {};
|
|
67
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
68
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
69
|
+
throw new GuardEventBusTopicError("event-bus-topic/bad-input",
|
|
70
|
+
"guardEventBusTopic.validate: name must be a non-empty string");
|
|
71
|
+
}
|
|
72
|
+
if (Buffer.byteLength(name, "utf8") > profile.maxBytes) {
|
|
73
|
+
throw new GuardEventBusTopicError("event-bus-topic/oversize",
|
|
74
|
+
"guardEventBusTopic.validate: name exceeds maxBytes=" + profile.maxBytes);
|
|
75
|
+
}
|
|
76
|
+
// Dot-count check — `<domain>.<source>.<event>` shape.
|
|
77
|
+
var dots = 0;
|
|
78
|
+
for (var d = 0; d < name.length; d += 1) if (name.charCodeAt(d) === 0x2E) dots += 1; // allow:raw-byte-literal — '.' codepoint
|
|
79
|
+
if (dots < profile.minDots) {
|
|
80
|
+
throw new GuardEventBusTopicError("event-bus-topic/insufficient-dots",
|
|
81
|
+
"guardEventBusTopic.validate: name '" + name + "' has " + dots +
|
|
82
|
+
" dots; minimum " + profile.minDots + " required (use <domain>.<source>.<event> shape)");
|
|
83
|
+
}
|
|
84
|
+
// Reserved prefix refusal.
|
|
85
|
+
for (var r = 0; r < RESERVED_PREFIXES.length; r += 1) {
|
|
86
|
+
if (name.indexOf(RESERVED_PREFIXES[r]) === 0) {
|
|
87
|
+
throw new GuardEventBusTopicError("event-bus-topic/reserved-prefix",
|
|
88
|
+
"guardEventBusTopic.validate: name '" + name + "' uses reserved prefix '" +
|
|
89
|
+
RESERVED_PREFIXES[r] + "'");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Path-traversal refusal.
|
|
93
|
+
if (name.indexOf("..") >= 0) {
|
|
94
|
+
throw new GuardEventBusTopicError("event-bus-topic/path-traversal",
|
|
95
|
+
"guardEventBusTopic.validate: name contains '..'");
|
|
96
|
+
}
|
|
97
|
+
// C0 / DEL / slash / non-ASCII refusal.
|
|
98
|
+
for (var i = 0; i < name.length; i += 1) {
|
|
99
|
+
var c = name.charCodeAt(i);
|
|
100
|
+
if (c > 0x7F) { // allow:raw-byte-literal — ASCII-only cap
|
|
101
|
+
throw new GuardEventBusTopicError("event-bus-topic/non-ascii",
|
|
102
|
+
"guardEventBusTopic.validate: name contains non-ASCII codepoint at offset " + i);
|
|
103
|
+
}
|
|
104
|
+
if (c < 0x20 || c === 0x7F || c === 0x2F || c === 0x5C) { // allow:raw-byte-literal — C0/DEL/slash/backslash
|
|
105
|
+
throw new GuardEventBusTopicError("event-bus-topic/bad-char",
|
|
106
|
+
"guardEventBusTopic.validate: forbidden char 0x" + c.toString(16) + " at offset " + i);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return name;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @primitive b.guardEventBusTopic.compliancePosture
|
|
114
|
+
* @signature b.guardEventBusTopic.compliancePosture(posture)
|
|
115
|
+
* @since 0.9.25
|
|
116
|
+
* @status stable
|
|
117
|
+
*
|
|
118
|
+
* Return the effective profile for a given compliance posture name.
|
|
119
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
120
|
+
* here instead of silently falling through to the default profile.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* b.guardEventBusTopic.compliancePosture("hipaa"); // → "strict"
|
|
124
|
+
*/
|
|
125
|
+
function compliancePosture(posture) {
|
|
126
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _resolveProfile(opts) {
|
|
130
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
131
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
132
|
+
}
|
|
133
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
134
|
+
if (!PROFILES[p]) {
|
|
135
|
+
throw new GuardEventBusTopicError("event-bus-topic/bad-profile",
|
|
136
|
+
"guardEventBusTopic: unknown profile '" + p + "'");
|
|
137
|
+
}
|
|
138
|
+
return p;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
validate: validate,
|
|
143
|
+
compliancePosture: compliancePosture,
|
|
144
|
+
PROFILES: PROFILES,
|
|
145
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
146
|
+
RESERVED_PREFIXES: RESERVED_PREFIXES,
|
|
147
|
+
GuardEventBusTopicError: GuardEventBusTopicError,
|
|
148
|
+
NAME: "eventBusTopic",
|
|
149
|
+
KIND: "event-bus-topic",
|
|
150
|
+
};
|