fhirsmith 0.15.0 → 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,56 @@ 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
+
26
+ ## [0.15.1] - 2026-10-15
27
+
28
+ ### Security
29
+
30
+ - Packages: the SSRF guard on the crawler's outbound requests could be bypassed with an IP address in the URL (`http://127.0.0.1/`, `http://169.254.169.254/`, `http://[::1]/`), because Node makes no DNS lookup for an IP address and the guard only ran there. IP addresses are now checked too
31
+
32
+ ### Added
33
+
34
+ - Packages: `/packages/npm-check` checks any registry against the npm registry protocol, the way npm clients use it (search by name, package document, version document, tarball), and reports each check with the reason for any failure. In a browser it is a form; otherwise it returns the report as JSON. The checks also run once a day against registry.npmjs.org, and the page says if any fail there. Requests are limited in time and size, may not go to non-public addresses other than loopback (so a server can check itself), and are rate limited per client. `npmCheck` in the packages config sets the limits; `"enabled": false` turns it off
35
+ - Packages: npm-protocol test suites that run the same checks against registry.npmjs.org (the control: if npm changes its behaviour, this is where it shows), and against the packages module with known content
36
+ - Packages: the package document has npm's `time` (each version's publication date, plus `created` and `modified`)
37
+ - Packages: `latest` can be used in place of a version (`/packages/{id}/latest`)
38
+ - Packages: `packages/dedupe-versions.js`, a one-off repair for an existing database: removes duplicate id#version rows, rewrites mirror files that don't match the database, and adds the unique index. It needs nothing but Node 24; see Repairing an older database in packages/readme.md
39
+
40
+ ### Changed
41
+
42
+ - Packages: `GET /packages/{id}/{version}` returns npm's version document (JSON) when the request's `Accept` asks for `application/json` (or `application/vnd.npm.install-v1+json`), and the tarball otherwise. On the npm registry this URL is always the version document, and npm clients and tools that treat this server as an npm registry (including the HL7 Java tools, when they are configured with a server of npm type) ask for it with `Accept: application/json` and then download from `dist.tarball`. But FHIR clients have always downloaded the tarball from this URL - the Java tools without sending an `Accept` header at all, browsers and curl with ones that don't ask for JSON - so the tarball stays the default, and only a request that explicitly asks for JSON gets the document. The risk is a client that downloads the tarball from here while asking for JSON (typically a generic HTTP wrapper that sets `Accept: application/json` on every request). For those, and as a guarantee for the Java tools, a request with a `FHIR-Package-Download` header (any value; the Java PackageClient sends `tarball`) always gets the tarball, whatever its `Accept`. The response carries `Vary: Accept, FHIR-Package-Download` so caches keep the two apart
43
+ - Packages: `/packages/-/v1/search` puts the package whose id is the whole search text first, as npm does. `/packages/catalog` is unchanged
44
+ - Packages: each id#version is stored once. Simplifier republishes packages that other feeds publish directly, so its copy is a secondary echo: a version already held is not fetched from Simplifier, and a version held only from Simplifier is replaced when it arrives directly from its publisher's feed. A second direct copy from a different GUID is logged as an error and ignored. The database enforces this with a unique index on (Id, Version); on an existing database the server logs a warning until `dedupe-versions.js` has been run
45
+ - Packages: package downloads keep the bytes the server sent: a `.tgz` labelled `Content-Encoding: gzip` is no longer unzipped by the HTTP client
46
+
47
+ ### Fixed
48
+
49
+ - Packages: the tarball served for some versions didn't match the shasum in the package document (46 of 6961 versions on packages2.fhir.org), so npm clients would reject them. Two causes: the same id#version stored twice from different feeds (the document showed one copy's hash, the mirror held the other), and a tarball that failed validation (e.g. a draft flagged `notForPublication`) overwriting the mirror file before it was rejected. The mirror file is now written only after the package has been validated and stored
50
+ - Packages: packages from feeds that label `.tgz` files `Content-Encoding: x-gzip` (e.g. doc.mednet.swiss) failed with "incorrect header check"
51
+ - Packages: the search, catalog and updates log lines showed `[object Object]` instead of the query
52
+ - Packages: the crawler log showed `??` for an item skipped because another feed had it in the same crawl
53
+
54
+ ### Tx Conformance Statement
55
+
56
+ FHIRsmith v0.15.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.1)
57
+
8
58
  ## [0.15.0] - 2026-10-08
9
59
 
10
60
  ### Added
@@ -619,7 +669,7 @@ FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+
619
669
 
620
670
  FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.2, runner v6.9.12)
621
671
 
622
- ## [v0.11.0] - 2026-mm-dd
672
+ ## [v0.11.0] - 2026-07-30
623
673
 
624
674
  ### Added
625
675
 
@@ -74,6 +74,16 @@
74
74
  "crawler": {
75
75
  "enabled": true,
76
76
  "schedule": "0 * * * *"
77
+ },
78
+ // /packages/npm-check: checks any registry against the npm registry protocol. On by
79
+ // default; the other settings are the defaults. "control": false stops the daily
80
+ // check against the npm registry itself.
81
+ "npmCheck": {
82
+ "enabled": true,
83
+ "maxConcurrentRuns": 2,
84
+ "runsPerWindow": 10,
85
+ "requestTimeout": 30000,
86
+ "maxDownloadMB": 50
77
87
  }
78
88
  },
79
89
  "registry": {
@@ -6,9 +6,17 @@
6
6
  // The package list comes from node_modules/.package-lock.json (what is actually
7
7
  // installed), falling back to package-lock.json. Dev dependencies are left out, and
8
8
  // fhirsmith itself is included, so an advisory published against fhirsmith shows up too.
9
+ // A server installed with `npm install -g fhirsmith` has neither - npm doesn't publish the
10
+ // lock file, and keeps no hidden one for a global package - so then the package list is read
11
+ // from the package.json files in node_modules (a published install has no dev dependencies).
9
12
  //
10
13
  // Config (all optional), top level of config.json:
11
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.
12
20
 
13
21
  const fs = require('fs');
14
22
  const path = require('path');
@@ -16,6 +24,7 @@ const axios = require('axios');
16
24
  const escape = require('escape-html');
17
25
 
18
26
  const DEFAULT_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk';
27
+ const DEFAULT_LATEST_URL = 'https://registry.npmjs.org/fhirsmith/latest';
19
28
  const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info'];
20
29
  const HOUR_MS = 60 * 60 * 1000;
21
30
  const FIRST_RUN_DELAY_MS = 60 * 1000; // let startup finish first
@@ -35,11 +44,34 @@ function describeAgo(time, now = Date.now()) {
35
44
  return `${Math.floor(hours / 24)} days ago`;
36
45
  }
37
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
+
38
69
  class NpmAudit {
39
70
  constructor(config = {}, logger = null, appDir = path.join(__dirname, '..'), stats = null) {
40
71
  this.enabled = config.enabled !== false;
41
72
  this.intervalHours = config.intervalHours > 0 ? config.intervalHours : 24;
42
73
  this.url = config.url || DEFAULT_URL;
74
+ this.latestUrl = config.latestUrl || DEFAULT_LATEST_URL;
43
75
  this.timeout = config.timeout || 60000;
44
76
  this.logger = logger;
45
77
  this.appDir = appDir;
@@ -52,6 +84,8 @@ class NpmAudit {
52
84
  this.findings = []; // [{name, installed: [versions], id, severity, title, url, range}]
53
85
  this.lastError = null; // message from the last attempt, if it failed
54
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
55
89
  }
56
90
 
57
91
  /**
@@ -67,9 +101,6 @@ class NpmAudit {
67
101
  // try the next one
68
102
  }
69
103
  }
70
- if (!lock || !lock.packages) {
71
- throw new Error('No package-lock.json found to audit');
72
- }
73
104
  const found = new Map();
74
105
  const add = (name, version) => {
75
106
  if (!found.has(name)) {
@@ -77,18 +108,23 @@ class NpmAudit {
77
108
  }
78
109
  found.get(name).add(version);
79
110
  };
80
- for (const [key, info] of Object.entries(lock.packages)) {
81
- if (!key || !info || info.dev || info.link || !info.version) {
82
- continue;
111
+ if (lock && lock.packages) {
112
+ for (const [key, info] of Object.entries(lock.packages)) {
113
+ if (!key || !info || info.dev || info.link || !info.version) {
114
+ continue;
115
+ }
116
+ const marker = 'node_modules/';
117
+ const name = info.name || key.substring(key.lastIndexOf(marker) + marker.length);
118
+ add(name, info.version);
83
119
  }
84
- const marker = 'node_modules/';
85
- const name = info.name || key.substring(key.lastIndexOf(marker) + marker.length);
86
- add(name, info.version);
120
+ } else if (!this.scanInstalled(path.join(this.appDir, 'node_modules'), add)) {
121
+ throw new Error('No package-lock.json or node_modules found to audit');
87
122
  }
88
123
  try {
89
124
  const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8'));
90
125
  if (pkg.name && pkg.version) {
91
126
  add(pkg.name, pkg.version);
127
+ this.installedVersion = pkg.version;
92
128
  }
93
129
  } catch (e) {
94
130
  // no package.json - nothing to add
@@ -100,6 +136,88 @@ class NpmAudit {
100
136
  return result;
101
137
  }
102
138
 
139
+ /**
140
+ * Read the installed packages from the package.json files under a node_modules folder,
141
+ * including scoped packages and the node_modules nested inside packages. Linked packages
142
+ * (symlinks) are left out, as they are from a lock file.
143
+ *
144
+ * @returns {boolean} false if there is no node_modules folder
145
+ */
146
+ scanInstalled(dir, add) {
147
+ let entries;
148
+ try {
149
+ entries = fs.readdirSync(dir, { withFileTypes: true });
150
+ } catch (e) {
151
+ return false;
152
+ }
153
+ for (const entry of entries) {
154
+ if (!entry.isDirectory() || entry.name.startsWith('.')) {
155
+ continue;
156
+ }
157
+ const dirs = [];
158
+ if (entry.name.startsWith('@')) {
159
+ let scoped = [];
160
+ try {
161
+ scoped = fs.readdirSync(path.join(dir, entry.name), { withFileTypes: true });
162
+ } catch (e) {
163
+ // unreadable scope folder - nothing in it
164
+ }
165
+ for (const s of scoped) {
166
+ if (s.isDirectory()) {
167
+ dirs.push(path.join(dir, entry.name, s.name));
168
+ }
169
+ }
170
+ } else {
171
+ dirs.push(path.join(dir, entry.name));
172
+ }
173
+ for (const pkgDir of dirs) {
174
+ try {
175
+ const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
176
+ if (pkg.name && pkg.version) {
177
+ add(pkg.name, pkg.version);
178
+ }
179
+ } catch (e) {
180
+ // not a package
181
+ }
182
+ this.scanInstalled(path.join(pkgDir, 'node_modules'), add);
183
+ }
184
+ }
185
+ return true;
186
+ }
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
+
103
221
  async run() {
104
222
  if (this.running) {
105
223
  return;
@@ -137,6 +255,7 @@ class NpmAudit {
137
255
  this.lastError = null;
138
256
  const summary = this.summary();
139
257
  if (findings.length > 0) {
258
+ await this.checkLatest();
140
259
  if (this.logger) {
141
260
  this.logger.warn(`npm audit: ${summary} in ${this.packageCount} packages: ` +
142
261
  findings.map(f => `${f.name} (${f.severity}) ${f.url}`).join('; '));
@@ -225,7 +344,7 @@ class NpmAudit {
225
344
  return `<div class="alert ${cls}" role="alert"><strong>Security:</strong> npm audit found ` +
226
345
  `${escape(this.summary())} in the packages this server uses ` +
227
346
  `(checked ${escape(describeAgo(this.checkedAt.getTime(), now))}). ` +
228
- 'The server administrator should upgrade to the latest FHIRsmith release. ' +
347
+ `${escape(this.advice())} ` +
229
348
  '<a href="/dashboard#npm-audit">Details</a></div>';
230
349
  }
231
350
 
@@ -250,6 +369,7 @@ class NpmAudit {
250
369
  }
251
370
  html += '</p>';
252
371
  if (this.findings.length > 0) {
372
+ html += `<p>${escape(this.advice())}</p>`;
253
373
  html += '<table class="grid"><tr><th>Severity</th><th>Package</th><th>Installed</th><th>Vulnerable</th><th>Advisory</th></tr>';
254
374
  for (const f of this.findings) {
255
375
  const link = /^https:\/\//.test(f.url) ? `<a href="${escape(f.url)}">${escape(f.title || f.url)}</a>` : escape(f.title);
@@ -262,4 +382,4 @@ class NpmAudit {
262
382
  }
263
383
  }
264
384
 
265
- 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,7 +1,7 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.15.0",
4
- "txVersion": "1.9.5-SNAPSHOT",
3
+ "version": "0.16.0",
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",
7
7
  "engines": {
@@ -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": {
@@ -0,0 +1,108 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ // HTML for /packages/npm-check: the form, and the report of a run (see npm-check.js)
8
+
9
+ const escape = require('escape-html');
10
+
11
+ const PASS = '<span style="color: #198754; font-weight: bold">&#10003;</span>';
12
+ const FAIL = '<span style="color: #dc3545; font-weight: bold">&#10007;</span>';
13
+
14
+ function when(iso) {
15
+ return escape(new Date(iso).toUTCString());
16
+ }
17
+
18
+ function form(values) {
19
+ let html = '<form method="get" action="/packages/npm-check" class="mb-4">';
20
+ html += '<table class="grid">';
21
+ html += `<tr><td><label for="registry">Registry URL</label></td><td><input type="url" id="registry" name="registry" size="60" required maxlength="500" placeholder="https://registry.example.org/packages" value="${escape(values.registry || '')}"></td></tr>`;
22
+ html += `<tr><td><label for="package">Package</label></td><td><input type="text" id="package" name="package" size="40" required maxlength="100" value="${escape(values.package || 'hl7.fhir.r4.core')}"></td></tr>`;
23
+ html += `<tr><td><label for="version">Version</label></td><td><input type="text" id="version" name="version" size="20" required maxlength="50" value="${escape(values.version || '4.0.1')}"></td></tr>`;
24
+ html += '</table>';
25
+ html += '<p class="mt-2"><button type="submit" class="btn btn-primary">Run the checks</button> ';
26
+ html += '<small>A run makes about ten requests to the registry, including downloading the package\'s tarball, and can take up to a minute.</small></p>';
27
+ html += '</form>';
28
+ return html;
29
+ }
30
+
31
+ function controlSummary(control, controlReport) {
32
+ if (!control) {
33
+ return '';
34
+ }
35
+ const what = `${escape(control.registry)} (${escape(control.pkg)} ${escape(control.version)})`;
36
+ if (!controlReport) {
37
+ return `<p><small>The checks are verified daily against the npm registry itself, ${what}. That hasn't run yet; reload this page in a minute to see it.</small></p>`;
38
+ }
39
+ const total = controlReport.passed + controlReport.failed;
40
+ if (controlReport.failed === 0) {
41
+ return `<p><small>${PASS} The checks are verified daily against the npm registry itself: all ${total} passed against ${what} on ${when(controlReport.finished)}.</small></p>`;
42
+ }
43
+ let html = `<div class="alert alert-warning"><p>${FAIL} The checks are verified daily against the npm registry itself, and on ${when(controlReport.finished)} ${controlReport.failed} of ${total} failed against ${what}. The npm registry is the reference, so those checks may be out of date - treat their results below with caution:</p><ul>`;
44
+ for (const step of controlReport.steps) {
45
+ for (const c of step.checks.filter(x => x.status === 'fail')) {
46
+ html += `<li>${escape(step.title)}: ${escape(c.title)} - ${escape(c.message)}</li>`;
47
+ }
48
+ }
49
+ return html + '</ul></div>';
50
+ }
51
+
52
+ function intro() {
53
+ let html = '<p>Checks a registry against the npm registry protocol, the way an npm client uses it: search for a package by name, ';
54
+ html += 'read its package document, read one version\'s document, and download that version\'s tarball. ';
55
+ html += 'At each step it checks the parts of the response that npm clients depend on. ';
56
+ html += 'Give the registry\'s base URL (the one you would pass to <code>npm --registry</code>) and a package version it holds.</p>';
57
+ return html;
58
+ }
59
+
60
+ function renderForm({ values = {}, control, controlReport }) {
61
+ return intro() + form(values) + controlSummary(control, controlReport);
62
+ }
63
+
64
+ function renderReport({ report, control, controlReport }) {
65
+ const total = report.passed + report.failed;
66
+ let html = '';
67
+ html += `<p><strong>Registry:</strong> ${escape(report.registry)}<br>`;
68
+ html += `<strong>Package:</strong> ${escape(report.package)} ${escape(report.version)}<br>`;
69
+ html += `<strong>Run:</strong> ${when(report.started)}</p>`;
70
+ if (report.failed === 0) {
71
+ html += `<div class="alert alert-success">${PASS} All ${total} checks passed.</div>`;
72
+ } else {
73
+ html += `<div class="alert alert-danger">${FAIL} ${report.failed} of ${total} checks failed.</div>`;
74
+ }
75
+ html += controlSummary(control, controlReport);
76
+
77
+ for (const step of report.steps) {
78
+ html += `<h4 class="mt-4">${escape(step.title)}: <code>${escape(step.request)}</code></h4>`;
79
+ html += '<table class="grid">';
80
+ for (const c of step.checks) {
81
+ html += `<tr><td>${c.status === 'pass' ? PASS : FAIL}</td><td>${escape(c.title)}`;
82
+ if (c.status !== 'pass') {
83
+ html += `<br><span style="color: #dc3545">${escape(c.message)}</span>`;
84
+ const urls = [...new Set(c.uses.map(u => report.requests[u]?.url).filter(Boolean))];
85
+ if (urls.length > 0) {
86
+ html += `<br><small>${urls.map(escape).join('<br>')}</small>`;
87
+ }
88
+ }
89
+ html += '</td></tr>';
90
+ }
91
+ html += '</table>';
92
+ }
93
+
94
+ html += '<h4 class="mt-4">Requests</h4>';
95
+ html += '<table class="grid"><tr><td><b>URL</b></td><td><b>Result</b></td></tr>';
96
+ for (const r of Object.values(report.requests)) {
97
+ const result = r.error ? `<span style="color: #dc3545">${escape(r.error)}</span>`
98
+ : `${escape(String(r.status))} ${escape(r.contentType || '')} (${escape(String(r.bytes))} bytes)`;
99
+ html += `<tr><td><small>${escape(r.url)}</small></td><td><small>${result}</small></td></tr>`;
100
+ }
101
+ html += '</table>';
102
+
103
+ html += '<h4 class="mt-4">Check again</h4>';
104
+ html += form({ registry: report.registry, package: report.package, version: report.version });
105
+ return html;
106
+ }
107
+
108
+ module.exports = { renderForm, renderReport };