From 7cafb8904aec5db32ebb8281339a3b95bbdfabe7 Mon Sep 17 00:00:00 2001 From: coder9io <342252087+coder9io@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:32:11 +0000 Subject: [PATCH 1/8] build: require Node.js >=22, dropping end-of-life 18 and 20 Node 18 (EOL 2025-04-30) and 20 (EOL 2026-04-30) get no security backports, including the TLS SPKI blocklist for the compromised eMudhra intermediates (nodejs/node#66871). Declare the supported floor in engines so consumers are warned on an unpatched-forever runtime. npm also synced the lockfile's stale root license field to MIT. --- README.md | 2 ++ package-lock.json | 5 ++++- package.json | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca26ba5..d33e9a3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ npm install @last9/otel-cron @opentelemetry/sdk-node @opentelemetry/exporter-tra Peer dependency: `@opentelemetry/api ^1.9.0`. +Requires Node.js 22 or later (a supported release line). The OTLP/HTTPS exporters validate TLS with your process's Node.js, so keep it on the latest patch release of its line. + ## Configuration Set these environment variables before starting your process. Never hardcode them — use `.env` files locally and your secrets manager in production. diff --git a/package-lock.json b/package-lock.json index 5f1df2c..d3f2f59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "@last9/otel-cron", "version": "0.1.0", - "license": "Apache-2.0", + "license": "MIT", "devDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.57.2", @@ -19,6 +19,9 @@ "ts-jest": "^29.3.4", "typescript": "^5.8.3" }, + "engines": { + "node": ">=22" + }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } diff --git a/package.json b/package.json index 501ca8c..fb1317a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ ], "author": "Last9 Inc.", "license": "MIT", + "engines": { + "node": ">=22" + }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" From a44c7d8e681819ba4d1320da26461bc584b8441b Mon Sep 17 00:00:00 2001 From: coder9io <342252087+coder9io@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:32:25 +0000 Subject: [PATCH 2/8] ci: test on Node 22/24/26, release on 24, always latest patch Drop end-of-life 18 and 20 to match engines.node >=22 and cover every supported line. check-latest makes setup-node resolve the newest patch from nodejs.org instead of the runner's cached toolchain, so CI and releases pick up the eMudhra blocklist fix as soon as it is released. --- .github/workflows/ci.yml | 3 ++- .github/workflows/release.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2374d2..b8fc984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,13 +12,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [18, 20, 22] + node: [22, 24, 26] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} + check-latest: true cache: npm - run: npm ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e74442..8bf201a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,8 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 + check-latest: true cache: npm - run: npm ci From b9a70a5da86ca27bae4919685085c6dc6ade3ba5 Mon Sep 17 00:00:00 2001 From: coder9io <342252087+coder9io@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:07:31 +0000 Subject: [PATCH 3/8] test: require Node releases with the eMudhra blocklist engines.node still admits any Node 22+, including releases that accept chains through the compromised eMudhra intermediates. Pin the expected floors (first release per line containing nodejs/node#66871) and check package.json, the lockfile and the README against them. --- test/node-versions.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 test/node-versions.test.ts diff --git a/test/node-versions.test.ts b/test/node-versions.test.ts new file mode 100644 index 0000000..b1cfca6 --- /dev/null +++ b/test/node-versions.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +// First release on each supported line that rejects the compromised eMudhra +// intermediates (SPKI blocklist, nodejs/node#66871). Odd lines 23/25 are +// end-of-life and never got the fix, so they stay excluded. +const PATCHED_NODE = ['22.23.4', '24.22.1', '26.12.1']; +const ENGINES = '^22.23.4 || ^24.22.1 || >=26.12.1'; + +const root = join(__dirname, '..'); +const read = (file: string) => readFileSync(join(root, file), 'utf8'); + +describe('supported Node.js versions', () => { + it('floors engines.node at the patched release of each line', () => { + expect(JSON.parse(read('package.json')).engines.node).toBe(ENGINES); + expect(ENGINES.match(/\d+\.\d+\.\d+/g)).toEqual(PATCHED_NODE); + }); + + it('keeps package-lock.json in step with package.json', () => { + expect(JSON.parse(read('package-lock.json')).packages[''].engines.node).toBe(ENGINES); + }); + + it('documents every patched floor in the README', () => { + const readme = read('README.md'); + for (const version of PATCHED_NODE) { + expect(readme).toContain(`v${version}`); + } + }); +}); From 0e9d7f6f3bd6f667749661b4e7ebbf3f1e9325e4 Mon Sep 17 00:00:00 2001 From: coder9io <342252087+coder9io@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:07:31 +0000 Subject: [PATCH 4/8] build: floor Node at 22.23.4, 24.22.1 and 26.12.1 These are the first releases on each supported line whose TLS stack rejects chains containing the blocked eMudhra public keys (nodejs/node#66871; changelog commits 4342503fd8, 01acf0ee74, a2caddd20e). Consumers' OTLP/HTTPS exporters validate TLS with their own Node, so the package should not claim support for unpatched releases. Odd lines 23 and 25 are end-of-life and stay excluded. --- README.md | 2 +- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d33e9a3..d66b945 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm install @last9/otel-cron @opentelemetry/sdk-node @opentelemetry/exporter-tra Peer dependency: `@opentelemetry/api ^1.9.0`. -Requires Node.js 22 or later (a supported release line). The OTLP/HTTPS exporters validate TLS with your process's Node.js, so keep it on the latest patch release of its line. +Requires Node.js v22.23.4+, v24.22.1+ or v26.12.1+. The OTLP/HTTPS exporters validate TLS with your process's Node.js, and these are the first releases on each line that reject certificate chains through the compromised eMudhra intermediates ([nodejs/node#66871](https://github.com/nodejs/node/pull/66871)). Keep your runtime on the latest patch release of its line. ## Configuration diff --git a/package-lock.json b/package-lock.json index d3f2f59..7480ff6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "typescript": "^5.8.3" }, "engines": { - "node": ">=22" + "node": "^22.23.4 || ^24.22.1 || >=26.12.1" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" diff --git a/package.json b/package.json index fb1317a..af823cb 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "author": "Last9 Inc.", "license": "MIT", "engines": { - "node": ">=22" + "node": "^22.23.4 || ^24.22.1 || >=26.12.1" }, "publishConfig": { "access": "public", From e1833475ee0706e5ba3a7cadba97f113a196470e Mon Sep 17 00:00:00 2001 From: coder9io <342252087+coder9io@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:09:32 +0000 Subject: [PATCH 5/8] build: add TLS probe for the eMudhra SPKI blocklist Pinning a version number only proves intent; the probe proves the runtime actually rejects chains through the compromised eMudhra intermediates (CERT_REVOKED, as nodejs/node#66871 reports it) while still connecting to a normally trusted host. Unpatched v24.22.0 fails it. --- scripts/tls-probe.mjs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 scripts/tls-probe.mjs diff --git a/scripts/tls-probe.mjs b/scripts/tls-probe.mjs new file mode 100644 index 0000000..adf3650 --- /dev/null +++ b/scripts/tls-probe.mjs @@ -0,0 +1,36 @@ +// Checks that this Node.js rejects certificate chains through the compromised +// eMudhra intermediates (nodejs/node#66871) while still trusting a normal host. +// Uses default TLS verification; exits non-zero if either expectation fails. +import https from 'node:https'; + +const BLOCKED = 'https://dv-g3b-emudhra.we-have-a-problem.com/'; +const TRUSTED = 'https://nodejs.org/'; + +function get(url) { + return new Promise((resolve) => { + https + .get(url, (res) => { + res.resume(); + resolve({ status: res.statusCode }); + }) + .on('error', (err) => resolve({ code: err.code, message: err.message })); + }); +} + +console.log(`process.version ${process.version}`); + +const blocked = await get(BLOCKED); +const trusted = await get(TRUSTED); +console.log(BLOCKED, JSON.stringify(blocked)); +console.log(TRUSTED, JSON.stringify(trusted)); + +let failed = false; +if (blocked.code !== 'CERT_REVOKED') { + console.error(`expected ${BLOCKED} to fail with CERT_REVOKED`); + failed = true; +} +if (trusted.status === undefined) { + console.error(`expected ${TRUSTED} to connect`); + failed = true; +} +process.exit(failed ? 1 : 0); From ea06eccd8f6299095a6a928dc34dd475856e5019 Mon Sep 17 00:00:00 2001 From: coder9io <342252087+coder9io@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:09:32 +0000 Subject: [PATCH 6/8] ci: pin CI and release to the patched Node releases check-latest picked whatever patch nodejs.org listed, which gave no guarantee the eMudhra blocklist was present. Pin the exact first fixed release per line (matching engines.node), release on 24.22.1, and run the TLS probe on each, which also logs process.version. The test now fails if the workflows drift from the engines floors. Kept as the last commit because it edits .github/workflows. --- .github/workflows/ci.yml | 7 +++++-- .github/workflows/release.yml | 5 +++-- test/node-versions.test.ts | 10 ++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8fc984..48f9325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,16 +12,19 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [22, 24, 26] + # First release per line with the eMudhra blocklist (nodejs/node#66871); + # keep in step with engines.node (test/node-versions.test.ts). + node: ['22.23.4', '24.22.1', '26.12.1'] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - check-latest: true cache: npm + - name: TLS probe (eMudhra chain rejected, trusted host accepted) + run: node scripts/tls-probe.mjs - run: npm ci - run: npm run lint - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bf201a..5bf31a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,11 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 24 - check-latest: true + node-version: '24.22.1' cache: npm + - name: TLS probe (eMudhra chain rejected, trusted host accepted) + run: node scripts/tls-probe.mjs - run: npm ci - run: npm test - run: npm run build diff --git a/test/node-versions.test.ts b/test/node-versions.test.ts index b1cfca6..cc8a265 100644 --- a/test/node-versions.test.ts +++ b/test/node-versions.test.ts @@ -26,4 +26,14 @@ describe('supported Node.js versions', () => { expect(readme).toContain(`v${version}`); } }); + + it('tests every patched floor in CI', () => { + const matrix = read('.github/workflows/ci.yml').match(/^\s*node: \[(.*)\]$/m); + expect(matrix?.[1].split(/,\s*/)).toEqual(PATCHED_NODE.map((v) => `'${v}'`)); + }); + + it('releases on a Node version CI tests', () => { + const versions = read('.github/workflows/release.yml').match(/node-version: (.*)$/gm); + expect(versions).toEqual([`node-version: '${PATCHED_NODE[1]}'`]); + }); }); From 347e617677b2bcab02e63da5b83694cd050c94ed Mon Sep 17 00:00:00 2001 From: coder9io <342252087+coder9io@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:32 +0000 Subject: [PATCH 7/8] test: reproduce TLS probe hanging on a stalled endpoint A host that accepts the connection and never answers leaves the probe's promise unsettled, so the CI and release steps that gate on it wait until GitHub cancels the job instead of failing. --- test/tls-probe.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 test/tls-probe.test.ts diff --git a/test/tls-probe.test.ts b/test/tls-probe.test.ts new file mode 100644 index 0000000..30b5972 --- /dev/null +++ b/test/tls-probe.test.ts @@ -0,0 +1,66 @@ +import { spawnSync } from 'child_process'; +import { join } from 'path'; + +type Outcome = number | string | 'stall'; + +const PROBE = join(__dirname, '..', 'scripts', 'tls-probe.mjs'); + +// Runs the real probe with https.get replaced: each request in turn answers +// with a status code, fails with an error code, or ('stall') never settles +// while holding the event loop open like a live socket. Timers are capped at +// 20 ms so the probe's own deadline fires well inside the 2 s watchdog. +function runProbe(blocked: Outcome, trusted: Outcome) { + const preload = ` + import https from 'node:https'; + import { EventEmitter } from 'node:events'; + const realSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = (fn, ms, ...args) => realSetTimeout(fn, Math.min(ms, 20), ...args); + const outcomes = ${JSON.stringify([blocked, trusted])}; + https.get = (_url, options, callback) => { + if (typeof options === 'function') callback = options; + const outcome = outcomes.shift(); + const req = new EventEmitter(); + const liveSocket = setInterval(() => {}, 1000); + req.destroy = (error) => { + clearInterval(liveSocket); + queueMicrotask(() => req.emit('error', error ?? new Error('aborted'))); + return req; + }; + queueMicrotask(() => { + if (outcome === 'stall') return; + clearInterval(liveSocket); + if (typeof outcome === 'number') callback({ statusCode: outcome, resume() {} }); + else req.emit('error', Object.assign(new Error(outcome), { code: outcome })); + }); + return req; + }; + `; + return spawnSync( + process.execPath, + ['--import', 'data:text/javascript,' + encodeURIComponent(preload), PROBE], + { encoding: 'utf8', timeout: 2000, killSignal: 'SIGKILL' }, + ); +} + +describe('scripts/tls-probe.mjs', () => { + it.each<[string, Outcome, Outcome, number]>([ + ['passes when the eMudhra host is revoked and the trusted host answers', 'CERT_REVOKED', 307, 0], + ['fails on a Node that still trusts the eMudhra chain', 200, 200, 1], + ['fails when the eMudhra host fails for another reason', 'ENOTFOUND', 200, 1], + ['fails when the trusted host does not connect', 'CERT_REVOKED', 'CERT_HAS_EXPIRED', 1], + ])('%s', (_name, blocked, trusted, status) => { + const result = runProbe(blocked, trusted); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(status); + }); + + it.each<[string, Outcome, Outcome]>([ + ['eMudhra host', 'stall', 200], + ['trusted host', 'CERT_REVOKED', 'stall'], + ])('fails within its deadline when the %s stalls', (_name, blocked, trusted) => { + const result = runProbe(blocked, trusted); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stdout).toContain('"code":"ETIMEDOUT"'); + }); +}); From 48078ea2814b5080e2e578fb6cbb43e8dac39c2b Mon Sep 17 00:00:00 2001 From: coder9io <342252087+coder9io@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:55:05 +0000 Subject: [PATCH 8/8] build: bound each TLS probe request with a 15 s deadline The probe gates CI and releases on two external hosts. Without a deadline, a host that accepts the connection and never responds hangs the job; now the request is destroyed with ETIMEDOUT and the probe exits non-zero. --- scripts/tls-probe.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/tls-probe.mjs b/scripts/tls-probe.mjs index adf3650..9f006c3 100644 --- a/scripts/tls-probe.mjs +++ b/scripts/tls-probe.mjs @@ -5,15 +5,27 @@ import https from 'node:https'; const BLOCKED = 'https://dv-g3b-emudhra.we-have-a-problem.com/'; const TRUSTED = 'https://nodejs.org/'; +// Overall per-request deadline, so a host that accepts the connection and then +// goes silent fails the probe instead of holding the CI/release job open. +const DEADLINE_MS = 15_000; function get(url) { return new Promise((resolve) => { - https + const req = https .get(url, (res) => { + clearTimeout(deadline); res.resume(); resolve({ status: res.statusCode }); }) - .on('error', (err) => resolve({ code: err.code, message: err.message })); + .on('error', (err) => { + clearTimeout(deadline); + resolve({ code: err.code, message: err.message }); + }); + const deadline = setTimeout(() => { + req.destroy( + Object.assign(new Error(`no response within ${DEADLINE_MS} ms`), { code: 'ETIMEDOUT' }), + ); + }, DEADLINE_MS); }); }