@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.
- package/CHANGELOG.md +2 -0
- package/index.js +10 -1
- package/lib/ai-content-detect.js +268 -0
- package/lib/ai-input.js +58 -8
- package/lib/ai-model-manifest.js +363 -0
- package/lib/atomic-file.js +83 -0
- package/lib/audit.js +5 -0
- package/lib/boot-gates.js +174 -0
- package/lib/content-credentials.js +140 -0
- package/lib/metrics.js +352 -18
- package/lib/pqc-agent.js +70 -1
- package/lib/promise-pool.js +162 -0
- package/lib/safe-path.js +254 -0
- package/lib/sd-notify.js +269 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
+
};
|
package/lib/atomic-file.js
CHANGED
|
@@ -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
|
+
};
|