fhirsmith 0.18.1 → 1.0.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 (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
package/registry/api.js CHANGED
@@ -27,6 +27,20 @@ function securityFlags(security) {
27
27
  }
28
28
  }
29
29
 
30
+ // Code systems that tx.fhir.org handles specially. When the registry doesn't settle where
31
+ // a request involving one of these goes, it goes to the primary server (see chooseServer)
32
+ const SNOMED_CT = 'http://snomed.info/sct';
33
+ const PRIMARY_SERVER_SYSTEMS = ['http://unitsofmeasure.org', 'http://loinc.org', SNOMED_CT,
34
+ 'http://www.nlm.nih.gov/research/umls/rxnorm', 'http://hl7.org/fhir/sid/cvx', 'urn:ietf:bcp:13', 'urn:ietf:bcp:47',
35
+ 'urn:ietf:rfc:3986', 'http://www.ama-assn.org/go/cpt', 'urn:oid:1.2.36.1.2001.1005.17', 'urn:iso:std:iso:3166',
36
+ 'http://varnomen.hgvs.org', 'http://unstats.un.org/unsd/methods/m49/m49.htm', 'urn:iso:std:iso:4217',
37
+ 'http://hl7.org/fhir/sid/ndc', 'http://fhir.ohdsi.org/CodeSystem/concepts', 'http://fdasis.nlm.nih.gov', 'https://www.usps.com/'];
38
+
39
+ function isTxFhirOrg(server) {
40
+ const s = server.replace('http://', 'https://');
41
+ return s.startsWith('https://tx.fhir.org/') || s.startsWith('https://tx-dev.fhir.org/');
42
+ }
43
+
30
44
  class RegistryAPI {
31
45
  constructor(crawler) {
32
46
  this.crawler = crawler;
@@ -592,6 +606,170 @@ class RegistryAPI {
592
606
  };
593
607
  }
594
608
 
609
+ /**
610
+ * Choose one server for a request that involves a set of code systems.
611
+ *
612
+ * This is a server side copy of the routing logic in the HL7 Java tooling
613
+ * (TerminologyClientManager.chooseServer in org.hl7.fhir.core), for clients that aren't
614
+ * Java. It doesn't replace that code. The Java client does the work that needs its own
615
+ * context before routing, and so must the caller here:
616
+ *
617
+ * - systems: the code systems involved, as url or url|version, from the codings and the
618
+ * value set's includes and excludes, following imported value sets. Versions fixed by
619
+ * expansion parameters (system-version, force-system-version) should already be applied.
620
+ * If the caller couldn't follow an import, it should just use its default server.
621
+ * - language: only for language sensitive requests (see resolveCodeSystem).
622
+ *
623
+ * Differences from the Java code:
624
+ * - 'Does a candidate really have the code system' is answered from the content mode the
625
+ * server reported in its TerminologyCapabilities, not by searching the server. If it
626
+ * didn't report one, the candidate is assumed to be usable.
627
+ * - The 'primary server' for the systems tx.fhir.org handles specially is the default.
628
+ * - The value set's source server is found by resolving the value set here.
629
+ * - Ties are broken by order (the first system, then registration order), not hash order.
630
+ *
631
+ * @returns {{server: string, log: string[]}} the server to use, and why
632
+ */
633
+ chooseServer(fhirVersion, systems, defaultServer, valueSet = '', usage = '', language = '') {
634
+ if (!fhirVersion) {
635
+ throw new Error('A FHIR version is required');
636
+ }
637
+ if (!defaultServer) {
638
+ throw new Error('A default server is required');
639
+ }
640
+ const log = [];
641
+ const list = [...new Set((systems || []).filter(s => s))];
642
+ if (valueSet) {
643
+ log.push(`Value set: ${valueSet}`);
644
+ }
645
+ if (list.length === 0) {
646
+ log.push('No code systems: using the default server');
647
+ return { server: defaultServer, log };
648
+ }
649
+ const server = this._chooseServer(fhirVersion, list, defaultServer, valueSet, usage, language, log);
650
+ return { server, log };
651
+ }
652
+
653
+ _chooseServer(fhirVersion, systems, defaultServer, valueSet, usage, language, log) {
654
+ // what the registry says about each system on its own
655
+ const choices = systems.map(system => {
656
+ const r = this.resolveCodeSystem(fhirVersion, system, false, usage, language).result;
657
+ const choice = { system, authoritative: [], candidates: [], content: {} };
658
+ for (const e of r.authoritative || []) {
659
+ if (!choice.authoritative.includes(e.url)) choice.authoritative.push(e.url);
660
+ }
661
+ for (const e of r.candidates || []) {
662
+ if (!choice.candidates.includes(e.url)) {
663
+ choice.candidates.push(e.url);
664
+ choice.content[e.url] = e.content;
665
+ }
666
+ }
667
+ log.push(`${system}: authoritative [${choice.authoritative.join(', ')}], candidates [${choice.candidates.join(', ')}]`);
668
+ return choice;
669
+ });
670
+ // the registry knows no server at all for the system
671
+ const unresolved = c => c.authoritative.length === 0 && c.candidates.length === 0;
672
+
673
+ // 1. a server that's authoritative for all of them
674
+ for (const c of choices) {
675
+ for (const s of c.authoritative) {
676
+ if (choices.every(t => t.authoritative.includes(s))) {
677
+ log.push(`Found authoritative server ${s}`);
678
+ return s;
679
+ }
680
+ }
681
+ }
682
+
683
+ // 2. a server that's authoritative for one of them, and a candidate for the others. A
684
+ // system that no server is known for doesn't constrain the choice
685
+ for (const c of choices) {
686
+ for (const s of c.authoritative) {
687
+ if (choices.every(t => unresolved(t) || t.authoritative.includes(s) || t.candidates.includes(s))) {
688
+ log.push(`Found partially authoritative server ${s}`);
689
+ return s;
690
+ }
691
+ }
692
+ }
693
+
694
+ // 3. code systems with special handling on tx.fhir.org, that might not be resolved above.
695
+ // We don't want them going to secondary servers by accident
696
+ const bases = systems.map(s => s.includes('|') ? s.substring(0, s.indexOf('|')) : s);
697
+ if (systems.length > 1 && bases.includes(SNOMED_CT)) {
698
+ log.push(`Several code systems including SNOMED CT: choosing for SNOMED CT alone`);
699
+ return this._chooseServer(fhirVersion, [SNOMED_CT], defaultServer, valueSet, usage, language, log);
700
+ }
701
+ const special = bases.find(b => PRIMARY_SERVER_SYSTEMS.includes(b));
702
+ if (special) {
703
+ log.push(`Use primary server (the default) for ${special}`);
704
+ return defaultServer;
705
+ }
706
+
707
+ // 4. a server that's a candidate for all of them (again, systems no server is known for
708
+ // don't constrain the choice), and that has a usable copy of the code systems
709
+ for (const c of choices) {
710
+ for (const s of c.candidates) {
711
+ if (choices.every(t => unresolved(t) || t.candidates.includes(s)) &&
712
+ this._supportsAll(s, choices, unresolved, log)) {
713
+ log.push(`Found candidate server ${s}`);
714
+ return s;
715
+ }
716
+ }
717
+ }
718
+
719
+ // 5. no agreement - take the one that is most authoritative
720
+ const counts = new Map();
721
+ for (const c of choices) {
722
+ for (const s of c.authoritative) {
723
+ counts.set(s, (counts.get(s) || 0) + 1);
724
+ }
725
+ }
726
+ let max = null;
727
+ for (const [s, n] of counts) {
728
+ if (max === null || n > counts.get(max)) max = s;
729
+ }
730
+ if (max !== null) {
731
+ log.push(`Found most authoritative server ${max}`);
732
+ return max;
733
+ }
734
+
735
+ // 6. nothing. Use the server the value set comes from, if it comes from one, else the default
736
+ const why = systems.length === 1 ? 'Not handled by any servers' : 'Handled by multiple servers';
737
+ if (valueSet) {
738
+ const r = this.resolveValueSet(fhirVersion, valueSet, false, usage).result;
739
+ let source = (r.authoritative || []).concat(r.candidates || []).map(e => e.url)[0];
740
+ if (source === 'https://vsac.nlm.nih.gov') {
741
+ source = defaultServer;
742
+ }
743
+ if (source) {
744
+ log.push(`${why}. Using source @ '${source}'`);
745
+ return source;
746
+ }
747
+ }
748
+ log.push(`${why}. Using the default server`);
749
+ return defaultServer;
750
+ }
751
+
752
+ /**
753
+ * Whether a candidate server has a usable copy (complete or fragment) of each code system
754
+ * it's a candidate for. Systems for which tx.fhir.org is a candidate aren't checked
755
+ */
756
+ _supportsAll(server, choices, unresolved, log) {
757
+ if (isTxFhirOrg(server)) {
758
+ return true;
759
+ }
760
+ for (const t of choices) {
761
+ if (unresolved(t) || !t.candidates.includes(server) || t.candidates.some(isTxFhirOrg)) {
762
+ continue;
763
+ }
764
+ const content = t.content[server];
765
+ if (content && content !== 'complete' && content !== 'fragment') {
766
+ log.push(`Candidate server ${server} dropped for ${t.system}: its copy of the code system is '${content}'`);
767
+ return false;
768
+ }
769
+ }
770
+ return true;
771
+ }
772
+
595
773
  /**
596
774
  * The ecosystem Discovery API (GET {root} as JSON): one row per server endpoint.
597
775
  *
@@ -774,7 +952,7 @@ class RegistryAPI {
774
952
  }
775
953
  html += '</td>\n';
776
954
 
777
- html += `<td>${row.security}/td>\n`;
955
+ html += `<td>${escape(row.security)}</td>\n`;
778
956
 
779
957
  html += '</tr>\n';
780
958
  }
@@ -2,10 +2,6 @@
2
2
  // Crawler for gathering server information from terminology servers
3
3
 
4
4
  const axios = require('axios');
5
- const dns = require('dns');
6
- const http = require('http');
7
- const https = require('https');
8
- const ipaddr = require('ipaddr.js');
9
5
  const {
10
6
  ServerRegistries,
11
7
  ServerRegistry,
@@ -13,24 +9,11 @@ const {
13
9
  ServerVersionInformation,
14
10
  } = require('./model');
15
11
  const { UptimeHistory } = require('./uptime');
12
+ const { ServerCredentials } = require('../library/server-credentials');
13
+ const { createGuardedAgents } = require('../library/ssrf-guard');
16
14
  const {Extensions} = require("../tx/library/extensions");
17
15
  const {debugLog} = require("../tx/operation-context");
18
16
 
19
- // True if an IP literal is anything other than a normal public (unicast) address:
20
- // loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
21
- // CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
22
- function isNonPublicAddress(ip) {
23
- try {
24
- let addr = ipaddr.parse(ip);
25
- if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
26
- addr = addr.toIPv4Address();
27
- }
28
- return addr.range() !== 'unicast';
29
- } catch (e) {
30
- return true; // unparseable - treat as unsafe
31
- }
32
- }
33
-
34
17
  const MASTER_URL = 'https://fhir.github.io/ig-registry/tx-servers.json';
35
18
 
36
19
  class RegistryCrawler {
@@ -42,7 +25,10 @@ class RegistryCrawler {
42
25
  masterUrl: config.masterUrl || MASTER_URL,
43
26
  userAgent: config.userAgent || 'HealthIntersections/FhirServer',
44
27
  crawlInterval: config.crawlInterval || 5 * 60 * 1000, // 5 minutes default
45
- apiKeys: config.apiKeys || {}, // Map of server URL or code to API key
28
+ apiKeys: config.apiKeys || {}, // server code -> API key (sent as Api-Key)
29
+ // server code -> { tokenEndpoint, clientId, clientSecret, scope }: an OAuth2
30
+ // client_credentials grant; the token is sent as Authorization: Bearer
31
+ oauth: config.oauth || {},
46
32
  allowPrivateAddresses: config.allowPrivateAddresses || false, // SSRF opt-out for local test servers
47
33
  // the most a server's response can be, once unzipped: the servers are other people's
48
34
  maxResponseBytes: (config.maxResponseMB || 100) * 1024 * 1024,
@@ -61,65 +47,30 @@ class RegistryCrawler {
61
47
  this.totalBytes = 0;
62
48
  this.log = console;
63
49
  this.abortController = null;
50
+ // API keys and OAuth tokens for the servers that need them (shared with the tx module's
51
+ // ecosystem resolution: library/server-credentials.js)
52
+ this.credentials = new ServerCredentials({
53
+ apiKeys: this.config.apiKeys,
54
+ oauth: this.config.oauth,
55
+ timeout: this.config.timeout,
56
+ userAgent: this.config.userAgent,
57
+ agents: () => this.guardedAgents(),
58
+ signal: () => this.abortController?.signal,
59
+ log: (level, message) => this.addLogEntry(level, message)
60
+ });
64
61
  }
65
62
 
66
63
  useLog(logv) {
67
64
  this.log = logv;
68
65
  }
69
66
 
70
- // A DNS lookup wrapper that rejects any host resolving to a non-public address.
71
- // Enforced at connection time (so it also covers redirect targets and defeats
72
- // DNS-rebinding), this is the SSRF guard for outbound fetches. The registry is
73
- // crawled from server-supplied "next" links, so the target host is not trusted.
74
- // Set config.allowPrivateAddresses = true to disable (e.g. for local test servers).
75
- ssrfLookup() {
76
- const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
77
- return (hostname, options, callback) => {
78
- if (typeof options === 'function') {
79
- callback = options;
80
- options = {};
81
- }
82
- const wantAll = !!(options && options.all);
83
- dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
84
- if (err) {
85
- callback(err);
86
- return;
87
- }
88
- if (!allowPrivate) {
89
- for (const a of addresses) {
90
- if (isNonPublicAddress(a.address)) {
91
- callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
92
- return;
93
- }
94
- }
95
- }
96
- if (wantAll) {
97
- callback(null, addresses);
98
- } else {
99
- callback(null, addresses[0].address, addresses[0].family);
100
- }
101
- });
102
- };
103
- }
104
-
105
- // http/https agents that route every connection through the SSRF lookup.
67
+ // http/https agents that only connect to public addresses: the SSRF guard for outbound
68
+ // fetches (library/ssrf-guard.js). The registry is crawled from server-supplied "next" links,
69
+ // so the target host is not trusted. Set config.allowPrivateAddresses = true to disable (e.g.
70
+ // for local test servers).
106
71
  guardedAgents() {
107
72
  if (!this._guardedAgents) {
108
- const lookup = this.ssrfLookup();
109
- class GuardedHttpAgent extends http.Agent {
110
- createConnection(options, cb) {
111
- return super.createConnection(Object.assign({}, options, { lookup }), cb);
112
- }
113
- }
114
- class GuardedHttpsAgent extends https.Agent {
115
- createConnection(options, cb) {
116
- return super.createConnection(Object.assign({}, options, { lookup }), cb);
117
- }
118
- }
119
- this._guardedAgents = {
120
- httpAgent: new GuardedHttpAgent({ keepAlive: true }),
121
- httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
122
- };
73
+ this._guardedAgents = createGuardedAgents({ allowPrivate: !!this.config.allowPrivateAddresses });
123
74
  }
124
75
  return this._guardedAgents;
125
76
  }
@@ -160,6 +111,8 @@ class RegistryCrawler {
160
111
  }
161
112
 
162
113
  newData.doco = masterJson.documentation || '';
114
+ newData.registration = masterJson.registration || '';
115
+ newData.ecosystemDoco = masterJson['ecosystem-documentation'] || '';
163
116
 
164
117
  // Process each registry
165
118
  const registries = masterJson.registries || [];
@@ -300,7 +253,7 @@ class RegistryCrawler {
300
253
  const version = new ServerVersionInformation();
301
254
  version.version = versionConfig.version;
302
255
  version.address = versionConfig.url;
303
- version.security = this.getApiKey(server.code) == null ? "open" : "api-key";
256
+ version.security = this.getOAuthConfig(server.code) ? "oauth" : this.getApiKey(server.code) == null ? "open" : "api-key";
304
257
 
305
258
  if (!version.address) {
306
259
  this.addLogEntry('error', `No URL for version ${version.version} of ${server.name}`, server.address);
@@ -336,7 +289,7 @@ class RegistryCrawler {
336
289
  version.lastTat = `${Date.now() - startTime}ms`;
337
290
  this.uptime.record(version.address, true, this.scanTime || startTime);
338
291
 
339
- this.addLogEntry('info', ` Server ${version.address}: ${version.lastTat} for ${version.codeSystems.length} CodeSystems and ${version.valueSets.length} ValueSets`);
292
+ this.addLogEntry('info', ` Server ${version.address}: ${version.lastTat} for ${version.codeSystems.length} CodeSystems and ${version.valueSets.length} ValueSets (${version.security})`);
340
293
 
341
294
  } catch (error) {
342
295
  debugLog(error);
@@ -413,7 +366,7 @@ class RegistryCrawler {
413
366
  async processServerVersionR3(version, server, exclusions) {
414
367
  // Get capability statement
415
368
  const capabilityUrl = `${version.address}/metadata`;
416
- const capability = await this.fetchJson(capabilityUrl, server.name);
369
+ const capability = await this.fetchJson(capabilityUrl, server.code, version.address);
417
370
 
418
371
  version.version = capability.fhirVersion || '3.0.2';
419
372
  version.software = capability.software ? capability.software.name : "unknown";
@@ -423,7 +376,7 @@ class RegistryCrawler {
423
376
  // Get terminology capabilities (R3 uses Parameters resource)
424
377
  try {
425
378
  const termCapUrl = `${version.address}/metadata?mode=terminology`;
426
- const termCap = await this.fetchJson(termCapUrl, server.name);
379
+ const termCap = await this.fetchJson(termCapUrl, server.code, version.address);
427
380
 
428
381
  if (termCap.parameter) {
429
382
  termCap.parameter.forEach(param => {
@@ -459,7 +412,7 @@ class RegistryCrawler {
459
412
  async processServerVersionR4or5(version, server, defVersion, exclusions) {
460
413
  // Get capability statement
461
414
  const capabilityUrl = `${version.address}/metadata`;
462
- const capability = await this.fetchJson(capabilityUrl, server.code);
415
+ const capability = await this.fetchJson(capabilityUrl, server.code, version.address);
463
416
 
464
417
  version.version = capability.fhirVersion || defVersion;
465
418
  version.software = capability.software ? capability.software.name : "unknown";
@@ -471,7 +424,7 @@ class RegistryCrawler {
471
424
  // Get terminology capabilities
472
425
  try {
473
426
  const termCapUrl = `${version.address}/metadata?mode=terminology`;
474
- const termCap = await this.fetchJson(termCapUrl, server.code);
427
+ const termCap = await this.fetchJson(termCapUrl, server.code, version.address);
475
428
 
476
429
  if (termCap.codeSystem) {
477
430
  termCap.codeSystem.forEach(cs => {
@@ -528,7 +481,7 @@ class RegistryCrawler {
528
481
 
529
482
  if (this.abortController?.signal.aborted) break;
530
483
  this.log.debug(`Fetching value sets from ${searchUrl}`);
531
- const bundle = await this.fetchJson(searchUrl, server.code);
484
+ const bundle = await this.fetchJson(searchUrl, server.code, version.address);
532
485
 
533
486
  // Process entries in this page
534
487
  if (bundle.entry) {
@@ -596,36 +549,27 @@ class RegistryCrawler {
596
549
 
597
550
  /**
598
551
  * Fetch JSON from a URL
552
+ * @param {string} url
553
+ * @param {string} serverCode - the code the server's credentials (apiKeys, oauth) are under
554
+ * @param {string} [credentialBase] - the server's own address. When given, credentials are
555
+ * sent only to that origin: next links come from the server, and may point anywhere
599
556
  */
600
- async fetchJson(url, serverName) {
557
+ async fetchJson(url, serverCode, credentialBase) {
601
558
  try {
602
559
  // Add timestamp to bypass cache
603
560
  const fetchUrl = url.includes('?')
604
561
  ? `${url}&_ts=${Date.now()}`
605
562
  : `${url}?_ts=${Date.now()}`;
606
-
607
- // Get API key if configured
608
- const apiKey = this.getApiKey(serverName);
609
- const headers = {
610
- 'Accept': 'application/json, application/fhir+json',
611
- 'User-Agent': this.config.userAgent
612
- };
613
-
614
- if (apiKey) {
615
- headers['Api-Key'] = apiKey;
563
+
564
+ const sendCredentials = !credentialBase || this.sameOrigin(url, credentialBase);
565
+ const useOAuth = sendCredentials && !!this.getOAuthConfig(serverCode);
566
+
567
+ let response = await this.getJson(fetchUrl, await this.credentialHeaders(serverCode, sendCredentials, false));
568
+ if (response.status === 401 && useOAuth) {
569
+ // the token was revoked or expired early: get a new one, and try once more
570
+ response = await this.getJson(fetchUrl, await this.credentialHeaders(serverCode, sendCredentials, true));
616
571
  }
617
-
618
- const response = await axios.get(fetchUrl, {
619
- timeout: this.config.timeout,
620
- headers: headers,
621
- signal: this.abortController?.signal,
622
- validateStatus: (status) => status < 500, // Don't throw on 4xx
623
- // counted as it is unzipped, so a small response that unzips to a lot is stopped too
624
- maxContentLength: this.config.maxResponseBytes,
625
- httpAgent: this.guardedAgents().httpAgent,
626
- httpsAgent: this.guardedAgents().httpsAgent
627
- });
628
-
572
+
629
573
  if (response.status >= 400) {
630
574
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
631
575
  }
@@ -655,16 +599,50 @@ class RegistryCrawler {
655
599
  }
656
600
  }
657
601
 
602
+ async getJson(fetchUrl, credentials) {
603
+ return axios.get(fetchUrl, {
604
+ timeout: this.config.timeout,
605
+ headers: Object.assign({
606
+ 'Accept': 'application/json, application/fhir+json',
607
+ 'User-Agent': this.config.userAgent
608
+ }, credentials),
609
+ signal: this.abortController?.signal,
610
+ validateStatus: (status) => status < 500, // Don't throw on 4xx
611
+ // counted as it is unzipped, so a small response that unzips to a lot is stopped too
612
+ maxContentLength: this.config.maxResponseBytes,
613
+ httpAgent: this.guardedAgents().httpAgent,
614
+ httpsAgent: this.guardedAgents().httpsAgent
615
+ });
616
+ }
617
+
618
+ sameOrigin(url, base) {
619
+ try {
620
+ return new URL(url).origin === new URL(base).origin;
621
+ } catch (e) {
622
+ return false;
623
+ }
624
+ }
625
+
658
626
  /**
659
- * Get API key for a given URL
627
+ * The headers that carry a server's credentials (see ServerCredentials)
660
628
  */
661
- getApiKey(name) {
662
- // Check for exact URL match
663
- if (this.config.apiKeys[name]) {
664
- return this.config.apiKeys[name];
629
+ async credentialHeaders(serverCode, sendCredentials, renewToken) {
630
+ if (!sendCredentials) {
631
+ return {};
665
632
  }
666
-
667
- return null;
633
+ return this.credentials.headers(serverCode, renewToken);
634
+ }
635
+
636
+ getApiKey(name) {
637
+ return this.credentials.getApiKey(name);
638
+ }
639
+
640
+ getOAuthConfig(name) {
641
+ return this.credentials.getOAuthConfig(name);
642
+ }
643
+
644
+ async getAccessToken(serverCode, renew = false) {
645
+ return this.credentials.getAccessToken(serverCode, renew);
668
646
  }
669
647
 
670
648
  /**
@@ -15,12 +15,13 @@ contract:
15
15
  Every response carries `Link: </tx-reg/openapi.json>; rel="service-desc"` (RFC 8631), and the
16
16
  HTML pages the matching `<link>` element.
17
17
 
18
- There are two operations, both `GET`:
18
+ There are two operations from the ecosystem IG, and one extension, all `GET`:
19
19
 
20
20
  | Endpoint | |
21
21
  |---|---|
22
22
  | `/tx-reg/` | **Discovery** - the server endpoints in the ecosystem |
23
23
  | `/tx-reg/resolve` | **Resolution** - which endpoints to use for a code system or value set |
24
+ | `/tx-reg/choose-server` | **Server choice** - which one server to use for a request involving several code systems (JSON only; [below](#server-choice-get-tx-regchoose-server)) |
24
25
 
25
26
  Both answer JSON, or an HTML page when the request's `Accept` header contains `text/html`.
26
27
  `/tx-reg/resolve` in a browser without parameters is a form for trying it. Parameters are read
@@ -143,7 +144,72 @@ when empty, so an empty object means nobody hosts it.
143
144
  * Resolve takes `version` as an alternative to `url|version`.
144
145
  * Resolve omits `authoritative` and `candidates` when they are empty.
145
146
 
146
- ## Operational pages
147
+ ## Server choice: `GET /tx-reg/choose-server`
148
+
149
+ Not in the ecosystem IG. A request often involves several code systems - a value set that
150
+ includes LOINC and a national code system, a CodeableConcept with codings from three - and
151
+ resolution answers for one at a time. The HL7 Java tooling combines the answers into a choice
152
+ of one server (`TerminologyClientManager.chooseServer` in org.hl7.fhir.core); this makes the
153
+ same choice here for clients that aren't Java. It doesn't replace the Java code, which still
154
+ does its own choosing.
155
+
156
+ | Parameter | |
157
+ |---|---|
158
+ | `fhirVersion` | Required, as for resolve |
159
+ | `system` | A code system the request involves, `url` or `url\|version`. Repeat it for each (`system=a&system=b`) - the one parameter that isn't first-value-only |
160
+ | `default` | Required. The client's default terminology server: the fallback, and the server for the code systems tx.fhir.org handles specially |
161
+ | `valueSet` | The value set the request is about, `url` or `url\|version`, if there is one |
162
+ | `language` | As for resolve, but only for language sensitive requests (an expansion where the display language matters, a validation that checks a display, a lookup that returns designations) |
163
+ | `usage` | As for resolve |
164
+
165
+ ```json
166
+ {
167
+ "server": "https://tx.ontoserver.csiro.au/fhir",
168
+ "log": [
169
+ "https://healthterminologies.gov.au/fhir/CodeSystem/x: authoritative [https://tx.ontoserver.csiro.au/fhir], candidates []",
170
+ "http://terminology.hl7.org/CodeSystem/v2-0203: authoritative [http://tx.fhir.org/r4], candidates [https://tx.ontoserver.csiro.au/fhir]",
171
+ "Found partially authoritative server https://tx.ontoserver.csiro.au/fhir"
172
+ ]
173
+ }
174
+ ```
175
+
176
+ `log` is for people: what resolution says about each code system, then the rule that decided.
177
+
178
+ ### What the client does first
179
+
180
+ What the Java tooling works out before it chooses, a client must too:
181
+
182
+ * The code systems: from the codings, and from the value set's includes and excludes,
183
+ following imported value sets. The imports are often only in the client's own context (the
184
+ IG being built), so the registry can't follow them. If the client can't follow one, it
185
+ should use its default server without asking.
186
+ * Versions: those fixed by the expansion parameters (`system-version`,
187
+ `force-system-version`) applied to the systems.
188
+ * Language: for an expansion, the `displayLanguage` of the expansion parameters if no other.
189
+
190
+ ### How the choice is made
191
+
192
+ In order:
193
+
194
+ 1. A server authoritative for all the code systems.
195
+ 2. A server authoritative for one, and authoritative or a candidate for each of the others.
196
+ 3. Several code systems including SNOMED CT: the choice for SNOMED CT alone. Otherwise, if a
197
+ code system is one tx.fhir.org handles specially (LOINC, UCUM, RxNorm, CVX, NDC, CPT,
198
+ BCP-13/47, ISO 3166/4217 ...): the default.
199
+ 4. A server that is a candidate for all of them, and has a usable copy of each - its
200
+ TerminologyCapabilities says `complete` or `fragment`, or gives no content mode. Not
201
+ checked for a code system tx.fhir.org is also a candidate for.
202
+ 5. The server authoritative for the most of them (the first, on a tie).
203
+ 6. The server the value set comes from (by resolving `valueSet`), else the default.
204
+
205
+ In 2 and 4, a code system that no server hosts doesn't count against a server.
206
+
207
+ Where this differs from the Java tooling: step 4 uses the content mode the registry scanned
208
+ instead of searching the server, and trusts a server that didn't say; the special handling in
209
+ step 3 sends to `default` rather than to whichever of tx.fhir.org / tx-dev the client has used;
210
+ ties go to the first rather than to hash order. Step 3 is tx.fhir.org specific, and applies to
211
+ any ecosystem for now.
212
+
147
213
 
148
214
  These are HTML, not part of the API, and never secured:
149
215
 
@@ -12,6 +12,7 @@ The registry is configured under `modules.registry` in the server's configuratio
12
12
  "timeout": 30000,
13
13
  "userAgent": "YourServer/1.0",
14
14
  "apiKeys": {},
15
+ "oauth": {},
15
16
  "maxResponseMB": 100,
16
17
  "uptimeWindowDays": 30,
17
18
  "uptimeRetentionDays": 90,
@@ -25,6 +26,39 @@ The registry is configured under `modules.registry` in the server's configuratio
25
26
  |---|---|---|
26
27
  | `masterUrl` | HL7's `tx-servers.json` | The ecosystem's master registration file ([readme.md](readme.md#the-specifications)). Point this at your own file to run a registry for your own ecosystem. |
27
28
  | `apiKeys` | `{}` | API keys for servers the registry needs one to scan, by server code (as in the server's registration). Sent as an `Api-Key` header. An endpoint scanned with a key is reported as `security: api-key` in the API. |
29
+ | `oauth` | `{}` | OAuth2 client credentials for servers the registry needs a token to scan, by server code - see [below](#oauth). An endpoint scanned with a token is reported as `security: oauth` in the API. |
30
+
31
+ Credentials are only sent to the origin (scheme, host and port) of the server's registered
32
+ address. A `next` link that points somewhere else is still followed, but without them.
33
+
34
+ ### OAuth
35
+
36
+ For a server that issues tokens by the OAuth2 `client_credentials` grant, the registry asks
37
+ the token endpoint for a token, sending its client id and secret in the form body
38
+ (`client_secret_post`), and sends the token as `Authorization: Bearer`. The token is reused
39
+ until it is about to expire (`expires_in`; 5 minutes if the endpoint doesn't say), and
40
+ replaced early if the server answers 401. A server with an `oauth` entry is not sent its
41
+ `apiKeys` entry.
42
+
43
+ ```json
44
+ "oauth": {
45
+ "nzhts": {
46
+ "tokenEndpoint": "https://authenticate.nzhts.digital.health.nz/auth/realms/nzhts/protocol/openid-connect/token",
47
+ "clientId": "hl7",
48
+ "clientSecret": "...",
49
+ "scope": ""
50
+ }
51
+ }
52
+ ```
53
+
54
+ | Property | |
55
+ |---|---|
56
+ | `tokenEndpoint` | The token endpoint. Must be `https:`. Redirects are not followed. |
57
+ | `clientId`, `clientSecret` | The client's credentials, from the server's operator. |
58
+ | `scope` | Optional. Sent if present and not empty. |
59
+
60
+ The field names are those of the HL7 Java tooling's `oauthClientCredentials` settings, so the
61
+ same values can be copied between the two.
28
62
 
29
63
  ## Scanning
30
64
 
@@ -28,7 +28,10 @@ How the module works, for anyone changing it. The user-facing behaviour is in
28
28
  `RegistryCrawler.crawl()` runs every `crawlInterval` minutes (first 5 seconds after start),
29
29
  and never concurrently with itself. A scan builds a fresh `ServerRegistries` from scratch:
30
30
 
31
- 1. fetch the master registration file (`formatVersion` must be `"1"`);
31
+ 1. fetch the master registration file (`formatVersion` must be `"1"`); its optional
32
+ `registration` and `ecosystem-documentation` URLs are the "Register your own server" and
33
+ "Documentation" links on the home page (each is omitted if the file doesn't give it), since
34
+ they depend on the ecosystem;
32
35
  2. for each registry it lists, fetch the registry file and build a `ServerRegistry`;
33
36
  3. for each server, a `ServerInformation` with its claims (`authoritative`,
34
37
  `authoritative-valuesets`, `languages`, `usage`, `exclusions`), and for each of its
@@ -27,8 +27,9 @@ it, [api.md](api.md) says so.
27
27
 
28
28
  **The registration files.** The ecosystem is declared by a *master registration file* that
29
29
  lists *registries*, each of which lists *servers*, each of which lists its endpoints (one per
30
- FHIR version) and its claims. The format is documented in the ig-registry repository's
31
- [tx-registry-doco.md](https://github.com/FHIR/ig-registry/blob/master/tx-registry-doco.md).
30
+ FHIR version) and its claims. The format is documented on the IG's ecosystem page; HL7's own
31
+ ecosystem (its locations, and how to register a server in it) is documented in the ig-registry
32
+ repository's [tx-registry-doco.md](https://github.com/FHIR/ig-registry/blob/master/tx-registry-doco.md).
32
33
  HL7's master file is https://fhir.github.io/ig-registry/tx-servers.json, and its registries
33
34
  are the per-affiliate `*-tx-servers.json` files in the same repository (HL7 International,
34
35
  HL7 Australia, HL7 Europe, and so on). A server's registration carries: