Skip to content

Reference source · 0.1.0-dev.11

external-fhir.ts

src/external-fhir.ts · Download original file

import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFileSync,writeFileSync,mkdirSync,mkdtempSync,rmSync,statSync } from 'node:fs';
import { resolve,join } from 'node:path';
import { fingerprint,jsonBytes,requireThat,integer,ProtocolError } from './core.ts';
import { validateClinical,resourceType,type ResourceType } from './resource-profile.ts';
import { subset } from './visibility.ts';
import { verifyDependencies,type DependencyLock } from './validator-lock.ts';
import { LocalTerminology } from './local-terminology.ts';
export type ValidationTarget='oprp'|'oprp-us-core';
export type ValidationIssue={severity:'fatal'|'error'|'warning'|'information';code:string;details?:{text?:string};diagnostics?:string;expression?:string[]};
export type ClinicalAssessment={accepted:boolean;digest:string;target:ValidationTarget;profiles:string[];engine:string;terminology:'disabled'|'local';issues:ValidationIssue[]};
export interface ClinicalAdmission {check(resource:Record<string,any>,patient:string,now:number):void;prepareArchive?(resources:Record<string,any>[],now:number):void;capabilities():Record<string,any>}
export type ValidatorOptions={jar:string;workspace:string;profiles:string;usCorePackage?:string;java?:string;timeoutMs?:number;terminology?:string};
// Only Host-generated revision metadata is excluded. Ownership, IDs, labels and every clinical field remain bound.
export function validationDigest(resource:Record<string,any>):string {const value=structuredClone(resource);if(value.meta){delete value.meta.versionId;delete value.meta.lastUpdated;if(!Object.keys(value.meta).length)delete value.meta;}return fingerprint(jsonBytes(value));}
export function outcomeIssues(outcome:any):ValidationIssue[]{
 requireThat(outcome&&outcome.resourceType==='OperationOutcome'&&Array.isArray(outcome.issue),'invalid_validation_report');
 for(const issue of outcome.issue)requireThat(issue&&['fatal','error','warning','information'].includes(issue.severity)&&typeof issue.code==='string'&&issue.code.length>0,'invalid_validation_report');return outcome.issue;
}
export class ExternalFhirValidator {
 options:ValidatorOptions;manifest:any;lock:any;engine:string;profileFiles:Map<string,string>;dependencies:DependencyLock;terminology?:LocalTerminology;readonly binding:string;
 constructor(options:ValidatorOptions){this.options=options;this.manifest=JSON.parse(readFileSync(join(options.profiles,'manifest.json'),'utf8'));this.lock=JSON.parse(readFileSync(join(options.profiles,'toolchain-lock.json'),'utf8'));this.engine=`hl7-validator-${this.lock.validator.version}`;
  requireThat(createHash('sha256').update(readFileSync(options.jar)).digest('hex')===this.lock.validator.sha256,'validator_integrity_failed');
  this.profileFiles=new Map();for(const type of Object.keys(this.manifest.profiles)){const path=join(options.profiles,'definitions',`StructureDefinition-oprp-${type}.json`);const sd=JSON.parse(readFileSync(path,'utf8'));requireThat(sd.url===this.manifest.profiles[type]&&sd.version===this.manifest.version&&sd.type===type,'invalid_profile_manifest');this.profileFiles.set(type,createHash('sha256').update(readFileSync(path)).digest('hex'));}
  if(options.usCorePackage)requireThat(createHash('sha256').update(readFileSync(options.usCorePackage)).digest('hex')===this.lock.us_core.sha256,'validator_package_integrity_failed');mkdirSync(options.workspace,{recursive:true});
  this.dependencies=JSON.parse(readFileSync(join(options.profiles,'dependency-lock.json'),'utf8'));verifyDependencies(resolve(options.workspace,'home/.fhir/packages'),this.dependencies);
  if(options.terminology)this.terminology=new LocalTerminology(options.terminology);
  this.binding=fingerprint(jsonBytes({manifest:this.manifest,toolchain:this.lock,dependencies:this.dependencies,profiles:[...this.profileFiles],terminology:this.terminology?.binding??'disabled'}));
 }
 profile(type:ResourceType,target:ValidationTarget):string[]{const result=[this.manifest.profiles[type]];requireThat(result[0],'unsupported_validation_target');if(target==='oprp-us-core'){requireThat(this.options.usCorePackage&&this.manifest.us_core.profiles[type],'unsupported_us_core_resource');result.push(this.manifest.us_core.profiles[type]);}return result;}
 async validate(resource:Record<string,any>,target:ValidationTarget='oprp'):Promise<ClinicalAssessment>{return (await this.validateMany([resource],target))[0]!;}
 async validateMany(resources:Record<string,any>[],target:ValidationTarget='oprp'):Promise<ClinicalAssessment[]>{
  resources=structuredClone(resources);
  verifyDependencies(resolve(this.options.workspace,'home/.fhir/packages'),this.dependencies);this.terminology?.verify();
  requireThat(['oprp','oprp-us-core'].includes(target)&&resources.length>0&&resources.length<=32,'invalid_validation_batch');
  requireThat(createHash('sha256').update(readFileSync(this.options.jar)).digest('hex')===this.lock.validator.sha256,'validator_integrity_failed');
  for(const [type,digest]of this.profileFiles)requireThat(createHash('sha256').update(readFileSync(join(this.options.profiles,'definitions',`StructureDefinition-oprp-${type}.json`))).digest('hex')===digest,'validation_profiles_changed');
  if(target==='oprp-us-core')requireThat(createHash('sha256').update(readFileSync(this.options.usCorePackage!)).digest('hex')===this.lock.us_core.sha256,'validator_package_integrity_failed');
  const selected=resources.map(r=>{resourceType(r.resourceType);requireThat(!subset(r),'incomplete_resource');return this.profile(r.resourceType,target);});
  const directory=mkdtempSync(join(resolve(this.options.workspace),'job-'));const result=join(directory,'outcome.json');
  try{
   const paths=resources.map((resource,index)=>{const value=structuredClone(resource);value.meta={...value.meta,profile:[...new Set([...(value.meta?.profile??[]),...selected[index]!])]};const data=jsonBytes(value);requireThat(data.length<=1048576,'resource_too_large');const path=join(directory,`${index}.json`);writeFileSync(path,data,{mode:0o600});return path;});
   const args=[`-Duser.home=${resolve(this.options.workspace,'home')}`,'-Xmx2g','-jar',resolve(this.options.jar),...paths,'-version','4.0.1','-tx','n/a','-no-http-access','-txCache',resolve(this.options.workspace,'tx-cache'),'-ig',resolve(this.options.profiles,'definitions'),'-disable-default-resource-fetcher','-validation-timeout','60000','-output',result];
   if(target==='oprp-us-core')args.push('-ig',resolve(this.options.usCorePackage!));
   const exitCode=await this.run(args);
   requireThat(statSync(result).size<=1048576,'invalid_validation_report');const output=JSON.parse(readFileSync(result,'utf8'));const outcomes=output.resourceType==='OperationOutcome'?[output]:output.resourceType==='Bundle'&&Array.isArray(output.entry)?output.entry.map((e:any)=>e.resource):[];
   if(exitCode!==0)requireThat(outcomes.some((o:any)=>outcomeIssues(o).some(i=>['fatal','error'].includes(i.severity))),'validator_failed');
   requireThat(outcomes.length===resources.length,'invalid_validation_report');const byFile=new Map<string,any>();for(const outcome of outcomes){outcomeIssues(outcome);const files=outcome.extension?.filter((e:any)=>e.url==='http://hl7.org/fhir/StructureDefinition/operationoutcome-file');requireThat(files?.length===1&&typeof files[0].valueString==='string'&&!byFile.has(files[0].valueString),'invalid_validation_report');byFile.set(files[0].valueString,outcome);}
   return resources.map((resource,index)=>{const outcome=byFile.get(paths[index]!);const issues=[...outcomeIssues(outcome),...(this.terminology?.check(resource)??[])];return {accepted:!issues.some(i=>['fatal','error'].includes(i.severity)),digest:validationDigest(resource),target,profiles:selected[index]!,engine:this.engine,terminology:this.terminology?'local':'disabled',issues};});
  }finally{rmSync(directory,{recursive:true,force:true});verifyDependencies(resolve(this.options.workspace,'home/.fhir/packages'),this.dependencies);this.terminology?.verify();}
 }
 private run(args:string[]):Promise<number>{return new Promise((success,failure)=>{const child=spawn(this.options.java??'java',args,{stdio:['ignore','pipe','pipe']});let bytes=0,timed=false,overflow=false;const timer=setTimeout(()=>{timed=true;child.kill('SIGKILL');},this.options.timeoutMs??180000);for(const stream of [child.stdout,child.stderr])stream.on('data',(chunk:Buffer)=>{bytes+=chunk.length;if(bytes>1048576){overflow=true;child.kill('SIGKILL');}});child.on('error',()=>{clearTimeout(timer);failure(new ProtocolError('validator_unavailable'));});child.on('close',code=>{clearTimeout(timer);if(timed||overflow||![0,1].includes(code??-1))failure(new ProtocolError(timed?'validator_timeout':overflow?'validator_output_too_large':'validator_failed'));else success(code!);});});}
}
// Trusted Host-owned preflight cache. No client-submitted report can authorize a write.
export class ValidatedClinicalAdmission implements ClinicalAdmission {
 readonly validator:ExternalFhirValidator;readonly target:ValidationTarget;readonly lifetime:number;private approved=new Map<string,number>();
 constructor(validator:ExternalFhirValidator,target:ValidationTarget='oprp',lifetime=300){integer(lifetime);requireThat(lifetime>0&&lifetime<=3600,'invalid_validation_lifetime');this.validator=validator;this.target=target;this.lifetime=lifetime;}
 async assess(resources:Record<string,any>[],patient:string,now:number):Promise<ClinicalAssessment[]>{integer(now);for(const r of resources)validateClinical(r,patient);const reports=await this.validator.validateMany(resources,this.target);for(const report of reports)if(report.accepted){if(this.approved.size>=256)this.approved.delete(this.approved.keys().next().value!);this.approved.set(report.digest,now+this.lifetime);}return reports;}
 check(resource:Record<string,any>,patient:string,now:number):void{validateClinical(resource,patient);requireThat(!subset(resource),'incomplete_resource');requireThat(now<(this.approved.get(validationDigest(resource))??0),'external_validation_required');}
 capabilities(){return {engine:this.validator.engine,target:this.target,fhir_version:'4.0.1',package_version:this.validator.manifest.version,us_core_version:this.target==='oprp-us-core'?this.validator.manifest.us_core.version:null,terminology:this.validator.terminology?'local':'disabled',preflight_lifetime_seconds:this.lifetime};}
}