import type { CapturedEventEvidence } from "./event-evidence-capture.js"; interface EventEvidenceUploaderConfig { sessionId: string; ingestUrl: string; appId?: string; fingerprintId?: string; maxRetries?: number; } export class EventEvidenceUploader { private readonly baseUrl: string; private readonly maxRetries: number; constructor(private readonly config: EventEvidenceUploaderConfig) { this.baseUrl = new URL(config.ingestUrl).origin; this.maxRetries = config.maxRetries ?? 3; } async uploadAvailable(input: { eventId: string; eventTimestamp: number; evidence: CapturedEventEvidence; }): Promise { const headers: Record = { "content-type": "image/jpeg", "x-evidence-source": input.evidence.source, "x-sync-quality": input.evidence.syncQuality, "x-captured-at": String(input.evidence.capturedAt), "x-event-timestamp": String(input.eventTimestamp), "x-evidence-width": String(input.evidence.width), "x-evidence-height": String(input.evidence.height), ...this.identityHeaders(), }; await this.request( `/event-evidence/sessions/${encodeURIComponent(this.config.sessionId)}/events/${encodeURIComponent(input.eventId)}/image`, { headers, body: input.evidence.blob }, ); } async uploadUnavailable(input: { eventId: string; eventTimestamp: number; source: "screen-share"; reason: string; }): Promise { await this.request( `/event-evidence/sessions/${encodeURIComponent(this.config.sessionId)}/events/${encodeURIComponent(input.eventId)}/unavailable`, { headers: { "content-type": "application/json", ...this.identityHeaders(), }, body: JSON.stringify({ source: input.source, capturedAt: Date.now(), eventTimestamp: input.eventTimestamp, reason: input.reason, }), }, ); } private identityHeaders(): Record { return { ...(this.config.appId ? { "x-app-id": this.config.appId } : {}), ...(this.config.fingerprintId ? { "x-fingerprint-id": this.config.fingerprintId } : {}), }; } private async request( path: string, init: { headers: Record; body: BodyInit }, ): Promise { let lastError: Error | null = null; let lastStatus: number | null = null; for (let attempt = 0; attempt <= this.maxRetries; attempt++) { try { const response = await fetch(`${this.baseUrl}${path}`, { method: "POST", headers: init.headers, body: init.body, }); if (response.ok) return; lastStatus = response.status; if (response.status < 500) { throw new EventEvidenceUploadError( `event evidence rejected (${response.status})`, { attempts: attempt + 1, status: response.status, retryable: false, }, ); } lastError = new Error(`event evidence upload failed (${response.status})`); } catch (error) { if (error instanceof EventEvidenceUploadError) throw error; lastError = error instanceof Error ? error : new Error(String(error)); } if (attempt < this.maxRetries) { await new Promise((resolve) => setTimeout(resolve, Math.min(250 * 2 ** attempt, 2_000)), ); } } throw new EventEvidenceUploadError("event evidence upload failed", { attempts: this.maxRetries + 1, status: lastStatus, retryable: true, cause: lastError ?? undefined, }); } } export class EventEvidenceUploadError extends Error { override readonly name = "EventEvidenceUploadError"; constructor( message: string, readonly diagnostic: { attempts: number; status: number | null; retryable: boolean; cause?: unknown; }, ) { super(message, { cause: diagnostic.cause }); } }