Skip to content

Reference source · 0.1.0-dev.11

sdk.ts

src/sdk.ts · Download original file

import { request as httpsRequest } from 'node:https';
import type { Identity, Credential, CredentialBody } from './identity.ts';
import { TrustStore } from './identity.ts';
import { open, seal } from './envelope.ts';
import type { Envelope } from './envelope.ts';
import type { AccessRequest, Decision } from './authorization.ts';
import type { Snapshot, ChangePage } from './change-feed.ts';
import type { RotationProof } from './rotation.ts';
import type { Archive } from './archive.ts';
import type { ResourceType } from './resource-profile.ts';
import type { SubscriptionView, Topic } from './subscriptions.ts';
import { DirectoryTrust } from './discovery.ts';
import type { LocatedHost } from './discovery.ts';
import { newId, seconds, requireThat, ProtocolError, parseObject, unb64 } from './core.ts';

export type Transport = (envelope: Envelope) => Promise<Envelope>;
export type ClientLocation = { address: string; patient: string; generation: number };
export type ClientState = { v: '0.1'; application: string; signingKid: string; host: string; location?: ClientLocation; authorization?: Extract<Decision, { status: 'granted' }>; pendingRefresh?: { token: string; requestId: string } };
export function httpsTransport(endpoint: string, ca?: string): Transport {
  const url = new URL(endpoint); requireThat(url.protocol === 'https:', 'https_required');
  return envelope => new Promise((resolve, reject) => {
    const payload = Buffer.from(JSON.stringify(envelope));
    const request = httpsRequest(url, { method: 'POST', ca, timeout: 10000,
      headers: { 'content-type': 'application/oprp+json', 'content-length': payload.length } }, response => {
      const chunks: Buffer[] = []; let length = 0;
      response.on('data', (chunk: Buffer) => {
        length += chunk.length;
        if (length > 1600000) { response.destroy(new ProtocolError('message_too_large')); return; }
        chunks.push(chunk);
      });
      response.on('error', reject);
      response.on('end', () => {
        try { requireThat(response.statusCode === 200 && response.headers['content-type']?.split(';')[0] === 'application/oprp+json', 'transport_error'); resolve(parseObject(Buffer.concat(chunks)) as Envelope); }
        catch (e) { reject(e); }
      });
    });
    request.on('timeout', () => request.destroy(new ProtocolError('transport_timeout')));
    request.on('error', reject); request.end(payload);
  });
}
export class Client {
  identity: Identity; credential: Credential; hostCredential: Credential; hostId: string; trust: TrustStore; transport: Transport; clock: () => number;
  authorization?: Extract<Decision, {status: 'granted'}>;
  refreshPromise?: Promise<Extract<Decision, {status: 'granted'}>>;
  pendingRefresh?: { token: string; requestId: string };
  location?: ClientLocation;
  persistState?: (state: ClientState) => Promise<void>;
  constructor(options: { identity: Identity; credential: Credential; hostCredential: Credential; hostId: string; trust: TrustStore; transport: Transport; clock?: () => number; persistState?: (state: ClientState) => Promise<void> }) {
    this.identity = options.identity; this.credential = options.credential; this.hostCredential = options.hostCredential;
    this.hostId = options.hostId; this.trust = options.trust; this.transport = options.transport; this.clock = options.clock ?? seconds;
    this.persistState = options.persistState;
  }
  exportState(): ClientState { return structuredClone({ v: '0.1', application: this.identity.public.subject, signingKid: this.identity.public.signingKid, host: this.hostId, location: this.location, authorization: this.authorization, pendingRefresh: this.pendingRefresh }); }
  restoreState(state: ClientState): void {
    requireThat(!this.refreshPromise && state.v === '0.1' && state.application === this.identity.public.subject && state.signingKid === this.identity.public.signingKid && state.host === this.hostId, 'wrong_client_state');
    this.authorization = structuredClone(state.authorization); this.pendingRefresh = structuredClone(state.pendingRefresh);
    this.location = structuredClone(state.location);
  }
  async followDiscovery(document: Record<string, any>, address: string, directory: DirectoryTrust, transport: Transport, expectedPatient: string, minimumGeneration = 1): Promise<LocatedHost> {
    requireThat(!this.refreshPromise, 'client_busy');
    const remembered = this.location?.address === address || this.location?.address === `acct:${address}` ? this.location.generation : 1;
    const located = directory.validate(document, address, this.clock(), Math.max(minimumGeneration, remembered));
    requireThat(located.record_id === expectedPatient, 'wrong_discovery_patient');
    this.trust.validate(located.credential, this.clock(), located.host, 'host');
    // Previous metadata is only used to decide whether to clear secrets. An expired/unavailable
    // old Host must not prevent following a freshly verified new binding.
    const previous = parseObject(unb64(this.hostCredential.payload));
    if (located.host !== this.hostId || located.identity.signingKid !== previous.signingKid || located.identity.encryptionKid !== previous.encryptionKid || (this.location && this.location.patient !== located.record_id)) {
      this.authorization = undefined; this.pendingRefresh = undefined;
    }
    this.hostId = located.host; this.hostCredential = located.credential; this.transport = transport;
    this.location = { address: located.address, patient: located.record_id, generation: located.generation };
    await this.saveState(); return located;
  }
  async saveState(): Promise<void> { await this.persistState?.(this.exportState()); }
  host(): CredentialBody { return this.trust.validate(this.hostCredential, this.clock(), this.hostId, 'host'); }
  async exchange(body: Record<string, any>): Promise<Record<string, any>> {
    const envelope = seal(body, this.identity, this.credential, this.host(), this.clock());
    const header = parseObject(Buffer.from(envelope.protected, 'base64url'));
    const opened = open(await this.transport(envelope), this.identity, this.trust, this.clock(), this.hostId, header.messageId);
    requireThat(opened.sender.signingKid === this.host().signingKid, 'wrong_host_key');
    if (opened.body.status === 'error') throw new ProtocolError(opened.body.error);
    return opened.body;
  }
  async requestAccess(request: Omit<AccessRequest, 'request_id'> & { request_id?: string }): Promise<Decision> {
    const result = await this.exchange({ operation: 'token', request: { ...request, request_id: request.request_id ?? newId() } }) as Decision;
    if (result.status === 'granted') { this.authorization = result; this.pendingRefresh = undefined; await this.saveState(); }
    return result;
  }
  async approvalStatus(requestId: string): Promise<Decision> {
    const result = await this.exchange({ operation: 'authorization_status', request_id: requestId }) as Decision;
    if (result.status === 'granted') { this.authorization = result; this.pendingRefresh = undefined; await this.saveState(); }
    return result;
  }
  async cancelAuthorization(requestId: string): Promise<Decision> {
    return await this.exchange({ operation: 'cancel_authorization', request_id: requestId }) as Decision;
  }
  async rotateRequester(proof: RotationProof): Promise<Record<string, any>> { return this.exchange({ operation: 'rotate_requester', proof }); }
  async replaceToken(grantId: string): Promise<Decision> {
    const result = await this.exchange({ operation: 'replace_token', grant_id: grantId }) as Decision;
    if (result.status === 'granted') { this.authorization = result; this.pendingRefresh = undefined; await this.saveState(); }
    return result;
  }
  async accessToken(): Promise<string> {
    requireThat(this.authorization, 'no_authorization');
    requireThat(this.clock() < this.authorization.authorized_until, 'authorization_expired');
    if (this.clock() < this.authorization.token_expires_at) return this.authorization.access_token;
    requireThat(this.authorization.refresh_token, 'token_replacement_required');
    // One refresh in flight per SDK instance. Across-instance race recovery is a separate profile requirement.
    if (!this.refreshPromise) {
      this.pendingRefresh ??= { token: this.authorization.refresh_token, requestId: newId() };
      const pending = this.pendingRefresh;
      this.refreshPromise = this.saveState().then(() => this.exchange({ operation: 'refresh', refresh_token: pending.token, request_id: pending.requestId })).then(async result => {
        requireThat(result.status === 'granted', 'refresh_denied');
        this.authorization = result as Extract<Decision, {status: 'granted'}>;
        this.pendingRefresh = undefined; await this.saveState();
        return this.authorization;
      }).finally(() => { this.refreshPromise = undefined; });
    }
    return (await this.refreshPromise).access_token;
  }
  async capabilities(): Promise<Record<string, any>> { return this.exchange({operation:'capabilities'}); }
  async readResource(patient: string, type: ResourceType, resourceId: string, version: number | null = null): Promise<Record<string, any>> {
    return this.exchange({operation:'read_resource',patient,resource_type:type,resource_id:resourceId,version,access_token:await this.accessToken()});
  }
  async searchResources(patient: string, type: ResourceType, limit=100, pageCursor:string|null=null): Promise<Record<string, any>> {
    return this.exchange({operation:'search_resources',patient,resource_type:type,limit,page_cursor:pageCursor,access_token:await this.accessToken()});
  }
  async resourceHistory(patient: string, type: ResourceType, resourceId: string, limit=100, pageCursor:string|null=null): Promise<Record<string, any>> {
    return this.exchange({operation:'resource_history',patient,resource_type:type,resource_id:resourceId,limit,page_cursor:pageCursor,access_token:await this.accessToken()});
  }
  async createResource(patient: string, resource: Record<string, any>, operationId: string): Promise<Record<string, any>> {
    return this.exchange({operation:'create_resource',patient,resource_type:resource.resourceType,resource,operation_id:operationId,access_token:await this.accessToken()});
  }
  async updateResource(patient: string, resource: Record<string, any>, baseVersion: number, operationId: string): Promise<Record<string, any>> {
    return this.exchange({operation:'update_resource',patient,resource_type:resource.resourceType,resource_id:resource.id,resource,base_version:baseVersion,operation_id:operationId,access_token:await this.accessToken()});
  }
  async deleteResource(patient: string, type: ResourceType, resourceId: string, baseVersion: number, operationId: string): Promise<Record<string, any>> {
    return this.exchange({operation:'delete_resource',patient,resource_type:type,resource_id:resourceId,base_version:baseVersion,operation_id:operationId,access_token:await this.accessToken()});
  }
  async subscribe(patient: string, callback: string, topics: Topic[], expiresAt: number, subscriptionId: string): Promise<SubscriptionView> {
    return await this.exchange({ operation: 'subscribe', patient, access_token: await this.accessToken(), subscription_id: subscriptionId, callback, topics, expires_at: expiresAt }) as SubscriptionView;
  }
  async subscriptionStatus(patient: string, subscriptionId: string): Promise<SubscriptionView> {
    return await this.exchange({ operation: 'subscription_status', patient, access_token: await this.accessToken(), subscription_id: subscriptionId }) as SubscriptionView;
  }
  async unsubscribe(patient: string, subscriptionId: string): Promise<SubscriptionView> {
    return await this.exchange({ operation: 'unsubscribe', patient, access_token: await this.accessToken(), subscription_id: subscriptionId }) as SubscriptionView;
  }
  async readDemographics(patient: string): Promise<Record<string, any>> {
    return this.exchange({ operation: 'read_demographics', patient, access_token: await this.accessToken() });
  }
  async listLabs(patient: string): Promise<Record<string, any>> {
    return this.exchange({ operation: 'list_labs', patient, access_token: await this.accessToken() });
  }
  async syncSnapshot(patient: string, limit=100, pageCursor:string|null=null): Promise<Snapshot> {
    return await this.exchange({ operation: 'sync_snapshot', patient, limit, page_cursor:pageCursor, access_token: await this.accessToken() }) as Snapshot;
  }
  async exportRecord(patient: string): Promise<Archive> {
    return (await this.exchange({ operation: 'export_record', patient, access_token: await this.accessToken() })).archive as Archive;
  }
  async changes(patient: string, cursor: string, limit = 50): Promise<ChangePage> {
    return await this.exchange({ operation: 'changes', patient, access_token: await this.accessToken(), cursor, limit }) as ChangePage;
  }
  async updateDemographics(patient: string, name: string, baseVersion: number, operationId = newId()): Promise<Record<string, any>> {
    return this.exchange({ operation: 'update_demographics', patient, operation_id: operationId, resource: { name }, base_version: baseVersion, access_token: await this.accessToken() });
  }
  async createLab(patient: string, resource: Record<string, any>, operationId = newId()): Promise<Record<string, any>> {
    return this.exchange({ operation: 'create_lab', patient, operation_id: operationId, resource, access_token: await this.accessToken() });
  }
}