fhirsmith 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to Health Intersections FHIRsmith will be documented in this
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.16.0] - 2026-10-15
9
+
10
+ ### Security
11
+ -
12
+ - Token: the login session store is now FHIRsmith's own (`library/sqlite-session-store.js`), in place of connect-sqlite3. connect-sqlite3 depends on sqlite3 5, which brought node-gyp 8, tar 6 and @tootallnate/once into every npm install of FHIRsmith (12 advisories, one critical). The `overrides` in package.json kept them out of a clone + `npm ci` (and so out of the Docker image and the repo's own `npm audit`), but npm ignores overrides when installing the published package, so `npm install -g fhirsmith` still got them. Sessions are kept in the same `sessions` table of the token database, in the same layout, so existing logins carry over
13
+ - Dependencies: fhirpath 5 (its ucum-lhc no longer brings csv-parse 4) and node-cron 4 (no longer depends on uuid), for the same reason: the csv-parse and uuid overrides didn't reach npm installs. The csv-parse override is gone
14
+ - Dependencies: natural 8 (uuid 13). Its stemmers are unchanged - every stemmer we use gives the same stem as natural 6 on ~1.6M words (RxNorm, LOINC's translations, and English, French, Spanish, Italian and Portuguese word lists), so existing SNOMED caches and RxNorm databases don't need rebuilding. FHIRsmith now loads only `natural/lib/natural/stemmers`: the package index also loads natural's storage backends, which run dotenv (reading any `.env` in the working directory into the environment, and in natural 8 printing a banner to stdout)
15
+ - Development: jest 30 and jest-junit 17, and `npm run dev` uses Node's own `node --watch` in place of nodemon. With these, `npm audit` is clean for the dev tooling too (jest 29 and nodemon brought braces, and jest's coverage tooling sprintf-js, neither of which has a fixed version). The package.json overrides are down to one, js-yaml 4 for @istanbuljs/load-nyc-config, which only affects the dev install; the rest were no longer doing anything
16
+ - The PR pipeline fails on high/critical advisories anywhere in the dependency tree, on moderate ones in what ships, and audits a fresh install of the packed package (installed the way users install it, so the repo's overrides don't apply)
17
+
18
+ ### Changed
19
+
20
+ - The npm audit self-check looks up the latest FHIRsmith release, and only tells the administrator to upgrade when there is a newer release to upgrade to; a server already on the latest release says so instead
21
+
22
+ ### Tx Conformance Statement
23
+
24
+ FHIRsmith v0.16.0 passed all 3630 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v7.0.1)
25
+
8
26
  ## [0.15.1] - 2026-10-15
9
27
 
10
28
  ### Security
@@ -12,6 +12,11 @@
12
12
  //
13
13
  // Config (all optional), top level of config.json:
14
14
  // "npmAudit": { "enabled": true, "intervalHours": 24 }
15
+ //
16
+ // Each check also looks up the latest fhirsmith release on npm, so the advice can say
17
+ // whether upgrading will help. When this server is already on the latest release it won't:
18
+ // npm ignores our package.json "overrides" for anything but a clone + npm ci, so an
19
+ // npm-installed server can carry transitive versions the repo itself has overridden away.
15
20
 
16
21
  const fs = require('fs');
17
22
  const path = require('path');
@@ -19,6 +24,7 @@ const axios = require('axios');
19
24
  const escape = require('escape-html');
20
25
 
21
26
  const DEFAULT_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk';
27
+ const DEFAULT_LATEST_URL = 'https://registry.npmjs.org/fhirsmith/latest';
22
28
  const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info'];
23
29
  const HOUR_MS = 60 * 60 * 1000;
24
30
  const FIRST_RUN_DELAY_MS = 60 * 1000; // let startup finish first
@@ -38,11 +44,34 @@ function describeAgo(time, now = Date.now()) {
38
44
  return `${Math.floor(hours / 24)} days ago`;
39
45
  }
40
46
 
47
+ /**
48
+ * Compare two x.y.z versions numerically (pre-release and build suffixes ignored).
49
+ * Returns <0, 0 or >0, or null if either can't be read
50
+ */
51
+ function compareVersions(a, b) {
52
+ const parse = v => {
53
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || '').trim());
54
+ return m ? m.slice(1).map(Number) : null;
55
+ };
56
+ const x = parse(a);
57
+ const y = parse(b);
58
+ if (!x || !y) {
59
+ return null;
60
+ }
61
+ for (let i = 0; i < 3; i++) {
62
+ if (x[i] !== y[i]) {
63
+ return x[i] - y[i];
64
+ }
65
+ }
66
+ return 0;
67
+ }
68
+
41
69
  class NpmAudit {
42
70
  constructor(config = {}, logger = null, appDir = path.join(__dirname, '..'), stats = null) {
43
71
  this.enabled = config.enabled !== false;
44
72
  this.intervalHours = config.intervalHours > 0 ? config.intervalHours : 24;
45
73
  this.url = config.url || DEFAULT_URL;
74
+ this.latestUrl = config.latestUrl || DEFAULT_LATEST_URL;
46
75
  this.timeout = config.timeout || 60000;
47
76
  this.logger = logger;
48
77
  this.appDir = appDir;
@@ -55,6 +84,8 @@ class NpmAudit {
55
84
  this.findings = []; // [{name, installed: [versions], id, severity, title, url, range}]
56
85
  this.lastError = null; // message from the last attempt, if it failed
57
86
  this.lastAttempt = null;
87
+ this.installedVersion = null; // this server's fhirsmith version, from package.json
88
+ this.latestVersion = null; // the latest fhirsmith release on npm, if it could be found
58
89
  }
59
90
 
60
91
  /**
@@ -93,6 +124,7 @@ class NpmAudit {
93
124
  const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8'));
94
125
  if (pkg.name && pkg.version) {
95
126
  add(pkg.name, pkg.version);
127
+ this.installedVersion = pkg.version;
96
128
  }
97
129
  } catch (e) {
98
130
  // no package.json - nothing to add
@@ -153,6 +185,39 @@ class NpmAudit {
153
185
  return true;
154
186
  }
155
187
 
188
+ /**
189
+ * Look up the latest fhirsmith release. A failure only means the advice is less specific,
190
+ * so it is logged and otherwise ignored (the previous answer is kept)
191
+ */
192
+ async checkLatest() {
193
+ try {
194
+ const response = await axios.get(this.latestUrl, { timeout: this.timeout, headers: { 'Accept': 'application/json' } });
195
+ const version = response.data && response.data.version;
196
+ if (typeof version === 'string' && compareVersions(version, version) === 0) {
197
+ this.latestVersion = version;
198
+ }
199
+ } catch (error) {
200
+ if (this.logger) {
201
+ this.logger.info('npm audit: could not look up the latest FHIRsmith release: ' + error.message);
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * What the administrator can do about the findings
208
+ */
209
+ advice() {
210
+ const cmp = compareVersions(this.installedVersion, this.latestVersion);
211
+ if (cmp === null) {
212
+ return 'The server administrator should upgrade to the latest FHIRsmith release.';
213
+ }
214
+ if (cmp < 0) {
215
+ return `The server administrator should upgrade to FHIRsmith ${this.latestVersion} (this server is running ${this.installedVersion}).`;
216
+ }
217
+ return `This server is already running the latest FHIRsmith release (${this.installedVersion}), ` +
218
+ 'so these will be fixed by a future FHIRsmith release.';
219
+ }
220
+
156
221
  async run() {
157
222
  if (this.running) {
158
223
  return;
@@ -190,6 +255,7 @@ class NpmAudit {
190
255
  this.lastError = null;
191
256
  const summary = this.summary();
192
257
  if (findings.length > 0) {
258
+ await this.checkLatest();
193
259
  if (this.logger) {
194
260
  this.logger.warn(`npm audit: ${summary} in ${this.packageCount} packages: ` +
195
261
  findings.map(f => `${f.name} (${f.severity}) ${f.url}`).join('; '));
@@ -278,7 +344,7 @@ class NpmAudit {
278
344
  return `<div class="alert ${cls}" role="alert"><strong>Security:</strong> npm audit found ` +
279
345
  `${escape(this.summary())} in the packages this server uses ` +
280
346
  `(checked ${escape(describeAgo(this.checkedAt.getTime(), now))}). ` +
281
- 'The server administrator should upgrade to the latest FHIRsmith release. ' +
347
+ `${escape(this.advice())} ` +
282
348
  '<a href="/dashboard#npm-audit">Details</a></div>';
283
349
  }
284
350
 
@@ -303,6 +369,7 @@ class NpmAudit {
303
369
  }
304
370
  html += '</p>';
305
371
  if (this.findings.length > 0) {
372
+ html += `<p>${escape(this.advice())}</p>`;
306
373
  html += '<table class="grid"><tr><th>Severity</th><th>Package</th><th>Installed</th><th>Vulnerable</th><th>Advisory</th></tr>';
307
374
  for (const f of this.findings) {
308
375
  const link = /^https:\/\//.test(f.url) ? `<a href="${escape(f.url)}">${escape(f.title || f.url)}</a>` : escape(f.title);
@@ -315,4 +382,4 @@ class NpmAudit {
315
382
  }
316
383
  }
317
384
 
318
- module.exports = { NpmAudit, describeAgo };
385
+ module.exports = { NpmAudit, describeAgo, compareVersions };
@@ -0,0 +1,133 @@
1
+ // library/sqlite-session-store.js
2
+ // An express-session store kept in a table of an already-open sqlite3 database.
3
+ //
4
+ // This replaces connect-sqlite3, which depends on sqlite3 ^5 and so brought a second, old
5
+ // copy of sqlite3 (and with it node-gyp 8 and tar 6) into every npm install of FHIRsmith.
6
+ // Our package.json "overrides" hid that from npm audit in the repo, but npm ignores
7
+ // overrides for anyone installing the published package.
8
+ //
9
+ // The table layout is the one connect-sqlite3 used - (sid PRIMARY KEY, expired, sess),
10
+ // with expired in epoch ms and sess as JSON - so sessions in an existing database carry over.
11
+
12
+ const session = require('express-session');
13
+
14
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
15
+ const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+
17
+ class SqliteSessionStore extends session.Store {
18
+ /**
19
+ * @param {object} options
20
+ * @param {object} options.db - an open sqlite3 Database; the caller owns it and closes it
21
+ * @param {string} [options.table='sessions']
22
+ * @param {number} [options.cleanupIntervalMs=one day] - how often expired sessions are deleted
23
+ */
24
+ constructor(options = {}) {
25
+ super();
26
+ if (!options.db) {
27
+ throw new Error('SqliteSessionStore needs an open sqlite3 database');
28
+ }
29
+ const table = options.table || 'sessions';
30
+ if (!TABLE_NAME.test(table)) {
31
+ throw new Error('Invalid session table name: ' + table);
32
+ }
33
+ this.db = options.db;
34
+ this.table = table;
35
+ this.timer = null;
36
+ this.ready = this.run(`CREATE TABLE IF NOT EXISTS ${table} (sid PRIMARY KEY, expired, sess)`)
37
+ .then(() => this.cleanup());
38
+ this.ready.catch(() => { /* reported by the first operation that waits on it */ });
39
+ const interval = options.cleanupIntervalMs > 0 ? options.cleanupIntervalMs : ONE_DAY_MS;
40
+ this.timer = setInterval(() => void this.cleanup().catch(() => {}), interval);
41
+ if (this.timer.unref) {
42
+ this.timer.unref();
43
+ }
44
+ }
45
+
46
+ run(sql, params = []) {
47
+ return new Promise((resolve, reject) => {
48
+ this.db.run(sql, params, function (err) {
49
+ if (err) {
50
+ reject(err);
51
+ } else {
52
+ resolve(this);
53
+ }
54
+ });
55
+ });
56
+ }
57
+
58
+ query(method, sql, params = []) {
59
+ return new Promise((resolve, reject) => {
60
+ this.db[method](sql, params, (err, result) => err ? reject(err) : resolve(result));
61
+ });
62
+ }
63
+
64
+ // adapt a promise to express-session's callback style
65
+ done(promise, cb) {
66
+ promise.then(result => cb && cb(null, result), err => cb && cb(err));
67
+ }
68
+
69
+ cleanup() {
70
+ return this.run(`DELETE FROM ${this.table} WHERE expired < ?`, [Date.now()]);
71
+ }
72
+
73
+ static expiry(sess) {
74
+ if (sess && sess.cookie) {
75
+ if (sess.cookie.expires) {
76
+ const t = new Date(sess.cookie.expires).getTime();
77
+ if (!isNaN(t)) {
78
+ return t;
79
+ }
80
+ }
81
+ if (sess.cookie.maxAge) {
82
+ return Date.now() + sess.cookie.maxAge;
83
+ }
84
+ }
85
+ return Date.now() + ONE_DAY_MS;
86
+ }
87
+
88
+ get(sid, cb) {
89
+ this.done(this.ready
90
+ .then(() => this.query('get', `SELECT sess FROM ${this.table} WHERE sid = ? AND expired >= ?`, [sid, Date.now()]))
91
+ .then(row => row ? JSON.parse(row.sess) : null), cb);
92
+ }
93
+
94
+ set(sid, sess, cb) {
95
+ this.done(this.ready
96
+ .then(() => this.run(`INSERT OR REPLACE INTO ${this.table} (sid, expired, sess) VALUES (?, ?, ?)`,
97
+ [sid, SqliteSessionStore.expiry(sess), JSON.stringify(sess)]))
98
+ .then(() => undefined), cb);
99
+ }
100
+
101
+ touch(sid, sess, cb) {
102
+ this.done(this.ready
103
+ .then(() => this.run(`UPDATE ${this.table} SET expired = ? WHERE sid = ? AND expired >= ?`,
104
+ [SqliteSessionStore.expiry(sess), sid, Date.now()]))
105
+ .then(() => undefined), cb);
106
+ }
107
+
108
+ destroy(sid, cb) {
109
+ this.done(this.ready
110
+ .then(() => this.run(`DELETE FROM ${this.table} WHERE sid = ?`, [sid]))
111
+ .then(() => undefined), cb);
112
+ }
113
+
114
+ length(cb) {
115
+ this.done(this.ready
116
+ .then(() => this.query('get', `SELECT COUNT(*) AS count FROM ${this.table} WHERE expired >= ?`, [Date.now()]))
117
+ .then(row => row.count), cb);
118
+ }
119
+
120
+ clear(cb) {
121
+ this.done(this.ready.then(() => this.run(`DELETE FROM ${this.table}`)).then(() => undefined), cb);
122
+ }
123
+
124
+ /** Stop the cleanup timer. The database itself belongs to the caller. */
125
+ close() {
126
+ if (this.timer) {
127
+ clearInterval(this.timer);
128
+ this.timer = null;
129
+ }
130
+ }
131
+ }
132
+
133
+ module.exports = { SqliteSessionStore };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "txVersion": "1.9.6",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -9,18 +9,18 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
- "dev": "nodemon server.js",
12
+ "dev": "node --watch server.js",
13
13
  "test": "jest",
14
14
  "test:watch": "jest --watch",
15
15
  "test:coverage": "jest --coverage",
16
16
  "test:ci": "jest --ci --coverage --watchAll=false --reporters=default --reporters=jest-junit --verbose",
17
- "test:shl": "jest --testPathPattern=shl",
18
- "test:vcl": "jest --testPathPattern=vcl",
19
- "test:xig": "jest --testPathPattern=xig",
20
- "test:packages": "jest --testPathPattern=packages",
21
- "test:token": "jest --testPathPattern=token",
22
- "test:integration": "jest --testPathPattern=integration",
23
- "test:unit": "jest --testPathPattern=unit",
17
+ "test:shl": "jest --testPathPatterns=shl",
18
+ "test:vcl": "jest --testPathPatterns=vcl",
19
+ "test:xig": "jest --testPathPatterns=xig",
20
+ "test:packages": "jest --testPathPatterns=packages",
21
+ "test:token": "jest --testPathPatterns=token",
22
+ "test:integration": "jest --testPathPatterns=integration",
23
+ "test:unit": "jest --testPathPatterns=unit",
24
24
  "lint": "eslint . --ext .js --ignore-path .gitignore",
25
25
  "lint:fix": "eslint . --fix --ignore-pattern 'static/assets/js/html5shiv.js'",
26
26
  "lint:relaxed": "eslint . --config .eslintrc.relaxed.js",
@@ -41,21 +41,20 @@
41
41
  "cli-progress": "^3.12.0",
42
42
  "commander": "^14.0.3",
43
43
  "commonmark": "^0.31.2",
44
- "connect-sqlite3": "^0.9.18",
45
44
  "cors": "^2.8.6",
46
45
  "express": "^5.2.1",
47
46
  "express-rate-limit": "^7.4.1",
48
47
  "express-session": "^1.19.0",
49
48
  "fast-xml-parser": "^5.3.4",
50
49
  "fhir-validator-wrapper": "1.4.0",
51
- "fhirpath": "^4.8.3",
50
+ "fhirpath": "^5.2.1",
52
51
  "fs-extra": "^11.3.3",
53
52
  "ini": "^6.0.0",
54
53
  "inquirer": "^8.2.5",
55
54
  "liquidjs": "^10.25.5",
56
55
  "lusca": "^1.7.0",
57
- "natural": "^6.12.0",
58
- "node-cron": "^3.0.3",
56
+ "natural": "^8.1.1",
57
+ "node-cron": "^4.6.0",
59
58
  "pako": "^2.1.0",
60
59
  "passport": "^0.7.0",
61
60
  "passport-facebook": "^3.0.0",
@@ -71,18 +70,17 @@
71
70
  "yaml": "^2.8.2"
72
71
  },
73
72
  "devDependencies": {
74
- "@types/jest": "^29.5.8",
73
+ "@types/jest": "^30.0.0",
75
74
  "@typescript-eslint/eslint-plugin": "^8.54.0",
76
75
  "@typescript-eslint/parser": "^8.54.0",
77
76
  "ajv": "^8.20.0",
78
77
  "eslint": "^8.57.1",
79
78
  "eslint-plugin-promise": "^7.2.1",
80
- "jest": "^29.7.0",
81
- "jest-environment-node": "^29.7.0",
82
- "jest-junit": "^16.0.0",
79
+ "jest": "^30.5.2",
80
+ "jest-environment-node": "^30.5.2",
81
+ "jest-junit": "^17.0.0",
83
82
  "mock-fs": "^5.5.0",
84
83
  "nock": "^13.4.0",
85
- "nodemon": "^3.1.11",
86
84
  "supertest": "^7.2.2",
87
85
  "tmp": "^0.2.5"
88
86
  },
@@ -107,16 +105,8 @@
107
105
  "author": "Health Intersections Pty Ltd",
108
106
  "license": "BSD-3",
109
107
  "overrides": {
110
- "tar": "^7.5.7",
111
- "fast-xml-parser": "^5.3.4",
112
- "inquirer": {
113
- "lodash": "4.18.1"
114
- },
115
- "brace-expansion": "^5.0.8",
116
- "csv-parse": "^7.0.3",
117
- "uuid": "^11.1.1",
118
- "connect-sqlite3": {
119
- "sqlite3": "$sqlite3"
108
+ "@istanbuljs/load-nyc-config": {
109
+ "js-yaml": "^4.1.0"
120
110
  }
121
111
  },
122
112
  "bin": {
package/token/token.js CHANGED
@@ -11,7 +11,6 @@ const crypto = require('crypto');
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
13
  const session = require('express-session');
14
- const SQLiteStore = require('connect-sqlite3')(session);
15
14
  const passport = require('passport');
16
15
  const GoogleStrategy = require('passport-google-oauth20').Strategy;
17
16
  const FacebookStrategy = require('passport-facebook').Strategy;
@@ -19,6 +18,7 @@ const GitHubStrategy = require('passport-github2').Strategy;
19
18
  const rateLimit = require('express-rate-limit');
20
19
  const lusca = require('lusca');
21
20
  const folders = require('../library/folder-setup');
21
+ const { SqliteSessionStore } = require('../library/sqlite-session-store');
22
22
  const escape = require('escape-html');
23
23
 
24
24
 
@@ -155,17 +155,11 @@ class TokenModule {
155
155
  }
156
156
 
157
157
  initializeSession() {
158
- const configDb = this.config.database || 'token.db';
159
- const sessionDbPath = path.isAbsolute(configDb)
160
- ? configDb
161
- : folders.ensureFilePath('token', configDb);
158
+ // sessions live in the module's own database (initializeDatabase has opened it)
159
+ this.sessionStore = new SqliteSessionStore({ db: this.db, table: 'sessions' });
162
160
 
163
161
  const sessionConfig = {
164
- store: new SQLiteStore({
165
- dir: path.dirname(sessionDbPath),
166
- db: path.basename(sessionDbPath),
167
- table: 'sessions'
168
- }),
162
+ store: this.sessionStore,
169
163
  secret: this.config.sessionSecret || crypto.randomBytes(64).toString('hex'),
170
164
  name: 'fhir.token.sid', // Don't use default session name
171
165
  resave: false,
@@ -1287,6 +1281,9 @@ class TokenModule {
1287
1281
  }
1288
1282
 
1289
1283
  async shutdown() {
1284
+ if (this.sessionStore) {
1285
+ this.sessionStore.close();
1286
+ }
1290
1287
  if (this.db) {
1291
1288
  await new Promise((resolve) => {
1292
1289
  this.db.close((err) => {
@@ -780,7 +780,7 @@ class RxNormImporter {
780
780
  if (this.options.verbose) console.log('Generating word stems...');
781
781
 
782
782
  // Simple English stemmer implementation
783
- var natural = require('natural');
783
+ var natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
784
784
  const stems = new Map();
785
785
 
786
786
  // Get all RXNORM concepts
@@ -4,7 +4,7 @@ const inquirer = require('inquirer');
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
6
  const chalk = require('chalk');
7
- const natural = require('natural');
7
+ const natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
8
8
 
9
9
  const {
10
10
  SnomedStrings, SnomedWords, SnomedStems, SnomedReferences,
@@ -1,6 +1,6 @@
1
1
  const { LanguagePartType, Languages, Language, LanguageDefinitions} = require('../../library/languages');
2
2
  const {validateParameter, validateOptionalParameter, validateArrayParameter} = require("../../library/utilities");
3
- const natural = require('natural');
3
+ const natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
4
4
  /**
5
5
  * Display checking modes for concept designations
6
6
  */
@@ -27,7 +27,7 @@
27
27
  * event loop as it goes, and a search takes milliseconds once it exists.
28
28
  */
29
29
 
30
- const natural = require('natural');
30
+ const natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
31
31
 
32
32
  const stemmer = natural.PorterStemmer;
33
33