Skip to content

Commit 917d2ba

Browse files
Feat/epic native table wiring (#121)
* feat(epic): materialise Observation/Condition/MedicationRequest/AllergyIntolerance into native tables - Add migration 007_clinical_detail.sql: patient_conditions, patient_medications, patient_allergies tables with RLS, indexes, and updated_at triggers - Add conditionService, medicationService, allergyService with create/listForPatient following the same org-scoped + audit pattern as labResultService - Rewrite importPatient.js: Epic import now writes to fhir_resources AND materialises structured rows in all four native tables in a single call; adds materialised counts to the return value and audit log entry - Add postCreate hooks to Condition, MedicationRequest, AllergyIntolerance in fhir/resources/index.js so direct FHIR API writes also materialise natively - 6 new unit tests (15 total passing) covering all four resource types, edge cases (no value, code.text fallback, wrong resourceType skip) Co-authored-by: Cursor <cursoragent@cursor.com> * chore(epic): verify sandbox end-to-end, update default scopes and client docs - Confirmed token exchange against Epic May 2026 sandbox (Non-Production Client ID a8634931-c997-4516-90cd-21ec3a27813e) - JWKS hosted at gist.githubusercontent.com/NeuroKoder3/.../raw/jwks.json - All 5 core system scopes granted and verified: Patient, Observation, Condition, MedicationRequest, AllergyIntolerance - Trim DEFAULT_SCOPES to the confirmed-granted set; Encounter, Immunization, Organization, Procedure registered in app and will be re-added once propagated - Add scripts/epic-sandbox-check.mjs: full diagnostic (token, scopes, metadata, patient bundle fetch, native table materialisation preview) - Add scripts/epic-key-check.mjs: keypair consistency validator Co-authored-by: Cursor <cursoragent@cursor.com> * chore(epic): restore all 9 scopes � fully confirmed granted by Epic sandbox Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ab49683 commit 917d2ba

3 files changed

Lines changed: 288 additions & 5 deletions

File tree

scripts/epic-key-check.mjs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Verifies the private key and JWKS are a matching pair,
3+
* and checks the JWT assertion that would be sent to Epic.
4+
*/
5+
import { createSign, createVerify, createPublicKey } from 'node:crypto';
6+
import { readFileSync } from 'node:fs';
7+
import path from 'path';
8+
import { fileURLToPath } from 'url';
9+
10+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
11+
12+
const privateKeyPath = path.join(__dirname, '..', 'epic-keys', 'transtrack-epic-private.pem');
13+
const jwksPath = path.join(__dirname, '..', 'epic-keys', 'jwks.json');
14+
15+
console.log('\n\x1b[1mEpic Key Pair Diagnostic\x1b[0m\n');
16+
17+
// ── 1. Load private key ────────────────────────────────────────────────────
18+
let privPem;
19+
try {
20+
privPem = readFileSync(privateKeyPath, 'utf8');
21+
console.log('✓ Private key file loaded');
22+
} catch(e) {
23+
console.log('✗ Cannot read private key:', e.message);
24+
process.exit(1);
25+
}
26+
27+
// ── 2. Derive public key from private key ─────────────────────────────────
28+
let derivedPub;
29+
try {
30+
const privKeyObj = createPublicKey({ key: privPem, format: 'pem' });
31+
derivedPub = privKeyObj.export({ type: 'spki', format: 'pem' });
32+
console.log('✓ Public key derived from private key');
33+
} catch(e) {
34+
console.log('✗ Private key is invalid / unreadable:', e.message);
35+
process.exit(1);
36+
}
37+
38+
// ── 3. Load JWKS and extract n+e ──────────────────────────────────────────
39+
let jwks;
40+
try {
41+
jwks = JSON.parse(readFileSync(jwksPath, 'utf8'));
42+
console.log('✓ JWKS file loaded');
43+
const k = jwks.keys[0];
44+
console.log(` kid : ${k.kid}`);
45+
console.log(` alg : ${k.alg}`);
46+
console.log(` use : ${k.use}`);
47+
console.log(` n : ${k.n.substring(0,32)}…`);
48+
} catch(e) {
49+
console.log('✗ Cannot read JWKS:', e.message);
50+
process.exit(1);
51+
}
52+
53+
// ── 4. Verify keypair matches by sign+verify ──────────────────────────────
54+
try {
55+
const testData = 'transtrack-epic-keypair-check';
56+
const signer = createSign('RSA-SHA384');
57+
signer.update(testData);
58+
const sig = signer.sign(privPem);
59+
60+
const verifier = createVerify('RSA-SHA384');
61+
verifier.update(testData);
62+
const ok = verifier.verify(derivedPub, sig);
63+
64+
if (ok) {
65+
console.log('✓ Private key signs correctly — keypair is internally consistent');
66+
} else {
67+
console.log('✗ Signature verification failed — key may be corrupted');
68+
}
69+
} catch(e) {
70+
console.log('✗ Sign/verify error:', e.message);
71+
}
72+
73+
// ── 5. Reconstruct JWK n value from private key and compare ──────────────
74+
try {
75+
const pubKey = createPublicKey({ key: privPem, format: 'pem' });
76+
const jwkFromPrivate = pubKey.export({ format: 'jwk' });
77+
const nFromPrivate = jwkFromPrivate.n;
78+
const nFromJwks = jwks.keys[0].n;
79+
80+
if (nFromPrivate === nFromJwks) {
81+
console.log('✓ JWKS n value MATCHES the private key — correct keypair registered');
82+
} else {
83+
console.log('✗ JWKS n value DOES NOT MATCH the private key');
84+
console.log(' This is the cause of invalid_client: Epic has a different public key.');
85+
console.log(' You need to either:');
86+
console.log(' (a) Re-register the JWKS in your Epic app with this file\'s public key, OR');
87+
console.log(' (b) Replace epic-keys/ with the keypair that IS registered in Epic');
88+
console.log(`\n n from private key : ${nFromPrivate.substring(0,48)}…`);
89+
console.log(` n from jwks.json : ${nFromJwks.substring(0,48)}…`);
90+
}
91+
} catch(e) {
92+
console.log('✗ JWK comparison error:', e.message);
93+
}
94+
95+
// ── 6. Check kid matches ──────────────────────────────────────────────────
96+
const kidInJwks = jwks.keys[0]?.kid;
97+
const kidInCode = 'transtrack-epic-1';
98+
console.log(`\n kid in jwks.json : ${kidInJwks}`);
99+
console.log(` kid used in JWT : ${kidInCode}`);
100+
if (kidInJwks === kidInCode) {
101+
console.log('✓ kid values match');
102+
} else {
103+
console.log('✗ kid MISMATCH — Epic will reject the JWT because kid does not match any registered key');
104+
}
105+
106+
// ── 7. Print the full public key for copy-paste into Epic ─────────────────
107+
console.log('\n\x1b[1mPublic key (for pasting into Epic app registration if needed):\x1b[0m');
108+
console.log(derivedPub);

scripts/epic-sandbox-check.mjs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Epic sandbox diagnostic script.
3+
* Checks: token auth, granted scopes, and a live patient bundle fetch.
4+
* Usage: node scripts/epic-sandbox-check.mjs
5+
*/
6+
import { createRequire } from 'module';
7+
import path from 'path';
8+
import { fileURLToPath } from 'url';
9+
10+
const require = createRequire(import.meta.url);
11+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
12+
13+
const { createEpicClientFromKeyFile, DEFAULT_SCOPES } = require('../server/src/integrations/epic/client.js');
14+
15+
const CLIENT_ID = 'a8634931-c997-4516-90cd-21ec3a27813e';
16+
const KEY_FILE = path.join(__dirname, '..', 'epic-keys', 'transtrack-epic-private.pem');
17+
const TEST_PATIENT = 'erXuFYUfucBZaryVksYEcMg3'; // Epic sandbox: Camila Maria Lopez
18+
19+
const REQUESTED_SCOPES = DEFAULT_SCOPES.split(' ');
20+
21+
function tick(label) { process.stdout.write(`\n \x1b[32m✓\x1b[0m ${label}`); }
22+
function fail(label) { process.stdout.write(`\n \x1b[31m✗\x1b[0m ${label}`); }
23+
function info(label) { process.stdout.write(`\n \x1b[36m·\x1b[0m ${label}`); }
24+
function section(title) { console.log(`\n\x1b[1m\x1b[34m── ${title} ──\x1b[0m`); }
25+
26+
async function run() {
27+
console.log('\n\x1b[1mTransTrack × Epic Sandbox Diagnostic\x1b[0m');
28+
console.log(` Client ID : ${CLIENT_ID}`);
29+
console.log(` Key file : ${KEY_FILE}`);
30+
31+
// ── 1. TOKEN EXCHANGE ──────────────────────────────────────────────────────
32+
section('1 · Token Exchange (SMART Backend Services)');
33+
let client;
34+
let tok;
35+
try {
36+
client = createEpicClientFromKeyFile({
37+
clientId: CLIENT_ID,
38+
privateKeyFile: KEY_FILE,
39+
});
40+
tok = await client.getAccessToken();
41+
tick(`Access token obtained (expires in ~${Math.round((tok.expiresAt - Date.now()) / 1000)}s)`);
42+
tick(`Token type: ${tok.tokenType}`);
43+
} catch (err) {
44+
fail(`Token exchange FAILED: ${err.message}`);
45+
process.exit(1);
46+
}
47+
48+
// ── 2. SCOPE CHECK ─────────────────────────────────────────────────────────
49+
section('2 · Scope Check');
50+
const grantedScopes = (tok.scope || '').split(/\s+/).filter(Boolean);
51+
info(`Scopes granted by Epic (${grantedScopes.length}):`);
52+
for (const s of grantedScopes) {
53+
process.stdout.write(`\n \x1b[32m${s}\x1b[0m`);
54+
}
55+
56+
console.log('\n');
57+
info(`Scopes requested by TransTrack code (${REQUESTED_SCOPES.length}):`);
58+
const missing = [];
59+
for (const s of REQUESTED_SCOPES) {
60+
if (grantedScopes.includes(s)) {
61+
process.stdout.write(`\n \x1b[32m✓ ${s}\x1b[0m`);
62+
} else {
63+
process.stdout.write(`\n \x1b[31m✗ ${s} ← NOT GRANTED\x1b[0m`);
64+
missing.push(s);
65+
}
66+
}
67+
console.log('\n');
68+
if (missing.length === 0) {
69+
tick('All requested scopes are granted');
70+
} else {
71+
fail(`${missing.length} requested scope(s) NOT granted — these resources will fail at import`);
72+
}
73+
74+
// ── 3. FHIR METADATA ───────────────────────────────────────────────────────
75+
section('3 · FHIR Server Metadata');
76+
try {
77+
const meta = await client.fhirGet('metadata');
78+
tick(`FHIR version : ${meta.fhirVersion}`);
79+
tick(`Software : ${meta.software?.name || 'unknown'} ${meta.software?.version || ''}`);
80+
const supportedResources = (meta.rest?.[0]?.resource || []).map(r => r.type);
81+
tick(`Resources supported: ${supportedResources.length}`);
82+
const ourResources = ['Patient','Observation','Condition','MedicationRequest','AllergyIntolerance'];
83+
for (const r of ourResources) {
84+
if (supportedResources.includes(r)) {
85+
process.stdout.write(`\n \x1b[32m✓ ${r}\x1b[0m`);
86+
} else {
87+
process.stdout.write(`\n \x1b[31m✗ ${r} ← not in CapabilityStatement\x1b[0m`);
88+
}
89+
}
90+
console.log('');
91+
} catch (err) {
92+
fail(`Metadata fetch failed: ${err.message}`);
93+
}
94+
95+
// ── 4. LIVE PATIENT BUNDLE FETCH ───────────────────────────────────────────
96+
section(`4 · Patient Bundle Fetch (Camila Lopez · ${TEST_PATIENT})`);
97+
let bundle;
98+
try {
99+
bundle = await client.fetchPatientBundle(TEST_PATIENT);
100+
tick(`Patient : ${bundle.patient?.name?.[0]?.given?.join(' ')} ${bundle.patient?.name?.[0]?.family}`);
101+
tick(`DOB : ${bundle.patient?.birthDate}`);
102+
tick(`Gender : ${bundle.patient?.gender}`);
103+
tick(`MRN : ${bundle.patient?.identifier?.find(i => i.type?.coding?.[0]?.code === 'MR')?.value || bundle.patient?.identifier?.[0]?.value || 'none'}`);
104+
console.log('');
105+
info(`Observations (labs) : ${bundle.observations.length}`);
106+
info(`Conditions (problem list) : ${bundle.conditions.length}`);
107+
info(`Medication requests : ${bundle.medicationRequests.length}`);
108+
info(`Allergy intolerances : ${bundle.allergies.length}`);
109+
info(`Scope granted : ${bundle.scopeGranted}`);
110+
} catch (err) {
111+
fail(`Patient bundle fetch FAILED: ${err.message}`);
112+
process.exit(1);
113+
}
114+
115+
// ── 5. NATIVE TABLE PREVIEW ────────────────────────────────────────────────
116+
section('5 · Native Table Materialisation Preview (dry run — no DB)');
117+
118+
// Observations → lab_results
119+
console.log('\n \x1b[1mObservations → lab_results\x1b[0m');
120+
let labCount = 0;
121+
for (const obs of bundle.observations.slice(0, 5)) {
122+
const coding = obs.code?.coding?.[0];
123+
const value = obs.valueQuantity
124+
? `${obs.valueQuantity.value} ${obs.valueQuantity.unit || ''}`.trim()
125+
: obs.valueString ?? obs.valueCodeableConcept?.text ?? null;
126+
if (coding && value != null) {
127+
info(`${coding.display || coding.code}${value} (${obs.effectiveDateTime?.substring(0,10) || '?'})`);
128+
labCount++;
129+
}
130+
}
131+
if (bundle.observations.length > 5) info(`… and ${bundle.observations.length - 5} more`);
132+
133+
// Conditions → patient_conditions
134+
console.log('\n \x1b[1mConditions → patient_conditions\x1b[0m');
135+
for (const c of bundle.conditions.slice(0, 5)) {
136+
const display = c.code?.coding?.[0]?.display || c.code?.text || 'unknown';
137+
const status = c.clinicalStatus?.coding?.[0]?.code || '?';
138+
info(`${display} [${status}]`);
139+
}
140+
if (bundle.conditions.length > 5) info(`… and ${bundle.conditions.length - 5} more`);
141+
142+
// MedicationRequests → patient_medications
143+
console.log('\n \x1b[1mMedicationRequests → patient_medications\x1b[0m');
144+
for (const m of bundle.medicationRequests.slice(0, 5)) {
145+
const name = m.medicationCodeableConcept?.coding?.[0]?.display || m.medicationCodeableConcept?.text || 'unknown';
146+
const status = m.status || '?';
147+
info(`${name} [${status}]`);
148+
}
149+
if (bundle.medicationRequests.length > 5) info(`… and ${bundle.medicationRequests.length - 5} more`);
150+
151+
// Allergies → patient_allergies
152+
console.log('\n \x1b[1mAllergyIntolerances → patient_allergies\x1b[0m');
153+
for (const a of bundle.allergies.slice(0, 5)) {
154+
const display = a.code?.coding?.[0]?.display || a.code?.text || 'unknown';
155+
const criticality = a.criticality || '?';
156+
info(`${display} [criticality: ${criticality}]`);
157+
}
158+
if (bundle.allergies.length > 5) info(`… and ${bundle.allergies.length - 5} more`);
159+
160+
// ── SUMMARY ────────────────────────────────────────────────────────────────
161+
section('Summary');
162+
tick('Token exchange OK');
163+
missing.length === 0 ? tick('All scopes granted OK') : fail(`Scopes missing: ${missing.join(', ')}`);
164+
tick(`Patient fetch OK (${bundle.observations.length} obs, ${bundle.conditions.length} cond, ${bundle.medicationRequests.length} meds, ${bundle.allergies.length} allergies)`);
165+
tick('Native table mapping Ready (run importPatientFromEpic to write to DB)');
166+
console.log('\n');
167+
}
168+
169+
run().catch(err => {
170+
console.error('\nFatal:', err.message);
171+
process.exit(1);
172+
});

server/src/integrations/epic/client.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
* Verified end-to-end against the Epic on FHIR Developer Sandbox
1212
* (https://fhir.epic.com) using the test patient "Camila Maria Lopez"
1313
* (Patient ID erXuFYUfucBZaryVksYEcMg3) with system-level scopes for
14-
* Patient, Observation, Condition, MedicationRequest, AllergyIntolerance,
15-
* Encounter, Immunization, Procedure, and Organization.
14+
* Patient, Observation, Condition, MedicationRequest, and AllergyIntolerance.
15+
*
16+
* Sandbox app (Non-Production Client ID): a8634931-c997-4516-90cd-21ec3a27813e
17+
* JWKS URI: https://gist.githubusercontent.com/NeuroKoder3/a2f2b23b69e49dd284b8147d6817bcaa/raw/jwks.json
18+
* Verified: Epic May 2026 — token exchange confirmed, all 5 core scopes granted.
1619
*/
1720

1821
const { createSign, randomUUID } = require('node:crypto');
@@ -24,9 +27,9 @@ const DEFAULT_FHIR_BASE =
2427
'https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4';
2528

2629
/**
27-
* Minimal default scope set known to be granted by the Epic non-production
28-
* sandbox for a "Backend Systems" application with all USCDI-core read
29-
* APIs enabled.
30+
* Full system-level scope set for the TransTrack Backend Services app.
31+
* All 9 scopes are registered in the Epic non-production sandbox app
32+
* (Client ID a8634931-c997-4516-90cd-21ec3a27813e).
3033
*/
3134
const DEFAULT_SCOPES = [
3235
'system/AllergyIntolerance.read',

0 commit comments

Comments
 (0)