Reference source · 0.1.0-dev.11
host.ts
src/host.ts · Download original file
import { Visibility } from './visibility.ts';
import { ReadPages } from './read-pages.ts';
import { SQLiteHostStorage,type HostStorage,type HostDatabase } from './host-storage.ts';
import type { Identity, Credential } from './identity.ts';
import { TrustStore } from './identity.ts';
import { seal, open } from './envelope.ts';
import type { Envelope } from './envelope.ts';
import { AuthorizationService, SCOPES } from './authorization.ts';
import type { AccessRequest, DecisionProvider, Representative, AuthorizationOptions } from './authorization.ts';
import { exact, id, integer, fingerprint, jsonBytes, newId, ProtocolError, requireThat } from './core.ts';
import { ChangeFeed } from './change-feed.ts';
import { signArchive, verifyArchive } from './archive.ts';
import type { Archive, ArchiveBody } from './archive.ts';
import { HostMigration } from './migration.ts';
import { ClinicalStore } from './clinical-store.ts';
import { RESOURCE_TYPES } from './resource-profile.ts';
import { Subscriptions } from './subscriptions.ts';
export class ReferenceHost {
pages: ReadPages; visibility: Visibility;
identity: Identity; credential: Credential; trust: TrustStore; db: HostDatabase; storage:HostStorage; authorization: AuthorizationService;
feed: ChangeFeed; migration: HostMigration; subscriptions: Subscriptions; clinical: ClinicalStore;
constructor(path: string|HostStorage, identity: Identity, credential: Credential, trust: TrustStore, policy: DecisionProvider, representatives: Representative[] = [], authorizationOptions: AuthorizationOptions = {}) {
this.identity = identity; this.credential = credential; this.trust = trust;
this.storage=typeof path==='string'?new SQLiteHostStorage(path):path;this.db=this.storage.open();
try{const prior=this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='host_storage_owner'").get();const populated=this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();requireThat(prior||!populated||this.storage.adoptLegacy,'host_storage_upgrade_required');this.db.exec('CREATE TABLE IF NOT EXISTS host_storage_owner (id INTEGER PRIMARY KEY CHECK(id=1), owner TEXT NOT NULL)');const owner=JSON.stringify([identity.public.subject,identity.public.signingKid,identity.public.encryptionKid]);this.db.prepare('INSERT INTO host_storage_owner VALUES (1,?) ON CONFLICT DO NOTHING').run(owner);requireThat(this.db.prepare('SELECT owner FROM host_storage_owner').get()?.owner===owner,'wrong_host_storage_owner');}catch(error){this.db.close();throw error;}
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS patients (id TEXT PRIMARY KEY, version INTEGER NOT NULL, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS labs (id TEXT PRIMARY KEY, patient TEXT NOT NULL, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS receipts (id TEXT PRIMARY KEY, digest TEXT NOT NULL, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS receipt_context (id TEXT PRIMARY KEY REFERENCES receipts(id), app TEXT NOT NULL, patient TEXT NOT NULL, operation_id TEXT NOT NULL, request TEXT NOT NULL, actor TEXT);
CREATE TABLE IF NOT EXISTS imported_archives (id TEXT PRIMARY KEY, patient TEXT NOT NULL, digest TEXT NOT NULL, document TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS audits (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER, app TEXT, actor TEXT, patient TEXT, operation TEXT, outcome TEXT);`);
this.authorization = new AuthorizationService(this.db, identity, trust, policy, representatives, 300, authorizationOptions);
this.feed = new ChangeFeed(this.db, this.authorization, identity.public.subject);
this.migration = new HostMigration(this);
this.clinical = new ClinicalStore(this); this.pages = new ReadPages(this);
this.subscriptions = new Subscriptions(this); this.visibility = new Visibility(this); this.feed.visibility=this.visibility;
}
seed(patient: string, resource: Record<string, any>): void {
id(patient);
this.authorization.transaction(() => {
this.migration.assertWritable(patient);
this.db.prepare('INSERT INTO patients VALUES (?, 1, ?) ON CONFLICT(id) DO NOTHING').run(patient, JSON.stringify({ ...resource, resourceType: 'Patient', id: patient }));
});
}
handle(envelope: Envelope, now: number): Envelope {
const request = open(envelope, this.identity, this.trust, now, undefined, null);
requireThat(['application','host'].includes(request.sender.kind), 'wrong_identity_kind');
this.db.prepare('DELETE FROM messages WHERE expires_at<=?').run(now);
const replayKey = fingerprint(jsonBytes([request.sender.subject, request.header.messageId]));
requireThat(!this.db.prepare('SELECT id FROM messages WHERE id=?').get(replayKey), 'replayed_message');
this.db.prepare('INSERT INTO messages VALUES (?, ?)').run(replayKey, request.header.expiresAt);
let response: Record<string, any>;
try { response = request.sender.kind === 'host' ? this.migration.dispatch(request.body, request.sender, now) : this.dispatch(request.body, request.sender, now, envelope.credential); }
catch (e) { response = { status: 'error', error: e instanceof ProtocolError ? e.code : 'invalid_request' }; }
return seal(response, this.identity, this.credential, request.sender, now, request.header.messageId);
}
dispatch(body: Record<string, any>, app: any, now: number, credential?: Credential, locked=false): Record<string, any> {
if(!locked&&['read_demographics','list_labs','export_record'].includes(body.operation))return this.authorization.transaction(()=>this.dispatch(body,app,now,credential,true));
const patient = body.patient ?? (body.operation === 'token' ? body.request?.patient : undefined);
if (typeof patient === 'string') this.migration.assertReadable(patient);
if (body.operation === 'rotate_requester') {
exact(body, ['operation', 'proof']); return this.authorization.rotate(body.proof, app, now);
}
this.authorization.requester(app);
if (body.operation === 'replace_token') {
exact(body, ['operation', 'grant_id']);
const grant = this.authorization.grant(body.grant_id);
if (grant.app === app.subject && grant.kid === app.signingKid) this.migration.assertReadable(grant.patient);
return this.authorization.replaceToken(body.grant_id, app, now);
}
if (body.operation === 'token') {
exact(body, ['operation', 'request']);
requireThat(body.request && typeof body.request === 'object');
const decision = this.authorization.request(body.request as AccessRequest, app, now);
this.audit(now, app.subject, undefined, body.request.patient, 'token', decision.status);
return decision;
}
if (body.operation === 'refresh') {
exact(body, ['operation', 'refresh_token', 'request_id']);
// A retired source must signal routing even after its access JWT expires. This keeps
// an outage queue pending rather than confusing migration retirement with consent denial.
if (typeof body.refresh_token === 'string' && body.refresh_token.length === 43) {
const row = this.db.prepare('SELECT grant_id FROM refreshes WHERE hash=?').get(fingerprint(Buffer.from(body.refresh_token))) as any;
if (row) {
const grant = this.authorization.grant(row.grant_id);
if (grant.app === app.subject && grant.kid === app.signingKid) this.migration.assertReadable(grant.patient);
}
}
return this.authorization.refresh(body.refresh_token, app, now, body.request_id);
}
if (body.operation === 'authorization_status') {
exact(body, ['operation', 'request_id']);
return this.authorization.status(app, body.request_id, now);
}
if (body.operation === 'cancel_authorization') {
exact(body, ['operation', 'request_id']); return this.authorization.cancel(app, body.request_id, now);
}
if (['read_resource','search_resources','create_resource','update_resource','delete_resource','resource_history'].includes(body.operation)) return this.clinical.dispatch(body, app, now);
if (body.operation === 'capabilities') { exact(body,['operation']); return {status:'ok',protocol:'0.1',fhir_version:'4.0.1',resources:RESOURCE_TYPES,interactions:['read','search','create','update','delete','history','vread'],subscriptions:true,pagination:{max_page_size:100,session_seconds:600},validation_profile:'oprp-r4-clinical-2',visibility_profile:'oprp-visibility-1',...(this.clinical.admission?{external_validation:this.clinical.admission.capabilities()}: {})}; }
if (body.operation === 'subscribe') { requireThat(credential, 'missing_credential'); return this.subscriptions.register(body, app, credential, now); }
if (body.operation === 'subscription_status' || body.operation === 'unsubscribe') return this.subscriptions.control(body, app, now);
const common = ['operation', 'access_token', 'patient'];
if (body.operation === 'export_record') {
exact(body, common); id(body.patient);
const claims = this.authorization.authorize(body.access_token, app, body.patient, SCOPES.recordExport, now);
const archive = this.snapshotArchive(body.patient, now);this.visibility.fullExport(claims.grant_id,archive);
this.audit(now, app.subject, claims.actor, body.patient, body.operation, 'ok');
return { status: 'ok', archive };
}
if (body.operation === 'sync_snapshot') {
exact(body, [...common,...(Object.hasOwn(body,'limit')?['limit']:[]),...(Object.hasOwn(body,'page_cursor')?['page_cursor']:[])]); id(body.patient);
const claims=this.authorization.authorize(body.access_token,app,body.patient,undefined,now);this.feed.visible(claims);
const page=this.pages.read(body,app,now,claims,{operation:'sync_snapshot'},()=>{const snapshot=this.feed.snapshot(body.access_token,app,body.patient,now,true);return {items:snapshot.resources,metadata:{cursor:snapshot.cursor,visibility_epoch:snapshot.visibility_epoch}};});
const {items,...metadata}=page;return {...metadata,resources:items};
}
if (body.operation === 'changes') {
exact(body, [...common, 'cursor', 'limit']); id(body.patient); return this.feed.read(body.access_token, app, body.patient, body.cursor, body.limit, now);
}
if (body.operation === 'read_demographics') {
exact(body, common); id(body.patient);
const c = this.authorization.authorize(body.access_token, app, body.patient, SCOPES.demographicsRead, now);
const row = this.db.prepare('SELECT * FROM patients WHERE id=?').get(body.patient) as any;
requireThat(row, 'not_found'); this.audit(now, app.subject, c.actor, body.patient, body.operation, 'ok');
const resource=this.visibility.project(c.grant_id,JSON.parse(row.body));requireThat(resource,'not_found');return { status: 'ok', version: row.version, resource };
}
if (body.operation === 'list_labs') {
exact(body, common); id(body.patient);
const c = this.authorization.authorize(body.access_token, app, body.patient, SCOPES.labsRead, now);
const rows = this.db.prepare('SELECT body FROM labs WHERE patient=? ORDER BY id').all(body.patient) as any[];
this.audit(now, app.subject, c.actor, body.patient, body.operation, 'ok');
const resources=rows.map(r=>this.visibility.project(c.grant_id,JSON.parse(r.body))).filter(Boolean);return { status:'ok',resource:{resourceType:'Bundle',type:'searchset',total:resources.length,entry:resources.map(resource=>({resource}))} };
}
if (body.operation === 'update_demographics' || body.operation === 'create_lab') {
exact(body, [...common, 'operation_id', 'resource', ...(body.operation === 'update_demographics' ? ['base_version'] : [])]);
id(body.patient); id(body.operation_id); requireThat(body.resource && typeof body.resource === 'object' && !Array.isArray(body.resource));
const c = this.authorization.authorize(body.access_token, app, body.patient, body.operation === 'create_lab' ? SCOPES.labsCreate : SCOPES.demographicsUpdate, now);
const logical = { operation: body.operation, patient: body.patient, resource: body.resource, base_version: body.base_version ?? null };
const digest = fingerprint(jsonBytes(logical));
const key = fingerprint(jsonBytes([app.subject, body.patient, body.operation_id]));
const visibleResult = (result: Record<string, any>) => {
const canRead = c.scope.includes(body.operation === 'update_demographics' ? SCOPES.demographicsRead : SCOPES.labsRead);
if (canRead) {const projected=this.visibility.project(c.grant_id,result.resource);requireThat(projected,'insufficient_visibility');return {...result,resource:projected};}
return { ...result, resource: body.operation === 'update_demographics'
? { resourceType: 'Patient', id: body.patient, name: [{ text: body.resource.name }] }
: { ...body.resource, id: result.resource.id } };
};
return this.authorization.transaction(() => {
// This fence is checked after acquiring the same write lock used by cutover.
this.migration.assertWritable(body.patient);
const current=body.operation==='update_demographics'?this.visibility.current(body.patient,'Patient',body.patient):body.resource;requireThat(current,'not_found');this.visibility.writable(c.grant_id,current);
const prior = this.db.prepare('SELECT * FROM receipts WHERE id=?').get(key) as any;
if (prior) { requireThat(prior.digest === digest, 'operation_id_conflict'); return visibleResult(JSON.parse(prior.body)); }
let result: Record<string, any>;
if (body.operation === 'update_demographics') {
integer(body.base_version);
exact(body.resource, ['name']);
requireThat(typeof body.resource.name === 'string' && body.resource.name.length >= 1 && body.resource.name.length <= 200);
const row = this.db.prepare('SELECT * FROM patients WHERE id=?').get(body.patient) as any;
requireThat(row, 'not_found'); requireThat(row.version === body.base_version, 'version_conflict');
const resource = JSON.parse(row.body);
resource.name = [{ text: body.resource.name }];
this.db.prepare('UPDATE patients SET body=?, version=version+1 WHERE id=?').run(JSON.stringify(resource), body.patient);
result = { status: 'ok', version: row.version + 1, resource };
this.feed.append(body.patient, resource, row.version + 1, 'updated');
} else {
exact(body.resource, ['resourceType', 'status', 'category', 'code', 'subject', 'valueQuantity']);
requireThat(body.resource.resourceType === 'Observation' && body.resource.status === 'final' && body.resource.subject?.reference === `Patient/${body.patient}`);
requireThat(body.resource.category?.length === 1 && body.resource.category[0]?.coding?.length === 1 && body.resource.category[0].coding[0].system === 'http://terminology.hl7.org/CodeSystem/observation-category' && body.resource.category[0].coding[0].code === 'laboratory', 'invalid_lab_category');
exact(body.resource.category[0], ['coding']); exact(body.resource.category[0].coding[0], ['system', 'code']);
exact(body.resource.subject, ['reference']); exact(body.resource.valueQuantity, ['value', 'unit']);
requireThat(typeof body.resource.valueQuantity.value === 'number' && Number.isFinite(body.resource.valueQuantity.value) && typeof body.resource.valueQuantity.unit === 'string' && body.resource.valueQuantity.unit.length <= 100);
requireThat(body.resource.code?.coding?.length === 1 && typeof body.resource.code.coding[0].code === 'string');
exact(body.resource.code, ['coding']); exact(body.resource.code.coding[0], ['system', 'code']);
requireThat(typeof body.resource.code.coding[0].system === 'string' && body.resource.code.coding[0].system.length <= 200 && body.resource.code.coding[0].code.length >= 1 && body.resource.code.coding[0].code.length <= 100);
requireThat(this.db.prepare('SELECT id FROM patients WHERE id=?').get(body.patient), 'not_found');
const labId = newId(), resource = { ...body.resource, id: labId };
this.db.prepare('INSERT INTO labs VALUES (?, ?, ?)').run(labId, body.patient, JSON.stringify(resource));
result = { status: 'ok', resource };
this.feed.append(body.patient, resource, 1, 'created');
}
this.db.prepare('INSERT INTO receipts VALUES (?, ?, ?)').run(key, digest, JSON.stringify(result));
this.db.prepare('INSERT INTO receipt_context VALUES (?, ?, ?, ?, ?, ?)').run(key, app.subject, body.patient, body.operation_id, JSON.stringify(logical), c.actor);
this.audit(now, app.subject, c.actor, body.patient, body.operation, 'ok');
return visibleResult(result);
});
}
throw new ProtocolError('unsupported_operation');
}
audit(now: number, app: string, actor: string | null | undefined, patient: string, operation: string, outcome: string): void {
this.db.prepare('INSERT INTO audits(at, app, actor, patient, operation, outcome) VALUES (?, ?, ?, ?, ?, ?)').run(now, app, actor ?? null, patient, operation, outcome);
}
// Host-owned export service; the wire dispatcher separately requires the export permission.
exportRecord(patient: string, now: number): Archive {
return this.authorization.transaction(() => { this.migration.assertReadable(patient); return this.snapshotArchive(patient, now); });
}
// Internal snapshot helper. Caller holds the transaction; migration can snapshot while fenced.
snapshotArchive(patient: string, now: number): Archive {
id(patient); this.trust.validate(this.credential, now, this.identity.public.subject, 'host');
// Old opaque receipts cannot be assigned to a patient safely. Fail rather than drop them.
requireThat(!this.db.prepare('SELECT r.id FROM receipts r LEFT JOIN receipt_context c ON c.id=r.id WHERE c.id IS NULL LIMIT 1').get(), 'legacy_receipt_context_missing');
const row = this.db.prepare('SELECT version,body FROM patients WHERE id=?').get(patient) as any; requireThat(row, 'not_found');
const resources = [{ resource: JSON.parse(row.body), version: row.version }, ...(this.db.prepare('SELECT body FROM labs WHERE patient=? ORDER BY id').all(patient) as any[]).map(lab => ({ resource: JSON.parse(lab.body), version: 1 }))].map(entry => ({ ...entry, digest: fingerprint(jsonBytes(entry)) }));
resources.push(...this.clinical.archive(patient));
const receipts = (this.db.prepare('SELECT r.id,r.digest,r.body,c.app,c.operation_id,c.request,c.actor FROM receipts r JOIN receipt_context c ON r.id=c.id WHERE c.patient=? ORDER BY r.id').all(patient) as any[]).map(receipt => ({ id: receipt.id, app: receipt.app, operation_id: receipt.operation_id, request: JSON.parse(receipt.request), digest: receipt.digest, result: JSON.parse(receipt.body), actor: receipt.actor }));
const retained_events = (this.db.prepare('SELECT * FROM change_events WHERE patient=? ORDER BY sequence').all(patient) as any[]).map(event => ({ ...event, resource: JSON.parse(event.resource) }));
const audits = this.db.prepare('SELECT * FROM audits WHERE patient=? ORDER BY id').all(patient) as any[];
const prior_archives = (this.db.prepare('SELECT document FROM imported_archives WHERE patient=? ORDER BY id').all(patient) as any[]).map(row => JSON.parse(row.document) as Archive);
const archive = signArchive({ v: '0.1', profile: resources.some(entry => RESOURCE_TYPES.includes(entry.resource.resourceType)) ? 'oprp-reference-r4-expanded' : 'oprp-reference-r4-patient-labs', archive_id: newId(), source: this.identity.public.subject, patient, created_at: now,
credential_digest: fingerprint(jsonBytes(this.credential)), resources, ...(this.clinical.history(patient).length ? { clinical_history: this.clinical.history(patient) } : {}), receipts, retained_events, audits, prior_archives,
...(this.migration.evidence(patient).length ? { handoffs: this.migration.evidence(patient) } : {}),
coverage: { complete_record: true, complete_history: false, retry_receipts: true } }, this.identity, this.credential, this.trust.statusEvidence(this.trust.validate(this.credential, now).issuer));
verifyArchive(archive, this.trust, now, patient); return archive;
}
// Trusted integration after authenticating the record owner. No unauthenticated import endpoint.
importRecord(archive: Archive, authenticatedPatient: string, now: number): Record<string, any> {
const body: ArchiveBody = verifyArchive(archive, this.trust, now, authenticatedPatient);
const digest = fingerprint(jsonBytes(archive));
return this.authorization.transaction(() => {
this.migration.assertWritable(body.patient);
const prior = this.db.prepare('SELECT * FROM imported_archives WHERE id=?').get(body.archive_id) as any;
if (prior) { requireThat(prior.digest === digest && prior.patient === body.patient, 'archive_id_conflict'); return { status: 'ok', imported: false, patient: body.patient, resources: body.resources.length, receipts: body.receipts.length }; }
this.insertArchive(archive, body, digest,now);
return { status: 'ok', imported: true, patient: body.patient, resources: body.resources.length, receipts: body.receipts.length };
});
}
// Only internal import/activation callers, while holding their database transaction.
insertArchive(archive: Archive, body: ArchiveBody, digest: string,now:number): void {
requireThat(!this.db.prepare('SELECT id FROM patients WHERE id=?').get(body.patient), 'destination_record_exists');
for (const entry of body.resources) {
if (entry.resource.resourceType === 'Patient') this.db.prepare('INSERT INTO patients VALUES (?, ?, ?)').run(body.patient, entry.version, JSON.stringify(entry.resource));
else if (entry.resource.resourceType === 'Observation') this.db.prepare('INSERT INTO labs VALUES (?, ?, ?)').run(entry.resource.id, body.patient, JSON.stringify(entry.resource));
}
this.clinical.import(body.resources.filter(entry => RESOURCE_TYPES.includes(entry.resource.resourceType)), body.clinical_history ?? [],now);
for (const receipt of body.receipts) {
this.db.prepare('INSERT INTO receipts VALUES (?, ?, ?)').run(receipt.id, receipt.digest, JSON.stringify(receipt.result));
this.db.prepare('INSERT INTO receipt_context VALUES (?, ?, ?, ?, ?, ?)').run(receipt.id, receipt.app, body.patient, receipt.operation_id, JSON.stringify(receipt.request), receipt.actor);
}
this.db.prepare('INSERT INTO imported_archives VALUES (?, ?, ?, ?)').run(body.archive_id, body.patient, digest, JSON.stringify(archive));
// Source event sequence numbers and audit IDs are retained as signed evidence, not reused as local IDs.
for (const entry of body.resources) this.feed.append(body.patient, entry.deleted ? {resourceType:entry.resource.resourceType,id:entry.resource.id} : entry.resource, entry.version, entry.deleted ? 'deleted' : 'created');
}
close(): void { this.feed.close(); this.db.close(); }
}