@blamejs/core 0.14.13 → 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,10 @@ 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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.14.12 (2026-05-31) — **Vault key rotation re-seals AAD-bound storage under the new root instead of silently orphaning it.** Every AAD-sealed cell derives its key from the live vault root, so rotating the vault keypair changes those keys. `b.vaultRotate.rotate` previously re-sealed only legacy `vault:`-prefixed cells in `db.enc` and skipped `vault.aad:` cells, AAD-bound at-rest files, and operator-supplied AAD stores — leaving them encrypted under the retired keypair while still returning a success result and a passing round-trip verify, so the loss was invisible until the old keypair was discarded and the cells became permanently undecryptable. Rotation now re-seals `db.enc` (preserving its dataDir-bound AAD), `db.key.enc` (location-bound), every `{ aad: true }` table column, and the overflow store under the new root; refuses up front with a fail-closed error when operator-supplied AAD stores (agent idempotency / orchestrator / tenant / snapshot) are reachable unless each has been re-sealed via its module hook and explicitly acknowledged; and the round-trip verify now decrypts AAD-sealed cells under the new root and treats any cell that still opens under the old root as a regression. New explicit-root `b.vault.aad` seal / unseal / reseal primitives carry a cell from the old root to the new one while preserving its AAD tuple; `b.archive.rewrapTenant` re-wraps tenant-scoped archive envelopes; and `b.cluster` can adopt a rotated vault-key fingerprint instead of partitioning the membership during a rolling rotation. **Added:** *`b.vault.aad.sealRoot` / `unsealRoot` / `resealRoot`* — Explicit-root variants of the AAD seal / unseal that take a root-keypair JSON (`b.vault.getKeysJson()` output) instead of reading the live vault singleton. `resealRoot(value, aadParts, oldRootJson, newRootJson)` opens a cell under the old root and re-seals it under the new one while preserving the same AAD tuple (`table` / `rowId` / `column` / `schemaVersion`), which is what lets a rotation worker move AAD-bound state across a keypair change without altering the bound context. The default-root `b.vault.aad.seal` / `unseal` behaviour is unchanged. · *Per-store AAD re-seal hooks on the agent primitives* — `b.agent.idempotency.reseal`, `b.agent.orchestrator.reseal`, `b.agent.snapshot.reseal`, and the `b.agent.tenant` registry / tenant-cell reseal paths re-seal that module's AAD-bound rows from an old root to a new root over the operator's own store. Each module also exposes an `AAD_ROTATION` descriptor naming the store the rotation pipeline cannot reach on its own, so an operator can enumerate exactly what to re-seal before a rotation. · *`b.archive.rewrapTenant`* — Re-wraps a `recipient: "tenant"` archive envelope from an old vault root to a new one for a given `tenantId`, so a keypair rotation does not strand tenant-scoped archives. Opens the blob under the old root + tenantId, refuses a blob that is not a tenant-recipient envelope or that does not open under the supplied old root, and emits a fresh envelope bound to the new root. This is offered alongside the documented re-export path (decrypt with the old keypair, re-archive with the new one) for operators who hold the envelope but not the source. · *Cluster vault-key rotation acceptance* — A vault-key rotation changes the public-key fingerprint recorded in the canonical cluster-state row, which a peer would otherwise report as `VAULT_KEY_DRIFT`. `b.cluster` configuration gains `acceptVaultKeyRotation: true` to declare the change legitimate — the node adopts the rotated fingerprint and bumps a rotation epoch instead of refusing — and an optional `expectedVaultKeyFp` that narrows acceptance to a single blessed post-rotation fingerprint. The drift guard stays in force whenever a rotation is not declared; supplying `expectedVaultKeyFp` without `acceptVaultKeyRotation` is rejected at configuration time as a misconfiguration. **Changed:** *`b.vaultRotate.rotate` refuses when reachable AAD stores are not acknowledged* — Because the rotation pipeline walks only `db.enc` and cannot introspect an operator's own AAD-backed stores, it now detects which AAD-store modules are loadable and throws `vault-rotate/external-aad-unresealed` unless `opts.externalAadResealed` is either `true` (you do not use those features) or an array naming every detected store (you have re-sealed each via its hook). This converts a path that previously discarded data and reported success into a fail-closed gate. The error names each store and the hook to call. **Fixed:** *Rotation re-seals `vault.aad:` cells and AAD-bound at-rest files* — `db.enc` is re-written bound to its dataDir-scoped AAD (it was previously re-written un-bound, silently stripping the at-rest AAD binding on every rotation), `db.key.enc` retains its location-bound AAD, and every `{ aad: true }` table column plus the overflow store is re-sealed under the new root. Previously only `vault:`-prefixed cells were carried across, so AAD-sealed data was left encrypted under the retired keypair and lost once it was discarded. · *Round-trip verify no longer reports a false success* — `b.vaultRotate.verify` now samples and decrypts AAD-sealed cells under the new root and treats any cell that still decrypts under the old root as a regression, so an incomplete rotation fails verification instead of passing it. The prior verify checked only `vault:` cells and therefore reported `ok` even when AAD-sealed cells had been orphaned. **Security:** *A vault key rotation can no longer silently destroy encrypted data* — The orphaning path lost agent idempotency / orchestrator / tenant / snapshot state, `{ aad: true }` columns, and tenant archives with no error and a passing verify; the data became unrecoverable the moment the old keypair was retired. Rotation is now fail-closed end to end: it re-seals what it can reach, refuses to proceed past what it cannot until you acknowledge it, and verifies the result under the new root. If you performed a rotation on v0.14.11 or earlier and still hold the retired keypair, re-seal the affected cells under the current root with the explicit-root primitives before discarding it. **Detectors:** *AAD-backed store modules must expose a rotation reseal path* — A new check flags a module that registers an external `{ aad: true }` store but does not expose an `AAD_ROTATION` descriptor and reseal hook, which would leave its state unreachable by the rotation pipeline. · *A root-keyed seal family must ship its reseal* — A new check flags adding a `sealRoot` / `unsealRoot` pair without the matching `resealRoot`, since without it a rotated cell cannot be carried from the old root to the new one. · *Live-root AAD seals need a reseal path* — A new check flags a primitive that AAD-seals under the live vault root without a way to re-seal that state under a new root during rotation. · *Tenant archive re-wrapping must compose `b.archive.rewrapTenant`* — A new check flags tenant-scoped archive re-wrapping that opens and re-seals a tenant envelope by hand instead of routing through `b.archive.rewrapTenant`. · *Cluster vault-key drift needs the rotation-epoch accept gate* — A new check flags a cluster vault-key fingerprint comparison that hard-rejects a mismatch without honouring the `acceptVaultKeyRotation` epoch window. **Migration:** *Re-seal operator AAD stores before rotating* — Before calling `b.vaultRotate.rotate`, re-seal each AAD-backed store you use via its hook (`b.agent.idempotency.reseal`, `b.agent.orchestrator.reseal`, `b.agent.snapshot.reseal`, the `b.agent.tenant` `AAD_ROTATION` reseal paths) with the old and new root JSON, re-wrap tenant archives with `b.archive.rewrapTenant`, then pass `opts.externalAadResealed` as an array naming each re-sealed store. If you use none of these features, pass `opts.externalAadResealed: true`. Declare the rotation to each cluster node with `acceptVaultKeyRotation: true` so the membership adopts the new fingerprint rather than reporting drift.
package/README.md CHANGED
@@ -212,7 +212,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
212
212
  - **Change control + WORM** — m-of-n approver DDL change-control with maintenance-window + ML-DSA-87 signed proposals (`b.ddlChangeControl`); row-level WORM triggers boot-asserted under `sec-17a-4` / `finra-4511` / `fda-21cfr11` (`b.db.declareWorm`); dual-control physical delete + crypto-erase + REINDEX in one transaction (`b.db.declareRequireDualControl`, `b.db.eraseHard`)
213
213
  - **Consumer-protection** — FTC click-to-cancel UX-parity attestation (`ftc-2024` / `ca-sb942` / `strict`) (`b.darkPatterns`)
214
214
  - **Differential privacy** — float-safe DP for aggregate releases: snapping-mechanism Laplace (Mironov 2012) + discrete Gaussian (Canonne–Kamath–Steinke 2020), CSPRNG noise, per-scope ε/δ budgets with basic + Rényi-DP accounting; defends the floating-point distinguishing attack that breaks naive Laplace samplers (NIST SP 800-226) (`b.ai.dp`)
215
- - **Privacy / DSR** — GDPR Articles 15–22 / CCPA / CPRA / LGPD / PIPEDA data-subject-rights workflow (`b.dsr`); IAB TCF v2 consent-string parse + encode + `disclosedVendors` validator (`b.iabTcf`); IAB MSPA / GPP universal-opt-out (USNAT / USCA / USVA / USCO / USCT / USUT) + GPC mirror (`b.iabMspa`); generic consent capture + withdrawal (`b.consent`)
215
+ - **Privacy / DSR** — GDPR Articles 15–22 / CCPA / CPRA / LGPD / PIPEDA data-subject-rights workflow (`b.dsr`); IAB TCF v2 consent-string parse + encode + `disclosedVendors` validator (`b.iabTcf`); IAB MSPA / GPP universal-opt-out (USNAT / USCA / USVA / USCO / USCT / USUT) + GPC mirror (`b.iabMspa`); generic consent capture + withdrawal (`b.consent`); educational-only consent purpose with FERPA / SOPIPA lawful-basis gating + annual EdTech third-party vendor-review attestation (`b.consent.recognizedPurpose`, `b.privacy.vendorReview`)
216
216
  - **Incident reporters** — EU DORA Article 17 ICT-incident workflow per Commission Delegated Regulation 2024/1772 (`b.dora`); EU NIS2 (`b.nis2`); EU Cyber Resilience Act SBOM + secure-software-attestation (`b.cra`); SEC Form 8-K Item 1.05 cybersecurity-incident materiality-disclosure (`b.secCyber`); incident lifecycle coordinator (`b.incident`)
217
217
  - **Outbound DLP** — interceptor-installed on httpClient + mail + webhook with built-in detectors for PAN (Luhn), SSN, EIN, IBAN (mod-97), api-key shapes, PEM, SSH private keys, JWTs, AWS access keys, PHI composite; refuse / redact / audit-only verdicts under pci-dss / hipaa / fapi2 / soc2 / gdpr presets (`b.redact.installOutboundDlp`)
218
218
  ### Observability
package/index.js CHANGED
@@ -26,7 +26,7 @@ _tls.DEFAULT_MIN_VERSION = "TLSv1.3";
26
26
  * frameworkSchema, clusterStorage, session, atomicFile,
27
27
  * cookies
28
28
  * Audit: audit, auditChain, auditSign, auditTools, consent,
29
- * subject, events, redact
29
+ * subject, events, redact, privacy
30
30
  * HTTP: router, middleware (csrf, cors, rate-limit, request-id,
31
31
  * security-headers, bot-guard, attach-user, require-auth,
32
32
  * error-handler, body-parser, csp-nonce, compression,
@@ -88,6 +88,7 @@ audit.export = function (opts) {
88
88
  };
89
89
  var auditChain = require("./lib/audit-chain");
90
90
  var consent = require("./lib/consent");
91
+ var privacy = require("./lib/privacy");
91
92
  var subject = require("./lib/subject");
92
93
  var session = require("./lib/session");
93
94
  var storage = require("./lib/storage");
@@ -459,6 +460,7 @@ module.exports = {
459
460
  auditTools: auditTools,
460
461
  events: events,
461
462
  consent: consent,
463
+ privacy: privacy,
462
464
  subject: subject,
463
465
  session: session,
464
466
  storage: storage,
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/audit.js CHANGED
@@ -270,6 +270,7 @@ var FRAMEWORK_NAMESPACES = [
270
270
  "flag", // b.flag (flag.evaluated / flag.evaluation.error / flag.cache.bust)
271
271
  "permissions", // b.permissions
272
272
  "pqcagent", // b.pqcAgent (pqcagent.operator_group.accepted)
273
+ "privacy", // b.privacy (privacy.vendor_review.recorded)
273
274
  "restore", // b.restore
274
275
  "retention", // b.retention (retention.rule.declared / sweep.started / row.processed / sweep.completed / sweep.failed)
275
276
  "scheduler", // b.scheduler (lifecycle: scheduler.start / scheduler.stop;
package/lib/consent.js CHANGED
@@ -26,6 +26,12 @@
26
26
  * enforcement at the trust boundary is the app's call (typical shape:
27
27
  * `if (!b.consent.isGranted({ subjectId, purpose })) return 403`).
28
28
  *
29
+ * `purpose` is free-form, but values matching the recognized-purpose
30
+ * vocabulary (`b.consent.recognizedPurpose` / `listPurposes`) carry
31
+ * lawful-basis constraints `grant()` enforces — e.g. `educational-only`
32
+ * (FERPA school-official exception / California SOPIPA) refuses a
33
+ * `legitimate_interests` basis.
34
+ *
29
35
  * Cluster mode keeps `_blamejs_consent_tip` current with a fenced
30
36
  * `INSERT … ON CONFLICT DO UPDATE … WHERE fencingToken <= EXCLUDED`
31
37
  * so a partitioned old leader cannot rewrite the tip even if its
@@ -52,6 +58,31 @@ var FRAMEWORK_SQL_TIMEOUT_MS = C.TIME.seconds(30);
52
58
  var LAWFUL_BASES = ["consent", "contract", "legal_obligation", "vital_interests", "public_task", "legitimate_interests"];
53
59
  var ACTIONS = ["granted", "withdrawn", "expired", "superseded"];
54
60
 
61
+ // Recognized consent purposes. A purpose value matching a key here carries
62
+ // lawful-basis constraints that grant() enforces; any other (free-form)
63
+ // purpose string stays valid and unconstrained, so the vocabulary is opt-in
64
+ // and never breaks operators passing their own purpose names. FERPA's
65
+ // school-official exception + California SOPIPA make "educational-only" the
66
+ // canonical constrained purpose.
67
+ //
68
+ // Null-prototype map: the purpose value is operator-controlled, so a plain
69
+ // object would let a free-form purpose colliding with an Object.prototype
70
+ // member ("toString" / "constructor" / "__proto__") resolve to the prototype
71
+ // value instead of undefined — recognizedPurpose() would return a function
72
+ // and grant() would enter the recognized branch for a value listPurposes()
73
+ // never exposes. A null prototype makes every unrecognized key resolve to
74
+ // undefined (CWE-1321 defense).
75
+ var PURPOSES = Object.freeze(Object.assign(Object.create(null), {
76
+ "educational-only": Object.freeze({
77
+ purpose: "educational-only",
78
+ forbidsLawfulBasis: Object.freeze(["legitimate_interests"]),
79
+ commercialUseProhibited: true,
80
+ dataMinimization: true,
81
+ citation: "FERPA 34 CFR 99.31(a)(1) school-official exception; Cal. SB 1177 SOPIPA 22584(b)(1)-(4); 16 CFR 312.5(c)(10) FTC school-authorized COPPA consent",
82
+ notes: "K-12 / school-official use only; no targeted advertising, no commercial profiling, no sale. Lawful basis must be school authorization (consent / public_task / legal_obligation), not legitimate_interests. The commercial-use prohibition is an operator trust-boundary obligation — isGranted() does not re-derive it.",
83
+ }),
84
+ }));
85
+
55
86
  var HASHABLE_COLS = [
56
87
  "_id", "recordedAt", "monotonicCounter",
57
88
  "subjectId", "subjectIdHash",
@@ -122,6 +153,22 @@ function grant(opts) {
122
153
  if (LAWFUL_BASES.indexOf(opts.lawfulBasis) === -1) {
123
154
  throw new Error("invalid lawfulBasis: '" + opts.lawfulBasis + "' (must be one of " + LAWFUL_BASES.join(", ") + ")");
124
155
  }
156
+ // Recognized-purpose vocabulary (opt-in): when the purpose matches a
157
+ // PURPOSES key, enforce its lawful-basis constraints. Free-form purposes
158
+ // remain unconstrained, so operators passing their own purpose names are
159
+ // unaffected.
160
+ var recognized = PURPOSES[opts.purpose];
161
+ if (recognized) {
162
+ if (recognized.forbidsLawfulBasis && recognized.forbidsLawfulBasis.indexOf(opts.lawfulBasis) !== -1) {
163
+ throw new Error("consent.grant: purpose '" + opts.purpose + "' forbids lawfulBasis '" +
164
+ opts.lawfulBasis + "' (" + recognized.citation + ")");
165
+ }
166
+ if (recognized.requiresLawfulBasis && recognized.requiresLawfulBasis.length > 0 &&
167
+ recognized.requiresLawfulBasis.indexOf(opts.lawfulBasis) === -1) {
168
+ throw new Error("consent.grant: purpose '" + opts.purpose + "' requires lawfulBasis in [" +
169
+ recognized.requiresLawfulBasis.join(", ") + "] (" + recognized.citation + ")");
170
+ }
171
+ }
125
172
  return _appendConsentRow({
126
173
  subjectId: opts.subjectId,
127
174
  purpose: opts.purpose,
@@ -133,6 +180,52 @@ function grant(opts) {
133
180
  });
134
181
  }
135
182
 
183
+ /**
184
+ * @primitive b.consent.recognizedPurpose
185
+ * @signature b.consent.recognizedPurpose(name)
186
+ * @since 0.14.14
187
+ * @status stable
188
+ * @compliance ferpa, ca-sopipa, coppa, gdpr
189
+ * @related b.consent.grant, b.consent.listPurposes, b.consent.isGranted
190
+ *
191
+ * Look up a recognized consent purpose by value. Recognized purposes carry
192
+ * lawful-basis constraints that `grant()` enforces; the `educational-only`
193
+ * purpose (FERPA school-official exception / SOPIPA) forbids a
194
+ * `legitimate_interests` basis and marks the data commercial-use-prohibited.
195
+ * That commercial-use prohibition is an operator trust-boundary obligation —
196
+ * `isGranted()` does not re-derive it. Returns the frozen entry, or `null`
197
+ * for a free-form purpose (which remains valid for `grant()`).
198
+ *
199
+ * @opts
200
+ * name: string, // a purpose value, e.g. "educational-only"
201
+ *
202
+ * @example
203
+ * b.consent.recognizedPurpose("educational-only");
204
+ * // → { purpose: "educational-only", forbidsLawfulBasis: ["legitimate_interests"], ... }
205
+ * b.consent.recognizedPurpose("marketing"); // → null (free-form)
206
+ */
207
+ function recognizedPurpose(name) {
208
+ return PURPOSES[name] || null;
209
+ }
210
+
211
+ /**
212
+ * @primitive b.consent.listPurposes
213
+ * @signature b.consent.listPurposes()
214
+ * @since 0.14.14
215
+ * @status stable
216
+ * @related b.consent.recognizedPurpose, b.consent.grant
217
+ *
218
+ * Return the recognized-purpose values as a frozen array. Free-form
219
+ * purposes are not listed — they remain valid for `grant()` but carry no
220
+ * lawful-basis constraint.
221
+ *
222
+ * @example
223
+ * b.consent.listPurposes(); // → ["educational-only"]
224
+ */
225
+ function listPurposes() {
226
+ return Object.freeze(Object.keys(PURPOSES));
227
+ }
228
+
136
229
  /**
137
230
  * @primitive b.consent.withdraw
138
231
  * @signature b.consent.withdraw(opts)
@@ -358,12 +451,15 @@ function _resetForTest() {
358
451
  }
359
452
 
360
453
  module.exports = {
361
- grant: grant,
362
- withdraw: withdraw,
363
- isGranted: isGranted,
364
- history: history,
365
- verify: verify,
366
- LAWFUL_BASES: LAWFUL_BASES,
367
- ACTIONS: ACTIONS,
368
- _resetForTest: _resetForTest,
454
+ grant: grant,
455
+ withdraw: withdraw,
456
+ isGranted: isGranted,
457
+ history: history,
458
+ verify: verify,
459
+ recognizedPurpose: recognizedPurpose,
460
+ listPurposes: listPurposes,
461
+ LAWFUL_BASES: LAWFUL_BASES,
462
+ PURPOSES: PURPOSES,
463
+ ACTIONS: ACTIONS,
464
+ _resetForTest: _resetForTest,
369
465
  };
@@ -370,6 +370,10 @@ var DoraError = defineClass("DoraError", { alwaysPermane
370
370
  // posture name, runtime-switch refusal, assertion failures.
371
371
  // Permanent — these are configuration errors, not transient.
372
372
  var ComplianceError = defineClass("ComplianceError", { alwaysPermanent: true });
373
+ // PrivacyError covers b.privacy config-time misuse: a malformed
374
+ // vendorReview opts object, a non-boolean clause attestation, or an
375
+ // unknown clause key. Permanent — operator configuration, not transient.
376
+ var PrivacyError = defineClass("PrivacyError", { alwaysPermanent: true });
373
377
  // SmtpPolicyError covers MTA-STS / DANE / TLS-RPT misuse: bad-policy
374
378
  // shape, fetch failures, TLSA-record format errors, missing records.
375
379
  // Permanent — these are policy / DNS configuration errors, not
@@ -698,6 +702,7 @@ module.exports = {
698
702
  GuardAuthError: GuardAuthError,
699
703
  DoraError: DoraError,
700
704
  ComplianceError: ComplianceError,
705
+ PrivacyError: PrivacyError,
701
706
  SmtpPolicyError: SmtpPolicyError,
702
707
  MailAuthError: MailAuthError,
703
708
  MailArfError: MailArfError,
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,
package/lib/privacy.js ADDED
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.privacy
4
+ * @nav Compliance
5
+ * @title Privacy
6
+ *
7
+ * @intro
8
+ * Privacy-program operational helpers. The first primitive,
9
+ * `vendorReview`, builds the annual third-party / EdTech vendor-review
10
+ * attestation that FERPA's school-official exception and California's
11
+ * SOPIPA expect a school or district to keep on file for every
12
+ * processor that touches student data: a dated, clause-by-clause
13
+ * record that the vendor uses the data only for the authorized
14
+ * educational purpose, runs no targeted advertising or commercial
15
+ * profiling, sells nothing, keeps reasonable security safeguards,
16
+ * deletes on request, and so on.
17
+ *
18
+ * The builder follows the operator-feeds-metadata pattern: the
19
+ * operator supplies the vendor's attested answers and `vendorReview`
20
+ * returns a frozen report — `{ attested, gaps, reviewedAt,
21
+ * nextReviewDueAt, ... }` — that composes into the operator's own
22
+ * retention / audit / export sink. It is not framework-persisted.
23
+ *
24
+ * @card
25
+ * Privacy-program helpers — annual FERPA / SOPIPA EdTech vendor-review attestation reports (`vendorReview`).
26
+ */
27
+
28
+ var lazyRequire = require("./lazy-require");
29
+ var validateOpts = require("./validate-opts");
30
+ var C = require("./constants");
31
+ var { PrivacyError } = require("./framework-error");
32
+
33
+ var audit = lazyRequire(function () { return require("./audit"); });
34
+
35
+ // The clause set a FERPA school-official / SOPIPA vendor review attests.
36
+ // Each entry: { id, required, citation, description }. Every `required`
37
+ // clause must be attested true for the review to pass (attested:true).
38
+ var VENDOR_REVIEW_CLAUSES = Object.freeze([
39
+ Object.freeze({ id: "educationalPurposeOnly", required: true, citation: "FERPA 34 CFR 99.31(a)(1)(i)(B)", description: "Vendor uses student data only for the authorized educational purpose under direct school control; no redisclosure." }),
40
+ Object.freeze({ id: "noTargetedAdvertising", required: true, citation: "SOPIPA Cal. B&P 22584(b)(1)", description: "No targeted advertising to students based on covered information." }),
41
+ Object.freeze({ id: "noCommercialProfiling", required: true, citation: "SOPIPA Cal. B&P 22584(b)(2)", description: "No amassing of a student profile except in furtherance of K-12 purposes." }),
42
+ Object.freeze({ id: "noSaleOfStudentData", required: true, citation: "SOPIPA Cal. B&P 22584(b)(3)", description: "No sale or rental of student information." }),
43
+ Object.freeze({ id: "securitySafeguards", required: true, citation: "SOPIPA Cal. B&P 22584(d)(1)", description: "Reasonable security procedures and practices appropriate to the data's sensitivity." }),
44
+ Object.freeze({ id: "deletionOnRequest", required: true, citation: "SOPIPA Cal. B&P 22584(d)(2)", description: "Deletes student PII within a reasonable time at the school's or district's request." }),
45
+ Object.freeze({ id: "subProcessorsCurrent", required: true, citation: "FERPA 34 CFR 99.33 (redisclosure)", description: "Sub-processor list is current and each is bound to the same restrictions." }),
46
+ Object.freeze({ id: "breachNotification", required: true, citation: "FERPA 34 CFR 99.31(a)(1) control + state breach law", description: "Notifies the school / district of any security breach without undue delay." }),
47
+ Object.freeze({ id: "schoolOfficialDesignation", required: true, citation: "FERPA 34 CFR 99.31(a)(1)(i)(B)", description: "Vendor is designated a school official with a legitimate educational interest." }),
48
+ Object.freeze({ id: "directoryInformationHandling", required: false, citation: "FERPA 34 CFR 99.37", description: "Handles directory information per the school's opt-out notice (only when applicable)." }),
49
+ ]);
50
+
51
+ var CLAUSE_IDS = VENDOR_REVIEW_CLAUSES.map(function (c) { return c.id; });
52
+
53
+ /**
54
+ * @primitive b.privacy.vendorReview
55
+ * @signature b.privacy.vendorReview(opts)
56
+ * @since 0.14.14
57
+ * @status stable
58
+ * @compliance ferpa, ca-sopipa, coppa
59
+ * @related b.consent.recognizedPurpose, b.compliance.describe, b.retention
60
+ *
61
+ * Build a dated annual third-party / EdTech vendor-review attestation —
62
+ * the record a FERPA school-official arrangement and California SOPIPA
63
+ * expect a school or district to keep for every processor of student
64
+ * data. The operator supplies the vendor's attested answer (a boolean)
65
+ * per clause; `vendorReview` validates the shape, computes whether every
66
+ * REQUIRED clause is attested (`attested`) and which are not (`gaps`),
67
+ * and stamps the review date plus a 365-day `nextReviewDueAt` re-review
68
+ * clock. Operator-feeds-metadata: the returned report is frozen and is
69
+ * NOT framework-persisted — compose it into your retention / audit /
70
+ * export sink. A best-effort `privacy.vendor_review.recorded` audit event
71
+ * fires when an audit sink is wired.
72
+ *
73
+ * @opts
74
+ * vendorName: string, // required — the processor under review
75
+ * reviewedAt: number, // required — epoch ms of this review
76
+ * clauses: { <clauseId>: boolean }, // attested answer per clause (see listVendorReviewClauses)
77
+ * reviewer: string, // optional — who performed the review
78
+ * notes: string, // optional — free-text reviewer notes
79
+ *
80
+ * @example
81
+ * var report = b.privacy.vendorReview({
82
+ * vendorName: "Acme LMS",
83
+ * reviewedAt: Date.now(),
84
+ * clauses: {
85
+ * educationalPurposeOnly: true, noTargetedAdvertising: true,
86
+ * noCommercialProfiling: true, noSaleOfStudentData: true,
87
+ * securitySafeguards: true, deletionOnRequest: true,
88
+ * subProcessorsCurrent: true, breachNotification: true,
89
+ * schoolOfficialDesignation: true,
90
+ * },
91
+ * });
92
+ * // → { vendorName, reviewedAt, nextReviewDueAt, attested: true, gaps: [], clauses: {...} }
93
+ */
94
+ function vendorReview(opts) {
95
+ validateOpts.requireObject(opts, "b.privacy.vendorReview: opts", PrivacyError, "privacy/bad-opts");
96
+ validateOpts.requireNonEmptyString(opts.vendorName, "b.privacy.vendorReview: opts.vendorName", PrivacyError, "privacy/bad-vendor");
97
+ if (typeof opts.reviewedAt !== "number" || !isFinite(opts.reviewedAt) || opts.reviewedAt <= 0) {
98
+ throw new PrivacyError("privacy/bad-reviewed-at",
99
+ "b.privacy.vendorReview: opts.reviewedAt must be a positive epoch-ms number");
100
+ }
101
+ var clauses = opts.clauses || {};
102
+ validateOpts.requireObject(clauses, "b.privacy.vendorReview: opts.clauses", PrivacyError, "privacy/bad-clauses");
103
+ // Reject unknown clause keys — a misspelled clause would otherwise
104
+ // silently never gate.
105
+ Object.keys(clauses).forEach(function (k) {
106
+ if (CLAUSE_IDS.indexOf(k) === -1) {
107
+ throw new PrivacyError("privacy/unknown-clause",
108
+ "b.privacy.vendorReview: unknown clause '" + k + "' (see b.privacy.listVendorReviewClauses())");
109
+ }
110
+ });
111
+ var resolved = {};
112
+ var gaps = [];
113
+ VENDOR_REVIEW_CLAUSES.forEach(function (clause) {
114
+ var v = clauses[clause.id];
115
+ // A supplied clause answer must be a boolean (config-time THROW); an
116
+ // omitted one defaults to not-attested.
117
+ validateOpts.optionalBoolean(v, "b.privacy.vendorReview: clauses." + clause.id, PrivacyError, "privacy/bad-clause-value");
118
+ var attestedTrue = v === true;
119
+ resolved[clause.id] = attestedTrue;
120
+ if (clause.required && !attestedTrue) gaps.push(clause.id);
121
+ });
122
+ var attested = gaps.length === 0;
123
+ var report = Object.freeze({
124
+ vendorName: opts.vendorName,
125
+ reviewedAt: opts.reviewedAt,
126
+ nextReviewDueAt: opts.reviewedAt + C.TIME.days(365),
127
+ coversPeriod: Object.freeze({ from: opts.reviewedAt - C.TIME.days(365), to: opts.reviewedAt }),
128
+ reviewer: opts.reviewer || null,
129
+ notes: opts.notes || null,
130
+ attested: attested,
131
+ gaps: Object.freeze(gaps),
132
+ clauses: Object.freeze(resolved),
133
+ });
134
+ try {
135
+ audit().safeEmit({
136
+ action: "privacy.vendor_review.recorded",
137
+ outcome: attested ? "success" : "denied",
138
+ metadata: { vendorName: opts.vendorName, attested: attested, gaps: gaps, reviewedAt: opts.reviewedAt },
139
+ });
140
+ } catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
141
+ return report;
142
+ }
143
+
144
+ /**
145
+ * @primitive b.privacy.listVendorReviewClauses
146
+ * @signature b.privacy.listVendorReviewClauses()
147
+ * @since 0.14.14
148
+ * @status stable
149
+ * @related b.privacy.vendorReview
150
+ *
151
+ * Return the frozen FERPA / SOPIPA vendor-review clause set — each entry
152
+ * is `{ id, required, citation, description }`. Use it to render a review
153
+ * form or to enumerate the clauses `vendorReview` evaluates.
154
+ *
155
+ * @example
156
+ * b.privacy.listVendorReviewClauses().map(function (c) { return c.id; });
157
+ * // → ["educationalPurposeOnly", "noTargetedAdvertising", ...]
158
+ */
159
+ function listVendorReviewClauses() {
160
+ return VENDOR_REVIEW_CLAUSES;
161
+ }
162
+
163
+ module.exports = {
164
+ vendorReview: vendorReview,
165
+ listVendorReviewClauses: listVendorReviewClauses,
166
+ VENDOR_REVIEW_CLAUSES: VENDOR_REVIEW_CLAUSES,
167
+ PrivacyError: PrivacyError,
168
+ };
@@ -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.13",
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:0632934d-fe4a-4185-bb87-eef8617ef0a0",
5
+ "serialNumber": "urn:uuid:44556dbf-5411-4644-ab81-5f0571d5e036",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-31T19:38:16.822Z",
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.13",
22
+ "bom-ref": "@blamejs/core@0.14.16",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.14.13",
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.13",
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.13",
57
+ "ref": "@blamejs/core@0.14.16",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]