@blamejs/core 0.10.7 → 0.10.9

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,363 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.ai.modelManifest
4
+ * @nav AI
5
+ * @title AI Model Manifest (AIBOM)
6
+ *
7
+ * @intro
8
+ * CycloneDX 1.6 ML-BOM emitter for AI bills of materials. EU AI Act
9
+ * Art. 11 + Annex IV require technical documentation for high-risk
10
+ * AI systems; the EU CRA (Regulation (EU) 2024/2847) transposition
11
+ * deadline (2027-12-11) extends SBOM-style documentation to AI
12
+ * components in products with digital elements. ML-BOM extends the
13
+ * CycloneDX 1.5 machine-learning component type with model-card,
14
+ * dataset, hyperparameter, formulation, and external-service
15
+ * sections per the OWASP CycloneDX Authoritative Guide to AI/ML-BOM
16
+ * and CycloneDX spec issue #702 (EU CRA alignment).
17
+ *
18
+ * Two paths:
19
+ * - `build({...})` constructs a 1.6-conformant JSON BOM in memory.
20
+ * - `sign(bom, { privateKeyPem })` signs the canonical-JSON-1785
21
+ * representation with the operator's signing key (ML-DSA-87 by
22
+ * default per project hard-rule 2). `verify(envelope, publicKeyPem)`
23
+ * re-canonicalizes and checks before trusting any field.
24
+ *
25
+ * Self-validation: `build` rejects BOMs missing CycloneDX 1.6
26
+ * required fields (`bomFormat`, `specVersion`, `metadata.timestamp`,
27
+ * `metadata.component` when shipping a model). Catches malformed
28
+ * inputs at emit time, not at downstream validator.
29
+ *
30
+ * @card
31
+ * CycloneDX 1.6 AI bill of materials (AIBOM) — model cards, datasets, hyperparameters, training workflows. ML-DSA-87 signed.
32
+ */
33
+
34
+ var bCrypto = require("./crypto");
35
+ var canonicalJson = require("./canonical-json");
36
+ var validateOpts = require("./validate-opts");
37
+ var { defineClass } = require("./framework-error");
38
+ var audit = require("./audit");
39
+
40
+ var AiModelManifestError = defineClass("AiModelManifestError", { alwaysPermanent: true });
41
+
42
+ var SPEC_VERSION = "1.6";
43
+ var BOM_FORMAT = "CycloneDX";
44
+ var COMPONENT_TYPE_MODEL = "machine-learning-model";
45
+ var VALID_DATA_TYPES = Object.freeze({
46
+ "source-code": true, configuration: true, dataset: true, definition: true,
47
+ "device-driver": true, documentation: true, evidence: true, executable: true,
48
+ file: true, firmware: true, framework: true, library: true,
49
+ "machine-learning-model": true, manifest: true, "operating-system": true,
50
+ patch: true, platform: true, "test-case": true,
51
+ });
52
+ var ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; // allow:duplicate-regex — ISO-8601 instant shape ships in three primitives (metrics text-render, content-credentials, mail-server-imap APPEND); each is bounded by its own caller and the regex itself is 50 bytes — extracting into a cross-module dep wouldn't carry its weight
53
+ var BOM_REF_RE = /^[A-Za-z0-9._:/+-]{1,256}$/; // allow:raw-byte-literal — CycloneDX bom-ref string-length cap, not bytes
54
+
55
+ function _requireString(obj, key, ownerName) {
56
+ if (typeof obj[key] !== "string" || obj[key].length === 0) {
57
+ throw new AiModelManifestError("aibom/bad-" + key,
58
+ ownerName + ": " + key + " must be a non-empty string");
59
+ }
60
+ }
61
+
62
+ function _validateModelComponent(c) {
63
+ if (!c || typeof c !== "object") {
64
+ throw new AiModelManifestError("aibom/bad-model-component",
65
+ "build: opts.model must be an object");
66
+ }
67
+ _requireString(c, "name", "build.model");
68
+ _requireString(c, "version", "build.model");
69
+ if (c["bom-ref"] !== undefined) {
70
+ if (typeof c["bom-ref"] !== "string" || c["bom-ref"].length > 256) { // allow:raw-byte-literal — bom-ref string-length cap, not bytes
71
+ throw new AiModelManifestError("aibom/bad-bom-ref",
72
+ "build.model: bom-ref must be a string of length 1-256");
73
+ }
74
+ if (!BOM_REF_RE.test(c["bom-ref"])) { // allow:regex-no-length-cap — length-bounded immediately above
75
+ throw new AiModelManifestError("aibom/bad-bom-ref",
76
+ "build.model: bom-ref must match [A-Za-z0-9._:/+-]");
77
+ }
78
+ }
79
+ // modelCard is CycloneDX 1.6 §4.5.5; required for ML components when
80
+ // the operator wants downstream tooling (e.g. Dependency-Track) to
81
+ // surface the card. We do not REQUIRE the card here (some operators
82
+ // ship a hash-only ML-BOM by policy) but we do validate shape when
83
+ // present.
84
+ if (c.modelCard !== undefined) {
85
+ if (typeof c.modelCard !== "object") {
86
+ throw new AiModelManifestError("aibom/bad-model-card",
87
+ "build.model.modelCard: must be an object");
88
+ }
89
+ }
90
+ }
91
+
92
+ function _validateDataComponent(d, idx) {
93
+ if (!d || typeof d !== "object") {
94
+ throw new AiModelManifestError("aibom/bad-dataset",
95
+ "build.datasets[" + idx + "]: must be an object");
96
+ }
97
+ _requireString(d, "name", "build.datasets[" + idx + "]");
98
+ if (d.type !== undefined && !VALID_DATA_TYPES[d.type]) {
99
+ throw new AiModelManifestError("aibom/bad-dataset-type",
100
+ "build.datasets[" + idx + "].type '" + d.type + "' not in CycloneDX 1.6 data-type vocabulary");
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @primitive b.ai.modelManifest.build
106
+ * @signature b.ai.modelManifest.build(opts)
107
+ * @since 0.10.8
108
+ * @status stable
109
+ * @compliance eu-ai-act-art-11, nist-ai-600-1, iso-42001, iso-23894
110
+ * @related b.ai.modelManifest.sign, b.ai.modelManifest.verify
111
+ *
112
+ * Build a CycloneDX 1.6 ML-BOM JSON document for the supplied model
113
+ * + datasets + hyperparameters + formulation + external services.
114
+ * Returns a frozen plain object ready for `sign({...})` or direct
115
+ * serialization. Validates against the spec's required-field set at
116
+ * emit time (`bomFormat` / `specVersion` / `metadata.timestamp` /
117
+ * `metadata.component`) plus the ML-BOM-specific shape (model
118
+ * component type, dataset type vocabulary, bom-ref grammar).
119
+ *
120
+ * @opts
121
+ * model: object, // { name, version, license?, bom-ref?, modelCard? }
122
+ * datasets: object[], // [{ name, type?, contents?, classification?, ... }]
123
+ * hyperparameters: object, // kv pairs → properties[]
124
+ * formulation: object[], // [{ ref, components, workflows }]
125
+ * services: object[], // [{ name, endpoints, authenticated }]
126
+ * tool: object, // tool metadata; defaults to blamejs core
127
+ * timestamp: string, // ISO 8601 UTC; defaults to now
128
+ * serialNumber: string, // urn:uuid:...; defaults to fresh UUIDv4
129
+ *
130
+ * @example
131
+ * var bom = b.ai.modelManifest.build({
132
+ * model: { name: "acme-classifier", version: "1.2.3" },
133
+ * datasets: [{ name: "training-2026", type: "dataset" }],
134
+ * });
135
+ * bom.bomFormat; // → "CycloneDX"
136
+ * bom.specVersion; // → "1.6"
137
+ */
138
+ function build(opts) {
139
+ opts = opts || {};
140
+ _validateModelComponent(opts.model);
141
+ if (opts.datasets !== undefined) {
142
+ if (!Array.isArray(opts.datasets)) {
143
+ throw new AiModelManifestError("aibom/bad-datasets",
144
+ "build: opts.datasets must be an array");
145
+ }
146
+ for (var i = 0; i < opts.datasets.length; i += 1) _validateDataComponent(opts.datasets[i], i);
147
+ }
148
+ var timestamp = opts.timestamp || new Date().toISOString();
149
+ if (!ISO8601_RE.test(timestamp)) {
150
+ throw new AiModelManifestError("aibom/bad-timestamp",
151
+ "build: timestamp must be ISO 8601 UTC (e.g. 2026-05-17T20:00:00Z)");
152
+ }
153
+ var serialNumber = opts.serialNumber || _uuidUrn();
154
+ if (typeof serialNumber !== "string" || serialNumber.indexOf("urn:uuid:") !== 0) {
155
+ throw new AiModelManifestError("aibom/bad-serial-number",
156
+ "build: serialNumber must start with `urn:uuid:`");
157
+ }
158
+
159
+ var primaryComponent = Object.assign({
160
+ type: COMPONENT_TYPE_MODEL,
161
+ "bom-ref": opts.model["bom-ref"] || ("model:" + opts.model.name + "@" + opts.model.version),
162
+ }, opts.model);
163
+ primaryComponent.type = COMPONENT_TYPE_MODEL;
164
+
165
+ var components = [];
166
+ // Per CycloneDX 1.6 §4.7: the primary component goes in
167
+ // metadata.component, NOT in components[]. Datasets, hyperparameter-
168
+ // bearing components, and external dependencies live in components[].
169
+ if (Array.isArray(opts.datasets)) {
170
+ for (var di = 0; di < opts.datasets.length; di += 1) {
171
+ var ds = opts.datasets[di];
172
+ components.push(Object.assign({
173
+ type: ds.type || "data",
174
+ "bom-ref": ds["bom-ref"] || ("dataset:" + ds.name),
175
+ }, ds));
176
+ }
177
+ }
178
+
179
+ // Hyperparameters → CycloneDX properties[] kv pairs per spec
180
+ // issue #702 EU CRA alignment.
181
+ var properties = [];
182
+ if (opts.hyperparameters && typeof opts.hyperparameters === "object") {
183
+ var keys = Object.keys(opts.hyperparameters);
184
+ for (var k = 0; k < keys.length; k += 1) {
185
+ var key = keys[k];
186
+ properties.push({ name: "ai:hyperparameter:" + key,
187
+ value: String(opts.hyperparameters[key]) });
188
+ }
189
+ }
190
+
191
+ var bom = {
192
+ bomFormat: BOM_FORMAT,
193
+ specVersion: SPEC_VERSION,
194
+ serialNumber: serialNumber,
195
+ version: 1,
196
+ metadata: {
197
+ timestamp: timestamp,
198
+ tools: [Object.assign({
199
+ vendor: "blamejs",
200
+ name: "@blamejs/core",
201
+ version: _frameworkVersion(),
202
+ }, opts.tool || {})],
203
+ component: primaryComponent,
204
+ },
205
+ };
206
+ if (components.length > 0) bom.components = components;
207
+ if (properties.length > 0) bom.properties = properties;
208
+ if (Array.isArray(opts.formulation) && opts.formulation.length > 0) {
209
+ bom.formulation = opts.formulation;
210
+ }
211
+ if (Array.isArray(opts.services) && opts.services.length > 0) {
212
+ bom.services = opts.services;
213
+ }
214
+ if (Array.isArray(opts.dependencies) && opts.dependencies.length > 0) {
215
+ bom.dependencies = opts.dependencies;
216
+ }
217
+ return Object.freeze(bom);
218
+ }
219
+
220
+ /**
221
+ * @primitive b.ai.modelManifest.sign
222
+ * @signature b.ai.modelManifest.sign(bom, opts)
223
+ * @since 0.10.8
224
+ * @status stable
225
+ * @compliance eu-ai-act-art-11
226
+ * @related b.ai.modelManifest.build, b.ai.modelManifest.verify
227
+ *
228
+ * Sign an AIBOM produced by `build({...})`. Signature is over the
229
+ * canonical-JSON-1785 representation of the BOM (deterministic byte
230
+ * stream regardless of object-key insertion order); the operator's
231
+ * `privateKeyPem` selects the signing alg (ML-DSA-87 by default per
232
+ * the project's PQC-first crypto rule). Returns `{ bom, signature }`
233
+ * where `signature` is base64-encoded.
234
+ *
235
+ * @opts
236
+ * privateKeyPem: string, // PEM-encoded private key
237
+ * audit: boolean, // default true
238
+ *
239
+ * @example
240
+ * var pair = b.crypto.generateSigningKeyPair("ml-dsa-87");
241
+ * var bom = b.ai.modelManifest.build({ model: { name: "x", version: "1" }});
242
+ * var env = b.ai.modelManifest.sign(bom, { privateKeyPem: pair.privateKey });
243
+ * typeof env.signature; // → "string"
244
+ */
245
+ function sign(bom, opts) {
246
+ opts = opts || {};
247
+ if (!bom || typeof bom !== "object") {
248
+ throw new AiModelManifestError("aibom/bad-bom",
249
+ "sign: bom must be an object produced by build({...})");
250
+ }
251
+ validateOpts.requireNonEmptyString(opts.privateKeyPem,
252
+ "sign: opts.privateKeyPem", AiModelManifestError, "aibom/bad-key");
253
+ var canonical = canonicalJson.stringify(bom);
254
+ var signature = bCrypto.sign(Buffer.from(canonical, "utf8"), opts.privateKeyPem);
255
+ if (opts.audit !== false) {
256
+ audit.safeEmit({
257
+ action: "aibom.signed",
258
+ outcome: "success",
259
+ metadata: {
260
+ modelName: bom.metadata && bom.metadata.component && bom.metadata.component.name,
261
+ modelVersion: bom.metadata && bom.metadata.component && bom.metadata.component.version,
262
+ serialNumber: bom.serialNumber,
263
+ },
264
+ });
265
+ }
266
+ return Object.freeze({
267
+ bom: bom,
268
+ signature: signature.toString("base64"),
269
+ });
270
+ }
271
+
272
+ /**
273
+ * @primitive b.ai.modelManifest.verify
274
+ * @signature b.ai.modelManifest.verify(envelope, publicKeyPem, opts)
275
+ * @since 0.10.8
276
+ * @status stable
277
+ * @compliance eu-ai-act-art-11
278
+ * @related b.ai.modelManifest.sign
279
+ *
280
+ * Verify an envelope produced by `sign(bom, {...})`. Re-canonicalizes
281
+ * the BOM with `canonicalJson.stringify` (NEVER trusts an embedded
282
+ * "signedBytes" field — defends the CVE-2025-29774 / CVE-2025-29775
283
+ * xml-crypto-style signature-substitution class) and checks the
284
+ * signature with `b.crypto.verify` against the supplied public-key
285
+ * PEM. Returns `{ valid, bom, reason }`; never throws.
286
+ *
287
+ * @opts
288
+ * audit: boolean, // default true
289
+ *
290
+ * @example
291
+ * var result = b.ai.modelManifest.verify(envelope, pair.publicKey);
292
+ * if (!result.valid) console.log(result.reason);
293
+ */
294
+ function verify(envelope, publicKeyPem, opts) {
295
+ opts = opts || {};
296
+ if (!envelope || typeof envelope !== "object" || !envelope.bom || !envelope.signature) {
297
+ return { valid: false, bom: null, reason: "envelope-shape" };
298
+ }
299
+ if (typeof publicKeyPem !== "string" || publicKeyPem.length === 0) {
300
+ return { valid: false, bom: null, reason: "public-key-required" };
301
+ }
302
+ if (envelope.bom.specVersion !== SPEC_VERSION || envelope.bom.bomFormat !== BOM_FORMAT) {
303
+ return { valid: false, bom: null, reason: "bom-spec-mismatch" };
304
+ }
305
+ var canonical = canonicalJson.stringify(envelope.bom);
306
+ var sigBuf;
307
+ try { sigBuf = Buffer.from(envelope.signature, "base64"); }
308
+ catch (_e) { return { valid: false, bom: null, reason: "signature-base64-bad" }; }
309
+ // `b.crypto.verify` can throw on malformed public-key PEM (Node's
310
+ // crypto layer surfaces `DECODER routines::unsupported` and similar).
311
+ // The documented contract here is `{ valid, bom, reason }` with no
312
+ // throws — wrap so a hostile / mistyped key returns a structured
313
+ // verdict instead of crashing the request path.
314
+ var ok;
315
+ try { ok = bCrypto.verify(Buffer.from(canonical, "utf8"), sigBuf, publicKeyPem); }
316
+ catch (_e2) { return { valid: false, bom: null, reason: "public-key-malformed" }; }
317
+ if (!ok) return { valid: false, bom: null, reason: "signature-invalid" };
318
+ if (opts.audit !== false) {
319
+ audit.safeEmit({
320
+ action: "aibom.verified",
321
+ outcome: "success",
322
+ metadata: {
323
+ modelName: envelope.bom.metadata && envelope.bom.metadata.component &&
324
+ envelope.bom.metadata.component.name,
325
+ serialNumber: envelope.bom.serialNumber,
326
+ },
327
+ });
328
+ }
329
+ return { valid: true, bom: envelope.bom, reason: null };
330
+ }
331
+
332
+ // UUIDv4 via the framework's CSPRNG path. Used for `serialNumber`
333
+ // defaults — operators may supply their own for cross-build stability.
334
+ function _uuidUrn() {
335
+ var b = bCrypto.generateBytes(16); // allow:raw-byte-literal — RFC 9562 §4.1 UUIDv4 is a 16-byte (128-bit) primitive
336
+ b[6] = (b[6] & 0x0f) | 0x40; // allow:raw-byte-literal — UUIDv4 version nibble (RFC 9562 §4.4)
337
+ b[8] = (b[8] & 0x3f) | 0x80; // allow:raw-byte-literal — UUIDv4 variant nibble (RFC 9562 §4.4)
338
+ var h = b.toString("hex");
339
+ return "urn:uuid:" + h.slice(0, 8) + "-" + h.slice(8, 12) + "-" + // allow:raw-byte-literal — RFC 9562 §4 UUID text representation hex offsets (8-4-4-4-12)
340
+ h.slice(12, 16) + "-" + h.slice(16, 20) + "-" + h.slice(20); // allow:raw-byte-literal — RFC 9562 §4 UUID hex offsets
341
+ }
342
+
343
+ // package.json read lives behind the call to dodge a circular-load
344
+ // chain: framework boot pulls index.js → ai-model-manifest → audit →
345
+ // db → framework-error → constants → package.json. Reading
346
+ // package.json at boot time would close the cycle. The lazy read is
347
+ // idempotent and the result is cached on first call.
348
+ var _cachedVersion = null;
349
+ function _frameworkVersion() {
350
+ if (_cachedVersion) return _cachedVersion;
351
+ try { _cachedVersion = require("../package.json").version; } // allow:inline-require — lazy package-version read to avoid boot-time circular load
352
+ catch (_e) { _cachedVersion = "0.0.0"; }
353
+ return _cachedVersion;
354
+ }
355
+
356
+ module.exports = {
357
+ build: build,
358
+ sign: sign,
359
+ verify: verify,
360
+ SPEC_VERSION: SPEC_VERSION,
361
+ BOM_FORMAT: BOM_FORMAT,
362
+ AiModelManifestError: AiModelManifestError,
363
+ };
@@ -279,6 +279,88 @@ function pathTimestamp(date) {
279
279
  return d.toISOString().replace(/[:.]/g, "-");
280
280
  }
281
281
 
282
+ // Generic [A-Za-z0-9_-]+ identifier shape used by conflictPath tag /
283
+ // suffix validation. The pattern collides with similar shapes in
284
+ // safe-buffer / redact / etc.; keeping the regex literal local to
285
+ // atomic-file rather than pulling in a cross-module dependency for a
286
+ // 30-byte regex keeps this file's lazy-load chain short.
287
+ var IDENT_RE = /^[A-Za-z0-9_-]+$/; // allow:regex-no-length-cap — caller bounds length before .test() // allow:duplicate-regex — generic [A-Za-z0-9_-]+ identifier shape; extracting a one-line regex into a cross-module dependency would lengthen atomic-file's boot-time lazy chain for no behavioral win
288
+
289
+ /**
290
+ * @primitive b.atomicFile.conflictPath
291
+ * @signature b.atomicFile.conflictPath(originalPath, opts?)
292
+ * @since 0.10.8
293
+ * @status stable
294
+ * @related b.atomicFile.pathTimestamp, b.atomicFile.write
295
+ *
296
+ * Build a filesystem-portable conflict-suffix path next to
297
+ * `originalPath`, e.g. `notes.md` → `notes.conflict-2026-05-17T19-30-00Z.md`.
298
+ * Drop-in name for last-write-wins reconciliation in sync / backup /
299
+ * dual-control workflows. Preserves the original extension. Inserts a
300
+ * caller-supplied `tag` (default `conflict`) between the basename and
301
+ * the timestamp. The timestamp uses `pathTimestamp` so the result is
302
+ * portable across Windows (no `:` / `.`), macOS, and Linux. Same-second
303
+ * collision handling: pass `opts.suffix` (e.g. a per-row crypto-random
304
+ * hex) when multiple conflicts may land in the same second; otherwise
305
+ * the timestamp's millisecond field disambiguates.
306
+ *
307
+ * @opts
308
+ * tag: string, // default "conflict"; sandwiched between basename and timestamp
309
+ * timestamp: Date, // default `new Date()`
310
+ * suffix: string, // optional extra disambiguator appended after timestamp
311
+ *
312
+ * @example
313
+ * var p = b.atomicFile.conflictPath("/srv/notes.md");
314
+ * // → "/srv/notes.conflict-2026-05-17T20-30-00-123Z.md"
315
+ *
316
+ * var withSuffix = b.atomicFile.conflictPath("/srv/notes.md", {
317
+ * tag: "merge", suffix: "abc123",
318
+ * });
319
+ * // → "/srv/notes.merge-2026-05-17T20-30-00-123Z.abc123.md"
320
+ */
321
+ function conflictPath(originalPath, opts) {
322
+ if (typeof originalPath !== "string" || originalPath.length === 0) {
323
+ throw new TypeError("b.atomicFile.conflictPath: originalPath must be a non-empty string");
324
+ }
325
+ opts = opts || {};
326
+ var tag = typeof opts.tag === "string" && opts.tag.length > 0 ? opts.tag : "conflict";
327
+ if (typeof tag !== "string" || tag.length === 0 || tag.length > 64) { // allow:raw-byte-literal — tag length cap, not bytes
328
+ throw new TypeError("b.atomicFile.conflictPath: tag must be a 1-64 char string");
329
+ }
330
+ if (!IDENT_RE.test(tag)) { // allow:regex-no-length-cap — length-bounded immediately above
331
+ throw new TypeError("b.atomicFile.conflictPath: tag must match [A-Za-z0-9_-]+");
332
+ }
333
+ var stamp = pathTimestamp(opts.timestamp);
334
+ var suffix = "";
335
+ if (opts.suffix !== undefined) {
336
+ if (typeof opts.suffix !== "string" || opts.suffix.length === 0 ||
337
+ opts.suffix.length > 64) { // allow:raw-byte-literal — suffix length cap, not bytes
338
+ throw new TypeError("b.atomicFile.conflictPath: suffix must be a 1-64 char string");
339
+ }
340
+ if (!IDENT_RE.test(opts.suffix)) { // allow:regex-no-length-cap — length-bounded immediately above
341
+ throw new TypeError("b.atomicFile.conflictPath: suffix must match [A-Za-z0-9_-]+");
342
+ }
343
+ suffix = "." + opts.suffix;
344
+ }
345
+ // Walk from the rightmost `.` to split base + ext. POSIX `path` module
346
+ // does it portably; using string ops here keeps the helper free of
347
+ // additional require()s in the hot atomic-file file (which is loaded
348
+ // before most of the framework's lazy chain). Extension preservation
349
+ // walks ONLY the basename — a directory containing a `.` doesn't
350
+ // confuse the suffix.
351
+ var sep = originalPath.lastIndexOf("/");
352
+ var bsep = originalPath.lastIndexOf("\\");
353
+ var lastSep = sep > bsep ? sep : bsep;
354
+ var dir = lastSep >= 0 ? originalPath.slice(0, lastSep + 1) : "";
355
+ var name = lastSep >= 0 ? originalPath.slice(lastSep + 1) : originalPath;
356
+ var dotIdx = name.lastIndexOf(".");
357
+ // Treat a leading dot (dotfile, e.g. `.env`) as part of the base, not
358
+ // as an extension separator. `dotIdx === 0` → no extension.
359
+ var base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
360
+ var ext = dotIdx > 0 ? name.slice(dotIdx) : "";
361
+ return dir + base + "." + tag + "-" + stamp + suffix + ext;
362
+ }
363
+
282
364
  /**
283
365
  * @primitive b.atomicFile.writeSync
284
366
  * @signature b.atomicFile.writeSync(filepath, data, opts)
@@ -935,6 +1017,7 @@ module.exports = {
935
1017
  ensureDir: ensureDir,
936
1018
  copyDirRecursive: copyDirRecursive,
937
1019
  pathTimestamp: pathTimestamp,
1020
+ conflictPath: conflictPath,
938
1021
  AtomicFileError: AtomicFileError,
939
1022
  DEFAULTS: DEFAULTS,
940
1023
  };
package/lib/audit.js CHANGED
@@ -296,6 +296,11 @@ var FRAMEWORK_NAMESPACES = [
296
296
  "localdb", // b.localDb.thin (localdb.thin.opened / recovered / closed — desktop-daemon SQLite wrapper)
297
297
  "dataact", // b.dataAct (EU Data Act 2023/2854 — product_declared / user_access / share_with_third_party / share_refused / switch_request)
298
298
  "idempotency", // b.middleware.idempotencyKey (idempotency.missing_key / bad_key / replay / key_reuse_mismatch / cache_store / store_read_failed / store_write_failed / skip_5xx / body_too_large — draft-ietf-httpapi-idempotency-key)
299
+ "aibom", // b.ai.modelManifest (aibom.signed / aibom.verified — CycloneDX 1.6 ML-BOM)
300
+ "aicontentdetect", // b.ai.aiContentDetect (aicontentdetect.report — AB-853 / EU AI Act Art. 50 inbound provenance)
301
+ "sdnotify", // b.sdNotify (sdnotify.send / sdnotify.send.skipped — systemd Type=notify)
302
+ "bootgates", // b.bootGates (bootgates.passed / bootgates.failed / bootgates.onfail_threw — boot-invariant runner)
303
+ "metrics", // b.metrics.snapshot.shadowRegistry (metrics.shadow.cardinality_dropped — namespaced metrics export)
299
304
  ];
300
305
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
301
306
 
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.bootGates
4
+ * @nav Process
5
+ * @title Boot Gates
6
+ *
7
+ * @intro
8
+ * Sequential gate runner for boot-time invariants — vault unseal,
9
+ * KEM key load, TLS material presence, DB schema migration, etc.
10
+ * Each gate is `{ name, fn, timeoutMs?, exitCode?, onFail? }`; the
11
+ * runner walks them in order and on FIRST failure:
12
+ *
13
+ * 1. emits `bootgates.failed { name, error, durationMs }` audit,
14
+ * 2. runs `onFail(err)` if provided (await async; swallows
15
+ * throws + emits a separate `bootgates.onfail_threw` audit),
16
+ * 3. writes a single-line failure summary to stderr,
17
+ * 4. calls `process.exit(gate.exitCode || 1)`.
18
+ *
19
+ * On success: emits `bootgates.passed { name, durationMs }` and
20
+ * proceeds. Returns `{ passed, totalMs }` when EVERY gate passes.
21
+ *
22
+ * Replaces the open-coded boot sequence operators write per-process
23
+ * (try / log / process.exit) with one greppable primitive that
24
+ * composes audit observability and gate-specific timeouts.
25
+ *
26
+ * @card
27
+ * Sequential boot-invariant runner — gate, audit, exit with the right exit code. The thing every daemon main() reaches for.
28
+ */
29
+
30
+ var C = require("./constants");
31
+ var audit = require("./audit");
32
+ var safeAsync = require("./safe-async");
33
+ var { defineClass } = require("./framework-error");
34
+
35
+ var BootGatesError = defineClass("BootGatesError", { alwaysPermanent: true });
36
+
37
+ var DEFAULT_GATE_TIMEOUT_MS = C.TIME.seconds(60);
38
+ var DEFAULT_EXIT_CODE = 1;
39
+
40
+ /**
41
+ * @primitive b.bootGates.run
42
+ * @signature b.bootGates.run(gates, opts?)
43
+ * @since 0.10.9
44
+ * @status stable
45
+ * @related b.appShutdown.create, b.audit.safeEmit
46
+ *
47
+ * Walk `gates` in order, awaiting each `fn`. First failure stops the
48
+ * sequence and (after `onFail` + audit + stderr) calls
49
+ * `process.exit(gate.exitCode || opts.exitCode || 1)`. Returns
50
+ * `{ passed: string[], totalMs: number }` on full success.
51
+ *
52
+ * @opts
53
+ * exitCode: number, // default 1 — overall fall-through
54
+ * log: function, // default console.error.bind(console)
55
+ * exit: function, // test seam; default process.exit
56
+ * overallTimeoutMs: number, // cap across the full sequence
57
+ *
58
+ * @example
59
+ * await b.bootGates.run([
60
+ * { name: "vault.unseal", fn: async function () { await b.vault.unseal(); } },
61
+ * { name: "tls.material", fn: async function () { await loadTls(); } },
62
+ * { name: "db.schemaMigration", fn: async function () { await migrate(); },
63
+ * onFail: async function () { await db.close(); } },
64
+ * ]);
65
+ */
66
+ async function run(gates, opts) {
67
+ opts = opts || {};
68
+ if (!Array.isArray(gates) || gates.length === 0) {
69
+ throw new BootGatesError("bootgates/bad-input",
70
+ "b.bootGates.run: gates must be a non-empty array");
71
+ }
72
+ var log = typeof opts.log === "function" ? opts.log : function (msg) {
73
+ process.stderr.write(msg + "\n");
74
+ };
75
+ // Default exit handler invokes process.exit on the platform — guarded
76
+ // behind the opt because lib/ code is forbidden from calling
77
+ // process.exit directly (codebase-patterns rule "no process.exit() in
78
+ // lib/ (CLI surface only)"); the indirection routes through an opts-
79
+ // supplied function so test code substitutes a capture, and the CLI
80
+ // (bin/blamejs.js) is the one that wires the real exit call. When
81
+ // opts.exit isn't supplied, the boot-gate failure path bubbles a
82
+ // throw rather than terminating the process — operators that wire
83
+ // bootGates from their daemon main() pass `exit: process.exit.bind(process)`.
84
+ var exit = typeof opts.exit === "function" ? opts.exit : function (code) {
85
+ var e = new BootGatesError("bootgates/no-exit-wired",
86
+ "b.bootGates.run: gate failed (exitCode=" + code + ") but no opts.exit handler was supplied; " +
87
+ "operators wire opts.exit to process.exit.bind(process) in their daemon main()");
88
+ e.exitCode = code;
89
+ throw e;
90
+ };
91
+ var overallTimeoutMs = opts.overallTimeoutMs;
92
+ var t0 = Date.now();
93
+ var passed = [];
94
+
95
+ for (var i = 0; i < gates.length; i += 1) {
96
+ var gate = gates[i];
97
+ if (!gate || typeof gate.name !== "string" || gate.name.length === 0 ||
98
+ typeof gate.fn !== "function") {
99
+ throw new BootGatesError("bootgates/bad-gate",
100
+ "b.bootGates.run: gates[" + i + "] must be { name: string, fn: function }");
101
+ }
102
+ var timeoutMs = gate.timeoutMs || DEFAULT_GATE_TIMEOUT_MS;
103
+ if (typeof timeoutMs !== "number" || !isFinite(timeoutMs) || timeoutMs < 1) {
104
+ throw new BootGatesError("bootgates/bad-timeout",
105
+ "b.bootGates.run: gates[" + i + "].timeoutMs must be a positive finite number");
106
+ }
107
+ var gateT0 = Date.now();
108
+ var failure = null;
109
+ try {
110
+ await safeAsync.withTimeout(Promise.resolve().then(gate.fn), timeoutMs,
111
+ new BootGatesError("bootgates/timeout",
112
+ "b.bootGates.run: gate '" + gate.name + "' exceeded " + timeoutMs + "ms"));
113
+ } catch (err) {
114
+ failure = err;
115
+ }
116
+ if (overallTimeoutMs !== undefined &&
117
+ Date.now() - t0 > overallTimeoutMs && failure === null) {
118
+ failure = new BootGatesError("bootgates/overall-timeout",
119
+ "b.bootGates.run: overall budget " + overallTimeoutMs + "ms exceeded after gate '" +
120
+ gate.name + "'");
121
+ }
122
+ var durationMs = Date.now() - gateT0;
123
+ if (failure !== null) {
124
+ try {
125
+ audit.safeEmit({
126
+ action: "bootgates.failed",
127
+ outcome: "failure",
128
+ metadata: { name: gate.name, error: (failure && failure.message) || String(failure),
129
+ durationMs: durationMs },
130
+ });
131
+ } catch (_e) { /* drop-silent */ }
132
+ if (typeof gate.onFail === "function") {
133
+ try {
134
+ await Promise.resolve().then(function () { return gate.onFail(failure); });
135
+ } catch (oe) {
136
+ try {
137
+ audit.safeEmit({
138
+ action: "bootgates.onfail_threw",
139
+ outcome: "failure",
140
+ metadata: { name: gate.name, error: (oe && oe.message) || String(oe) },
141
+ });
142
+ } catch (_e2) { /* drop-silent */ }
143
+ }
144
+ }
145
+ log("[bootgates] FAILED gate=" + gate.name + " durationMs=" + durationMs +
146
+ " error=" + ((failure && failure.message) || String(failure)));
147
+ if (failure && failure.stack) log(failure.stack);
148
+ var code = gate.exitCode || opts.exitCode || DEFAULT_EXIT_CODE;
149
+ exit(code);
150
+ // The test seam swaps `exit` out; in that case we still surface
151
+ // a synthetic return value so the caller's promise resolves.
152
+ return { passed: passed, failed: gate.name, exitCode: code, totalMs: Date.now() - t0 };
153
+ }
154
+ try {
155
+ audit.safeEmit({
156
+ action: "bootgates.passed",
157
+ outcome: "success",
158
+ metadata: { name: gate.name, durationMs: durationMs },
159
+ });
160
+ } catch (_e3) { /* drop-silent */ }
161
+ if (typeof opts.onPassed === "function") {
162
+ try { opts.onPassed({ name: gate.name, durationMs: durationMs }); }
163
+ catch (_e4) { /* drop-silent */ }
164
+ }
165
+ passed.push(gate.name);
166
+ }
167
+
168
+ return { passed: passed, totalMs: Date.now() - t0 };
169
+ }
170
+
171
+ module.exports = {
172
+ run: run,
173
+ BootGatesError: BootGatesError,
174
+ };