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.
- package/CHANGELOG.md +83 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +7 -2
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- 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]
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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]
|
|
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 (
|
|
820
|
-
recipient = '
|
|
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],
|
|
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:
|
|
888
|
+
location: `https://${host}/shl/file/${file.id}`
|
|
869
889
|
};
|
|
870
890
|
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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.
|