fhirsmith 0.9.6 → 0.10.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 (49) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/library/folder-content-loader.js +91 -0
  3. package/npmprojector/npmprojector.js +2 -6
  4. package/package.json +1 -1
  5. package/packages/package-crawler.js +123 -6
  6. package/packages/packages.js +104 -0
  7. package/publisher/publisher.js +290 -12
  8. package/registry/crawler.js +85 -6
  9. package/registry/registry.js +24 -7
  10. package/server.js +11 -2
  11. package/stats.js +26 -18
  12. package/translations/Messages.properties +2 -1
  13. package/tx/cs/cs-cs.js +8 -0
  14. package/tx/cs/cs-loinc.js +1 -0
  15. package/tx/cs/cs-provider-list.js +2 -1
  16. package/tx/cs/cs-snomed.js +142 -59
  17. package/tx/data/snomed-testing.cache +0 -0
  18. package/tx/html/home-metrics.liquid +10 -10
  19. package/tx/library/canonical-resource.js +4 -2
  20. package/tx/library/codesystem.js +31 -0
  21. package/tx/library/conceptmap.js +24 -0
  22. package/tx/library/designations.js +27 -20
  23. package/tx/library/renderer.js +303 -22
  24. package/tx/library/ucum-types.js +4 -1
  25. package/tx/library/valueset.js +46 -0
  26. package/tx/library.js +65 -21
  27. package/tx/operation-context.js +122 -27
  28. package/tx/params.js +36 -8
  29. package/tx/provider.js +6 -3
  30. package/tx/tx-html.js +34 -0
  31. package/tx/tx.js +92 -30
  32. package/tx/vs/vs-vsac.js +157 -9
  33. package/tx/workers/cache-control.js +186 -0
  34. package/tx/workers/{related.js → compare.js} +27 -27
  35. package/tx/workers/expand.js +100 -96
  36. package/tx/workers/lookup.js +6 -0
  37. package/tx/workers/metadata.js +11 -6
  38. package/tx/workers/read.js +1 -1
  39. package/tx/workers/translate.js +20 -29
  40. package/tx/workers/validate.js +18 -10
  41. package/tx/workers/worker.js +134 -47
  42. package/tx/xversion/xv-bundle.js +1 -2
  43. package/tx/xversion/xv-codesystem.js +5 -2
  44. package/tx/xversion/xv-parameters.js +4 -4
  45. package/tx/xversion/xv-resource.js +2 -2
  46. package/tx/xversion/xv-terminologyCapabilities.js +11 -6
  47. package/tx/xversion/xv-valueset.js +7 -7
  48. package/publisher/task-draft.js +0 -463
  49. package/tx/data/OperationDefinition-ValueSet-related.json +0 -133
@@ -17,6 +17,7 @@ class PublisherModule {
17
17
  this.logger = null;
18
18
  this.taskProcessor = null;
19
19
  this.isProcessing = false;
20
+ this.activeTaskIds = new Set();
20
21
  this.shutdownRequested = false;
21
22
  this.stats = stats;
22
23
  }
@@ -199,6 +200,17 @@ class PublisherModule {
199
200
  });
200
201
  });
201
202
  }
203
+ if (!columnNames.includes('publisher_version')) {
204
+ await new Promise((resolve, reject) => {
205
+ this.db.run('ALTER TABLE tasks ADD COLUMN publisher_version TEXT', (err) => {
206
+ if (err) reject(err);
207
+ else {
208
+ this.logger.info('Migration: added publisher_version column to tasks table');
209
+ resolve();
210
+ }
211
+ });
212
+ });
213
+ }
202
214
  const websiteColumns = await new Promise((resolve, reject) => {
203
215
  this.db.all("PRAGMA table_info(websites)", (err, rows) => {
204
216
  if (err) reject(err);
@@ -308,13 +320,19 @@ class PublisherModule {
308
320
  if (this.shutdownRequested) return;
309
321
 
310
322
  if (this.isProcessing) {
323
+ // A publication can legitimately run for longer than an hour (go-publish on a
324
+ // large IG), so never reset isProcessing while a task is in flight — doing so
325
+ // lets the poller start a second, concurrent run of the same task, which then
326
+ // fails on 'ig-registry already exists' and stomps the real run's status.
327
+ // Recovery from a genuinely hung IG Publisher is handled by the
328
+ // igPublisherTimeoutMinutes kill in runIgPublisher/runPublisherGoPublish.
311
329
  const stuckMs = this.isProcessingStarted ? Date.now() - this.isProcessingStarted : 0;
312
- if (stuckMs > 60 * 60 * 1000) {
313
- this.logger.warn('Task processor appears stuck (' + Math.round(stuckMs / 60000) + ' min) — resetting');
314
- this.isProcessing = false;
315
- } else {
316
- return;
330
+ const warnAfterMs = ((this.config.igPublisherTimeoutMinutes || 60) + 30) * 60 * 1000;
331
+ if (stuckMs > warnAfterMs) {
332
+ this.logger.warn('Task processor still busy after ' + Math.round(stuckMs / 60000) + ' min (tasks: ' +
333
+ Array.from(this.activeTaskIds).join(', ') + ') — possible hang, not starting new work');
317
334
  }
335
+ return;
318
336
  }
319
337
 
320
338
  await this.processNextTask();
@@ -325,10 +343,14 @@ class PublisherModule {
325
343
  this.isProcessing = true;
326
344
  this.isProcessingStarted = Date.now();
327
345
 
346
+ let claimedTaskId = null;
328
347
  try {
329
348
  // Look for queued tasks first (draft builds)
330
349
  let task = await this.getNextQueuedTask();
331
350
  if (task) {
351
+ if (this.activeTaskIds.has(task.id)) return; // already being processed
352
+ claimedTaskId = task.id;
353
+ this.activeTaskIds.add(task.id);
332
354
  this.stats.task('Publisher', 'Building ' + task.npm_package_id + '#' + task.version);
333
355
  await this.processDraftBuild(task);
334
356
  this.stats.taskDone('Publisher', 'Built ' + task.npm_package_id + '#' + task.version);
@@ -338,6 +360,9 @@ class PublisherModule {
338
360
  // Then look for approved tasks (publishing)
339
361
  task = await this.getNextApprovedTask();
340
362
  if (task) {
363
+ if (this.activeTaskIds.has(task.id)) return; // already being processed
364
+ claimedTaskId = task.id;
365
+ this.activeTaskIds.add(task.id);
341
366
  this.stats.task('Publisher', 'Publishing ' + task.npm_package_id + '#' + task.version);
342
367
  await this.processPublication(task);
343
368
  this.stats.taskDone('Publisher', 'Published ' + task.npm_package_id + '#' + task.version);
@@ -349,6 +374,9 @@ class PublisherModule {
349
374
  this.logger.error('Error in task processor:', error);
350
375
  this.stats.taskError('Publisher', 'Error: ' + error.message);
351
376
  } finally {
377
+ if (claimedTaskId !== null) {
378
+ this.activeTaskIds.delete(claimedTaskId);
379
+ }
352
380
  this.isProcessing = false;
353
381
  }
354
382
  }
@@ -390,6 +418,9 @@ class PublisherModule {
390
418
  fields.push('waiting_approval_at = CURRENT_TIMESTAMP');
391
419
  } else if (status === 'complete') {
392
420
  fields.push('completed_at = CURRENT_TIMESTAMP');
421
+ // A successful completion invalidates any earlier failure (e.g. from a
422
+ // superseded duplicate run) — don't let a stale message outlive success.
423
+ fields.push('failure_reason = NULL');
393
424
  } else if (status === 'failed') {
394
425
  fields.push('failed_at = CURRENT_TIMESTAMP');
395
426
  }
@@ -414,6 +445,28 @@ class PublisherModule {
414
445
  });
415
446
  }
416
447
 
448
+ // Update arbitrary task fields WITHOUT touching status. Use this from long-running
449
+ // work (e.g. saving the announcement mid-publication) — writing back the in-memory
450
+ // task.status can resurrect a stale status and cause the poller to re-run the task.
451
+ async updateTaskFields(taskId, fields) {
452
+ const keys = Object.keys(fields);
453
+ if (keys.length === 0) return;
454
+ const assignments = keys.map(key => key + ' = ?');
455
+ const values = keys.map(key => fields[key]);
456
+ values.push(taskId);
457
+
458
+ return new Promise((resolve, reject) => {
459
+ this.db.run(
460
+ 'UPDATE tasks SET ' + assignments.join(', ') + ' WHERE id = ?',
461
+ values,
462
+ (err) => {
463
+ if (err) reject(err);
464
+ else resolve();
465
+ }
466
+ );
467
+ });
468
+ }
469
+
417
470
  async logTaskMessage(taskId, level, message) {
418
471
  return new Promise((resolve) => {
419
472
  this.db.run(
@@ -500,6 +553,11 @@ class PublisherModule {
500
553
  // Step 3: Clone GitHub repository
501
554
  await this.cloneRepository(task, draftDir);
502
555
 
556
+ // Step 3b: Update SUSHI to the latest release. The IG Publisher runs SUSHI with
557
+ // --require-latest, so a SUSHI that has fallen behind npm's latest makes the build
558
+ // fail. Refreshing it here keeps draft builds working.
559
+ await this.ensureLatestSushi(task.id);
560
+
503
561
  // Step 4: Run IG publisher
504
562
  await this.runIGPublisher(publisherJar, draftDir, logFile, task.id);
505
563
 
@@ -536,6 +594,16 @@ class PublisherModule {
536
594
  throw new Error('Could not find publisher.jar in latest release');
537
595
  }
538
596
 
597
+ // Record which IG Publisher version this task is using. The same jar is reused
598
+ // for the publication run, so this is the version that produced the output.
599
+ const publisherVersion = releaseResponse.data.tag_name || releaseResponse.data.name || 'unknown';
600
+ try {
601
+ await this.updateTaskFields(taskId, { publisher_version: publisherVersion });
602
+ } catch (e) {
603
+ this.logger.warn('Failed to record publisher_version for task ' + taskId + ': ' + e.message);
604
+ }
605
+ await this.logTaskMessage(taskId, 'info', 'Using IG Publisher version ' + publisherVersion);
606
+
539
607
  await this.logTaskMessage(taskId, 'info', 'Downloading from: ' + downloadUrl);
540
608
 
541
609
  // Download the file
@@ -561,6 +629,62 @@ class PublisherModule {
561
629
  }
562
630
  }
563
631
 
632
+ // Directory holding FHIRsmith's own copy of SUSHI. Installing here (rather than -g)
633
+ // needs no root: on the server the global npm prefix (/usr) is root-owned.
634
+ sushiDir() {
635
+ return folders.filePath('publisher', 'sushi');
636
+ }
637
+
638
+ // Environment for spawning the IG Publisher so it finds our managed SUSHI on PATH.
639
+ // The publisher resolves the `sushi` command from PATH, so prepending our bin dir
640
+ // makes it use the version we just installed.
641
+ publisherEnv() {
642
+ const binDir = path.join(this.sushiDir(), 'bin');
643
+ return Object.assign({}, process.env, {
644
+ PATH: binDir + path.delimiter + (process.env.PATH || '')
645
+ });
646
+ }
647
+
648
+ // Update FHIRsmith's managed SUSHI to the latest npm release before a run. The IG
649
+ // Publisher invokes SUSHI with --require-latest, so a SUSHI that has fallen behind
650
+ // npm's latest makes the build abort. We install into a FHIRsmith-owned prefix
651
+ // (sushiDir) to avoid needing root for a global install, and the publisher picks it
652
+ // up via publisherEnv(). Best-effort: a failure is logged but not fatal - the
653
+ // corrected runBuild check in the IG Publisher now turns a stale SUSHI into a loud
654
+ // publication failure rather than a silently-published draft.
655
+ async ensureLatestSushi(taskId) {
656
+ const { spawn } = require('child_process');
657
+ const dir = this.sushiDir();
658
+ fs.mkdirSync(dir, { recursive: true });
659
+ await this.logTaskMessage(taskId, 'info', 'Ensuring SUSHI is up to date in ' + dir + ' ...');
660
+ await new Promise((resolve) => {
661
+ // -g with --prefix makes npm treat `dir` as the global prefix, so the binary
662
+ // lands at dir/bin/sushi (a FHIRsmith-owned, writable location).
663
+ const npm = spawn('npm', ['install', '-g', 'fsh-sushi@latest', '--prefix', dir], { stdio: ['ignore', 'pipe', 'pipe'] });
664
+ let err = '';
665
+ npm.stdout.on('data', () => { /* ignore */ });
666
+ npm.stderr.on('data', (d) => { err += d.toString(); });
667
+ npm.on('error', async (e) => {
668
+ await this.logTaskMessage(taskId, 'warn', 'Could not run npm to update SUSHI: ' + e.message);
669
+ resolve();
670
+ });
671
+ npm.on('close', async (code) => {
672
+ if (code === 0) {
673
+ let version = '';
674
+ try {
675
+ version = require('child_process').execSync('sushi --version', { env: this.publisherEnv(), encoding: 'utf8' }).trim();
676
+ } catch (e) {
677
+ version = '(version check failed)';
678
+ }
679
+ await this.logTaskMessage(taskId, 'info', 'SUSHI is up to date: ' + version);
680
+ } else {
681
+ await this.logTaskMessage(taskId, 'warn', 'SUSHI update exited with code ' + code + (err ? ': ' + err.trim().slice(-400) : ''));
682
+ }
683
+ resolve();
684
+ });
685
+ });
686
+ }
687
+
564
688
  async cloneRepository(task, draftDir) {
565
689
  const { spawn } = require('child_process');
566
690
  const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
@@ -616,20 +740,45 @@ class PublisherModule {
616
740
  '.'
617
741
  ], {
618
742
  cwd: draftDir,
619
- stdio: ['pipe', 'pipe', 'pipe']
743
+ stdio: ['pipe', 'pipe', 'pipe'],
744
+ env: this.publisherEnv()
620
745
  });
621
746
 
622
747
  // Create log file stream
623
748
  const logStream = fs.createWriteStream(logFile);
624
749
 
750
+ const buildStart = Date.now();
751
+ let lastDataAt = Date.now();
752
+
625
753
  java.stdout.on('data', (data) => {
754
+ lastDataAt = Date.now();
626
755
  logStream.write(data);
627
756
  });
628
757
 
629
758
  java.stderr.on('data', (data) => {
759
+ lastDataAt = Date.now();
630
760
  logStream.write(data);
631
761
  });
632
762
 
763
+ // Heartbeat: emit a status line every 60s regardless of stdout activity,
764
+ // so silent phases of the Publisher (e.g. "Validating Resources") still
765
+ // surface a signal-of-life in the task log.
766
+ const heartbeat = setInterval(async () => {
767
+ const elapsedMs = Date.now() - buildStart;
768
+ const sinceDataMs = Date.now() - lastDataAt;
769
+ let logKb = 0;
770
+ try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) { /* log file not created yet */ }
771
+ const elapsedMin = Math.floor(elapsedMs / 60000);
772
+ const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
773
+ const idleSec = Math.floor(sinceDataMs / 1000);
774
+ await this.logTaskMessage(
775
+ taskId,
776
+ 'info',
777
+ 'IG Publisher heartbeat: elapsed ' + elapsedMin + 'm' + elapsedSec + 's, ' +
778
+ 'log ' + logKb + ' KB, last output ' + idleSec + 's ago'
779
+ );
780
+ }, 60 * 1000);
781
+
633
782
  java.on('close', async (code) => {
634
783
  logStream.end();
635
784
 
@@ -660,6 +809,7 @@ class PublisherModule {
660
809
 
661
810
  java.on('close', () => {
662
811
  clearTimeout(timeout);
812
+ clearInterval(heartbeat);
663
813
  });
664
814
  });
665
815
  }
@@ -693,6 +843,91 @@ class PublisherModule {
693
843
  await this.logTaskMessage(task.id, 'info', 'Build output verified: package-id=' + qaData['package-id'] + ', version=' + qaData['ig-ver']);
694
844
  }
695
845
 
846
+ // Read package/package.json out of a .tgz without unpacking the whole archive.
847
+ inspectPackageTgz(tgzPath) {
848
+ const { spawn } = require('child_process');
849
+ return new Promise((resolve, reject) => {
850
+ const tar = spawn('tar', ['-xzOf', tgzPath, 'package/package.json']);
851
+ let out = '';
852
+ let err = '';
853
+ tar.stdout.on('data', (d) => { out += d.toString(); });
854
+ tar.stderr.on('data', (d) => { err += d.toString(); });
855
+ tar.on('error', reject);
856
+ tar.on('close', (code) => {
857
+ if (code !== 0) {
858
+ reject(new Error('Could not read ' + tgzPath + ': ' + (err.trim() || ('tar exit ' + code))));
859
+ } else {
860
+ try {
861
+ resolve(JSON.parse(out));
862
+ } catch (e) {
863
+ reject(new Error('Invalid package.json in ' + tgzPath + ': ' + e.message));
864
+ }
865
+ }
866
+ });
867
+ });
868
+ }
869
+
870
+ // Confirm the package.tgz files that landed in the web tree are publication builds,
871
+ // not the draft. Throws (failing the task) if a draft package was published.
872
+ async verifyPublishedPackage(task, website, draftDir) {
873
+ await this.logTaskMessage(task.id, 'info', 'Verifying published package(s) are publication builds...');
874
+
875
+ // The intended publication path comes from the IG's publication-request.json.
876
+ const prPath = path.join(draftDir, 'publication-request.json');
877
+ if (!fs.existsSync(prPath)) {
878
+ await this.logTaskMessage(task.id, 'warn', 'No publication-request.json found at ' + prPath + ' - skipping published-package check');
879
+ return;
880
+ }
881
+ const pr = JSON.parse(fs.readFileSync(prPath, 'utf8'));
882
+ const pubPath = pr.path;
883
+
884
+ // The website base url lets us map a canonical url to a folder in the web tree.
885
+ const setupPath = path.join(website.local_folder, 'publish-setup.json');
886
+ if (!pubPath || !fs.existsSync(setupPath)) {
887
+ await this.logTaskMessage(task.id, 'warn', 'Cannot resolve web path (path or publish-setup.json missing) - skipping published-package check');
888
+ return;
889
+ }
890
+ const setup = JSON.parse(fs.readFileSync(setupPath, 'utf8'));
891
+ const baseUrl = setup.website && setup.website.url;
892
+ if (!baseUrl || !pubPath.startsWith(baseUrl)) {
893
+ await this.logTaskMessage(task.id, 'warn', 'Publication path ' + pubPath + ' is not under website url ' + baseUrl + ' - skipping published-package check');
894
+ return;
895
+ }
896
+
897
+ const relVer = pubPath.substring(baseUrl.length).replace(/^\/+/, '');
898
+ const relCur = relVer.replace(/\/[^/]+\/?$/, ''); // strip the version segment for the "current" copy
899
+ const candidates = [
900
+ path.join(website.local_folder, relVer, 'package.tgz'),
901
+ path.join(website.local_folder, relCur, 'package.tgz')
902
+ ];
903
+
904
+ let checked = 0;
905
+ for (const pkgPath of candidates) {
906
+ if (!fs.existsSync(pkgPath)) {
907
+ continue;
908
+ }
909
+ checked++;
910
+ const json = await this.inspectPackageTgz(pkgPath);
911
+ const problems = [];
912
+ if (json.notForPublication) {
913
+ problems.push('notForPublication is set');
914
+ }
915
+ if (typeof json.url === 'string' && json.url.startsWith('file:')) {
916
+ problems.push('url is a local file path (' + json.url + ')');
917
+ }
918
+ if (problems.length > 0) {
919
+ throw new Error('Published package ' + pkgPath + ' is a draft build, not a publication build (' +
920
+ problems.join('; ') + '). The IG Publisher publication run likely skipped package ' +
921
+ 'regeneration (e.g. a Jekyll/template failure). Not committing.');
922
+ }
923
+ await this.logTaskMessage(task.id, 'info', 'Verified publication package: ' + pkgPath);
924
+ }
925
+
926
+ if (checked === 0) {
927
+ await this.logTaskMessage(task.id, 'warn', 'No published package.tgz found to verify under ' + relVer + ' or ' + relCur);
928
+ }
929
+ }
930
+
696
931
  async runPublication(task) {
697
932
  const website = await this.getWebsite(task.website_id);
698
933
  if (!website) {
@@ -757,6 +992,12 @@ class PublisherModule {
757
992
  // Step 3: Pull latest web folder before publishing into it
758
993
  await this.runCommand('git', ['pull'], { cwd: website.git_root }, task.id, 'Pulling latest web folder');
759
994
 
995
+ // Step 3b: Update SUSHI to the latest release before the publication run. This is the
996
+ // step that previously failed silently: the publication build runs SUSHI with
997
+ // --require-latest, and a draft approved days earlier may now face a newer SUSHI on
998
+ // npm. Refresh it so the publication build doesn't abort on a version mismatch.
999
+ await this.ensureLatestSushi(task.id);
1000
+
760
1001
  // Step 4: Run the IG publisher in go-publish mode
761
1002
  await this.runPublisherGoPublish(task.id, publisherJar, draftDir, website.local_folder,
762
1003
  registryDir, historyDir, templatesDir, zipsDir, publishLogFile);
@@ -769,6 +1010,12 @@ class PublisherModule {
769
1010
  }
770
1011
  await this.logTaskMessage(task.id, 'info', 'Publication run verified: ' + pubLogName + ' found');
771
1012
 
1013
+ // Step 5b: Verify the published package is actually a publication build.
1014
+ // If the IG Publisher's publication run skipped package regeneration (e.g. a Jekyll
1015
+ // or template failure), the draft package - flagged notForPublication with a file://
1016
+ // url - can survive into the web tree. Catch that here, before anything is committed.
1017
+ await this.verifyPublishedPackage(task, website, draftDir);
1018
+
772
1019
  // Step 6: Commit and push the web folder
773
1020
  await this.logTaskMessage(task.id, 'info', 'Committing changes to web folder...');
774
1021
  const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
@@ -789,7 +1036,7 @@ class PublisherModule {
789
1036
  if (fs.existsSync(announcementPath)) {
790
1037
  try {
791
1038
  const announcement = fs.readFileSync(announcementPath, 'utf8');
792
- await this.updateTaskStatus(task.id, task.status, { announcement: announcement });
1039
+ await this.updateTaskFields(task.id, { announcement: announcement });
793
1040
  await this.logTaskMessage(task.id, 'info', 'Announcement text saved (' + announcement.length + ' chars)');
794
1041
  } catch (err) {
795
1042
  await this.logTaskMessage(task.id, 'warn', 'Failed to read announcement file: ' + err.message);
@@ -825,19 +1072,43 @@ class PublisherModule {
825
1072
 
826
1073
  return new Promise((resolve, reject) => {
827
1074
  const java = spawn('java', args, {
828
- stdio: ['pipe', 'pipe', 'pipe']
1075
+ stdio: ['pipe', 'pipe', 'pipe'],
1076
+ env: this.publisherEnv()
829
1077
  });
830
1078
 
831
1079
  const logStream = fs.createWriteStream(logFile);
832
1080
 
1081
+ const buildStart = Date.now();
1082
+ let lastDataAt = Date.now();
1083
+
833
1084
  java.stdout.on('data', (data) => {
1085
+ lastDataAt = Date.now();
834
1086
  logStream.write(data);
835
1087
  });
836
1088
 
837
1089
  java.stderr.on('data', (data) => {
1090
+ lastDataAt = Date.now();
838
1091
  logStream.write(data);
839
1092
  });
840
1093
 
1094
+ // Heartbeat: emit a status line every 60s regardless of stdout activity,
1095
+ // so silent phases of the Publisher still surface a signal-of-life.
1096
+ const heartbeat = setInterval(async () => {
1097
+ const elapsedMs = Date.now() - buildStart;
1098
+ const sinceDataMs = Date.now() - lastDataAt;
1099
+ let logKb = 0;
1100
+ try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) { /* log file not created yet */ }
1101
+ const elapsedMin = Math.floor(elapsedMs / 60000);
1102
+ const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
1103
+ const idleSec = Math.floor(sinceDataMs / 1000);
1104
+ await this.logTaskMessage(
1105
+ taskId,
1106
+ 'info',
1107
+ 'IG Publisher go-publish heartbeat: elapsed ' + elapsedMin + 'm' + elapsedSec + 's, ' +
1108
+ 'log ' + logKb + ' KB, last output ' + idleSec + 's ago'
1109
+ );
1110
+ }, 60 * 1000);
1111
+
841
1112
  java.on('close', async (code) => {
842
1113
  logStream.end();
843
1114
  if (code === 0) {
@@ -856,16 +1127,18 @@ class PublisherModule {
856
1127
  reject(error);
857
1128
  });
858
1129
 
859
- // Timeout after 60 minutes for publication (longer than draft build)
1130
+ // Timeout configurable via publisher.igPublisherTimeoutMinutes (default: 60 minutes)
1131
+ const timeoutMinutes = this.config.igPublisherTimeoutMinutes || 60;
860
1132
  const timeout = setTimeout(async () => {
861
1133
  java.kill();
862
1134
  logStream.end();
863
- await this.logTaskMessage(taskId, 'error', 'IG Publisher go-publish timed out after 60 minutes');
1135
+ await this.logTaskMessage(taskId, 'error', 'IG Publisher go-publish timed out after ' + timeoutMinutes + ' minutes');
864
1136
  reject(new Error('IG Publisher go-publish timed out'));
865
- }, 60 * 60 * 1000);
1137
+ }, timeoutMinutes * 60 * 1000);
866
1138
 
867
1139
  java.on('close', () => {
868
1140
  clearTimeout(timeout);
1141
+ clearInterval(heartbeat);
869
1142
  });
870
1143
  });
871
1144
  }
@@ -1125,7 +1398,7 @@ class PublisherModule {
1125
1398
  } else {
1126
1399
  content += '<div class="table-responsive">';
1127
1400
  content += '<table class="table table-striped">';
1128
- content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
1401
+ content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>IG Publisher</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
1129
1402
  content += '<tbody>';
1130
1403
 
1131
1404
  for (const task of tasks) {
@@ -1146,6 +1419,7 @@ class PublisherModule {
1146
1419
  content += '<td>' + task.version + '</td>';
1147
1420
  content += '<td>' + task.website_name + '</td>';
1148
1421
  content += '<td><span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></td>';
1422
+ content += '<td>' + (task.publisher_version ? '<code>' + escape(task.publisher_version) + '</code>' : '<span class="text-muted">—</span>') + '</td>';
1149
1423
  content += '<td>' + new Date(task.queued_at).toLocaleString() + '</td>';
1150
1424
  content += '<td>' + task.user_name + '</td>';
1151
1425
  content += '<td class="task-actions">';
@@ -1450,6 +1724,10 @@ class PublisherModule {
1450
1724
  content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></p>';
1451
1725
  content += '<p><strong>GitHub:</strong> ' + task.github_org + '/' + task.github_repo + ' (' + task.git_branch + ')</p>';
1452
1726
 
1727
+ if (task.publisher_version) {
1728
+ content += '<p><strong>IG Publisher:</strong> <code>' + escape(task.publisher_version) + '</code></p>';
1729
+ }
1730
+
1453
1731
  if (task.local_folder) {
1454
1732
  content += '<p><strong>Local Folder:</strong> <code>' + task.local_folder + '</code></p>';
1455
1733
  }
@@ -2,15 +2,34 @@
2
2
  // Crawler for gathering server information from terminology servers
3
3
 
4
4
  const axios = require('axios');
5
- const {
6
- ServerRegistries,
7
- ServerRegistry,
8
- ServerInformation,
5
+ const dns = require('dns');
6
+ const http = require('http');
7
+ const https = require('https');
8
+ const ipaddr = require('ipaddr.js');
9
+ const {
10
+ ServerRegistries,
11
+ ServerRegistry,
12
+ ServerInformation,
9
13
  ServerVersionInformation,
10
14
  } = require('./model');
11
15
  const {Extensions} = require("../tx/library/extensions");
12
16
  const {debugLog} = require("../tx/operation-context");
13
17
 
18
+ // True if an IP literal is anything other than a normal public (unicast) address:
19
+ // loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
20
+ // CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
21
+ function isNonPublicAddress(ip) {
22
+ try {
23
+ let addr = ipaddr.parse(ip);
24
+ if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
25
+ addr = addr.toIPv4Address();
26
+ }
27
+ return addr.range() !== 'unicast';
28
+ } catch (e) {
29
+ return true; // unparseable - treat as unsafe
30
+ }
31
+ }
32
+
14
33
  const MASTER_URL = 'https://fhir.github.io/ig-registry/tx-servers.json';
15
34
 
16
35
  class RegistryCrawler {
@@ -22,7 +41,8 @@ class RegistryCrawler {
22
41
  masterUrl: config.masterUrl || MASTER_URL,
23
42
  userAgent: config.userAgent || 'HealthIntersections/FhirServer',
24
43
  crawlInterval: config.crawlInterval || 5 * 60 * 1000, // 5 minutes default
25
- apiKeys: config.apiKeys || {} // Map of server URL or code to API key
44
+ apiKeys: config.apiKeys || {}, // Map of server URL or code to API key
45
+ allowPrivateAddresses: config.allowPrivateAddresses || false // SSRF opt-out for local test servers
26
46
  };
27
47
  this.stats = stats;
28
48
 
@@ -39,6 +59,63 @@ class RegistryCrawler {
39
59
  this.log = logv;
40
60
  }
41
61
 
62
+ // A DNS lookup wrapper that rejects any host resolving to a non-public address.
63
+ // Enforced at connection time (so it also covers redirect targets and defeats
64
+ // DNS-rebinding), this is the SSRF guard for outbound fetches. The registry is
65
+ // crawled from server-supplied "next" links, so the target host is not trusted.
66
+ // Set config.allowPrivateAddresses = true to disable (e.g. for local test servers).
67
+ ssrfLookup() {
68
+ const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
69
+ return (hostname, options, callback) => {
70
+ if (typeof options === 'function') {
71
+ callback = options;
72
+ options = {};
73
+ }
74
+ const wantAll = !!(options && options.all);
75
+ dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
76
+ if (err) {
77
+ callback(err);
78
+ return;
79
+ }
80
+ if (!allowPrivate) {
81
+ for (const a of addresses) {
82
+ if (isNonPublicAddress(a.address)) {
83
+ callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
84
+ return;
85
+ }
86
+ }
87
+ }
88
+ if (wantAll) {
89
+ callback(null, addresses);
90
+ } else {
91
+ callback(null, addresses[0].address, addresses[0].family);
92
+ }
93
+ });
94
+ };
95
+ }
96
+
97
+ // http/https agents that route every connection through the SSRF lookup.
98
+ guardedAgents() {
99
+ if (!this._guardedAgents) {
100
+ const lookup = this.ssrfLookup();
101
+ class GuardedHttpAgent extends http.Agent {
102
+ createConnection(options, cb) {
103
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
104
+ }
105
+ }
106
+ class GuardedHttpsAgent extends https.Agent {
107
+ createConnection(options, cb) {
108
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
109
+ }
110
+ }
111
+ this._guardedAgents = {
112
+ httpAgent: new GuardedHttpAgent({ keepAlive: true }),
113
+ httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
114
+ };
115
+ }
116
+ return this._guardedAgents;
117
+ }
118
+
42
119
 
43
120
  /**
44
121
  * Main entry point - crawl the registry starting from the master URL
@@ -455,7 +532,9 @@ class RegistryCrawler {
455
532
  timeout: this.config.timeout,
456
533
  headers: headers,
457
534
  signal: this.abortController?.signal,
458
- validateStatus: (status) => status < 500 // Don't throw on 4xx
535
+ validateStatus: (status) => status < 500, // Don't throw on 4xx
536
+ httpAgent: this.guardedAgents().httpAgent,
537
+ httpsAgent: this.guardedAgents().httpsAgent
459
538
  });
460
539
 
461
540
  if (response.status >= 400) {