@blamejs/core 0.14.14 → 0.14.17
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 +4 -0
- package/lib/app.js +8 -0
- package/lib/error-page.js +5 -1
- package/lib/http-client.js +37 -7
- package/lib/mail.js +1 -0
- package/lib/middleware/api-encrypt.js +58 -11
- package/lib/middleware/deny-response.js +7 -2
- package/lib/network-dns.js +1 -0
- package/lib/network-nts.js +4 -0
- package/lib/ntp-check.js +8 -0
- package/lib/problem-details.js +15 -3
- package/lib/redis-client.js +8 -0
- package/lib/router.js +13 -6
- package/lib/sse.js +7 -5
- package/lib/validate-opts.js +23 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.17 (2026-05-31) — **In-session API-encrypt errors stay confidential, and the encrypted client can read them.** When b.middleware.apiEncrypt is active, a normal response is sealed in the { _ct } envelope, but a terminal error that bypassed res.json (an error page, a validation refusal, an RFC 9457 problem+json document, a deny-middleware body) shipped in plaintext on the otherwise-encrypted channel, and the b.httpClient.encrypted client threw on the response shape because it tried to decrypt every reply. This release makes errors symmetric: those four sinks now seal their body in the same envelope a success uses whenever a session is active (via a new req.apiEncryptEncode the middleware installs after a successful decrypt), and the client gains a responseMode: "passthrough" that reads a non-2xx — decrypting an in-session error and returning a plaintext one verbatim — instead of throwing. Errors raised before a session exists (a Bearer 401, a handshake rejection, a replay refusal) deliberately stay plaintext and human-readable. Two adjacent streaming fixes ride along: a streamed (responseMode "stream") non-2xx now keeps a bounded prefix of the error body on the thrown error instead of draining it, and an SSE channel closed by a transport fault audits as a failure with a reason rather than looking like a clean operator close. **Added:** *`b.httpClient.encrypted` gains `responseMode: "passthrough"`* — The encrypted client defaults to `responseMode: "reject"` (a non-2xx rejects, exactly as before). Set `responseMode: "passthrough"` at create time, or per request, to resolve a non-2xx instead: the result is `{ statusCode, headers, body, ok }`, where `body` is the decrypted object when the reply carries an encrypted `_ct` envelope (an in-session error) and the parsed plaintext otherwise (a pre-session error such as a Bearer 401 or a proxy 502). The additive `ok` boolean (status 200–299) is present on every result. This lets a client read an error's status and detail instead of failing on the response shape. · *`req.apiEncryptEncode` — the in-session error encoder* — `b.middleware.apiEncrypt` installs `req.apiEncryptEncode(obj)` after it successfully decrypts a request body. It returns the same `{ _ct }` (plus `_sid` / `_ctr` in per-session mode) envelope a normal response uses, so a terminal handler that writes its body directly (bypassing the wrapped `res.json`) can keep an error confidential on the encrypted channel. It is present only after a valid decrypt, so every pre-session path has no encoder and its errors stay plaintext. **Changed:** *In-session terminal errors are sealed in the encrypted envelope* — The error page (JSON branch), the router's schema-validation refusal, `b.problemDetails.respond` (now accepting an optional `req`), and the access-deny middleware now seal their error body via `req.apiEncryptEncode` when a session is active — so an in-session error no longer ships as plaintext while the surrounding traffic is encrypted. A sealed problem+json is labelled `application/json` (the envelope), not `application/problem+json`. With no active session (or if encoding fails) the body stays plaintext and readable, unchanged. · *Streamed non-2xx responses preserve a bounded error-body prefix* — `b.httpClient.request({ responseMode: "stream" })` (and `b.httpClient.downloadStream`, which composes it) previously drained and discarded the body of a `>= 400` response. The rejection is unchanged, but the thrown error now carries a bounded (16 KiB) prefix of the body on `err.body`, so a caller can read the problem+json / error detail. The prefix is collected through `b.safeBuffer.boundedChunkCollector`, so a hostile oversized error body can't accumulate unbounded. · *SSE transport-fault close audits as a failure* — An SSE channel closed by a transport fault (a stream `error`, a failed heartbeat write) now emits its `sse.channel_closed` audit event with `outcome: "failure"` and a `reason`, instead of the `"success"` outcome an intentional `channel.close()` records — so an operator's evidence stream can tell a dropped connection from a clean shutdown. **Security:** *Error bodies no longer leak in plaintext on an encrypted channel* — Before this release, a request that established an apiEncrypt session but then failed (validation error, access denial, server error rendered through the error page or problem-details) returned its error body in plaintext, even though every successful response on the same channel was encrypted — exposing error detail (paths, field names, refusal reasons) to a network observer. In-session errors are now encrypted symmetrically with successes; pre-session errors, which a client must be able to read to recover, remain plaintext by design. **Migration:** *Opt into reading non-2xx encrypted errors* — No change is required for existing callers — `b.httpClient.encrypted` still defaults to `responseMode: "reject"` (throw on non-2xx). To read an error's status and decrypted detail, create the client with `responseMode: "passthrough"` (or pass it per request) and branch on the returned `ok` / `statusCode`. The new `ok` field is additive and does not affect existing `{ statusCode, headers, body }` consumers.
|
|
12
|
+
|
|
13
|
+
- 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).
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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/error-page.js
CHANGED
|
@@ -394,8 +394,12 @@ function create(opts) {
|
|
|
394
394
|
if (mode === "dev" && info.stack && showStack && info.status >= 500) {
|
|
395
395
|
errorObj.stack = info.stack;
|
|
396
396
|
}
|
|
397
|
+
var errorBody = { error: errorObj };
|
|
398
|
+
if (req && typeof req.apiEncryptEncode === "function") {
|
|
399
|
+
try { errorBody = req.apiEncryptEncode(errorBody); } catch (_e) { errorBody = { error: errorObj }; }
|
|
400
|
+
}
|
|
397
401
|
_writeResponse(res, info.status, "application/json; charset=utf-8",
|
|
398
|
-
JSON.stringify(
|
|
402
|
+
JSON.stringify(errorBody));
|
|
399
403
|
return;
|
|
400
404
|
}
|
|
401
405
|
|
package/lib/http-client.js
CHANGED
|
@@ -378,6 +378,39 @@ function _isPermanentStatus(statusCode) {
|
|
|
378
378
|
return false;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// Reject a streamed non-2xx response, preserving a bounded prefix of the
|
|
382
|
+
// error body (problem+json / encrypted error) on err.body instead of
|
|
383
|
+
// silently draining it.
|
|
384
|
+
function _rejectStreamHttpError(stream, errorClass, statusCode, statusMessage, reject) {
|
|
385
|
+
var cap = C.BYTES.kib(16);
|
|
386
|
+
var collector = safeBuffer.boundedChunkCollector({ maxBytes: cap });
|
|
387
|
+
var done = false;
|
|
388
|
+
function finish() {
|
|
389
|
+
if (done) return;
|
|
390
|
+
done = true;
|
|
391
|
+
var e = _makeError(errorClass, "HTTP_ERROR",
|
|
392
|
+
"HTTP " + statusCode + (statusMessage ? " " + statusMessage : ""),
|
|
393
|
+
_isPermanentStatus(statusCode), statusCode);
|
|
394
|
+
e.body = collector.result();
|
|
395
|
+
reject(e);
|
|
396
|
+
}
|
|
397
|
+
// Collect at most `cap` bytes of the error body, slicing each chunk to the
|
|
398
|
+
// remaining room so the bounded collector never overflows. As soon as the
|
|
399
|
+
// prefix is full, reject + destroy the stream — don't leave the request
|
|
400
|
+
// promise pending while a large / slow error body drains to its close.
|
|
401
|
+
stream.on("data", function (c) {
|
|
402
|
+
if (done) return;
|
|
403
|
+
var room = cap - collector.bytesCollected();
|
|
404
|
+
if (room > 0) collector.push(c.length > room ? c.subarray(0, room) : c);
|
|
405
|
+
if (collector.bytesCollected() >= cap) {
|
|
406
|
+
if (typeof stream.destroy === "function") stream.destroy();
|
|
407
|
+
finish();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
stream.on("end", finish);
|
|
411
|
+
stream.on("error", finish);
|
|
412
|
+
}
|
|
413
|
+
|
|
381
414
|
// h2 sends headers as lowercased keys plus :method / :path / :scheme /
|
|
382
415
|
// :authority pseudo-headers. Convert from h1-shaped headers.
|
|
383
416
|
function _toH2Headers(method, u, headers) {
|
|
@@ -1421,10 +1454,8 @@ function _requestH1(transport, u, opts) {
|
|
|
1421
1454
|
|
|
1422
1455
|
if (responseMode === "stream") {
|
|
1423
1456
|
if (res.statusCode >= 400 && responseMode !== "always-resolve") {
|
|
1424
|
-
res.
|
|
1425
|
-
return
|
|
1426
|
-
"HTTP " + res.statusCode + " " + (res.statusMessage || ""),
|
|
1427
|
-
_isPermanentStatus(res.statusCode), res.statusCode));
|
|
1457
|
+
_rejectStreamHttpError(res, opts.errorClass, res.statusCode, res.statusMessage || "", _reject);
|
|
1458
|
+
return;
|
|
1428
1459
|
}
|
|
1429
1460
|
if (onDownloadProgress || onChunk) {
|
|
1430
1461
|
// Wrap the stream so chunks emit progress + onChunk to the
|
|
@@ -1639,9 +1670,8 @@ function _requestH2(transport, u, opts) {
|
|
|
1639
1670
|
|
|
1640
1671
|
if (responseMode === "stream") {
|
|
1641
1672
|
if (statusCode >= 400 && responseMode !== "always-resolve") {
|
|
1642
|
-
stream.
|
|
1643
|
-
return
|
|
1644
|
-
"HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
|
|
1673
|
+
_rejectStreamHttpError(stream, opts.errorClass, statusCode, "", _reject);
|
|
1674
|
+
return;
|
|
1645
1675
|
}
|
|
1646
1676
|
if (onChunkH2) {
|
|
1647
1677
|
var passthroughH2 = new nodeStream.PassThrough();
|
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;
|
|
@@ -440,6 +440,21 @@ function create(opts) {
|
|
|
440
440
|
});
|
|
441
441
|
}
|
|
442
442
|
|
|
443
|
+
// _encodeEnvelope — build the wire envelope for a single response
|
|
444
|
+
// body. In per-request mode it is `{ _ct }`; in per-session mode it
|
|
445
|
+
// carries `{ _ct, _sid, _ctr }` so the client can detect tampered /
|
|
446
|
+
// replayed responses with a monotonic counter check.
|
|
447
|
+
function _encodeEnvelope(data, sessionKey, sessionCtx) {
|
|
448
|
+
var ptBuf = Buffer.from(JSON.stringify(data), "utf8");
|
|
449
|
+
var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey);
|
|
450
|
+
var encrypted = { _ct: ctBuf.toString("base64") };
|
|
451
|
+
if (sessionCtx) {
|
|
452
|
+
encrypted._sid = sessionCtx.sid;
|
|
453
|
+
encrypted._ctr = sessionCtx.responseCtr;
|
|
454
|
+
}
|
|
455
|
+
return encrypted;
|
|
456
|
+
}
|
|
457
|
+
|
|
443
458
|
// _wrapResJson — install res.json that encrypts the response with the
|
|
444
459
|
// session key. In per-request mode the response is `{ _ct }`; in
|
|
445
460
|
// per-session mode it carries `{ _ct, _sid, _ctr }` so the client can
|
|
@@ -448,13 +463,7 @@ function create(opts) {
|
|
|
448
463
|
var origJson = res.json;
|
|
449
464
|
res.json = function (data) {
|
|
450
465
|
try {
|
|
451
|
-
var
|
|
452
|
-
var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey);
|
|
453
|
-
var encrypted = { _ct: ctBuf.toString("base64") };
|
|
454
|
-
if (sessionCtx) {
|
|
455
|
-
encrypted._sid = sessionCtx.sid;
|
|
456
|
-
encrypted._ctr = sessionCtx.responseCtr;
|
|
457
|
-
}
|
|
466
|
+
var encrypted = _encodeEnvelope(data, sessionKey, sessionCtx);
|
|
458
467
|
if (typeof origJson === "function") {
|
|
459
468
|
return origJson.call(res, encrypted);
|
|
460
469
|
}
|
|
@@ -669,6 +678,10 @@ function create(opts) {
|
|
|
669
678
|
req.apiEncryptSessionKey = sessionKey;
|
|
670
679
|
if (sessionCtx) req.apiEncryptSession = { sid: sessionCtx.sid };
|
|
671
680
|
|
|
681
|
+
// Error-path encoder for terminal writers that bypass res.json; set
|
|
682
|
+
// only here (after a valid decrypt) so pre-session errors stay plaintext.
|
|
683
|
+
req.apiEncryptEncode = function (data) { return _encodeEnvelope(data, sessionKey, sessionCtx); };
|
|
684
|
+
|
|
672
685
|
_wrapResJson(res, sessionKey, sessionCtx);
|
|
673
686
|
_maybePrune();
|
|
674
687
|
|
|
@@ -895,6 +908,7 @@ function httpClientEncrypted(opts) {
|
|
|
895
908
|
opts = opts || {};
|
|
896
909
|
validateOpts(opts, [
|
|
897
910
|
"pubkey", "baseUrl", "headers", "method", "maxDecryptedBytes", "keying",
|
|
911
|
+
"responseMode",
|
|
898
912
|
], "middleware.apiEncrypt.httpClient");
|
|
899
913
|
if (!opts.pubkey) {
|
|
900
914
|
throw _err("CLIENT_INVALID_PUBKEY",
|
|
@@ -904,6 +918,16 @@ function httpClientEncrypted(opts) {
|
|
|
904
918
|
? opts.maxDecryptedBytes
|
|
905
919
|
: C.BYTES.mib(4);
|
|
906
920
|
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
921
|
+
// responseMode controls how a non-2xx / non-encrypted response is
|
|
922
|
+
// handled: "reject" (default) keeps the core throwing on non-2xx and
|
|
923
|
+
// requires an encrypted `_ct` body; "passthrough" resolves any status
|
|
924
|
+
// and returns a plaintext body verbatim when it carries no `_ct`.
|
|
925
|
+
var responseModeDefault = opts.responseMode != null ? opts.responseMode : "reject";
|
|
926
|
+
if (responseModeDefault !== "reject" && responseModeDefault !== "passthrough") {
|
|
927
|
+
throw _err("CLIENT_BAD_OPT",
|
|
928
|
+
"httpClient.encrypted: responseMode must be 'reject' (default) or 'passthrough', got " +
|
|
929
|
+
JSON.stringify(opts.responseMode), 500);
|
|
930
|
+
}
|
|
907
931
|
var clientCtx = client({
|
|
908
932
|
pubkey: opts.pubkey,
|
|
909
933
|
maxDecryptedBytes: maxDecryptedBytes,
|
|
@@ -929,6 +953,12 @@ function httpClientEncrypted(opts) {
|
|
|
929
953
|
async function request(reqOpts) {
|
|
930
954
|
reqOpts = reqOpts || {};
|
|
931
955
|
var url = _resolveUrl(reqOpts);
|
|
956
|
+
var mode = (reqOpts && reqOpts.responseMode != null) ? reqOpts.responseMode : responseModeDefault;
|
|
957
|
+
if (mode !== "reject" && mode !== "passthrough") {
|
|
958
|
+
throw _err("CLIENT_BAD_OPT",
|
|
959
|
+
"httpClient.encrypted.request: responseMode must be 'reject' (default) or 'passthrough', got " +
|
|
960
|
+
JSON.stringify(reqOpts.responseMode), 500);
|
|
961
|
+
}
|
|
932
962
|
var encrypted = clientCtx.encryptRequest(
|
|
933
963
|
reqOpts.body !== undefined ? reqOpts.body : null
|
|
934
964
|
);
|
|
@@ -947,16 +977,24 @@ function httpClientEncrypted(opts) {
|
|
|
947
977
|
}
|
|
948
978
|
|
|
949
979
|
var rawBody = Buffer.from(JSON.stringify(encrypted.body), "utf8");
|
|
950
|
-
var
|
|
980
|
+
var requestArgs = Object.assign({
|
|
951
981
|
url: url,
|
|
952
982
|
method: reqOpts.method || defaultMethod,
|
|
953
983
|
headers: headers,
|
|
954
984
|
body: rawBody,
|
|
955
|
-
}, passThrough)
|
|
985
|
+
}, passThrough);
|
|
986
|
+
// In passthrough mode a non-2xx status resolves instead of throwing,
|
|
987
|
+
// so the caller can inspect the (possibly plaintext) error body.
|
|
988
|
+
if (mode === "passthrough") {
|
|
989
|
+
requestArgs.responseMode = "always-resolve";
|
|
990
|
+
}
|
|
991
|
+
var resp = await httpClient().request(requestArgs);
|
|
992
|
+
|
|
993
|
+
var ok = resp.statusCode >= 200 && resp.statusCode < 300;
|
|
956
994
|
|
|
957
995
|
// Empty body → no decryption (e.g. 204 No Content).
|
|
958
996
|
if (!resp.body || resp.body.length === 0) {
|
|
959
|
-
return { statusCode: resp.statusCode, headers: resp.headers, body: null };
|
|
997
|
+
return { statusCode: resp.statusCode, headers: resp.headers, body: null, ok: ok };
|
|
960
998
|
}
|
|
961
999
|
var parsed;
|
|
962
1000
|
try { parsed = safeJson.parse(resp.body.toString("utf8"), { maxBytes: maxDecryptedBytes }); }
|
|
@@ -964,10 +1002,19 @@ function httpClientEncrypted(opts) {
|
|
|
964
1002
|
throw _err("CLIENT_RESPONSE_NOT_JSON",
|
|
965
1003
|
"httpClient.encrypted: response body is not valid JSON: " + e.message);
|
|
966
1004
|
}
|
|
1005
|
+
// passthrough: decrypt only an encrypted envelope; return a plaintext
|
|
1006
|
+
// body verbatim. reject: always decrypt (throws on a missing _ct).
|
|
1007
|
+
var body;
|
|
1008
|
+
if (mode === "passthrough") {
|
|
1009
|
+
body = (parsed && typeof parsed._ct === "string") ? encrypted.decryptResponse(parsed) : parsed;
|
|
1010
|
+
} else {
|
|
1011
|
+
body = encrypted.decryptResponse(parsed);
|
|
1012
|
+
}
|
|
967
1013
|
return {
|
|
968
1014
|
statusCode: resp.statusCode,
|
|
969
1015
|
headers: resp.headers,
|
|
970
|
-
body:
|
|
1016
|
+
body: body,
|
|
1017
|
+
ok: ok,
|
|
971
1018
|
};
|
|
972
1019
|
}
|
|
973
1020
|
|
|
@@ -125,13 +125,18 @@ function denyResponse(req, res, ctx) {
|
|
|
125
125
|
res.setHeader(hk[h], extra[hk[h]]);
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
-
problemDetails.respond(res, problem);
|
|
128
|
+
problemDetails.respond(res, problem, req);
|
|
129
129
|
return undefined;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
var head = _mergeInto({ "Content-Type": ctx.contentType }, extra);
|
|
133
|
+
var denyOut = (ctx.body === undefined || ctx.body === null) ? ""
|
|
134
|
+
: (typeof ctx.body === "string" ? ctx.body : JSON.stringify(ctx.body));
|
|
135
|
+
if (ctx.body !== undefined && ctx.body !== null && req && typeof req.apiEncryptEncode === "function") {
|
|
136
|
+
try { denyOut = JSON.stringify(req.apiEncryptEncode(ctx.body)); } catch (_e) { /* plaintext kept */ }
|
|
137
|
+
}
|
|
133
138
|
res.writeHead(ctx.status, head);
|
|
134
|
-
res.end(
|
|
139
|
+
res.end(denyOut);
|
|
135
140
|
return undefined;
|
|
136
141
|
}
|
|
137
142
|
|
package/lib/network-dns.js
CHANGED
|
@@ -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",
|
package/lib/network-nts.js
CHANGED
|
@@ -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/problem-details.js
CHANGED
|
@@ -313,7 +313,7 @@ function fromError(err, opts2) {
|
|
|
313
313
|
|
|
314
314
|
/**
|
|
315
315
|
* @primitive b.problemDetails.respond
|
|
316
|
-
* @signature b.problemDetails.respond(res, problem)
|
|
316
|
+
* @signature b.problemDetails.respond(res, problem, req?)
|
|
317
317
|
* @since 0.8.84
|
|
318
318
|
* @status stable
|
|
319
319
|
* @related b.problemDetails.create, b.problemDetails.fromError
|
|
@@ -339,7 +339,7 @@ function fromError(err, opts2) {
|
|
|
339
339
|
* // res.body: <JSON-stringified problem>
|
|
340
340
|
* // res.statusCode: 400
|
|
341
341
|
*/
|
|
342
|
-
function respond(res, problem) {
|
|
342
|
+
function respond(res, problem, req) {
|
|
343
343
|
if (!res || typeof res !== "object" || typeof res.setHeader !== "function" ||
|
|
344
344
|
typeof res.end !== "function") {
|
|
345
345
|
throw new ProblemDetailsError("problem-details/bad-res",
|
|
@@ -352,8 +352,20 @@ function respond(res, problem) {
|
|
|
352
352
|
var status = (typeof problem.status === "number" && Number.isInteger(problem.status) &&
|
|
353
353
|
problem.status >= 100 && problem.status <= 599) ? problem.status : 500; // HTTP status range + default 500
|
|
354
354
|
var body = JSON.stringify(problem);
|
|
355
|
+
// Seal the problem body when an encrypted session is active — the
|
|
356
|
+
// encoder is present only after a request body decrypted, so its
|
|
357
|
+
// envelope decrypts identically on the client. Pre-session paths
|
|
358
|
+
// leave req/encoder absent and keep plaintext problem+json. An
|
|
359
|
+
// encryption failure falls back to plaintext rather than crashing.
|
|
360
|
+
var contentType = "application/problem+json";
|
|
361
|
+
if (req && typeof req.apiEncryptEncode === "function") {
|
|
362
|
+
try {
|
|
363
|
+
body = JSON.stringify(req.apiEncryptEncode(problem));
|
|
364
|
+
contentType = "application/json";
|
|
365
|
+
} catch (_e) { /* keep plaintext problem+json */ }
|
|
366
|
+
}
|
|
355
367
|
res.statusCode = status;
|
|
356
|
-
res.setHeader("Content-Type",
|
|
368
|
+
res.setHeader("Content-Type", contentType);
|
|
357
369
|
res.setHeader("Cache-Control", "no-store");
|
|
358
370
|
res.end(body);
|
|
359
371
|
}
|
package/lib/redis-client.js
CHANGED
|
@@ -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;
|
package/lib/router.js
CHANGED
|
@@ -112,13 +112,20 @@ function _validateRouteSpec(spec, method, pattern) {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
function _writeValidationError(res, where, errors) {
|
|
115
|
+
function _writeValidationError(req, res, where, errors) {
|
|
116
116
|
if (res.writableEnded || res.headersSent) return;
|
|
117
|
-
var
|
|
117
|
+
var payload = {
|
|
118
118
|
error: "validation",
|
|
119
119
|
where: where,
|
|
120
120
|
issues: errors,
|
|
121
|
-
}
|
|
121
|
+
};
|
|
122
|
+
var body = JSON.stringify(payload);
|
|
123
|
+
// Seal the body when an encrypted session is active; pre-session paths
|
|
124
|
+
// (Bearer auth, handshake reject, replay refusal) lack the encoder and
|
|
125
|
+
// stay plaintext. An encryption failure falls back to the plaintext body.
|
|
126
|
+
if (req && typeof req.apiEncryptEncode === "function") {
|
|
127
|
+
try { body = JSON.stringify(req.apiEncryptEncode(payload)); } catch (_e) { /* plaintext body kept */ }
|
|
128
|
+
}
|
|
122
129
|
res.writeHead(HTTP_STATUS.BAD_REQUEST, {
|
|
123
130
|
"Content-Type": "application/json; charset=utf-8",
|
|
124
131
|
"Content-Length": Buffer.byteLength(body),
|
|
@@ -131,17 +138,17 @@ function _makeSchemaValidator(spec) {
|
|
|
131
138
|
return function schemaValidator(req, res, next) {
|
|
132
139
|
if (spec.params && req.params !== undefined) {
|
|
133
140
|
var pp = spec.params.safeParse(req.params);
|
|
134
|
-
if (!pp.ok) return _writeValidationError(res, "params", pp.errors);
|
|
141
|
+
if (!pp.ok) return _writeValidationError(req, res, "params", pp.errors);
|
|
135
142
|
req.params = pp.value;
|
|
136
143
|
}
|
|
137
144
|
if (spec.query && req.query !== undefined) {
|
|
138
145
|
var qq = spec.query.safeParse(req.query);
|
|
139
|
-
if (!qq.ok) return _writeValidationError(res, "query", qq.errors);
|
|
146
|
+
if (!qq.ok) return _writeValidationError(req, res, "query", qq.errors);
|
|
140
147
|
req.query = qq.value;
|
|
141
148
|
}
|
|
142
149
|
if (spec.body && req.body !== undefined) {
|
|
143
150
|
var bb = spec.body.safeParse(req.body);
|
|
144
|
-
if (!bb.ok) return _writeValidationError(res, "body", bb.errors);
|
|
151
|
+
if (!bb.ok) return _writeValidationError(req, res, "body", bb.errors);
|
|
145
152
|
req.body = bb.value;
|
|
146
153
|
}
|
|
147
154
|
next();
|
package/lib/sse.js
CHANGED
|
@@ -287,7 +287,7 @@ function create(req, res, opts) {
|
|
|
287
287
|
_writeRaw(":" + text + "\n\n");
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
function close() {
|
|
290
|
+
function close(cause) {
|
|
291
291
|
if (closed) return;
|
|
292
292
|
closed = true;
|
|
293
293
|
if (heartbeatTimer) {
|
|
@@ -296,10 +296,12 @@ function create(req, res, opts) {
|
|
|
296
296
|
}
|
|
297
297
|
try { res.end(); } catch (_e) { /* already destroyed */ }
|
|
298
298
|
if (auditOn) {
|
|
299
|
+
var closeMeta = { lastEventId: lastEventId };
|
|
300
|
+
if (cause) closeMeta.reason = cause.reason || "fault";
|
|
299
301
|
audit.safeEmit({
|
|
300
302
|
action: "sse.channel_closed",
|
|
301
|
-
outcome: "success",
|
|
302
|
-
metadata:
|
|
303
|
+
outcome: cause ? "failure" : "success",
|
|
304
|
+
metadata: closeMeta,
|
|
303
305
|
});
|
|
304
306
|
}
|
|
305
307
|
}
|
|
@@ -308,7 +310,7 @@ function create(req, res, opts) {
|
|
|
308
310
|
// the heartbeat timer.
|
|
309
311
|
if (typeof res.on === "function") {
|
|
310
312
|
res.on("close", close);
|
|
311
|
-
res.on("error", function (_e) { close(); });
|
|
313
|
+
res.on("error", function (_e) { close({ reason: "stream-error" }); });
|
|
312
314
|
res.on("finish", function () { closed = true; if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } });
|
|
313
315
|
}
|
|
314
316
|
|
|
@@ -325,7 +327,7 @@ function create(req, res, opts) {
|
|
|
325
327
|
heartbeatTimer = setInterval(function () {
|
|
326
328
|
if (closed) return;
|
|
327
329
|
try { _writeRaw(":keepalive\n\n"); }
|
|
328
|
-
catch (_e) { close(); }
|
|
330
|
+
catch (_e) { close({ reason: "heartbeat-write-failed" }); }
|
|
329
331
|
}, heartbeatMs).unref();
|
|
330
332
|
}
|
|
331
333
|
|
package/lib/validate-opts.js
CHANGED
|
@@ -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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:e5d17e91-0802-4373-b6b4-f26370a14472",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-
|
|
8
|
+
"timestamp": "2026-06-01T03:23:48.524Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.14.17",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.14.
|
|
25
|
+
"version": "0.14.17",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.14.17",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.14.17",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|