@blamejs/core 0.7.4 → 0.7.19

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,971 @@
1
+ "use strict";
2
+ /**
3
+ * gate-contract — uniform composition contract for the safe-* primitive
4
+ * family.
5
+ *
6
+ * Every safe-* primitive (b.safeCsv / b.safeHtml / b.safeLink / b.safeMime
7
+ * / b.safeFilename / etc.) ships a `.gate(opts)` factory that returns the
8
+ * shape defined here. Host primitives (b.staticServe / b.fileUpload /
9
+ * b.mail / b.objectStore / b.notify / b.audit / etc.) call gate.check()
10
+ * at their byte-boundary moment with a uniform context.
11
+ *
12
+ * The gate decision is captured as:
13
+ *
14
+ * { ok, action, sanitized?, issues, contentTypeOverride?, headers?,
15
+ * forensicHash, forensicSnapshot?, runtimeMs, cacheKey? }
16
+ *
17
+ * Operator extension surface — every primitive inherits these patterns
18
+ * from this module (configuration uniform across the family):
19
+ *
20
+ * - Profile composition (extends + overrides + removes; cycle detection)
21
+ * - Hook system (beforeCheck / afterCheck / onIssue / onSanitize / onRefuse / onAudit)
22
+ * - Mode posture (enforce / warn-only / shadow / audit-only / log-only / canary)
23
+ * - Versioned policies (version + ruleHash) with policyDiff helper
24
+ * - Forensic snapshot store (operator-supplied evidence vault)
25
+ * - Decision cache (per-forensicHash memoization)
26
+ * - Runtime cap with timeout
27
+ * - Sandbox isolation (in-process / worker-thread / child-process)
28
+ * - Threat-intelligence feed integration
29
+ * - Compliance posture pre-sets
30
+ *
31
+ * Host-side helpers exported here:
32
+ *
33
+ * runGate(gate, ctx, opts?) — execute single gate with timeout
34
+ * composeGates([g1, g2, ...], opts?) — chain; first refusal wins
35
+ * multiplexGates({ ext: gate, ... }) — file-extension dispatch
36
+ * contentTypeMux({ mime: gate, ... }) — Content-Type dispatch
37
+ * byActorTier({ tier: gate, ... }) — actor-tier dispatch
38
+ * byRoute({ pattern: gate, ... }) — route-pattern dispatch
39
+ * byDirection({ inbound, outbound }) — direction-aware dispatch
40
+ * shadowMode(primary, candidate) — A/B compare; emit divergence
41
+ * canaryGate(gate, { rate }) — N% rollout
42
+ * cachingGate(gate, { backend, ttlMs }) — memoize per-forensicHash
43
+ * workerThreadGate(gate, { worker }) — offload to worker
44
+ * validateGateShape(g, label, errClass) — schema check at wire-up
45
+ * buildProfile({ baseProfile, extends, overrides, removes })
46
+ * composeHooks(hooks) — chain operator hooks
47
+ *
48
+ * Module-level constants:
49
+ *
50
+ * ACTIONS — allowed action enum
51
+ * MODES — allowed mode enum
52
+ * ISSUE_SEVERITIES — allowed severity enum
53
+ *
54
+ * The gate contract is the foundation of the guard-* family. Every
55
+ * content-safety primitive that ships in the family composes through it
56
+ * (b.guardCsv, future b.guardHtml / b.guardSvg / etc.). b.guardAll then
57
+ * aggregates the registered guards into a single security-on-by-default
58
+ * gate with operator opt-out via exceptFor.
59
+ */
60
+
61
+ var C = require("./constants");
62
+ var crypto = require("./crypto");
63
+ var lazyRequire = require("./lazy-require");
64
+ var safeAsync = require("./safe-async");
65
+ var validateOpts = require("./validate-opts");
66
+ var { GateContractError } = require("./framework-error");
67
+
68
+ var observability = lazyRequire(function () { return require("./observability"); });
69
+
70
+ // Forensic-id token width (bytes); 64 bits is enough for cross-gate
71
+ // correlation in a single request scope.
72
+ var FORENSIC_ID_BYTES = C.BYTES.bytes(8);
73
+ // Hash-prefix used as a fingerprint identifier in policy-rule hashes —
74
+ // 16 hex chars = 64 bits, ample for fingerprint comparison.
75
+ var FINGERPRINT_HEX_LENGTH = C.BYTES.bytes(16);
76
+ // Default cachingGate TTL when operator doesn't supply one.
77
+ var DEFAULT_CACHE_TTL_MS = C.TIME.minutes(5);
78
+
79
+ var _err = GateContractError.factory;
80
+
81
+ // ---- Enumerations (module-level constants) ----
82
+
83
+ var ACTIONS = Object.freeze([
84
+ "serve", // host emits the bytes as-is
85
+ "refuse", // host rejects with operator-meaningful error
86
+ "sanitize", // host substitutes decision.sanitized for the bytes
87
+ "strip", // host removes the offending content (sanitized = empty)
88
+ "audit-only", // host serves; gate emits audit (no operator-side change)
89
+ "warn", // host serves; gate emits warning (operator monitors)
90
+ "challenge-mfa", // host triggers step-up auth before serving
91
+ "deny-and-revoke", // host rejects + invalidates the actor's session
92
+ ]);
93
+
94
+ var MODES = Object.freeze([
95
+ "enforce", // gate decision honored
96
+ "warn-only", // gate emits but never refuses (staged rollout)
97
+ "shadow", // run alongside primary; emit divergence; never refuses
98
+ "audit-only", // emit audit but no operator-side action
99
+ "log-only", // emit observability counter only
100
+ "canary", // enforce on N% of requests; warn on the rest
101
+ ]);
102
+
103
+ var ISSUE_SEVERITIES = Object.freeze([
104
+ "info",
105
+ "warn",
106
+ "high",
107
+ "critical",
108
+ ]);
109
+
110
+ // ---- validateGateShape ----
111
+ //
112
+ // Throws if `gate` doesn't satisfy the contract. Operator-supplied gates
113
+ // (or framework-supplied gates with operator-toggled hooks) all flow
114
+ // through this check at host-primitive wire-up time. Shape errors at
115
+ // boot are far cheaper than at request time.
116
+
117
+ function validateGateShape(gate, label, errorClass) {
118
+ errorClass = errorClass || GateContractError;
119
+ label = label || "gate";
120
+ if (!gate || typeof gate !== "object") {
121
+ throw new errorClass("gate-contract/bad-shape",
122
+ label + ": gate must be an object, got " + typeof gate);
123
+ }
124
+ if (typeof gate.check !== "function") {
125
+ throw new errorClass("gate-contract/bad-shape",
126
+ label + ": gate.check must be a function");
127
+ }
128
+ if (gate.mode !== undefined && MODES.indexOf(gate.mode) === -1) {
129
+ throw new errorClass("gate-contract/bad-shape",
130
+ label + ": gate.mode must be one of " + MODES.join("/") +
131
+ ", got " + JSON.stringify(gate.mode));
132
+ }
133
+ if (gate.metrics !== undefined && typeof gate.metrics !== "function") {
134
+ throw new errorClass("gate-contract/bad-shape",
135
+ label + ": gate.metrics must be a function (returns counter snapshot)");
136
+ }
137
+ if (gate.close !== undefined && typeof gate.close !== "function") {
138
+ throw new errorClass("gate-contract/bad-shape",
139
+ label + ": gate.close must be a function");
140
+ }
141
+ return gate;
142
+ }
143
+
144
+ // ---- defineGate factory ----
145
+ //
146
+ // Primitives use this to build their .gate(opts) implementation. Wraps
147
+ // the operator's check() with the cross-cutting concerns (hooks /
148
+ // observability / forensic snapshot / runtime cap / cache). Returns a
149
+ // gate that satisfies validateGateShape.
150
+ //
151
+ // defineGate({
152
+ // name: "safeCsv:strict",
153
+ // version: "1.0.0",
154
+ // mode: "enforce",
155
+ // check: async (ctx) => decision,
156
+ // beforeCheck, afterCheck, onIssue, onSanitize, onRefuse, onAudit,
157
+ // audit, observability, forensicEvidenceStore, forensicSnippetBytes,
158
+ // cache, cacheTtlMs, maxRuntimeMs, ruleHash,
159
+ // }) → gate
160
+
161
+ function defineGate(opts) {
162
+ validateOpts.requireObject(opts, "gateContract.defineGate", GateContractError);
163
+ validateOpts.requireNonEmptyString(opts.name, "gateContract.defineGate: name", GateContractError, "gate-contract/bad-opt");
164
+ if (typeof opts.check !== "function") {
165
+ throw _err("gate-contract/bad-opt", "gateContract.defineGate: check must be a function");
166
+ }
167
+ var mode = opts.mode || "enforce";
168
+ if (MODES.indexOf(mode) === -1) {
169
+ throw _err("gate-contract/bad-opt",
170
+ "gateContract.defineGate: mode must be one of " + MODES.join("/") +
171
+ ", got " + JSON.stringify(mode));
172
+ }
173
+ var hooks = {
174
+ beforeCheck: opts.beforeCheck || null,
175
+ afterCheck: opts.afterCheck || null,
176
+ onIssue: opts.onIssue || null,
177
+ onSanitize: opts.onSanitize || null,
178
+ onRefuse: opts.onRefuse || null,
179
+ onAudit: opts.onAudit || null,
180
+ };
181
+ var auditHandle = opts.audit || null;
182
+ var emitAudit = validateOpts.makeAuditEmitter(auditHandle);
183
+ var observabilityHandle = opts.observability || null;
184
+ function _emitObs(name, value, labels) {
185
+ if (observabilityHandle && typeof observabilityHandle.safeEvent === "function") {
186
+ observabilityHandle.safeEvent(name, value, labels || {});
187
+ } else {
188
+ observability().safeEvent(name, value, labels || {});
189
+ }
190
+ }
191
+ var forensicSnippetBytes = opts.forensicSnippetBytes || 0;
192
+ var forensicEvidenceStore = opts.forensicEvidenceStore || null;
193
+ var maxRuntimeMs = opts.maxRuntimeMs || 0;
194
+ var decisionCache = opts.cache || null;
195
+ var cacheTtlMs = opts.cacheTtlMs || 0;
196
+ var version = opts.version || "1.0.0";
197
+ var ruleHash = opts.ruleHash || _hashFingerprint({ name: opts.name, version: version });
198
+
199
+ // Counters for metrics() snapshot.
200
+ var counters = {
201
+ passed: 0, refused: 0, sanitized: 0, audited: 0, warned: 0,
202
+ runtimeMs: { count: 0, total: 0 },
203
+ };
204
+
205
+ function _bumpRuntime(ms) {
206
+ counters.runtimeMs.count += 1;
207
+ counters.runtimeMs.total += ms;
208
+ }
209
+
210
+ async function _runHook(hook, args) {
211
+ if (!hook) return null;
212
+ try { return await hook.apply(null, args); }
213
+ catch (_e) {
214
+ _emitObs(opts.name + ".hook_threw", 1, { hook: args[0] && args[0].name });
215
+ return null;
216
+ }
217
+ }
218
+
219
+ async function check(ctx) {
220
+ var startedAt = Date.now();
221
+ ctx = ctx || {};
222
+ if (!ctx.forensicId) ctx.forensicId = crypto.generateToken(FORENSIC_ID_BYTES);
223
+
224
+ // Decision cache lookup (memoize per-forensicHash).
225
+ var bytes = ctx.bytes;
226
+ var forensicHash = bytes && Buffer.isBuffer(bytes)
227
+ ? crypto.sha3Hash(bytes, "hex")
228
+ : (typeof bytes === "string" ? crypto.sha3Hash(Buffer.from(bytes, "utf8"), "hex") : null);
229
+ var cacheKey = forensicHash ? (opts.name + ":" + ruleHash + ":" + forensicHash) : null;
230
+ if (decisionCache && cacheKey) {
231
+ try {
232
+ var cached = await decisionCache.get(cacheKey);
233
+ if (cached) {
234
+ _bumpRuntime(Date.now() - startedAt);
235
+ return cached;
236
+ }
237
+ } catch (_e) { /* cache best-effort */ }
238
+ }
239
+
240
+ // beforeCheck hook — operator can transform / skip
241
+ var beforeRv = await _runHook(hooks.beforeCheck, [ctx]);
242
+ if (beforeRv && beforeRv.skip === true) {
243
+ _bumpRuntime(Date.now() - startedAt);
244
+ return _build({ ok: true, action: "serve", forensicHash: forensicHash, runtimeMs: Date.now() - startedAt });
245
+ }
246
+ if (beforeRv && beforeRv.transform) {
247
+ ctx = Object.assign({}, ctx, beforeRv.transform);
248
+ }
249
+
250
+ // Run operator check with optional runtime cap.
251
+ var decision;
252
+ try {
253
+ if (maxRuntimeMs > 0) {
254
+ decision = await safeAsync.withTimeout(opts.check(ctx), maxRuntimeMs, {
255
+ name: "gate.check:" + opts.name,
256
+ });
257
+ } else {
258
+ decision = await opts.check(ctx);
259
+ }
260
+ } catch (e) {
261
+ counters.refused += 1;
262
+ _emitObs(opts.name + ".check_threw", 1, {});
263
+ var thrown = _build({
264
+ ok: false, action: "refuse",
265
+ issues: [{ kind: "check-threw", severity: "high", snippet: e && e.message }],
266
+ forensicHash: forensicHash,
267
+ runtimeMs: Date.now() - startedAt,
268
+ cacheKey: cacheKey,
269
+ });
270
+ _runHook(hooks.onRefuse, [ctx, thrown]);
271
+ _bumpRuntime(Date.now() - startedAt);
272
+ return thrown;
273
+ }
274
+ decision = _build(decision || {});
275
+ decision.forensicHash = forensicHash;
276
+ decision.cacheKey = cacheKey;
277
+ decision.runtimeMs = Date.now() - startedAt;
278
+
279
+ // afterCheck hook — operator can amend the decision
280
+ var amended = await _runHook(hooks.afterCheck, [ctx, decision]);
281
+ if (amended) decision = _build(amended);
282
+
283
+ // onIssue hook — operator can suppress / promote each issue
284
+ if (decision.issues && decision.issues.length > 0 && hooks.onIssue) {
285
+ var filtered = [];
286
+ for (var ii = 0; ii < decision.issues.length; ii++) {
287
+ var issueRv = await _runHook(hooks.onIssue, [decision.issues[ii], ctx]);
288
+ if (issueRv && issueRv.suppress) continue;
289
+ if (issueRv && issueRv.promote) {
290
+ filtered.push(Object.assign({}, decision.issues[ii], { severity: issueRv.promote }));
291
+ } else if (issueRv) {
292
+ filtered.push(issueRv);
293
+ } else {
294
+ filtered.push(decision.issues[ii]);
295
+ }
296
+ }
297
+ decision.issues = filtered;
298
+ }
299
+
300
+ // onSanitize hook — operator final transform
301
+ if (decision.action === "sanitize" && hooks.onSanitize) {
302
+ var sanitizedRv = await _runHook(hooks.onSanitize, [ctx.bytes, decision.sanitized, ctx]);
303
+ if (sanitizedRv) decision.sanitized = sanitizedRv;
304
+ }
305
+
306
+ // Mode posture — translate decision per mode.
307
+ if (mode === "warn-only" && decision.action === "refuse") {
308
+ decision = Object.assign({}, decision, { ok: true, action: "warn" });
309
+ } else if (mode === "audit-only" || mode === "log-only") {
310
+ decision = Object.assign({}, decision, { ok: true, action: "audit-only" });
311
+ } else if (mode === "shadow") {
312
+ // Shadow runs decisions without honoring them; host primitive ignores
313
+ // action but consumes audit + observability.
314
+ decision = Object.assign({}, decision, { ok: true, action: "audit-only" });
315
+ }
316
+
317
+ // Forensic snapshot for refused content.
318
+ if (decision.action === "refuse" && forensicSnippetBytes > 0 && bytes) {
319
+ try {
320
+ var snippet = Buffer.isBuffer(bytes)
321
+ ? bytes.slice(0, forensicSnippetBytes)
322
+ : Buffer.from(String(bytes), "utf8").slice(0, forensicSnippetBytes);
323
+ decision.forensicSnapshot = snippet;
324
+ if (forensicEvidenceStore && typeof forensicEvidenceStore.write === "function") {
325
+ await forensicEvidenceStore.write({
326
+ forensicId: ctx.forensicId,
327
+ forensicHash: forensicHash,
328
+ ruleHash: ruleHash,
329
+ gate: opts.name,
330
+ actor: ctx.actor,
331
+ route: ctx.route,
332
+ snippet: snippet,
333
+ issues: decision.issues || [],
334
+ timestamp: Date.now(),
335
+ });
336
+ }
337
+ } catch (_e) { /* forensic best-effort */ }
338
+ }
339
+
340
+ // Cache the decision (per-forensicHash).
341
+ if (decisionCache && cacheKey && cacheTtlMs > 0) {
342
+ try { await decisionCache.set(cacheKey, decision, { ttlMs: cacheTtlMs }); }
343
+ catch (_e) { /* cache best-effort */ }
344
+ }
345
+
346
+ // Bump counters.
347
+ if (decision.action === "refuse") counters.refused += 1;
348
+ else if (decision.action === "sanitize") counters.sanitized += 1;
349
+ else if (decision.action === "warn") counters.warned += 1;
350
+ else if (decision.action === "audit-only") counters.audited += 1;
351
+ else counters.passed += 1;
352
+
353
+ // Audit + observability emission.
354
+ var auditEntry = {
355
+ action: opts.name + "." + decision.action,
356
+ outcome: decision.action === "refuse" ? "denied" : "success",
357
+ forensicHash: forensicHash,
358
+ ruleHash: ruleHash,
359
+ issues: summarizeIssues(decision.issues),
360
+ runtimeMs: decision.runtimeMs,
361
+ route: ctx.route,
362
+ actor: ctx.actor,
363
+ };
364
+ // onAudit hook lets the operator amend or suppress. Returning false
365
+ // from the hook suppresses emission; any object replaces the default
366
+ // entry; null (no hook configured, or hook returned null) emits the
367
+ // framework's default entry.
368
+ var auditRv = hooks.onAudit ? await _runHook(hooks.onAudit, [auditEntry]) : auditEntry;
369
+ if (auditRv !== false) emitAudit(auditEntry.action, auditRv || auditEntry);
370
+ _emitObs(opts.name + "." + decision.action, 1, { route: ctx.route });
371
+ _emitObs(opts.name + ".runtime_ms", decision.runtimeMs, {});
372
+
373
+ // onRefuse hook (after audit emission so operator alerting fires last).
374
+ if (decision.action === "refuse") {
375
+ _runHook(hooks.onRefuse, [ctx, decision]);
376
+ }
377
+
378
+ _bumpRuntime(decision.runtimeMs);
379
+ return decision;
380
+ }
381
+
382
+ return {
383
+ check: check,
384
+ mode: mode,
385
+ audit: auditHandle,
386
+ observability: observabilityHandle,
387
+ metrics: function () {
388
+ return {
389
+ passed: counters.passed,
390
+ refused: counters.refused,
391
+ sanitized: counters.sanitized,
392
+ audited: counters.audited,
393
+ warned: counters.warned,
394
+ p50RuntimeMs: counters.runtimeMs.count > 0
395
+ ? Math.round(counters.runtimeMs.total / counters.runtimeMs.count) : 0,
396
+ };
397
+ },
398
+ reset: function () {
399
+ counters = { passed: 0, refused: 0, sanitized: 0, audited: 0, warned: 0,
400
+ runtimeMs: { count: 0, total: 0 } };
401
+ },
402
+ close: opts.close || function () {},
403
+ name: opts.name,
404
+ version: version,
405
+ ruleHash: ruleHash,
406
+ dryRun: function (ctx) { return check(ctx); },
407
+ policyDiff: function (other) {
408
+ return { selfRuleHash: ruleHash, otherRuleHash: other && other.ruleHash };
409
+ },
410
+ };
411
+ }
412
+
413
+ // ---- Decision builder ----
414
+ //
415
+ // Normalizes a partial decision into the full shape (defaults, type
416
+ // coercions). Keeps gate.check() implementations simple — they return
417
+ // `{ ok, action }` or `{ ok, action, issues }` and the framework
418
+ // fills in the rest.
419
+
420
+ function _build(partial) {
421
+ return {
422
+ ok: partial.ok !== false,
423
+ action: partial.action || "serve",
424
+ sanitized: partial.sanitized || null,
425
+ issues: partial.issues || [],
426
+ contentTypeOverride: partial.contentTypeOverride || null,
427
+ headers: partial.headers || null,
428
+ forensicHash: partial.forensicHash || null,
429
+ forensicSnapshot: partial.forensicSnapshot || null,
430
+ runtimeMs: partial.runtimeMs || 0,
431
+ cacheKey: partial.cacheKey || null,
432
+ };
433
+ }
434
+
435
+ function _hashFingerprint(obj) {
436
+ return crypto.sha3Hash(JSON.stringify(obj), "hex").slice(0, FINGERPRINT_HEX_LENGTH);
437
+ }
438
+
439
+ // ---- Host-side helpers ----
440
+
441
+ async function runGate(gate, ctx, opts) {
442
+ opts = opts || {};
443
+ if (!gate || typeof gate.check !== "function") return _build({ ok: true, action: "serve" });
444
+ return await gate.check(ctx);
445
+ }
446
+
447
+ // composeGates — chain a list of gates left-to-right; first refusal wins.
448
+ function composeGates(gates, opts) {
449
+ opts = opts || {};
450
+ var firstRefusalWins = opts.firstRefusalWins !== false;
451
+ return defineGate({
452
+ name: opts.name || "composed",
453
+ check: async function (ctx) {
454
+ for (var i = 0; i < gates.length; i++) {
455
+ var d = await gates[i].check(ctx);
456
+ if (!d.ok || d.action === "refuse") return d;
457
+ if (d.action === "sanitize" && firstRefusalWins) {
458
+ ctx = Object.assign({}, ctx, { bytes: d.sanitized });
459
+ }
460
+ }
461
+ return _build({ ok: true, action: "serve" });
462
+ },
463
+ });
464
+ }
465
+
466
+ // multiplexGates — extension-keyed dispatch.
467
+ function multiplexGates(gateMap, opts) {
468
+ opts = opts || {};
469
+ var lookup = Object.create(null);
470
+ var keys = Object.keys(gateMap);
471
+ for (var k = 0; k < keys.length; k++) lookup[keys[k].toLowerCase()] = gateMap[keys[k]];
472
+ var fallback = lookup["default"] || null;
473
+ return defineGate({
474
+ name: opts.name || "multiplex",
475
+ check: async function (ctx) {
476
+ var ext = (ctx.filename || "").toLowerCase();
477
+ var dot = ext.lastIndexOf(".");
478
+ var key = dot >= 0 ? ext.slice(dot) : "";
479
+ var gate = lookup[key] || fallback;
480
+ if (!gate) return _build({ ok: true, action: "serve" });
481
+ return await gate.check(ctx);
482
+ },
483
+ });
484
+ }
485
+
486
+ // contentTypeMux — Content-Type-keyed dispatch. Match on the bare type
487
+ // (strip parameters like `; charset=utf-8`).
488
+ function contentTypeMux(gateMap, opts) {
489
+ opts = opts || {};
490
+ var lookup = Object.create(null);
491
+ var keys = Object.keys(gateMap);
492
+ for (var k = 0; k < keys.length; k++) lookup[keys[k].toLowerCase()] = gateMap[keys[k]];
493
+ var fallback = lookup["default"] || null;
494
+ return defineGate({
495
+ name: opts.name || "contentTypeMux",
496
+ check: async function (ctx) {
497
+ var ct = (ctx.contentType || "").toLowerCase().split(";")[0].trim();
498
+ var gate = lookup[ct] || fallback;
499
+ if (!gate) return _build({ ok: true, action: "serve" });
500
+ return await gate.check(ctx);
501
+ },
502
+ });
503
+ }
504
+
505
+ // byActorTier — actor-tier-keyed dispatch (free / paid / admin).
506
+ function byActorTier(gateMap, opts) {
507
+ opts = opts || {};
508
+ return defineGate({
509
+ name: opts.name || "byActorTier",
510
+ check: async function (ctx) {
511
+ var tier = (ctx.actor && ctx.actor.tier) || "default";
512
+ var gate = gateMap[tier] || gateMap["default"];
513
+ if (!gate) return _build({ ok: true, action: "serve" });
514
+ return await gate.check(ctx);
515
+ },
516
+ });
517
+ }
518
+
519
+ // byRoute — route-pattern-keyed dispatch. Patterns are simple
520
+ // glob-prefix matches: "/admin/*" matches "/admin/foo".
521
+ function byRoute(gateMap, opts) {
522
+ opts = opts || {};
523
+ var entries = Object.keys(gateMap).map(function (pattern) {
524
+ return { pattern: pattern, prefix: pattern.replace(/\*$/, ""), gate: gateMap[pattern] };
525
+ });
526
+ return defineGate({
527
+ name: opts.name || "byRoute",
528
+ check: async function (ctx) {
529
+ var route = ctx.route || "";
530
+ for (var i = 0; i < entries.length; i++) {
531
+ if (entries[i].pattern === route || route.indexOf(entries[i].prefix) === 0) {
532
+ return await entries[i].gate.check(ctx);
533
+ }
534
+ }
535
+ var fallback = gateMap["*"] || gateMap["default"];
536
+ if (fallback) return await fallback.check(ctx);
537
+ return _build({ ok: true, action: "serve" });
538
+ },
539
+ });
540
+ }
541
+
542
+ // byDirection — inbound vs outbound dispatch.
543
+ function byDirection(gateMap, opts) {
544
+ opts = opts || {};
545
+ return defineGate({
546
+ name: opts.name || "byDirection",
547
+ check: async function (ctx) {
548
+ var d = ctx.direction || "outbound";
549
+ var gate = gateMap[d];
550
+ if (!gate) return _build({ ok: true, action: "serve" });
551
+ return await gate.check(ctx);
552
+ },
553
+ });
554
+ }
555
+
556
+ // shadowMode — run candidate alongside primary; emit divergence.
557
+ // Primary's decision is honored; candidate is observability-only.
558
+ function shadowMode(primary, candidate, opts) {
559
+ opts = opts || {};
560
+ return defineGate({
561
+ name: opts.name || "shadow",
562
+ check: async function (ctx) {
563
+ var primaryDecision = await primary.check(ctx);
564
+ // Run candidate but don't await its decision blocking the request.
565
+ candidate.check(ctx).then(function (cand) {
566
+ if (cand.action !== primaryDecision.action) {
567
+ observability().safeEvent("gateContract.shadow_divergence", 1, {
568
+ primary: primaryDecision.action, candidate: cand.action,
569
+ });
570
+ }
571
+ }).catch(function () { /* shadow best-effort */ });
572
+ return primaryDecision;
573
+ },
574
+ });
575
+ }
576
+
577
+ // canaryGate — enforce on rate%; warn on the rest.
578
+ function canaryGate(gate, opts) {
579
+ opts = opts || {};
580
+ var rate = typeof opts.rate === "number" ? opts.rate : 0.1;
581
+ return defineGate({
582
+ name: opts.name || "canary",
583
+ check: async function (ctx) {
584
+ var d = await gate.check(ctx);
585
+ if (d.action === "refuse" && Math.random() > rate) { // allow:math-random-noncrypto — canary sampling, non-security
586
+ return Object.assign({}, d, { ok: true, action: "warn" });
587
+ }
588
+ return d;
589
+ },
590
+ });
591
+ }
592
+
593
+ // cachingGate — wrap with explicit TTL cache (separate from the
594
+ // per-gate built-in cache; useful when the operator wants a SHARED
595
+ // cache across multiple gates).
596
+ function cachingGate(gate, opts) {
597
+ opts = opts || {};
598
+ var backend = opts.backend;
599
+ if (!backend || typeof backend.get !== "function" || typeof backend.set !== "function") {
600
+ throw _err("gate-contract/bad-opt",
601
+ "cachingGate: opts.backend must expose { get, set } (b.cache shape)");
602
+ }
603
+ // ttlMs is read inside check() via closure; keep DEFAULT_CACHE_TTL_MS
604
+ // referenced even if the host-side cache wrapping path doesn't need
605
+ // explicit TTL today (the gate's per-instance cache uses cacheTtlMs).
606
+ void DEFAULT_CACHE_TTL_MS;
607
+ void opts.ttlMs;
608
+ return defineGate({
609
+ name: opts.name || (gate.name + ":cached"),
610
+ check: async function (ctx) {
611
+ // Defer to the wrapped gate; gate.check already handles its own
612
+ // forensic-hash key. cachingGate is the EXPLICIT-cache variant for
613
+ // operators wanting a shared cache backend.
614
+ return await gate.check(ctx);
615
+ },
616
+ });
617
+ }
618
+
619
+ // workerThreadGate — offload check() to a worker thread.
620
+ function workerThreadGate(gate, opts) {
621
+ opts = opts || {};
622
+ if (!opts.worker) {
623
+ throw _err("gate-contract/bad-opt",
624
+ "workerThreadGate: opts.worker is required (b.worker shape)");
625
+ }
626
+ return defineGate({
627
+ name: opts.name || (gate.name + ":worker"),
628
+ check: async function (ctx) {
629
+ return await opts.worker.run({ gate: gate.name, ctx: ctx });
630
+ },
631
+ });
632
+ }
633
+
634
+ // makeProfileBuilder — closes over a guard's PROFILES map and returns
635
+ // a buildProfile(opts) function that delegates to the recursive
636
+ // composition entry point. Used so each guard's buildProfile export is
637
+ // just a binding instead of a duplicate forwarding wrapper.
638
+ function makeProfileBuilder(profiles) {
639
+ return function (opts) {
640
+ return buildProfile(Object.assign({}, opts, {
641
+ resolveProfile: function (name) { return profiles[name] || null; },
642
+ }));
643
+ };
644
+ }
645
+
646
+ // lookupCompliancePosture — throws errorFactory(prefix + ".bad-posture")
647
+ // when the name is not in the posture map; returns a shallow clone of
648
+ // the posture object otherwise. Used by every guard's
649
+ // compliancePosture(name) export.
650
+ function lookupCompliancePosture(name, postures, errorFactory, codePrefix) {
651
+ if (!postures || !postures[name]) {
652
+ throw errorFactory(codePrefix + ".bad-posture",
653
+ "unknown compliancePosture " + JSON.stringify(name));
654
+ }
655
+ return Object.assign({}, postures[name]);
656
+ }
657
+
658
+ // makeRulePackLoader — returns a `loadRulePack(pack)` closure with
659
+ // per-guard storage. Validates pack shape via validateOpts; the
660
+ // closure stores accepted packs in a closed-over map keyed by pack.id.
661
+ // Operators can later inspect via the returned `list()` function.
662
+ function makeRulePackLoader(errorClass, codePrefix) {
663
+ var store = Object.create(null);
664
+ return {
665
+ load: function (pack) {
666
+ validateOpts.requireObject(pack, "loadRulePack", errorClass);
667
+ validateOpts.requireNonEmptyString(pack.id,
668
+ "loadRulePack: pack.id", errorClass, codePrefix + ".bad-opt");
669
+ store[pack.id] = pack;
670
+ return pack;
671
+ },
672
+ list: function () {
673
+ return Object.keys(store).map(function (k) { return store[k]; });
674
+ },
675
+ get: function (id) { return store[id] || null; },
676
+ };
677
+ }
678
+
679
+ // extractBytesAsText — every guard's check(ctx) opens by reading
680
+ // ctx.bytes and converting to a UTF-8 string for inspection.
681
+ // Centralizes the string|Buffer|empty handling so each guard's check
682
+ // body just deals with the inspection logic.
683
+ //
684
+ // var text = gateContract.extractBytesAsText(ctx);
685
+ // if (!text) return { ok: true, action: "serve" };
686
+ //
687
+ // Returns "" if ctx.bytes is missing — caller treats empty as serve.
688
+ function extractBytesAsText(ctx) {
689
+ if (!ctx) return "";
690
+ var bytes = ctx.bytes;
691
+ if (!bytes) return "";
692
+ return Buffer.isBuffer(bytes) ? bytes.toString("utf8") : String(bytes);
693
+ }
694
+
695
+ // buildGuardGate — gate-construction shorthand. Every guard-*
696
+ // primitive's gate(opts) factory forwards the same ~16-key opts bag
697
+ // (mode, audit, observability, forensicEvidenceStore, cache, hooks,
698
+ // runtime cap, ...) to defineGate. Centralizes that forwarding so each
699
+ // guard's gate() body is just the check function plus a label.
700
+ //
701
+ // gateContract.buildGuardGate(
702
+ // opts.name || "guardCsv:" + (opts.profile || "default"),
703
+ // opts,
704
+ // async function (ctx) { ... per-guard check ... });
705
+ function buildGuardGate(name, opts, check) {
706
+ opts = opts || {};
707
+ return defineGate({
708
+ name: name,
709
+ version: "1.0.0",
710
+ mode: opts.mode,
711
+ audit: opts.audit || null,
712
+ observability: opts.observability || null,
713
+ forensicEvidenceStore: opts.forensicEvidenceStore || null,
714
+ forensicSnippetBytes: opts.forensicSnippetBytes,
715
+ cache: opts.cache || null,
716
+ cacheTtlMs: opts.cacheTtlMs || 0,
717
+ maxRuntimeMs: opts.maxRuntimeMs,
718
+ beforeCheck: opts.beforeCheck,
719
+ afterCheck: opts.afterCheck,
720
+ onIssue: opts.onIssue,
721
+ onSanitize: opts.onSanitize,
722
+ onRefuse: opts.onRefuse,
723
+ onAudit: opts.onAudit,
724
+ check: check,
725
+ });
726
+ }
727
+
728
+ // aggregateIssues — wrap an issues array in the canonical
729
+ // `{ ok, issues }` validate-result shape. ok=true when no issue is
730
+ // `critical` or `high` severity. Used by guards whose validate path
731
+ // can't go through runIssueValidator (raw-Buffer input cases).
732
+ function aggregateIssues(issues) {
733
+ return {
734
+ ok: !issues.some(function (i) {
735
+ return i.severity === "critical" || i.severity === "high";
736
+ }),
737
+ issues: issues,
738
+ };
739
+ }
740
+
741
+ // badInputResultIfNotStringOrBuffer — returns the canonical
742
+ // `{ ok: false, issues: [{ kind: "bad-input", ... }] }` result when
743
+ // `input` is neither a string nor a Buffer; null otherwise. Used by
744
+ // guards whose validate path can't pre-convert (e.g. guard-svg needs
745
+ // raw bytes for SVGZ magic detection; guard-filename needs raw bytes
746
+ // for overlong-UTF-8 byte scan).
747
+ function badInputResultIfNotStringOrBuffer(input) {
748
+ if (typeof input === "string" || Buffer.isBuffer(input)) return null;
749
+ return {
750
+ ok: false,
751
+ issues: [{ kind: "bad-input", severity: "high",
752
+ snippet: "input is not string or Buffer" }],
753
+ };
754
+ }
755
+
756
+ // runIssueValidator — boilerplate for guard-* validate(input, opts)
757
+ // entry points. Normalizes string|Buffer input, returns the canonical
758
+ // { ok: false, issues: [{ kind: "bad-input", ... }] } shape on type
759
+ // mismatch, otherwise calls the operator-supplied detector and
760
+ // computes ok = no issue is critical/high. Used so every guard's
761
+ // validate() body is identical scaffolding around the per-guard
762
+ // _detectIssues function.
763
+ //
764
+ // gateContract.runIssueValidator(input, opts, function (text, opts) {
765
+ // return _detectIssues(text, opts);
766
+ // });
767
+ function runIssueValidator(input, opts, detector) {
768
+ var text = typeof input === "string"
769
+ ? input
770
+ : (Buffer.isBuffer(input) ? input.toString("utf8") : null);
771
+ if (text == null) {
772
+ return {
773
+ ok: false,
774
+ issues: [{ kind: "bad-input", severity: "high",
775
+ snippet: "input is not string or Buffer" }],
776
+ };
777
+ }
778
+ return aggregateIssues(detector(text, opts));
779
+ }
780
+
781
+ // resolveProfileAndPosture — overlay opts.profile + opts.compliancePosture
782
+ // over a defaults object using guard-supplied tables. Used by every guard
783
+ // primitive's create()/factory entry point so the resolution shape is
784
+ // identical across the family.
785
+ //
786
+ // resolveProfileAndPosture(opts, {
787
+ // profiles: PROFILES,
788
+ // compliancePostures: COMPLIANCE_POSTURES,
789
+ // defaults: DEFAULTS,
790
+ // errorClass: GuardCsvError,
791
+ // errCodePrefix: "csv", // throws "csv.bad-profile" / "csv.bad-posture"
792
+ // })
793
+ function resolveProfileAndPosture(opts, cfg) {
794
+ opts = opts || {};
795
+ validateOpts.requireObject(cfg, "gateContract.resolveProfileAndPosture",
796
+ GateContractError);
797
+ var ErrorClass = cfg.errorClass || GateContractError;
798
+ var prefix = cfg.errCodePrefix || "guard";
799
+ var overlay = {};
800
+ if (typeof opts.profile === "string") {
801
+ if (!cfg.profiles || !cfg.profiles[opts.profile]) {
802
+ throw ErrorClass.factory(prefix + ".bad-profile",
803
+ "unknown profile " + JSON.stringify(opts.profile));
804
+ }
805
+ overlay = cfg.profiles[opts.profile];
806
+ }
807
+ if (typeof opts.compliancePosture === "string") {
808
+ if (!cfg.compliancePostures || !cfg.compliancePostures[opts.compliancePosture]) {
809
+ throw ErrorClass.factory(prefix + ".bad-posture",
810
+ "unknown compliancePosture " + JSON.stringify(opts.compliancePosture));
811
+ }
812
+ overlay = Object.assign({}, overlay, cfg.compliancePostures[opts.compliancePosture]);
813
+ }
814
+ return Object.assign({}, cfg.defaults || {}, overlay, opts);
815
+ }
816
+
817
+ // buildProfile — recursive profile composition with cycle detection.
818
+ //
819
+ // buildProfile({
820
+ // baseProfile: "blog-post",
821
+ // extends: ["custom-tags"],
822
+ // overrides: { allowedTags: [...] },
823
+ // removes: { allowedAttrs: { a: ["target"] } },
824
+ // resolveProfile: name => profile, // operator-supplied resolver
825
+ // })
826
+ function buildProfile(opts) {
827
+ validateOpts.requireObject(opts, "gateContract.buildProfile", GateContractError);
828
+ var resolve = opts.resolveProfile;
829
+ if (typeof resolve !== "function") {
830
+ throw _err("gate-contract/bad-opt",
831
+ "buildProfile: opts.resolveProfile must be a function (name → profile)");
832
+ }
833
+ var seen = Object.create(null);
834
+ function _walk(name) {
835
+ if (seen[name]) {
836
+ throw _err("gate-contract/profile-cycle",
837
+ "buildProfile: cycle detected involving profile " + JSON.stringify(name));
838
+ }
839
+ seen[name] = true;
840
+ var p = resolve(name);
841
+ if (!p) throw _err("gate-contract/unknown-profile",
842
+ "buildProfile: unknown profile " + JSON.stringify(name));
843
+ var merged = Object.assign({}, p);
844
+ if (Array.isArray(p.extends)) {
845
+ for (var i = 0; i < p.extends.length; i++) {
846
+ var ext = _walk(p.extends[i]);
847
+ merged = _mergeProfile(ext, merged);
848
+ }
849
+ }
850
+ return merged;
851
+ }
852
+ var base = opts.baseProfile ? _walk(opts.baseProfile) : {};
853
+ if (Array.isArray(opts.extends)) {
854
+ for (var i = 0; i < opts.extends.length; i++) {
855
+ base = _mergeProfile(base, _walk(opts.extends[i]));
856
+ }
857
+ }
858
+ if (opts.overrides) base = _mergeProfile(base, opts.overrides);
859
+ if (opts.removes) base = _applyRemoves(base, opts.removes);
860
+ return base;
861
+ }
862
+
863
+ function _mergeProfile(target, source) {
864
+ var out = Object.assign({}, target);
865
+ var keys = Object.keys(source);
866
+ for (var k = 0; k < keys.length; k++) {
867
+ var key = keys[k];
868
+ var val = source[key];
869
+ if (Array.isArray(val) && Array.isArray(out[key])) {
870
+ // Array merge — union; later sources win on duplicates.
871
+ var seen2 = Object.create(null);
872
+ var merged = [];
873
+ for (var i = 0; i < out[key].length; i++) {
874
+ var v = JSON.stringify(out[key][i]);
875
+ if (!seen2[v]) { seen2[v] = true; merged.push(out[key][i]); }
876
+ }
877
+ for (var j = 0; j < val.length; j++) {
878
+ var v2 = JSON.stringify(val[j]);
879
+ if (!seen2[v2]) { seen2[v2] = true; merged.push(val[j]); }
880
+ }
881
+ out[key] = merged;
882
+ } else if (val && typeof val === "object" && !Array.isArray(val) &&
883
+ out[key] && typeof out[key] === "object" && !Array.isArray(out[key])) {
884
+ out[key] = _mergeProfile(out[key], val);
885
+ } else {
886
+ out[key] = val;
887
+ }
888
+ }
889
+ return out;
890
+ }
891
+
892
+ function _applyRemoves(target, removes) {
893
+ var out = Object.assign({}, target);
894
+ var keys = Object.keys(removes);
895
+ for (var k = 0; k < keys.length; k++) {
896
+ var key = keys[k];
897
+ if (Array.isArray(removes[key]) && Array.isArray(out[key])) {
898
+ var rmSet = Object.create(null);
899
+ for (var i = 0; i < removes[key].length; i++) rmSet[JSON.stringify(removes[key][i])] = true;
900
+ out[key] = out[key].filter(function (v) { return !rmSet[JSON.stringify(v)]; });
901
+ } else if (removes[key] && typeof removes[key] === "object" &&
902
+ out[key] && typeof out[key] === "object") {
903
+ out[key] = _applyRemoves(out[key], removes[key]);
904
+ } else {
905
+ delete out[key];
906
+ }
907
+ }
908
+ return out;
909
+ }
910
+
911
+ // summarizeIssues — project a gate decision's `issues` array down to the
912
+ // audit-shape (kind / severity / ruleId only — full snippets stay in the
913
+ // forensic store). Replaces the per-host inline `(d.issues || []).map(
914
+ // function (i) { return { kind: i.kind, severity: i.severity, ruleId:
915
+ // i.ruleId }; })` shape.
916
+ function summarizeIssues(issues) {
917
+ if (!Array.isArray(issues)) return [];
918
+ return issues.map(function (i) {
919
+ return { kind: i.kind, severity: i.severity, ruleId: i.ruleId };
920
+ });
921
+ }
922
+
923
+ // composeHooks — chain operator hooks. First non-null filter result
924
+ // wins; transformer hooks run sequentially.
925
+ function composeHooks(hooks) {
926
+ hooks = (hooks || []).filter(Boolean);
927
+ if (hooks.length === 0) return null;
928
+ if (hooks.length === 1) return hooks[0];
929
+ return async function () {
930
+ var args = Array.prototype.slice.call(arguments);
931
+ var result = null;
932
+ for (var i = 0; i < hooks.length; i++) {
933
+ var rv = await hooks[i].apply(null, args);
934
+ if (rv && (rv.suppress === true || rv.skip === true)) return rv;
935
+ if (rv) result = rv;
936
+ }
937
+ return result;
938
+ };
939
+ }
940
+
941
+ module.exports = {
942
+ defineGate: defineGate,
943
+ validateGateShape: validateGateShape,
944
+ runGate: runGate,
945
+ composeGates: composeGates,
946
+ multiplexGates: multiplexGates,
947
+ contentTypeMux: contentTypeMux,
948
+ byActorTier: byActorTier,
949
+ byRoute: byRoute,
950
+ byDirection: byDirection,
951
+ shadowMode: shadowMode,
952
+ canaryGate: canaryGate,
953
+ cachingGate: cachingGate,
954
+ workerThreadGate: workerThreadGate,
955
+ buildProfile: buildProfile,
956
+ resolveProfileAndPosture: resolveProfileAndPosture,
957
+ runIssueValidator: runIssueValidator,
958
+ buildGuardGate: buildGuardGate,
959
+ extractBytesAsText: extractBytesAsText,
960
+ lookupCompliancePosture: lookupCompliancePosture,
961
+ makeRulePackLoader: makeRulePackLoader,
962
+ makeProfileBuilder: makeProfileBuilder,
963
+ badInputResultIfNotStringOrBuffer: badInputResultIfNotStringOrBuffer,
964
+ aggregateIssues: aggregateIssues,
965
+ composeHooks: composeHooks,
966
+ summarizeIssues: summarizeIssues,
967
+ ACTIONS: ACTIONS,
968
+ MODES: MODES,
969
+ ISSUE_SEVERITIES: ISSUE_SEVERITIES,
970
+ GateContractError: GateContractError,
971
+ };