fhirsmith 0.18.2 → 1.0.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 (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
@@ -17,6 +17,7 @@ const https = require('https');
17
17
  const ipaddr = require('ipaddr.js');
18
18
  const {debugLog} = require("../tx/operation-context");
19
19
  const {Parser: TarParser} = require('tar');
20
+ const {integrityOfSync, verifyPackageSignature} = require('./npm-signing');
20
21
 
21
22
  const MB = 1024 * 1024;
22
23
 
@@ -72,6 +73,16 @@ function isLoopbackAddress(ip) {
72
73
  }
73
74
  }
74
75
 
76
+ // A mirror file is written as <file>.tgz.tmp-<pid> and then renamed into place
77
+ const MIRROR_TMP_MARKER = '.tmp-';
78
+
79
+ // crawler.signatures: what to do with publishers' package signatures (see signingFor)
80
+ const SIGNATURE_MODES = ['ignore', 'check', 'require'];
81
+ // a signature file is a few KB; anything much bigger isn't one
82
+ const MAX_SIGNATURE_BYTES = 1024 * 1024;
83
+ // A temporary mirror file older than this was left by a crawler that stopped mid-write
84
+ const MIRROR_TMP_MAX_AGE_MS = 60 * 60 * 1000;
85
+
75
86
  class PackageCrawler {
76
87
  log;
77
88
  packages = new Set();
@@ -84,6 +95,15 @@ class PackageCrawler {
84
95
  this.crawlerLog = {};
85
96
  this.errors = '';
86
97
  this.abortController = null;
98
+ // the npm signing key (NpmSigner), or null when this server doesn't sign
99
+ this.signer = null;
100
+ this.skippedFeeds = new Set();
101
+ // publisher signatures (fhir:signature in a feed, keys in package-feeds.json): ignore,
102
+ // check, or require - see signingFor()
103
+ this.signatureMode = (config.crawler && config.crawler.signatures) || 'check';
104
+ if (!SIGNATURE_MODES.includes(this.signatureMode)) {
105
+ throw new Error(`crawler.signatures must be one of ${SIGNATURE_MODES.join(', ')}, not ${this.signatureMode}`);
106
+ }
87
107
  this.db.run('PRAGMA journal_mode = WAL');
88
108
  this.db.run('PRAGMA busy_timeout = 5000');
89
109
  }
@@ -110,6 +130,7 @@ class PackageCrawler {
110
130
  async crawl(log) {
111
131
  this.log = log;
112
132
  this.packages.clear();
133
+ this.skippedFeeds = new Set();
113
134
  this.abortController = new AbortController();
114
135
 
115
136
  const startTime = Date.now();
@@ -122,6 +143,11 @@ class PackageCrawler {
122
143
  };
123
144
 
124
145
  this.log.info('Running web crawler for packages using master URL: '+ this.config.masterUrl);
146
+ try {
147
+ this.cleanMirrorTempFiles();
148
+ } catch (e) {
149
+ this.log.warn(`Could not clean the package mirror: ${e.message}`);
150
+ }
125
151
  this.stats.task('Package Crawler', 'Running');
126
152
 
127
153
  try {
@@ -144,9 +170,12 @@ class PackageCrawler {
144
170
  }
145
171
  try {
146
172
  let url = this.fixUrl(feedConfig.url)
173
+ if (this.skipUnsigned(feedConfig)) {
174
+ continue;
175
+ }
147
176
  if (!url.includes('simplifier')) {
148
177
  this.stats.task('Package Crawler', 'Running for '+feedConfig.url);
149
- await this.updateTheFeed(url, this.config.masterUrl,feedConfig.errors ? feedConfig.errors.replace(/\|/g, '@').replace(/_/g, '.') : '', packageRestrictions);
178
+ await this.updateTheFeed(url, this.config.masterUrl,feedConfig.errors ? feedConfig.errors.replace(/\|/g, '@').replace(/_/g, '.') : '', packageRestrictions, this.signingFor(feedConfig.signing));
150
179
  }
151
180
  } catch (feedError) {
152
181
  this.log.error(`Failed to process feed ${feedConfig.url}: `+ feedError.message);
@@ -163,9 +192,12 @@ class PackageCrawler {
163
192
 
164
193
  try {
165
194
  let url = this.fixUrl(feedConfig.url)
195
+ if (this.skipUnsigned(feedConfig)) {
196
+ continue;
197
+ }
166
198
  if (url.includes('simplifier')) {
167
199
  this.stats.task('Package Crawler', 'Running for '+feedConfig.url);
168
- await this.updateTheFeed(url, this.config.masterUrl,feedConfig.errors ? feedConfig.errors.replace(/\|/g, '@').replace(/_/g, '.') : '', packageRestrictions);
200
+ await this.updateTheFeed(url, this.config.masterUrl,feedConfig.errors ? feedConfig.errors.replace(/\|/g, '@').replace(/_/g, '.') : '', packageRestrictions, this.signingFor(feedConfig.signing));
169
201
  }
170
202
  } catch (feedError) {
171
203
  this.log.error(`Failed to process feed ${feedConfig.url}: `+ feedError.message);
@@ -466,7 +498,7 @@ class PackageCrawler {
466
498
  return body;
467
499
  }
468
500
 
469
- async updateTheFeed(url, source, email, packageRestrictions) {
501
+ async updateTheFeed(url, source, email, packageRestrictions, signing = null) {
470
502
  const feedLog = {
471
503
  url: url,
472
504
  items: []
@@ -522,7 +554,7 @@ class PackageCrawler {
522
554
  for (let i = 0; i < items.length; i++) {
523
555
  if (this.abortController?.signal.aborted) break;
524
556
  try {
525
- await this.updateItem(currentUrl, items[i], i, packageRestrictions, feedLog);
557
+ await this.updateItem(currentUrl, items[i], i, packageRestrictions, feedLog, signing);
526
558
  } catch (itemError) {
527
559
  if (itemError.message.includes('RATE_LIMITED')) {
528
560
  this.log.info(`Rate limited while downloading package from ${currentUrl}, stopping feed processing`);
@@ -568,7 +600,8 @@ class PackageCrawler {
568
600
  }
569
601
  }
570
602
 
571
- async updateItem(source, item, index, packageRestrictions, feedLog) {
603
+ // signing: how this feed's signatures are checked (signingFor), or null
604
+ async updateItem(source, item, index, packageRestrictions, feedLog, signing = null) {
572
605
  const itemLog = {
573
606
  status: '??'
574
607
  };
@@ -628,6 +661,7 @@ class PackageCrawler {
628
661
  // Check if already processed
629
662
  if (await this.hasStored(guid)) {
630
663
  itemLog.status = 'Already Processed';
664
+ await this.checkStoredSignature(item, guid, signing, itemLog);
631
665
  return;
632
666
  }
633
667
 
@@ -672,7 +706,8 @@ class PackageCrawler {
672
706
  this.log.info('Fetching package: ' + url);
673
707
 
674
708
  const packageContent = await this.fetchUrl(url, 'application/tar+gzip');
675
- const outcome = await this.store(source, url, guid, pubDate, packageContent, id, itemLog);
709
+ const signature = signing ? await this.fetchSignature(item) : null;
710
+ const outcome = await this.store(source, url, guid, pubDate, packageContent, id, itemLog, signing, signature);
676
711
 
677
712
  itemLog.status = {stored: 'Fetched', replaced: 'Replaced Simplifier copy', skipped: 'Already Stored'}[outcome];
678
713
 
@@ -735,6 +770,141 @@ class PackageCrawler {
735
770
  }
736
771
  }
737
772
 
773
+ // --- publisher signatures (see npm-signing.js) --------------------------------------------------
774
+ //
775
+ // A feed may list keys in package-feeds.json (feeds[].signing: {required, keys}), and its items
776
+ // may link a signature (fhir:signature). crawler.signatures says what to do with them:
777
+ // ignore - nothing
778
+ // check - (the default) check each signature of a feed that lists keys; one that fails is
779
+ // logged, and the package is stored anyway, unless the feed says signing.required
780
+ // require - as check, but an unsigned package, or one whose signature fails, is refused, for
781
+ // every feed that lists keys
782
+ // Feeds that list no keys are never affected. The signature file is kept (PackageSignatures) as
783
+ // it was when it was checked, valid or not.
784
+
785
+ // With crawler.signatures = require, a feed that lists no keys isn't read at all: none of its
786
+ // packages could be accepted. Noted in the crawl log.
787
+ skipUnsigned(feedConfig) {
788
+ if (this.signatureMode !== 'require' || this.signingFor(feedConfig.signing)) {
789
+ return false;
790
+ }
791
+ if (!this.skippedFeeds.has(feedConfig.url)) {
792
+ this.skippedFeeds.add(feedConfig.url);
793
+ this.log.info(`Skipping feed ${feedConfig.url}: it lists no signing keys, and signatures are required`);
794
+ this.crawlerLog.feeds.push({ url: feedConfig.url, skipped: 'it lists no signing keys, and signatures are required', items: [] });
795
+ }
796
+ return true;
797
+ }
798
+
799
+ // How a feed's signatures are checked, or null if they aren't
800
+ signingFor(feedSigning) {
801
+ if (this.signatureMode === 'ignore' || !feedSigning || !Array.isArray(feedSigning.keys) || feedSigning.keys.length === 0) {
802
+ return null;
803
+ }
804
+ return { keys: feedSigning.keys, required: this.signatureMode === 'require' || feedSigning.required === true };
805
+ }
806
+
807
+ signatureUrl(item) {
808
+ const s = item['fhir:signature'];
809
+ const v = s && typeof s === 'object' ? s['#text'] : s;
810
+ return v ? String(v).trim() : null;
811
+ }
812
+
813
+ // The item's signature file: {url, text}, {url: null} if it has none, or {url, fetchError}
814
+ async fetchSignature(item) {
815
+ const url = this.signatureUrl(item);
816
+ if (!url) {
817
+ return { url: null };
818
+ }
819
+ try {
820
+ const content = await this.fetchUrl(this.fixUrl(url), 'application/json');
821
+ if (content.length > MAX_SIGNATURE_BYTES) {
822
+ return { url, fetchError: `it is too large (${content.length} bytes)` };
823
+ }
824
+ return { url, text: content.toString('utf8') };
825
+ } catch (e) {
826
+ if (e.message.includes('RATE_LIMITED')) {
827
+ throw e;
828
+ }
829
+ return { url, fetchError: e.message };
830
+ }
831
+ }
832
+
833
+ // Checks a package's signature. Returns {record} - what to keep in PackageSignatures - and
834
+ // {refuse} - why the package must not be stored - either or neither
835
+ checkSignature(signing, signature, packageBuffer, id, version, date, itemLog) {
836
+ if (!signing) {
837
+ return {};
838
+ }
839
+ if (!signature || !signature.url) {
840
+ itemLog.signature = 'unsigned';
841
+ return signing.required ? { refuse: 'it is not signed, and this feed\'s packages must be' } : {};
842
+ }
843
+ if (signature.fetchError) {
844
+ itemLog.signature = `the signature ${signature.url} could not be fetched: ${signature.fetchError}`;
845
+ this.log.error(`Signature for ${id}#${version} could not be fetched (${signature.url}): ${signature.fetchError}`);
846
+ return signing.required ? { refuse: itemLog.signature } : {};
847
+ }
848
+ const record = { url: signature.url, bundle: signature.text, keyid: null, valid: false, message: null };
849
+ try {
850
+ let bundle;
851
+ try {
852
+ bundle = JSON.parse(signature.text);
853
+ } catch (e) {
854
+ throw new Error('the signature file is not JSON');
855
+ }
856
+ const v = verifyPackageSignature(packageBuffer, bundle, signing.keys, id, version, date instanceof Date && !isNaN(date) ? date : null);
857
+ record.keyid = v.keyid;
858
+ record.valid = true;
859
+ itemLog.signature = `valid (${v.keyid})`;
860
+ return { record };
861
+ } catch (e) {
862
+ record.message = e.message;
863
+ itemLog.signature = `invalid: ${e.message}`;
864
+ this.log.error(`Signature check failed for ${id}#${version} (${signature.url}): ${e.message}`);
865
+ return { record, refuse: signing.required ? `its signature is not valid: ${e.message}` : null };
866
+ }
867
+ }
868
+
869
+ async recordSignature(id, version, record) {
870
+ await this.run(`INSERT OR REPLACE INTO PackageSignatures (PackageVersionKey, Url, KeyId, Valid, Message, Checked, Bundle)
871
+ SELECT PackageVersionKey, ?, ?, ?, ?, datetime('now'), ? FROM PackageVersions WHERE Id = ? AND Version = ?`,
872
+ [record.url, record.keyid, record.valid ? 1 : 0, record.message, record.bundle, id, version]);
873
+ }
874
+
875
+ // A package stored before its feed was signed (or before this server checked): its signature is
876
+ // checked against the stored bytes, once. Never refused - it is already held - but a failure is
877
+ // logged and kept. A signature that can't be fetched is tried again next crawl.
878
+ async checkStoredSignature(item, guid, signing, itemLog) {
879
+ if (!signing || !this.signatureUrl(item)) {
880
+ return;
881
+ }
882
+ const row = await new Promise((resolve, reject) => {
883
+ this.db.get(`SELECT v.PackageVersionKey, v.Id, v.Version, v.PubDate, s.PackageVersionKey AS Checked
884
+ FROM PackageVersions v LEFT JOIN PackageSignatures s ON s.PackageVersionKey = v.PackageVersionKey
885
+ WHERE v.GUID = ?`, [guid], (err, r) => err ? reject(err) : resolve(r));
886
+ });
887
+ if (!row || row.Checked) {
888
+ return;
889
+ }
890
+ const signature = await this.fetchSignature(item);
891
+ if (signature.fetchError) {
892
+ itemLog.signature = `the signature ${signature.url} could not be fetched: ${signature.fetchError}`;
893
+ this.log.warn(`Signature for ${row.Id}#${row.Version} could not be fetched (${signature.url}): ${signature.fetchError}`);
894
+ return;
895
+ }
896
+ const content = await new Promise((resolve, reject) => {
897
+ this.db.get('SELECT Content FROM PackageVersions WHERE PackageVersionKey = ?', [row.PackageVersionKey], (err, r) => err ? reject(err) : resolve(r));
898
+ });
899
+ if (!content) {
900
+ return;
901
+ }
902
+ const verdict = this.checkSignature({ ...signing, required: false }, signature, content.Content, row.Id, row.Version, new Date(row.PubDate), itemLog);
903
+ if (verdict.record) {
904
+ await this.recordSignature(row.Id, row.Version, verdict.record);
905
+ }
906
+ }
907
+
738
908
  async hasStored(guid) {
739
909
  return new Promise((resolve, reject) => {
740
910
  this.db.get('SELECT COUNT(*) as count FROM PackageVersions WHERE GUID = ?', [guid], (err, row) => {
@@ -786,6 +956,55 @@ class PackageCrawler {
786
956
  });
787
957
  }
788
958
 
959
+ // Runs fn inside a database transaction: all of its writes commit, or none do. The crawler
960
+ // stores one package at a time, so its transactions never nest. The connection is shared
961
+ // with the web side, so a write it makes meanwhile (a download count) joins the transaction.
962
+ async transaction(fn) {
963
+ await this.run('BEGIN IMMEDIATE');
964
+ try {
965
+ const result = await fn();
966
+ await this.run('COMMIT');
967
+ return result;
968
+ } catch (e) {
969
+ try {
970
+ await this.run('ROLLBACK');
971
+ } catch (rollbackError) {
972
+ this.log?.error?.(`Rollback failed: ${rollbackError.message}`);
973
+ }
974
+ throw e;
975
+ }
976
+ }
977
+
978
+ // Removes mirror files that a crawler stopped before renaming into place. Only files old
979
+ // enough that nothing can still be writing them: the mirror may be shared, and in a
980
+ // container the pid in the name is no guide.
981
+ cleanMirrorTempFiles(maxAgeMs = MIRROR_TMP_MAX_AGE_MS) {
982
+ if (!this.config.mirrorPath || !fs.existsSync(this.config.mirrorPath)) {
983
+ return 0;
984
+ }
985
+ const cutoff = Date.now() - maxAgeMs;
986
+ let removed = 0;
987
+ for (const name of fs.readdirSync(this.config.mirrorPath)) {
988
+ if (!name.includes(MIRROR_TMP_MARKER)) {
989
+ continue;
990
+ }
991
+ const file = path.join(this.config.mirrorPath, name);
992
+ try {
993
+ const stat = fs.statSync(file);
994
+ if (stat.isFile() && stat.mtimeMs < cutoff) {
995
+ fs.rmSync(file, { force: true });
996
+ removed++;
997
+ }
998
+ } catch (e) {
999
+ this.log?.warn?.(`Could not remove stale mirror file ${name}: ${e.message}`);
1000
+ }
1001
+ }
1002
+ if (removed > 0) {
1003
+ this.log?.info?.(`Removed ${removed} stale temporary file(s) from the package mirror`);
1004
+ }
1005
+ return removed;
1006
+ }
1007
+
789
1008
  // Point Packages.CurrentVersion at the most recent version of id
790
1009
  async refreshCurrentVersion(id) {
791
1010
  const latest = await new Promise((resolve, reject) => {
@@ -801,7 +1020,7 @@ class PackageCrawler {
801
1020
 
802
1021
  // A direct copy replacing the Simplifier copies we held: it takes over the first row (so its
803
1022
  // key and download count survive), and any further Simplifier rows go.
804
- async replaceVersion(keys, packageBuffer, npmPackage, date, guid, id, canonical, urls) {
1023
+ async replaceVersion(keys, packageBuffer, npmPackage, date, guid, id, version, canonical, urls) {
805
1024
  const [vkey, ...extra] = keys;
806
1025
  await this.run(`UPDATE PackageVersions
807
1026
  SET GUID = ?, PubDate = ?, Indexed = datetime('now'), Kind = ?, Canonical = ?,
@@ -812,7 +1031,7 @@ class PackageCrawler {
812
1031
  npmPackage.description, this.genHash(packageBuffer), npmPackage.author, npmPackage.license,
813
1032
  npmPackage.url, packageBuffer, vkey]);
814
1033
  for (const key of keys) {
815
- for (const table of ['PackageFHIRVersions', 'PackageDependencies', 'PackageURLs']) {
1034
+ for (const table of ['PackageFHIRVersions', 'PackageDependencies', 'PackageURLs', 'PackageDist', 'PackageSignatures']) {
816
1035
  await this.run(`DELETE FROM ${table} WHERE PackageVersionKey = ?`, [key]);
817
1036
  }
818
1037
  }
@@ -820,6 +1039,7 @@ class PackageCrawler {
820
1039
  await this.run('DELETE FROM PackageVersions WHERE PackageVersionKey = ?', [key]);
821
1040
  }
822
1041
  await this.insertRelatedData(vkey, npmPackage, urls);
1042
+ await this.storeDist(vkey, id, version, packageBuffer);
823
1043
  await this.refreshCurrentVersion(id);
824
1044
  }
825
1045
 
@@ -848,7 +1068,8 @@ class PackageCrawler {
848
1068
  return date;
849
1069
  }
850
1070
 
851
- async store(source, url, guid, date, packageBuffer, idver, itemLog) {
1071
+ // signing and signature: the feed's signature checking (signingFor) and what fetchSignature got
1072
+ async store(source, url, guid, date, packageBuffer, idver, itemLog, signing = null, signature = null) {
852
1073
  try {
853
1074
  // Extract and parse the NPM package
854
1075
  const npmPackage = await this.extractNpmPackage(packageBuffer, `${source}#${guid}`);
@@ -876,48 +1097,63 @@ class PackageCrawler {
876
1097
  throw new Error(check.reason);
877
1098
  }
878
1099
 
879
- if (!this.isValidSemVersion(version)) {
880
- throw new Error(`NPM Version "${version}" is not valid from ${source}`);
881
- }
1100
+ const canonical = this.checkPackageContent(npmPackage, idver, source);
882
1101
 
883
- let canonical = npmPackage.canonical || `http://simplifier.net/packages/${id}`;
884
- if (!this.isAbsoluteUrl(canonical)) {
885
- throw new Error(`NPM Canonical "${canonical}" is not valid from ${source}`);
886
- }
887
-
888
- const isTemplate = npmPackage.kind === 2; // fhir.template
889
- if (npmPackage.hasInstallScripts) {
890
- throw new Error(`Package ${idver} rejected: contains install scripts (preinstall/install/postinstall)`);
891
- }
892
- if (npmPackage.hasJavaScript && !isTemplate && id !== 'hl7.fhir.pubpack') {
893
- throw new Error(`Package ${idver} rejected: contains JavaScript files but is not a template package`);
894
- }
895
- // The feed gate (item.notForPublication) only sees the RSS entry. A package whose
896
- // feed entry is clean can still carry notForPublication inside the tarball - that is
897
- // a draft build that must never enter the registry. Reject it here too.
898
- if (npmPackage.notForPublication) {
899
- throw new Error(`Package ${idver} rejected: tarball is flagged notForPublication (draft build, not suitable for publication)`);
1102
+ const verdict = this.checkSignature(signing, signature, packageBuffer, id, version, date, itemLog);
1103
+ if (verdict.refuse) {
1104
+ throw new Error(`Package ${idver} rejected: ${verdict.refuse}`);
900
1105
  }
901
1106
 
902
1107
  // Extract URLs from package
903
1108
  const urls = this.processPackageUrls(npmPackage);
904
1109
 
905
- // Commit to database
906
- await this.commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls, check.keys);
907
-
908
- // Only now write the mirror copy: a package that failed validation or didn't commit
909
- // must not replace the file that is served for the copy we hold
1110
+ // The mirror file has to be complete before the registry advertises the package (the
1111
+ // download redirect points at it), and a replacement must not touch the file served for
1112
+ // the copy we hold unless it commits. So: write the new file beside its target first;
1113
+ // a new package's file is put in place before the transaction, a replacement's at the
1114
+ // end of it, just before COMMIT, so a failed rename rolls the replacement back.
1115
+ // Rename within a directory is atomic, so readers see the old file or the whole new one.
1116
+ let filepath = null;
1117
+ let tmpPath = null;
910
1118
  if (this.config.mirrorPath) {
911
1119
  let fid = this.fixPrefix(id);
912
- const filename = `${fid}-${version}.tgz`;
913
- const filepath = path.join(this.config.mirrorPath, filename);
1120
+ filepath = path.join(this.config.mirrorPath, `${fid}-${version}.tgz`);
914
1121
  // id and version are validated, but the file must land in the mirror whatever they say
915
1122
  if (path.dirname(path.resolve(filepath)) !== path.resolve(this.config.mirrorPath)) {
916
1123
  throw new Error(`Package ${id}#${version} rejected: its mirror file name is not a plain file name`);
917
1124
  }
918
- fs.writeFileSync(filepath, packageBuffer);
1125
+ tmpPath = `${filepath}${MIRROR_TMP_MARKER}${process.pid}`;
1126
+ }
1127
+ const replacing = check.action === 'replace';
1128
+ let placed = false;
1129
+ try {
1130
+ if (tmpPath) {
1131
+ fs.writeFileSync(tmpPath, packageBuffer);
1132
+ if (!replacing) {
1133
+ fs.renameSync(tmpPath, filepath);
1134
+ placed = true;
1135
+ }
1136
+ }
1137
+ await this.transaction(async () => {
1138
+ await this.commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls, check.keys);
1139
+ if (tmpPath && replacing) {
1140
+ fs.renameSync(tmpPath, filepath);
1141
+ }
1142
+ });
1143
+ } catch (e) {
1144
+ if (tmpPath) {
1145
+ fs.rmSync(tmpPath, { force: true });
1146
+ }
1147
+ if (placed) {
1148
+ // a new package that didn't commit isn't advertised, so its file goes too
1149
+ fs.rmSync(filepath, { force: true });
1150
+ }
1151
+ throw e;
919
1152
  }
920
1153
 
1154
+ if (verdict.record) {
1155
+ await this.recordSignature(id, version, verdict.record);
1156
+ }
921
1157
  return check.action === 'replace' ? 'replaced' : 'stored';
922
1158
 
923
1159
  } catch (error) {
@@ -927,6 +1163,40 @@ class PackageCrawler {
927
1163
  }
928
1164
  }
929
1165
 
1166
+ // What a package must be to be stored (beyond being new): a valid id, a whole semver version,
1167
+ // an absolute canonical, no install scripts, no JavaScript unless it's a template, and not
1168
+ // flagged notForPublication. Throws saying what's wrong; returns the canonical. Shared with
1169
+ // the feed check (feed-check.js), which stores nothing.
1170
+ checkPackageContent(npmPackage, idver, source) {
1171
+ const {id, version} = npmPackage;
1172
+ if (!this.isValidPackageId(id)) {
1173
+ throw new Error(`NPM Id "${id}" is not valid from ${source}`);
1174
+ }
1175
+ if (!this.isValidSemVersion(version)) {
1176
+ throw new Error(`NPM Version "${version}" is not valid from ${source}`);
1177
+ }
1178
+
1179
+ let canonical = npmPackage.canonical || `http://simplifier.net/packages/${id}`;
1180
+ if (!this.isAbsoluteUrl(canonical)) {
1181
+ throw new Error(`NPM Canonical "${canonical}" is not valid from ${source}`);
1182
+ }
1183
+
1184
+ const isTemplate = npmPackage.kind === 2; // fhir.template
1185
+ if (npmPackage.hasInstallScripts) {
1186
+ throw new Error(`Package ${idver} rejected: contains install scripts (preinstall/install/postinstall)`);
1187
+ }
1188
+ if (npmPackage.hasJavaScript && !isTemplate && id !== 'hl7.fhir.pubpack') {
1189
+ throw new Error(`Package ${idver} rejected: contains JavaScript files but is not a template package`);
1190
+ }
1191
+ // The feed gate (item.notForPublication) only sees the RSS entry. A package whose
1192
+ // feed entry is clean can still carry notForPublication inside the tarball - that is
1193
+ // a draft build that must never enter the registry. Reject it here too.
1194
+ if (npmPackage.notForPublication) {
1195
+ throw new Error(`Package ${idver} rejected: tarball is flagged notForPublication (draft build, not suitable for publication)`);
1196
+ }
1197
+ return canonical;
1198
+ }
1199
+
930
1200
  // Reads what the crawler needs from a package as the package is unzipped and untarred:
931
1201
  // package.json, .index.json and ig.ini, the file names, and the url of each resource. Only
932
1202
  // those files are held, one at a time, so memory is bounded by maxFileMB however large
@@ -1262,9 +1532,19 @@ class PackageCrawler {
1262
1532
  return crypto.createHash('sha1').update(data).digest('hex');
1263
1533
  }
1264
1534
 
1535
+ // The version's dist.integrity, and its registry signature when this server signs
1536
+ // (this.signer, set by the packages module - see npm-signing.js). In their own table,
1537
+ // PackageDist: a column after Content in PackageVersions can only be read by walking the
1538
+ // tarball's overflow pages, and the package document reads these for every version.
1539
+ async storeDist(vkey, id, version, packageBuffer) {
1540
+ const integrity = integrityOfSync(packageBuffer);
1541
+ await this.run('INSERT OR REPLACE INTO PackageDist (PackageVersionKey, Integrity, KeyId, Signature) VALUES (?, ?, ?, ?)',
1542
+ [vkey, integrity, this.signer ? this.signer.keyid : null, this.signer ? this.signer.sign(id, version, integrity) : null]);
1543
+ }
1544
+
1265
1545
  async commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls, replaceKeys = null) {
1266
1546
  if (replaceKeys && replaceKeys.length > 0) {
1267
- return this.replaceVersion(replaceKeys, packageBuffer, npmPackage, date, guid, id, canonical, urls);
1547
+ return this.replaceVersion(replaceKeys, packageBuffer, npmPackage, date, guid, id, version, canonical, urls);
1268
1548
  }
1269
1549
  return new Promise((resolve, reject) => {
1270
1550
  // Get next version key
@@ -1298,10 +1578,11 @@ class PackageCrawler {
1298
1578
  }
1299
1579
 
1300
1580
  // Insert FHIR versions, dependencies, and URLs
1301
- this.insertRelatedData(vkey, npmPackage, urls).then(() => {
1581
+ this.insertRelatedData(vkey, npmPackage, urls)
1582
+ .then(() => this.storeDist(vkey, id, version, packageBuffer))
1302
1583
  // Handle package table (insert or update)
1303
- this.upsertPackage(id, vkey, canonical).then(resolve).catch(reject);
1304
- }).catch(reject);
1584
+ .then(() => this.upsertPackage(id, vkey, canonical))
1585
+ .then(resolve).catch(reject);
1305
1586
  });
1306
1587
  });
1307
1588
  });
@@ -54,13 +54,12 @@
54
54
  <div id="stripe"> </div>
55
55
  <div class="container"> <!-- container -->
56
56
  <div style="background-color: #ad1f2f; padding: 6px; color: white;"> <!-- container -->
57
- <a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> &copy; HL7.org &nbsp;|&nbsp;
58
- <a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] &nbsp;|&nbsp;
59
57
  <a href="/" style="color: gold"> Server Home</a> &nbsp;|&nbsp;
60
58
  <a href="/packages" style="color: gold">Packages Home</a> &nbsp;|&nbsp;
61
59
  <a href="/packages/updates" style="color: gold">Updates</a> &nbsp;|&nbsp;
62
60
  <a href="/packages/broken" style="color: gold">Broken</a> &nbsp;|&nbsp;
63
61
  <a href="/packages/log" style="color: gold">Crawler Log</a> &nbsp;|&nbsp;
62
+ [%feed-check-link%]
64
63
  <a href="/packages/openapi" style="color: gold">API</a>
65
64
  </div> <!-- /container -->
66
65
  </div> <!-- /container -->
@@ -74,7 +73,7 @@
74
73
  <div class="inner-wrapper">
75
74
  <div class="col-9">
76
75
 
77
- <h2><img border="0" src="../FHIRsmith32.png" style="vertical-align: text-bottom"/> [%title%] </h2>
76
+ <h2><a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" title="FHIRsmith [%ver%]"><img border="0" src="/FHIRsmith32.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/></a> [%title%] </h2>
78
77
 
79
78
  [%content%]
80
79
 
@@ -92,8 +91,7 @@
92
91
  <div class="container"> <!-- container -->
93
92
  <div class="inner-wrapper">
94
93
  <p>
95
- <a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> &copy; HL7.org 2011+. &nbsp;|&nbsp;
96
- <a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] &copy; HealthIntersections.com.au 2023+ &nbsp;|&nbsp;
94
+ <a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] &copy; HealthIntersections.com.au 2023+ &nbsp;|&nbsp;
97
95
  Package Registry [%crawler-status%] &nbsp;|&nbsp; [%total-packages%] packages &nbsp;|
98
96
  &nbsp; ([%ms%] ms)
99
97
  [%sponsorMessage%]
@@ -117,10 +115,10 @@
117
115
  ================================================== -->
118
116
  <!-- Placed at the end of the document so the pages load faster -->
119
117
  <!-- <script src="/assets/js/jquery.js"/> -->
120
- <script src="/assets/js/bootstrap.min.js"/>
121
- <script src="/assets/js/respond.min.js"/>
118
+ <script src="/assets/js/bootstrap.min.js"></script>
119
+ <script src="/assets/js/respond.min.js"></script>
122
120
 
123
- <script src="/assets/js/fhir.js"/>
121
+ <script src="/assets/js/fhir.js"></script>
124
122
 
125
123
  <!-- Analytics Below
126
124
  ================================================== -->