@precisa-saude/fhir-rnds-sandbox 0.1.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/LICENSE +201 -0
- package/README.md +477 -0
- package/dist/cli.cjs +1588 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +18 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +1556 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +1425 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +338 -0
- package/dist/index.d.ts +338 -0
- package/dist/index.js +1375 -0
- package/dist/index.js.map +1 -0
- package/dist/types-C61Coait.d.cts +133 -0
- package/dist/types-C61Coait.d.ts +133 -0
- package/package.json +69 -0
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
20
|
+
};
|
|
21
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
22
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
23
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
24
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
25
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
26
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
|
+
mod
|
|
28
|
+
));
|
|
29
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
30
|
+
|
|
31
|
+
// src/cli.ts
|
|
32
|
+
var cli_exports = {};
|
|
33
|
+
__export(cli_exports, {
|
|
34
|
+
main: () => main,
|
|
35
|
+
parseArgs: () => parseArgs
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(cli_exports);
|
|
38
|
+
var import_node_fs3 = __toESM(require("fs"), 1);
|
|
39
|
+
var import_node_url = require("url");
|
|
40
|
+
|
|
41
|
+
// src/scenarios/internacao.ts
|
|
42
|
+
var CPF = "98765432100";
|
|
43
|
+
var CNS = "700000000000020";
|
|
44
|
+
var CNES_HOSPITAL = "3456789";
|
|
45
|
+
var CNS_PROFISSIONAL = "700000000000030";
|
|
46
|
+
var internacao = {
|
|
47
|
+
data: {
|
|
48
|
+
organizations: [
|
|
49
|
+
{
|
|
50
|
+
active: true,
|
|
51
|
+
id: CNES_HOSPITAL,
|
|
52
|
+
identifier: [
|
|
53
|
+
{ system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cnes", value: CNES_HOSPITAL }
|
|
54
|
+
],
|
|
55
|
+
name: "Hospital Sint\xE9tico do Cora\xE7\xE3o (sandbox)",
|
|
56
|
+
resourceType: "Organization"
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
patients: [
|
|
60
|
+
{
|
|
61
|
+
birthDate: "1958-02-28",
|
|
62
|
+
gender: "male",
|
|
63
|
+
id: CNS,
|
|
64
|
+
identifier: [
|
|
65
|
+
{ system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cpf", value: CPF },
|
|
66
|
+
{ system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns", value: CNS }
|
|
67
|
+
],
|
|
68
|
+
name: [{ family: "Souza", given: ["Carlos", "Henrique"] }],
|
|
69
|
+
resourceType: "Patient"
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
practitioners: [
|
|
73
|
+
{
|
|
74
|
+
active: true,
|
|
75
|
+
id: CNS_PROFISSIONAL,
|
|
76
|
+
identifier: [
|
|
77
|
+
{
|
|
78
|
+
system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns",
|
|
79
|
+
value: CNS_PROFISSIONAL
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
name: [{ family: "Mendes", given: ["Roberto"] }],
|
|
83
|
+
resourceType: "Practitioner"
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
submittedBundles: [
|
|
87
|
+
{
|
|
88
|
+
entry: [
|
|
89
|
+
{
|
|
90
|
+
request: { method: "POST", url: "Encounter" },
|
|
91
|
+
resource: {
|
|
92
|
+
class: { code: "IMP", display: "inpatient encounter" },
|
|
93
|
+
period: { start: "2026-04-15T10:30:00-03:00" },
|
|
94
|
+
resourceType: "Encounter",
|
|
95
|
+
serviceProvider: { reference: `Organization/${CNES_HOSPITAL}` },
|
|
96
|
+
status: "in-progress",
|
|
97
|
+
subject: { reference: `Patient/${CNS}` }
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
request: { method: "POST", url: "Condition" },
|
|
102
|
+
resource: {
|
|
103
|
+
code: {
|
|
104
|
+
coding: [
|
|
105
|
+
{
|
|
106
|
+
code: "I50.0",
|
|
107
|
+
display: "Insufici\xEAncia card\xEDaca congestiva",
|
|
108
|
+
system: "http://hl7.org/fhir/sid/icd-10"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
recordedDate: "2026-04-15",
|
|
113
|
+
resourceType: "Condition",
|
|
114
|
+
subject: { reference: `Patient/${CNS}` }
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
request: { method: "POST", url: "Observation" },
|
|
119
|
+
resource: {
|
|
120
|
+
code: {
|
|
121
|
+
coding: [{ code: "33762-6", display: "NT-proBNP", system: "http://loinc.org" }]
|
|
122
|
+
},
|
|
123
|
+
effectiveDateTime: "2026-04-15T11:15:00-03:00",
|
|
124
|
+
resourceType: "Observation",
|
|
125
|
+
status: "final",
|
|
126
|
+
subject: { reference: `Patient/${CNS}` },
|
|
127
|
+
valueQuantity: {
|
|
128
|
+
code: "pg/mL",
|
|
129
|
+
system: "http://unitsofmeasure.org",
|
|
130
|
+
unit: "pg/mL",
|
|
131
|
+
value: 4820
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
request: { method: "POST", url: "Observation" },
|
|
137
|
+
resource: {
|
|
138
|
+
code: {
|
|
139
|
+
coding: [{ code: "49563-0", display: "Troponina I", system: "http://loinc.org" }]
|
|
140
|
+
},
|
|
141
|
+
effectiveDateTime: "2026-04-15T11:15:00-03:00",
|
|
142
|
+
resourceType: "Observation",
|
|
143
|
+
status: "final",
|
|
144
|
+
subject: { reference: `Patient/${CNS}` },
|
|
145
|
+
valueQuantity: {
|
|
146
|
+
code: "ng/mL",
|
|
147
|
+
system: "http://unitsofmeasure.org",
|
|
148
|
+
unit: "ng/mL",
|
|
149
|
+
value: 0.08
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
resourceType: "Bundle",
|
|
155
|
+
type: "transaction"
|
|
156
|
+
}
|
|
157
|
+
]
|
|
158
|
+
},
|
|
159
|
+
description: "Carlos Henrique Souza (CPF 987.654.321-00, CNS 700 0000 0000 0020), 67 anos, internado por insufici\xEAncia card\xEDaca descompensada (CID I50.0). Hospital CNES 3456789.",
|
|
160
|
+
name: "internacao"
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// src/scenarios/paciente-com-exames.ts
|
|
164
|
+
var CPF2 = "12345678901";
|
|
165
|
+
var CNS2 = "700000000000001";
|
|
166
|
+
var CNES_LAB = "2345678";
|
|
167
|
+
var CNS_PROFISSIONAL2 = "700000000000010";
|
|
168
|
+
var pacienteComExames = {
|
|
169
|
+
data: {
|
|
170
|
+
organizations: [
|
|
171
|
+
{
|
|
172
|
+
active: true,
|
|
173
|
+
id: CNES_LAB,
|
|
174
|
+
identifier: [
|
|
175
|
+
{
|
|
176
|
+
system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cnes",
|
|
177
|
+
value: CNES_LAB
|
|
178
|
+
}
|
|
179
|
+
],
|
|
180
|
+
name: "Laborat\xF3rio Sint\xE9tico S\xE3o Paulo (sandbox)",
|
|
181
|
+
resourceType: "Organization"
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
patients: [
|
|
185
|
+
{
|
|
186
|
+
birthDate: "1983-08-12",
|
|
187
|
+
gender: "female",
|
|
188
|
+
id: CNS2,
|
|
189
|
+
identifier: [
|
|
190
|
+
{ system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cpf", value: CPF2 },
|
|
191
|
+
{ system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns", value: CNS2 }
|
|
192
|
+
],
|
|
193
|
+
name: [{ family: "Silva", given: ["Joana", "Maria", "da"] }],
|
|
194
|
+
resourceType: "Patient"
|
|
195
|
+
}
|
|
196
|
+
],
|
|
197
|
+
practitioners: [
|
|
198
|
+
{
|
|
199
|
+
active: true,
|
|
200
|
+
id: CNS_PROFISSIONAL2,
|
|
201
|
+
identifier: [
|
|
202
|
+
{
|
|
203
|
+
system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns",
|
|
204
|
+
value: CNS_PROFISSIONAL2
|
|
205
|
+
}
|
|
206
|
+
],
|
|
207
|
+
name: [{ family: "Pereira", given: ["Ana", "Carolina"] }],
|
|
208
|
+
resourceType: "Practitioner"
|
|
209
|
+
}
|
|
210
|
+
],
|
|
211
|
+
submittedBundles: [
|
|
212
|
+
{
|
|
213
|
+
entry: [
|
|
214
|
+
{
|
|
215
|
+
request: { method: "POST", url: "Observation" },
|
|
216
|
+
resource: {
|
|
217
|
+
code: {
|
|
218
|
+
coding: [
|
|
219
|
+
{
|
|
220
|
+
code: "2093-3",
|
|
221
|
+
display: "Colesterol total",
|
|
222
|
+
system: "http://loinc.org"
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
},
|
|
226
|
+
effectiveDateTime: "2025-09-10",
|
|
227
|
+
resourceType: "Observation",
|
|
228
|
+
status: "final",
|
|
229
|
+
subject: { reference: `Patient/${CNS2}` },
|
|
230
|
+
valueQuantity: {
|
|
231
|
+
code: "mg/dL",
|
|
232
|
+
system: "http://unitsofmeasure.org",
|
|
233
|
+
unit: "mg/dL",
|
|
234
|
+
value: 198
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
request: { method: "POST", url: "Observation" },
|
|
240
|
+
resource: {
|
|
241
|
+
code: {
|
|
242
|
+
coding: [{ code: "2089-1", display: "LDL-Colesterol", system: "http://loinc.org" }]
|
|
243
|
+
},
|
|
244
|
+
effectiveDateTime: "2025-09-10",
|
|
245
|
+
resourceType: "Observation",
|
|
246
|
+
status: "final",
|
|
247
|
+
subject: { reference: `Patient/${CNS2}` },
|
|
248
|
+
valueQuantity: {
|
|
249
|
+
code: "mg/dL",
|
|
250
|
+
system: "http://unitsofmeasure.org",
|
|
251
|
+
unit: "mg/dL",
|
|
252
|
+
value: 124
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
request: { method: "POST", url: "Observation" },
|
|
258
|
+
resource: {
|
|
259
|
+
code: {
|
|
260
|
+
coding: [{ code: "2085-9", display: "HDL-Colesterol", system: "http://loinc.org" }]
|
|
261
|
+
},
|
|
262
|
+
effectiveDateTime: "2025-09-10",
|
|
263
|
+
resourceType: "Observation",
|
|
264
|
+
status: "final",
|
|
265
|
+
subject: { reference: `Patient/${CNS2}` },
|
|
266
|
+
valueQuantity: {
|
|
267
|
+
code: "mg/dL",
|
|
268
|
+
system: "http://unitsofmeasure.org",
|
|
269
|
+
unit: "mg/dL",
|
|
270
|
+
value: 56
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
request: { method: "POST", url: "Observation" },
|
|
276
|
+
resource: {
|
|
277
|
+
code: {
|
|
278
|
+
coding: [{ code: "2345-7", display: "Glicose", system: "http://loinc.org" }]
|
|
279
|
+
},
|
|
280
|
+
effectiveDateTime: "2025-09-10",
|
|
281
|
+
resourceType: "Observation",
|
|
282
|
+
status: "final",
|
|
283
|
+
subject: { reference: `Patient/${CNS2}` },
|
|
284
|
+
valueQuantity: {
|
|
285
|
+
code: "mg/dL",
|
|
286
|
+
system: "http://unitsofmeasure.org",
|
|
287
|
+
unit: "mg/dL",
|
|
288
|
+
value: 96
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
],
|
|
293
|
+
resourceType: "Bundle",
|
|
294
|
+
type: "transaction"
|
|
295
|
+
}
|
|
296
|
+
]
|
|
297
|
+
},
|
|
298
|
+
description: "Joana Maria da Silva (CPF 123.456.789-01, CNS 700 0000 0000 0001), 42 anos, com hist\xF3rico de lipidograma e glicemia. Laborat\xF3rio CNES 2345678. Profissional Dr(a). Pereira (CNS 700 0000 0000 0010).",
|
|
299
|
+
name: "paciente-com-exames"
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/scenarios/vacina.ts
|
|
303
|
+
var CNS3 = "700000000000040";
|
|
304
|
+
var CNES_UBS = "4567890";
|
|
305
|
+
var CNS_PROFISSIONAL3 = "700000000000050";
|
|
306
|
+
var vacina = {
|
|
307
|
+
data: {
|
|
308
|
+
organizations: [
|
|
309
|
+
{
|
|
310
|
+
active: true,
|
|
311
|
+
id: CNES_UBS,
|
|
312
|
+
identifier: [
|
|
313
|
+
{ system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cnes", value: CNES_UBS }
|
|
314
|
+
],
|
|
315
|
+
name: "UBS Sint\xE9tica Vila Sa\xFAde (sandbox)",
|
|
316
|
+
resourceType: "Organization"
|
|
317
|
+
}
|
|
318
|
+
],
|
|
319
|
+
patients: [
|
|
320
|
+
{
|
|
321
|
+
birthDate: "2017-11-04",
|
|
322
|
+
gender: "male",
|
|
323
|
+
id: CNS3,
|
|
324
|
+
identifier: [{ system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns", value: CNS3 }],
|
|
325
|
+
name: [{ family: "Almeida", given: ["Pedro", "Lima"] }],
|
|
326
|
+
resourceType: "Patient"
|
|
327
|
+
}
|
|
328
|
+
],
|
|
329
|
+
practitioners: [
|
|
330
|
+
{
|
|
331
|
+
active: true,
|
|
332
|
+
id: CNS_PROFISSIONAL3,
|
|
333
|
+
identifier: [
|
|
334
|
+
{
|
|
335
|
+
system: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns",
|
|
336
|
+
value: CNS_PROFISSIONAL3
|
|
337
|
+
}
|
|
338
|
+
],
|
|
339
|
+
name: [{ family: "Costa", given: ["Fernanda"] }],
|
|
340
|
+
resourceType: "Practitioner"
|
|
341
|
+
}
|
|
342
|
+
],
|
|
343
|
+
submittedBundles: [
|
|
344
|
+
{
|
|
345
|
+
entry: [
|
|
346
|
+
{
|
|
347
|
+
request: { method: "POST", url: "Immunization" },
|
|
348
|
+
resource: {
|
|
349
|
+
occurrenceDateTime: "2017-11-05",
|
|
350
|
+
patient: { reference: `Patient/${CNS3}` },
|
|
351
|
+
resourceType: "Immunization",
|
|
352
|
+
status: "completed",
|
|
353
|
+
vaccineCode: {
|
|
354
|
+
coding: [{ code: "BCG", display: "BCG", system: "urn:oid:2.16.840.1.113883.6.59" }]
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
request: { method: "POST", url: "Immunization" },
|
|
360
|
+
resource: {
|
|
361
|
+
occurrenceDateTime: "2017-11-05",
|
|
362
|
+
patient: { reference: `Patient/${CNS3}` },
|
|
363
|
+
resourceType: "Immunization",
|
|
364
|
+
status: "completed",
|
|
365
|
+
vaccineCode: {
|
|
366
|
+
coding: [
|
|
367
|
+
{
|
|
368
|
+
code: "HBV",
|
|
369
|
+
display: "Hepatite B (RN)",
|
|
370
|
+
system: "urn:oid:2.16.840.1.113883.6.59"
|
|
371
|
+
}
|
|
372
|
+
]
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
request: { method: "POST", url: "Immunization" },
|
|
378
|
+
resource: {
|
|
379
|
+
occurrenceDateTime: "2018-12-12",
|
|
380
|
+
patient: { reference: `Patient/${CNS3}` },
|
|
381
|
+
resourceType: "Immunization",
|
|
382
|
+
status: "completed",
|
|
383
|
+
vaccineCode: {
|
|
384
|
+
coding: [
|
|
385
|
+
{
|
|
386
|
+
code: "MMR",
|
|
387
|
+
display: "Tr\xEDplice viral (SCR)",
|
|
388
|
+
system: "urn:oid:2.16.840.1.113883.6.59"
|
|
389
|
+
}
|
|
390
|
+
]
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
],
|
|
395
|
+
resourceType: "Bundle",
|
|
396
|
+
type: "transaction"
|
|
397
|
+
}
|
|
398
|
+
]
|
|
399
|
+
},
|
|
400
|
+
description: "Pedro Lima Almeida (CNS 700 0000 0000 0040), 8 anos, com calend\xE1rio vacinal aplicado em UBS CNES 4567890.",
|
|
401
|
+
name: "vacina"
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// src/scenarios/index.ts
|
|
405
|
+
var SCENARIOS = {
|
|
406
|
+
internacao,
|
|
407
|
+
"paciente-com-exames": pacienteComExames,
|
|
408
|
+
vacina,
|
|
409
|
+
vazio: {
|
|
410
|
+
data: { organizations: [], patients: [], practitioners: [] },
|
|
411
|
+
description: "Estado limpo. \xDAtil para testar fluxos do zero.",
|
|
412
|
+
name: "vazio"
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
function resolveScenario(name) {
|
|
416
|
+
return SCENARIOS[name ?? "paciente-com-exames"];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/server.ts
|
|
420
|
+
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
421
|
+
var import_node_http = __toESM(require("http"), 1);
|
|
422
|
+
var import_node_https = __toESM(require("https"), 1);
|
|
423
|
+
|
|
424
|
+
// src/keys.ts
|
|
425
|
+
var import_node_crypto = require("crypto");
|
|
426
|
+
var import_node_fs = __toESM(require("fs"), 1);
|
|
427
|
+
var DEFAULT_KEY_ID = "rnds-sandbox-1";
|
|
428
|
+
var cachedDefaultKeys;
|
|
429
|
+
function resolveSigningKeys(options = {}) {
|
|
430
|
+
const keyId = options.keyId ?? DEFAULT_KEY_ID;
|
|
431
|
+
if (options.privateKey) {
|
|
432
|
+
const privatePem = readPem(options.privateKey);
|
|
433
|
+
const privateKey = (0, import_node_crypto.createPrivateKey)(privatePem);
|
|
434
|
+
const publicKey = options.publicKey ? (0, import_node_crypto.createPublicKey)(readPem(options.publicKey)) : (0, import_node_crypto.createPublicKey)(privateKey);
|
|
435
|
+
return { keyId, privateKey, publicKey };
|
|
436
|
+
}
|
|
437
|
+
if (keyId === DEFAULT_KEY_ID && cachedDefaultKeys) {
|
|
438
|
+
return cachedDefaultKeys;
|
|
439
|
+
}
|
|
440
|
+
const generated = (0, import_node_crypto.generateKeyPairSync)("rsa", { modulusLength: 2048 });
|
|
441
|
+
const result = {
|
|
442
|
+
keyId,
|
|
443
|
+
privateKey: generated.privateKey,
|
|
444
|
+
publicKey: generated.publicKey
|
|
445
|
+
};
|
|
446
|
+
if (keyId === DEFAULT_KEY_ID) {
|
|
447
|
+
cachedDefaultKeys = result;
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
function readPem(value) {
|
|
452
|
+
if (typeof value !== "string") {
|
|
453
|
+
return value;
|
|
454
|
+
}
|
|
455
|
+
if (value.includes("-----BEGIN")) {
|
|
456
|
+
return value;
|
|
457
|
+
}
|
|
458
|
+
return import_node_fs.default.readFileSync(value);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// src/auth.ts
|
|
462
|
+
var import_node_crypto2 = require("crypto");
|
|
463
|
+
var TOKEN_TTL_MS = 30 * 60 * 1e3;
|
|
464
|
+
function issueToken(keys, options = {}) {
|
|
465
|
+
const now = options.now ?? Date.now();
|
|
466
|
+
const audience = options.audience ?? "rnds-sandbox";
|
|
467
|
+
const issuer = options.issuer ?? "https://rnds-sandbox.local";
|
|
468
|
+
const claims = {
|
|
469
|
+
aud: audience,
|
|
470
|
+
exp: Math.floor((now + TOKEN_TTL_MS) / 1e3),
|
|
471
|
+
iat: Math.floor(now / 1e3),
|
|
472
|
+
iss: issuer,
|
|
473
|
+
sub: options.identity?.cn ?? "sandbox",
|
|
474
|
+
...options.identity?.cn ? { cn: options.identity.cn } : {},
|
|
475
|
+
...options.identity?.cnes ? { cnes: options.identity.cnes } : {}
|
|
476
|
+
};
|
|
477
|
+
const accessToken = signJwt(claims, keys);
|
|
478
|
+
return {
|
|
479
|
+
access_token: accessToken,
|
|
480
|
+
expires_in: Math.floor(TOKEN_TTL_MS / 1e3),
|
|
481
|
+
scope: "rnds:read rnds:write",
|
|
482
|
+
token_type: "Bearer"
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function verifyToken(token, keys, options = {}) {
|
|
486
|
+
const now = options.now ?? Date.now();
|
|
487
|
+
const expectedAudience = options.audience ?? "rnds-sandbox";
|
|
488
|
+
const parts = token.split(".");
|
|
489
|
+
if (parts.length !== 3) {
|
|
490
|
+
return { reason: "malformed", valid: false };
|
|
491
|
+
}
|
|
492
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
493
|
+
let header;
|
|
494
|
+
let payload;
|
|
495
|
+
try {
|
|
496
|
+
header = JSON.parse(Buffer.from(headerB64, "base64url").toString("utf-8"));
|
|
497
|
+
payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf-8"));
|
|
498
|
+
} catch {
|
|
499
|
+
return { reason: "malformed", valid: false };
|
|
500
|
+
}
|
|
501
|
+
if (header.alg !== "RS256") {
|
|
502
|
+
return { reason: "malformed", valid: false };
|
|
503
|
+
}
|
|
504
|
+
if (header.kid && header.kid !== keys.keyId) {
|
|
505
|
+
return { reason: "wrong-key", valid: false };
|
|
506
|
+
}
|
|
507
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
508
|
+
const verifier = (0, import_node_crypto2.createVerify)("RSA-SHA256").update(signingInput);
|
|
509
|
+
const signatureValid = verifier.verify(keys.publicKey, Buffer.from(signatureB64, "base64url"));
|
|
510
|
+
if (!signatureValid) {
|
|
511
|
+
return { reason: "bad-signature", valid: false };
|
|
512
|
+
}
|
|
513
|
+
if (payload.exp * 1e3 < now) {
|
|
514
|
+
return { reason: "expired", valid: false };
|
|
515
|
+
}
|
|
516
|
+
if (payload.aud !== expectedAudience) {
|
|
517
|
+
return { reason: "wrong-audience", valid: false };
|
|
518
|
+
}
|
|
519
|
+
return { claims: payload, valid: true };
|
|
520
|
+
}
|
|
521
|
+
function signJwt(claims, keys) {
|
|
522
|
+
const header = { alg: "RS256", kid: keys.keyId, typ: "JWT" };
|
|
523
|
+
const headerB64 = base64UrlJson(header);
|
|
524
|
+
const payloadB64 = base64UrlJson(claims);
|
|
525
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
526
|
+
const signer = (0, import_node_crypto2.createSign)("RSA-SHA256").update(signingInput);
|
|
527
|
+
const signature = signer.sign(keys.privateKey).toString("base64url");
|
|
528
|
+
return `${signingInput}.${signature}`;
|
|
529
|
+
}
|
|
530
|
+
function base64UrlJson(value) {
|
|
531
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/jwks.ts
|
|
535
|
+
function buildJwks(keys) {
|
|
536
|
+
const exported = keys.publicKey.export({ format: "jwk" });
|
|
537
|
+
if (typeof exported !== "object" || exported === null || exported.kty !== "RSA") {
|
|
538
|
+
throw new Error("Chave p\xFAblica n\xE3o \xE9 RSA \u2014 JWKS s\xF3 suporta RS256 no sandbox");
|
|
539
|
+
}
|
|
540
|
+
if (typeof exported.n !== "string" || typeof exported.e !== "string") {
|
|
541
|
+
throw new Error("Chave p\xFAblica RSA exportada est\xE1 incompleta (n/e ausentes)");
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
keys: [
|
|
545
|
+
{
|
|
546
|
+
alg: "RS256",
|
|
547
|
+
e: exported.e,
|
|
548
|
+
kid: keys.keyId,
|
|
549
|
+
kty: "RSA",
|
|
550
|
+
n: exported.n,
|
|
551
|
+
use: "sig"
|
|
552
|
+
}
|
|
553
|
+
]
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/validation/helpers.ts
|
|
558
|
+
function issue(severity, code, diagnostics, path) {
|
|
559
|
+
return { code, diagnostics, expression: [path], severity };
|
|
560
|
+
}
|
|
561
|
+
function requireField(value, label, path) {
|
|
562
|
+
if (value === void 0 || value === null) {
|
|
563
|
+
return [issue("error", "required", `${label} \xE9 obrigat\xF3rio`, path)];
|
|
564
|
+
}
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
function requireMinCardinality(value, min, label, path) {
|
|
568
|
+
if (!Array.isArray(value) || value.length < min) {
|
|
569
|
+
return [
|
|
570
|
+
issue(
|
|
571
|
+
"error",
|
|
572
|
+
"required",
|
|
573
|
+
`${label} requer pelo menos ${min} elemento(s); recebido ${Array.isArray(value) ? value.length : 0}`,
|
|
574
|
+
path
|
|
575
|
+
)
|
|
576
|
+
];
|
|
577
|
+
}
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
function requireOneOf(actual, allowed, label, path) {
|
|
581
|
+
if (typeof actual !== "string" || !allowed.includes(actual)) {
|
|
582
|
+
return [
|
|
583
|
+
issue(
|
|
584
|
+
"error",
|
|
585
|
+
"invalid",
|
|
586
|
+
`${label} deve ser um de [${allowed.join(", ")}]; recebido "${String(actual)}"`,
|
|
587
|
+
path
|
|
588
|
+
)
|
|
589
|
+
];
|
|
590
|
+
}
|
|
591
|
+
return [];
|
|
592
|
+
}
|
|
593
|
+
function get(obj, key) {
|
|
594
|
+
if (obj && typeof obj === "object") {
|
|
595
|
+
return obj[key];
|
|
596
|
+
}
|
|
597
|
+
return void 0;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/validation/profiles.ts
|
|
601
|
+
var NS_CPF = "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cpf";
|
|
602
|
+
var NS_CNS = "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns";
|
|
603
|
+
var SYS_LOINC = "http://loinc.org";
|
|
604
|
+
var SYS_UCUM = "http://unitsofmeasure.org";
|
|
605
|
+
var SYS_OBS_CATEGORY = "http://terminology.hl7.org/CodeSystem/observation-category";
|
|
606
|
+
var SYS_V2_0074 = "http://terminology.hl7.org/CodeSystem/v2-0074";
|
|
607
|
+
var PATIENT_GENDERS = ["male", "female", "other", "unknown"];
|
|
608
|
+
var LAB_OBS_STATUS = ["final", "amended", "corrected"];
|
|
609
|
+
var DIAG_REPORT_STATUS = ["final", "amended", "corrected"];
|
|
610
|
+
function validateBRPatient(resource, path) {
|
|
611
|
+
const issues = [];
|
|
612
|
+
const identifiers = get(resource, "identifier");
|
|
613
|
+
issues.push(...requireMinCardinality(identifiers, 1, "Patient.identifier", `${path}.identifier`));
|
|
614
|
+
if (Array.isArray(identifiers)) {
|
|
615
|
+
const hasBrazilianId = identifiers.some((id) => {
|
|
616
|
+
const sys = get(id, "system");
|
|
617
|
+
return sys === NS_CPF || sys === NS_CNS;
|
|
618
|
+
});
|
|
619
|
+
if (!hasBrazilianId) {
|
|
620
|
+
issues.push(
|
|
621
|
+
issue(
|
|
622
|
+
"error",
|
|
623
|
+
"invariant",
|
|
624
|
+
"Patient deve ter pelo menos um identificador CPF ou CNS (br-patient-identifier)",
|
|
625
|
+
`${path}.identifier`
|
|
626
|
+
)
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const names = get(resource, "name");
|
|
631
|
+
issues.push(...requireMinCardinality(names, 1, "Patient.name", `${path}.name`));
|
|
632
|
+
issues.push(
|
|
633
|
+
...requireField(get(resource, "birthDate"), "Patient.birthDate", `${path}.birthDate`)
|
|
634
|
+
);
|
|
635
|
+
const gender = get(resource, "gender");
|
|
636
|
+
issues.push(...requireField(gender, "Patient.gender", `${path}.gender`));
|
|
637
|
+
if (gender !== void 0) {
|
|
638
|
+
issues.push(...requireOneOf(gender, PATIENT_GENDERS, "Patient.gender", `${path}.gender`));
|
|
639
|
+
}
|
|
640
|
+
return issues;
|
|
641
|
+
}
|
|
642
|
+
function validateBRLabObservation(resource, path) {
|
|
643
|
+
const issues = [];
|
|
644
|
+
const status = get(resource, "status");
|
|
645
|
+
issues.push(...requireField(status, "Observation.status", `${path}.status`));
|
|
646
|
+
if (status !== void 0) {
|
|
647
|
+
issues.push(...requireOneOf(status, LAB_OBS_STATUS, "Observation.status", `${path}.status`));
|
|
648
|
+
}
|
|
649
|
+
const categories = get(resource, "category");
|
|
650
|
+
issues.push(...requireMinCardinality(categories, 1, "Observation.category", `${path}.category`));
|
|
651
|
+
if (Array.isArray(categories)) {
|
|
652
|
+
const hasLab = categories.some((cat) => {
|
|
653
|
+
const codings = get(cat, "coding");
|
|
654
|
+
return Array.isArray(codings) && codings.some(
|
|
655
|
+
(c) => get(c, "system") === SYS_OBS_CATEGORY && get(c, "code") === "laboratory"
|
|
656
|
+
);
|
|
657
|
+
});
|
|
658
|
+
if (!hasLab) {
|
|
659
|
+
issues.push(
|
|
660
|
+
issue(
|
|
661
|
+
"error",
|
|
662
|
+
"invalid",
|
|
663
|
+
"Observation.category deve incluir { system: observation-category, code: laboratory }",
|
|
664
|
+
`${path}.category`
|
|
665
|
+
)
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const code = get(resource, "code");
|
|
670
|
+
issues.push(...requireField(code, "Observation.code", `${path}.code`));
|
|
671
|
+
if (code) {
|
|
672
|
+
const codings = get(code, "coding");
|
|
673
|
+
const hasLoinc = Array.isArray(codings) && codings.some((c) => get(c, "system") === SYS_LOINC);
|
|
674
|
+
if (!hasLoinc) {
|
|
675
|
+
issues.push(
|
|
676
|
+
issue(
|
|
677
|
+
"error",
|
|
678
|
+
"required",
|
|
679
|
+
"Observation.code.coding deve incluir um coding com system http://loinc.org",
|
|
680
|
+
`${path}.code.coding`
|
|
681
|
+
)
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const value = get(resource, "valueQuantity");
|
|
686
|
+
issues.push(...requireField(value, "Observation.valueQuantity", `${path}.valueQuantity`));
|
|
687
|
+
if (value) {
|
|
688
|
+
issues.push(
|
|
689
|
+
...requireField(get(value, "value"), "valueQuantity.value", `${path}.valueQuantity.value`)
|
|
690
|
+
);
|
|
691
|
+
issues.push(
|
|
692
|
+
...requireField(get(value, "unit"), "valueQuantity.unit", `${path}.valueQuantity.unit`)
|
|
693
|
+
);
|
|
694
|
+
issues.push(
|
|
695
|
+
...requireField(get(value, "code"), "valueQuantity.code", `${path}.valueQuantity.code`)
|
|
696
|
+
);
|
|
697
|
+
const sys = get(value, "system");
|
|
698
|
+
if (sys === void 0) {
|
|
699
|
+
issues.push(
|
|
700
|
+
issue(
|
|
701
|
+
"error",
|
|
702
|
+
"required",
|
|
703
|
+
"valueQuantity.system \xE9 obrigat\xF3rio",
|
|
704
|
+
`${path}.valueQuantity.system`
|
|
705
|
+
)
|
|
706
|
+
);
|
|
707
|
+
} else if (sys !== SYS_UCUM) {
|
|
708
|
+
issues.push(
|
|
709
|
+
issue(
|
|
710
|
+
"error",
|
|
711
|
+
"invalid",
|
|
712
|
+
`valueQuantity.system deve ser ${SYS_UCUM}; recebido "${String(sys)}"`,
|
|
713
|
+
`${path}.valueQuantity.system`
|
|
714
|
+
)
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
issues.push(...requireField(get(resource, "subject"), "Observation.subject", `${path}.subject`));
|
|
719
|
+
issues.push(
|
|
720
|
+
...requireField(
|
|
721
|
+
get(resource, "effectiveDateTime"),
|
|
722
|
+
"Observation.effectiveDateTime",
|
|
723
|
+
`${path}.effectiveDateTime`
|
|
724
|
+
)
|
|
725
|
+
);
|
|
726
|
+
return issues;
|
|
727
|
+
}
|
|
728
|
+
function validateBRDiagnosticReport(resource, path) {
|
|
729
|
+
const issues = [];
|
|
730
|
+
const status = get(resource, "status");
|
|
731
|
+
issues.push(...requireField(status, "DiagnosticReport.status", `${path}.status`));
|
|
732
|
+
if (status !== void 0) {
|
|
733
|
+
issues.push(
|
|
734
|
+
...requireOneOf(status, DIAG_REPORT_STATUS, "DiagnosticReport.status", `${path}.status`)
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
const categories = get(resource, "category");
|
|
738
|
+
issues.push(
|
|
739
|
+
...requireMinCardinality(categories, 1, "DiagnosticReport.category", `${path}.category`)
|
|
740
|
+
);
|
|
741
|
+
if (Array.isArray(categories)) {
|
|
742
|
+
const hasLab = categories.some((cat) => {
|
|
743
|
+
const codings = get(cat, "coding");
|
|
744
|
+
return Array.isArray(codings) && codings.some(
|
|
745
|
+
(c) => get(c, "system") === SYS_V2_0074 && get(c, "code") === "LAB"
|
|
746
|
+
);
|
|
747
|
+
});
|
|
748
|
+
if (!hasLab) {
|
|
749
|
+
issues.push(
|
|
750
|
+
issue(
|
|
751
|
+
"error",
|
|
752
|
+
"invalid",
|
|
753
|
+
"DiagnosticReport.category deve incluir { system: v2-0074, code: LAB }",
|
|
754
|
+
`${path}.category`
|
|
755
|
+
)
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
issues.push(...requireField(get(resource, "code"), "DiagnosticReport.code", `${path}.code`));
|
|
760
|
+
issues.push(
|
|
761
|
+
...requireField(get(resource, "subject"), "DiagnosticReport.subject", `${path}.subject`)
|
|
762
|
+
);
|
|
763
|
+
issues.push(
|
|
764
|
+
...requireField(
|
|
765
|
+
get(resource, "effectiveDateTime"),
|
|
766
|
+
"DiagnosticReport.effectiveDateTime",
|
|
767
|
+
`${path}.effectiveDateTime`
|
|
768
|
+
)
|
|
769
|
+
);
|
|
770
|
+
const results = get(resource, "result");
|
|
771
|
+
issues.push(...requireMinCardinality(results, 1, "DiagnosticReport.result", `${path}.result`));
|
|
772
|
+
const performers = get(resource, "performer");
|
|
773
|
+
issues.push(
|
|
774
|
+
...requireMinCardinality(performers, 1, "DiagnosticReport.performer", `${path}.performer`)
|
|
775
|
+
);
|
|
776
|
+
return issues;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/validation/index.ts
|
|
780
|
+
var PROFILE_URI = {
|
|
781
|
+
diagnosticReport: "https://fhir-brasil.dev.br/ig/StructureDefinition/br-diagnostic-report",
|
|
782
|
+
labObservation: "https://fhir-brasil.dev.br/ig/StructureDefinition/br-lab-observation",
|
|
783
|
+
patient: "https://fhir-brasil.dev.br/ig/StructureDefinition/br-patient"
|
|
784
|
+
};
|
|
785
|
+
function validateBundle(bundle, basePath = "Bundle") {
|
|
786
|
+
const issues = [];
|
|
787
|
+
const b = bundle;
|
|
788
|
+
const entries = b?.entry ?? [];
|
|
789
|
+
entries.forEach((entry, index) => {
|
|
790
|
+
const resource = entry?.resource;
|
|
791
|
+
if (!resource) return;
|
|
792
|
+
const entryPath = `${basePath}.entry[${index}].resource`;
|
|
793
|
+
issues.push(...validateResource(resource, entryPath));
|
|
794
|
+
});
|
|
795
|
+
return { issues, valid: !issues.some((i) => i.severity === "error" || i.severity === "fatal") };
|
|
796
|
+
}
|
|
797
|
+
function validateResource(resource, path) {
|
|
798
|
+
const declaredProfiles = resource?.meta?.profile ?? [];
|
|
799
|
+
if (declaredProfiles.includes(PROFILE_URI.patient)) {
|
|
800
|
+
return validateBRPatient(resource, path);
|
|
801
|
+
}
|
|
802
|
+
if (declaredProfiles.includes(PROFILE_URI.labObservation)) {
|
|
803
|
+
return validateBRLabObservation(resource, path);
|
|
804
|
+
}
|
|
805
|
+
if (declaredProfiles.includes(PROFILE_URI.diagnosticReport)) {
|
|
806
|
+
return validateBRDiagnosticReport(resource, path);
|
|
807
|
+
}
|
|
808
|
+
switch (resource?.resourceType) {
|
|
809
|
+
case "Patient":
|
|
810
|
+
return validateBRPatient(resource, path);
|
|
811
|
+
case "Observation":
|
|
812
|
+
return looksLikeLabObservation(resource) ? validateBRLabObservation(resource, path) : [];
|
|
813
|
+
case "DiagnosticReport":
|
|
814
|
+
return validateBRDiagnosticReport(resource, path);
|
|
815
|
+
default:
|
|
816
|
+
return [];
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
var SYS_OBS_CATEGORY2 = "http://terminology.hl7.org/CodeSystem/observation-category";
|
|
820
|
+
function looksLikeLabObservation(resource) {
|
|
821
|
+
const r = resource;
|
|
822
|
+
const cats = r?.category;
|
|
823
|
+
if (!Array.isArray(cats)) return false;
|
|
824
|
+
return cats.some(
|
|
825
|
+
(cat) => (cat?.coding ?? []).some((c) => c?.system === SYS_OBS_CATEGORY2 && c?.code === "laboratory")
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// src/router.ts
|
|
830
|
+
var FHIR_PREFIX = "/api/fhir/r4";
|
|
831
|
+
var TOKEN_PATH = "/api/token";
|
|
832
|
+
var JWKS_PATH = "/.well-known/jwks.json";
|
|
833
|
+
var NS = {
|
|
834
|
+
cns: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns",
|
|
835
|
+
cpf: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cpf"
|
|
836
|
+
};
|
|
837
|
+
function dispatch(ctx) {
|
|
838
|
+
if (ctx.method === "GET" && ctx.path === JWKS_PATH) {
|
|
839
|
+
return { body: buildJwks(ctx.keys), status: 200 };
|
|
840
|
+
}
|
|
841
|
+
if (ctx.method === "POST" && ctx.path === TOKEN_PATH) {
|
|
842
|
+
return handleToken(ctx);
|
|
843
|
+
}
|
|
844
|
+
if (!ctx.path.startsWith(FHIR_PREFIX)) {
|
|
845
|
+
return notFound(`Recurso n\xE3o encontrado: ${ctx.path}`);
|
|
846
|
+
}
|
|
847
|
+
if (ctx.strict) {
|
|
848
|
+
const authError = enforceFhirAuth(ctx);
|
|
849
|
+
if (authError) {
|
|
850
|
+
return authError;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const fhirPath = ctx.path.slice(FHIR_PREFIX.length);
|
|
854
|
+
if (ctx.method === "GET") {
|
|
855
|
+
return handleFhirGet(fhirPath, ctx);
|
|
856
|
+
}
|
|
857
|
+
if (ctx.method === "POST" && fhirPath === "/Bundle") {
|
|
858
|
+
return handleSubmitBundle(ctx);
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
body: operationOutcome("error", "not-supported", `${ctx.method} ${ctx.path} n\xE3o suportado`),
|
|
862
|
+
status: 405
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
function handleToken(ctx) {
|
|
866
|
+
if (ctx.strict && !ctx.peerCert?.presented) {
|
|
867
|
+
return {
|
|
868
|
+
body: operationOutcome(
|
|
869
|
+
"error",
|
|
870
|
+
"security",
|
|
871
|
+
"Certificado mTLS \xE9 obrigat\xF3rio em /api/token quando o sandbox est\xE1 em modo --mtls"
|
|
872
|
+
),
|
|
873
|
+
status: 401
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
const tokenPayload = issueToken(ctx.keys, {
|
|
877
|
+
identity: ctx.peerCert?.presented ? { cn: ctx.peerCert.cn, cnes: ctx.peerCert.cnes } : void 0
|
|
878
|
+
});
|
|
879
|
+
return { body: tokenPayload, status: 200 };
|
|
880
|
+
}
|
|
881
|
+
function enforceFhirAuth(ctx) {
|
|
882
|
+
const bearerHeader = ctx.headers["x-authorization-server"];
|
|
883
|
+
if (!bearerHeader) {
|
|
884
|
+
return {
|
|
885
|
+
body: operationOutcome(
|
|
886
|
+
"error",
|
|
887
|
+
"security",
|
|
888
|
+
"header X-Authorization-Server: Bearer <token> \xE9 obrigat\xF3rio"
|
|
889
|
+
),
|
|
890
|
+
status: 401
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
const match = /^Bearer\s+(.+)$/i.exec(bearerHeader);
|
|
894
|
+
if (!match) {
|
|
895
|
+
return {
|
|
896
|
+
body: operationOutcome(
|
|
897
|
+
"error",
|
|
898
|
+
"security",
|
|
899
|
+
'X-Authorization-Server deve usar o esquema "Bearer <token>"'
|
|
900
|
+
),
|
|
901
|
+
status: 401
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
const token = match[1].trim();
|
|
905
|
+
const verification = verifyToken(token, ctx.keys);
|
|
906
|
+
if (!verification.valid) {
|
|
907
|
+
return {
|
|
908
|
+
body: operationOutcome("error", "security", `Token inv\xE1lido (${verification.reason})`),
|
|
909
|
+
status: 401
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
const cnsHeader = ctx.headers["authorization"];
|
|
913
|
+
if (!cnsHeader) {
|
|
914
|
+
return {
|
|
915
|
+
body: operationOutcome(
|
|
916
|
+
"error",
|
|
917
|
+
"required",
|
|
918
|
+
"header Authorization: <CNS-do-profissional> \xE9 obrigat\xF3rio"
|
|
919
|
+
),
|
|
920
|
+
status: 400
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
if (!/^\d{15}$/.test(cnsHeader.trim())) {
|
|
924
|
+
return {
|
|
925
|
+
body: operationOutcome("error", "invalid", "Authorization deve conter um CNS de 15 d\xEDgitos"),
|
|
926
|
+
status: 400
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
var SEARCHABLE_BY_PATIENT = /* @__PURE__ */ new Set([
|
|
932
|
+
"Observation",
|
|
933
|
+
"DiagnosticReport",
|
|
934
|
+
"Immunization",
|
|
935
|
+
"Condition",
|
|
936
|
+
"Encounter"
|
|
937
|
+
]);
|
|
938
|
+
function handleFhirGet(fhirPath, ctx) {
|
|
939
|
+
if (fhirPath === "/Patient") {
|
|
940
|
+
return handlePatientSearch(ctx);
|
|
941
|
+
}
|
|
942
|
+
const patientMatch = /^\/Patient\/([^/]+)$/.exec(fhirPath);
|
|
943
|
+
if (patientMatch) {
|
|
944
|
+
const cns = decodeURIComponent(patientMatch[1]);
|
|
945
|
+
const patient = ctx.store.findPatientByCns(cns);
|
|
946
|
+
return patient ? { body: patient, status: 200 } : notFound(`Paciente CNS ${cns} n\xE3o encontrado`);
|
|
947
|
+
}
|
|
948
|
+
const orgMatch = /^\/Organization\/([^/]+)$/.exec(fhirPath);
|
|
949
|
+
if (orgMatch) {
|
|
950
|
+
const cnes = decodeURIComponent(orgMatch[1]);
|
|
951
|
+
const org = ctx.store.findOrganizationByCnes(cnes);
|
|
952
|
+
return org ? { body: org, status: 200 } : notFound(`Organiza\xE7\xE3o CNES ${cnes} n\xE3o encontrada`);
|
|
953
|
+
}
|
|
954
|
+
const practMatch = /^\/Practitioner\/([^/]+)$/.exec(fhirPath);
|
|
955
|
+
if (practMatch) {
|
|
956
|
+
const cns = decodeURIComponent(practMatch[1]);
|
|
957
|
+
const pract = ctx.store.findPractitionerByCns(cns);
|
|
958
|
+
return pract ? { body: pract, status: 200 } : notFound(`Profissional CNS ${cns} n\xE3o encontrado`);
|
|
959
|
+
}
|
|
960
|
+
const searchMatch = /^\/([A-Z][A-Za-z]+)$/.exec(fhirPath);
|
|
961
|
+
if (searchMatch) {
|
|
962
|
+
const resourceType = searchMatch[1];
|
|
963
|
+
if (SEARCHABLE_BY_PATIENT.has(resourceType)) {
|
|
964
|
+
return handleResourceSearch(resourceType, ctx);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return notFound(`Recurso FHIR n\xE3o suportado: ${fhirPath}`);
|
|
968
|
+
}
|
|
969
|
+
function handleResourceSearch(resourceType, ctx) {
|
|
970
|
+
const ref = ctx.query.get("subject") ?? ctx.query.get("patient");
|
|
971
|
+
if (!ref) {
|
|
972
|
+
return {
|
|
973
|
+
body: operationOutcome(
|
|
974
|
+
"error",
|
|
975
|
+
"required",
|
|
976
|
+
`${resourceType} search exige ?subject=Patient/{cns} ou ?patient=Patient/{cns}`
|
|
977
|
+
),
|
|
978
|
+
status: 400
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
const cns = ref.startsWith("Patient/") ? ref.slice("Patient/".length) : ref;
|
|
982
|
+
const matches = ctx.store.searchResourcesByPatient(resourceType, cns);
|
|
983
|
+
const entry = matches.map((resource) => ({ resource }));
|
|
984
|
+
return {
|
|
985
|
+
body: {
|
|
986
|
+
entry,
|
|
987
|
+
resourceType: "Bundle",
|
|
988
|
+
total: matches.length,
|
|
989
|
+
type: "searchset"
|
|
990
|
+
},
|
|
991
|
+
status: 200
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
function handlePatientSearch(ctx) {
|
|
995
|
+
const identifier = ctx.query.get("identifier");
|
|
996
|
+
if (!identifier) {
|
|
997
|
+
return {
|
|
998
|
+
body: operationOutcome("error", "required", "par\xE2metro identifier \xE9 obrigat\xF3rio em /Patient"),
|
|
999
|
+
status: 400
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
const [system, value] = identifier.split("|");
|
|
1003
|
+
if (!system || !value) {
|
|
1004
|
+
return {
|
|
1005
|
+
body: operationOutcome("error", "invalid", "identifier deve ser no formato system|value"),
|
|
1006
|
+
status: 400
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
let patient = void 0;
|
|
1010
|
+
if (system === NS.cpf) {
|
|
1011
|
+
patient = ctx.store.findPatientByCpf(value);
|
|
1012
|
+
} else if (system === NS.cns) {
|
|
1013
|
+
patient = ctx.store.findPatientByCns(value);
|
|
1014
|
+
} else {
|
|
1015
|
+
return notFound(`NamingSystem n\xE3o suportado: ${system}`);
|
|
1016
|
+
}
|
|
1017
|
+
return patient ? { body: patient, status: 200 } : notFound(`Paciente ${system}|${value} n\xE3o encontrado`);
|
|
1018
|
+
}
|
|
1019
|
+
function handleSubmitBundle(ctx) {
|
|
1020
|
+
if (typeof ctx.body === "symbol") {
|
|
1021
|
+
return {
|
|
1022
|
+
body: operationOutcome("error", "structure", "corpo da requisi\xE7\xE3o n\xE3o \xE9 JSON v\xE1lido"),
|
|
1023
|
+
status: 400
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
const bundle = ctx.body;
|
|
1027
|
+
if (!bundle || typeof bundle !== "object" || bundle.resourceType !== "Bundle") {
|
|
1028
|
+
return {
|
|
1029
|
+
body: operationOutcome("error", "structure", "corpo da requisi\xE7\xE3o n\xE3o \xE9 um Bundle FHIR"),
|
|
1030
|
+
status: 400
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
if (bundle.type !== "transaction" && bundle.type !== "batch") {
|
|
1034
|
+
return {
|
|
1035
|
+
body: operationOutcome(
|
|
1036
|
+
"error",
|
|
1037
|
+
"invalid",
|
|
1038
|
+
`Bundle.type "${String(bundle.type)}" n\xE3o suportado \u2014 use transaction ou batch`
|
|
1039
|
+
),
|
|
1040
|
+
status: 400
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
if (ctx.validateProfiles) {
|
|
1044
|
+
const result = validateBundle(bundle);
|
|
1045
|
+
if (!result.valid) {
|
|
1046
|
+
return {
|
|
1047
|
+
body: {
|
|
1048
|
+
issue: result.issues.map((i) => ({
|
|
1049
|
+
code: i.code,
|
|
1050
|
+
diagnostics: i.diagnostics,
|
|
1051
|
+
expression: i.expression,
|
|
1052
|
+
severity: i.severity
|
|
1053
|
+
})),
|
|
1054
|
+
resourceType: "OperationOutcome"
|
|
1055
|
+
},
|
|
1056
|
+
status: 422
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
ctx.store.recordBundle(bundle);
|
|
1061
|
+
const responseEntries = (bundle.entry ?? []).map(() => ({
|
|
1062
|
+
response: {
|
|
1063
|
+
location: `Resource/sandbox-${ctx.store.nextResourceId()}`,
|
|
1064
|
+
status: "201 Created"
|
|
1065
|
+
}
|
|
1066
|
+
}));
|
|
1067
|
+
const response = {
|
|
1068
|
+
entry: responseEntries,
|
|
1069
|
+
resourceType: "Bundle",
|
|
1070
|
+
type: "transaction-response"
|
|
1071
|
+
};
|
|
1072
|
+
return { body: response, status: 200 };
|
|
1073
|
+
}
|
|
1074
|
+
function notFound(diagnostics) {
|
|
1075
|
+
return {
|
|
1076
|
+
body: operationOutcome("error", "not-found", diagnostics),
|
|
1077
|
+
status: 404
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function operationOutcome(severity, code, diagnostics) {
|
|
1081
|
+
return {
|
|
1082
|
+
issue: [{ code, diagnostics, severity }],
|
|
1083
|
+
resourceType: "OperationOutcome"
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/store.ts
|
|
1088
|
+
var NS2 = {
|
|
1089
|
+
cnes: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cnes",
|
|
1090
|
+
cns: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns",
|
|
1091
|
+
cpf: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cpf"
|
|
1092
|
+
};
|
|
1093
|
+
var SandboxStore = class {
|
|
1094
|
+
patientsByCpf = /* @__PURE__ */ new Map();
|
|
1095
|
+
patientsByCns = /* @__PURE__ */ new Map();
|
|
1096
|
+
organizationsByCnes = /* @__PURE__ */ new Map();
|
|
1097
|
+
practitionersByCns = /* @__PURE__ */ new Map();
|
|
1098
|
+
submittedBundles = [];
|
|
1099
|
+
resourceSeq = 0;
|
|
1100
|
+
load(scenario) {
|
|
1101
|
+
this.reset();
|
|
1102
|
+
for (const patient of scenario.data.patients) {
|
|
1103
|
+
this.indexPatient(patient);
|
|
1104
|
+
}
|
|
1105
|
+
for (const org of scenario.data.organizations) {
|
|
1106
|
+
this.indexOrganization(org);
|
|
1107
|
+
}
|
|
1108
|
+
for (const pract of scenario.data.practitioners) {
|
|
1109
|
+
this.indexPractitioner(pract);
|
|
1110
|
+
}
|
|
1111
|
+
for (const bundle of scenario.data.submittedBundles ?? []) {
|
|
1112
|
+
this.submittedBundles.push(bundle);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
reset() {
|
|
1116
|
+
this.patientsByCpf.clear();
|
|
1117
|
+
this.patientsByCns.clear();
|
|
1118
|
+
this.organizationsByCnes.clear();
|
|
1119
|
+
this.practitionersByCns.clear();
|
|
1120
|
+
this.submittedBundles = [];
|
|
1121
|
+
this.resourceSeq = 0;
|
|
1122
|
+
}
|
|
1123
|
+
/** Próximo número de sequência global para gerar `Resource/sandbox-N`. */
|
|
1124
|
+
nextResourceId() {
|
|
1125
|
+
this.resourceSeq += 1;
|
|
1126
|
+
return this.resourceSeq;
|
|
1127
|
+
}
|
|
1128
|
+
findPatientByCpf(cpf) {
|
|
1129
|
+
return this.patientsByCpf.get(stripFormatting(cpf));
|
|
1130
|
+
}
|
|
1131
|
+
findPatientByCns(cns) {
|
|
1132
|
+
return this.patientsByCns.get(stripFormatting(cns));
|
|
1133
|
+
}
|
|
1134
|
+
findOrganizationByCnes(cnes) {
|
|
1135
|
+
return this.organizationsByCnes.get(stripFormatting(cnes));
|
|
1136
|
+
}
|
|
1137
|
+
findPractitionerByCns(cns) {
|
|
1138
|
+
return this.practitionersByCns.get(stripFormatting(cns));
|
|
1139
|
+
}
|
|
1140
|
+
recordBundle(bundle) {
|
|
1141
|
+
this.submittedBundles.push(bundle);
|
|
1142
|
+
}
|
|
1143
|
+
getSubmittedBundles() {
|
|
1144
|
+
return this.submittedBundles;
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Lista todos os recursos de um tipo presentes nos bundles submetidos
|
|
1148
|
+
* (incluindo os pré-carregados pelo cenário). Usado pelos endpoints
|
|
1149
|
+
* de busca FHIR (`GET /Observation?subject=...`, etc.).
|
|
1150
|
+
*/
|
|
1151
|
+
listResourcesByType(resourceType) {
|
|
1152
|
+
const out = [];
|
|
1153
|
+
for (const bundle of this.submittedBundles) {
|
|
1154
|
+
for (const entry of bundle.entry ?? []) {
|
|
1155
|
+
if (entry.resource && entry.resource.resourceType === resourceType) {
|
|
1156
|
+
out.push(entry.resource);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return out;
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Filtra recursos cujo `subject.reference` ou `patient.reference`
|
|
1164
|
+
* aponta para o paciente indicado (aceita tanto `Patient/{cns}` quanto
|
|
1165
|
+
* apenas o CNS sem prefixo).
|
|
1166
|
+
*/
|
|
1167
|
+
searchResourcesByPatient(resourceType, patientCns) {
|
|
1168
|
+
const cns = stripFormatting(patientCns);
|
|
1169
|
+
return this.listResourcesByType(resourceType).filter((resource) => {
|
|
1170
|
+
const subjectRef = resource.subject?.reference;
|
|
1171
|
+
const patientRef = resource.patient?.reference;
|
|
1172
|
+
return subjectRef && referenceMatchesCns(subjectRef, cns) || patientRef && referenceMatchesCns(patientRef, cns);
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
indexPatient(patient) {
|
|
1176
|
+
const cns = patient.id ?? findIdentifier(patient.identifier, NS2.cns);
|
|
1177
|
+
if (cns) {
|
|
1178
|
+
this.patientsByCns.set(stripFormatting(cns), patient);
|
|
1179
|
+
}
|
|
1180
|
+
const cpf = findIdentifier(patient.identifier, NS2.cpf);
|
|
1181
|
+
if (cpf) {
|
|
1182
|
+
this.patientsByCpf.set(stripFormatting(cpf), patient);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
indexOrganization(org) {
|
|
1186
|
+
const cnes = org.id ?? findIdentifier(org.identifier, NS2.cnes);
|
|
1187
|
+
if (cnes) {
|
|
1188
|
+
this.organizationsByCnes.set(stripFormatting(cnes), org);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
indexPractitioner(pract) {
|
|
1192
|
+
const cns = pract.id ?? findIdentifier(pract.identifier, NS2.cns);
|
|
1193
|
+
if (cns) {
|
|
1194
|
+
this.practitionersByCns.set(stripFormatting(cns), pract);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
function findIdentifier(ids, system) {
|
|
1199
|
+
return ids?.find((id) => id.system === system)?.value;
|
|
1200
|
+
}
|
|
1201
|
+
function stripFormatting(value) {
|
|
1202
|
+
return value.replace(/[^0-9A-Za-z]/g, "");
|
|
1203
|
+
}
|
|
1204
|
+
function referenceMatchesCns(ref, cns) {
|
|
1205
|
+
const lastSegment = ref.includes("/") ? ref.slice(ref.lastIndexOf("/") + 1) : ref;
|
|
1206
|
+
return lastSegment === cns;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// src/server.ts
|
|
1210
|
+
function createSandboxServer(options = {}) {
|
|
1211
|
+
const log = options.log ?? ((msg) => console.log(`[rnds-sandbox] ${msg}`));
|
|
1212
|
+
const useMtls = options.mtls === true;
|
|
1213
|
+
const strict = options.strict ?? useMtls;
|
|
1214
|
+
const validateProfiles = options.validateSubmissions ?? true;
|
|
1215
|
+
const port = options.port ?? (useMtls ? 8443 : 8080);
|
|
1216
|
+
const host = options.host ?? "127.0.0.1";
|
|
1217
|
+
const store = new SandboxStore();
|
|
1218
|
+
store.load(resolveScenario(options.scenario));
|
|
1219
|
+
const signingKeys = resolveSigningKeys({
|
|
1220
|
+
keyId: options.jwtKeyId,
|
|
1221
|
+
privateKey: options.jwtPrivateKey,
|
|
1222
|
+
publicKey: options.jwtPublicKey
|
|
1223
|
+
});
|
|
1224
|
+
const handler = (req, res) => {
|
|
1225
|
+
handleRequest(req, res, {
|
|
1226
|
+
keys: signingKeys,
|
|
1227
|
+
log,
|
|
1228
|
+
store,
|
|
1229
|
+
strict,
|
|
1230
|
+
useMtls,
|
|
1231
|
+
validateProfiles
|
|
1232
|
+
}).catch((err) => {
|
|
1233
|
+
log(`erro inesperado: ${err instanceof Error ? err.message : String(err)}`);
|
|
1234
|
+
if (res.headersSent) {
|
|
1235
|
+
if (!res.writableEnded) res.end();
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
res.writeHead(500, { "Content-Type": "application/fhir+json; charset=utf-8" });
|
|
1239
|
+
res.end(
|
|
1240
|
+
JSON.stringify({
|
|
1241
|
+
issue: [{ code: "exception", diagnostics: String(err), severity: "fatal" }],
|
|
1242
|
+
resourceType: "OperationOutcome"
|
|
1243
|
+
})
|
|
1244
|
+
);
|
|
1245
|
+
});
|
|
1246
|
+
};
|
|
1247
|
+
const server = useMtls ? import_node_https.default.createServer(buildTlsOptions(options), handler) : import_node_http.default.createServer(handler);
|
|
1248
|
+
return {
|
|
1249
|
+
server,
|
|
1250
|
+
signingKeys,
|
|
1251
|
+
start: () => new Promise((resolve, reject) => {
|
|
1252
|
+
const onError = (err) => {
|
|
1253
|
+
server.off("error", onError);
|
|
1254
|
+
reject(err);
|
|
1255
|
+
};
|
|
1256
|
+
server.once("error", onError);
|
|
1257
|
+
server.listen(port, host, () => {
|
|
1258
|
+
server.off("error", onError);
|
|
1259
|
+
const address = server.address();
|
|
1260
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
1261
|
+
log(
|
|
1262
|
+
`${useMtls ? "HTTPS+mTLS" : "HTTP"} ouvindo em ${host}:${actualPort} (cen\xE1rio: ${options.scenario ?? "paciente-com-exames"}, strict=${strict}, validate=${validateProfiles}, kid=${signingKeys.keyId})`
|
|
1263
|
+
);
|
|
1264
|
+
resolve({ host, port: actualPort });
|
|
1265
|
+
});
|
|
1266
|
+
}),
|
|
1267
|
+
stop: () => new Promise((resolve, reject) => {
|
|
1268
|
+
server.closeAllConnections?.();
|
|
1269
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
1270
|
+
}),
|
|
1271
|
+
store
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
async function handleRequest(req, res, ctx) {
|
|
1275
|
+
const url = new URL(req.url ?? "/", "http://sandbox.local");
|
|
1276
|
+
const body = await readBody(req);
|
|
1277
|
+
const peerCert = ctx.useMtls ? extractPeerCert(req) : void 0;
|
|
1278
|
+
const headers = lowercaseHeaders(req.headers);
|
|
1279
|
+
const routeCtx = {
|
|
1280
|
+
body,
|
|
1281
|
+
headers,
|
|
1282
|
+
keys: ctx.keys,
|
|
1283
|
+
method: req.method ?? "GET",
|
|
1284
|
+
path: url.pathname,
|
|
1285
|
+
peerCert,
|
|
1286
|
+
query: url.searchParams,
|
|
1287
|
+
store: ctx.store,
|
|
1288
|
+
strict: ctx.strict,
|
|
1289
|
+
validateProfiles: ctx.validateProfiles
|
|
1290
|
+
};
|
|
1291
|
+
const response = dispatch(routeCtx);
|
|
1292
|
+
ctx.log(`${routeCtx.method} ${routeCtx.path} \u2192 ${response.status}`);
|
|
1293
|
+
res.writeHead(response.status, {
|
|
1294
|
+
"Content-Type": "application/fhir+json; charset=utf-8"
|
|
1295
|
+
});
|
|
1296
|
+
res.end(JSON.stringify(response.body));
|
|
1297
|
+
}
|
|
1298
|
+
var INVALID_BODY = /* @__PURE__ */ Symbol.for("rnds-sandbox.invalidBody");
|
|
1299
|
+
async function readBody(req) {
|
|
1300
|
+
if (req.method !== "POST" && req.method !== "PUT") {
|
|
1301
|
+
return void 0;
|
|
1302
|
+
}
|
|
1303
|
+
const chunks = [];
|
|
1304
|
+
for await (const chunk of req) {
|
|
1305
|
+
chunks.push(chunk);
|
|
1306
|
+
}
|
|
1307
|
+
if (chunks.length === 0) {
|
|
1308
|
+
return void 0;
|
|
1309
|
+
}
|
|
1310
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
1311
|
+
if (!text) {
|
|
1312
|
+
return void 0;
|
|
1313
|
+
}
|
|
1314
|
+
try {
|
|
1315
|
+
return JSON.parse(text);
|
|
1316
|
+
} catch {
|
|
1317
|
+
return INVALID_BODY;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
function extractPeerCert(req) {
|
|
1321
|
+
const socket = req.socket;
|
|
1322
|
+
if (typeof socket.getPeerCertificate !== "function") {
|
|
1323
|
+
return { presented: false };
|
|
1324
|
+
}
|
|
1325
|
+
const cert = socket.getPeerCertificate();
|
|
1326
|
+
if (!cert || Object.keys(cert).length === 0) {
|
|
1327
|
+
return { presented: false };
|
|
1328
|
+
}
|
|
1329
|
+
const cn = readCommonName(cert.subject);
|
|
1330
|
+
return {
|
|
1331
|
+
cn,
|
|
1332
|
+
cnes: cn ? extractCnesFromCn(cn) : void 0,
|
|
1333
|
+
presented: true
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
function readCommonName(subject) {
|
|
1337
|
+
if (!subject || typeof subject !== "object") {
|
|
1338
|
+
return void 0;
|
|
1339
|
+
}
|
|
1340
|
+
const cnValue = subject.CN;
|
|
1341
|
+
if (typeof cnValue === "string") {
|
|
1342
|
+
return cnValue;
|
|
1343
|
+
}
|
|
1344
|
+
return void 0;
|
|
1345
|
+
}
|
|
1346
|
+
function extractCnesFromCn(cn) {
|
|
1347
|
+
const match = /\b(\d{7})\b/.exec(cn);
|
|
1348
|
+
return match ? match[1] : void 0;
|
|
1349
|
+
}
|
|
1350
|
+
function lowercaseHeaders(headers) {
|
|
1351
|
+
const out = {};
|
|
1352
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1353
|
+
if (Array.isArray(value)) {
|
|
1354
|
+
out[key.toLowerCase()] = value.join(", ");
|
|
1355
|
+
} else {
|
|
1356
|
+
out[key.toLowerCase()] = value;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return out;
|
|
1360
|
+
}
|
|
1361
|
+
function buildTlsOptions(options) {
|
|
1362
|
+
const tlsOptions = {
|
|
1363
|
+
rejectUnauthorized: false,
|
|
1364
|
+
requestCert: true
|
|
1365
|
+
};
|
|
1366
|
+
if (options.serverPfx) {
|
|
1367
|
+
tlsOptions.pfx = resolveBuffer(options.serverPfx);
|
|
1368
|
+
if (options.serverPfxPassword) {
|
|
1369
|
+
tlsOptions.passphrase = options.serverPfxPassword;
|
|
1370
|
+
}
|
|
1371
|
+
} else if (options.serverKey && options.serverCert) {
|
|
1372
|
+
tlsOptions.key = resolveBuffer(options.serverKey);
|
|
1373
|
+
tlsOptions.cert = resolveBuffer(options.serverCert);
|
|
1374
|
+
} else {
|
|
1375
|
+
throw new Error("mTLS requer --pfx + --pfx-password OU --server-key + --server-cert (PEM)");
|
|
1376
|
+
}
|
|
1377
|
+
return tlsOptions;
|
|
1378
|
+
}
|
|
1379
|
+
function resolveBuffer(value) {
|
|
1380
|
+
if (typeof value !== "string") {
|
|
1381
|
+
return value;
|
|
1382
|
+
}
|
|
1383
|
+
if (value.includes("-----BEGIN")) {
|
|
1384
|
+
return Buffer.from(value);
|
|
1385
|
+
}
|
|
1386
|
+
return import_node_fs2.default.readFileSync(value);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/cli.ts
|
|
1390
|
+
var import_meta = {};
|
|
1391
|
+
var HELP = `rnds-sandbox \u2014 mock local da RNDS
|
|
1392
|
+
|
|
1393
|
+
Uso:
|
|
1394
|
+
rnds-sandbox start [op\xE7\xF5es]
|
|
1395
|
+
rnds-sandbox scenarios
|
|
1396
|
+
rnds-sandbox --help
|
|
1397
|
+
|
|
1398
|
+
Op\xE7\xF5es de start:
|
|
1399
|
+
--port <n> Porta (padr\xE3o: 8080, ou 8443 se --mtls)
|
|
1400
|
+
--host <h> Host (padr\xE3o: 127.0.0.1)
|
|
1401
|
+
--scenario <nome> paciente-com-exames | internacao | vacina | vazio
|
|
1402
|
+
|
|
1403
|
+
Modos de fidelidade RNDS:
|
|
1404
|
+
--strict Exige bearer token + header Authorization: <CNS> em /api/fhir/r4/*
|
|
1405
|
+
(impl\xEDcito quando --mtls est\xE1 ativo)
|
|
1406
|
+
--mtls HTTPS + handshake mTLS (cliente apresenta cert).
|
|
1407
|
+
/api/token exige cert; FHIR API exige bearer.
|
|
1408
|
+
--no-validate Desabilita valida\xE7\xE3o contra perfis BR* do IG em POST /Bundle
|
|
1409
|
+
(default: valida\xE7\xE3o ON, retorna 422 OperationOutcome em falha)
|
|
1410
|
+
|
|
1411
|
+
Cert do servidor (use uma forma OU outra):
|
|
1412
|
+
--pfx <caminho> PFX do servidor
|
|
1413
|
+
--pfx-password <s> Senha do PFX
|
|
1414
|
+
--server-key <caminho> PEM da chave privada do servidor
|
|
1415
|
+
--server-cert <caminho> PEM do certificado do servidor
|
|
1416
|
+
|
|
1417
|
+
Chaves de assinatura JWT (RS256):
|
|
1418
|
+
--jwt-private-key <p> PEM da chave privada (padr\xE3o: gera uma RSA-2048 ef\xEAmera)
|
|
1419
|
+
--jwt-public-key <p> PEM da chave p\xFAblica (opcional \u2014 derivada da privada)
|
|
1420
|
+
--jwt-key-id <s> kid no JWT/JWKS (padr\xE3o: rnds-sandbox-1)
|
|
1421
|
+
|
|
1422
|
+
Exemplos:
|
|
1423
|
+
rnds-sandbox start
|
|
1424
|
+
rnds-sandbox start --strict --jwt-private-key ./jwt.pem
|
|
1425
|
+
rnds-sandbox start --port 9000 --scenario internacao
|
|
1426
|
+
rnds-sandbox start --mtls --pfx ./cert.pfx --pfx-password senha
|
|
1427
|
+
rnds-sandbox start --mtls --server-key ./srv.key --server-cert ./srv.crt
|
|
1428
|
+
`;
|
|
1429
|
+
var STRING_ARGS = {
|
|
1430
|
+
"--host": (o, v) => {
|
|
1431
|
+
o.host = v;
|
|
1432
|
+
},
|
|
1433
|
+
"--jwt-key-id": (o, v) => {
|
|
1434
|
+
o.jwtKeyId = v;
|
|
1435
|
+
},
|
|
1436
|
+
"--jwt-private-key": (o, v) => {
|
|
1437
|
+
o.jwtPrivateKey = v;
|
|
1438
|
+
},
|
|
1439
|
+
"--jwt-public-key": (o, v) => {
|
|
1440
|
+
o.jwtPublicKey = v;
|
|
1441
|
+
},
|
|
1442
|
+
"--pfx": (o, v) => {
|
|
1443
|
+
o.serverPfx = v;
|
|
1444
|
+
},
|
|
1445
|
+
"--pfx-password": (o, v) => {
|
|
1446
|
+
o.serverPfxPassword = v;
|
|
1447
|
+
},
|
|
1448
|
+
"--server-cert": (o, v) => {
|
|
1449
|
+
o.serverCert = v;
|
|
1450
|
+
},
|
|
1451
|
+
"--server-key": (o, v) => {
|
|
1452
|
+
o.serverKey = v;
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
function parseArgs(argv) {
|
|
1456
|
+
const errors = [];
|
|
1457
|
+
const options = {};
|
|
1458
|
+
let command;
|
|
1459
|
+
const args = [...argv];
|
|
1460
|
+
while (args.length > 0) {
|
|
1461
|
+
const token = args.shift();
|
|
1462
|
+
if (token === "--help" || token === "-h") {
|
|
1463
|
+
command = "help";
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
if (!command && (token === "start" || token === "scenarios" || token === "help")) {
|
|
1467
|
+
command = token;
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
if (token === "--port") {
|
|
1471
|
+
const value = args.shift();
|
|
1472
|
+
const port = Number(value);
|
|
1473
|
+
if (!value || Number.isNaN(port)) {
|
|
1474
|
+
errors.push(`--port requer valor num\xE9rico, recebido: ${String(value)}`);
|
|
1475
|
+
} else {
|
|
1476
|
+
options.port = port;
|
|
1477
|
+
}
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
if (token === "--scenario") {
|
|
1481
|
+
const value = args.shift();
|
|
1482
|
+
if (!value || !(value in SCENARIOS)) {
|
|
1483
|
+
errors.push(
|
|
1484
|
+
`--scenario inv\xE1lido. Dispon\xEDveis: ${Object.keys(SCENARIOS).join(", ")}. Recebido: ${String(value)}`
|
|
1485
|
+
);
|
|
1486
|
+
} else {
|
|
1487
|
+
options.scenario = value;
|
|
1488
|
+
}
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
if (token === "--mtls") {
|
|
1492
|
+
options.mtls = true;
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
if (token === "--strict") {
|
|
1496
|
+
options.strict = true;
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
if (token === "--no-validate") {
|
|
1500
|
+
options.validateSubmissions = false;
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
const stringSetter = STRING_ARGS[token];
|
|
1504
|
+
if (stringSetter) {
|
|
1505
|
+
const value = args.shift();
|
|
1506
|
+
if (!value) {
|
|
1507
|
+
errors.push(`${token} requer valor`);
|
|
1508
|
+
} else {
|
|
1509
|
+
stringSetter(options, value);
|
|
1510
|
+
}
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
errors.push(`argumento desconhecido: ${token}`);
|
|
1514
|
+
}
|
|
1515
|
+
return { command, errors, options };
|
|
1516
|
+
}
|
|
1517
|
+
async function main(argv = process.argv.slice(2)) {
|
|
1518
|
+
const parsed = parseArgs(argv);
|
|
1519
|
+
if (parsed.errors.length > 0) {
|
|
1520
|
+
for (const err of parsed.errors) {
|
|
1521
|
+
process.stderr.write(`erro: ${err}
|
|
1522
|
+
`);
|
|
1523
|
+
}
|
|
1524
|
+
process.stderr.write(`
|
|
1525
|
+
${HELP}`);
|
|
1526
|
+
return 1;
|
|
1527
|
+
}
|
|
1528
|
+
if (!parsed.command || parsed.command === "help") {
|
|
1529
|
+
process.stdout.write(HELP);
|
|
1530
|
+
return 0;
|
|
1531
|
+
}
|
|
1532
|
+
if (parsed.command === "scenarios") {
|
|
1533
|
+
process.stdout.write("Cen\xE1rios dispon\xEDveis:\n\n");
|
|
1534
|
+
for (const scenario of Object.values(SCENARIOS)) {
|
|
1535
|
+
process.stdout.write(` ${scenario.name}
|
|
1536
|
+
${scenario.description}
|
|
1537
|
+
|
|
1538
|
+
`);
|
|
1539
|
+
}
|
|
1540
|
+
return 0;
|
|
1541
|
+
}
|
|
1542
|
+
if (parsed.options.mtls) {
|
|
1543
|
+
const hasPfx = parsed.options.serverPfx;
|
|
1544
|
+
const hasKeyAndCert = parsed.options.serverKey && parsed.options.serverCert;
|
|
1545
|
+
if (!hasPfx && !hasKeyAndCert) {
|
|
1546
|
+
process.stderr.write(
|
|
1547
|
+
"erro: --mtls requer --pfx + --pfx-password OU --server-key + --server-cert\n"
|
|
1548
|
+
);
|
|
1549
|
+
return 1;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
const sandbox = createSandboxServer(parsed.options);
|
|
1553
|
+
await sandbox.start();
|
|
1554
|
+
const shutdown = async (signal) => {
|
|
1555
|
+
process.stdout.write(`
|
|
1556
|
+
[rnds-sandbox] recebido ${signal}, encerrando...
|
|
1557
|
+
`);
|
|
1558
|
+
await sandbox.stop();
|
|
1559
|
+
process.exit(0);
|
|
1560
|
+
};
|
|
1561
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
1562
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
1563
|
+
return 0;
|
|
1564
|
+
}
|
|
1565
|
+
function isCliEntryPoint() {
|
|
1566
|
+
const argv1 = process.argv[1];
|
|
1567
|
+
if (typeof argv1 !== "string" || argv1.length === 0) return false;
|
|
1568
|
+
try {
|
|
1569
|
+
const here = import_node_fs3.default.realpathSync((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
1570
|
+
const invoked = import_node_fs3.default.realpathSync(argv1);
|
|
1571
|
+
return here === invoked;
|
|
1572
|
+
} catch {
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
if (isCliEntryPoint()) {
|
|
1577
|
+
main().catch((err) => {
|
|
1578
|
+
process.stderr.write(`erro fatal: ${err instanceof Error ? err.message : String(err)}
|
|
1579
|
+
`);
|
|
1580
|
+
process.exit(1);
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1584
|
+
0 && (module.exports = {
|
|
1585
|
+
main,
|
|
1586
|
+
parseArgs
|
|
1587
|
+
});
|
|
1588
|
+
//# sourceMappingURL=cli.cjs.map
|