fhirsmith 0.10.1 → 0.11.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 (53) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/changelog-template.md +22 -0
  3. package/library/logger.js +8 -1
  4. package/library/package-manager.js +40 -2
  5. package/npmprojector/watcher.js +1 -1
  6. package/package.json +3 -2
  7. package/packages/packages.js +28 -15
  8. package/publisher/publisher.js +8 -8
  9. package/registry/api.js +92 -31
  10. package/registry/crawler.js +15 -1
  11. package/registry/model.js +158 -8
  12. package/registry/readme.md +5 -13
  13. package/registry/registry.js +87 -46
  14. package/registry/test-runner.js +1 -1
  15. package/server.js +8 -1
  16. package/token/token.js +1 -1
  17. package/translations/Messages.properties +3 -1
  18. package/translations/rendering-phrases.properties +1227 -1218
  19. package/translations/rendering-phrases_ar.properties +1097 -1091
  20. package/translations/rendering-phrases_fr.properties +626 -624
  21. package/translations/rendering-phrases_nl.properties +976 -970
  22. package/translations/rendering-phrases_pt.properties +1026 -1020
  23. package/translations/rendering-phrases_ru.properties +1100 -1094
  24. package/tsconfig.eslint.json +9 -0
  25. package/tx/cs/cs-api.js +57 -4
  26. package/tx/cs/cs-cs.js +98 -25
  27. package/tx/cs/cs-hgvs.js +4 -0
  28. package/tx/cs/cs-lang.js +8 -0
  29. package/tx/cs/cs-mimetypes.js +4 -0
  30. package/tx/cs/cs-snomed.js +530 -75
  31. package/tx/cs/cs-uri.js +4 -0
  32. package/tx/data/snomed-subset.txt +40 -3
  33. package/tx/importers/import-sct.module.js +18 -0
  34. package/tx/library/designations.js +3 -5
  35. package/tx/library/renderer.js +227 -30
  36. package/tx/library.js +5 -1
  37. package/tx/operation-context.js +40 -3
  38. package/tx/provider.js +38 -9
  39. package/tx/sct/ecl.js +30 -11
  40. package/tx/sct/expressions.js +28 -1
  41. package/tx/sct/structures.js +5 -5
  42. package/tx/tx-html.js +24 -15
  43. package/tx/tx.fhir.org.yml +1 -4
  44. package/tx/vs/vs-package.js +52 -0
  45. package/tx/workers/batch-validate.js +125 -18
  46. package/tx/workers/cache-control.js +31 -2
  47. package/tx/workers/expand.js +50 -32
  48. package/tx/workers/metadata.js +27 -6
  49. package/tx/workers/search.js +7 -1
  50. package/tx/workers/validate.js +30 -18
  51. package/tx/workers/worker.js +30 -7
  52. package/tx/xversion/xv-valueset.js +5 -2
  53. package/xig/xig.js +3 -3
package/registry/model.js CHANGED
@@ -86,6 +86,8 @@ class ServerInformation {
86
86
  this.authCSList = []; // Authoritative code systems (with wildcards)
87
87
  this.authVSList = []; // Authoritative value sets (with wildcards)
88
88
  this.usageList = []; // Usage tags
89
+ this.languages = {}; // Language specific authoritative claims: BCP-47 tag -> list of CS masks
90
+ this.exclusions = []; // CodeSystem/ValueSet masks hidden from the ecosystem entirely
89
91
  this.versions = []; // Array of ServerVersionInformation
90
92
  }
91
93
 
@@ -100,6 +102,11 @@ class ServerInformation {
100
102
  this.authCSList = [...source.authCSList];
101
103
  this.authVSList = [...source.authVSList];
102
104
  this.usageList = [...source.usageList];
105
+ this.languages = {};
106
+ Object.keys(source.languages || {}).forEach(tag => {
107
+ this.languages[tag] = [...source.languages[tag]];
108
+ });
109
+ this.exclusions = [...(source.exclusions || [])];
103
110
 
104
111
  source.versions.forEach(sourceVersion => {
105
112
  const existing = this.getVersion(sourceVersion.version);
@@ -116,11 +123,80 @@ class ServerInformation {
116
123
  }
117
124
 
118
125
  isAuthCS(codeSystem) {
119
- return this.authCSList.some(mask => this._passesMask(mask, codeSystem));
126
+ return !this.isExcludedTarget(codeSystem) &&
127
+ this.authCSList.some(mask => this._passesMask(mask, codeSystem));
120
128
  }
121
129
 
122
130
  isAuthVS(valueSet) {
123
- return this.authVSList.some(mask => this._passesMask(mask, valueSet));
131
+ return !this.isExcludedTarget(valueSet) &&
132
+ this.authVSList.some(mask => this._passesMask(mask, valueSet));
133
+ }
134
+
135
+ /**
136
+ * True if the code system / value set is hidden from the ecosystem for this server
137
+ * (matches an entry in exclusions). Excluded content is hidden entirely: the server
138
+ * is never offered for it, neither as authoritative nor as a candidate, even if an
139
+ * authoritative mask (or language specific claim) would otherwise match it.
140
+ * Masks are tested against both the full canonical (url|version) and the base url.
141
+ */
142
+ isExcludedTarget(value) {
143
+ return (this.exclusions || []).some(mask => this._passesMaskEx(mask, value, true));
144
+ }
145
+
146
+ /**
147
+ * Match this server's authoritative claims for a code system, taking language specific
148
+ * claims (the languages property: BCP-47 tag -> mask list) into account.
149
+ *
150
+ * Language specific claims are only visible when the request specifies a language. A
151
+ * claimed tag is inherently a mask: it covers a request language that is equal to it or
152
+ * more specific ('de' covers 'de' and 'de-AT'; 'de-AT' does not cover plain 'de'). The
153
+ * best match is on the earliest (heaviest) request language, then the most specific
154
+ * claimed tag. Claims in the authoritative list apply irrespective of language, but rank
155
+ * after all language specific matches (score = MAX_SAFE_INTEGER).
156
+ *
157
+ * @param {string} codeSystem - canonical url or url|version
158
+ * @param {Array|null} requestLangs - parsed Accept-Language list ([{tag, q}] in descending
159
+ * weight order, per ServerRegistryUtilities.parseAcceptLanguage), or null for no language
160
+ * @param {boolean} maskBase - also test masks against the base URL (before '|'), matching
161
+ * the semantics of hasMatchingCodeSystem as used for the discovery rows
162
+ * @returns {Object} {isAuth: boolean, scoped: boolean, tag?: string, score: number}
163
+ * score: lower is better - earlier request languages and more specific tags win
164
+ */
165
+ matchAuthCS(codeSystem, requestLangs = null, maskBase = false) {
166
+ // excluded content is hidden entirely - no claim of any kind can match it
167
+ if (this.isExcludedTarget(codeSystem)) {
168
+ return { isAuth: false, scoped: false, score: Number.MAX_SAFE_INTEGER };
169
+ }
170
+
171
+ const masksMatch = (masks) => masks.some(mask => this._passesMaskEx(mask, codeSystem, maskBase));
172
+
173
+ if (requestLangs && requestLangs.length > 0 && this.languages) {
174
+ let best = null;
175
+ for (let i = 0; i < requestLangs.length; i++) {
176
+ for (const tag of Object.keys(this.languages)) {
177
+ if (!ServerRegistryUtilities.languageTagCovers(tag, requestLangs[i].tag)) continue;
178
+ if (!masksMatch(this.languages[tag] || [])) continue;
179
+ // earlier request language (higher weight) dominates; then longer (more specific) tag
180
+ const score = i * 1000 + (256 - tag.length);
181
+ if (!best || score < best.score) {
182
+ best = { isAuth: true, scoped: true, tag, score };
183
+ }
184
+ }
185
+ if (best) return best; // don't consider later (lighter) request languages
186
+ }
187
+ }
188
+ if (masksMatch(this.authCSList)) {
189
+ return { isAuth: true, scoped: false, score: Number.MAX_SAFE_INTEGER };
190
+ }
191
+ return { isAuth: false, scoped: false, score: Number.MAX_SAFE_INTEGER };
192
+ }
193
+
194
+ _passesMaskEx(mask, value, includeBase) {
195
+ if (this._passesMask(mask, value)) return true;
196
+ if (includeBase && value.includes('|')) {
197
+ return this._passesMask(mask, value.substring(0, value.indexOf('|')));
198
+ }
199
+ return false;
124
200
  }
125
201
 
126
202
  _passesMask(mask, value) {
@@ -132,11 +208,34 @@ class ServerInformation {
132
208
 
133
209
  getDescription() {
134
210
  let result = '';
135
-
211
+
136
212
  if (this.usageList.length > 0) {
137
213
  result = `Usage Tags: ${this.usageList.join(', ')}`;
138
214
  }
139
-
215
+
216
+ const langTags = Object.keys(this.languages || {});
217
+ if (langTags.length > 0) {
218
+ if (result) result += '. ';
219
+ result += 'Authoritative for particular languages: <ul>';
220
+ langTags.forEach(tag => {
221
+ (this.languages[tag] || []).forEach(mask => {
222
+ const escaped = escape(mask).replace(/\*/g, '<b>*</b>');
223
+ result += `<li>${escape(tag)}: ${escaped}</li>`;
224
+ });
225
+ });
226
+ result += '</ul>';
227
+ }
228
+
229
+ if (this.exclusions && this.exclusions.length > 0) {
230
+ if (result) result += '. ';
231
+ result += 'Excluded (hidden from the ecosystem): <ul>';
232
+ this.exclusions.forEach(mask => {
233
+ const escaped = escape(mask).replace(/\*/g, '<b>*</b>');
234
+ result += `<li>${escaped}</li>`;
235
+ });
236
+ result += '</ul>';
237
+ }
238
+
140
239
  if (this.authCSList.length > 0) {
141
240
  if (result) result += '. ';
142
241
  result += 'Authoritative for the following CodeSystems: <ul>';
@@ -169,6 +268,8 @@ class ServerInformation {
169
268
  authoritative: this.authCSList.join(','),
170
269
  'authoritative-valuesets': this.authVSList.join(','),
171
270
  usageList: this.usageList,
271
+ languages: this.languages,
272
+ exclusions: this.exclusions.join(','),
172
273
  versions: this.versions.map(v => v.toJSON())
173
274
  };
174
275
  }
@@ -179,13 +280,20 @@ class ServerInformation {
179
280
  instance.name = json.name || '';
180
281
  instance.address = json.address || '';
181
282
  instance.accessInfo = json['access-info'] || '';
182
- instance.authCSList = json.authoritative
283
+ instance.authCSList = json.authoritative
183
284
  ? json.authoritative.split(',').filter(s => s)
184
285
  : [];
185
- instance.authVSList = json['authoritative-valuesets']
286
+ instance.authVSList = json['authoritative-valuesets']
186
287
  ? json['authoritative-valuesets'].split(',').filter(s => s)
187
288
  : [];
188
289
  instance.usageList = json.usageList || [];
290
+ // languages is an object (tag -> mask list); tolerate legacy/absent data
291
+ instance.languages = (json.languages && typeof json.languages === 'object' && !Array.isArray(json.languages))
292
+ ? json.languages
293
+ : {};
294
+ instance.exclusions = json.exclusions
295
+ ? json.exclusions.split(',').filter(s => s)
296
+ : [];
189
297
  instance.versions = (json.versions || []).map(v => ServerVersionInformation.fromJSON(v));
190
298
  return instance;
191
299
  }
@@ -327,6 +435,7 @@ class ServerRow {
327
435
  this.systems = 0; // count of code systems
328
436
  this.sets = 0; // count of value sets
329
437
  this.authoritative = false;
438
+ this.languages = []; // language tag(s) of the language specific claim matched (empty = none)
330
439
  }
331
440
 
332
441
  toJSON() {
@@ -357,7 +466,10 @@ class ServerRow {
357
466
  json['authoritative-valuesets'] = this.authVSList;
358
467
  }
359
468
 
360
-
469
+ if (this.languages.length > 0) {
470
+ json.languages = this.languages;
471
+ }
472
+
361
473
  return json;
362
474
  }
363
475
 
@@ -378,12 +490,13 @@ class ServerRow {
378
490
  instance.authCSList = json.authoritative || [];
379
491
  instance.authVSList = json['authoritative-valuesets'] || [];
380
492
  instance.security = json['security'] || '';
493
+ instance.languages = json.languages || [];
381
494
 
382
495
  return instance;
383
496
  }
384
497
  }
385
498
 
386
- // Utility functions (similar to TServerRegistryUtilities)
499
+ // Utility functions for registry matching and row building
387
500
  class ServerRegistryUtilities {
388
501
  static passesMask(mask, value) {
389
502
  if (mask.endsWith('*')) {
@@ -392,6 +505,43 @@ class ServerRegistryUtilities {
392
505
  return value === mask;
393
506
  }
394
507
 
508
+ /**
509
+ * Parse an Accept-Language style value ('de', 'de-AT, de;q=0.9, en;q=0.1') into
510
+ * an ordered list of {tag, q}, highest weight first. Wildcards ('*') and zero-weight
511
+ * entries are dropped - they don't constrain routing. Returns null when nothing usable.
512
+ */
513
+ static parseAcceptLanguage(value) {
514
+ if (!value || typeof value !== 'string') return null;
515
+ const items = [];
516
+ for (const part of value.split(',')) {
517
+ const pieces = part.trim().split(';').map(p => p.trim());
518
+ const tag = pieces[0];
519
+ if (!tag || tag === '*') continue;
520
+ let q = 1.0;
521
+ for (let i = 1; i < pieces.length; i++) {
522
+ const m = /^q\s*=\s*([0-9.]+)$/i.exec(pieces[i]);
523
+ if (m) q = parseFloat(m[1]);
524
+ }
525
+ if (!(q > 0)) continue;
526
+ items.push({ tag, q });
527
+ }
528
+ if (items.length === 0) return null;
529
+ items.sort((a, b) => b.q - a.q); // stable: preserves order among equal weights
530
+ return items;
531
+ }
532
+
533
+ /**
534
+ * BCP-47 tags are hierarchical, so a tag is inherently a mask: a scoped tag covers a
535
+ * request language that is equal to it, or more specific (on '-' boundaries).
536
+ * languageTagCovers('de', 'de-AT') = true; ('de', 'dent') = false; ('de-AT', 'de') = false.
537
+ */
538
+ static languageTagCovers(scopedTag, requestTag) {
539
+ if (!scopedTag || !requestTag) return false;
540
+ const s = scopedTag.toLowerCase();
541
+ const r = requestTag.toLowerCase();
542
+ return r === s || r.startsWith(s + '-');
543
+ }
544
+
395
545
  static hasMatchingCodeSystem(cs, list, supportMask, content, noVersionIndependentMatching = false) {
396
546
  if (!cs || list.length === 0) return false;
397
547
 
@@ -178,6 +178,10 @@ Servers can be marked as authoritative for specific code systems or value sets.
178
178
  - Authoritative servers are always ranked first
179
179
  - Wildcards are supported (e.g., `http://loinc.org*`)
180
180
  - Non-authoritative servers are still returned but ranked lower
181
+ - Language specific claims (`languages`: BCP-47 tag -> mask list) make a server authoritative for
182
+ requests in that language only; matched entries rank ahead of language independent claims
183
+ - `exclusions` hides matching code systems/value sets from the ecosystem entirely (never
184
+ authoritative, never a candidate)
181
185
 
182
186
  ## Testing
183
187
 
@@ -205,18 +209,6 @@ Start development server with auto-reload:
205
209
  npm run dev
206
210
  ```
207
211
 
208
- ## Migration from Pascal
209
-
210
- This module is a JavaScript port of a Pascal implementation. Key differences:
211
-
212
- | Pascal | JavaScript |
213
- |--------|------------|
214
- | TStringList | Array |
215
- | TFslList<T> | Array |
216
- | set of TServerSecurity | Set |
217
- | TFslDateTime | Date |
218
- | class methods | static methods |
219
-
220
212
  ## Security Models
221
213
 
222
214
  The crawler detects the following security models:
@@ -230,4 +222,4 @@ The crawler detects the following security models:
230
222
 
231
223
  ## License
232
224
 
233
- BSD-3-Clause (matching the original Pascal implementation)
225
+ BSD-3-Clause
@@ -116,13 +116,13 @@ class RegistryModule {
116
116
 
117
117
  // Run initial crawl after a short delay
118
118
  setTimeout(() => {
119
- this.performCrawl();
119
+ this.performCrawl().catch((err) => this.logger.error('Registry crawl failed: ' + err.message));
120
120
  }, 5000);
121
121
 
122
122
  // Set up periodic crawling
123
123
  this.stats.addTask("TxRegistry", `${intervalMinutes} min`);
124
124
  this.crawlInterval = setInterval(() => {
125
- this.performCrawl();
125
+ this.performCrawl().catch((err) => this.logger.error('Registry crawl failed: ' + err.message));
126
126
  }, intervalMs);
127
127
 
128
128
  this.logger.info(`Started periodic crawl every ${intervalMinutes} minutes`);
@@ -299,7 +299,7 @@ class RegistryModule {
299
299
 
300
300
  /**
301
301
  * Get status text about crawling
302
- * Based on Pascal status function
302
+ * Describes the crawl schedule state
303
303
  */
304
304
  getStatusText() {
305
305
  if (this.crawlInProgress) {
@@ -331,7 +331,7 @@ class RegistryModule {
331
331
 
332
332
  /**
333
333
  * Format a time period in milliseconds to a human-readable string
334
- * Based on Pascal DescribePeriod function
334
+
335
335
  */
336
336
  describePeriod(milliseconds) {
337
337
  const seconds = Math.floor(milliseconds / 1000);
@@ -552,35 +552,43 @@ class RegistryModule {
552
552
 
553
553
  if (!data || !data.registries) return [];
554
554
 
555
- // Gather all authoritative code systems
556
- data.registries.forEach(registry => {
557
- registry.servers.forEach(server => {
558
- server.authCSList.forEach(csMask => {
559
- // Create or update entry for this code system mask
560
- if (!authCSMap.has(csMask)) {
561
- authCSMap.set(csMask, {
562
- mask: csMask,
563
- servers: new Map()
564
- });
565
- }
555
+ // Gather all authoritative code systems - both the language independent claims
556
+ // (authoritative) and the language specific claims (languages: tag -> mask list)
557
+ const addClaim = (server, csMask, languageTag) => {
558
+ if (!authCSMap.has(csMask)) {
559
+ authCSMap.set(csMask, {
560
+ mask: csMask,
561
+ servers: new Map()
562
+ });
563
+ }
566
564
 
567
- // Add server info
568
- const csEntry = authCSMap.get(csMask);
569
- if (!csEntry.servers.has(server.name)) {
570
- csEntry.servers.set(server.name, {
571
- name: server.name,
572
- url: server.address,
573
- versions: new Set()
574
- });
575
- }
565
+ // Key by server code + language tag, so a server's language independent and
566
+ // language specific claims for the same mask show as separate rows
567
+ const csEntry = authCSMap.get(csMask);
568
+ const key = `${server.code}|${languageTag || ''}`;
569
+ if (!csEntry.servers.has(key)) {
570
+ csEntry.servers.set(key, {
571
+ name: server.name,
572
+ url: server.address,
573
+ languages: languageTag ? [languageTag] : [],
574
+ versions: new Set()
575
+ });
576
+ }
576
577
 
577
- // Add version info for this server
578
- const serverEntry = csEntry.servers.get(server.name);
579
- server.versions.forEach(version => {
580
- if (!version.error) {
581
- serverEntry.versions.add(version.version);
582
- }
583
- });
578
+ // Add version info for this server
579
+ const serverEntry = csEntry.servers.get(key);
580
+ server.versions.forEach(version => {
581
+ if (!version.error) {
582
+ serverEntry.versions.add(version.version);
583
+ }
584
+ });
585
+ };
586
+
587
+ data.registries.forEach(registry => {
588
+ registry.servers.forEach(server => {
589
+ server.authCSList.forEach(csMask => addClaim(server, csMask, null));
590
+ Object.keys(server.languages || {}).forEach(tag => {
591
+ (server.languages[tag] || []).forEach(csMask => addClaim(server, csMask, tag));
584
592
  });
585
593
  });
586
594
  });
@@ -626,18 +634,19 @@ class RegistryModule {
626
634
  });
627
635
  }
628
636
 
629
- // Add server info
637
+ // Add server info (value set claims have no language dimension at this time)
630
638
  const vsEntry = authVSMap.get(vsMask);
631
- if (!vsEntry.servers.has(server.name)) {
632
- vsEntry.servers.set(server.name, {
639
+ if (!vsEntry.servers.has(server.code)) {
640
+ vsEntry.servers.set(server.code, {
633
641
  name: server.name,
634
642
  url: server.address,
643
+ languages: [],
635
644
  versions: new Set()
636
645
  });
637
646
  }
638
647
 
639
648
  // Add version info for this server
640
- const serverEntry = vsEntry.servers.get(server.name);
649
+ const serverEntry = vsEntry.servers.get(server.code);
641
650
  server.versions.forEach(version => {
642
651
  if (!version.error) {
643
652
  serverEntry.versions.add(version.version);
@@ -788,6 +797,19 @@ class RegistryModule {
788
797
  return text.replace(/\*/g, '<strong class="text-primary">*</strong>');
789
798
  }
790
799
 
800
+ /**
801
+ * Render the language tag(s) of a language specific claim (empty string when the
802
+ * claim is language independent)
803
+ * @param {Object} serverEntry - entry with a languages array
804
+ * @returns {string} HTML fragment
805
+ */
806
+ _renderLanguageScope(serverEntry) {
807
+ if (!serverEntry.languages || serverEntry.languages.length === 0) {
808
+ return '';
809
+ }
810
+ return ` <span class="badge badge-info" title="This claim applies to requests in these languages only">${escape(serverEntry.languages.join(', '))}</span>`;
811
+ }
812
+
791
813
  /**
792
814
  * Render HTML table for authoritative code systems
793
815
  * @returns {string} HTML string
@@ -821,7 +843,7 @@ class RegistryModule {
821
843
  const rowspan = cs.servers.length;
822
844
  html += '<tr>';
823
845
  html += `<td rowspan="${rowspan}">${this._highlightWildcard(escape(formattedMask))}</td>`;
824
- html += `<td><a href="${escape(cs.servers[0].url)}" target="_blank">${escape(cs.servers[0].url)}</a></td>`;
846
+ html += `<td><a href="${escape(cs.servers[0].url)}" target="_blank">${escape(cs.servers[0].url)}</a>${this._renderLanguageScope(cs.servers[0])}</td>`;
825
847
 
826
848
  // Format versions as R3/R4/R5
827
849
  const formattedVersions = cs.servers[0].versions.map(v => this._formatFhirVersion(v));
@@ -831,7 +853,7 @@ class RegistryModule {
831
853
  // Additional rows for this code system (if any)
832
854
  for (let i = 1; i < cs.servers.length; i++) {
833
855
  html += '<tr>';
834
- html += `<td><a href="${escape(cs.servers[i].url)}" target="_blank">${escape(cs.servers[i].url)}</a></td>`;
856
+ html += `<td><a href="${escape(cs.servers[i].url)}" target="_blank">${escape(cs.servers[i].url)}</a>${this._renderLanguageScope(cs.servers[i])}</td>`;
835
857
 
836
858
  // Format versions as R3/R4/R5
837
859
  const formattedVersions = cs.servers[i].versions.map(v => this._formatFhirVersion(v));
@@ -877,7 +899,7 @@ class RegistryModule {
877
899
  const rowspan = vs.servers.length;
878
900
  html += '<tr>';
879
901
  html += `<td rowspan="${rowspan}">${this._highlightWildcard(escape(vs.mask))}</td>`;
880
- html += `<td><a href="${escape(vs.servers[0].url)}" target="_blank">${escape(vs.servers[0].url)}</a></td>`;
902
+ html += `<td><a href="${escape(vs.servers[0].url)}" target="_blank">${escape(vs.servers[0].url)}</a>${this._renderLanguageScope(vs.servers[0])}</td>`;
881
903
 
882
904
  // Format versions as R3/R4/R5
883
905
  const formattedVersions = vs.servers[0].versions.map(v => this._formatFhirVersion(v));
@@ -887,7 +909,7 @@ class RegistryModule {
887
909
  // Additional rows for this value set (if any)
888
910
  for (let i = 1; i < vs.servers.length; i++) {
889
911
  html += '<tr>';
890
- html += `<td><a href="${escape(vs.servers[i].url)}" target="_blank">${escape(vs.servers[i].url)}</a></td>`;
912
+ html += `<td><a href="${escape(vs.servers[i].url)}" target="_blank">${escape(vs.servers[i].url)}</a>${this._renderLanguageScope(vs.servers[i])}</td>`;
891
913
 
892
914
  // Format versions as R3/R4/R5
893
915
  const formattedVersions = vs.servers[i].versions.map(v => this._formatFhirVersion(v));
@@ -976,7 +998,7 @@ class RegistryModule {
976
998
 
977
999
  try {
978
1000
  const params = this._normalizeQueryParams(req.query);
979
- const {fhirVersion, valueSet, usage, version} = params;
1001
+ const {fhirVersion, valueSet, usage, version, language} = params;
980
1002
  let {url} = params;
981
1003
 
982
1004
  // If a version was supplied separately, fold it into the code system
@@ -1050,16 +1072,16 @@ class RegistryModule {
1050
1072
 
1051
1073
  if (valueSet) {
1052
1074
  // Value set resolve
1053
- const resolveResult = this.api.resolveValueSet(fhirVersion, valueSet, authoritativeOnly, usage);
1075
+ const resolveResult = this.api.resolveValueSet(fhirVersion, valueSet, authoritativeOnly, usage, language);
1054
1076
  result = resolveResult.result;
1055
1077
  matches = resolveResult.matches;
1056
- this.logger.info(`Resolved ValueSet ${valueSet} for FHIR ${fhirVersion} (usage=${usage}): ${matches}`);
1078
+ this.logger.info(`Resolved ValueSet ${valueSet} for FHIR ${fhirVersion} (usage=${usage}, language=${language || 'none'}): ${matches}`);
1057
1079
  } else {
1058
1080
  // Code system resolve
1059
- const resolveResult = this.api.resolveCodeSystem(fhirVersion, url, authoritativeOnly, usage);
1081
+ const resolveResult = this.api.resolveCodeSystem(fhirVersion, url, authoritativeOnly, usage, language);
1060
1082
  result = resolveResult.result;
1061
1083
  matches = resolveResult.matches;
1062
- this.logger.info(`Resolved CodeSystem ${url} for FHIR ${fhirVersion} (usage=${usage}): ${matches}`);
1084
+ this.logger.info(`Resolved CodeSystem ${url} for FHIR ${fhirVersion} (usage=${usage}, language=${language || 'none'}): ${matches}`);
1063
1085
  }
1064
1086
 
1065
1087
  // If only authoritative servers are requested, filter results
@@ -1076,7 +1098,7 @@ class RegistryModule {
1076
1098
  htmlServer.loadTemplate('registry', templatePath);
1077
1099
  }
1078
1100
 
1079
- const content = this.buildResolveResultContent(result, fhirVersion, url || valueSet, usage);
1101
+ const content = this.buildResolveResultContent(result, fhirVersion, url || valueSet, usage, language);
1080
1102
  const stats = this.api.getStatistics();
1081
1103
  stats.processingTime = Date.now() - startTime;
1082
1104
 
@@ -1104,7 +1126,7 @@ class RegistryModule {
1104
1126
  }
1105
1127
  }
1106
1128
 
1107
- buildResolveResultContent(result, fhirVersion, resourceUrl, usage) {
1129
+ buildResolveResultContent(result, fhirVersion, resourceUrl, usage, language) {
1108
1130
  let html = '';
1109
1131
 
1110
1132
  // Query information section
@@ -1119,6 +1141,9 @@ class RegistryModule {
1119
1141
  if (usage) {
1120
1142
  html += `<p><strong>Usage:</strong> ${escape(usage)}</p>`;
1121
1143
  }
1144
+ if (language) {
1145
+ html += `<p><strong>Language:</strong> ${escape(language)}</p>`;
1146
+ }
1122
1147
  html += '</div>';
1123
1148
  html += '</div>';
1124
1149
 
@@ -1136,6 +1161,7 @@ class RegistryModule {
1136
1161
  html += '<th>Server Name</th>';
1137
1162
  html += '<th>URL</th>';
1138
1163
  html += '<th>Security</th>';
1164
+ html += '<th>Languages</th>';
1139
1165
  html += '<th>Access Info</th>';
1140
1166
  html += '</tr>';
1141
1167
  html += '</thead>';
@@ -1146,6 +1172,7 @@ class RegistryModule {
1146
1172
  html += `<td>${escape(server['server-name'])}</td>`;
1147
1173
  html += `<td><a href="${server.url}" target="_blank">${escape(server.url)}</a></td>`;
1148
1174
  html += `<td>${this.renderSecurityTags(server)}</td>`;
1175
+ html += `<td>${server.languages ? escape(server.languages.join(', ')) : ''}</td>`;
1149
1176
  html += `<td>${server.access_info ? escape(server.access_info) : ''}</td>`;
1150
1177
  html += '</tr>';
1151
1178
  });
@@ -1173,6 +1200,7 @@ class RegistryModule {
1173
1200
  html += '<th>Server Name</th>';
1174
1201
  html += '<th>URL</th>';
1175
1202
  html += '<th>Security</th>';
1203
+ html += '<th>Languages</th>';
1176
1204
  html += '<th>Access Info</th>';
1177
1205
  html += '<th>Content</th>';
1178
1206
  html += '</tr>';
@@ -1184,6 +1212,11 @@ class RegistryModule {
1184
1212
  html += `<td>${escape(server['server-name'])}</td>`;
1185
1213
  html += `<td><a href="${server.url}" target="_blank">${escape(server.url)}</a></td>`;
1186
1214
  html += `<td>${this.renderSecurityTags(server)}</td>`;
1215
+ if (server['language-support'] === 'unknown') {
1216
+ 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>';
1217
+ } else {
1218
+ html += `<td>${server.languages ? escape(server.languages.join(', ')) : ''}</td>`;
1219
+ }
1187
1220
  html += `<td>${server.access_info ? escape(server.access_info) : ''}</td>`;
1188
1221
  html += `<td>${server.content ? escape(server.content) : ''}</td>`;
1189
1222
  html += '</tr>';
@@ -1229,6 +1262,7 @@ class RegistryModule {
1229
1262
  const url = queryParams.url || '';
1230
1263
  const version = queryParams.version || '';
1231
1264
  const valueSet = queryParams.valueSet || '';
1265
+ const language = queryParams.language || '';
1232
1266
  const authoritativeOnly = queryParams.authoritativeOnly === 'true';
1233
1267
 
1234
1268
  let html = '';
@@ -1271,6 +1305,13 @@ class RegistryModule {
1271
1305
  html += '</p>';
1272
1306
  html += '<p class="text-muted small">Example: http://hl7.org/fhir/ValueSet/observation-codes</p>';
1273
1307
 
1308
+ // Language field (optional)
1309
+ html += '<p>';
1310
+ html += '<label for="language" class="form-label fw-bold">Language</label>';
1311
+ html += `<input type="text" class="form-control" id="language" name="language"
1312
+ value="${escape(language)}">`;
1313
+ html += '</p>';
1314
+ html += '<p class="text-muted small">Optional. The language of the request being routed, in Accept-Language syntax. Examples: de or de-AT, de;q=0.9, en;q=0.1</p>';
1274
1315
 
1275
1316
  // Authoritative Only checkbox
1276
1317
  html += '<p>';
@@ -187,7 +187,7 @@ function runTests() {
187
187
  if (rows2.length > 0) {
188
188
  console.log(` Versions: ${rows2.map(r => `${r.version} (auth=${r.authoritative})`).join(', ')}`);
189
189
  }
190
- console.log(` Test ${rows2.length === 1 && rows2[0].version === '4.0.1' ? '✓ PASSED (strict)' : rows2.length === 2 ? '✓ PASSED (Pascal-style)' : '✗ FAILED'}\n`);
190
+ console.log(` Test ${rows2.length === 1 && rows2[0].version === '4.0.1' ? '✓ PASSED (strict)' : rows2.length === 2 ? '✓ PASSED (lenient)' : '✗ FAILED'}\n`);
191
191
 
192
192
  // Test 3: should rank servers without errors higher
193
193
  console.log('Test 3: should not include servers with errors even if authoritative');
package/server.js CHANGED
@@ -68,6 +68,10 @@ htmlServer.setSponsorMessage(config.sponsorMessage ? config.sponsorMessage : '')
68
68
 
69
69
  const app = express();
70
70
 
71
+ // Behind nginx (or any reverse proxy): honor X-Forwarded-* so req.protocol and
72
+ // req.hostname reflect what the client actually requested, not the loopback hop.
73
+ app.set('trust proxy', config.server.trustProxy ?? true);
74
+
71
75
  const PORT = process.env.PORT || config.server.port || 3000;
72
76
 
73
77
  // Middleware
@@ -860,4 +864,7 @@ async function serveFhirsmithHome(req, res) {
860
864
  }
861
865
 
862
866
  // Start the server
863
- startServer();
867
+ startServer().catch((err) => {
868
+ serverLog.error('Fatal error during startup:', err);
869
+ process.exit(1);
870
+ });
package/token/token.js CHANGED
@@ -528,7 +528,7 @@ class TokenModule {
528
528
  }
529
529
 
530
530
  if (userId) {
531
- this.logSecurityEvent(userId, 'logout', req.ip, req.get('User-Agent'), {});
531
+ void this.logSecurityEvent(userId, 'logout', req.ip, req.get('User-Agent'), {}); // fire-and-forget audit; errors handled internally
532
532
  }
533
533
 
534
534
  res.redirect('/token/login');
@@ -158,6 +158,7 @@ CODESYSTEM_SHAREABLE_MISSING_HL7 = Code systems published by HL7 SHALL conform t
158
158
  CODESYSTEM_SUPP_NO_DISPLAY = This display (''{0}'') differs from that defined by the base code system (''{1}''). Both displays claim to be the ''primary designation'' for the same language (''{2}''), and the correct interpretation of this is undefined
159
159
  CODESYSTEM_THO_CHECK = Most code systems defined in HL7 IGs will need to move to THO later during the process. Consider giving this code system a THO URL now (See https://confluence.hl7.org/display/TSMG/Terminology+Play+Book, and/or talk to TSMG)
160
160
  CODESYSTEM_TOO_COSTLY_TIME = The operation ''{1}'' took too long to process (>{0}sec) (at {2})
161
+ CODESYSTEM_NOT_ENUMERABLE = The code system ''{0}'' cannot be expanded because its codes cannot be iterated or enumerated in any meaningful sense
161
162
  Coding_has_no_system__cannot_validate = Coding has no system. A code with no system has no defined meaning, and it cannot be validated. A system should be provided
162
163
  CONCEPTMAP_GROUP_SOURCE_CODE_INVALID = The source code ''{0}'' is not valid in the code system {1}
163
164
  CONCEPTMAP_GROUP_SOURCE_CODE_INVALID_VS = The source code ''{0}'' is not valid in the value set {1}
@@ -1492,7 +1493,8 @@ RESOURCE_INTERNAL_USE_ONLY = This {0} comes from the package {1} which has been
1492
1493
  TYPE_SPECIFIC_CHECKS_DT_SID_INCORRECT = The URL {0} is not a correct URL to use (in spite of being in the standard, and/or accepted in the past)
1493
1494
  INACTIVE_DISPLAY_FOUND_one = ''{1}'' is no longer considered a correct display for code ''{2}'' (status = {4}). The correct display is ''{3}''
1494
1495
  INACTIVE_DISPLAY_FOUND_other = ''{1}'' is no longer considered a correct display for code ''{2}'' (status = {4}). The correct display is one of {3}
1495
- INACTIVE_CONCEPT_FOUND = The concept ''{1}'' has a status of {0} and its use should be reviewed
1496
+ INACTIVE_CONCEPT_FOUND = The concept ''{1}'' has a status of {0} and its use should be reviewed
1497
+ INACTIVE_CONCEPT_FOUND_ADD = The concept ''{1}'' has a status of {0} and {2} and its use should be reviewed
1496
1498
  DEPRECATED_CONCEPT_FOUND = The concept ''{1}'' is deprecated and its use should be reviewed
1497
1499
  CONCEPT_DEPRECATED_IN_VALUESET = The presence of the concept ''{1}'' in the system ''{0}'' in the value set {3} is marked with a status of {2} and its use should be reviewed
1498
1500
  SYSTEM_VERSION_MULTIPLE_OVERRIDE = Multiple version overrides found for system {0}: ''{1}'', ''{2}'');