From a2073822945910adcd96d81b09386fd0d9b0f771 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Tue, 6 Oct 2026 21:02:06 +1300 Subject: [PATCH 1/4] add dependabot setup --- .github/dependabot.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3e8ebe8f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +# Dependabot for FHIRsmith. +# +# Email notification of vulnerable dependencies does NOT come from this file - it comes +# from Dependabot alerts, which are switched on in the repository settings +# (Settings > Code security > Dependabot alerts, and Dependabot security updates), and +# delivered according to each person's GitHub notification settings +# (Settings > Notifications > Dependabot alerts > Email). +# +# What this file does: when an alert is raised, Dependabot opens one pull request that +# moves the affected packages to fixed versions. Routine version-bump PRs are turned off +# (open-pull-requests-limit: 0) - only security fixes produce PRs. + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 0 + groups: + npm-security: + applies-to: security-updates + patterns: + - "*" + commit-message: + prefix: "deps" From 7b4ce86ae2ebbe6c8f08306a1facda73363300a4 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Tue, 6 Oct 2026 21:02:47 +1300 Subject: [PATCH 2/4] NPM Audit test --- config-template.json | 9 ++ library/npm-audit.js | 265 +++++++++++++++++++++++++++++++++ tests/server/npm-audit.test.js | 158 ++++++++++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 library/npm-audit.js create mode 100644 tests/server/npm-audit.test.js diff --git a/config-template.json b/config-template.json index 8b404282..24cdd89c 100644 --- a/config-template.json +++ b/config-template.json @@ -18,6 +18,15 @@ // how often the counters are written out "intervalMinutes": 10 }, + // Once a day the server checks its installed npm packages against the npm advisory + // database (what `npm audit` does - report only, nothing is changed). Problems show on + // the home page and the dashboard, and in the log. Needs outbound access to + // registry.npmjs.org. The whole block is optional. + "npmAudit": { + // set false to turn the check off (e.g. a server with no internet access) + "enabled": true, + "intervalHours": 24 + }, "modules": { "shl": { "enabled": false, diff --git a/library/npm-audit.js b/library/npm-audit.js new file mode 100644 index 00000000..8b5826a2 --- /dev/null +++ b/library/npm-audit.js @@ -0,0 +1,265 @@ +// library/npm-audit.js +// Periodic self-check of this server's installed npm packages against the npm advisory +// database - the same lookup `npm audit` does, made directly so it needs neither the npm +// binary nor a particular working directory. Reports only; it never changes anything. +// +// The package list comes from node_modules/.package-lock.json (what is actually +// installed), falling back to package-lock.json. Dev dependencies are left out, and +// fhirsmith itself is included, so an advisory published against fhirsmith shows up too. +// +// Config (all optional), top level of config.json: +// "npmAudit": { "enabled": true, "intervalHours": 24 } + +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); +const escape = require('escape-html'); + +const DEFAULT_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'; +const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info']; +const HOUR_MS = 60 * 60 * 1000; +const FIRST_RUN_DELAY_MS = 60 * 1000; // let startup finish first + +function describeAgo(time, now = Date.now()) { + const mins = Math.max(0, Math.floor((now - time) / 60000)); + if (mins < 1) { + return 'just now'; + } + if (mins < 60) { + return `${mins} minute${mins === 1 ? '' : 's'} ago`; + } + const hours = Math.floor(mins / 60); + if (hours < 48) { + return `${hours} hour${hours === 1 ? '' : 's'} ago`; + } + return `${Math.floor(hours / 24)} days ago`; +} + +class NpmAudit { + constructor(config = {}, logger = null, appDir = path.join(__dirname, '..'), stats = null) { + this.enabled = config.enabled !== false; + this.intervalHours = config.intervalHours > 0 ? config.intervalHours : 24; + this.url = config.url || DEFAULT_URL; + this.timeout = config.timeout || 60000; + this.logger = logger; + this.appDir = appDir; + this.stats = stats; + this.timers = []; + this.running = false; + // the outcome of the last completed check + this.checkedAt = null; // Date of the last successful check + this.packageCount = 0; + this.findings = []; // [{name, installed: [versions], id, severity, title, url, range}] + this.lastError = null; // message from the last attempt, if it failed + this.lastAttempt = null; + } + + /** + * name -> [installed versions], production packages only, plus this package itself + */ + collectPackages() { + let lock = null; + for (const file of [path.join(this.appDir, 'node_modules', '.package-lock.json'), path.join(this.appDir, 'package-lock.json')]) { + try { + lock = JSON.parse(fs.readFileSync(file, 'utf8')); + break; + } catch (e) { + // try the next one + } + } + if (!lock || !lock.packages) { + throw new Error('No package-lock.json found to audit'); + } + const found = new Map(); + const add = (name, version) => { + if (!found.has(name)) { + found.set(name, new Set()); + } + found.get(name).add(version); + }; + for (const [key, info] of Object.entries(lock.packages)) { + if (!key || !info || info.dev || info.link || !info.version) { + continue; + } + const marker = 'node_modules/'; + const name = info.name || key.substring(key.lastIndexOf(marker) + marker.length); + add(name, info.version); + } + try { + const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8')); + if (pkg.name && pkg.version) { + add(pkg.name, pkg.version); + } + } catch (e) { + // no package.json - nothing to add + } + const result = {}; + for (const [name, versions] of found) { + result[name] = [...versions].sort(); + } + return result; + } + + async run() { + if (this.running) { + return; + } + this.running = true; + this.lastAttempt = new Date(); + if (this.stats) { + this.stats.task('npm audit', 'Checking'); + } + try { + const packages = this.collectPackages(); + const response = await axios.post(this.url, packages, { + timeout: this.timeout, + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } + }); + const data = response.data && typeof response.data === 'object' ? response.data : {}; + const findings = []; + for (const [name, advisories] of Object.entries(data)) { + for (const a of Array.isArray(advisories) ? advisories : []) { + findings.push({ + name, + installed: packages[name] || [], + id: a.id, + severity: SEVERITIES.includes(a.severity) ? a.severity : 'info', + title: a.title || '', + url: a.url || '', + range: a.vulnerable_versions || '' + }); + } + } + findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || a.name.localeCompare(b.name)); + this.findings = findings; + this.packageCount = Object.keys(packages).length; + this.checkedAt = new Date(); + this.lastError = null; + const summary = this.summary(); + if (findings.length > 0) { + if (this.logger) { + this.logger.warn(`npm audit: ${summary} in ${this.packageCount} packages: ` + + findings.map(f => `${f.name} (${f.severity}) ${f.url}`).join('; ')); + } + if (this.stats) { + this.stats.taskError('npm audit', summary); + } + } else { + if (this.logger) { + this.logger.info(`npm audit: no known vulnerabilities in ${this.packageCount} packages`); + } + if (this.stats) { + this.stats.taskDone('npm audit', `No known vulnerabilities (${this.packageCount} packages)`); + } + } + } catch (error) { + this.lastError = error.message; + if (this.logger) { + this.logger.warn('npm audit could not be run: ' + error.message); + } + if (this.stats) { + this.stats.taskError('npm audit', 'Could not check: ' + error.message); + } + } finally { + this.running = false; + } + } + + start() { + if (!this.enabled) { + return; + } + if (this.stats) { + this.stats.addTask('npm audit', `${this.intervalHours} hr`); + } + // run() handles its own errors, so the promise never rejects + const first = setTimeout(() => void this.run(), FIRST_RUN_DELAY_MS); + const repeat = setInterval(() => void this.run(), this.intervalHours * HOUR_MS); + for (const t of [first, repeat]) { + if (t.unref) { + t.unref(); + } + this.timers.push(t); + } + } + + stop() { + for (const t of this.timers) { + clearTimeout(t); + clearInterval(t); + } + this.timers = []; + } + + counts() { + const counts = {}; + for (const f of this.findings) { + counts[f.severity] = (counts[f.severity] || 0) + 1; + } + return counts; + } + + // "3 known vulnerabilities (1 critical, 2 high)" + summary() { + const n = this.findings.length; + if (n === 0) { + return 'no known vulnerabilities'; + } + const counts = this.counts(); + const parts = SEVERITIES.filter(s => counts[s]).map(s => `${counts[s]} ${s}`); + return `${n} known vulnerabilit${n === 1 ? 'y' : 'ies'} (${parts.join(', ')})`; + } + + isSerious() { + return this.findings.some(f => f.severity === 'critical' || f.severity === 'high'); + } + + /** + * Home page: nothing unless there is something to report + */ + renderBanner(now = Date.now()) { + if (!this.enabled || !this.checkedAt || this.findings.length === 0) { + return ''; + } + const cls = this.isSerious() ? 'alert-danger' : 'alert-warning'; + return `'; + } + + /** + * Dashboard: always shown, so a check that is failing or switched off is visible too + */ + renderDashboard(now = Date.now()) { + let html = '
'; + if (!this.enabled) { + return html + '

npm audit: disabled (npmAudit.enabled = false)

'; + } + if (!this.checkedAt) { + html += '

npm audit: '; + html += this.lastError ? `could not be run: ${escape(this.lastError)}` : 'not run yet'; + return html + '

'; + } + const colour = this.findings.length === 0 ? '#070' : (this.isSerious() ? '#b00' : '#b60'); + html += `

npm audit: ${escape(this.summary())}` + + ` in ${this.packageCount} packages, checked ${escape(describeAgo(this.checkedAt.getTime(), now))}`; + if (this.lastError) { + html += ` (the latest check failed: ${escape(this.lastError)})`; + } + html += '

'; + if (this.findings.length > 0) { + html += ''; + for (const f of this.findings) { + const link = /^https:\/\//.test(f.url) ? `${escape(f.title || f.url)}` : escape(f.title); + html += `` + + ``; + } + html += '
SeverityPackageInstalledVulnerableAdvisory
${escape(f.severity)}${escape(f.name)}${escape(f.installed.join(', '))}${escape(f.range)}${link}
'; + } + return html + ''; + } +} + +module.exports = { NpmAudit, describeAgo }; diff --git a/tests/server/npm-audit.test.js b/tests/server/npm-audit.test.js new file mode 100644 index 00000000..ff2f6670 --- /dev/null +++ b/tests/server/npm-audit.test.js @@ -0,0 +1,158 @@ +// The npm advisory self-check (library/npm-audit.js) + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const axios = require('axios'); +const { NpmAudit, describeAgo } = require('../../library/npm-audit'); + + +const NOW = new Date('2026-10-06T12:00:00Z').getTime(); + +function appDir(lock, hiddenLock = true) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-audit-')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'fhirsmith', version: '0.13.4' })); + if (hiddenLock) { + fs.mkdirSync(path.join(dir, 'node_modules')); + fs.writeFileSync(path.join(dir, 'node_modules', '.package-lock.json'), JSON.stringify(lock)); + } else { + fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify(lock)); + } + return dir; +} + +const LOCK = { + packages: { + '': { name: 'fhirsmith', version: '0.13.4' }, + 'node_modules/axios': { version: '1.15.2' }, + 'node_modules/tar': { version: '7.5.1' }, + 'node_modules/foo/node_modules/tar': { version: '6.2.0' }, + 'node_modules/jest': { version: '30.0.0', dev: true }, + 'node_modules/local': { resolved: '../local', link: true }, + 'node_modules/@scope/pkg': { version: '2.0.0' } + } +}; + +function stats() { + return { addTask: jest.fn(), task: jest.fn(), taskDone: jest.fn(), taskError: jest.fn() }; +} + +beforeEach(() => jest.spyOn(axios, 'post')); +afterEach(() => jest.restoreAllMocks()); + +describe('collectPackages', () => { + test('production packages from the installed lock file, plus fhirsmith itself', () => { + const audit = new NpmAudit({}, null, appDir(LOCK)); + expect(audit.collectPackages()).toEqual({ + axios: ['1.15.2'], + tar: ['6.2.0', '7.5.1'], + '@scope/pkg': ['2.0.0'], + fhirsmith: ['0.13.4'] + }); + }); + + test('falls back to package-lock.json', () => { + const audit = new NpmAudit({}, null, appDir(LOCK, false)); + expect(Object.keys(audit.collectPackages())).toContain('axios'); + }); + + test('no lock file at all is an error', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-audit-')); + expect(() => new NpmAudit({}, null, dir).collectPackages()).toThrow('No package-lock.json'); + }); +}); + +describe('run', () => { + test('a clean result', async () => { + axios.post.mockResolvedValue({ data: {} }); + const s = stats(); + const audit = new NpmAudit({}, null, appDir(LOCK), s); + await audit.run(); + expect(axios.post.mock.calls[0][0]).toBe('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'); + expect(axios.post.mock.calls[0][1].jest).toBeUndefined(); + expect(audit.findings).toEqual([]); + expect(audit.summary()).toBe('no known vulnerabilities'); + expect(s.taskDone).toHaveBeenCalled(); + expect(audit.renderBanner()).toBe(''); + expect(audit.renderDashboard()).toContain('no known vulnerabilities in 4 packages'); + }); + + test('advisories are reported, worst first', async () => { + axios.post.mockResolvedValue({ data: { + axios: [{ id: 1, severity: 'moderate', title: 'Prototype ', url: 'https://github.com/advisories/GHSA-1', vulnerable_versions: '<1.18.0' }], + tar: [{ id: 2, severity: 'critical', title: 'Parse DoS', url: 'https://github.com/advisories/GHSA-2', vulnerable_versions: '<=7.5.18' }] + } }); + const s = stats(); + const audit = new NpmAudit({}, null, appDir(LOCK), s); + await audit.run(); + expect(audit.findings.map(f => f.name)).toEqual(['tar', 'axios']); + expect(audit.findings[0].installed).toEqual(['6.2.0', '7.5.1']); + expect(audit.summary()).toBe('2 known vulnerabilities (1 critical, 1 moderate)'); + expect(s.taskError).toHaveBeenCalledWith('npm audit', audit.summary()); + + const banner = audit.renderBanner(NOW); + expect(banner).toContain('alert-danger'); + expect(banner).toContain('2 known vulnerabilities (1 critical, 1 moderate)'); + expect(banner).toContain('/dashboard#npm-audit'); + + const dash = audit.renderDashboard(NOW); + expect(dash).toContain('Prototype <pollution>'); + expect(dash).toContain('Parse DoS'); + expect(dash).toContain('6.2.0, 7.5.1'); + }); + + test('only low and moderate findings make a warning, not a danger, banner', async () => { + axios.post.mockResolvedValue({ data: { axios: [{ severity: 'low', title: 't', url: 'javascript:alert(1)' }] } }); + const audit = new NpmAudit({}, null, appDir(LOCK)); + await audit.run(); + expect(audit.renderBanner()).toContain('alert-warning'); + expect(audit.renderDashboard()).not.toContain('javascript:'); + }); + + test('a failed check keeps the previous result and says so', async () => { + axios.post.mockResolvedValueOnce({ data: {} }).mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND')); + const s = stats(); + const audit = new NpmAudit({}, null, appDir(LOCK), s); + await audit.run(); + await audit.run(); + expect(audit.checkedAt).not.toBeNull(); + expect(s.taskError).toHaveBeenCalledWith('npm audit', 'Could not check: getaddrinfo ENOTFOUND'); + expect(audit.renderDashboard()).toContain('the latest check failed: getaddrinfo ENOTFOUND'); + }); + + test('a check that has never worked says so on the dashboard and not on the home page', async () => { + axios.post.mockRejectedValue(new Error('timeout')); + const audit = new NpmAudit({}, null, appDir(LOCK)); + await audit.run(); + expect(audit.renderBanner()).toBe(''); + expect(audit.renderDashboard()).toContain('could not be run: timeout'); + }); +}); + +describe('scheduling and config', () => { + test('disabled: no task, no timers, and the dashboard says so', () => { + const s = stats(); + const audit = new NpmAudit({ enabled: false }, null, appDir(LOCK), s); + audit.start(); + expect(s.addTask).not.toHaveBeenCalled(); + expect(audit.timers).toEqual([]); + expect(audit.renderDashboard()).toContain('disabled'); + }); + + test('start registers a background task and stop clears the timers', () => { + const s = stats(); + const audit = new NpmAudit({ intervalHours: 6 }, null, appDir(LOCK), s); + audit.start(); + expect(s.addTask).toHaveBeenCalledWith('npm audit', '6 hr'); + expect(audit.timers.length).toBe(2); + audit.stop(); + expect(audit.timers).toEqual([]); + }); + + test('describeAgo', () => { + expect(describeAgo(NOW, NOW)).toBe('just now'); + expect(describeAgo(NOW - 5 * 60000, NOW)).toBe('5 minutes ago'); + expect(describeAgo(NOW - 3 * 3600000, NOW)).toBe('3 hours ago'); + expect(describeAgo(NOW - 72 * 3600000, NOW)).toBe('3 days ago'); + }); +}); From fe86eb194404a1ed10c8ac3eddff20b87c100384 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Tue, 6 Oct 2026 21:03:25 +1300 Subject: [PATCH 3/4] better version and state tracking --- library/html-server.js | 2 + library/utilities.js | 21 +++ packages/packages-template.html | 2 +- packages/packages.js | 5 +- registry/api.js | 5 + registry/crawler.js | 24 ++++ registry/fhirsmith-releases.js | 170 +++++++++++++++++++++++ registry/model.js | 3 + registry/registry-template.html | 3 +- registry/registry.js | 120 ++++++++++++++++ server.js | 14 +- tests/registry/openapi.test.js | 1 + tests/registry/registry-footer.test.js | 39 ++++++ tests/registry/registry-software.test.js | 153 ++++++++++++++++++++ 14 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 registry/fhirsmith-releases.js create mode 100644 tests/registry/registry-footer.test.js create mode 100644 tests/registry/registry-software.test.js diff --git a/library/html-server.js b/library/html-server.js index 358ab2d1..3dba94a3 100644 --- a/library/html-server.js +++ b/library/html-server.js @@ -75,6 +75,8 @@ class HtmlServer { // [%ver%] is the FHIRsmith version in every template (it follows the FHIRsmith link) .replace(/\[%ver%\]/g, escape(packageJson.version)) .replace(/\[%download-date%\]/g, escape(renderOptions.downloadDate)) + // "last updated 35 minutes ago" / "not yet updated", for the crawler-driven modules + .replace(/\[%crawler-status%\]/g, escape(renderOptions.crawlerStatus || 'not yet updated')) .replace(/\[%total-resources%\]/g, escape(renderOptions.totalResources.toLocaleString())) .replace(/\[%total-packages%\]/g, escape(renderOptions.totalPackages.toLocaleString())) .replace(/\[%endpoint-path%\]/g, escape(renderOptions.endpointpath)) diff --git a/library/utilities.js b/library/utilities.js index 2cac6aa8..b39ea451 100644 --- a/library/utilities.js +++ b/library/utilities.js @@ -60,6 +60,27 @@ const Utilities = { } return parts.join(' '); + }, + + /** + * How long ago a time was, for people: "35 minutes ago", "3 hours ago", "2 days ago" + * @param {Date|string|number} time - the earlier time + * @param {number} now - Date.now(), for testing + * @returns {string} + */ + describeAgo(time, now = Date.now()) { + const secs = Math.max(0, Math.floor((now - new Date(time).getTime()) / 1000)); + const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'} ago`; + if (secs < 60) { + return 'just now'; + } + if (secs < 3600) { + return plural(Math.floor(secs / 60), 'minute'); + } + if (secs < 172800) { + return plural(Math.floor(secs / 3600), 'hour'); + } + return plural(Math.floor(secs / 86400), 'day'); } }; diff --git a/packages/packages-template.html b/packages/packages-template.html index 132577bb..bf4db15c 100644 --- a/packages/packages-template.html +++ b/packages/packages-template.html @@ -94,7 +94,7 @@

FHIR © HL7.org 2011+.  |  FHIRsmith [%ver%] © HealthIntersections.com.au 2023+  |  - Package Registry last updated as of [%crawler-date%]  |  [%total-packages%] packages  | + Package Registry [%crawler-status%]  |  [%total-packages%] packages  |   ([%ms%] ms) [%sponsorMessage%]

diff --git a/packages/packages.js b/packages/packages.js index 322a4609..002def94 100644 --- a/packages/packages.js +++ b/packages/packages.js @@ -14,7 +14,7 @@ const htmlServer = require('../library/html-server'); const folders = require('../library/folder-setup'); const escape = require('escape-html'); const Logger = require('../library/logger'); -const {validateParameter} = require("../library/utilities"); +const {validateParameter, Utilities} = require("../library/utilities"); const {describeCron} = require("../library/cron-utilities"); const {tokenMatches, tokenConfigured} = require("../library/request-token"); const pckLog = Logger.getInstance().child({ module: 'packages' }); @@ -481,8 +481,11 @@ class PackagesModule { // Get counts from database const tableCounts = await this.getDatabaseTableCounts(); + // the page footer: the last crawl this run, else when the database was last written + const updated = this.lastRunTime || dbAge.lastModified; return { downloadDate: downloadDate, + crawlerStatus: updated ? `last updated ${Utilities.describeAgo(updated)}` : 'not yet updated', totalResources: 0, // Packages don't track individual resources totalPackages: tableCounts.packages || 0, totalVersions: tableCounts.packageVersions || 0, diff --git a/registry/api.js b/registry/api.js index c937486c..4ba83fa7 100644 --- a/registry/api.js +++ b/registry/api.js @@ -2,6 +2,7 @@ const { ServerRegistryUtilities } = require('./model'); const escape = require('escape-html'); +const { Utilities } = require('../library/utilities'); const RELEASE_VERSIONS = { R2: '1.0', @@ -286,6 +287,10 @@ class RegistryAPI { return { lastRun: data.lastRun, + // the page footer: nothing to report until the first crawl has finished + crawlerStatus: data.lastRun ? + `last updated ${Utilities.describeAgo(data.lastRun)}, ${totalServers} server${totalServers === 1 ? '' : 's'}` : + 'not yet updated', outcome: data.outcome, registryCount: data.registries.length, serverCount: totalServers, diff --git a/registry/crawler.js b/registry/crawler.js index 846ee3e5..c2ec2cd1 100644 --- a/registry/crawler.js +++ b/registry/crawler.js @@ -333,11 +333,33 @@ class RegistryCrawler { this.addLogEntry('error', `Server ${version.address}: Error after ${elapsed}ms: ${error.message}`); version.error = error.message; version.lastTat = `${elapsed}ms`; + this.carryForwardSoftware(version); } return version; } + /** + * A server that can't be reached this time is still running whatever it was running + * last time we saw it - keep that, so the software page doesn't lose track of it + */ + carryForwardSoftware(version) { + if (version.software && version.software !== 'unknown') { + return; + } + for (const registry of (this.currentData && this.currentData.registries) || []) { + for (const server of registry.servers || []) { + for (const prev of server.versions || []) { + if (prev.address === version.address && prev.software) { + version.software = prev.software; + version.softwareVersion = prev.softwareVersion || ''; + return; + } + } + } + } + } + /** * Process an R3 server */ @@ -348,6 +370,7 @@ class RegistryCrawler { version.version = capability.fhirVersion || '3.0.2'; version.software = capability.software ? capability.software.name : "unknown"; + version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : ''; // Get terminology capabilities (R3 uses Parameters resource) try { @@ -392,6 +415,7 @@ class RegistryCrawler { version.version = capability.fhirVersion || defVersion; version.software = capability.software ? capability.software.name : "unknown"; + version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : ''; let set = new Set(); diff --git a/registry/fhirsmith-releases.js b/registry/fhirsmith-releases.js new file mode 100644 index 00000000..bbd470ab --- /dev/null +++ b/registry/fhirsmith-releases.js @@ -0,0 +1,170 @@ +// registry/fhirsmith-releases.js +// The list of FHIRsmith releases and their dates, so the registry can say how old the +// FHIRsmith version a registered server reports is. +// +// The list comes from the GitHub releases API, refreshed with each crawl. Until that +// succeeds (or if it can't be reached), the dated headings in this server's own +// CHANGELOG.md are used instead - those only go up to this server's own version. + +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); + +const DEFAULT_RELEASES_URL = 'https://api.github.com/repos/HealthIntersections/FHIRsmith/releases'; +const DAY_MS = 24 * 60 * 60 * 1000; + +// "v0.14.1" -> [0, 14, 1]; anything that isn't n.n.n (optionally with a -suffix) -> null +function parseVersion(v) { + const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim()); + if (!m) { + return null; + } + return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], suffix: m[4] || '' }; +} + +function compareParts(a, b) { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { + return a[i] - b[i]; + } + } + return 0; +} + +function isFhirsmith(softwareName) { + return /fhirsmith/i.test(softwareName || ''); +} + +class FhirsmithReleases { + constructor(config = {}, logger = null) { + this.url = config.releasesUrl || DEFAULT_RELEASES_URL; + this.timeout = config.timeout || 30000; + this.userAgent = config.userAgent || 'FHIRRegistryServer/1.0'; + this.logger = logger; + this.releases = []; // [{version: '0.14.1', parts: [0,14,1], date: Date}], newest first + this.source = 'none'; + this.lastRefresh = null; + } + + setReleases(list, source) { + const seen = new Set(); + const releases = []; + for (const r of list) { + const pv = parseVersion(r.version); + const date = r.date instanceof Date ? r.date : new Date(r.date); + // a release is n.n.n - pre-release suffixes are not tracked + if (!pv || pv.suffix || isNaN(date.getTime())) { + continue; + } + const key = pv.parts.join('.'); + if (!seen.has(key)) { + seen.add(key); + releases.push({ version: key, parts: pv.parts, date }); + } + } + releases.sort((a, b) => compareParts(b.parts, a.parts)); + this.releases = releases; + this.source = source; + } + + /** + * Read the dated release headings from a CHANGELOG.md - "## [v0.13.4] - 2026-09-17" + */ + loadFromChangelog(changelogPath = path.join(__dirname, '..', 'CHANGELOG.md')) { + try { + const text = fs.readFileSync(changelogPath, 'utf8'); + const list = []; + const re = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm; + let m; + while ((m = re.exec(text)) !== null) { + list.push({ version: m[1], date: new Date(m[2] + 'T00:00:00Z') }); + } + this.setReleases(list, 'CHANGELOG.md'); + } catch (error) { + if (this.logger) { + this.logger.warn('Could not read FHIRsmith release dates from CHANGELOG.md: ' + error.message); + } + } + } + + /** + * Fetch the release list from GitHub. On failure the current list is kept. + */ + async refresh() { + try { + const list = []; + for (let page = 1; page <= 10; page++) { + const response = await axios.get(`${this.url}?per_page=100&page=${page}`, { + timeout: this.timeout, + headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': this.userAgent } + }); + const batch = Array.isArray(response.data) ? response.data : []; + for (const r of batch) { + if (!r.draft && !r.prerelease && r.tag_name && r.published_at) { + list.push({ version: r.tag_name, date: r.published_at }); + } + } + if (batch.length < 100) { + break; + } + } + if (list.length > 0) { + this.setReleases(list, 'GitHub'); + this.lastRefresh = new Date(); + } + } catch (error) { + if (this.logger) { + this.logger.warn('Could not fetch FHIRsmith releases from GitHub: ' + error.message); + } + } + } + + latest() { + return this.releases.length > 0 ? this.releases[0] : null; + } + + /** + * How a reported FHIRsmith version stands against the releases: + * status: 'current' | 'outdated' | 'dev' (a build between/after releases) | 'unknown' + * release: the release it is (or, for 'dev', the most recent release before it) + * ageDays: days since that release came out ('current'/'outdated' only) + * behind: how many releases have come out since + */ + describe(reportedVersion, now = Date.now()) { + const pv = parseVersion(reportedVersion); + if (!pv || this.releases.length === 0) { + return { status: 'unknown' }; + } + const behind = this.releases.filter(r => compareParts(r.parts, pv.parts) > 0).length; + const exact = !pv.suffix ? this.releases.find(r => compareParts(r.parts, pv.parts) === 0) : null; + if (exact) { + return { + status: behind === 0 ? 'current' : 'outdated', + release: exact, + ageDays: Math.max(0, Math.floor((now - exact.date.getTime()) / DAY_MS)), + behind + }; + } + // a snapshot (0.14.2-snapshot), or a version we have no release for + const base = this.releases.find(r => compareParts(r.parts, pv.parts) < 0) || null; + return { status: 'dev', release: base, behind }; + } +} + +function describeAge(days) { + if (days < 1) { + return 'today'; + } + if (days < 14) { + return `${days} day${days === 1 ? '' : 's'}`; + } + if (days < 60) { + return `${Math.floor(days / 7)} weeks`; + } + if (days < 730) { + return `${Math.floor(days / 30.44)} months`; + } + return `${(days / 365.25).toFixed(1)} years`; +} + +module.exports = { FhirsmithReleases, isFhirsmith, parseVersion, describeAge }; diff --git a/registry/model.js b/registry/model.js index d26441fc..d06d9531 100644 --- a/registry/model.js +++ b/registry/model.js @@ -11,6 +11,7 @@ class ServerVersionInformation { this.lastSuccess = null; // Date object this.lastTat = ''; this.software = ''; // what software is running + this.softwareVersion = ''; // CapabilityStatement.software.version, if the server reports one this.codeSystems = []; // Array of strings (sorted, unique) this.valueSets = []; // Array of strings (sorted, unique) } @@ -58,6 +59,7 @@ class ServerVersionInformation { lastTat: this.lastTat, terminologies: this.codeSystems, software: this.software, + 'software-version': this.softwareVersion, valuesets: this.valueSets }; } @@ -71,6 +73,7 @@ class ServerVersionInformation { instance.lastSuccess = json['last-success'] ? new Date(json['last-success']) : null; instance.lastTat = json.lastTat || ''; instance.software = json.software; + instance.softwareVersion = json['software-version'] || ''; instance.codeSystems = json.terminologies || []; instance.valueSets = json.valuesets || []; return instance; diff --git a/registry/registry-template.html b/registry/registry-template.html index 8e0e7c72..3bffc017 100644 --- a/registry/registry-template.html +++ b/registry/registry-template.html @@ -60,6 +60,7 @@ Server Home  |  Registry Home  |  Resolve  |  + Software  |  Crawler Log  |  API @@ -94,7 +95,7 @@

FHIR © HL7.org 2011+.  |  FHIRsmith [%ver%] © HealthIntersections.com.au 2023+  |  - Terminology Registry last updated as of [%crawler-date%]  |  [%total-packages%] packages  | + Terminology Registry [%crawler-status%]  |   ([%ms%] ms) [%sponsorMessage%]

diff --git a/registry/registry.js b/registry/registry.js index 3c2bf64c..04a7982e 100644 --- a/registry/registry.js +++ b/registry/registry.js @@ -10,6 +10,7 @@ const regLog = Logger.getInstance().child({ module: 'registry' }); const folders = require('../library/folder-setup'); const escape = require('escape-html'); const registryOpenApi = require('./openapi'); +const { FhirsmithReleases, isFhirsmith, describeAge } = require('./fhirsmith-releases'); // The query parameters of the public API. These are the contract published in openapi.yaml, // and tests/registry/openapi.test.js checks that the two agree - so change both together. @@ -58,6 +59,13 @@ class RegistryModule { // Initialize API with crawler this.api = new RegistryAPI(this.crawler); + // FHIRsmith release dates, for the software page. CHANGELOG.md until GitHub answers + this.releases = new FhirsmithReleases(crawlerConfig, this.logger); + this.releases.loadFromChangelog(); + if (config.releasesUrl !== '') { + this.releases.refresh().catch(() => {}); + } + // Load saved data if available await this.loadSavedData(); @@ -153,6 +161,9 @@ class RegistryModule { try { // Perform the crawl const newData = await this.crawler.crawl(this.config.masterUrl); + if (this.releases && this.config.releasesUrl !== '') { + await this.releases.refresh(); + } // Thread-safe update of current data await this.updateData(() => { @@ -241,6 +252,7 @@ class RegistryModule { this.router.get('/', this.handleMainPage.bind(this)); this.router.get('/resolve', this.handleResolveEndpoint.bind(this)); this.router.get('/log', this.handleLogEndpoint.bind(this)); + this.router.get('/software', this.handleSoftwarePage.bind(this)); // OpenAPI description of this API: /openapi.json, /openapi.yaml, and /openapi (an HTML // reference for browsers, the JSON otherwise) @@ -458,6 +470,114 @@ class RegistryModule { } } + /** + * The software page - what each registered server is running, and for FHIRsmith, + * how old that release is + */ + async handleSoftwarePage(req, res) { + const start = Date.now(); + try { + if (!htmlServer.hasTemplate('registry')) { + htmlServer.loadTemplate('registry', path.join(__dirname, 'registry-template.html')); + } + const startTime = Date.now(); + const content = this.buildSoftwareContent(); + const stats = this.api.getStatistics(); + stats.processingTime = Date.now() - startTime; + const html = htmlServer.renderPage('registry', 'Terminology Server Software', content, stats); + res.setHeader('Content-Type', 'text/html'); + res.send(html); + } catch (error) { + this.logger.error('Error rendering software page:', error); + res.status(500).send(`

Error

${escape(error.message)}

`); + } finally { + this.stats.countRequest('software', Date.now() - start); + } + } + + buildSoftwareContent(now = Date.now()) { + const data = this.api.getData(); + if (!data || !data.registries) { + return '

Registry data not yet available

' + + '

The initial crawl is in progress. Please refresh in a moment.

'; + } + + const rows = []; + for (const registry of data.registries) { + for (const server of registry.servers || []) { + for (const version of server.versions || []) { + rows.push({ + name: server.name || server.code || '', + url: version.address || '', + fhirVersion: version.version || '', + software: version.software && version.software !== 'unknown' ? version.software : '', + softwareVersion: version.softwareVersion || '', + error: version.error, + lastSuccess: version.lastSuccess + }); + } + } + } + rows.sort((a, b) => a.name.localeCompare(b.name) || a.url.localeCompare(b.url)); + + let html = '

Terminology Server Software

'; + const latest = this.releases ? this.releases.latest() : null; + if (latest) { + html += `

The current FHIRsmith release is v${escape(latest.version)}, released ` + + `${escape(latest.date.toISOString().substring(0, 10))}. ` + + 'Servers running older FHIRsmith releases are missing security fixes, both in FHIRsmith ' + + 'and in the libraries it depends on, and should be upgraded.

'; + } + + html += ''; + html += '' + + ''; + for (const row of rows) { + html += ''; + html += ``; + html += `'; + html += ``; + html += ``; + html += ``; + html += this._renderReleaseCells(row, now); + html += ''; + } + html += '
ServerURLFHIRSoftwareVersionReleasedAge
${escape(row.name)}${escape(row.url)}`; + if (row.error) { + html += ` (unreachable)`; + } + html += '${escape(row.fhirVersion)}${row.software ? escape(row.software.replace('Reference Server', 'HealthIntersections')) : 'unknown'}${row.softwareVersion ? escape(row.softwareVersion) : 'unknown'}
'; + + if (this.releases && this.releases.source !== 'none') { + html += `

FHIRsmith release dates from ${escape(this.releases.source)}.

`; + } + return html; + } + + _renderReleaseCells(row, now) { + if (!isFhirsmith(row.software) || !this.releases) { + return ''; + } + const d = this.releases.describe(row.softwareVersion, now); + const behind = d.behind ? ` (${d.behind} release${d.behind === 1 ? '' : 's'} behind)` : ''; + switch (d.status) { + case 'current': + return `${d.release.date.toISOString().substring(0, 10)}` + + `${describeAge(d.ageDays)} - current release`; + case 'outdated': { + // more than 3 months, or more than 3 releases, out of date is flagged + const cls = d.ageDays > 90 || d.behind > 3 ? 'text-danger' : 'text-warning'; + return `${d.release.date.toISOString().substring(0, 10)}` + + `${describeAge(d.ageDays)}${behind}`; + } + case 'dev': + return 'development build' + + (d.release ? ` after v${escape(d.release.version)}` : '') + behind + ''; + default: + return 'unknown'; + } + } + /** * Build HTML content for main page */ diff --git a/server.js b/server.js index eef8bed5..202d32b2 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,7 @@ const { statSync, readdirSync } = require('fs'); const escape = require('escape-html'); const { resolveWithin } = require('./library/path-safety'); const { readCgroupMemoryLimit } = require('./library/cgroup-memory'); +const { NpmAudit } = require('./library/npm-audit'); // Load configuration BEFORE logger let config; @@ -110,10 +111,13 @@ app.use(cors(config.server.cors)); const modules = {}; let stats = null; +// periodic npm advisory check of the installed packages - see library/npm-audit.js +let npmAudit = null; // Initialize modules based on configuration async function initializeModules() { stats = new ServerStats(config.stats, serverLog); + npmAudit = new NpmAudit(config.npmAudit || {}, Logger.getInstance().child({ module: 'npm-audit' }), __dirname, stats); // Initialize SHL module if (config.modules?.shl?.enabled) { @@ -305,7 +309,8 @@ async function loadTemplates() { async function buildRootPageContent() { stats.requestCount++; - let content = '
'; + let content = npmAudit ? npmAudit.renderBanner() : ''; + content += '
'; content += '
'; content += '

Available Modules

'; @@ -660,6 +665,9 @@ app.get('/dashboard', async (req, res) => { startTime: stats.startTime }); content += stats.taskDetails(); + if (npmAudit) { + content += npmAudit.renderDashboard(); + } content += `

Data: ${folders.dataDir()}

`; content = '
' + content + '
'; @@ -792,6 +800,7 @@ async function startServer() { stats.markStarted(); serverLog.info(`=== Server running on http://localhost:${PORT} ===`); }); + npmAudit.start(); if (modules.packages && config.modules.packages.enabled) { modules.packages.startInitialCrawler(); } @@ -819,6 +828,9 @@ async function startServer() { // Graceful shutdown process.on('SIGINT', async () => { serverLog.info('\nShutting down server...'); + if (npmAudit) { + npmAudit.stop(); + } // Shutdown all modules for (const [moduleName, moduleInstance] of Object.entries(modules)) { diff --git a/tests/registry/openapi.test.js b/tests/registry/openapi.test.js index 0becc0ee..ef874e0e 100644 --- a/tests/registry/openapi.test.js +++ b/tests/registry/openapi.test.js @@ -15,6 +15,7 @@ const { parametersOf, operations, describeSpecBasics, describeRouterAgreement } // Routes the spec deliberately does not describe, with the reason. const EXCLUDED = { 'GET /log': 'operational: crawler log', + 'GET /software': 'operational: software versions of the registered servers (HTML page)', 'GET /openapi': 'the description itself', 'GET /openapi.json': 'the description itself', 'GET /openapi.yaml': 'the description itself' diff --git a/tests/registry/registry-footer.test.js b/tests/registry/registry-footer.test.js new file mode 100644 index 00000000..299fde3c --- /dev/null +++ b/tests/registry/registry-footer.test.js @@ -0,0 +1,39 @@ +// The registry page footer: "last updated ..." only once a crawl has completed + +const path = require('path'); +const htmlServer = require('../../library/html-server'); +const RegistryAPI = require('../../registry/api'); +const { ServerRegistries, ServerRegistry, ServerInformation } = require('../../registry/model'); + +function apiFor(data) { + return new RegistryAPI({ getData: () => data }); +} + +function footer(stats) { + if (!htmlServer.hasTemplate('registry')) { + htmlServer.loadTemplate('registry', path.join(__dirname, '../../registry/registry-template.html')); + } + const html = htmlServer.renderPage('registry', 'Test', '', stats); + return html.substring(html.indexOf('Terminology Registry'), html.indexOf('Terminology Registry') + 80); +} + +describe('registry footer', () => { + test('before the first crawl', () => { + const stats = apiFor(new ServerRegistries()).getStatistics(); + expect(stats.crawlerStatus).toBe('not yet updated'); + expect(footer(stats)).toContain('Terminology Registry not yet updated'); + }); + + test('after a crawl', () => { + const data = new ServerRegistries(); + data.lastRun = new Date(Date.now() - 35 * 60 * 1000); + const registry = new ServerRegistry(); + registry.servers.push(new ServerInformation(), new ServerInformation()); + data.registries.push(registry); + const stats = apiFor(data).getStatistics(); + expect(stats.crawlerStatus).toBe('last updated 35 minutes ago, 2 servers'); + const text = footer(stats); + expect(text).toContain('Terminology Registry last updated 35 minutes ago, 2 servers'); + expect(text).not.toContain('[%'); + }); +}); diff --git a/tests/registry/registry-software.test.js b/tests/registry/registry-software.test.js new file mode 100644 index 00000000..ef9def68 --- /dev/null +++ b/tests/registry/registry-software.test.js @@ -0,0 +1,153 @@ +// The registry software page: FHIRsmith release dating, and what the page shows + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { FhirsmithReleases, isFhirsmith, describeAge } = require('../../registry/fhirsmith-releases'); +const { ServerRegistries, ServerRegistry, ServerInformation, ServerVersionInformation } = require('../../registry/model'); +const RegistryModule = require('../../registry/registry'); + +const NOW = new Date('2026-10-06T00:00:00Z').getTime(); + +function releases() { + const r = new FhirsmithReleases(); + r.setReleases([ + { version: 'v0.14.1', date: '2026-09-29T00:00:00Z' }, + { version: 'v0.14.0', date: '2026-09-27T00:00:00Z' }, + { version: 'v0.13.4', date: '2026-09-17T00:00:00Z' }, + { version: 'v0.9.7', date: '2026-06-12T00:00:00Z' }, + { version: 'v0.15.0-beta', date: '2026-10-01T00:00:00Z' } + ], 'test'); + return r; +} + +describe('FhirsmithReleases', () => { + test('pre-releases are not tracked, and the newest release is first', () => { + expect(releases().latest().version).toBe('0.14.1'); + }); + + test('the current release', () => { + const d = releases().describe('0.14.1', NOW); + expect(d.status).toBe('current'); + expect(d.ageDays).toBe(7); + expect(d.behind).toBe(0); + }); + + test('an old release, with or without a leading v', () => { + for (const v of ['0.9.7', 'v0.9.7']) { + const d = releases().describe(v, NOW); + expect(d.status).toBe('outdated'); + expect(d.ageDays).toBe(116); + expect(d.behind).toBe(3); + } + }); + + test('a snapshot is a development build after the release before it', () => { + const d = releases().describe('0.14.2-snapshot', NOW); + expect(d.status).toBe('dev'); + expect(d.release.version).toBe('0.14.1'); + expect(d.behind).toBe(0); + }); + + test('a version that is not n.n.n is unknown', () => { + expect(releases().describe('', NOW).status).toBe('unknown'); + expect(releases().describe('1.0', NOW).status).toBe('unknown'); + }); + + test('release dates can be read from a changelog', () => { + const file = path.join(os.tmpdir(), `changelog-${process.pid}.md`); + fs.writeFileSync(file, '# Changelog\n\n## [0.14.2] - \n\n## [0.14.1] - 2026-09-29\n\n## [v0.13.4] - 2026-09-17\n\n## [v0.11.0] - 2026-mm-dd\n'); + try { + const r = new FhirsmithReleases(); + r.loadFromChangelog(file); + expect(r.releases.map(x => x.version)).toEqual(['0.14.1', '0.13.4']); + expect(r.source).toBe('CHANGELOG.md'); + } finally { + fs.unlinkSync(file); + } + }); + + test('the shipped CHANGELOG.md has release dates', () => { + const r = new FhirsmithReleases(); + r.loadFromChangelog(); + expect(r.releases.length).toBeGreaterThan(10); + }); + + test('software names and ages', () => { + expect(isFhirsmith('FHIRsmith')).toBe(true); + expect(isFhirsmith('HAPI FHIR Server')).toBe(false); + expect(isFhirsmith(undefined)).toBe(false); + expect(describeAge(0)).toBe('today'); + expect(describeAge(1)).toBe('1 day'); + expect(describeAge(20)).toBe('2 weeks'); + expect(describeAge(116)).toBe('3 months'); + expect(describeAge(800)).toBe('2.2 years'); + }); +}); + +describe('model', () => { + test('the software version survives a save and load', () => { + const v = new ServerVersionInformation(); + v.software = 'FHIRsmith'; + v.softwareVersion = '0.9.7'; + const back = ServerVersionInformation.fromJSON(JSON.parse(JSON.stringify(v.toJSON()))); + expect(back.softwareVersion).toBe('0.9.7'); + expect(ServerVersionInformation.fromJSON({}).softwareVersion).toBe(''); + }); +}); + +describe('software page', () => { + function addServer(registry, name, url, software, softwareVersion, error) { + const server = new ServerInformation(); + server.code = name.toLowerCase(); + server.name = name; + const v = new ServerVersionInformation(); + v.version = '4.0.1'; + v.address = url; + v.software = software; + v.softwareVersion = softwareVersion; + v.error = error || ''; + server.versions.push(v); + registry.servers.push(server); + } + + function page() { + const data = new ServerRegistries(); + const registry = new ServerRegistry(); + registry.name = 'Test'; + addServer(registry, 'Old', 'https://old.example.org/r4', 'FHIRsmith', '0.9.7'); + addServer(registry, 'Current', 'https://current.example.org/r4', 'FHIRsmith', '0.14.1'); + addServer(registry, 'Dev', 'https://dev.example.org/r4', 'FHIRsmith', '0.14.2-snapshot'); + addServer(registry, 'Other', 'https://other.example.org/r4', 'Other ', '6.1', 'HTTP 503'); + addServer(registry, 'Silent', 'https://silent.example.org/r4', 'unknown', ''); + data.registries.push(registry); + + const module = new RegistryModule({}); + module.api = { getData: () => data }; + module.releases = releases(); + return module.buildSoftwareContent(NOW); + } + + test('lists each server with its software and version', () => { + const html = page(); + expect(html).toContain('https://old.example.org/r4'); + expect(html).toContain('Other <Server>'); + expect(html).toContain('6.1'); + expect(html).toContain('(unreachable)'); + expect(html).toContain('unknown'); + expect(html).toContain('current FHIRsmith release is v0.14.1'); + }); + + test('says how old a FHIRsmith release is', () => { + const html = page(); + expect(html).toContain('3 months (3 releases behind)'); + expect(html).toContain('7 days - current release'); + expect(html).toContain('development build after v0.14.1'); + }); + + test('does not date software that is not FHIRsmith', () => { + const html = page(); + const otherRow = html.split('').find(r => r.includes('other.example.org')); + expect(otherRow).toContain(''); + }); +}); From 5d5c519119b0c8a21c40f5c75374b1391630a58e Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Tue, 6 Oct 2026 21:41:55 +1300 Subject: [PATCH 4/4] set up 0.14.2 release --- CHANGELOG.md | 35 +++++++++++++++++++++++- package-lock.json | 4 +-- package.json | 2 +- tests/registry/registry-software.test.js | 3 +- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1263a1c..b5c597d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,41 @@ All notable changes to Health Intersections FHIRsmith will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.14.2] - +## [0.14.2] - 2026-10-06 +### Security + +- Dependency updates for published advisories, including axios (several high severity), brace-expansion (denial of service), and http-cache-semantics and @tootallnate/once (removed with the old sqlite3 build chain) + +### Added + +- OpenAPI descriptions for the package server (/packages), the terminology registry (/tx-reg), the TestReport module (/testing) and the R5 terminology endpoints: each serves `openapi.json`, `openapi.yaml` and an HTML reference at `openapi` (JSON for non-browser clients), linked from the module's pages and advertised in an RFC 8631 `Link` header on every response. The FHIR resource schemas are generated from the FHIR definitions, constrained to what each module accepts +- npm audit self-check: once a day the server checks its installed packages (and FHIRsmith itself) against the npm advisory database - report only, nothing is changed. Findings show as a banner on the home page, in full on the dashboard, and in the log. `npmAudit.enabled` = false turns it off (e.g. for servers with no internet access) +- Registry: new Software page (/tx-reg/software) listing each registered server's software and version, and for FHIRsmith servers, the release date, its age, and how many releases behind it is. The crawler now records `CapabilityStatement.software.version` + +### Changed + +- Terminology server: contained resources are supported only for ValueSets that contain ValueSets (which `compose.include.valueSet` can import by `#id`). Any other contained resource is rejected (CONTAINED_RESOURCE_NOT_SUPPORTED) wherever the resource comes from; when loading a package, the offending resource is skipped rather than stopping the load +- $validate-code: the `inactive` and `status` output parameters describe the code being returned. In a CodeableConcept where a different coding is inactive, that coding still gets its warning, but the parameters are not set for the returned code +- Registry discovery API follows the tx ecosystem IG: rows carry `fhirVersion` and the IG's security flags (`open`, `token`, ...) alongside the existing security string; candidate lists are only given when `url` is supplied; R-codes (R4, R4B, R5, ...) map to their release versions; and the resolve fallback that returned servers authoritative for a code system but not hosting it has been removed +- The TerminologyCapabilities statement no longer lists expansion parameters the server doesn't act on (`limitedExpansion`, `_incomplete`, `incomplete-ok` and others); `limitedExpansion`/`incomplete-ok` are no longer read, and no longer part of the expansion cache key (they never had any effect) +- TestReport module: a TestReport with contained resources is refused +- Packages and registry page footers say when the crawler last updated the data ("last updated 35 minutes ago"), or "not yet updated" before the first crawl - they used to show a raw `[%crawler-date%]` placeholder +- Packages crawler log: the start time is shown relative ("35 minutes ago") and the duration in seconds; the end time is dropped +- Dependencies: sqlite3 6 (connect-sqlite3 now uses the same sqlite3, which drops the old node-gyp/make-fetch-happen chain), and updates for axios, brace-expansion, moment, ip-address, csv-parse and uuid. The PR build now fails only on high/critical advisories in what ships (`npm audit --omit=dev`) + +### Fixed + +- $expand: RxNorm expansions that asked for a concept's children failed with `SQLITE_MISUSE`; an over-limit expansion of the whole of RxNorm is now refused before it is built +- $expand: a value set with neither a compose nor an expansion gets a clear error (VALUESET_NO_COMPOSE) +- $validate-code: in a CodeableConcept, the inactive warning names the coding that is actually inactive (it used to name the first coding), and a later active coding no longer hides the warning from an earlier inactive one +- Packages: `/status`, `/stats` and `/search` hung instead of answering (the `/:id` route swallowed them) +- Packages: dependency searches accept `id#version` and `id|version` (as sent by the Java PackageClient) as well as `id@version`; npm search (`/-/v1/search`) honours `text`, `size` and `from`, and accepts the other npm and PackageClient parameters +- XIG: the version shown on the pages + +### Tx Conformance Statement + +FHIRsmith passed all 3585 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v6.10.4) ## [0.14.1] - 2026-09-29 diff --git a/package-lock.json b/package-lock.json index 3daa0321..f41b6c87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fhirsmith", - "version": "0.14.2-snapshot", + "version": "0.14.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fhirsmith", - "version": "0.14.2-snapshot", + "version": "0.14.2", "license": "BSD-3", "dependencies": { "axios": "^1.13.4", diff --git a/package.json b/package.json index cffe2fc6..5212dc97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fhirsmith", - "version": "0.14.2-snapshot", + "version": "0.14.2", "txVersion": "1.9.5-SNAPSHOT", "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem", "main": "server.js", diff --git a/tests/registry/registry-software.test.js b/tests/registry/registry-software.test.js index ef9def68..47d8826b 100644 --- a/tests/registry/registry-software.test.js +++ b/tests/registry/registry-software.test.js @@ -147,7 +147,8 @@ describe('software page', () => { test('does not date software that is not FHIRsmith', () => { const html = page(); - const otherRow = html.split('').find(r => r.includes('other.example.org')); + // find the row by its server name cell (CodeQL flags a substring match on a host name) + const otherRow = html.split('').find(r => r.startsWith('Other')); expect(otherRow).toContain(''); }); });