@blamejs/core 0.9.24 → 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.
@@ -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
+ };