fhirsmith 0.13.4 → 0.14.1

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 (52) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +16 -3
  3. package/config-template.json +81 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/library/languages.js +15 -0
  7. package/library/same-origin.js +62 -0
  8. package/nginx.md +399 -0
  9. package/package.json +3 -2
  10. package/publisher/git-remote.js +141 -0
  11. package/publisher/github-release.js +195 -0
  12. package/publisher/publisher-template.html +1 -0
  13. package/publisher/publisher.js +241 -56
  14. package/registry/registry.js +3 -1
  15. package/server.js +57 -15
  16. package/stats.js +19 -13
  17. package/testing/readme.md +144 -0
  18. package/testing/render.js +723 -0
  19. package/testing/search.js +378 -0
  20. package/testing/store.js +410 -0
  21. package/testing/testing-template.html +126 -0
  22. package/testing/testing.js +760 -0
  23. package/translations/Messages.properties +32 -0
  24. package/tx/closure/closure-store.js +396 -0
  25. package/tx/cs/cs-api.js +6 -1
  26. package/tx/cs/cs-rxnorm.js +35 -8
  27. package/tx/cs/cs-snomed.js +19 -69
  28. package/tx/html/codesystem-operations.liquid +177 -0
  29. package/tx/html/ecl-panel.liquid +93 -0
  30. package/tx/html/home-metrics.liquid +39 -76
  31. package/tx/html/tx-template.html +1 -0
  32. package/tx/library/conceptmap.js +22 -6
  33. package/tx/library/designations.js +12 -3
  34. package/tx/library/operation-outcome.js +3 -1
  35. package/tx/library/renderer.js +42 -5
  36. package/tx/operation-context.js +80 -17
  37. package/tx/provider.js +85 -0
  38. package/tx/sct/editions.js +70 -0
  39. package/tx/sct/expressions.js +128 -44
  40. package/tx/tx-html.js +426 -44
  41. package/tx/tx.js +117 -9
  42. package/tx/workers/cache-control.js +2 -2
  43. package/tx/workers/closure.js +483 -13
  44. package/tx/workers/expand.js +2 -2
  45. package/tx/workers/metadata.js +8 -3
  46. package/tx/workers/read.js +18 -3
  47. package/tx/workers/subsumes.js +18 -2
  48. package/tx/workers/translate.js +192 -21
  49. package/tx/workers/validate.js +70 -187
  50. package/tx/workers/worker.js +12 -35
  51. package/tx/xversion/xv-conceptmap.js +17 -8
  52. package/utilities/submit-test-report.js +150 -0
package/server.js CHANGED
@@ -16,6 +16,7 @@ const folders = require('./library/folder-setup'); // <-- ADD: load early
16
16
  const { statSync, readdirSync } = require('fs');
17
17
  const escape = require('escape-html');
18
18
  const { resolveWithin } = require('./library/path-safety');
19
+ const { readCgroupMemoryLimit } = require('./library/cgroup-memory');
19
20
 
20
21
  // Load configuration BEFORE logger
21
22
  let config;
@@ -39,6 +40,12 @@ serverLog.info(`========================================`);
39
40
  serverLog.info(`FHIRsmith v${packageJson.version} starting (PID ${process.pid})`);
40
41
  serverLog.info(`Node.js ${process.version} on ${os.type()} ${os.release()} (${os.arch()})`);
41
42
  serverLog.info(`Memory: ${freeMemGB} GB free / ${totalMemGB} GB total`);
43
+ {
44
+ const cg = readCgroupMemoryLimit();
45
+ serverLog.info(cg.limit > 0
46
+ ? `Memory limit: ${(cg.limit / 1024 / 1024 / 1024).toFixed(1)} GB (from ${cg.source})`
47
+ : 'Memory limit: none found in cgroup - RSS guard disabled');
48
+ }
42
49
  serverLog.info(`Data directory: ${folders.dataDir()}`);
43
50
  serverLog.info(`========================================`);
44
51
 
@@ -57,6 +64,7 @@ const PublisherModule = require('./publisher/publisher.js');
57
64
  const TokenModule = require('./token/token.js');
58
65
  const NpmProjectorModule = require('./npmprojector/npmprojector.js');
59
66
  const TXModule = require('./tx/tx.js');
67
+ const TestingModule = require('./testing/testing.js');
60
68
 
61
69
  const htmlServer = require('./library/html-server');
62
70
  const ServerStats = require("./stats");
@@ -71,7 +79,12 @@ const app = express();
71
79
 
72
80
  // Behind nginx (or any reverse proxy): honor X-Forwarded-* so req.protocol and
73
81
  // req.hostname reflect what the client actually requested, not the loopback hop.
74
- app.set('trust proxy', config.server.trustProxy ?? true);
82
+ // Only proxies on the same host or a private network are trusted by default. `true` would
83
+ // trust every X-Forwarded-For hop, so anyone who could reach the port directly could name
84
+ // their own IP address and walk around every rate limit (express-rate-limit refuses to be
85
+ // quiet about it: ERR_ERL_PERMISSIVE_TRUST_PROXY). See nginx.md.
86
+ const DEFAULT_TRUST_PROXY = 'loopback, linklocal, uniquelocal';
87
+ app.set('trust proxy', config.server.trustProxy ?? DEFAULT_TRUST_PROXY);
75
88
 
76
89
  const PORT = process.env.PORT || config.server.port || 3000;
77
90
 
@@ -219,6 +232,18 @@ async function initializeModules() {
219
232
  throw error;
220
233
  }
221
234
  }
235
+ if (config.modules?.testing?.enabled) {
236
+ try {
237
+ serverLog.info('Initializing module: testing...');
238
+ modules.testing = new TestingModule(stats.forModule('testing'));
239
+ await modules.testing.initialize(config.modules.testing);
240
+ app.use('/testing', modules.testing.router);
241
+ } catch (error) {
242
+ serverLog.error('Failed to initialize testing module:', error);
243
+ throw error;
244
+ }
245
+ }
246
+
222
247
  // Initialize TX module
223
248
  // Note: TX module registers its own endpoints directly on the app
224
249
  // because it supports multiple endpoints at different paths
@@ -346,6 +371,13 @@ async function buildRootPageContent() {
346
371
  content += '</li>';
347
372
  }
348
373
 
374
+ if (config.modules?.testing?.enabled) {
375
+ content += '<li class="list-group-item">';
376
+ content += '<a href="/testing" class="text-decoration-none">Test Reports</a>: ';
377
+ content += 'TestReports submitted by TxTester and other test tools';
378
+ content += '</li>';
379
+ }
380
+
349
381
  if (config.modules.folder && config.modules.folder.enabled) {
350
382
  content += '<li class="list-group-item">';
351
383
  content += '<strong>Cache Folder</strong>: ';
@@ -405,13 +437,7 @@ async function buildRootPageContent() {
405
437
 
406
438
  // Process RSS vs cgroup limit (or system total)
407
439
  const rssMB = (memUsage.rss / 1024 / 1024).toFixed(0);
408
- let memLimit;
409
- try {
410
- const raw = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
411
- memLimit = raw === 'max' ? os.totalmem() : parseInt(raw);
412
- } catch {
413
- memLimit = os.totalmem();
414
- }
440
+ const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
415
441
  const memLimitMB = (memLimit / 1024 / 1024).toFixed(0);
416
442
  const processPCT = (memUsage.rss * 100) / memLimit;
417
443
 
@@ -440,6 +466,15 @@ async function buildRootPageContent() {
440
466
  content += `<td><strong>Client Cache:</strong> ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts</td>`;
441
467
  content += `<td><strong>Max Client Cache:</strong> ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts</td>`;
442
468
  content += '</tr>';
469
+ const closure = stats.closureStats();
470
+ if (closure) {
471
+ content += '<tr>';
472
+ content += `<td><strong>Closure Tables:</strong> ${closure.tables} tables` +
473
+ (closure.retentionDays ? ` (unused for ${closure.retentionDays} days = deleted)` : '') + `</td>`;
474
+ content += `<td><strong>Closure Entries:</strong> ${closure.entries} entries / ${closure.concepts} concepts</td>`;
475
+ content += `<td><strong>Closure Database:</strong> ${closure.bytes === null ? 'in memory' : formatBytes(closure.bytes)}</td>`;
476
+ content += '</tr>';
477
+ }
443
478
  content += getLogStats();
444
479
  content += '</table>';
445
480
 
@@ -591,13 +626,7 @@ app.get('/dashboard', async (req, res) => {
591
626
  const v8PCT = (memUsage.heapUsed * 100) / heapStats.heap_size_limit; // % of V8 heap limit used
592
627
 
593
628
  // Process RSS as % of cgroup memory limit (or system total as fallback)
594
- let memLimit;
595
- try {
596
- const raw = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
597
- memLimit = raw === 'max' ? os.totalmem() : parseInt(raw);
598
- } catch {
599
- memLimit = os.totalmem();
600
- }
629
+ const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
601
630
  const processPCT = (memUsage.rss * 100) / memLimit;
602
631
 
603
632
  const totalMemBytes = os.totalmem();
@@ -649,6 +678,19 @@ app.get('/dashboard', async (req, res) => {
649
678
  }
650
679
  });
651
680
 
681
+ function formatBytes(bytes) {
682
+ if (bytes < 1024) {
683
+ return `${bytes} bytes`;
684
+ }
685
+ if (bytes < 1024 * 1024) {
686
+ return `${(bytes / 1024).toFixed(1)} KB`;
687
+ }
688
+ if (bytes < 1024 * 1024 * 1024) {
689
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
690
+ }
691
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
692
+ }
693
+
652
694
  function pctColor(pct) {
653
695
  // Gradient from green (#deffe0) at 0% to red (#ffd3d1) at 100%
654
696
  const t = Math.max(0, Math.min(100, pct)) / 100;
package/stats.js CHANGED
@@ -124,20 +124,13 @@ class ServerStats {
124
124
  // event-loop block into noise; the max is what shows request-freezing
125
125
  // stalls (see the 2026-07-30 tx.fhir.org incident).
126
126
  const loopMax = this.eventLoopMonitor.max / 1e6;
127
- // Two distinct caches: the expansion cache (count entries) and the client
128
- // (resource) cache (count concepts held - a sense of how much it's carrying).
129
- let expansionItems = 0;
130
- let clientConcepts = 0;
131
- for (let m of this.cachingModules) {
132
- if (typeof m.expansionItemCount === 'function') {
133
- expansionItems = expansionItems + m.expansionItemCount();
134
- }
135
- if (typeof m.clientConceptCount === 'function') {
136
- clientConcepts = clientConcepts + m.clientConceptCount();
137
- }
138
- }
127
+ // The client (resource) cache, as concepts held - a sense of how much it's carrying.
128
+ // (The expansion cache isn't tracked over time: it fills to its limit over a few
129
+ // days and then sits there, so a history of it says nothing. The home page still
130
+ // shows its current size.)
131
+ const clientConcepts = this._sumCachingModules('clientConceptCount');
139
132
 
140
- this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, blockMax: loopMax, expansion: expansionItems, clientConcepts: clientConcepts});
133
+ this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, blockMax: loopMax, clientConcepts: clientConcepts});
141
134
 
142
135
  this.eventLoopMonitor.reset();
143
136
  this.requestCountSnapshot = combinedCount;
@@ -328,6 +321,19 @@ class ServerStats {
328
321
  maxClientCaches() { return this._sumCachingModules('maxClientCacheCount'); }
329
322
  maxClientConcepts() { return this._sumCachingModules('maxClientConceptCount'); }
330
323
 
324
+ // $closure usage, from whichever module keeps closure tables; null if none does
325
+ closureStats() {
326
+ for (let m of this.cachingModules) {
327
+ if (typeof m.closureStats === 'function') {
328
+ const s = m.closureStats();
329
+ if (s) {
330
+ return s;
331
+ }
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+
331
337
  getTaskColor(status) {
332
338
  switch (status) {
333
339
  case "started": return "LightGrey";
@@ -0,0 +1,144 @@
1
+ # Testing module
2
+
3
+ Receives FHIR TestReports - from TxTester, and from any other tool that produces them - and
4
+ makes them available through a FHIR API and a set of web pages. It runs at `/testing` when
5
+ `modules.testing.enabled` is true.
6
+
7
+ ## Submitting reports
8
+
9
+ POST /testing/TestReport
10
+ Content-Type: application/fhir+json
11
+ Authorization: Bearer <token> (only if a token is configured)
12
+
13
+ R4 and R5 TestReports are treated the same. A report is accepted if it is JSON, has
14
+ `resourceType: TestReport`, and has `name`, `status`, `result`, `tester`, `issued` (a valid
15
+ dateTime) and at least one `participant` with a `uri`. `score`, if present, must be a number.
16
+ Nothing else is checked.
17
+
18
+ The server gives the report a new id (any id sent is ignored), sets `meta.lastUpdated` to the
19
+ time it was received, and drops `meta.versionId`: reports can't be changed once received, so
20
+ there are no versions. Sending the same report twice stores it twice.
21
+
22
+ Responses follow the spec: `201 Created` with a `Location` header, and the stored report,
23
+ or nothing (`Prefer: return=minimal`), or an OperationOutcome (`Prefer: return=OperationOutcome`).
24
+ Errors are OperationOutcomes: 400 (not JSON, or not an acceptable TestReport), 401 (token),
25
+ 413 (too big), 415 (not JSON), 429 (rate limit).
26
+
27
+ The submitter's IP address is recorded, for tracing abuse. It is never returned by the API or
28
+ shown on any page.
29
+
30
+ FHIRsmith's own tx test run writes its results as `test-cases-report.json` in the
31
+ FHIRsmith root; `node utilities/submit-test-report.js [-server url] [-token token]` sends it
32
+ to a /testing server (by default https://testing.fhir.org/testing).
33
+
34
+ ## Reading and searching
35
+
36
+ GET /testing/TestReport/{id}
37
+ GET /testing/TestReport?{params}
38
+ GET /testing/metadata
39
+
40
+ Both return HTML to a browser (an `Accept` that includes `text/html`); `_format=json` or
41
+ `_format=html` overrides that.
42
+
43
+ None of the search parameters are formally defined yet, and the CapabilityStatement lists them
44
+ without definitions. What they do:
45
+
46
+ | Parameter | Type | Searches | Notes |
47
+ |---|---|---|---|
48
+ | `_id` | token | id | |
49
+ | `name` | string | name | starts with, case-insensitive; `:exact`, `:contains`, `:missing` |
50
+ | `tester` | string | tester | as for name |
51
+ | `status` | token | status | `:not`, `:missing` |
52
+ | `result` | token | result | `:not`, `:missing` |
53
+ | `testscript` | uri | testScript (R5 canonical, or R4 `reference`) | `:below` (starts with), `:missing` |
54
+ | `participant` | uri | any participant.uri | `:below` |
55
+ | `score` | number | score | prefixes; implicit precision for eq (`90` means 89.5 to 90.5); `:missing` |
56
+ | `issued` | date | issued | prefixes; dates are ranges, so `issued=2026-09` is all of September |
57
+ | `_lastUpdated` | date | when the report was received | prefixes |
58
+
59
+ Commas OR values, repeated parameters AND. `_sort` takes any of these except `_id`, comma
60
+ separated, `-` for descending; the default is newest received first. `_count` defaults to 50
61
+ and is capped at 500; paging is by `_offset`, with first/previous/next/last links. `total` is
62
+ always returned, and `_summary=count` returns only that.
63
+
64
+ Unknown parameters are ignored, with a warning OperationOutcome in the Bundle, unless the
65
+ request has `Prefer: handling=strict`, in which case they're a 400. Values that can't be
66
+ understood (a bad date or number) are always a 400.
67
+
68
+ ## Web pages
69
+
70
+ * `/testing` - the list of reports, with filters (these map onto the search parameters above),
71
+ sortable columns and paging
72
+ * `/testing/summary` - a grid of test script x participant, with the latest report (by issued
73
+ date) in each cell and a link to all the runs. `?by=tester` makes the columns testers.
74
+ * `/testing/TestReport/{id}` - the report: header, participants, setup / tests / teardown with
75
+ each action's result and message, anything else in the report as JSON, and the raw JSON.
76
+
77
+ Everything in a report comes from whoever sent it, so all of it is escaped, and only http(s)
78
+ URLs become links. The narrative (`text.div`) is never rendered as HTML - it appears only as
79
+ text in the raw JSON.
80
+
81
+ ## What the pages show
82
+
83
+ The list has a column each for the test engine, client and server participants (the client
84
+ column only appears once some report has a client; a participant of any other type is shown
85
+ as a server). **Tests** is the number of entries in `TestReport.test`. **Run Length** is how long the run took, from the earliest `test.period.start`
86
+ to the latest `test.period.end`; it is blank when the report's tests have no periods. Both are
87
+ worked out when the report is received, and for reports received before they existed, the
88
+ first time the server starts with this version.
89
+
90
+ Where a test script or participant URL, ignoring any `|version`, is one of the **named
91
+ links**, its name is shown instead of the URL - linked to the named link's link, with the
92
+ full URL in the tooltip and the version after the name.
93
+
94
+ ## Administration
95
+
96
+ `/testing/login`. The administrator logs in as `admin` with the `adminPassword` from the
97
+ configuration, and manages the other users at `/testing/admin/users`. A user can have either
98
+ or both of two rights:
99
+
100
+ * **edit named links** - `/testing/admin/links`: the canonical URLs that are shown by name
101
+ * **delete reports** - a Delete button on each report's page
102
+
103
+ Logins work as the publisher's do: a session cookie (set `sessionSecret`, and `cookieSecure:
104
+ false` if the server is reached over plain http), bcrypt password hashes, and a rate limited
105
+ login (`loginAttemptsPerWindow`, default 20 per 15 minutes). Rights are checked on every
106
+ request, so changing or deleting a user takes effect at once. Every form post must come from
107
+ the server's own pages: each form carries a per-session CSRF token (lusca) that the post must
108
+ return, the cookie is SameSite=Lax, and a post whose `Origin` is another host is refused
109
+ (behind a proxy, that needs the proxy to pass on `Host` or `X-Forwarded-Host`). The session
110
+ and CSRF checks are only on the web pages: the FHIR API is authenticated by the header
111
+ token, and sets no cookie.
112
+
113
+ ## Deleting
114
+
115
+ DELETE /testing/TestReport/{id}
116
+ Authorization: Bearer <adminToken>
117
+
118
+ Only when `adminToken` is configured; without it, nothing can be deleted through the API.
119
+
120
+ ## Configuration
121
+
122
+ ```json
123
+ "testing": {
124
+ "enabled": true,
125
+ "database": "testing.db",
126
+ "token": "secret",
127
+ "tokenHeader": "Authorization",
128
+ "adminToken": "another-secret",
129
+ "maxSize": 512000,
130
+ "rateLimit": { "windowMinutes": 1, "max": 60 },
131
+ "retentionDays": 365
132
+ }
133
+ ```
134
+
135
+ * `database` - relative paths are under the data folder's `databases` directory
136
+ * `token` - if set, every POST must carry it in `tokenHeader` (a `Bearer ` prefix is optional).
137
+ If not set, anyone can submit.
138
+ * `tokenHeader` - defaults to `Authorization`. `adminToken` uses the same header.
139
+ * `maxSize` - bytes; default 500 KB. Note that the server's body parsers accept up to 50 MB
140
+ before this module sees the request; a `Content-Length` over the limit is refused before
141
+ the body is looked at.
142
+ * `rateLimit` - POSTs per IP address per window; `max: 0` turns it off. Default 60 a minute.
143
+ * `retentionDays` - if set, reports received longer ago than this are deleted, at startup and
144
+ daily. Default: keep everything.