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 +51 -1
- package/config-template.json +10 -0
- package/library/npm-audit.js +131 -11
- package/library/sqlite-session-store.js +133 -0
- package/package.json +19 -29
- package/packages/npm-check-page.js +108 -0
- package/packages/npm-check.js +450 -0
- package/packages/openapi.yaml +158 -5
- package/packages/package-crawler.js +203 -20
- package/packages/packages.js +250 -8
- package/packages/readme.md +55 -2
- package/packages/utilities/dedupe-versions.js +242 -0
- package/registry/registry.js +10 -3
- package/token/token.js +7 -10
- package/translations/Messages.properties +3 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-icd11.js +23 -6
- package/tx/cs/icd11-expressions.js +5 -6
- package/tx/importers/icd11-schema.md +1 -0
- package/tx/importers/import-icd11.js +39 -0
- package/tx/importers/import-rxnorm.module.js +1 -1
- package/tx/importers/import-sct.module.js +1 -1
- package/tx/library/designations.js +1 -1
- package/tx/sct/text-index.js +1 -1
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/workers/lookup.js +7 -4
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-
|
|
672
|
+
## [v0.11.0] - 2026-07-30
|
|
623
673
|
|
|
624
674
|
### Added
|
|
625
675
|
|
package/config-template.json
CHANGED
|
@@ -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": {
|
package/library/npm-audit.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"txVersion": "1.9.
|
|
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": "
|
|
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 --
|
|
18
|
-
"test:vcl": "jest --
|
|
19
|
-
"test:xig": "jest --
|
|
20
|
-
"test:packages": "jest --
|
|
21
|
-
"test:token": "jest --
|
|
22
|
-
"test:integration": "jest --
|
|
23
|
-
"test:unit": "jest --
|
|
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": "^
|
|
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": "^
|
|
58
|
-
"node-cron": "^
|
|
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": "^
|
|
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": "^
|
|
81
|
-
"jest-environment-node": "^
|
|
82
|
-
"jest-junit": "^
|
|
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
|
-
"
|
|
111
|
-
|
|
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">✓</span>';
|
|
12
|
+
const FAIL = '<span style="color: #dc3545; font-weight: bold">✗</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 };
|