fhirsmith 0.14.0 → 0.14.2

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 (73) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +3 -2
  3. package/config-template.json +20 -0
  4. package/library/fhir-openapi-schema.js +441 -0
  5. package/library/html-server.js +9 -3
  6. package/library/languages.js +15 -0
  7. package/library/npm-audit.js +265 -0
  8. package/library/openapi-doc.js +557 -0
  9. package/library/same-origin.js +62 -0
  10. package/library/utilities.js +21 -0
  11. package/nginx.md +17 -9
  12. package/package.json +10 -4
  13. package/packages/openapi.js +13 -0
  14. package/packages/openapi.yaml +603 -0
  15. package/packages/packages-template.html +4 -2
  16. package/packages/packages.js +286 -81
  17. package/packages/readme.md +120 -0
  18. package/publisher/publisher-template.html +1 -0
  19. package/publisher/publisher.js +95 -42
  20. package/registry/api.js +117 -41
  21. package/registry/crawler.js +24 -0
  22. package/registry/fhirsmith-releases.js +170 -0
  23. package/registry/model.js +10 -0
  24. package/registry/openapi.js +13 -0
  25. package/registry/openapi.yaml +311 -0
  26. package/registry/readme.md +107 -208
  27. package/registry/registry-template.html +4 -1
  28. package/registry/registry.js +183 -20
  29. package/server.js +25 -6
  30. package/testing/openapi-schemas.config.js +58 -0
  31. package/testing/openapi-schemas.json +1920 -0
  32. package/testing/openapi.js +15 -0
  33. package/testing/openapi.yaml +273 -0
  34. package/testing/readme.md +75 -1
  35. package/testing/render.js +270 -29
  36. package/testing/search.js +1 -1
  37. package/testing/store.js +152 -8
  38. package/testing/testing-template.html +2 -0
  39. package/testing/testing.js +307 -9
  40. package/translations/Messages.properties +1 -0
  41. package/tx/README.md +58 -0
  42. package/tx/cm/cm-database.js +10 -1
  43. package/tx/cm/cm-package.js +8 -1
  44. package/tx/cs/cs-rxnorm.js +23 -6
  45. package/tx/cs/cs-snomed.js +3 -72
  46. package/tx/html/tx-template.html +2 -0
  47. package/tx/library/canonical-resource.js +50 -1
  48. package/tx/library/codesystem.js +1 -0
  49. package/tx/library/conceptmap.js +1 -0
  50. package/tx/library/valueset.js +16 -0
  51. package/tx/library.js +17 -2
  52. package/tx/openapi-operations.js +363 -0
  53. package/tx/openapi-schemas.config.js +118 -0
  54. package/tx/openapi-schemas.json +7903 -0
  55. package/tx/openapi.js +289 -0
  56. package/tx/openapi.yaml +151 -0
  57. package/tx/operation-context.js +15 -0
  58. package/tx/params.js +3 -12
  59. package/tx/provider.js +10 -1
  60. package/tx/sct/editions.js +70 -0
  61. package/tx/tx-html.js +6 -0
  62. package/tx/tx.js +55 -0
  63. package/tx/vs/vs-database.js +14 -5
  64. package/tx/vs/vs-package.js +10 -0
  65. package/tx/workers/expand.js +37 -18
  66. package/tx/workers/metadata.js +0 -13
  67. package/tx/workers/translate.js +25 -1
  68. package/tx/workers/validate.js +74 -24
  69. package/tx/workers/worker.js +32 -43
  70. package/utilities/extract-schema-json.js +167 -0
  71. package/utilities/generate-openapi-schemas.js +49 -0
  72. package/utilities/submit-test-report.js +1 -1
  73. package/xig/xig.js +2 -4
@@ -14,10 +14,70 @@ const htmlServer = require('../library/html-server');
14
14
  const folders = require('../library/folder-setup');
15
15
  const escape = require('escape-html');
16
16
  const Logger = require('../library/logger');
17
- const {validateParameter} = require("../library/utilities");
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
20
  const pckLog = Logger.getInstance().child({ module: 'packages' });
21
+ const packagesOpenApi = require('./openapi');
22
+
23
+ // Query parameter rules for validateQueryParams. These are also the contract published in
24
+ // openapi.yaml, and tests/packages/openapi.test.js checks that the two agree - so change
25
+ // both together.
26
+ const SEARCH_PARAMS = {
27
+ name: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ },
28
+ dependson: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|@-]*$/ },
29
+ pkgcanonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*%?$/ },
30
+ canonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*$/ },
31
+ fhirversion: { maxLength: 10, pattern: /^(R2|R2B|R3|R4|R4B|R5|R6)?$/ },
32
+ dependency: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|@-]*$/ },
33
+ sort: { maxLength: 20, pattern: /^-?(name|version|date|count|fhirversion|kind|canonical)$/ },
34
+ objWrapper: { maxLength: 10, pattern: /^(true|false)?$/ },
35
+ // Sent by the Java PackageClient (org.hl7.fhir.utilities.npm). Accepted, but has no
36
+ // effect: a search already returns pre-release versions.
37
+ prerelease: { maxLength: 5, pattern: /^(true|false)?$/ }
38
+ };
39
+
40
+ // /-/v1/search also takes the npm registry search parameters. npm always sends all of
41
+ // these; quality, popularity and maintenance are ranking weights, accepted and ignored.
42
+ const V1_SEARCH_PARAMS = {
43
+ ...SEARCH_PARAMS,
44
+ text: { maxLength: 200, pattern: /^[a-zA-Z0-9._:@/#| -]*$/ },
45
+ size: { maxLength: 3, pattern: /^\d{1,3}$/ },
46
+ from: { maxLength: 6, pattern: /^\d{1,6}$/ },
47
+ quality: { maxLength: 10, pattern: /^(\d+(\.\d+)?)?$/ },
48
+ popularity: { maxLength: 10, pattern: /^(\d+(\.\d+)?)?$/ },
49
+ maintenance: { maxLength: 10, pattern: /^(\d+(\.\d+)?)?$/ }
50
+ };
51
+ const V1_SEARCH_MAX_SIZE = 250;
52
+
53
+ // FHIR release codes accepted by fhirversion=, mapped to the version prefix they select.
54
+ const FHIR_RELEASE_VERSIONS = {
55
+ 'R2': '1.0',
56
+ 'R2B': '1.4',
57
+ 'R3': '3.0',
58
+ 'R4': '4.0',
59
+ 'R4B': '4.3',
60
+ 'R5': '5.0',
61
+ 'R6': '6.0'
62
+ };
63
+
64
+ // No default for dateValue: a default computed here would be frozen at the date the server
65
+ // started. The /updates handler supplies today's date when none is given.
66
+ const UPDATES_PARAMS = {
67
+ dateType: { maxLength: 10, pattern: /^(relative|absolute)?$/, default: 'relative' },
68
+ daysValue: { maxLength: 3, pattern: /^\d{1,3}$/, default: '10' },
69
+ dateValue: { maxLength: 10, pattern: /^\d{4}-\d{2}-\d{2}$/ }
70
+ };
71
+
72
+ const BROKEN_PARAMS = {
73
+ filter: { maxLength: 100, pattern: /^[a-zA-Z0-9._-]*$/ }
74
+ };
75
+
76
+ // Path parameter rules for GET /:id/:version (also published in openapi.yaml).
77
+ const PACKAGE_ID_PATTERN = /^(@[a-z0-9._-]+\/)?[a-zA-Z0-9._-]+$/;
78
+ const PACKAGE_ID_MAX_LENGTH = 100;
79
+ const PACKAGE_VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/;
80
+ const PACKAGE_VERSION_MAX_LENGTH = 50;
21
81
 
22
82
  class PackagesModule {
23
83
  constructor(stats) {
@@ -52,6 +112,8 @@ class PackagesModule {
52
112
  "frame-ancestors 'none'"
53
113
  ].join('; '));
54
114
  res.removeHeader('X-Powered-By');
115
+ // RFC 8631: where to find the machine-readable description of this API
116
+ res.setHeader('Link', `<${req.baseUrl}/openapi.json>; rel="service-desc", <${req.baseUrl}/openapi>; rel="service-doc"`);
55
117
  next();
56
118
  });
57
119
  }
@@ -145,6 +207,10 @@ class PackagesModule {
145
207
  } else if (condition.operator === 'IN_SUBQUERY') {
146
208
  query += ` AND ${condition.column} IN (${condition.subquery})`;
147
209
  params.push(condition.value);
210
+ } else if (condition.operator === 'TEXT') {
211
+ // A free-text search term: in the package id or its description
212
+ query += ' AND (PackageVersions.Id LIKE ? OR CAST(PackageVersions.Description AS TEXT) LIKE ?)';
213
+ params.push(`%${condition.value}%`, `%${condition.value}%`);
148
214
  }
149
215
  });
150
216
  return { query, params };
@@ -158,6 +224,7 @@ class PackagesModule {
158
224
  canonicalUrl = '',
159
225
  fhirVersion = '',
160
226
  dependency = '',
227
+ text = '',
161
228
  sort = ''
162
229
  } = params;
163
230
 
@@ -182,13 +249,14 @@ class PackagesModule {
182
249
  // Add the missing dependency search logic
183
250
  if (dependson) {
184
251
  validateParameter(dependson, "dependson", String);
185
- versioned = dependson.includes('#');
186
- // This requires a subquery to PackageDependencies table
252
+ versioned = /[#|@]/.test(dependson);
253
+ // Dependencies are stored as id@version (npm convention), so accept # and | as
254
+ // the separator too.
187
255
  conditions.push({
188
256
  column: 'PackageVersions.PackageVersionKey',
189
257
  operator: 'IN_SUBQUERY',
190
258
  subquery: 'SELECT PackageVersionKey FROM PackageDependencies WHERE Dependency LIKE ?',
191
- value: `%${dependson}%`
259
+ value: `%${dependson.replace(/[#|]/g, '@')}%`
192
260
  });
193
261
  }
194
262
 
@@ -212,7 +280,7 @@ class PackagesModule {
212
280
 
213
281
  // Add FHIR version search (requires PackageFHIRVersions table)
214
282
  if (fhirVersion) {
215
- const mappedVersion = this.getVersion(fhirVersion);
283
+ const mappedVersion = FHIR_RELEASE_VERSIONS[fhirVersion] || fhirVersion;
216
284
  conditions.push({
217
285
  column: 'PackageVersions.PackageVersionKey',
218
286
  operator: 'IN_SUBQUERY',
@@ -224,13 +292,13 @@ class PackagesModule {
224
292
  // Add dependency search
225
293
  if (dependency) {
226
294
  validateParameter(dependency, "dependency", String);
295
+ // Dependencies are stored as id@version (npm convention). Clients send id#version
296
+ // or id|version (the Java PackageClient sends |), or just id for any version.
227
297
  let depQuery;
228
- if (dependency.includes('#')) {
229
- depQuery = `${dependency}%`;
230
- } else if (dependency.includes('|')) {
231
- depQuery = `${dependency.replace('|', '#')}%`;
298
+ if (/[#|@]/.test(dependency)) {
299
+ depQuery = `${dependency.replace(/[#|]/g, '@')}%`;
232
300
  } else {
233
- depQuery = `${dependency}#%`;
301
+ depQuery = `${dependency}@%`;
234
302
  }
235
303
 
236
304
  conditions.push({
@@ -241,6 +309,12 @@ class PackagesModule {
241
309
  });
242
310
  }
243
311
 
312
+ // Free text (npm search): every term must appear in the id or the description.
313
+ // npm qualifiers (keywords:, author:, scope: etc.) have no equivalent here and are ignored.
314
+ for (const term of text.split(/\s+/).filter(t => t && !/^[a-z-]+:/.test(t))) {
315
+ conditions.push({ operator: 'TEXT', value: term });
316
+ }
317
+
244
318
  // Build appropriate base query
245
319
  if (versioned) {
246
320
  baseQuery = `SELECT Id, Version, PubDate, FhirVersions, Kind, Canonical, Description
@@ -407,12 +481,14 @@ class PackagesModule {
407
481
  // Get counts from database
408
482
  const tableCounts = await this.getDatabaseTableCounts();
409
483
 
484
+ // the page footer: the last crawl this run, else when the database was last written
485
+ const updated = this.lastRunTime || dbAge.lastModified;
410
486
  return {
411
487
  downloadDate: downloadDate,
488
+ crawlerStatus: updated ? `last updated ${Utilities.describeAgo(updated)}` : 'not yet updated',
412
489
  totalResources: 0, // Packages don't track individual resources
413
490
  totalPackages: tableCounts.packages || 0,
414
491
  totalVersions: tableCounts.packageVersions || 0,
415
- version: '4.0.1',
416
492
  crawlerEnabled: this.config.crawler.enabled,
417
493
  lastCrawlerRun: this.lastRunTime,
418
494
  totalCrawlerRuns: this.totalRuns
@@ -426,7 +502,6 @@ class PackagesModule {
426
502
  totalResources: 0,
427
503
  totalPackages: 0,
428
504
  totalVersions: 0,
429
- version: '4.0.1',
430
505
  crawlerEnabled: false,
431
506
  lastCrawlerRun: null,
432
507
  totalCrawlerRuns: 0
@@ -434,8 +509,15 @@ class PackagesModule {
434
509
  }
435
510
  }
436
511
 
512
+ // The database file, resolved the same way initializeDatabase opens it: a relative
513
+ // config.database is relative to the data directory, not the working directory.
514
+ getDatabasePath() {
515
+ return path.isAbsolute(this.config.database) ? this.config.database : folders.filePath('packages', this.config.database);
516
+ }
517
+
437
518
  getDatabaseAgeInfo() {
438
- if (!fs.existsSync(this.config.database)) {
519
+ const dbPath = this.getDatabasePath();
520
+ if (!fs.existsSync(dbPath)) {
439
521
  return {
440
522
  lastModified: null,
441
523
  daysOld: null,
@@ -443,10 +525,11 @@ class PackagesModule {
443
525
  };
444
526
  }
445
527
 
446
- const stats = fs.statSync(this.config.database);
528
+ const stats = fs.statSync(dbPath);
447
529
  const lastModified = stats.mtime;
448
530
  const now = new Date();
449
- const ageInDays = Math.floor((now - lastModified) / (1000 * 60 * 60 * 24));
531
+ // Clamped: a file timestamp slightly ahead of this clock (e.g. on a network mount) is 'Today', not '-1 days ago'
532
+ const ageInDays = Math.max(0, Math.floor((now - lastModified) / (1000 * 60 * 60 * 24)));
450
533
 
451
534
  return {
452
535
  lastModified: lastModified,
@@ -596,7 +679,7 @@ class PackagesModule {
596
679
  async initializeDatabase() {
597
680
  return new Promise((resolve, reject) => {
598
681
  // Use config path if absolute, otherwise resolve relative to data dir
599
- const dbPath = path.isAbsolute(this.config.database) ? this.config.database : folders.filePath('packages', this.config.database);
682
+ const dbPath = this.getDatabasePath();
600
683
 
601
684
  // Ensure directory exists
602
685
  const dbDir = path.dirname(dbPath);
@@ -909,23 +992,64 @@ class PackagesModule {
909
992
  }
910
993
 
911
994
  setupRoutes() {
912
- // Parameter validation configs
913
- const searchParams = {
914
- name: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ },
915
- dependson: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ },
916
- pkgcanonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*%?$/ },
917
- canonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*$/ },
918
- fhirversion: { maxLength: 10, pattern: /^(R2|R2B|R3|R4|R4B|R5|R6)?$/ },
919
- dependency: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|-]*$/ },
920
- sort: { maxLength: 20, pattern: /^-?(name|version|date|count|fhirversion|kind|canonical)$/ },
921
- objWrapper: { maxLength: 10, pattern: /^(true|false)?$/ }
922
- };
995
+ const searchParams = SEARCH_PARAMS;
996
+ const updatesParams = UPDATES_PARAMS;
923
997
 
924
- const updatesParams = {
925
- dateType: { maxLength: 10, pattern: /^(relative|absolute)?$/, default: 'relative' },
926
- daysValue: { maxLength: 3, pattern: /^\d{1,3}$/, default: '10' },
927
- dateValue: { maxLength: 10, pattern: /^\d{4}-\d{2}-\d{2}$/, default: new Date().toISOString().split('T')[0] }
928
- };
998
+ // OpenAPI description of this API: /openapi.json, /openapi.yaml, and /openapi (an HTML
999
+ // reference for browsers, the JSON otherwise). Registered before /:id, which would
1000
+ // otherwise treat "openapi" as a package id.
1001
+ this.router.get('/openapi.json', (req, res) => {
1002
+ const start = Date.now();
1003
+ try {
1004
+ res.type('application/json').send(packagesOpenApi.getJson());
1005
+ } catch (error) {
1006
+ pckLog.error('Error in /packages/openapi.json:', error);
1007
+ res.status(500).json({error: 'Failed to load the OpenAPI description', message: error.message});
1008
+ } finally {
1009
+ this.stats.countRequest('openapi', Date.now() - start);
1010
+ }
1011
+ });
1012
+
1013
+ this.router.get('/openapi.yaml', (req, res) => {
1014
+ const start = Date.now();
1015
+ try {
1016
+ res.setHeader('Content-Type', 'application/yaml');
1017
+ res.send(packagesOpenApi.getYaml());
1018
+ } catch (error) {
1019
+ pckLog.error('Error in /packages/openapi.yaml:', error);
1020
+ res.status(500).json({error: 'Failed to load the OpenAPI description', message: error.message});
1021
+ } finally {
1022
+ this.stats.countRequest('openapi', Date.now() - start);
1023
+ }
1024
+ });
1025
+
1026
+ this.router.get('/openapi', async (req, res) => {
1027
+ const start = Date.now();
1028
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
1029
+ try {
1030
+ if (!acceptsHtml) {
1031
+ res.type('application/json').send(packagesOpenApi.getJson());
1032
+ return;
1033
+ }
1034
+ if (!htmlServer.hasTemplate('packages')) {
1035
+ htmlServer.loadTemplate('packages', path.join(__dirname, 'packages-template.html'));
1036
+ }
1037
+ const content = packagesOpenApi.renderHtml();
1038
+ const stats = await this.gatherPackageStatistics();
1039
+ stats.processingTime = Date.now() - start;
1040
+ res.setHeader('Content-Type', 'text/html');
1041
+ res.send(htmlServer.renderPage('packages', 'Package Server API', content, stats));
1042
+ } catch (error) {
1043
+ pckLog.error('Error in /packages/openapi:', error);
1044
+ if (acceptsHtml) {
1045
+ htmlServer.sendErrorResponse(res, 'packages', error);
1046
+ } else {
1047
+ res.status(500).json({error: 'Failed to load the OpenAPI description', message: error.message});
1048
+ }
1049
+ } finally {
1050
+ this.stats.countRequest('openapi', Date.now() - start);
1051
+ }
1052
+ });
929
1053
 
930
1054
  // GET /packages/catalog - Search packages or get updates
931
1055
  this.router.get('/catalog', this.validateQueryParams(searchParams), async (req, res) => {
@@ -944,12 +1068,11 @@ class PackagesModule {
944
1068
  });
945
1069
 
946
1070
  // GET /packages/-/v1/search - Search packages (v1 API)
947
- this.router.get('/-/v1/search', this.validateQueryParams(searchParams), async (req, res) => {
1071
+ this.router.get('/-/v1/search', this.validateQueryParams(V1_SEARCH_PARAMS), async (req, res) => {
948
1072
  const start = Date.now();
949
1073
  try {
950
1074
  try {
951
- req.query.objWrapper = 'true';
952
- await this.serveSearch(req, res);
1075
+ await this.serveV1Search(req, res);
953
1076
  pckLog.info("/search?" + searchParams);
954
1077
  } catch (error) {
955
1078
  pckLog.error('Error in /packages/-/v1/search:', error);
@@ -1056,9 +1179,7 @@ class PackagesModule {
1056
1179
  });
1057
1180
 
1058
1181
  // GET /packages/broken
1059
- this.router.get('/broken', this.validateQueryParams({
1060
- filter: { maxLength: 100, pattern: /^[a-zA-Z0-9._-]*$/ }
1061
- }), async (req, res) => {
1182
+ this.router.get('/broken', this.validateQueryParams(BROKEN_PARAMS), async (req, res) => {
1062
1183
  const start = Date.now();
1063
1184
  try {
1064
1185
  try {
@@ -1082,13 +1203,11 @@ class PackagesModule {
1082
1203
  // Validate path parameters
1083
1204
  const {id, version} = req.params;
1084
1205
 
1085
- if (!id || !version ||
1086
- !/^(@[a-z0-9._-]+\/)?[a-zA-Z0-9._-]+$/.test(id) ||
1087
- !/^[a-zA-Z0-9._-]+$/.test(version)) {
1206
+ if (!id || !version || !PACKAGE_ID_PATTERN.test(id) || !PACKAGE_VERSION_PATTERN.test(version)) {
1088
1207
  return res.status(400).json({error: `Invalid package id or version format: ${id}`});
1089
1208
  }
1090
1209
 
1091
- if (id.length > 100 || version.length > 50) {
1210
+ if (id.length > PACKAGE_ID_MAX_LENGTH || version.length > PACKAGE_VERSION_MAX_LENGTH) {
1092
1211
  return res.status(400).json({error: `Package id or version too long: ${id}`});
1093
1212
  }
1094
1213
 
@@ -1146,7 +1265,7 @@ class PackagesModule {
1146
1265
  });
1147
1266
 
1148
1267
  // GET /packages/:id - Get package versions
1149
- this.router.get('/:id', async (req, res) => {
1268
+ this.router.get('/:id', async (req, res, next) => {
1150
1269
  const start = Date.now();
1151
1270
  try {
1152
1271
 
@@ -1154,10 +1273,13 @@ class PackagesModule {
1154
1273
  const {id} = req.params;
1155
1274
  const {sort} = req.query;
1156
1275
 
1157
- // Don't process routes that are handled elsewhere
1276
+ // Don't process routes that are handled elsewhere. These must call next():
1277
+ // returning without responding leaves the request hanging, which is what
1278
+ // /status, /stats and /search (registered after this route) used to do.
1158
1279
  if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates'].includes(id) ||
1159
1280
  id.endsWith('.html') || id === '-') {
1160
- return; // Let other routes handle these
1281
+ next();
1282
+ return;
1161
1283
  }
1162
1284
 
1163
1285
  await this.serveVersions(id, sort, req.secure, req, res);
@@ -1277,10 +1399,6 @@ class PackagesModule {
1277
1399
  totalRuns: this.totalRuns,
1278
1400
  lastLog: this.lastCrawlerLog || null
1279
1401
  },
1280
- paths: {
1281
- database: this.config.database,
1282
- mirror: this.config.mirrorPath
1283
- },
1284
1402
  config: {
1285
1403
  masterUrl: this.config.masterUrl
1286
1404
  }
@@ -1794,7 +1912,7 @@ class PackagesModule {
1794
1912
 
1795
1913
  const versionObj = {
1796
1914
  name: id,
1797
- _id: `${id}@${this.interpretVersion(pv.FhirVersions)}`,
1915
+ _id: `${id}@${pv.Version}`,
1798
1916
  version: pv.Version,
1799
1917
  date: new Date(pv.PubDate).toISOString(),
1800
1918
  fhirVersion: this.interpretVersion(pv.FhirVersions),
@@ -1845,7 +1963,7 @@ class PackagesModule {
1845
1963
  buildTarballUrl(id, version, secure, req) {
1846
1964
  if (this.config.bucketPath) {
1847
1965
  let bucketUrl = this.getBucketUrl(secure);
1848
- return `${bucketUrl}${id}-${version}.tgz`;
1966
+ return `${bucketUrl}${this.fixPrefix(id)}-${version}.tgz`;
1849
1967
  } else {
1850
1968
  // Use direct server URL
1851
1969
  const protocol = secure ? 'https' : 'http';
@@ -1987,6 +2105,52 @@ class PackagesModule {
1987
2105
  return escape(text).replace(/\n/g, '<br>');
1988
2106
  }
1989
2107
 
2108
+ // GET /-/v1/search: the npm registry search API. Takes the FHIR search parameters plus
2109
+ // npm's text/size/from, and returns npm's result shape. Without size, every result is
2110
+ // returned (npm's own clients always send size).
2111
+ async serveV1Search(req, res) {
2112
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
2113
+ if (acceptsHtml) {
2114
+ await this.serveSearch(req, res);
2115
+ return;
2116
+ }
2117
+
2118
+ const q = req.query;
2119
+ const secure = req.secure || req.headers['x-forwarded-proto'] === 'https';
2120
+ try {
2121
+ const results = await this.searchPackages({
2122
+ name: q.name,
2123
+ dependson: q.dependson,
2124
+ canonicalPkg: q.pkgcanonical,
2125
+ canonicalUrl: q.canonical,
2126
+ fhirVersion: q.fhirversion,
2127
+ dependency: q.dependency,
2128
+ text: q.text,
2129
+ sort: q.sort
2130
+ }, req, secure);
2131
+
2132
+ const from = parseInt(q.from, 10) || 0;
2133
+ const page = q.size
2134
+ ? results.slice(from, from + Math.min(parseInt(q.size, 10), V1_SEARCH_MAX_SIZE))
2135
+ : results.slice(from);
2136
+
2137
+ res.setHeader('Content-Type', 'application/json');
2138
+ res.json({
2139
+ objects: page.map(pkg => ({
2140
+ // The npm CLI requires maintainers (it calls maintainers.map) and reads keywords
2141
+ package: { ...pkg, keywords: [], maintainers: [] },
2142
+ score: { final: 1, detail: { quality: 1, popularity: 1, maintenance: 1 } },
2143
+ searchScore: 1
2144
+ })),
2145
+ total: results.length,
2146
+ time: new Date().toUTCString()
2147
+ });
2148
+ } catch (error) {
2149
+ pckLog.error('Error in v1 search:', error);
2150
+ res.status(500).json({error: 'Search failed', message: error.message});
2151
+ }
2152
+ }
2153
+
1990
2154
  async serveSearch(req, res) {
1991
2155
  const {
1992
2156
  name = '',
@@ -2029,7 +2193,7 @@ class PackagesModule {
2029
2193
  // Return JSON response
2030
2194
  let responseData;
2031
2195
 
2032
- if (objWrapper) {
2196
+ if (objWrapper === true || objWrapper === 'true') {
2033
2197
  // V1 API format with object wrapper
2034
2198
  responseData = {
2035
2199
  objects: results.map(pkg => ({package: pkg}))
@@ -2265,18 +2429,6 @@ class PackagesModule {
2265
2429
  return str.replace(/'/g, "''");
2266
2430
  }
2267
2431
 
2268
- getVersion(fhirVersion) {
2269
- // Map common FHIR version aliases to actual versions
2270
- const versionMap = {
2271
- 'R2': '1.0.2',
2272
- 'R3': '3.0.2',
2273
- 'R4': '4.0.1',
2274
- 'R5': '5.0.0'
2275
- };
2276
-
2277
- return versionMap[fhirVersion] || fhirVersion;
2278
- }
2279
-
2280
2432
  interpretVersion(fhirVersions) {
2281
2433
  if (!fhirVersions) return '';
2282
2434
 
@@ -2300,7 +2452,7 @@ class PackagesModule {
2300
2452
  buildPackageUrl(id, version, secure = false, req = null) {
2301
2453
  if (this.config.bucketPath) {
2302
2454
  let bucketUrl = this.getBucketUrl(secure);
2303
- return `${bucketUrl}${id}-${version}.tgz`;
2455
+ return `${bucketUrl}${this.fixPrefix(id)}-${version}.tgz`;
2304
2456
  } else {
2305
2457
  // Use direct server URL
2306
2458
  const protocol = secure ? 'https' : 'http';
@@ -2337,6 +2489,15 @@ class PackagesModule {
2337
2489
  case 'count':
2338
2490
  comparison = (a.count || 0) - (b.count || 0);
2339
2491
  break;
2492
+ case 'fhirversion':
2493
+ comparison = this.compareVersions(a.fhirVersion || '', b.fhirVersion || '');
2494
+ break;
2495
+ case 'kind':
2496
+ comparison = (a.kind || '').localeCompare(b.kind || '');
2497
+ break;
2498
+ case 'canonical':
2499
+ comparison = (a.canonical || '').localeCompare(b.canonical || '');
2500
+ break;
2340
2501
  default:
2341
2502
  return 0;
2342
2503
  }
@@ -2345,20 +2506,38 @@ class PackagesModule {
2345
2506
  });
2346
2507
  }
2347
2508
 
2509
+ // Semver-style comparison: numeric major.minor.patch, then a version with a pre-release
2510
+ // label (1.0.0-ballot) sorts before the release (1.0.0). Anything unparseable falls back
2511
+ // to string order, so the result is always a number (Array.sort misbehaves on NaN).
2348
2512
  compareVersions(a, b) {
2349
- const aParts = a.split('.').map(Number);
2350
- const bParts = b.split('.').map(Number);
2351
-
2352
- for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
2353
- const aPart = aParts[i] || 0;
2354
- const bPart = bParts[i] || 0;
2355
-
2513
+ const parse = v => {
2514
+ const [core, ...pre] = String(v).trim().split('-');
2515
+ return { nums: core.split('.').map(n => parseInt(n, 10)), pre: pre.join('-') };
2516
+ };
2517
+ const va = parse(a);
2518
+ const vb = parse(b);
2519
+
2520
+ for (let i = 0; i < Math.max(va.nums.length, vb.nums.length); i++) {
2521
+ const aPart = va.nums[i] || 0;
2522
+ const bPart = vb.nums[i] || 0;
2523
+ if (Number.isNaN(aPart) || Number.isNaN(bPart)) {
2524
+ return String(a).localeCompare(String(b));
2525
+ }
2356
2526
  if (aPart !== bPart) {
2357
2527
  return aPart - bPart;
2358
2528
  }
2359
2529
  }
2360
2530
 
2361
- return 0;
2531
+ if (va.pre === vb.pre) {
2532
+ return 0;
2533
+ }
2534
+ if (!va.pre) {
2535
+ return 1;
2536
+ }
2537
+ if (!vb.pre) {
2538
+ return -1;
2539
+ }
2540
+ return va.pre.localeCompare(vb.pre, undefined, { numeric: true });
2362
2541
  }
2363
2542
 
2364
2543
  generateSearchHtml(req, results, params) {
@@ -2691,13 +2870,14 @@ class PackagesModule {
2691
2870
 
2692
2871
  if (logData) {
2693
2872
  if (logData.startTime) {
2694
- content += `<tr><td>Start Time:</td><td>${new Date(logData.startTime).toLocaleString()}</td></tr>`;
2695
- }
2696
- if (logData.endTime) {
2697
- content += `<tr><td>End Time:</td><td>${new Date(logData.endTime).toLocaleString()}</td></tr>`;
2873
+ // relative, because the server's local clock means nothing to the reader
2874
+ content += `<tr><td>Started:</td><td title="${new Date(logData.startTime).toISOString()}">${this.formatTimeAgo(logData.startTime)}</td></tr>`;
2698
2875
  }
2699
2876
  if (logData.runTime) {
2700
- content += `<tr><td>Duration:</td><td>${logData.runTime}</td></tr>`;
2877
+ const ms = parseInt(logData.runTime, 10);
2878
+ if (!isNaN(ms)) {
2879
+ content += `<tr><td>Duration:</td><td>${(ms / 1000).toFixed(1)} seconds</td></tr>`;
2880
+ }
2701
2881
  }
2702
2882
  if (logData.totalBytes) {
2703
2883
  content += `<tr><td>Total Bytes:</td><td>${logData.totalBytes.toLocaleString()}</td></tr>`;
@@ -2733,6 +2913,24 @@ class PackagesModule {
2733
2913
  return content;
2734
2914
  }
2735
2915
 
2916
+ formatTimeAgo(time) {
2917
+ const secs = Math.max(0, Math.round((Date.now() - new Date(time).getTime()) / 1000));
2918
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'} ago`;
2919
+ if (secs < 60) {
2920
+ return plural(secs, 'second');
2921
+ }
2922
+ const mins = Math.floor(secs / 60);
2923
+ if (mins < 60) {
2924
+ return plural(mins, 'minute');
2925
+ }
2926
+ const hours = Math.floor(mins / 60);
2927
+ if (hours < 24) {
2928
+ const rem = mins % 60;
2929
+ return rem ? `${hours} hour${hours === 1 ? '' : 's'} ${rem} minute${rem === 1 ? '' : 's'} ago` : plural(hours, 'hour');
2930
+ }
2931
+ return plural(Math.floor(hours / 24), 'day');
2932
+ }
2933
+
2736
2934
  // Add this new method to format the crawler log as readable text
2737
2935
  formatCrawlerLog(logData) {
2738
2936
  let output = '';
@@ -2773,12 +2971,12 @@ class PackagesModule {
2773
2971
  getStatus() {
2774
2972
  return {
2775
2973
  enabled: true,
2974
+ // No file system paths here: this is served publicly (/packages/status and the
2975
+ // server health check)
2776
2976
  database: {
2777
- connected: this.db ? true : false,
2778
- path: this.config.database
2977
+ connected: this.db ? true : false
2779
2978
  },
2780
2979
  mirror: {
2781
- path: this.config.mirrorPath,
2782
2980
  exists: fs.existsSync(this.config.mirrorPath)
2783
2981
  },
2784
2982
  crawler: {
@@ -2857,4 +3055,11 @@ class PackagesModule {
2857
3055
  }
2858
3056
  }
2859
3057
 
3058
+ PackagesModule.QUERY_PARAMS = { search: SEARCH_PARAMS, v1Search: V1_SEARCH_PARAMS, updates: UPDATES_PARAMS, broken: BROKEN_PARAMS };
3059
+ PackagesModule.FHIR_RELEASE_VERSIONS = FHIR_RELEASE_VERSIONS;
3060
+ PackagesModule.PATH_PARAMS = {
3061
+ id: { maxLength: PACKAGE_ID_MAX_LENGTH, pattern: PACKAGE_ID_PATTERN },
3062
+ version: { maxLength: PACKAGE_VERSION_MAX_LENGTH, pattern: PACKAGE_VERSION_PATTERN }
3063
+ };
3064
+
2860
3065
  module.exports = PackagesModule;