diff --git a/src/hooks/useEvents.test.ts b/src/hooks/useEvents.test.ts index 221e57c8..695dd7ab 100644 --- a/src/hooks/useEvents.test.ts +++ b/src/hooks/useEvents.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest" -import { renderHook, act } from "@testing-library/react" +import { renderHook, act, waitFor } from "@testing-library/react" +import type { AppConfig } from "../types" const mockDispatch = vi.fn() const mockInvoke = vi.fn() @@ -12,13 +13,21 @@ const trackSyncCompleted = vi.fn() const trackSyncFailed = vi.fn() const trackSyncSkipped = vi.fn() const trackSyncStarted = vi.fn() +const mockRefreshAccessToken = vi.fn() let currentRuns: Array> = [] +let currentAppConfig: AppConfig = { + storageProvider: "local", + serverMode: "local", +} type EventHandler = (event: { payload: T }) => void const listeners = new Map() vi.mock("react-redux", () => ({ useDispatch: () => mockDispatch, + useSelector: (selector: (state: unknown) => unknown) => + selector({ app: { appConfig: currentAppConfig } }), + shallowEqual: vi.fn(), })) vi.mock("@tauri-apps/api/core", () => ({ @@ -40,6 +49,10 @@ vi.mock("../services/personalServerIngest", () => ({ ingestExportData: vi.fn(() => Promise.resolve([])), })) +vi.mock("../services/vanaSession", () => ({ + refreshAccessToken: (...args: unknown[]) => mockRefreshAccessToken(...args), +})) + vi.mock("@/lib/telemetry/events", () => ({ trackCollectionCancelled, trackCollectionCompleted, @@ -58,7 +71,9 @@ vi.mock("../state/store", async importOriginal => { ...actual, store: { ...actual.store, - getState: () => ({ app: { runs: currentRuns } }), + getState: () => ({ + app: { runs: currentRuns, appConfig: currentAppConfig }, + }), }, } }) @@ -82,6 +97,10 @@ describe("useEvents", () => { vi.clearAllMocks() listeners.clear() currentRuns = [] + currentAppConfig = { + storageProvider: "local", + serverMode: "local", + } }) it("persists export-complete payloads missing company using event metadata", async () => { @@ -193,4 +212,194 @@ describe("useEvents", () => { }) expect(trackCollectionFailed).not.toHaveBeenCalled() }) + + it("surfaces remote URL discovery gaps after saving an export", async () => { + const useEvents = await importHook() + const exportPath = "/tmp/dataconnect/exported_data/OpenAI/ChatGPT/run-1" + currentAppConfig = { + storageProvider: "local", + serverMode: "remote", + vanaAccessToken: "ory_at_test", + vanaAccessTokenExpiresAt: Math.floor(Date.now() / 1000) + 900, + } + + mockInvoke.mockImplementation((command: string) => { + if (command === "write_export_data") { + return Promise.resolve(exportPath) + } + return Promise.resolve(null) + }) + + renderHook(() => useEvents()) + + await act(async () => { + emit("export-complete", { + runId: "chatgpt-run-1", + platformId: "chatgpt-playwright", + company: "OpenAI", + name: "ChatGPT", + data: { + platform: "chatgpt", + company: "OpenAI", + exportedAt: "2026-05-06T00:00:00.000Z", + conversations: [], + }, + timestamp: Date.now(), + }) + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: "app/updateRunExportData", + payload: { + runId: "chatgpt-run-1", + statusMessage: + "Export saved locally. Add a Personal Server URL to deliver it.", + }, + }) + expect(trackSyncSkipped).toHaveBeenCalledWith( + expect.objectContaining({ + collectionRunId: "chatgpt-run-1", + reason: "server_unavailable", + }) + ) + }) + + it("re-delivers an existing saved export when remote config becomes usable", async () => { + const { ingestExportData } = + await import("../services/personalServerIngest") + vi.mocked(ingestExportData).mockResolvedValueOnce(["chatgpt.conversations"]) + const useEvents = await importHook() + currentAppConfig = { + storageProvider: "local", + serverMode: "remote", + remoteServerUrl: "https://0xfake.myvana.app", + vanaAccessToken: "ory_at_test", + vanaAccessTokenExpiresAt: Math.floor(Date.now() / 1000) + 900, + } + currentRuns = [ + { + id: "chatgpt-run-1", + platformId: "chatgpt-playwright", + company: "OpenAI", + name: "ChatGPT", + startDate: "2026-05-06T00:00:00.000Z", + status: "success", + exportPath: "/tmp/dataconnect/exported_data/OpenAI/ChatGPT/run-1", + syncedToPersonalServer: false, + }, + ] + + mockInvoke.mockImplementation((command: string) => { + if (command === "load_run_export_data") { + return Promise.resolve({ + content: { + platform: "chatgpt", + company: "OpenAI", + exportedAt: "2026-05-06T00:00:00.000Z", + }, + }) + } + if (command === "mark_export_synced") { + return Promise.resolve(null) + } + return Promise.resolve(null) + }) + + renderHook(() => useEvents()) + + await waitFor(() => { + expect(ingestExportData).toHaveBeenCalledWith( + { + kind: "remote", + baseUrl: "https://0xfake.myvana.app", + bearerToken: "ory_at_test", + }, + "chatgpt-playwright", + expect.objectContaining({ platform: "chatgpt" }) + ) + }) + expect(mockInvoke).toHaveBeenCalledWith("mark_export_synced", { + runId: "chatgpt-run-1", + exportPath: "/tmp/dataconnect/exported_data/OpenAI/ChatGPT/run-1", + itemsExported: null, + itemLabel: null, + scope: "chatgpt.conversations", + }) + expect(mockDispatch).toHaveBeenCalledWith({ + type: "app/markRunSynced", + payload: { runId: "chatgpt-run-1", scope: "chatgpt.conversations" }, + }) + }) + + it("refreshes a Vana token before re-delivering a saved export", async () => { + const { ingestExportData } = + await import("../services/personalServerIngest") + vi.mocked(ingestExportData).mockResolvedValueOnce(["chatgpt.conversations"]) + mockRefreshAccessToken.mockResolvedValueOnce({ + access_token: "ory_at_rotated", + refresh_token: "ory_rt_rotated", + expires_in: 900, + token_type: "bearer", + }) + const useEvents = await importHook() + currentAppConfig = { + storageProvider: "local", + serverMode: "remote", + remoteServerUrl: "https://0xfake.myvana.app", + vanaRefreshToken: "ory_rt_old", + } + currentRuns = [ + { + id: "chatgpt-run-refresh", + platformId: "chatgpt-playwright", + company: "OpenAI", + name: "ChatGPT", + startDate: "2026-05-06T00:00:00.000Z", + status: "success", + exportPath: "/tmp/dataconnect/exported_data/OpenAI/ChatGPT/run-refresh", + syncedToPersonalServer: false, + }, + ] + + mockInvoke.mockImplementation((command: string) => { + if (command === "load_run_export_data") { + return Promise.resolve({ + content: { + platform: "chatgpt", + company: "OpenAI", + exportedAt: "2026-05-06T00:00:00.000Z", + }, + }) + } + if (command === "mark_export_synced") { + return Promise.resolve(null) + } + return Promise.resolve(null) + }) + + renderHook(() => useEvents()) + + await waitFor(() => { + expect(mockRefreshAccessToken).toHaveBeenCalledWith("ory_rt_old") + expect(ingestExportData).toHaveBeenCalledWith( + { + kind: "remote", + baseUrl: "https://0xfake.myvana.app", + bearerToken: "ory_at_rotated", + }, + "chatgpt-playwright", + expect.objectContaining({ platform: "chatgpt" }) + ) + }) + expect(mockDispatch).toHaveBeenCalledWith({ + type: "app/setAppConfig", + payload: expect.objectContaining({ + vanaAccessToken: "ory_at_rotated", + vanaRefreshToken: "ory_rt_rotated", + vanaAccessTokenExpiresAt: expect.any(Number), + }), + }) + }) }) diff --git a/src/hooks/useEvents.ts b/src/hooks/useEvents.ts index ab4e38ef..25c628f3 100644 --- a/src/hooks/useEvents.ts +++ b/src/hooks/useEvents.ts @@ -1,7 +1,7 @@ -import { useEffect, useRef } from 'react'; -import { listen } from '@tauri-apps/api/event'; -import { invoke } from '@tauri-apps/api/core'; -import { useDispatch } from 'react-redux'; +import { useEffect, useRef } from "react" +import { listen } from "@tauri-apps/api/event" +import { invoke } from "@tauri-apps/api/core" +import { shallowEqual, useDispatch, useSelector } from "react-redux" import { updateRunLogs, updateRunStatus, @@ -9,24 +9,27 @@ import { updateRunConnected, updateRunExportData, markRunSynced, + setAppConfig, type AppDispatch, + type RootState, store, -} from '../state/store'; +} from "../state/store" import type { + AppConfig, ConnectorLogEvent, DownloadProgressEvent, ExportCompleteEvent, ExportedData, ProgressPhase, -} from '../types'; -import { normalizeExportData } from '../lib/export-data'; +} from "../types" +import { normalizeExportData } from "../lib/export-data" import { ingestExportData, type IngestTarget, -} from '../services/personalServerIngest'; -import { refreshAccessToken } from '../services/vanaSession'; -import { getPlatformRegistryEntry } from '@/lib/platform/utils'; -import { durationSince } from '@/lib/telemetry/client'; +} from "../services/personalServerIngest" +import { refreshAccessToken } from "../services/vanaSession" +import { getPlatformRegistryEntry } from "@/lib/platform/utils" +import { durationSince } from "@/lib/telemetry/client" import { trackCollectionCancelled, trackCollectionCompleted, @@ -37,89 +40,127 @@ import { trackSyncFailed, trackSyncSkipped, trackSyncStarted, -} from '@/lib/telemetry/events'; -import type { TelemetryErrorClass, TelemetryScopeSummary } from '@/lib/telemetry/contract'; - -const isDev = import.meta.env.DEV; +} from "@/lib/telemetry/events" +import type { + TelemetryErrorClass, + TelemetryScopeSummary, +} from "@/lib/telemetry/contract" + +const isDev = import.meta.env.DEV +const deliveryRunIdsInFlight = new Set() +let refreshInFlight: Promise<{ + access: string + refresh?: string + expiresAt: number +}> | null = null function debugLog(...args: unknown[]) { - if (!isDev) return; - console.log(...args); + if (!isDev) return + console.log(...args) +} + +function beginRunDelivery(runId: string) { + if (deliveryRunIdsInFlight.has(runId)) return false + deliveryRunIdsInFlight.add(runId) + return true +} + +function endRunDelivery(runId: string) { + deliveryRunIdsInFlight.delete(runId) +} + +async function refreshVanaSessionTokens(refreshToken: string) { + if (!refreshInFlight) { + refreshInFlight = refreshAccessToken(refreshToken) + .then(tokens => ({ + access: tokens.access_token, + refresh: tokens.refresh_token, + expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in, + })) + .finally(() => { + refreshInFlight = null + }) + } + return refreshInFlight } interface ConnectorStatusEventPayload { - runId: string; - status: + runId: string + status: | string | { - type: string; - message?: string; - data?: unknown; - phase?: ProgressPhase; - count?: number; - outcome?: "success" | "partial" | "failure" | "cancelled"; - errorClass?: TelemetryErrorClass; - recordCount?: number; - scopeSummary?: TelemetryScopeSummary; - }; - timestamp: number; + type: string + message?: string + data?: unknown + phase?: ProgressPhase + count?: number + outcome?: "success" | "partial" | "failure" | "cancelled" + errorClass?: TelemetryErrorClass + recordCount?: number + scopeSummary?: TelemetryScopeSummary + } + timestamp: number } interface ConnectorExportCompleteEvent { - runId: string; - platformId: string; - company: string; - name: string; - data: unknown; - timestamp: number; + runId: string + platformId: string + company: string + name: string + data: unknown + timestamp: number } function toExportedData( value: unknown, fallback: { platform: string; company: string } ): ExportedData | null { - if (typeof value !== 'object' || value === null) return null; - const candidate = value as Record; + if (typeof value !== "object" || value === null) return null + const candidate = value as Record const platform = - typeof candidate.platform === 'string' && candidate.platform.length > 0 + typeof candidate.platform === "string" && candidate.platform.length > 0 ? candidate.platform - : fallback.platform; + : fallback.platform const company = - typeof candidate.company === 'string' && candidate.company.length > 0 + typeof candidate.company === "string" && candidate.company.length > 0 ? candidate.company - : fallback.company; + : fallback.company const exportedAt = - typeof candidate.exportedAt === 'string' && candidate.exportedAt.length > 0 + typeof candidate.exportedAt === "string" && candidate.exportedAt.length > 0 ? candidate.exportedAt - : typeof candidate.timestamp === 'string' && candidate.timestamp.length > 0 + : typeof candidate.timestamp === "string" && + candidate.timestamp.length > 0 ? candidate.timestamp - : new Date().toISOString(); + : new Date().toISOString() return { ...candidate, platform, company, exportedAt, - } as ExportedData; + } as ExportedData } function getRunTelemetryContext(runId: string) { - const run = store.getState().app.runs.find((candidate) => candidate.id === runId); - if (!run) return null; + const run = store + .getState() + .app.runs.find(candidate => candidate.id === runId) + if (!run) return null return { run, - source: getPlatformRegistryEntry({ - id: run.platformId, - name: run.name, - company: run.company, - })?.id ?? run.platformId, + source: + getPlatformRegistryEntry({ + id: run.platformId, + name: run.name, + company: run.company, + })?.id ?? run.platformId, durationMs: durationSince(run.startDate), - }; + } } function createSyncRunId(collectionRunId: string) { - return `${collectionRunId}:sync:${crypto.randomUUID()}`; + return `${collectionRunId}:sync:${crypto.randomUUID()}` } /** @@ -132,135 +173,192 @@ function createSyncRunId(collectionRunId: string) { * Returns null when configuration is incomplete; callers should treat * null as "skip ingest, surface the reason via telemetry." */ -async function resolveIngestTarget(): Promise<{ - target: IngestTarget; - refreshedTokens?: { access: string; refresh?: string; expiresAt: number }; -} | { error: string } | null> { - const state = store.getState(); - const cfg = state.app.appConfig; - - if (cfg.serverMode === 'remote') { +async function resolveIngestTarget(): Promise< + | { + target: IngestTarget + refreshedTokens?: { access: string; refresh?: string; expiresAt: number } + } + | { error: string } + | null +> { + const state = store.getState() + const cfg = state.app.appConfig + + if (cfg.serverMode === "remote") { if (!cfg.remoteServerUrl) { - return { error: 'remote_url_missing' }; + return { error: "remote_url_missing" } } - const access = cfg.vanaAccessToken; - const expiresAt = cfg.vanaAccessTokenExpiresAt ?? 0; - const now = Math.floor(Date.now() / 1000); + const access = cfg.vanaAccessToken + const expiresAt = cfg.vanaAccessTokenExpiresAt ?? 0 + const now = Math.floor(Date.now() / 1000) if (access && expiresAt > now + 60) { return { target: { - kind: 'remote', + kind: "remote", baseUrl: cfg.remoteServerUrl, bearerToken: access, }, - }; + } } if (!cfg.vanaRefreshToken) { - return { error: 'not_connected_to_vana' }; + return { error: "not_connected_to_vana" } } try { - const tokens = await refreshAccessToken(cfg.vanaRefreshToken); + const refreshedTokens = await refreshVanaSessionTokens( + cfg.vanaRefreshToken + ) return { target: { - kind: 'remote', + kind: "remote", baseUrl: cfg.remoteServerUrl, - bearerToken: tokens.access_token, + bearerToken: refreshedTokens.access, }, - refreshedTokens: { - access: tokens.access_token, - refresh: tokens.refresh_token, - expiresAt: now + tokens.expires_in, - }, - }; + refreshedTokens, + } } catch (err) { + if (isDev) { + console.warn("[Data Delivery] Vana token refresh failed", err) + } return { - error: err instanceof Error ? err.message : 'refresh_failed', - }; + error: "refresh_failed", + } } } // local mode const serverStatus = await invoke<{ running: boolean; port?: number }>( - 'get_personal_server_status' - ); + "get_personal_server_status" + ) if (!serverStatus.running || !serverStatus.port) { - return null; + return null } - return { target: { kind: 'local', port: serverStatus.port } }; + return { target: { kind: "local", port: serverStatus.port } } +} + +function persistRefreshedTokens( + dispatch: AppDispatch, + refreshedTokens?: { access: string; refresh?: string; expiresAt: number } +) { + if (!refreshedTokens) return + const nextConfig: Partial = { + vanaAccessToken: refreshedTokens.access, + vanaAccessTokenExpiresAt: refreshedTokens.expiresAt, + } + if (refreshedTokens.refresh) { + nextConfig.vanaRefreshToken = refreshedTokens.refresh + } + dispatch(setAppConfig(nextConfig)) +} + +function getRemoteDeliveryMessage(error: string) { + if (error === "remote_url_missing") { + return "Export saved locally. Add a Personal Server URL to deliver it." + } + if (error === "not_connected_to_vana") { + return "Export saved locally. Connect with Vana to deliver it." + } + if (error === "refresh_failed") { + return "Export saved locally. Reconnect with Vana to deliver it." + } + return `Export saved locally. Delivery paused: ${error}` } async function deliverRunToPersonalServer( run: { - id: string; - platformId: string; - exportPath?: string; - itemsExported?: number; - itemLabel?: string; - syncedToPersonalServer?: boolean; + id: string + platformId: string + exportPath?: string + itemsExported?: number + itemLabel?: string + syncedToPersonalServer?: boolean }, target: IngestTarget, dispatch: AppDispatch ): Promise { - if (!run.exportPath || run.syncedToPersonalServer) return false; + if (!run.exportPath || run.syncedToPersonalServer) return false + if (!beginRunDelivery(run.id)) return false + const source = + getPlatformRegistryEntry({ id: run.platformId })?.id ?? run.platformId + const syncRunId = createSyncRunId(run.id) - const source = getPlatformRegistryEntry({ id: run.platformId })?.id ?? run.platformId; - const syncRunId = createSyncRunId(run.id); - trackSyncStarted({ - collectionRunId: run.id, - syncRunId, - source, - }); + try { + const latest = store.getState().app.runs.find(r => r.id === run.id) + if (latest?.syncedToPersonalServer) return false - const dirPath = run.exportPath.endsWith('.json') - ? run.exportPath.replace(/\/[^/]+$/, '') - : run.exportPath; + trackSyncStarted({ + collectionRunId: run.id, + syncRunId, + source, + }) - try { - const data = await invoke>('load_run_export_data', { + const dirPath = run.exportPath.endsWith(".json") + ? run.exportPath.replace(/\/[^/]+$/, "") + : run.exportPath + + const data = await invoke>("load_run_export_data", { runId: run.id, exportPath: dirPath, - }); - const payload = (data.content ?? data) as Record; - const ingested = await ingestExportData(target, run.platformId, payload); + }) + const payload = (data.content ?? data) as Record + const ingested = await ingestExportData(target, run.platformId, payload) if (ingested.length === 0) { trackSyncFailed({ collectionRunId: run.id, syncRunId, source, - errorClass: 'runtime_error', - }); - return false; + errorClass: "runtime_error", + }) + return false } - await invoke('mark_export_synced', { + await invoke("mark_export_synced", { runId: run.id, exportPath: run.exportPath, itemsExported: run.itemsExported ?? null, itemLabel: run.itemLabel ?? null, scope: ingested[0], - }); + }) - dispatch(markRunSynced({ runId: run.id, scope: ingested[0] })); + dispatch( + updateRunExportData({ + runId: run.id, + statusMessage: "Delivered saved export", + }) + ) + dispatch(markRunSynced({ runId: run.id, scope: ingested[0] })) trackSyncCompleted({ collectionRunId: run.id, syncRunId, source, storedScopeCount: ingested.length, failedScopeCount: 0, - }); - debugLog('[Data Delivery] Synced run', run.id, 'scopes:', ingested); - return true; + }) + debugLog("[Data Delivery] Synced run", run.id, "scopes:", ingested) + return true } catch (err) { if (isDev) { - console.warn('[Data Delivery] Failed for run', run.id, '(non-blocking):', err); + console.warn( + "[Data Delivery] Failed for run", + run.id, + "(non-blocking):", + err + ) } + dispatch( + updateRunExportData({ + runId: run.id, + statusMessage: `Saved export delivery failed: ${err instanceof Error ? err.message : String(err)}`, + }) + ) trackSyncFailed({ collectionRunId: run.id, syncRunId, source, error: err, - }); - return false; + }) + return false + } finally { + endRunDelivery(run.id) } } @@ -273,220 +371,314 @@ async function persistAndDeliverExport({ dispatch, persistedRunIds, }: { - runId: string; - platformId: string; - company: string; - name: string; - exportData: ExportedData; - dispatch: AppDispatch; - persistedRunIds: Set; + runId: string + platformId: string + company: string + name: string + exportData: ExportedData + dispatch: AppDispatch + persistedRunIds: Set }): Promise { - if (persistedRunIds.has(runId)) return; + if (persistedRunIds.has(runId)) return - const serializedExport = JSON.stringify(exportData); - const { itemsExported, itemLabel } = normalizeExportData(exportData); - const source = getPlatformRegistryEntry({ id: platformId, company, name })?.id ?? platformId; + const serializedExport = JSON.stringify(exportData) + const { itemsExported, itemLabel } = normalizeExportData(exportData) + const source = + getPlatformRegistryEntry({ id: platformId, company, name })?.id ?? + platformId dispatch( updateRunExportData({ runId, - statusMessage: 'Export complete', + statusMessage: "Export complete", itemsExported, itemLabel, exportData, }) - ); + ) - persistedRunIds.add(runId); + persistedRunIds.add(runId) try { - const exportPath = await invoke('write_export_data', { + const exportPath = await invoke("write_export_data", { runId, platformId, company, name: name || platformId, data: serializedExport, - }); + }) + const ownsDelivery = beginRunDelivery(runId) dispatch( updateExportStatus({ runId, exportPath, exportSize: serializedExport.length, }) - ); + ) + if (!ownsDelivery) return - const resolved = await resolveIngestTarget(); - if (!resolved) { - trackSyncSkipped({ - collectionRunId: runId, - syncRunId: createSyncRunId(runId), - source, - reason: 'server_unavailable', - }); - return; - } - if ('error' in resolved) { - // Remote-mode errors (URL missing, not connected to Vana, refresh - // failed) all fall under "server_unavailable" for the existing - // telemetry enum. Detail logged separately for diagnosis. - console.warn('[useEvents] Remote ingest target unavailable:', resolved.error); - trackSyncSkipped({ + try { + const resolved = await resolveIngestTarget() + if (!resolved) { + trackSyncSkipped({ + collectionRunId: runId, + syncRunId: createSyncRunId(runId), + source, + reason: "server_unavailable", + }) + return + } + if ("error" in resolved) { + // Remote-mode errors (URL missing, not connected to Vana, refresh + // failed) all fall under "server_unavailable" for the existing + // telemetry enum. Detail logged separately for diagnosis. + console.warn( + "[useEvents] Remote ingest target unavailable:", + resolved.error + ) + dispatch( + updateRunExportData({ + runId, + statusMessage: getRemoteDeliveryMessage(resolved.error), + }) + ) + trackSyncSkipped({ + collectionRunId: runId, + syncRunId: createSyncRunId(runId), + source, + reason: "server_unavailable", + }) + return + } + persistRefreshedTokens(dispatch, resolved.refreshedTokens) + const target = resolved.target + + const syncRunId = createSyncRunId(runId) + trackSyncStarted({ collectionRunId: runId, - syncRunId: createSyncRunId(runId), + syncRunId, source, - reason: 'server_unavailable', - }); - return; - } - if (resolved.refreshedTokens) { - // Persist rotated refresh token + new access token expiry. - dispatch({ - type: 'app/setAppConfig', - payload: { - vanaAccessToken: resolved.refreshedTokens.access, - vanaRefreshToken: resolved.refreshedTokens.refresh, - vanaAccessTokenExpiresAt: resolved.refreshedTokens.expiresAt, - }, - }); - } - const target = resolved.target; + }) - const syncRunId = createSyncRunId(runId); - trackSyncStarted({ - collectionRunId: runId, - syncRunId, - source, - }); + const ingested = await ingestExportData( + target, + platformId, + exportData as unknown as Record + ) + if (ingested.length === 0) { + trackSyncFailed({ + collectionRunId: runId, + syncRunId, + source, + errorClass: "runtime_error", + }) + return + } - const ingested = await ingestExportData(target, platformId, exportData as unknown as Record); - if (ingested.length === 0) { - trackSyncFailed({ + await invoke("mark_export_synced", { + runId, + exportPath, + itemsExported: itemsExported ?? null, + itemLabel: itemLabel ?? null, + scope: ingested[0], + }) + dispatch(markRunSynced({ runId, scope: ingested[0] })) + trackSyncCompleted({ collectionRunId: runId, syncRunId, source, - errorClass: 'runtime_error', - }); - return; + storedScopeCount: ingested.length, + failedScopeCount: 0, + }) + debugLog("[Data Delivery] Synced run", runId, "scopes:", ingested) + } finally { + endRunDelivery(runId) } - - await invoke('mark_export_synced', { - runId, - exportPath, - itemsExported: itemsExported ?? null, - itemLabel: itemLabel ?? null, - scope: ingested[0], - }); - dispatch(markRunSynced({ runId, scope: ingested[0] })); - trackSyncCompleted({ - collectionRunId: runId, - syncRunId, - source, - storedScopeCount: ingested.length, - failedScopeCount: 0, - }); - debugLog('[Data Delivery] Synced run', runId, 'scopes:', ingested); } catch (err) { - persistedRunIds.delete(runId); - const message = err instanceof Error ? err.message : String(err); + persistedRunIds.delete(runId) + const message = err instanceof Error ? err.message : String(err) dispatch( updateRunExportData({ runId, statusMessage: `Failed to save export locally: ${message}`, }) - ); + ) dispatch( updateRunLogs({ runId, logs: `[Export Persistence Error] ${message}`, }) - ); + ) trackSyncFailed({ collectionRunId: runId, syncRunId: createSyncRunId(runId), source, error: err, - }); + }) if (isDev) { - console.warn('[Export Persistence] Deferred or failed for run', runId, err); + console.warn( + "[Export Persistence] Deferred or failed for run", + runId, + err + ) } } } export function useEvents() { - const dispatch = useDispatch(); - const deliveryInProgressRef = useRef(false); + const dispatch = useDispatch() + const remoteDeliveryConfig = useSelector( + (state: RootState) => ({ + serverMode: state.app.appConfig.serverMode, + remoteServerUrl: state.app.appConfig.remoteServerUrl, + hasVanaSessionToken: Boolean( + state.app.appConfig.vanaAccessToken || + state.app.appConfig.vanaRefreshToken + ), + }), + shallowEqual + ) + const remoteDeliveryInProgressRef = useRef(false) + const localDeliveryInProgressRef = useRef(false) useEffect(() => { - let cancelled = false; - const unlistenFns: (() => void)[] = []; - const persistedRunIds = new Set(); - const needsInputRunIds = new Set(); - const terminalCollectionRunIds = new Set(); + if (remoteDeliveryConfig.serverMode !== "remote") return + if (!remoteDeliveryConfig.remoteServerUrl) return + if (!remoteDeliveryConfig.hasVanaSessionToken) return + if (remoteDeliveryInProgressRef.current) return + + let cancelled = false + remoteDeliveryInProgressRef.current = true + + void (async () => { + try { + const resolved = await resolveIngestTarget() + if (cancelled) return + if (!resolved || "error" in resolved) { + debugLog( + "[Data Delivery] Remote retry skipped:", + resolved && "error" in resolved + ? resolved.error + : "target_unavailable" + ) + return + } + persistRefreshedTokens(dispatch, resolved.refreshedTokens) + + const runs = store.getState().app.runs + const pending = runs.filter( + r => + r.exportPath && + !r.syncedToPersonalServer && + (r.status === "success" || r.status === "partial") + ) + if (pending.length === 0) return + + debugLog( + "[Data Delivery]", + pending.length, + "saved exports to retry for remote PS" + ) + for (const run of pending) { + if (cancelled) break + dispatch( + updateRunExportData({ + runId: run.id, + statusMessage: "Delivering saved export...", + }) + ) + await deliverRunToPersonalServer(run, resolved.target, dispatch) + } + } finally { + remoteDeliveryInProgressRef.current = false + } + })() + + return () => { + cancelled = true + } + }, [ + dispatch, + remoteDeliveryConfig.hasVanaSessionToken, + remoteDeliveryConfig.remoteServerUrl, + remoteDeliveryConfig.serverMode, + ]) + + useEffect(() => { + let cancelled = false + const unlistenFns: (() => void)[] = [] + const persistedRunIds = new Set() + const needsInputRunIds = new Set() + const terminalCollectionRunIds = new Set() function addListener(eventName: string, handler: (payload: T) => void) { - listen(eventName, (event) => { - if (cancelled) return; - handler(event.payload); - }).then((unlisten) => { + listen(eventName, event => { + if (cancelled) return + handler(event.payload) + }).then(unlisten => { if (cancelled) { - unlisten(); + unlisten() } else { - unlistenFns.push(unlisten); + unlistenFns.push(unlisten) } - }); + }) } function markCollectionCompleted( runId: string, args?: { recordCount?: number; scopeSummary?: TelemetryScopeSummary } ) { - if (terminalCollectionRunIds.has(runId)) return; - const context = getRunTelemetryContext(runId); - if (!context) return; - terminalCollectionRunIds.add(runId); + if (terminalCollectionRunIds.has(runId)) return + const context = getRunTelemetryContext(runId) + if (!context) return + terminalCollectionRunIds.add(runId) trackCollectionCompleted({ collectionRunId: runId, source: context.source, durationMs: context.durationMs ?? 0, - ...(args?.recordCount !== undefined ? { recordCount: args.recordCount } : {}), + ...(args?.recordCount !== undefined + ? { recordCount: args.recordCount } + : {}), ...(args?.scopeSummary ? { scopeSummary: args.scopeSummary } : {}), - }); + }) } function markCollectionPartial( runId: string, args: { - errorClass?: TelemetryErrorClass; - error?: unknown; - recordCount?: number; - scopeSummary?: TelemetryScopeSummary; + errorClass?: TelemetryErrorClass + error?: unknown + recordCount?: number + scopeSummary?: TelemetryScopeSummary } ) { - if (terminalCollectionRunIds.has(runId)) return; - const context = getRunTelemetryContext(runId); - if (!context) return; - terminalCollectionRunIds.add(runId); + if (terminalCollectionRunIds.has(runId)) return + const context = getRunTelemetryContext(runId) + if (!context) return + terminalCollectionRunIds.add(runId) trackCollectionPartial({ collectionRunId: runId, source: context.source, durationMs: context.durationMs ?? 0, - errorClass: args.errorClass ?? 'unknown', - ...(args.recordCount !== undefined ? { recordCount: args.recordCount } : {}), + errorClass: args.errorClass ?? "unknown", + ...(args.recordCount !== undefined + ? { recordCount: args.recordCount } + : {}), ...(args.scopeSummary ? { scopeSummary: args.scopeSummary } : {}), - }); + }) } function markCollectionFailed( runId: string, error?: unknown, errorClass?: TelemetryErrorClass, - scopeSummary?: TelemetryScopeSummary, + scopeSummary?: TelemetryScopeSummary ) { - if (terminalCollectionRunIds.has(runId)) return; - const context = getRunTelemetryContext(runId); - if (!context) return; - terminalCollectionRunIds.add(runId); + if (terminalCollectionRunIds.has(runId)) return + const context = getRunTelemetryContext(runId) + if (!context) return + terminalCollectionRunIds.add(runId) trackCollectionFailed({ collectionRunId: runId, source: context.source, @@ -494,187 +686,216 @@ export function useEvents() { error, errorClass, ...(scopeSummary ? { scopeSummary } : {}), - }); + }) } function markCollectionCancelled(runId: string) { - if (terminalCollectionRunIds.has(runId)) return; - const context = getRunTelemetryContext(runId); - if (!context) return; - terminalCollectionRunIds.add(runId); + if (terminalCollectionRunIds.has(runId)) return + const context = getRunTelemetryContext(runId) + if (!context) return + terminalCollectionRunIds.add(runId) trackCollectionCancelled({ collectionRunId: runId, source: context.source, durationMs: context.durationMs, - }); + }) } - addListener('connector-log', ({ runId, message }) => { - debugLog('[Connector Log]', message); - dispatch(updateRunLogs({ runId, logs: message })); - }); - - addListener('connector-status', ({ runId, status }) => { - debugLog('[Connector Status]', runId, status); - - const statusType = typeof status === 'string' ? status : status.type; - const statusMessage = typeof status === 'object' ? status.message : undefined; - const fallbackStatusMessage = - statusType === 'WAITING_FOR_USER' - ? 'Waiting for sign in...' - : statusType === 'RUNNING' - ? 'Collecting data...' - : undefined; - const phase = typeof status === 'object' ? status.phase : undefined; - const itemCount = typeof status === 'object' ? status.count : undefined; - const outcome = typeof status === 'object' ? status.outcome : undefined; - const terminalErrorClass = - typeof status === 'object' ? status.errorClass : undefined; - const recordCount = - typeof status === 'object' ? status.recordCount : undefined; - const scopeSummary = - typeof status === 'object' ? status.scopeSummary : undefined; - - const updateProgress = () => { - dispatch(updateRunExportData({ - runId, - statusMessage: statusMessage ?? fallbackStatusMessage, - phase, - itemCount, - })); - }; - - if ( - statusType === 'CONNECT_WEBSITE' || - statusType === 'WAITING_LOGIN' || - statusType === 'WAITING_FOR_USER' - ) { - dispatch(updateRunConnected({ runId, isConnected: false })); - updateProgress(); - if (!needsInputRunIds.has(runId)) { - const context = getRunTelemetryContext(runId); - if (context) { - needsInputRunIds.add(runId); - trackCollectionNeedsInput({ - collectionRunId: runId, - source: context.source, - }); - } + addListener("connector-log", ({ runId, message }) => { + debugLog("[Connector Log]", message) + dispatch(updateRunLogs({ runId, logs: message })) + }) + + addListener( + "connector-status", + ({ runId, status }) => { + debugLog("[Connector Status]", runId, status) + + const statusType = typeof status === "string" ? status : status.type + const statusMessage = + typeof status === "object" ? status.message : undefined + const fallbackStatusMessage = + statusType === "WAITING_FOR_USER" + ? "Waiting for sign in..." + : statusType === "RUNNING" + ? "Collecting data..." + : undefined + const phase = typeof status === "object" ? status.phase : undefined + const itemCount = typeof status === "object" ? status.count : undefined + const outcome = typeof status === "object" ? status.outcome : undefined + const terminalErrorClass = + typeof status === "object" ? status.errorClass : undefined + const recordCount = + typeof status === "object" ? status.recordCount : undefined + const scopeSummary = + typeof status === "object" ? status.scopeSummary : undefined + + const updateProgress = () => { + dispatch( + updateRunExportData({ + runId, + statusMessage: statusMessage ?? fallbackStatusMessage, + phase, + itemCount, + }) + ) } - } else if (statusType === 'DOWNLOADING' || statusType === 'COLLECTING') { - dispatch(updateRunStatus({ runId, status: 'running' })); - dispatch(updateRunConnected({ runId, isConnected: true })); - updateProgress(); - } else if (statusType === 'RUNNING') { - dispatch(updateRunStatus({ runId, status: 'running' })); - updateProgress(); - } else if (statusType === 'STARTED') { - dispatch(updateRunStatus({ runId, status: 'running' })); - updateProgress(); - } else if (statusType === 'COMPLETE') { - dispatch( - updateRunStatus({ - runId, - status: 'success', - endDate: new Date().toISOString(), - }) - ); - dispatch(updateRunConnected({ runId, isConnected: true })); - markCollectionCompleted(runId, { - recordCount, - scopeSummary, - }); - - if (typeof status === 'object') { - const activeRun = store.getState().app.runs.find((r) => r.id === runId); - if (!activeRun) { - if (isDev) { - console.warn('[Connector Status] COMPLETE for unknown run', runId); + + if ( + statusType === "CONNECT_WEBSITE" || + statusType === "WAITING_LOGIN" || + statusType === "WAITING_FOR_USER" + ) { + dispatch(updateRunConnected({ runId, isConnected: false })) + updateProgress() + if (!needsInputRunIds.has(runId)) { + const context = getRunTelemetryContext(runId) + if (context) { + needsInputRunIds.add(runId) + trackCollectionNeedsInput({ + collectionRunId: runId, + source: context.source, + }) } - return; } - const normalizedData = toExportedData(status.data, { - platform: activeRun.platformId, - company: activeRun.company ?? 'Unknown', - }); - if (!normalizedData) return; - void persistAndDeliverExport({ - runId, - platformId: normalizedData.platform, - company: normalizedData.company, - name: normalizedData.platform, - exportData: normalizedData, - dispatch, - persistedRunIds, - }); - } - } else if (statusType === 'ERROR') { - const isPartial = outcome === 'partial'; - dispatch( - updateRunStatus({ - runId, - status: isPartial ? 'partial' : 'error', - endDate: new Date().toISOString(), - }) - ); - if (isPartial) { - dispatch(updateRunConnected({ runId, isConnected: true })); - } - if (statusMessage) { - dispatch(updateRunExportData({ runId, statusMessage })); - } - if (isPartial) { - markCollectionPartial(runId, { - errorClass: terminalErrorClass, - error: statusMessage ?? statusType, + } else if ( + statusType === "DOWNLOADING" || + statusType === "COLLECTING" + ) { + dispatch(updateRunStatus({ runId, status: "running" })) + dispatch(updateRunConnected({ runId, isConnected: true })) + updateProgress() + } else if (statusType === "RUNNING") { + dispatch(updateRunStatus({ runId, status: "running" })) + updateProgress() + } else if (statusType === "STARTED") { + dispatch(updateRunStatus({ runId, status: "running" })) + updateProgress() + } else if (statusType === "COMPLETE") { + dispatch( + updateRunStatus({ + runId, + status: "success", + endDate: new Date().toISOString(), + }) + ) + dispatch(updateRunConnected({ runId, isConnected: true })) + markCollectionCompleted(runId, { recordCount, scopeSummary, - }); - } else { - markCollectionFailed( - runId, - statusMessage ?? statusType, - terminalErrorClass, - scopeSummary, - ); - } - } else if (statusType === 'STOPPED') { - const currentRun = store.getState().app.runs.find((candidate) => candidate.id === runId); - dispatch( - updateRunStatus({ - runId, - status: 'stopped', - endDate: new Date().toISOString(), - onlyIfRunning: true, }) - ); - if (statusMessage) { - dispatch(updateRunExportData({ runId, statusMessage })); - } - if (currentRun?.status === 'running') { - markCollectionCancelled(runId); + + if (typeof status === "object") { + const activeRun = store + .getState() + .app.runs.find(r => r.id === runId) + if (!activeRun) { + if (isDev) { + console.warn( + "[Connector Status] COMPLETE for unknown run", + runId + ) + } + return + } + const normalizedData = toExportedData(status.data, { + platform: activeRun.platformId, + company: activeRun.company ?? "Unknown", + }) + if (!normalizedData) return + void persistAndDeliverExport({ + runId, + platformId: normalizedData.platform, + company: normalizedData.company, + name: normalizedData.platform, + exportData: normalizedData, + dispatch, + persistedRunIds, + }) + } + } else if (statusType === "ERROR") { + const isPartial = outcome === "partial" + dispatch( + updateRunStatus({ + runId, + status: isPartial ? "partial" : "error", + endDate: new Date().toISOString(), + }) + ) + if (isPartial) { + dispatch(updateRunConnected({ runId, isConnected: true })) + } + if (statusMessage) { + dispatch(updateRunExportData({ runId, statusMessage })) + } + if (isPartial) { + markCollectionPartial(runId, { + errorClass: terminalErrorClass, + error: statusMessage ?? statusType, + recordCount, + scopeSummary, + }) + } else { + markCollectionFailed( + runId, + statusMessage ?? statusType, + terminalErrorClass, + scopeSummary + ) + } + } else if (statusType === "STOPPED") { + const currentRun = store + .getState() + .app.runs.find(candidate => candidate.id === runId) + dispatch( + updateRunStatus({ + runId, + status: "stopped", + endDate: new Date().toISOString(), + onlyIfRunning: true, + }) + ) + if (statusMessage) { + dispatch(updateRunExportData({ runId, statusMessage })) + } + if (currentRun?.status === "running") { + markCollectionCancelled(runId) + } } } - }); + ) - addListener('download-progress', ({ percent }) => { + addListener("download-progress", ({ percent }) => { if (isDev) { - console.log('[Download Progress]', percent.toFixed(1) + '%'); + console.log("[Download Progress]", percent.toFixed(1) + "%") } - }); + }) - addListener<{ port: number }>('personal-server-ready', async ({ port }) => { - if (!port || deliveryInProgressRef.current) return; - deliveryInProgressRef.current = true; - debugLog('[Data Delivery] Personal server ready on port', port, '— scanning for pending exports'); + addListener<{ port: number }>("personal-server-ready", async ({ port }) => { + if (!port) return + if (store.getState().app.appConfig.serverMode !== "local") return + if (localDeliveryInProgressRef.current) return + localDeliveryInProgressRef.current = true + debugLog( + "[Data Delivery] Personal server ready on port", + port, + "— scanning for pending exports" + ) try { - const runs = store.getState().app.runs; + const runs = store.getState().app.runs const pending = runs.filter( - (r) => r.exportPath && !r.syncedToPersonalServer && (r.status === 'success' || r.status === 'partial') - ); - debugLog('[Data Delivery]', pending.length, 'pending exports to deliver'); + r => + r.exportPath && + !r.syncedToPersonalServer && + (r.status === "success" || r.status === "partial") + ) + debugLog( + "[Data Delivery]", + pending.length, + "pending exports to deliver" + ) for (const run of pending) { - if (cancelled) break; + if (cancelled) break // The personal-server-ready event fires from the local Tauri // subprocess, so we hardcode local mode here. Remote-mode // delivery is wired through the connector-run completion path @@ -684,12 +905,12 @@ export function useEvents() { run, { kind: "local", port }, dispatch - ); + ) } } finally { - deliveryInProgressRef.current = false; + localDeliveryInProgressRef.current = false } - }); + }) // `export-complete` carries the data payload that arrives when the // connector calls `page.setData('result', ...)`. For multi-step connectors @@ -698,39 +919,45 @@ export function useEvents() { // `collection_completed` telemetry. The terminal signal comes from the // `connector-status: COMPLETE` handler above, which fires when the // connector process actually exits. - addListener('export-complete', ({ runId, platformId, company, name, data }) => { - const normalizedData = toExportedData(data, { - platform: platformId, - company, - }); - if (!normalizedData) return; - - void persistAndDeliverExport({ - runId, - platformId, - company, - name, - exportData: normalizedData, - dispatch, - persistedRunIds, - }); - }); - - addListener('export-complete-rust', ({ run_id, export_path, export_size }) => { - debugLog('[Export Complete Rust]', run_id, export_path); - dispatch( - updateExportStatus({ - runId: run_id, - exportPath: export_path, - exportSize: export_size, + addListener( + "export-complete", + ({ runId, platformId, company, name, data }) => { + const normalizedData = toExportedData(data, { + platform: platformId, + company, }) - ); - markCollectionCompleted(run_id); - }); + if (!normalizedData) return + + void persistAndDeliverExport({ + runId, + platformId, + company, + name, + exportData: normalizedData, + dispatch, + persistedRunIds, + }) + } + ) + + addListener( + "export-complete-rust", + ({ run_id, export_path, export_size }) => { + debugLog("[Export Complete Rust]", run_id, export_path) + dispatch( + updateExportStatus({ + runId: run_id, + exportPath: export_path, + exportSize: export_size, + }) + ) + markCollectionCompleted(run_id) + } + ) return () => { - cancelled = true; - unlistenFns.forEach((fn) => fn()); - }; - }, [dispatch]); + cancelled = true + unlistenFns.forEach(fn => fn()) + } + }, [dispatch]) } diff --git a/src/hooks/useVanaLogin.ts b/src/hooks/useVanaLogin.ts index 899fdec0..cc871507 100644 --- a/src/hooks/useVanaLogin.ts +++ b/src/hooks/useVanaLogin.ts @@ -88,11 +88,22 @@ export function useVanaLogin() { remoteServerUrl: ps.url, }) ) + } else { + setError( + "Connected to Vana, but no Personal Server URL was found. Paste it below to deliver saved exports." + ) } - } catch { - // Discovery failure isn't fatal — the user can paste the PS URL - // manually in Settings. Surface via console. - console.warn("[vana-login] PS auto-discovery failed") + } catch (err) { + const message = + err instanceof VanaDeviceFlowError + ? err.message + : err instanceof Error + ? err.message + : String(err) + setError( + `Connected to Vana, but Personal Server URL discovery failed: ${message}. Paste it below to deliver saved exports.` + ) + console.warn("[vana-login] PS auto-discovery failed", err) } setPending(null) diff --git a/src/pages/settings/components/settings-server-mode.tsx b/src/pages/settings/components/settings-server-mode.tsx index 34054a0e..1d595b85 100644 --- a/src/pages/settings/components/settings-server-mode.tsx +++ b/src/pages/settings/components/settings-server-mode.tsx @@ -2,7 +2,8 @@ import { Text } from "@/components/typography/text" import { useVanaLogin } from "@/hooks/useVanaLogin" import { setAppConfig } from "@/state/store" import type { RootState } from "@/state/store" -import { useState } from "react" +import { invoke } from "@tauri-apps/api/core" +import { useEffect, useState } from "react" import { useDispatch, useSelector } from "react-redux" import { @@ -26,9 +27,15 @@ export function SettingsServerModeSection() { const appConfig = useSelector((state: RootState) => state.app.appConfig) const { connect, disconnect, pending, error, busy, isConnected } = useVanaLogin() - const [manualUrl, setManualUrl] = useState( - appConfig.remoteServerUrl ?? "" - ) + const [manualUrl, setManualUrl] = useState(appConfig.remoteServerUrl ?? "") + + // Sync the editable input whenever Redux's remoteServerUrl changes from + // outside this component — most importantly when auto-discovery populates + // it after Connect with Vana succeeds. Without this, the field stays + // showing whatever value was present at first mount. + useEffect(() => { + setManualUrl(appConfig.remoteServerUrl ?? "") + }, [appConfig.remoteServerUrl]) const isRemote = appConfig.serverMode === "remote" @@ -70,9 +77,11 @@ export function SettingsServerModeSection() {
Connect with Vana - {isConnected + {isConnected && appConfig.remoteServerUrl ? "Signed in. Your Personal Server URL was discovered automatically." - : "Open your browser to authorize this device. Your Personal Server URL will be auto-discovered."} + : isConnected + ? "Signed in. Paste your Personal Server URL below to deliver saved exports." + : "Open your browser to authorize this device. Your Personal Server URL will be auto-discovered."}
@@ -102,9 +111,29 @@ export function SettingsServerModeSection() {
Code: {pending.user_code}
-
- {pending.verification_uri_complete ?? - pending.verification_uri} +
+
) : null} @@ -116,12 +145,10 @@ export function SettingsServerModeSection() { ) : null}
- - Personal Server URL - + Personal Server URL - Auto-populated when you Connect with Vana. You can override - it manually here. + Auto-populated when you Connect with Vana. You can override it + manually here. setManualUrl(e.target.value)} + onChange={e => setManualUrl(e.target.value)} placeholder="https://0xabc.myvana.app" spellCheck={false} value={manualUrl} diff --git a/src/pages/settings/sections/imports/components/import-history-row-actions.tsx b/src/pages/settings/sections/imports/components/import-history-row-actions.tsx index ca272dd1..861f2793 100644 --- a/src/pages/settings/sections/imports/components/import-history-row-actions.tsx +++ b/src/pages/settings/sections/imports/components/import-history-row-actions.tsx @@ -66,6 +66,7 @@ const ImportHistoryRowActionsInner = ({ run.status === "error" || run.status === "stopped" const canSync = canRunAgain && Boolean(rerunPlatform) + const syncLabel = "Collect again" if (isRemoving) { return ( @@ -141,7 +142,7 @@ const ImportHistoryRowActionsInner = ({ className={cn(actionSvgClass, rightIconPaddingClass)} onClick={() => onRunAgain(rerunPlatform)} > - Sync + {syncLabel} ) : null} @@ -194,7 +195,7 @@ const ImportHistoryRowActionsInner = ({ className={itemStyle} onSelect={() => onRunAgain(rerunPlatform)} > - Sync + {syncLabel} ) : null} { expect(String((init as RequestInit).body)).toContain( "client_id=data-connect" ) + // Audience defaults to the stable family identifier — not a per-user + // PS URL. URLSearchParams encodes the literal hyphen as-is. + expect(String((init as RequestInit).body)).toContain( + "audience=vana-personal-server" + ) + }) + + it("allows callers to override the audience", async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "dev-2", + user_code: "WXYZ-1234", + verification_uri: `${HYDRA_PUBLIC}/oauth2/device/verify`, + expires_in: 600, + interval: 5, + }), + { status: 200 } + ) + ) + await startDeviceAuthorization({ audience: ["custom-aud"] }) + const [, init] = vi.mocked(fetch).mock.calls[0] + expect(String((init as RequestInit).body)).toContain("audience=custom-aud") + expect(String((init as RequestInit).body)).not.toContain( + "vana-personal-server" + ) }) it("throws on non-2xx response", async () => { diff --git a/src/services/vanaSession.ts b/src/services/vanaSession.ts index 8ddd9c34..37d4a6d4 100644 --- a/src/services/vanaSession.ts +++ b/src/services/vanaSession.ts @@ -30,6 +30,12 @@ const DATA_CONNECT_CLIENT_ID = // refresh token. 'openid' triggers an id_token in the response too, useful // for downstream surfaces. const DEFAULT_SCOPE = "openid offline" +// Audience requested for the access token. This is a stable family-level +// identifier shared by every user's Personal Server — not the user's +// per-PS URL. The PS gates incoming requests by validating this audience +// plus a wallet-ownership check on the token's subject. Hydra's +// `data-connect` client whitelist must include this value. +const VANA_PS_AUDIENCE = "vana-personal-server" export type DeviceAuthorization = { device_code: string @@ -71,9 +77,11 @@ export async function startDeviceAuthorization(opts?: { const body = new URLSearchParams() body.set("client_id", DATA_CONNECT_CLIENT_ID) body.set("scope", opts?.scope ?? DEFAULT_SCOPE) - if (opts?.audience && opts.audience.length > 0) { - body.set("audience", opts.audience.join(" ")) - } + const audience = + opts?.audience && opts.audience.length > 0 + ? opts.audience + : [VANA_PS_AUDIENCE] + body.set("audience", audience.join(" ")) const res = await tauriFetch( `${HYDRA_PUBLIC_URL.replace(/\/+$/, "")}/oauth2/device/auth`, { diff --git a/src/styles/index.css b/src/styles/index.css index 4177c76a..dcbfea69 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -327,5 +327,3 @@ opacity var(--motion-lg) var(--ease-standard); } } - -