fhirsmith 0.18.2 → 1.0.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.
- package/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +149 -0
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +132 -59
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +74 -21
- package/ips/presenter.js +1 -1
- package/ips/readme.md +132 -25
- package/ips/render.js +466 -77
- package/ips/store.js +79 -22
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/crawler.js +84 -108
- package/registry/documentation/configuration.md +34 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +5 -4
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +154 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +29 -1
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +148 -14
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +185 -66
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +608 -64
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +57 -58
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +278 -42
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/ecl.js +47 -6
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +166 -13
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +27 -4
- package/tx/vs/vs-vsac.js +648 -100
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
package/library/role.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process roles, for running one master and several terminology workers on one data
|
|
3
|
+
* directory (see cluster.md).
|
|
4
|
+
*
|
|
5
|
+
* node server.js no role: everything exactly as before
|
|
6
|
+
* node server.js --role=master --instance-code=m0 --port=3001
|
|
7
|
+
* node server.js --role=tx-worker --instance-code=tx1 --port=3002
|
|
8
|
+
*
|
|
9
|
+
* The role also comes from FHIRSMITH_ROLE and the instance code from FHIRSMITH_INSTANCE_CODE
|
|
10
|
+
* (flags win). With no role, nothing here changes the configuration: server.js only calls
|
|
11
|
+
* applyRole when parseRole returns a role.
|
|
12
|
+
*
|
|
13
|
+
* - master: every configured module, every background job, $closure and the syndication
|
|
14
|
+
* feed. The VSAC provider syncs and, after each sync, bumps a generation counter that the
|
|
15
|
+
* workers watch.
|
|
16
|
+
* - tx-worker: the tx module only. No background jobs, no statistics database, no $closure
|
|
17
|
+
* or syndication store (those requests go to the master), and the VSAC provider only
|
|
18
|
+
* reads what the master's sync wrote.
|
|
19
|
+
*
|
|
20
|
+
* @module library/role
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const ROLES = ['master', 'tx-worker'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* An instance code goes in front of every cache-id the process issues, and a front door
|
|
27
|
+
* routes on it (nginx.md's map on "^tx1\."). It has to be 1-16 letters and digits: the
|
|
28
|
+
* longest cache-id, "<code>.<generation>.<random>" (two 22-character tokens) or
|
|
29
|
+
* "<code>.<start>.<uuid>", is then 62 characters, inside a FHIR id's 64. The tx module
|
|
30
|
+
* checks the same (ResourceCache.checkInstanceCode); this fails before anything starts.
|
|
31
|
+
* @param {string} code
|
|
32
|
+
* @param {string} where - what to call it in the error
|
|
33
|
+
*/
|
|
34
|
+
function checkInstanceCode(code, where = 'instance code') {
|
|
35
|
+
if (typeof code !== 'string' || !/^[A-Za-z0-9]{1,16}$/.test(code)) {
|
|
36
|
+
throw new Error(`The ${where} '${code}' is not valid: it must be 1-16 letters and digits`);
|
|
37
|
+
}
|
|
38
|
+
return code;
|
|
39
|
+
}
|
|
40
|
+
const FLAGS = ['role', 'instance-code', 'port'];
|
|
41
|
+
|
|
42
|
+
// the IPC message a process started by the launcher sends from its listen callback: the
|
|
43
|
+
// launcher marks it ready on this, from this child, rather than on whoever answers its port
|
|
44
|
+
const READY = 'fhirsmith:ready';
|
|
45
|
+
|
|
46
|
+
// The address a process with a role listens on (server.cluster.bindAddress): loopback, since
|
|
47
|
+
// only the front door (cluster.js, or nginx on the same host) should reach the master and
|
|
48
|
+
// the workers. "0.0.0.0" or "::" for every interface; a specific address for one
|
|
49
|
+
const DEFAULT_BIND_ADDRESS = '127.0.0.1';
|
|
50
|
+
|
|
51
|
+
// server.cluster.sharedCacheMaxMB: the most client resource JSON the shared cache file holds,
|
|
52
|
+
// all endpoints together, in MB. The largest IG Publisher cache session in our corpus of
|
|
53
|
+
// builds against tx.fhir.org (2,817 sessions) held 3.5 MB (p99 2.8 MB, median 55 KB), so 256
|
|
54
|
+
// is about 70x the largest: the room cacheMaxTotalConcepts gives one endpoint (about 40x the
|
|
55
|
+
// largest session's concepts), for two busy endpoints. 0: no cap
|
|
56
|
+
const DEFAULT_SHARED_CACHE_MAX_MB = 256;
|
|
57
|
+
|
|
58
|
+
// stats.sampleSeconds with a role, when not given: the graphs' sample interval, at most this,
|
|
59
|
+
// so a short session on a cluster can be read (cluster.md, "Graphs"). The counters are still
|
|
60
|
+
// written every stats.intervalMinutes
|
|
61
|
+
const ROLE_SAMPLE_SECONDS = 60;
|
|
62
|
+
|
|
63
|
+
// server.memory.trimAllocator with a role: on, unless the config says false. N+1 processes
|
|
64
|
+
// share one limit, and malloc_trim gives back what loading the library leaves resident
|
|
65
|
+
// (0.6-0.7 GB a process) and what a burst leaves (cluster.md, "Memory"). With no role the
|
|
66
|
+
// memory guard's own default (off) applies
|
|
67
|
+
const ROLE_TRIM_ALLOCATOR = true;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The address a process with a role listens on; with no role (null), none: every interface,
|
|
71
|
+
* as before.
|
|
72
|
+
* @param {Object} config - config.json
|
|
73
|
+
*/
|
|
74
|
+
function bindAddress(config) {
|
|
75
|
+
return (config.server && config.server.cluster && config.server.cluster.bindAddress) ?? DEFAULT_BIND_ADDRESS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The address to reach a process listening on `bind` at: loopback for every interface.
|
|
80
|
+
* @param {string} bind
|
|
81
|
+
*/
|
|
82
|
+
function connectAddress(bind) {
|
|
83
|
+
return bind === '0.0.0.0' ? '127.0.0.1' : bind === '::' ? '::1' : bind;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The role this process was started with, or null for none.
|
|
88
|
+
* @param {string[]} argv - process.argv.slice(2)
|
|
89
|
+
* @param {Object} env - process.env
|
|
90
|
+
* @returns {{role: string, instanceCode: string|null, port: number|null}|null}
|
|
91
|
+
*/
|
|
92
|
+
function parseRole(argv = [], env = {}) {
|
|
93
|
+
const flags = {};
|
|
94
|
+
for (let i = 0; i < argv.length; i++) {
|
|
95
|
+
const m = /^--([a-z-]+)(?:=(.*))?$/.exec(argv[i]);
|
|
96
|
+
if (m && FLAGS.includes(m[1])) {
|
|
97
|
+
flags[m[1]] = m[2] !== undefined ? m[2] : argv[++i];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const role = flags.role || env.FHIRSMITH_ROLE || null;
|
|
101
|
+
if (!role) {
|
|
102
|
+
if (flags['instance-code'] !== undefined || flags.port !== undefined) {
|
|
103
|
+
throw new Error('--instance-code and --port are only used with --role (master or tx-worker)');
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!ROLES.includes(role)) {
|
|
108
|
+
throw new Error(`Unknown role '${role}': use master or tx-worker`);
|
|
109
|
+
}
|
|
110
|
+
let port = null;
|
|
111
|
+
if (flags.port !== undefined) {
|
|
112
|
+
port = Number(flags.port);
|
|
113
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
114
|
+
throw new Error(`--port '${flags.port}' is not a port number`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { role, instanceCode: flags['instance-code'] || env.FHIRSMITH_INSTANCE_CODE || null, port };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Change the configuration (in place) for the role.
|
|
122
|
+
* @param {Object} config - the parsed config.json
|
|
123
|
+
* @param {{role: string, instanceCode: string|null}} role - from parseRole
|
|
124
|
+
* @returns {Object} config
|
|
125
|
+
*/
|
|
126
|
+
function applyRole(config, role) {
|
|
127
|
+
const tx = config.modules && config.modules.tx;
|
|
128
|
+
if (!tx || !tx.enabled) {
|
|
129
|
+
throw new Error(`--role=${role.role} needs the tx module enabled in config.json`);
|
|
130
|
+
}
|
|
131
|
+
if (role.role === 'tx-worker' && !role.instanceCode) {
|
|
132
|
+
// the config's code is the master's: every process needs its own
|
|
133
|
+
throw new Error('A tx-worker needs its own instance code: --instance-code or FHIRSMITH_INSTANCE_CODE');
|
|
134
|
+
}
|
|
135
|
+
tx.instanceCode = role.instanceCode || tx.instanceCode;
|
|
136
|
+
if (!tx.instanceCode) {
|
|
137
|
+
throw new Error('The master needs an instance code: --instance-code, FHIRSMITH_INSTANCE_CODE or modules.tx.instanceCode');
|
|
138
|
+
}
|
|
139
|
+
checkInstanceCode(tx.instanceCode);
|
|
140
|
+
tx.clusterRole = role.role;
|
|
141
|
+
// the options in server.cluster (cluster.md, "Options"). Every process reads the same
|
|
142
|
+
// config.json, so they all agree; without a role these are never looked at
|
|
143
|
+
const options = (config.server && config.server.cluster) || {};
|
|
144
|
+
for (const name of ['sharedCache', 'cacheAffinity', 'sharedClosure']) {
|
|
145
|
+
if (options[name] !== undefined && typeof options[name] !== 'boolean') {
|
|
146
|
+
throw new Error(`server.cluster.${name} must be true or false`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (options.bindAddress != null && (typeof options.bindAddress !== 'string' || !options.bindAddress)) {
|
|
150
|
+
throw new Error('server.cluster.bindAddress must be an address to listen on, e.g. "127.0.0.1" (the default) or "0.0.0.0"');
|
|
151
|
+
}
|
|
152
|
+
if (options.sharedCache === true) {
|
|
153
|
+
tx.sharedCache = true;
|
|
154
|
+
const mb = Number(options.sharedCacheMaxMB ?? DEFAULT_SHARED_CACHE_MAX_MB);
|
|
155
|
+
if (!Number.isFinite(mb) || mb < 0) {
|
|
156
|
+
throw new Error('server.cluster.sharedCacheMaxMB must be a number of megabytes (0: no cap)');
|
|
157
|
+
}
|
|
158
|
+
tx.sharedCacheMaxBytes = Math.round(mb * 1024 * 1024);
|
|
159
|
+
}
|
|
160
|
+
if (options.sharedClosure === true) {
|
|
161
|
+
tx.sharedClosure = true;
|
|
162
|
+
}
|
|
163
|
+
if (tx.vsacCfg) {
|
|
164
|
+
tx.vsacCfg = { ...tx.vsacCfg, cluster: role.role === 'master' ? 'leader' : 'follower' };
|
|
165
|
+
}
|
|
166
|
+
config.server = config.server || {};
|
|
167
|
+
// the memory limit is the whole group's (master and workers in one cgroup)
|
|
168
|
+
const memory = config.server.memory || {};
|
|
169
|
+
config.server.memory = { ...memory, group: true, trimAllocator: memory.trimAllocator ?? ROLE_TRIM_ALLOCATOR };
|
|
170
|
+
const stats = config.stats || {};
|
|
171
|
+
if (stats.sampleSeconds === undefined) {
|
|
172
|
+
const interval = stats.intervalMinutes > 0 ? stats.intervalMinutes * 60 : 600;
|
|
173
|
+
config.stats = { ...stats, sampleSeconds: Math.min(ROLE_SAMPLE_SECONDS, interval) };
|
|
174
|
+
}
|
|
175
|
+
if (role.role === 'tx-worker') {
|
|
176
|
+
for (const [name, mod] of Object.entries(config.modules)) {
|
|
177
|
+
if (name !== 'tx' && mod) {
|
|
178
|
+
mod.enabled = false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// the master keeps the statistics database, runs the npm audit and sends the report
|
|
182
|
+
config.stats = { ...(config.stats || {}), enabled: false };
|
|
183
|
+
config.npmAudit = { ...(config.npmAudit || {}), enabled: false };
|
|
184
|
+
config.dailyReport = { ...(config.dailyReport || {}), enabled: false };
|
|
185
|
+
}
|
|
186
|
+
return config;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function describeRole(role, config) {
|
|
190
|
+
const code = config.modules.tx.instanceCode;
|
|
191
|
+
return role.role === 'master'
|
|
192
|
+
? `Role: master (instance code ${code}): all modules, background jobs, $closure`
|
|
193
|
+
: `Role: tx-worker (instance code ${code}): the tx module only, no background jobs`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { parseRole, applyRole, describeRole, checkInstanceCode, bindAddress, connectAddress, DEFAULT_BIND_ADDRESS,
|
|
197
|
+
DEFAULT_SHARED_CACHE_MAX_MB, ROLE_TRIM_ALLOCATOR, ROLE_SAMPLE_SECONDS, READY, ROLES };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Credentials for other terminology servers: an API key, or an OAuth2 client_credentials token,
|
|
2
|
+
// under the server's code in the registry. Used by the registry crawler (registry/crawler.js) and
|
|
3
|
+
// by the tx module when it asks other servers in the ecosystem (tx/ecosystem-resolver.js)
|
|
4
|
+
|
|
5
|
+
const axios = require('axios');
|
|
6
|
+
|
|
7
|
+
class ServerCredentials {
|
|
8
|
+
/**
|
|
9
|
+
* @param {Object} options
|
|
10
|
+
* @param {Object} options.apiKeys - server code -> API key (sent as Api-Key)
|
|
11
|
+
* @param {Object} options.oauth - server code -> { tokenEndpoint, clientId, clientSecret, scope }
|
|
12
|
+
* @param {number} [options.timeout] - for the token request, in ms
|
|
13
|
+
* @param {string} [options.userAgent]
|
|
14
|
+
* @param {function(): {httpAgent, httpsAgent}} [options.agents] - for the token request (the crawler's SSRF guard)
|
|
15
|
+
* @param {function(): AbortSignal} [options.signal] - for the token request
|
|
16
|
+
* @param {function(string, string)} [options.log] - (level, message)
|
|
17
|
+
*/
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.apiKeys = options.apiKeys || {};
|
|
20
|
+
this.oauth = options.oauth || {};
|
|
21
|
+
this.timeout = options.timeout || 30000;
|
|
22
|
+
this.userAgent = options.userAgent || 'FHIRsmith';
|
|
23
|
+
this.agents = options.agents || (() => ({}));
|
|
24
|
+
this.signal = options.signal || (() => undefined);
|
|
25
|
+
this.log = options.log || (() => {});
|
|
26
|
+
// server code -> { token, renewAt } or { pending: Promise }
|
|
27
|
+
this.tokens = new Map();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The API key for a server code, or null
|
|
32
|
+
*/
|
|
33
|
+
getApiKey(code) {
|
|
34
|
+
if (code && Object.prototype.hasOwnProperty.call(this.apiKeys, code)) {
|
|
35
|
+
return this.apiKeys[code];
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The OAuth client credentials configured for a server code, or null
|
|
42
|
+
*/
|
|
43
|
+
getOAuthConfig(code) {
|
|
44
|
+
if (code && Object.prototype.hasOwnProperty.call(this.oauth, code)) {
|
|
45
|
+
return this.oauth[code] || null;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 'oauth', 'api-key' or 'open': what is sent to the server with this code
|
|
52
|
+
*/
|
|
53
|
+
security(code) {
|
|
54
|
+
return this.getOAuthConfig(code) ? 'oauth' : this.getApiKey(code) == null ? 'open' : 'api-key';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
hasCredentials(code) {
|
|
58
|
+
return this.security(code) !== 'open';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The headers that carry a server's credentials: an OAuth bearer token if the server has
|
|
63
|
+
* an oauth entry, otherwise its API key, otherwise none
|
|
64
|
+
* @param {string} code
|
|
65
|
+
* @param {boolean} renewToken - discard the cached token (the server refused it)
|
|
66
|
+
*/
|
|
67
|
+
async headers(code, renewToken = false) {
|
|
68
|
+
if (this.getOAuthConfig(code)) {
|
|
69
|
+
return { 'Authorization': `Bearer ${await this.getAccessToken(code, renewToken)}` };
|
|
70
|
+
}
|
|
71
|
+
const apiKey = this.getApiKey(code);
|
|
72
|
+
return apiKey ? { 'Api-Key': apiKey } : {};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* An access token for a server, from the cache while it has more than a minute (or a tenth
|
|
77
|
+
* of its life, if less) to run, otherwise from the token endpoint. Concurrent callers share
|
|
78
|
+
* one token request.
|
|
79
|
+
* @param {string} code
|
|
80
|
+
* @param {boolean} renew - discard the cached token (the server refused it)
|
|
81
|
+
* @returns {Promise<string>}
|
|
82
|
+
*/
|
|
83
|
+
async getAccessToken(code, renew = false) {
|
|
84
|
+
const cached = this.tokens.get(code);
|
|
85
|
+
if (cached) {
|
|
86
|
+
if (cached.pending) {
|
|
87
|
+
return cached.pending;
|
|
88
|
+
}
|
|
89
|
+
if (!renew && Date.now() < cached.renewAt) {
|
|
90
|
+
return cached.token;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const pending = this.requestAccessToken(code)
|
|
94
|
+
.then(t => {
|
|
95
|
+
this.tokens.set(code, t);
|
|
96
|
+
return t.token;
|
|
97
|
+
})
|
|
98
|
+
.catch(err => {
|
|
99
|
+
this.tokens.delete(code);
|
|
100
|
+
throw err;
|
|
101
|
+
});
|
|
102
|
+
this.tokens.set(code, { pending });
|
|
103
|
+
return pending;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The client_credentials grant (RFC 6749 4.4), the client authenticating with its secret in
|
|
108
|
+
* the form body (client_secret_post). Error messages name the server and the endpoint, but
|
|
109
|
+
* never carry the secret or anything the endpoint said beyond its error code.
|
|
110
|
+
*/
|
|
111
|
+
async requestAccessToken(code) {
|
|
112
|
+
const cfg = this.getOAuthConfig(code);
|
|
113
|
+
const endpoint = cfg.tokenEndpoint;
|
|
114
|
+
const fail = (why) => new Error(`Could not get an OAuth token for ${code} from ${endpoint}: ${why}`);
|
|
115
|
+
if (!endpoint || !/^https:\/\//i.test(endpoint)) {
|
|
116
|
+
throw new Error(`The OAuth tokenEndpoint for ${code} must be an https: URL`);
|
|
117
|
+
}
|
|
118
|
+
if (!cfg.clientId || !cfg.clientSecret) {
|
|
119
|
+
throw new Error(`The OAuth configuration for ${code} needs a clientId and clientSecret`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const form = new URLSearchParams();
|
|
123
|
+
form.set('grant_type', 'client_credentials');
|
|
124
|
+
form.set('client_id', cfg.clientId);
|
|
125
|
+
form.set('client_secret', cfg.clientSecret);
|
|
126
|
+
if (cfg.scope) {
|
|
127
|
+
form.set('scope', cfg.scope);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await axios.post(endpoint, form.toString(), {
|
|
133
|
+
timeout: this.timeout,
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
136
|
+
'Accept': 'application/json',
|
|
137
|
+
'User-Agent': this.userAgent
|
|
138
|
+
},
|
|
139
|
+
signal: this.signal(),
|
|
140
|
+
validateStatus: () => true,
|
|
141
|
+
maxContentLength: 1024 * 1024,
|
|
142
|
+
maxRedirects: 0, // the form carries the secret: it goes where it was configured to, or nowhere
|
|
143
|
+
httpAgent: this.agents().httpAgent,
|
|
144
|
+
httpsAgent: this.agents().httpsAgent
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
throw fail(error.response ? `HTTP ${error.response.status}` : `no response (${error.code || error.message})`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const body = response.data && typeof response.data === 'object' ? response.data : {};
|
|
151
|
+
if (response.status !== 200) {
|
|
152
|
+
const ec = typeof body.error === 'string' && /^[\w.-]{1,64}$/.test(body.error) ? ` ${body.error}` : '';
|
|
153
|
+
throw fail(`HTTP ${response.status}${ec}`);
|
|
154
|
+
}
|
|
155
|
+
if (typeof body.access_token !== 'string' || !body.access_token) {
|
|
156
|
+
throw fail('the response has no access_token');
|
|
157
|
+
}
|
|
158
|
+
if (body.token_type && String(body.token_type).toLowerCase() !== 'bearer') {
|
|
159
|
+
throw fail(`unsupported token_type ${String(body.token_type).substring(0, 20)}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const lifetime = Number(body.expires_in) > 0 ? Number(body.expires_in) * 1000 : 5 * 60 * 1000;
|
|
163
|
+
const margin = Math.min(60 * 1000, lifetime / 10);
|
|
164
|
+
this.log('info', ` Got an OAuth token for ${code} from ${endpoint}, valid for ${Math.round(lifetime / 1000)}s`);
|
|
165
|
+
return { token: body.access_token, renewAt: Date.now() + lifetime - margin };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { ServerCredentials };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* node-sqlite3 runs its callbacks from native code. An exception thrown inside
|
|
3
|
+
* one leaves whatever was waiting on it unsettled (a promise never settles, a
|
|
4
|
+
* request never gets a response), and N-API keeps it pending: the process aborts
|
|
5
|
+
* (napi_throw FATAL) when the statement is finalized, at the latest on exit. So
|
|
6
|
+
* callbacks are wrapped, and what one throws goes to onError instead:
|
|
7
|
+
*
|
|
8
|
+
* db.all(sql, params, guarded(reject, (err, rows) => { ... })); // in a promise
|
|
9
|
+
* db.get(sql, params, guarded(next, (err, row) => { ... })); // in a route handler
|
|
10
|
+
* db.run(sql, guarded(null, err => { ... })); // nothing waits: log only
|
|
11
|
+
*
|
|
12
|
+
* `this` is passed through, for run() callbacks that read this.lastID / this.changes.
|
|
13
|
+
* What it throws is also logged, because after resolve() a reject does nothing.
|
|
14
|
+
*
|
|
15
|
+
* (better-sqlite3, used by ips, testing, registry and extension-tracker, is synchronous:
|
|
16
|
+
* its exceptions are ordinary ones, and need none of this.)
|
|
17
|
+
*
|
|
18
|
+
* @param {function(Error)|null} onError - reject, next, failRequest(res), or null
|
|
19
|
+
* @param {Function} callback
|
|
20
|
+
*/
|
|
21
|
+
function guarded(onError, callback) {
|
|
22
|
+
return function (...args) {
|
|
23
|
+
try {
|
|
24
|
+
return callback.apply(this, args);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
require('./logger').getInstance().child({ module: 'sqlite' }).warn(`sqlite callback threw: ${e.message}`);
|
|
27
|
+
if (typeof onError === 'function') {
|
|
28
|
+
onError(e);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* For a route helper that has the response but no next: answer 500, if nothing has been
|
|
36
|
+
* sent yet (what Express does with an error passed to next).
|
|
37
|
+
* @param {Object} res
|
|
38
|
+
* @returns {function(Error)}
|
|
39
|
+
*/
|
|
40
|
+
function failRequest(res) {
|
|
41
|
+
return () => {
|
|
42
|
+
if (!res.headersSent) {
|
|
43
|
+
res.status(500).send('Internal Server Error');
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A string for the inside of a '...' literal in SQLite: each ' doubled (SQLite has no backslash
|
|
50
|
+
* escapes, so that is all a literal needs). Prefer a ? parameter wherever the SQL allows one.
|
|
51
|
+
* @param {string} str
|
|
52
|
+
* @returns {string} '' for a missing value
|
|
53
|
+
*/
|
|
54
|
+
function sqlEscapeString(str) {
|
|
55
|
+
if (!str) return '';
|
|
56
|
+
return str.replace(/'/g, "''");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { guarded, failRequest, sqlEscapeString };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The SSRF guard for outbound http(s) requests to addresses that came from somewhere else (a
|
|
2
|
+
// registry, a feed, a server's "next" link): http/https agents that refuse to connect to anything
|
|
3
|
+
// but a public address. Used by the registry crawler (registry/crawler.js) and by the tx module
|
|
4
|
+
// when it asks other servers in the ecosystem (tx/ecosystem-resolver.js)
|
|
5
|
+
//
|
|
6
|
+
// The check is made when the connection is made, after the DNS lookup, so it also covers redirect
|
|
7
|
+
// targets and defeats DNS rebinding. Node doesn't do a lookup when the host is an IP address
|
|
8
|
+
// (http://127.0.0.1/, http://169.254.169.254/), so those are checked as well.
|
|
9
|
+
|
|
10
|
+
const dns = require('dns');
|
|
11
|
+
const http = require('http');
|
|
12
|
+
const https = require('https');
|
|
13
|
+
const net = require('net');
|
|
14
|
+
const ipaddr = require('ipaddr.js');
|
|
15
|
+
|
|
16
|
+
// True if an IP literal is anything other than a normal public (unicast) address:
|
|
17
|
+
// loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
|
|
18
|
+
// CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
|
|
19
|
+
function isNonPublicAddress(ip) {
|
|
20
|
+
try {
|
|
21
|
+
let addr = ipaddr.parse(ip);
|
|
22
|
+
if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
|
|
23
|
+
addr = addr.toIPv4Address();
|
|
24
|
+
}
|
|
25
|
+
return addr.range() !== 'unicast';
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return true; // unparseable - treat as unsafe
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A dns.lookup replacement that fails if the host resolves to any non-public address
|
|
33
|
+
*
|
|
34
|
+
* @param {boolean} allowPrivate - don't check (e.g. for local test servers)
|
|
35
|
+
*/
|
|
36
|
+
function guardedLookup(allowPrivate = false) {
|
|
37
|
+
return (hostname, options, callback) => {
|
|
38
|
+
if (typeof options === 'function') {
|
|
39
|
+
callback = options;
|
|
40
|
+
options = {};
|
|
41
|
+
}
|
|
42
|
+
const wantAll = !!(options && options.all);
|
|
43
|
+
dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
|
|
44
|
+
if (err) {
|
|
45
|
+
callback(err);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!allowPrivate) {
|
|
49
|
+
for (const a of addresses) {
|
|
50
|
+
if (isNonPublicAddress(a.address)) {
|
|
51
|
+
callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (wantAll) {
|
|
57
|
+
callback(null, addresses);
|
|
58
|
+
} else {
|
|
59
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* http and https agents (keep-alive) that only connect to public addresses. Pass them to axios
|
|
67
|
+
* as httpAgent and httpsAgent
|
|
68
|
+
*
|
|
69
|
+
* @param {Object} [options]
|
|
70
|
+
* @param {boolean} [options.allowPrivate] - don't check (e.g. for local test servers)
|
|
71
|
+
* @returns {{httpAgent: http.Agent, httpsAgent: https.Agent}}
|
|
72
|
+
*/
|
|
73
|
+
function createGuardedAgents({ allowPrivate = false } = {}) {
|
|
74
|
+
const lookup = guardedLookup(allowPrivate);
|
|
75
|
+
const blockedLiteral = (options) => {
|
|
76
|
+
const host = String(options.host || options.hostname || '').replace(/^\[(.*)\]$/, '$1');
|
|
77
|
+
if (!allowPrivate && net.isIP(host) && isNonPublicAddress(host)) {
|
|
78
|
+
return new Error('Blocked request to non-public address ' + host);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
};
|
|
82
|
+
const guard = (Base) => class extends Base {
|
|
83
|
+
createConnection(options, cb) {
|
|
84
|
+
const err = blockedLiteral(options);
|
|
85
|
+
if (err) {
|
|
86
|
+
process.nextTick(() => cb(err));
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return super.createConnection(Object.assign({}, options, { lookup }), cb);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const GuardedHttpAgent = guard(http.Agent);
|
|
93
|
+
const GuardedHttpsAgent = guard(https.Agent);
|
|
94
|
+
return {
|
|
95
|
+
httpAgent: new GuardedHttpAgent({ keepAlive: true }),
|
|
96
|
+
httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { isNonPublicAddress, guardedLookup, createGuardedAgents };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Count only responses handled by the static-file middleware. A fall-through
|
|
2
|
+
// request belongs to a later API/page route, even when that route returns 200.
|
|
3
|
+
function countStaticResponses(middleware, onHit) {
|
|
4
|
+
return (req, res, next) => {
|
|
5
|
+
const finished = () => {
|
|
6
|
+
if (res.statusCode >= 200 && res.statusCode < 300) onHit();
|
|
7
|
+
};
|
|
8
|
+
res.once('finish', finished);
|
|
9
|
+
middleware(req, res, error => {
|
|
10
|
+
res.removeListener('finish', finished);
|
|
11
|
+
next(error);
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { countStaticResponses };
|
|
@@ -857,6 +857,56 @@ class VersionUtilities {
|
|
|
857
857
|
}
|
|
858
858
|
}
|
|
859
859
|
|
|
860
|
+
/**
|
|
861
|
+
* Whether a resource's version matches the version a search asks for (the version search
|
|
862
|
+
* parameter): version-aware matching (see versionMatchesByAlgorithm), so 3.0.x finds 3.0.2,
|
|
863
|
+
* but 0 doesn't find 3.0.0
|
|
864
|
+
* @param {string} criteria - the version asked for
|
|
865
|
+
* @param {string} candidate - the resource's version
|
|
866
|
+
* @param {string} [versionAlgorithm] - the resource's versionAlgorithm, if it has one
|
|
867
|
+
* @returns {boolean}
|
|
868
|
+
*/
|
|
869
|
+
static versionSearchMatches(criteria, candidate, versionAlgorithm = null) {
|
|
870
|
+
if (!criteria) {
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
if (!candidate) {
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
return this.versionMatchesByAlgorithm(criteria, candidate, versionAlgorithm);
|
|
878
|
+
} catch (e) {
|
|
879
|
+
return criteria === candidate;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* What every version that matches the criteria (see versionSearchMatches) starts with - the
|
|
885
|
+
* criteria up to its first wildcard - so a database can narrow a search before the
|
|
886
|
+
* matching is done
|
|
887
|
+
* @param {string} criteria
|
|
888
|
+
* @returns {string}
|
|
889
|
+
*/
|
|
890
|
+
static versionSearchPrefix(criteria) {
|
|
891
|
+
const i = criteria.search(/[xX*?]/);
|
|
892
|
+
return i < 0 ? criteria : criteria.substring(0, i);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* The versionAlgorithm of a resource (JSON), if it states one
|
|
897
|
+
* @param {Object} json
|
|
898
|
+
* @returns {string|null}
|
|
899
|
+
*/
|
|
900
|
+
static versionAlgorithmOf(json) {
|
|
901
|
+
if (!json) {
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
if (json.versionAlgorithmCoding && json.versionAlgorithmCoding.code) {
|
|
905
|
+
return json.versionAlgorithmCoding.code;
|
|
906
|
+
}
|
|
907
|
+
return json.versionAlgorithmString || null;
|
|
908
|
+
}
|
|
909
|
+
|
|
860
910
|
static versionMatchesByAlgorithm(criteria, candidate, versionAlgorithm) {
|
|
861
911
|
validateOptionalParameter(criteria, "criteria", String);
|
|
862
912
|
validateOptionalParameter(candidate, "candidate", String);
|