fhirsmith 0.17.0 → 0.18.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.
Files changed (131) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +432 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +8 -3
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +10 -0
  89. package/tx/README.md +52 -0
  90. package/tx/closure/closure-store.js +14 -0
  91. package/tx/cs/cs-api.js +92 -14
  92. package/tx/cs/cs-cs.js +64 -3
  93. package/tx/cs/cs-icd11.js +366 -112
  94. package/tx/cs/cs-icf.js +431 -0
  95. package/tx/cs/cs-loinc.js +296 -27
  96. package/tx/cs/cs-omop.js +10 -3
  97. package/tx/cs/cs-snomed.js +1210 -80
  98. package/tx/cs/cs-ucum.js +6 -0
  99. package/tx/cs/icd11-expressions.js +191 -78
  100. package/tx/importers/csv-records.js +35 -0
  101. package/tx/importers/export-icd11-codesystem.js +58 -12
  102. package/tx/importers/icd11-schema.md +9 -5
  103. package/tx/importers/import-loinc.module.js +177 -41
  104. package/tx/importers/import-sct.module.js +66 -1
  105. package/tx/importers/subset-loinc.module.js +25 -6
  106. package/tx/library/codesystem.js +4 -1
  107. package/tx/library/designations.js +23 -30
  108. package/tx/library.js +98 -4
  109. package/tx/openapi-operations.js +6 -1
  110. package/tx/params.js +8 -1
  111. package/tx/provider.js +24 -6
  112. package/tx/sct/documentation.md +251 -0
  113. package/tx/sct/ecl.js +758 -46
  114. package/tx/sct/expressions.js +113 -52
  115. package/tx/sct/structures.js +8 -2
  116. package/tx/syndication/syndication-feed.js +218 -0
  117. package/tx/syndication/syndication-store.js +415 -0
  118. package/tx/tx-html.js +15 -1
  119. package/tx/tx.fhir.org.yml +3 -4
  120. package/tx/tx.js +144 -2
  121. package/tx/vs/vs-vsac.js +25 -6
  122. package/tx/workers/closure.js +20 -0
  123. package/tx/workers/expand.js +35 -8
  124. package/tx/workers/lookup.js +61 -6
  125. package/tx/workers/subsumes.js +1 -1
  126. package/tx/workers/translate.js +166 -15
  127. package/tx/workers/validate.js +138 -6
  128. package/utilities/make-sct-loinc-subset.js +250 -0
  129. package/utilities/make-sct-test-extension.js +264 -0
  130. package/xig/xig.js +32 -0
  131. 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 };