fhirsmith 0.14.2 → 0.15.1
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.
- package/CHANGELOG.md +56 -1
- package/config-template.json +10 -0
- package/library/changelog.js +57 -0
- package/library/npm-audit.js +62 -9
- package/package.json +2 -2
- package/packages/npm-check-page.js +108 -0
- package/packages/npm-check.js +450 -0
- package/packages/openapi.yaml +158 -5
- package/packages/package-crawler.js +203 -20
- package/packages/packages.js +250 -8
- package/packages/readme.md +55 -2
- package/packages/utilities/dedupe-versions.js +242 -0
- package/registry/crawler.js +17 -0
- package/registry/fhirsmith-releases.js +3 -6
- package/registry/model.js +3 -0
- package/registry/registry.js +28 -3
- package/translations/Messages.properties +3 -0
- package/tx/README.md +20 -4
- package/tx/cm/cm-api.js +25 -10
- package/tx/cm/cm-database.js +0 -4
- package/tx/cm/cm-package.js +53 -14
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-icd11.js +23 -6
- package/tx/cs/cs-provider-api.js +22 -7
- package/tx/cs/cs-provider-list.js +10 -9
- package/tx/cs/icd11-expressions.js +5 -6
- package/tx/importers/icd11-schema.md +1 -0
- package/tx/importers/import-icd11.js +39 -0
- package/tx/library/resource-ids.js +107 -0
- package/tx/library.js +96 -24
- package/tx/ocl/cm-ocl.cjs +23 -15
- package/tx/ocl/cs-ocl.cjs +9 -10
- package/tx/ocl/vs-ocl.cjs +17 -17
- package/tx/provider.js +80 -13
- package/tx/tx.fhir.org.yml +17 -9
- package/tx/tx.js +7 -1
- package/tx/vs/vs-api.js +25 -10
- package/tx/vs/vs-database.js +0 -4
- package/tx/vs/vs-package.js +48 -31
- package/tx/vs/vs-vsac.js +20 -5
- package/tx/workers/lookup.js +7 -4
- package/tx/workers/metadata.js +2 -1
- package/tx/workers/read.js +3 -18
|
@@ -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 };
|
package/packages/openapi.yaml
CHANGED
|
@@ -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
|
|
103
|
-
|
|
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]
|