fhirsmith 0.9.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/library/folder-content-loader.js +91 -0
  3. package/npmprojector/npmprojector.js +2 -6
  4. package/package.json +1 -1
  5. package/packages/package-crawler.js +123 -6
  6. package/packages/packages.js +104 -0
  7. package/publisher/publisher.js +290 -12
  8. package/registry/crawler.js +85 -6
  9. package/registry/registry.js +24 -7
  10. package/server.js +11 -2
  11. package/stats.js +26 -18
  12. package/translations/Messages.properties +2 -1
  13. package/tx/cs/cs-cs.js +8 -0
  14. package/tx/cs/cs-loinc.js +1 -0
  15. package/tx/cs/cs-provider-list.js +2 -1
  16. package/tx/cs/cs-snomed.js +142 -59
  17. package/tx/data/snomed-testing.cache +0 -0
  18. package/tx/html/home-metrics.liquid +10 -10
  19. package/tx/library/canonical-resource.js +4 -2
  20. package/tx/library/codesystem.js +31 -0
  21. package/tx/library/conceptmap.js +24 -0
  22. package/tx/library/designations.js +27 -20
  23. package/tx/library/renderer.js +303 -22
  24. package/tx/library/ucum-types.js +4 -1
  25. package/tx/library/valueset.js +46 -0
  26. package/tx/library.js +65 -21
  27. package/tx/operation-context.js +122 -27
  28. package/tx/params.js +36 -8
  29. package/tx/provider.js +6 -3
  30. package/tx/tx-html.js +34 -0
  31. package/tx/tx.js +92 -30
  32. package/tx/vs/vs-vsac.js +157 -9
  33. package/tx/workers/cache-control.js +186 -0
  34. package/tx/workers/{related.js → compare.js} +27 -27
  35. package/tx/workers/expand.js +100 -96
  36. package/tx/workers/lookup.js +6 -0
  37. package/tx/workers/metadata.js +11 -6
  38. package/tx/workers/read.js +1 -1
  39. package/tx/workers/translate.js +20 -29
  40. package/tx/workers/validate.js +18 -10
  41. package/tx/workers/worker.js +134 -47
  42. package/tx/xversion/xv-bundle.js +1 -2
  43. package/tx/xversion/xv-codesystem.js +5 -2
  44. package/tx/xversion/xv-parameters.js +4 -4
  45. package/tx/xversion/xv-resource.js +2 -2
  46. package/tx/xversion/xv-terminologyCapabilities.js +11 -6
  47. package/tx/xversion/xv-valueset.js +7 -7
  48. package/publisher/task-draft.js +0 -463
  49. package/tx/data/OperationDefinition-ValueSet-related.json +0 -133
package/CHANGELOG.md CHANGED
@@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [v0.10.0] - 2026-06-27
10
+
11
+ ### Added
12
+
13
+ - Publisher: track the IG Publisher version used for each task, and show it on the tasks list and task detail pages
14
+ - Packages: new `POST /packages/update-package` endpoint to force-refresh specific packages in the registry, bypassing the feed crawler's de-duplication (used to push out a corrected package after a bad publish)
15
+ - Publisher: automatically update SUSHI to the latest release before each draft build and publication run, installed into a FHIRsmith-owned prefix so it needs no root privileges
16
+
17
+ ### Changed
18
+
19
+ - Rebuilt how caching works (see the tools IG for documentation)
20
+ - Renamed the compare worker/operation
21
+
22
+ ### Fixed
23
+
24
+ - Publisher: the publication run now verifies that the package about to be published is a real publication build (not a draft) before committing it to the web tree, so a failed publication build can no longer silently ship a `notForPublication` draft package
25
+ - Packages: the crawler now rejects any package flagged `notForPublication` at ingest, instead of only checking the feed entry
26
+ - Registry: better handling of versions on manual queries against the external registry
27
+ - Fixed handling of the cache-control header
28
+ - Corrections to generated test cases
29
+
30
+ ### Security
31
+
32
+ - SSRF protection: outbound fetches in the packages and registry crawlers now reject any host that resolves to a non-public address (private, loopback, link-local including cloud-metadata, CGNAT, unique-local, etc.). Enforced at connection time, so it also covers redirect targets and DNS rebinding, and prevents leaking registry API keys to a redirected host
33
+ - Path-injection hardening: local-file feed reads in the packages crawler are confined to explicitly-allowed directories; the `update-package` endpoint only accepts http(s) URLs
34
+ - Dependency updates (npm audit)
35
+
36
+ ### Tx Conformance Statement
37
+
38
+ FHIRsmith passed all 2503 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.11
39
+
40
+ ## [v0.9.7] - 2026-06-12
41
+
42
+ ### Added
43
+
44
+ - Support for tx modele to load resources directly
45
+ - Missing overwork protection processing ECL
46
+
47
+ ### Fixed
48
+
49
+ - Fix bug validating secondary displays across languages
50
+ - Fix CORS issue (double headers)
51
+ - Fix czech code in snomed import
52
+ - Many minor validation fixes
53
+ - ECL processing bugs
54
+ - Publishing: better logging, and fix timeout & restart error
55
+
56
+ ### Tx Conformance Statement
57
+
58
+ FHIRsmith passed all 2503 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.10)
59
+
9
60
  ## [v0.9.6] - 2026-05-21
10
61
 
11
62
  ### Added
@@ -0,0 +1,91 @@
1
+ const fs = require('fs').promises;
2
+ const path = require('path');
3
+ const { PackageContentLoader } = require('./package-manager');
4
+
5
+ /**
6
+ * A content loader that scans a folder for JSON FHIR terminology resources
7
+ * (CodeSystem, ValueSet, ConceptMap). It synthesizes the package layout that
8
+ * PackageContentLoader expects so the existing PackageValueSetProvider /
9
+ * PackageConceptMapProvider machinery can be reused without modification.
10
+ *
11
+ * The user's source folder is treated as read-only. Any SQLite caches built
12
+ * by the Package*Provider classes are written to the cacheFolder passed in,
13
+ * not to the source folder.
14
+ */
15
+ class FolderContentLoader extends PackageContentLoader {
16
+ /**
17
+ * @param {string} sourceFolder - Folder to scan for JSON resources
18
+ * @param {string} cacheFolder - Folder where Package*Provider DBs may live
19
+ */
20
+ constructor(sourceFolder, cacheFolder) {
21
+ super(cacheFolder);
22
+ this.sourceFolder = sourceFolder;
23
+ // Resources are read directly from the source folder rather than from
24
+ // <packageFolder>/package as PackageContentLoader assumes.
25
+ this.packageSubfolder = sourceFolder;
26
+ }
27
+
28
+ /**
29
+ * Replaces the standard initialize: there is no package.json or .index.json
30
+ * to read, so we synthesize an index from the JSON files in the folder.
31
+ */
32
+ async initialize() {
33
+ if (this.loaded) {
34
+ return;
35
+ }
36
+
37
+ try {
38
+ const stat = await fs.stat(this.sourceFolder);
39
+ if (!stat.isDirectory()) {
40
+ throw new Error(`Folder source path is not a directory: ${this.sourceFolder}`);
41
+ }
42
+ } catch (err) {
43
+ if (err.code === 'ENOENT') {
44
+ throw new Error(`Folder source path does not exist: ${this.sourceFolder}`);
45
+ }
46
+ throw err;
47
+ }
48
+
49
+ // Synthesize package metadata so id()/version()/pid() return something usable.
50
+ this.package = {
51
+ name: 'folder.' + path.basename(this.sourceFolder),
52
+ version: '0.0.0',
53
+ fhirVersions: ['5.0.0']
54
+ };
55
+
56
+ const files = [];
57
+ const entries = await fs.readdir(this.sourceFolder, { withFileTypes: true });
58
+ for (const entry of entries) {
59
+ if (!entry.isFile()) continue;
60
+ if (!entry.name.toLowerCase().endsWith('.json')) continue;
61
+
62
+ const fullPath = path.join(this.sourceFolder, entry.name);
63
+ let json;
64
+ try {
65
+ const content = await fs.readFile(fullPath, 'utf8');
66
+ json = JSON.parse(content);
67
+ } catch {
68
+ // Skip unreadable / non-JSON files rather than failing the whole load.
69
+ continue;
70
+ }
71
+
72
+ if (!json || typeof json !== 'object') continue;
73
+ const rt = json.resourceType;
74
+ if (rt !== 'CodeSystem' && rt !== 'ValueSet' && rt !== 'ConceptMap') continue;
75
+
76
+ files.push({
77
+ filename: entry.name,
78
+ resourceType: rt,
79
+ id: json.id,
80
+ url: json.url,
81
+ version: json.version
82
+ });
83
+ }
84
+
85
+ this.index = { files, 'index-version': 2 };
86
+ this.buildIndexes();
87
+ this.loaded = true;
88
+ }
89
+ }
90
+
91
+ module.exports = { FolderContentLoader };
@@ -127,12 +127,8 @@ class NpmProjectorModule {
127
127
  * Set up Express routes
128
128
  */
129
129
  setupRoutes() {
130
- // CORS for browser access
131
- this.router.use((req, res, next) => {
132
- res.header('Access-Control-Allow-Origin', '*');
133
- res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
134
- next();
135
- });
130
+ // CORS is handled once at the app level (server.js, from config.server.cors).
131
+ // Do not set Access-Control-* headers here.
136
132
 
137
133
  // Root - module info
138
134
  this.router.get('/', (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.9.6",
3
+ "version": "0.10.0",
4
4
  "txVersion": "1.9.1",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -9,8 +9,27 @@ const {XMLParser} = require('fast-xml-parser');
9
9
  const crypto = require('crypto');
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const dns = require('dns');
13
+ const http = require('http');
14
+ const https = require('https');
15
+ const ipaddr = require('ipaddr.js');
12
16
  const {debugLog} = require("../tx/operation-context");
13
17
 
18
+ // True if an IP literal is anything other than a normal public (unicast) address:
19
+ // loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
20
+ // CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
21
+ function isNonPublicAddress(ip) {
22
+ try {
23
+ let addr = ipaddr.parse(ip);
24
+ if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
25
+ addr = addr.toIPv4Address();
26
+ }
27
+ return addr.range() !== 'unicast';
28
+ } catch (e) {
29
+ return true; // unparseable - treat as unsafe
30
+ }
31
+ }
32
+
14
33
  class PackageCrawler {
15
34
  log;
16
35
  packages = new Set();
@@ -139,10 +158,96 @@ class PackageCrawler {
139
158
  return url.replace(/^http:/, 'https:');
140
159
  }
141
160
 
161
+ // Roots under which local-file feed reads are permitted. Reading local files supports
162
+ // locally-configured feeds (e.g. for testing); allowed roots come from
163
+ // config.localFeedDirs, plus the directory of a local master feed url. Anything else
164
+ // is rejected so a third-party feed can't point a read at an arbitrary server file.
165
+ allowedLocalRoots() {
166
+ const roots = [];
167
+ const cfg = this.config && this.config.localFeedDirs;
168
+ if (Array.isArray(cfg)) {
169
+ roots.push(...cfg);
170
+ } else if (typeof cfg === 'string' && cfg.length > 0) {
171
+ roots.push(cfg);
172
+ }
173
+ if (this.config && typeof this.config.masterUrl === 'string' && this.config.masterUrl.startsWith('/')) {
174
+ roots.push(path.dirname(this.config.masterUrl));
175
+ }
176
+ return roots.map((r) => path.resolve(r));
177
+ }
178
+
179
+ // A DNS lookup wrapper that rejects any host resolving to a non-public address.
180
+ // Enforced at connection time (so it also covers redirect targets and defeats
181
+ // DNS-rebinding), this is the SSRF guard for all outbound http(s) fetches.
182
+ // Set config.allowPrivateAddresses = true to disable (e.g. for local test registries).
183
+ ssrfLookup() {
184
+ const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
185
+ return (hostname, options, callback) => {
186
+ if (typeof options === 'function') {
187
+ callback = options;
188
+ options = {};
189
+ }
190
+ const wantAll = !!(options && options.all);
191
+ dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
192
+ if (err) {
193
+ callback(err);
194
+ return;
195
+ }
196
+ if (!allowPrivate) {
197
+ for (const a of addresses) {
198
+ if (isNonPublicAddress(a.address)) {
199
+ callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
200
+ return;
201
+ }
202
+ }
203
+ }
204
+ if (wantAll) {
205
+ callback(null, addresses);
206
+ } else {
207
+ callback(null, addresses[0].address, addresses[0].family);
208
+ }
209
+ });
210
+ };
211
+ }
212
+
213
+ // http/https agents that route every connection through the SSRF lookup. Cached so
214
+ // connections can be pooled across requests.
215
+ guardedAgents() {
216
+ if (!this._guardedAgents) {
217
+ const lookup = this.ssrfLookup();
218
+ class GuardedHttpAgent extends http.Agent {
219
+ createConnection(options, cb) {
220
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
221
+ }
222
+ }
223
+ class GuardedHttpsAgent extends https.Agent {
224
+ createConnection(options, cb) {
225
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
226
+ }
227
+ }
228
+ this._guardedAgents = {
229
+ httpAgent: new GuardedHttpAgent({ keepAlive: true }),
230
+ httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
231
+ };
232
+ }
233
+ return this._guardedAgents;
234
+ }
235
+
236
+ // Resolve a local feed path and confine it to an allowed root (path-injection guard).
237
+ resolveLocalReadPath(url) {
238
+ const resolved = path.resolve(url);
239
+ const roots = this.allowedLocalRoots();
240
+ const allowed = roots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
241
+ if (!allowed) {
242
+ throw new Error('Refusing to read local file outside the allowed feed directories: ' + url);
243
+ }
244
+ return resolved;
245
+ }
246
+
142
247
  async fetchJson(url) {
143
248
  try {
144
249
  if (url.startsWith("/")) {
145
- const content = await fs.promises.readFile(url, "utf8");
250
+ const content = await fs.promises.readFile(this.resolveLocalReadPath(url), "utf8");
146
251
  return JSON.parse(content);
147
252
  } else {
148
253
  const response = await axios.get(url, {
@@ -150,7 +255,9 @@ class PackageCrawler {
150
255
  signal: this.abortController?.signal,
151
256
  headers: {
152
257
  'User-Agent': 'FHIR Package Crawler/1.0'
153
- }
258
+ },
259
+ httpAgent: this.guardedAgents().httpAgent,
260
+ httpsAgent: this.guardedAgents().httpsAgent
154
261
  });
155
262
  return response.data;
156
263
  }
@@ -166,7 +273,7 @@ class PackageCrawler {
166
273
  async fetchXml(url) {
167
274
  try {
168
275
  if (url.startsWith("/")) {
169
- const content = await fs.promises.readFile(url, 'utf8');
276
+ const content = await fs.promises.readFile(this.resolveLocalReadPath(url), 'utf8');
170
277
  const parser = new XMLParser({
171
278
  ignoreAttributes: false,
172
279
  attributeNamePrefix: '@_',
@@ -180,7 +287,9 @@ class PackageCrawler {
180
287
  signal: this.abortController?.signal,
181
288
  headers: {
182
289
  'User-Agent': 'FHIR Package Crawler/1.0'
183
- }
290
+ },
291
+ httpAgent: this.guardedAgents().httpAgent,
292
+ httpsAgent: this.guardedAgents().httpsAgent
184
293
  });
185
294
 
186
295
  const parser = new XMLParser({
@@ -203,7 +312,7 @@ class PackageCrawler {
203
312
  async fetchUrl(url) {
204
313
  try {
205
314
  if (url.startsWith("/")) {
206
- const buffer = await fs.promises.readFile(url);
315
+ const buffer = await fs.promises.readFile(this.resolveLocalReadPath(url));
207
316
  this.totalBytes += buffer.byteLength;
208
317
  return buffer;
209
318
  } else {
@@ -213,7 +322,9 @@ class PackageCrawler {
213
322
  signal: this.abortController?.signal,
214
323
  headers: {
215
324
  'User-Agent': 'FHIR Package Crawler/1.0'
216
- }
325
+ },
326
+ httpAgent: this.guardedAgents().httpAgent,
327
+ httpsAgent: this.guardedAgents().httpsAgent
217
328
  });
218
329
 
219
330
  this.totalBytes += response.data.byteLength;
@@ -558,6 +669,12 @@ class PackageCrawler {
558
669
  if (npmPackage.hasJavaScript && !isTemplate && id !== 'hl7.fhir.pubpack') {
559
670
  throw new Error(`Package ${idver} rejected: contains JavaScript files but is not a template package`);
560
671
  }
672
+ // The feed gate (item.notForPublication) only sees the RSS entry. A package whose
673
+ // feed entry is clean can still carry notForPublication inside the tarball - that is
674
+ // a draft build that must never enter the registry. Reject it here too.
675
+ if (npmPackage.notForPublication) {
676
+ throw new Error(`Package ${idver} rejected: tarball is flagged notForPublication (draft build, not suitable for publication)`);
677
+ }
561
678
 
562
679
  // Extract URLs from package
563
680
  const urls = this.processPackageUrls(npmPackage);
@@ -592,6 +592,64 @@ class PackagesModule {
592
592
  }
593
593
  }
594
594
 
595
+ // Re-fetch a single package tarball and replace whatever is stored for it, bypassing
596
+ // the crawler's GUID dedup and notForPublication feed gate. Used to push out a
597
+ // corrected package that was already (mis)published.
598
+ async forceUpdatePackage(link) {
599
+ // Only allow fetching over http(s). This endpoint must never be usable to read
600
+ // local server files (path injection) - tarballs are always published web URLs.
601
+ if (!link || !/^https?:\/\//i.test(link)) {
602
+ throw new Error('Invalid package link (must be an http(s) URL): ' + link);
603
+ }
604
+ if (!this.crawler) {
605
+ this.crawler = new PackageCrawler(this.config, this.db, this.stats);
606
+ }
607
+
608
+ // The feed uses the versioned package.tgz url as the (permalink) GUID, so reusing
609
+ // the link as the GUID keeps a later crawl from inserting a duplicate.
610
+ const guid = link;
611
+
612
+ const buffer = await this.crawler.fetchUrl(link);
613
+ const npm = await this.crawler.extractNpmPackage(buffer, link);
614
+
615
+ // Refuse to re-store a still-broken package - that would just re-publish the bug.
616
+ if (npm.notForPublication) {
617
+ throw new Error('Refusing to store ' + npm.id + '#' + npm.version + ': fetched tarball is still flagged notForPublication');
618
+ }
619
+
620
+ const idver = npm.id + '#' + npm.version;
621
+ const replaced = await this.deleteVersionsByGuid(guid);
622
+
623
+ const itemLog = { status: '??' };
624
+ await this.crawler.store(link, link, guid, new Date(), buffer, idver, itemLog);
625
+
626
+ pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' + replaced + ' existing row(s))');
627
+ return { status: 'updated', id: npm.id, version: npm.version, replaced };
628
+ }
629
+
630
+ // Delete a stored package version and all of its child rows, by GUID.
631
+ deleteVersionsByGuid(guid) {
632
+ return new Promise((resolve, reject) => {
633
+ this.db.all('SELECT PackageVersionKey FROM PackageVersions WHERE GUID = ?', [guid], (err, rows) => {
634
+ if (err) return reject(err);
635
+ const keys = (rows || []).map(r => r.PackageVersionKey);
636
+ if (keys.length === 0) return resolve(0);
637
+ const ph = keys.map(() => '?').join(',');
638
+ const stmts = [
639
+ 'DELETE FROM PackageFHIRVersions WHERE PackageVersionKey IN (' + ph + ')',
640
+ 'DELETE FROM PackageDependencies WHERE PackageVersionKey IN (' + ph + ')',
641
+ 'DELETE FROM PackageURLs WHERE PackageVersionKey IN (' + ph + ')',
642
+ 'DELETE FROM PackageVersions WHERE PackageVersionKey IN (' + ph + ')'
643
+ ];
644
+ const runNext = (i) => {
645
+ if (i >= stmts.length) return resolve(keys.length);
646
+ this.db.run(stmts[i], keys, (e) => e ? reject(e) : runNext(i + 1));
647
+ };
648
+ runNext(0);
649
+ });
650
+ });
651
+ }
652
+
595
653
  async initializeDatabase() {
596
654
  return new Promise((resolve, reject) => {
597
655
  // Use config path if absolute, otherwise resolve relative to data dir
@@ -1223,6 +1281,52 @@ class PackagesModule {
1223
1281
  }
1224
1282
  });
1225
1283
 
1284
+ // Force-refresh specific packages, bypassing the feed. The crawler only fetches a
1285
+ // package once (it dedupes on the feed GUID) and skips anything flagged
1286
+ // notForPublication, so there is normally no way to make it re-pick-up a package
1287
+ // that was published incorrectly and later corrected. This endpoint re-fetches the
1288
+ // tarball(s) directly and replaces whatever is stored.
1289
+ //
1290
+ // POST /update-package { "links": ["http://hl7.org/fhir/uv/ips/2.0.1/package.tgz", ...] }
1291
+ //
1292
+ // The link is the versioned package.tgz url, which is exactly the GUID the feed uses,
1293
+ // so the replacement keeps the same GUID and a later crawl won't create a duplicate.
1294
+ // If config.updateToken is set, the request must carry it in the x-update-token header.
1295
+ this.router.post('/update-package', async (req, res) => {
1296
+ const start = Date.now();
1297
+ try {
1298
+ if (this.config.updateToken && req.headers['x-update-token'] !== this.config.updateToken) {
1299
+ res.status(403).json({ error: 'forbidden: missing or invalid x-update-token' });
1300
+ return;
1301
+ }
1302
+ let links = req.body && (req.body.links || req.body.packages || (req.body.url ? [req.body.url] : (req.body.link ? [req.body.link] : null)));
1303
+ if (typeof links === 'string') links = [links];
1304
+ if (!Array.isArray(links) || links.length === 0) {
1305
+ res.status(400).json({ error: 'Provide a JSON body like {"links": ["<package.tgz url>", ...]}' });
1306
+ return;
1307
+ }
1308
+ const results = [];
1309
+ for (const link of links) {
1310
+ try {
1311
+ results.push(Object.assign({ link }, await this.forceUpdatePackage(link)));
1312
+ } catch (e) {
1313
+ pckLog.error('Force update failed for ' + link + ': ' + e.message);
1314
+ results.push({ link, status: 'error', error: e.message });
1315
+ }
1316
+ }
1317
+ const failed = results.filter(r => r.status === 'error').length;
1318
+ res.status(failed === results.length ? 500 : 200).json({
1319
+ message: 'Processed ' + results.length + ' package(s), ' + failed + ' failed',
1320
+ results
1321
+ });
1322
+ } catch (error) {
1323
+ pckLog.error('update-package endpoint failed:', error);
1324
+ res.status(500).json({ error: 'update-package failed', message: error.message });
1325
+ } finally {
1326
+ this.stats.countRequest('update-package', Date.now() - start);
1327
+ }
1328
+ });
1329
+
1226
1330
  // Crawler statistics endpoint (existing)
1227
1331
  this.router.get('/stats', async (req, res) => {
1228
1332
  const start = Date.now();