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.
@@ -0,0 +1,450 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * npm registry protocol checks.
9
+ *
10
+ * There is no published conformance suite for the npm registry protocol, so this is our own.
11
+ * It walks the path an npm client takes: search for a package by name, read its package
12
+ * document, read one version's document, and download that version's tarball. At each step
13
+ * it checks the parts of the response that npm clients depend on.
14
+ *
15
+ * Every check must pass against registry.npmjs.org. If one fails there, the check is wrong
16
+ * (or npm has changed its behaviour, which has happened before), not the registry.
17
+ *
18
+ * Used by /packages/npm-check (a report on any registry) and by the test suites
19
+ * tests/packages/npm-registry*.test.js, so all of them run the same checks.
20
+ *
21
+ * References:
22
+ * https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md
23
+ * https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md
24
+ */
25
+
26
+ const crypto = require('crypto');
27
+ const zlib = require('zlib');
28
+ const axios = require('axios');
29
+
30
+ // What the npm CLI sends when it fetches a package document
31
+ const NPM_ACCEPT = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*';
32
+ const MISSING_PACKAGE = 'no-such-package-zz9-plural-z-alpha';
33
+ const SEARCH_SIZE = 20;
34
+
35
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
36
+ const SHA1_HEX = /^[0-9a-f]{40}$/;
37
+ const SRI = /^(sha512|sha384|sha256|sha1)-[A-Za-z0-9+/]+=*$/;
38
+
39
+ const STEPS = [
40
+ { id: 'search', title: '1. Search', request: 'GET /-/v1/search?text={name}' },
41
+ { id: 'package', title: '2. Package document', request: 'GET /{name}' },
42
+ { id: 'version', title: '3. Version document', request: 'GET /{name}/{version}' },
43
+ { id: 'tarball', title: '4. Tarball', request: 'GET {dist.tarball}' }
44
+ ];
45
+
46
+ // A check's failure, with a message for people
47
+ class CheckFailure extends Error {}
48
+
49
+ function show(v) {
50
+ const s = v === undefined ? 'nothing' : JSON.stringify(v);
51
+ return s.length > 120 ? s.substring(0, 117) + '...' : s;
52
+ }
53
+
54
+ function isDate(s) {
55
+ return typeof s === 'string' && !Number.isNaN(Date.parse(s));
56
+ }
57
+
58
+ // What each check is given: the responses, and assertions that fail with a readable message
59
+ function context(target, r) {
60
+ const c = {
61
+ pkg: target.pkg,
62
+ version: target.version,
63
+ r,
64
+ fail(msg) {
65
+ throw new CheckFailure(msg);
66
+ },
67
+ ok(cond, msg) {
68
+ if (!cond) {
69
+ c.fail(msg);
70
+ }
71
+ },
72
+ eq(actual, expected, what) {
73
+ if (actual !== expected) {
74
+ c.fail(`${what}: expected ${show(expected)}, got ${show(actual)}`);
75
+ }
76
+ },
77
+ status(name, expected) {
78
+ const res = c.response(name);
79
+ c.ok(res.status === expected, `expected HTTP ${expected}, got ${res.status}`);
80
+ },
81
+ response(name) {
82
+ const res = r[name];
83
+ if (!res) {
84
+ c.fail(`not requested: ${name === 'tarball' ? 'the package document gave no tarball URL for this version' : name}`);
85
+ }
86
+ if (res.error) {
87
+ c.fail(`the request failed: ${res.error}`);
88
+ }
89
+ return res;
90
+ },
91
+ json(name) {
92
+ const res = c.response(name);
93
+ if (!/^application\/(json|vnd\.npm\.install-v1\+json)\b/i.test(res.contentType)) {
94
+ c.fail(`expected JSON, got HTTP ${res.status} ${res.contentType || 'with no Content-Type'}`);
95
+ }
96
+ try {
97
+ return JSON.parse(res.body.toString('utf8'));
98
+ } catch (e) {
99
+ return c.fail(`the response isn't valid JSON: ${e.message}`);
100
+ }
101
+ }
102
+ };
103
+ return c;
104
+ }
105
+
106
+ const CHECKS = [
107
+ // 1. search
108
+ {
109
+ step: 'search', uses: ['search'], title: 'returns 200 with a JSON search result',
110
+ run(c) {
111
+ c.status('search', 200);
112
+ const body = c.json('search');
113
+ c.ok(Array.isArray(body.objects), 'objects is not an array');
114
+ c.ok(Number.isInteger(body.total), `total is not an integer: ${show(body.total)}`);
115
+ c.ok(body.total >= body.objects.length, `total (${body.total}) is less than the number of results (${body.objects.length})`);
116
+ c.ok(isDate(body.time), `time is not a date: ${show(body.time)}`);
117
+ }
118
+ },
119
+ {
120
+ step: 'search', uses: ['search'], title: 'every result has a package with a name and a semver version, and a score',
121
+ run(c) {
122
+ const body = c.json('search');
123
+ c.ok(body.objects?.length > 0, 'there are no results');
124
+ body.objects.forEach((o, i) => {
125
+ const at = `result ${i + 1}`;
126
+ c.ok(typeof o.package?.name === 'string', `${at}: package.name is not a string`);
127
+ c.ok(SEMVER.test(o.package.version), `${at} (${o.package.name}): version is not semver: ${show(o.package.version)}`);
128
+ c.ok(o.package.date === undefined || isDate(o.package.date), `${at} (${o.package.name}): date is not a date: ${show(o.package.date)}`);
129
+ c.ok(typeof o.score?.final === 'number', `${at} (${o.package.name}): score.final is not a number`);
130
+ for (const k of ['quality', 'popularity', 'maintenance']) {
131
+ c.ok(typeof o.score.detail?.[k] === 'number', `${at} (${o.package.name}): score.detail.${k} is not a number`);
132
+ }
133
+ c.ok(typeof o.searchScore === 'number', `${at} (${o.package.name}): searchScore is not a number`);
134
+ });
135
+ }
136
+ },
137
+ {
138
+ step: 'search', uses: ['search'], title: 'the package searched for by name is the first result',
139
+ run(c) {
140
+ const names = c.json('search').objects.map(o => o.package?.name);
141
+ c.ok(names.includes(c.pkg), `${c.pkg} is not in the results`);
142
+ c.eq(names[0], c.pkg, 'the first result');
143
+ }
144
+ },
145
+ {
146
+ step: 'search', uses: ['search', 'searchSize1'], title: 'size limits the number of results',
147
+ run(c) {
148
+ const n = c.json('search').objects.length;
149
+ c.ok(n <= SEARCH_SIZE, `size=${SEARCH_SIZE} returned ${n} results`);
150
+ c.status('searchSize1', 200);
151
+ c.eq(c.json('searchSize1').objects.length, 1, 'the number of results for size=1');
152
+ }
153
+ },
154
+ {
155
+ step: 'search', uses: ['search', 'searchFrom1'], title: 'from skips results',
156
+ run(c) {
157
+ const all = c.json('search').objects.map(o => o.package?.name);
158
+ c.ok(all.length > 1, 'there is only one result, so from can\'t be checked');
159
+ c.eq(c.json('searchFrom1').objects[0]?.package?.name, all[1], 'the first result with from=1');
160
+ }
161
+ },
162
+
163
+ // 2. package document
164
+ {
165
+ step: 'package', uses: ['packument'], title: 'returns 200 with JSON for the Accept header the npm CLI sends',
166
+ run(c) {
167
+ c.status('packument', 200);
168
+ c.json('packument');
169
+ }
170
+ },
171
+ {
172
+ step: 'package', uses: ['packument'], title: 'names the package and has a latest dist-tag that points at a listed version',
173
+ run(c) {
174
+ const doc = c.json('packument');
175
+ c.eq(doc.name, c.pkg, 'name');
176
+ c.ok(typeof doc['dist-tags']?.latest === 'string', 'there is no dist-tags.latest');
177
+ const versions = Object.keys(doc.versions || {});
178
+ c.ok(versions.includes(doc['dist-tags'].latest), `dist-tags.latest (${doc['dist-tags'].latest}) is not one of the versions`);
179
+ c.ok(versions.includes(c.version), `version ${c.version} is not in the document`);
180
+ }
181
+ },
182
+ {
183
+ step: 'package', uses: ['packument'], title: 'every version is a semver, names the package, and has a tarball with a checksum',
184
+ run(c) {
185
+ const doc = c.json('packument');
186
+ for (const [v, man] of Object.entries(doc.versions || {})) {
187
+ c.ok(SEMVER.test(v), `version ${show(v)} is not semver`);
188
+ c.eq(man.name, c.pkg, `versions.${v}.name`);
189
+ c.eq(man.version, v, `versions.${v}.version`);
190
+ c.ok(/^https?:\/\//.test(man.dist?.tarball || ''), `versions.${v}: dist.tarball is not an http(s) URL: ${show(man.dist?.tarball)}`);
191
+ // npm needs at least one of these to verify the download
192
+ c.ok(SHA1_HEX.test(man.dist.shasum || '') || SRI.test(man.dist.integrity || ''), `versions.${v}: no valid dist.shasum or dist.integrity`);
193
+ c.ok(man.dist.integrity === undefined || SRI.test(man.dist.integrity), `versions.${v}: dist.integrity is not a valid SRI string`);
194
+ for (const deps of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
195
+ for (const [k, range] of Object.entries(man[deps] || {})) {
196
+ c.ok(typeof range === 'string', `versions.${v}.${deps}.${k} is not a string`);
197
+ }
198
+ }
199
+ }
200
+ }
201
+ },
202
+ {
203
+ step: 'package', uses: ['fullPackument'], title: 'the full document (Accept: application/json) has _id, version _ids and publish times',
204
+ run(c) {
205
+ c.status('fullPackument', 200);
206
+ const doc = c.json('fullPackument');
207
+ c.eq(doc._id, c.pkg, '_id');
208
+ c.eq(doc.name, c.pkg, 'name');
209
+ c.eq(doc.versions?.[c.version]?._id, `${c.pkg}@${c.version}`, `versions.${c.version}._id`);
210
+ c.ok(isDate(doc.time?.created), 'time.created is missing or not a date');
211
+ c.ok(isDate(doc.time?.modified), 'time.modified is missing or not a date');
212
+ for (const v of Object.keys(doc.versions || {})) {
213
+ c.ok(isDate(doc.time[v]), `time.${v} is missing or not a date`);
214
+ }
215
+ }
216
+ },
217
+ {
218
+ step: 'package', uses: ['missing'], title: 'an unknown package is a 404',
219
+ run(c) {
220
+ c.status('missing', 404);
221
+ }
222
+ },
223
+
224
+ // 3. version document
225
+ {
226
+ step: 'version', uses: ['versionDoc'], title: 'returns 200 with the JSON manifest of that version',
227
+ run(c) {
228
+ c.status('versionDoc', 200);
229
+ const man = c.json('versionDoc');
230
+ c.eq(man.name, c.pkg, 'name');
231
+ c.eq(man.version, c.version, 'version');
232
+ }
233
+ },
234
+ {
235
+ step: 'version', uses: ['versionDoc', 'packument'], title: 'matches the version in the package document',
236
+ run(c) {
237
+ const man = c.json('versionDoc');
238
+ const fromDoc = c.json('packument').versions?.[c.version];
239
+ c.ok(fromDoc, `version ${c.version} is not in the package document`);
240
+ c.eq(man.dist?.tarball, fromDoc.dist?.tarball, 'dist.tarball');
241
+ if (fromDoc.dist?.shasum) {
242
+ c.eq(man.dist?.shasum, fromDoc.dist.shasum, 'dist.shasum');
243
+ }
244
+ if (fromDoc.dist?.integrity) {
245
+ c.eq(man.dist?.integrity, fromDoc.dist.integrity, 'dist.integrity');
246
+ }
247
+ }
248
+ },
249
+ {
250
+ step: 'version', uses: ['latestDoc', 'packument'], title: 'a dist-tag in place of a version resolves to the tagged version',
251
+ run(c) {
252
+ c.status('latestDoc', 200);
253
+ c.eq(c.json('latestDoc').version, c.json('packument')['dist-tags']?.latest, 'version for /latest');
254
+ }
255
+ },
256
+ {
257
+ step: 'version', uses: ['missingVersion'], title: 'an unknown version is a 404',
258
+ run(c) {
259
+ c.status('missingVersion', 404);
260
+ }
261
+ },
262
+
263
+ // 4. tarball
264
+ {
265
+ step: 'tarball', uses: ['tarball'], title: 'downloads (following redirects) as a gzip file',
266
+ run(c) {
267
+ c.status('tarball', 200);
268
+ const body = c.r.tarball.body;
269
+ c.ok(body[0] === 0x1f && body[1] === 0x8b, `not a gzip file (it starts with ${body.subarray(0, 8).toString('hex') || 'nothing'})`);
270
+ }
271
+ },
272
+ {
273
+ step: 'tarball', uses: ['tarball', 'packument'], title: 'matches the checksums in the package document',
274
+ run(c) {
275
+ const body = c.response('tarball').body;
276
+ const dist = c.json('packument').versions?.[c.version]?.dist || {};
277
+ if (dist.shasum) {
278
+ c.eq(crypto.createHash('sha1').update(body).digest('hex'), dist.shasum, 'the sha1 of the tarball (dist.shasum)');
279
+ }
280
+ if (dist.integrity) {
281
+ const [alg, digest] = dist.integrity.split(/-(.*)/s);
282
+ c.eq(crypto.createHash(alg).update(body).digest('base64'), digest, `the ${alg} of the tarball (dist.integrity)`);
283
+ }
284
+ }
285
+ },
286
+ {
287
+ step: 'tarball', uses: ['tarball'], title: 'contains package/package.json for this name and version',
288
+ run(c) {
289
+ let files;
290
+ try {
291
+ files = untar(zlib.gunzipSync(c.response('tarball').body));
292
+ } catch (e) {
293
+ c.fail(`can't unpack the tarball: ${e.message}`);
294
+ }
295
+ c.ok(files['package/package.json'], 'there is no package/package.json');
296
+ let manifest;
297
+ try {
298
+ manifest = JSON.parse(files['package/package.json'].toString('utf8'));
299
+ } catch (e) {
300
+ c.fail(`package/package.json isn't valid JSON: ${e.message}`);
301
+ }
302
+ c.eq(manifest.name, c.pkg, 'package.json name');
303
+ c.eq(manifest.version, c.version, 'package.json version');
304
+ }
305
+ }
306
+ ];
307
+
308
+ // Minimal ustar reader: returns { path: Buffer } for regular files
309
+ function untar(buf) {
310
+ const files = {};
311
+ let pos = 0;
312
+ let longName = null;
313
+ while (pos + 512 <= buf.length) {
314
+ const header = buf.subarray(pos, pos + 512);
315
+ if (header.every(b => b === 0)) {
316
+ break;
317
+ }
318
+ const field = (off, len) => header.subarray(off, off + len).toString('utf8').replace(/\0.*$/s, '');
319
+ let name = field(0, 100);
320
+ const prefix = field(345, 155);
321
+ if (prefix) {
322
+ name = `${prefix}/${name}`;
323
+ }
324
+ const size = parseInt(field(124, 12).trim() || '0', 8);
325
+ const type = field(156, 1);
326
+ const data = buf.subarray(pos + 512, pos + 512 + size);
327
+ if (type === 'L') {
328
+ longName = data.toString('utf8').replace(/\0.*$/s, '');
329
+ } else {
330
+ if (longName) {
331
+ name = longName;
332
+ longName = null;
333
+ }
334
+ if (type === '0' || type === '') {
335
+ files[name] = data;
336
+ }
337
+ }
338
+ pos += 512 + Math.ceil(size / 512) * 512;
339
+ }
340
+ return files;
341
+ }
342
+
343
+ function packageUrl(base, name, version) {
344
+ // npm's form: everything escaped, including the '/' of a scoped name, except its leading '@'
345
+ const id = encodeURIComponent(name).replace(/^%40/, '@');
346
+ return version ? `${base}/${id}/${encodeURIComponent(version)}` : `${base}/${id}`;
347
+ }
348
+
349
+ /**
350
+ * An HTTP GET for the checks: { url, status, contentType, body } or { url, error }.
351
+ *
352
+ * The URLs come from whoever asks for a check (the registry), and from the registry itself
353
+ * (the tarball URL, redirects), so every request must go through SSRF-guarded agents:
354
+ * PackageCrawler.guardedAgents(), which refuse non-public addresses at connection time,
355
+ * redirects included. httpAgent and httpsAgent are required for that reason.
356
+ * options: httpAgent, httpsAgent, timeout (ms), maxBytes (per response), userAgent.
357
+ */
358
+ function makeGetter(options = {}) {
359
+ if (!options.httpAgent || !options.httpsAgent) {
360
+ throw new Error('makeGetter needs the SSRF-guarded httpAgent and httpsAgent (PackageCrawler.guardedAgents())');
361
+ }
362
+ return async (url, accept = 'application/json') => {
363
+ if (!/^https?:\/\//i.test(url)) {
364
+ return { url, error: 'not an http(s) URL' };
365
+ }
366
+ try {
367
+ const res = await axios.get(url, {
368
+ responseType: 'arraybuffer',
369
+ headers: { accept, 'user-agent': options.userAgent || 'FHIRsmith npm registry check' },
370
+ timeout: options.timeout || 30000,
371
+ maxContentLength: options.maxBytes || 100 * 1024 * 1024,
372
+ maxRedirects: 5,
373
+ validateStatus: () => true,
374
+ httpAgent: options.httpAgent,
375
+ httpsAgent: options.httpsAgent
376
+ });
377
+ return { url, status: res.status, contentType: String(res.headers['content-type'] || ''), body: Buffer.from(res.data) };
378
+ } catch (e) {
379
+ const why = e.code === 'ECONNABORTED' ? 'timed out' : /maxContentLength/.test(e.message) ? 'the response is too large' : e.message;
380
+ return { url, error: why };
381
+ }
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Runs every check against one registry. target is { registry, pkg, version }; get is a
387
+ * function from makeGetter. Returns the report: per step, per check, pass or fail with a
388
+ * message, and the requests the checks were based on.
389
+ */
390
+ async function runNpmCheck(target, get) {
391
+ let base = target.registry;
392
+ while (base.endsWith('/')) {
393
+ base = base.slice(0, -1);
394
+ }
395
+ const { pkg, version } = target;
396
+ const started = new Date();
397
+ const search = extra => get(`${base}/-/v1/search?text=${encodeURIComponent(pkg)}${extra}`);
398
+ const r = {};
399
+ [r.search, r.searchSize1, r.searchFrom1, r.packument, r.fullPackument, r.versionDoc, r.latestDoc, r.missing, r.missingVersion] = await Promise.all([
400
+ search(`&size=${SEARCH_SIZE}`),
401
+ search('&size=1'),
402
+ search(`&size=${SEARCH_SIZE}&from=1`),
403
+ get(packageUrl(base, pkg), NPM_ACCEPT),
404
+ get(packageUrl(base, pkg), 'application/json'),
405
+ get(packageUrl(base, pkg, version)),
406
+ get(packageUrl(base, pkg, 'latest')),
407
+ get(packageUrl(base, MISSING_PACKAGE)),
408
+ get(packageUrl(base, pkg, '99999.0.0'))
409
+ ]);
410
+ if (r.packument.status === 200) {
411
+ try {
412
+ const tarball = JSON.parse(r.packument.body.toString('utf8')).versions?.[version]?.dist?.tarball;
413
+ if (typeof tarball === 'string' && /^https?:\/\//.test(tarball)) {
414
+ r.tarball = await get(tarball, '*/*');
415
+ }
416
+ } catch (e) {
417
+ // the package document checks report it
418
+ }
419
+ }
420
+
421
+ const c = context({ pkg, version }, r);
422
+ const results = CHECKS.map(check => {
423
+ try {
424
+ check.run(c);
425
+ return { step: check.step, title: check.title, uses: check.uses, status: 'pass' };
426
+ } catch (e) {
427
+ const message = e instanceof CheckFailure ? e.message : `the check itself failed: ${e.message}`;
428
+ return { step: check.step, title: check.title, uses: check.uses, status: 'fail', message };
429
+ }
430
+ });
431
+
432
+ const requests = {};
433
+ for (const [name, res] of Object.entries(r)) {
434
+ requests[name] = res.error ? { url: res.url, error: res.error }
435
+ : { url: res.url, status: res.status, contentType: res.contentType, bytes: res.body.length };
436
+ }
437
+ return {
438
+ registry: base,
439
+ package: pkg,
440
+ version,
441
+ started: started.toISOString(),
442
+ finished: new Date().toISOString(),
443
+ passed: results.filter(x => x.status === 'pass').length,
444
+ failed: results.filter(x => x.status === 'fail').length,
445
+ steps: STEPS.map(s => ({ ...s, checks: results.filter(x => x.step === s.id) })),
446
+ requests
447
+ };
448
+ }
449
+
450
+ module.exports = { runNpmCheck, makeGetter, CHECKS, STEPS, untar, packageUrl };
@@ -42,6 +42,8 @@ tags:
42
42
  description: npm-compatible package documents and downloads
43
43
  - name: reports
44
44
  description: Registry-wide reports
45
+ - name: tools
46
+ description: Tools for other registries
45
47
 
46
48
  paths:
47
49
  /catalog:
@@ -99,8 +101,9 @@ paths:
99
101
  npm qualifiers (`keywords:`, `author:`, `scope:`, `not:`, `is:` ...) are
100
102
  ignored.
101
103
  * There is no relevance ranking: `quality`, `popularity` and `maintenance` are
102
- accepted and ignored, every `score` is 1, and results are in publication
103
- order unless `sort` is given.
104
+ accepted and ignored, and every `score` is 1. Unless `sort` is given, a
105
+ package whose id is the whole of `text` comes first (as on npm), then the
106
+ rest in publication order.
104
107
  * Without `size`, every result is returned (npm defaults to 20, but npm's own
105
108
  clients always send `size`).
106
109
  * `keywords` and `maintainers` are always empty, and there is no `publisher`.
@@ -237,6 +240,78 @@ paths:
237
240
  '400': { $ref: '#/components/responses/BadRequest' }
238
241
  '500': { $ref: '#/components/responses/ServerError' }
239
242
 
243
+ /npm-check:
244
+ get:
245
+ tags: [tools]
246
+ operationId: checkNpmRegistry
247
+ summary: Check a registry against the npm registry protocol
248
+ description: |
249
+ Runs the npm registry protocol checks against any registry, the way an npm client
250
+ uses it: search for the package by name, read its package document, read the
251
+ version's document, and download the version's tarball. Returns a report of each
252
+ check, pass or fail, with the reason for each failure and the requests it was based
253
+ on. The same checks run, as a control, against registry.npmjs.org once a day.
254
+
255
+ With `Accept: text/html` and no parameters, this is a form for running the checks.
256
+
257
+ A run makes about ten requests, each limited in time and size, and only to public
258
+ addresses. Runs are rate limited per client address, and the server runs only a
259
+ few at once. A server can turn the check off (`npmCheck.enabled` in its
260
+ configuration), and then this returns 404.
261
+ parameters:
262
+ - name: registry
263
+ in: query
264
+ required: true
265
+ description: The registry's base URL, as given to `npm --registry`.
266
+ schema:
267
+ type: string
268
+ maxLength: 500
269
+ pattern: '^https?:\/\/[^\s<>"''`]+$'
270
+ example: https://packages2.fhir.org/packages
271
+ - name: package
272
+ in: query
273
+ required: true
274
+ description: A package the registry holds.
275
+ schema:
276
+ type: string
277
+ maxLength: 100
278
+ pattern: '^(@[a-z0-9._-]+\/)?[a-zA-Z0-9._-]+$'
279
+ example: hl7.fhir.r4.core
280
+ - name: version
281
+ in: query
282
+ required: true
283
+ description: A version of that package the registry holds.
284
+ schema:
285
+ type: string
286
+ maxLength: 50
287
+ pattern: '^[a-zA-Z0-9._-]+$'
288
+ example: 4.0.1
289
+ responses:
290
+ '200':
291
+ description: The report
292
+ content:
293
+ application/json:
294
+ schema: { $ref: '#/components/schemas/NpmCheckReport' }
295
+ text/html:
296
+ schema: { type: string }
297
+ '400': { $ref: '#/components/responses/BadRequest' }
298
+ '404':
299
+ description: The check is turned off on this server
300
+ content:
301
+ application/json:
302
+ schema: { $ref: '#/components/schemas/Error' }
303
+ '429':
304
+ description: Too many runs from this client address; try again later
305
+ content:
306
+ application/json:
307
+ schema: { $ref: '#/components/schemas/Error' }
308
+ '503':
309
+ description: The server is already running as many checks as it allows; try again shortly
310
+ content:
311
+ application/json:
312
+ schema: { $ref: '#/components/schemas/Error' }
313
+ '500': { $ref: '#/components/responses/ServerError' }
314
+
240
315
  /{id}:
241
316
  get:
242
317
  tags: [registry]
@@ -272,9 +347,19 @@ paths:
272
347
  get:
273
348
  tags: [registry]
274
349
  operationId: downloadPackage
275
- summary: Download a package tarball
350
+ summary: Download a package tarball, or get a version's document
276
351
  description: |
277
- Returns the package tarball (`.tgz`).
352
+ Returns the package tarball (`.tgz`) - or, when the request's `Accept` header
353
+ asks for `application/json` (or npm's `application/vnd.npm.install-v1+json`),
354
+ the version's npm document: the same object as that version's entry in the
355
+ package document. The response varies on `Accept` and `FHIR-Package-Download`.
356
+
357
+ A request with a `FHIR-Package-Download` header always gets the tarball. The
358
+ HL7 Java tools send it; a client that downloads tarballs from this URL while
359
+ asking for JSON should send it too.
360
+
361
+ `latest` in place of a version (npm's dist-tag) means the version in the package
362
+ document's `dist-tags.latest`.
278
363
 
279
364
  If no version matches exactly, the most recently published pre-release of that
280
365
  version (`{version}-*`) is returned instead, so `1.0.0` will find `1.0.0-ballot`
@@ -302,9 +387,15 @@ paths:
302
387
  maxLength: 50
303
388
  pattern: '^[a-zA-Z0-9._-]+$'
304
389
  example: 1.1.0
390
+ - name: FHIR-Package-Download
391
+ in: header
392
+ required: false
393
+ description: Present (with any value, conventionally `tarball`), the tarball is returned whatever `Accept` says.
394
+ schema: { type: string }
395
+ example: tarball
305
396
  responses:
306
397
  '200':
307
- description: The package tarball
398
+ description: The package tarball, or the version document
308
399
  headers:
309
400
  Content-Disposition:
310
401
  description: '`attachment; filename="{id}#{version}.tgz"`'
@@ -314,6 +405,8 @@ paths:
314
405
  schema:
315
406
  type: string
316
407
  contentEncoding: binary
408
+ application/json:
409
+ schema: { $ref: '#/components/schemas/PackageVersion' }
317
410
  '302':
318
411
  description: Redirect to the tarball in bucket storage
319
412
  headers:
@@ -325,6 +418,8 @@ paths:
325
418
  content:
326
419
  text/plain:
327
420
  schema: { type: string }
421
+ application/json:
422
+ schema: { $ref: '#/components/schemas/Error' }
328
423
  '500': { $ref: '#/components/responses/ServerError' }
329
424
 
330
425
  components:
@@ -545,6 +640,16 @@ components:
545
640
  type: object
546
641
  description: Every version this registry holds, keyed by version.
547
642
  additionalProperties: { $ref: '#/components/schemas/PackageVersion' }
643
+ time:
644
+ type: object
645
+ description: |
646
+ Publication time of each version, keyed by version, plus `created` (the
647
+ earliest) and `modified` (the latest), as in npm's package document.
648
+ required: [created, modified]
649
+ properties:
650
+ created: { type: string, format: date-time }
651
+ modified: { type: string, format: date-time }
652
+ additionalProperties: { type: string, format: date-time }
548
653
 
549
654
  PackageVersion:
550
655
  type: object
@@ -594,6 +699,54 @@ components:
594
699
  date: '2026-10-04T00:00:00.000Z'
595
700
  'example.fhir.ig#1.0.0': ['hl7.fhir.uv.missing@2.0.0']
596
701
 
702
+ NpmCheckReport:
703
+ type: object
704
+ description: The result of running the npm registry protocol checks against a registry.
705
+ required: [registry, package, version, started, finished, passed, failed, steps, requests]
706
+ properties:
707
+ registry: { type: string, format: uri }
708
+ package: { type: string }
709
+ version: { type: string }
710
+ started: { type: string, format: date-time }
711
+ finished: { type: string, format: date-time }
712
+ passed: { type: integer, description: Checks that passed }
713
+ failed: { type: integer, description: Checks that failed }
714
+ steps:
715
+ type: array
716
+ items:
717
+ type: object
718
+ required: [id, title, request, checks]
719
+ properties:
720
+ id: { type: string, enum: [search, package, version, tarball] }
721
+ title: { type: string }
722
+ request: { type: string, description: The request the step is about }
723
+ checks:
724
+ type: array
725
+ items:
726
+ type: object
727
+ required: [step, title, uses, status]
728
+ properties:
729
+ step: { type: string }
730
+ title: { type: string }
731
+ status: { type: string, enum: [pass, fail] }
732
+ message: { type: string, description: Why the check failed }
733
+ uses:
734
+ type: array
735
+ description: The requests (keys of `requests`) the check is based on
736
+ items: { type: string }
737
+ requests:
738
+ type: object
739
+ description: Every request the checks made, by name.
740
+ additionalProperties:
741
+ type: object
742
+ required: [url]
743
+ properties:
744
+ url: { type: string, format: uri }
745
+ status: { type: integer }
746
+ contentType: { type: string }
747
+ bytes: { type: integer }
748
+ error: { type: string, description: Why the request failed, when it did }
749
+
597
750
  Error:
598
751
  type: object
599
752
  required: [error]