fhirsmith 0.18.1 → 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 (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  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 +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
@@ -17,11 +17,16 @@ const Logger = require('../library/logger');
17
17
  const {validateParameter, Utilities} = require("../library/utilities");
18
18
  const {describeCron} = require("../library/cron-utilities");
19
19
  const {tokenMatches, tokenConfigured} = require("../library/request-token");
20
+ const { guarded } = require('../library/sqlite-utilities');
20
21
  const pckLog = Logger.getInstance().child({ module: 'packages' });
21
22
  const packagesOpenApi = require('./openapi');
22
23
  const rateLimit = require('express-rate-limit');
23
24
  const { runNpmCheck, makeGetter } = require('./npm-check');
24
25
  const npmCheckPage = require('./npm-check-page');
26
+ const { FeedCheck } = require('./feed-check');
27
+ const feedCheckPage = require('./feed-check-page');
28
+ const crypto = require('crypto');
29
+ const { NpmSigner, integrityOf } = require('./npm-signing');
25
30
 
26
31
  // Query parameter rules for validateQueryParams. These are also the contract published in
27
32
  // openapi.yaml, and tests/packages/openapi.test.js checks that the two agree - so change
@@ -101,6 +106,25 @@ const NPM_CHECK_DEFAULTS = {
101
106
  };
102
107
  const NPM_CHECK_CONTROL_MAX_AGE = 24 * 60 * 60 * 1000;
103
108
 
109
+ // GET /feed-check: check a package feed as the crawler reads it, storing nothing (also in openapi.yaml)
110
+ const FEED_CHECK_PARAMS = {
111
+ url: { maxLength: 500, pattern: /^https?:\/\/[^\s<>"'`]+$/ },
112
+ // the feed's signing keys, as JSON: package-feeds.json's signing block ({required, keys}), or a
113
+ // whole feed entry with one. Used instead of what the registry lists for the feed
114
+ signing: { maxLength: 5000, pattern: /^[^<>`]*$/ }
115
+ };
116
+ // feed-check defaults; all can be set under modules.packages.feedCheck in the config. Only one
117
+ // check runs at a time, across all users
118
+ const FEED_CHECK_DEFAULTS = {
119
+ enabled: true,
120
+ maxItems: 50, // items checked per run (each one's package is downloaded)
121
+ maxTotalMB: 500, // downloaded per run; later items are reported but not downloaded
122
+ keepMinutes: 60 // how long a run's report can be fetched
123
+ };
124
+ const FEED_CHECK_MAX_JOBS = 100;
125
+ const FEED_CHECK_JOB_PATTERN = /^[0-9a-f]{24}$/;
126
+ const MASTER_LIST_MAX_AGE = 60 * 60 * 1000;
127
+
104
128
  // The query string of a request, for logging (the parameters were validated before the handler runs)
105
129
  function queryString(req) {
106
130
  const i = req.originalUrl.indexOf('?');
@@ -115,6 +139,60 @@ const TARBALL_HEADER = 'FHIR-Package-Download';
115
139
  // Each id#version is stored once (see PackageCrawler.checkVersion)
116
140
  const UNIQUE_ID_VERSION_INDEX = 'CREATE UNIQUE INDEX SK_PackageVersions_IdVersion ON PackageVersions (Id, Version)';
117
141
 
142
+ // Each version's dist.integrity and npm registry signature (see npm-signing.js), kept apart
143
+ // from PackageVersions so reading them doesn't mean reading the tarball (Content)
144
+ const PACKAGE_DIST_TABLE = `CREATE TABLE IF NOT EXISTS PackageDist
145
+ (
146
+ PackageVersionKey INTEGER PRIMARY KEY,
147
+ Integrity TEXT NOT NULL,
148
+ KeyId TEXT,
149
+ Signature TEXT
150
+ )`;
151
+ const DEFAULT_SIGNING_KEY_FILE = 'npm-signing-key.pem';
152
+
153
+ // Publishers' signatures of the packages (fhir:signature in their feeds), kept as they were when
154
+ // the crawler checked them - valid or not - with the outcome (see PackageCrawler.signingFor)
155
+ const PACKAGE_SIGNATURES_TABLE = `CREATE TABLE IF NOT EXISTS PackageSignatures
156
+ (
157
+ PackageVersionKey INTEGER PRIMARY KEY,
158
+ Url TEXT NOT NULL,
159
+ KeyId TEXT,
160
+ Valid INTEGER NOT NULL,
161
+ Message TEXT,
162
+ Checked TEXT NOT NULL,
163
+ Bundle TEXT NOT NULL
164
+ )`;
165
+
166
+ // The signing information a user gives the feed check: package-feeds.json's signing block
167
+ // ({required, keys: [{keyid, key, expires}]}), or a feed entry that has one. Throws if unusable.
168
+ function parseSigningJson(text) {
169
+ let json;
170
+ try {
171
+ json = JSON.parse(text);
172
+ } catch (e) {
173
+ throw new Error(`it isn't JSON (${e.message})`);
174
+ }
175
+ const block = json && typeof json === 'object' && json.signing && typeof json.signing === 'object' ? json.signing : json;
176
+ if (!block || typeof block !== 'object' || !Array.isArray(block.keys) || block.keys.length === 0) {
177
+ throw new Error('it needs a keys array, as in package-feeds.json\'s signing block');
178
+ }
179
+ const keys = block.keys.map((k, i) => {
180
+ if (!k || typeof k.keyid !== 'string' || typeof k.key !== 'string') {
181
+ throw new Error(`keys[${i}] needs a keyid and a key`);
182
+ }
183
+ try {
184
+ crypto.createPublicKey({key: Buffer.from(k.key, 'base64'), format: 'der', type: 'spki'});
185
+ } catch (e) {
186
+ throw new Error(`keys[${i}].key isn't a base64 public key (SubjectPublicKeyInfo)`);
187
+ }
188
+ if (k.expires !== undefined && k.expires !== null && Number.isNaN(Date.parse(k.expires))) {
189
+ throw new Error(`keys[${i}].expires isn't a date`);
190
+ }
191
+ return {keyid: k.keyid, key: k.key, expires: k.expires || null};
192
+ });
193
+ return {required: block.required === true, keys};
194
+ }
195
+
118
196
  class PackagesModule {
119
197
  constructor(stats) {
120
198
  this.router = express.Router();
@@ -126,6 +204,15 @@ class PackagesModule {
126
204
  this.totalRuns = 0;
127
205
  this.lastCrawlerLog = {};
128
206
  this.crawlerRunning = false;
207
+ // npm registry signatures: the key (null when this server doesn't sign), and the
208
+ // background job that fills in PackageDist for versions stored without it
209
+ this.signer = null;
210
+ // feed checks (/feed-check): id -> {id, url, state, report, error, started, ip}
211
+ this.feedCheckJobs = new Map();
212
+ this.feedCheckRunning = false;
213
+ this.distStatus = { state: 'not started', missing: null, done: 0, failed: 0 };
214
+ this.distBackfill = null;
215
+ this.stopping = false;
129
216
  this.setupSecurityMiddleware();
130
217
  this.setupRoutes();
131
218
  this.stats = stats;
@@ -365,7 +452,7 @@ class PackagesModule {
365
452
 
366
453
  const { query, params: queryParams } = this.buildSecureQuery(baseQuery, conditions);
367
454
 
368
- this.db.all(query + ' ORDER BY PubDate', queryParams, (err, rows) => {
455
+ this.db.all(query + ' ORDER BY PubDate', queryParams, guarded(reject, (err, rows) => {
369
456
  if (err) {
370
457
  reject(err);
371
458
  return;
@@ -399,7 +486,7 @@ class PackagesModule {
399
486
  });
400
487
 
401
488
  resolve(this.applySorting(results, sort));
402
- });
489
+ }));
403
490
  } catch (error) {
404
491
  reject(error);
405
492
  }
@@ -503,6 +590,14 @@ class PackagesModule {
503
590
  }
504
591
 
505
592
  async gatherPackageStatistics() {
593
+ const stats = await this.gatherPackageCounts();
594
+ // the nav bar offers the feed check only where it's turned on
595
+ stats.feedCheckLink = this.feedCheckConfig().enabled
596
+ ? '<a href="/packages/feed-check" style="color: gold">Feed Check</a> &nbsp;|&nbsp;' : '';
597
+ return stats;
598
+ }
599
+
600
+ async gatherPackageCounts() {
506
601
  try {
507
602
  // Get database age info
508
603
  const dbAge = this.getDatabaseAgeInfo();
@@ -577,7 +672,7 @@ class PackagesModule {
577
672
  }
578
673
 
579
674
  async getDatabaseTableCounts() {
580
- return new Promise((resolve) => {
675
+ return new Promise((resolve, reject) => {
581
676
  if (!this.db) {
582
677
  resolve({packages: 0, packageVersions: 0});
583
678
  return;
@@ -587,7 +682,7 @@ class PackagesModule {
587
682
  let completedQueries = 0;
588
683
  const totalQueries = 2;
589
684
 
590
- this.db.get('SELECT COUNT(*) as count FROM Packages', [], (err, row) => {
685
+ this.db.get('SELECT COUNT(*) as count FROM Packages', [], guarded(reject, (err, row) => {
591
686
  if (err) {
592
687
  counts.packages = 0;
593
688
  } else {
@@ -598,9 +693,9 @@ class PackagesModule {
598
693
  if (completedQueries === totalQueries) {
599
694
  resolve(counts);
600
695
  }
601
- });
696
+ }));
602
697
 
603
- this.db.get('SELECT COUNT(*) as count FROM PackageVersions', [], (err, row) => {
698
+ this.db.get('SELECT COUNT(*) as count FROM PackageVersions', [], guarded(reject, (err, row) => {
604
699
  if (err) {
605
700
  counts.packageVersions = 0;
606
701
  } else {
@@ -611,7 +706,7 @@ class PackagesModule {
611
706
  if (completedQueries === totalQueries) {
612
707
  resolve(counts);
613
708
  }
614
- });
709
+ }));
615
710
  });
616
711
  }
617
712
 
@@ -673,12 +768,25 @@ class PackagesModule {
673
768
 
674
769
  // Initialize database
675
770
  await this.initializeDatabase();
771
+ await this.dbRun(PACKAGE_DIST_TABLE);
772
+ await this.dbRun(PACKAGE_SIGNATURES_TABLE);
676
773
 
677
774
  // Ensure mirror directory exists
678
775
  await this.ensureMirrorDirectory();
679
776
 
777
+ // npm registry signatures: on unless turned off
778
+ if (this.config.npmSigning?.enabled !== false) {
779
+ this.signer = NpmSigner.load(this.getSigningKeyPath(), pckLog);
780
+ pckLog.info(`Signing packages with npm key ${this.signer.keyid}`);
781
+ }
782
+
680
783
  // Initialize the crawler
681
784
  this.crawler = new PackageCrawler(this.config, this.db, this.stats);
785
+ this.crawler.signer = this.signer;
786
+
787
+ // Versions stored without dist.integrity or a signature by this key get them in the
788
+ // background; /-/npm/v1/keys is a 404 until they all have them
789
+ this.startDistBackfill();
682
790
 
683
791
  // Start the hourly web crawler if enabled
684
792
  if (config.crawler.enabled) {
@@ -712,9 +820,137 @@ class PackagesModule {
712
820
  }
713
821
  } finally {
714
822
  this.crawlerRunning = false;
823
+ // if the backfill couldn't finish (a crawl's transaction rolled back one of its
824
+ // writes, say), it tries again
825
+ if (this.distStatus.state === 'incomplete') {
826
+ this.startDistBackfill();
827
+ }
715
828
  }
716
829
  }
717
830
 
831
+ // The signing key file: npmSigning.keyFile, relative to the database's folder
832
+ getSigningKeyPath() {
833
+ const file = this.config.npmSigning?.keyFile || DEFAULT_SIGNING_KEY_FILE;
834
+ return path.isAbsolute(file) ? file : path.join(path.dirname(this.getDatabasePath()), file);
835
+ }
836
+
837
+ dbRun(sql, params = []) {
838
+ return new Promise((resolve, reject) => {
839
+ this.db.run(sql, params, guarded(reject, function (err) {
840
+ if (err) {
841
+ reject(err);
842
+ } else {
843
+ resolve(this);
844
+ }
845
+ }));
846
+ });
847
+ }
848
+
849
+ dbAll(sql, params = []) {
850
+ return new Promise((resolve, reject) => {
851
+ this.db.all(sql, params, guarded(reject, (err, rows) => err ? reject(err) : resolve(rows)));
852
+ });
853
+ }
854
+
855
+ dbGet(sql, params = []) {
856
+ return new Promise((resolve, reject) => {
857
+ this.db.get(sql, params, guarded(reject, (err, row) => err ? reject(err) : resolve(row)));
858
+ });
859
+ }
860
+
861
+ // Starts the background job that fills in PackageDist, unless it is already running.
862
+ // Returns its promise.
863
+ startDistBackfill() {
864
+ if (!this.distBackfill) {
865
+ this.distBackfill = this.runDistBackfill()
866
+ .catch(e => {
867
+ this.distStatus.state = 'incomplete';
868
+ pckLog.error('Adding npm integrity and signatures failed: ' + e.message);
869
+ })
870
+ .finally(() => {
871
+ this.distBackfill = null;
872
+ });
873
+ }
874
+ return this.distBackfill;
875
+ }
876
+
877
+ // The keys of versions with no PackageDist row, or (when signing) one not signed by this key
878
+ async versionsMissingDist() {
879
+ const sql = `SELECT v.PackageVersionKey AS k, d.Integrity AS Integrity
880
+ FROM PackageVersions v LEFT JOIN PackageDist d ON d.PackageVersionKey = v.PackageVersionKey
881
+ WHERE d.PackageVersionKey IS NULL` + (this.signer ? ' OR d.KeyId IS NOT ?' : '') + `
882
+ ORDER BY v.PackageVersionKey`;
883
+ return this.dbAll(sql, this.signer ? [this.signer.keyid] : []);
884
+ }
885
+
886
+ // Gives every version its dist.integrity, and its signature when this server signs: the
887
+ // versions stored before this server did, or signed with an earlier key. A row at a time,
888
+ // in the background, so the server serves meanwhile; a large tarball is hashed in slices.
889
+ async runDistBackfill() {
890
+ // rows for versions that are gone (packages/utilities/dedupe-versions.js deletes versions)
891
+ await this.dbRun('DELETE FROM PackageDist WHERE PackageVersionKey NOT IN (SELECT PackageVersionKey FROM PackageVersions)');
892
+ await this.dbRun('DELETE FROM PackageSignatures WHERE PackageVersionKey NOT IN (SELECT PackageVersionKey FROM PackageVersions)');
893
+ const todo = await this.versionsMissingDist();
894
+ this.distStatus = { state: 'running', missing: todo.length, done: 0, failed: 0 };
895
+ const what = this.signer ? 'npm integrity and signatures' : 'npm integrity';
896
+ if (todo.length > 0) {
897
+ pckLog.info(`Adding ${what} to ${todo.length} package versions, in the background`);
898
+ }
899
+ for (const row of todo) {
900
+ if (this.stopping) {
901
+ this.distStatus.state = 'stopped';
902
+ return;
903
+ }
904
+ try {
905
+ await this.fillDist(row.k, row.Integrity);
906
+ this.distStatus.done++;
907
+ } catch (e) {
908
+ this.distStatus.failed++;
909
+ pckLog.error(`Adding ${what} to package version ${row.k} failed: ${e.message}`);
910
+ }
911
+ if (this.distStatus.done % 1000 === 0 && this.distStatus.done > 0) {
912
+ pckLog.info(`Added ${what} to ${this.distStatus.done} of ${todo.length} package versions`);
913
+ }
914
+ await new Promise(resolve => setImmediate(resolve));
915
+ }
916
+ // Something stored meanwhile is done by the crawler, but a write here can be lost (it
917
+ // joins a crawl's transaction, and that rolls back), so count again
918
+ const left = todo.length === 0 ? 0 : (await this.versionsMissingDist()).length;
919
+ this.distStatus.missing = left;
920
+ this.distStatus.state = left === 0 ? 'complete' : 'incomplete';
921
+ if (todo.length > 0) {
922
+ if (left === 0) {
923
+ pckLog.info(`Added ${what} to every package version`);
924
+ } else {
925
+ pckLog.warn(`${left} package versions still have no ${what}; trying again after the next crawl`);
926
+ }
927
+ }
928
+ }
929
+
930
+ // integrity is the stored one, when only the signature is missing
931
+ async fillDist(key, integrity) {
932
+ let hash = null;
933
+ if (!integrity) {
934
+ const row = await this.dbGet('SELECT Content, Hash FROM PackageVersions WHERE PackageVersionKey = ?', [key]);
935
+ if (!row) {
936
+ return; // deleted meanwhile
937
+ }
938
+ integrity = await integrityOf(row.Content);
939
+ hash = row.Hash;
940
+ }
941
+ const v = await this.dbGet('SELECT Id, Version, Hash FROM PackageVersions WHERE PackageVersionKey = ?', [key]);
942
+ if (!v) {
943
+ return;
944
+ }
945
+ const keyid = this.signer ? this.signer.keyid : null;
946
+ const sig = this.signer ? this.signer.sign(v.Id, v.Version, integrity) : null;
947
+ // Only if the tarball is still the one hashed: the crawler may have replaced it meanwhile
948
+ // (and then it wrote this row itself)
949
+ await this.dbRun(`INSERT OR REPLACE INTO PackageDist (PackageVersionKey, Integrity, KeyId, Signature)
950
+ SELECT ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM PackageVersions WHERE PackageVersionKey = ? AND Hash = ?)`,
951
+ [key, integrity, keyid, sig, key, hash || v.Hash]);
952
+ }
953
+
718
954
  async initializeDatabase() {
719
955
  return new Promise((resolve, reject) => {
720
956
  // Use config path if absolute, otherwise resolve relative to data dir
@@ -744,14 +980,14 @@ class PackagesModule {
744
980
  this.db.run(`CREATE TABLE IF NOT EXISTS FeedPages (
745
981
  Url TEXT PRIMARY KEY,
746
982
  VisitedAt TEXT NOT NULL
747
- )`, (err) => {
983
+ )`, guarded(reject, (err) => {
748
984
  if (err) pckLog.error('Failed to create FeedPages table:', err.message);
749
- });
985
+ }));
750
986
  // Fails while the database still holds duplicate id#version rows (from before the
751
987
  // crawler checked for them): packages/utilities/dedupe-versions.js removes them
752
- this.db.run(UNIQUE_ID_VERSION_INDEX.replace('CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX IF NOT EXISTS'), (err) => {
988
+ this.db.run(UNIQUE_ID_VERSION_INDEX.replace('CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX IF NOT EXISTS'), guarded(reject, (err) => {
753
989
  if (err) pckLog.warn('PackageVersions has duplicate id#version rows, so they are not yet unique; run packages/utilities/dedupe-versions.js: ' + err.message);
754
- });
990
+ }));
755
991
  resolve();
756
992
  }
757
993
  }
@@ -834,7 +1070,13 @@ class PackagesModule {
834
1070
  (
835
1071
  Url TEXT PRIMARY KEY,
836
1072
  VisitedAt TEXT NOT NULL
837
- )`
1073
+ )`,
1074
+
1075
+ // PackageDist table - each version's dist.integrity and npm signature
1076
+ PACKAGE_DIST_TABLE,
1077
+
1078
+ // PackageSignatures table - the publisher's signature of each version, as checked
1079
+ PACKAGE_SIGNATURES_TABLE
838
1080
  ];
839
1081
 
840
1082
  const indexes = [
@@ -884,13 +1126,13 @@ class PackagesModule {
884
1126
 
885
1127
  // Create indexes
886
1128
  indexes.forEach(sql => {
887
- this.db.run(sql, (err) => {
1129
+ this.db.run(sql, guarded(reject, (err) => {
888
1130
  if (err) {
889
1131
  handleIndexError(err);
890
1132
  } else {
891
1133
  checkIndexesComplete();
892
1134
  }
893
- });
1135
+ }));
894
1136
  });
895
1137
  };
896
1138
 
@@ -901,13 +1143,13 @@ class PackagesModule {
901
1143
 
902
1144
  // Create tables first
903
1145
  tables.forEach(sql => {
904
- this.db.run(sql, (err) => {
1146
+ this.db.run(sql, guarded(reject, (err) => {
905
1147
  if (err) {
906
1148
  handleTableError(err);
907
1149
  } else {
908
1150
  checkTablesComplete();
909
1151
  }
910
- });
1152
+ }));
911
1153
  });
912
1154
  });
913
1155
  }
@@ -1120,6 +1362,34 @@ class PackagesModule {
1120
1362
  }
1121
1363
  });
1122
1364
 
1365
+ // GET /packages/feed-check - check a package feed as the crawler reads it, storing nothing.
1366
+ // With url, starts a check and redirects to its report; the report page reloads until done.
1367
+ // One check at a time, across all users.
1368
+ this.router.get('/feed-check', this.validateQueryParams(FEED_CHECK_PARAMS), async (req, res) => {
1369
+ const start = Date.now();
1370
+ try {
1371
+ await this.serveFeedCheck(req, res);
1372
+ } catch (error) {
1373
+ pckLog.error('Error in /packages/feed-check:', error);
1374
+ res.status(500).json({error: 'Internal server error'});
1375
+ } finally {
1376
+ this.stats.countRequest('feed-check', Date.now() - start);
1377
+ }
1378
+ });
1379
+
1380
+ // GET /packages/feed-check/{run} - a feed check's report (while it runs, what it has so far)
1381
+ this.router.get('/feed-check/:run', async (req, res) => {
1382
+ const start = Date.now();
1383
+ try {
1384
+ await this.serveFeedCheckRun(req, res);
1385
+ } catch (error) {
1386
+ pckLog.error('Error in /packages/feed-check/{run}:', error);
1387
+ res.status(500).json({error: 'Internal server error'});
1388
+ } finally {
1389
+ this.stats.countRequest('feed-check', Date.now() - start);
1390
+ }
1391
+ });
1392
+
1123
1393
  // GET /packages/catalog - Search packages or get updates
1124
1394
  this.router.get('/catalog', this.validateQueryParams(searchParams), async (req, res) => {
1125
1395
  const start = Date.now();
@@ -1136,6 +1406,56 @@ class PackagesModule {
1136
1406
  }
1137
1407
  });
1138
1408
 
1409
+ // GET /packages/-/npm/v1/keys - the key the registry signs packages with, as npm's
1410
+ // registry does (npm audit signatures). A 404 says this registry doesn't sign, which is
1411
+ // also the answer while versions are still being signed: once there are keys, npm reports
1412
+ // a version with no signature as an error.
1413
+ this.router.get('/-/npm/v1/keys', (req, res) => {
1414
+ const start = Date.now();
1415
+ try {
1416
+ if (!this.signer) {
1417
+ res.status(404).json({error: 'This registry does not sign packages'});
1418
+ } else if (this.distStatus.state !== 'complete') {
1419
+ res.status(404).json({error: 'This registry is still signing the packages it holds; try again later'});
1420
+ } else {
1421
+ res.json(this.signer.keysDocument());
1422
+ }
1423
+ } finally {
1424
+ this.stats.countRequest('keys', Date.now() - start);
1425
+ }
1426
+ });
1427
+
1428
+ // GET /packages/-/fhir/v1/signatures/{name}@{version} - the publisher's signature of a
1429
+ // package version (its {package}.tgz.sigstore.json), exactly as the crawler fetched and
1430
+ // checked it. dist['fhir-signature'] in the package document links it, with the outcome.
1431
+ this.router.get('/-/fhir/v1/signatures/:spec', async (req, res) => {
1432
+ const start = Date.now();
1433
+ try {
1434
+ const spec = req.params.spec || '';
1435
+ const at = spec.lastIndexOf('@');
1436
+ const id = at > 0 ? spec.substring(0, at) : '';
1437
+ const version = at > 0 ? spec.substring(at + 1) : '';
1438
+ if (!PACKAGE_ID_PATTERN.test(id) || !PACKAGE_VERSION_PATTERN.test(version) ||
1439
+ id.length > PACKAGE_ID_MAX_LENGTH || version.length > PACKAGE_VERSION_MAX_LENGTH) {
1440
+ res.status(400).json({error: 'Expected {name}@{version}'});
1441
+ return;
1442
+ }
1443
+ const row = await this.dbGet(`SELECT s.Bundle FROM PackageSignatures s
1444
+ JOIN PackageVersions v ON v.PackageVersionKey = s.PackageVersionKey
1445
+ WHERE v.Id = ? AND v.Version = ?`, [id, version]);
1446
+ if (!row) {
1447
+ res.status(404).json({error: `No signature is held for ${id}#${version}`});
1448
+ return;
1449
+ }
1450
+ res.type('application/json').send(row.Bundle);
1451
+ } catch (error) {
1452
+ pckLog.error('Error in /packages/-/fhir/v1/signatures:', error);
1453
+ res.status(500).json({error: 'Internal server error'});
1454
+ } finally {
1455
+ this.stats.countRequest('signature', Date.now() - start);
1456
+ }
1457
+ });
1458
+
1139
1459
  // GET /packages/-/v1/search - Search packages (v1 API)
1140
1460
  this.router.get('/-/v1/search', this.validateQueryParams(V1_SEARCH_PARAMS), async (req, res) => {
1141
1461
  const start = Date.now();
@@ -1359,7 +1679,7 @@ class PackagesModule {
1359
1679
  // Don't process routes that are handled elsewhere. These must call next():
1360
1680
  // returning without responding leaves the request hanging, which is what
1361
1681
  // /status, /stats and /search (registered after this route) used to do.
1362
- if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates', 'npm-check'].includes(id) ||
1682
+ if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates', 'npm-check', 'feed-check'].includes(id) ||
1363
1683
  id.endsWith('.html') || id === '-') {
1364
1684
  next();
1365
1685
  return;
@@ -1617,6 +1937,8 @@ class PackagesModule {
1617
1937
  htmlServer.loadTemplate('packages', templatePath);
1618
1938
  }
1619
1939
 
1940
+ await this.annotateSignatures(updates, u => u.name, u => u.version);
1941
+
1620
1942
  // Build template variables
1621
1943
  const vars = {
1622
1944
  fromDate: fromDate.split('T')[0], // Just the date part
@@ -1644,6 +1966,37 @@ class PackagesModule {
1644
1966
  }
1645
1967
  }
1646
1968
 
1969
+ // Sets signatureState on each item of a list for an HTML page: its PackageSignatures row (the
1970
+ // publisher's signature, as the crawler checked it), or null when there is none
1971
+ async annotateSignatures(list, idOf, versionOf) {
1972
+ const ids = [...new Set(list.map(idOf).filter(Boolean))];
1973
+ const found = new Map();
1974
+ for (let i = 0; i < ids.length; i += 500) {
1975
+ const chunk = ids.slice(i, i + 500);
1976
+ const rows = await this.dbAll(`SELECT v.Id, v.Version, s.Valid, s.KeyId, s.Message, s.Checked
1977
+ FROM PackageSignatures s JOIN PackageVersions v ON v.PackageVersionKey = s.PackageVersionKey
1978
+ WHERE v.Id IN (${chunk.map(() => '?').join(',')})`, chunk);
1979
+ for (const r of rows) {
1980
+ found.set(`${r.Id}#${r.Version}`, r);
1981
+ }
1982
+ }
1983
+ for (const item of list) {
1984
+ item.signatureState = found.get(`${idOf(item)}#${versionOf(item)}`) || null;
1985
+ }
1986
+ }
1987
+
1988
+ // The Signature column: confirmed (the publisher's signature checked out), rejected (it didn't),
1989
+ // or absent (unsigned, or not checked: the feed lists no keys, or signatures are ignored)
1990
+ signatureCell(sig) {
1991
+ if (!sig) {
1992
+ return '<td class="text-muted">absent</td>';
1993
+ }
1994
+ if (sig.Valid) {
1995
+ return `<td title="${escape(`Signed with ${sig.KeyId}; checked ${sig.Checked} UTC`)}"><span class="text-success">confirmed</span></td>`;
1996
+ }
1997
+ return `<td title="${escape(`${sig.Message || 'The signature is not valid'}; checked ${sig.Checked} UTC`)}"><span class="text-danger">rejected</span></td>`;
1998
+ }
1999
+
1647
2000
  generateUpdatesTable(updates) {
1648
2001
  if (updates.length === 0) {
1649
2002
  return '<div class="alert alert-info">No package updates found for the specified time period.</div>';
@@ -1657,6 +2010,7 @@ class PackagesModule {
1657
2010
  table += '<th>Type</th>';
1658
2011
  table += '<th>Published</th>';
1659
2012
  table += '<th>Canonical</th>';
2013
+ table += '<th>Signature</th>';
1660
2014
  table += '</tr></thead><tbody>';
1661
2015
 
1662
2016
  for (const pkg of updates) {
@@ -1667,6 +2021,7 @@ class PackagesModule {
1667
2021
  table += `<td>${escape(pkg.kind)}</td>`;
1668
2022
  table += `<td>${new Date(pkg.date).toLocaleDateString()} ${new Date(pkg.date).toLocaleTimeString()}</td>`;
1669
2023
  table += `<td>${escape(pkg.canonical || '')}</td>`;
2024
+ table += this.signatureCell(pkg.signatureState);
1670
2025
  table += '</tr>';
1671
2026
  }
1672
2027
 
@@ -1683,14 +2038,14 @@ class PackagesModule {
1683
2038
 
1684
2039
  content += '<form method="GET" action="/packages/updates">';
1685
2040
 
1686
- content += `<input type="radio" name="dateType" id="dateType" value="relative" ${formData.dt == 'relative' ? 'checked' : ''}> `;
1687
- content += '<label for="relativeDays">Last</label> ';
1688
- content += `<input type="number" name="daysValue" value="${formData.days}" min="1" max="365" style="width: 80px; margin: 0 5px;"> `;
2041
+ content += `<input type="radio" aria-label="Is Relative?" name="dateType" id="dateType" value="relative" ${formData.dt == 'relative' ? 'checked' : ''}> `;
2042
+ content += '<label for="daysValue">Last</label> ';
2043
+ content += `<input type="number" id="daysValue" aria-label="Number of Days" name="daysValue" value="${formData.days}" min="1" max="365" style="width: 80px; margin: 0 5px;"> `;
1689
2044
  content += '<label>days</label> &nbsp;&nbsp;';
1690
2045
 
1691
- content += `<input type="radio" name="dateType" id="dateType" value="absolute" ${formData.dt != 'relative' ? 'checked' : ''}> `;
1692
- content += '<label for="specificDate">Since date:</label> ';
1693
- content += `<input type="date" name="dateValue" value="${formData.date}" style="margin-left: 5px;"> `;
2046
+ content += `<input type="radio" name="dateType" aria-label="Is Date?" id="dateType" value="absolute" ${formData.dt != 'relative' ? 'checked' : ''}> `;
2047
+ content += '<label for="dateValue">Since date:</label> ';
2048
+ content += `<input type="date" name="dateValue" id="dateValue" aria-label="Date" value="${formData.date}" style="margin-left: 5px;"> `;
1694
2049
  content += '<button type="submit" class="btn btn-primary btn-sm" style="margin-left: 10px;">Update Results</button>';
1695
2050
 
1696
2051
  content += '</form>';
@@ -1718,13 +2073,13 @@ class PackagesModule {
1718
2073
  WHERE PubDate >= ?
1719
2074
  ORDER BY PubDate DESC`;
1720
2075
 
1721
- this.db.all(sql, [date], (err, rows) => {
2076
+ this.db.all(sql, [date], guarded(reject, (err, rows) => {
1722
2077
  if (err) {
1723
2078
  reject(err);
1724
2079
  } else {
1725
2080
  resolve(rows);
1726
2081
  }
1727
- });
2082
+ }));
1728
2083
  });
1729
2084
  }
1730
2085
 
@@ -1831,6 +2186,140 @@ class PackagesModule {
1831
2186
  res.json(report);
1832
2187
  }
1833
2188
 
2189
+ feedCheckConfig() {
2190
+ return {...FEED_CHECK_DEFAULTS, ...(this.config?.feedCheck || {})};
2191
+ }
2192
+
2193
+ // The registry's feed list (package-feeds.json), for the feed check; fetched at most hourly
2194
+ async feedCheckMasterList() {
2195
+ if (this.masterListCache && Date.now() - this.masterListCache.at < MASTER_LIST_MAX_AGE) {
2196
+ return this.masterListCache.data;
2197
+ }
2198
+ const reader = Object.create(PackageCrawler.prototype);
2199
+ reader.config = this.config;
2200
+ const data = await reader.fetchJson(this.config.masterUrl);
2201
+ this.masterListCache = {at: Date.now(), data};
2202
+ return data;
2203
+ }
2204
+
2205
+ // Forgets runs older than keepMinutes, and the oldest beyond FEED_CHECK_MAX_JOBS
2206
+ pruneFeedCheckJobs() {
2207
+ const keep = this.feedCheckConfig().keepMinutes * 60 * 1000;
2208
+ for (const [id, job] of this.feedCheckJobs) {
2209
+ if (job.state !== 'running' && Date.now() - job.started > keep) {
2210
+ this.feedCheckJobs.delete(id);
2211
+ }
2212
+ }
2213
+ while (this.feedCheckJobs.size > FEED_CHECK_MAX_JOBS) {
2214
+ this.feedCheckJobs.delete(this.feedCheckJobs.keys().next().value);
2215
+ }
2216
+ }
2217
+
2218
+ async serveFeedCheck(req, res) {
2219
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
2220
+ const cfg = this.feedCheckConfig();
2221
+ const fail = (status, message) => acceptsHtml
2222
+ ? this.sendFeedCheckPage(res, status, `<div class="alert alert-danger">${escape(message)}</div>` + feedCheckPage.renderForm(req.query))
2223
+ : res.status(status).json({error: message});
2224
+
2225
+ if (!cfg.enabled) {
2226
+ return fail(404, 'The feed check is not enabled on this server');
2227
+ }
2228
+ const target = req.query.url;
2229
+ if (!target) {
2230
+ if (acceptsHtml) {
2231
+ return this.sendFeedCheckPage(res, 200, feedCheckPage.renderForm({}));
2232
+ }
2233
+ return res.status(400).json({error: 'Parameter url is required'});
2234
+ }
2235
+ if (typeof target !== 'string') {
2236
+ return fail(400, 'Give the feed URL once');
2237
+ }
2238
+ let url;
2239
+ try {
2240
+ url = new URL(target);
2241
+ } catch (e) {
2242
+ return fail(400, `The URL isn't valid: ${target}`);
2243
+ }
2244
+ // http(s) only: the crawler also reads local files, which a user must never reach
2245
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
2246
+ return fail(400, 'The feed URL must be http(s), with no user name or password');
2247
+ }
2248
+ let signing = null;
2249
+ if (req.query.signing && String(req.query.signing).trim()) {
2250
+ try {
2251
+ signing = parseSigningJson(String(req.query.signing));
2252
+ } catch (e) {
2253
+ return fail(400, `The signing information isn't usable: ${e.message}`);
2254
+ }
2255
+ }
2256
+ if (this.feedCheckRunning) {
2257
+ res.setHeader('Retry-After', '60');
2258
+ return fail(503, 'A feed check is already running (only one runs at a time); try again in a minute');
2259
+ }
2260
+
2261
+ this.pruneFeedCheckJobs();
2262
+ const job = {id: crypto.randomBytes(12).toString('hex'), url: target, state: 'running', report: null, error: null, started: Date.now()};
2263
+ this.feedCheckJobs.set(job.id, job);
2264
+ this.feedCheckRunning = true;
2265
+ const check = new FeedCheck(this.config, {
2266
+ master: () => this.feedCheckMasterList(),
2267
+ held: (id, version) => this.dbGet('SELECT Hash AS hash FROM PackageVersions WHERE Id = ? AND Version = ?', [id, version]),
2268
+ signing,
2269
+ maxItems: cfg.maxItems,
2270
+ maxTotalMB: cfg.maxTotalMB
2271
+ });
2272
+ check.run(target, report => {
2273
+ job.report = report;
2274
+ }).then(report => {
2275
+ job.report = report;
2276
+ job.state = 'done';
2277
+ const errors = report.items.filter(i => i.status === 'error').length;
2278
+ pckLog.info(`feed-check ${target}: ${report.items.length} items, ${errors} with errors, ${report.errors.length} feed errors`);
2279
+ }).catch(e => {
2280
+ job.state = 'failed';
2281
+ job.error = e.message;
2282
+ pckLog.error(`feed-check ${target} failed: ${e.message}`);
2283
+ }).finally(() => {
2284
+ this.feedCheckRunning = false;
2285
+ });
2286
+
2287
+ const where = `/packages/feed-check/${job.id}`;
2288
+ if (acceptsHtml) {
2289
+ return res.redirect(303, where);
2290
+ }
2291
+ res.status(202).setHeader('Location', where);
2292
+ return res.json({id: job.id, state: job.state, report: where});
2293
+ }
2294
+
2295
+ async serveFeedCheckRun(req, res) {
2296
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
2297
+ const id = req.params.run;
2298
+ const job = FEED_CHECK_JOB_PATTERN.test(id || '') ? this.feedCheckJobs.get(id) : null;
2299
+ if (!job) {
2300
+ const message = 'There is no feed check with that id (reports are kept for a while after the check)';
2301
+ return acceptsHtml
2302
+ ? this.sendFeedCheckPage(res, 404, `<div class="alert alert-info">${escape(message)}</div>` + feedCheckPage.renderForm({}))
2303
+ : res.status(404).json({error: message});
2304
+ }
2305
+ if (acceptsHtml) {
2306
+ if (job.state === 'running') {
2307
+ res.setHeader('Refresh', '5');
2308
+ }
2309
+ return this.sendFeedCheckPage(res, 200, feedCheckPage.renderJob(job));
2310
+ }
2311
+ return res.json({id: job.id, url: job.url, state: job.state, error: job.error || undefined, report: job.report});
2312
+ }
2313
+
2314
+ async sendFeedCheckPage(res, status, content) {
2315
+ if (!htmlServer.hasTemplate('packages')) {
2316
+ htmlServer.loadTemplate('packages', path.join(__dirname, 'packages-template.html'));
2317
+ }
2318
+ const stats = await this.gatherPackageStatistics();
2319
+ res.status(status).setHeader('Content-Type', 'text/html');
2320
+ res.send(htmlServer.renderPage('packages', 'Package Feed Check', content, stats));
2321
+ }
2322
+
1834
2323
  async sendNpmCheckPage(res, status, content) {
1835
2324
  if (!htmlServer.hasTemplate('packages')) {
1836
2325
  htmlServer.loadTemplate('packages', path.join(__dirname, 'packages-template.html'));
@@ -1964,13 +2453,13 @@ class PackagesModule {
1964
2453
  ? [id, version]
1965
2454
  : [id, `${version}-%`];
1966
2455
 
1967
- this.db.get(sql, params, (err, row) => {
2456
+ this.db.get(sql, params, guarded(reject, (err, row) => {
1968
2457
  if (err) {
1969
2458
  reject(err);
1970
2459
  } else {
1971
2460
  resolve(row || null);
1972
2461
  }
1973
- });
2462
+ }));
1974
2463
  });
1975
2464
  }
1976
2465
 
@@ -2012,10 +2501,10 @@ class PackagesModule {
2012
2501
  this.db.run(
2013
2502
  'UPDATE PackageVersions SET DownloadCount = DownloadCount + 1 WHERE PackageVersionKey = ?',
2014
2503
  [packageVersionKey],
2015
- (err) => {
2504
+ guarded(reject, (err) => {
2016
2505
  if (err) reject(err);
2017
2506
  else resolve();
2018
- }
2507
+ })
2019
2508
  );
2020
2509
  });
2021
2510
 
@@ -2024,10 +2513,10 @@ class PackagesModule {
2024
2513
  this.db.run(
2025
2514
  'UPDATE Packages SET DownloadCount = DownloadCount + 1 WHERE Id = ?',
2026
2515
  [packageId],
2027
- (err) => {
2516
+ guarded(reject, (err) => {
2028
2517
  if (err) reject(err);
2029
2518
  else resolve();
2030
- }
2519
+ })
2031
2520
  );
2032
2521
  });
2033
2522
 
@@ -2075,7 +2564,7 @@ class PackagesModule {
2075
2564
 
2076
2565
  async getPackageVersions(id) {
2077
2566
  return new Promise((resolve, reject) => {
2078
- const sql = `SELECT PackageVersionKey,
2567
+ const sql = `SELECT v.PackageVersionKey,
2079
2568
  Version,
2080
2569
  PubDate,
2081
2570
  FhirVersions,
@@ -2087,18 +2576,24 @@ class PackagesModule {
2087
2576
  License,
2088
2577
  Hash,
2089
2578
  GUID,
2090
- Description
2091
- FROM PackageVersions
2579
+ Description,
2580
+ d.Integrity,
2581
+ d.KeyId,
2582
+ d.Signature,
2583
+ s.Valid AS PublisherSigValid,
2584
+ s.KeyId AS PublisherSigKeyId
2585
+ FROM PackageVersions v LEFT JOIN PackageDist d ON d.PackageVersionKey = v.PackageVersionKey
2586
+ LEFT JOIN PackageSignatures s ON s.PackageVersionKey = v.PackageVersionKey
2092
2587
  WHERE Id = ?
2093
2588
  ORDER BY PubDate DESC`; // Changed from ASC to DESC for most recent first
2094
2589
 
2095
- this.db.all(sql, [id], (err, rows) => {
2590
+ this.db.all(sql, [id], guarded(reject, (err, rows) => {
2096
2591
  if (err) {
2097
2592
  reject(err);
2098
2593
  } else {
2099
2594
  resolve(rows);
2100
2595
  }
2101
- });
2596
+ }));
2102
2597
  });
2103
2598
  }
2104
2599
 
@@ -2111,7 +2606,7 @@ class PackagesModule {
2111
2606
  FROM PackageDependencies
2112
2607
  WHERE PackageVersionKey IN (${placeholders})`;
2113
2608
 
2114
- this.db.all(sql, packageVersionKeys, (err, rows) => {
2609
+ this.db.all(sql, packageVersionKeys, guarded(reject, (err, rows) => {
2115
2610
  if (err) {
2116
2611
  reject(err);
2117
2612
  } else {
@@ -2132,7 +2627,7 @@ class PackagesModule {
2132
2627
  }
2133
2628
  resolve(deps);
2134
2629
  }
2135
- });
2630
+ }));
2136
2631
  });
2137
2632
  }
2138
2633
 
@@ -2179,6 +2674,23 @@ class PackagesModule {
2179
2674
  tarball: this.buildTarballUrl(id, pv.Version, secure, req)
2180
2675
  }
2181
2676
  };
2677
+ if (pv.Integrity) {
2678
+ versionObj.dist.integrity = pv.Integrity;
2679
+ }
2680
+ if (this.signer && pv.Signature && pv.KeyId === this.signer.keyid) {
2681
+ versionObj.dist.signatures = [{keyid: pv.KeyId, sig: pv.Signature}];
2682
+ }
2683
+ // The publisher's signature, as the crawler checked it. Not dist.attestations: npm would
2684
+ // try to verify these as Sigstore attestations, and can't (see readme)
2685
+ if (pv.PublisherSigValid !== null && pv.PublisherSigValid !== undefined) {
2686
+ versionObj.dist['fhir-signature'] = {
2687
+ url: this.buildSignatureUrl(id, pv.Version, secure, req),
2688
+ status: pv.PublisherSigValid ? 'confirmed' : 'rejected'
2689
+ };
2690
+ if (pv.PublisherSigKeyId) {
2691
+ versionObj.dist['fhir-signature'].keyid = pv.PublisherSigKeyId;
2692
+ }
2693
+ }
2182
2694
 
2183
2695
  // Add optional fields
2184
2696
  if (pv.HomePage) {
@@ -2221,6 +2733,14 @@ class PackagesModule {
2221
2733
  return registry;
2222
2734
  }
2223
2735
 
2736
+ // Where the publisher's signature of a version is served: npm's form of name@version
2737
+ buildSignatureUrl(id, version, secure, req) {
2738
+ const spec = encodeURIComponent(id).replace(/^%40/, '@') + '@' + encodeURIComponent(version);
2739
+ const base = this.config.baseUrl ? this.config.baseUrl.replace(/\/+$/, '')
2740
+ : `${secure ? 'https' : 'http'}://${(req && req.get && req.get('host')) || 'localhost:3000'}`;
2741
+ return `${base}/packages/-/fhir/v1/signatures/${spec}`;
2742
+ }
2743
+
2224
2744
  buildTarballUrl(id, version, secure, req) {
2225
2745
  if (this.config.bucketPath) {
2226
2746
  let bucketUrl = this.getBucketUrl(secure);
@@ -2243,6 +2763,8 @@ class PackagesModule {
2243
2763
  htmlServer.loadTemplate('packages', templatePath);
2244
2764
  }
2245
2765
 
2766
+ await this.annotateSignatures(packageVersions, () => id, pv => pv.Version);
2767
+
2246
2768
  // Get package counts
2247
2769
  const versionCount = packageVersions.length;
2248
2770
  const totalDownloads = packageVersions.reduce((sum, pv) => sum + (pv.DownloadCount || 0), 0);
@@ -2288,6 +2810,7 @@ class PackagesModule {
2288
2810
  table += '<th>Type</th>';
2289
2811
  table += '<th>Published</th>';
2290
2812
  table += '<th>Downloads</th>';
2813
+ table += '<th>Signature</th>';
2291
2814
  table += '<th>Actions</th>';
2292
2815
  table += '</tr></thead><tbody>';
2293
2816
 
@@ -2298,6 +2821,7 @@ class PackagesModule {
2298
2821
  table += `<td>${escape(this.codeForKind(pv.Kind))}</td>`;
2299
2822
  table += `<td>${new Date(pv.PubDate).toLocaleDateString()}</td>`;
2300
2823
  table += `<td>${(pv.DownloadCount || 0).toLocaleString()}</td>`;
2824
+ table += this.signatureCell(pv.signatureState);
2301
2825
  table += `<td><a href="/packages/${encodeURIComponent(id)}/${escape(pv.Version)}" class="btn btn-sm btn-primary">Download</a></td>`;
2302
2826
  table += '</tr>';
2303
2827
  }
@@ -2495,6 +3019,7 @@ class PackagesModule {
2495
3019
  // Get total package count
2496
3020
  const packageCount = await this.getTotalPackageCount();
2497
3021
  const downloadCount = await this.getTotalDownloadCount();
3022
+ await this.annotateSignatures(results, r => r.name, r => r.version);
2498
3023
 
2499
3024
  // Build template variables
2500
3025
  const vars = {
@@ -2532,25 +3057,25 @@ class PackagesModule {
2532
3057
 
2533
3058
  async getTotalPackageCount() {
2534
3059
  return new Promise((resolve, reject) => {
2535
- this.db.get('SELECT COUNT(*) as count FROM PackageVersions', [], (err, row) => {
3060
+ this.db.get('SELECT COUNT(*) as count FROM PackageVersions', [], guarded(reject, (err, row) => {
2536
3061
  if (err) {
2537
3062
  reject(err);
2538
3063
  } else {
2539
3064
  resolve(row ? row.count : 0);
2540
3065
  }
2541
- });
3066
+ }));
2542
3067
  });
2543
3068
  }
2544
3069
 
2545
3070
  async getTotalDownloadCount() {
2546
3071
  return new Promise((resolve, reject) => {
2547
- this.db.get('SELECT SUM(DownloadCount) as total FROM PackageVersions', [], (err, row) => {
3072
+ this.db.get('SELECT SUM(DownloadCount) as total FROM PackageVersions', [], guarded(reject, (err, row) => {
2548
3073
  if (err) {
2549
3074
  reject(err);
2550
3075
  } else {
2551
3076
  resolve(row ? row.total || 0 : 0);
2552
3077
  }
2553
- });
3078
+ }));
2554
3079
  });
2555
3080
  }
2556
3081
 
@@ -2588,6 +3113,7 @@ class PackagesModule {
2588
3113
  table += `<th>${this.generateSortHeader('Published', 'date', baseUrl, currentSort)}</th>`;
2589
3114
  table += `<th>${this.generateSortHeader('Downloads', 'count', baseUrl, currentSort)}</th>`;
2590
3115
  table += `<th>${this.generateSortHeader('Canonical', 'canonical', baseUrl, currentSort)}</th>`;
3116
+ table += '<th>Signature</th>';
2591
3117
  table += '</tr></thead><tbody>';
2592
3118
 
2593
3119
  for (const pkg of results) {
@@ -2599,6 +3125,7 @@ class PackagesModule {
2599
3125
  table += `<td>${pkg.date ? new Date(pkg.date).toLocaleDateString() : 'N/A'}</td>`;
2600
3126
  table += `<td>${pkg.count ? pkg.count.toLocaleString() : 'N/A'}</td>`;
2601
3127
  table += `<td>${escape(pkg.canonical || '')}</td>`;
3128
+ table += this.signatureCell(pkg.signatureState);
2602
3129
  table += '</tr>';
2603
3130
  }
2604
3131
 
@@ -2652,27 +3179,27 @@ class PackagesModule {
2652
3179
 
2653
3180
  content += '<tr>';
2654
3181
  content += '<td>Id</td>';
2655
- content += `<td><input type="text" name="name" value="${escape(vars.name)}"></td>`;
3182
+ content += `<td><input type="text" aria-label="Id" name="name" value="${escape(vars.name)}"></td>`;
2656
3183
  content += '</tr>';
2657
3184
 
2658
3185
  content += '<tr>';
2659
3186
  content += '<td>Depends On</td>';
2660
- content += `<td><input type="text" name="dependson" value="${escape(vars.dependson)}"> <i>includes both direct and indirect dependencies</i></td>`;
3187
+ content += `<td><input type="text" aria-label="Depends On" name="dependson" value="${escape(vars.dependson)}"> <i>includes both direct and indirect dependencies</i></td>`;
2661
3188
  content += '</tr>';
2662
3189
 
2663
3190
  content += '<tr>';
2664
3191
  content += '<td>Canonical (Package)</td>';
2665
- content += `<td><input type="text" name="pkgcanonical" value="${escape(vars.canonicalPkg)}"></td>`;
3192
+ content += `<td><input type="text" aria-label="Canonical (Package)" name="pkgcanonical" value="${escape(vars.canonicalPkg)}"></td>`;
2666
3193
  content += '</tr>';
2667
3194
 
2668
3195
  content += '<tr>';
2669
3196
  content += '<td>Canonical (Resource)</td>';
2670
- content += `<td><input type="text" name="canonical" value="${escape(vars.canonicalUrl)}"></td>`;
3197
+ content += `<td><input type="text" aria-label="Canonical (Resource)" name="canonical" value="${escape(vars.canonicalUrl)}"></td>`;
2671
3198
  content += '</tr>';
2672
3199
 
2673
3200
  content += '<tr>';
2674
3201
  content += '<td>FHIR Version</td>';
2675
- content += '<td><select name="fhirversion">';
3202
+ content += '<td><select name="fhirversion" aria-label="FHIR Version">';
2676
3203
  content += '<option value=""></option>';
2677
3204
  content += `<option value="R2" ${vars.r2selected}>R2</option>`;
2678
3205
  content += `<option value="R3" ${vars.r3selected}>R3</option>`;
@@ -2825,7 +3352,7 @@ class PackagesModule {
2825
3352
  const row = await new Promise((resolve, reject) => this.db.get(
2826
3353
  'SELECT COUNT(*) AS n, COUNT(DISTINCT Id) AS ids FROM PackageVersions WHERE Indexed > ?',
2827
3354
  [new Date(since).toISOString().replace('T', ' ').substring(0, 19)],
2828
- (err, row) => err ? reject(err) : resolve(row)));
3355
+ guarded(reject, (err, row) => err ? reject(err) : resolve(row))));
2829
3356
  sentences.push(row.n === 0
2830
3357
  ? 'No new package versions were found.'
2831
3358
  : `${plural(row.n, 'new package version')} ${row.n === 1 ? 'was' : 'were'} found, of ${plural(row.ids, 'package')}.`);
@@ -2841,17 +3368,22 @@ class PackagesModule {
2841
3368
 
2842
3369
  await this.stopCrawlerJob();
2843
3370
 
3371
+ this.stopping = true;
3372
+ if (this.distBackfill) {
3373
+ await this.distBackfill;
3374
+ }
3375
+
2844
3376
  // Close database connection
2845
3377
  if (this.db) {
2846
- return new Promise((resolve) => {
2847
- this.db.close((err) => {
3378
+ return new Promise((resolve, reject) => {
3379
+ this.db.close(guarded(reject, (err) => {
2848
3380
  if (err) {
2849
3381
  pckLog.error('Error closing packages database:', err.message);
2850
3382
  } else {
2851
3383
  pckLog.info('Packages database connection closed');
2852
3384
  }
2853
3385
  resolve();
2854
- });
3386
+ }));
2855
3387
  });
2856
3388
  }
2857
3389
 
@@ -2896,7 +3428,7 @@ class PackagesModule {
2896
3428
  return new Promise((resolve, reject) => {
2897
3429
  const sql = 'SELECT Id, Version FROM PackageVersions';
2898
3430
 
2899
- this.db.all(sql, [], (err, rows) => {
3431
+ this.db.all(sql, [], guarded(reject, (err, rows) => {
2900
3432
  if (err) {
2901
3433
  reject(err);
2902
3434
  } else {
@@ -2911,7 +3443,7 @@ class PackagesModule {
2911
3443
 
2912
3444
  resolve(validPackages);
2913
3445
  }
2914
- });
3446
+ }));
2915
3447
  });
2916
3448
  }
2917
3449
 
@@ -2923,7 +3455,7 @@ class PackagesModule {
2923
3455
  PackageVersions
2924
3456
  WHERE PackageDependencies.PackageVersionKey = PackageVersions.PackageVersionKey`;
2925
3457
 
2926
- this.db.all(sql, [], (err, rows) => {
3458
+ this.db.all(sql, [], guarded(reject, (err, rows) => {
2927
3459
  if (err) {
2928
3460
  reject(err);
2929
3461
  } else {
@@ -2959,7 +3491,7 @@ class PackagesModule {
2959
3491
 
2960
3492
  resolve(brokenDeps);
2961
3493
  }
2962
- });
3494
+ }));
2963
3495
  });
2964
3496
  }
2965
3497
 
@@ -3058,7 +3590,7 @@ class PackagesModule {
3058
3590
  content += '</table>';
3059
3591
 
3060
3592
  // Help info
3061
- content += '<h5>About This Report</h5>';
3593
+ content += '<h3>About This Report</h3>';
3062
3594
  content += '<p>This report shows packages that have dependencies which cannot be resolved. A dependency is considered broken if:</p>';
3063
3595
  content += '<ul>';
3064
3596
  content += '<li>The referenced package does not exist</li>';
@@ -3178,7 +3710,9 @@ class PackagesModule {
3178
3710
 
3179
3711
  if (logData.feeds && logData.feeds.length > 0) {
3180
3712
  for (const feed of logData.feeds) {
3181
- if (feed.exception || feed.rateLimited) {
3713
+ if (feed.skipped) {
3714
+ output += `Feed: ${feed.url}: skipped - ${feed.skipped}\n`;
3715
+ } else if (feed.exception || feed.rateLimited) {
3182
3716
  // Feed itself had an error
3183
3717
  const error = feed.rateLimited ? feed.rateLimitMessage : feed.exception;
3184
3718
  output += `Feed: ${feed.url}: ${error}\n`;
@@ -3193,6 +3727,9 @@ class PackagesModule {
3193
3727
  const guid = item.guid || 'unknown';
3194
3728
  output += ` error: ${guid}: ${item.error}\n`;
3195
3729
  }
3730
+ if (item.signature && !item.signature.startsWith('valid') && item.signature !== 'unsigned') {
3731
+ output += ` signature: ${item.guid || 'unknown'}: ${item.signature}\n`;
3732
+ }
3196
3733
  }
3197
3734
  }
3198
3735
  }
@@ -3222,6 +3759,16 @@ class PackagesModule {
3222
3759
  schedule: this.config.crawler.schedule,
3223
3760
  lastRun: this.lastRunTime,
3224
3761
  totalRuns: this.totalRuns
3762
+ },
3763
+ npmSigning: {
3764
+ enabled: this.signer ? true : false,
3765
+ keyid: this.signer ? this.signer.keyid : undefined,
3766
+ // complete: every version has its integrity (and signature); running, incomplete
3767
+ // (tried again after the next crawl), stopped
3768
+ state: this.distStatus.state,
3769
+ missing: this.distStatus.missing,
3770
+ done: this.distStatus.done,
3771
+ failed: this.distStatus.failed
3225
3772
  }
3226
3773
  };
3227
3774
  }