@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/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