@blamejs/core 0.10.8 → 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 CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.10.x
10
10
 
11
+ - v0.10.9 (2026-05-17) — **Ergonomic helpers bundle (A / B / C / D / E / H).** Six small DX primitives bundled into one release. **(a) `b.safePath.resolve` / `.resolveOrNull` / `.validate`** — path-traversal-safe multi-segment resolve. Refuses absolute / UNC / drive-letter `rel`, NUL bytes, C0 control chars, bidi-override codepoints (CVE-2021-42574 Trojan Source class), URL-encoded + fullwidth + division-slash path separators, Windows reserved device names CON / PRN / AUX / NUL / COM[0-9] / LPT[0-9] on EVERY platform (closes [CVE-2025-27210](https://nvd.nist.gov/vuln/detail/CVE-2025-27210) cross-mount class), trailing-`.`/trailing-space segments under windows-mode, NTFS Alternate Data Stream markers ([CVE-2024-12217](https://nvd.nist.gov/vuln/detail/CVE-2024-12217) class), and `..` segments that escape `base` after lexical resolve. Optional `opts.realpath: true` adds symlink-escape detection via `fs.realpathSync.native`. Every documented failure mode → coded refusal (`safe-path/absolute-rel` / `null-byte` / `bidi` / `win-reserved` / `escapes-base` / etc.); no best-effort path. **(b) `b.bootGates.run([{ name, fn, timeoutMs?, exitCode?, onFail? }], opts?)`** — sequential boot-invariant runner. Each gate runs in order; on first failure: emits `bootgates.failed` audit, runs the gate's `onFail` callback (swallows + audits onFail throws), writes a single-line failure summary via `opts.log`, and calls the operator-supplied `opts.exit(code)`. The default `exit` throws `BootGatesError("bootgates/no-exit-wired")` rather than calling `process.exit` directly (lib/ never terminates the process — the CLI surface owns that wiring). Each gate runs under a 60s default `timeoutMs` budget configurable per-gate; overall budget via `opts.overallTimeoutMs`. **(c) `b.metrics.snapshot.shadowRegistry({ namespace, counters, gauges, info, cardinalityCap?, onCardinalityExceeded? })`** — namespaced shadow registry that mirrors a subset of a primary registry's metrics for export to systems needing isolated views (sidecar / per-tenant scrape endpoint / compliance-tagged subset). Cardinality cap (default 10000 per metric name) closes the [client_golang CVE-2022-21698](https://nvd.nist.gov/vuln/detail/CVE-2022-21698) unbounded-cardinality DoS class; policy is `drop` (default), `audit-only`, or `refuse`. Emits `metrics.shadow.cardinality_dropped` audit (rate-limited to 1/sec per shadow registry). **(d) Per-instance `agent.reloadCerts({ cert, key, ca })` on `b.pqcAgent.create()` returns** — long-running daemons that rotate TLS material via explicit `b.pqcAgent.create()` agents previously needed a process restart; the new instance method tests the new material via `tls.createSecureContext`, swaps `agent.options` atomically, closes idle keep-alive sockets via `agent.destroy()` (in-flight sockets complete naturally), and emits `pqcagent.reloadCerts` audit. Cert/key mismatch surfaces as `pqcagent/reload-mismatch` with the OpenSSL chain; CA bundle parse failures surface as `pqcagent/reload-bad-ca`. **(e) `b.metrics.snapshot.render(snap, { format: "text", groups })`** — operator-readable text format gains an `opts.groups` map that sections the output (`== HTTP ==` / `== Queue ==` / `== TLS ==`); fields not named in any group fall to `== Other ==`. Group ordering preserved per insertion order. Prometheus / OpenMetrics formats unchanged. **(h) ISO-8601 date strings render-eligible in the text format** — timestamps shaped as `2026-05-17T20:00:00.000Z` (length-bounded at 64 chars) now render verbatim in the text format instead of degrading to `[skipped: non-numeric]`; the Prometheus format gets a parallel `<name>_epoch_ms` gauge so downstream alerting can compute durations per OpenMetrics 1.0 §3.4 (Timestamps MUST be float64 Unix-epoch). Non-ISO strings continue to skip in Prometheus (label-value injection defense). **New compliance scaffold:** new namespace `b.bootGates`. New audit namespaces: `bootgates`, `metrics`. **Operator impact:** no breaking changes. `b.bootGates.run` callers MUST supply `opts.exit: process.exit.bind(process)` from their daemon main() if they want the failure path to terminate the process — the default-throw shape exists so lib/-internal callers can't accidentally `process.exit` from inside a primitive. `b.safePath.resolve` is a brand-new primitive; existing code is unaffected. References: [Node.js path.resolve docs](https://nodejs.org/api/path.html#pathresolvepaths) · [CVE-2025-27210 Windows device-name bypass](https://nvd.nist.gov/vuln/detail/CVE-2025-27210) · [CVE-2024-12217 NTFS ADS](https://nvd.nist.gov/vuln/detail/CVE-2024-12217) · [CVE-2021-42574 Trojan Source](https://nvd.nist.gov/vuln/detail/CVE-2021-42574) · [CVE-2022-21698 Prometheus cardinality DoS](https://nvd.nist.gov/vuln/detail/CVE-2022-21698) · [OpenMetrics 1.0 spec](https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md) · [CVE-2026-21637 SNI sync-throw](https://nvd.nist.gov/vuln/detail/CVE-2026-21637).
11
12
  - v0.10.8 (2026-05-17) — **EU AI Act Art. 50 + AB-853 + CAC implicit label + AIBOM + operator-surfaced DX primitives.** Calendar-bound release ahead of the 2026-08-02 EU AI Act Art. 50 transparency / California SB-942-as-amended-by-AB-853 effective date and the live (2025-09-01) China CAC GB 45438-2025 labeling regime. Three new AI-transparency surfaces + three operator-surfaced DX primitives (issues #91 / #92 / #93). **(a) `b.ai.aiContentDetect.report`** — inbound-asset provenance detector. Operators extract C2PA-COSE envelopes / CAC implicit-label JSON / IPTC PhotoMetadata via their format-specific muxer and feed the artifacts to `report({...})`; the framework verifies signatures, anchors against an operator-pinned trust list, and returns a normalized provenance report for the AB-853 §22757.21 disclosure UI. Trust-list-empty surfaces as an alert rather than silent acceptance. Profile / posture cascade: `ca-ab-853`, `ca-sb-942`, `eu-ai-act-art-50`, `cac-genai-label` pin to `strict` (refuse on signer not on trust list); `nist-ai-600-1`, `iso-42001`, `iso-23894`, `nist-ai-rmf` pin to `balanced`. **(b) `b.contentCredentials.cacImplicitLabel` + `b.contentCredentials.cacImplicitLabelRead`** — China CAC (Cyberspace Administration) "Measures for Labeling AI-Generated Synthetic Content" + mandatory standard GB 45438-2025 implicit metadata emitter and reverse parser. Validates the 18-character Chinese unified social credit code (统一社会信用代码 per GB 32100-2015), `aigcMarker` field, and `contentKind` enum at the config-time tier. Operators co-emit alongside the C2PA-COSE manifest by declaring `cac-genai-label` posture on the existing `b.contentCredentials.build`. **(c) `b.ai.modelManifest.build` / `.sign` / `.verify`** — CycloneDX 1.6 ML-BOM (AI bill of materials) emitter. EU AI Act Art. 11 + Annex IV require technical documentation for high-risk AI systems; CycloneDX 1.6 ML-BOM is the de-facto serialization (and forward-positioned for EU CRA 2027-12-11 — Regulation (EU) 2024/2847 requires SBOM-style documentation for AI components in products with digital elements). Emits `bomFormat: "CycloneDX"` + `specVersion: "1.6"` + `serialNumber` UUIDv4 URN + `metadata.timestamp` + `metadata.tools[]` + `metadata.component` (primary model with `type: "machine-learning-model"`) + `components[]` datasets + `properties[]` hyperparameters (per CycloneDX spec issue #702 EU CRA alignment) + `formulation[]` workflows + `services[]` external model APIs. ML-DSA-87 signature over canonical-JSON-1785 representation; verify path NEVER trusts an embedded "signedBytes" field — defends the CVE-2025-29774 / CVE-2025-29775 xml-crypto-style signature-substitution class. Self-validates required CycloneDX 1.6 fields at emit time. **(d) `b.atomicFile.conflictPath`** — filesystem-portable conflict-suffix path builder (issue #91). `notes.md` → `notes.conflict-2026-05-17T19-30-00Z.md`; Windows-safe (no `:` / `.`), extension-preserving, dotfile-aware, optional `tag` + `suffix` disambiguator for same-second collisions. Composes the existing `b.atomicFile.pathTimestamp`. **(e) `b.promisePool.create`** — bounded-concurrency promise pool (issue #92). The gap between `b.workerPool` (worker-thread CPU-bound work) and `b.queue` (durable cross-process messaging). `run(taskFn)` / `fire(taskFn)` / `drain({ close? })` shape with back-pressure on enqueue, queueLimit refusal, composes with `b.appShutdown` for drain-on-shutdown. No hidden retry — operators compose `b.retry.withRetry` inside the task body when they want it. **(f) `b.sdNotify.send` / `.ready` / `.stopping` / `.reloading` / `.watchdog`** — sd_notify protocol surface for systemd Type=notify daemons (issue #93). Reads `$NOTIFY_SOCKET` via `b.parsers.safeEnv.readVar`, dispatches `READY=1` / `STOPPING=1` / `RELOADING=1` / `WATCHDOG=1` via `systemd-notify(1)` with `execFile` (no shell). No-op (with audit) when `$NOTIFY_SOCKET` is unset (foreground / container / non-systemd init). Compose with `b.appShutdown.create` for the STOPPING signal; compose with a periodic watchdog interval for systemd's auto-restart-on-hang guarantee. **(g) `b.crypto.randomInt` substrate** — exported alongside the v0.10.7 substrate to give the new AIBOM UUID generator a single greppable random-int path. **(h) New compliance postures in `b.contentCredentials` / `b.ai.aiContentDetect` / `b.ai.modelManifest`:** `ca-ab-853`, `ca-sb-942`, `eu-ai-act-art-50`, `eu-ai-act-art-11`, `cac-genai-label`, `nist-ai-600-1`, `nist-ai-rmf`, `iso-42001`, `iso-23894`. **(i) New audit namespaces:** `aibom` (aibom.signed / aibom.verified), `aicontentdetect` (aicontentdetect.report), `sdnotify` (sdnotify.send / sdnotify.send.skipped). **Deferred to v0.10.9:** in-tree IPTC PhotoMetadata reader for `digitalSourceType` field — operators pre-parse with their tool of choice and pass via `opts.ipmd`. **Operator impact:** no breaking changes. Operators already declaring `eu-ai-act-art-50` posture should pin a trust list via the new primitive before turning on default-on detection in production; AB-853 §22757.21 platform-detection obligations are 2027-01-01 effective so there's runway. References: [EU AI Act Regulation (EU) 2024/1689](https://eur-lex.europa.eu/eli/reg/2024/1689) · [California SB-942 + AB-853](https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202320240SB942) · [CAC GB 45438-2025](http://www.cac.gov.cn/2025-03/14/c_1742700786675936.htm) · [C2PA 2.1 / 2.2 spec](https://c2pa.org/specifications/specifications/2.2/) · [CycloneDX 1.6 ML-BOM](https://cyclonedx.org/docs/1.6/json/) · [OWASP CycloneDX AI/ML-BOM Authoritative Guide](https://owasp.org/www-project-cyclonedx/) · [NIST AI 600-1 Generative AI Profile](https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.600-1.pdf) · [ISO/IEC 42001:2023](https://www.iso.org/standard/81230.html) · [systemd-notify(1)](https://www.freedesktop.org/software/systemd/man/latest/systemd-notify.html) · [CVE-2025-29774](https://nvd.nist.gov/vuln/detail/CVE-2025-29774) · [CVE-2025-29775](https://nvd.nist.gov/vuln/detail/CVE-2025-29775) · [CVE-2025-32711 EchoLeak](https://nvd.nist.gov/vuln/detail/CVE-2025-32711).
12
13
  - v0.10.7 (2026-05-17) — **Mail-stack P3 / P4 hardening sweep.** Twenty-plus refusals + observability additions across the four mail listeners, the DKIM verifier, ARC signer, MIME parser, and the DNS / DSN / List-* guards. No new public primitives; one substrate addition (`b.crypto.randomInt`); two new operator-visible opts on the submission listener. **(a) `b.crypto.randomInt(min, max)`** — substrate wrapper that routes every framework integer draw through one greppable primitive. Migrates the inline `nodeCrypto.randomInt` sites in `b.network.dns` / `b.network.dns.resolver` (DNS query-ID), `b.mail.auth` (DMARC `pct` sampling), and `b.externalDb` (transaction-retry jitter) so the audit trail is uniform and future detectors see one shape. **(b) `b.mail.server.submission.create({ requireDkim, dkimRequireMode })`** — outbound DKIM-required gate per Yahoo / Google 2024 bulk-sender alignment. `requireDkim` defaults `true` under `strict` profile (`false` under `balanced` / `permissive`). `dkimRequireMode` is `"self"` (signer's `d=` must match authenticated identity's domain), `"any"` (any signer present), or `"off"` (no gate). Default `"any"`. Submission listener that doesn't carry a `DKIM-Signature:` header at DATA-end refuses with `5.7.20`. **(c) `b.mail.server.{mx,submission}.create({ allowSmtpUtf8 })`** — single per-listener SMTPUTF8 (RFC 6531) switch threaded end-to-end into `guardSmtpCommand.validate`. Default `false`. Operators that accept EAI envelopes flip to `true` and the toggle reaches every wire-line guard call. **(d) DKIM verifier signature-count cap.** `b.mail.dkim.verify` now refuses (`policy` verdict) rather than silently truncating when a message carries more `DKIM-Signature` headers than `maxSignatures` (default 8). The opt is range-checked at config time against a ceiling of 16; out-of-range throws `dkim/bad-max-signatures`. Closes a verifier-fan-out DoS shape per RFC 6376 §6.1. Emits `dkim.verify.signature_count_cap` audit on the refusal so postmasters see DoS attempts in the authentication-results stream. **(e) MX listener size-overrun + observability.** `MAIL FROM SIZE=` is now reconciled against the actual DATA byte count after dot-stuffing reversal — senders that understate `SIZE=` to probe `maxMessageBytes` get `552 5.3.4` rather than silently accepted, with `mail.server.mx.size_overrun` audit. Refused-recipient list (bounded at 32 per transaction) now surfaces in the `data_accepted` / `delivered` audit metadata. Write-backpressure on every reply attaches a once-per-socket `mail.server.mx.write_backpressure` audit so operators see stalled connections without flooding on every reply. **(f) IMAP refinements.** `APPEND mailbox [flags] [date-time] {literal}` now honors the optional RFC 9051 §6.3.12 date-time argument (parsed into `internalDate` ms-epoch, refused with `BAD` rather than silently falling back to `Date.now()`). `FETCH` / `STORE` outside of Selected state now respond `BAD` (RFC 9051 §6.4.5 / §6.4.6 — protocol-context violation, not policy refusal). `LOGIN` quoted-string args honor `\"` / `\\` escape pairs per the RFC 9051 §5.1 grammar (the prior shape terminated at the first `"`, letting a hostile client smuggle `LOGIN "alice\"@example.com" "pw"` past the username binding). **(g) ARC signer hop-count ceiling.** `b.mail.arc.sign` extracts prior hops with the RFC 8617 §5 50-hop cap; an inbound chain claiming >50 hops or an out-of-range `i=` tag is refused rather than enumerated. **(h) MIME parser charset + observability.** `b.safeMime.parse` now decodes `utf-16` (RFC 2781 §3.3 BOM detection + BE default), `utf-16be`, and `utf-16le` end-to-end — the prior shape advertised `utf-16` / `utf-16be` in the allowlist but only decoded `utf-16le`. `binary` Content-Transfer-Encoding is removed from the default allowlist (RFC 3030 §3 — `binary` requires explicit BINARYMIME negotiation; operators that wire BINARYMIME opt back in via `transferEncodingAllowlist: [..., "binary"]`). Control-character refusal errors now report the BYTE offset (via `Buffer.byteLength` on the JS string prefix) rather than the UTF-16 code-unit index, so audit lines align with wire-level inspection. **(i) DKIM / DMARC / ARC / iPrev / DSN tightening.** `b.guardDsn` splits the RFC 3464 §2.1.1 block separator on literal `\r\n\r\n` only (the prior `\n\s*\n` accepted `\v` / `\f` whitespace as a block boundary, letting a hostile sender bend the per-message vs per-recipient boundary). `b.guardMessageId` now validates id-left + id-right against RFC 5322 §3.2.3 dot-atom-text shape under `strict` profile; `b.guardListId` extends the localhost FQDN exception to `.local` (RFC 6762) and `.lan` (draft-chapin-rfc2606bis). **(j) `b.guardListUnsubscribe` SSRF defense.** HTTPS one-click URIs now refuse IP-literal hosts (v4 + v6), reserved-local hostnames (`localhost` / `localhost.localdomain` / `ip6-localhost` / `ip6-loopback`), and reserved-local TLD suffixes (`.local` / `.lan` / `.internal`). New optional `allowedHosts` opt provides a domain allowlist — when supplied, every HTTPS host (or any ancestor) must be on the list. **(k) `b.mailStore` JMAP objectid bump to 128 bits.** RFC 8474 §1.5.1 — the prior 24-char hex prefix cut entropy to 96 bits; full 32-char hex restores 128 bits. **Operator impact:** Submission listeners on `strict` profile WITHOUT operator-side DKIM signing (`b.mail.dkim.sign` pre-relay) now refuse outbound DATA — operators in this state either wire DKIM signing, opt to `dkimRequireMode: "off"`, or step down to `balanced`. `b.mail.dkim.verify` callers passing `maxSignatures > 16` now throw at config time — clamp via the opt or rely on the framework default. `b.safeMime.parse` callers that legitimately receive `binary` Content-Transfer-Encoding (BINARYMIME-aware downstream pipelines) opt back in via `transferEncodingAllowlist`. `b.guardListUnsubscribe.validate` callers that legitimately rely on IP-literal one-click URIs (test harnesses, internal-network operators) opt in via `allowedHosts: ["10.0.0.0/8"]` style ancestor matches. **Deferred to v0.10.8 / v0.10.12:** per-tenant pepper on `b.mailStore` derived hashes (`from_hash` / `message_id_hash`) ships in v0.10.12 alongside the `b.agent.tenant` adoption refactor; the schema migration is too invasive to fold into this patch. `b.mailStore` forensic-recovery columns (original Content-Transfer-Encoding + charset preserved alongside decoded body) defer to v0.10.8 — the schema change carries its own backwards-compatibility surface. References: [RFC 9051 IMAP4rev2](https://www.rfc-editor.org/rfc/rfc9051), [RFC 6376 DKIM §6.1](https://www.rfc-editor.org/rfc/rfc6376#section-6.1), [RFC 6531 SMTPUTF8](https://www.rfc-editor.org/rfc/rfc6531), [RFC 3030 BINARYMIME](https://www.rfc-editor.org/rfc/rfc3030), [RFC 2781 UTF-16 BOM](https://www.rfc-editor.org/rfc/rfc2781), [RFC 1870 SMTP SIZE](https://www.rfc-editor.org/rfc/rfc1870), [RFC 8474 JMAP objectid](https://www.rfc-editor.org/rfc/rfc8474), [RFC 8617 ARC §5](https://www.rfc-editor.org/rfc/rfc8617#section-5), [RFC 3464 DSN §2.1.1](https://www.rfc-editor.org/rfc/rfc3464#section-2.1.1), [RFC 5322 Message Format §3.2.3](https://www.rfc-editor.org/rfc/rfc5322#section-3.2.3), [RFC 6761 Reserved Domain Names](https://www.rfc-editor.org/rfc/rfc6761), [Yahoo / Gmail bulk-sender 2024](https://blog.google/products/gmail/gmail-security-authentication-spam-protection/).
13
14
  - v0.10.6 (2026-05-17) — **Vendored-SBOM CycloneDX 1.6 conformance + cosign verification recipe pin.** Build-side + verification-side improvements; no runtime changes. **(a) `scripts/build-vendored-sbom.js` per-component `cpe` field** — every vendored bundle gets a CPE 2.3 string (`cpe:2.3:a:<vendor>:<product>:<version>:*:*:*:*:*:*:*`). CISA / NVD CVE-matching tools (Dependency-Track, OWASP Dependency-Check, Snyk SBOM Monitor) match CVE advisories against components by CPE; the prior emit had no CPE field, so vendored bundles were invisible to operator-side CVE scanners. **(b) Per-component `supplier` block** — `metadata.supplier` (framework-level) was already populated; each vendored bundle now also carries its own `components[].supplier` with the upstream maintainer / org per [SLSA v1.0 provenance requirements](https://slsa.dev/spec/v1.0/provenance) — operators auditing the SBOM see both the framework supplier (blamejs) AND the vendored bundle's upstream supplier (noble-curves, noble-ciphers, etc.) at the component level. **(c) `metadata.lifecycles[].externalReferences[]`** — CycloneDX 1.6 §4.4.2 requires `lifecycles` entries to carry build-provenance references (workflow URL, run ID); the npm-publish workflow now populates these so the SBOM points back at the SLSA-attesting workflow run that produced the tarball. **(d) Sub-component `dependsOn` graph** — when a vendored bundle exposes sub-components (e.g. `noble-ciphers` exports `xchacha20poly1305` + `aes-gcm` as named sub-modules), each sub-component now emits its own SBOM entry with a `dependencies` edge pointing to its parent (CycloneDX 1.6 §4.7). Operators get the full transitive graph instead of just the top-level vendored bundle. **(e) `_licenseFor()` inline-path fix** — the path-resolution branch that handles vendored bundles whose `package.json` is under `lib/vendor/<name>/package.json` now correctly returns the SPDX `license.id` (was returning `null` for that branch, causing CycloneDX-validator warnings). **(f) `SECURITY.md` cosign verification recipe pinned to workflow path + tag-ref** — the operator-side recipe now constrains `cosign verify-blob --certificate-identity-regexp` to the specific workflow file (`.github/workflows/npm-publish.yml`) + tag-ref shape (`refs/tags/v[0-9]+\.[0-9]+\.[0-9]+`), refusing certificates issued for any other workflow or ref class. Also documents `--rekor-url` for operators running on an air-gapped network with a local transparency log + offline TUF root path for `cosign initialize --root <local-root.json>`. **(g) `.github/workflows/npm-publish.yml` recipe comment** synchronized to match the SECURITY.md recipe so operators copy-pasting from either source see identical verification steps. **Operator impact:** SBOM consumers that previously saw vendored bundles as opaque now see CPE-matched components with proper supplier attribution + transitive sub-component graph. The Sigstore-keyless verification recipe is more restrictive (rejects certificates issued for non-`npm-publish.yml` workflows on this repo) — operators already verifying against the prior recipe see the same successful verification with the tighter identity match. References: [CycloneDX 1.6 §4](https://cyclonedx.org/docs/1.6/json/), [CPE 2.3 spec](https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7695.pdf), [SLSA v1.0 provenance](https://slsa.dev/spec/v1.0/provenance), [Sigstore cosign verify-blob](https://docs.sigstore.dev/cosign/verifying/verify/), [TUF specification](https://theupdateframework.github.io/specification/latest/).
package/index.js CHANGED
@@ -405,6 +405,8 @@ module.exports = {
405
405
  },
406
406
  promisePool: require("./lib/promise-pool"),
407
407
  sdNotify: require("./lib/sd-notify"),
408
+ safePath: require("./lib/safe-path"),
409
+ bootGates: require("./lib/boot-gates"),
408
410
  queue: queue,
409
411
  logStream: logStream,
410
412
  redact: redact,
@@ -49,7 +49,7 @@ var VALID_DATA_TYPES = Object.freeze({
49
49
  "machine-learning-model": true, manifest: true, "operating-system": true,
50
50
  patch: true, platform: true, "test-case": true,
51
51
  });
52
- var ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
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
53
  var BOM_REF_RE = /^[A-Za-z0-9._:/+-]{1,256}$/; // allow:raw-byte-literal — CycloneDX bom-ref string-length cap, not bytes
54
54
 
55
55
  function _requireString(obj, key, ownerName) {
package/lib/audit.js CHANGED
@@ -299,6 +299,8 @@ var FRAMEWORK_NAMESPACES = [
299
299
  "aibom", // b.ai.modelManifest (aibom.signed / aibom.verified — CycloneDX 1.6 ML-BOM)
300
300
  "aicontentdetect", // b.ai.aiContentDetect (aicontentdetect.report — AB-853 / EU AI Act Art. 50 inbound provenance)
301
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)
302
304
  ];
303
305
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
304
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
+ };
package/lib/metrics.js CHANGED
@@ -970,6 +970,68 @@ function snapshotRead(p) {
970
970
  * res.setHeader("Content-Type", "text/plain; version=0.0.4");
971
971
  * res.end(b.metrics.snapshot.render(snap, { format: "prometheus", prefix: "myapp" }));
972
972
  */
973
+ var ISO_DATE_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
974
+
975
+ // Formats a single field value for the text renderer. ISO-date-shaped
976
+ // strings render verbatim (with millisecond precision) so the human
977
+ // operator reads them as timestamps; everything else degrades to the
978
+ // existing number / string / boolean / JSON formatting.
979
+ function _formatTextValue(v) {
980
+ if (typeof v === "number") return String(v);
981
+ if (typeof v === "boolean") return v ? "true" : "false";
982
+ if (typeof v === "string") {
983
+ if (ISO_DATE_RE.test(v) && isFinite(Date.parse(v))) return v; // allow:regex-no-length-cap — ISO-date shape, length-bounded by the anchored pattern
984
+ return v;
985
+ }
986
+ return JSON.stringify(v);
987
+ }
988
+
989
+ // Internal text-format renderer extracted from snapshotRender so the
990
+ // E.grouped-text + H.iso-date paths share one code path.
991
+ function _renderText(fields, snap, opts) {
992
+ var lines = ["snapshot written-at: " + snap.writtenAt];
993
+ // E. operator-supplied group map. Group ordering follows the
994
+ // insertion order of the `opts.groups` object; fields not named in
995
+ // any group fall to the bottom under `== Other ==`.
996
+ if (opts.groups && typeof opts.groups === "object" && !Array.isArray(opts.groups)) {
997
+ var groupNames = Object.keys(opts.groups);
998
+ var named = Object.create(null);
999
+ for (var gi = 0; gi < groupNames.length; gi += 1) {
1000
+ var gName = groupNames[gi];
1001
+ var fieldNames = opts.groups[gName];
1002
+ if (!Array.isArray(fieldNames)) continue;
1003
+ lines.push("");
1004
+ lines.push("== " + gName + " ==");
1005
+ for (var fi = 0; fi < fieldNames.length; fi += 1) {
1006
+ var fn = fieldNames[fi];
1007
+ named[fn] = true;
1008
+ if (Object.prototype.hasOwnProperty.call(fields, fn)) {
1009
+ lines.push(" " + fn + ": " + _formatTextValue(fields[fn]));
1010
+ }
1011
+ }
1012
+ }
1013
+ // Stable order for the unnamed remainder.
1014
+ // allow:bare-canonicalize-walk — operator-facing display ordering
1015
+ var remainder = Object.keys(fields).sort().filter(function (k) { return !named[k]; });
1016
+ if (remainder.length > 0) {
1017
+ lines.push("");
1018
+ lines.push("== Other ==");
1019
+ for (var ri = 0; ri < remainder.length; ri += 1) {
1020
+ lines.push(" " + remainder[ri] + ": " + _formatTextValue(fields[remainder[ri]]));
1021
+ }
1022
+ }
1023
+ return lines.join("\n") + "\n";
1024
+ }
1025
+ // Default flat rendering.
1026
+ // allow:bare-canonicalize-walk — operator-facing display ordering
1027
+ var keys = Object.keys(fields).sort();
1028
+ for (var i = 0; i < keys.length; i += 1) {
1029
+ var k = keys[i];
1030
+ lines.push(" " + k + ": " + _formatTextValue(fields[k]));
1031
+ }
1032
+ return lines.join("\n") + "\n";
1033
+ }
1034
+
973
1035
  function snapshotRender(snap, opts) {
974
1036
  opts = opts || {};
975
1037
  var format = opts.format || "text";
@@ -979,21 +1041,7 @@ function snapshotRender(snap, opts) {
979
1041
  }
980
1042
  var fields = snap.fields;
981
1043
  if (format === "text") {
982
- var lines = ["snapshot written-at: " + snap.writtenAt];
983
- // allow:bare-canonicalize-walk — sort is for stable human-readable
984
- // output ordering, not canonicalize-for-hashing
985
- var keys = Object.keys(fields).sort();
986
- for (var i = 0; i < keys.length; i++) {
987
- var k = keys[i];
988
- var v = fields[k];
989
- var s;
990
- if (typeof v === "number") s = String(v);
991
- else if (typeof v === "string") s = v;
992
- else if (typeof v === "boolean") s = v ? "true" : "false";
993
- else s = JSON.stringify(v);
994
- lines.push(" " + k + ": " + s);
995
- }
996
- return lines.join("\n") + "\n";
1044
+ return _renderText(fields, snap, opts);
997
1045
  }
998
1046
  if (format === "prometheus") {
999
1047
  var prefix = opts.prefix || "blamejs";
@@ -1032,16 +1080,302 @@ function snapshotRender(snap, opts) {
1032
1080
  out.push("# TYPE " + metric + " " + fieldType);
1033
1081
  out.push(metric + " " + v2);
1034
1082
  }
1083
+ // ISO-date string fields → parallel `<name>_epoch_ms` gauge per
1084
+ // OpenMetrics 1.0 §3.4 (Timestamps MUST be float64 Unix-epoch). The
1085
+ // operator-facing text format renders the ISO string verbatim; the
1086
+ // Prometheus / OpenMetrics format gets the epoch-ms equivalent so
1087
+ // downstream alerting can compute durations.
1088
+ for (var jd = 0; jd < keys2.length; jd += 1) {
1089
+ var kd = keys2[jd];
1090
+ var vd = fields[kd];
1091
+ if (typeof vd !== "string") continue;
1092
+ if (vd.length > 64) continue; // allow:raw-byte-literal — ISO 8601 max length cap, not bytes
1093
+ if (!ISO_DATE_RE.test(vd)) continue; // allow:regex-no-length-cap — length-bounded immediately above
1094
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(kd)) continue; // allow:regex-no-length-cap — field-name shape, length-bounded by snap field naming
1095
+ var ms = Date.parse(vd);
1096
+ if (!isFinite(ms)) continue;
1097
+ var emName = prefix + "_" + kd + "_epoch_ms";
1098
+ out.push("# TYPE " + emName + " gauge");
1099
+ out.push(emName + " " + ms);
1100
+ }
1035
1101
  return out.join("\n") + "\n";
1036
1102
  }
1037
1103
  throw new MetricsError("metrics-snapshot/bad-format",
1038
1104
  "metrics.snapshot.render: format must be 'text' or 'prometheus', got '" + format + "'");
1039
1105
  }
1040
1106
 
1107
+ /**
1108
+ * @primitive b.metrics.snapshot.shadowRegistry
1109
+ * @signature b.metrics.snapshot.shadowRegistry(opts)
1110
+ * @since 0.10.9
1111
+ * @status stable
1112
+ * @related b.metrics.snapshot.render, b.metrics.create
1113
+ *
1114
+ * Build a namespaced shadow metrics registry that mirrors a subset of
1115
+ * a primary registry's counters / gauges / info for export to systems
1116
+ * needing isolated views (sidecar / per-tenant scrape endpoint /
1117
+ * compliance-tagged subset). Cardinality cap closes the
1118
+ * [client_golang CVE-2022-21698](https://nvd.nist.gov/vuln/detail/CVE-2022-21698)
1119
+ * unbounded-cardinality DoS class. Returns
1120
+ * `{ inc, set, setInfo, snapshot, render, reset }`.
1121
+ *
1122
+ * @opts
1123
+ * namespace: string, // identifier prefix; required
1124
+ * counters: string[], // counter names to mirror
1125
+ * gauges: string[], // gauge names to mirror
1126
+ * info: string[], // info names to mirror
1127
+ * cardinalityCap: number, // default 10000 per metric name
1128
+ * onCardinalityExceeded: "drop" | "audit-only" | "refuse", // default "drop"
1129
+ *
1130
+ * @example
1131
+ * var shadow = b.metrics.snapshot.shadowRegistry({
1132
+ * namespace: "tenant_a",
1133
+ * counters: ["requests_total", "errors_total"],
1134
+ * gauges: ["queue_depth"],
1135
+ * });
1136
+ * shadow.inc("requests_total");
1137
+ * shadow.set("queue_depth", 42);
1138
+ * shadow.snapshot();
1139
+ */
1140
+ var SHADOW_DEFAULT_CARDINALITY = 10000; // allow:raw-byte-literal — cardinality cap, not bytes
1141
+ function shadowRegistry(opts) {
1142
+ if (!opts || typeof opts !== "object") {
1143
+ throw new MetricsError("metrics-shadow/bad-opts",
1144
+ "shadowRegistry: opts object required");
1145
+ }
1146
+ validateOpts.requireNonEmptyString(opts.namespace,
1147
+ "shadowRegistry: opts.namespace", MetricsError, "metrics-shadow/bad-namespace");
1148
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(opts.namespace)) { // allow:regex-no-length-cap — OpenMetrics name-shape, length-bounded by namespace
1149
+ throw new MetricsError("metrics-shadow/bad-namespace",
1150
+ "shadowRegistry: namespace must match [a-zA-Z_][a-zA-Z0-9_]*");
1151
+ }
1152
+ var counterSet = _shadowSetOf(opts.counters, "counters");
1153
+ var gaugeSet = _shadowSetOf(opts.gauges, "gauges");
1154
+ var infoSet = _shadowSetOf(opts.info, "info");
1155
+ var cap = opts.cardinalityCap === undefined ? SHADOW_DEFAULT_CARDINALITY : opts.cardinalityCap;
1156
+ if (typeof cap !== "number" || !isFinite(cap) || cap < 1 || Math.floor(cap) !== cap) {
1157
+ throw new MetricsError("metrics-shadow/bad-cap",
1158
+ "shadowRegistry: cardinalityCap must be a positive integer");
1159
+ }
1160
+ var policy = opts.onCardinalityExceeded || "drop";
1161
+ if (policy !== "drop" && policy !== "audit-only" && policy !== "refuse") {
1162
+ throw new MetricsError("metrics-shadow/bad-policy",
1163
+ "shadowRegistry: onCardinalityExceeded must be 'drop', 'audit-only', or 'refuse'");
1164
+ }
1165
+ var counters = Object.create(null);
1166
+ var gauges = Object.create(null);
1167
+ var info = Object.create(null);
1168
+ var lastCardinalityAuditMs = 0;
1169
+
1170
+ function _cardinalityHit(metric) {
1171
+ var now = Date.now();
1172
+ // Rate-limit cardinality audit emissions to once per second per
1173
+ // shadow registry so a hostile label flood doesn't fan out into
1174
+ // the audit log.
1175
+ if (now - lastCardinalityAuditMs >= C.TIME.seconds(1)) {
1176
+ lastCardinalityAuditMs = now;
1177
+ try {
1178
+ require("./audit").safeEmit({
1179
+ action: "metrics.shadow.cardinality_dropped",
1180
+ outcome: policy === "refuse" ? "denied" : "denied",
1181
+ metadata: { namespace: opts.namespace, metric: metric, cap: cap, policy: policy },
1182
+ });
1183
+ } catch (_e) { /* drop-silent */ }
1184
+ }
1185
+ if (policy === "refuse") {
1186
+ throw new MetricsError("metrics-shadow/cardinality-exceeded",
1187
+ "shadowRegistry.inc/set: '" + metric + "' cardinality exceeds cap=" + cap);
1188
+ }
1189
+ }
1190
+
1191
+ function _labelKey(labels) {
1192
+ if (!labels || typeof labels !== "object") return "";
1193
+ var keys = Object.keys(labels).sort(); // allow:bare-canonicalize-walk — label-set canonicalization for cardinality keying
1194
+ var parts = [];
1195
+ for (var i = 0; i < keys.length; i += 1) {
1196
+ parts.push(keys[i] + "=" + String(labels[keys[i]]));
1197
+ }
1198
+ return parts.join(",");
1199
+ }
1200
+
1201
+ function inc(name, labels) {
1202
+ if (!counterSet[name]) return;
1203
+ var lk = _labelKey(labels);
1204
+ if (!counters[name]) counters[name] = Object.create(null);
1205
+ var current = counters[name][lk];
1206
+ if (current === undefined) {
1207
+ if (Object.keys(counters[name]).length >= cap) {
1208
+ _cardinalityHit(name);
1209
+ return;
1210
+ }
1211
+ counters[name][lk] = 1;
1212
+ } else {
1213
+ counters[name][lk] = current + 1;
1214
+ }
1215
+ }
1216
+
1217
+ function set(name, value, labels) {
1218
+ if (!gaugeSet[name]) return;
1219
+ if (typeof value !== "number" || !isFinite(value)) {
1220
+ throw new MetricsError("metrics-shadow/bad-gauge-value",
1221
+ "shadowRegistry.set: '" + name + "' value must be a finite number");
1222
+ }
1223
+ var lk = _labelKey(labels);
1224
+ if (!gauges[name]) gauges[name] = Object.create(null);
1225
+ if (gauges[name][lk] === undefined && Object.keys(gauges[name]).length >= cap) {
1226
+ _cardinalityHit(name);
1227
+ return;
1228
+ }
1229
+ gauges[name][lk] = value;
1230
+ }
1231
+
1232
+ function setInfo(name, value) {
1233
+ if (!infoSet[name]) return;
1234
+ info[name] = value;
1235
+ }
1236
+
1237
+ function snapshotShadow() {
1238
+ return Object.freeze({
1239
+ namespace: opts.namespace,
1240
+ counters: _shallowClone(counters),
1241
+ gauges: _shallowClone(gauges),
1242
+ info: Object.assign({}, info),
1243
+ });
1244
+ }
1245
+
1246
+ function renderShadow(renderOpts) {
1247
+ renderOpts = renderOpts || {};
1248
+ var format = renderOpts.format || "text";
1249
+ // Prometheus / OpenMetrics — emit labeled metric lines directly so
1250
+ // counters / gauges with label sets survive the export. Routing
1251
+ // through `snapshotRender` would have filtered synthetic
1252
+ // `name{labelKey=value}` field names against the Prometheus
1253
+ // metric-name shape `[a-zA-Z_][a-zA-Z0-9_]*` and dropped them all.
1254
+ if (format === "prometheus" || format === "openmetrics") {
1255
+ var out = [];
1256
+ var prefix = opts.namespace;
1257
+ function _emitLabeled(name, labelMap, kind) {
1258
+ var metric = prefix + "_" + name;
1259
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(metric)) return; // allow:regex-no-length-cap — Prometheus name-shape; metric length bounded by namespace + name caps
1260
+ out.push("# TYPE " + metric + " " + kind);
1261
+ var lks = Object.keys(labelMap);
1262
+ for (var li = 0; li < lks.length; li += 1) {
1263
+ var lk = lks[li];
1264
+ if (lk === "") { out.push(metric + " " + labelMap[lk]); continue; }
1265
+ // The label-key string was assembled by `_labelKey` from a
1266
+ // single shadow-registry call's `labels` object — values
1267
+ // are framework-internal (operator code that supplied them
1268
+ // is bounded by guards upstream); split on `,` is safe.
1269
+ // Not a header-value parse (which would need a quoted-
1270
+ // string aware split per RFC 9110).
1271
+ var lpairs = lk.split(","); // allow:bare-split-on-quoted-header — framework-internal label-key (assembled by _labelKey), not an HTTP header parse
1272
+ var formatted = [];
1273
+ for (var pi = 0; pi < lpairs.length; pi += 1) {
1274
+ var eqIdx = lpairs[pi].indexOf("=");
1275
+ if (eqIdx === -1) continue;
1276
+ var lname = lpairs[pi].slice(0, eqIdx);
1277
+ var lvalue = lpairs[pi].slice(eqIdx + 1);
1278
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(lname)) continue; // allow:regex-no-length-cap — Prometheus label-name shape
1279
+ // Prometheus exposition: escape `\`, `"`, `\n` in label values.
1280
+ lvalue = String(lvalue).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n"); // allow:regex-no-length-cap — fixed-char-set escape // allow:duplicate-regex — Prometheus value escape shape
1281
+ formatted.push(lname + '="' + lvalue + '"');
1282
+ }
1283
+ out.push(metric + "{" + formatted.join(",") + "} " + labelMap[lk]);
1284
+ }
1285
+ }
1286
+ var cn2 = Object.keys(counters);
1287
+ for (var ci = 0; ci < cn2.length; ci += 1) {
1288
+ _emitLabeled(cn2[ci], counters[cn2[ci]], /_total$/.test(cn2[ci]) ? "counter" : "gauge"); // allow:regex-no-length-cap — name-suffix check
1289
+ }
1290
+ var gn2 = Object.keys(gauges);
1291
+ for (var ggi = 0; ggi < gn2.length; ggi += 1) {
1292
+ _emitLabeled(gn2[ggi], gauges[gn2[ggi]], "gauge");
1293
+ }
1294
+ return out.join("\n") + (out.length ? "\n" : "");
1295
+ }
1296
+ // Text format — route through snapshotRender via synthetic field
1297
+ // names. The text-format renderer accepts arbitrary field names so
1298
+ // labeled series survive here.
1299
+ var snap = { writtenAt: new Date().toISOString(), fields: {} };
1300
+ var cn = Object.keys(counters);
1301
+ for (var i = 0; i < cn.length; i += 1) {
1302
+ var labels = counters[cn[i]];
1303
+ var labelKeys = Object.keys(labels);
1304
+ if (labelKeys.length === 1 && labelKeys[0] === "") {
1305
+ snap.fields[cn[i]] = labels[""];
1306
+ } else {
1307
+ for (var j = 0; j < labelKeys.length; j += 1) {
1308
+ var key = labelKeys[j] === "" ? cn[i] : cn[i] + "{" + labelKeys[j] + "}";
1309
+ snap.fields[key] = labels[labelKeys[j]];
1310
+ }
1311
+ }
1312
+ }
1313
+ var gn = Object.keys(gauges);
1314
+ for (var gi = 0; gi < gn.length; gi += 1) {
1315
+ var glabels = gauges[gn[gi]];
1316
+ var glk = Object.keys(glabels);
1317
+ if (glk.length === 1 && glk[0] === "") {
1318
+ snap.fields[gn[gi]] = glabels[""];
1319
+ } else {
1320
+ for (var gj = 0; gj < glk.length; gj += 1) {
1321
+ var gkey = glk[gj] === "" ? gn[gi] : gn[gi] + "{" + glk[gj] + "}";
1322
+ snap.fields[gkey] = glabels[glk[gj]];
1323
+ }
1324
+ }
1325
+ }
1326
+ var inames = Object.keys(info);
1327
+ for (var ii = 0; ii < inames.length; ii += 1) snap.fields[inames[ii]] = info[inames[ii]];
1328
+ return snapshotRender(snap, Object.assign({ prefix: opts.namespace }, renderOpts));
1329
+ }
1330
+
1331
+ function reset() {
1332
+ counters = Object.create(null);
1333
+ gauges = Object.create(null);
1334
+ info = Object.create(null);
1335
+ lastCardinalityAuditMs = 0;
1336
+ }
1337
+
1338
+ return {
1339
+ inc: inc,
1340
+ set: set,
1341
+ setInfo: setInfo,
1342
+ snapshot: snapshotShadow,
1343
+ render: renderShadow,
1344
+ reset: reset,
1345
+ };
1346
+ }
1347
+
1348
+ function _shadowSetOf(arr, label) {
1349
+ if (arr === undefined) return Object.create(null);
1350
+ if (!Array.isArray(arr)) {
1351
+ throw new MetricsError("metrics-shadow/bad-" + label,
1352
+ "shadowRegistry: opts." + label + " must be an array of metric names");
1353
+ }
1354
+ var set = Object.create(null);
1355
+ for (var i = 0; i < arr.length; i += 1) {
1356
+ if (typeof arr[i] !== "string" || arr[i].length === 0) {
1357
+ throw new MetricsError("metrics-shadow/bad-" + label,
1358
+ "shadowRegistry: opts." + label + "[" + i + "] must be a non-empty string");
1359
+ }
1360
+ set[arr[i]] = true;
1361
+ }
1362
+ return set;
1363
+ }
1364
+
1365
+ function _shallowClone(obj) {
1366
+ var out = Object.create(null);
1367
+ var keys = Object.keys(obj);
1368
+ for (var i = 0; i < keys.length; i += 1) {
1369
+ out[keys[i]] = Object.assign(Object.create(null), obj[keys[i]]);
1370
+ }
1371
+ return out;
1372
+ }
1373
+
1041
1374
  var snapshot = {
1042
- startWriter: snapshotStartWriter,
1043
- read: snapshotRead,
1044
- render: snapshotRender,
1375
+ startWriter: snapshotStartWriter,
1376
+ read: snapshotRead,
1377
+ render: snapshotRender,
1378
+ shadowRegistry: shadowRegistry,
1045
1379
  };
1046
1380
 
1047
1381
  module.exports = {
package/lib/pqc-agent.js CHANGED
@@ -32,6 +32,10 @@ var C = require("./constants");
32
32
  var lazyRequire = require("./lazy-require");
33
33
  var networkTls = require("./network-tls");
34
34
  var safeBuffer = require("./safe-buffer");
35
+ var validateOpts = require("./validate-opts");
36
+ var { defineClass } = require("./framework-error");
37
+
38
+ var PqcAgentError = defineClass("PqcAgentError", { alwaysPermanent: true });
35
39
 
36
40
  // audit imports crypto/handlers transitively — lazy to avoid load
37
41
  // cycles when pqc-agent is required during framework bootstrap.
@@ -179,7 +183,72 @@ function _buildAgentOpts(opts) {
179
183
  * req.end();
180
184
  */
181
185
  function create(opts) {
182
- return new https.Agent(_buildAgentOpts(opts));
186
+ var built = _buildAgentOpts(opts);
187
+ var agent = new https.Agent(built);
188
+ agent._builtOpts = built;
189
+ // Per-instance cert rotation. The pre-v0.10.9 path required process
190
+ // restart for cert rotation on agents built via explicit `create()`
191
+ // (only the framework's lazy default had `b.pqcAgent.reload()`).
192
+ // Attach `reloadCerts` so long-running daemons can pivot in place.
193
+ agent.reloadCerts = function (newMaterial) {
194
+ return _reloadCertsOnAgent(agent, opts, newMaterial);
195
+ };
196
+ return agent;
197
+ }
198
+
199
+ function _reloadCertsOnAgent(agent, originalOpts, newMaterial) {
200
+ validateOpts.requireObject(newMaterial, "agent.reloadCerts",
201
+ PqcAgentError, "pqcagent/reload-bad-opts");
202
+ if (typeof newMaterial.cert !== "string" || newMaterial.cert.length === 0 ||
203
+ typeof newMaterial.key !== "string" || newMaterial.key.length === 0) {
204
+ throw new PqcAgentError("pqcagent/reload-missing-material",
205
+ "agent.reloadCerts: both cert and key are required (non-empty PEM strings)");
206
+ }
207
+ // Compound on the AGENT's last-known-good builtOpts (which start as
208
+ // the create-time opts but are updated on each successful reload).
209
+ // A sequence like "reload with new ca once, then reload only
210
+ // cert/key" preserves the new ca because the previous successful
211
+ // reload wrote it into agent._builtOpts.
212
+ var nextOpts = Object.assign({}, agent._builtOpts, {
213
+ cert: newMaterial.cert,
214
+ key: newMaterial.key,
215
+ });
216
+ if (newMaterial.ca !== undefined) nextOpts.ca = newMaterial.ca;
217
+ var t0 = Date.now();
218
+ try {
219
+ // tls.createSecureContext throws on mismatched cert/key — surface
220
+ // as a typed framework error with the underlying OpenSSL chain.
221
+ require("node:tls").createSecureContext({ // allow:inline-require — node:tls only needed during cert rotation (a non-hot path); a top-level require would pull TLS into the boot graph of every process that never reaches reloadCerts
222
+ cert: nextOpts.cert,
223
+ key: nextOpts.key,
224
+ ca: nextOpts.ca,
225
+ });
226
+ } catch (e) {
227
+ var errMsg = (e && e.message) ? e.message : String(e);
228
+ if (/ca\b/i.test(errMsg)) { // allow:regex-no-length-cap — error-message shape match; error text owned by Node, not adversarial input
229
+ throw new PqcAgentError("pqcagent/reload-bad-ca",
230
+ "agent.reloadCerts: ca bundle failed to parse: " + errMsg);
231
+ }
232
+ throw new PqcAgentError("pqcagent/reload-mismatch",
233
+ "agent.reloadCerts: cert/key mismatch or malformed PEM (" + errMsg + ")");
234
+ }
235
+ agent.options = Object.assign({}, agent.options, {
236
+ cert: nextOpts.cert,
237
+ key: nextOpts.key,
238
+ ca: nextOpts.ca,
239
+ });
240
+ agent._builtOpts = nextOpts;
241
+ // Close idle keep-alive sockets so the next request uses the new
242
+ // material. In-flight sockets complete naturally.
243
+ try { agent.destroy(); } catch (_e) { /* best-effort */ }
244
+ try {
245
+ audit.safeEmit({
246
+ action: "pqcagent.reloadCerts",
247
+ outcome: "success",
248
+ metadata: { durationMs: Date.now() - t0 },
249
+ });
250
+ } catch (_e2) { /* drop-silent */ }
251
+ return { reloaded: true, durationMs: Date.now() - t0 };
183
252
  }
184
253
 
185
254
  /**
@@ -0,0 +1,254 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.safePath
4
+ * @nav Filesystem
5
+ * @title Safe Path
6
+ *
7
+ * @intro
8
+ * Path-traversal-safe multi-segment resolve. Operators consuming
9
+ * operator-OR-user-supplied path segments (uploaded filenames,
10
+ * tarball entries, archive extraction, dynamic include paths) pass
11
+ * `base + rel` to `b.safePath.resolve` and get back the absolute
12
+ * canonicalized path — guaranteed to lie strictly within `base` —
13
+ * or a typed `SafePathError` with a stable `code` on refusal.
14
+ *
15
+ * Refusal classes (each a documented code, never best-effort):
16
+ *
17
+ * - `safe-path/absolute-rel` — rel is absolute, UNC, or carries a drive letter
18
+ * - `safe-path/escapes-base` — `..` segments escape base after lexical resolve
19
+ * - `safe-path/null-byte` — NUL anywhere (closes Node poison-NUL class)
20
+ * - `safe-path/control-char` — C0 control char other than NUL
21
+ * - `safe-path/bidi` — bidi-override codepoint (CVE-2021-42574 Trojan Source)
22
+ * - `safe-path/win-reserved` — Windows reserved name (CON/PRN/AUX/NUL/COM0-9/LPT0-9)
23
+ * on EVERY platform — closes CVE-2025-27210 cross-mount class
24
+ * - `safe-path/win-trailing` — segment ends with `.` or ` ` under windows-mode resolve
25
+ * - `safe-path/separator-in-segment` — encoded path-separator in a segment (URL / fullwidth /
26
+ * overlong UTF-8 / division-slash)
27
+ * - `safe-path/ads-marker` — NTFS Alternate Data Stream `foo:bar` marker
28
+ * - `safe-path/realpath-escapes-base` — symlink resolution escapes base (opt-in via opts.realpath)
29
+ *
30
+ * Per-segment filename validation composes `b.guardFilename`'s
31
+ * reserved-name + overlong UTF-8 + bidi tables; the multi-segment
32
+ * resolve + base-escape check is the new code.
33
+ *
34
+ * @card
35
+ * Traversal-safe multi-segment path resolve. Every documented failure mode → coded refusal. Composes b.guardFilename.
36
+ */
37
+
38
+ var nodePath = require("node:path");
39
+ var nodeFs = require("node:fs");
40
+ var { defineClass } = require("./framework-error");
41
+
42
+ var SafePathError = defineClass("SafePathError", { alwaysPermanent: true });
43
+
44
+ // Windows reserved device names — CON, PRN, AUX, NUL, COM0–COM9,
45
+ // LPT0–LPT9, CONIN$, CONOUT$. Enforced on EVERY platform to defend
46
+ // the cross-mount case where a POSIX server writes a path that a
47
+ // Windows operator later mounts (closes CVE-2025-27210 class).
48
+ var WIN_RESERVED_RE = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)(?:\..*)?$/i;
49
+ // Path separators outside the platform-native set. Each entry MUST
50
+ // be rejected as a segment-internal character. Includes both raw +
51
+ // canonical-encoded forms.
52
+ var ENCODED_SEPARATOR_RE = /(%2[fF]|%5[cC]|%C0%AF|%C1%9C|[/\∕⧸⁄])/;
53
+ // Bidi-override codepoints (RTL/LTR markers + isolate enclosures).
54
+ var BIDI_RE = /[‪-‮⁦-⁩‎‏]/;
55
+ // C0 control byte range (excluding NUL which has its own dedicated
56
+ // refusal so the error code matches the historical poison-NUL class).
57
+ // eslint-disable-next-line no-control-regex
58
+ var C0_RE = /[\x01-\x1F\x7F]/;
59
+
60
+ function _refuse(code, message) {
61
+ throw new SafePathError(code, message);
62
+ }
63
+
64
+ /**
65
+ * @primitive b.safePath.resolve
66
+ * @signature b.safePath.resolve(base, rel, opts?)
67
+ * @since 0.10.9
68
+ * @status stable
69
+ * @related b.safePath.validate, b.guardFilename.validate, b.atomicFile.write
70
+ *
71
+ * Resolve `rel` against `base` and return the absolute canonicalized
72
+ * path — guaranteed to lie strictly within `base`. Throws
73
+ * `SafePathError` with a stable refusal code on any rejection.
74
+ *
75
+ * @opts
76
+ * realpath: boolean, // default false; true → fs.realpathSync check (symlink-escape)
77
+ * platform: string, // "windows" forces win-trailing / UNC refusal regardless of host
78
+ * allowAbsoluteRel: boolean, // default false; opt-in for absolute rel that still resolves inside base
79
+ *
80
+ * @example
81
+ * var p = b.safePath.resolve("/srv/uploads", req.body.path);
82
+ * // → "/srv/uploads/<safe-rel>" OR throws SafePathError on traversal
83
+ */
84
+ function resolve(base, rel, opts) {
85
+ return _resolveCore(base, rel, opts || {});
86
+ }
87
+
88
+ /**
89
+ * @primitive b.safePath.resolveOrNull
90
+ * @signature b.safePath.resolveOrNull(base, rel, opts?)
91
+ * @since 0.10.9
92
+ * @status stable
93
+ * @related b.safePath.resolve, b.safePath.validate
94
+ *
95
+ * Same contract as `resolve` but returns `null` on refusal instead of
96
+ * throwing. Useful for hot-path callers that want a boolean-ish gate
97
+ * without try/catch overhead.
98
+ *
99
+ * @opts
100
+ * realpath: boolean,
101
+ * platform: string,
102
+ * allowAbsoluteRel: boolean,
103
+ *
104
+ * @example
105
+ * var p = b.safePath.resolveOrNull("/srv/uploads", req.body.path);
106
+ * if (p === null) { res.statusCode = 400; res.end("bad path"); return; }
107
+ */
108
+ function resolveOrNull(base, rel, opts) {
109
+ try { return _resolveCore(base, rel, opts || {}); }
110
+ catch (_e) { return null; }
111
+ }
112
+
113
+ /**
114
+ * @primitive b.safePath.validate
115
+ * @signature b.safePath.validate(base, rel, opts?)
116
+ * @since 0.10.9
117
+ * @status stable
118
+ * @related b.safePath.resolve
119
+ *
120
+ * Same gate as `resolve` but returns a verdict object instead of
121
+ * throwing — `{ ok: true, resolved }` on success, `{ ok: false,
122
+ * code, message }` on refusal. Use when the caller wants to log the
123
+ * refusal class without throw/catch.
124
+ *
125
+ * @opts
126
+ * realpath: boolean,
127
+ * platform: string,
128
+ * allowAbsoluteRel: boolean,
129
+ *
130
+ * @example
131
+ * var v = b.safePath.validate("/srv/uploads", req.body.path);
132
+ * if (!v.ok) { res.end("rejected: " + v.code); return; }
133
+ */
134
+ function validate(base, rel, opts) {
135
+ try { return { ok: true, resolved: _resolveCore(base, rel, opts || {}) }; }
136
+ catch (e) { return { ok: false, code: e.code || "safe-path/unknown", message: e.message }; }
137
+ }
138
+
139
+ function _resolveCore(base, rel, opts) {
140
+ if (typeof base !== "string" || base.length === 0) {
141
+ _refuse("safe-path/bad-input", "b.safePath.resolve: base must be a non-empty string");
142
+ }
143
+ if (typeof rel !== "string") {
144
+ _refuse("safe-path/bad-input", "b.safePath.resolve: rel must be a string");
145
+ }
146
+ var platform = opts.platform || process.platform;
147
+ var isWin = platform === "win32" || platform === "windows";
148
+
149
+ // NUL byte ANYWHERE — its own refusal so the audit code matches
150
+ // the historical Node poison-NUL class.
151
+ if (rel.indexOf("\0") !== -1) {
152
+ _refuse("safe-path/null-byte", "b.safePath.resolve: NUL byte in rel");
153
+ }
154
+ // Other C0 + DEL.
155
+ if (C0_RE.test(rel)) { // allow:regex-no-length-cap — anchored C0/DEL set, length bounded by rel
156
+ _refuse("safe-path/control-char", "b.safePath.resolve: C0 control char in rel");
157
+ }
158
+ // Bidi override (Trojan Source).
159
+ if (BIDI_RE.test(rel)) { // allow:regex-no-length-cap — fixed bidi set, length bounded by rel
160
+ _refuse("safe-path/bidi",
161
+ "b.safePath.resolve: bidi-override codepoint in rel (CVE-2021-42574 class)");
162
+ }
163
+ // Encoded path separators inside what should be a single segment.
164
+ if (ENCODED_SEPARATOR_RE.test(rel)) { // allow:regex-no-length-cap — fixed separator-shape set
165
+ _refuse("safe-path/separator-in-segment",
166
+ "b.safePath.resolve: encoded path-separator codepoint in rel");
167
+ }
168
+ // Absolute rel (POSIX, Windows drive-letter, UNC) — refuse unless
169
+ // operator opted in.
170
+ var isAbsolute = nodePath.isAbsolute(rel) ||
171
+ /^[A-Za-z]:[\\/]/.test(rel) || // allow:regex-no-length-cap — anchored drive-letter shape
172
+ /^\\\\/.test(rel) || // allow:regex-no-length-cap — UNC `\\` prefix
173
+ /^\/\//.test(rel); // allow:regex-no-length-cap — POSIX `//` prefix
174
+ if (isAbsolute && !opts.allowAbsoluteRel) {
175
+ _refuse("safe-path/absolute-rel",
176
+ "b.safePath.resolve: rel is absolute/UNC/drive-letter (set opts.allowAbsoluteRel for opt-in)");
177
+ }
178
+
179
+ // Per-segment walk. Reserved-name + ADS + win-trailing + segment-
180
+ // shape checks happen here.
181
+ var sep = isWin ? /[\\/]/ : /\//;
182
+ var segments = rel.split(sep); // allow:regex-no-length-cap — fixed separator
183
+ for (var si = 0; si < segments.length; si += 1) {
184
+ var seg = segments[si];
185
+ if (seg.length === 0) continue; // empty (leading/trailing/double-sep)
186
+ if (seg === "." || seg === "..") continue; // resolution handled below
187
+ var segLc = seg.toLowerCase();
188
+ var baseName = segLc.indexOf(".") === -1 ? segLc : segLc.slice(0, segLc.indexOf("."));
189
+ if (WIN_RESERVED_RE.test(seg) || WIN_RESERVED_RE.test(baseName)) { // allow:regex-no-length-cap — anchored reserved-name set
190
+ _refuse("safe-path/win-reserved",
191
+ "b.safePath.resolve: segment '" + seg + "' is a Windows reserved name (CVE-2025-27210 class)");
192
+ }
193
+ if (isWin) {
194
+ var last = seg.charAt(seg.length - 1);
195
+ if (last === "." || last === " ") {
196
+ _refuse("safe-path/win-trailing",
197
+ "b.safePath.resolve: segment '" + seg + "' ends with '.' or ' ' (Windows silently strips)");
198
+ }
199
+ }
200
+ // NTFS Alternate Data Stream marker — refuse `foo:bar` ANYWHERE
201
+ // except where the colon is part of a Windows drive prefix (the
202
+ // absolute-rel branch above already refused those).
203
+ if (seg.indexOf(":") !== -1) {
204
+ _refuse("safe-path/ads-marker",
205
+ "b.safePath.resolve: segment '" + seg + "' contains ':' (NTFS Alternate Data Stream marker; CVE-2024-12217 class)");
206
+ }
207
+ }
208
+
209
+ // Lexical resolve.
210
+ var baseResolved = nodePath.resolve(base);
211
+ var joined = nodePath.resolve(baseResolved, rel);
212
+ // Cross-check via posix.normalize so a Windows host with mixed
213
+ // separators still surfaces escapes consistently.
214
+ var sepChar = isWin ? "\\" : "/";
215
+ if (joined !== baseResolved && joined.slice(0, baseResolved.length + 1) !== baseResolved + sepChar) {
216
+ _refuse("safe-path/escapes-base",
217
+ "b.safePath.resolve: rel resolves outside base ('" + joined + "' not inside '" + baseResolved + "')");
218
+ }
219
+ if (opts.realpath === true) {
220
+ var baseRealpath;
221
+ try { baseRealpath = nodeFs.realpathSync.native(baseResolved); }
222
+ catch (e) {
223
+ _refuse("safe-path/realpath-base-unresolvable",
224
+ "b.safePath.resolve: opts.realpath set but base realpath failed: " + (e && e.message));
225
+ }
226
+ // Walk up the joined path from the leaf, finding the longest
227
+ // ancestor that exists, and check its realpath. Operators want
228
+ // refusal when ANY ancestor symlink escapes — nodeFs.realpathSync on a
229
+ // non-existent path would throw.
230
+ var ancestor = joined;
231
+ while (ancestor.length > baseResolved.length) {
232
+ try {
233
+ var ancRealpath = nodeFs.realpathSync.native(ancestor);
234
+ if (ancRealpath !== baseRealpath &&
235
+ ancRealpath.slice(0, baseRealpath.length + 1) !== baseRealpath + sepChar) {
236
+ _refuse("safe-path/realpath-escapes-base",
237
+ "b.safePath.resolve: symlink resolution at '" + ancestor +
238
+ "' escapes base realpath '" + baseRealpath + "'");
239
+ }
240
+ break;
241
+ } catch (_ie) {
242
+ ancestor = nodePath.dirname(ancestor);
243
+ }
244
+ }
245
+ }
246
+ return joined;
247
+ }
248
+
249
+ module.exports = {
250
+ resolve: resolve,
251
+ resolveOrNull: resolveOrNull,
252
+ validate: validate,
253
+ SafePathError: SafePathError,
254
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.8",
3
+ "version": "0.10.9",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:2db3bd59-b835-4672-aac5-7c874f2f9276",
5
+ "serialNumber": "urn:uuid:d3396b9d-5788-4743-8236-73318b661112",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-18T00:17:19.942Z",
8
+ "timestamp": "2026-05-18T01:44:25.642Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.10.8",
22
+ "bom-ref": "@blamejs/core@0.10.9",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.8",
25
+ "version": "0.10.9",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.10.8",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.9",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.10.8",
57
+ "ref": "@blamejs/core@0.10.9",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]