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