@fhirfly-io/shl 0.3.2 → 0.5.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.
Files changed (48) hide show
  1. package/README.md +53 -0
  2. package/dist/{chunk-63Q54EKN.cjs → chunk-CN44QKWJ.cjs} +185 -36
  3. package/dist/chunk-CN44QKWJ.cjs.map +1 -0
  4. package/dist/{chunk-QXSWM5QV.cjs → chunk-H37YQWF2.cjs} +90 -11
  5. package/dist/chunk-H37YQWF2.cjs.map +1 -0
  6. package/dist/{chunk-ZEE5RXIS.js → chunk-IYRQRY4A.js} +90 -12
  7. package/dist/chunk-IYRQRY4A.js.map +1 -0
  8. package/dist/{chunk-YBDRWUQU.js → chunk-YUMCDN7I.js} +185 -36
  9. package/dist/chunk-YUMCDN7I.js.map +1 -0
  10. package/dist/cli.cjs +11 -11
  11. package/dist/cli.js +2 -2
  12. package/dist/express.cjs +12 -3
  13. package/dist/express.cjs.map +1 -1
  14. package/dist/express.d.cts +3 -2
  15. package/dist/express.d.ts +3 -2
  16. package/dist/express.js +11 -2
  17. package/dist/express.js.map +1 -1
  18. package/dist/fastify.cjs +22 -5
  19. package/dist/fastify.cjs.map +1 -1
  20. package/dist/fastify.d.cts +3 -2
  21. package/dist/fastify.d.ts +3 -2
  22. package/dist/fastify.js +21 -4
  23. package/dist/fastify.js.map +1 -1
  24. package/dist/index.cjs +3 -3
  25. package/dist/index.d.cts +15 -5
  26. package/dist/index.d.ts +15 -5
  27. package/dist/index.js +1 -1
  28. package/dist/lambda.cjs +4 -3
  29. package/dist/lambda.cjs.map +1 -1
  30. package/dist/lambda.d.cts +3 -2
  31. package/dist/lambda.d.ts +3 -2
  32. package/dist/lambda.js +3 -2
  33. package/dist/lambda.js.map +1 -1
  34. package/dist/server.cjs +6 -2
  35. package/dist/server.d.cts +4 -4
  36. package/dist/server.d.ts +4 -4
  37. package/dist/server.js +1 -1
  38. package/dist/{storage-B3GyJD2y.d.ts → storage-CvsOM1Eu.d.ts} +1 -1
  39. package/dist/{storage-BwszYwFo.d.cts → storage-DggeMhOI.d.cts} +1 -1
  40. package/dist/{types-BegxU0wQ.d.ts → types--SjcaaWT.d.ts} +26 -2
  41. package/dist/{types-hHf-a3hH.d.cts → types-BcfxBDTA.d.cts} +26 -2
  42. package/dist/{types-Doq5cGNm.d.ts → types-CmeXnyth.d.cts} +31 -3
  43. package/dist/{types-Doq5cGNm.d.cts → types-CmeXnyth.d.ts} +31 -3
  44. package/package.json +1 -1
  45. package/dist/chunk-63Q54EKN.cjs.map +0 -1
  46. package/dist/chunk-QXSWM5QV.cjs.map +0 -1
  47. package/dist/chunk-YBDRWUQU.js.map +0 -1
  48. package/dist/chunk-ZEE5RXIS.js.map +0 -1
package/README.md CHANGED
@@ -90,6 +90,7 @@ const result = await SHL.create({
90
90
  storage,
91
91
  passcode: "1234",
92
92
  label: "Maria's Health Summary",
93
+ expiresAt: "travel", // 90 days — or use "point-of-care" (15min), "appointment" (24h), "permanent"
93
94
  });
94
95
 
95
96
  console.log(result.url); // shlink:/eyJ1cmwiOiJodHRwczovL...
@@ -97,6 +98,35 @@ console.log(result.qrCode); // data:image/png;base64,...
97
98
  console.log(result.passcode); // "1234"
98
99
  ```
99
100
 
101
+ ### Expiration Presets
102
+
103
+ Instead of calculating `Date` objects, use named presets:
104
+
105
+ ```typescript
106
+ await SHL.create({ bundle, storage, expiresAt: "point-of-care" }); // 15 minutes
107
+ await SHL.create({ bundle, storage, expiresAt: "appointment" }); // 24 hours
108
+ await SHL.create({ bundle, storage, expiresAt: "travel" }); // 90 days
109
+ await SHL.create({ bundle, storage, expiresAt: "permanent" }); // no expiration
110
+ await SHL.create({ bundle, storage, expiresAt: new Date(...) }); // raw Date still works
111
+ ```
112
+
113
+ ### PSHD Compliance
114
+
115
+ For CMS-aligned patient-to-provider sharing at the point of care:
116
+
117
+ ```typescript
118
+ const fhirBundle = await bundle.build({ profile: "pshd" }); // strips meta.profile, uses collection bundle
119
+
120
+ const result = await SHL.create({
121
+ bundle: fhirBundle,
122
+ storage,
123
+ compliance: "pshd",
124
+ expiresAt: "point-of-care",
125
+ });
126
+ ```
127
+
128
+ See the [PSHD Guide](https://fhirfly.io/docs/shl/pshd) for full details.
129
+
100
130
  ## Storage Adapters
101
131
 
102
132
  ```typescript
@@ -171,6 +201,29 @@ app.listen(3000);
171
201
 
172
202
  Also available for Fastify (`@fhirfly-io/shl/fastify`) and Lambda (`@fhirfly-io/shl/lambda`).
173
203
 
204
+ ### Audit Logging
205
+
206
+ Use `AuditableStorage` to capture access events at the storage level — plug in any logging backend:
207
+
208
+ ```typescript
209
+ import { AuditableStorage, SHLServerStorage, AccessEvent } from "@fhirfly-io/shl/server";
210
+
211
+ class MyAuditStorage extends ServerLocalStorage implements AuditableStorage {
212
+ async onAccess(shlId: string, event: AccessEvent): Promise<void> {
213
+ await db.auditLog.insert({
214
+ shlId,
215
+ recipient: event.recipient,
216
+ ip: event.ip,
217
+ timestamp: event.timestamp,
218
+ });
219
+ }
220
+ }
221
+
222
+ app.use("/shl", expressMiddleware({ storage: new MyAuditStorage({ ... }) }));
223
+ ```
224
+
225
+ The server handler detects `AuditableStorage` at runtime via `isAuditableStorage()` — existing storage implementations work unchanged.
226
+
174
227
  ## Live Exercise
175
228
 
176
229
  Run the comprehensive integration test against the live API to exercise every SDK path:
@@ -180,7 +180,11 @@ var CODE_SYSTEMS = {
180
180
  CVX: "http://hl7.org/fhir/sid/cvx",
181
181
  ICD10CM: "http://hl7.org/fhir/sid/icd-10-cm",
182
182
  CONDITION_CLINICAL: "http://terminology.hl7.org/CodeSystem/condition-clinical",
183
- ALLERGY_CLINICAL: "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical"
183
+ ALLERGY_CLINICAL: "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
184
+ /** CMS Patient-Shared Health Document category code system */
185
+ CMS_PATIENT_SHARED_CATEGORY: "https://cms.gov/fhir/CodeSystem/patient-shared-category",
186
+ /** V3 ActCode security label for patient-asserted data */
187
+ SECURITY_PATAST: "PATAST"
184
188
  };
185
189
 
186
190
  // src/ips/medication.ts
@@ -1106,8 +1110,9 @@ function resolveDocuments(documents, patientRef, profile, generateUuid2) {
1106
1110
  contentType,
1107
1111
  data: base64Content
1108
1112
  };
1109
- const typeCode = doc.typeCode ?? "34133-9";
1110
- const typeDisplay = doc.typeDisplay ?? "Summarization of episode note";
1113
+ const isPshd = profile === "pshd";
1114
+ const typeCode = isPshd ? "60591-5" : doc.typeCode ?? "34133-9";
1115
+ const typeDisplay = isPshd ? "Patient summary Document" : doc.typeDisplay ?? "Summarization of episode note";
1111
1116
  const docRefResource = {
1112
1117
  resourceType: "DocumentReference",
1113
1118
  id: docRefId,
@@ -1133,7 +1138,29 @@ function resolveDocuments(documents, patientRef, profile, generateUuid2) {
1133
1138
  }
1134
1139
  ]
1135
1140
  };
1136
- if (profile === "ips") {
1141
+ if (isPshd) {
1142
+ docRefResource.category = [
1143
+ {
1144
+ coding: [
1145
+ {
1146
+ system: CODE_SYSTEMS.CMS_PATIENT_SHARED_CATEGORY,
1147
+ code: "patient-shared",
1148
+ display: "Patient Shared"
1149
+ }
1150
+ ]
1151
+ }
1152
+ ];
1153
+ docRefResource.author = [{ reference: patientRef }];
1154
+ docRefResource.meta = {
1155
+ security: [
1156
+ {
1157
+ system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
1158
+ code: CODE_SYSTEMS.SECURITY_PATAST,
1159
+ display: "patient asserted"
1160
+ }
1161
+ ]
1162
+ };
1163
+ } else if (profile === "ips") {
1137
1164
  docRefResource.meta = {
1138
1165
  profile: ["http://hl7.org/fhir/uv/ips/StructureDefinition/DocumentReference-uv-ips"]
1139
1166
  };
@@ -1332,11 +1359,9 @@ var Bundle = class {
1332
1359
  async build(options) {
1333
1360
  const profile = options?.profile ?? "ips";
1334
1361
  const bundleId = options?.bundleId ?? generateUuid();
1335
- const compositionId = generateUuid();
1336
1362
  const patientId = generateUuid();
1337
- const compositionDate = options?.compositionDate ?? (/* @__PURE__ */ new Date()).toISOString();
1363
+ const timestamp = options?.compositionDate ?? (/* @__PURE__ */ new Date()).toISOString();
1338
1364
  const patientFullUrl = `urn:uuid:${patientId}`;
1339
- const compositionFullUrl = `urn:uuid:${compositionId}`;
1340
1365
  const patientResource = normalizePatient(this._patient, patientId, profile);
1341
1366
  const [medResult, condResult, allergyResult, immResult, resultResult] = await Promise.all([
1342
1367
  resolveMedications(this._medications, patientFullUrl, profile, generateUuid),
@@ -1353,6 +1378,22 @@ var Bundle = class {
1353
1378
  ...immResult.warnings,
1354
1379
  ...resultResult.warnings
1355
1380
  ];
1381
+ if (profile === "pshd") {
1382
+ return this.buildPshdBundle(
1383
+ bundleId,
1384
+ timestamp,
1385
+ patientFullUrl,
1386
+ patientResource,
1387
+ medResult.entries,
1388
+ condResult.entries,
1389
+ allergyResult.entries,
1390
+ immResult.entries,
1391
+ resultResult.entries,
1392
+ docResult.entries
1393
+ );
1394
+ }
1395
+ const compositionId = generateUuid();
1396
+ const compositionFullUrl = `urn:uuid:${compositionId}`;
1356
1397
  const medRefs = medResult.entries.map((e) => ({ reference: e.fullUrl }));
1357
1398
  const condRefs = condResult.entries.map((e) => ({ reference: e.fullUrl }));
1358
1399
  const allergyRefs = allergyResult.entries.map((e) => ({ reference: e.fullUrl }));
@@ -1361,7 +1402,7 @@ var Bundle = class {
1361
1402
  const composition = this.buildComposition(
1362
1403
  compositionId,
1363
1404
  patientFullUrl,
1364
- compositionDate,
1405
+ timestamp,
1365
1406
  profile,
1366
1407
  medRefs,
1367
1408
  allergyRefs,
@@ -1387,7 +1428,7 @@ var Bundle = class {
1387
1428
  value: `urn:uuid:${bundleId}`
1388
1429
  },
1389
1430
  type: "document",
1390
- timestamp: compositionDate,
1431
+ timestamp,
1391
1432
  entry: entries
1392
1433
  };
1393
1434
  return bundle;
@@ -1407,6 +1448,37 @@ var Bundle = class {
1407
1448
  path: "Patient.birthDate"
1408
1449
  });
1409
1450
  }
1451
+ if (profile === "pshd") {
1452
+ if (this._documents.length === 0) {
1453
+ issues.push({
1454
+ severity: "error",
1455
+ message: "PSHD requires at least one DocumentReference (1..1)",
1456
+ path: "Bundle.entry:DocumentReference"
1457
+ });
1458
+ } else {
1459
+ const hasPdf = this._documents.some(
1460
+ (d) => (d.contentType ?? "application/pdf") === "application/pdf"
1461
+ );
1462
+ if (!hasPdf) {
1463
+ issues.push({
1464
+ severity: "error",
1465
+ message: "PSHD requires at least one PDF document (contentType application/pdf)",
1466
+ path: "DocumentReference.content.attachment.contentType"
1467
+ });
1468
+ }
1469
+ }
1470
+ if (!this._patient.gender) {
1471
+ issues.push({
1472
+ severity: "warning",
1473
+ message: "Patient.gender recommended for PSHD demographic matching",
1474
+ path: "Patient.gender"
1475
+ });
1476
+ }
1477
+ return {
1478
+ valid: issues.filter((i) => i.severity === "error").length === 0,
1479
+ issues
1480
+ };
1481
+ }
1410
1482
  if (profile === "ips") {
1411
1483
  if (!this.hasValidName()) {
1412
1484
  issues.push({
@@ -1482,6 +1554,37 @@ var Bundle = class {
1482
1554
  const s = this._patient;
1483
1555
  return !!(s.given || s.family || s.name);
1484
1556
  }
1557
+ buildPshdBundle(bundleId, timestamp, patientFullUrl, patientResource, medEntries, condEntries, allergyEntries, immEntries, resultEntries, docEntries) {
1558
+ const entries = [
1559
+ { fullUrl: patientFullUrl, resource: patientResource },
1560
+ ...medEntries,
1561
+ ...condEntries,
1562
+ ...allergyEntries,
1563
+ ...immEntries,
1564
+ ...resultEntries,
1565
+ ...docEntries
1566
+ ];
1567
+ for (const entry of entries) {
1568
+ const meta = entry.resource.meta;
1569
+ if (meta?.profile) {
1570
+ delete meta.profile;
1571
+ if (Object.keys(meta).length === 0) {
1572
+ delete entry.resource.meta;
1573
+ }
1574
+ }
1575
+ }
1576
+ return {
1577
+ resourceType: "Bundle",
1578
+ id: bundleId,
1579
+ identifier: {
1580
+ system: "urn:ietf:rfc:3986",
1581
+ value: `urn:uuid:${bundleId}`
1582
+ },
1583
+ type: "collection",
1584
+ timestamp,
1585
+ entry: entries
1586
+ };
1587
+ }
1485
1588
  buildComposition(id, patientRef, date, profile, medRefs, allergyRefs, condRefs, immRefs, resultRefs) {
1486
1589
  const composition = {
1487
1590
  resourceType: "Composition",
@@ -1610,6 +1713,7 @@ function generateUuid() {
1610
1713
  var shl_exports = {};
1611
1714
  chunkQ7SFCCGT_cjs.__export(shl_exports, {
1612
1715
  AzureStorage: () => chunkUDS6UJAL_cjs.AzureStorage,
1716
+ EXPIRATION_PRESETS: () => EXPIRATION_PRESETS,
1613
1717
  FhirflyStorage: () => chunkUDS6UJAL_cjs.FhirflyStorage,
1614
1718
  GCSStorage: () => chunkUDS6UJAL_cjs.GCSStorage,
1615
1719
  LocalStorage: () => chunkUDS6UJAL_cjs.LocalStorage,
@@ -1621,6 +1725,18 @@ chunkQ7SFCCGT_cjs.__export(shl_exports, {
1621
1725
  getEntryContent: () => getEntryContent,
1622
1726
  revoke: () => revoke
1623
1727
  });
1728
+
1729
+ // src/shl/types.ts
1730
+ var EXPIRATION_PRESETS = {
1731
+ "point-of-care": 15 * 60 * 1e3,
1732
+ // 15 minutes
1733
+ "appointment": 24 * 60 * 60 * 1e3,
1734
+ // 24 hours
1735
+ "travel": 90 * 24 * 60 * 60 * 1e3,
1736
+ // 90 days
1737
+ "permanent": 0
1738
+ // no expiration
1739
+ };
1624
1740
  function base64url(data) {
1625
1741
  return data.toString("base64url");
1626
1742
  }
@@ -1708,13 +1824,43 @@ async function generateQRCode(url) {
1708
1824
  });
1709
1825
  }
1710
1826
  async function create(options) {
1711
- const { bundle, passcode, expiresAt, maxAccesses, label, storage, debug } = options;
1827
+ const { bundle, passcode, maxAccesses, label, storage, debug } = options;
1828
+ let expiresAt;
1829
+ if (typeof options.expiresAt === "string") {
1830
+ const preset = options.expiresAt;
1831
+ const durationMs = EXPIRATION_PRESETS[preset];
1832
+ if (durationMs === void 0) {
1833
+ throw new chunkUDS6UJAL_cjs.ValidationError(`Unknown expiration preset: "${preset}"`);
1834
+ }
1835
+ expiresAt = durationMs > 0 ? new Date(Date.now() + durationMs) : void 0;
1836
+ } else {
1837
+ expiresAt = options.expiresAt;
1838
+ }
1712
1839
  if (!bundle || typeof bundle !== "object") {
1713
1840
  throw new chunkUDS6UJAL_cjs.ValidationError("bundle is required and must be an object");
1714
1841
  }
1715
1842
  if (!storage?.baseUrl) {
1716
1843
  throw new chunkUDS6UJAL_cjs.ValidationError("storage with baseUrl is required");
1717
1844
  }
1845
+ let mode = options.mode ?? "manifest";
1846
+ if (options.compliance === "pshd") {
1847
+ mode = "direct";
1848
+ if (passcode) {
1849
+ throw new chunkUDS6UJAL_cjs.ValidationError(
1850
+ "PSHD compliance forbids passcode (flag U is incompatible with flag P)"
1851
+ );
1852
+ }
1853
+ if (!expiresAt && options.expiresAt !== "permanent") {
1854
+ throw new chunkUDS6UJAL_cjs.ValidationError(
1855
+ "PSHD compliance requires expiresAt (short-lived links for point-of-care)"
1856
+ );
1857
+ }
1858
+ }
1859
+ if (mode === "direct" && passcode) {
1860
+ throw new chunkUDS6UJAL_cjs.ValidationError(
1861
+ "Direct mode (flag U) is incompatible with passcode (flag P)"
1862
+ );
1863
+ }
1718
1864
  const key = generateKey();
1719
1865
  const shlId = generateShlId();
1720
1866
  let jwe;
@@ -1774,31 +1920,34 @@ async function create(options) {
1774
1920
  );
1775
1921
  }
1776
1922
  }
1777
- const manifest = {
1778
- files: [
1779
- {
1780
- contentType: "application/fhir+json;fhirVersion=4.0.1",
1781
- location: `${baseUrl}/${shlId}/content`
1782
- },
1783
- ...attachments.map((att, i) => ({
1784
- contentType: att.contentType,
1785
- location: `${baseUrl}/${shlId}/attachment/${i}`
1786
- }))
1787
- ],
1788
- status: "finalized",
1789
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
1790
- };
1791
- try {
1792
- await storage.store(`${shlId}/manifest.json`, JSON.stringify(manifest));
1793
- } catch (err) {
1794
- throw new chunkUDS6UJAL_cjs.StorageError(
1795
- `Failed to store manifest: ${err instanceof Error ? err.message : String(err)}`,
1796
- "store"
1797
- );
1923
+ if (mode === "manifest") {
1924
+ const manifest = {
1925
+ files: [
1926
+ {
1927
+ contentType: "application/fhir+json;fhirVersion=4.0.1",
1928
+ location: `${baseUrl}/${shlId}/content`
1929
+ },
1930
+ ...attachments.map((att, i) => ({
1931
+ contentType: att.contentType,
1932
+ location: `${baseUrl}/${shlId}/attachment/${i}`
1933
+ }))
1934
+ ],
1935
+ status: "finalized",
1936
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
1937
+ };
1938
+ try {
1939
+ await storage.store(`${shlId}/manifest.json`, JSON.stringify(manifest));
1940
+ } catch (err) {
1941
+ throw new chunkUDS6UJAL_cjs.StorageError(
1942
+ `Failed to store manifest: ${err instanceof Error ? err.message : String(err)}`,
1943
+ "store"
1944
+ );
1945
+ }
1798
1946
  }
1799
1947
  const metadata = {
1800
1948
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1801
1949
  };
1950
+ if (mode === "direct") metadata.mode = "direct";
1802
1951
  if (passcode) {
1803
1952
  metadata.passcode = crypto$1.createHash("sha256").update(passcode).digest("hex");
1804
1953
  }
@@ -1812,7 +1961,7 @@ async function create(options) {
1812
1961
  "store"
1813
1962
  );
1814
1963
  }
1815
- const flags = buildFlags(passcode);
1964
+ const flags = buildFlags(mode, passcode);
1816
1965
  const shlPayload = {
1817
1966
  url: `${baseUrl}/${shlId}`,
1818
1967
  key: base64url(key),
@@ -1839,8 +1988,8 @@ async function create(options) {
1839
1988
  if (debug) result.debugBundlePath = `${shlId}/bundle.json`;
1840
1989
  return result;
1841
1990
  }
1842
- function buildFlags(passcode) {
1843
- const flags = ["L"];
1991
+ function buildFlags(mode, passcode) {
1992
+ const flags = [mode === "direct" ? "U" : "L"];
1844
1993
  if (passcode) flags.push("P");
1845
1994
  return flags.sort().join("");
1846
1995
  }
@@ -1948,5 +2097,5 @@ async function revoke(shlId, storage) {
1948
2097
 
1949
2098
  exports.ips_exports = ips_exports;
1950
2099
  exports.shl_exports = shl_exports;
1951
- //# sourceMappingURL=chunk-63Q54EKN.cjs.map
1952
- //# sourceMappingURL=chunk-63Q54EKN.cjs.map
2100
+ //# sourceMappingURL=chunk-CN44QKWJ.cjs.map
2101
+ //# sourceMappingURL=chunk-CN44QKWJ.cjs.map