fhirsmith 0.15.0 → 0.16.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.
@@ -19,6 +19,9 @@ const {describeCron} = require("../library/cron-utilities");
19
19
  const {tokenMatches, tokenConfigured} = require("../library/request-token");
20
20
  const pckLog = Logger.getInstance().child({ module: 'packages' });
21
21
  const packagesOpenApi = require('./openapi');
22
+ const rateLimit = require('express-rate-limit');
23
+ const { runNpmCheck, makeGetter } = require('./npm-check');
24
+ const npmCheckPage = require('./npm-check-page');
22
25
 
23
26
  // Query parameter rules for validateQueryParams. These are also the contract published in
24
27
  // openapi.yaml, and tests/packages/openapi.test.js checks that the two agree - so change
@@ -79,6 +82,39 @@ const PACKAGE_ID_MAX_LENGTH = 100;
79
82
  const PACKAGE_VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/;
80
83
  const PACKAGE_VERSION_MAX_LENGTH = 50;
81
84
 
85
+ // GET /npm-check: run the npm registry protocol checks against a registry (also in openapi.yaml)
86
+ const NPM_CHECK_PARAMS = {
87
+ registry: { maxLength: 500, pattern: /^https?:\/\/[^\s<>"'`]+$/ },
88
+ package: { maxLength: PACKAGE_ID_MAX_LENGTH, pattern: PACKAGE_ID_PATTERN },
89
+ version: { maxLength: PACKAGE_VERSION_MAX_LENGTH, pattern: PACKAGE_VERSION_PATTERN }
90
+ };
91
+
92
+ // npm-check defaults; all can be set under modules.packages.npmCheck in the config
93
+ const NPM_CHECK_DEFAULTS = {
94
+ enabled: true,
95
+ maxConcurrentRuns: 2, // across all users; more get a 503
96
+ runsPerWindow: 10, // per client IP, per 10 minutes
97
+ requestTimeout: 30000, // ms, per request the checks make
98
+ maxDownloadMB: 50, // per response, so for the tarball
99
+ // the npm registry itself, checked once a day so the page can show the checks still hold
100
+ control: { registry: 'https://registry.npmjs.org', package: 'fhirpath', version: '3.15.0' }
101
+ };
102
+ const NPM_CHECK_CONTROL_MAX_AGE = 24 * 60 * 60 * 1000;
103
+
104
+ // The query string of a request, for logging (the parameters were validated before the handler runs)
105
+ function queryString(req) {
106
+ const i = req.originalUrl.indexOf('?');
107
+ return i < 0 ? '' : req.originalUrl.substring(i + 1);
108
+ }
109
+
110
+ // A request for /{id}/{version} with this header gets the tarball, whatever its Accept header
111
+ // says. The HL7 Java PackageClient sends it; any client that downloads from that URL while
112
+ // asking for JSON can send it too.
113
+ const TARBALL_HEADER = 'FHIR-Package-Download';
114
+
115
+ // Each id#version is stored once (see PackageCrawler.checkVersion)
116
+ const UNIQUE_ID_VERSION_INDEX = 'CREATE UNIQUE INDEX SK_PackageVersions_IdVersion ON PackageVersions (Id, Version)';
117
+
82
118
  class PackagesModule {
83
119
  constructor(stats) {
84
120
  this.router = express.Router();
@@ -610,6 +646,9 @@ class PackagesModule {
610
646
  content += '<div class="card-body">';
611
647
  content += '<p><a href="/packages/search" class="btn btn-primary mb-2">Search Packages</a></p>';
612
648
  content += '<p><a href="/packages/log" class="btn btn-secondary mb-2">View Crawler Log</a></p>';
649
+ if (this.npmCheckConfig().enabled) {
650
+ content += '<p><a href="/packages/npm-check" class="btn btn-secondary mb-2">Check an npm Registry</a></p>';
651
+ }
613
652
  if (this.config.crawler.enabled) {
614
653
  content += '<p><button onclick="triggerCrawl()" class="btn btn-warning mb-2">Manual Crawl</button></p>';
615
654
  }
@@ -708,6 +747,11 @@ class PackagesModule {
708
747
  )`, (err) => {
709
748
  if (err) pckLog.error('Failed to create FeedPages table:', err.message);
710
749
  });
750
+ // Fails while the database still holds duplicate id#version rows (from before the
751
+ // 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) => {
753
+ 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
+ });
711
755
  resolve();
712
756
  }
713
757
  }
@@ -797,6 +841,7 @@ class PackagesModule {
797
841
  'CREATE INDEX SK_Packages_Id ON Packages (Id, PackageKey)',
798
842
  'CREATE INDEX SK_Packages_Canonical ON Packages (Canonical, PackageKey)',
799
843
  'CREATE INDEX SK_PackageVersions_Id ON PackageVersions (Id, Version, PackageVersionKey)',
844
+ UNIQUE_ID_VERSION_INDEX,
800
845
  'CREATE INDEX SK_PackageVersions_Canonical ON PackageVersions (Canonical, PackageVersionKey)',
801
846
  'CREATE INDEX SK_PackageVersions_PubDate ON PackageVersions (Id, PubDate, PackageVersionKey)',
802
847
  'CREATE INDEX SK_PackageVersions_Indexed ON PackageVersions (Indexed, PackageVersionKey)',
@@ -1051,13 +1096,34 @@ class PackagesModule {
1051
1096
  }
1052
1097
  });
1053
1098
 
1099
+ // GET /packages/npm-check - the npm registry protocol checks, against any registry
1100
+ const npmCheckLimiter = rateLimit({
1101
+ windowMs: 10 * 60 * 1000,
1102
+ limit: () => this.npmCheckConfig().runsPerWindow,
1103
+ skip: req => !req.query.registry, // only runs count, not the form
1104
+ standardHeaders: true,
1105
+ legacyHeaders: false,
1106
+ message: {error: 'Too many npm registry checks from this address; try again in a few minutes'}
1107
+ });
1108
+ this.router.get('/npm-check', npmCheckLimiter, this.validateQueryParams(NPM_CHECK_PARAMS), async (req, res) => {
1109
+ const start = Date.now();
1110
+ try {
1111
+ await this.serveNpmCheck(req, res);
1112
+ } catch (error) {
1113
+ pckLog.error('Error in /packages/npm-check:', error);
1114
+ res.status(500).json({error: 'Internal server error'});
1115
+ } finally {
1116
+ this.stats.countRequest('npm-check', Date.now() - start);
1117
+ }
1118
+ });
1119
+
1054
1120
  // GET /packages/catalog - Search packages or get updates
1055
1121
  this.router.get('/catalog', this.validateQueryParams(searchParams), async (req, res) => {
1056
1122
  const start = Date.now();
1057
1123
  try {
1058
1124
  try {
1059
1125
  await this.serveSearch(req, res);
1060
- pckLog.info("/catalog" + searchParams);
1126
+ pckLog.info("/catalog?" + queryString(req));
1061
1127
  } catch (error) {
1062
1128
  pckLog.error('Error in /packages/catalog:', error);
1063
1129
  res.status(500).json({error: 'Internal server error'});
@@ -1073,7 +1139,7 @@ class PackagesModule {
1073
1139
  try {
1074
1140
  try {
1075
1141
  await this.serveV1Search(req, res);
1076
- pckLog.info("/search?" + searchParams);
1142
+ pckLog.info("/search?" + queryString(req));
1077
1143
  } catch (error) {
1078
1144
  pckLog.error('Error in /packages/-/v1/search:', error);
1079
1145
  res.status(500).json({error: 'Internal server error'});
@@ -1093,7 +1159,7 @@ class PackagesModule {
1093
1159
  let days = daysValue || '10';
1094
1160
  let date = dateValue || new Date().toISOString().split('T')[0];
1095
1161
  await this.serveUpdates(req.secure, res, req, dt, days, date);
1096
- pckLog.info("/updates?" + searchParams);
1162
+ pckLog.info("/updates?" + queryString(req));
1097
1163
  } catch (error) {
1098
1164
  pckLog.error('Error in /packages/updates:', error);
1099
1165
  res.status(500).json({error: 'Internal server error'});
@@ -1221,8 +1287,16 @@ class PackagesModule {
1221
1287
  try {
1222
1288
  try {
1223
1289
  const {id, version} = req.params;
1224
- await this.serveDownload(req.secure, id, version, res);
1225
- pckLog.info(`/download/${id}/${version}`);
1290
+ // The same URL gives the tarball or (npm) the version's JSON document
1291
+ res.vary('Accept');
1292
+ res.vary(TARBALL_HEADER);
1293
+ if (this.wantsVersionDocument(req)) {
1294
+ await this.serveVersionDocument(req.secure, id, version, req, res);
1295
+ pckLog.info(`/version/${id}/${version}`);
1296
+ } else {
1297
+ await this.serveDownload(req.secure, id, version, res);
1298
+ pckLog.info(`/download/${id}/${version}`);
1299
+ }
1226
1300
  } catch (error) {
1227
1301
  pckLog.error('Error in /packages/:id/:version:', error);
1228
1302
  res.status(500).json({error: 'Internal server error'});
@@ -1276,7 +1350,7 @@ class PackagesModule {
1276
1350
  // Don't process routes that are handled elsewhere. These must call next():
1277
1351
  // returning without responding leaves the request hanging, which is what
1278
1352
  // /status, /stats and /search (registered after this route) used to do.
1279
- if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates'].includes(id) ||
1353
+ if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates', 'npm-check'].includes(id) ||
1280
1354
  id.endsWith('.html') || id === '-') {
1281
1355
  next();
1282
1356
  return;
@@ -1645,8 +1719,159 @@ class PackagesModule {
1645
1719
  });
1646
1720
  }
1647
1721
 
1722
+ npmCheckConfig() {
1723
+ const cfg = this.config?.npmCheck || {};
1724
+ const control = cfg.control === false ? null : {...NPM_CHECK_DEFAULTS.control, ...(cfg.control || {})};
1725
+ return {...NPM_CHECK_DEFAULTS, ...cfg, control};
1726
+ }
1727
+
1728
+ // The HTTP client for the checks: the crawler's SSRF-guarded agents, a timeout and a size
1729
+ // cap on every response. Loopback addresses are allowed, so the checks can run against this
1730
+ // server itself; every other non-public address (the server's network, cloud metadata) is not.
1731
+ npmCheckGetter() {
1732
+ const cfg = this.npmCheckConfig();
1733
+ return makeGetter({
1734
+ ...this.crawler.guardedAgents({allowLoopback: true}),
1735
+ timeout: cfg.requestTimeout,
1736
+ maxBytes: cfg.maxDownloadMB * 1024 * 1024,
1737
+ userAgent: 'FHIRsmith npm registry check (+https://github.com/HealthIntersections/FHIRsmith)'
1738
+ });
1739
+ }
1740
+
1741
+ // Runs the checks against the npm registry itself, at most once a day, in the background
1742
+ refreshNpmCheckControl() {
1743
+ const control = this.npmCheckConfig().control;
1744
+ if (!control || this.npmCheckControlRunning) {
1745
+ return;
1746
+ }
1747
+ if (this.npmCheckControlReport && Date.now() - Date.parse(this.npmCheckControlReport.finished) < NPM_CHECK_CONTROL_MAX_AGE) {
1748
+ return;
1749
+ }
1750
+ this.npmCheckControlRunning = true;
1751
+ runNpmCheck({registry: control.registry, pkg: control.package, version: control.version}, this.npmCheckGetter())
1752
+ .then(report => {
1753
+ this.npmCheckControlReport = report;
1754
+ if (report.failed > 0) {
1755
+ pckLog.warn(`npm-check: ${report.failed} checks fail against the control ${control.registry} - they may be out of date`);
1756
+ }
1757
+ })
1758
+ .catch(error => pckLog.error('npm-check control run failed: ' + error.message))
1759
+ .finally(() => {
1760
+ this.npmCheckControlRunning = false;
1761
+ });
1762
+ }
1763
+
1764
+ async serveNpmCheck(req, res) {
1765
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
1766
+ const cfg = this.npmCheckConfig();
1767
+ const fail = (status, message) => {
1768
+ if (acceptsHtml) {
1769
+ return this.sendNpmCheckPage(res, status, `<div class="alert alert-danger">${escape(message)}</div>` +
1770
+ npmCheckPage.renderForm({values: req.query, control: cfg.control, controlReport: this.npmCheckControlReport}));
1771
+ }
1772
+ return res.status(status).json({error: message});
1773
+ };
1774
+
1775
+ if (!cfg.enabled) {
1776
+ const message = 'The npm registry check is not enabled on this server';
1777
+ return acceptsHtml ? this.sendNpmCheckPage(res, 404, `<div class="alert alert-info">${escape(message)}</div>`)
1778
+ : res.status(404).json({error: message});
1779
+ }
1780
+ const {registry, package: pkg, version} = req.query;
1781
+ if (!registry) {
1782
+ if (acceptsHtml) {
1783
+ this.refreshNpmCheckControl();
1784
+ return this.sendNpmCheckPage(res, 200, npmCheckPage.renderForm({values: req.query, control: cfg.control, controlReport: this.npmCheckControlReport}));
1785
+ }
1786
+ return res.status(400).json({error: 'Parameter registry is required'});
1787
+ }
1788
+ // validateQueryParams already refuses a repeated parameter (which Express makes an array);
1789
+ // checked again here so this handler doesn't depend on that
1790
+ if (typeof registry !== 'string' || typeof pkg !== 'string' || typeof version !== 'string') {
1791
+ return fail(400, 'The registry, package and version must each be given once');
1792
+ }
1793
+ if (!pkg || !version) {
1794
+ return fail(400, 'Give the package and version to check, as well as the registry');
1795
+ }
1796
+ let url;
1797
+ try {
1798
+ url = new URL(registry);
1799
+ } catch (e) {
1800
+ return fail(400, `The registry URL isn't valid: ${registry}`);
1801
+ }
1802
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
1803
+ return fail(400, 'The registry URL must be http(s), with no user name or password');
1804
+ }
1805
+ if ((this.npmChecksRunning || 0) >= cfg.maxConcurrentRuns) {
1806
+ res.setHeader('Retry-After', '30');
1807
+ return fail(503, 'This server is already running as many npm registry checks as it allows; try again in a minute');
1808
+ }
1809
+
1810
+ this.npmChecksRunning = (this.npmChecksRunning || 0) + 1;
1811
+ let report;
1812
+ try {
1813
+ report = await runNpmCheck({registry, pkg, version}, this.npmCheckGetter());
1814
+ } finally {
1815
+ this.npmChecksRunning--;
1816
+ }
1817
+ pckLog.info(`npm-check ${registry} ${pkg}#${version}: ${report.passed} passed, ${report.failed} failed`);
1818
+ if (acceptsHtml) {
1819
+ this.refreshNpmCheckControl();
1820
+ return this.sendNpmCheckPage(res, 200, npmCheckPage.renderReport({report, control: cfg.control, controlReport: this.npmCheckControlReport}));
1821
+ }
1822
+ res.json(report);
1823
+ }
1824
+
1825
+ async sendNpmCheckPage(res, status, content) {
1826
+ if (!htmlServer.hasTemplate('packages')) {
1827
+ htmlServer.loadTemplate('packages', path.join(__dirname, 'packages-template.html'));
1828
+ }
1829
+ const stats = await this.gatherPackageStatistics();
1830
+ res.status(status).setHeader('Content-Type', 'text/html');
1831
+ res.send(htmlServer.renderPage('packages', 'npm Registry Check', content, stats));
1832
+ }
1833
+
1834
+ // GET /{id}/{version} is npm's version document when the request asks for JSON, and the
1835
+ // tarball otherwise - which is what FHIR clients have always got there, without asking
1836
+ // for JSON. A request carrying the TARBALL_HEADER always gets the tarball.
1837
+ wantsVersionDocument(req) {
1838
+ if (req.get(TARBALL_HEADER) !== undefined) {
1839
+ return false;
1840
+ }
1841
+ return /application\/(json|vnd\.npm\.install-v1\+json)/i.test(req.get('accept') || '');
1842
+ }
1843
+
1844
+ async serveVersionDocument(secure, id, version, req, res) {
1845
+ const packageVersions = await this.getPackageVersions(id);
1846
+ if (packageVersions.length === 0) {
1847
+ res.status(404).json({error: `Package "${id}" not found`});
1848
+ return;
1849
+ }
1850
+ const registry = await this.buildRegistryResponse(id, packageVersions, secure, req);
1851
+ // Resolved as a download is: the exact version, a dist-tag, or the most recently
1852
+ // published pre-release of that version
1853
+ let v = Object.hasOwn(registry.versions, version) ? version
1854
+ : Object.hasOwn(registry['dist-tags'], version) ? registry['dist-tags'][version] : undefined;
1855
+ if (!v) {
1856
+ v = packageVersions.find(pv => pv.Version.startsWith(`${version}-`))?.Version;
1857
+ }
1858
+ if (!v) {
1859
+ res.status(404).json({error: `Version "${version}" of package "${id}" not found`});
1860
+ return;
1861
+ }
1862
+ res.json(registry.versions[v]);
1863
+ }
1864
+
1648
1865
  async serveDownload(secure, id, version, res) {
1649
1866
  try {
1867
+ // A dist-tag in place of a version (npm). The only tag is latest.
1868
+ if (version === 'latest') {
1869
+ const versions = await this.getPackageVersions(id);
1870
+ if (versions.length > 0) {
1871
+ version = versions[0].Version; // as dist-tags.latest: the most recently published
1872
+ }
1873
+ }
1874
+
1650
1875
  // First try exact version match
1651
1876
  let packageData = await this.findPackageVersion(id, version, true);
1652
1877
 
@@ -1951,6 +2176,13 @@ class PackagesModule {
1951
2176
  registry.versions[pv.Version] = versionObj;
1952
2177
  }
1953
2178
 
2179
+ // Publication times, as npm gives them: one per version, plus created and modified
2180
+ const dates = packageVersions.map(pv => new Date(pv.PubDate).toISOString()).sort();
2181
+ registry.time = {created: dates[0], modified: dates[dates.length - 1]};
2182
+ for (const pv of packageVersions) {
2183
+ registry.time[pv.Version] = new Date(pv.PubDate).toISOString();
2184
+ }
2185
+
1954
2186
  // Set latest version and description at package level
1955
2187
  registry['dist-tags'].latest = latestVersion;
1956
2188
  if (description) {
@@ -2118,7 +2350,7 @@ class PackagesModule {
2118
2350
  const q = req.query;
2119
2351
  const secure = req.secure || req.headers['x-forwarded-proto'] === 'https';
2120
2352
  try {
2121
- const results = await this.searchPackages({
2353
+ let results = await this.searchPackages({
2122
2354
  name: q.name,
2123
2355
  dependson: q.dependson,
2124
2356
  canonicalPkg: q.pkgcanonical,
@@ -2129,6 +2361,16 @@ class PackagesModule {
2129
2361
  sort: q.sort
2130
2362
  }, req, secure);
2131
2363
 
2364
+ // npm puts the package whose name is the search text first. Without a sort, the
2365
+ // rest stay in publication order.
2366
+ if (q.text && !q.sort) {
2367
+ const name = q.text.trim().toLowerCase();
2368
+ const exact = results.filter(r => r.name.toLowerCase() === name);
2369
+ if (exact.length > 0) {
2370
+ results = [...exact, ...results.filter(r => r.name.toLowerCase() !== name)];
2371
+ }
2372
+ }
2373
+
2132
2374
  const from = parseInt(q.from, 10) || 0;
2133
2375
  const page = q.size
2134
2376
  ? results.slice(from, from + Math.min(parseInt(q.size, 10), V1_SEARCH_MAX_SIZE))
@@ -3055,7 +3297,7 @@ class PackagesModule {
3055
3297
  }
3056
3298
  }
3057
3299
 
3058
- PackagesModule.QUERY_PARAMS = { search: SEARCH_PARAMS, v1Search: V1_SEARCH_PARAMS, updates: UPDATES_PARAMS, broken: BROKEN_PARAMS };
3300
+ PackagesModule.QUERY_PARAMS = { search: SEARCH_PARAMS, v1Search: V1_SEARCH_PARAMS, updates: UPDATES_PARAMS, broken: BROKEN_PARAMS, npmCheck: NPM_CHECK_PARAMS };
3059
3301
  PackagesModule.FHIR_RELEASE_VERSIONS = FHIR_RELEASE_VERSIONS;
3060
3302
  PackagesModule.PATH_PARAMS = {
3061
3303
  id: { maxLength: PACKAGE_ID_MAX_LENGTH, pattern: PACKAGE_ID_PATTERN },
@@ -31,9 +31,10 @@ In summary:
31
31
  | `GET /packages/catalog` | Search packages: `name`, `canonical`, `pkgcanonical`, `fhirversion`, `dependency`, `dependson`, `sort` |
32
32
  | `GET /packages/-/v1/search` | npm search API: `text`, `size`, `from`, plus the `/catalog` parameters |
33
33
  | `GET /packages/{id}` | npm package document: all versions, dependencies, tarball URLs |
34
- | `GET /packages/{id}/{version}` | Download a package tarball |
34
+ | `GET /packages/{id}/{version}` | Download a package tarball; or, when the request's `Accept` asks for JSON, npm's document for that version. A `FHIR-Package-Download` header always gets the tarball (the HL7 Java tools send it) |
35
35
  | `GET /packages/updates` | Versions published since a date |
36
36
  | `GET /packages/broken` | Package versions whose dependencies this registry doesn't hold |
37
+ | `GET /packages/npm-check` | Check any registry against the npm registry protocol (a form, in a browser) |
37
38
 
38
39
  Most endpoints return an HTML page instead of JSON when the request's `Accept`
39
40
  header contains `text/html`. The JSON is the contract; the HTML is for people.
@@ -93,7 +94,33 @@ In the server config, under `modules.packages`:
93
94
  | `crawler.schedule` | Cron expression for crawls. |
94
95
  | `masterUrl` | The list of feeds to crawl. Defaults to `https://fhir.github.io/ig-registry/package-feeds.json`. |
95
96
  | `localFeedDirs` | Optional. Directories feeds may be read from as local files (for testing). |
96
- | `allowPrivateAddresses` | Optional, for testing only. Lets the crawler fetch from private and loopback addresses, which it otherwise refuses (SSRF protection). |
97
+ | `allowPrivateAddresses` | Optional, for testing only. Lets the crawler and `/npm-check` fetch from private and loopback addresses, which they otherwise refuse (SSRF protection). |
98
+ | `npmCheck.enabled` | `/packages/npm-check`. On unless set to `false`. |
99
+ | `npmCheck.maxConcurrentRuns` | Runs at once, across all users; more get a 503. Default 2. |
100
+ | `npmCheck.runsPerWindow` | Runs per client address per 10 minutes; more get a 429. Default 10. |
101
+ | `npmCheck.requestTimeout` | Milliseconds allowed for each request a run makes. Default 30000. |
102
+ | `npmCheck.maxDownloadMB` | Largest response a run will read (so, the tarball). Default 50. |
103
+ | `npmCheck.control` | The registry, package and version the checks are verified against daily; default `registry.npmjs.org`, `fhirpath` 3.15.0. `false` turns that off. |
104
+
105
+ ## Checking npm compatibility
106
+
107
+ This server aims to be an npm registry, carrying FHIR fields as well. `npm-check.js`
108
+ holds checks of the npm registry protocol, written from the way npm clients use it:
109
+ search by name, the package document, one version's document, and the tarball. They
110
+ are used three ways:
111
+
112
+ * `/packages/npm-check` runs them against any registry and shows a report. Its requests
113
+ go through the crawler's SSRF guard, with a timeout and a size cap, and runs are rate
114
+ limited. Unlike the crawler, it may use loopback addresses (`localhost`, `127.0.0.1`,
115
+ `::1`), so it can check this server itself; other non-public addresses are refused.
116
+ * `tests/packages/npm-registry.test.js` runs them against registry.npmjs.org, which must
117
+ pass every check: if npm changes its behaviour, that test is how we find out. Set
118
+ `NPM_REGISTRY_TARGETS` to run them against other registries too.
119
+ * `tests/packages/npm-registry-local.test.js` runs them against this module, started
120
+ locally with known packages.
121
+
122
+ `/packages/npm-check` also runs them against registry.npmjs.org once a day, and says on
123
+ the page if any fail there.
97
124
 
98
125
  ## Crawling
99
126
 
@@ -108,6 +135,16 @@ stores it.
108
135
  crawl; older pages are read once and remembered in the `FeedPages` table.
109
136
  * A feed that rate-limits (HTTP 429) is abandoned for that crawl and retried next
110
137
  time.
138
+ * Each id#version is stored once. Simplifier re-publishes packages that other feeds
139
+ publish directly, so its copy is a secondary echo: a version we already hold is
140
+ never fetched from Simplifier, and a version we only hold from Simplifier is
141
+ replaced when it arrives directly. A second direct copy of a version we already
142
+ hold, from a different GUID, is logged as an error and ignored. This is checked
143
+ against the feed's title before downloading, and against the tarball's own
144
+ package.json before storing. The database enforces it with a unique index on
145
+ `(Id, Version)`.
146
+ * The mirror copy of a tarball is written only after the package has passed
147
+ validation and been stored.
111
148
 
112
149
  `/packages/log` shows what the last crawl did, feed by feed and item by item.
113
150
 
@@ -118,3 +155,19 @@ metadata and the tarball itself. `Packages` holds one row per package id, with i
118
155
  current version and download count. `PackageDependencies` (stored as `id@version`),
119
156
  `PackageFHIRVersions` and `PackageURLs` (the canonical URLs of the resources in each
120
157
  version) support search.
158
+
159
+ ### Repairing an older database
160
+
161
+ Databases built before each id#version was stored once can hold duplicate rows, and
162
+ mirror files that don't match the stored hash. `dedupe-versions.js` repairs both:
163
+
164
+ ```
165
+ node packages/dedupe-versions.js -db <packages.db> -mirror <mirror folder> # report only
166
+ node packages/dedupe-versions.js -db <packages.db> -mirror <mirror folder> -apply
167
+ ```
168
+
169
+ It keeps one row per id#version by the crawler's rule (direct beats Simplifier,
170
+ otherwise the first stored), lists any id#version with two different direct copies
171
+ for manual cleanup, creates the unique index once no duplicates remain, and rewrites
172
+ any mirror file whose content doesn't match its row's hash, from the database. Back up
173
+ the database first. Until the index exists, the server logs a warning at startup.