Skip to content

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(); }
}