fhirsmith 0.17.0 → 0.18.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.
Files changed (131) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +432 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +8 -3
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +10 -0
  89. package/tx/README.md +52 -0
  90. package/tx/closure/closure-store.js +14 -0
  91. package/tx/cs/cs-api.js +92 -14
  92. package/tx/cs/cs-cs.js +64 -3
  93. package/tx/cs/cs-icd11.js +366 -112
  94. package/tx/cs/cs-icf.js +431 -0
  95. package/tx/cs/cs-loinc.js +296 -27
  96. package/tx/cs/cs-omop.js +10 -3
  97. package/tx/cs/cs-snomed.js +1210 -80
  98. package/tx/cs/cs-ucum.js +6 -0
  99. package/tx/cs/icd11-expressions.js +191 -78
  100. package/tx/importers/csv-records.js +35 -0
  101. package/tx/importers/export-icd11-codesystem.js +58 -12
  102. package/tx/importers/icd11-schema.md +9 -5
  103. package/tx/importers/import-loinc.module.js +177 -41
  104. package/tx/importers/import-sct.module.js +66 -1
  105. package/tx/importers/subset-loinc.module.js +25 -6
  106. package/tx/library/codesystem.js +4 -1
  107. package/tx/library/designations.js +23 -30
  108. package/tx/library.js +98 -4
  109. package/tx/openapi-operations.js +6 -1
  110. package/tx/params.js +8 -1
  111. package/tx/provider.js +24 -6
  112. package/tx/sct/documentation.md +251 -0
  113. package/tx/sct/ecl.js +758 -46
  114. package/tx/sct/expressions.js +113 -52
  115. package/tx/sct/structures.js +8 -2
  116. package/tx/syndication/syndication-feed.js +218 -0
  117. package/tx/syndication/syndication-store.js +415 -0
  118. package/tx/tx-html.js +15 -1
  119. package/tx/tx.fhir.org.yml +3 -4
  120. package/tx/tx.js +144 -2
  121. package/tx/vs/vs-vsac.js +25 -6
  122. package/tx/workers/closure.js +20 -0
  123. package/tx/workers/expand.js +35 -8
  124. package/tx/workers/lookup.js +61 -6
  125. package/tx/workers/subsumes.js +1 -1
  126. package/tx/workers/translate.js +166 -15
  127. package/tx/workers/validate.js +138 -6
  128. package/utilities/make-sct-loinc-subset.js +250 -0
  129. package/utilities/make-sct-test-extension.js +264 -0
  130. package/xig/xig.js +32 -0
  131. package/registry/readme.md +0 -124
@@ -0,0 +1,77 @@
1
+ // library/mailer.js
2
+ // Outbound email for the server, over SMTP (nodemailer). One of these is made from the
3
+ // top-level "email" block of config.json, and anything that wants to send mail asks it
4
+ // whether it's configured first - a server with no "email" block simply doesn't send.
5
+ //
6
+ // "email": {
7
+ // "host": "smtp.example.org",
8
+ // "port": 587, // default 587; 465 implies secure
9
+ // "secure": false, // TLS from the start (true for 465), else STARTTLS
10
+ // "user": "...", "password": "...", // optional - leave out for an open relay
11
+ // "from": "FHIRsmith <fhirsmith@example.org>"
12
+ // }
13
+ //
14
+ // nodemailer is only loaded when a message is actually sent, so a server that doesn't
15
+ // send mail doesn't need it installed.
16
+
17
+ class Mailer {
18
+
19
+ constructor(config, log) {
20
+ this.config = config || {};
21
+ this.log = log || console;
22
+ this.transport = null;
23
+ }
24
+
25
+ /**
26
+ * Whether enough has been configured to send: a host to send through and an
27
+ * address to send from.
28
+ */
29
+ configured() {
30
+ return Boolean(this.config.host && this.config.from);
31
+ }
32
+
33
+ _transport() {
34
+ if (!this.transport) {
35
+ const nodemailer = require('nodemailer');
36
+ const port = this.config.port || 587;
37
+ const options = {
38
+ host: this.config.host,
39
+ port: port,
40
+ secure: this.config.secure ?? (port === 465)
41
+ };
42
+ if (this.config.user) {
43
+ options.auth = {user: this.config.user, pass: this.config.password};
44
+ }
45
+ this.transport = nodemailer.createTransport(options);
46
+ }
47
+ return this.transport;
48
+ }
49
+
50
+ /**
51
+ * Send one message. `to` is an address or a list of them; text is the body.
52
+ * Resolves with nodemailer's result, or throws - callers decide what a
53
+ * failure means to them.
54
+ */
55
+ async send({to, subject, text, html}) {
56
+ if (!this.configured()) {
57
+ throw new Error('Email is not configured (email.host and email.from are needed)');
58
+ }
59
+ if (!to || (Array.isArray(to) && to.length === 0)) {
60
+ throw new Error('No recipient given');
61
+ }
62
+ const message = {
63
+ from: this.config.from,
64
+ to: Array.isArray(to) ? to.join(', ') : to,
65
+ subject: subject,
66
+ text: text
67
+ };
68
+ if (html) {
69
+ message.html = html;
70
+ }
71
+ const result = await this._transport().sendMail(message);
72
+ this.log.info(`Email sent to ${message.to}: ${subject}`);
73
+ return result;
74
+ }
75
+ }
76
+
77
+ module.exports = Mailer;
@@ -61,10 +61,29 @@ function urlsIn(name, value) {
61
61
  return [value];
62
62
  }
63
63
 
64
+ // CSS comments removed by a single pass: the obvious regex, /\/\*[\s\S]*?\*\//g, rescans to
65
+ // the end of the string for every unterminated '/*', which is quadratic - a style attribute
66
+ // of a few hundred KB of '/* ' held the event loop for seconds
67
+ function stripCssComments(css) {
68
+ let out = '';
69
+ let i = 0;
70
+ for (;;) {
71
+ const start = css.indexOf('/*', i);
72
+ if (start < 0) {
73
+ return out + css.substring(i);
74
+ }
75
+ out += css.substring(i, start);
76
+ const end = css.indexOf('*/', start + 2);
77
+ if (end < 0) {
78
+ return out;
79
+ }
80
+ i = end + 2;
81
+ }
82
+ }
83
+
64
84
  // CSS with comments removed and escapes decoded, so \65 xpression( is seen as expression(
65
85
  function normaliseCss(css) {
66
- return String(css)
67
- .replace(/\/\*[\s\S]*?\*\//g, '')
86
+ return stripCssComments(String(css))
68
87
  .replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => {
69
88
  const cp = parseInt(hex, 16);
70
89
  return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : '';
@@ -371,6 +371,31 @@ class NpmAudit {
371
371
  return this.findings.some(f => f.severity === 'critical' || f.severity === 'high');
372
372
  }
373
373
 
374
+ /**
375
+ * For the daily report: plain text, and only when there's something to act
376
+ * on - packages with known vulnerabilities (and what to do about them), a
377
+ * check that couldn't be run, or a newer FHIRsmith release. '' otherwise.
378
+ */
379
+ reportText() {
380
+ if (!this.enabled) {
381
+ return '';
382
+ }
383
+ const sentences = [];
384
+ if (this.findings.length > 0) {
385
+ const list = this.findings.map(f => `${f.name} ${f.installed.join('/')} (${f.severity}: ${f.range})`).join(', ');
386
+ sentences.push(`npm audit found ${this.summary()} in the packages this server uses: ${list}. ${this.advice()}`);
387
+ }
388
+ if (this.lastError) {
389
+ sentences.push(this.checkedAt
390
+ ? `The latest npm audit check failed: ${this.lastError}.`
391
+ : `The npm audit check could not be run: ${this.lastError}.`);
392
+ }
393
+ if (this.findings.length === 0 && this.installedVersion && compareVersions(this.installedVersion, this.latestVersion) < 0) {
394
+ sentences.push(`FHIRsmith ${this.latestVersion} is available (this server is running ${this.installedVersion}).`);
395
+ }
396
+ return sentences.join(' ');
397
+ }
398
+
374
399
  /**
375
400
  * Home page: nothing unless there is something to report
376
401
  */
@@ -520,8 +520,10 @@ class PackageManager {
520
520
  const packagePath = path.join(this.cacheFolder, packageName);
521
521
 
522
522
  try {
523
- const stats = await fs.stat(packagePath);
524
- if (stats.isDirectory()) {
523
+ // the folder alone is not enough: a download that failed after the folder was made
524
+ // leaves an empty one, which must not count as the package
525
+ const stats = await fs.stat(path.join(packagePath, 'package', 'package.json'));
526
+ if (stats.isFile()) {
525
527
  return packageName;
526
528
  }
527
529
  } catch (error) {
@@ -130,4 +130,32 @@ class SqliteSessionStore extends session.Store {
130
130
  }
131
131
  }
132
132
 
133
- module.exports = { SqliteSessionStore };
133
+ /**
134
+ * The callback API this store uses (run, get), over a better-sqlite3 database - for the
135
+ * modules that keep their data with better-sqlite3 rather than sqlite3.
136
+ * @param {object} db - an open better-sqlite3 Database
137
+ */
138
+ function betterSqlite(db) {
139
+ return {
140
+ run(sql, params, cb) {
141
+ let info;
142
+ try {
143
+ info = db.prepare(sql).run(...params);
144
+ } catch (e) {
145
+ return cb(e);
146
+ }
147
+ cb.call(info, null);
148
+ },
149
+ get(sql, params, cb) {
150
+ let row;
151
+ try {
152
+ row = db.prepare(sql).get(...params);
153
+ } catch (e) {
154
+ return cb(e);
155
+ }
156
+ cb(null, row);
157
+ }
158
+ };
159
+ }
160
+
161
+ module.exports = { SqliteSessionStore, betterSqlite };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.17.0",
4
- "txVersion": "1.9.6",
3
+ "version": "0.18.1",
4
+ "txVersion": "1.10.0",
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": {
@@ -19,6 +19,8 @@
19
19
  "test:xig": "jest --testPathPatterns=xig",
20
20
  "test:packages": "jest --testPathPatterns=packages",
21
21
  "test:token": "jest --testPathPatterns=token",
22
+ "test:testing": "jest --testPathPatterns=tests/testing",
23
+ "test:ips": "jest --testPathPatterns=tests/ips",
22
24
  "test:integration": "jest --testPathPatterns=integration",
23
25
  "test:unit": "jest --testPathPatterns=unit",
24
26
  "lint": "eslint . --ext .js --ignore-path .gitignore",
@@ -46,15 +48,17 @@
46
48
  "express-rate-limit": "^7.4.1",
47
49
  "express-session": "^1.19.0",
48
50
  "fast-xml-parser": "^5.3.4",
49
- "fhir-validator-wrapper": "1.4.0",
51
+ "fhir-validator-wrapper": "1.5.0",
50
52
  "fhirpath": "^5.2.1",
51
53
  "fs-extra": "^11.3.3",
52
54
  "ini": "^6.0.0",
53
55
  "inquirer": "^8.2.5",
56
+ "jose": "^5.10.0",
54
57
  "liquidjs": "^10.25.5",
55
58
  "lusca": "^1.7.0",
56
59
  "natural": "^8.1.1",
57
60
  "node-cron": "^4.6.0",
61
+ "nodemailer": "^10.2.0",
58
62
  "pako": "^2.1.0",
59
63
  "parse5": "^7.3.0",
60
64
  "passport": "^0.7.0",
@@ -62,6 +66,7 @@
62
66
  "passport-github2": "^0.1.12",
63
67
  "passport-google-oauth20": "^2.0.0",
64
68
  "properties-file": "^3.6.4",
69
+ "qrcode-generator": "^2.0.4",
65
70
  "re2js": "^2.8.0",
66
71
  "rimraf": "^5.0.10",
67
72
  "sqlite3": "^6.0.1",
@@ -1282,11 +1282,15 @@ class PackagesModule {
1282
1282
 
1283
1283
  next();
1284
1284
  pckLog.info(`/download/${id}/${version}`);
1285
- } finally {
1285
+ } catch (error) {
1286
1286
  this.stats.countRequest('version', Date.now() - start);
1287
+ throw error;
1287
1288
  }
1288
1289
  }, async (req, res) => {
1289
1290
  const start = Date.now();
1291
+ // counted as 'download' (the tarball) or 'version' (its JSON document) - the
1292
+ // daily report counts the downloads as packages served
1293
+ let name = 'version';
1290
1294
  try {
1291
1295
  try {
1292
1296
  const {id, version} = req.params;
@@ -1297,6 +1301,7 @@ class PackagesModule {
1297
1301
  await this.serveVersionDocument(req.secure, id, version, req, res);
1298
1302
  pckLog.info(`/version/${id}/${version}`);
1299
1303
  } else {
1304
+ name = 'download';
1300
1305
  await this.serveDownload(req.secure, id, version, res);
1301
1306
  pckLog.info(`/download/${id}/${version}`);
1302
1307
  }
@@ -1305,7 +1310,7 @@ class PackagesModule {
1305
1310
  res.status(500).json({error: 'Internal server error'});
1306
1311
  }
1307
1312
  } finally {
1308
- this.stats.countRequest('version', Date.now() - start);
1313
+ this.stats.countRequest(name, Date.now() - start);
1309
1314
  }
1310
1315
  });
1311
1316
 
@@ -1322,8 +1327,9 @@ class PackagesModule {
1322
1327
 
1323
1328
  next();
1324
1329
  pckLog.info(`/page/${page}`);
1325
- } finally {
1330
+ } catch (error) {
1326
1331
  this.stats.countRequest('page', Date.now() - start);
1332
+ throw error;
1327
1333
  }
1328
1334
  }, async (req, res) => {
1329
1335
  const start = Date.now();
@@ -2805,6 +2811,31 @@ class PackagesModule {
2805
2811
  return va.pre.localeCompare(vb.pre, undefined, { numeric: true });
2806
2812
  }
2807
2813
 
2814
+ /**
2815
+ * The paragraph for the server's daily report: packages served (tarball
2816
+ * downloads) and package versions newly indexed by the crawler in the period.
2817
+ */
2818
+ async dailyReport({since, now}) {
2819
+ const {plural} = require('../library/daily-report');
2820
+ const counts = this.stats.countsSince(since, now);
2821
+ const downloads = counts.operations.download || 0;
2822
+ const sentences = [`${plural(downloads, 'package')} ${downloads === 1 ? 'was' : 'were'} served (${plural(counts.total, 'request')} in all).`];
2823
+ if (this.db) {
2824
+ try {
2825
+ const row = await new Promise((resolve, reject) => this.db.get(
2826
+ 'SELECT COUNT(*) AS n, COUNT(DISTINCT Id) AS ids FROM PackageVersions WHERE Indexed > ?',
2827
+ [new Date(since).toISOString().replace('T', ' ').substring(0, 19)],
2828
+ (err, row) => err ? reject(err) : resolve(row)));
2829
+ sentences.push(row.n === 0
2830
+ ? 'No new package versions were found.'
2831
+ : `${plural(row.n, 'new package version')} ${row.n === 1 ? 'was' : 'were'} found, of ${plural(row.ids, 'package')}.`);
2832
+ } catch (e) {
2833
+ sentences.push(`The new packages could not be counted: ${e.message}.`);
2834
+ }
2835
+ }
2836
+ return sentences.join(' ');
2837
+ }
2838
+
2808
2839
  async shutdown() {
2809
2840
  pckLog.info('Shutting down Packages module...');
2810
2841
 
@@ -2987,6 +2987,35 @@ class PublisherModule {
2987
2987
  });
2988
2988
  }
2989
2989
 
2990
+ /**
2991
+ * The paragraph for the server's daily report: publication runs started in
2992
+ * the period, and how many finished successfully.
2993
+ */
2994
+ async dailyReport({since, now}) {
2995
+ const {plural} = require('../library/daily-report');
2996
+ if (!this.db) {
2997
+ return 'The database is not open.';
2998
+ }
2999
+ const get = (sql, params) => new Promise((resolve, reject) =>
3000
+ this.db.get(sql, params, (err, row) => err ? reject(err) : resolve(row)));
3001
+ // the timestamps are CURRENT_TIMESTAMP: UTC, 'YYYY-MM-DD HH:MM:SS'
3002
+ const utc = ms => new Date(ms).toISOString().replace('T', ' ').substring(0, 19);
3003
+ try {
3004
+ const started = await get('SELECT COUNT(*) AS n FROM tasks WHERE building_at > ? AND building_at <= ?', [utc(since), utc(now)]);
3005
+ const finished = await get('SELECT COUNT(*) AS n FROM tasks WHERE completed_at > ? AND completed_at <= ?', [utc(since), utc(now)]);
3006
+ const failed = await get('SELECT COUNT(*) AS n FROM tasks WHERE failed_at > ? AND failed_at <= ?', [utc(since), utc(now)]);
3007
+ const waiting = await get("SELECT COUNT(*) AS n FROM tasks WHERE status IN ('queued', 'waiting for approval')", []);
3008
+ let text = `${plural(started.n, 'publication run')} ${started.n === 1 ? 'was' : 'were'} started, and ${finished.n} finished successfully`;
3009
+ text += failed.n > 0 ? ` (${failed.n} failed).` : '.';
3010
+ if (waiting.n > 0) {
3011
+ text += ` ${plural(waiting.n, 'task')} ${waiting.n === 1 ? 'is' : 'are'} queued or waiting for approval.`;
3012
+ }
3013
+ return text;
3014
+ } catch (e) {
3015
+ return `The database could not be read: ${e.message}.`;
3016
+ }
3017
+ }
3018
+
2990
3019
  async shutdown() {
2991
3020
  this.logger.info('Shutting down publisher module...');
2992
3021