fhirsmith 0.16.1 → 0.18.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
@@ -62,7 +62,7 @@
62
62
  <a href="/tx-reg/resolve" style="color: gold">Resolve</a> &nbsp;|&nbsp;
63
63
  <a href="/tx-reg/software" style="color: gold">Software</a> &nbsp;|&nbsp;
64
64
  <a href="/tx-reg/log" style="color: gold">Crawler Log</a> &nbsp;|&nbsp;
65
- <a href="/tx-reg/openapi" style="color: gold">API</a>
65
+ <a href="/tx-reg/openapi" style="color: gold">API</a>[%account%]
66
66
  </div> <!-- /container -->
67
67
  </div> <!-- /container -->
68
68
  </div>
@@ -11,6 +11,8 @@ const folders = require('../library/folder-setup');
11
11
  const escape = require('escape-html');
12
12
  const registryOpenApi = require('./openapi');
13
13
  const { FhirsmithReleases, isFhirsmith, describeAge } = require('./fhirsmith-releases');
14
+ const { formatUptime } = require('./uptime');
15
+ const { RegistryAuth } = require('./auth');
14
16
 
15
17
  // The query parameters of the public API. These are the contract published in openapi.yaml,
16
18
  // and tests/registry/openapi.test.js checks that the two agree - so change both together.
@@ -19,6 +21,41 @@ const { FhirsmithReleases, isFhirsmith, describeAge } = require('./fhirsmith-rel
19
21
  const DISCOVERY_PARAMS = ['registry', 'server', 'fhirVersion', 'url', 'authoritativeOnly', 'language'];
20
22
  const RESOLVE_PARAMS = ['fhirVersion', 'url', 'version', 'valueSet', 'authoritativeOnly', 'language', 'usage'];
21
23
 
24
+ // A link to a URL from a registry list or a crawled server: those are other people's
25
+ // content, so only an http(s) URL is linked, and it is escaped
26
+ function safeHref(url) {
27
+ return typeof url === 'string' && /^https?:\/\//i.test(url.trim()) ? escape(url) : '#';
28
+ }
29
+
30
+ /**
31
+ * The base URL that a server's endpoints share: http://tx.fhir.org for http://tx.fhir.org/r4
32
+ * and http://tx.fhir.org/r5. One endpoint is its own base
33
+ */
34
+ function commonBaseUrl(urls) {
35
+ const list = urls.filter(u => u);
36
+ if (list.length === 0) {
37
+ return '';
38
+ }
39
+ if (list.length === 1) {
40
+ return list[0];
41
+ }
42
+ let prefix = list[0];
43
+ for (const url of list.slice(1)) {
44
+ let i = 0;
45
+ while (i < prefix.length && i < url.length && prefix[i] === url[i]) {
46
+ i++;
47
+ }
48
+ prefix = prefix.substring(0, i);
49
+ }
50
+ // back up to a path boundary, unless the prefix already is one
51
+ if (!list.every(u => u === prefix || u.startsWith(prefix + '/'))) {
52
+ prefix = prefix.substring(0, prefix.lastIndexOf('/'));
53
+ }
54
+ prefix = prefix.replace(/\/+$/, '');
55
+ // less than scheme + host is not a base URL at all
56
+ return /^https?:\/\/[^/]+/.test(prefix) ? prefix : list[0];
57
+ }
58
+
22
59
  class RegistryModule {
23
60
  constructor(stats) {
24
61
  this.router = express.Router();
@@ -35,6 +72,8 @@ class RegistryModule {
35
72
  this.currentData = null;
36
73
  this.dataLock = false;
37
74
  this.stats = stats;
75
+ // open until initialize() reads modules.registry.security
76
+ this.auth = new RegistryAuth({}, { logger: this.logger, renderPage: () => {} });
38
77
  }
39
78
 
40
79
  /**
@@ -50,7 +89,10 @@ class RegistryModule {
50
89
  masterUrl: config.masterUrl || 'https://fhir.github.io/ig-registry/tx-servers.json',
51
90
  timeout: config.timeout || 30000,
52
91
  userAgent: config.userAgent || 'FHIRRegistryServer/1.0',
53
- apiKeys: config.apiKeys || {}
92
+ apiKeys: config.apiKeys || {},
93
+ maxResponseMB: config.maxResponseMB,
94
+ uptimeWindowDays: config.uptimeWindowDays,
95
+ uptimeRetentionDays: config.uptimeRetentionDays
54
96
  };
55
97
 
56
98
  this.crawler = new RegistryCrawler(crawlerConfig, this.stats);
@@ -69,6 +111,17 @@ class RegistryModule {
69
111
  // Load saved data if available
70
112
  await this.loadSavedData();
71
113
 
114
+ // Securing the API (see auth.js). With no modules.registry.security, the API is open
115
+ this.auth = new RegistryAuth(config.security, {
116
+ logger: this.logger,
117
+ renderPage: (req, res, title, content, status) => {
118
+ res.status(status || 200);
119
+ res.setHeader('Content-Type', 'text/html');
120
+ res.send(this.renderPage('registry', title, content, this.api.getStatistics(), req));
121
+ }
122
+ });
123
+ await this.auth.initialize();
124
+
72
125
  // Set up routes
73
126
  this.setupRoutes();
74
127
 
@@ -106,6 +159,13 @@ class RegistryModule {
106
159
  } catch (error) {
107
160
  this.logger.info('No saved registry data found, will fetch fresh data');
108
161
  }
162
+ try {
163
+ const fs = require('fs').promises;
164
+ const uptimePath = folders.ensureFilePath('registry', 'registry-uptime.json');
165
+ this.crawler.loadUptime(JSON.parse(await fs.readFile(uptimePath, 'utf8')));
166
+ } catch (error) {
167
+ this.logger.info('No saved uptime history found');
168
+ }
109
169
  }
110
170
 
111
171
  /**
@@ -118,6 +178,8 @@ class RegistryModule {
118
178
 
119
179
  const data = this.crawler.saveData();
120
180
  await fs.writeFile(dataPath, JSON.stringify(data, null, 2));
181
+ const uptimePath = folders.ensureFilePath('registry', 'registry-uptime.json');
182
+ await fs.writeFile(uptimePath, JSON.stringify(this.crawler.saveUptime()));
121
183
  this.logger.debug('Saved registry data to disk');
122
184
  } catch (error) {
123
185
  this.logger.error('Failed to save registry data:', error);
@@ -248,11 +310,23 @@ class RegistryModule {
248
310
  next();
249
311
  });
250
312
 
313
+ // The JSON API is what security protects; the HTML pages stay open
314
+ const guard = this.auth.apiGuard();
315
+ const apiGuard = (req, res, next) => {
316
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
317
+ return acceptsHtml ? next() : guard(req, res, next);
318
+ };
319
+ // failed authentications are limited (successful ones aren't counted)
320
+ const apiLimiter = this.auth.apiLimiter();
321
+ // the session, where there are users, so the pages know who is logged in (it makes none)
322
+ const session = this.auth.optionalSession();
323
+ this.auth.setupRoutes(this.router);
324
+
251
325
  // Main registry page
252
- this.router.get('/', this.handleMainPage.bind(this));
253
- this.router.get('/resolve', this.handleResolveEndpoint.bind(this));
254
- this.router.get('/log', this.handleLogEndpoint.bind(this));
255
- this.router.get('/software', this.handleSoftwarePage.bind(this));
326
+ this.router.get('/', apiLimiter, apiGuard, session, this.handleMainPage.bind(this));
327
+ this.router.get('/resolve', apiLimiter, apiGuard, session, this.handleResolveEndpoint.bind(this));
328
+ this.router.get('/log', session, this.handleLogEndpoint.bind(this));
329
+ this.router.get('/software', session, this.handleSoftwarePage.bind(this));
256
330
 
257
331
  // OpenAPI description of this API: /openapi.json, /openapi.yaml, and /openapi (an HTML
258
332
  // reference for browsers, the JSON otherwise)
@@ -273,8 +347,8 @@ class RegistryModule {
273
347
  if (!htmlServer.hasTemplate('registry')) {
274
348
  htmlServer.loadTemplate('registry', path.join(__dirname, 'registry-template.html'));
275
349
  }
276
- const html = htmlServer.renderPage('registry', 'Terminology Server Registry API',
277
- registryOpenApi.renderHtml(), this.api ? this.api.getStatistics() : {});
350
+ const html = this.renderPage('registry', 'Terminology Server Registry API',
351
+ registryOpenApi.renderHtml(), this.api ? this.api.getStatistics() : {}, req);
278
352
  res.setHeader('Content-Type', 'text/html');
279
353
  res.send(html);
280
354
  } catch (error) {
@@ -284,6 +358,18 @@ class RegistryModule {
284
358
  });
285
359
  }
286
360
 
361
+ /**
362
+ * The registry's pages: html-server's template, plus the login/account link in the
363
+ * navbar (pages rendered without a request show the login link)
364
+ */
365
+ renderPage(template, title, content, stats, req) {
366
+ if (template === 'registry' && !htmlServer.hasTemplate('registry')) {
367
+ htmlServer.loadTemplate('registry', path.join(__dirname, 'registry-template.html'));
368
+ }
369
+ const html = htmlServer.renderPage(template, title, content, stats);
370
+ return html.replace('[%account%]', () => this.auth ? this.auth.navLinks(req || {}) : '');
371
+ }
372
+
287
373
  /**
288
374
  * Render HTML page for code system or value set query
289
375
  * Combines functionality from sendHtmlCS and sendHtmlVS
@@ -334,14 +420,15 @@ class RegistryModule {
334
420
  htmlServer.loadTemplate('registry', templatePath);
335
421
  }
336
422
 
337
- return htmlServer.renderPage(
423
+ return this.renderPage(
338
424
  'registry',
339
425
  'FHIR Terminology Server Registry',
340
426
  this.buildHtmlContent(),
341
427
  {
342
428
  ...this.api.getStatistics(),
343
429
  templateVars: templateVars
344
- }
430
+ },
431
+ req
345
432
  );
346
433
  } catch (error) {
347
434
  this.logger.error('Error rendering page:', error);
@@ -451,11 +538,11 @@ class RegistryModule {
451
538
  stats.crawlInProgress = this.crawlInProgress;
452
539
  stats.lastCrawl = this.lastCrawlTime;
453
540
 
454
- const html = htmlServer.renderPage(
541
+ const html = this.renderPage(
455
542
  'registry',
456
543
  'FHIR Terminology Server Registry',
457
544
  content,
458
- stats
545
+ stats, req
459
546
  );
460
547
 
461
548
  res.setHeader('Content-Type', 'text/html');
@@ -484,7 +571,7 @@ class RegistryModule {
484
571
  const content = this.buildSoftwareContent();
485
572
  const stats = this.api.getStatistics();
486
573
  stats.processingTime = Date.now() - startTime;
487
- const html = htmlServer.renderPage('registry', 'Terminology Server Software', content, stats);
574
+ const html = this.renderPage('registry', 'Terminology Server Software', content, stats, req);
488
575
  res.setHeader('Content-Type', 'text/html');
489
576
  res.send(html);
490
577
  } catch (error) {
@@ -502,24 +589,8 @@ class RegistryModule {
502
589
  '<p>The initial crawl is in progress. Please refresh in a moment.</p></div>';
503
590
  }
504
591
 
505
- const rows = [];
506
- for (const registry of data.registries) {
507
- for (const server of registry.servers || []) {
508
- for (const version of server.versions || []) {
509
- rows.push({
510
- name: server.name || server.code || '',
511
- url: version.address || '',
512
- fhirVersion: version.version || '',
513
- software: version.software && version.software !== 'unknown' ? version.software : '',
514
- softwareVersion: version.softwareVersion || '',
515
- softwareReleaseDate: version.softwareReleaseDate || '',
516
- error: version.error,
517
- lastSuccess: version.lastSuccess
518
- });
519
- }
520
- }
521
- }
522
- rows.sort((a, b) => a.name.localeCompare(b.name) || a.url.localeCompare(b.url));
592
+ const rows = this.softwareRows(data);
593
+ const uptime = this.crawler ? this.crawler.uptime : null;
523
594
 
524
595
  let html = '<h3>Terminology Server Software</h3>';
525
596
  const latest = this.releases ? this.releases.latest() : null;
@@ -532,29 +603,102 @@ class RegistryModule {
532
603
 
533
604
  html += '<table class="grid">';
534
605
  html += '<thead><tr><th>Server</th><th>URL</th><th>FHIR</th><th>Software</th><th>Version</th>' +
535
- '<th>Released</th><th>Age</th></tr></thead><tbody>';
606
+ '<th>Released</th><th>Age</th><th>Uptime</th></tr></thead><tbody>';
536
607
  for (const row of rows) {
537
608
  html += '<tr>';
538
609
  html += `<td>${escape(row.name)}</td>`;
539
- html += `<td><a href="${escape(row.url)}" target="_blank" rel="noopener">${escape(row.url)}</a>`;
540
- if (row.error) {
541
- html += ` <span class="text-danger" title="${escape(row.error)}">(unreachable)</span>`;
542
- }
610
+ html += `<td><a href="${safeHref(row.url)}" target="_blank" rel="noopener">${escape(row.url)}</a>`;
611
+ html += this._renderUnreachable(row);
543
612
  html += '</td>';
544
- html += `<td>${escape(row.fhirVersion)}</td>`;
613
+ html += `<td>${escape(row.fhirVersions.join(', '))}</td>`;
545
614
  html += `<td>${row.software ? escape(row.software.replace('Reference Server', 'HealthIntersections')) : '<i>unknown</i>'}</td>`;
546
615
  html += `<td>${row.softwareVersion ? escape(row.softwareVersion) : '<i>unknown</i>'}</td>`;
547
616
  html += this._renderReleaseCells(row, now);
617
+ html += this._renderUptimeCell(uptime ? uptime.uptime(row.endpoints.map(e => e.url), now) : null);
548
618
  html += '</tr>';
549
619
  }
550
620
  html += '</tbody></table>';
551
621
 
622
+ html += '<p class="text-muted"><small>';
623
+ if (uptime) {
624
+ const days = Math.round(uptime.windowMs / (24 * 60 * 60 * 1000));
625
+ html += `Uptime is the share of this registry's scans over the last ${days} days that the server answered ` +
626
+ '(every scan probes each of its endpoints; a server with one of two endpoints down is half down for that scan). ' +
627
+ 'Scans the registry itself missed are not counted. ';
628
+ }
629
+ html += 'Endpoints of one server that run the same software are shown as one row. ';
552
630
  if (this.releases && this.releases.source !== 'none') {
553
- html += `<p class="text-muted"><small>FHIRsmith release dates from ${escape(this.releases.source)}.</small></p>`;
631
+ html += `FHIRsmith release dates from ${escape(this.releases.source)}.`;
554
632
  }
633
+ html += '</small></p>';
555
634
  return html;
556
635
  }
557
636
 
637
+ /**
638
+ * The rows of the software page: one per server, or per software a server runs, since
639
+ * a server's endpoints (its FHIR versions) are nearly always the same software
640
+ * @returns {Array<{name, url, fhirVersions, software, softwareVersion, softwareReleaseDate, endpoints}>}
641
+ */
642
+ softwareRows(data) {
643
+ const rows = [];
644
+ for (const registry of data.registries) {
645
+ for (const server of registry.servers || []) {
646
+ const groups = new Map(); // software|version -> row
647
+ for (const version of server.versions || []) {
648
+ const software = version.software && version.software !== 'unknown' ? version.software : '';
649
+ const softwareVersion = version.softwareVersion || '';
650
+ const key = `${software}|${softwareVersion}`;
651
+ let row = groups.get(key);
652
+ if (!row) {
653
+ row = {
654
+ name: server.name || server.code || '',
655
+ url: '',
656
+ fhirVersions: [],
657
+ software,
658
+ softwareVersion,
659
+ softwareReleaseDate: version.softwareReleaseDate || '',
660
+ endpoints: []
661
+ };
662
+ groups.set(key, row);
663
+ rows.push(row);
664
+ }
665
+ row.fhirVersions.push(version.version || '');
666
+ row.endpoints.push({ url: version.address || '', fhirVersion: version.version || '', error: version.error || '' });
667
+ if (!row.softwareReleaseDate && version.softwareReleaseDate) {
668
+ row.softwareReleaseDate = version.softwareReleaseDate;
669
+ }
670
+ }
671
+ for (const row of groups.values()) {
672
+ row.url = commonBaseUrl(row.endpoints.map(e => e.url));
673
+ row.fhirVersions.sort();
674
+ }
675
+ }
676
+ }
677
+ rows.sort((a, b) => a.name.localeCompare(b.name) || a.url.localeCompare(b.url));
678
+ return rows;
679
+ }
680
+
681
+ _renderUnreachable(row) {
682
+ const down = row.endpoints.filter(e => e.error);
683
+ if (down.length === 0) {
684
+ return '';
685
+ }
686
+ const title = down.map(e => `${e.fhirVersion}: ${e.error}`).join('\n');
687
+ const what = down.length === row.endpoints.length ? 'unreachable' :
688
+ `${down.map(e => e.fhirVersion).join(', ')} unreachable`;
689
+ return ` <span class="text-danger" title="${escape(title)}">(${escape(what)})</span>`;
690
+ }
691
+
692
+ _renderUptimeCell(u) {
693
+ if (!u) {
694
+ return '<td></td>';
695
+ }
696
+ const cls = u.failures === 0 ? 'text-success' : u.percent >= 99 ? '' : u.percent >= 95 ? 'text-warning' : 'text-danger';
697
+ const since = new Date(u.since).toISOString().substring(0, 10);
698
+ const title = `${u.probes - u.failures} of ${u.probes} probes answered since ${since}`;
699
+ return `<td><span class="${cls}" title="${escape(title)}">${formatUptime(u)}</span></td>`;
700
+ }
701
+
558
702
  _renderReleaseCells(row, now) {
559
703
  if (!isFhirsmith(row.software) || !this.releases) {
560
704
  return this._renderReportedReleaseCells(row, now);
@@ -682,7 +826,7 @@ class RegistryModule {
682
826
 
683
827
  for (const server of serverVersions) {
684
828
  html += '<tr>';
685
- html += `<td><a href="${server.serverUrl}" target="_blank">${escape(server.serverUrl)}</a></td>`;
829
+ html += `<td><a href="${safeHref(server.serverUrl)}" target="_blank">${escape(server.serverUrl)}</a></td>`;
686
830
  html += `<td>${escape(server.software.replace("Reference Server", "HealthIntersections"))}</td>`;
687
831
  html += `<td>${escape(server.authority.replace("Published by", ""))}</td>`;
688
832
  html += `<td>${escape(server.version)}</td>`;
@@ -1032,7 +1176,7 @@ class RegistryModule {
1032
1176
  const rowspan = cs.servers.length;
1033
1177
  html += '<tr>';
1034
1178
  html += `<td rowspan="${rowspan}">${this._highlightWildcard(escape(formattedMask))}</td>`;
1035
- html += `<td><a href="${escape(cs.servers[0].url)}" target="_blank">${escape(cs.servers[0].url)}</a>${this._renderLanguageScope(cs.servers[0])}</td>`;
1179
+ html += `<td><a href="${safeHref(cs.servers[0].url)}" target="_blank">${escape(cs.servers[0].url)}</a>${this._renderLanguageScope(cs.servers[0])}</td>`;
1036
1180
 
1037
1181
  // Format versions as R3/R4/R5
1038
1182
  const formattedVersions = cs.servers[0].versions.map(v => this._formatFhirVersion(v));
@@ -1042,7 +1186,7 @@ class RegistryModule {
1042
1186
  // Additional rows for this code system (if any)
1043
1187
  for (let i = 1; i < cs.servers.length; i++) {
1044
1188
  html += '<tr>';
1045
- html += `<td><a href="${escape(cs.servers[i].url)}" target="_blank">${escape(cs.servers[i].url)}</a>${this._renderLanguageScope(cs.servers[i])}</td>`;
1189
+ html += `<td><a href="${safeHref(cs.servers[i].url)}" target="_blank">${escape(cs.servers[i].url)}</a>${this._renderLanguageScope(cs.servers[i])}</td>`;
1046
1190
 
1047
1191
  // Format versions as R3/R4/R5
1048
1192
  const formattedVersions = cs.servers[i].versions.map(v => this._formatFhirVersion(v));
@@ -1088,7 +1232,7 @@ class RegistryModule {
1088
1232
  const rowspan = vs.servers.length;
1089
1233
  html += '<tr>';
1090
1234
  html += `<td rowspan="${rowspan}">${this._highlightWildcard(escape(vs.mask))}</td>`;
1091
- html += `<td><a href="${escape(vs.servers[0].url)}" target="_blank">${escape(vs.servers[0].url)}</a>${this._renderLanguageScope(vs.servers[0])}</td>`;
1235
+ html += `<td><a href="${safeHref(vs.servers[0].url)}" target="_blank">${escape(vs.servers[0].url)}</a>${this._renderLanguageScope(vs.servers[0])}</td>`;
1092
1236
 
1093
1237
  // Format versions as R3/R4/R5
1094
1238
  const formattedVersions = vs.servers[0].versions.map(v => this._formatFhirVersion(v));
@@ -1098,7 +1242,7 @@ class RegistryModule {
1098
1242
  // Additional rows for this value set (if any)
1099
1243
  for (let i = 1; i < vs.servers.length; i++) {
1100
1244
  html += '<tr>';
1101
- html += `<td><a href="${escape(vs.servers[i].url)}" target="_blank">${escape(vs.servers[i].url)}</a>${this._renderLanguageScope(vs.servers[i])}</td>`;
1245
+ html += `<td><a href="${safeHref(vs.servers[i].url)}" target="_blank">${escape(vs.servers[i].url)}</a>${this._renderLanguageScope(vs.servers[i])}</td>`;
1102
1246
 
1103
1247
  // Format versions as R3/R4/R5
1104
1248
  const formattedVersions = vs.servers[i].versions.map(v => this._formatFhirVersion(v));
@@ -1133,6 +1277,49 @@ class RegistryModule {
1133
1277
  };
1134
1278
  }
1135
1279
 
1280
+ /**
1281
+ * The paragraph for the server's daily report: how many client requests the
1282
+ * API answered, how many servers are registered, and which of them (if any)
1283
+ * answered none of the registry's scans in the period.
1284
+ */
1285
+ dailyReport({since, now}) {
1286
+ const {plural} = require('../library/daily-report');
1287
+ const counts = this.stats.countsSince(since, now);
1288
+ const api = counts.operations.resolve || 0;
1289
+ const sentences = [`The API answered ${plural(api, 'client request')} (${plural(counts.total, 'request')} in all, including the web pages).`];
1290
+ const data = this.api ? this.api.getData() : null;
1291
+ if (!data || !data.registries) {
1292
+ sentences.push('The registry data is not available (no crawl has completed).');
1293
+ return sentences.join(' ');
1294
+ }
1295
+ let servers = 0;
1296
+ let endpoints = 0;
1297
+ const down = [];
1298
+ const uptime = this.crawler ? this.crawler.uptime : null;
1299
+ for (const registry of data.registries) {
1300
+ for (const server of registry.servers || []) {
1301
+ servers++;
1302
+ const addresses = (server.versions || []).map(v => v.address || v.url).filter(Boolean);
1303
+ endpoints += addresses.length;
1304
+ if (uptime && addresses.length > 0) {
1305
+ const u = uptime.uptime(addresses, now, since);
1306
+ if (u && u.failures === u.probes) {
1307
+ down.push(server.name || server.code || addresses[0]);
1308
+ }
1309
+ }
1310
+ }
1311
+ }
1312
+ sentences.push(`${plural(servers, 'server')} ${servers === 1 ? 'is' : 'are'} registered, with ${plural(endpoints, 'endpoint')}.`);
1313
+ if (!uptime) {
1314
+ sentences.push('No scan history is kept, so availability is unknown.');
1315
+ } else if (down.length === 0) {
1316
+ sentences.push('Every server answered at least one scan during the period.');
1317
+ } else {
1318
+ sentences.push(`${down.length === 1 ? 'This server' : 'These servers'} did not answer any scan during the period: ${down.join(', ')}.`);
1319
+ }
1320
+ return sentences.join(' ');
1321
+ }
1322
+
1136
1323
  /**
1137
1324
  * Shutdown the module
1138
1325
  */
@@ -1148,6 +1335,9 @@ class RegistryModule {
1148
1335
  if (this.crawler) {
1149
1336
  this.crawler.shutdown();
1150
1337
  }
1338
+ if (this.auth) {
1339
+ this.auth.shutdown();
1340
+ }
1151
1341
  // Save current data
1152
1342
  if (this.crawler && this.currentData) {
1153
1343
  await this.saveData();
@@ -1232,11 +1422,11 @@ class RegistryModule {
1232
1422
  const stats = this.api.getStatistics();
1233
1423
  stats.processingTime = Date.now() - startTime;
1234
1424
 
1235
- const html = htmlServer.renderPage(
1425
+ const html = this.renderPage(
1236
1426
  'registry',
1237
1427
  'FHIR Terminology Server Resolver',
1238
1428
  content,
1239
- stats
1429
+ stats, req
1240
1430
  );
1241
1431
 
1242
1432
  res.setHeader('Content-Type', 'text/html');
@@ -1287,18 +1477,20 @@ class RegistryModule {
1287
1477
  const stats = this.api.getStatistics();
1288
1478
  stats.processingTime = Date.now() - startTime;
1289
1479
 
1290
- const html = htmlServer.renderPage(
1480
+ const html = this.renderPage(
1291
1481
  'registry',
1292
1482
  'FHIR Terminology Server Resolution Results',
1293
1483
  content,
1294
- stats
1484
+ stats, req
1295
1485
  );
1296
1486
 
1297
1487
  res.setHeader('Content-Type', 'text/html');
1298
1488
  return res.send(html);
1299
1489
  } catch (error) {
1300
1490
  this.logger.error('Error rendering resolve result page:', error);
1301
- // Fall back to JSON if template rendering fails
1491
+ // not the JSON instead: this request was let through as a page, without a token
1492
+ res.status(500).setHeader('Content-Type', 'text/html');
1493
+ return res.send('<!DOCTYPE html><html><body><p>The resolution page could not be shown (see the server log).</p></body></html>');
1302
1494
  }
1303
1495
  }
1304
1496
  res.json(result);
@@ -1322,7 +1514,7 @@ class RegistryModule {
1322
1514
  html += '<div class="card-body">';
1323
1515
  html += `<p><strong>FHIR Version:</strong> ${escape(fhirVersion)}</p>`;
1324
1516
  html += `<p><strong>Resource URL:</strong> ${escape(resourceUrl)}</p>`;
1325
- html += `<p><strong>Registry URL:</strong> <a href="${result['registry-url']}" target="_blank">${escape(result['registry-url'])}</a></p>`;
1517
+ html += `<p><strong>Registry URL:</strong> <a href="${safeHref(result['registry-url'])}" target="_blank">${escape(result['registry-url'])}</a></p>`;
1326
1518
  if (usage) {
1327
1519
  html += `<p><strong>Usage:</strong> ${escape(usage)}</p>`;
1328
1520
  }
@@ -1355,7 +1547,7 @@ class RegistryModule {
1355
1547
  result.authoritative.forEach(server => {
1356
1548
  html += '<tr>';
1357
1549
  html += `<td>${escape(server['server-name'])}</td>`;
1358
- html += `<td><a href="${server.url}" target="_blank">${escape(server.url)}</a></td>`;
1550
+ html += `<td><a href="${safeHref(server.url)}" target="_blank">${escape(server.url)}</a></td>`;
1359
1551
  html += `<td>${this.renderSecurityTags(server)}</td>`;
1360
1552
  html += `<td>${server.languages ? escape(server.languages.join(', ')) : ''}</td>`;
1361
1553
  html += `<td>${server.access_info ? escape(server.access_info) : ''}</td>`;
@@ -1395,7 +1587,7 @@ class RegistryModule {
1395
1587
  result.candidates.forEach(server => {
1396
1588
  html += '<tr>';
1397
1589
  html += `<td>${escape(server['server-name'])}</td>`;
1398
- html += `<td><a href="${server.url}" target="_blank">${escape(server.url)}</a></td>`;
1590
+ html += `<td><a href="${safeHref(server.url)}" target="_blank">${escape(server.url)}</a></td>`;
1399
1591
  html += `<td>${this.renderSecurityTags(server)}</td>`;
1400
1592
  if (server['language-support'] === 'unknown') {
1401
1593
  html += '<td><span class="text-muted" title="This server hosts the code system, but the ecosystem does not know whether it can supply the requested language">unknown</span></td>';
@@ -1559,18 +1751,18 @@ class RegistryModule {
1559
1751
  const stats = this.api.getStatistics();
1560
1752
  stats.processingTime = Date.now() - startTime;
1561
1753
 
1562
- const html = htmlServer.renderPage(
1754
+ const html = this.renderPage(
1563
1755
  'registry',
1564
1756
  'FHIR Terminology Server Registry - Logs',
1565
1757
  content,
1566
- stats
1758
+ stats, req
1567
1759
  );
1568
1760
 
1569
1761
  res.setHeader('Content-Type', 'text/html');
1570
1762
  res.send(html);
1571
1763
  } catch (error) {
1572
1764
  this.logger.error('Error rendering log page:', error);
1573
- res.status(500).send(`<pre>Error rendering log page: ${error.message}</pre>`);
1765
+ res.status(500).send(`<pre>Error rendering log page: ${escape(error.message)}</pre>`);
1574
1766
  }
1575
1767
  } else {
1576
1768
  // Return JSON logs
@@ -1642,4 +1834,5 @@ class RegistryModule {
1642
1834
  RegistryModule.DISCOVERY_PARAMS = DISCOVERY_PARAMS;
1643
1835
  RegistryModule.RESOLVE_PARAMS = RESOLVE_PARAMS;
1644
1836
 
1645
- module.exports = RegistryModule;
1837
+ module.exports = RegistryModule;
1838
+ module.exports.commonBaseUrl = commonBaseUrl;