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/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.11.0] - 2026-mm-dd
9
+
10
+ ### Added
11
+
12
+ - Upgrade the tx registry module to support exclusions and languages
13
+ - Add support for sealed caches
14
+
15
+ ### Changed
16
+
17
+ - rework caching
18
+ - Remember filter analysis on cached value sets
19
+ - Improved Rendering for CodeSystem and ValueSets
20
+ - Update ECL & tests given ECL functionality confirmations
21
+ - Update snomed subset from test IG
22
+ - improved error when multiple cache-id headers passed to the server
23
+ - fix slow crawling for FHIRsmith servers by the tx registry
24
+ - make tx search page size default to 1000 for json/xml and leave it at 20 for html
25
+ - rework batch processing for caches
26
+
27
+ ### Fixed
28
+
29
+ - Report the FIRST matching coding, not the last, and only one message about status
30
+ - rework how expansions on complex codesystems are handled
31
+ - better handling of expansions on grammar based systems
32
+ - (SNOMED CT) consistent use of designations
33
+ - give preferredForLanguage a display in expansions
34
+ - fix various ECL processing bugs
35
+ - fix precedence order problem loading THO content after core content
36
+ - fix links in metadata statements
37
+ - fix unhandled promises
38
+ - fix handling of properties in R4 expansions
39
+ - fix bug validating codes from erroneously coded R4 v2 tables
40
+
41
+ ### Tx Conformance Statement
42
+
43
+ FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.2, runner v6.9.12
8
44
 
9
45
  ## [v0.10.1] - 2026-06-27
10
46
 
@@ -0,0 +1,22 @@
1
+ ## [m.n.p] - 2026-mm-dd
2
+
3
+ ### Security
4
+
5
+ -
6
+
7
+ ### Added
8
+
9
+ -
10
+
11
+ ### Changed
12
+
13
+ -
14
+
15
+ ### Fixed
16
+
17
+ -
18
+
19
+ ### Tx Conformance Statement
20
+
21
+ (paste)
22
+
package/library/logger.js CHANGED
@@ -226,7 +226,14 @@ class Logger {
226
226
 
227
227
  if (messageOrError instanceof Error) {
228
228
  message = messageOrError.message;
229
- stack = messageOrError.stack;
229
+ // Expected, client-facing errors - an OperationOutcome/Issue carrying a 4xx
230
+ // statusCode (e.g. "code not found", "cache-id unknown") - are routine and
231
+ // don't warrant a stack trace in the log. Keep the stack for genuinely
232
+ // unexpected errors (5xx, or no statusCode at all), and for everything when
233
+ // debug logging is enabled.
234
+ const sc = messageOrError.statusCode;
235
+ const expected = typeof sc === 'number' && sc >= 400 && sc < 500;
236
+ stack = (expected && !this._shouldLog('debug')) ? undefined : messageOrError.stack;
230
237
  } else {
231
238
  message = String(messageOrError);
232
239
  stack = meta?.stack;
@@ -823,7 +823,7 @@ class PackageContentLoader {
823
823
 
824
824
  try {
825
825
  const content = await fs.readFile(filePath, 'utf8');
826
- return JSON.parse(content);
826
+ return normalizeVersionedCanonicals(JSON.parse(content));
827
827
  } catch (error) {
828
828
  throw new Error(`Failed to load file ${entry.filename}: ${error.message}`);
829
829
  }
@@ -923,4 +923,42 @@ class PackageContentLoader {
923
923
  }
924
924
 
925
925
 
926
- module.exports = { PackageManager, PackageContentLoader };
926
+ /**
927
+ * Some R4-era core package resources bake a version into the canonical url itself:
928
+ * hl7.fhir.r4.core#4.0.1 publishes the multi-version v2 tables (0006, 0360, 0391)
929
+ * as e.g. url "http://terminology.hl7.org/CodeSystem/v2-0360|2.7" with version "0360"
930
+ * (the table number!). The base packages can never be republished, so normalize at
931
+ * load time: split the pipe into url + version, on the CodeSystems themselves and on
932
+ * ValueSet compose include/exclude systems that reference them. Without this,
933
+ * provider.system() never matches the system in a coding, so validation fails -
934
+ * silently, in the CodeSystem $validate-code case.
935
+ */
936
+ function normalizeVersionedCanonicals(resource) {
937
+ if (!resource || typeof resource !== 'object') {
938
+ return resource;
939
+ }
940
+ if (resource.resourceType === 'CodeSystem' || resource.resourceType === 'ValueSet') {
941
+ if (typeof resource.url === 'string' && resource.url.includes('|')) {
942
+ const cut = resource.url.indexOf('|');
943
+ // the url-embedded version wins over the version element (which is "0360" etc)
944
+ resource.version = resource.url.substring(cut + 1);
945
+ resource.url = resource.url.substring(0, cut);
946
+ }
947
+ if (resource.resourceType === 'ValueSet' && resource.compose) {
948
+ for (const list of [resource.compose.include, resource.compose.exclude]) {
949
+ for (const inc of list || []) {
950
+ if (typeof inc.system === 'string' && inc.system.includes('|')) {
951
+ const cut = inc.system.indexOf('|');
952
+ if (!inc.version) {
953
+ inc.version = inc.system.substring(cut + 1);
954
+ }
955
+ inc.system = inc.system.substring(0, cut);
956
+ }
957
+ }
958
+ }
959
+ }
960
+ }
961
+ return resource;
962
+ }
963
+
964
+ module.exports = { PackageManager, PackageContentLoader, normalizeVersionedCanonicals };
@@ -313,7 +313,7 @@ class PackageWatcher {
313
313
  */
314
314
  stop() {
315
315
  if (this.watcher) {
316
- this.watcher.close();
316
+ this.watcher.close().catch((err) => this.log.error('Error closing watcher: ' + err.message));
317
317
  this.watcher = null;
318
318
  }
319
319
  if (this.debounceTimer) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "txVersion": "1.9.1",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -109,7 +109,8 @@
109
109
  "fast-xml-parser": "^5.3.4",
110
110
  "inquirer": {
111
111
  "lodash": "4.18.1"
112
- }
112
+ },
113
+ "brace-expansion": "^5.0.8"
113
114
  },
114
115
  "bin": {
115
116
  "tx-import": "./tx/importers/tx-import.js",
@@ -605,10 +605,6 @@ class PackagesModule {
605
605
  this.crawler = new PackageCrawler(this.config, this.db, this.stats);
606
606
  }
607
607
 
608
- // The feed uses the versioned package.tgz url as the (permalink) GUID, so reusing
609
- // the link as the GUID keeps a later crawl from inserting a duplicate.
610
- const guid = link;
611
-
612
608
  const buffer = await this.crawler.fetchUrl(link);
613
609
  const npm = await this.crawler.extractNpmPackage(buffer, link);
614
610
 
@@ -618,22 +614,37 @@ class PackagesModule {
618
614
  }
619
615
 
620
616
  const idver = npm.id + '#' + npm.version;
621
- const replaced = await this.deleteVersionsByGuid(guid);
617
+
618
+ // Delete whatever is stored for this package version, matching on the id#version
619
+ // actually found in the fetched tarball. This used to match on GUID = link, but the
620
+ // stored GUID is whatever the feed said at crawl time (http vs https, or another
621
+ // permalink form), so an exact string match could silently delete nothing and leave
622
+ // the stale copy in place alongside the newly stored one.
623
+ const old = await this.deleteVersionsByIdVersion(npm.id, npm.version);
624
+
625
+ // Keep the old row's GUID when there was one - it is the feed's permalink, and
626
+ // reusing it stops the next crawl from re-inserting the package as a duplicate.
627
+ // When we never had the package, the link is the best available GUID (the feeds
628
+ // use the versioned package.tgz url as the permalink GUID anyway).
629
+ const guid = old.guids[0] || link;
622
630
 
623
631
  const itemLog = { status: '??' };
624
632
  await this.crawler.store(link, link, guid, new Date(), buffer, idver, itemLog);
625
633
 
626
- pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' + replaced + ' existing row(s))');
627
- return { status: 'updated', id: npm.id, version: npm.version, replaced };
634
+ pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' + old.count + ' existing row(s), guid ' + guid + ')');
635
+ return { status: 'updated', id: npm.id, version: npm.version, replaced: old.count };
628
636
  }
629
637
 
630
- // Delete a stored package version and all of its child rows, by GUID.
631
- deleteVersionsByGuid(guid) {
638
+ // Delete a stored package version and all of its child rows, by package id and
639
+ // version. Resolves to { count, guids }: how many PackageVersions rows went, and
640
+ // the distinct GUIDs they were stored under (so a caller can reuse the permalink).
641
+ deleteVersionsByIdVersion(id, version) {
632
642
  return new Promise((resolve, reject) => {
633
- this.db.all('SELECT PackageVersionKey FROM PackageVersions WHERE GUID = ?', [guid], (err, rows) => {
643
+ this.db.all('SELECT PackageVersionKey, GUID FROM PackageVersions WHERE Id = ? AND Version = ?', [id, version], (err, rows) => {
634
644
  if (err) return reject(err);
635
645
  const keys = (rows || []).map(r => r.PackageVersionKey);
636
- if (keys.length === 0) return resolve(0);
646
+ const guids = [...new Set((rows || []).map(r => r.GUID))];
647
+ if (keys.length === 0) return resolve({ count: 0, guids: [] });
637
648
  const ph = keys.map(() => '?').join(',');
638
649
  const stmts = [
639
650
  'DELETE FROM PackageFHIRVersions WHERE PackageVersionKey IN (' + ph + ')',
@@ -642,7 +653,7 @@ class PackagesModule {
642
653
  'DELETE FROM PackageVersions WHERE PackageVersionKey IN (' + ph + ')'
643
654
  ];
644
655
  const runNext = (i) => {
645
- if (i >= stmts.length) return resolve(keys.length);
656
+ if (i >= stmts.length) return resolve({ count: keys.length, guids });
646
657
  this.db.run(stmts[i], keys, (e) => e ? reject(e) : runNext(i + 1));
647
658
  };
648
659
  runNext(0);
@@ -1289,8 +1300,10 @@ class PackagesModule {
1289
1300
  //
1290
1301
  // POST /update-package { "links": ["http://hl7.org/fhir/uv/ips/2.0.1/package.tgz", ...] }
1291
1302
  //
1292
- // The link is the versioned package.tgz url, which is exactly the GUID the feed uses,
1293
- // so the replacement keeps the same GUID and a later crawl won't create a duplicate.
1303
+ // The link is the versioned package.tgz url. The stored copy to replace is found by
1304
+ // the id#version inside the fetched tarball (not by matching the link against the
1305
+ // stored GUID, which can differ in scheme or form from what the feed published), and
1306
+ // the replacement keeps the old row's GUID so a later crawl won't create a duplicate.
1294
1307
  // If config.updateToken is set, the request must carry it in the x-update-token header.
1295
1308
  this.router.post('/update-package', async (req, res) => {
1296
1309
  const start = Date.now();
@@ -1712,7 +1725,7 @@ class PackagesModule {
1712
1725
  // Update download counts after successful response
1713
1726
  // Do this asynchronously to not delay the response
1714
1727
  setImmediate(() => {
1715
- this.incrementDownloadCounts(packageData.PackageVersionKey, id);
1728
+ void this.incrementDownloadCounts(packageData.PackageVersionKey, id); // fire-and-forget; errors handled internally
1716
1729
  });
1717
1730
 
1718
1731
  } catch (error) {
@@ -2665,14 +2665,14 @@ class PublisherModule {
2665
2665
  return colors[status] || 'secondary';
2666
2666
  }
2667
2667
 
2668
- async logUserAction(userId, action, targetId, ipAddress) {
2669
- return new Promise((resolve) => {
2670
- this.db.run(
2671
- 'INSERT INTO user_actions (user_id, action, target_id, ip_address) VALUES (?, ?, ?, ?)',
2672
- [userId, action, targetId, ipAddress],
2673
- () => resolve() // Don't fail if logging fails
2674
- );
2675
- });
2668
+ // Fire-and-forget audit logging: callers don't await this, and it must never fail a
2669
+ // request, so it returns void and swallows any DB error in the callback.
2670
+ logUserAction(userId, action, targetId, ipAddress) {
2671
+ this.db.run(
2672
+ 'INSERT INTO user_actions (user_id, action, target_id, ip_address) VALUES (?, ?, ?, ?)',
2673
+ [userId, action, targetId, ipAddress],
2674
+ () => {} // don't fail (or block) the request if audit logging fails
2675
+ );
2676
2676
  }
2677
2677
 
2678
2678
  getStatus() {
package/registry/api.js CHANGED
@@ -10,18 +10,20 @@ class RegistryAPI {
10
10
 
11
11
  /**
12
12
  * Build rows for code system queries
13
- * Matches the Pascal buildRowsCS functionality
13
+ * Build the discovery rows for a code system query
14
14
  */
15
15
  buildRowsForCodeSystem(params = {}) {
16
16
  const {
17
17
  registryCode = '',
18
18
  serverCode = '',
19
19
  version = '',
20
- codeSystem = ''
20
+ codeSystem = '',
21
+ language = ''
21
22
  } = params;
22
23
 
23
24
  const rows = [];
24
25
  const data = this.crawler.getData();
26
+ const requestLangs = ServerRegistryUtilities.parseAcceptLanguage(language);
25
27
 
26
28
  data.registries.forEach(registry => {
27
29
  if (registryCode && registry.code !== registryCode) return;
@@ -29,13 +31,15 @@ class RegistryAPI {
29
31
  registry.servers.forEach(server => {
30
32
  if (serverCode && server.code !== serverCode) return;
31
33
 
32
- // Check if server is authoritative for this code system
33
- const isAuth = codeSystem ? ServerRegistryUtilities.hasMatchingCodeSystem(
34
- codeSystem,
35
- server.authCSList,
36
- true, // support wildcards,
37
- false // allow version matching
38
- ) : false;
34
+ // Excluded content is hidden from the ecosystem entirely for this server
35
+ if (codeSystem && server.isExcludedTarget(codeSystem)) return;
36
+
37
+ // Check if server is authoritative for this code system, taking language
38
+ // specific claims into account (maskBase matches hasMatchingCodeSystem semantics)
39
+ const authMatch = codeSystem
40
+ ? server.matchAuthCS(codeSystem, requestLangs, true)
41
+ : { isAuth: false, scoped: false };
42
+ const isAuth = authMatch.isAuth;
39
43
 
40
44
  server.versions.forEach(versionInfo => {
41
45
  if (version && !ServerRegistryUtilities.versionMatches(version, versionInfo.version)) {
@@ -64,6 +68,9 @@ class RegistryAPI {
64
68
  versionInfo,
65
69
  isAuth
66
70
  );
71
+ if (authMatch.scoped) {
72
+ row.languages = [authMatch.tag];
73
+ }
67
74
  rows.push(row);
68
75
  }
69
76
  });
@@ -75,7 +82,7 @@ class RegistryAPI {
75
82
 
76
83
  /**
77
84
  * Build rows for value set queries
78
- * Matches the Pascal buildRowsVS functionality
85
+ * Build the discovery rows for a value set query
79
86
  */
80
87
  buildRowsForValueSet(params = {}) {
81
88
  const {
@@ -94,7 +101,11 @@ class RegistryAPI {
94
101
  registry.servers.forEach(server => {
95
102
  if (serverCode && server.code !== serverCode) return;
96
103
 
104
+ // Excluded content is hidden from the ecosystem entirely for this server
105
+ if (valueSet && server.isExcludedTarget(valueSet)) return;
106
+
97
107
  // Check if server is authoritative for this value set
108
+ // (value set claims have no language dimension at this time)
98
109
  const isAuth = valueSet ? ServerRegistryUtilities.hasMatchingValueSet(
99
110
  valueSet,
100
111
  server.authVSList,
@@ -129,7 +140,7 @@ class RegistryAPI {
129
140
  );
130
141
 
131
142
  // Include if authoritative OR has the value set
132
- // This matches the Pascal logic: if auth or hasMatchingValueSet
143
+ // if the server is authoritative, all its versions are included
133
144
  if (isAuth || hasValueSet) {
134
145
  includeRow = true;
135
146
  }
@@ -339,13 +350,13 @@ class RegistryAPI {
339
350
  /**
340
351
  * Find best server for a given code system/value set
341
352
  */
342
- findBestServer(type, url, version) {
353
+ findBestServer(type, url, version, language = '') {
343
354
  let rows;
344
355
 
345
356
  if (type === 'codesystem') {
346
- rows = this.buildRowsForCodeSystem({ codeSystem: url, version });
357
+ rows = this.buildRowsForCodeSystem({ codeSystem: url, version, language });
347
358
  } else if (type === 'valueset') {
348
- rows = this.buildRowsForValueSet({ valueSet: url, version });
359
+ rows = this.buildRowsForValueSet({ valueSet: url, version, language });
349
360
  } else {
350
361
  throw new Error(`Unknown type: ${type}`);
351
362
  }
@@ -378,9 +389,9 @@ class RegistryAPI {
378
389
 
379
390
  /**
380
391
  * NEW FUNCTION: Resolve the best server for a code system
381
- * Based on Pascal resolveCS function
392
+ * Implements the ecosystem Resolution API for code systems
382
393
  */
383
- resolveCodeSystem(fhirVersion, codeSystem, authoritativeOnly, usage = '') {
394
+ resolveCodeSystem(fhirVersion, codeSystem, authoritativeOnly, usage = '', language = '') {
384
395
  if (!fhirVersion) {
385
396
  throw new Error('A FHIR version is required');
386
397
  }
@@ -388,6 +399,7 @@ class RegistryAPI {
388
399
  throw new Error('A code system URL is required');
389
400
  }
390
401
  const normalizedVersion = this._normalizeFhirVersion(fhirVersion);
402
+ const requestLangs = ServerRegistryUtilities.parseAcceptLanguage(language);
391
403
 
392
404
  const result = {
393
405
  formatVersion: '1',
@@ -397,18 +409,26 @@ class RegistryAPI {
397
409
  };
398
410
 
399
411
  const matchedServers = [];
412
+ const authMatches = []; // {entry, score} - sorted by language match before returning
400
413
  const data = this.crawler.getData();
401
414
 
402
415
  data.registries.forEach(registry => {
403
416
  registry.servers.forEach(server => {
404
417
  let added = false;
405
418
 
419
+ // Excluded content is hidden from the ecosystem entirely for this server -
420
+ // not authoritative, not a candidate
421
+ if (server.isExcludedTarget(codeSystem)) return;
422
+
406
423
  // Check if server supports the requested usage tag
407
424
  if (server.usageList.length === 0 ||
408
425
  (usage && server.usageList.includes(usage))) {
409
426
 
410
- // Check if server is authoritative for this code system
411
- const isAuth = server.isAuthCS(codeSystem);
427
+ // Check if server is authoritative for this code system, taking language
428
+ // specific claims into account (a language specific claim is invisible to
429
+ // requests without a matching language)
430
+ const authMatch = server.matchAuthCS(codeSystem, requestLangs);
431
+ const isAuth = authMatch.isAuth;
412
432
 
413
433
  server.versions.forEach(version => {
414
434
  if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) {
@@ -425,9 +445,19 @@ class RegistryAPI {
425
445
 
426
446
  if (hasMatchingCS) {
427
447
  if (isAuth) {
428
- result.authoritative.push(this.createServerEntry(server, version));
448
+ authMatches.push({
449
+ entry: this.createServerEntry(server, version, null, authMatch),
450
+ score: authMatch.score
451
+ });
429
452
  } else if (!authoritativeOnly) {
430
- result.candidates.push(this.createServerEntry(server, version, content.content));
453
+ const candidate = this.createServerEntry(server, version, content.content);
454
+ if (requestLangs) {
455
+ // the ecosystem has no knowledge of a candidate's language abilities -
456
+ // mark it so clients can apply their wildcard strictness policy
457
+ // (see 'Language Specific Claims' in the tx ecosystem IG)
458
+ candidate['language-support'] = 'unknown';
459
+ }
460
+ result.candidates.push(candidate);
431
461
  }
432
462
  added = true;
433
463
  }
@@ -442,20 +472,27 @@ class RegistryAPI {
442
472
  });
443
473
 
444
474
  // NEW: Fallback - if no matches found, check for authoritative pattern matches
445
- if (result.authoritative.length === 0 && result.candidates.length === 0) {
475
+ if (authMatches.length === 0 && result.candidates.length === 0) {
446
476
  data.registries.forEach(registry => {
447
477
  registry.servers.forEach(server => {
478
+ // Excluded content stays hidden in the fallback path too
479
+ if (server.isExcludedTarget(codeSystem)) return;
480
+
448
481
  // Check if server supports the requested usage tag
449
482
  if (server.usageList.length === 0 ||
450
483
  (usage && server.usageList.includes(usage))) {
451
484
 
452
485
  // Check if server is authoritative for this code system
453
- const isAuth = server.isAuthCS(codeSystem);
486
+ // (taking language specific claims into account)
487
+ const authMatch = server.matchAuthCS(codeSystem, requestLangs);
454
488
 
455
- if (isAuth) {
489
+ if (authMatch.isAuth) {
456
490
  server.versions.forEach(version => {
457
491
  if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) {
458
- result.authoritative.push(this.createServerEntry(server, version));
492
+ authMatches.push({
493
+ entry: this.createServerEntry(server, version, null, authMatch),
494
+ score: authMatch.score
495
+ });
459
496
  if (!matchedServers.includes(server.code)) {
460
497
  matchedServers.push(server.code);
461
498
  }
@@ -467,6 +504,12 @@ class RegistryAPI {
467
504
  });
468
505
  }
469
506
 
507
+ // Language specific matches rank before authoritative-list matches, most specific
508
+ // match first. Array.prototype.sort is stable, so entries with equal scores (e.g.
509
+ // all matched on the authoritative list) keep their existing (registration) order
510
+ authMatches.sort((a, b) => a.score - b.score);
511
+ result.authoritative = authMatches.map(m => m.entry);
512
+
470
513
  return {
471
514
  result: this._cleanEmptyArrays(result),
472
515
  matches: matchedServers.length > 0 ? matchedServers.join(',') : '--'
@@ -475,9 +518,13 @@ class RegistryAPI {
475
518
 
476
519
  /**
477
520
  * NEW FUNCTION: Resolve the best server for a value set
478
- * Based on Pascal resolveVS function
521
+ * Implements the ecosystem Resolution API for value sets
479
522
  */
480
- resolveValueSet(fhirVersion, valueSet, authoritativeOnly, usage = '') {
523
+ // eslint-disable-next-line no-unused-vars -- language is accepted for API symmetry (see note below)
524
+ resolveValueSet(fhirVersion, valueSet, authoritativeOnly, usage = '', language = '') {
525
+ // Note: the language parameter is accepted for API symmetry with resolveCodeSystem,
526
+ // but value set claims have no language dimension at this time (language specific
527
+ // claims - the languages property - apply to code systems only), so it has no effect
481
528
  if (!fhirVersion) {
482
529
  throw new Error('A FHIR version is required');
483
530
  }
@@ -508,6 +555,10 @@ class RegistryAPI {
508
555
  registry.servers.forEach(server => {
509
556
  let added = false;
510
557
 
558
+ // Excluded content is hidden from the ecosystem entirely for this server -
559
+ // not authoritative, not a candidate
560
+ if (server.isExcludedTarget(valueSet)) return;
561
+
511
562
  // Check if server supports the requested usage tag
512
563
  if (server.usageList.length === 0 ||
513
564
  (usage && server.usageList.includes(usage))) {
@@ -562,8 +613,11 @@ class RegistryAPI {
562
613
 
563
614
  /**
564
615
  * Helper function to create a server entry for resolve results
616
+ * @param {Object} authMatch - optional result of server.matchAuthCS(); when the entry
617
+ * was matched on a language specific claim, the claim's language tag is reported so
618
+ * the client can tell language routing applied (vs falling through to the default server)
565
619
  */
566
- createServerEntry(server, version) {
620
+ createServerEntry(server, version, content = null, authMatch = null) {
567
621
  const entry = {
568
622
  'server-name': server.name,
569
623
  url: version.address
@@ -575,8 +629,15 @@ class RegistryAPI {
575
629
  if (server.accessInfo) {
576
630
  entry.access_info = server.accessInfo;
577
631
  }
578
- if (version.content) {
579
- entry.content = version.content;
632
+ // Report the content mode of the matched code system, if the server provided one
633
+ // (per the resolve API in the tx ecosystem IG, candidates report content when known).
634
+ // This was previously read from version.content, which doesn't exist - the value
635
+ // passed by resolveCodeSystem was silently dropped.
636
+ if (content) {
637
+ entry.content = content;
638
+ }
639
+ if (authMatch && authMatch.scoped && authMatch.isAuth) {
640
+ entry.languages = [authMatch.tag];
580
641
  }
581
642
 
582
643
  return entry;
@@ -584,7 +645,7 @@ class RegistryAPI {
584
645
 
585
646
  /**
586
647
  * NEW FUNCTION: Render a JSON result as an HTML table
587
- * Based on Pascal renderJson function
648
+ * Renders a discovery result as an HTML table
588
649
  */
589
650
  renderJsonToHtml(json, path, regCode = '', serverCode = '', versionCode = '') {
590
651
  let html = '<table class="grid">\n';
@@ -648,7 +709,7 @@ class RegistryAPI {
648
709
 
649
710
  /**
650
711
  * NEW FUNCTION: Render registry info as HTML
651
- * Based on Pascal renderInfo function
712
+ * Renders the registry tree as HTML
652
713
  */
653
714
  renderInfoToHtml() {
654
715
  const data = this.crawler.getData();
@@ -255,6 +255,20 @@ class RegistryCrawler {
255
255
  server.authCSList = (serverConfig.authoritative || []).sort();
256
256
  server.authVSList = (serverConfig['authoritative-valuesets'] || []).sort();
257
257
  server.usageList = (serverConfig.usage || []).sort();
258
+ // Content hidden from the ecosystem entirely - also filtered out of the discovered
259
+ // content lists below, and checked during claim matching (see the tx ecosystem IG)
260
+ server.exclusions = (serverConfig.exclusions || []).sort();
261
+ // Language specific authoritative claims: BCP-47 tag -> list of CS masks.
262
+ // A backwards compatible addition to registry format 1 - see the tx ecosystem IG
263
+ server.languages = {};
264
+ if (serverConfig.languages && typeof serverConfig.languages === 'object' && !Array.isArray(serverConfig.languages)) {
265
+ for (const tag of Object.keys(serverConfig.languages)) {
266
+ const masks = serverConfig.languages[tag];
267
+ if (Array.isArray(masks)) {
268
+ server.languages[tag] = masks.filter(m => typeof m === 'string').sort();
269
+ }
270
+ }
271
+ }
258
272
 
259
273
  // Process each FHIR version
260
274
  const fhirVersions = serverConfig.fhirVersions || [];
@@ -427,7 +441,7 @@ class RegistryCrawler {
427
441
  async fetchValueSets(version, server, exclusions) {
428
442
  // Initial search URL
429
443
  let count = 0;
430
- let searchUrl = `${version.address}/ValueSet?_elements=url,version`+(version.address.includes("fhir.org") ? "&_count=200" : "");
444
+ let searchUrl = `${version.address}/ValueSet?_elements=url,version&_count=1000`;
431
445
  try {
432
446
  // Set of URLs to avoid duplicates
433
447
  const valueSetUrls = new Set();