Files
extension/packages/epic/src/app.ts
T
Hanzo Dev 73370ce288 feat(epic): Hanzo AI for Epic (SMART on FHIR)
Read+assist clinical copilot embedded in the EHR via SMART on FHIR. Loads the
launched patient's chart over FHIR R4 (problems, meds, allergies, labs/vitals,
notes) and runs four actions — summarize patient, draft SOAP note, extract
problems & meds, ask — through the api.hanzo.ai gateway via @hanzo/ai@^0.2.0.

PHI posture: the FHIR/PHI access token is confined server-side (server.ts);
the browser holds only an opaque HttpOnly session cookie and a Hanzo gateway
key. No PHI is ever logged; SMART is auth-code + PKCE (S256) with the
confidential secret read from the environment; the proxy is scoped to the six
read resources and the session's own FHIR base (SSRF/cross-tenant guards).
Read + assist only — no clinical write-back, no *.write scope.

Tests: 109 vitest across config/smart-oauth/fhir-client/chart/hanzo/auth/server.
Workspace-wired like every sibling: test/ dir, root pnpm-lock importer, no
package-level lock.
2026-07-04 01:45:59 -07:00

344 lines
13 KiB
TypeScript

// The SMART app panel glue: the browser page that launches from Epic with a
// patient in context, loads that patient's chart through the server-side FHIR
// proxy (so no PHI token touches the browser), and drives the four Hanzo AI
// actions over the assembled chart. All the logic-heavy work — SMART/PKCE
// shaping, FHIR request wrappers, chart assembly, action prompts — lives in its
// own tested pure modules; this file is the one impure, browser-only entry point
// that binds them to the DOM.
//
// The launch dance (see README for the full flow):
// 1. EHR launch: Epic opens this page with `iss` + `launch`. Standalone: the
// page is opened with an `iss` (or the sandbox default) and no `launch`.
// 2. The page mints PKCE + state, asks the server (/oauth/prepare) to discover
// Epic's authorize/token endpoints, then redirects the browser to authorize.
// 3. Epic redirects back to /oauth/callback?code&state; the page posts them to
// the server, which exchanges (secret + PKCE server-side) and sets an opaque
// session cookie. The browser learns only the patient id + granted scope.
// 4. The page loads the chart via /fhir?path=… (proxied), assembles the
// context, and runs actions against api.hanzo.ai with the pasted Hanzo key.
//
// These build-time constants are stamped by build.js (the client id + redirect
// are PUBLIC; the secret is server-only and never bundled).
declare const SMART_CLIENT_ID: string;
declare const SMART_REDIRECT_URI: string;
declare const SMART_DEFAULT_ISS: string;
import {
createPkce,
authorizeUrl,
parseLaunch,
parseCallback,
} from './smart-oauth.js';
import {
getPatient,
searchConditions,
searchMedicationRequests,
searchAllergyIntolerances,
searchObservations,
searchDocumentReferences,
bundleEntries,
fetchAllPages,
type FhirResource,
type FhirRequest,
} from './fhir-client.js';
import { assembleChartContext, chartIsEmpty, emptyChart, type Chart } from './chart.js';
import { ACTIONS, isActionId, runAction, listModels } from './hanzo.js';
import { scopeString } from './config.js';
import { getApiKey, setApiKey, bearer, validateKey } from './auth.js';
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const STATE_KEY = 'hanzo.epic.oauthState';
const VERIFIER_KEY = 'hanzo.epic.pkceVerifier';
// The panel's live SMART context (non-PHI parts) once authenticated.
interface Session {
patient: string;
scope: string;
}
let controller: AbortController | null = null;
let session: Session | null = null;
let chartContext = '';
window.addEventListener('DOMContentLoaded', () => void boot());
// boot decides which phase of the launch we are in: a callback (we have a code),
// a launch (we have iss/launch — start authorize), or a cold open (show the
// standalone launcher). Then wires the UI.
async function boot(): Promise<void> {
wireApiKey();
wireActions();
void populateModels();
const search = window.location.search;
const cb = parseCallback(search);
const launch = parseLaunch(search);
if (cb.error) {
status(`Launch declined: ${cb.errorDescription || cb.error}`, 'error');
return;
}
if (cb.code) {
await completeCallback(cb.code, cb.state);
return;
}
if (launch.iss || SMART_DEFAULT_ISS) {
await beginAuthorize(launch.iss || SMART_DEFAULT_ISS, launch.launch);
return;
}
status('Open this app from Epic, or configure a FHIR server to launch standalone.', 'warn');
}
// beginAuthorize mints PKCE + state, asks the server to discover Epic's
// endpoints, stores the verifier/state, and redirects the browser to authorize.
async function beginAuthorize(iss: string, launchToken: string): Promise<void> {
status('Connecting to the EHR…');
const pkce = await createPkce();
const state = crypto.randomUUID();
sessionStorage.setItem(STATE_KEY, state);
sessionStorage.setItem(VERIFIER_KEY, pkce.verifier);
const prep = await postJson('/oauth/prepare', { iss, state, codeVerifier: pkce.verifier });
if (!prep.authorizationEndpoint) {
status('Could not discover the EHR OAuth endpoints.', 'error');
return;
}
window.location.href = authorizeUrl({
authorizationEndpoint: prep.authorizationEndpoint,
clientId: SMART_CLIENT_ID,
redirectUri: SMART_REDIRECT_URI,
aud: iss,
scope: scopeString(),
state,
codeChallenge: pkce.challenge,
launch: launchToken || undefined,
});
}
// completeCallback verifies state, posts code+state to the server (which does
// the secret+PKCE exchange and sets the session cookie), then loads the chart.
async function completeCallback(code: string, returnedState: string): Promise<void> {
const expected = sessionStorage.getItem(STATE_KEY) || '';
if (!expected || returnedState !== expected) {
status('OAuth state mismatch — refusing to continue.', 'error');
return;
}
sessionStorage.removeItem(STATE_KEY);
sessionStorage.removeItem(VERIFIER_KEY);
status('Completing sign-in…');
const out = await postJson('/oauth/callback', { code, state: returnedState });
if (out.error || !out.patient) {
status(`Sign-in failed: ${out.error || 'no patient context'}`, 'error');
return;
}
// Clean the code/state out of the URL bar (never leave an auth code in history).
window.history.replaceState({}, document.title, window.location.pathname);
session = { patient: out.patient, scope: out.scope || '' };
await loadChart();
}
// loadChart pulls the launched patient's chart through the proxy and assembles
// the clinical context. Every FHIR read goes through /fhir — the browser never
// holds the FHIR token. Reads run concurrently; each is independent.
async function loadChart(): Promise<void> {
if (!session) return;
status('Loading patient chart…');
const patientId = session.patient;
// The FHIR base/token live server-side; the request wrappers here shape a
// RELATIVE path the proxy resolves. token/base are placeholders the proxy
// replaces, so we pass empty strings and send only the resolved path.
const rel = (req: FhirRequest) => proxyPath(req.url);
const chart: Chart = emptyChart();
try {
const [patient, conditions, meds, allergies, obs, docs] = await Promise.all([
proxyGet(rel(getPatient('', '', patientId))),
proxyGetAll(searchConditions(sp(patientId))),
proxyGetAll(searchMedicationRequests(sp(patientId))),
proxyGetAll(searchAllergyIntolerances(sp(patientId))),
proxyGetAll(searchObservations(sp(patientId))),
proxyGetAll(searchDocumentReferences(sp(patientId))),
]);
chart.patient = (patient as FhirResource)?.resourceType === 'Patient' ? (patient as FhirResource) : undefined;
chart.conditions = conditions;
chart.medications = meds;
chart.allergies = allergies;
chart.observations = obs;
chart.documents = docs;
} catch (e: unknown) {
status(`Could not load the chart: ${(e as Error).message}`, 'error');
return;
}
chartContext = assembleChartContext(chart);
const label = chart.patient
? summarizeHeader(chart.patient)
: `Patient ${patientId}`;
$('patient-title').textContent = label;
status(chartIsEmpty(chart) ? 'Chart loaded (no clinical data recorded).' : 'Chart loaded.', 'ok');
setActionsEnabled(true);
}
// sp builds a patient-scoped SearchParams with empty base/token — the proxy
// supplies both. Only the relative path matters here.
function sp(patientId: string) {
return { base: '', token: '', patientId };
}
// proxyPath turns a FHIR request URL (which the wrappers built with an empty
// base, so it is like `/Condition?patient=…`) into the proxy query. The wrappers
// prepend the normalized base; with an empty base the URL is just the path.
function proxyPath(fhirUrl: string): string {
const path = fhirUrl.replace(/^\/+/, '');
return `/fhir?path=${encodeURIComponent(path)}`;
}
// proxyGet does one proxied FHIR read and returns the parsed resource/Bundle.
async function proxyGet(path: string): Promise<unknown> {
const resp = await fetch(path, { credentials: 'same-origin' });
if (!resp.ok) throw new Error(`FHIR proxy ${resp.status}`);
return resp.json();
}
// proxyGetAll pages a search to completion through the proxy, following the
// Bundle `next` links (rewritten as proxy paths). Returns the flattened
// resources. Uses fetchAllPages' Bundle logic via a proxy-backed fetch.
async function proxyGetAll(first: FhirRequest): Promise<FhirResource[]> {
const doFetch: typeof fetch = async (input) => {
const raw = typeof input === 'string' ? input : (input as Request).url;
// `raw` is either our first relative path or an absolute FHIR `next` URL;
// both go through the proxy by path.
return fetch(proxyPath(raw), { credentials: 'same-origin' }) as Promise<Response>;
};
return fetchAllPages({ ...first, url: first.url.replace(/^\/+/, '') }, '', { doFetch });
}
// summarizeHeader renders a short patient label for the panel header from a
// Patient resource (name only — the sensitive detail stays in the chart body).
function summarizeHeader(patient: FhirResource): string {
const p = patient as { name?: Array<{ text?: string; given?: string[]; family?: string }> };
const n = Array.isArray(p.name) ? p.name[0] : undefined;
if (n?.text) return n.text;
const given = Array.isArray(n?.given) ? n!.given!.join(' ') : '';
return `${given} ${n?.family ?? ''}`.trim() || 'Patient';
}
// ── Actions ────────────────────────────────────────────────────────────────
function wireActions(): void {
const chipRow = $('chips');
for (const a of ACTIONS) {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = a.label;
b.dataset.action = a.id;
b.disabled = true;
b.onclick = () => void run(a.id);
chipRow.appendChild(b);
}
$<HTMLButtonElement>('stop').onclick = () => controller?.abort();
}
function setActionsEnabled(on: boolean): void {
for (const el of document.querySelectorAll<HTMLButtonElement>('.chip')) el.disabled = !on;
}
// run executes one action over the assembled chart context. Requires a loaded
// chart; a prompt-taking action reads the detail box.
async function run(id: string): Promise<void> {
if (!isActionId(id)) return;
if (!chartContext) {
status('Load a patient chart first (launch from Epic).', 'warn');
return;
}
const def = ACTIONS.find((a) => a.id === id)!;
const detail = $<HTMLTextAreaElement>('prompt').value.trim();
if (def.needsPrompt && id === 'ask' && !detail) {
status('Type a question about this patient.', 'warn');
return;
}
controller?.abort();
controller = new AbortController();
const outputEl = $<HTMLTextAreaElement>('output');
outputEl.value = '';
status(`Running “${def.label}”…`);
$<HTMLButtonElement>('stop').disabled = false;
setActionsEnabled(false);
const model = $<HTMLSelectElement>('model').value || undefined;
try {
const text = await runAction({
id,
detail,
chartContext,
opts: { token: bearer(), model, signal: controller.signal },
});
outputEl.value = text;
status('Done. Review before use — decision support, not a diagnosis.', 'ok');
} catch (e: unknown) {
if ((e as Error).name === 'AbortError') status('Stopped.', 'warn');
else status(`Error: ${(e as Error).message}`, 'error');
} finally {
$<HTMLButtonElement>('stop').disabled = true;
setActionsEnabled(true);
}
}
// ── Hanzo key + models ─────────────────────────────────────────────────────
function wireApiKey(): void {
const apiKeyEl = $<HTMLInputElement>('apikey');
apiKeyEl.value = getApiKey();
$<HTMLButtonElement>('savekey').onclick = async () => {
const key = apiKeyEl.value.trim();
try {
const models = await validateKey(key);
setApiKey(key);
fillModels(models);
status('Hanzo API key saved.', 'ok');
} catch (e: unknown) {
status(`Key rejected: ${(e as Error).message}`, 'error');
}
};
}
async function populateModels(): Promise<void> {
try {
fillModels(await listModels({ token: bearer() }));
} catch {
/* anonymous / offline — the picker stays with its default option */
}
}
function fillModels(ids: string[]): void {
const sel = $<HTMLSelectElement>('model');
const current = sel.value;
sel.innerHTML = '';
for (const id of ids) {
const o = document.createElement('option');
o.value = id;
o.textContent = id;
sel.appendChild(o);
}
if (current && ids.includes(current)) sel.value = current;
}
// ── Small helpers ──────────────────────────────────────────────────────────
async function postJson(path: string, body: unknown): Promise<any> {
const resp = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
return resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
}
function status(msg: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
const el = $('status');
el.textContent = msg;
el.className = `status${kind ? ` ${kind}` : ''}`;
}
// Re-export a couple of pure helpers the panel uses so the module boundary is
// explicit (and so a smoke test can import this file under a DOM shim if needed).
export { bundleEntries };