diff --git a/.env.example b/.env.example index 5eb14cea26e..4cb2dc8e642 100644 --- a/.env.example +++ b/.env.example @@ -68,15 +68,14 @@ FREESTYLE_API_KEY= # ----------------------------------------------------------------------------- # Streams (AI Chat Server) # ----------------------------------------------------------------------------- -# Desktop app / client-facing +# Clients (Desktop Web Mobile) STREAMS_URL=http://localhost:8080 -STREAMS_SECRET= -# Streams server internals +# Streams server internals (optional) STREAMS_PORT=8080 STREAMS_INTERNAL_PORT=8081 -STREAMS_INTERNAL_URL=http://127.0.0.1:8081 -STREAMS_DATA_DIR=.data +STREAMS_INTERNAL_URL=http://localhost:8081 +STREAMS_DATA_DIR=./data # ----------------------------------------------------------------------------- # Sentry Error Tracking diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 08cfd883d49..01aee12c549 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -11,11 +11,11 @@ - default-src 'self': Only allow resources from same origin - script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + WebAssembly (for xterm ImageAddon) + PostHog - style-src 'self' 'unsafe-inline': Allow styles from same origin + inline (needed for CSS-in-JS) - - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% http://localhost:8080 http://localhost:8081 https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API (includes Electric proxy) + Durable Streams proxy + PostHog + Sentry + - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% %STREAMS_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API (includes Electric proxy) + Durable Streams proxy + PostHog + Sentry - img-src 'self' data: %NEXT_PUBLIC_API_URL% https://*.public.blob.vercel-storage.com https://github.com https://avatars.githubusercontent.com https://models.dev: Allow images from same origin + data URIs + API (Linear image proxy) + Vercel blob storage + GitHub avatars + model provider logos - font-src 'self': Allow fonts from same origin --> - + diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx index b3b1e50fc0a..8ad206450f5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx @@ -1,3 +1,4 @@ +import { StreamError } from "@superset/durable-session"; import { useDurableChat } from "@superset/durable-session/react"; import { Conversation, @@ -343,11 +344,16 @@ export function ChatInterface({
- {error && ( -
- {error.message} -
- )} + {error && + (() => { + const { message, code } = StreamError.friendly(error); + return ( +
+ {message} + {code && ({code})} +
+ ); + })()} to streams -└── Renderer SSE connection also uses session token (via getConfig tRPC procedure) - -Streams server (apps/streams) -├── Middleware on /v1/* extracts Bearer token from Authorization header -├── Queries auth.sessions table: match token + check expiresAt > now() -├── Returns 401 if no valid session found -├── Attaches userId to Hono context for downstream use -└── Token expires naturally (30 days, same as session config) -``` - -## Implementation Summary - -### Streams server (`apps/streams`) - -- **`src/server.ts`**: Removed `authToken` from `AIDBProxyServerOptions`. Replaced string-comparison middleware with a Drizzle query against `auth.sessions` table — matches token and checks expiry. Attaches `userId` to Hono context. -- **`src/env.ts`**: Replaced `STREAMS_SECRET` with `DATABASE_URL` in env schema. -- **`src/index.ts`**: Removed `authToken: env.STREAMS_SECRET` from `createServer()` call. -- **`package.json`**: Added `@superset/db` and `drizzle-orm` dependencies. -- **`Dockerfile`**: Updated to include `packages/db` in the build and runtime stages. - -### Desktop (`apps/desktop`) - -- **`session-manager.ts`**: Replaced `const STREAMS_SECRET = env.STREAMS_SECRET` with `loadToken()` import. Made `buildProxyHeaders()` async — reads the user's encrypted session token from disk. Added `await` to all call sites. -- **`ai-chat/index.ts`**: Made `getConfig` procedure async. Returns `loadToken()` result instead of `env.STREAMS_SECRET`. -- **`env.main.ts`**: Removed `STREAMS_SECRET` from server schema and runtimeEnv. - -### CI/CD and setup cleanup - -| File | Change | -|------|--------| -| `turbo.jsonc` | Removed `STREAMS_SECRET` from `globalEnv` | -| `.github/workflows/ci.yml` | Removed `STREAMS_SECRET` env and TODO comment | -| `.github/workflows/deploy-preview.yml` | Replaced `STREAMS_SECRET` secret with `DATABASE_URL`; added `needs: deploy-database` | -| `.github/workflows/deploy-production.yml` | Replaced `STREAMS_SECRET` with `DATABASE_URL` in `flyctl secrets set` | -| `.superset/setup.sh` | Removed `step_setup_streams()` function and `STREAMS_SECRET` env output | - -## Key Files - -| File | Role | -|------|------| -| `apps/streams/src/server.ts` | DB-based session validation middleware | -| `apps/streams/src/env.ts` | DATABASE_URL env definition | -| `apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts` | Async buildProxyHeaders() using loadToken() | -| `apps/desktop/src/lib/trpc/routers/ai-chat/index.ts` | getConfig returns session token to renderer | -| `apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.ts` | loadToken() — reads encrypted auth token from disk | -| `packages/db/src/schema/auth.ts` | Sessions table schema used by streams middleware | - -## Verification - -1. **Auth flow works**: Sign in via OAuth on desktop → token saved → streams requests use that token → streams server validates via DB -2. **Unauthenticated requests rejected**: Streams server returns 401 without a valid session token -3. **Session expiry works**: After session expires (30 days default), streams requests fail → user must re-authenticate -4. **No STREAMS_SECRET references remain**: `grep -r STREAMS_SECRET` across source code returns no matches -5. **CI builds pass**: Desktop builds without STREAMS_SECRET env var -6. **SSE connections work**: Renderer ChatInterface connects to streams SSE with session token in Authorization header diff --git a/packages/durable-session/src/client.ts b/packages/durable-session/src/client.ts index 26a3b2de222..cb719995725 100644 --- a/packages/durable-session/src/client.ts +++ b/packages/durable-session/src/client.ts @@ -24,6 +24,7 @@ import { createToolResultsCollection, updateConnectionStatus, } from "./collections"; +import { StreamError } from "./errors"; import { extractTextContent, messageRowToUIMessage } from "./materialize"; import type { ActorType, @@ -131,7 +132,10 @@ export class DurableChatClient< // ═══════════════════════════════════════════════════════════════════════ constructor(options: DurableChatClientOptions) { - this.options = options; + this.options = { + ...options, + proxyUrl: options.proxyUrl.replace(/\/+$/, ""), + }; this.sessionId = options.sessionId; this.actorId = options.actorId ?? crypto.randomUUID(); this.actorType = options.actorType ?? "user"; @@ -335,8 +339,7 @@ export class DurableChatClient< }); if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Request failed: ${response.status} ${errorText}`); + throw StreamError.fromResponse(response); } } @@ -381,17 +384,12 @@ export class DurableChatClient< }); } - stop(): void { - fetch(`${this.options.proxyUrl}/v1/sessions/${this.sessionId}/stop`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messageId: null }), // null = stop all - }).catch((err) => { - console.warn("Failed to stop generation:", err); + async stop(): Promise { + await this.postToProxy(`/v1/sessions/${this.sessionId}/stop`, { + messageId: null, }); } - /** Local-only clear — does not affect the durable stream. */ clear(): void { this.options.onMessagesChange?.([]); } @@ -589,10 +587,7 @@ export class DurableChatClient< ); if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Failed to fork session: ${response.status} ${errorText}`, - ); + throw StreamError.fromResponse(response); } return (await response.json()) as ForkResult; @@ -614,10 +609,7 @@ export class DurableChatClient< ); if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Failed to register agents: ${response.status} ${errorText}`, - ); + throw StreamError.fromResponse(response); } } @@ -635,10 +627,7 @@ export class DurableChatClient< ); if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Failed to unregister agent: ${response.status} ${errorText}`, - ); + throw StreamError.fromResponse(response); } } @@ -660,7 +649,6 @@ export class DurableChatClient< updateConnectionStatus(meta, "connecting"), ); - // Skip server call in test mode (injected sessionDB) if (!this.options.sessionDB) { const response = await fetch( `${this.options.proxyUrl}/v1/sessions/${this.sessionId}`, @@ -671,12 +659,8 @@ export class DurableChatClient< }, ); - if ( - !response.ok && - response.status !== 200 && - response.status !== 201 - ) { - throw new Error(`Failed to create session: ${response.status}`); + if (!response.ok) { + throw StreamError.fromResponse(response); } } diff --git a/packages/durable-session/src/errors.ts b/packages/durable-session/src/errors.ts new file mode 100644 index 00000000000..6ad89ab6567 --- /dev/null +++ b/packages/durable-session/src/errors.ts @@ -0,0 +1,53 @@ +const FRIENDLY_MESSAGES: Record = { + 401: "Your session has expired. Please sign in again.", + 403: "You don't have permission to access this chat.", + 404: "Chat session not found. It may have been deleted.", + 429: "Too many requests. Please wait a moment and try again.", + 500: "Something went wrong on our end. Please try again.", + 502: "Chat server is temporarily unavailable. Please try again.", + 503: "Chat server is temporarily unavailable. Please try again.", +}; + +const NETWORK_MESSAGE = + "Unable to connect to the chat server. Check your internet connection."; + +export class StreamError extends Error { + readonly status: number; + readonly friendlyMessage: string; + + constructor(status: number, detail?: string) { + const friendly = + FRIENDLY_MESSAGES[status] ?? `Unexpected error (${status})`; + super(detail ?? friendly); + this.name = "StreamError"; + this.status = status; + this.friendlyMessage = friendly; + } + + static fromResponse(response: Response): StreamError { + return new StreamError(response.status); + } + + static friendly(error: unknown): { message: string; code: string | null } { + if (error instanceof StreamError) { + return { + message: error.friendlyMessage, + code: error.status > 0 ? `HTTP ${error.status}` : "NETWORK_ERROR", + }; + } + if (error instanceof TypeError && error.message.includes("fetch")) { + return { message: NETWORK_MESSAGE, code: "NETWORK_ERROR" }; + } + if (error instanceof Error) { + if (error.message.includes("Content Security Policy")) { + return { + message: + "Connection blocked by security policy. The chat server URL may not be allowed.", + code: "CSP_VIOLATION", + }; + } + return { message: error.message, code: null }; + } + return { message: "An unexpected error occurred.", code: "UNKNOWN" }; + } +} diff --git a/packages/durable-session/src/index.ts b/packages/durable-session/src/index.ts index 78d02d4ea3a..9fe08eaf8c4 100644 --- a/packages/durable-session/src/index.ts +++ b/packages/durable-session/src/index.ts @@ -63,6 +63,7 @@ // ============================================================================ export { createDurableChatClient, DurableChatClient } from "./client"; +export { StreamError } from "./errors"; // ============================================================================ // Schema (STATE-PROTOCOL) diff --git a/packages/durable-session/src/react/use-durable-chat.ts b/packages/durable-session/src/react/use-durable-chat.ts index 78eedcdea1f..3eb0acd49d2 100644 --- a/packages/durable-session/src/react/use-durable-chat.ts +++ b/packages/durable-session/src/react/use-durable-chat.ts @@ -271,8 +271,12 @@ export function useDurableChat< [client], ); - const stop = useCallback(() => { - client.stop(); + const stop = useCallback(async () => { + try { + await client.stop(); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } }, [client]); const clear = useCallback(() => {