fhirsmith 0.14.1 → 0.14.2

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 (62) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +3 -2
  3. package/config-template.json +9 -0
  4. package/library/fhir-openapi-schema.js +441 -0
  5. package/library/html-server.js +9 -3
  6. package/library/npm-audit.js +265 -0
  7. package/library/openapi-doc.js +557 -0
  8. package/library/utilities.js +21 -0
  9. package/package.json +10 -4
  10. package/packages/openapi.js +13 -0
  11. package/packages/openapi.yaml +603 -0
  12. package/packages/packages-template.html +4 -2
  13. package/packages/packages.js +286 -81
  14. package/packages/readme.md +120 -0
  15. package/registry/api.js +117 -41
  16. package/registry/crawler.js +24 -0
  17. package/registry/fhirsmith-releases.js +170 -0
  18. package/registry/model.js +10 -0
  19. package/registry/openapi.js +13 -0
  20. package/registry/openapi.yaml +311 -0
  21. package/registry/readme.md +107 -208
  22. package/registry/registry-template.html +4 -1
  23. package/registry/registry.js +180 -19
  24. package/server.js +19 -5
  25. package/testing/openapi-schemas.config.js +58 -0
  26. package/testing/openapi-schemas.json +1920 -0
  27. package/testing/openapi.js +15 -0
  28. package/testing/openapi.yaml +273 -0
  29. package/testing/readme.md +43 -1
  30. package/testing/search.js +1 -1
  31. package/testing/testing-template.html +2 -0
  32. package/testing/testing.js +24 -0
  33. package/translations/Messages.properties +1 -0
  34. package/tx/README.md +58 -0
  35. package/tx/cm/cm-database.js +10 -1
  36. package/tx/cm/cm-package.js +8 -1
  37. package/tx/cs/cs-rxnorm.js +23 -6
  38. package/tx/html/tx-template.html +2 -0
  39. package/tx/library/canonical-resource.js +50 -1
  40. package/tx/library/codesystem.js +1 -0
  41. package/tx/library/conceptmap.js +1 -0
  42. package/tx/library/valueset.js +16 -0
  43. package/tx/library.js +17 -2
  44. package/tx/openapi-operations.js +363 -0
  45. package/tx/openapi-schemas.config.js +118 -0
  46. package/tx/openapi-schemas.json +7903 -0
  47. package/tx/openapi.js +289 -0
  48. package/tx/openapi.yaml +151 -0
  49. package/tx/operation-context.js +15 -0
  50. package/tx/params.js +3 -12
  51. package/tx/provider.js +10 -1
  52. package/tx/tx-html.js +6 -0
  53. package/tx/tx.js +55 -0
  54. package/tx/vs/vs-database.js +14 -5
  55. package/tx/vs/vs-package.js +10 -0
  56. package/tx/workers/expand.js +35 -16
  57. package/tx/workers/metadata.js +0 -13
  58. package/tx/workers/validate.js +65 -19
  59. package/tx/workers/worker.js +29 -8
  60. package/utilities/extract-schema-json.js +167 -0
  61. package/utilities/generate-openapi-schemas.js +49 -0
  62. package/xig/xig.js +2 -4
@@ -0,0 +1,120 @@
1
+ # Package Server
2
+
3
+ A registry of FHIR NPM packages. It crawls the package feeds listed in the FHIR IG
4
+ registry, stores every package version it finds, and serves them using the npm
5
+ registry protocol, with FHIR-specific search on top (by canonical URL, FHIR version
6
+ and dependency). It is the server behind packages2.fhir.org.
7
+
8
+ Its main clients are the HL7 Java tools (`org.hl7.fhir.utilities.npm.PackageClient`,
9
+ used by the IG Publisher and validator). Package documents, tarball URLs and search
10
+ results follow npm's formats, so npm clients can point `--registry` at
11
+ `<server>/packages`.
12
+
13
+ ## API
14
+
15
+ The public API is described by an OpenAPI 3.1 spec:
16
+
17
+ | URL | |
18
+ |---|---|
19
+ | `/packages/openapi` | Browsable reference (HTML), with a "try it" form for each GET operation |
20
+ | `/packages/openapi.json` | The spec as JSON |
21
+ | `/packages/openapi.yaml` | The spec as YAML (the source file, [openapi.yaml](openapi.yaml)) |
22
+
23
+ Every response from the package server carries a
24
+ `Link: </packages/openapi.json>; rel="service-desc"` header (RFC 8631), and the
25
+ HTML pages carry the matching `<link>` element.
26
+
27
+ In summary:
28
+
29
+ | Endpoint | Purpose |
30
+ |---|---|
31
+ | `GET /packages/catalog` | Search packages: `name`, `canonical`, `pkgcanonical`, `fhirversion`, `dependency`, `dependson`, `sort` |
32
+ | `GET /packages/-/v1/search` | npm search API: `text`, `size`, `from`, plus the `/catalog` parameters |
33
+ | `GET /packages/{id}` | npm package document: all versions, dependencies, tarball URLs |
34
+ | `GET /packages/{id}/{version}` | Download a package tarball |
35
+ | `GET /packages/updates` | Versions published since a date |
36
+ | `GET /packages/broken` | Package versions whose dependencies this registry doesn't hold |
37
+
38
+ Most endpoints return an HTML page instead of JSON when the request's `Accept`
39
+ header contains `text/html`. The JSON is the contract; the HTML is for people.
40
+
41
+ Query parameters are validated strictly. An unknown parameter, a repeated parameter,
42
+ or a value that's too long or badly formed is a 400, not silently ignored.
43
+
44
+ Not in the spec, deliberately:
45
+
46
+ * Operational pages: `/packages/stats`, `/packages/log` (the last crawler run),
47
+ `/packages/status`.
48
+ * `POST /packages/crawl`, which runs a full crawl on demand. It requires the
49
+ `x-crawl-token` header to match the `crawlToken` setting, and is disabled when no
50
+ token is configured.
51
+
52
+ ### Keeping the spec honest
53
+
54
+ [openapi.yaml](openapi.yaml) is maintained by hand. `tests/packages/openapi.test.js`
55
+ fails if:
56
+
57
+ * a route is added to the router without being described in the spec, or listed in
58
+ the test's `EXCLUDED` table with a reason;
59
+ * the spec describes a route the router doesn't have;
60
+ * a query or path parameter's name, length limit, pattern or default differs from the
61
+ validation rules in `packages.js` (`SEARCH_PARAMS`, `V1_SEARCH_PARAMS`,
62
+ `UPDATES_PARAMS`, `BROKEN_PARAMS`, `PATH_PARAMS`).
63
+
64
+ So when you change a route or a parameter rule, change `openapi.yaml` with it.
65
+ `tests/packages/search.test.js` covers the search behaviour itself against an
66
+ in-memory database.
67
+
68
+ ## Configuration
69
+
70
+ In the server config, under `modules.packages`:
71
+
72
+ ```json
73
+ "packages": {
74
+ "enabled": true,
75
+ "database": "packages.db",
76
+ "mirrorPath": "/absolute/path/to/mirror",
77
+ "crawlToken": "a-long-random-secret",
78
+ "crawler": {
79
+ "enabled": true,
80
+ "schedule": "0 * * * *"
81
+ }
82
+ }
83
+ ```
84
+
85
+ | Setting | |
86
+ |---|---|
87
+ | `database` | The SQLite database. A relative path is resolved against the data directory's `packages` folder. Created on first run. |
88
+ | `mirrorPath` | Directory where the crawler saves a copy of each tarball, as `{id}-{version}.tgz`. A scoped id `@scope/name` is saved as `$scope$name`. |
89
+ | `bucketPath` | Optional. If set, downloads redirect to `{bucketPath}/{file}` and tarball URLs point there, instead of being served from the database. The file names are the mirror's, so the bucket is expected to be a copy of the mirror. |
90
+ | `baseUrl` | Optional. The public base URL used when building package URLs. Defaults to the request's host. |
91
+ | `crawlToken` | Shared secret for `POST /packages/crawl`. Omit it to disable that endpoint; the scheduled crawler is unaffected. |
92
+ | `crawler.enabled` | Run the crawler at startup and on the schedule. |
93
+ | `crawler.schedule` | Cron expression for crawls. |
94
+ | `masterUrl` | The list of feeds to crawl. Defaults to `https://fhir.github.io/ig-registry/package-feeds.json`. |
95
+ | `localFeedDirs` | Optional. Directories feeds may be read from as local files (for testing). |
96
+ | `allowPrivateAddresses` | Optional, for testing only. Lets the crawler fetch from private and loopback addresses, which it otherwise refuses (SSRF protection). |
97
+
98
+ ## Crawling
99
+
100
+ Each crawl reads the master feed list, then each RSS feed in it (Simplifier last).
101
+ For each item, it downloads the package, checks the id, version and canonical, and
102
+ stores it.
103
+
104
+ * The master list's `package-restrictions` say which feeds may publish which package
105
+ ids. A package from a feed that isn't allowed to publish it is skipped.
106
+ * Items marked `notForPublication` are skipped.
107
+ * Feeds can be paginated (RFC 5005 `rel="next"`). The first page is read on every
108
+ crawl; older pages are read once and remembered in the `FeedPages` table.
109
+ * A feed that rate-limits (HTTP 429) is abandoned for that crawl and retried next
110
+ time.
111
+
112
+ `/packages/log` shows what the last crawl did, feed by feed and item by item.
113
+
114
+ ## Storage
115
+
116
+ Everything is in the SQLite database. `PackageVersions` holds each version's
117
+ metadata and the tarball itself. `Packages` holds one row per package id, with its
118
+ current version and download count. `PackageDependencies` (stored as `id@version`),
119
+ `PackageFHIRVersions` and `PackageURLs` (the canonical URLs of the resources in each
120
+ version) support search.
package/registry/api.js CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  const { ServerRegistryUtilities } = require('./model');
4
4
  const escape = require('escape-html');
5
+ const { Utilities } = require('../library/utilities');
6
+
7
+ const RELEASE_VERSIONS = {
8
+ R2: '1.0',
9
+ R2B: '1.4',
10
+ R3: '3.0',
11
+ R4: '4.0',
12
+ R4B: '4.3',
13
+ R5: '5.0',
14
+ R6: '6.0'
15
+ };
16
+
17
+ // The ecosystem IG reports a server's security as boolean flags (open, password, token,
18
+ // oauth, smart, cert). The crawler records a single string; this maps it. 'api-key' is a
19
+ // token. The string itself is still reported as 'security' for existing clients.
20
+ function securityFlags(security) {
21
+ switch (security) {
22
+ case 'open': return { open: true };
23
+ case 'api-key': return { token: true };
24
+ case 'password': case 'token': case 'oauth': case 'smart': case 'cert':
25
+ return { [security]: true };
26
+ default: return {};
27
+ }
28
+ }
5
29
 
6
30
  class RegistryAPI {
7
31
  constructor(crawler) {
@@ -263,6 +287,10 @@ class RegistryAPI {
263
287
 
264
288
  return {
265
289
  lastRun: data.lastRun,
290
+ // the page footer: nothing to report until the first crawl has finished
291
+ crawlerStatus: data.lastRun ?
292
+ `last updated ${Utilities.describeAgo(data.lastRun)}, ${totalServers} server${totalServers === 1 ? '' : 's'}` :
293
+ 'not yet updated',
266
294
  outcome: data.outcome,
267
295
  registryCount: data.registries.length,
268
296
  serverCount: totalServers,
@@ -317,16 +345,11 @@ class RegistryAPI {
317
345
  });
318
346
  }
319
347
 
348
+ // Release codes (R4, r4b ...) to the version prefix they select; anything else (4.0.1,
349
+ // 4.0) is used as given. R4B and R2B can't be derived from the number alone.
320
350
  _normalizeFhirVersion(version) {
321
351
  if (!version) return version;
322
-
323
- // Convert R4 or r4 to 4.0, R5 or r5 to 5.0, etc.
324
- const rMatch = /^[rR](\d+)$/.exec(version);
325
- if (rMatch) {
326
- return `${rMatch[1]}.0`;
327
- }
328
-
329
- return version;
352
+ return RELEASE_VERSIONS[version.toUpperCase()] || version;
330
353
  }
331
354
 
332
355
  /**
@@ -471,38 +494,9 @@ class RegistryAPI {
471
494
  });
472
495
  });
473
496
 
474
- // NEW: Fallback - if no matches found, check for authoritative pattern matches
475
- if (authMatches.length === 0 && result.candidates.length === 0) {
476
- data.registries.forEach(registry => {
477
- registry.servers.forEach(server => {
478
- // Excluded content stays hidden in the fallback path too
479
- if (server.isExcludedTarget(codeSystem)) return;
480
-
481
- // Check if server supports the requested usage tag
482
- if (server.usageList.length === 0 ||
483
- (usage && server.usageList.includes(usage))) {
484
-
485
- // Check if server is authoritative for this code system
486
- // (taking language specific claims into account)
487
- const authMatch = server.matchAuthCS(codeSystem, requestLangs);
488
-
489
- if (authMatch.isAuth) {
490
- server.versions.forEach(version => {
491
- if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) {
492
- authMatches.push({
493
- entry: this.createServerEntry(server, version, null, authMatch),
494
- score: authMatch.score
495
- });
496
- if (!matchedServers.includes(server.code)) {
497
- matchedServers.push(server.code);
498
- }
499
- }
500
- });
501
- }
502
- }
503
- });
504
- });
505
- }
497
+ // No fallback to servers that claim authority but don't host the code system: per the
498
+ // ecosystem IG, 'Servers are not listed as authoritative unless they actually host the
499
+ // CodeSystem(+version) in the request'.
506
500
 
507
501
  // Language specific matches rank before authoritative-list matches, most specific
508
502
  // match first. Array.prototype.sort is stable, so entries with equal scores (e.g.
@@ -598,6 +592,86 @@ class RegistryAPI {
598
592
  };
599
593
  }
600
594
 
595
+ /**
596
+ * The ecosystem Discovery API (GET {root} as JSON): one row per server endpoint.
597
+ *
598
+ * Filters, all optional: registry and server (codes), fhirVersion (RX or M.n.p), url (a
599
+ * code system, url or url|version), authoritativeOnly, language (only with url).
600
+ *
601
+ * With url, only endpoints that host that code system are listed (an excluded code
602
+ * system hides the server), and the row's 'candidate' list says whether it's hosted but
603
+ * not claimed; authoritativeOnly then keeps only the endpoints authoritative for it.
604
+ * Without url, authoritative/authoritative-valuesets are the server's claim masks and the
605
+ * candidate lists are omitted - listing everything a server hosts would be huge.
606
+ */
607
+ discover(params = {}) {
608
+ const { registry = '', server = '', fhirVersion = '', url = '', language = '' } = params;
609
+ const authoritativeOnly = params.authoritativeOnly === true || params.authoritativeOnly === 'true';
610
+ const normalizedVersion = this._normalizeFhirVersion(fhirVersion);
611
+ const requestLangs = url ? ServerRegistryUtilities.parseAcceptLanguage(language) : null;
612
+ const data = this.crawler.getData();
613
+
614
+ const authRows = [];
615
+ const otherRows = [];
616
+ data.registries.forEach(reg => {
617
+ if (registry && reg.code !== registry) return;
618
+ reg.servers.forEach(srv => {
619
+ if (server && srv.code !== server) return;
620
+ if (url && srv.isExcludedTarget(url)) return;
621
+
622
+ const authMatch = url ? srv.matchAuthCS(url, requestLangs) : { isAuth: false, scoped: false };
623
+
624
+ srv.versions.forEach(version => {
625
+ if (normalizedVersion && !ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) {
626
+ return;
627
+ }
628
+ let hosted = false;
629
+ if (url) {
630
+ hosted = ServerRegistryUtilities.hasMatchingCodeSystem(url, version.codeSystems, false, {});
631
+ if (!hosted || (authoritativeOnly && !authMatch.isAuth)) {
632
+ return;
633
+ }
634
+ }
635
+
636
+ const row = {
637
+ 'server-name': srv.name,
638
+ 'server-code': srv.code,
639
+ 'registry-name': reg.name,
640
+ 'registry-code': reg.code,
641
+ 'registry-url': reg.address,
642
+ url: version.address,
643
+ fhirVersion: version.version,
644
+ error: version.error || null,
645
+ 'last-success': version.lastSuccess ? Math.floor(Date.now() - new Date(version.lastSuccess).getTime()) : null,
646
+ systems: version.codeSystems.length,
647
+ authoritative: [...srv.authCSList],
648
+ 'authoritative-valuesets': [...srv.authVSList]
649
+ };
650
+ if (url && !authMatch.isAuth) {
651
+ row.candidate = [url];
652
+ }
653
+ if (authMatch.isAuth && authMatch.scoped) {
654
+ row.languages = [authMatch.tag];
655
+ }
656
+ if (version.security) {
657
+ row.security = version.security;
658
+ Object.assign(row, securityFlags(version.security));
659
+ }
660
+ (authMatch.isAuth ? authRows : otherRows).push({ row, score: authMatch.score || 0 });
661
+ });
662
+ });
663
+ });
664
+
665
+ // Endpoints authoritative for url first (language specific matches before the rest;
666
+ // sort is stable, so registration order is kept otherwise)
667
+ authRows.sort((a, b) => a.score - b.score);
668
+ return {
669
+ 'last-update': data.lastRun ? new Date(data.lastRun).toISOString() : null,
670
+ 'master-url': data.address,
671
+ results: [...authRows, ...otherRows].map(r => r.row)
672
+ };
673
+ }
674
+
601
675
  _cleanEmptyArrays(result) {
602
676
  const cleanedResult = { ...result };
603
677
 
@@ -620,11 +694,13 @@ class RegistryAPI {
620
694
  createServerEntry(server, version, content = null, authMatch = null) {
621
695
  const entry = {
622
696
  'server-name': server.name,
623
- url: version.address
697
+ url: version.address,
698
+ fhirVersion: version.version
624
699
  };
625
700
 
626
701
  if (version.security) {
627
702
  entry.security = version.security;
703
+ Object.assign(entry, securityFlags(version.security));
628
704
  }
629
705
  if (server.accessInfo) {
630
706
  entry.access_info = server.accessInfo;
@@ -333,11 +333,33 @@ class RegistryCrawler {
333
333
  this.addLogEntry('error', `Server ${version.address}: Error after ${elapsed}ms: ${error.message}`);
334
334
  version.error = error.message;
335
335
  version.lastTat = `${elapsed}ms`;
336
+ this.carryForwardSoftware(version);
336
337
  }
337
338
 
338
339
  return version;
339
340
  }
340
341
 
342
+ /**
343
+ * A server that can't be reached this time is still running whatever it was running
344
+ * last time we saw it - keep that, so the software page doesn't lose track of it
345
+ */
346
+ carryForwardSoftware(version) {
347
+ if (version.software && version.software !== 'unknown') {
348
+ return;
349
+ }
350
+ for (const registry of (this.currentData && this.currentData.registries) || []) {
351
+ for (const server of registry.servers || []) {
352
+ for (const prev of server.versions || []) {
353
+ if (prev.address === version.address && prev.software) {
354
+ version.software = prev.software;
355
+ version.softwareVersion = prev.softwareVersion || '';
356
+ return;
357
+ }
358
+ }
359
+ }
360
+ }
361
+ }
362
+
341
363
  /**
342
364
  * Process an R3 server
343
365
  */
@@ -348,6 +370,7 @@ class RegistryCrawler {
348
370
 
349
371
  version.version = capability.fhirVersion || '3.0.2';
350
372
  version.software = capability.software ? capability.software.name : "unknown";
373
+ version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
351
374
 
352
375
  // Get terminology capabilities (R3 uses Parameters resource)
353
376
  try {
@@ -392,6 +415,7 @@ class RegistryCrawler {
392
415
 
393
416
  version.version = capability.fhirVersion || defVersion;
394
417
  version.software = capability.software ? capability.software.name : "unknown";
418
+ version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
395
419
 
396
420
  let set = new Set();
397
421
 
@@ -0,0 +1,170 @@
1
+ // registry/fhirsmith-releases.js
2
+ // The list of FHIRsmith releases and their dates, so the registry can say how old the
3
+ // FHIRsmith version a registered server reports is.
4
+ //
5
+ // The list comes from the GitHub releases API, refreshed with each crawl. Until that
6
+ // succeeds (or if it can't be reached), the dated headings in this server's own
7
+ // CHANGELOG.md are used instead - those only go up to this server's own version.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const axios = require('axios');
12
+
13
+ const DEFAULT_RELEASES_URL = 'https://api.github.com/repos/HealthIntersections/FHIRsmith/releases';
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+
16
+ // "v0.14.1" -> [0, 14, 1]; anything that isn't n.n.n (optionally with a -suffix) -> null
17
+ function parseVersion(v) {
18
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim());
19
+ if (!m) {
20
+ return null;
21
+ }
22
+ return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], suffix: m[4] || '' };
23
+ }
24
+
25
+ function compareParts(a, b) {
26
+ for (let i = 0; i < 3; i++) {
27
+ if (a[i] !== b[i]) {
28
+ return a[i] - b[i];
29
+ }
30
+ }
31
+ return 0;
32
+ }
33
+
34
+ function isFhirsmith(softwareName) {
35
+ return /fhirsmith/i.test(softwareName || '');
36
+ }
37
+
38
+ class FhirsmithReleases {
39
+ constructor(config = {}, logger = null) {
40
+ this.url = config.releasesUrl || DEFAULT_RELEASES_URL;
41
+ this.timeout = config.timeout || 30000;
42
+ this.userAgent = config.userAgent || 'FHIRRegistryServer/1.0';
43
+ this.logger = logger;
44
+ this.releases = []; // [{version: '0.14.1', parts: [0,14,1], date: Date}], newest first
45
+ this.source = 'none';
46
+ this.lastRefresh = null;
47
+ }
48
+
49
+ setReleases(list, source) {
50
+ const seen = new Set();
51
+ const releases = [];
52
+ for (const r of list) {
53
+ const pv = parseVersion(r.version);
54
+ const date = r.date instanceof Date ? r.date : new Date(r.date);
55
+ // a release is n.n.n - pre-release suffixes are not tracked
56
+ if (!pv || pv.suffix || isNaN(date.getTime())) {
57
+ continue;
58
+ }
59
+ const key = pv.parts.join('.');
60
+ if (!seen.has(key)) {
61
+ seen.add(key);
62
+ releases.push({ version: key, parts: pv.parts, date });
63
+ }
64
+ }
65
+ releases.sort((a, b) => compareParts(b.parts, a.parts));
66
+ this.releases = releases;
67
+ this.source = source;
68
+ }
69
+
70
+ /**
71
+ * Read the dated release headings from a CHANGELOG.md - "## [v0.13.4] - 2026-09-17"
72
+ */
73
+ loadFromChangelog(changelogPath = path.join(__dirname, '..', 'CHANGELOG.md')) {
74
+ try {
75
+ const text = fs.readFileSync(changelogPath, 'utf8');
76
+ const list = [];
77
+ const re = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm;
78
+ let m;
79
+ while ((m = re.exec(text)) !== null) {
80
+ list.push({ version: m[1], date: new Date(m[2] + 'T00:00:00Z') });
81
+ }
82
+ this.setReleases(list, 'CHANGELOG.md');
83
+ } catch (error) {
84
+ if (this.logger) {
85
+ this.logger.warn('Could not read FHIRsmith release dates from CHANGELOG.md: ' + error.message);
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Fetch the release list from GitHub. On failure the current list is kept.
92
+ */
93
+ async refresh() {
94
+ try {
95
+ const list = [];
96
+ for (let page = 1; page <= 10; page++) {
97
+ const response = await axios.get(`${this.url}?per_page=100&page=${page}`, {
98
+ timeout: this.timeout,
99
+ headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': this.userAgent }
100
+ });
101
+ const batch = Array.isArray(response.data) ? response.data : [];
102
+ for (const r of batch) {
103
+ if (!r.draft && !r.prerelease && r.tag_name && r.published_at) {
104
+ list.push({ version: r.tag_name, date: r.published_at });
105
+ }
106
+ }
107
+ if (batch.length < 100) {
108
+ break;
109
+ }
110
+ }
111
+ if (list.length > 0) {
112
+ this.setReleases(list, 'GitHub');
113
+ this.lastRefresh = new Date();
114
+ }
115
+ } catch (error) {
116
+ if (this.logger) {
117
+ this.logger.warn('Could not fetch FHIRsmith releases from GitHub: ' + error.message);
118
+ }
119
+ }
120
+ }
121
+
122
+ latest() {
123
+ return this.releases.length > 0 ? this.releases[0] : null;
124
+ }
125
+
126
+ /**
127
+ * How a reported FHIRsmith version stands against the releases:
128
+ * status: 'current' | 'outdated' | 'dev' (a build between/after releases) | 'unknown'
129
+ * release: the release it is (or, for 'dev', the most recent release before it)
130
+ * ageDays: days since that release came out ('current'/'outdated' only)
131
+ * behind: how many releases have come out since
132
+ */
133
+ describe(reportedVersion, now = Date.now()) {
134
+ const pv = parseVersion(reportedVersion);
135
+ if (!pv || this.releases.length === 0) {
136
+ return { status: 'unknown' };
137
+ }
138
+ const behind = this.releases.filter(r => compareParts(r.parts, pv.parts) > 0).length;
139
+ const exact = !pv.suffix ? this.releases.find(r => compareParts(r.parts, pv.parts) === 0) : null;
140
+ if (exact) {
141
+ return {
142
+ status: behind === 0 ? 'current' : 'outdated',
143
+ release: exact,
144
+ ageDays: Math.max(0, Math.floor((now - exact.date.getTime()) / DAY_MS)),
145
+ behind
146
+ };
147
+ }
148
+ // a snapshot (0.14.2-snapshot), or a version we have no release for
149
+ const base = this.releases.find(r => compareParts(r.parts, pv.parts) < 0) || null;
150
+ return { status: 'dev', release: base, behind };
151
+ }
152
+ }
153
+
154
+ function describeAge(days) {
155
+ if (days < 1) {
156
+ return 'today';
157
+ }
158
+ if (days < 14) {
159
+ return `${days} day${days === 1 ? '' : 's'}`;
160
+ }
161
+ if (days < 60) {
162
+ return `${Math.floor(days / 7)} weeks`;
163
+ }
164
+ if (days < 730) {
165
+ return `${Math.floor(days / 30.44)} months`;
166
+ }
167
+ return `${(days / 365.25).toFixed(1)} years`;
168
+ }
169
+
170
+ module.exports = { FhirsmithReleases, isFhirsmith, parseVersion, describeAge };
package/registry/model.js CHANGED
@@ -11,6 +11,7 @@ class ServerVersionInformation {
11
11
  this.lastSuccess = null; // Date object
12
12
  this.lastTat = '';
13
13
  this.software = ''; // what software is running
14
+ this.softwareVersion = ''; // CapabilityStatement.software.version, if the server reports one
14
15
  this.codeSystems = []; // Array of strings (sorted, unique)
15
16
  this.valueSets = []; // Array of strings (sorted, unique)
16
17
  }
@@ -58,6 +59,7 @@ class ServerVersionInformation {
58
59
  lastTat: this.lastTat,
59
60
  terminologies: this.codeSystems,
60
61
  software: this.software,
62
+ 'software-version': this.softwareVersion,
61
63
  valuesets: this.valueSets
62
64
  };
63
65
  }
@@ -71,6 +73,7 @@ class ServerVersionInformation {
71
73
  instance.lastSuccess = json['last-success'] ? new Date(json['last-success']) : null;
72
74
  instance.lastTat = json.lastTat || '';
73
75
  instance.software = json.software;
76
+ instance.softwareVersion = json['software-version'] || '';
74
77
  instance.codeSystems = json.terminologies || [];
75
78
  instance.valueSets = json.valuesets || [];
76
79
  return instance;
@@ -561,6 +564,13 @@ class ServerRegistryUtilities {
561
564
  } else {
562
565
  // Otherwise do exact matching on both full and base URL
563
566
  ok = vurl === cs || vurl === baseCs;
567
+ // A SNOMED CT version URI extends its edition URI, so a server hosting
568
+ // sct|.../{edition}/version/{date} hosts the edition sct|.../{edition}. Servers
569
+ // list only full versions, so without this an edition-level request would never
570
+ // find the servers that host the edition.
571
+ if (!ok && noVersionIndependentMatching && cs.includes('|')) {
572
+ ok = vurl.startsWith(cs + '/');
573
+ }
564
574
  }
565
575
  if (ok && content) {
566
576
  content.content = item.content;
@@ -0,0 +1,13 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ // The terminology registry's OpenAPI description. openapi.yaml is the source of truth; see
8
+ // the comment at its top.
9
+
10
+ const path = require('path');
11
+ const { createOpenApiDoc } = require('../library/openapi-doc');
12
+
13
+ module.exports = createOpenApiDoc(path.join(__dirname, 'openapi.yaml'), '/tx-reg');