@fhirfly-io/shl 0.1.1 → 0.3.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/README.md +131 -53
- package/dist/chunk-5I5H3SLO.cjs +286 -0
- package/dist/chunk-5I5H3SLO.cjs.map +1 -0
- package/dist/{chunk-7WIM2QP5.js → chunk-7SVQ24UF.js} +13 -3
- package/dist/chunk-7SVQ24UF.js.map +1 -0
- package/dist/chunk-IDS43FSS.cjs +1956 -0
- package/dist/chunk-IDS43FSS.cjs.map +1 -0
- package/dist/{chunk-CNVYKA4D.cjs → chunk-M4HWBUJW.cjs} +13 -3
- package/dist/chunk-M4HWBUJW.cjs.map +1 -0
- package/dist/chunk-NCMJCDJQ.js +1949 -0
- package/dist/chunk-NCMJCDJQ.js.map +1 -0
- package/dist/chunk-NEBFJSJW.js +281 -0
- package/dist/chunk-NEBFJSJW.js.map +1 -0
- package/dist/chunk-UDS6UJAL.cjs +388 -0
- package/dist/chunk-UDS6UJAL.cjs.map +1 -0
- package/dist/chunk-VKB3ESIV.js +378 -0
- package/dist/chunk-VKB3ESIV.js.map +1 -0
- package/dist/cli.cjs +461 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +458 -0
- package/dist/cli.js.map +1 -0
- package/dist/express.cjs +2 -2
- package/dist/express.d.cts +2 -2
- package/dist/express.d.ts +2 -2
- package/dist/express.js +1 -1
- package/dist/fastify.cjs +2 -2
- package/dist/fastify.d.cts +2 -2
- package/dist/fastify.d.ts +2 -2
- package/dist/fastify.js +1 -1
- package/dist/index.cjs +13 -1594
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +134 -4
- package/dist/index.d.ts +134 -4
- package/dist/index.js +3 -1591
- package/dist/index.js.map +1 -1
- package/dist/lambda.cjs +2 -2
- package/dist/lambda.d.cts +2 -2
- package/dist/lambda.d.ts +2 -2
- package/dist/lambda.js +1 -1
- package/dist/server.cjs +21 -156
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +54 -5
- package/dist/server.d.ts +54 -5
- package/dist/server.js +3 -156
- package/dist/server.js.map +1 -1
- package/dist/storage-B3GyJD2y.d.ts +205 -0
- package/dist/storage-BwszYwFo.d.cts +205 -0
- package/dist/{types--f4ITgu9.d.cts → types-B6RKj_xO.d.cts} +1 -1
- package/dist/{types-DKtPO4DP.d.ts → types-D_oopfOL.d.ts} +1 -1
- package/dist/{types-yk0mDByJ.d.cts → types-Doq5cGNm.d.cts} +8 -1
- package/dist/{types-yk0mDByJ.d.ts → types-Doq5cGNm.d.ts} +8 -1
- package/package.json +16 -2
- package/dist/chunk-7WIM2QP5.js.map +0 -1
- package/dist/chunk-CNVYKA4D.cjs.map +0 -1
- package/dist/chunk-KI44MYPE.cjs +0 -170
- package/dist/chunk-KI44MYPE.cjs.map +0 -1
- package/dist/chunk-XTLU6O32.js +0 -163
- package/dist/chunk-XTLU6O32.js.map +0 -1
- package/dist/storage-CHi9vLD_.d.cts +0 -82
- package/dist/storage-iI_tyHcX.d.ts +0 -82
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @fhirfly-io/shl
|
|
2
2
|
|
|
3
|
-
SMART Health Links SDK for Node.js — build FHIR Bundles from clinical codes,
|
|
3
|
+
SMART Health Links SDK for Node.js — build IPS FHIR Bundles from clinical codes, encrypt, and share via SHL/QR code.
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
@@ -14,104 +14,182 @@ Raw codes + patient info → Enriched FHIR Bundle → Encrypted SHL → QR code
|
|
|
14
14
|
|
|
15
15
|
PHI never leaves your server. Only terminology codes are sent to FHIRfly for enrichment — no BAA required.
|
|
16
16
|
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- **Node.js** 18 or later
|
|
20
|
+
- **FHIRfly API key** — optional, for code enrichment (display names, SNOMED mappings). [Get a free key](https://fhirfly.io/register). Without it, use manual `code`/`system`/`display` input or `bySNOMED`/`fromResource` (no API call needed).
|
|
21
|
+
|
|
17
22
|
## Installation
|
|
18
23
|
|
|
19
24
|
```bash
|
|
20
25
|
npm install @fhirfly-io/shl
|
|
21
26
|
```
|
|
22
27
|
|
|
28
|
+
For FHIRfly API enrichment (optional — adds display names and SNOMED cross-mappings):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @fhirfly-io/shl @fhirfly-io/terminology
|
|
32
|
+
```
|
|
33
|
+
|
|
23
34
|
## Quick Start
|
|
24
35
|
|
|
36
|
+
### Without FHIRfly API (no key needed)
|
|
37
|
+
|
|
25
38
|
```typescript
|
|
26
|
-
import { IPS, SHL } from
|
|
27
|
-
|
|
39
|
+
import { IPS, SHL } from "@fhirfly-io/shl";
|
|
40
|
+
|
|
41
|
+
const bundle = new IPS.Bundle({
|
|
42
|
+
name: "Maria Garcia",
|
|
43
|
+
birthDate: "1985-03-15",
|
|
44
|
+
gender: "female",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Manual coding — no API dependency
|
|
48
|
+
bundle.addMedication({ code: "376988009", system: "http://snomed.info/sct", display: "Levothyroxine" });
|
|
49
|
+
bundle.addCondition({ code: "44054006", system: "http://snomed.info/sct", display: "Type 2 diabetes" });
|
|
50
|
+
bundle.addAllergy({ bySNOMED: "387207008" });
|
|
28
51
|
|
|
29
|
-
const
|
|
52
|
+
const fhirBundle = await bundle.build();
|
|
30
53
|
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
54
|
+
const storage = new SHL.LocalStorage({ directory: "./shl-data", baseUrl: "http://localhost:3456/shl" });
|
|
55
|
+
const result = await SHL.create({ bundle: fhirBundle, storage, passcode: "1234" });
|
|
56
|
+
|
|
57
|
+
console.log(result.url); // shlink:/eyJ...
|
|
58
|
+
console.log(result.qrCode); // data:image/png;base64,...
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### With FHIRfly API (enriched)
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { IPS, SHL } from "@fhirfly-io/shl";
|
|
65
|
+
import Fhirfly from "@fhirfly-io/terminology";
|
|
66
|
+
|
|
67
|
+
const client = new Fhirfly({ apiKey: process.env.FHIRFLY_API_KEY });
|
|
68
|
+
|
|
69
|
+
const bundle = new IPS.Bundle({
|
|
70
|
+
name: "Maria Garcia",
|
|
71
|
+
birthDate: "1985-03-15",
|
|
72
|
+
gender: "female",
|
|
34
73
|
});
|
|
35
74
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
75
|
+
// Code-based input — FHIRfly enriches with display names, SNOMED mappings, etc.
|
|
76
|
+
bundle.addMedication({ byNDC: "00071015523", fhirfly: client.ndc });
|
|
77
|
+
bundle.addCondition({ byICD10: "E11.9", fhirfly: client.icd10 });
|
|
78
|
+
bundle.addAllergy({ bySNOMED: "387207008" });
|
|
79
|
+
bundle.addImmunization({ byCVX: "208", fhirfly: client.cvx });
|
|
80
|
+
bundle.addResult({ byLOINC: "2339-0", fhirfly: client.loinc, value: 95, unit: "mg/dL" });
|
|
81
|
+
bundle.addDocument({ content: pdfBuffer, contentType: "application/pdf", title: "Visit Summary" });
|
|
41
82
|
|
|
42
|
-
|
|
43
|
-
const bundle = await ips.build({ profile: 'ips' }); // or 'r4' for generic FHIR
|
|
83
|
+
const fhirBundle = await bundle.build();
|
|
44
84
|
|
|
45
|
-
//
|
|
46
|
-
const
|
|
85
|
+
// FhirflyStorage — zero infrastructure, included free in all plans
|
|
86
|
+
const storage = new SHL.FhirflyStorage({ apiKey: process.env.FHIRFLY_API_KEY });
|
|
47
87
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
storage
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
label: 'Medical Summary for Oliver Brown',
|
|
88
|
+
const result = await SHL.create({
|
|
89
|
+
bundle: fhirBundle,
|
|
90
|
+
storage,
|
|
91
|
+
passcode: "1234",
|
|
92
|
+
label: "Maria's Health Summary",
|
|
54
93
|
});
|
|
55
94
|
|
|
56
|
-
console.log(
|
|
57
|
-
console.log(
|
|
58
|
-
console.log(
|
|
95
|
+
console.log(result.url); // shlink:/eyJ1cmwiOiJodHRwczovL...
|
|
96
|
+
console.log(result.qrCode); // data:image/png;base64,...
|
|
97
|
+
console.log(result.passcode); // "1234"
|
|
59
98
|
```
|
|
60
99
|
|
|
61
|
-
##
|
|
100
|
+
## Storage Adapters
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// FHIRfly hosted (zero infrastructure, recommended)
|
|
104
|
+
new SHL.FhirflyStorage({ apiKey: "..." });
|
|
62
105
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
- **Bring Your Own Storage** — developer controls where encrypted PHI lives (S3, Azure, GCS, local)
|
|
66
|
-
- **Bring Your Own PDF** — SDK wraps pre-rendered PDFs as FHIR DocumentReference; you control rendering
|
|
67
|
-
- **Validation as a gate** — `SHL.create()` refuses to package invalid Bundles
|
|
68
|
-
- **Create-only for v1** — SHL creation; receiving/decoding is out of scope
|
|
106
|
+
// AWS S3
|
|
107
|
+
new SHL.S3Storage({ bucket: "my-bucket", region: "us-east-1", baseUrl: "https://shl.example.com" });
|
|
69
108
|
|
|
70
|
-
|
|
109
|
+
// Azure Blob Storage
|
|
110
|
+
new SHL.AzureStorage({ container: "shl-data", connectionString: "...", baseUrl: "https://shl.example.com" });
|
|
71
111
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
112
|
+
// Google Cloud Storage
|
|
113
|
+
new SHL.GCSStorage({ bucket: "my-bucket", baseUrl: "https://shl.example.com" });
|
|
114
|
+
|
|
115
|
+
// Local filesystem (development)
|
|
116
|
+
new SHL.LocalStorage({ directory: "./shl-data", baseUrl: "http://localhost:3456/shl" });
|
|
117
|
+
```
|
|
76
118
|
|
|
77
119
|
## Input Formats
|
|
78
120
|
|
|
79
121
|
Each `add*` method supports multiple input formats:
|
|
80
122
|
|
|
81
123
|
```typescript
|
|
82
|
-
// From
|
|
83
|
-
|
|
84
|
-
|
|
124
|
+
// From codes (enriched via FHIRfly API)
|
|
125
|
+
bundle.addMedication({ byNDC: "00071015523", fhirfly: client.ndc });
|
|
126
|
+
bundle.addMedication({ byRxNorm: "161", fhirfly: client.rxnorm });
|
|
127
|
+
bundle.addCondition({ byICD10: "E11.9", fhirfly: client.icd10 });
|
|
128
|
+
bundle.addResult({ byLOINC: "2339-0", fhirfly: client.loinc, value: 95, unit: "mg/dL" });
|
|
129
|
+
bundle.addImmunization({ byCVX: "208", fhirfly: client.cvx });
|
|
85
130
|
|
|
86
131
|
// From SNOMED (no API call needed)
|
|
87
|
-
|
|
132
|
+
bundle.addMedication({ bySNOMED: "376988009" });
|
|
133
|
+
bundle.addAllergy({ bySNOMED: "387207008" });
|
|
88
134
|
|
|
89
135
|
// From existing FHIR R4 resources
|
|
90
|
-
|
|
136
|
+
bundle.addMedication({ fromResource: existingMedicationStatement });
|
|
137
|
+
bundle.addCondition({ fromResource: existingCondition });
|
|
138
|
+
|
|
139
|
+
// Manual coding (no API dependency)
|
|
140
|
+
bundle.addMedication({ code: "376988009", system: "http://snomed.info/sct", display: "Levothyroxine" });
|
|
91
141
|
```
|
|
92
142
|
|
|
93
|
-
##
|
|
143
|
+
## CLI
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npx @fhirfly-io/shl validate bundle.json # Validate a FHIR Bundle
|
|
147
|
+
npx @fhirfly-io/shl create bundle.json # Create an SHL from a bundle
|
|
148
|
+
npx @fhirfly-io/shl decode shlink:/eyJ... # Decode an SHL URL
|
|
149
|
+
npx @fhirfly-io/shl serve # Start a local SHL server
|
|
150
|
+
npx @fhirfly-io/shl demo # Full round-trip demo
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Server Middleware
|
|
154
|
+
|
|
155
|
+
Host your own SHL endpoints:
|
|
94
156
|
|
|
95
157
|
```typescript
|
|
96
|
-
|
|
97
|
-
|
|
158
|
+
import express from "express";
|
|
159
|
+
import { expressMiddleware } from "@fhirfly-io/shl/express";
|
|
160
|
+
import { ServerLocalStorage } from "@fhirfly-io/shl/server";
|
|
98
161
|
|
|
99
|
-
|
|
100
|
-
|
|
162
|
+
const storage = new ServerLocalStorage({
|
|
163
|
+
directory: "./shl-data",
|
|
164
|
+
baseUrl: "http://localhost:3000/shl",
|
|
165
|
+
});
|
|
101
166
|
|
|
102
|
-
|
|
103
|
-
|
|
167
|
+
const app = express();
|
|
168
|
+
app.use("/shl", expressMiddleware({ storage }));
|
|
169
|
+
app.listen(3000);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Also available for Fastify (`@fhirfly-io/shl/fastify`) and Lambda (`@fhirfly-io/shl/lambda`).
|
|
104
173
|
|
|
105
|
-
|
|
106
|
-
|
|
174
|
+
## Live Exercise
|
|
175
|
+
|
|
176
|
+
Run the comprehensive integration test against the live API to exercise every SDK path:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npx tsx examples/live-exercise/index.ts --api-key <your-key> --verbose
|
|
107
180
|
```
|
|
108
181
|
|
|
182
|
+
Covers bundle building, FhirflyStorage, LocalStorage + Express, SHL consumption, access control, and edge cases. See [`examples/live-exercise/README.md`](examples/live-exercise/README.md) for details.
|
|
183
|
+
|
|
109
184
|
## Related
|
|
110
185
|
|
|
111
|
-
- [
|
|
186
|
+
- [EHR Integration Guide](https://fhirfly.io/docs/shl/ehr-integration) — Map HL7v2, CCDA, FHIR R4, and flat database records to IPS bundles
|
|
187
|
+
- [Security & Compliance](https://fhirfly.io/docs/shl/security) — Zero-knowledge architecture, HIPAA, compliance checklist
|
|
188
|
+
- [@fhirfly-io/terminology](https://www.npmjs.com/package/@fhirfly-io/terminology) — FHIRfly terminology API SDK
|
|
112
189
|
- [SMART Health Links Spec](https://docs.smarthealthit.org/smart-health-links/spec/)
|
|
113
190
|
- [IPS Implementation Guide](https://build.fhir.org/ig/HL7/fhir-ips/)
|
|
114
|
-
- [FHIRfly
|
|
191
|
+
- [FHIRfly SHL Docs](https://fhirfly.io/docs/shl/overview)
|
|
192
|
+
- [SHL Viewer](https://fhirfly.io/shl/viewer)
|
|
115
193
|
|
|
116
194
|
## License
|
|
117
195
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var chunkUDS6UJAL_cjs = require('./chunk-UDS6UJAL.cjs');
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
|
|
7
|
+
var ServerLocalStorage = class extends chunkUDS6UJAL_cjs.LocalStorage {
|
|
8
|
+
constructor(config) {
|
|
9
|
+
super(config);
|
|
10
|
+
}
|
|
11
|
+
async read(key) {
|
|
12
|
+
const filePath = path.join(this.config.directory, key);
|
|
13
|
+
if (!fs.existsSync(filePath)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return fs.readFileSync(filePath, "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async updateMetadata(shlId, updater) {
|
|
23
|
+
const key = `${shlId}/metadata.json`;
|
|
24
|
+
const raw = await this.read(key);
|
|
25
|
+
if (raw === null) return null;
|
|
26
|
+
const current = JSON.parse(raw);
|
|
27
|
+
const updated = updater(current);
|
|
28
|
+
if (updated === null) return null;
|
|
29
|
+
await this.store(key, JSON.stringify(updated));
|
|
30
|
+
return updated;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var _s3Module;
|
|
34
|
+
async function getS3Module() {
|
|
35
|
+
if (_s3Module) return _s3Module;
|
|
36
|
+
try {
|
|
37
|
+
_s3Module = await import('@aws-sdk/client-s3');
|
|
38
|
+
return _s3Module;
|
|
39
|
+
} catch {
|
|
40
|
+
throw new chunkUDS6UJAL_cjs.StorageError(
|
|
41
|
+
"@aws-sdk/client-s3 is required for ServerS3Storage. Install it: npm install @aws-sdk/client-s3",
|
|
42
|
+
"import"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
var ServerS3Storage = class {
|
|
47
|
+
_config;
|
|
48
|
+
_client;
|
|
49
|
+
constructor(config) {
|
|
50
|
+
this._config = config;
|
|
51
|
+
}
|
|
52
|
+
get baseUrl() {
|
|
53
|
+
return this._config.baseUrl.replace(/\/+$/, "");
|
|
54
|
+
}
|
|
55
|
+
async store(key, content) {
|
|
56
|
+
try {
|
|
57
|
+
const s3 = await getS3Module();
|
|
58
|
+
const client = this._getClient(s3);
|
|
59
|
+
const body = typeof content === "string" ? Buffer.from(content, "utf8") : content;
|
|
60
|
+
const command = new s3.PutObjectCommand({
|
|
61
|
+
Bucket: this._config.bucket,
|
|
62
|
+
Key: this._s3Key(key),
|
|
63
|
+
Body: body,
|
|
64
|
+
ContentType: this._contentType(key)
|
|
65
|
+
});
|
|
66
|
+
await client.send(command);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err instanceof chunkUDS6UJAL_cjs.StorageError) throw err;
|
|
69
|
+
throw new chunkUDS6UJAL_cjs.StorageError(
|
|
70
|
+
`Failed to store ${key}: ${err instanceof Error ? err.message : String(err)}`,
|
|
71
|
+
"store"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async delete(prefix) {
|
|
76
|
+
try {
|
|
77
|
+
const s3 = await getS3Module();
|
|
78
|
+
const client = this._getClient(s3);
|
|
79
|
+
const s3Prefix = this._s3Key(prefix);
|
|
80
|
+
let continuationToken;
|
|
81
|
+
do {
|
|
82
|
+
const listInput = {
|
|
83
|
+
Bucket: this._config.bucket,
|
|
84
|
+
Prefix: s3Prefix
|
|
85
|
+
};
|
|
86
|
+
if (continuationToken) listInput["ContinuationToken"] = continuationToken;
|
|
87
|
+
const listCommand = new s3.ListObjectsV2Command(listInput);
|
|
88
|
+
const response = await client.send(listCommand);
|
|
89
|
+
const objects = response.Contents;
|
|
90
|
+
if (!objects || objects.length === 0) break;
|
|
91
|
+
const deleteCommand = new s3.DeleteObjectsCommand({
|
|
92
|
+
Bucket: this._config.bucket,
|
|
93
|
+
Delete: {
|
|
94
|
+
Objects: objects.map((obj) => ({ Key: obj.Key })),
|
|
95
|
+
Quiet: true
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
await client.send(deleteCommand);
|
|
99
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
|
|
100
|
+
} while (continuationToken);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof chunkUDS6UJAL_cjs.StorageError) throw err;
|
|
103
|
+
throw new chunkUDS6UJAL_cjs.StorageError(
|
|
104
|
+
`Failed to delete ${prefix}: ${err instanceof Error ? err.message : String(err)}`,
|
|
105
|
+
"delete"
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async read(key) {
|
|
110
|
+
try {
|
|
111
|
+
const s3 = await getS3Module();
|
|
112
|
+
const client = this._getClient(s3);
|
|
113
|
+
const command = new s3.GetObjectCommand({
|
|
114
|
+
Bucket: this._config.bucket,
|
|
115
|
+
Key: this._s3Key(key)
|
|
116
|
+
});
|
|
117
|
+
const response = await client.send(command);
|
|
118
|
+
if (!response.Body) return null;
|
|
119
|
+
return response.Body.transformToString();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const code = err.name;
|
|
122
|
+
if (code === "NoSuchKey") return null;
|
|
123
|
+
if (err instanceof chunkUDS6UJAL_cjs.StorageError) throw err;
|
|
124
|
+
throw new chunkUDS6UJAL_cjs.StorageError(
|
|
125
|
+
`Failed to read ${key}: ${err instanceof Error ? err.message : String(err)}`,
|
|
126
|
+
"read"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async updateMetadata(shlId, updater) {
|
|
131
|
+
const key = `${shlId}/metadata.json`;
|
|
132
|
+
const raw = await this.read(key);
|
|
133
|
+
if (raw === null) return null;
|
|
134
|
+
const current = JSON.parse(raw);
|
|
135
|
+
const updated = updater(current);
|
|
136
|
+
if (updated === null) return null;
|
|
137
|
+
await this.store(key, JSON.stringify(updated));
|
|
138
|
+
return updated;
|
|
139
|
+
}
|
|
140
|
+
_getClient(s3) {
|
|
141
|
+
if (!this._client) {
|
|
142
|
+
this._client = new s3.S3Client({ region: this._config.region });
|
|
143
|
+
}
|
|
144
|
+
return this._client;
|
|
145
|
+
}
|
|
146
|
+
_s3Key(key) {
|
|
147
|
+
const prefix = this._config.prefix?.replace(/\/+$/, "");
|
|
148
|
+
return prefix ? `${prefix}/${key}` : key;
|
|
149
|
+
}
|
|
150
|
+
_contentType(key) {
|
|
151
|
+
if (key.endsWith(".jwe")) return "application/jose";
|
|
152
|
+
if (key.endsWith(".json")) return "application/json";
|
|
153
|
+
return "application/octet-stream";
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
var _azureModule;
|
|
157
|
+
async function getAzureModule() {
|
|
158
|
+
if (_azureModule) return _azureModule;
|
|
159
|
+
try {
|
|
160
|
+
_azureModule = await import('@azure/storage-blob');
|
|
161
|
+
return _azureModule;
|
|
162
|
+
} catch {
|
|
163
|
+
throw new chunkUDS6UJAL_cjs.StorageError(
|
|
164
|
+
"@azure/storage-blob is required for ServerAzureStorage. Install it: npm install @azure/storage-blob",
|
|
165
|
+
"import"
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
var ServerAzureStorage = class extends chunkUDS6UJAL_cjs.AzureStorage {
|
|
170
|
+
_serverContainerClient;
|
|
171
|
+
constructor(config) {
|
|
172
|
+
super(config);
|
|
173
|
+
}
|
|
174
|
+
async read(key) {
|
|
175
|
+
try {
|
|
176
|
+
const container = await this._getServerContainer();
|
|
177
|
+
const blobName = this._serverBlobName(key);
|
|
178
|
+
const client = container.getBlockBlobClient(blobName);
|
|
179
|
+
const response = await client.download();
|
|
180
|
+
if (!response.readableStreamBody) return null;
|
|
181
|
+
return await streamToString(response.readableStreamBody);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
const code = err.statusCode;
|
|
184
|
+
if (code === 404) return null;
|
|
185
|
+
if (err instanceof chunkUDS6UJAL_cjs.StorageError) throw err;
|
|
186
|
+
throw new chunkUDS6UJAL_cjs.StorageError(
|
|
187
|
+
`Failed to read ${key}: ${err instanceof Error ? err.message : String(err)}`,
|
|
188
|
+
"read"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async updateMetadata(shlId, updater) {
|
|
193
|
+
const key = `${shlId}/metadata.json`;
|
|
194
|
+
const raw = await this.read(key);
|
|
195
|
+
if (raw === null) return null;
|
|
196
|
+
const current = JSON.parse(raw);
|
|
197
|
+
const updated = updater(current);
|
|
198
|
+
if (updated === null) return null;
|
|
199
|
+
await this.store(key, JSON.stringify(updated));
|
|
200
|
+
return updated;
|
|
201
|
+
}
|
|
202
|
+
async _getServerContainer() {
|
|
203
|
+
if (!this._serverContainerClient) {
|
|
204
|
+
const azure = await getAzureModule();
|
|
205
|
+
const serviceClient = azure.BlobServiceClient.fromConnectionString(this.config.connectionString);
|
|
206
|
+
this._serverContainerClient = serviceClient.getContainerClient(this.config.container);
|
|
207
|
+
}
|
|
208
|
+
return this._serverContainerClient;
|
|
209
|
+
}
|
|
210
|
+
_serverBlobName(key) {
|
|
211
|
+
const prefix = this.config.prefix?.replace(/\/+$/, "");
|
|
212
|
+
return prefix ? `${prefix}/${key}` : key;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
var _gcsModule;
|
|
216
|
+
async function getGCSModule() {
|
|
217
|
+
if (_gcsModule) return _gcsModule;
|
|
218
|
+
try {
|
|
219
|
+
_gcsModule = await import('@google-cloud/storage');
|
|
220
|
+
return _gcsModule;
|
|
221
|
+
} catch {
|
|
222
|
+
throw new chunkUDS6UJAL_cjs.StorageError(
|
|
223
|
+
"@google-cloud/storage is required for ServerGCSStorage. Install it: npm install @google-cloud/storage",
|
|
224
|
+
"import"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
var ServerGCSStorage = class extends chunkUDS6UJAL_cjs.GCSStorage {
|
|
229
|
+
_serverBucket;
|
|
230
|
+
constructor(config) {
|
|
231
|
+
super(config);
|
|
232
|
+
}
|
|
233
|
+
async read(key) {
|
|
234
|
+
try {
|
|
235
|
+
const bucket = await this._getServerBucket();
|
|
236
|
+
const fileName = this._serverFileName(key);
|
|
237
|
+
const file = bucket.file(fileName);
|
|
238
|
+
const [content] = await file.download();
|
|
239
|
+
return content.toString("utf8");
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const code = err.code;
|
|
242
|
+
if (code === 404) return null;
|
|
243
|
+
if (err instanceof chunkUDS6UJAL_cjs.StorageError) throw err;
|
|
244
|
+
throw new chunkUDS6UJAL_cjs.StorageError(
|
|
245
|
+
`Failed to read ${key}: ${err instanceof Error ? err.message : String(err)}`,
|
|
246
|
+
"read"
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async updateMetadata(shlId, updater) {
|
|
251
|
+
const key = `${shlId}/metadata.json`;
|
|
252
|
+
const raw = await this.read(key);
|
|
253
|
+
if (raw === null) return null;
|
|
254
|
+
const current = JSON.parse(raw);
|
|
255
|
+
const updated = updater(current);
|
|
256
|
+
if (updated === null) return null;
|
|
257
|
+
await this.store(key, JSON.stringify(updated));
|
|
258
|
+
return updated;
|
|
259
|
+
}
|
|
260
|
+
async _getServerBucket() {
|
|
261
|
+
if (!this._serverBucket) {
|
|
262
|
+
const gcs = await getGCSModule();
|
|
263
|
+
const storage = new gcs.Storage();
|
|
264
|
+
this._serverBucket = storage.bucket(this.config.bucket);
|
|
265
|
+
}
|
|
266
|
+
return this._serverBucket;
|
|
267
|
+
}
|
|
268
|
+
_serverFileName(key) {
|
|
269
|
+
const prefix = this.config.prefix?.replace(/\/+$/, "");
|
|
270
|
+
return prefix ? `${prefix}/${key}` : key;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
async function streamToString(stream) {
|
|
274
|
+
const chunks = [];
|
|
275
|
+
for await (const chunk of stream) {
|
|
276
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
277
|
+
}
|
|
278
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
exports.ServerAzureStorage = ServerAzureStorage;
|
|
282
|
+
exports.ServerGCSStorage = ServerGCSStorage;
|
|
283
|
+
exports.ServerLocalStorage = ServerLocalStorage;
|
|
284
|
+
exports.ServerS3Storage = ServerS3Storage;
|
|
285
|
+
//# sourceMappingURL=chunk-5I5H3SLO.cjs.map
|
|
286
|
+
//# sourceMappingURL=chunk-5I5H3SLO.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server/storage.ts"],"names":["LocalStorage","join","existsSync","readFileSync","StorageError","AzureStorage","GCSStorage"],"mappings":"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","file":"chunk-5I5H3SLO.cjs","sourcesContent":["// Copyright 2026 FHIRfly.io LLC. All rights reserved.\n// Licensed under the MIT License. See LICENSE file in the project root.\nimport { readFileSync, existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { LocalStorage, AzureStorage, GCSStorage } from \"../shl/storage.js\";\nimport type { LocalStorageConfig, S3StorageConfig, AzureStorageConfig, GCSStorageConfig } from \"../shl/storage.js\";\nimport type { SHLServerStorage } from \"./types.js\";\nimport type { SHLMetadata } from \"../shl/types.js\";\nimport { StorageError } from \"../errors.js\";\n\n/**\n * Local filesystem server storage for SMART Health Links.\n *\n * Extends the base `LocalStorage` (write-only) with `read` and\n * `updateMetadata` methods needed for serving SHLs.\n *\n * @example\n * ```ts\n * import { ServerLocalStorage } from \"@fhirfly-io/shl/server\";\n *\n * const storage = new ServerLocalStorage({\n * directory: \"./shl-data\",\n * baseUrl: \"https://shl.example.com\",\n * });\n * ```\n */\nexport class ServerLocalStorage extends LocalStorage implements SHLServerStorage {\n constructor(config: LocalStorageConfig) {\n super(config);\n }\n\n async read(key: string): Promise<string | Uint8Array | null> {\n const filePath = join(this.config.directory, key);\n if (!existsSync(filePath)) {\n return null;\n }\n try {\n // Read as UTF-8 for JSON/JWE files, binary for others\n return readFileSync(filePath, \"utf8\");\n } catch {\n return null;\n }\n }\n\n async updateMetadata(\n shlId: string,\n updater: (current: SHLMetadata) => SHLMetadata | null,\n ): Promise<SHLMetadata | null> {\n const key = `${shlId}/metadata.json`;\n const raw = await this.read(key);\n if (raw === null) return null;\n\n const current = JSON.parse(raw as string) as SHLMetadata;\n const updated = updater(current);\n if (updated === null) return null;\n\n await this.store(key, JSON.stringify(updated));\n return updated;\n }\n}\n\n// Minimal S3 interfaces (same pattern as shl/storage.ts)\ninterface S3ClientInstance {\n send(command: unknown): Promise<unknown>;\n}\ninterface S3Module {\n S3Client: new (config: { region: string }) => S3ClientInstance;\n PutObjectCommand: new (input: Record<string, unknown>) => unknown;\n GetObjectCommand: new (input: Record<string, unknown>) => unknown;\n ListObjectsV2Command: new (input: Record<string, unknown>) => unknown;\n DeleteObjectsCommand: new (input: Record<string, unknown>) => unknown;\n}\n\nlet _s3Module: S3Module | undefined;\nasync function getS3Module(): Promise<S3Module> {\n if (_s3Module) return _s3Module;\n try {\n _s3Module = (await import(\"@aws-sdk/client-s3\")) as unknown as S3Module;\n return _s3Module;\n } catch {\n throw new StorageError(\n \"@aws-sdk/client-s3 is required for ServerS3Storage. Install it: npm install @aws-sdk/client-s3\",\n \"import\",\n );\n }\n}\n\n/**\n * S3-backed server storage for SMART Health Links.\n *\n * Implements the full `SHLServerStorage` interface with `read` and\n * `updateMetadata` on top of S3.\n *\n * Uses conditional PutObject for optimistic concurrency on metadata updates.\n *\n * @example\n * ```ts\n * import { ServerS3Storage } from \"@fhirfly-io/shl/server\";\n *\n * const storage = new ServerS3Storage({\n * bucket: \"my-shl-bucket\",\n * region: \"us-east-1\",\n * baseUrl: \"https://shl.example.com\",\n * });\n * ```\n */\nexport class ServerS3Storage implements SHLServerStorage {\n private readonly _config: S3StorageConfig;\n private _client?: S3ClientInstance;\n\n constructor(config: S3StorageConfig) {\n this._config = config;\n }\n\n get baseUrl(): string {\n return this._config.baseUrl.replace(/\\/+$/, \"\");\n }\n\n async store(key: string, content: string | Uint8Array): Promise<void> {\n try {\n const s3 = await getS3Module();\n const client = this._getClient(s3);\n const body = typeof content === \"string\" ? Buffer.from(content, \"utf8\") : content;\n\n const command = new s3.PutObjectCommand({\n Bucket: this._config.bucket,\n Key: this._s3Key(key),\n Body: body,\n ContentType: this._contentType(key),\n });\n\n await client.send(command);\n } catch (err) {\n if (err instanceof StorageError) throw err;\n throw new StorageError(\n `Failed to store ${key}: ${err instanceof Error ? err.message : String(err)}`,\n \"store\",\n );\n }\n }\n\n async delete(prefix: string): Promise<void> {\n try {\n const s3 = await getS3Module();\n const client = this._getClient(s3);\n const s3Prefix = this._s3Key(prefix);\n\n let continuationToken: string | undefined;\n do {\n const listInput: Record<string, unknown> = {\n Bucket: this._config.bucket,\n Prefix: s3Prefix,\n };\n if (continuationToken) listInput[\"ContinuationToken\"] = continuationToken;\n\n const listCommand = new s3.ListObjectsV2Command(listInput);\n const response = (await client.send(listCommand)) as {\n Contents?: Array<{ Key?: string }>;\n IsTruncated?: boolean;\n NextContinuationToken?: string;\n };\n\n const objects = response.Contents;\n if (!objects || objects.length === 0) break;\n\n const deleteCommand = new s3.DeleteObjectsCommand({\n Bucket: this._config.bucket,\n Delete: {\n Objects: objects.map((obj) => ({ Key: obj.Key })),\n Quiet: true,\n },\n });\n await client.send(deleteCommand);\n\n continuationToken = response.IsTruncated\n ? response.NextContinuationToken\n : undefined;\n } while (continuationToken);\n } catch (err) {\n if (err instanceof StorageError) throw err;\n throw new StorageError(\n `Failed to delete ${prefix}: ${err instanceof Error ? err.message : String(err)}`,\n \"delete\",\n );\n }\n }\n\n async read(key: string): Promise<string | Uint8Array | null> {\n try {\n const s3 = await getS3Module();\n const client = this._getClient(s3);\n\n const command = new s3.GetObjectCommand({\n Bucket: this._config.bucket,\n Key: this._s3Key(key),\n });\n\n const response = (await client.send(command)) as {\n Body?: { transformToString(): Promise<string> };\n };\n\n if (!response.Body) return null;\n return response.Body.transformToString();\n } catch (err) {\n // S3 returns NoSuchKey for missing objects\n const code = (err as { name?: string }).name;\n if (code === \"NoSuchKey\") return null;\n if (err instanceof StorageError) throw err;\n throw new StorageError(\n `Failed to read ${key}: ${err instanceof Error ? err.message : String(err)}`,\n \"read\",\n );\n }\n }\n\n async updateMetadata(\n shlId: string,\n updater: (current: SHLMetadata) => SHLMetadata | null,\n ): Promise<SHLMetadata | null> {\n const key = `${shlId}/metadata.json`;\n const raw = await this.read(key);\n if (raw === null) return null;\n\n const current = JSON.parse(raw as string) as SHLMetadata;\n const updated = updater(current);\n if (updated === null) return null;\n\n await this.store(key, JSON.stringify(updated));\n return updated;\n }\n\n private _getClient(s3: S3Module): S3ClientInstance {\n if (!this._client) {\n this._client = new s3.S3Client({ region: this._config.region });\n }\n return this._client;\n }\n\n private _s3Key(key: string): string {\n const prefix = this._config.prefix?.replace(/\\/+$/, \"\");\n return prefix ? `${prefix}/${key}` : key;\n }\n\n private _contentType(key: string): string {\n if (key.endsWith(\".jwe\")) return \"application/jose\";\n if (key.endsWith(\".json\")) return \"application/json\";\n return \"application/octet-stream\";\n }\n}\n\n// ---------------------------------------------------------------------------\n// Azure Blob Storage (Server)\n// ---------------------------------------------------------------------------\n\n// Minimal Azure interfaces for server-side operations\ninterface AzureBlobModule {\n BlobServiceClient: {\n fromConnectionString(connectionString: string): AzureBlobServiceClient;\n };\n}\ninterface AzureBlobServiceClient {\n getContainerClient(container: string): AzureContainerClient;\n}\ninterface AzureContainerClient {\n getBlockBlobClient(blobName: string): AzureBlockBlobClient;\n listBlobsFlat(options?: { prefix?: string }): AsyncIterable<{ name: string }>;\n}\ninterface AzureBlockBlobClient {\n upload(content: Uint8Array | Buffer, contentLength: number, options?: Record<string, unknown>): Promise<unknown>;\n deleteIfExists(): Promise<unknown>;\n download(): Promise<{ readableStreamBody?: NodeJS.ReadableStream }>;\n}\n\nlet _azureModule: AzureBlobModule | undefined;\nasync function getAzureModule(): Promise<AzureBlobModule> {\n if (_azureModule) return _azureModule;\n try {\n _azureModule = (await import(\"@azure/storage-blob\")) as unknown as AzureBlobModule;\n return _azureModule;\n } catch {\n throw new StorageError(\n \"@azure/storage-blob is required for ServerAzureStorage. Install it: npm install @azure/storage-blob\",\n \"import\",\n );\n }\n}\n\n/**\n * Azure Blob Storage server storage for SMART Health Links.\n *\n * Extends the base `AzureStorage` with `read` and `updateMetadata`\n * methods needed for serving SHLs.\n *\n * @example\n * ```ts\n * import { ServerAzureStorage } from \"@fhirfly-io/shl/server\";\n *\n * const storage = new ServerAzureStorage({\n * container: \"shl-data\",\n * connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING!,\n * baseUrl: \"https://shl.example.com\",\n * });\n * ```\n */\nexport class ServerAzureStorage extends AzureStorage implements SHLServerStorage {\n private _serverContainerClient?: AzureContainerClient;\n\n constructor(config: AzureStorageConfig) {\n super(config);\n }\n\n async read(key: string): Promise<string | Uint8Array | null> {\n try {\n const container = await this._getServerContainer();\n const blobName = this._serverBlobName(key);\n const client = container.getBlockBlobClient(blobName);\n\n const response = await client.download();\n if (!response.readableStreamBody) return null;\n\n return await streamToString(response.readableStreamBody);\n } catch (err) {\n const code = (err as { statusCode?: number }).statusCode;\n if (code === 404) return null;\n if (err instanceof StorageError) throw err;\n throw new StorageError(\n `Failed to read ${key}: ${err instanceof Error ? err.message : String(err)}`,\n \"read\",\n );\n }\n }\n\n async updateMetadata(\n shlId: string,\n updater: (current: SHLMetadata) => SHLMetadata | null,\n ): Promise<SHLMetadata | null> {\n const key = `${shlId}/metadata.json`;\n const raw = await this.read(key);\n if (raw === null) return null;\n\n const current = JSON.parse(raw as string) as SHLMetadata;\n const updated = updater(current);\n if (updated === null) return null;\n\n await this.store(key, JSON.stringify(updated));\n return updated;\n }\n\n private async _getServerContainer(): Promise<AzureContainerClient> {\n if (!this._serverContainerClient) {\n const azure = await getAzureModule();\n const serviceClient = azure.BlobServiceClient.fromConnectionString(this.config.connectionString);\n this._serverContainerClient = serviceClient.getContainerClient(this.config.container);\n }\n return this._serverContainerClient;\n }\n\n private _serverBlobName(key: string): string {\n const prefix = this.config.prefix?.replace(/\\/+$/, \"\");\n return prefix ? `${prefix}/${key}` : key;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Google Cloud Storage (Server)\n// ---------------------------------------------------------------------------\n\n// Minimal GCS interfaces for server-side operations\ninterface GCSModule {\n Storage: new () => GCSStorageClient;\n}\ninterface GCSStorageClient {\n bucket(name: string): GCSBucket;\n}\ninterface GCSBucket {\n file(name: string): GCSFile;\n getFiles(options?: { prefix?: string }): Promise<[GCSFile[]]>;\n}\ninterface GCSFile {\n save(content: Buffer, options?: Record<string, unknown>): Promise<void>;\n delete(options?: Record<string, unknown>): Promise<unknown>;\n download(): Promise<[Buffer]>;\n name: string;\n}\n\nlet _gcsModule: GCSModule | undefined;\nasync function getGCSModule(): Promise<GCSModule> {\n if (_gcsModule) return _gcsModule;\n try {\n _gcsModule = (await import(\"@google-cloud/storage\")) as unknown as GCSModule;\n return _gcsModule;\n } catch {\n throw new StorageError(\n \"@google-cloud/storage is required for ServerGCSStorage. Install it: npm install @google-cloud/storage\",\n \"import\",\n );\n }\n}\n\n/**\n * Google Cloud Storage server storage for SMART Health Links.\n *\n * Extends the base `GCSStorage` with `read` and `updateMetadata`\n * methods needed for serving SHLs.\n *\n * @example\n * ```ts\n * import { ServerGCSStorage } from \"@fhirfly-io/shl/server\";\n *\n * const storage = new ServerGCSStorage({\n * bucket: \"my-shl-bucket\",\n * baseUrl: \"https://shl.example.com\",\n * });\n * ```\n */\nexport class ServerGCSStorage extends GCSStorage implements SHLServerStorage {\n private _serverBucket?: GCSBucket;\n\n constructor(config: GCSStorageConfig) {\n super(config);\n }\n\n async read(key: string): Promise<string | Uint8Array | null> {\n try {\n const bucket = await this._getServerBucket();\n const fileName = this._serverFileName(key);\n const file = bucket.file(fileName);\n\n const [content] = await file.download();\n return content.toString(\"utf8\");\n } catch (err) {\n const code = (err as { code?: number }).code;\n if (code === 404) return null;\n if (err instanceof StorageError) throw err;\n throw new StorageError(\n `Failed to read ${key}: ${err instanceof Error ? err.message : String(err)}`,\n \"read\",\n );\n }\n }\n\n async updateMetadata(\n shlId: string,\n updater: (current: SHLMetadata) => SHLMetadata | null,\n ): Promise<SHLMetadata | null> {\n const key = `${shlId}/metadata.json`;\n const raw = await this.read(key);\n if (raw === null) return null;\n\n const current = JSON.parse(raw as string) as SHLMetadata;\n const updated = updater(current);\n if (updated === null) return null;\n\n await this.store(key, JSON.stringify(updated));\n return updated;\n }\n\n private async _getServerBucket(): Promise<GCSBucket> {\n if (!this._serverBucket) {\n const gcs = await getGCSModule();\n const storage = new gcs.Storage();\n this._serverBucket = storage.bucket(this.config.bucket);\n }\n return this._serverBucket;\n }\n\n private _serverFileName(key: string): string {\n const prefix = this.config.prefix?.replace(/\\/+$/, \"\");\n return prefix ? `${prefix}/${key}` : key;\n }\n}\n\n/** Helper: convert a Node.js ReadableStream to a string. */\nasync function streamToString(stream: NodeJS.ReadableStream): Promise<string> {\n const chunks: Buffer[] = [];\n for await (const chunk of stream) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));\n }\n return Buffer.concat(chunks).toString(\"utf8\");\n}\n"]}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'crypto';
|
|
2
|
+
|
|
1
3
|
// src/server/handler.ts
|
|
2
4
|
function createHandler(config) {
|
|
3
5
|
const { storage, onAccess } = config;
|
|
@@ -49,7 +51,15 @@ async function handleManifest(shlId, req, storage, onAccess) {
|
|
|
49
51
|
return null;
|
|
50
52
|
}
|
|
51
53
|
if (metadata.passcode) {
|
|
52
|
-
if (!providedPasscode
|
|
54
|
+
if (!providedPasscode) {
|
|
55
|
+
accessDeniedReason = "passcode";
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const providedHash = createHash("sha256").update(providedPasscode).digest("hex");
|
|
59
|
+
const storedHash = metadata.passcode;
|
|
60
|
+
const a = Buffer.from(providedHash);
|
|
61
|
+
const b = Buffer.from(storedHash);
|
|
62
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
53
63
|
accessDeniedReason = "passcode";
|
|
54
64
|
return null;
|
|
55
65
|
}
|
|
@@ -129,5 +139,5 @@ function jsonResponse(status, body) {
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
export { createHandler };
|
|
132
|
-
//# sourceMappingURL=chunk-
|
|
133
|
-
//# sourceMappingURL=chunk-
|
|
142
|
+
//# sourceMappingURL=chunk-7SVQ24UF.js.map
|
|
143
|
+
//# sourceMappingURL=chunk-7SVQ24UF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server/handler.ts"],"names":[],"mappings":";;;AAiCO,SAAS,cACd,MAAA,EACmD;AACnD,EAAA,MAAM,EAAE,OAAA,EAAS,QAAA,EAAS,GAAI,MAAA;AAE9B,EAAA,OAAO,OAAO,GAAA,KAAkD;AAE9D,IAAA,MAAM,IAAA,GAAO,GAAA,CAAI,IAAA,CAAK,OAAA,CAAQ,QAAQ,EAAE,CAAA;AACxC,IAAA,MAAM,WAAW,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA,CAAE,OAAO,OAAO,CAAA;AAG/C,IAAA,IAAI,QAAA,CAAS,MAAA,KAAW,CAAA,IAAK,GAAA,CAAI,WAAW,MAAA,EAAQ;AAClD,MAAA,OAAO,eAAe,QAAA,CAAS,CAAC,CAAA,EAAI,GAAA,EAAK,SAAS,QAAQ,CAAA;AAAA,IAC5D;AAGA,IAAA,IAAI,QAAA,CAAS,WAAW,CAAA,IAAK,QAAA,CAAS,CAAC,CAAA,KAAM,SAAA,IAAa,GAAA,CAAI,MAAA,KAAW,KAAA,EAAO;AAC9E,MAAA,OAAO,aAAA,CAAc,QAAA,CAAS,CAAC,CAAA,EAAI,OAAO,CAAA;AAAA,IAC5C;AAGA,IAAA,IAAI,QAAA,CAAS,WAAW,CAAA,IAAK,QAAA,CAAS,CAAC,CAAA,KAAM,YAAA,IAAgB,GAAA,CAAI,MAAA,KAAW,KAAA,EAAO;AACjF,MAAA,MAAM,KAAA,GAAQ,SAAS,CAAC,CAAA;AACxB,MAAA,OAAO,gBAAA,CAAiB,QAAA,CAAS,CAAC,CAAA,EAAI,OAAO,OAAO,CAAA;AAAA,IACtD;AAGA,IAAA,IAAI,QAAA,CAAS,MAAA,KAAW,CAAA,IAAK,GAAA,CAAI,WAAW,MAAA,EAAQ;AAClD,MAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,uDAAuD,CAAA;AAAA,IAC3F;AACA,IAAA,IAAI,QAAA,CAAS,WAAW,CAAA,IAAK,QAAA,CAAS,CAAC,CAAA,KAAM,SAAA,IAAa,GAAA,CAAI,MAAA,KAAW,KAAA,EAAO;AAC9E,MAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,qDAAqD,CAAA;AAAA,IACzF;AACA,IAAA,IAAI,QAAA,CAAS,WAAW,CAAA,IAAK,QAAA,CAAS,CAAC,CAAA,KAAM,YAAA,IAAgB,GAAA,CAAI,MAAA,KAAW,KAAA,EAAO;AACjF,MAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,wDAAwD,CAAA;AAAA,IAC5F;AAEA,IAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,aAAa,CAAA;AAAA,EACjD,CAAA;AACF;AAEA,eAAe,cAAA,CACb,KAAA,EACA,GAAA,EACA,OAAA,EACA,QAAA,EAC0B;AAE1B,EAAA,MAAM,cAAc,MAAM,OAAA,CAAQ,IAAA,CAAK,CAAA,EAAG,KAAK,CAAA,cAAA,CAAgB,CAAA;AAC/D,EAAA,IAAI,gBAAgB,IAAA,EAAM;AACxB,IAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,iBAAiB,CAAA;AAAA,EACrD;AAGA,EAAA,IAAI,eAAA,GAAsC,IAAA;AAC1C,EAAA,IAAI,kBAAA,GAAkE,IAAA;AACtE,EAAA,MAAM,OAAA,GAAW,IAAI,IAAA,IAAQ,OAAO,IAAI,IAAA,KAAS,QAAA,GAAW,GAAA,CAAI,IAAA,GAAO,EAAC;AACxE,EAAA,MAAM,gBAAA,GAAmB,OAAO,OAAA,CAAQ,UAAU,MAAM,QAAA,GAAW,OAAA,CAAQ,UAAU,CAAA,GAAI,MAAA;AAEzF,EAAA,eAAA,GAAkB,MAAM,OAAA,CAAQ,cAAA,CAAe,KAAA,EAAO,CAAC,QAAA,KAAa;AAElE,IAAA,IAAI,SAAS,SAAA,EAAW;AACtB,MAAA,MAAM,SAAA,GAAY,IAAI,IAAA,CAAK,QAAA,CAAS,SAAS,CAAA;AAC7C,MAAA,IAAI,SAAA,CAAU,OAAA,EAAQ,IAAK,IAAA,CAAK,KAAI,EAAG;AACrC,QAAA,kBAAA,GAAqB,SAAA;AACrB,QAAA,OAAO,IAAA;AAAA,MACT;AAAA,IACF;AAGA,IAAA,MAAM,YAAA,GAAe,SAAS,WAAA,IAAe,CAAA;AAC7C,IAAA,IAAI,QAAA,CAAS,WAAA,KAAgB,MAAA,IAAa,YAAA,IAAgB,SAAS,WAAA,EAAa;AAC9E,MAAA,kBAAA,GAAqB,WAAA;AACrB,MAAA,OAAO,IAAA;AAAA,IACT;AAGA,IAAA,IAAI,SAAS,QAAA,EAAU;AACrB,MAAA,IAAI,CAAC,gBAAA,EAAkB;AACrB,QAAA,kBAAA,GAAqB,UAAA;AACrB,QAAA,OAAO,IAAA;AAAA,MACT;AACA,MAAA,MAAM,YAAA,GAAe,WAAW,QAAQ,CAAA,CAAE,OAAO,gBAAgB,CAAA,CAAE,OAAO,KAAK,CAAA;AAC/E,MAAA,MAAM,aAAa,QAAA,CAAS,QAAA;AAC5B,MAAA,MAAM,CAAA,GAAI,MAAA,CAAO,IAAA,CAAK,YAAY,CAAA;AAClC,MAAA,MAAM,CAAA,GAAI,MAAA,CAAO,IAAA,CAAK,UAAU,CAAA;AAEhC,MAAA,IAAI,CAAA,CAAE,WAAW,CAAA,CAAE,MAAA,IAAU,CAAC,eAAA,CAAgB,CAAA,EAAG,CAAC,CAAA,EAAG;AACnD,QAAA,kBAAA,GAAqB,UAAA;AACrB,QAAA,OAAO,IAAA;AAAA,MACT;AAAA,IACF;AAGA,IAAA,OAAO;AAAA,MACL,GAAG,QAAA;AAAA,MACH,aAAa,YAAA,GAAe;AAAA,KAC9B;AAAA,EACF,CAAC,CAAA;AAGD,EAAA,IAAI,uBAAuB,SAAA,EAAW;AACpC,IAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,mBAAmB,CAAA;AAAA,EACvD;AACA,EAAA,IAAI,uBAAuB,WAAA,EAAa;AACtC,IAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,4BAA4B,CAAA;AAAA,EAChE;AACA,EAAA,IAAI,uBAAuB,UAAA,EAAY;AACrC,IAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,oBAAoB,CAAA;AAAA,EACxD;AAGA,EAAA,IAAI,oBAAoB,IAAA,EAAM;AAC5B,IAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,iBAAiB,CAAA;AAAA,EACrD;AAGA,EAAA,IAAI,QAAA,EAAU;AACZ,IAAA,MAAM,KAAA,GAAQ;AAAA,MACZ,KAAA;AAAA,MACA,WAAA,EAAa,gBAAgB,WAAA,IAAe,CAAA;AAAA,MAC5C,SAAA,sBAAe,IAAA;AAAK,KACtB;AAEA,IAAA,OAAA,CAAQ,QAAQ,QAAA,CAAS,KAAK,CAAC,CAAA,CAAE,MAAM,MAAM;AAAA,IAAC,CAAC,CAAA;AAAA,EACjD;AAGA,EAAA,MAAM,WAAA,GAAc,OAAO,WAAA,KAAgB,QAAA,GACvC,cACA,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,WAAW,CAAA;AACxC,EAAA,MAAM,QAAA,GAAW,IAAA,CAAK,KAAA,CAAM,WAAW,CAAA;AAEvC,EAAA,OAAO,YAAA,CAAa,KAAK,QAAQ,CAAA;AACnC;AAEA,eAAe,aAAA,CACb,OACA,OAAA,EAC0B;AAC1B,EAAA,MAAM,UAAU,MAAM,OAAA,CAAQ,IAAA,CAAK,CAAA,EAAG,KAAK,CAAA,YAAA,CAAc,CAAA;AACzD,EAAA,IAAI,YAAY,IAAA,EAAM;AACpB,IAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,qBAAqB,CAAA;AAAA,EACzD;AAEA,EAAA,MAAM,IAAA,GAAO,OAAO,OAAA,KAAY,QAAA,GAC5B,UACA,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,OAAO,CAAA;AAEpC,EAAA,OAAO;AAAA,IACL,MAAA,EAAQ,GAAA;AAAA,IACR,OAAA,EAAS;AAAA,MACP,cAAA,EAAgB,kBAAA;AAAA,MAChB,eAAA,EAAiB;AAAA,KACnB;AAAA,IACA;AAAA,GACF;AACF;AAEA,eAAe,gBAAA,CACb,KAAA,EACA,KAAA,EACA,OAAA,EAC0B;AAC1B,EAAA,IAAI,CAAC,OAAA,CAAQ,IAAA,CAAK,KAAK,CAAA,EAAG;AACxB,IAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,4BAA4B,CAAA;AAAA,EAChE;AACA,EAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,IAAA,CAAK,GAAG,KAAK,CAAA,YAAA,EAAe,KAAK,CAAA,IAAA,CAAM,CAAA;AACrE,EAAA,IAAI,YAAY,IAAA,EAAM;AACpB,IAAA,OAAO,YAAA,CAAa,GAAA,EAAK,EAAE,KAAA,EAAO,wBAAwB,CAAA;AAAA,EAC5D;AACA,EAAA,MAAM,IAAA,GAAO,OAAO,OAAA,KAAY,QAAA,GAC5B,UACA,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,OAAO,CAAA;AACpC,EAAA,OAAO;AAAA,IACL,MAAA,EAAQ,GAAA;AAAA,IACR,OAAA,EAAS;AAAA,MACP,cAAA,EAAgB,kBAAA;AAAA,MAChB,eAAA,EAAiB;AAAA,KACnB;AAAA,IACA;AAAA,GACF;AACF;AAEA,SAAS,YAAA,CAAa,QAAgB,IAAA,EAAgC;AACpE,EAAA,OAAO;AAAA,IACL,MAAA;AAAA,IACA,OAAA,EAAS;AAAA,MACP,cAAA,EAAgB,kBAAA;AAAA,MAChB,eAAA,EAAiB;AAAA,KACnB;AAAA,IACA,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,IAAI;AAAA,GAC3B;AACF","file":"chunk-7SVQ24UF.js","sourcesContent":["// Copyright 2026 FHIRfly.io LLC. All rights reserved.\n// Licensed under the MIT License. See LICENSE file in the project root.\nimport type {\n HandlerRequest,\n HandlerResponse,\n SHLHandlerConfig,\n} from \"./types.js\";\nimport type { SHLMetadata, Manifest } from \"../shl/types.js\";\nimport { createHash, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Create a framework-agnostic SHL request handler.\n *\n * Returns an async function that processes incoming requests and returns\n * responses. This handler implements two routes:\n *\n * - `POST /{shlId}` — Manifest endpoint (validates passcode, checks access limits)\n * - `GET /{shlId}/content` — Content endpoint (serves encrypted JWE)\n *\n * Framework adapters (Express, Fastify, Lambda) translate their native\n * request/response types to/from `HandlerRequest`/`HandlerResponse`.\n *\n * @example\n * ```ts\n * const handle = createHandler({ storage });\n * const response = await handle({\n * method: \"POST\",\n * path: \"/abc123\",\n * body: { passcode: \"1234\" },\n * headers: { \"content-type\": \"application/json\" },\n * });\n * ```\n */\nexport function createHandler(\n config: SHLHandlerConfig,\n): (req: HandlerRequest) => Promise<HandlerResponse> {\n const { storage, onAccess } = config;\n\n return async (req: HandlerRequest): Promise<HandlerResponse> => {\n // Normalize path: strip leading slash, split into segments\n const path = req.path.replace(/^\\/+/, \"\");\n const segments = path.split(\"/\").filter(Boolean);\n\n // Route: POST /{shlId} → manifest\n if (segments.length === 1 && req.method === \"POST\") {\n return handleManifest(segments[0]!, req, storage, onAccess);\n }\n\n // Route: GET /{shlId}/content → serve encrypted content\n if (segments.length === 2 && segments[1] === \"content\" && req.method === \"GET\") {\n return handleContent(segments[0]!, storage);\n }\n\n // Route: GET /{shlId}/attachment/{index} → serve encrypted attachment\n if (segments.length === 3 && segments[1] === \"attachment\" && req.method === \"GET\") {\n const index = segments[2]!;\n return handleAttachment(segments[0]!, index, storage);\n }\n\n // Method not allowed for known paths\n if (segments.length === 1 && req.method !== \"POST\") {\n return jsonResponse(405, { error: \"Method not allowed. Use POST for manifest requests.\" });\n }\n if (segments.length === 2 && segments[1] === \"content\" && req.method !== \"GET\") {\n return jsonResponse(405, { error: \"Method not allowed. Use GET for content requests.\" });\n }\n if (segments.length === 3 && segments[1] === \"attachment\" && req.method !== \"GET\") {\n return jsonResponse(405, { error: \"Method not allowed. Use GET for attachment requests.\" });\n }\n\n return jsonResponse(404, { error: \"Not found\" });\n };\n}\n\nasync function handleManifest(\n shlId: string,\n req: HandlerRequest,\n storage: SHLHandlerConfig[\"storage\"],\n onAccess?: SHLHandlerConfig[\"onAccess\"],\n): Promise<HandlerResponse> {\n // Read manifest to verify the SHL exists\n const manifestRaw = await storage.read(`${shlId}/manifest.json`);\n if (manifestRaw === null) {\n return jsonResponse(404, { error: \"SHL not found\" });\n }\n\n // Atomically check access control + increment counter\n let updatedMetadata: SHLMetadata | null = null;\n let accessDeniedReason: \"expired\" | \"exhausted\" | \"passcode\" | null = null;\n const reqBody = (req.body && typeof req.body === \"object\" ? req.body : {}) as Record<string, unknown>;\n const providedPasscode = typeof reqBody[\"passcode\"] === \"string\" ? reqBody[\"passcode\"] : undefined;\n\n updatedMetadata = await storage.updateMetadata(shlId, (metadata) => {\n // Check expiration\n if (metadata.expiresAt) {\n const expiresAt = new Date(metadata.expiresAt);\n if (expiresAt.getTime() <= Date.now()) {\n accessDeniedReason = \"expired\";\n return null;\n }\n }\n\n // Check access count\n const currentCount = metadata.accessCount ?? 0;\n if (metadata.maxAccesses !== undefined && currentCount >= metadata.maxAccesses) {\n accessDeniedReason = \"exhausted\";\n return null;\n }\n\n // Check passcode (timing-safe comparison with SHA-256 hash)\n if (metadata.passcode) {\n if (!providedPasscode) {\n accessDeniedReason = \"passcode\";\n return null;\n }\n const providedHash = createHash(\"sha256\").update(providedPasscode).digest(\"hex\");\n const storedHash = metadata.passcode;\n const a = Buffer.from(providedHash);\n const b = Buffer.from(storedHash);\n // Constant-time comparison: compare with self if lengths differ to avoid timing leak\n if (a.length !== b.length || !timingSafeEqual(a, b)) {\n accessDeniedReason = \"passcode\";\n return null;\n }\n }\n\n // Access granted — increment count\n return {\n ...metadata,\n accessCount: currentCount + 1,\n };\n });\n\n // Handle access control failures\n if (accessDeniedReason === \"expired\") {\n return jsonResponse(410, { error: \"SHL has expired\" });\n }\n if (accessDeniedReason === \"exhausted\") {\n return jsonResponse(410, { error: \"SHL access limit reached\" });\n }\n if (accessDeniedReason === \"passcode\") {\n return jsonResponse(401, { error: \"Invalid passcode\" });\n }\n\n // If updateMetadata returned null but no denied reason, metadata file is missing\n if (updatedMetadata === null) {\n return jsonResponse(404, { error: \"SHL not found\" });\n }\n\n // Fire access event (non-blocking)\n if (onAccess) {\n const event = {\n shlId,\n accessCount: updatedMetadata.accessCount ?? 1,\n timestamp: new Date(),\n };\n // Fire and forget — don't let callback errors break the response\n Promise.resolve(onAccess(event)).catch(() => {});\n }\n\n // Return manifest\n const manifestStr = typeof manifestRaw === \"string\"\n ? manifestRaw\n : new TextDecoder().decode(manifestRaw);\n const manifest = JSON.parse(manifestStr) as Manifest;\n\n return jsonResponse(200, manifest);\n}\n\nasync function handleContent(\n shlId: string,\n storage: SHLHandlerConfig[\"storage\"],\n): Promise<HandlerResponse> {\n const content = await storage.read(`${shlId}/content.jwe`);\n if (content === null) {\n return jsonResponse(404, { error: \"Content not found\" });\n }\n\n const body = typeof content === \"string\"\n ? content\n : new TextDecoder().decode(content);\n\n return {\n status: 200,\n headers: {\n \"content-type\": \"application/jose\",\n \"cache-control\": \"no-store\",\n },\n body,\n };\n}\n\nasync function handleAttachment(\n shlId: string,\n index: string,\n storage: SHLHandlerConfig[\"storage\"],\n): Promise<HandlerResponse> {\n if (!/^\\d+$/.test(index)) {\n return jsonResponse(400, { error: \"Invalid attachment index\" });\n }\n const content = await storage.read(`${shlId}/attachment-${index}.jwe`);\n if (content === null) {\n return jsonResponse(404, { error: \"Attachment not found\" });\n }\n const body = typeof content === \"string\"\n ? content\n : new TextDecoder().decode(content);\n return {\n status: 200,\n headers: {\n \"content-type\": \"application/jose\",\n \"cache-control\": \"no-store\",\n },\n body,\n };\n}\n\nfunction jsonResponse(status: number, body: unknown): HandlerResponse {\n return {\n status,\n headers: {\n \"content-type\": \"application/json\",\n \"cache-control\": \"no-store\",\n },\n body: JSON.stringify(body),\n };\n}\n"]}
|