diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 426f36c4676..b45387ab59f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,4 @@ jobs: - name: Build CLI and Desktop run: bun turbo run build --filter=@superset/cli --filter=@superset/desktop env: - # TODO: Remove once desktop app no longer validates STREAMS_* at build time STREAMS_URL: ${{ secrets.STREAMS_URL }} - STREAMS_SECRET: ${{ secrets.STREAMS_SECRET }} diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 66f98a0ca4a..0687c52d811 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -124,9 +124,21 @@ jobs: deploy-streams-preview: name: Deploy Streams (Fly.io) runs-on: ubuntu-latest + needs: deploy-database steps: - uses: actions/checkout@v4 + + - name: Download database info + uses: actions/download-artifact@v4 + with: + name: database-status + + - name: Load database URL + run: | + source database-status.env + echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV + - name: Deploy Streams preview to Fly.io uses: superfly/fly-pr-review-apps@1.3.0 with: @@ -135,7 +147,7 @@ jobs: org: ${{ vars.FLY_ORG }} config: apps/streams/fly.toml secrets: | - STREAMS_SECRET=${{ secrets.STREAMS_SECRET }} + DATABASE_URL=${{ env.DATABASE_URL }} env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - name: Save streams status diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 814c8841c85..a86bded6d10 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -411,7 +411,7 @@ jobs: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} run: | flyctl secrets set \ - STREAMS_SECRET="${{ secrets.STREAMS_SECRET }}" \ + DATABASE_URL="${{ secrets.DATABASE_URL }}" \ --app superset-stream \ --stage - name: Deploy to Fly.io diff --git a/.superset/setup.sh b/.superset/setup.sh index 8ee4ac394f6..4413647f4cc 100755 --- a/.superset/setup.sh +++ b/.superset/setup.sh @@ -299,16 +299,6 @@ step_start_electric() { return 0 } -step_setup_streams() { - echo "🔄 Setting up Streams secret..." - - STREAMS_SECRET=$(openssl rand -hex 32) - export STREAMS_SECRET - - success "Streams secret generated" - return 0 -} - step_write_env() { echo "📝 Writing .env file..." @@ -352,9 +342,6 @@ step_write_env() { echo "ELECTRIC_SECRET=$ELECTRIC_SECRET" fi - echo "" - echo "# Workspace Streams (AI Chat Server)" - echo "STREAMS_SECRET=$STREAMS_SECRET" } >> .env success "Workspace .env written" @@ -390,12 +377,7 @@ main() { step_failed "Start Electric SQL" fi - # Step 6: Setup Streams secret - if ! step_setup_streams; then - step_failed "Setup Streams secret" - fi - - # Step 7: Write .env file + # Step 6: Write .env file if ! step_write_env; then step_failed "Write .env file" fi diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts index b71593850ea..08f77934cf7 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { env } from "main/env.main"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { loadToken } from "../auth/utils/auth-functions"; import { readClaudeSessionMessages, scanClaudeSessions, @@ -55,10 +56,13 @@ function scanCustomCommands(cwd: string): CommandEntry[] { export const createAiChatRouter = () => { return router({ - getConfig: publicProcedure.query(() => ({ - proxyUrl: env.STREAMS_URL, - authToken: env.STREAMS_SECRET, - })), + getConfig: publicProcedure.query(async () => { + const { token } = await loadToken(); + return { + proxyUrl: env.STREAMS_URL, + authToken: token, + }; + }), getSlashCommands: publicProcedure .input(z.object({ cwd: z.string() })) diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts index ba2e5cce226..7490c7c8804 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts @@ -9,11 +9,11 @@ import { } from "@superset/agent"; import { app } from "electron"; import { env } from "main/env.main"; +import { loadToken } from "../../../auth/utils/auth-functions"; import { buildClaudeEnv } from "../auth"; import type { SessionStore } from "../session-store"; const PROXY_URL = env.STREAMS_URL; -const STREAMS_SECRET = env.STREAMS_SECRET; function getClaudeBinaryPath(): string { if (app.isPackaged) { @@ -30,10 +30,14 @@ function getClaudeBinaryPath(): string { ); } -function buildProxyHeaders(): Record { +async function buildProxyHeaders(): Promise> { + const { token } = await loadToken(); + if (!token) { + throw new Error("User not authenticated"); + } return { "Content-Type": "application/json", - Authorization: `Bearer ${STREAMS_SECRET}`, + Authorization: `Bearer ${token}`, }; } @@ -97,7 +101,7 @@ export class ChatSessionManager extends EventEmitter { permissionMode?: string; maxThinkingTokens?: number; }): Promise { - const headers = buildProxyHeaders(); + const headers = await buildProxyHeaders(); const createRes = await fetch(`${PROXY_URL}/v1/sessions/${sessionId}`, { method: "PUT", @@ -258,7 +262,7 @@ export class ChatSessionManager extends EventEmitter { const abortController = new AbortController(); this.runningAgents.set(sessionId, abortController); - const headers = buildProxyHeaders(); + const headers = await buildProxyHeaders(); let messageId: string | undefined; try { @@ -455,7 +459,7 @@ export class ChatSessionManager extends EventEmitter { try { await fetch(`${PROXY_URL}/v1/sessions/${sessionId}/stop`, { method: "POST", - headers: buildProxyHeaders(), + headers: await buildProxyHeaders(), body: JSON.stringify({}), }); } catch (error) { @@ -479,7 +483,7 @@ export class ChatSessionManager extends EventEmitter { try { await fetch(`${PROXY_URL}/v1/sessions/${sessionId}/stop`, { method: "POST", - headers: buildProxyHeaders(), + headers: await buildProxyHeaders(), body: JSON.stringify({}), }); } catch (err) { @@ -516,7 +520,7 @@ export class ChatSessionManager extends EventEmitter { async deleteSession({ sessionId }: { sessionId: string }): Promise { console.log(`[chat/session] Deleting session ${sessionId}`); - const headers = buildProxyHeaders(); + const headers = await buildProxyHeaders(); const controller = this.runningAgents.get(sessionId); if (controller) { diff --git a/apps/desktop/src/main/env.main.ts b/apps/desktop/src/main/env.main.ts index 2ad404687a1..78042cbc2fd 100644 --- a/apps/desktop/src/main/env.main.ts +++ b/apps/desktop/src/main/env.main.ts @@ -20,7 +20,6 @@ export const env = createEnv({ NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), SENTRY_DSN_DESKTOP: z.string().optional(), STREAMS_URL: z.url(), - STREAMS_SECRET: z.string().min(1), }, runtimeEnv: { @@ -34,7 +33,6 @@ export const env = createEnv({ NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, SENTRY_DSN_DESKTOP: process.env.SENTRY_DSN_DESKTOP, STREAMS_URL: process.env.STREAMS_URL, - STREAMS_SECRET: process.env.STREAMS_SECRET, }, emptyStringAsUndefined: true, // Only allow skipping validation in development (never in production) diff --git a/apps/streams/Dockerfile b/apps/streams/Dockerfile index ed71660d151..04618f76974 100644 --- a/apps/streams/Dockerfile +++ b/apps/streams/Dockerfile @@ -7,11 +7,13 @@ WORKDIR /app # Install dependencies (workspace root) COPY package.json bun.lock ./ COPY tooling/typescript/package.json tooling/typescript/ +COPY packages/db/package.json packages/db/ COPY apps/streams/package.json apps/streams/ RUN bun install --frozen-lockfile # Copy source COPY tooling/typescript tooling/typescript +COPY packages/db packages/db COPY apps/streams apps/streams # Build @@ -28,10 +30,12 @@ ENV NODE_ENV=production # Install production dependencies (workspace root) COPY package.json bun.lock ./ COPY tooling/typescript/package.json tooling/typescript/ +COPY packages/db/package.json packages/db/ COPY apps/streams/package.json apps/streams/ RUN bun install --frozen-lockfile --production -# Copy built files +# Copy built files and db package (needed at runtime for schema) +COPY --from=builder /app/packages/db ./packages/db COPY --from=builder /app/apps/streams/dist ./apps/streams/dist WORKDIR /app/apps/streams diff --git a/apps/streams/package.json b/apps/streams/package.json index 85780c9fc72..a959cb7320f 100644 --- a/apps/streams/package.json +++ b/apps/streams/package.json @@ -16,10 +16,12 @@ "@durable-streams/client": "^0.2.1", "@durable-streams/server": "^0.2.0", "@hono/node-server": "^1.13.0", + "@superset/db": "workspace:*", "@superset/durable-session": "workspace:*", "@t3-oss/env-core": "^0.13.8", "@tanstack/ai": "^0.3.0", "@tanstack/db": "0.5.25", + "drizzle-orm": "0.45.1", "hono": "^4.4.0", "zod": "^4.3.5" }, diff --git a/apps/streams/src/env.ts b/apps/streams/src/env.ts index f219c421eac..d280c406276 100644 --- a/apps/streams/src/env.ts +++ b/apps/streams/src/env.ts @@ -7,7 +7,7 @@ export const env = createEnv({ STREAMS_INTERNAL_PORT: z.coerce.number(), STREAMS_INTERNAL_URL: z.string().url(), STREAMS_DATA_DIR: z.string().min(1), - STREAMS_SECRET: z.string().min(1), + DATABASE_URL: z.string().url(), }, clientPrefix: "PUBLIC_", client: {}, diff --git a/apps/streams/src/index.ts b/apps/streams/src/index.ts index 546fee9fbf9..bdc360dfbec 100644 --- a/apps/streams/src/index.ts +++ b/apps/streams/src/index.ts @@ -40,7 +40,6 @@ const { app } = createServer({ baseUrl: env.STREAMS_INTERNAL_URL, cors: true, logging: true, - authToken: env.STREAMS_SECRET, }); const proxyServer = serve( diff --git a/apps/streams/src/server.ts b/apps/streams/src/server.ts index 26d7e3176c3..21a221fa3f1 100644 --- a/apps/streams/src/server.ts +++ b/apps/streams/src/server.ts @@ -1,3 +1,6 @@ +import { db } from "@superset/db"; +import { sessions } from "@superset/db/schema/auth"; +import { and, eq, gt } from "drizzle-orm"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; @@ -16,15 +19,20 @@ import { } from "./routes"; import type { AIDBProtocolOptions } from "./types"; +type SessionEnv = { + Variables: { + userId: string; + }; +}; + export interface AIDBProxyServerOptions extends AIDBProtocolOptions { cors?: boolean; logging?: boolean; corsOrigins?: string | string[]; - authToken?: string; } export function createServer(options: AIDBProxyServerOptions) { - const app = new Hono(); + const app = new Hono(); const protocol = new AIDBSessionProtocol({ baseUrl: options.baseUrl, @@ -56,16 +64,26 @@ export function createServer(options: AIDBProxyServerOptions) { app.route("/health", createHealthRoutes()); // No auth on health; Bearer token required on /v1/* - if (options.authToken) { - const expectedHeader = `Bearer ${options.authToken}`; - app.use("/v1/*", async (c, next) => { - const authorization = c.req.header("Authorization"); - if (authorization !== expectedHeader) { - return c.json({ error: "Unauthorized" }, 401); - } - return next(); - }); - } + app.use("/v1/*", async (c, next) => { + const authorization = c.req.header("Authorization"); + if (!authorization?.startsWith("Bearer ")) { + return c.json({ error: "Unauthorized" }, 401); + } + + const token = authorization.slice(7); + const [session] = await db + .select({ userId: sessions.userId }) + .from(sessions) + .where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date()))) + .limit(1); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + c.set("userId", session.userId); + return next(); + }); const v1 = new Hono(); v1.route("/sessions", createSessionRoutes(protocol)); diff --git a/bun.lock b/bun.lock index ef670d2f713..940caff94bf 100644 --- a/bun.lock +++ b/bun.lock @@ -486,10 +486,12 @@ "@durable-streams/client": "^0.2.1", "@durable-streams/server": "^0.2.0", "@hono/node-server": "^1.13.0", + "@superset/db": "workspace:*", "@superset/durable-session": "workspace:*", "@t3-oss/env-core": "^0.13.8", "@tanstack/ai": "^0.3.0", "@tanstack/db": "0.5.25", + "drizzle-orm": "0.45.1", "hono": "^4.4.0", "zod": "^4.3.5", }, @@ -805,9 +807,6 @@ "version": "0.1.0", }, }, - "overrides": { - "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", - }, "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], @@ -1103,7 +1102,7 @@ "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], - "@electric-sql/client": ["@electric-sql/client@https://pkg.pr.new/@electric-sql/client@3724", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }], + "@electric-sql/client": ["@electric-sql/client@1.5.2", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }, "sha512-4kDyEXBhRz7+84Y1JQtKXy0FdRyXkjU/9rM9N9TOmGxWT0zuAl6dWIG/iJUu+0geBELYo8Ot8fVSMTqg5bf/Tw=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], diff --git a/docs/replace-streams-secret-with-session-auth.md b/docs/replace-streams-secret-with-session-auth.md index 9e6931f57e5..b25630b9089 100644 --- a/docs/replace-streams-secret-with-session-auth.md +++ b/docs/replace-streams-secret-with-session-auth.md @@ -1,230 +1,86 @@ # Replace STREAMS_SECRET with Session-Based Auth +## Status: Implemented + ## Problem -The streams server uses a shared static `STREAMS_SECRET` (a 64-char hex string) for authentication. Every desktop instance uses the same secret. This means: +The streams server used a shared static `STREAMS_SECRET` (a 64-char hex string) for authentication. Every desktop instance used the same secret. This meant: -- **No per-user identity** — streams server can't tell who is making requests -- **No expiration** — the secret never expires -- **No revocation** — can't invalidate a single client without rotating the secret for everyone -- **Extra env coupling** — desktop build requires `STREAMS_SECRET` at compile time +- **No per-user identity** — streams server couldn't tell who was making requests +- **No expiration** — the secret never expired +- **No revocation** — couldn't invalidate a single client without rotating the secret for everyone +- **Extra env coupling** — desktop build required `STREAMS_SECRET` at compile time - **Separate from real auth** — not integrated with the better-auth system used everywhere else ## Solution Use the user's existing better-auth session token (already obtained via OAuth login) to authenticate streams requests instead of a shared secret. -The desktop already authenticates users via OAuth → gets a session token → stores it encrypted on disk. We just need to: +The desktop authenticates users via OAuth, gets a session token, and stores it encrypted at `~/.superset/auth-token.enc`. We: 1. Pass that token to the streams server instead of `STREAMS_SECRET` -2. Have the streams server validate it against better-auth - -## Current Flow - -``` -Desktop app -├── OAuth login → gets session token → stored encrypted at ~/.superset/auth-token.enc -├── Renderer uses token for better-auth API calls (via auth-client.ts) -├── BUT for streams: uses separate STREAMS_SECRET from env -└── Session manager sends Authorization: Bearer to streams +2. Have the streams server validate it via a direct DB query against the `auth.sessions` table -Streams server (apps/streams) -├── Loads STREAMS_SECRET from env -├── Middleware on /v1/* does string comparison: authorization === `Bearer ${STREAMS_SECRET}` -└── No user identity, no session validation -``` +**Why a direct DB query instead of `@superset/auth/server`?** The auth server module initializes Stripe, Resend, QStash, OAuth providers, etc. — requiring ~15 env vars. Way too heavy for the streams server. A direct DB query against the sessions table needs only `DATABASE_URL`. -## Target Flow +## Flow ``` Desktop app -├── OAuth login → gets session token (same as today) -├── Main process loads token from auth-token.enc (same as today) +├── OAuth login → gets session token → stored encrypted at ~/.superset/auth-token.enc +├── Main process loads token via loadToken() from auth-functions.ts ├── Session manager sends Authorization: Bearer to streams -└── Renderer SSE connection also uses session token +└── Renderer SSE connection also uses session token (via getConfig tRPC procedure) Streams server (apps/streams) -├── Middleware on /v1/* validates token via better-auth -├── Calls auth.api.getSession({ headers }) — same pattern as apps/api -├── Knows which user is making requests +├── 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 +## Implementation Summary -### Step 1: Streams server — replace string comparison with better-auth validation +### Streams server (`apps/streams`) -**File: `apps/streams/src/server.ts`** +- **`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. -The current auth middleware (lines 59-68) does a simple string match: +### Desktop (`apps/desktop`) -```typescript -// CURRENT — remove this -if (options.authToken) { - const expectedHeader = `Bearer ${options.authToken}`; - app.use("/v1/*", async (c, next) => { - const authorization = c.req.header("Authorization"); - if (authorization !== expectedHeader) { - return c.json({ error: "Unauthorized" }, 401); - } - return next(); - }); -} -``` - -Replace with better-auth session validation: - -```typescript -// NEW — validate session via better-auth -import { auth } from "@superset/auth/server"; - -app.use("/v1/*", async (c, next) => { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }); - if (!session) { - return c.json({ error: "Unauthorized" }, 401); - } - // Optionally attach session to context for downstream use - c.set("session", session); - return next(); -}); -``` +- **`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. -This is the exact same pattern used by `apps/api/src/trpc/context.ts` (line 10-12). - -**File: `apps/streams/src/types.ts`** (or wherever `AIDBProxyServerOptions` is) - -Remove `authToken` from the server options interface. The server now validates tokens itself via better-auth rather than comparing against a static string. - -**File: `apps/streams/src/index.ts`** - -Remove `authToken: env.STREAMS_SECRET` from the `createServer()` call (line 43). - -**File: `apps/streams/src/env.ts`** - -Remove `STREAMS_SECRET` from the env schema (line 10). The streams server will need the database connection and `BETTER_AUTH_SECRET` env vars instead (same as `apps/api`). Check what `@superset/auth/server` needs to initialize — it uses `packages/auth/src/env.ts` for its env requirements. - -### Step 2: Desktop — pass user session token to streams instead of STREAMS_SECRET - -**File: `apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts`** - -The session manager currently imports `STREAMS_SECRET` from env and uses it in `buildProxyHeaders()` (lines 15-37): - -```typescript -// CURRENT — remove -const STREAMS_SECRET = env.STREAMS_SECRET; - -function buildProxyHeaders(): Record { - return { - "Content-Type": "application/json", - Authorization: `Bearer ${STREAMS_SECRET}`, - }; -} -``` - -Replace with the user's auth token loaded from encrypted disk storage: - -```typescript -// NEW — use the user's session token -import { loadToken } from "../../auth/utils/auth-functions"; - -async function buildProxyHeaders(): Promise> { - const { token } = await loadToken(); - if (!token) { - throw new Error("User not authenticated"); - } - return { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }; -} -``` - -`loadToken()` reads the encrypted token from `~/.superset/auth-token.enc` — see `apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.ts` (lines 29-40). - -Note: `buildProxyHeaders()` becomes async. Update all call sites (they're all in async functions already, so this is straightforward — just add `await`). - -**File: `apps/desktop/src/lib/trpc/routers/ai-chat/index.ts`** - -The `getConfig` procedure currently exposes `STREAMS_SECRET` to the renderer (lines 58-61): - -```typescript -// CURRENT -getConfig: publicProcedure.query(() => ({ - proxyUrl: env.STREAMS_URL, - authToken: env.STREAMS_SECRET, -})), -``` - -Replace with the user's session token: - -```typescript -// NEW -getConfig: publicProcedure.query(async () => { - const { token } = await loadToken(); - return { - proxyUrl: env.STREAMS_URL, - authToken: token, - }; -}), -``` - -The renderer uses this in `ChatInterface.tsx` (line 70, 87-88) to set the `Authorization` header on SSE connections. No renderer changes needed — it already passes `authToken` as a Bearer token. - -### Step 3: Remove STREAMS_SECRET from desktop env - -**File: `apps/desktop/src/main/env.main.ts`** - -Remove `STREAMS_SECRET` from the env schema (line 23) and runtimeEnv (line 37). - -### Step 4: Remove STREAMS_SECRET from CI/CD and setup - -**Files to update:** +### CI/CD and setup cleanup | File | Change | |------|--------| -| `.github/workflows/ci.yml` | Remove `STREAMS_SECRET` from desktop build env (lines ~131-132) | -| `.github/workflows/deploy-preview.yml` | Remove `STREAMS_SECRET` from `flyctl secrets set` (line ~138) | -| `.github/workflows/deploy-production.yml` | Remove `STREAMS_SECRET` from `flyctl secrets set` (line ~414) | -| `.superset/setup.sh` | Remove `STREAMS_SECRET` generation (lines ~302-310) | -| `.env` / `.env.example` | Remove `STREAMS_SECRET` | - -### Step 5: Add auth dependencies to streams server - -The streams server (`apps/streams`) needs to import `@superset/auth/server` for session validation. This means it needs: - -1. Add `@superset/auth` as a workspace dependency in `apps/streams/package.json` -2. Ensure the auth-related env vars are available to the streams server: - - `BETTER_AUTH_SECRET` - - `DATABASE_URL` (auth needs DB access for session lookup) - - Other vars required by `packages/auth/src/env.ts` - -Check `packages/auth/src/env.ts` for the full list of required env vars. Some (like Stripe, Resend, OAuth secrets) may need to be made optional if they aren't already, since the streams server only needs session validation — not the full auth feature set. - -**Alternative**: If adding the full auth dependency to streams feels heavy, you can use better-auth's JWT verification instead. The `jwt()` plugin is already configured in the auth server (RS256, 1hr expiry). The streams server could verify JWTs using just the public key (JWKS endpoint) without needing a database connection. This is a lighter-weight option but requires the desktop to obtain a JWT (via `auth.api.getToken()`) rather than using the raw session token. +| `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 Reference +## Key Files | File | Role | |------|------| -| `apps/streams/src/server.ts:59-68` | Current string-comparison auth middleware | -| `apps/streams/src/env.ts:10` | STREAMS_SECRET env definition | -| `apps/streams/src/index.ts:43` | Passes STREAMS_SECRET to createServer() | -| `apps/desktop/src/main/env.main.ts:23,37` | Desktop env schema with STREAMS_SECRET | -| `apps/desktop/src/lib/trpc/routers/ai-chat/index.ts:58-61` | getConfig exposes STREAMS_SECRET to renderer | -| `apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts:15-37` | buildProxyHeaders() uses STREAMS_SECRET | -| `apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.ts:29-40` | loadToken() — reads encrypted auth token from disk | -| `apps/desktop/src/renderer/lib/auth-client.ts:11-18` | Renderer auth token management | -| `apps/desktop/src/renderer/screens/main/.../ChatInterface.tsx:70,85-88` | Renderer uses getConfig authToken for SSE | -| `apps/api/src/trpc/context.ts:10-12` | Reference pattern — API validates sessions via `auth.api.getSession()` | -| `packages/auth/src/server.ts:126-135` | JWT plugin config (RS256, 1hr) | -| `packages/auth/src/server.ts:427` | bearer() plugin for header-based auth | +| `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 it +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 the codebase returns nothing +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/turbo.jsonc b/turbo.jsonc index 65506106c65..041a7bb6abd 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -17,8 +17,7 @@ "POSTHOG_PROJECT_ID", "KV_REST_API_URL", "KV_REST_API_TOKEN", - "STREAMS_URL", - "STREAMS_SECRET" + "STREAMS_URL" ], "globalPassThroughEnv": [ "NODE_ENV",