From a1561e8c0d7b5425aa08b7e4e1fd201a24a036e9 Mon Sep 17 00:00:00 2001 From: Sam Margolis Date: Mon, 2 Mar 2026 08:45:56 -0800 Subject: [PATCH] fix: remove query-token auth from transcription stream --- .../transcription/stream/[sessionId]/route.ts | 9 ++ apps/web/src/app/page.tsx | 83 ++++++++++++++++--- apps/web/src/types/desktop.d.ts | 6 ++ .../src/hooks/segment-upload-controller.ts | 11 +++ .../src/hooks/use-segment-upload.ts | 1 + 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/api/transcription/stream/[sessionId]/route.ts b/apps/web/src/app/api/transcription/stream/[sessionId]/route.ts index 3846269..a2fc709 100644 --- a/apps/web/src/app/api/transcription/stream/[sessionId]/route.ts +++ b/apps/web/src/app/api/transcription/stream/[sessionId]/route.ts @@ -1,5 +1,6 @@ import type { NextRequest } from "next/server" import { transcriptionSessionStore } from "@transcript-assembly" +import { requireAuth } from "@/lib/auth" export const runtime = "nodejs" @@ -11,6 +12,14 @@ export async function GET( req: NextRequest, context: { params: Promise<{ sessionId: string }> | { sessionId: string } }, ) { + const auth = await requireAuth(req) + if (!auth) { + return new Response(JSON.stringify({ error: { code: "unauthorized", message: "Authentication required" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + } + const resolvedParams = "then" in context.params ? await context.params : context.params const { sessionId } = resolvedParams let cleanup: (() => void) | null = null diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b6ba12b..d3b8daf 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -6,7 +6,6 @@ import { useEncounters, EncounterList, IdleView, NewEncounterForm, RecordingView import { NoteEditor } from "@note-rendering" import { useAudioRecorder, type RecordedSegment, warmupMicrophonePermission, warmupSystemAudioPermission } from "@audio" import { useSegmentUpload, type UploadError } from "@transcription"; -import { generateClinicalNote } from "@/app/actions" import { getPreferences, setPreferences, @@ -82,6 +81,18 @@ function resolveApiBaseUrl(): string { return "http://localhost:3001" } +function isHostedModeClient(): boolean { + return String(process.env.NEXT_PUBLIC_HOSTED_MODE || "").toLowerCase() === "true" +} + +function getStoredIdToken(): string | null { + if (typeof window === "undefined") return null + const fromStorage = window.localStorage.getItem("openscribe_id_token") + if (fromStorage) return fromStorage + const fromGlobal = window.__OPENSCRIBE_ID_TOKEN + return typeof fromGlobal === "string" && fromGlobal.length > 0 ? fromGlobal : null +} + function HomePageContent() { const { encounters, addEncounter, updateEncounter, deleteEncounter: removeEncounter, refresh } = useEncounters() @@ -91,7 +102,7 @@ function HomePageContent() { const [view, setView] = useState({ type: "idle" }) const [transcriptionStatus, setTranscriptionStatus] = useState("pending") const [noteGenerationStatus, setNoteGenerationStatus] = useState("pending") - const [processingMetrics, setProcessingMetrics] = useState({}) + const [, setProcessingMetrics] = useState({}) const [sessionId, setSessionId] = useState(null) const currentEncounterIdRef = useRef(null) @@ -112,7 +123,7 @@ function HomePageContent() { const [localDurationMs, setLocalDurationMs] = useState(0) const [localPaused, setLocalPaused] = useState(false) const localSessionNameRef = useRef(null) - const localBackendRef = useRef(null) + const localBackendRef = useRef["openscribeBackend"] | null>(null) const localLastTickRef = useRef(null) useEffect(() => { @@ -217,8 +228,33 @@ function HomePageContent() { const { enqueueSegment, resetQueue } = useSegmentUpload(sessionId, { onError: handleUploadError, apiBaseUrl: apiBaseUrlRef.current || undefined, + getAuthToken: async () => { + if (!isHostedModeClient()) return null + return getStoredIdToken() + }, }) + useEffect(() => { + if (!isHostedModeClient()) return + const token = getStoredIdToken() + if (!token) return + + const baseUrl = apiBaseUrlRef.current + const url = baseUrl + ? `${baseUrl.replace(/\/+$/, "")}/api/auth/bootstrap` + : "/api/auth/bootstrap" + + void fetch(url, { + method: "POST", + credentials: "include", + headers: { + Authorization: `Bearer ${token}`, + }, + }).catch((error) => { + debugWarn("Hosted bootstrap call failed", error) + }) + }, []) + const cleanupSession = useCallback(() => { debugLog('[Cleanup] Closing EventSource connection') if (eventSourceRef.current) { @@ -379,13 +415,33 @@ function HomePageContent() { noteGenerationStartedAt: prev.noteGenerationStartedAt ?? Date.now(), })) try { - const note = await generateClinicalNote({ - transcript, - patient_name: patientName, - visit_reason: visitReason, - noteLength: noteLengthRef.current, - template, + const baseUrl = apiBaseUrlRef.current + const url = baseUrl ? `${baseUrl.replace(/\/+$/, "")}/api/notes/generate` : "/api/notes/generate" + const token = isHostedModeClient() ? getStoredIdToken() : null + const headers: Record = { + "Content-Type": "application/json", + } + if (token) { + headers.Authorization = `Bearer ${token}` + } + const response = await fetch(url, { + method: "POST", + headers, + credentials: "include", + body: JSON.stringify({ + transcript, + patient_name: patientName, + visit_reason: visitReason, + noteLength: noteLengthRef.current, + template, + }), }) + if (!response.ok) { + const errBody = (await response.json().catch(() => ({}))) as { error?: { message?: string } } + throw new Error(errBody?.error?.message || `Note generation failed (${response.status})`) + } + const body = (await response.json()) as { note: string } + const note = body.note await updateEncounterRef.current(encounterId, { note_text: note, status: "completed", @@ -595,8 +651,15 @@ function HomePageContent() { const url = baseUrl ? `${baseUrl.replace(/\/+$/, "")}/api/transcription/final` : "/api/transcription/final" + const token = isHostedModeClient() ? getStoredIdToken() : null + const headers: Record = {} + if (token) { + headers.Authorization = `Bearer ${token}` + } const response = await fetch(url, { method: "POST", + headers, + credentials: "include", body: formData, }) if (!response.ok) { @@ -737,7 +800,7 @@ function HomePageContent() { processingEndedAt: Date.now(), })) } - } catch (error) { + } catch { setTranscriptionStatus("failed") setNoteGenerationStatus("failed") setProcessingMetrics((prev) => ({ diff --git a/apps/web/src/types/desktop.d.ts b/apps/web/src/types/desktop.d.ts index b868758..fe8c61b 100644 --- a/apps/web/src/types/desktop.d.ts +++ b/apps/web/src/types/desktop.d.ts @@ -26,10 +26,16 @@ declare global { readEntries: (filter?: unknown) => Promise exportLog: (options: { data: string; filename: string }) => Promise<{ success: boolean; canceled?: boolean; filePath?: string; error?: string }> } + openscribeBackend?: { + invoke: (channel: string, ...args: unknown[]) => Promise + on: (channel: string, listener: (event: unknown, payload: unknown) => void) => void + removeAllListeners: (channel: string) => void + } } interface Window { desktop?: DesktopAPI __openscribePermissionsPrimed?: boolean + __OPENSCRIBE_ID_TOKEN?: string } } diff --git a/packages/pipeline/transcribe/src/hooks/segment-upload-controller.ts b/packages/pipeline/transcribe/src/hooks/segment-upload-controller.ts index 7c1038d..07aa33a 100644 --- a/packages/pipeline/transcribe/src/hooks/segment-upload-controller.ts +++ b/packages/pipeline/transcribe/src/hooks/segment-upload-controller.ts @@ -15,6 +15,7 @@ export interface UploadError { export interface SegmentUploadControllerOptions { onError?: (error: UploadError) => void apiBaseUrl?: string + getAuthToken?: () => Promise } export interface SegmentUploadControllerDeps { @@ -48,6 +49,7 @@ export class SegmentUploadController { private readonly waitFn: (ms: number) => Promise private readonly options?: SegmentUploadControllerOptions private readonly apiBaseUrl?: string + private readonly getAuthToken?: () => Promise constructor( sessionId: string | null, @@ -57,6 +59,7 @@ export class SegmentUploadController { this.sessionId = sessionId this.options = options this.apiBaseUrl = options?.apiBaseUrl + this.getAuthToken = options?.getAuthToken this.fetchFn = deps?.fetchFn ?? globalThis.fetch.bind(globalThis) if (!this.fetchFn) { throw new Error("fetch API is required for SegmentUploadController") @@ -145,8 +148,16 @@ export class SegmentUploadController { const url = this.apiBaseUrl ? `${this.apiBaseUrl.replace(/\/+$/, "")}/api/transcription/segment` : "/api/transcription/segment" + const token = this.getAuthToken ? await this.getAuthToken() : null + const headers: Record = {} + if (token) { + headers.Authorization = `Bearer ${token}` + } + const response = await this.fetchFn(url, { method: "POST", + headers, + credentials: "include", body: formData, }) diff --git a/packages/pipeline/transcribe/src/hooks/use-segment-upload.ts b/packages/pipeline/transcribe/src/hooks/use-segment-upload.ts index 6a83311..c74cd48 100644 --- a/packages/pipeline/transcribe/src/hooks/use-segment-upload.ts +++ b/packages/pipeline/transcribe/src/hooks/use-segment-upload.ts @@ -21,6 +21,7 @@ export function useSegmentUpload(sessionId: string | null, options?: UseSegmentU controllerRef.current = new SegmentUploadController(sessionId, { onError: (error) => onErrorRef.current?.(error), apiBaseUrl: options?.apiBaseUrl, + getAuthToken: options?.getAuthToken, }) } else { // Update sessionId synchronously to avoid race condition with first segment