fhirsmith 0.17.0 → 0.18.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 (121) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +422 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +7 -2
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +9 -0
  89. package/tx/README.md +52 -0
  90. package/tx/cs/cs-api.js +90 -14
  91. package/tx/cs/cs-cs.js +56 -1
  92. package/tx/cs/cs-icd11.js +366 -112
  93. package/tx/cs/cs-icf.js +418 -0
  94. package/tx/cs/cs-loinc.js +47 -25
  95. package/tx/cs/cs-omop.js +10 -3
  96. package/tx/cs/cs-snomed.js +918 -55
  97. package/tx/cs/cs-ucum.js +6 -0
  98. package/tx/cs/icd11-expressions.js +191 -78
  99. package/tx/importers/export-icd11-codesystem.js +58 -12
  100. package/tx/importers/icd11-schema.md +9 -5
  101. package/tx/library.js +98 -4
  102. package/tx/openapi-operations.js +4 -0
  103. package/tx/params.js +8 -1
  104. package/tx/provider.js +16 -4
  105. package/tx/sct/documentation.md +251 -0
  106. package/tx/sct/ecl.js +758 -46
  107. package/tx/sct/expressions.js +113 -52
  108. package/tx/syndication/syndication-feed.js +218 -0
  109. package/tx/syndication/syndication-store.js +415 -0
  110. package/tx/tx-html.js +15 -1
  111. package/tx/tx.fhir.org.yml +2 -3
  112. package/tx/tx.js +144 -2
  113. package/tx/vs/vs-vsac.js +25 -6
  114. package/tx/workers/expand.js +33 -6
  115. package/tx/workers/lookup.js +61 -6
  116. package/tx/workers/subsumes.js +1 -1
  117. package/tx/workers/translate.js +166 -15
  118. package/tx/workers/validate.js +128 -6
  119. package/utilities/make-sct-test-extension.js +264 -0
  120. package/xig/xig.js +32 -0
  121. package/registry/readme.md +0 -124
package/server.js CHANGED
@@ -18,6 +18,8 @@ const escape = require('escape-html');
18
18
  const { resolveWithin } = require('./library/path-safety');
19
19
  const { readCgroupMemoryLimit } = require('./library/cgroup-memory');
20
20
  const { NpmAudit } = require('./library/npm-audit');
21
+ const Mailer = require('./library/mailer');
22
+ const DailyReport = require('./library/daily-report');
21
23
 
22
24
  // Load configuration BEFORE logger
23
25
  let config;
@@ -51,7 +53,7 @@ serverLog.info(`Data directory: ${folders.dataDir()}`);
51
53
  serverLog.info(`========================================`);
52
54
 
53
55
  const activeModules = config.modules ? Object.keys(config.modules)
54
- .filter(mod => config.modules[mod].enabled)
56
+ .filter(mod => config.modules[mod]?.enabled)
55
57
  .join(', ') : [];
56
58
  serverLog.info(`Loaded Configuration. Active modules = ${activeModules}`);
57
59
 
@@ -66,6 +68,7 @@ const TokenModule = require('./token/token.js');
66
68
  const NpmProjectorModule = require('./npmprojector/npmprojector.js');
67
69
  const TXModule = require('./tx/tx.js');
68
70
  const TestingModule = require('./testing/testing.js');
71
+ const IpsModule = require('./ips/ips.js');
69
72
 
70
73
  const htmlServer = require('./library/html-server');
71
74
  const ServerStats = require("./stats");
@@ -90,9 +93,15 @@ app.set('trust proxy', config.server.trustProxy ?? DEFAULT_TRUST_PROXY);
90
93
  const PORT = process.env.PORT || config.server.port || 3000;
91
94
 
92
95
  // Middleware
93
- app.use(express.raw({ type: 'application/fhir+json', limit: '50mb' }));
94
- app.use(express.raw({ type: 'application/fhir+xml', limit: '50mb' }));
95
- app.use(express.json({ limit: '50mb' }));
96
+ // The body parsers, for every module but those that read their own bodies with their own
97
+ // limits and checks (the ips module: a document is read only once its submitter's token has
98
+ // been checked, and no bigger than the module's maxSize)
99
+ const OWN_BODIES = ['/ips'];
100
+ const ownsBody = (req) => OWN_BODIES.some(p => req.path === p || req.path.startsWith(p + '/'));
101
+ const unlessOwnBody = (parser) => (req, res, next) => (ownsBody(req) ? next() : parser(req, res, next));
102
+ app.use(unlessOwnBody(express.raw({ type: 'application/fhir+json', limit: '50mb' })));
103
+ app.use(unlessOwnBody(express.raw({ type: 'application/fhir+xml', limit: '50mb' })));
104
+ app.use(unlessOwnBody(express.json({ limit: '50mb' })));
96
105
  app.use((req, res, next) => {
97
106
  const requestId = req.headers['x-request-id'];
98
107
  if (requestId) {
@@ -113,6 +122,8 @@ const modules = {};
113
122
  let stats = null;
114
123
  // periodic npm advisory check of the installed packages - see library/npm-audit.js
115
124
  let npmAudit = null;
125
+ // the daily report, emailed at midnight when "email" and "dailyReport.to" are configured - see library/daily-report.js
126
+ let dailyReport = null;
116
127
 
117
128
  // Initialize modules based on configuration
118
129
  async function initializeModules() {
@@ -247,6 +258,22 @@ async function initializeModules() {
247
258
  throw error;
248
259
  }
249
260
  }
261
+ if (config.modules?.ips?.enabled) {
262
+ try {
263
+ serverLog.info('Initializing module: ips...');
264
+ // sharing through this server's own shl module: its password, unless the ips
265
+ // configuration names an SHL server of its own
266
+ if (config.modules.ips.shl && !config.modules.ips.shl.password && config.modules.shl?.password) {
267
+ config.modules.ips.shl = { server: '/shl', ...config.modules.ips.shl, password: config.modules.shl.password };
268
+ }
269
+ modules.ips = new IpsModule(stats.forModule('ips'));
270
+ await modules.ips.initialize(config.modules.ips, app);
271
+ app.use('/ips', modules.ips.router);
272
+ } catch (error) {
273
+ serverLog.error('Failed to initialize ips module:', error);
274
+ throw error;
275
+ }
276
+ }
250
277
 
251
278
  // Initialize TX module
252
279
  // Note: TX module registers its own endpoints directly on the app
@@ -255,7 +282,7 @@ async function initializeModules() {
255
282
  try {
256
283
  serverLog.info('Initializing module: tx...');
257
284
  modules.tx = new TXModule(stats.forModule('tx'));
258
- await modules.tx.initialize(config.modules.tx, app);
285
+ await modules.tx.initialize(config.modules.tx, app, { hostName: config.hostName });
259
286
  } catch (error) {
260
287
  serverLog.error('Failed to initialize TX module:', error);
261
288
  throw error;
@@ -317,52 +344,52 @@ async function buildRootPageContent() {
317
344
  content += '<ul class="list-group">';
318
345
 
319
346
  // Check which modules are enabled and add them to the list
320
- if (config.modules.packages.enabled) {
347
+ if (config.modules?.packages?.enabled) {
321
348
  content += '<li class="list-group-item">';
322
349
  content += '<a href="/packages" class="text-decoration-none">Package Server</a>: Browse and download FHIR Implementation Guide packages (<a href="/packages/openapi">API</a>)';
323
350
  content += '</li>';
324
351
  }
325
352
 
326
- if (config.modules.xig.enabled) {
353
+ if (config.modules?.xig?.enabled) {
327
354
  content += '<li class="list-group-item">';
328
355
  content += '<a href="/xig" class="text-decoration-none">FHIR IG Statistics</a>: Statistics and analysis of FHIR Implementation Guides';
329
356
  content += '</li>';
330
357
  }
331
358
 
332
- if (config.modules.shl.enabled) {
359
+ if (config.modules?.shl?.enabled) {
333
360
  content += '<li class="list-group-item">';
334
361
  content += '<a href="/shl" class="text-decoration-none">SHL Server</a>: SMART Health Links management and validation';
335
362
  content += '</li>';
336
363
  }
337
364
 
338
- if (config.modules.vcl.enabled) {
365
+ if (config.modules?.vcl?.enabled) {
339
366
  content += '<li class="list-group-item">';
340
367
  content += '<a href="/VCL" class="text-decoration-none">VCL Server</a>: ValueSet Compose Language expression parsing';
341
368
  content += '</li>';
342
369
  }
343
370
 
344
- if (config.modules.registry && config.modules.registry.enabled) {
371
+ if (config.modules?.registry?.enabled) {
345
372
  content += '<li class="list-group-item">';
346
373
  content += '<a href="/tx-reg" class="text-decoration-none">Terminology Server Registry</a>: ';
347
374
  content += 'Discover and query FHIR terminology servers for code system and value set support (<a href="/tx-reg/openapi">API</a>)';
348
375
  content += '</li>';
349
376
  }
350
377
 
351
- if (config.modules.publisher && config.modules.publisher.enabled) {
378
+ if (config.modules?.publisher?.enabled) {
352
379
  content += '<li class="list-group-item">';
353
380
  content += '<a href="/publisher" class="text-decoration-none">FHIR Publisher</a>: ';
354
381
  content += 'Manage FHIR Implementation Guide publication tasks and approvals';
355
382
  content += '</li>';
356
383
  }
357
384
 
358
- if (config.modules.token && config.modules.token.enabled) {
385
+ if (config.modules?.token?.enabled) {
359
386
  content += '<li class="list-group-item">';
360
387
  content += '<a href="/token" class="text-decoration-none">Token Server</a>: ';
361
388
  content += 'OAuth authentication and API key management for FHIR services';
362
389
  content += '</li>';
363
390
  }
364
391
 
365
- if (config.modules.npmprojector && config.modules.npmprojector.enabled) {
392
+ if (config.modules?.npmprojector?.enabled) {
366
393
  content += '<li class="list-group-item">';
367
394
  content += '<a href="/npmprojector" class="text-decoration-none">NpmProjector</a>: ';
368
395
  content += 'Hot-reloading FHIR server with FHIRPath-based search indexes';
@@ -383,7 +410,14 @@ async function buildRootPageContent() {
383
410
  content += '</li>';
384
411
  }
385
412
 
386
- if (config.modules.folder && config.modules.folder.enabled) {
413
+ if (config.modules?.ips?.enabled) {
414
+ content += '<li class="list-group-item">';
415
+ content += '<a href="/ips" class="text-decoration-none">IPS</a>: ';
416
+ content += 'Collections of International Patient Summary documents, each with its own FHIR endpoint';
417
+ content += '</li>';
418
+ }
419
+
420
+ if (config.modules?.folder?.enabled) {
387
421
  content += '<li class="list-group-item">';
388
422
  content += '<strong>Cache Folder</strong>: ';
389
423
  content += 'Cache Folder for Kindling';
@@ -400,7 +434,7 @@ async function buildRootPageContent() {
400
434
  }
401
435
 
402
436
 
403
- if (config.modules.tx && config.modules.tx.enabled) {
437
+ if (config.modules?.tx?.enabled) {
404
438
  content += '<li class="list-group-item">';
405
439
  content += '<strong>TX Terminology Server</strong>: ';
406
440
  content += 'FHIR terminology services (CodeSystem, ValueSet, ConceptMap)';
@@ -422,16 +456,7 @@ async function buildRootPageContent() {
422
456
 
423
457
  // Calculate uptime
424
458
  const uptimeMs = Date.now() - stats.startTime;
425
- const uptimeSeconds = Math.floor(uptimeMs / 1000);
426
- const uptimeDays = Math.floor(uptimeSeconds / 86400);
427
- const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600);
428
- const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
429
- const uptimeSecs = uptimeSeconds % 60;
430
- let uptimeStr = '';
431
- if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `;
432
- if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `;
433
- if (uptimeMinutes > 0 || uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeMinutes}m `;
434
- uptimeStr += `${uptimeSecs}s`;
459
+ const uptimeStr = uptimeText(uptimeMs);
435
460
 
436
461
  // Memory usage
437
462
  const memUsage = process.memoryUsage();
@@ -550,7 +575,7 @@ app.get('/', async (req, res) => {
550
575
  // Build basic stats for root page
551
576
  const stats = {
552
577
  version: packageJson.version,
553
- enabledModules: Object.keys(config.modules).filter(m => config.modules[m].enabled).length,
578
+ enabledModules: Object.keys(config.modules).filter(m => config.modules[m]?.enabled).length,
554
579
  processingTime: Date.now() - startTime,
555
580
  about
556
581
  };
@@ -616,16 +641,7 @@ app.get('/dashboard', async (req, res) => {
616
641
  const startTime = Date.now();
617
642
  // Calculate uptime
618
643
  const uptimeMs = Date.now() - stats.startTime;
619
- const uptimeSeconds = Math.floor(uptimeMs / 1000);
620
- const uptimeDays = Math.floor(uptimeSeconds / 86400);
621
- const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600);
622
- const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
623
- const uptimeSecs = uptimeSeconds % 60;
624
- let uptimeStr = '';
625
- if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `;
626
- if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `;
627
- if (uptimeMinutes > 0 || uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeMinutes}m `;
628
- uptimeStr += `${uptimeSecs}s`;
644
+ const uptimeStr = uptimeText(uptimeMs);
629
645
 
630
646
  // Memory usage
631
647
  const memUsage = process.memoryUsage();
@@ -674,7 +690,7 @@ app.get('/dashboard', async (req, res) => {
674
690
 
675
691
  const pageStats = {
676
692
  version: packageJson.version,
677
- enabledModules: Object.keys(config.modules).filter(m => config.modules[m].enabled).length,
693
+ enabledModules: Object.keys(config.modules).filter(m => config.modules[m]?.enabled).length,
678
694
  processingTime: Date.now() - startTime
679
695
  };
680
696
 
@@ -701,6 +717,30 @@ function formatBytes(bytes) {
701
717
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
702
718
  }
703
719
 
720
+ /**
721
+ * "3d 4h 5m 6s" for this run, followed by the proportion of time the server has
722
+ * been up over all its recorded runs, when the statistics database is keeping that.
723
+ */
724
+ function uptimeText(uptimeMs) {
725
+ const uptimeSeconds = Math.floor(uptimeMs / 1000);
726
+ const uptimeDays = Math.floor(uptimeSeconds / 86400);
727
+ const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600);
728
+ const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
729
+ const uptimeSecs = uptimeSeconds % 60;
730
+ let uptimeStr = '';
731
+ if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `;
732
+ if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `;
733
+ if (uptimeMinutes > 0 || uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeMinutes}m `;
734
+ uptimeStr += `${uptimeSecs}s`;
735
+ const overall = stats.overallUptime();
736
+ if (overall) {
737
+ // floor rather than round, so 99.96% doesn't claim to be 100%
738
+ const pct = overall.percent >= 100 ? '100' : (Math.floor(overall.percent * 10) / 10).toFixed(1);
739
+ uptimeStr += ` (${pct}% overall)`;
740
+ }
741
+ return uptimeStr;
742
+ }
743
+
704
744
  function pctColor(pct) {
705
745
  // Gradient from green (#deffe0) at 0% to red (#ffd3d1) at 100%
706
746
  const t = Math.max(0, Math.min(100, pct)) / 100;
@@ -797,11 +837,21 @@ async function startServer() {
797
837
 
798
838
  // Start server
799
839
  app.listen(PORT, () => {
800
- stats.markStarted();
840
+ stats.markStarted(packageJson.version);
801
841
  serverLog.info(`=== Server running on http://localhost:${PORT} ===`);
802
842
  });
803
843
  npmAudit.start();
804
- if (modules.packages && config.modules.packages.enabled) {
844
+ dailyReport = new DailyReport(config.dailyReport, {
845
+ log: Logger.getInstance().child({ module: 'daily-report' }),
846
+ stats: stats,
847
+ modules: modules,
848
+ mailer: new Mailer(config.email, Logger.getInstance().child({ module: 'email' })),
849
+ npmAudit: npmAudit,
850
+ version: packageJson.version,
851
+ serverId: config.hostName
852
+ });
853
+ dailyReport.start();
854
+ if (modules.packages && config.modules?.packages?.enabled) {
805
855
  modules.packages.startInitialCrawler();
806
856
  }
807
857
  // Run usage tracker in background after startup
@@ -825,12 +875,21 @@ async function startServer() {
825
875
  }
826
876
  }
827
877
 
828
- // Graceful shutdown
829
- process.on('SIGINT', async () => {
878
+ // Graceful shutdown: on Ctrl-C (SIGINT), and on SIGTERM - what systemd, docker stop and
879
+ // kill send - so the modules can save what they hold (usage counts, crawl data) either way
880
+ let shuttingDown = false;
881
+ async function shutdown() {
882
+ if (shuttingDown) {
883
+ return;
884
+ }
885
+ shuttingDown = true;
830
886
  serverLog.info('\nShutting down server...');
831
887
  if (npmAudit) {
832
888
  npmAudit.stop();
833
889
  }
890
+ if (dailyReport) {
891
+ dailyReport.stop();
892
+ }
834
893
 
835
894
  // Shutdown all modules
836
895
  for (const [moduleName, moduleInstance] of Object.entries(modules)) {
@@ -847,7 +906,9 @@ process.on('SIGINT', async () => {
847
906
  stats.finishStats();
848
907
  serverLog.info('Server shutdown complete');
849
908
  process.exit(0);
850
- });
909
+ }
910
+ process.on('SIGINT', () => void shutdown());
911
+ process.on('SIGTERM', () => void shutdown());
851
912
 
852
913
  async function serveFhirsmithHome(req, res) {
853
914
  // Check if client wants HTML response
@@ -868,7 +929,7 @@ async function serveFhirsmithHome(req, res) {
868
929
  // Build basic stats for root page
869
930
  const stats = {
870
931
  version: packageJson.version,
871
- enabledModules: Object.keys(config.modules).filter(m => config.modules[m].enabled).length,
932
+ enabledModules: Object.keys(config.modules).filter(m => config.modules[m]?.enabled).length,
872
933
  processingTime: Date.now() - startTime
873
934
  };
874
935
 
@@ -885,7 +946,7 @@ async function serveFhirsmithHome(req, res) {
885
946
  // Return JSON response for API clients
886
947
  const enabledModules = {};
887
948
  Object.keys(config.modules).forEach(moduleName => {
888
- if (config.modules[moduleName].enabled) {
949
+ if (config.modules[moduleName]?.enabled) {
889
950
  if (moduleName === 'tx') {
890
951
  // TX module has multiple endpoints
891
952
  enabledModules[moduleName] = {
package/shl/shl.js CHANGED
@@ -808,16 +808,26 @@ class SHLModule {
808
808
  }
809
809
  });
810
810
 
811
- // Helper function for the shared access logic
811
+ // Helper function for the shared access logic.
812
+ //
813
+ // POST is the manifest request. GET is only defined by the SHL spec for the
814
+ // U flag (direct file access): the client does GET {url}?recipient=... and
815
+ // expects the single encrypted file (JWE) back, not a manifest. The flag is
816
+ // not sent to or stored on the server, so a GET is taken to mean U. If the
817
+ // SHL has more than one file (U isn't valid for it), or it's a VHL, GET
818
+ // keeps returning the manifest / VHL bundle as before.
812
819
  const handleSHLAccess = (req, res) => {
813
820
  const start = Date.now();
814
821
  try {
815
822
  const {uuid} = req.params;
823
+ const isGet = req.method === 'GET';
816
824
 
817
825
  let recipient, embeddedLengthMax;
818
826
 
819
- if (req.method === 'GET') {
820
- recipient = 'anonymous';
827
+ if (isGet) {
828
+ recipient = typeof req.query.recipient === 'string' && req.query.recipient.length > 0
829
+ ? req.query.recipient.substring(0, 255)
830
+ : 'anonymous';
821
831
  embeddedLengthMax = undefined;
822
832
  } else {
823
833
  ({recipient, embeddedLengthMax} = req.body);
@@ -846,7 +856,7 @@ class SHLModule {
846
856
 
847
857
  const logAccessSql = 'INSERT INTO SHLViews (shl_uuid, recipient, ip_address) VALUES (?, ?, ?)';
848
858
 
849
- this.db.run(logAccessSql, [uuid, recipient, clientIP], function (logErr) {
859
+ this.db.run(logAccessSql, [uuid, recipient, clientIP], (logErr) => {
850
860
  if (logErr) {
851
861
  shlLog.error('Failed to log SHL access:', logErr.message);
852
862
  }
@@ -858,24 +868,37 @@ class SHLModule {
858
868
  return res.status(500).json({error: 'Failed to retrieve files'});
859
869
  }
860
870
 
871
+ // U flag: return the one file directly
872
+ if (isGet && !shlRow.vhl && fileRows.length === 1) {
873
+ try {
874
+ res.set('Content-Type', 'application/jose');
875
+ return res.send(Buffer.from(fileRows[0].cnt, 'base64'));
876
+ } catch (decodeErr) {
877
+ return res.status(500).json({error: 'Failed to decode file content'});
878
+ }
879
+ }
880
+
881
+ // SHL links are always https; the server always sits behind an https proxy
861
882
  const host = req.get('host') || 'localhost:3000';
862
- const protocol = req.secure ? 'https' : 'http';
863
883
  const maxLength = embeddedLengthMax ? parseInt(embeddedLengthMax) : undefined;
864
884
 
865
885
  const files = fileRows.map(file => {
866
886
  const fileResponse = {
867
887
  contentType: file.type,
868
- location: `${protocol}://${host}/shl/file/${file.id}`
888
+ location: `https://${host}/shl/file/${file.id}`
869
889
  };
870
890
 
871
- if (maxLength === undefined || file.cnt.length <= maxLength) {
872
- fileResponse.embedded = file.cnt;
891
+ // cnt is stored base64-encoded; embedded must be the JWE compact serialization itself
892
+ const jwe = Buffer.from(file.cnt, 'base64').toString('utf8');
893
+ if (maxLength === undefined || jwe.length <= maxLength) {
894
+ fileResponse.embedded = jwe;
873
895
  }
874
896
 
875
897
  return fileResponse;
876
898
  });
877
899
 
878
- const standardResponse = {files};
900
+ // finalized: content is fixed for the life of the link (expired links 404 above)
901
+ const standardResponse = {status: 'finalized', files};
879
902
 
880
903
  if (shlRow.vhl && vhlProcessor) {
881
904
  try {
@@ -1059,6 +1082,38 @@ class SHLModule {
1059
1082
  });
1060
1083
  }
1061
1084
 
1085
+ /**
1086
+ * The paragraph for the server's daily report: links (QR codes) created in
1087
+ * the period, in either form, how often links were accessed, and the
1088
+ * documents stored behind them.
1089
+ */
1090
+ async dailyReport({since, now}) {
1091
+ const {plural} = require('../library/daily-report');
1092
+ if (!this.db) {
1093
+ return 'The database is not open.';
1094
+ }
1095
+ const get = (sql, params) => new Promise((resolve, reject) =>
1096
+ this.db.get(sql, params, (err, row) => err ? reject(err) : resolve(row)));
1097
+ // created_at is CURRENT_TIMESTAMP: UTC, 'YYYY-MM-DD HH:MM:SS'
1098
+ const utc = ms => new Date(ms).toISOString().replace('T', ' ').substring(0, 19);
1099
+ try {
1100
+ const created = await get(
1101
+ 'SELECT SUM(CASE WHEN vhl THEN 0 ELSE 1 END) AS shl, SUM(CASE WHEN vhl THEN 1 ELSE 0 END) AS vhl FROM SHL WHERE created_at > ? AND created_at <= ?',
1102
+ [utc(since), utc(now)]);
1103
+ const views = await get('SELECT COUNT(*) AS n FROM SHLViews WHERE created_at > ? AND created_at <= ?', [utc(since), utc(now)]);
1104
+ const files = await get('SELECT COUNT(*) AS n, COUNT(DISTINCT shl_uuid) AS links FROM SHLFiles', []);
1105
+ const shl = created.shl || 0;
1106
+ const vhl = created.vhl || 0;
1107
+ const total = shl + vhl;
1108
+ const sentences = [];
1109
+ sentences.push(`${plural(total, 'QR code')} ${total === 1 ? 'was' : 'were'} served (${shl} SMART Health ${shl === 1 ? 'Link' : 'Links'}, ${plural(vhl, 'Verifiable Health Link')}), and links were accessed ${plural(views.n, 'time')}.`);
1110
+ sentences.push(`${plural(files.n, 'document')} ${files.n === 1 ? 'is' : 'are'} stored to serve ${plural(files.links, 'QR code')}.`);
1111
+ return sentences.join(' ');
1112
+ } catch (e) {
1113
+ return `The database could not be read: ${e.message}.`;
1114
+ }
1115
+ }
1116
+
1062
1117
  async shutdown() {
1063
1118
  shlLog.info('Shutting down SHL module...');
1064
1119
 
package/stats-db.js CHANGED
@@ -77,6 +77,22 @@ class StatsDatabase {
77
77
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_time ON request_counts(time)`);
78
78
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_key ON request_counts(module, endpoint, operation, id)`);
79
79
 
80
+ // One row per run of the server: when it started serving, and the last
81
+ // time it was known to be alive. See ServerStats.overallUptime().
82
+ this.db.exec(`
83
+ CREATE TABLE IF NOT EXISTS server_runs (
84
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
85
+ started INTEGER NOT NULL,
86
+ last_seen INTEGER NOT NULL,
87
+ version TEXT
88
+ )
89
+ `);
90
+ // version was added after the table first shipped
91
+ if (!this.db.prepare(`PRAGMA table_info(server_runs)`).all().some(c => c.name === 'version')) {
92
+ this.db.exec(`ALTER TABLE server_runs ADD COLUMN version TEXT`);
93
+ }
94
+ this.touchRunStatement = this.db.prepare(`UPDATE server_runs SET last_seen = ? WHERE id = ?`);
95
+
80
96
  this.insertStatement = this.db.prepare(`
81
97
  INSERT INTO request_counts
82
98
  (time, interval_ms, module, endpoint, operation, count_delta, count_total, time_delta, time_total)
@@ -106,6 +122,83 @@ class StatsDatabase {
106
122
  `).all();
107
123
  }
108
124
 
125
+ /**
126
+ * Record that a new run of the server has started serving, and return its
127
+ * row id for touchRun().
128
+ */
129
+ startRun(now, version = null) {
130
+ if (!this.db) {
131
+ return null;
132
+ }
133
+ return this.db.prepare(`INSERT INTO server_runs (started, last_seen, version) VALUES (?, ?, ?)`).run(now, now, version).lastInsertRowid;
134
+ }
135
+
136
+ /**
137
+ * The runs that were alive at any point since `since`, oldest first, plus
138
+ * the last run that ended before then (so a caller can tell whether the
139
+ * first run in the period was an upgrade). Each is {id, started, lastSeen,
140
+ * version}.
141
+ */
142
+ loadRuns(since) {
143
+ if (!this.db) {
144
+ return [];
145
+ }
146
+ const before = this.db.prepare(`
147
+ SELECT id, started, last_seen AS lastSeen, version FROM server_runs
148
+ WHERE last_seen < ? ORDER BY last_seen DESC LIMIT 1
149
+ `).get(since);
150
+ const during = this.db.prepare(`
151
+ SELECT id, started, last_seen AS lastSeen, version FROM server_runs
152
+ WHERE last_seen >= ? ORDER BY started
153
+ `).all(since);
154
+ return before ? [before, ...during] : during;
155
+ }
156
+
157
+ /**
158
+ * The server is still alive: move the run's last_seen forward.
159
+ */
160
+ touchRun(runId, now) {
161
+ if (this.db && runId != null) {
162
+ this.touchRunStatement.run(now, runId);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * How long the server has been running over all the runs recorded before
168
+ * this one: the earliest start, and the total time between each run's start
169
+ * and the last time it was seen alive. {firstStart: null, runningMs: 0} if
170
+ * there are no earlier runs.
171
+ */
172
+ loadRunHistory(excludeRunId) {
173
+ if (!this.db) {
174
+ return {firstStart: null, runningMs: 0};
175
+ }
176
+ const row = this.db.prepare(`
177
+ SELECT MIN(started) AS firstStart, COALESCE(SUM(last_seen - started), 0) AS runningMs
178
+ FROM server_runs
179
+ WHERE id IS NOT ?
180
+ `).get(excludeRunId == null ? -1 : excludeRunId);
181
+ return {firstStart: row.firstStart, runningMs: row.runningMs};
182
+ }
183
+
184
+ /**
185
+ * The requests counted against a module in (since, now], by endpoint and
186
+ * operation: [{endpoint, operation, count}]. A row is attributed to the
187
+ * time it was written, so the edges of the period are as fine as the
188
+ * collection interval (and ServerStats.flushNow() sharpens the near edge).
189
+ */
190
+ countsSince(since, now, module) {
191
+ if (!this.db) {
192
+ return [];
193
+ }
194
+ return this.db.prepare(`
195
+ SELECT endpoint, operation, SUM(count_delta) AS count
196
+ FROM request_counts
197
+ WHERE module = ? AND time > ? AND time <= ?
198
+ GROUP BY endpoint, operation
199
+ `).all(module, since, now);
200
+ }
201
+
109
202
  /**
110
203
  * Write one interval's worth of rows. All or nothing - if the transaction
111
204
  * fails the caller keeps its deltas and tries again next interval.