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