2026-04-27 04:25:52 +00:00

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_BASE_URL 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 };