@blamejs/core 0.14.14 → 0.14.16

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.14.x
10
10
 
11
+ - v0.14.16 (2026-05-31) — **Connection entry-point ports are validated at config time.** Six connection entry points previously read opts.port with a bare `|| <default>` fallback, silently coercing a string, negative, NaN, or out-of-range port instead of catching the operator's typo. A new b.validateOpts.optionalPort enforces the RFC 6335 §6 wire-valid range and is wired into b.mail.smtpTransport, b.ntpCheck.querySingle, b.networkDns.useDnsOverTls, b.networkNts (KE handshake / query / facade), b.redisClient.create, and createApp().listen — each now throws at construction with a clear message naming the bad value. The app.listen / createApp bind site opts into allowZero so port 0 (the legitimate ephemeral-bind sentinel) still works; the five outbound-connect sites require [1,65535]. **Added:** *`b.validateOpts.optionalPort`* — A config-time port validator: `optionalPort(value, label, errorClass, code, opts?)` returns an omitted (`undefined` / `null`) port unchanged, and otherwise requires an integer in the RFC 6335 §6 wire-valid range [1,65535] — rejecting a string, negative, NaN, Infinity, fractional, or out-of-range value. Pass `{ allowZero: true }` for a listen-bind site where port 0 is the OS ephemeral-bind sentinel. The thrown message reports the offending shape (so `Infinity` / `"443"` stay visible), and routes a caller-supplied typed framework error (or a plain Error when none is given), matching the existing `optionalPositiveFinite` family. **Changed:** *Connection entry points reject a malformed port at construction* — `b.mail.smtpTransport`, `b.ntpCheck.querySingle`, `b.networkDns.useDnsOverTls`, `b.networkNts.performKeHandshake` / `query` / `querySingle`, `b.redisClient.create`, and `createApp().listen` (plus the `createApp` constructor's default port) now validate `opts.port` and throw synchronously on a non-integer / out-of-range value rather than coercing it through `||` to a default. This is a behavior change for a caller that was passing a non-canonical port (e.g. the string `"587"` or a NaN) and relying on the silent fallback — pass an integer in [1,65535] instead (or `0` for an ephemeral `createApp().listen` bind). `b.ntpCheck` gains a typed `NtpCheckError` for this (it had no error class before). **Detectors:** *Connection entry points must compose the port validator* — A new check flags a lib connection entry point that reads `opts.port` / `opts.kePort` / `opts.ntpPort` with a `|| <default>` fallback without composing `b.validateOpts.optionalPort` (or the equivalent `numericBounds.isPositiveFiniteInt` + 65535 cap), so an unvalidated port read can't slip back in. **Migration:** *Pass an integer port to connection primitives* — If you were passing a non-integer or out-of-range `opts.port` to a mail / NTP / NTS / DNS-over-TLS / Redis transport or to `createApp().listen` and relying on the silent `|| default` fallback, that now throws at construction. Pass an integer in [1,65535]; for an ephemeral `createApp().listen` bind, pass `0` (still accepted).
12
+
11
13
  - v0.14.14 (2026-05-31) — **Recognized consent purposes with lawful-basis gating, and a new b.privacy namespace for annual EdTech vendor-review attestations.** Closes the student-data gap where an educational-only consent purpose and an annual third-party vendor-review report were described but never implemented. b.consent gains a recognized-purpose vocabulary: a purpose value matching a recognized key carries lawful-basis constraints that grant() enforces, and the named educational-only purpose (FERPA's school-official exception and California's SOPIPA) refuses a legitimate_interests lawful basis. The new b.privacy namespace ships vendorReview(), a builder for the dated, clause-by-clause annual EdTech third-party / processor review FERPA and SOPIPA expect a school or district to keep — it computes whether every required clause (no targeted advertising, no commercial profiling, no sale of student data, deletion on request, school-official designation, and so on) is attested, names the gaps, and stamps a 365-day re-review clock. Free-form consent purposes keep working unchanged, so the vocabulary is opt-in and additive. **Added:** *`b.consent` recognized-purpose vocabulary + lawful-basis gating* — `b.consent.recognizedPurpose(name)` looks up a recognized purpose and `b.consent.listPurposes()` enumerates them. When a `grant({ purpose })` value matches a recognized key, `grant()` enforces that purpose's lawful-basis constraints; the `educational-only` purpose forbids a `legitimate_interests` basis (FERPA 34 CFR 99.31(a)(1) school-official exception; California SOPIPA Cal. B&P 22584; FTC school-authorized COPPA consent 16 CFR 312.5(c)(10)) and marks the data commercial-use-prohibited. The commercial-use prohibition is an operator trust-boundary obligation — `isGranted()` does not re-derive it. Any purpose value NOT in the vocabulary stays free-form and unconstrained, so existing callers are unaffected; the hash-chain column set is unchanged, so `b.consent.verify()` over existing rows is unaffected. · *`b.privacy.vendorReview` — annual EdTech vendor-review attestation* — A new `b.privacy` namespace whose `vendorReview(opts)` builds the dated third-party / processor review a FERPA school-official arrangement and California SOPIPA expect for every vendor that touches student data. The operator supplies a boolean attestation per clause (educational-purpose-only, no-targeted-advertising, no-commercial-profiling, no-sale-of-student-data, security-safeguards, deletion-on-request, sub-processor-currency, breach-notification, school-official-designation, directory-information-handling); `vendorReview` validates the shape, computes whether every required clause is attested (`attested`) and which are not (`gaps`), and stamps `reviewedAt` plus a 365-day `nextReviewDueAt` re-review clock. `b.privacy.listVendorReviewClauses()` returns the clause set with citations. Operator-feeds-metadata: the frozen report is not framework-persisted — compose it into your retention / audit / export sink. A best-effort `privacy.vendor_review.recorded` audit event fires when an audit sink is wired. **Detectors:** *A gated consent purpose must go through `b.consent`* — A new check flags any lib code that mints a consent row with a hardcoded `educational-only` purpose literal without composing the recognized-purpose vocabulary — which would record the value while never enforcing its FERPA / SOPIPA lawful-basis constraint.
12
14
 
13
15
  - v0.14.13 (2026-05-31) — **Close advertised-but-missing surface: SRS1 chained forwarding, DCQL array-wildcard claim paths, and in-memory safe-archive extraction.** Three primitives advertised a capability in their documentation or card but refused or omitted it at runtime; this release implements each. b.mail.srs gains srs1Rewrite for the SRS1 double-forward (and multi-hop) case — previously the @intro described SRS1 and create() threw, pointing at a function that was never exported. b.safeArchive gains extractToMemory, the in-memory counterpart to extract for read-only / serverless filesystems — previously the card advertised in-memory extraction but the orchestrator required a destination directory. b.auth.oid4vp.matchDcql now honours a null claims-path segment as the array wildcard the OpenID4VP DCQL spec defines, rather than refusing it as unsupported while the card advertised DCQL. A stale version-pinned wording in a safe-archive error message is corrected. Every change is additive or message-only — no existing caller changes behaviour. **Added:** *`b.mail.srs` SRS1 chained forwarding — `srs1Rewrite`* — `b.mail.srs.create(...)` now returns `srs1Rewrite` alongside `rewrite` / `reverse`. `srs1Rewrite(srsAddress)` chains an already-SRS0 (or SRS1) envelope-from for a further forwarding hop: it keeps the original SRS0 body verbatim, prepends the SRS0 originator's domain, and binds the pair with this forwarder's own HMAC-SHA-256 tag — no new timestamp, no repeated original local-part — emitting `SRS1=tag=originator==<SRS0-body>@thisForwarder`. `reverse()` now detects an SRS1 address, verifies this hop's tag and forwarder-domain binding, and unwraps exactly one hop back to the originator's SRS0 so a multi-hop bounce routes straight to the forwarder that can recover the original sender. Typed failure modes: `srs/not-srs0` (input not SRS-encoded), `srs/malformed` (missing the `==` separator), `srs/bad-tag` (tampered), `srs/too-long` (chain exceeds the RFC 5321 256-octet path limit). Implements the Sender Rewriting Scheme SRS1 wire format; the second-hop SPF rationale is RFC 7208 §2.4. · *`b.safeArchive.extractToMemory` — in-memory safe extraction* — An async generator counterpart to `b.safeArchive.extract` for read-only / serverless filesystems: it resolves the source, sniffs the format, auto-unwraps recipient (`BAWRP`) / passphrase (`BAWPP`) envelopes, and dispatches to the zip / tar / tar.gz reader's in-memory `extractEntries()`, yielding `{ name, bytes, size }` per regular-file entry without ever writing to disk. It takes no `destination`. Every defense the disk path runs applies unchanged: the zip-bomb caps (entry-count / per-entry / total / expansion-ratio), the `b.guardArchive` metadata cascade (Zip-Slip / path-traversal / symlink-escape / encrypted-entry refusal, CVE-2025-3445 class), and the entry-type policy. The disk-only realpath-agreement check (CVE-2025-4517 PATH_MAX TOCTOU defense) is intentionally absent — there is no extraction root — so the archive-level name refusals carry containment. Trusted-stream sources are refused upfront (the adversarial-safe central-directory walk needs random access). gzip magic per RFC 1952 §2.3.1. **Fixed:** *OID4VP DCQL `null` claim-path segment now resolves the array wildcard* — `b.auth.oid4vp.matchDcql` previously threw `auth-oid4vp/null-path-segment-not-supported` for a `null` claims-path segment while the namespace card advertised DCQL — under-disclosing a legitimate presentation (CWE-863). Per OpenID4VP 1.0 §7.1.1 a `null` segment selects all elements of the array at that depth; the matcher now recurses over array elements with existence semantics (with DCQL value-matching applied to any selected leaf), composed to arbitrary depth. A `null` segment on a non-array node — like an integer index into a non-array, or a string key into an array — is a clean non-match, not a thrown error, because the matcher walks holder credential data rather than operator config. String and integer claim paths are byte-identical to before; only queries that previously threw now succeed or fail cleanly. · *safe-archive trusted-stream refusal message no longer cites a stale version* — The thrown `safe-archive/trusted-stream-unsupported` message and its comment claimed trusted-stream extraction was "deferred to v0.12.8 / when the v0.12.8 sequential extract path lands." That path shipped long ago — `b.archive.read.zip.fromTrustedStream` and the tar sequential mode exist — so the message now points at them as present capabilities and drops the version-pinned wording. The error code is unchanged. **Detectors:** *A primitive may not advertise a capability and then throw an unimplemented stub* — A new check flags a bare `not yet supported` / `operator demand TBD` / `not supported in v1` refusal in a lib throw string (comments excluded). A defer is only complete with a written re-open condition; the SRS1 and DCQL stubs that this release implements both carried this bare-defer shape, and the detector keeps it from re-entering. · *DCQL `null` path segments must recurse, never refuse* — A new check flags the `null path segment not supported` refusal shape in `lib/auth/oid4vp.js`, so the spec-mandated array wildcard cannot be re-stubbed. · *`extractToMemory` must stay disk-free* — A new check flags any `writeFileSync` / `renameSync` / `mkdirSync` / `createWriteStream` inside the `extractToMemory` generator body, so the read-only / serverless contract cannot regress into a disk write.
package/lib/app.js CHANGED
@@ -93,6 +93,7 @@ var nodeFs = require("node:fs");
93
93
  var nodePath = require("node:path");
94
94
  var appShutdown = require("./app-shutdown");
95
95
  var audit = require("./audit");
96
+ var validateOpts = require("./validate-opts");
96
97
  var C = require("./constants");
97
98
  var cluster = require("./cluster");
98
99
  var db = require("./db");
@@ -139,6 +140,9 @@ async function createApp(opts) {
139
140
  if (!opts.dataDir || typeof opts.dataDir !== "string") {
140
141
  throw new Error("createApp: opts.dataDir is required");
141
142
  }
143
+ // Constructor-time default port (used by listen() when listenOpts.port is
144
+ // omitted); allowZero for the ephemeral-bind sentinel.
145
+ validateOpts.optionalPort(opts.port, "createApp: opts.port", undefined, undefined, { allowZero: true });
142
146
  var dataDir = nodePath.resolve(opts.dataDir);
143
147
  if (!nodeFs.existsSync(dataDir)) {
144
148
  nodeFs.mkdirSync(dataDir, { recursive: true });
@@ -279,6 +283,10 @@ async function createApp(opts) {
279
283
 
280
284
  function listen(listenOpts) {
281
285
  listenOpts = listenOpts || {};
286
+ // Port 0 is the legitimate ephemeral-bind sentinel for a listen socket
287
+ // (RFC 6335 §6 / POSIX bind), so allowZero — but a non-integer / NaN /
288
+ // out-of-range port is an operator typo that must fail at boot.
289
+ validateOpts.optionalPort(listenOpts.port, "createApp.listen: listenOpts.port", undefined, undefined, { allowZero: true });
282
290
  var port = (listenOpts.port !== undefined) ? listenOpts.port
283
291
  : (opts.port !== undefined) ? opts.port
284
292
  : 0;
package/lib/mail.js CHANGED
@@ -767,6 +767,7 @@ function smtpTransport(opts) {
767
767
  "dkimSigner must be an object with a .sign(rfc822) method " +
768
768
  "(see b.mail.dkim.create)", true);
769
769
  }
770
+ validateOpts.optionalPort(opts.port, "smtp transport: opts.port", MailError, "mail/smtp-misconfigured");
770
771
  var port = opts.port || 587;
771
772
  var useImplicitTLS = port === 465 || opts.implicitTls === true;
772
773
  var rejectUnauthorized = opts.rejectUnauthorized !== false;
@@ -253,6 +253,7 @@ function useDnsOverTls(opts) {
253
253
  opts = opts || {};
254
254
  validateOpts(opts, ["host", "port", "servername", "ca"], "dns.useDnsOverTls");
255
255
  validateOpts.requireNonEmptyString(opts.host, "dns.useDnsOverTls: host", DnsError, "dns/bad-dot-host");
256
+ validateOpts.optionalPort(opts.port, "dns.useDnsOverTls: opts.port", DnsError, "dns/bad-dot-port");
256
257
  if (opts.ca !== undefined && opts.ca !== null &&
257
258
  !Buffer.isBuffer(opts.ca) && typeof opts.ca !== "string" && !Array.isArray(opts.ca)) {
258
259
  throw new DnsError("dns/bad-dot-ca",
@@ -241,6 +241,7 @@ function performKeHandshake(opts) {
241
241
  opts = opts || {};
242
242
  validateOpts(opts, ["host", "port", "servername", "aead", "ca", "timeoutMs"], "nts.performKeHandshake");
243
243
  validateOpts.requireNonEmptyString(opts.host, "nts.performKeHandshake: host", NtsError, "nts/bad-host");
244
+ validateOpts.optionalPort(opts.port, "nts.performKeHandshake: opts.port", NtsError, "nts/bad-ke-port");
244
245
  var timeoutMs = opts.timeoutMs || C.TIME.seconds(10);
245
246
  return new Promise(function (resolve, reject) {
246
247
  var settled = false;
@@ -408,6 +409,7 @@ function _walkExtensions(msg, startOff) {
408
409
  function querySingle(opts) {
409
410
  opts = opts || {};
410
411
  validateOpts(opts, ["host", "port", "aeadId", "c2sKey", "s2cKey", "cookies", "timeoutMs"], "nts.querySingle");
412
+ validateOpts.optionalPort(opts.port, "nts.querySingle: opts.port", NtsError, "nts/bad-ntp-port");
411
413
  if (!Buffer.isBuffer(opts.c2sKey) || opts.c2sKey.length === 0) {
412
414
  throw new NtsError("nts/no-c2s-key", "nts.querySingle: c2sKey required (Buffer)");
413
415
  }
@@ -542,6 +544,8 @@ function querySingle(opts) {
542
544
  async function query(opts) {
543
545
  opts = opts || {};
544
546
  validateOpts(opts, ["host", "kePort", "ntpPort", "aead", "ca", "timeoutMs", "servername"], "nts.query");
547
+ validateOpts.optionalPort(opts.kePort, "nts.query: opts.kePort", NtsError, "nts/bad-ke-port");
548
+ validateOpts.optionalPort(opts.ntpPort, "nts.query: opts.ntpPort", NtsError, "nts/bad-ntp-port");
545
549
  var ke = await performKeHandshake({
546
550
  host: opts.host,
547
551
  port: opts.kePort,
package/lib/ntp-check.js CHANGED
@@ -48,10 +48,16 @@ var dgram = require("node:dgram");
48
48
  var C = require("./constants");
49
49
  var lazyRequire = require("./lazy-require");
50
50
  var safeAsync = require("./safe-async");
51
+ var validateOpts = require("./validate-opts");
52
+ var { defineClass } = require("./framework-error");
51
53
 
52
54
  var audit = lazyRequire(function () { return require("./audit"); });
53
55
  var observability = lazyRequire(function () { return require("./observability"); });
54
56
 
57
+ // Config-time misuse (a bad opts.port) throws a typed, permanent error so an
58
+ // operator catches the typo at boot rather than as a Promise rejection.
59
+ var NtpCheckError = defineClass("NtpCheckError", { alwaysPermanent: true });
60
+
55
61
  // NTP epoch: 1900-01-01. Unix epoch: 1970-01-01. Offset: 70 years incl. 17
56
62
  // leap days = 2,208,988,800 seconds.
57
63
  var NTP_TO_UNIX_OFFSET_SECONDS = 2208988800;
@@ -166,6 +172,7 @@ function _resetThresholdsForTest() {
166
172
  */
167
173
  function querySingle(server, opts) {
168
174
  opts = opts || {};
175
+ validateOpts.optionalPort(opts.port, "ntpCheck.querySingle: opts.port", NtpCheckError, "ntp/bad-port");
169
176
  var port = opts.port || DEFAULT_PORT;
170
177
  var timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
171
178
 
@@ -445,6 +452,7 @@ function monitor(opts) {
445
452
 
446
453
  module.exports = {
447
454
  querySingle: querySingle,
455
+ NtpCheckError: NtpCheckError,
448
456
  checkDrift: checkDrift,
449
457
  bootCheck: bootCheck,
450
458
  monitor: monitor,
@@ -155,9 +155,17 @@ function _frameToValue(frame) {
155
155
  function create(opts) {
156
156
  opts = opts || {};
157
157
  validateOpts.requireNonEmptyString(opts.url, "redis.create: opts.url", RedisError, "BAD_OPTS");
158
+ // Validate an operator-supplied opts.port up front for a clear typo
159
+ // message (e.g. the string "6379" or a negative value).
160
+ validateOpts.optionalPort(opts.port, "redis.create: opts.port", RedisError, "BAD_OPTS");
158
161
  var parsed = _parseRedisUrl(opts.url);
159
162
  var host = opts.host || parsed.host;
160
163
  var port = opts.port || parsed.port;
164
+ // Re-validate the RESOLVED port. A url-supplied port (redis://h:0,
165
+ // redis://h:99999) is not range-checked by _parseRedisUrl, so without
166
+ // this an outbound connect could inherit a zero / out-of-range port that
167
+ // the opts.port guard above never sees.
168
+ validateOpts.optionalPort(port, "redis.create: resolved port (opts.port or url)", RedisError, "BAD_OPTS");
161
169
  var useTls = opts.tls !== undefined ? !!opts.tls : parsed.tls;
162
170
  var password = opts.password !== undefined ? opts.password : parsed.password;
163
171
  var username = opts.username !== undefined ? opts.username : parsed.username;
@@ -27,6 +27,8 @@
27
27
  * a typed error wrap the call.
28
28
  */
29
29
 
30
+ var numericBounds = require("./numeric-bounds");
31
+
30
32
  function _format(primitive, unknownKey, allowedKeys) {
31
33
  return primitive + ": unknown option '" + unknownKey + "'. " +
32
34
  "Allowed keys: " + allowedKeys.slice().sort().join(", ") + ".";
@@ -150,6 +152,26 @@ function optionalFunction(value, label, errorClass, code) {
150
152
  return value;
151
153
  }
152
154
 
155
+ // optionalPort — a TCP/UDP port number must be an integer in the wire-valid
156
+ // range (RFC 6335 §6). Outbound-connect sites require [1,65535]; pass
157
+ // { allowZero: true } for a listen-bind site where port 0 is the legitimate
158
+ // ephemeral-bind sentinel the OS replaces with a kernel-assigned port. Uses
159
+ // numericBounds.shape() in the message so Infinity / NaN / "443" stay visible.
160
+ function optionalPort(value, label, errorClass, code, opts) {
161
+ if (value === undefined || value === null) return value;
162
+ opts = opts || {};
163
+ var ok = opts.allowZero
164
+ ? (numericBounds.isNonNegativeFiniteInt(value) && value <= 65535)
165
+ : (numericBounds.isPositiveFiniteInt(value) && value <= 65535);
166
+ if (!ok) {
167
+ _throw(errorClass, code, (label || "opt") + " must be " +
168
+ (opts.allowZero ? "0 (ephemeral) or " : "") +
169
+ "an integer in [" + (opts.allowZero ? 0 : 1) + ",65535], got " + numericBounds.shape(value),
170
+ "validate-opts/bad-port");
171
+ }
172
+ return value;
173
+ }
174
+
153
175
  // applyDefaults — resolve every key in DEFAULTS against opts. For each
154
176
  // key, the operator's value (if not undefined) wins; otherwise the
155
177
  // default is used. Returns a new plain object — NOT a frozen one, so
@@ -391,6 +413,7 @@ module.exports.optionalBoolean = optionalBoolean;
391
413
  module.exports.optionalPositiveInt = optionalPositiveInt;
392
414
  module.exports.optionalFiniteNonNegative = optionalFiniteNonNegative;
393
415
  module.exports.optionalPositiveFinite = optionalPositiveFinite;
416
+ module.exports.optionalPort = optionalPort;
394
417
  module.exports.optionalFunction = optionalFunction;
395
418
  module.exports.optionalNonEmptyString = optionalNonEmptyString;
396
419
  module.exports.optionalNonEmptyStringArray = optionalNonEmptyStringArray;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.14",
3
+ "version": "0.14.16",
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.5",
5
- "serialNumber": "urn:uuid:d4bb0c68-2752-4234-ac11-14ff95eaf273",
5
+ "serialNumber": "urn:uuid:44556dbf-5411-4644-ab81-5f0571d5e036",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-31T20:49:54.998Z",
8
+ "timestamp": "2026-06-01T02:09:53.597Z",
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.14.14",
22
+ "bom-ref": "@blamejs/core@0.14.16",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.14.14",
25
+ "version": "0.14.16",
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.14.14",
29
+ "purl": "pkg:npm/%40blamejs/core@0.14.16",
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.14.14",
57
+ "ref": "@blamejs/core@0.14.16",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]