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(() => {