@precisa-saude/fhir-rnds 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/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @precisa-saude/fhir-rnds
2
+
3
+ Cliente HTTP para a RNDS (Rede Nacional de Dados em Saúde) — API FHIR R4 do DATASUS com autenticação mTLS, cache de token JWT e zero dependências runtime externas.
4
+
5
+ ## Instalação
6
+
7
+ ```bash
8
+ npm install @precisa-saude/fhir-rnds
9
+ ```
10
+
11
+ > **Nota:** Requer Node.js >= 20 (fetch nativo) e `@precisa-saude/fhir` como peer dependency.
12
+
13
+ ## Pré-requisitos
14
+
15
+ Para utilizar a RNDS, é necessário:
16
+
17
+ 1. **Certificado digital ICP-Brasil** — arquivo `.pfx` emitido para o estabelecimento de saúde
18
+ 2. **CNES** — código do Cadastro Nacional de Estabelecimentos de Saúde (7 dígitos)
19
+ 3. **CNS** — Cartão Nacional de Saúde do profissional responsável (15 dígitos)
20
+ 4. **Credenciamento no Portal de Serviços DATASUS** — via conta gov.br
21
+
22
+ Consulte o [guia oficial da RNDS](https://rnds-guia.saude.gov.br/docs/publico-alvo/ti/conhecer) para detalhes sobre o processo de credenciamento.
23
+
24
+ ## Uso rápido
25
+
26
+ ### Configurar o cliente
27
+
28
+ ```ts
29
+ import { RNDSClient } from '@precisa-saude/fhir-rnds';
30
+
31
+ const client = new RNDSClient({
32
+ certificate: './certificado.pfx', // caminho ou Buffer
33
+ certificatePassword: process.env.RNDS_CERT_PASSWORD!,
34
+ cnes: '1234567',
35
+ cns: '123456789012345',
36
+ environment: 'homologation', // ou 'production'
37
+ });
38
+ ```
39
+
40
+ ### Consultar paciente
41
+
42
+ ```ts
43
+ // Por CPF
44
+ const patient = await client.getPatientByCpf('12345678900');
45
+ console.log(patient?.name);
46
+ // [{ family: 'Silva', given: ['João'] }]
47
+
48
+ // Por CNS
49
+ const patient2 = await client.getPatientByCns('123456789012345');
50
+ ```
51
+
52
+ ### Consultar estabelecimento e profissional
53
+
54
+ ```ts
55
+ const org = await client.getOrganizationByCnes('1234567');
56
+ console.log(org?.name);
57
+ // 'Hospital São Paulo'
58
+
59
+ const practitioner = await client.getPractitionerByCns('123456789012345');
60
+ console.log(practitioner?.name);
61
+ // [{ family: 'Santos', given: ['Maria'] }]
62
+ ```
63
+
64
+ ### Enviar resultados laboratoriais
65
+
66
+ ```ts
67
+ import { labResultToFHIRBundle } from '@precisa-saude/fhir';
68
+
69
+ const bundle = labResultToFHIRBundle(report, observations, profile);
70
+ const result = await client.submitBundle(bundle);
71
+ ```
72
+
73
+ ## Autenticação
74
+
75
+ O cliente gerencia a autenticação automaticamente:
76
+
77
+ 1. Apresenta o certificado `.pfx` via mutual TLS ao endpoint de autenticação
78
+ 2. Recebe um token JWT (válido por 30 minutos)
79
+ 3. Cacheia o token e renova automaticamente antes de expirar
80
+ 4. Deduplica requisições de renovação concorrentes
81
+
82
+ ## Ambientes
83
+
84
+ | Ambiente | Autenticação | API |
85
+ | -------------- | --------------------------- | ------------------------------- |
86
+ | `homologation` | `ehr-auth-hmg.saude.gov.br` | `ehr-services.hmg.saude.gov.br` |
87
+ | `production` | `ehr-auth.saude.gov.br` | `ehr-services.saude.gov.br` |
88
+
89
+ ## API
90
+
91
+ ### `RNDSClient`
92
+
93
+ | Método | Retorno | Descrição |
94
+ | ----------------------------- | -------------------------- | -------------------------------------------------- |
95
+ | `getPatientByCpf(cpf)` | `FHIRPatient \| null` | Busca paciente por CPF |
96
+ | `getPatientByCns(cns)` | `FHIRPatient \| null` | Busca paciente por CNS |
97
+ | `getOrganizationByCnes(cnes)` | `FHIROrganization \| null` | Busca estabelecimento por CNES |
98
+ | `getPractitionerByCns(cns)` | `FHIRPractitioner \| null` | Busca profissional por CNS |
99
+ | `submitBundle(bundle)` | `FHIRBundle` | Envia bundle FHIR (resultados laboratoriais, etc.) |
100
+
101
+ ### Erros
102
+
103
+ | Classe | Quando |
104
+ | --------------------- | ------------------------------------------------------- |
105
+ | `RNDSAuthError` | Falha de autenticação (401, 403, certificado inválido) |
106
+ | `RNDSValidationError` | Erro de validação FHIR (422), inclui `OperationOutcome` |
107
+ | `RNDSNotFoundError` | Recurso não encontrado (404) |
108
+ | `RNDSError` | Outros erros HTTP |
109
+
110
+ ### Tipos FHIR adicionais
111
+
112
+ O pacote exporta tipos FHIR R4 que ainda não existem no core:
113
+
114
+ - `FHIROrganization`, `FHIRPractitioner`, `FHIRIdentifier`
115
+ - `FHIROperationOutcome`, `FHIROperationOutcomeIssue`
116
+ - `RNDSSearchBundle<T>`, `RNDSTokenResponse`
117
+
118
+ ## Referências
119
+
120
+ - [Guia oficial RNDS — DATASUS](https://rnds-guia.saude.gov.br/docs/conector)
121
+ - [kyriosdata/rnds](https://github.com/kyriosdata/rnds) — implementação de referência
122
+
123
+ ## Aviso médico
124
+
125
+ Este pacote é uma ferramenta de integração técnica com a RNDS. Os dados acessados ou enviados **não constituem diagnóstico médico** e não substituem avaliação profissional. Consulte o [DISCLAIMER.md](../../DISCLAIMER.md) na raiz do repositório para detalhes completos.
126
+
127
+ ## Licença
128
+
129
+ [Apache-2.0](../../LICENSE)
package/dist/index.cjs ADDED
@@ -0,0 +1,311 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class;// src/client.ts
2
+ var _fs = require('fs'); var _fs2 = _interopRequireDefault(_fs);
3
+
4
+ // src/errors.ts
5
+ var RNDSError = class extends Error {
6
+ constructor(message, statusCode, operationOutcome) {
7
+ super(message);
8
+ this.statusCode = statusCode;
9
+ this.operationOutcome = operationOutcome;
10
+ this.name = "RNDSError";
11
+ }
12
+ };
13
+ var RNDSAuthError = class extends RNDSError {
14
+ constructor(message, statusCode) {
15
+ super(message, statusCode);
16
+ this.name = "RNDSAuthError";
17
+ }
18
+ };
19
+ var RNDSNotFoundError = class extends RNDSError {
20
+ constructor(message) {
21
+ super(message, 404);
22
+ this.name = "RNDSNotFoundError";
23
+ }
24
+ };
25
+ var RNDSValidationError = class extends RNDSError {
26
+ constructor(message, operationOutcome) {
27
+ super(message, 422, operationOutcome);
28
+ this.name = "RNDSValidationError";
29
+ }
30
+ };
31
+
32
+ // src/http.ts
33
+ var _https = require('https'); var _https2 = _interopRequireDefault(_https);
34
+ function httpsRequestWithCert(url, certOptions, options) {
35
+ return new Promise((resolve, reject) => {
36
+ const parsedUrl = new URL(url);
37
+ const req = _https2.default.request(
38
+ {
39
+ headers: _optionalChain([options, 'optionalAccess', _ => _.headers]),
40
+ hostname: parsedUrl.hostname,
41
+ method: _nullishCoalesce(_optionalChain([options, 'optionalAccess', _2 => _2.method]), () => ( "GET")),
42
+ passphrase: certOptions.passphrase,
43
+ path: parsedUrl.pathname + parsedUrl.search,
44
+ pfx: certOptions.pfx,
45
+ port: Number(parsedUrl.port) || 443,
46
+ timeout: _nullishCoalesce(_optionalChain([options, 'optionalAccess', _3 => _3.timeout]), () => ( 3e4))
47
+ },
48
+ (res) => {
49
+ const chunks = [];
50
+ res.on("data", (chunk) => {
51
+ chunks.push(chunk);
52
+ });
53
+ res.on("error", (err) => {
54
+ reject(new Error(`Erro na resposta HTTPS: ${err.message}`));
55
+ });
56
+ res.on("end", () => {
57
+ resolve({
58
+ body: Buffer.concat(chunks).toString("utf-8"),
59
+ headers: res.headers,
60
+ statusCode: _nullishCoalesce(res.statusCode, () => ( 0))
61
+ });
62
+ });
63
+ }
64
+ );
65
+ req.on("error", (err) => {
66
+ reject(new Error(`Erro na requisi\xE7\xE3o HTTPS: ${err.message}`));
67
+ });
68
+ req.on("timeout", () => {
69
+ req.destroy();
70
+ reject(new Error("Timeout na requisi\xE7\xE3o HTTPS"));
71
+ });
72
+ if (_optionalChain([options, 'optionalAccess', _4 => _4.body])) {
73
+ req.write(options.body);
74
+ }
75
+ req.end();
76
+ });
77
+ }
78
+
79
+ // src/auth.ts
80
+ var TOKEN_REFRESH_MARGIN_MS = 6e4;
81
+ var RNDSAuth = (_class = class {
82
+ constructor(pfx, passphrase, authBaseUrl) {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this);_class.prototype.__init3.call(this);
83
+ this.pfx = pfx;
84
+ this.passphrase = passphrase;
85
+ this.authBaseUrl = authBaseUrl;
86
+ }
87
+ __init() {this.token = null}
88
+ __init2() {this.expiresAt = 0}
89
+ __init3() {this.pendingRefresh = null}
90
+ async getToken() {
91
+ if (this.isTokenValid()) {
92
+ return this.token;
93
+ }
94
+ return this.refreshToken();
95
+ }
96
+ async refreshToken() {
97
+ if (this.pendingRefresh) {
98
+ return this.pendingRefresh;
99
+ }
100
+ this.pendingRefresh = this.fetchToken();
101
+ try {
102
+ const token = await this.pendingRefresh;
103
+ return token;
104
+ } finally {
105
+ this.pendingRefresh = null;
106
+ }
107
+ }
108
+ isTokenValid() {
109
+ return this.token !== null && Date.now() < this.expiresAt - TOKEN_REFRESH_MARGIN_MS;
110
+ }
111
+ async fetchToken() {
112
+ const url = `${this.authBaseUrl}/api/token`;
113
+ let response;
114
+ try {
115
+ response = await httpsRequestWithCert(url, {
116
+ passphrase: this.passphrase,
117
+ pfx: this.pfx
118
+ });
119
+ } catch (err) {
120
+ throw new RNDSAuthError(
121
+ `Falha na autentica\xE7\xE3o com a RNDS: ${err instanceof Error ? err.message : String(err)}`
122
+ );
123
+ }
124
+ if (response.statusCode !== 200) {
125
+ throw new RNDSAuthError(
126
+ `Autentica\xE7\xE3o RNDS falhou com status ${response.statusCode}: ${response.body}`,
127
+ response.statusCode
128
+ );
129
+ }
130
+ let tokenData;
131
+ try {
132
+ tokenData = JSON.parse(response.body);
133
+ } catch (e) {
134
+ throw new RNDSAuthError("Resposta de autentica\xE7\xE3o RNDS inv\xE1lida: JSON malformado");
135
+ }
136
+ if (!tokenData.access_token) {
137
+ throw new RNDSAuthError("Resposta de autentica\xE7\xE3o RNDS n\xE3o cont\xE9m access_token");
138
+ }
139
+ this.token = tokenData.access_token;
140
+ this.expiresAt = this.extractExpiry(tokenData);
141
+ return this.token;
142
+ }
143
+ extractExpiry(tokenData) {
144
+ if (tokenData.expires_in) {
145
+ return Date.now() + tokenData.expires_in;
146
+ }
147
+ try {
148
+ const parts = tokenData.access_token.split(".");
149
+ if (parts.length === 3) {
150
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
151
+ if (payload.exp) {
152
+ return payload.exp * 1e3;
153
+ }
154
+ }
155
+ } catch (e2) {
156
+ }
157
+ return Date.now() + 30 * 60 * 1e3;
158
+ }
159
+ }, _class);
160
+
161
+ // src/config.ts
162
+ var RNDS_ENDPOINTS = {
163
+ homologation: {
164
+ api: "https://ehr-services.hmg.saude.gov.br/api/fhir/r4",
165
+ auth: "https://ehr-auth-hmg.saude.gov.br"
166
+ },
167
+ production: {
168
+ api: "https://ehr-services.saude.gov.br/api/fhir/r4",
169
+ auth: "https://ehr-auth.saude.gov.br"
170
+ }
171
+ };
172
+ function resolveEndpoints(environment) {
173
+ return RNDS_ENDPOINTS[environment];
174
+ }
175
+ function validateConfig(config) {
176
+ const errors = [];
177
+ if (!config.certificate) {
178
+ errors.push("certificate \xE9 obrigat\xF3rio");
179
+ }
180
+ if (!config.certificatePassword) {
181
+ errors.push("certificatePassword \xE9 obrigat\xF3rio");
182
+ }
183
+ if (!config.cnes) {
184
+ errors.push("cnes \xE9 obrigat\xF3rio");
185
+ } else if (!/^\d{7}$/.test(config.cnes)) {
186
+ errors.push("cnes deve ter exatamente 7 d\xEDgitos");
187
+ }
188
+ if (!config.cns) {
189
+ errors.push("cns \xE9 obrigat\xF3rio");
190
+ } else if (!/^\d{15}$/.test(config.cns)) {
191
+ errors.push("cns deve ter exatamente 15 d\xEDgitos");
192
+ }
193
+ if (!config.environment) {
194
+ errors.push("environment \xE9 obrigat\xF3rio");
195
+ } else if (config.environment !== "homologation" && config.environment !== "production") {
196
+ errors.push('environment deve ser "homologation" ou "production"');
197
+ }
198
+ return { errors, valid: errors.length === 0 };
199
+ }
200
+
201
+ // src/client.ts
202
+ var RNDS_NS = {
203
+ cns: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns",
204
+ cpf: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cpf"
205
+ };
206
+ var RNDSClient = class {
207
+
208
+
209
+
210
+ constructor(config) {
211
+ const validation = validateConfig(config);
212
+ if (!validation.valid) {
213
+ throw new RNDSError(`Configura\xE7\xE3o inv\xE1lida: ${validation.errors.join("; ")}`);
214
+ }
215
+ const pfx = typeof config.certificate === "string" ? _fs2.default.readFileSync(config.certificate) : config.certificate;
216
+ const endpoints = resolveEndpoints(config.environment);
217
+ this.apiBaseUrl = endpoints.api;
218
+ this.cns = config.cns;
219
+ this.auth = new RNDSAuth(pfx, config.certificatePassword, endpoints.auth);
220
+ }
221
+ async getPatientByCpf(cpf) {
222
+ return this.get(`/Patient?identifier=${RNDS_NS.cpf}|${encodeURIComponent(cpf)}`);
223
+ }
224
+ async getPatientByCns(cns) {
225
+ return this.get(`/Patient/${encodeURIComponent(cns)}`);
226
+ }
227
+ async getOrganizationByCnes(cnes) {
228
+ return this.get(`/Organization/${encodeURIComponent(cnes)}`);
229
+ }
230
+ async getPractitionerByCns(cns) {
231
+ return this.get(`/Practitioner/${encodeURIComponent(cns)}`);
232
+ }
233
+ async submitBundle(bundle) {
234
+ return this.post("/Bundle", bundle);
235
+ }
236
+ async get(path) {
237
+ const response = await this.request(path, "GET");
238
+ if (response.status === 404) {
239
+ return null;
240
+ }
241
+ await this.handleErrorResponse(response);
242
+ return await response.json();
243
+ }
244
+ async post(path, body) {
245
+ const response = await this.request(path, "POST", body);
246
+ await this.handleErrorResponse(response);
247
+ return await response.json();
248
+ }
249
+ async request(path, method, body) {
250
+ const headers = await this.buildHeaders();
251
+ const url = `${this.apiBaseUrl}${path}`;
252
+ return fetch(url, {
253
+ body: body ? JSON.stringify(body) : void 0,
254
+ headers,
255
+ method
256
+ });
257
+ }
258
+ async buildHeaders() {
259
+ const token = await this.auth.getToken();
260
+ return {
261
+ Accept: "application/json",
262
+ Authorization: this.cns,
263
+ "Content-Type": "application/json",
264
+ "X-Authorization-Server": `Bearer ${token}`
265
+ };
266
+ }
267
+ async handleErrorResponse(response) {
268
+ if (response.ok) {
269
+ return;
270
+ }
271
+ let body;
272
+ try {
273
+ body = await response.text();
274
+ } catch (e3) {
275
+ body = "";
276
+ }
277
+ let operationOutcome;
278
+ try {
279
+ const parsed = JSON.parse(body);
280
+ if (parsed.resourceType === "OperationOutcome") {
281
+ operationOutcome = parsed;
282
+ }
283
+ } catch (e4) {
284
+ }
285
+ if (response.status === 401 || response.status === 403) {
286
+ throw new RNDSAuthError(
287
+ `Autentica\xE7\xE3o RNDS falhou (${response.status}): ${body}`,
288
+ response.status
289
+ );
290
+ }
291
+ if (response.status === 422) {
292
+ throw new RNDSValidationError(`Erro de valida\xE7\xE3o FHIR (422): ${body}`, operationOutcome);
293
+ }
294
+ throw new RNDSError(
295
+ `Erro na requisi\xE7\xE3o RNDS (${response.status}): ${body}`,
296
+ response.status,
297
+ operationOutcome
298
+ );
299
+ }
300
+ };
301
+
302
+
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+ exports.RNDSAuthError = RNDSAuthError; exports.RNDSClient = RNDSClient; exports.RNDSError = RNDSError; exports.RNDSNotFoundError = RNDSNotFoundError; exports.RNDSValidationError = RNDSValidationError; exports.RNDS_ENDPOINTS = RNDS_ENDPOINTS; exports.resolveEndpoints = resolveEndpoints; exports.validateConfig = validateConfig;
311
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/home/runner/work/fhir-brasil/fhir-brasil/packages/rnds/dist/index.cjs","../src/client.ts","../src/errors.ts","../src/http.ts","../src/auth.ts","../src/config.ts"],"names":[],"mappings":"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","file":"/home/runner/work/fhir-brasil/fhir-brasil/packages/rnds/dist/index.cjs","sourcesContent":[null,"/**\n * Cliente RNDS (Rede Nacional de Dados em Saúde)\n *\n * Cliente HTTP fino para a API FHIR R4 da RNDS,\n * usando fetch nativo para chamadas de dados e\n * node:https para autenticação mTLS.\n */\n\nimport fs from 'node:fs';\n\nimport type { FHIRBundle, FHIRPatient } from '@precisa-saude/fhir';\n\nimport { RNDSAuth } from './auth';\nimport type { RNDSConfig } from './config';\nimport { resolveEndpoints, validateConfig } from './config';\nimport { RNDSAuthError, RNDSError, RNDSValidationError } from './errors';\nimport type { FHIROperationOutcome, FHIROrganization, FHIRPractitioner } from './types';\n\nconst RNDS_NS = {\n cns: 'http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns',\n cpf: 'http://rnds.saude.gov.br/fhir/r4/NamingSystem/cpf',\n} as const;\n\nexport class RNDSClient {\n private readonly auth: RNDSAuth;\n private readonly apiBaseUrl: string;\n private readonly cns: string;\n\n constructor(config: RNDSConfig) {\n const validation = validateConfig(config);\n if (!validation.valid) {\n throw new RNDSError(`Configuração inválida: ${validation.errors.join('; ')}`);\n }\n\n const pfx =\n typeof config.certificate === 'string'\n ? fs.readFileSync(config.certificate)\n : config.certificate;\n\n const endpoints = resolveEndpoints(config.environment);\n this.apiBaseUrl = endpoints.api;\n this.cns = config.cns;\n this.auth = new RNDSAuth(pfx, config.certificatePassword, endpoints.auth);\n }\n\n async getPatientByCpf(cpf: string): Promise<FHIRPatient | null> {\n return this.get<FHIRPatient>(`/Patient?identifier=${RNDS_NS.cpf}|${encodeURIComponent(cpf)}`);\n }\n\n async getPatientByCns(cns: string): Promise<FHIRPatient | null> {\n return this.get<FHIRPatient>(`/Patient/${encodeURIComponent(cns)}`);\n }\n\n async getOrganizationByCnes(cnes: string): Promise<FHIROrganization | null> {\n return this.get<FHIROrganization>(`/Organization/${encodeURIComponent(cnes)}`);\n }\n\n async getPractitionerByCns(cns: string): Promise<FHIRPractitioner | null> {\n return this.get<FHIRPractitioner>(`/Practitioner/${encodeURIComponent(cns)}`);\n }\n\n async submitBundle(bundle: FHIRBundle): Promise<FHIRBundle> {\n return this.post<FHIRBundle>('/Bundle', bundle);\n }\n\n private async get<T>(path: string): Promise<T | null> {\n const response = await this.request(path, 'GET');\n\n if (response.status === 404) {\n return null;\n }\n\n await this.handleErrorResponse(response);\n return (await response.json()) as T;\n }\n\n private async post<T>(path: string, body: unknown): Promise<T> {\n const response = await this.request(path, 'POST', body);\n await this.handleErrorResponse(response);\n return (await response.json()) as T;\n }\n\n private async request(path: string, method: string, body?: unknown): Promise<Response> {\n const headers = await this.buildHeaders();\n const url = `${this.apiBaseUrl}${path}`;\n\n return fetch(url, {\n body: body ? JSON.stringify(body) : undefined,\n headers,\n method,\n });\n }\n\n private async buildHeaders(): Promise<Record<string, string>> {\n const token = await this.auth.getToken();\n\n return {\n Accept: 'application/json',\n Authorization: this.cns,\n 'Content-Type': 'application/json',\n 'X-Authorization-Server': `Bearer ${token}`,\n };\n }\n\n private async handleErrorResponse(response: Response): Promise<void> {\n if (response.ok) {\n return;\n }\n\n let body: string;\n try {\n body = await response.text();\n } catch {\n body = '';\n }\n\n let operationOutcome: FHIROperationOutcome | undefined;\n try {\n const parsed = JSON.parse(body) as { resourceType?: string };\n if (parsed.resourceType === 'OperationOutcome') {\n operationOutcome = parsed as FHIROperationOutcome;\n }\n } catch {\n // corpo não é JSON válido\n }\n\n if (response.status === 401 || response.status === 403) {\n throw new RNDSAuthError(\n `Autenticação RNDS falhou (${response.status}): ${body}`,\n response.status,\n );\n }\n\n if (response.status === 422) {\n throw new RNDSValidationError(`Erro de validação FHIR (422): ${body}`, operationOutcome);\n }\n\n throw new RNDSError(\n `Erro na requisição RNDS (${response.status}): ${body}`,\n response.status,\n operationOutcome,\n );\n }\n}\n","/**\n * Erros customizados para o cliente RNDS\n */\n\nimport type { FHIROperationOutcome } from './types';\n\nexport class RNDSError extends Error {\n constructor(\n message: string,\n public readonly statusCode?: number,\n public readonly operationOutcome?: FHIROperationOutcome,\n ) {\n super(message);\n this.name = 'RNDSError';\n }\n}\n\nexport class RNDSAuthError extends RNDSError {\n constructor(message: string, statusCode?: number) {\n super(message, statusCode);\n this.name = 'RNDSAuthError';\n }\n}\n\nexport class RNDSNotFoundError extends RNDSError {\n constructor(message: string) {\n super(message, 404);\n this.name = 'RNDSNotFoundError';\n }\n}\n\nexport class RNDSValidationError extends RNDSError {\n constructor(message: string, operationOutcome?: FHIROperationOutcome) {\n super(message, 422, operationOutcome);\n this.name = 'RNDSValidationError';\n }\n}\n","/**\n * Wrapper mTLS sobre node:https\n *\n * Encapsula node:https.request para fazer chamadas HTTPS\n * com certificado PFX (mutual TLS). Usado apenas para\n * autenticação com a RNDS.\n */\n\nimport https from 'node:https';\n\nexport interface HttpsResponse {\n body: string;\n headers: Record<string, string | string[] | undefined>;\n statusCode: number;\n}\n\nexport interface HttpsCertOptions {\n passphrase: string;\n pfx: Buffer;\n}\n\nexport function httpsRequestWithCert(\n url: string,\n certOptions: HttpsCertOptions,\n options?: {\n method?: string;\n headers?: Record<string, string>;\n body?: string;\n timeout?: number;\n },\n): Promise<HttpsResponse> {\n return new Promise((resolve, reject) => {\n const parsedUrl = new URL(url);\n\n const req = https.request(\n {\n headers: options?.headers,\n hostname: parsedUrl.hostname,\n method: options?.method ?? 'GET',\n passphrase: certOptions.passphrase,\n path: parsedUrl.pathname + parsedUrl.search,\n pfx: certOptions.pfx,\n port: Number(parsedUrl.port) || 443,\n timeout: options?.timeout ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n\n res.on('data', (chunk: Buffer) => {\n chunks.push(chunk);\n });\n\n res.on('error', (err) => {\n reject(new Error(`Erro na resposta HTTPS: ${err.message}`));\n });\n\n res.on('end', () => {\n resolve({\n body: Buffer.concat(chunks).toString('utf-8'),\n headers: res.headers as Record<string, string | string[] | undefined>,\n statusCode: res.statusCode ?? 0,\n });\n });\n },\n );\n\n req.on('error', (err) => {\n reject(new Error(`Erro na requisição HTTPS: ${err.message}`));\n });\n\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('Timeout na requisição HTTPS'));\n });\n\n if (options?.body) {\n req.write(options.body);\n }\n\n req.end();\n });\n}\n","/**\n * Autenticação RNDS com cache de token JWT\n *\n * Obtém token JWT via mutual TLS no endpoint de autenticação\n * da RNDS. O token é cacheado e renovado automaticamente\n * antes de expirar.\n */\n\nimport { RNDSAuthError } from './errors';\nimport { httpsRequestWithCert } from './http';\nimport type { RNDSTokenResponse } from './types';\n\nconst TOKEN_REFRESH_MARGIN_MS = 60_000;\n\nexport class RNDSAuth {\n private token: string | null = null;\n private expiresAt = 0;\n private pendingRefresh: Promise<string> | null = null;\n\n constructor(\n private readonly pfx: Buffer,\n private readonly passphrase: string,\n private readonly authBaseUrl: string,\n ) {}\n\n async getToken(): Promise<string> {\n if (this.isTokenValid()) {\n return this.token!;\n }\n\n return this.refreshToken();\n }\n\n async refreshToken(): Promise<string> {\n if (this.pendingRefresh) {\n return this.pendingRefresh;\n }\n\n this.pendingRefresh = this.fetchToken();\n\n try {\n const token = await this.pendingRefresh;\n return token;\n } finally {\n this.pendingRefresh = null;\n }\n }\n\n isTokenValid(): boolean {\n return this.token !== null && Date.now() < this.expiresAt - TOKEN_REFRESH_MARGIN_MS;\n }\n\n private async fetchToken(): Promise<string> {\n const url = `${this.authBaseUrl}/api/token`;\n\n let response;\n try {\n response = await httpsRequestWithCert(url, {\n passphrase: this.passphrase,\n pfx: this.pfx,\n });\n } catch (err) {\n throw new RNDSAuthError(\n `Falha na autenticação com a RNDS: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n\n if (response.statusCode !== 200) {\n throw new RNDSAuthError(\n `Autenticação RNDS falhou com status ${response.statusCode}: ${response.body}`,\n response.statusCode,\n );\n }\n\n let tokenData: RNDSTokenResponse;\n try {\n tokenData = JSON.parse(response.body) as RNDSTokenResponse;\n } catch {\n throw new RNDSAuthError('Resposta de autenticação RNDS inválida: JSON malformado');\n }\n\n if (!tokenData.access_token) {\n throw new RNDSAuthError('Resposta de autenticação RNDS não contém access_token');\n }\n\n this.token = tokenData.access_token;\n this.expiresAt = this.extractExpiry(tokenData);\n\n return this.token;\n }\n\n private extractExpiry(tokenData: RNDSTokenResponse): number {\n // RNDS retorna expires_in em milissegundos (ex: 1800000 = 30 min)\n if (tokenData.expires_in) {\n return Date.now() + tokenData.expires_in;\n }\n\n try {\n const parts = tokenData.access_token.split('.');\n if (parts.length === 3) {\n const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()) as {\n exp?: number;\n };\n if (payload.exp) {\n return payload.exp * 1000;\n }\n }\n } catch {\n // fallback para 30 minutos\n }\n\n return Date.now() + 30 * 60 * 1000;\n }\n}\n","/**\n * Configuração do cliente RNDS\n *\n * Define tipos de configuração e constantes de ambiente para\n * conexão com a Rede Nacional de Dados em Saúde (RNDS).\n */\n\nexport type RNDSEnvironment = 'homologation' | 'production';\n\nexport interface RNDSConfig {\n /** Certificado PFX como Buffer ou caminho do arquivo */\n certificate: Buffer | string;\n /** Senha do certificado PFX */\n certificatePassword: string;\n /** CNES do estabelecimento de saúde */\n cnes: string;\n /** CNS do profissional de saúde responsável */\n cns: string;\n /** Ambiente da RNDS */\n environment: RNDSEnvironment;\n}\n\nexport interface RNDSEndpoints {\n api: string;\n auth: string;\n}\n\nexport const RNDS_ENDPOINTS: Record<RNDSEnvironment, RNDSEndpoints> = {\n homologation: {\n api: 'https://ehr-services.hmg.saude.gov.br/api/fhir/r4',\n auth: 'https://ehr-auth-hmg.saude.gov.br',\n },\n production: {\n api: 'https://ehr-services.saude.gov.br/api/fhir/r4',\n auth: 'https://ehr-auth.saude.gov.br',\n },\n};\n\nexport function resolveEndpoints(environment: RNDSEnvironment): RNDSEndpoints {\n return RNDS_ENDPOINTS[environment];\n}\n\nexport function validateConfig(config: RNDSConfig): { valid: boolean; errors: string[] } {\n const errors: string[] = [];\n\n if (!config.certificate) {\n errors.push('certificate é obrigatório');\n }\n\n if (!config.certificatePassword) {\n errors.push('certificatePassword é obrigatório');\n }\n\n if (!config.cnes) {\n errors.push('cnes é obrigatório');\n } else if (!/^\\d{7}$/.test(config.cnes)) {\n errors.push('cnes deve ter exatamente 7 dígitos');\n }\n\n if (!config.cns) {\n errors.push('cns é obrigatório');\n } else if (!/^\\d{15}$/.test(config.cns)) {\n errors.push('cns deve ter exatamente 15 dígitos');\n }\n\n if (!config.environment) {\n errors.push('environment é obrigatório');\n } else if (config.environment !== 'homologation' && config.environment !== 'production') {\n errors.push('environment deve ser \"homologation\" ou \"production\"');\n }\n\n return { errors, valid: errors.length === 0 };\n}\n"]}
@@ -0,0 +1,151 @@
1
+ import { FHIRCodeableConcept, FHIRAddress, FHIRContactPoint, FHIRHumanName, FHIRReference, FHIRPatient, FHIRBundle } from '@precisa-saude/fhir';
2
+
3
+ /**
4
+ * Configuração do cliente RNDS
5
+ *
6
+ * Define tipos de configuração e constantes de ambiente para
7
+ * conexão com a Rede Nacional de Dados em Saúde (RNDS).
8
+ */
9
+ type RNDSEnvironment = 'homologation' | 'production';
10
+ interface RNDSConfig {
11
+ /** Certificado PFX como Buffer ou caminho do arquivo */
12
+ certificate: Buffer | string;
13
+ /** Senha do certificado PFX */
14
+ certificatePassword: string;
15
+ /** CNES do estabelecimento de saúde */
16
+ cnes: string;
17
+ /** CNS do profissional de saúde responsável */
18
+ cns: string;
19
+ /** Ambiente da RNDS */
20
+ environment: RNDSEnvironment;
21
+ }
22
+ interface RNDSEndpoints {
23
+ api: string;
24
+ auth: string;
25
+ }
26
+ declare const RNDS_ENDPOINTS: Record<RNDSEnvironment, RNDSEndpoints>;
27
+ declare function resolveEndpoints(environment: RNDSEnvironment): RNDSEndpoints;
28
+ declare function validateConfig(config: RNDSConfig): {
29
+ valid: boolean;
30
+ errors: string[];
31
+ };
32
+
33
+ /**
34
+ * Tipos FHIR R4 específicos para a RNDS
35
+ *
36
+ * Tipos que ainda não existem no pacote core e são necessários
37
+ * para interação com a API da RNDS.
38
+ */
39
+
40
+ interface FHIRIdentifier {
41
+ system?: string;
42
+ type?: FHIRCodeableConcept;
43
+ use?: 'usual' | 'official' | 'temp' | 'secondary' | 'old';
44
+ value?: string;
45
+ }
46
+ interface FHIROrganization {
47
+ active?: boolean;
48
+ address?: FHIRAddress[];
49
+ alias?: string[];
50
+ id?: string;
51
+ identifier?: FHIRIdentifier[];
52
+ name?: string;
53
+ resourceType: 'Organization';
54
+ telecom?: FHIRContactPoint[];
55
+ type?: FHIRCodeableConcept[];
56
+ }
57
+ interface FHIRPractitioner {
58
+ active?: boolean;
59
+ address?: FHIRAddress[];
60
+ birthDate?: string;
61
+ gender?: 'male' | 'female' | 'other' | 'unknown';
62
+ id?: string;
63
+ identifier?: FHIRIdentifier[];
64
+ name?: FHIRHumanName[];
65
+ qualification?: FHIRPractitionerQualification[];
66
+ resourceType: 'Practitioner';
67
+ telecom?: FHIRContactPoint[];
68
+ }
69
+ interface FHIRPractitionerQualification {
70
+ code: FHIRCodeableConcept;
71
+ identifier?: FHIRIdentifier[];
72
+ issuer?: FHIRReference;
73
+ period?: {
74
+ start?: string;
75
+ end?: string;
76
+ };
77
+ }
78
+ interface FHIROperationOutcomeIssue {
79
+ code: string;
80
+ details?: FHIRCodeableConcept;
81
+ diagnostics?: string;
82
+ expression?: string[];
83
+ location?: string[];
84
+ severity: 'fatal' | 'error' | 'warning' | 'information';
85
+ }
86
+ interface FHIROperationOutcome {
87
+ issue: FHIROperationOutcomeIssue[];
88
+ resourceType: 'OperationOutcome';
89
+ }
90
+ interface RNDSSearchBundleEntry<T> {
91
+ fullUrl?: string;
92
+ resource: T;
93
+ }
94
+ interface RNDSSearchBundle<T> {
95
+ entry?: RNDSSearchBundleEntry<T>[];
96
+ resourceType: 'Bundle';
97
+ total?: number;
98
+ type: 'searchset';
99
+ }
100
+ interface RNDSTokenResponse {
101
+ access_token: string;
102
+ expires_in: number;
103
+ scope: string;
104
+ token_type: string;
105
+ }
106
+
107
+ /**
108
+ * Cliente RNDS (Rede Nacional de Dados em Saúde)
109
+ *
110
+ * Cliente HTTP fino para a API FHIR R4 da RNDS,
111
+ * usando fetch nativo para chamadas de dados e
112
+ * node:https para autenticação mTLS.
113
+ */
114
+
115
+ declare class RNDSClient {
116
+ private readonly auth;
117
+ private readonly apiBaseUrl;
118
+ private readonly cns;
119
+ constructor(config: RNDSConfig);
120
+ getPatientByCpf(cpf: string): Promise<FHIRPatient | null>;
121
+ getPatientByCns(cns: string): Promise<FHIRPatient | null>;
122
+ getOrganizationByCnes(cnes: string): Promise<FHIROrganization | null>;
123
+ getPractitionerByCns(cns: string): Promise<FHIRPractitioner | null>;
124
+ submitBundle(bundle: FHIRBundle): Promise<FHIRBundle>;
125
+ private get;
126
+ private post;
127
+ private request;
128
+ private buildHeaders;
129
+ private handleErrorResponse;
130
+ }
131
+
132
+ /**
133
+ * Erros customizados para o cliente RNDS
134
+ */
135
+
136
+ declare class RNDSError extends Error {
137
+ readonly statusCode?: number | undefined;
138
+ readonly operationOutcome?: FHIROperationOutcome | undefined;
139
+ constructor(message: string, statusCode?: number | undefined, operationOutcome?: FHIROperationOutcome | undefined);
140
+ }
141
+ declare class RNDSAuthError extends RNDSError {
142
+ constructor(message: string, statusCode?: number);
143
+ }
144
+ declare class RNDSNotFoundError extends RNDSError {
145
+ constructor(message: string);
146
+ }
147
+ declare class RNDSValidationError extends RNDSError {
148
+ constructor(message: string, operationOutcome?: FHIROperationOutcome);
149
+ }
150
+
151
+ export { type FHIRIdentifier, type FHIROperationOutcome, type FHIROperationOutcomeIssue, type FHIROrganization, type FHIRPractitioner, type FHIRPractitionerQualification, RNDSAuthError, RNDSClient, type RNDSConfig, type RNDSEndpoints, type RNDSEnvironment, RNDSError, RNDSNotFoundError, type RNDSSearchBundle, type RNDSSearchBundleEntry, type RNDSTokenResponse, RNDSValidationError, RNDS_ENDPOINTS, resolveEndpoints, validateConfig };
@@ -0,0 +1,151 @@
1
+ import { FHIRCodeableConcept, FHIRAddress, FHIRContactPoint, FHIRHumanName, FHIRReference, FHIRPatient, FHIRBundle } from '@precisa-saude/fhir';
2
+
3
+ /**
4
+ * Configuração do cliente RNDS
5
+ *
6
+ * Define tipos de configuração e constantes de ambiente para
7
+ * conexão com a Rede Nacional de Dados em Saúde (RNDS).
8
+ */
9
+ type RNDSEnvironment = 'homologation' | 'production';
10
+ interface RNDSConfig {
11
+ /** Certificado PFX como Buffer ou caminho do arquivo */
12
+ certificate: Buffer | string;
13
+ /** Senha do certificado PFX */
14
+ certificatePassword: string;
15
+ /** CNES do estabelecimento de saúde */
16
+ cnes: string;
17
+ /** CNS do profissional de saúde responsável */
18
+ cns: string;
19
+ /** Ambiente da RNDS */
20
+ environment: RNDSEnvironment;
21
+ }
22
+ interface RNDSEndpoints {
23
+ api: string;
24
+ auth: string;
25
+ }
26
+ declare const RNDS_ENDPOINTS: Record<RNDSEnvironment, RNDSEndpoints>;
27
+ declare function resolveEndpoints(environment: RNDSEnvironment): RNDSEndpoints;
28
+ declare function validateConfig(config: RNDSConfig): {
29
+ valid: boolean;
30
+ errors: string[];
31
+ };
32
+
33
+ /**
34
+ * Tipos FHIR R4 específicos para a RNDS
35
+ *
36
+ * Tipos que ainda não existem no pacote core e são necessários
37
+ * para interação com a API da RNDS.
38
+ */
39
+
40
+ interface FHIRIdentifier {
41
+ system?: string;
42
+ type?: FHIRCodeableConcept;
43
+ use?: 'usual' | 'official' | 'temp' | 'secondary' | 'old';
44
+ value?: string;
45
+ }
46
+ interface FHIROrganization {
47
+ active?: boolean;
48
+ address?: FHIRAddress[];
49
+ alias?: string[];
50
+ id?: string;
51
+ identifier?: FHIRIdentifier[];
52
+ name?: string;
53
+ resourceType: 'Organization';
54
+ telecom?: FHIRContactPoint[];
55
+ type?: FHIRCodeableConcept[];
56
+ }
57
+ interface FHIRPractitioner {
58
+ active?: boolean;
59
+ address?: FHIRAddress[];
60
+ birthDate?: string;
61
+ gender?: 'male' | 'female' | 'other' | 'unknown';
62
+ id?: string;
63
+ identifier?: FHIRIdentifier[];
64
+ name?: FHIRHumanName[];
65
+ qualification?: FHIRPractitionerQualification[];
66
+ resourceType: 'Practitioner';
67
+ telecom?: FHIRContactPoint[];
68
+ }
69
+ interface FHIRPractitionerQualification {
70
+ code: FHIRCodeableConcept;
71
+ identifier?: FHIRIdentifier[];
72
+ issuer?: FHIRReference;
73
+ period?: {
74
+ start?: string;
75
+ end?: string;
76
+ };
77
+ }
78
+ interface FHIROperationOutcomeIssue {
79
+ code: string;
80
+ details?: FHIRCodeableConcept;
81
+ diagnostics?: string;
82
+ expression?: string[];
83
+ location?: string[];
84
+ severity: 'fatal' | 'error' | 'warning' | 'information';
85
+ }
86
+ interface FHIROperationOutcome {
87
+ issue: FHIROperationOutcomeIssue[];
88
+ resourceType: 'OperationOutcome';
89
+ }
90
+ interface RNDSSearchBundleEntry<T> {
91
+ fullUrl?: string;
92
+ resource: T;
93
+ }
94
+ interface RNDSSearchBundle<T> {
95
+ entry?: RNDSSearchBundleEntry<T>[];
96
+ resourceType: 'Bundle';
97
+ total?: number;
98
+ type: 'searchset';
99
+ }
100
+ interface RNDSTokenResponse {
101
+ access_token: string;
102
+ expires_in: number;
103
+ scope: string;
104
+ token_type: string;
105
+ }
106
+
107
+ /**
108
+ * Cliente RNDS (Rede Nacional de Dados em Saúde)
109
+ *
110
+ * Cliente HTTP fino para a API FHIR R4 da RNDS,
111
+ * usando fetch nativo para chamadas de dados e
112
+ * node:https para autenticação mTLS.
113
+ */
114
+
115
+ declare class RNDSClient {
116
+ private readonly auth;
117
+ private readonly apiBaseUrl;
118
+ private readonly cns;
119
+ constructor(config: RNDSConfig);
120
+ getPatientByCpf(cpf: string): Promise<FHIRPatient | null>;
121
+ getPatientByCns(cns: string): Promise<FHIRPatient | null>;
122
+ getOrganizationByCnes(cnes: string): Promise<FHIROrganization | null>;
123
+ getPractitionerByCns(cns: string): Promise<FHIRPractitioner | null>;
124
+ submitBundle(bundle: FHIRBundle): Promise<FHIRBundle>;
125
+ private get;
126
+ private post;
127
+ private request;
128
+ private buildHeaders;
129
+ private handleErrorResponse;
130
+ }
131
+
132
+ /**
133
+ * Erros customizados para o cliente RNDS
134
+ */
135
+
136
+ declare class RNDSError extends Error {
137
+ readonly statusCode?: number | undefined;
138
+ readonly operationOutcome?: FHIROperationOutcome | undefined;
139
+ constructor(message: string, statusCode?: number | undefined, operationOutcome?: FHIROperationOutcome | undefined);
140
+ }
141
+ declare class RNDSAuthError extends RNDSError {
142
+ constructor(message: string, statusCode?: number);
143
+ }
144
+ declare class RNDSNotFoundError extends RNDSError {
145
+ constructor(message: string);
146
+ }
147
+ declare class RNDSValidationError extends RNDSError {
148
+ constructor(message: string, operationOutcome?: FHIROperationOutcome);
149
+ }
150
+
151
+ export { type FHIRIdentifier, type FHIROperationOutcome, type FHIROperationOutcomeIssue, type FHIROrganization, type FHIRPractitioner, type FHIRPractitionerQualification, RNDSAuthError, RNDSClient, type RNDSConfig, type RNDSEndpoints, type RNDSEnvironment, RNDSError, RNDSNotFoundError, type RNDSSearchBundle, type RNDSSearchBundleEntry, type RNDSTokenResponse, RNDSValidationError, RNDS_ENDPOINTS, resolveEndpoints, validateConfig };
package/dist/index.js ADDED
@@ -0,0 +1,311 @@
1
+ // src/client.ts
2
+ import fs from "fs";
3
+
4
+ // src/errors.ts
5
+ var RNDSError = class extends Error {
6
+ constructor(message, statusCode, operationOutcome) {
7
+ super(message);
8
+ this.statusCode = statusCode;
9
+ this.operationOutcome = operationOutcome;
10
+ this.name = "RNDSError";
11
+ }
12
+ };
13
+ var RNDSAuthError = class extends RNDSError {
14
+ constructor(message, statusCode) {
15
+ super(message, statusCode);
16
+ this.name = "RNDSAuthError";
17
+ }
18
+ };
19
+ var RNDSNotFoundError = class extends RNDSError {
20
+ constructor(message) {
21
+ super(message, 404);
22
+ this.name = "RNDSNotFoundError";
23
+ }
24
+ };
25
+ var RNDSValidationError = class extends RNDSError {
26
+ constructor(message, operationOutcome) {
27
+ super(message, 422, operationOutcome);
28
+ this.name = "RNDSValidationError";
29
+ }
30
+ };
31
+
32
+ // src/http.ts
33
+ import https from "https";
34
+ function httpsRequestWithCert(url, certOptions, options) {
35
+ return new Promise((resolve, reject) => {
36
+ const parsedUrl = new URL(url);
37
+ const req = https.request(
38
+ {
39
+ headers: options?.headers,
40
+ hostname: parsedUrl.hostname,
41
+ method: options?.method ?? "GET",
42
+ passphrase: certOptions.passphrase,
43
+ path: parsedUrl.pathname + parsedUrl.search,
44
+ pfx: certOptions.pfx,
45
+ port: Number(parsedUrl.port) || 443,
46
+ timeout: options?.timeout ?? 3e4
47
+ },
48
+ (res) => {
49
+ const chunks = [];
50
+ res.on("data", (chunk) => {
51
+ chunks.push(chunk);
52
+ });
53
+ res.on("error", (err) => {
54
+ reject(new Error(`Erro na resposta HTTPS: ${err.message}`));
55
+ });
56
+ res.on("end", () => {
57
+ resolve({
58
+ body: Buffer.concat(chunks).toString("utf-8"),
59
+ headers: res.headers,
60
+ statusCode: res.statusCode ?? 0
61
+ });
62
+ });
63
+ }
64
+ );
65
+ req.on("error", (err) => {
66
+ reject(new Error(`Erro na requisi\xE7\xE3o HTTPS: ${err.message}`));
67
+ });
68
+ req.on("timeout", () => {
69
+ req.destroy();
70
+ reject(new Error("Timeout na requisi\xE7\xE3o HTTPS"));
71
+ });
72
+ if (options?.body) {
73
+ req.write(options.body);
74
+ }
75
+ req.end();
76
+ });
77
+ }
78
+
79
+ // src/auth.ts
80
+ var TOKEN_REFRESH_MARGIN_MS = 6e4;
81
+ var RNDSAuth = class {
82
+ constructor(pfx, passphrase, authBaseUrl) {
83
+ this.pfx = pfx;
84
+ this.passphrase = passphrase;
85
+ this.authBaseUrl = authBaseUrl;
86
+ }
87
+ token = null;
88
+ expiresAt = 0;
89
+ pendingRefresh = null;
90
+ async getToken() {
91
+ if (this.isTokenValid()) {
92
+ return this.token;
93
+ }
94
+ return this.refreshToken();
95
+ }
96
+ async refreshToken() {
97
+ if (this.pendingRefresh) {
98
+ return this.pendingRefresh;
99
+ }
100
+ this.pendingRefresh = this.fetchToken();
101
+ try {
102
+ const token = await this.pendingRefresh;
103
+ return token;
104
+ } finally {
105
+ this.pendingRefresh = null;
106
+ }
107
+ }
108
+ isTokenValid() {
109
+ return this.token !== null && Date.now() < this.expiresAt - TOKEN_REFRESH_MARGIN_MS;
110
+ }
111
+ async fetchToken() {
112
+ const url = `${this.authBaseUrl}/api/token`;
113
+ let response;
114
+ try {
115
+ response = await httpsRequestWithCert(url, {
116
+ passphrase: this.passphrase,
117
+ pfx: this.pfx
118
+ });
119
+ } catch (err) {
120
+ throw new RNDSAuthError(
121
+ `Falha na autentica\xE7\xE3o com a RNDS: ${err instanceof Error ? err.message : String(err)}`
122
+ );
123
+ }
124
+ if (response.statusCode !== 200) {
125
+ throw new RNDSAuthError(
126
+ `Autentica\xE7\xE3o RNDS falhou com status ${response.statusCode}: ${response.body}`,
127
+ response.statusCode
128
+ );
129
+ }
130
+ let tokenData;
131
+ try {
132
+ tokenData = JSON.parse(response.body);
133
+ } catch {
134
+ throw new RNDSAuthError("Resposta de autentica\xE7\xE3o RNDS inv\xE1lida: JSON malformado");
135
+ }
136
+ if (!tokenData.access_token) {
137
+ throw new RNDSAuthError("Resposta de autentica\xE7\xE3o RNDS n\xE3o cont\xE9m access_token");
138
+ }
139
+ this.token = tokenData.access_token;
140
+ this.expiresAt = this.extractExpiry(tokenData);
141
+ return this.token;
142
+ }
143
+ extractExpiry(tokenData) {
144
+ if (tokenData.expires_in) {
145
+ return Date.now() + tokenData.expires_in;
146
+ }
147
+ try {
148
+ const parts = tokenData.access_token.split(".");
149
+ if (parts.length === 3) {
150
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
151
+ if (payload.exp) {
152
+ return payload.exp * 1e3;
153
+ }
154
+ }
155
+ } catch {
156
+ }
157
+ return Date.now() + 30 * 60 * 1e3;
158
+ }
159
+ };
160
+
161
+ // src/config.ts
162
+ var RNDS_ENDPOINTS = {
163
+ homologation: {
164
+ api: "https://ehr-services.hmg.saude.gov.br/api/fhir/r4",
165
+ auth: "https://ehr-auth-hmg.saude.gov.br"
166
+ },
167
+ production: {
168
+ api: "https://ehr-services.saude.gov.br/api/fhir/r4",
169
+ auth: "https://ehr-auth.saude.gov.br"
170
+ }
171
+ };
172
+ function resolveEndpoints(environment) {
173
+ return RNDS_ENDPOINTS[environment];
174
+ }
175
+ function validateConfig(config) {
176
+ const errors = [];
177
+ if (!config.certificate) {
178
+ errors.push("certificate \xE9 obrigat\xF3rio");
179
+ }
180
+ if (!config.certificatePassword) {
181
+ errors.push("certificatePassword \xE9 obrigat\xF3rio");
182
+ }
183
+ if (!config.cnes) {
184
+ errors.push("cnes \xE9 obrigat\xF3rio");
185
+ } else if (!/^\d{7}$/.test(config.cnes)) {
186
+ errors.push("cnes deve ter exatamente 7 d\xEDgitos");
187
+ }
188
+ if (!config.cns) {
189
+ errors.push("cns \xE9 obrigat\xF3rio");
190
+ } else if (!/^\d{15}$/.test(config.cns)) {
191
+ errors.push("cns deve ter exatamente 15 d\xEDgitos");
192
+ }
193
+ if (!config.environment) {
194
+ errors.push("environment \xE9 obrigat\xF3rio");
195
+ } else if (config.environment !== "homologation" && config.environment !== "production") {
196
+ errors.push('environment deve ser "homologation" ou "production"');
197
+ }
198
+ return { errors, valid: errors.length === 0 };
199
+ }
200
+
201
+ // src/client.ts
202
+ var RNDS_NS = {
203
+ cns: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns",
204
+ cpf: "http://rnds.saude.gov.br/fhir/r4/NamingSystem/cpf"
205
+ };
206
+ var RNDSClient = class {
207
+ auth;
208
+ apiBaseUrl;
209
+ cns;
210
+ constructor(config) {
211
+ const validation = validateConfig(config);
212
+ if (!validation.valid) {
213
+ throw new RNDSError(`Configura\xE7\xE3o inv\xE1lida: ${validation.errors.join("; ")}`);
214
+ }
215
+ const pfx = typeof config.certificate === "string" ? fs.readFileSync(config.certificate) : config.certificate;
216
+ const endpoints = resolveEndpoints(config.environment);
217
+ this.apiBaseUrl = endpoints.api;
218
+ this.cns = config.cns;
219
+ this.auth = new RNDSAuth(pfx, config.certificatePassword, endpoints.auth);
220
+ }
221
+ async getPatientByCpf(cpf) {
222
+ return this.get(`/Patient?identifier=${RNDS_NS.cpf}|${encodeURIComponent(cpf)}`);
223
+ }
224
+ async getPatientByCns(cns) {
225
+ return this.get(`/Patient/${encodeURIComponent(cns)}`);
226
+ }
227
+ async getOrganizationByCnes(cnes) {
228
+ return this.get(`/Organization/${encodeURIComponent(cnes)}`);
229
+ }
230
+ async getPractitionerByCns(cns) {
231
+ return this.get(`/Practitioner/${encodeURIComponent(cns)}`);
232
+ }
233
+ async submitBundle(bundle) {
234
+ return this.post("/Bundle", bundle);
235
+ }
236
+ async get(path) {
237
+ const response = await this.request(path, "GET");
238
+ if (response.status === 404) {
239
+ return null;
240
+ }
241
+ await this.handleErrorResponse(response);
242
+ return await response.json();
243
+ }
244
+ async post(path, body) {
245
+ const response = await this.request(path, "POST", body);
246
+ await this.handleErrorResponse(response);
247
+ return await response.json();
248
+ }
249
+ async request(path, method, body) {
250
+ const headers = await this.buildHeaders();
251
+ const url = `${this.apiBaseUrl}${path}`;
252
+ return fetch(url, {
253
+ body: body ? JSON.stringify(body) : void 0,
254
+ headers,
255
+ method
256
+ });
257
+ }
258
+ async buildHeaders() {
259
+ const token = await this.auth.getToken();
260
+ return {
261
+ Accept: "application/json",
262
+ Authorization: this.cns,
263
+ "Content-Type": "application/json",
264
+ "X-Authorization-Server": `Bearer ${token}`
265
+ };
266
+ }
267
+ async handleErrorResponse(response) {
268
+ if (response.ok) {
269
+ return;
270
+ }
271
+ let body;
272
+ try {
273
+ body = await response.text();
274
+ } catch {
275
+ body = "";
276
+ }
277
+ let operationOutcome;
278
+ try {
279
+ const parsed = JSON.parse(body);
280
+ if (parsed.resourceType === "OperationOutcome") {
281
+ operationOutcome = parsed;
282
+ }
283
+ } catch {
284
+ }
285
+ if (response.status === 401 || response.status === 403) {
286
+ throw new RNDSAuthError(
287
+ `Autentica\xE7\xE3o RNDS falhou (${response.status}): ${body}`,
288
+ response.status
289
+ );
290
+ }
291
+ if (response.status === 422) {
292
+ throw new RNDSValidationError(`Erro de valida\xE7\xE3o FHIR (422): ${body}`, operationOutcome);
293
+ }
294
+ throw new RNDSError(
295
+ `Erro na requisi\xE7\xE3o RNDS (${response.status}): ${body}`,
296
+ response.status,
297
+ operationOutcome
298
+ );
299
+ }
300
+ };
301
+ export {
302
+ RNDSAuthError,
303
+ RNDSClient,
304
+ RNDSError,
305
+ RNDSNotFoundError,
306
+ RNDSValidationError,
307
+ RNDS_ENDPOINTS,
308
+ resolveEndpoints,
309
+ validateConfig
310
+ };
311
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts","../src/errors.ts","../src/http.ts","../src/auth.ts","../src/config.ts"],"sourcesContent":["/**\n * Cliente RNDS (Rede Nacional de Dados em Saúde)\n *\n * Cliente HTTP fino para a API FHIR R4 da RNDS,\n * usando fetch nativo para chamadas de dados e\n * node:https para autenticação mTLS.\n */\n\nimport fs from 'node:fs';\n\nimport type { FHIRBundle, FHIRPatient } from '@precisa-saude/fhir';\n\nimport { RNDSAuth } from './auth';\nimport type { RNDSConfig } from './config';\nimport { resolveEndpoints, validateConfig } from './config';\nimport { RNDSAuthError, RNDSError, RNDSValidationError } from './errors';\nimport type { FHIROperationOutcome, FHIROrganization, FHIRPractitioner } from './types';\n\nconst RNDS_NS = {\n cns: 'http://rnds.saude.gov.br/fhir/r4/NamingSystem/cns',\n cpf: 'http://rnds.saude.gov.br/fhir/r4/NamingSystem/cpf',\n} as const;\n\nexport class RNDSClient {\n private readonly auth: RNDSAuth;\n private readonly apiBaseUrl: string;\n private readonly cns: string;\n\n constructor(config: RNDSConfig) {\n const validation = validateConfig(config);\n if (!validation.valid) {\n throw new RNDSError(`Configuração inválida: ${validation.errors.join('; ')}`);\n }\n\n const pfx =\n typeof config.certificate === 'string'\n ? fs.readFileSync(config.certificate)\n : config.certificate;\n\n const endpoints = resolveEndpoints(config.environment);\n this.apiBaseUrl = endpoints.api;\n this.cns = config.cns;\n this.auth = new RNDSAuth(pfx, config.certificatePassword, endpoints.auth);\n }\n\n async getPatientByCpf(cpf: string): Promise<FHIRPatient | null> {\n return this.get<FHIRPatient>(`/Patient?identifier=${RNDS_NS.cpf}|${encodeURIComponent(cpf)}`);\n }\n\n async getPatientByCns(cns: string): Promise<FHIRPatient | null> {\n return this.get<FHIRPatient>(`/Patient/${encodeURIComponent(cns)}`);\n }\n\n async getOrganizationByCnes(cnes: string): Promise<FHIROrganization | null> {\n return this.get<FHIROrganization>(`/Organization/${encodeURIComponent(cnes)}`);\n }\n\n async getPractitionerByCns(cns: string): Promise<FHIRPractitioner | null> {\n return this.get<FHIRPractitioner>(`/Practitioner/${encodeURIComponent(cns)}`);\n }\n\n async submitBundle(bundle: FHIRBundle): Promise<FHIRBundle> {\n return this.post<FHIRBundle>('/Bundle', bundle);\n }\n\n private async get<T>(path: string): Promise<T | null> {\n const response = await this.request(path, 'GET');\n\n if (response.status === 404) {\n return null;\n }\n\n await this.handleErrorResponse(response);\n return (await response.json()) as T;\n }\n\n private async post<T>(path: string, body: unknown): Promise<T> {\n const response = await this.request(path, 'POST', body);\n await this.handleErrorResponse(response);\n return (await response.json()) as T;\n }\n\n private async request(path: string, method: string, body?: unknown): Promise<Response> {\n const headers = await this.buildHeaders();\n const url = `${this.apiBaseUrl}${path}`;\n\n return fetch(url, {\n body: body ? JSON.stringify(body) : undefined,\n headers,\n method,\n });\n }\n\n private async buildHeaders(): Promise<Record<string, string>> {\n const token = await this.auth.getToken();\n\n return {\n Accept: 'application/json',\n Authorization: this.cns,\n 'Content-Type': 'application/json',\n 'X-Authorization-Server': `Bearer ${token}`,\n };\n }\n\n private async handleErrorResponse(response: Response): Promise<void> {\n if (response.ok) {\n return;\n }\n\n let body: string;\n try {\n body = await response.text();\n } catch {\n body = '';\n }\n\n let operationOutcome: FHIROperationOutcome | undefined;\n try {\n const parsed = JSON.parse(body) as { resourceType?: string };\n if (parsed.resourceType === 'OperationOutcome') {\n operationOutcome = parsed as FHIROperationOutcome;\n }\n } catch {\n // corpo não é JSON válido\n }\n\n if (response.status === 401 || response.status === 403) {\n throw new RNDSAuthError(\n `Autenticação RNDS falhou (${response.status}): ${body}`,\n response.status,\n );\n }\n\n if (response.status === 422) {\n throw new RNDSValidationError(`Erro de validação FHIR (422): ${body}`, operationOutcome);\n }\n\n throw new RNDSError(\n `Erro na requisição RNDS (${response.status}): ${body}`,\n response.status,\n operationOutcome,\n );\n }\n}\n","/**\n * Erros customizados para o cliente RNDS\n */\n\nimport type { FHIROperationOutcome } from './types';\n\nexport class RNDSError extends Error {\n constructor(\n message: string,\n public readonly statusCode?: number,\n public readonly operationOutcome?: FHIROperationOutcome,\n ) {\n super(message);\n this.name = 'RNDSError';\n }\n}\n\nexport class RNDSAuthError extends RNDSError {\n constructor(message: string, statusCode?: number) {\n super(message, statusCode);\n this.name = 'RNDSAuthError';\n }\n}\n\nexport class RNDSNotFoundError extends RNDSError {\n constructor(message: string) {\n super(message, 404);\n this.name = 'RNDSNotFoundError';\n }\n}\n\nexport class RNDSValidationError extends RNDSError {\n constructor(message: string, operationOutcome?: FHIROperationOutcome) {\n super(message, 422, operationOutcome);\n this.name = 'RNDSValidationError';\n }\n}\n","/**\n * Wrapper mTLS sobre node:https\n *\n * Encapsula node:https.request para fazer chamadas HTTPS\n * com certificado PFX (mutual TLS). Usado apenas para\n * autenticação com a RNDS.\n */\n\nimport https from 'node:https';\n\nexport interface HttpsResponse {\n body: string;\n headers: Record<string, string | string[] | undefined>;\n statusCode: number;\n}\n\nexport interface HttpsCertOptions {\n passphrase: string;\n pfx: Buffer;\n}\n\nexport function httpsRequestWithCert(\n url: string,\n certOptions: HttpsCertOptions,\n options?: {\n method?: string;\n headers?: Record<string, string>;\n body?: string;\n timeout?: number;\n },\n): Promise<HttpsResponse> {\n return new Promise((resolve, reject) => {\n const parsedUrl = new URL(url);\n\n const req = https.request(\n {\n headers: options?.headers,\n hostname: parsedUrl.hostname,\n method: options?.method ?? 'GET',\n passphrase: certOptions.passphrase,\n path: parsedUrl.pathname + parsedUrl.search,\n pfx: certOptions.pfx,\n port: Number(parsedUrl.port) || 443,\n timeout: options?.timeout ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n\n res.on('data', (chunk: Buffer) => {\n chunks.push(chunk);\n });\n\n res.on('error', (err) => {\n reject(new Error(`Erro na resposta HTTPS: ${err.message}`));\n });\n\n res.on('end', () => {\n resolve({\n body: Buffer.concat(chunks).toString('utf-8'),\n headers: res.headers as Record<string, string | string[] | undefined>,\n statusCode: res.statusCode ?? 0,\n });\n });\n },\n );\n\n req.on('error', (err) => {\n reject(new Error(`Erro na requisição HTTPS: ${err.message}`));\n });\n\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('Timeout na requisição HTTPS'));\n });\n\n if (options?.body) {\n req.write(options.body);\n }\n\n req.end();\n });\n}\n","/**\n * Autenticação RNDS com cache de token JWT\n *\n * Obtém token JWT via mutual TLS no endpoint de autenticação\n * da RNDS. O token é cacheado e renovado automaticamente\n * antes de expirar.\n */\n\nimport { RNDSAuthError } from './errors';\nimport { httpsRequestWithCert } from './http';\nimport type { RNDSTokenResponse } from './types';\n\nconst TOKEN_REFRESH_MARGIN_MS = 60_000;\n\nexport class RNDSAuth {\n private token: string | null = null;\n private expiresAt = 0;\n private pendingRefresh: Promise<string> | null = null;\n\n constructor(\n private readonly pfx: Buffer,\n private readonly passphrase: string,\n private readonly authBaseUrl: string,\n ) {}\n\n async getToken(): Promise<string> {\n if (this.isTokenValid()) {\n return this.token!;\n }\n\n return this.refreshToken();\n }\n\n async refreshToken(): Promise<string> {\n if (this.pendingRefresh) {\n return this.pendingRefresh;\n }\n\n this.pendingRefresh = this.fetchToken();\n\n try {\n const token = await this.pendingRefresh;\n return token;\n } finally {\n this.pendingRefresh = null;\n }\n }\n\n isTokenValid(): boolean {\n return this.token !== null && Date.now() < this.expiresAt - TOKEN_REFRESH_MARGIN_MS;\n }\n\n private async fetchToken(): Promise<string> {\n const url = `${this.authBaseUrl}/api/token`;\n\n let response;\n try {\n response = await httpsRequestWithCert(url, {\n passphrase: this.passphrase,\n pfx: this.pfx,\n });\n } catch (err) {\n throw new RNDSAuthError(\n `Falha na autenticação com a RNDS: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n\n if (response.statusCode !== 200) {\n throw new RNDSAuthError(\n `Autenticação RNDS falhou com status ${response.statusCode}: ${response.body}`,\n response.statusCode,\n );\n }\n\n let tokenData: RNDSTokenResponse;\n try {\n tokenData = JSON.parse(response.body) as RNDSTokenResponse;\n } catch {\n throw new RNDSAuthError('Resposta de autenticação RNDS inválida: JSON malformado');\n }\n\n if (!tokenData.access_token) {\n throw new RNDSAuthError('Resposta de autenticação RNDS não contém access_token');\n }\n\n this.token = tokenData.access_token;\n this.expiresAt = this.extractExpiry(tokenData);\n\n return this.token;\n }\n\n private extractExpiry(tokenData: RNDSTokenResponse): number {\n // RNDS retorna expires_in em milissegundos (ex: 1800000 = 30 min)\n if (tokenData.expires_in) {\n return Date.now() + tokenData.expires_in;\n }\n\n try {\n const parts = tokenData.access_token.split('.');\n if (parts.length === 3) {\n const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()) as {\n exp?: number;\n };\n if (payload.exp) {\n return payload.exp * 1000;\n }\n }\n } catch {\n // fallback para 30 minutos\n }\n\n return Date.now() + 30 * 60 * 1000;\n }\n}\n","/**\n * Configuração do cliente RNDS\n *\n * Define tipos de configuração e constantes de ambiente para\n * conexão com a Rede Nacional de Dados em Saúde (RNDS).\n */\n\nexport type RNDSEnvironment = 'homologation' | 'production';\n\nexport interface RNDSConfig {\n /** Certificado PFX como Buffer ou caminho do arquivo */\n certificate: Buffer | string;\n /** Senha do certificado PFX */\n certificatePassword: string;\n /** CNES do estabelecimento de saúde */\n cnes: string;\n /** CNS do profissional de saúde responsável */\n cns: string;\n /** Ambiente da RNDS */\n environment: RNDSEnvironment;\n}\n\nexport interface RNDSEndpoints {\n api: string;\n auth: string;\n}\n\nexport const RNDS_ENDPOINTS: Record<RNDSEnvironment, RNDSEndpoints> = {\n homologation: {\n api: 'https://ehr-services.hmg.saude.gov.br/api/fhir/r4',\n auth: 'https://ehr-auth-hmg.saude.gov.br',\n },\n production: {\n api: 'https://ehr-services.saude.gov.br/api/fhir/r4',\n auth: 'https://ehr-auth.saude.gov.br',\n },\n};\n\nexport function resolveEndpoints(environment: RNDSEnvironment): RNDSEndpoints {\n return RNDS_ENDPOINTS[environment];\n}\n\nexport function validateConfig(config: RNDSConfig): { valid: boolean; errors: string[] } {\n const errors: string[] = [];\n\n if (!config.certificate) {\n errors.push('certificate é obrigatório');\n }\n\n if (!config.certificatePassword) {\n errors.push('certificatePassword é obrigatório');\n }\n\n if (!config.cnes) {\n errors.push('cnes é obrigatório');\n } else if (!/^\\d{7}$/.test(config.cnes)) {\n errors.push('cnes deve ter exatamente 7 dígitos');\n }\n\n if (!config.cns) {\n errors.push('cns é obrigatório');\n } else if (!/^\\d{15}$/.test(config.cns)) {\n errors.push('cns deve ter exatamente 15 dígitos');\n }\n\n if (!config.environment) {\n errors.push('environment é obrigatório');\n } else if (config.environment !== 'homologation' && config.environment !== 'production') {\n errors.push('environment deve ser \"homologation\" ou \"production\"');\n }\n\n return { errors, valid: errors.length === 0 };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@precisa-saude/fhir-rnds",
3
+ "version": "0.1.0",
4
+ "description": "Cliente HTTP para a RNDS (Rede Nacional de Dados em Saúde) — API FHIR R4 do DATASUS",
5
+ "keywords": [
6
+ "fhir",
7
+ "rnds",
8
+ "datasus",
9
+ "sus",
10
+ "saude",
11
+ "brasil"
12
+ ],
13
+ "homepage": "https://github.com/Precisa-Saude/fhir-brasil/tree/main/packages/rnds#readme",
14
+ "bugs": "https://github.com/Precisa-Saude/fhir-brasil/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/Precisa-Saude/fhir-brasil.git",
18
+ "directory": "packages/rnds"
19
+ },
20
+ "license": "Apache-2.0",
21
+ "sideEffects": false,
22
+ "type": "module",
23
+ "exports": {
24
+ ".": {
25
+ "import": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "require": {
30
+ "types": "./dist/index.d.cts",
31
+ "default": "./dist/index.cjs"
32
+ }
33
+ }
34
+ },
35
+ "main": "./dist/index.cjs",
36
+ "module": "./dist/index.js",
37
+ "types": "./dist/index.d.ts",
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "clean": "rm -rf dist .turbo",
44
+ "lint": "eslint src/ --max-warnings 0",
45
+ "test": "vitest run --passWithNoTests",
46
+ "test:coverage": "vitest run --coverage",
47
+ "typecheck": "tsc --noEmit"
48
+ },
49
+ "dependencies": {
50
+ "@precisa-saude/fhir": "workspace:*"
51
+ },
52
+ "devDependencies": {
53
+ "tsup": "^8.3.5",
54
+ "typescript": "~5.7.3",
55
+ "vitest": "^2.1.8"
56
+ },
57
+ "engines": {
58
+ "node": ">=20"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }