110 lines
3.7 KiB
JavaScript
110 lines
3.7 KiB
JavaScript
'use strict';
|
|
|
|
const axios = require('axios');
|
|
const createError = require('http-errors');
|
|
const config = require('../config');
|
|
const { getPrivateKey, getKid } = require('../utils/vhlKeys');
|
|
const { buildVHLCWT, encryptBundle, serializeEncrypted } = require('../utils/vhlCrypto');
|
|
const { storeDocument, storeManifest } = require('../utils/vhlStorage');
|
|
|
|
/**
|
|
* Fetches the most recent IPS document-type Bundle for a patient from HAPI FHIR.
|
|
*
|
|
* Searches using the FHIR chained parameter:
|
|
* GET /Bundle?composition.subject=Patient/{patientId}&type=document&_sort=-_lastUpdated&_count=1
|
|
*
|
|
* @param {string} patientId - FHIR Patient logical ID.
|
|
* @returns {Promise<object>} FHIR Bundle resource.
|
|
* @throws {HttpError} 404 if no Bundle is found; 502 on FHIR server error.
|
|
*/
|
|
async function fetchIPSBundle(patientId) {
|
|
let response;
|
|
try {
|
|
response = await axios.get(`${config.fhir.url}/Bundle`, {
|
|
params: {
|
|
'composition.subject': `Patient/${patientId}`,
|
|
type: 'document',
|
|
_sort: '-_lastUpdated',
|
|
_count: 1,
|
|
},
|
|
headers: { Accept: 'application/fhir+json' },
|
|
});
|
|
} catch (err) {
|
|
throw createError(502, `HAPI FHIR error fetching Bundle: ${err.message}`);
|
|
}
|
|
|
|
const searchSet = response.data;
|
|
const entries = Array.isArray(searchSet.entry) ? searchSet.entry : [];
|
|
if (entries.length === 0) {
|
|
throw createError(404, `No se encontró un IPS Bundle para el paciente ${patientId}`);
|
|
}
|
|
|
|
const bundle = entries[0].resource;
|
|
if (!bundle) {
|
|
throw createError(502, 'HAPI FHIR returned an entry without a resource');
|
|
}
|
|
return bundle;
|
|
}
|
|
|
|
/**
|
|
* Issues a Verifiable Health Link (VHL) for a patient.
|
|
*
|
|
* Flow (matching obtener_qr_vhl.mmd — Fase 1. Emisión del VHL):
|
|
* 1. Fetch the patient's IPS Bundle from HAPI FHIR.
|
|
* 2. Encrypt the Bundle with AES-256-GCM (random key per issuance).
|
|
* 3. Store the encrypted document; derive its public URL.
|
|
* 4. Build a manifest { files: [{ contentType, location }] } and store it.
|
|
* 5. Sign a CWT (COSE_Sign1 / tag-18) containing the manifest URL and the
|
|
* symmetric key so the holder can share both atomically via a QR code.
|
|
* 6. Return the raw CWT bytes as base64url (MiArgentina encodes as Base45 + QR).
|
|
*
|
|
* @param {object} opts
|
|
* @param {string} opts.patientId - FHIR Patient logical ID.
|
|
* @param {string} [opts.pin] - Optional PIN; the manifest will require it.
|
|
* @returns {Promise<{ cwt: string }>} Base64url-encoded COSE_Sign1 CWT.
|
|
*/
|
|
async function issueVHL({ patientId, pin }) {
|
|
if (!config.vhl.baseUrl) {
|
|
throw createError(500, 'VHL_NODO_BASEURL is not configured');
|
|
}
|
|
|
|
const bundle = await fetchIPSBundle(patientId);
|
|
|
|
const encrypted = encryptBundle(bundle);
|
|
const serialized = serializeEncrypted(encrypted);
|
|
|
|
const ttl = config.vhl.ttl;
|
|
const baseUrl = config.vhl.baseUrl.replace(/\/$/, '');
|
|
|
|
const documentId = storeDocument(serialized, ttl);
|
|
const documentUrl = `${baseUrl}/vhl/document/${documentId}`;
|
|
|
|
const manifest = {
|
|
files: [
|
|
{
|
|
contentType: 'application/fhir+json',
|
|
location: documentUrl,
|
|
},
|
|
],
|
|
};
|
|
const manifestId = storeManifest(manifest, ttl, pin);
|
|
const manifestUrl = `${baseUrl}/vhl/manifest/${manifestId}`;
|
|
|
|
const cwtBytes = buildVHLCWT(
|
|
{
|
|
issuer: config.vhl.issuer,
|
|
patientId,
|
|
manifestUrl,
|
|
symmetricKey: encrypted.key,
|
|
ttl,
|
|
pin,
|
|
},
|
|
getPrivateKey(),
|
|
getKid(),
|
|
);
|
|
|
|
return { cwt: cwtBytes.toString('base64url') };
|
|
}
|
|
|
|
module.exports = { issueVHL, fetchIPSBundle };
|