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 `Security: npm audit found ` +
+ `${escape(this.summary())} in the packages this server uses ` +
+ `(checked ${escape(describeAgo(this.checkedAt.getTime(), now))}). ` +
+ 'The server administrator should upgrade to the latest FHIRsmith release. ' +
+ '
Details ';
+ }
+
+ /**
+ * 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 += '| Severity | Package | Installed | Vulnerable | Advisory |
';
+ for (const f of this.findings) {
+ const link = /^https:\/\//.test(f.url) ? `${escape(f.title || f.url)}` : escape(f.title);
+ html += `| ${escape(f.severity)} | ${escape(f.name)} | ${escape(f.installed.join(', '))} | ` +
+ `${escape(f.range)} | ${link} |
`;
+ }
+ html += '
';
+ }
+ 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 += '| Server | URL | FHIR | Software | Version | ' +
+ 'Released | Age |
';
+ for (const row of rows) {
+ html += '';
+ html += `| ${escape(row.name)} | `;
+ html += `${escape(row.url)}`;
+ if (row.error) {
+ html += ` (unreachable)`;
+ }
+ html += ' | ';
+ html += `${escape(row.fhirVersion)} | `;
+ html += `${row.software ? escape(row.software.replace('Reference Server', 'HealthIntersections')) : 'unknown'} | `;
+ html += `${row.softwareVersion ? escape(row.softwareVersion) : 'unknown'} | `;
+ html += this._renderReleaseCells(row, now);
+ html += '
';
+ }
+ html += '
';
+
+ 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 = '
';
@@ -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(' | | ');
});
});