fhirsmith 0.16.1 → 0.18.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/CHANGELOG.md +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/readme.md +1 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +107 -6
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
package/ips/share.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
* Sharing an IPS document as a QR code: a SMART Health Link (SHL), or a Verifiable Health
|
|
9
|
+
* Link (VHL) signed as the GDHCN does it. The same steps the healthintersections.com.au
|
|
10
|
+
* passport page takes in the browser, here on the server, against an SHL server - this
|
|
11
|
+
* server's own shl module (an internal path, dispatched in-process) or another FHIRsmith's:
|
|
12
|
+
*
|
|
13
|
+
* 1. POST {shl}/create { password, days, vhl } -> { uuid, pword, link }
|
|
14
|
+
* 2. encrypt the document as a JWE (dir / A256GCM, a random 256-bit key)
|
|
15
|
+
* 3. POST {shl}/upload { uuid, pword, files: [{ cnt: base64(jwe), type }] }
|
|
16
|
+
* 4. the shlink payload { url: link, flag: 'U', key, label }, base64url (no flag for a VHL:
|
|
17
|
+
* the VHL GET returns a DocumentReference bundle, not the file)
|
|
18
|
+
* SHL: {viewer}#shlink:/{payload} - the QR carries that
|
|
19
|
+
* VHL: POST {shl}/sign { url: 'shlink://{payload}' } -> { signature } (a COSE_Sign1 of
|
|
20
|
+
* the GDHCN payload, deflated and base45) - the QR carries 'HC1:' + signature
|
|
21
|
+
* 5. the QR code, as SVG
|
|
22
|
+
*
|
|
23
|
+
* @module ips/share
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
const jose = require('jose');
|
|
28
|
+
const qrcode = require('qrcode-generator');
|
|
29
|
+
const { dispatch } = require('./tx-client');
|
|
30
|
+
|
|
31
|
+
const DEFAULT_VIEWER = 'https://viewer.tcpdev.org/#';
|
|
32
|
+
const DEFAULT_DAYS = 100;
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
34
|
+
const FHIR_JSON = 'application/fhir+json';
|
|
35
|
+
// the most a document can be, as its JWE, before it isn't shared this way
|
|
36
|
+
const MAX_SHARE_BYTES = 2 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
function b64url(bytes) {
|
|
39
|
+
return Buffer.from(bytes).toString('base64url');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A client for an SHL server: `server` an http(s) URL of the module (https://host/shl), or
|
|
44
|
+
* a path on this server (/shl) dispatched in-process.
|
|
45
|
+
*/
|
|
46
|
+
class ShlClient {
|
|
47
|
+
/**
|
|
48
|
+
* @param {{server: string, password: string, days?: number, viewer?: string, timeoutMs?: number}} config
|
|
49
|
+
* @param {{app?: Object, log?: Object}} [options]
|
|
50
|
+
*/
|
|
51
|
+
constructor(config, options = {}) {
|
|
52
|
+
if (!config || typeof config.server !== 'string' || !config.server.trim()) {
|
|
53
|
+
throw new Error('modules.ips.shl.server is required (a URL, or a path on this server)');
|
|
54
|
+
}
|
|
55
|
+
if (typeof config.password !== 'string' || !config.password) {
|
|
56
|
+
throw new Error('modules.ips.shl.password is required (the shl module\'s password)');
|
|
57
|
+
}
|
|
58
|
+
this.base = config.server.trim().replace(/\/+$/, '');
|
|
59
|
+
this.internal = this.base.startsWith('/');
|
|
60
|
+
if (!this.internal && !/^https?:\/\//.test(this.base)) {
|
|
61
|
+
throw new Error('modules.ips.shl.server must be an http(s) URL or a path on this server');
|
|
62
|
+
}
|
|
63
|
+
if (this.internal && !options.app) {
|
|
64
|
+
throw new Error('An SHL server on this server needs the app to dispatch to');
|
|
65
|
+
}
|
|
66
|
+
this.app = options.app || null;
|
|
67
|
+
this.log = options.log || null;
|
|
68
|
+
this.password = config.password;
|
|
69
|
+
this.days = Number.isInteger(config.days) && config.days > 0 ? config.days : DEFAULT_DAYS;
|
|
70
|
+
this.viewer = typeof config.viewer === 'string' && config.viewer ? config.viewer : DEFAULT_VIEWER;
|
|
71
|
+
this.timeoutMs = Number.isInteger(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where the server is, for messages. */
|
|
75
|
+
get url() {
|
|
76
|
+
return this.base;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A JSON POST to the SHL server. The Host header goes along for an internal call, so
|
|
81
|
+
* the links the server makes name this server as the browser sees it.
|
|
82
|
+
*/
|
|
83
|
+
async post(path, body, host) {
|
|
84
|
+
const target = `${this.base}${path}`;
|
|
85
|
+
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
86
|
+
let status;
|
|
87
|
+
let text;
|
|
88
|
+
if (this.internal) {
|
|
89
|
+
if (host) {
|
|
90
|
+
headers.Host = host;
|
|
91
|
+
}
|
|
92
|
+
let timer;
|
|
93
|
+
const r = await Promise.race([
|
|
94
|
+
dispatch(this.app, { method: 'POST', url: target, headers, body: JSON.stringify(body) }),
|
|
95
|
+
new Promise((_, reject) => {
|
|
96
|
+
timer = setTimeout(() => reject(new Error(`No answer from ${target} within ${this.timeoutMs}ms`)), this.timeoutMs);
|
|
97
|
+
})
|
|
98
|
+
]).finally(() => clearTimeout(timer));
|
|
99
|
+
status = r.status;
|
|
100
|
+
text = r.body;
|
|
101
|
+
} else {
|
|
102
|
+
const r = await fetch(target, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
103
|
+
status = r.status;
|
|
104
|
+
text = await r.text();
|
|
105
|
+
}
|
|
106
|
+
let json = null;
|
|
107
|
+
try {
|
|
108
|
+
json = text ? JSON.parse(text) : null;
|
|
109
|
+
} catch {
|
|
110
|
+
json = null;
|
|
111
|
+
}
|
|
112
|
+
if (status < 200 || status >= 300) {
|
|
113
|
+
// the server's own message is for the log: the page gets the step that failed
|
|
114
|
+
if (this.log) {
|
|
115
|
+
this.log.error(`IPS module: the SHL server answered ${status} to ${path}: ${(json && json.error) || (text || '').substring(0, 200)}`);
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`The SHL server answered HTTP ${status} to ${path}`);
|
|
118
|
+
}
|
|
119
|
+
if (!json || typeof json !== 'object') {
|
|
120
|
+
throw new Error(`The SHL server did not answer ${path} with JSON`);
|
|
121
|
+
}
|
|
122
|
+
return json;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Share a document.
|
|
127
|
+
*
|
|
128
|
+
* @param {Object} bundle - the document
|
|
129
|
+
* @param {{kind: 'shl'|'vhl', host?: string, label?: string, days?: number}} options
|
|
130
|
+
* @returns {Promise<{kind: string, link: string, qr: string, text: string, expires: string, bytes: number}>}
|
|
131
|
+
* link: the SHL server's link to the manifest; qr: the QR code as SVG; text: what the QR
|
|
132
|
+
* carries (the viewer link with the shlink, or HC1:...)
|
|
133
|
+
*/
|
|
134
|
+
async share(bundle, options) {
|
|
135
|
+
const kind = options.kind === 'vhl' ? 'vhl' : 'shl';
|
|
136
|
+
const days = Number.isInteger(options.days) && options.days > 0 ? options.days : this.days;
|
|
137
|
+
const json = JSON.stringify(bundle);
|
|
138
|
+
const keyBytes = crypto.randomBytes(32);
|
|
139
|
+
const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(json))
|
|
140
|
+
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', cty: FHIR_JSON })
|
|
141
|
+
.encrypt(new Uint8Array(keyBytes));
|
|
142
|
+
if (jwe.length > MAX_SHARE_BYTES) {
|
|
143
|
+
throw new Error(`The document is too big to share this way (${jwe.length} bytes encrypted; the limit is ${MAX_SHARE_BYTES})`);
|
|
144
|
+
}
|
|
145
|
+
const created = await this.post('/create', { password: this.password, days, vhl: kind === 'vhl' }, options.host);
|
|
146
|
+
if (typeof created.uuid !== 'string' || typeof created.pword !== 'string' || typeof created.link !== 'string') {
|
|
147
|
+
throw new Error('The SHL server did not give a uuid, password and link');
|
|
148
|
+
}
|
|
149
|
+
await this.post('/upload', { uuid: created.uuid, pword: created.pword, files: [{ cnt: Buffer.from(jwe, 'utf8').toString('base64'), type: FHIR_JSON }] }, options.host);
|
|
150
|
+
const payload = kind === 'vhl'
|
|
151
|
+
? { url: created.link, key: b64url(keyBytes), label: options.label || 'Patient Health Document' }
|
|
152
|
+
: { url: created.link, flag: 'U', key: b64url(keyBytes), label: options.label || 'Patient Health Document' };
|
|
153
|
+
const encoded = b64url(Buffer.from(JSON.stringify(payload), 'utf8'));
|
|
154
|
+
let text;
|
|
155
|
+
if (kind === 'vhl') {
|
|
156
|
+
// 'shlink://' (two slashes) is what the GDHCN validator expects
|
|
157
|
+
const signed = await this.post('/sign', { url: `shlink://${encoded}` }, options.host);
|
|
158
|
+
if (typeof signed.signature !== 'string' || !signed.signature) {
|
|
159
|
+
throw new Error('The SHL server did not sign the link');
|
|
160
|
+
}
|
|
161
|
+
text = `HC1:${signed.signature}`;
|
|
162
|
+
} else {
|
|
163
|
+
text = `${this.viewer}shlink:/${encoded}`;
|
|
164
|
+
}
|
|
165
|
+
const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
|
166
|
+
return { kind, link: created.link, qr: qrSvg(text), text, expires, bytes: jwe.length };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A QR code (error correction L, as the passport page makes them) as an SVG. */
|
|
171
|
+
function qrSvg(text) {
|
|
172
|
+
const qr = qrcode(0, 'L');
|
|
173
|
+
qr.addData(text);
|
|
174
|
+
qr.make();
|
|
175
|
+
return qr.createSvgTag({ cellSize: 4, margin: 8, scalable: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { ShlClient, qrSvg, DEFAULT_VIEWER, DEFAULT_DAYS, MAX_SHARE_BYTES };
|