diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index df4abcec..b92e3fe9 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -42,6 +42,8 @@ import { versionHistory, voice } from '@x/core'; import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js'; import { getBillingInfo } from '@x/core/dist/billing/billing.js'; import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; +import { getAccessToken } from '@x/core/dist/auth/tokens.js'; +import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -144,10 +146,10 @@ export function registerIpcHandlers(handlers: InvokeHandlers) { ipcMain.handle(channel, async (event, rawArgs) => { // Validate request payload const args = ipc.validateRequest(channel, rawArgs); - + // Call handler const result = await handler(event, args); - + // Validate response payload return ipc.validateResponse(channel, result); }); @@ -471,6 +473,21 @@ export function setupIpcHandlers() { const config = await repo.getClientFacingConfig(); return { config }; }, + 'account:getRowboat': async () => { + const signedIn = await isSignedIn(); + if (!signedIn) { + return { signedIn: false, accessToken: null, config: null }; + } + + const config = await getRowboatConfig(); + + try { + const accessToken = await getAccessToken(); + return { signedIn: true, accessToken, config }; + } catch { + return { signedIn: true, accessToken: null, config }; + } + }, 'granola:getConfig': async () => { const repo = container.resolve('granolaConfigRepo'); const config = await repo.getConfig(); @@ -719,9 +736,6 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, - 'voice:getDeepgramToken': async () => { - return voice.getDeepgramToken(); - }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index b38a42d2..99865d8e 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -75,7 +75,7 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { * Get or create OAuth configuration for a provider */ async function getProviderConfiguration(provider: string, clientIdOverride?: string): Promise { - const config = getProviderConfig(provider); + const config = await getProviderConfig(provider); const resolveClientId = async (): Promise => { if (config.client.mode === 'static' && config.client.clientId) { return config.client.clientId; @@ -156,7 +156,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom cancelActiveFlow('new_flow_started'); const oauthRepo = getOAuthRepo(); - const providerConfig = getProviderConfig(provider); + const providerConfig = await getProviderConfig(provider); if (provider === 'google') { if (!clientId) { diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 12743fbf..35a0a703 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -1,4 +1,6 @@ import { useCallback, useRef, useState } from 'react'; +import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url'; +import { useRowboatAccount } from '@/hooks/useRowboatAccount'; export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping'; @@ -101,6 +103,7 @@ function formatTranscript(entries: TranscriptEntry[], date: string, calendarEven // Hook // --------------------------------------------------------------------------- export function useMeetingTranscription(onAutoStop?: () => void) { + const { refresh: refreshRowboatAccount } = useRowboatAccount(); const [state, setState] = useState('idle'); const wsRef = useRef(null); const micStreamRef = useRef(null); @@ -188,13 +191,18 @@ export function useMeetingTranscription(onAutoStop?: () => void) { const usingHeadphones = await detectHeadphones(); console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`); - // Get Deepgram token + // Rowboat WebSocket + bearer token when signed in; else local Deepgram API key let ws: WebSocket; try { - const result = await window.ipc.invoke('voice:getDeepgramToken', null); - if (result) { - console.log('[meeting] Using proxy token'); - ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['bearer', result.token]); + const account = await refreshRowboatAccount(); + if ( + account?.signedIn && + account.accessToken && + account.config?.websocketApiUrl + ) { + const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS); + console.log('[meeting] Using Rowboat WebSocket'); + ws = new WebSocket(listenUrl, ['bearer', account.accessToken]); } else { const config = await window.ipc.invoke('voice:getConfig', null); if (!config?.deepgram) { @@ -202,11 +210,11 @@ export function useMeetingTranscription(onAutoStop?: () => void) { setState('idle'); return null; } - console.log('[meeting] Using API key'); + console.log('[meeting] Using Deepgram API key'); ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]); } } catch (err) { - console.error('[meeting] Failed to get Deepgram token:', err); + console.error('[meeting] Failed to connect Deepgram:', err); setState('idle'); return null; } @@ -389,7 +397,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) { setState('recording'); return notePath; - }, [state, cleanup, scheduleDebouncedWrite]); + }, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]); const stop = useCallback(async () => { if (state !== 'recording') return; diff --git a/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts b/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts new file mode 100644 index 00000000..1cab2414 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import { useCallback, useEffect, useState } from 'react'; +import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js'; + + +interface RowboatAccountState { + signedIn: boolean; + accessToken: string | null; + config: z.infer | null; +} + +export type RowboatAccountSnapshot = RowboatAccountState; + +const DEFAULT_STATE: RowboatAccountState = { + signedIn: false, + accessToken: null, + config: null, +}; + +export function useRowboatAccount() { + const [state, setState] = useState(DEFAULT_STATE); + const [isLoading, setIsLoading] = useState(true); + + const refresh = useCallback(async (): Promise => { + try { + setIsLoading(true); + const result = await window.ipc.invoke('account:getRowboat', null); + const next: RowboatAccountSnapshot = { + signedIn: result.signedIn, + accessToken: result.accessToken, + config: result.config, + }; + setState(next); + return next; + } catch (error) { + console.error('Failed to load Rowboat account state:', error); + setState(DEFAULT_STATE); + return null; + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider !== 'rowboat') { + return; + } + refresh(); + }); + return cleanup; + }, [refresh]); + + return { + signedIn: state.signedIn, + accessToken: state.accessToken, + config: state.config, + isLoading, + refresh, + }; +} diff --git a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts index 439aec3b..854ac9ea 100644 --- a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts +++ b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts @@ -1,4 +1,6 @@ import { useCallback, useRef, useState } from 'react'; +import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url'; +import { useRowboatAccount } from '@/hooks/useRowboatAccount'; export type VoiceState = 'idle' | 'connecting' | 'listening'; @@ -15,6 +17,7 @@ const DEEPGRAM_PARAMS = new URLSearchParams({ const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`; export function useVoiceMode() { + const { refresh: refreshRowboatAccount } = useRowboatAccount(); const [state, setState] = useState('idle'); const [interimText, setInterimText] = useState(''); const wsRef = useRef(null); @@ -25,16 +28,20 @@ export function useVoiceMode() { const interimRef = useRef(''); // Connect (or reconnect) the Deepgram WebSocket. - // Fetches a fresh token on each connect — temp tokens have short TTL. + // Refreshes Rowboat account before connect so access token is current. const connectWs = useCallback(async () => { if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return; let ws: WebSocket; - // Try signed-in proxy token first (passed as query param for JWTs) - const result = await window.ipc.invoke('voice:getDeepgramToken', null); - if (result) { - ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['bearer', result.token]); + const account = await refreshRowboatAccount(); + if ( + account?.signedIn && + account.accessToken && + account.config?.websocketApiUrl + ) { + const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS); + ws = new WebSocket(listenUrl, ['bearer', account.accessToken]); } else { // Fall back to local API key (passed as subprotocol) const config = await window.ipc.invoke('voice:getConfig', null); @@ -72,7 +79,7 @@ export function useVoiceMode() { console.log('[voice] WebSocket closed'); wsRef.current = null; }; - }, []); + }, [refreshRowboatAccount]); // Stop audio capture and close WS const stopAudioCapture = useCallback(() => { diff --git a/apps/x/apps/renderer/src/lib/deepgram-listen-url.ts b/apps/x/apps/renderer/src/lib/deepgram-listen-url.ts new file mode 100644 index 00000000..fc286c19 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/deepgram-listen-url.ts @@ -0,0 +1,10 @@ +/** + * Merge Deepgram query params onto a Rowboat WebSocket base URL from account config. + */ +export function buildDeepgramListenUrl(baseWsUrl: string, params: URLSearchParams): string { + const url = new URL("/deepgram/v1/listen", baseWsUrl); + for (const [key, value] of params) { + url.searchParams.set(key, value); + } + return url.toString(); +} diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 2fc644d9..52bd0ab5 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { SUPABASE_PROJECT_URL } from '../config/env.js'; +import { getRowboatConfig } from '../config/rowboat.js'; /** * Discovery configuration - how to get OAuth endpoints @@ -55,7 +55,7 @@ const providerConfigs: ProviderConfig = { rowboat: { discovery: { mode: 'issuer', - issuer: `${SUPABASE_PROJECT_URL}/auth/v1/.well-known/oauth-authorization-server`, + issuer: "TBD", }, client: { mode: 'dcr', @@ -98,21 +98,21 @@ const providerConfigs: ProviderConfig = { /** * Get provider configuration by name */ -export function getProviderConfig(providerName: string): ProviderConfigEntry { +export async function getProviderConfig(providerName: string): Promise { const config = providerConfigs[providerName]; if (!config) { throw new Error(`Unknown OAuth provider: ${providerName}`); } + if (providerName === 'rowboat') { + const rowboatConfig = await getRowboatConfig(); + config.discovery = { + mode: 'issuer', + issuer: `${rowboatConfig.supabaseUrl}/auth/v1/.well-known/oauth-authorization-server`, + } + } return config; } -/** - * Get all provider configurations - */ -export function getAllProviderConfigs(): ProviderConfig { - return providerConfigs; -} - /** * Get list of all configured OAuth providers */ diff --git a/apps/x/packages/core/src/auth/tokens.ts b/apps/x/packages/core/src/auth/tokens.ts index e89c911d..8a30bf9f 100644 --- a/apps/x/packages/core/src/auth/tokens.ts +++ b/apps/x/packages/core/src/auth/tokens.ts @@ -19,7 +19,7 @@ export async function getAccessToken(): Promise { throw new Error('Rowboat token expired and no refresh token available. Please sign in again.'); } - const providerConfig = getProviderConfig('rowboat'); + const providerConfig = await getProviderConfig('rowboat'); if (providerConfig.discovery.mode !== 'issuer') { throw new Error('Rowboat provider requires issuer discovery mode'); } diff --git a/apps/x/packages/core/src/config/env.ts b/apps/x/packages/core/src/config/env.ts index b8c0e3d7..4c638986 100644 --- a/apps/x/packages/core/src/config/env.ts +++ b/apps/x/packages/core/src/config/env.ts @@ -1,5 +1,2 @@ export const API_URL = - process.env.API_URL || 'https://api.x.rowboatlabs.com'; - -export const SUPABASE_PROJECT_URL = - process.env.SUPABASE_PROJECT_URL || 'https://jpxoiuhlshgwixajvsbu.supabase.co'; + process.env.API_URL || 'https://api.x.rowboatlabs.com'; \ No newline at end of file diff --git a/apps/x/packages/core/src/config/rowboat.ts b/apps/x/packages/core/src/config/rowboat.ts new file mode 100644 index 00000000..3fa282e6 --- /dev/null +++ b/apps/x/packages/core/src/config/rowboat.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { RowboatApiConfig } from "@x/shared/dist/rowboat-account.js"; +import { API_URL } from "./env.js"; + +let cached: z.infer | null = null; + +export async function getRowboatConfig(): Promise> { + if (cached) { + return cached; + } + const response = await fetch(`${API_URL}/v1/config`); + const data = RowboatApiConfig.parse(await response.json()); + cached = data; + return data; +} \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts index 1ee865e9..03bbff5a 100644 --- a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts @@ -135,7 +135,7 @@ export class FirefliesClientFactory { } console.log(`[Fireflies] Initializing OAuth configuration...`); - const providerConfig = getProviderConfig(this.PROVIDER_NAME); + const providerConfig = await getProviderConfig(this.PROVIDER_NAME); if (providerConfig.discovery.mode === 'issuer') { if (providerConfig.client.mode === 'static') { diff --git a/apps/x/packages/core/src/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts index 3cb83cd1..b3bcfe7a 100644 --- a/apps/x/packages/core/src/knowledge/google-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts @@ -155,7 +155,7 @@ export class GoogleClientFactory { } console.log(`[OAuth] Initializing Google OAuth configuration...`); - const providerConfig = getProviderConfig(this.PROVIDER_NAME); + const providerConfig = await getProviderConfig(this.PROVIDER_NAME); if (providerConfig.discovery.mode === 'issuer') { if (providerConfig.client.mode === 'static') { diff --git a/apps/x/packages/core/src/voice/voice.ts b/apps/x/packages/core/src/voice/voice.ts index 4c698a83..895c81b9 100644 --- a/apps/x/packages/core/src/voice/voice.ts +++ b/apps/x/packages/core/src/voice/voice.ts @@ -33,23 +33,6 @@ export async function getVoiceConfig(): Promise { }; } -export async function getDeepgramToken(): Promise<{ token: string } | null> { - const signedIn = await isSignedIn(); - if (!signedIn) return null; - - const accessToken = await getAccessToken(); - const response = await fetch(`${API_URL}/v1/voice/deepgram-token`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${accessToken}` }, - }); - if (!response.ok) { - console.error('[voice] Deepgram token error:', response.status); - return null; - } - const data = await response.json(); - return { token: data.token }; -} - export async function synthesizeSpeech(text: string): Promise<{ audioBase64: string; mimeType: string }> { const config = await getVoiceConfig(); const signedIn = await isSignedIn(); diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 82dc5275..5f4988f4 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -7,6 +7,7 @@ import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; import { UserMessageContent } from './message.js'; +import { RowboatApiConfig } from './rowboat-account.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -252,6 +253,14 @@ const ipcSchemas = { })), }), }, + 'account:getRowboat': { + req: z.null(), + res: z.object({ + signedIn: z.boolean(), + accessToken: z.string().nullable(), + config: RowboatApiConfig.nullable(), + }), + }, 'oauth:didConnect': { req: z.object({ provider: z.string(), @@ -492,12 +501,6 @@ const ipcSchemas = { mimeType: z.string(), }), }, - 'voice:getDeepgramToken': { - req: z.null(), - res: z.object({ - token: z.string(), - }).nullable(), - }, 'meeting:summarize': { req: z.object({ transcript: z.string(), diff --git a/apps/x/packages/shared/src/rowboat-account.ts b/apps/x/packages/shared/src/rowboat-account.ts new file mode 100644 index 00000000..c6b7cff4 --- /dev/null +++ b/apps/x/packages/shared/src/rowboat-account.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const RowboatApiConfig = z.object({ + appUrl: z.string(), + websocketApiUrl: z.string(), + supabaseUrl: z.string(), +});