fhirsmith 0.15.1 → 0.16.1

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,42 @@ 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.1] - 2026-10-16
9
+
10
+ ### Added
11
+
12
+ - The dashboard always says which FHIRsmith release the server runs, and whether a newer one is available (looked up once a day with the npm audit check; before, the latest release was only looked up, and only mentioned, when the audit found something)
13
+ - README: how to follow releases (GitHub release emails, the releases Atom feed, and the dashboard)
14
+
15
+ ### Fixed
16
+
17
+ - Packages: SUSHI (and anything else using fhir-package-loader) couldn't download packages from this server: `GET /packages/{id}/{version}` returned the version's JSON document instead of the tarball (since 0.16.0). The rule was "JSON when Accept includes `application/json`", but fhir-package-loader downloads with axios's default `Accept: application/json, text/plain, */*`, and plenty of other HTTP clients send JSON alongside `*/*` by default without wanting it. The version document is now returned only when the request prefers JSON: a JSON type ranked above any `*/*`, `application/*` or tarball type by q-value (`application/json` alone, or `application/json, */*;q=0.1`). Everything else gets the tarball, and `FHIR-Package-Download` still forces it
18
+ - A POST with no Content-Type now says so (it gave "Unsupported Media Type: " with nothing after it)
19
+ - A form post whose Content-Type has a charset (`application/x-www-form-urlencoded; charset=UTF-8`) was refused as an unsupported media type
20
+ - Links in published IGs to a resource by the id it had in its source (`ValueSet/2.16.840.1.113762.1.4.1`, from before ids were prefixed, as in `ValueSet/vsac-2.16.840.1.113762.1.4.1`) work again in a browser: a read of an id nothing has redirects to the one resource that had that id in its source, or lists them when there are several. Other requests still get a 404
21
+
22
+ ### Tx Conformance Statement
23
+
24
+ FHIRsmith v0.16.1 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.2)
25
+
26
+ ## [0.16.0] - 2026-10-15
27
+
28
+ ### Security
29
+ -
30
+ - 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
31
+ - 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
32
+ - 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)
33
+ - 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
34
+ - 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)
35
+
36
+ ### Changed
37
+
38
+ - 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
39
+
40
+ ### Tx Conformance Statement
41
+
42
+ 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)
43
+
8
44
  ## [0.15.1] - 2026-10-15
9
45
 
10
46
  ### Security
package/README.md CHANGED
@@ -259,7 +259,7 @@ Each GitHub Release includes:
259
259
  - `ghcr.io/healthintersections/fhirsmith:latest`
260
260
  - `ghcr.io/healthintersections/fhirsmith:vX.Y.Z`
261
261
  - `ghcr.io/healthintersections/fhirsmith:X.Y.Z`
262
- - **npm package** published to npmjs.org as `fhirsmith` *(if you add this)*
262
+ - **npm package** published to npmjs.org as [`fhirsmith`](https://www.npmjs.com/package/fhirsmith)
263
263
 
264
264
  Release images carry OCI labels, so an image says which release it is without
265
265
  having to trust the tag it was pulled under:
@@ -271,6 +271,19 @@ docker inspect --format '{{json .Config.Labels}}' ghcr.io/healthintersections/fh
271
271
  `org.opencontainers.image.version` is the release, `.revision` the commit it was
272
272
  built from, and `.source` is what links the package to this repository on GHCR.
273
273
 
274
+ ### Tracking Releases
275
+
276
+ Server administrators should follow releases: a server is only as safe as the
277
+ release it runs.
278
+
279
+ - **Email**: on the [GitHub repository](https://github.com/HealthIntersections/FHIRsmith),
280
+ choose **Watch → Custom → Releases**. Each release is emailed with its notes (needs a GitHub account)
281
+ - **Atom feed**: `https://github.com/HealthIntersections/FHIRsmith/releases.atom` - each
282
+ entry carries the release notes, for a feed reader or a Slack/Teams feed integration
283
+ - **The server itself**: once a day the server looks up the latest release on npm, and the
284
+ dashboard says whether a newer one is available (this is part of the npm audit check, so
285
+ `npmAudit.enabled = false` turns it off too)
286
+
274
287
  ### CI Images
275
288
 
276
289
  Every push to main also publishes `:cibuild` and `:cibuild-<sha>` to the same
package/gr.sh ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ git tag v$1
3
+ git push origin v$1
@@ -12,6 +12,12 @@
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. The dashboard always says
17
+ // whether a newer release is available, and the advice about any findings says whether
18
+ // upgrading will help. When this server is already on the latest release it won't:
19
+ // npm ignores our package.json "overrides" for anything but a clone + npm ci, so an
20
+ // npm-installed server can carry transitive versions the repo itself has overridden away.
15
21
 
16
22
  const fs = require('fs');
17
23
  const path = require('path');
@@ -19,6 +25,7 @@ const axios = require('axios');
19
25
  const escape = require('escape-html');
20
26
 
21
27
  const DEFAULT_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk';
28
+ const DEFAULT_LATEST_URL = 'https://registry.npmjs.org/fhirsmith/latest';
22
29
  const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info'];
23
30
  const HOUR_MS = 60 * 60 * 1000;
24
31
  const FIRST_RUN_DELAY_MS = 60 * 1000; // let startup finish first
@@ -38,11 +45,34 @@ function describeAgo(time, now = Date.now()) {
38
45
  return `${Math.floor(hours / 24)} days ago`;
39
46
  }
40
47
 
48
+ /**
49
+ * Compare two x.y.z versions numerically (pre-release and build suffixes ignored).
50
+ * Returns <0, 0 or >0, or null if either can't be read
51
+ */
52
+ function compareVersions(a, b) {
53
+ const parse = v => {
54
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || '').trim());
55
+ return m ? m.slice(1).map(Number) : null;
56
+ };
57
+ const x = parse(a);
58
+ const y = parse(b);
59
+ if (!x || !y) {
60
+ return null;
61
+ }
62
+ for (let i = 0; i < 3; i++) {
63
+ if (x[i] !== y[i]) {
64
+ return x[i] - y[i];
65
+ }
66
+ }
67
+ return 0;
68
+ }
69
+
41
70
  class NpmAudit {
42
71
  constructor(config = {}, logger = null, appDir = path.join(__dirname, '..'), stats = null) {
43
72
  this.enabled = config.enabled !== false;
44
73
  this.intervalHours = config.intervalHours > 0 ? config.intervalHours : 24;
45
74
  this.url = config.url || DEFAULT_URL;
75
+ this.latestUrl = config.latestUrl || DEFAULT_LATEST_URL;
46
76
  this.timeout = config.timeout || 60000;
47
77
  this.logger = logger;
48
78
  this.appDir = appDir;
@@ -55,6 +85,8 @@ class NpmAudit {
55
85
  this.findings = []; // [{name, installed: [versions], id, severity, title, url, range}]
56
86
  this.lastError = null; // message from the last attempt, if it failed
57
87
  this.lastAttempt = null;
88
+ this.installedVersion = null; // this server's fhirsmith version, from package.json
89
+ this.latestVersion = null; // the latest fhirsmith release on npm, if it could be found
58
90
  }
59
91
 
60
92
  /**
@@ -93,6 +125,7 @@ class NpmAudit {
93
125
  const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8'));
94
126
  if (pkg.name && pkg.version) {
95
127
  add(pkg.name, pkg.version);
128
+ this.installedVersion = pkg.version;
96
129
  }
97
130
  } catch (e) {
98
131
  // no package.json - nothing to add
@@ -153,6 +186,74 @@ class NpmAudit {
153
186
  return true;
154
187
  }
155
188
 
189
+ /**
190
+ * This server's own version, from package.json (collectPackages reads it too, but that
191
+ * can fail for reasons that have nothing to do with the version)
192
+ */
193
+ readInstalledVersion() {
194
+ try {
195
+ const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8'));
196
+ if (pkg.version) {
197
+ this.installedVersion = pkg.version;
198
+ }
199
+ } catch (e) {
200
+ // leave it as it was
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Dashboard line: this server's version, and whether there is a newer release
206
+ */
207
+ renderRelease() {
208
+ if (!this.installedVersion) {
209
+ return '';
210
+ }
211
+ let html = `<p><strong>FHIRsmith release:</strong> this server runs ${escape(this.installedVersion)}`;
212
+ const cmp = compareVersions(this.installedVersion, this.latestVersion);
213
+ if (cmp === null) {
214
+ html += ' (the latest release could not be checked)';
215
+ } else if (cmp < 0) {
216
+ html += ` - <span style="color:#b60">FHIRsmith ${escape(this.latestVersion)} is available</span>` +
217
+ ' (<a href="https://github.com/HealthIntersections/FHIRsmith/releases">release notes</a>)';
218
+ } else {
219
+ html += ', the latest release';
220
+ }
221
+ return html + '</p>';
222
+ }
223
+
224
+ /**
225
+ * Look up the latest fhirsmith release. A failure only means the advice is less specific,
226
+ * so it is logged and otherwise ignored (the previous answer is kept)
227
+ */
228
+ async checkLatest() {
229
+ try {
230
+ const response = await axios.get(this.latestUrl, { timeout: this.timeout, headers: { 'Accept': 'application/json' } });
231
+ const version = response.data && response.data.version;
232
+ if (typeof version === 'string' && compareVersions(version, version) === 0) {
233
+ this.latestVersion = version;
234
+ }
235
+ } catch (error) {
236
+ if (this.logger) {
237
+ this.logger.info('npm audit: could not look up the latest FHIRsmith release: ' + error.message);
238
+ }
239
+ }
240
+ }
241
+
242
+ /**
243
+ * What the administrator can do about the findings
244
+ */
245
+ advice() {
246
+ const cmp = compareVersions(this.installedVersion, this.latestVersion);
247
+ if (cmp === null) {
248
+ return 'The server administrator should upgrade to the latest FHIRsmith release.';
249
+ }
250
+ if (cmp < 0) {
251
+ return `The server administrator should upgrade to FHIRsmith ${this.latestVersion} (this server is running ${this.installedVersion}).`;
252
+ }
253
+ return `This server is already running the latest FHIRsmith release (${this.installedVersion}), ` +
254
+ 'so these will be fixed by a future FHIRsmith release.';
255
+ }
256
+
156
257
  async run() {
157
258
  if (this.running) {
158
259
  return;
@@ -162,6 +263,9 @@ class NpmAudit {
162
263
  if (this.stats) {
163
264
  this.stats.task('npm audit', 'Checking');
164
265
  }
266
+ // independent of the audit: failures here are logged and otherwise ignored
267
+ this.readInstalledVersion();
268
+ await this.checkLatest();
165
269
  try {
166
270
  const packages = this.collectPackages();
167
271
  const response = await axios.post(this.url, packages, {
@@ -278,7 +382,7 @@ class NpmAudit {
278
382
  return `<div class="alert ${cls}" role="alert"><strong>Security:</strong> npm audit found ` +
279
383
  `${escape(this.summary())} in the packages this server uses ` +
280
384
  `(checked ${escape(describeAgo(this.checkedAt.getTime(), now))}). ` +
281
- 'The server administrator should upgrade to the latest FHIRsmith release. ' +
385
+ `${escape(this.advice())} ` +
282
386
  '<a href="/dashboard#npm-audit">Details</a></div>';
283
387
  }
284
388
 
@@ -290,6 +394,7 @@ class NpmAudit {
290
394
  if (!this.enabled) {
291
395
  return html + '<p><strong>npm audit:</strong> disabled (npmAudit.enabled = false)</p></div>';
292
396
  }
397
+ html += this.renderRelease();
293
398
  if (!this.checkedAt) {
294
399
  html += '<p><strong>npm audit:</strong> ';
295
400
  html += this.lastError ? `<span style="color:#b00">could not be run: ${escape(this.lastError)}</span>` : 'not run yet';
@@ -303,6 +408,7 @@ class NpmAudit {
303
408
  }
304
409
  html += '</p>';
305
410
  if (this.findings.length > 0) {
411
+ html += `<p>${escape(this.advice())}</p>`;
306
412
  html += '<table class="grid"><tr><th>Severity</th><th>Package</th><th>Installed</th><th>Vulnerable</th><th>Advisory</th></tr>';
307
413
  for (const f of this.findings) {
308
414
  const link = /^https:\/\//.test(f.url) ? `<a href="${escape(f.url)}">${escape(f.title || f.url)}</a>` : escape(f.title);
@@ -315,4 +421,4 @@ class NpmAudit {
315
421
  }
316
422
  }
317
423
 
318
- module.exports = { NpmAudit, describeAgo };
424
+ 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.1",
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": {
@@ -350,9 +350,12 @@ paths:
350
350
  summary: Download a package tarball, or get a version's document
351
351
  description: |
352
352
  Returns the package tarball (`.tgz`) - or, when the request's `Accept` header
353
- asks for `application/json` (or npm's `application/vnd.npm.install-v1+json`),
354
- the version's npm document: the same object as that version's entry in the
355
- package document. The response varies on `Accept` and `FHIR-Package-Download`.
353
+ prefers JSON, the version's npm document: the same object as that version's entry
354
+ in the package document. Preferring JSON means ranking `application/json` (or npm's
355
+ `application/vnd.npm.install-v1+json`) above any `*/*`, `application/*` or tarball
356
+ type by q-value: `application/json` alone does, but `application/json, text/plain, */*`
357
+ (axios's default, which SUSHI sends) does not. The response varies on `Accept` and
358
+ `FHIR-Package-Download`.
356
359
 
357
360
  A request with a `FHIR-Package-Download` header always gets the tarball. The
358
361
  HL7 Java tools send it; a client that downloads tarballs from this URL while
@@ -944,11 +944,13 @@ class PackagesModule {
944
944
  }
945
945
  }
946
946
 
947
- stopCrawlerJob() {
947
+ // node-cron 4: stop() may return a promise
948
+ async stopCrawlerJob() {
948
949
  if (this.crawlerJob) {
949
950
  this.crawler.shutdown();
950
- this.crawlerJob.stop();
951
+ const job = this.crawlerJob;
951
952
  this.crawlerJob = null;
953
+ await job.stop();
952
954
  pckLog.info('Package crawler job stopped');
953
955
  }
954
956
  }
@@ -1831,14 +1833,34 @@ class PackagesModule {
1831
1833
  res.send(htmlServer.renderPage('packages', 'npm Registry Check', content, stats));
1832
1834
  }
1833
1835
 
1834
- // GET /{id}/{version} is npm's version document when the request asks for JSON, and the
1835
- // tarball otherwise - which is what FHIR clients have always got there, without asking
1836
- // for JSON. A request carrying the TARBALL_HEADER always gets the tarball.
1836
+ // GET /{id}/{version} is npm's version document when the request prefers JSON, and the
1837
+ // tarball otherwise - which is what FHIR clients have always got there. Preferring JSON
1838
+ // means ranking it above anything else the request accepts: a JSON type with a higher q
1839
+ // than any */*, application/* or tarball type. Many clients send JSON together with */*
1840
+ // at the same q - axios's default, application/json, text/plain, */*, which is what
1841
+ // SUSHI (fhir-package-loader) sends when it downloads a tarball from here - and they get
1842
+ // the tarball. A request carrying the TARBALL_HEADER always gets the tarball.
1837
1843
  wantsVersionDocument(req) {
1838
1844
  if (req.get(TARBALL_HEADER) !== undefined) {
1839
1845
  return false;
1840
1846
  }
1841
- return /application\/(json|vnd\.npm\.install-v1\+json)/i.test(req.get('accept') || '');
1847
+ let json = 0;
1848
+ let other = 0;
1849
+ for (const part of String(req.get('accept') || '').split(',')) {
1850
+ const [type, ...params] = part.split(';').map(s => s.trim().toLowerCase());
1851
+ const qParam = params.find(p => p.startsWith('q='));
1852
+ const q = qParam ? parseFloat(qParam.substring(2)) : 1;
1853
+ if (!type || Number.isNaN(q)) {
1854
+ continue;
1855
+ }
1856
+ if (type === 'application/json' || type === 'application/vnd.npm.install-v1+json') {
1857
+ json = Math.max(json, q);
1858
+ } else if (['*/*', 'application/*', 'application/tar+gzip', 'application/gzip', 'application/x-gzip',
1859
+ 'application/x-tar', 'application/octet-stream'].includes(type)) {
1860
+ other = Math.max(other, q);
1861
+ }
1862
+ }
1863
+ return json > 0 && json > other;
1842
1864
  }
1843
1865
 
1844
1866
  async serveVersionDocument(secure, id, version, req, res) {
@@ -2844,7 +2866,7 @@ class PackagesModule {
2844
2866
  async shutdown() {
2845
2867
  pckLog.info('Shutting down Packages module...');
2846
2868
 
2847
- this.stopCrawlerJob();
2869
+ await this.stopCrawlerJob();
2848
2870
 
2849
2871
  // Close database connection
2850
2872
  if (this.db) {
@@ -31,7 +31,7 @@ In summary:
31
31
  | `GET /packages/catalog` | Search packages: `name`, `canonical`, `pkgcanonical`, `fhirversion`, `dependency`, `dependson`, `sort` |
32
32
  | `GET /packages/-/v1/search` | npm search API: `text`, `size`, `from`, plus the `/catalog` parameters |
33
33
  | `GET /packages/{id}` | npm package document: all versions, dependencies, tarball URLs |
34
- | `GET /packages/{id}/{version}` | Download a package tarball; or, when the request's `Accept` asks for JSON, npm's document for that version. A `FHIR-Package-Download` header always gets the tarball (the HL7 Java tools send it) |
34
+ | `GET /packages/{id}/{version}` | Download a package tarball; or, when the request's `Accept` prefers JSON (ranks it above `*/*`), npm's document for that version. A `FHIR-Package-Download` header always gets the tarball (the HL7 Java tools send it) |
35
35
  | `GET /packages/updates` | Versions published since a date |
36
36
  | `GET /packages/broken` | Package versions whose dependencies this registry doesn't hold |
37
37
  | `GET /packages/npm-check` | Check any registry against the npm registry protocol (a form, in a browser) |
package/shl/shl.js CHANGED
@@ -418,10 +418,12 @@ class SHLModule {
418
418
  }
419
419
  }
420
420
 
421
- stopCleanupJob() {
421
+ // node-cron 4: stop() may return a promise
422
+ async stopCleanupJob() {
422
423
  if (this.cleanupJob) {
423
- this.cleanupJob.stop();
424
+ const job = this.cleanupJob;
424
425
  this.cleanupJob = null;
426
+ await job.stop();
425
427
  }
426
428
  }
427
429
 
@@ -1060,7 +1062,7 @@ class SHLModule {
1060
1062
  async shutdown() {
1061
1063
  shlLog.info('Shutting down SHL module...');
1062
1064
 
1063
- this.stopCleanupJob();
1065
+ await this.stopCleanupJob();
1064
1066
 
1065
1067
  // Stop FHIR validator
1066
1068
  if (this.fhirValidator) {
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
  */
@@ -58,6 +58,21 @@ function stripIdPrefix(id, spaceId) {
58
58
  return id.startsWith(prefix) && id.length > prefix.length ? id.substring(prefix.length) : null;
59
59
  }
60
60
 
61
+ /**
62
+ * The id a resource had in its source, for an id in any id space: everything after the
63
+ * prefix, which never contains a '-' (see checkIdPrefix). Null if the id has no prefix.
64
+ *
65
+ * @param {string} id
66
+ * @returns {string|null}
67
+ */
68
+ function originalId(id) {
69
+ if (typeof id !== 'string') {
70
+ return null;
71
+ }
72
+ const i = id.indexOf('-');
73
+ return i > 0 && i < id.length - 1 ? id.substring(i + 1) : null;
74
+ }
75
+
61
76
  /**
62
77
  * Whether a prefix given in the library YAML is acceptable. It's the start of an id, so it
63
78
  * may only use id characters, and no '-', so that the prefix of any id is unambiguous.
@@ -104,4 +119,4 @@ function assignIdSpace(provider, spaceId) {
104
119
 
105
120
  const CORE_PREFIX = 'core';
106
121
 
107
- module.exports = { applyIdPrefix, stripIdPrefix, checkIdPrefix, assignIdSpace, CORE_PREFIX };
122
+ module.exports = { applyIdPrefix, stripIdPrefix, originalId, checkIdPrefix, assignIdSpace, CORE_PREFIX };
@@ -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
 
package/tx/tx-html.js CHANGED
@@ -1329,6 +1329,30 @@ class TxHtmlRenderer {
1329
1329
  return html;
1330
1330
  }
1331
1331
 
1332
+ /**
1333
+ * The page for a read of an id that nothing has, but that several resources had in their
1334
+ * source (see ReadWorker.notFound): a list of them, to pick from.
1335
+ *
1336
+ * @param {string} resourceType
1337
+ * @param {string} id - the id that was asked for
1338
+ * @param {Array<{id, url, version, title, source}>} matches
1339
+ * @returns {string} html
1340
+ */
1341
+ renderIdChoices(resourceType, id, matches) {
1342
+ let html = `<p>There is no ${escape(resourceType)} with the id <code>${escape(id)}</code> on this server. ` +
1343
+ `Every resource here has an id that starts with a prefix for the source it came from, and ` +
1344
+ `${matches.length} ${escape(resourceType)} resources had the id <code>${escape(id)}</code> in their source:</p>`;
1345
+ html += '<table class="grid"><tr><th>Id</th><th>URL</th><th>Version</th><th>Title</th><th>Source</th></tr>';
1346
+ for (const m of matches) {
1347
+ // relative to /{type}/{id}, like the redirect when there is only one
1348
+ html += `<tr><td><a href="${escape(encodeURIComponent(m.id))}">${escape(m.id)}</a></td>` +
1349
+ `<td>${escape(m.url || '')}</td><td>${escape(m.version || '')}</td>` +
1350
+ `<td>${escape(m.title || '')}</td><td>${escape(m.source || '')}</td></tr>`;
1351
+ }
1352
+ html += '</table>';
1353
+ return html;
1354
+ }
1355
+
1332
1356
  /**
1333
1357
  * Render Bundle resource
1334
1358
  */
package/tx/tx.js CHANGED
@@ -516,8 +516,12 @@ class TXModule {
516
516
  return res.status(400).json(buildOperationOutcome('error', 'invalid', `Invalid XML: ${e.message}`));
517
517
  }
518
518
  }
519
- } else if (contentType != 'application/x-www-form-urlencoded') {
520
- return res.status(415).json(buildOperationOutcome('error', 'invalid', `Unsupported Media Type: ${contentType}`));
519
+ } else if (contentType.split(';')[0].trim().toLowerCase() != 'application/x-www-form-urlencoded') {
520
+ // compare the media type alone: a form post may carry a charset parameter
521
+ const message = contentType
522
+ ? `Unsupported Media Type: ${contentType}`
523
+ : `No Content-Type was given for this ${req.method}; use application/fhir+json or application/fhir+xml`;
524
+ return res.status(415).json(buildOperationOutcome('error', 'invalid', message));
521
525
  }
522
526
 
523
527
  if (req.body) {
@@ -994,7 +998,7 @@ class TXModule {
994
998
  ));
995
999
  }
996
1000
  let worker = new ReadWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
997
- await worker.handle(req, res, resourceType);
1001
+ await worker.handle(req, res, resourceType, (req, res, resourceType, id, matches) => this.renderIdChoices(req, res, resourceType, id, matches));
998
1002
  } finally {
999
1003
  this.countRequest(endpointPath, 'read', Date.now() - start);
1000
1004
  }
@@ -1432,6 +1436,17 @@ class TXModule {
1432
1436
  return count;
1433
1437
  }
1434
1438
 
1439
+ // A read of an unprefixed id that several resources had in their source: the page
1440
+ // that lists them (status 300, Multiple Choices). See ReadWorker.notFound.
1441
+ async renderIdChoices(req, res, resourceType, id, matches) {
1442
+ const txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txEndpoint.provider), this.liquid, this.languages, this.i18n, req.txEndpoint.path);
1443
+ const content = txhtml.renderIdChoices(resourceType, id, matches);
1444
+ const html = await txhtml.renderPage(`${resourceType}/${id}`, content, req.txEndpoint, req.txStartTime);
1445
+ res.status(300);
1446
+ res.setHeader('Content-Type', 'text/html');
1447
+ return res.send(html);
1448
+ }
1449
+
1435
1450
  // $closure usage for the home page: null when closure isn't turned on
1436
1451
  closureStats() {
1437
1452
  if (!this.closureStore) {
@@ -8,6 +8,9 @@ const { TerminologyWorker } = require('./worker');
8
8
  const {debugLog} = require("../operation-context");
9
9
 
10
10
  const { buildOperationOutcome } = require('../library/operation-outcome');
11
+ const { originalId } = require('../library/resource-ids');
12
+ const { acceptsHtml } = require('../tx-html');
13
+
11
14
  class ReadWorker extends TerminologyWorker {
12
15
  /**
13
16
  * @param {OperationContext} opContext - Operation context
@@ -32,10 +35,12 @@ class ReadWorker extends TerminologyWorker {
32
35
  * @param {express.Request} req - Express request (with txProvider attached)
33
36
  * @param {express.Response} res - Express response
34
37
  * @param {string} resourceType - The resource type (CodeSystem, ValueSet, ConceptMap)
35
- * @param {Object} log - Logger instance
38
+ * @param {Function} [renderChoices] - (req, res, resourceType, id, matches) renders the
39
+ * page that lists the resources an unprefixed id could mean (see notFound)
36
40
  */
37
- async handle(req, res, resourceType) {
41
+ async handle(req, res, resourceType, renderChoices = null) {
38
42
  const { id } = req.params;
43
+ this.renderChoices = renderChoices;
39
44
 
40
45
  this.log.debug(`Read ${resourceType}/${id}`);
41
46
 
@@ -114,7 +119,7 @@ class ReadWorker extends TerminologyWorker {
114
119
  }
115
120
  }
116
121
 
117
- return res.status(404).json(buildOperationOutcome('error', 'not-found', `CodeSystem/${id} not found`));
122
+ return await this.notFound(req, res, 'CodeSystem', id);
118
123
  }
119
124
 
120
125
  /**
@@ -131,7 +136,7 @@ class ReadWorker extends TerminologyWorker {
131
136
  }
132
137
  }
133
138
 
134
- return res.status(404).json(buildOperationOutcome('error', 'not-found', `ValueSet/${id} not found`));
139
+ return await this.notFound(req, res, 'ValueSet', id);
135
140
  }
136
141
  /**
137
142
  * Handle ConceptMap read
@@ -147,7 +152,84 @@ class ReadWorker extends TerminologyWorker {
147
152
  }
148
153
  }
149
154
 
150
- return res.status(404).json(buildOperationOutcome('error', 'not-found', `ConceptMap/${id} not found`));
155
+ return await this.notFound(req, res, 'ConceptMap', id);
156
+ }
157
+
158
+ /**
159
+ * Nothing has the id asked for. Published IGs link to resources by the id they had in
160
+ * their source (ValueSet/2.16.840.1.113762.1.4.1), from before every id was put in its
161
+ * provider's id space (ValueSet/vsac-2.16.840.1.113762.1.4.1). So for a browser, look
162
+ * for resources that had that id in their source: if there's one, redirect to it, and
163
+ * if there are several, list them. Anything other than a browser still gets a 404 -
164
+ * an unprefixed id can't say which resource it means (see library/resource-ids.js).
165
+ */
166
+ async notFound(req, res, resourceType, id) {
167
+ if (this.renderChoices && acceptsHtml(req)) {
168
+ const matches = await this.findByOriginalId(resourceType, id);
169
+ if (matches.length === 1) {
170
+ req.logInfo = `redirected to ${matches[0].id}`;
171
+ // relative, so that it works whatever path the server is behind; keeps the query
172
+ const q = req.originalUrl.indexOf('?');
173
+ return res.redirect(302, encodeURIComponent(matches[0].id) + (q >= 0 ? req.originalUrl.substring(q) : ''));
174
+ }
175
+ if (matches.length > 1) {
176
+ req.logInfo = `${matches.length} matches`;
177
+ return await this.renderChoices(req, res, resourceType, id, matches);
178
+ }
179
+ }
180
+ return res.status(404).json(buildOperationOutcome('error', 'not-found', `${resourceType}/${id} not found`));
181
+ }
182
+
183
+ /**
184
+ * The resources of a type that had this id in their source, across all the providers.
185
+ *
186
+ * @returns {Promise<Array<{id, url, version, title, source}>>} in provider order
187
+ */
188
+ async findByOriginalId(resourceType, id) {
189
+ const found = [];
190
+ const add = (resource) => {
191
+ const json = resource.jsonObj || resource;
192
+ if (json.id && !found.some(m => m.id === json.id)) {
193
+ found.push({
194
+ id: json.id,
195
+ url: json.url || null,
196
+ version: json.version || null,
197
+ title: json.title || json.name || null,
198
+ source: typeof resource.sourcePackage === 'string' ? resource.sourcePackage : null
199
+ });
200
+ }
201
+ };
202
+
203
+ switch (resourceType) {
204
+ case 'CodeSystem':
205
+ // the map holds a code system under more than one key (url, url|version)
206
+ for (const cs of new Set(this.provider.codeSystems.values())) {
207
+ this.deadCheck('findByOriginalId-cs');
208
+ if (originalId(cs.jsonObj.id) === id) {
209
+ add(cs);
210
+ }
211
+ }
212
+ break;
213
+ case 'ValueSet':
214
+ for (const vsp of this.provider.valueSetProviders) {
215
+ this.deadCheck('findByOriginalId-vs');
216
+ const vs = vsp.spaceId ? await vsp.fetchValueSetById(`${vsp.spaceId}-${id}`) : null;
217
+ if (vs) {
218
+ add(vs);
219
+ }
220
+ }
221
+ break;
222
+ case 'ConceptMap':
223
+ for (const cmp of this.provider.conceptMapProviders) {
224
+ this.deadCheck('findByOriginalId-cm');
225
+ const cm = cmp.spaceId ? await cmp.fetchConceptMapById(`${cmp.spaceId}-${id}`) : null;
226
+ if (cm) {
227
+ add(cm);
228
+ }
229
+ }
230
+ break;
231
+ }
232
+ return found;
151
233
  }
152
234
  }
153
235