From 8628be10371cc528310022057663a7c68ca998a7 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 11:30:45 -0500 Subject: [PATCH 01/40] Investigate Convex Upstash warnings --- .env.example | 19 +- apps/server/convex/_generated/api.d.ts | 41 +- apps/server/convex/backgroundStream.ts | 123 ++- apps/server/convex/chats.test.ts | 191 +++- apps/server/convex/chats.ts | 393 +++++-- apps/server/convex/convex.config.ts | 2 - apps/server/convex/crons.ts | 34 +- apps/server/convex/http.ts | 15 - apps/server/convex/lib/redisRest.ts | 238 ----- apps/server/convex/lib/upstashUsage.ts | 90 ++ apps/server/convex/schema.ts | 2 + apps/server/convex/streaming.ts | 981 ------------------ apps/server/convex/users.ts | 264 ++++- apps/server/package.json | 17 +- apps/web/package.json | 7 +- apps/web/src/components/app-sidebar.tsx | 45 +- .../src/components/delete-account-modal.tsx | 20 +- .../web/src/hooks/use-persistent-chat.test.ts | 75 ++ apps/web/src/hooks/use-persistent-chat.ts | 89 +- apps/web/src/lib/redis.ts | 231 ++--- apps/web/src/lib/server-auth.test.ts | 63 ++ apps/web/src/lib/server-auth.ts | 38 +- apps/web/src/lib/title-generation.ts | 57 + apps/web/src/lib/upstash.ts | 51 + apps/web/src/routeTree.gen.ts | 120 ++- apps/web/src/routes/api/chat.ts | 518 --------- apps/web/src/routes/api/models.ts | 73 ++ apps/web/src/routes/api/openrouter-key.ts | 47 +- apps/web/src/routes/api/typing.ts | 49 +- apps/web/src/routes/api/workflow/cleanup.ts | 163 +++ .../src/routes/api/workflow/delete-account.ts | 263 +++++ .../src/routes/api/workflow/export-chat.ts | 235 +++++ .../src/routes/api/workflow/generate-title.ts | 300 ++++++ apps/web/src/stores/model.ts | 2 +- bun.lock | 44 +- package.json | 6 +- scripts/check-redis.ts | 108 +- scripts/dev.ts | 53 + 38 files changed, 2816 insertions(+), 2251 deletions(-) delete mode 100644 apps/server/convex/lib/redisRest.ts create mode 100644 apps/server/convex/lib/upstashUsage.ts delete mode 100644 apps/server/convex/streaming.ts create mode 100644 apps/web/src/hooks/use-persistent-chat.test.ts create mode 100644 apps/web/src/lib/server-auth.test.ts create mode 100644 apps/web/src/lib/title-generation.ts create mode 100644 apps/web/src/lib/upstash.ts delete mode 100644 apps/web/src/routes/api/chat.ts create mode 100644 apps/web/src/routes/api/models.ts create mode 100644 apps/web/src/routes/api/workflow/cleanup.ts create mode 100644 apps/web/src/routes/api/workflow/delete-account.ts create mode 100644 apps/web/src/routes/api/workflow/export-chat.ts create mode 100644 apps/web/src/routes/api/workflow/generate-title.ts create mode 100644 scripts/dev.ts diff --git a/.env.example b/.env.example index bd4384e5..627e8cbe 100644 --- a/.env.example +++ b/.env.example @@ -67,15 +67,20 @@ VITE_CONVEX_SITE_URL=http://localhost:3210 # VITE_POSTHOG_HOST=https://us.i.posthog.com # ============================================================================== -# OPTIONAL: Redis (Resumable Streams) +# OPTIONAL: Upstash Redis + QStash (Rate limiting, workflows, caching) # ============================================================================== -# Local Redis (recommended for resumable streams in dev) -# REDIS_URL=redis://localhost:6379 - -# Upstash Redis (for resumable streams in production) -# Get credentials from: https://console.upstash.com/redis +# Get Redis credentials from: https://console.upstash.com/redis # UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io -# UPSTASH_REDIS_REST_TOKEN=your_token_here +# UPSTASH_REDIS_REST_TOKEN=your_redis_token_here +# +# Get QStash credentials from: https://console.upstash.com/qstash +# QSTASH_URL=https://qstash-us-east-1.upstash.io +# QSTASH_TOKEN=your_qstash_token_here +# QSTASH_CURRENT_SIGNING_KEY=sig_current_key_here +# QSTASH_NEXT_SIGNING_KEY=sig_next_key_here +# +# Protects maintenance workflow endpoints +# WORKFLOW_CLEANUP_TOKEN=generate_a_long_random_secret # ============================================================================== # DEV ONLY: Auth Bypass diff --git a/apps/server/convex/_generated/api.d.ts b/apps/server/convex/_generated/api.d.ts index d26b8854..036493c6 100644 --- a/apps/server/convex/_generated/api.d.ts +++ b/apps/server/convex/_generated/api.d.ts @@ -26,15 +26,14 @@ import type * as lib_origins from "../lib/origins.js"; import type * as lib_profiles from "../lib/profiles.js"; import type * as lib_rateLimitUtils from "../lib/rateLimitUtils.js"; import type * as lib_rateLimiter from "../lib/rateLimiter.js"; -import type * as lib_redisRest from "../lib/redisRest.js"; import type * as lib_sanitize from "../lib/sanitize.js"; +import type * as lib_upstashUsage from "../lib/upstashUsage.js"; import type * as messages from "../messages.js"; import type * as migrations from "../migrations.js"; import type * as previewSeed from "../previewSeed.js"; import type * as promptTemplates from "../promptTemplates.js"; import type * as search from "../search.js"; import type * as stats from "../stats.js"; -import type * as streaming from "../streaming.js"; import type * as users from "../users.js"; import type { @@ -62,15 +61,14 @@ declare const fullApi: ApiFromModules<{ "lib/profiles": typeof lib_profiles; "lib/rateLimitUtils": typeof lib_rateLimitUtils; "lib/rateLimiter": typeof lib_rateLimiter; - "lib/redisRest": typeof lib_redisRest; "lib/sanitize": typeof lib_sanitize; + "lib/upstashUsage": typeof lib_upstashUsage; messages: typeof messages; migrations: typeof migrations; previewSeed: typeof previewSeed; promptTemplates: typeof promptTemplates; search: typeof search; stats: typeof stats; - streaming: typeof streaming; users: typeof users; }>; @@ -2212,39 +2210,4 @@ export declare const components: { getServerTime: FunctionReference<"mutation", "internal", {}, number>; }; }; - persistentTextStreaming: { - lib: { - addChunk: FunctionReference< - "mutation", - "internal", - { final: boolean; streamId: string; text: string }, - any - >; - createStream: FunctionReference<"mutation", "internal", {}, any>; - getStreamStatus: FunctionReference< - "query", - "internal", - { streamId: string }, - "pending" | "streaming" | "done" | "error" | "timeout" - >; - getStreamText: FunctionReference< - "query", - "internal", - { streamId: string }, - { - status: "pending" | "streaming" | "done" | "error" | "timeout"; - text: string; - } - >; - setStreamStatus: FunctionReference< - "mutation", - "internal", - { - status: "pending" | "streaming" | "done" | "error" | "timeout"; - streamId: string; - }, - any - >; - }; - }; }; diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index 26054767..c99d5d04 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -11,6 +11,10 @@ import { calculateUsageCents, } from "./lib/billingUtils"; import type { UsagePayload } from "./lib/billingUtils"; +import { + getDailyUsageFromUpstash, + incrementDailyUsageInUpstash, +} from "./lib/upstashUsage"; import { requireAuthUserId } from "./lib/auth"; import { decryptSecret } from "./lib/crypto"; @@ -26,6 +30,16 @@ const chainOfThoughtPartValidator = v.object({ errorText: v.optional(v.string()), }); +const streamOptionsValidator = v.object({ + enableReasoning: v.optional(v.boolean()), + reasoningEffort: v.optional(v.string()), + enableWebSearch: v.optional(v.boolean()), + supportsToolCalls: v.optional(v.boolean()), + maxSteps: v.optional(v.number()), + dynamicPrompt: v.optional(v.boolean()), + jonMode: v.optional(v.boolean()), +}); + type ChainOfThoughtPart = { type: "reasoning" | "tool"; index: number; @@ -89,6 +103,20 @@ function isWebSearchToolName(toolName: string | undefined): boolean { return normalized === "websearch" || normalized === "web_search"; } +function getLatestUserSeedText( + messages: Array<{ role: string; content: string }>, +): string | null { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role !== "user") continue; + const normalized = message.content.trim().slice(0, 300); + if (normalized.length > 0) { + return normalized; + } + } + return null; +} + function truncateText(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, Math.max(0, maxChars - 1))}...`; @@ -265,32 +293,26 @@ export const startStream = mutation({ role: v.string(), content: v.string(), })), - options: v.optional(v.object({ - enableReasoning: v.optional(v.boolean()), - reasoningEffort: v.optional(v.string()), - enableWebSearch: v.optional(v.boolean()), - supportsToolCalls: v.optional(v.boolean()), - maxSteps: v.optional(v.number()), - })), + options: v.optional(streamOptionsValidator), }, returns: v.id("streamJobs"), - handler: async (ctx, args) => { + handler: async (ctx, args) => { const userId = await requireAuthUserId(ctx, args.userId); const chat = await ctx.db.get(args.chatId); if (!chat || chat.userId !== userId) { throw new Error("Chat not found or unauthorized"); } - if (args.provider === "osschat") { + if (args.provider === "osschat") { + const currentDate = getCurrentDateKey(); const user = await ctx.db.get(userId); if (!user) { throw new Error("User not found"); } - const currentDate = getCurrentDateKey(); - const usedCents = + const persistedUsageCents = user.aiUsageDate === currentDate ? (user.aiUsageCents ?? 0) : 0; - if (usedCents >= DAILY_AI_LIMIT_CENTS) { + if (persistedUsageCents >= DAILY_AI_LIMIT_CENTS) { throw new Error("Daily usage limit reached. Connect your OpenRouter account to continue."); } } @@ -343,6 +365,21 @@ export const startStream = mutation({ jobId, }); + const shouldGenerateAutoTitle = + (chat.title === "New Chat" || !chat.title) && + (chat.messageCount ?? 0) <= 1; + const seedText = shouldGenerateAutoTitle ? getLatestUserSeedText(args.messages) : null; + if (seedText) { + await ctx.scheduler.runAfter(0, internal.chats.generateAndSetTitleInternal, { + chatId: args.chatId, + userId, + seedText, + length: "standard", + provider: args.provider === "openrouter" ? "openrouter" : "osschat", + force: false, + }); + } + return jobId; }, }); @@ -358,13 +395,7 @@ export const getStreamJob = query({ status: v.string(), model: v.string(), provider: v.string(), - options: v.optional(v.object({ - enableReasoning: v.optional(v.boolean()), - reasoningEffort: v.optional(v.string()), - enableWebSearch: v.optional(v.boolean()), - supportsToolCalls: v.optional(v.boolean()), - maxSteps: v.optional(v.number()), - })), + options: v.optional(streamOptionsValidator), content: v.string(), reasoning: v.optional(v.string()), chainOfThoughtParts: v.optional(v.array(chainOfThoughtPartValidator)), @@ -422,13 +453,7 @@ export const getActiveStreamJob = query({ status: v.string(), model: v.string(), provider: v.string(), - options: v.optional(v.object({ - enableReasoning: v.optional(v.boolean()), - reasoningEffort: v.optional(v.string()), - enableWebSearch: v.optional(v.boolean()), - supportsToolCalls: v.optional(v.boolean()), - maxSteps: v.optional(v.number()), - })), + options: v.optional(streamOptionsValidator), content: v.string(), reasoning: v.optional(v.string()), chainOfThoughtParts: v.optional(v.array(chainOfThoughtPartValidator)), @@ -771,9 +796,21 @@ export const executeStream = internalAction({ jobId: args.jobId }); - if (!job) { - return; - } + if (!job) { + return; + } + + if (job.provider === "osschat") { + const currentDate = getCurrentDateKey(); + const redisUsageCents = await getDailyUsageFromUpstash(job.userId, currentDate); + if (redisUsageCents !== null && redisUsageCents >= DAILY_AI_LIMIT_CENTS) { + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: "Daily usage limit reached. Connect your OpenRouter account to continue.", + }); + return; + } + } await ctx.runMutation(internal.backgroundStream.updateStreamContent, { jobId: args.jobId, @@ -1317,17 +1354,23 @@ export const executeStream = internalAction({ job.messages, fullContent, ); - if (usageCents && usageCents > 0) { - for (let attempt = 0; attempt < 2; attempt++) { - try { - await ctx.runMutation(internal.users.incrementAiUsage, { - userId: job.userId, - usageCents, - }); - break; - } catch { - // No-op; retried below. - } + if (usageCents && usageCents > 0) { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await ctx.runMutation(internal.users.incrementAiUsage, { + userId: job.userId, + usageCents, + skipRedisSync: true, + }); + await incrementDailyUsageInUpstash( + job.userId, + getCurrentDateKey(), + usageCents, + ); + break; + } catch { + // No-op; retried below. + } } } } diff --git a/apps/server/convex/chats.test.ts b/apps/server/convex/chats.test.ts index e910d20a..e3db2148 100644 --- a/apps/server/convex/chats.test.ts +++ b/apps/server/convex/chats.test.ts @@ -11,10 +11,10 @@ * - Edge cases and error handling */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { convexTest } from 'convex-test'; import schema from './schema'; -import { api } from './_generated/api'; +import { api, internal } from './_generated/api'; import type { Id } from './_generated/dataModel'; import { modules, rateLimiter } from './testSetup.test'; @@ -1111,3 +1111,190 @@ describe('chats.checkExportRateLimit', () => { expect(result2.ok).toBe(true); }); }); + +describe('chats.setGeneratedTitle', () => { + let t: ReturnType; + let userId: Id<'users'>; + + beforeEach(async () => { + t = createConvexTest(); + + userId = await t.run(async (ctx) => { + return await ctx.db.insert('users', { + externalId: 'test-user', + email: 'test@example.com', + name: 'Test User', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + }); + + it('should update title when chat still has default title', async () => { + const chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'New Chat', + createdAt: 1, + updatedAt: 1, + }); + }); + + await asExternalId(t, 'test-user').mutation(api.chats.setGeneratedTitle, { + chatId, + userId, + title: 'Generated Title', + force: false, + }); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.title).toBe('Generated Title'); + }); + + it('should bump updatedAt for generated title updates', async () => { + const chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'New Chat', + createdAt: 1, + updatedAt: 1, + }); + }); + + await asExternalId(t, 'test-user').mutation(api.chats.setGeneratedTitle, { + chatId, + userId, + title: 'Fresh Title', + force: false, + }); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.updatedAt).toBeGreaterThan(1); + }); + + it('should not overwrite a custom title without force', async () => { + const chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'Already Custom', + createdAt: 1, + updatedAt: 1, + }); + }); + + await asExternalId(t, 'test-user').mutation(api.chats.setGeneratedTitle, { + chatId, + userId, + title: 'Should Not Apply', + force: false, + }); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.title).toBe('Already Custom'); + }); + + it('should overwrite a custom title when force=true', async () => { + const chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'Already Custom', + createdAt: 1, + updatedAt: 1, + }); + }); + + await asExternalId(t, 'test-user').mutation(api.chats.setGeneratedTitle, { + chatId, + userId, + title: 'Forced Replace', + force: true, + }); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.title).toBe('Forced Replace'); + }); +}); + +describe('chats.generateAndSetTitleInternal', () => { + let t: ReturnType; + let userId: Id<'users'>; + let chatId: Id<'chats'>; + const originalApiKey = process.env.OPENROUTER_API_KEY; + + beforeEach(async () => { + t = createConvexTest(); + process.env.OPENROUTER_API_KEY = 'test-openrouter-key'; + vi.restoreAllMocks(); + + userId = await t.run(async (ctx) => { + return await ctx.db.insert('users', { + externalId: 'internal-title-user', + email: 'internal-title@test.com', + name: 'Internal Title User', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + + chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'New Chat', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + }); + + afterEach(() => { + process.env.OPENROUTER_API_KEY = originalApiKey; + vi.restoreAllMocks(); + }); + + it('generates and persists a title for default-title chats', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + choices: [{ message: { content: 'Helpful Testing Title' } }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + + const result = await t.action(internal.chats.generateAndSetTitleInternal, { + chatId, + userId, + seedText: 'this is the first user message', + length: 'standard', + provider: 'osschat', + force: false, + }); + + expect(result.saved).toBe(true); + expect(result.title).toBe('Helpful Testing Title'); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.title).toBe('Helpful Testing Title'); + }); + + it('does not overwrite existing custom titles unless forced', async () => { + await t.run(async (ctx) => { + await ctx.db.patch(chatId, { title: 'Custom Existing Title' }); + }); + + const result = await t.action(internal.chats.generateAndSetTitleInternal, { + chatId, + userId, + seedText: 'first message', + length: 'standard', + provider: 'osschat', + force: false, + }); + + expect(result.saved).toBe(false); + expect(result.reason).toBe('title_already_set'); + }); +}); diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index 3863ad3f..a69a9fee 100644 --- a/apps/server/convex/chats.ts +++ b/apps/server/convex/chats.ts @@ -1,6 +1,6 @@ import type { Id } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; -import { action, internalMutation, mutation, query } from "./_generated/server"; +import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server"; +import { action, internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { internal } from "./_generated/api"; import { v } from "convex/values"; import { incrementStat, STAT_KEYS } from "./lib/dbStats"; @@ -526,6 +526,84 @@ const TITLE_STYLE_PROMPTS: Record<"short" | "standard" | "long", string> = { long: "Use 7-10 words.", }; +type TitleLength = "short" | "standard" | "long"; +type TitleProvider = "osschat" | "openrouter"; + +async function resolveOpenRouterKey( + ctx: ActionCtx, + userId: Id<"users">, + provider: TitleProvider, +): Promise { + if (provider === "osschat") { + return process.env.OPENROUTER_API_KEY ?? null; + } + + const encryptedKey = await ctx.runQuery(internal.users.getOpenRouterKeyInternal, { + userId, + }); + return encryptedKey ? await decryptSecret(encryptedKey) : null; +} + +async function generateTitleFromSeed( + seedText: string, + length: TitleLength, + openRouterKey: string, +): Promise { + const systemPrompt = [ + "Create a specific, useful chat title.", + "Return only the title in Title Case; no quotes, no trailing punctuation.", + "Focus on the core topic or task; avoid filler words like 'and', 'with', 'about'.", + TITLE_STYLE_PROMPTS[length], + ].join(" "); + + try { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openRouterKey}`, + "HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io", + "X-Title": "OSSChat", + }, + body: JSON.stringify({ + model: TITLE_MODEL_ID, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: seedText }, + ], + temperature: 0.2, + max_tokens: 32, + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.warn("[Chat Title] OpenRouter error:", response.status, errorBody); + return null; + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content; + if (!content) return null; + + let title = content.trim(); + if ( + (title.startsWith("\"") && title.endsWith("\"")) || + (title.startsWith("'") && title.endsWith("'")) + ) { + title = title.slice(1, -1).trim(); + } + + const sanitizedTitle = sanitizeTitle(title, TITLE_MAX_LENGTH); + return sanitizedTitle || null; + } catch (error) { + console.warn("[Chat Title] Failed to generate title:", error); + return null; + } +} + export const generateTitle = action({ args: { userId: v.id("users"), @@ -543,70 +621,74 @@ export const generateTitle = action({ const seedText = args.seedText.trim(); if (!seedText) return null; - let openRouterKey: string | null = null; - if (args.provider === "osschat") { - openRouterKey = process.env.OPENROUTER_API_KEY ?? null; - } else { - const encryptedKey = await _ctx.runQuery(internal.users.getOpenRouterKeyInternal, { - userId, - }); - openRouterKey = encryptedKey ? await decryptSecret(encryptedKey) : null; - } + const openRouterKey = await resolveOpenRouterKey(_ctx, userId, args.provider); if (!openRouterKey) return null; - const systemPrompt = [ - "Create a specific, useful chat title.", - "Return only the title in Title Case; no quotes, no trailing punctuation.", - "Focus on the core topic or task; avoid filler words like 'and', 'with', 'about'.", - TITLE_STYLE_PROMPTS[args.length], - ].join(" "); - - try { - const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${openRouterKey}`, - "HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io", - "X-Title": "OSSChat", - }, - body: JSON.stringify({ - model: TITLE_MODEL_ID, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: seedText }, - ], - temperature: 0.2, - max_tokens: 32, - }), - }); + return generateTitleFromSeed(seedText, args.length, openRouterKey); + }, +}); - if (!response.ok) { - const errorBody = await response.text(); - console.warn("[Chat Title] OpenRouter error:", response.status, errorBody); - return null; - } +export const generateAndSetTitleInternal = internalAction({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + seedText: v.string(), + length: v.union(v.literal("short"), v.literal("standard"), v.literal("long")), + provider: v.union(v.literal("osschat"), v.literal("openrouter")), + force: v.optional(v.boolean()), + }, + returns: v.object({ + saved: v.boolean(), + title: v.optional(v.string()), + reason: v.optional(v.string()), + }), + handler: async (ctx, args) => { + const chat = await ctx.runQuery(internal.chats.getChatForTitleGenerationInternal, { + chatId: args.chatId, + userId: args.userId, + }); + if (!chat) { + return { saved: false, reason: "chat_not_found" }; + } - const data = (await response.json()) as { - choices?: Array<{ message?: { content?: string } }>; - }; - const content = data.choices?.[0]?.message?.content; - if (!content) return null; - - let title = content.trim(); - if ( - (title.startsWith("\"") && title.endsWith("\"")) || - (title.startsWith("'") && title.endsWith("'")) - ) { - title = title.slice(1, -1).trim(); - } + if (!args.force && chat.title && chat.title !== "New Chat") { + return { saved: false, reason: "title_already_set" }; + } - const sanitizedTitle = sanitizeTitle(title, TITLE_MAX_LENGTH); - return sanitizedTitle || null; - } catch (error) { - console.warn("[Chat Title] Failed to generate title:", error); - return null; + const seedText = args.seedText.trim(); + if (!seedText) { + return { saved: false, reason: "empty_seed" }; + } + + await ctx.runMutation(internal.chats.enforceTitleRateLimit, { + userId: args.userId, + }); + + const openRouterKey = await resolveOpenRouterKey(ctx, args.userId, args.provider); + if (!openRouterKey) { + return { saved: false, reason: "missing_openrouter_key" }; + } + + const generatedTitle = await generateTitleFromSeed( + seedText, + args.length, + openRouterKey, + ); + if (!generatedTitle) { + return { saved: false, reason: "generation_failed" }; } + + await ctx.runMutation(internal.chats.setGeneratedTitleInternal, { + chatId: args.chatId, + userId: args.userId, + title: generatedTitle, + force: args.force, + }); + + return { + saved: true, + title: generatedTitle, + }; }, }); @@ -658,6 +740,98 @@ export const updateTitle = mutation({ }, }); +export const setGeneratedTitle = mutation({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + title: v.string(), + force: v.optional(v.boolean()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await requireAuthUserId(ctx, args.userId); + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== userId || chat.deletedAt) { + return null; + } + + const sanitizedTitle = sanitizeTitle(args.title, TITLE_MAX_LENGTH); + if (!sanitizedTitle) return null; + + const shouldForce = args.force === true; + if (!shouldForce && chat.title !== "New Chat" && chat.title) { + return null; + } + + await ctx.db.patch(args.chatId, { + title: sanitizedTitle, + updatedAt: Date.now(), + }); + + return null; + }, +}); + +export const getChatForTitleGenerationInternal = internalQuery({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + }, + returns: v.union( + v.object({ + _id: v.id("chats"), + userId: v.id("users"), + title: v.string(), + deletedAt: v.optional(v.number()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== args.userId || chat.deletedAt) { + return null; + } + + return { + _id: chat._id, + userId: chat.userId, + title: chat.title, + deletedAt: chat.deletedAt, + }; + }, +}); + +export const setGeneratedTitleInternal = internalMutation({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + title: v.string(), + force: v.optional(v.boolean()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== args.userId || chat.deletedAt) { + return null; + } + + const sanitizedTitle = sanitizeTitle(args.title, TITLE_MAX_LENGTH); + if (!sanitizedTitle) return null; + + const shouldForce = args.force === true; + if (!shouldForce && chat.title !== "New Chat" && chat.title) { + return null; + } + + await ctx.db.patch(args.chatId, { + title: sanitizedTitle, + updatedAt: Date.now(), + }); + + return null; + }, +}); + /** * Force set a chat title (used for manual regeneration). */ @@ -687,6 +861,105 @@ export const setTitle = mutation({ }, }); +export const getChatExportData = query({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + }, + returns: v.union( + v.object({ + chat: v.object({ + _id: v.id("chats"), + title: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + }), + messages: v.array( + v.object({ + _id: v.id("messages"), + role: v.string(), + content: v.string(), + createdAt: v.number(), + modelId: v.optional(v.string()), + provider: v.optional(v.string()), + reasoning: v.optional(v.string()), + attachments: v.optional( + v.array( + v.object({ + filename: v.string(), + contentType: v.string(), + size: v.number(), + uploadedAt: v.number(), + url: v.optional(v.string()), + }), + ), + ), + }), + ), + }), + v.null(), + ), + handler: async (ctx, args) => { + const userId = await requireAuthUserId(ctx, args.userId); + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== userId || chat.deletedAt) { + return null; + } + + const messages = await ctx.db + .query("messages") + .withIndex("by_chat_not_deleted", (q) => + q.eq("chatId", args.chatId).eq("deletedAt", undefined), + ) + .order("asc") + .collect(); + + const storageIds = new Set>(); + for (const message of messages) { + for (const attachment of message.attachments ?? []) { + storageIds.add(attachment.storageId); + } + } + + const urlMap = new Map, string | null>(); + await Promise.all( + [...storageIds].map(async (storageId) => { + try { + const url = await ctx.storage.getUrl(storageId); + urlMap.set(storageId, url); + } catch { + urlMap.set(storageId, null); + } + }), + ); + + return { + chat: { + _id: chat._id, + title: chat.title, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + }, + messages: messages.map((message) => ({ + _id: message._id, + role: message.role, + content: message.content, + createdAt: message.createdAt, + modelId: message.modelId, + provider: message.provider, + reasoning: message.reasoning, + attachments: message.attachments?.map((attachment) => ({ + filename: attachment.filename, + contentType: attachment.contentType, + size: attachment.size, + uploadedAt: attachment.uploadedAt, + url: urlMap.get(attachment.storageId) ?? undefined, + })), + })), + }; + }, +}); + export const setActiveStream = mutation({ args: { chatId: v.id("chats"), diff --git a/apps/server/convex/convex.config.ts b/apps/server/convex/convex.config.ts index 77070733..3a4cb26a 100644 --- a/apps/server/convex/convex.config.ts +++ b/apps/server/convex/convex.config.ts @@ -1,11 +1,9 @@ import { defineApp } from "convex/server"; import betterAuth from "@convex-dev/better-auth/convex.config"; import rateLimiter from "@convex-dev/rate-limiter/convex.config"; -import persistentTextStreaming from "@convex-dev/persistent-text-streaming/convex.config"; const app = defineApp(); app.use(betterAuth); app.use(rateLimiter); -app.use(persistentTextStreaming); export default app; diff --git a/apps/server/convex/crons.ts b/apps/server/convex/crons.ts index 4ecffac2..e18a3f8c 100644 --- a/apps/server/convex/crons.ts +++ b/apps/server/convex/crons.ts @@ -13,7 +13,8 @@ */ import { cronJobs } from "convex/server"; -import { internalMutation } from "./_generated/server"; +import { action, internalMutation } from "./_generated/server"; +import { internal } from "./_generated/api"; import { v } from "convex/values"; import { decrementStat, getStats, STAT_KEYS } from "./lib/dbStats"; import { createLogger } from "./lib/logger"; @@ -156,6 +157,37 @@ export const cleanupSoftDeletedRecords = internalMutation({ }, }); +export const runCleanupBatchForWorkflow = action({ + args: { + workflowToken: v.string(), + retentionDays: v.optional(v.number()), + batchSize: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + }, + returns: v.object({ + success: v.boolean(), + deleted: v.number(), + dryRun: v.boolean(), + cutoffDate: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ success: boolean; deleted: number; dryRun: boolean; cutoffDate: string }> => { + const expectedToken = process.env.WORKFLOW_CLEANUP_TOKEN; + if (!expectedToken || args.workflowToken !== expectedToken) { + throw new Error("Unauthorized"); + } + + const result = await ctx.runMutation(internal.crons.cleanupSoftDeletedRecords, { + retentionDays: args.retentionDays, + batchSize: args.batchSize, + dryRun: args.dryRun, + }); + return result; + }, +}); + /** * Cleanup expired rate limit buckets * diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index cad01d3d..c912c5c6 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -1,7 +1,6 @@ import "./polyfills"; import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; -import { streamLLM } from "./streaming"; import { api } from "./_generated/api"; import { authComponent, createAuth } from "./auth"; import { getAllowedOrigins } from "./lib/origins"; @@ -36,20 +35,6 @@ http.route({ }), }); -// LLM streaming endpoint - runs on Convex infrastructure for persistence -http.route({ - path: "/stream-llm", - method: "POST", - handler: streamLLM, -}); - -// Handle CORS preflight for streaming endpoint -http.route({ - path: "/stream-llm", - method: "OPTIONS", - handler: streamLLM, -}); - // Public stats endpoint for sign-in page http.route({ path: "/stats", diff --git a/apps/server/convex/lib/redisRest.ts b/apps/server/convex/lib/redisRest.ts deleted file mode 100644 index f017b4a9..00000000 --- a/apps/server/convex/lib/redisRest.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Upstash Redis REST API client for Convex httpActions - * - * Convex httpActions run on Convex infrastructure and cannot use npm packages - * like @upstash/redis directly. This module uses the Upstash REST API via fetch. - * - * ARCHITECTURE: - * 1. Tokens are pushed to Redis streams during LLM streaming - * 2. Client connects to Next.js SSE endpoint (/api/stream/[id]) for real-time tokens - * 3. On completion: single write to Convex with full message - * 4. Redis streams auto-expire after TTL - * - * UPSTASH REST API: - * - POST https:///pipeline - Execute multiple commands - * - Headers: Authorization: Bearer - * - Body: Array of command arrays - * - * @see https://upstash.com/docs/redis/sdks/ts/pipelining - */ - -import { createLogger } from "./logger"; - -const logger = createLogger("RedisRest"); - -// Key prefixes for namespacing -const KEY_PREFIX = { - stream: "stream:", - meta: "meta:", - restoration: "restoration:", -} as const; - -/** - * Stream metadata structure - */ -export interface StreamMetadata { - status: "streaming" | "completed" | "error"; - totalTokens?: number; - finalContent?: string; - error?: string; - completedAt?: number; -} - -/** - * Check if Redis streaming is available by checking environment variables - */ -export function isRedisAvailable(): boolean { - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; - return Boolean(url && token); -} - -/** - * Get Redis credentials with validation - */ -function getRedisCredentials(): { url: string; token: string } | null { - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; - - if (!url || !token) { - return null; - } - - return { url, token }; -} - -/** - * Execute multiple Redis commands in a pipeline - */ -async function executePipeline( - commands: (string | number)[][] -): Promise { - const creds = getRedisCredentials(); - if (!creds) { - throw new Error("Redis credentials not configured"); - } - - const pipelineUrl = `${creds.url}/pipeline`; - - const response = await fetch(pipelineUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${creds.token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(commands), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Redis pipeline failed: ${response.status} - ${errorText}` - ); - } - - const results = await response.json(); - // Pipeline returns array of { result } objects - return results.map((r: { result: unknown }) => r.result) as T; -} - -/** - * Generate a unique stream ID - */ -export function generateStreamId(): string { - const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).substring(2, 10); - return `${timestamp}-${random}`; -} - -/** - * Initialize a new Redis stream for token streaming - * - * @param restorationId - User-facing restoration ID (e.g., message ID) - * @param ttlSeconds - Time to live (default 1 hour) - * @returns Stream ID for internal use - */ -export async function initializeStream( - restorationId: string, - ttlSeconds: number = 3600 -): Promise { - const streamId = generateStreamId(); - - const metaKey = `${KEY_PREFIX.meta}${streamId}`; - const restorationKey = `${KEY_PREFIX.restoration}${restorationId}`; - - const metadata: StreamMetadata = { status: "streaming" }; - - // Pipeline: SET metadata + SET restoration mapping - await executePipeline([ - ["SET", metaKey, JSON.stringify(metadata), "EX", ttlSeconds], - ["SET", restorationKey, streamId, "EX", ttlSeconds], - ]); - - logger.debug("Stream initialized", { streamId, restorationId }); - return streamId; -} - -/** - * Append tokens to Redis stream (batched) - * - * @param streamId - Internal stream ID - * @param tokens - Array of tokens to append - */ -export async function appendTokenBatch( - streamId: string, - tokens: string[] -): Promise { - if (tokens.length === 0) return; - - const streamKey = `${KEY_PREFIX.stream}${streamId}`; - const now = Date.now(); - - // Build XADD commands for each token - const commands = tokens.map((token, i) => [ - "XADD", - streamKey, - "*", - "delta", - token, - "ts", - (now + i).toString(), - ]); - - await executePipeline(commands); -} - -/** - * Finalize stream with completed status - * - * @param streamId - Internal stream ID - * @param finalContent - Complete message content - * @param totalTokens - Total token count - * @param ttlSeconds - TTL for completed stream (default 1 hour) - */ -export async function finalizeStream( - streamId: string, - finalContent: string, - totalTokens: number, - ttlSeconds: number = 3600 -): Promise { - const streamKey = `${KEY_PREFIX.stream}${streamId}`; - const metaKey = `${KEY_PREFIX.meta}${streamId}`; - - const metadata: StreamMetadata = { - status: "completed", - totalTokens, - finalContent, - completedAt: Date.now(), - }; - - // Pipeline: SET metadata + EXPIRE stream + EXPIRE metadata - await executePipeline([ - ["SET", metaKey, JSON.stringify(metadata), "EX", ttlSeconds], - ["EXPIRE", streamKey, ttlSeconds], - ]); - - logger.debug("Stream finalized", { streamId, totalTokens }); -} - -/** - * Mark stream as errored - * - * @param streamId - Internal stream ID - * @param error - Error message - * @param ttlSeconds - TTL for error record (default 10 minutes) - */ -export async function errorStream( - streamId: string, - error: string, - ttlSeconds: number = 600 -): Promise { - const streamKey = `${KEY_PREFIX.stream}${streamId}`; - const metaKey = `${KEY_PREFIX.meta}${streamId}`; - - const metadata: StreamMetadata = { - status: "error", - error, - completedAt: Date.now(), - }; - - await executePipeline([ - ["SET", metaKey, JSON.stringify(metadata), "EX", ttlSeconds], - ["EXPIRE", streamKey, ttlSeconds], - ]); - - logger.debug("Stream errored", { streamId, error }); -} - -/** - * Redis streaming operations for use in httpActions - */ -export const redisStreamOps = { - isAvailable: isRedisAvailable, - generateStreamId, - initializeStream, - appendTokenBatch, - finalizeStream, - errorStream, -}; diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts new file mode 100644 index 00000000..a52beccc --- /dev/null +++ b/apps/server/convex/lib/upstashUsage.ts @@ -0,0 +1,90 @@ +const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL?.trim(); +const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); + +type PipelineResult = Array<{ result: unknown }>; + +function isConfigured(): boolean { + return Boolean(UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN); +} + +function shouldLogUpstashUsageErrors(): boolean { + return process.env.LOG_UPSTASH_USAGE_ERRORS === "true"; +} + +function usageCounterKey(userId: string, dateKey: string): string { + return `openchat:usage:${userId}:${dateKey}`; +} + +function getMidnightUtcEpochSeconds(dateKey: string): number { + const [year, month, day] = dateKey.split("-").map((segment) => Number.parseInt(segment, 10)); + const expiresAtMs = Date.UTC(year, month - 1, day + 1, 0, 0, 0, 0); + return Math.floor(expiresAtMs / 1000); +} + +async function executePipeline(commands: Array>): Promise { + if (!isConfigured()) return null; + + const response = await fetch(`${UPSTASH_REDIS_REST_URL}/pipeline`, { + method: "POST", + headers: { + Authorization: `Bearer ${UPSTASH_REDIS_REST_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(commands), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Upstash pipeline failed: ${response.status} ${body}`); + } + + return (await response.json()) as PipelineResult; +} + +export async function getDailyUsageFromUpstash( + userId: string, + dateKey: string, +): Promise { + if (!isConfigured()) return null; + + try { + const key = usageCounterKey(userId, dateKey); + const result = await executePipeline([["GET", key]]); + const raw = result?.[0]?.result; + if (raw === null || raw === undefined) return null; + if (typeof raw === "number") return raw; + if (typeof raw === "string") { + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + } catch (error) { + if (shouldLogUpstashUsageErrors()) { + console.warn("[Usage] Upstash GET failed", error); + } + return null; + } +} + +export async function incrementDailyUsageInUpstash( + userId: string, + dateKey: string, + usageCents: number, +): Promise { + if (!isConfigured()) return; + if (!Number.isFinite(usageCents) || usageCents <= 0) return; + + const key = usageCounterKey(userId, dateKey); + const expiresAt = getMidnightUtcEpochSeconds(dateKey); + + try { + await executePipeline([ + ["INCRBY", key, Math.floor(usageCents)], + ["EXPIREAT", key, expiresAt], + ]); + } catch (error) { + if (shouldLogUpstashUsageErrors()) { + console.warn("[Usage] Upstash INCRBY failed", error); + } + } +} diff --git a/apps/server/convex/schema.ts b/apps/server/convex/schema.ts index 26f4b936..948cdf29 100644 --- a/apps/server/convex/schema.ts +++ b/apps/server/convex/schema.ts @@ -262,6 +262,8 @@ export default defineSchema({ enableWebSearch: v.optional(v.boolean()), supportsToolCalls: v.optional(v.boolean()), maxSteps: v.optional(v.number()), + dynamicPrompt: v.optional(v.boolean()), + jonMode: v.optional(v.boolean()), })), content: v.string(), reasoning: v.optional(v.string()), diff --git a/apps/server/convex/streaming.ts b/apps/server/convex/streaming.ts deleted file mode 100644 index 671ef725..00000000 --- a/apps/server/convex/streaming.ts +++ /dev/null @@ -1,981 +0,0 @@ -import { v } from "convex/values"; -import { mutation, query, httpAction, internalMutation, internalQuery } from "./_generated/server"; -import { api, components, internal } from "./_generated/api"; -import { - PersistentTextStreaming, - StreamIdValidator, - type StreamId, -} from "@convex-dev/persistent-text-streaming"; -import type { Id } from "./_generated/dataModel"; -import { redisStreamOps } from "./lib/redisRest"; -import { createLogger } from "./lib/logger"; -import { requireAuthUserId } from "./lib/auth"; -import { getCorsOrigin } from "./lib/origins"; -import { decryptSecret } from "./lib/crypto"; - -// Initialize the persistent text streaming component -export const persistentTextStreaming = new PersistentTextStreaming( - components.persistentTextStreaming -); - -// Re-export StreamId type and validator for use in other files -export { StreamIdValidator, type StreamId }; - -const logger = createLogger("StreamingLLM"); - -/** - * Create a new stream and associate it with a message - * Called before starting the LLM generation - */ -export const createStream = mutation({ - args: { - chatId: v.id("chats"), - userId: v.id("users"), - clientMessageId: v.optional(v.string()), - }, - returns: v.object({ - streamId: StreamIdValidator, - messageId: v.id("messages"), - }), - handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); - // Verify user owns the chat - const chat = await ctx.db.get(args.chatId); - if (!chat || chat.userId !== userId || chat.deletedAt) { - throw new Error("Chat not found or access denied"); - } - - // Create the persistent stream - const streamId = await persistentTextStreaming.createStream(ctx); - - // Create the assistant message with the stream ID - const now = Date.now(); - const messageId = await ctx.db.insert("messages", { - chatId: args.chatId, - clientMessageId: args.clientMessageId, - role: "assistant", - content: "", // Will be populated by stream - status: "streaming", - streamId: streamId as string, - createdAt: now, - userId, - }); - - // Update chat's lastMessageAt - await ctx.db.patch(args.chatId, { - lastMessageAt: now, - updatedAt: now, - }); - - return { streamId, messageId }; - }, -}); - -/** - * Get the current body/content of a stream - * Used by useStream hook for database fallback when not driving - * IMPORTANT: Must return { text, status } format for useStream hook compatibility - * - * STREAM RECONNECTION: This query handles the case where a user reloads during streaming. - * The persistent stream component may have cleared its body, but: - * 1. The message has content from periodic saves - * 2. The message status tells us if streaming is still in progress - * We use the message's status (not "done") to determine the correct streaming state. - */ -export const getStreamBody = query({ - args: { - streamId: StreamIdValidator, - }, - handler: async (ctx, args) => { - // Get stream body from persistent streaming - returns { text, status } - const streamBody = await persistentTextStreaming.getStreamBody(ctx, args.streamId as StreamId); - - // Always fetch the message to check its actual status and get reasoning - const message = await ctx.db - .query("messages") - .withIndex("by_stream_id", (q) => q.eq("streamId", args.streamId as string)) - .unique(); - - // If stream body has content and is still streaming, return it with reasoning from message - if (streamBody && typeof streamBody === "object" && streamBody.text) { - return { - ...streamBody, - reasoning: message?.reasoning ?? null, - thinkingTimeMs: message?.thinkingTimeMs ?? null, - }; - } - - // If stream body is empty but message has content, use message content - // CRITICAL: Use the message's status, not hardcoded "done" - // This allows stream reconnection to show content while still detecting streaming - if (message?.content) { - // Determine the correct status based on message status - const status = message.status === "streaming" ? "streaming" : "done"; - return { - text: message.content, - status: status as "streaming" | "done", - reasoning: message.reasoning ?? null, - thinkingTimeMs: message.thinkingTimeMs ?? null, - }; - } - - // Fallback: return stream body with null reasoning (may be empty/null) - return streamBody ? { ...streamBody, reasoning: null, thinkingTimeMs: null } : streamBody; - }, -}); - -export const getStreamMessage = internalQuery({ - args: { - messageId: v.id("messages"), - streamId: v.string(), - }, - returns: v.union( - v.object({ - userId: v.id("users"), - }), - v.null(), - ), - handler: async (ctx, args) => { - const message = await ctx.db.get(args.messageId); - if (!message || message.streamId !== args.streamId || !message.userId) return null; - return { userId: message.userId }; - }, -}); - -/** - * Get stream status and content by querying the associated message - */ -export const getStreamStatus = query({ - args: { - streamId: v.string(), - }, - returns: v.object({ - body: v.union(v.string(), v.null()), - status: v.union(v.literal("streaming"), v.literal("completed"), v.literal("error"), v.null()), - reasoning: v.optional(v.union(v.string(), v.null())), - thinkingTimeMs: v.optional(v.union(v.number(), v.null())), - }), - handler: async (ctx, args) => { - // Find the message associated with this stream first - // We need this to check if stream completed and fall back to message content - const message = await ctx.db - .query("messages") - .withIndex("by_stream_id", (q) => q.eq("streamId", args.streamId)) - .unique(); - - // Get the stream body - returns {status, text} object - const rawBody = await persistentTextStreaming.getStreamBody(ctx, args.streamId as StreamId); - - // Extract text from the StreamBody object - let body: string | null = null; - if (rawBody && typeof rawBody === "object" && "text" in rawBody) { - body = (rawBody as { text: string }).text || null; - } else if (typeof rawBody === "string") { - body = rawBody; - } - - // STREAM RECONNECTION FIX: If stream body is empty but message has content, - // use the message content. This handles the case where: - // 1. Stream completed while user was reloading - // 2. Persistent streaming cleared the body - // 3. But message.content has the final content - if (!body && message?.content) { - body = message.content; - } - - return { - body, - status: (message?.status as "streaming" | "completed" | "error") ?? null, - reasoning: message?.reasoning ?? null, - thinkingTimeMs: message?.thinkingTimeMs ?? null, - }; - }, -}); - -/** - * Cancel an active stream - * Called by the client when user clicks stop button - * The streamLLM action will check for this status and stop streaming - */ -export const cancelStream = mutation({ - args: { - streamId: v.string(), - userId: v.id("users"), - }, - returns: v.object({ - success: v.boolean(), - }), - handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); - // Find the message associated with this stream - const message = await ctx.db - .query("messages") - .withIndex("by_stream_id", (q) => q.eq("streamId", args.streamId)) - .unique(); - - if (!message) { - return { success: false }; - } - - // Verify user owns the message - if (message.userId !== userId) { - return { success: false }; - } - - // Only cancel if currently streaming - if (message.status !== "streaming") { - return { success: false }; - } - - // Mark as cancelled - await ctx.db.patch(message._id, { - status: "cancelled", - }); - - // Also reset the chat status - const chat = await ctx.db.get(message.chatId); - if (chat && chat.status === "streaming") { - await ctx.db.patch(message.chatId, { - status: "idle", - updatedAt: Date.now(), - }); - } - - return { success: true }; - }, -}); - -/** - * Check if a stream has been cancelled (internal query for streamLLM) - */ -export const isStreamCancelled = query({ - args: { - streamId: v.string(), - }, - returns: v.boolean(), - handler: async (ctx, args) => { - const message = await ctx.db - .query("messages") - .withIndex("by_stream_id", (q) => q.eq("streamId", args.streamId)) - .unique(); - - return message?.status === "cancelled"; - }, -}); - -/** - * Mark a stream as complete and update the message content - * Internal mutation - called from HTTP action after streaming completes - */ -export const completeStream = internalMutation({ - args: { - messageId: v.id("messages"), - content: v.string(), - reasoning: v.optional(v.string()), - thinkingTimeMs: v.optional(v.number()), - reasoningRequested: v.optional(v.boolean()), - }, - handler: async (ctx, args) => { - // Get the message to find the chatId - const message = await ctx.db.get(args.messageId); - - if (!message) { - throw new Error("Message not found"); - } - - await ctx.db.patch(args.messageId, { - content: args.content, - reasoning: args.reasoning, - thinkingTimeMs: args.thinkingTimeMs, - reasoningRequested: args.reasoningRequested, - status: "completed", - }); - - // Reset chat status to idle when streaming completes - await ctx.db.patch(message.chatId, { - status: "idle", - updatedAt: Date.now(), - }); - }, -}); - -/** - * Update message content during streaming (periodic saves) - * Internal mutation - called from HTTP action during streaming - */ -export const updateStreamContent = internalMutation({ - args: { - messageId: v.id("messages"), - content: v.string(), - reasoning: v.optional(v.string()), - }, - handler: async (ctx, args) => { - await ctx.db.patch(args.messageId, { - content: args.content, - reasoning: args.reasoning, - }); - }, -}); - -/** - * Fix stuck streaming messages that never completed - * This can happen when a stream is interrupted (server crash, timeout, etc.) - * Marks stuck messages as "error" status to allow UI to recover - */ -export const fixStuckStreamingMessages = mutation({ - args: { - userId: v.id("users"), - }, - returns: v.object({ - fixedCount: v.number(), - fixedMessageIds: v.array(v.id("messages")), - }), - handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); - // Find all streaming messages for this user - const streamingMessages = await ctx.db - .query("messages") - .withIndex("by_user_status", (q) => - q.eq("userId", userId).eq("status", "streaming") - ) - .collect(); - - const fixedIds: Id<"messages">[] = []; - - for (const message of streamingMessages) { - // Mark as error status - the stream never completed - await ctx.db.patch(message._id, { - status: "error", - }); - fixedIds.push(message._id); - - // Also reset the chat status if it's stuck - const chat = await ctx.db.get(message.chatId); - if (chat && chat.status === "streaming") { - await ctx.db.patch(message.chatId, { - status: "idle", - updatedAt: Date.now(), - }); - } - } - - return { - fixedCount: fixedIds.length, - fixedMessageIds: fixedIds, - }; - }, -}); - -/** - * Admin-only: Fix ALL stuck streaming messages across all users - * Use with caution - should only be run by admins - */ -export const fixAllStuckStreamingMessages = internalMutation({ - args: {}, - returns: v.object({ - fixedCount: v.number(), - }), - handler: async (ctx) => { - // Find all streaming messages - const streamingMessages = await ctx.db - .query("messages") - .filter((q) => q.eq(q.field("status"), "streaming")) - .collect(); - - let fixedCount = 0; - - for (const message of streamingMessages) { - await ctx.db.patch(message._id, { - status: "error", - }); - fixedCount++; - - // Also reset the chat status if stuck - const chat = await ctx.db.get(message.chatId); - if (chat && chat.status === "streaming") { - await ctx.db.patch(message.chatId, { - status: "idle", - updatedAt: Date.now(), - }); - } - } - - return { fixedCount }; - }, -}); - -/** - * Admin-only: Soft-delete broken error messages (empty content with error status) - * These messages were never completed and have no useful content - */ -export const cleanupBrokenErrorMessages = internalMutation({ - args: {}, - returns: v.object({ - deletedCount: v.number(), - }), - handler: async (ctx) => { - // Find all error messages with empty content - const errorMessages = await ctx.db - .query("messages") - .filter((q) => q.eq(q.field("status"), "error")) - .collect(); - - let deletedCount = 0; - const now = Date.now(); - - for (const message of errorMessages) { - // Only delete if content is empty or very short (broken) - if (!message.content || message.content.trim().length < 10) { - await ctx.db.patch(message._id, { - deletedAt: now, - }); - deletedCount++; - } - } - - return { deletedCount }; - }, -}); - -/** - * Prepare a chat for streaming - creates user message, stream, and assistant message - * Called by the client before starting the stream - */ -export const prepareChat = mutation({ - args: { - chatId: v.id("chats"), - userId: v.id("users"), - userContent: v.string(), - userMessageId: v.optional(v.string()), - assistantMessageId: v.optional(v.string()), - }, - returns: v.object({ - streamId: StreamIdValidator, - userMessageId: v.id("messages"), - assistantMessageId: v.id("messages"), - }), - handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); - // Verify user owns the chat - const chat = await ctx.db.get(args.chatId); - if (!chat || chat.userId !== userId || chat.deletedAt) { - throw new Error("Chat not found or access denied"); - } - - const now = Date.now(); - - // Create the user message - const userMsgId = await ctx.db.insert("messages", { - chatId: args.chatId, - clientMessageId: args.userMessageId, - role: "user", - content: args.userContent, - status: "completed", - createdAt: now, - userId, - }); - - // Create the persistent stream for assistant response - const streamId = await persistentTextStreaming.createStream(ctx); - - // Create the assistant message placeholder with the stream ID - const assistantMsgId = await ctx.db.insert("messages", { - chatId: args.chatId, - clientMessageId: args.assistantMessageId, - role: "assistant", - content: "", // Will be populated by stream - status: "streaming", - streamId: streamId as string, - createdAt: now + 1, // Slightly after user message - userId, - }); - - // Update chat's lastMessageAt and set status to streaming - // This allows the sidebar to show a streaming indicator - await ctx.db.patch(args.chatId, { - lastMessageAt: now, - updatedAt: now, - status: "streaming", - }); - - return { - streamId, - userMessageId: userMsgId, - assistantMessageId: assistantMsgId, - }; - }, -}); - -// Types for the stream request -type StreamRequestBody = { - streamId: string; - messageId: string; - modelId: string; - apiKey?: string; - messages: Array<{ - role: string; - content: string; - }>; - provider?: "osschat" | "openrouter"; - reasoningConfig?: { - enabled: boolean; - effort?: "low" | "medium" | "high"; - max_tokens?: number; - }; -}; - -// Redis streaming configuration -const REDIS_TOKEN_BATCH_SIZE = 10; // Flush every 10 tokens -const REDIS_TOKEN_BATCH_INTERVAL_MS = 50; // Or every 50ms - -/** - * HTTP Action for streaming LLM responses - * This runs on Convex infrastructure and continues even if client disconnects - * - * REDIS STREAMING: - * When Redis is available (UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN set), - * tokens are also pushed to Redis for low-latency client streaming via SSE. - * Client can connect to /api/stream/[redisStreamId] for real-time tokens. - * The X-Stream-Id header contains the Redis stream ID for client connection. - */ -export const streamLLM = httpAction(async (ctx, request) => { - const origin = request.headers.get("origin"); - const corsOrigin = getCorsOrigin(origin); - const withCorsHeaders = (base?: HeadersInit) => { - const headers = new Headers(base); - if (corsOrigin) { - headers.set("Access-Control-Allow-Origin", corsOrigin); - headers.set("Vary", "Origin"); - } - return headers; - }; - // Handle CORS preflight - if (request.method === "OPTIONS") { - if (origin && !corsOrigin) { - return new Response(null, { status: 403 }); - } - return new Response(null, { - status: 204, - headers: withCorsHeaders({ - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", - }), - }); - } - - try { - if (origin && !corsOrigin) { - return new Response(JSON.stringify({ error: "Invalid origin" }), { - status: 403, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - const body = (await request.json()) as StreamRequestBody; - const { streamId, messageId, modelId, messages, reasoningConfig, provider } = body; - - if (!streamId || !messageId || !modelId || !messages) { - return new Response( - JSON.stringify({ error: "Missing required fields" }), - { - status: 400, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - } - ); - } - - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - const user = await ctx.runQuery(internal.users.getByExternalIdInternal, { - externalId: identity.subject, - }); - if (!user) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - const message = await ctx.runQuery(internal.streaming.getStreamMessage, { - messageId: messageId as Id<"messages">, - streamId, - }); - if (!message || message.userId !== user._id) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 403, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - const requestProvider = provider ?? "osschat"; - let apiKey: string | null = null; - if (requestProvider === "osschat") { - apiKey = process.env.OPENROUTER_API_KEY ?? null; - } else { - const encryptedKey = await ctx.runQuery(api.users.getOpenRouterKey, { - userId: user._id, - }); - apiKey = encryptedKey ? await decryptSecret(encryptedKey) : null; - } - if (!apiKey) { - return new Response(JSON.stringify({ error: "API key not configured" }), { - status: 400, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - // Build the OpenRouter request - const openRouterMessages = messages.map((m) => ({ - role: m.role as "user" | "assistant" | "system", - content: m.content, - })); - - // Determine if reasoning is enabled - // Trust the config - UI only shows reasoning controls for models that support it - const hasReasoning = reasoningConfig?.enabled; - - let fullContent = ""; - let fullReasoning = ""; - let reasoningStartTime: number | null = null; - let reasoningEndTime: number | null = null; - let chunkCount = 0; - let reasoningChunkCount = 0; - let isCancelled = false; - let lastCancellationCheck = Date.now(); - const CHUNKS_PER_UPDATE = 10; // More frequent saves for responsive UI - const REASONING_CHUNKS_PER_UPDATE = 1; // Real-time streaming: save on every reasoning chunk - const CANCELLATION_CHECK_INTERVAL_MS = 200; // Check for cancellation every 200ms for faster response - - // Redis streaming state - const useRedisStreaming = redisStreamOps.isAvailable(); - let redisStreamId: string | null = null; - let pendingTokenBatch: string[] = []; - let lastBatchFlush = Date.now(); - - // Initialize Redis stream if available - if (useRedisStreaming) { - try { - // Use messageId as restoration ID for client reconnection - redisStreamId = await redisStreamOps.initializeStream(messageId); - logger.debug("Redis stream initialized", { redisStreamId, messageId }); - } catch (error) { - logger.error("Failed to initialize Redis stream, falling back to Convex-only streaming", error); - redisStreamId = null; - } - } - - // Helper to flush token batch to Redis - const flushTokenBatch = async () => { - if (pendingTokenBatch.length === 0 || !redisStreamId) return; - const batch = pendingTokenBatch; - pendingTokenBatch = []; - lastBatchFlush = Date.now(); - try { - await redisStreamOps.appendTokenBatch(redisStreamId, batch); - } catch (error) { - logger.error("Failed to append token batch to Redis", error); - // Don't fail the stream on Redis errors - content is still saved to Convex - } - }; - - // Generator function for the stream - // This function runs to completion even if client disconnects - // Using 'any' for actionCtx to avoid type conflicts between httpAction ctx and stream() expected type - const generateResponse = async ( - actionCtx: any, - _request: Request, - _streamId: StreamId, - chunkAppender: (chunk: string) => Promise - ) => { - const openRouterUrl = process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1/chat/completions"; - - const requestBody: Record = { - model: modelId, - messages: openRouterMessages, - stream: true, - max_tokens: 4096, - }; - - // Add reasoning config if enabled - if (hasReasoning && reasoningConfig) { - requestBody.reasoning = { - effort: reasoningConfig.effort || "medium", - ...(reasoningConfig.max_tokens && { max_tokens: reasoningConfig.max_tokens }), - }; - } - - const response = await fetch(openRouterUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "HTTP-Referer": process.env.CONVEX_SITE_URL || "https://openchat.dev", - "X-Title": "OpenChat", - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - // Mark Redis stream as errored if initialized - if (redisStreamId) { - try { - await redisStreamOps.errorStream(redisStreamId, `OpenRouter error: ${response.status}`); - } catch (e) { - logger.error("Failed to mark Redis stream as errored", e); - } - } - throw new Error(`OpenRouter error: ${response.status} - ${errorText}`); - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error("No response body"); - } - - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - // Check for cancellation periodically (every CANCELLATION_CHECK_INTERVAL_MS) - const now = Date.now(); - if (now - lastCancellationCheck >= CANCELLATION_CHECK_INTERVAL_MS) { - lastCancellationCheck = now; - const cancelled = await actionCtx.runQuery(api.streaming.isStreamCancelled, { - streamId: streamId, - }); - if (cancelled) { - isCancelled = true; - logger.debug("Stream cancelled by user", { streamId }); - // Close the reader to stop receiving data - await reader.cancel(); - break; - } - } - - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const data = line.slice(6); - if (data === "[DONE]") continue; - - try { - const parsed = JSON.parse(data); - const delta = parsed.choices?.[0]?.delta; - - if (delta?.content) { - fullContent += delta.content; - await chunkAppender(delta.content); - - // Redis streaming: batch tokens for efficient network usage - if (redisStreamId) { - pendingTokenBatch.push(delta.content); - const now = Date.now(); - // Flush batch when size threshold reached or time interval elapsed (with non-empty check) - if ( - pendingTokenBatch.length >= REDIS_TOKEN_BATCH_SIZE || - (pendingTokenBatch.length > 0 && now - lastBatchFlush >= REDIS_TOKEN_BATCH_INTERVAL_MS) - ) { - try { - await flushTokenBatch(); - } catch (error) { - logger.error("Failed to flush token batch", error); - // Don't rethrow - continue streaming - } - } - } - - // Always do periodic database saves for reliable streaming - // This ensures content is persisted regardless of Redis status - chunkCount++; - if (chunkCount % CHUNKS_PER_UPDATE === 0) { - await actionCtx.runMutation(internal.streaming.updateStreamContent, { - messageId: messageId as Id<"messages">, - content: fullContent, - reasoning: fullReasoning.length > 0 ? fullReasoning : undefined, - }); - } - } - - // Handle reasoning content from OpenRouter - // OpenRouter returns reasoning in different formats depending on model: - // - delta.reasoning_details: array of objects - type varies by provider: - // - Anthropic: { type: "reasoning.text", text: "..." } - // - Others: { type: "text", text: "..." } - // - delta.reasoning: string format (some providers) - // - choice.reasoning_details: array at choice level (non-streaming) - let reasoningChunk: string | null = null; - - // Check for reasoning_details array (OpenRouter's primary format) - if (delta?.reasoning_details && Array.isArray(delta.reasoning_details)) { - // Extract text from reasoning_details array - // Handle multiple type formats: "text", "reasoning.text", "reasoning", etc. - reasoningChunk = delta.reasoning_details - .filter((item: { type?: string; text?: string }) => { - // Accept any type that contains "text" or "reasoning" and has text content - const itemType = item.type?.toLowerCase() || ""; - const hasValidType = itemType.includes("text") || itemType.includes("reasoning") || itemType === ""; - return hasValidType && item.text; - }) - .map((item: { type?: string; text?: string }) => item.text) - .join(""); - } - // Fallback: check for reasoning string - else if (delta?.reasoning && typeof delta.reasoning === "string") { - reasoningChunk = delta.reasoning; - } - - if (reasoningChunk) { - if (!reasoningStartTime) { - reasoningStartTime = Date.now(); - } - fullReasoning += reasoningChunk; - reasoningEndTime = Date.now(); - - // Also stream reasoning to Redis with prefix - if (redisStreamId) { - pendingTokenBatch.push(`[reasoning]${reasoningChunk}`); - const now = Date.now(); - // Flush batch when size threshold reached or time interval elapsed (with non-empty check) - if ( - pendingTokenBatch.length >= REDIS_TOKEN_BATCH_SIZE || - (pendingTokenBatch.length > 0 && now - lastBatchFlush >= REDIS_TOKEN_BATCH_INTERVAL_MS) - ) { - try { - await flushTokenBatch(); - } catch (error) { - logger.error("Failed to flush token batch", error); - // Don't rethrow - continue streaming - } - } - } - - // CRITICAL: Also save reasoning to DB for getStreamBody query - // Reasoning comes before content, so we need frequent saves here - // to enable real-time reasoning display via Convex queries - reasoningChunkCount++; - if (reasoningChunkCount % REASONING_CHUNKS_PER_UPDATE === 0) { - await actionCtx.runMutation(internal.streaming.updateStreamContent, { - messageId: messageId as Id<"messages">, - content: fullContent, - reasoning: fullReasoning, - }); - } - } - } catch (parseError) { - // Log JSON parse errors for debugging OpenRouter protocol issues - logger.warn("Failed to parse streaming chunk", { - data: data.slice(0, 200), // Truncate for logging - error: parseError instanceof Error ? parseError.message : String(parseError), - }); - } - } - } - - // Flush any remaining tokens to Redis - if (redisStreamId) { - try { - await flushTokenBatch(); - } catch (error) { - logger.error("Failed to flush final token batch", error); - // Don't rethrow - continue to finalize stream - } - } - - // Calculate thinking time - const thinkingTimeMs = reasoningStartTime && reasoningEndTime - ? reasoningEndTime - reasoningStartTime - : undefined; - - // Finalize Redis stream - if (redisStreamId) { - try { - const totalTokens = fullContent.split(/\s+/).length; // Simple word-based estimate - await redisStreamOps.finalizeStream(redisStreamId, fullContent, totalTokens); - logger.debug("Redis stream finalized", { redisStreamId, totalTokens }); - } catch (error) { - logger.error("Failed to finalize Redis stream", error); - // Don't fail - Convex write is the source of truth - } - } - - // CRITICAL: Update the message record with final content - // This runs even if the client disconnects - ensuring content is saved - // Skip if cancelled - the cancelStream mutation already set the status - if (isCancelled) { - // Just save the partial content, status is already "cancelled" - await actionCtx.runMutation(internal.streaming.updateStreamContent, { - messageId: messageId as Id<"messages">, - content: fullContent, - reasoning: fullReasoning.length > 0 ? fullReasoning : undefined, - }); - logger.debug("Stream cancelled, saved partial content", { - streamId, - contentLength: fullContent.length, - reasoningLength: fullReasoning.length - }); - return; - } - - await actionCtx.runMutation(internal.streaming.completeStream, { - messageId: messageId as Id<"messages">, - content: fullContent, - reasoning: fullReasoning.length > 0 ? fullReasoning : undefined, - thinkingTimeMs, - reasoningRequested: hasReasoning, - }); - }; - - // Use the persistent streaming component - // Type assertion needed because httpAction ctx is GenericActionCtx - // but stream() expects GenericActionCtx - const streamResponse = await persistentTextStreaming.stream( - ctx as Parameters[0], - request, - streamId as StreamId, - generateResponse - ); - - // Add CORS headers - const headers = new Headers(streamResponse.headers); - if (corsOrigin) { - headers.set("Access-Control-Allow-Origin", corsOrigin); - headers.set("Vary", "Origin"); - } - - // Add Redis stream ID header for client SSE connection - if (redisStreamId) { - headers.set("X-Stream-Id", redisStreamId); - headers.set("Access-Control-Expose-Headers", "X-Stream-Id"); - } - - return new Response(streamResponse.body, { - status: streamResponse.status, - headers, - }); - } catch (error) { - logger.error("Stream error", error); - return new Response( - JSON.stringify({ - error: "Stream failed", - }), - { - status: 500, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - } - ); - } -}); diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 6aa7ab28..86b62c83 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -1,11 +1,11 @@ -import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; +import { action, internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { incrementStat, decrementStat, STAT_KEYS } from "./lib/dbStats"; import { rateLimiter } from "./lib/rateLimiter"; import { throwRateLimitError } from "./lib/rateLimitUtils"; import { getProfileByUserId, getOrCreateProfile } from "./lib/profiles"; -import { components } from "./_generated/api"; -import { requireAuthUserId } from "./lib/auth"; +import { components, internal } from "./_generated/api"; +import { requireAuthUserId, requireAuthUserIdFromAction } from "./lib/auth"; // User with profile data (for backwards-compatible responses) // Includes merged profile data that prefers profile over user during migration @@ -313,6 +313,7 @@ export const incrementAiUsage = internalMutation({ args: { userId: v.id("users"), usageCents: v.number(), + skipRedisSync: v.optional(v.boolean()), }, returns: v.object({ usedCents: v.number(), @@ -367,6 +368,10 @@ export const incrementAiUsage = internalMutation({ updatedAt: Date.now(), }); + // NOTE: Redis sync is performed from action callsites (e.g. backgroundStream) + // to avoid HTTP side effects inside mutations. + void args.skipRedisSync; + return { usedCents: nextCents, remainingCents: Math.max(0, DAILY_AI_LIMIT_CENTS - nextCents), @@ -598,6 +603,259 @@ export const updateName = mutation({ }, }); +const DELETE_BATCH_SIZE_DEFAULT = 100; +const DELETE_BATCH_SIZE_MAX = 500; + +function normalizeBatchSize(value?: number): number { + if (!value || !Number.isFinite(value) || value <= 0) { + return DELETE_BATCH_SIZE_DEFAULT; + } + return Math.min(Math.floor(value), DELETE_BATCH_SIZE_MAX); +} + +const deletionBatchResult = v.object({ + deleted: v.number(), + hasMore: v.boolean(), +}); + +export const deleteUserStreamJobs = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const streamJobs = await ctx.db + .query("streamJobs") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const job of streamJobs) { + await ctx.db.delete(job._id); + } + + return { + deleted: streamJobs.length, + hasMore: streamJobs.length === batchSize, + }; + }, +}); + +export const deleteUserMessages = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const messages = await ctx.db + .query("messages") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const message of messages) { + await ctx.db.delete(message._id); + } + + return { + deleted: messages.length, + hasMore: messages.length === batchSize, + }; + }, +}); + +export const deleteUserChats = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const chats = await ctx.db + .query("chats") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const chat of chats) { + await ctx.db.delete(chat._id); + } + + return { + deleted: chats.length, + hasMore: chats.length === batchSize, + }; + }, +}); + +export const deleteUserFiles = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const files = await ctx.db + .query("fileUploads") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const file of files) { + try { + await ctx.storage.delete(file.storageId); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (!message.toLowerCase().includes("not found")) { + console.error("Unexpected error deleting storage file:", file.storageId, message); + } + } + await ctx.db.delete(file._id); + } + + return { + deleted: files.length, + hasMore: files.length === batchSize, + }; + }, +}); + +export const deleteUserRecord = internalMutation({ + args: { + userId: v.id("users"), + externalId: v.string(), + }, + returns: v.object({ success: v.boolean() }), + handler: async (ctx, args) => { + const user = await ctx.db.get(args.userId); + if (!user || user.externalId !== args.externalId) { + return { success: false }; + } + + await ctx.runMutation(components.betterAuth.adapter.deleteMany, { + input: { + model: "session", + where: [{ field: "userId", operator: "eq", value: args.externalId }], + }, + paginationOpts: { cursor: null, numItems: 1000 }, + }); + + await ctx.runMutation(components.betterAuth.adapter.deleteMany, { + input: { + model: "account", + where: [{ field: "userId", operator: "eq", value: args.externalId }], + }, + paginationOpts: { cursor: null, numItems: 100 }, + }); + + await ctx.runMutation(components.betterAuth.adapter.deleteMany, { + input: { + model: "user", + where: [{ field: "_id", operator: "eq", value: args.externalId }], + }, + paginationOpts: { cursor: null, numItems: 1 }, + }); + + const readStatuses = await ctx.db + .query("chatReadStatus") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .collect(); + for (const status of readStatuses) { + await ctx.db.delete(status._id); + } + + const templates = await ctx.db + .query("promptTemplates") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .collect(); + for (const template of templates) { + await ctx.db.delete(template._id); + } + + const profile = await ctx.db + .query("profiles") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .unique(); + if (profile) { + await ctx.db.delete(profile._id); + } + + await ctx.db.delete(args.userId); + await decrementStat(ctx, STAT_KEYS.USERS_TOTAL); + + return { success: true }; + }, +}); + +export const deleteAccountWorkflowStep = action({ + args: { + userId: v.id("users"), + externalId: v.string(), + step: v.union( + v.literal("delete-stream-jobs"), + v.literal("delete-messages"), + v.literal("delete-chats"), + v.literal("delete-files"), + v.literal("delete-user"), + ), + batchSize: v.optional(v.number()), + }, + returns: v.object({ + deleted: v.number(), + hasMore: v.boolean(), + success: v.optional(v.boolean()), + }), + handler: async ( + ctx, + args, + ): Promise<{ deleted: number; hasMore: boolean; success?: boolean }> => { + const userId = await requireAuthUserIdFromAction(ctx, args.userId); + const identity = await ctx.auth.getUserIdentity(); + if (!identity || identity.subject !== args.externalId) { + throw new Error("Unauthorized"); + } + + switch (args.step) { + case "delete-stream-jobs": + return await ctx.runMutation(internal.users.deleteUserStreamJobs, { + userId, + batchSize: args.batchSize, + }); + case "delete-messages": + return await ctx.runMutation(internal.users.deleteUserMessages, { + userId, + batchSize: args.batchSize, + }); + case "delete-chats": + return await ctx.runMutation(internal.users.deleteUserChats, { + userId, + batchSize: args.batchSize, + }); + case "delete-files": + return await ctx.runMutation(internal.users.deleteUserFiles, { + userId, + batchSize: args.batchSize, + }); + case "delete-user": { + const result: { success: boolean } = await ctx.runMutation( + internal.users.deleteUserRecord, + { + userId, + externalId: args.externalId, + }, + ); + return { + deleted: result.success ? 1 : 0, + hasMore: false, + success: result.success, + }; + } + } + }, +}); + /** * Permanently delete a user account and all associated data. * This is an irreversible action that removes: diff --git a/apps/server/package.json b/apps/server/package.json index 5ff52f84..9556c7a6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -11,15 +11,14 @@ "check-types": "tsc --noEmit || true", "test": "echo \"No Convex tests yet\"" }, - "dependencies": { - "@convex-dev/better-auth": "^0.10.10", - "@convex-dev/persistent-text-streaming": "^0.3.0", - "@convex-dev/rate-limiter": "^0.3.2", - "better-auth": "1.4.18", - "@openrouter/ai-sdk-provider": "2.1.1", - "@valyu/ai-sdk": "^1.0.3", - "convex": "^1.31.7" - }, + "dependencies": { + "@convex-dev/better-auth": "^0.10.10", + "@convex-dev/rate-limiter": "^0.3.2", + "better-auth": "1.4.18", + "@openrouter/ai-sdk-provider": "2.1.1", + "@valyu/ai-sdk": "^1.0.3", + "convex": "^1.31.7" + }, "devDependencies": { "convex-test": "^0.0.41" } diff --git a/apps/web/package.json b/apps/web/package.json index 3406d6cd..27e4eaea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "node": ">=22" }, "scripts": { - "dev": "vite dev --port 3000", + "dev": "NODE_NO_WARNINGS=1 BROWSERSLIST_IGNORE_OLD_DATA=true BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA=true vite dev --port 3000", "build": "vite build", "start": "node .output/server/index.mjs", "preview": "vite preview", @@ -30,6 +30,9 @@ "@tanstack/react-router-ssr-query": "^1.158.1", "@tanstack/react-start": "^1.158.3", "@tanstack/router-plugin": "^1.158.1", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.36.2", + "@upstash/workflow": "^1.1.0", "@valyu/ai-sdk": "^1.1.0", "@xyflow/react": "^12.10.0", "ai": "6.0.75", @@ -48,7 +51,6 @@ "posthog-js": "^1.342.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "redis": "^5.10.0", "resumable-stream": "^2.2.10", "server": "workspace:*", "shadcn": "^3.8.4", @@ -70,6 +72,7 @@ "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", + "baseline-browser-mapping": "^2.9.19", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "^5.9.3", diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 35ee43e5..e5390b75 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -422,34 +422,39 @@ export function AppSidebar({ return; } - const generatedTitle = await convexClient.action(api.chats.generateTitle, { - userId: convexUser._id, - seedText: seedText.trim().slice(0, 300), - length: chatTitleLength, - provider: activeProvider, - }); - + const generatedTitle = await convexClient.action(api.chats.generateTitle, { + userId: convexUser._id, + seedText: seedText.trim().slice(0, 300), + length: chatTitleLength, + provider: activeProvider, + }); if (!generatedTitle) { - toast.error("Unable to generate a new chat name."); + if (activeProvider === "openrouter") { + const hasOpenRouterKey = await convexClient.query(api.users.hasOpenRouterKey, { + userId: convexUser._id, + }); + if (!hasOpenRouterKey) { + toast.error("Connect your OpenRouter API key to generate titles with this provider."); + return; + } + } + toast.error("Could not generate a chat title right now. Try again."); return; } - await convexClient.mutation(api.chats.setTitle, { + await convexClient.mutation(api.chats.setGeneratedTitle, { chatId: chatId as Id<"chats">, userId: convexUser._id, title: generatedTitle, - updateUpdatedAt: false, + force: true, }); - } catch (error) { - console.warn("[Chat] Title regeneration failed:", error); - if (error instanceof Error && error.name === "RateLimitError") { - toast.error(error.message); - } else { - toast.error("Failed to regenerate chat name"); - } - } finally { - setTitleGenerating(chatId, false); - } + } catch (error) { + console.warn("[Chat] Title regeneration failed:", error); + const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; + toast.error(message); + } finally { + setTitleGenerating(chatId, false); + } }; const handleDeleteChat = useCallback( diff --git a/apps/web/src/components/delete-account-modal.tsx b/apps/web/src/components/delete-account-modal.tsx index 366e8b0f..7ede7cd6 100644 --- a/apps/web/src/components/delete-account-modal.tsx +++ b/apps/web/src/components/delete-account-modal.tsx @@ -1,6 +1,4 @@ import { useState } from "react"; -import { useMutation } from "convex/react"; -import { api } from "@server/convex/_generated/api"; import { AlertTriangleIcon } from "lucide-react"; import type { Id } from "@server/convex/_generated/dataModel"; import { signOut } from "@/lib/auth-client"; @@ -34,8 +32,6 @@ export function DeleteAccountModal({ const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(null); - const deleteAccount = useMutation(api.users.deleteAccount); - const isConfirmValid = confirmText === "DELETE"; const handleDelete = async () => { @@ -45,10 +41,20 @@ export function DeleteAccountModal({ setError(null); try { - await deleteAccount({ - userId, - externalId, + const response = await fetch("/api/workflow/delete-account", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId, + externalId, + }), }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { error?: string }; + throw new Error(payload.error || "Failed to delete account"); + } // Account deleted successfully - sign out and redirect // Even if signOut fails, redirect to sign-in since the account is gone diff --git a/apps/web/src/hooks/use-persistent-chat.test.ts b/apps/web/src/hooks/use-persistent-chat.test.ts new file mode 100644 index 00000000..84f9d5af --- /dev/null +++ b/apps/web/src/hooks/use-persistent-chat.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + formatTitleGenerationError, + shouldAttemptAutoTitle, + shouldTriggerAutoTitle, +} from "@/lib/title-generation"; + +describe("shouldAttemptAutoTitle", () => { + it("returns true for the first user message with non-empty seed", () => { + const result = shouldAttemptAutoTitle({ + existingMessageCount: 0, + seedText: "hello world", + }); + + expect(result).toBe(true); + }); + + it("returns false when chat already has prior messages", () => { + const result = shouldAttemptAutoTitle({ + existingMessageCount: 1, + seedText: "hello world", + }); + + expect(result).toBe(false); + }); + + it("returns false when seed text is empty/whitespace", () => { + const result = shouldAttemptAutoTitle({ + existingMessageCount: 0, + seedText: " ", + }); + + expect(result).toBe(false); + }); +}); + +describe("formatTitleGenerationError", () => { + it("returns payload error string when present", () => { + expect(formatTitleGenerationError({ error: "Unauthorized" })).toBe("Unauthorized"); + }); + + it("maps known reason codes", () => { + expect(formatTitleGenerationError({ reason: "missing_openrouter_key" })).toContain( + "OpenRouter API key", + ); + expect(formatTitleGenerationError({ reason: "empty_seed" })).toContain( + "Not enough message content", + ); + expect(formatTitleGenerationError({ reason: "generation_failed" })).toContain( + "Could not generate", + ); + }); +}); + +describe("shouldTriggerAutoTitle", () => { + it("returns true when started without a chat id", () => { + expect( + shouldTriggerAutoTitle({ + startedWithoutChatId: true, + existingMessageCount: 5, + seedText: "hello", + }), + ).toBe(true); + }); + + it("returns false for empty seed text", () => { + expect( + shouldTriggerAutoTitle({ + startedWithoutChatId: true, + existingMessageCount: 0, + seedText: " ", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/use-persistent-chat.ts b/apps/web/src/hooks/use-persistent-chat.ts index 75ca7a60..0f2384f8 100644 --- a/apps/web/src/hooks/use-persistent-chat.ts +++ b/apps/web/src/hooks/use-persistent-chat.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useAction, useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "convex/react"; import { api } from "@server/convex/_generated/api"; import { toast } from "sonner"; import type { Id } from "@server/convex/_generated/dataModel"; @@ -7,7 +7,6 @@ import type { UIMessage } from "ai"; import { useAuth } from "@/lib/auth-client"; import { getModelById, getModelCapabilities, useModelStore, useModels } from "@/stores/model"; import { useProviderStore } from "@/stores/provider"; -import { useChatTitleStore } from "@/stores/chat-title"; import { useStreamStore } from "@/stores/stream"; import { analytics } from "@/lib/analytics"; @@ -88,8 +87,6 @@ function getReasoningText(part: unknown): string | undefined { return isReasoningPart(part) ? part.text : undefined; } -const TITLE_GENERATION_RETRY_DELAY_MS = 1500; - function sanitizeToolName(toolName: string | undefined): string { if (!toolName || toolName.trim().length === 0) return "tool"; return toolName.replace(/[^a-zA-Z0-9_-]/g, "_"); @@ -263,8 +260,6 @@ export function usePersistentChat({ const { models } = useModels(); const activeProvider = useProviderStore((s) => s.activeProvider); const webSearchEnabled = useProviderStore((s) => s.webSearchEnabled); - const chatTitleLength = useChatTitleStore((s) => s.length); - const setTitleGenerating = useChatTitleStore((s) => s.setGenerating); const [messages, setMessages] = useState>([]); const [status, setStatus] = useState<"ready" | "submitted" | "streaming" | "error">("ready"); @@ -314,8 +309,6 @@ export function usePersistentChat({ const editAndRegenerate = useMutation(api.messages.editAndRegenerate); const retryMessageMut = useMutation(api.messages.retryMessage); const forkChatMut = useMutation(api.chats.fork); - const updateTitle = useMutation(api.chats.updateTitle); - const generateTitle = useAction(api.chats.generateTitle); const startBackgroundStream = useMutation(api.backgroundStream.startStream); const cleanupStaleJobs = useMutation(api.backgroundStream.cleanupStaleJobs); @@ -521,7 +514,7 @@ export function usePersistentChat({ return; } - let targetChatId = chatIdRef.current; + let targetChatId = chatIdRef.current; if (!targetChatId) { try { @@ -610,71 +603,6 @@ export function usePersistentChat({ }, ]); - if (!chatId) { - const seedText = message.text.trim().slice(0, 300); - if (seedText) { - void (async () => { - if (!targetChatId) return; - - const attemptGenerate = async () => { - try { - const generatedTitle = await generateTitle({ - userId: convexUserId, - seedText, - length: chatTitleLength, - provider: activeProvider, - }); - - if (generatedTitle) { - await updateTitle({ - chatId: targetChatId as Id<"chats">, - userId: convexUserId, - title: generatedTitle, - }); - return { status: "success" } as const; - } - return { status: "empty" } as const; - } catch (err) { - const parsedError = err instanceof Error ? err : new Error(String(err)); - return { - status: "error", - message: parsedError.message, - name: parsedError.name, - } as const; - } - }; - - if (isMountedRef.current) { - setTitleGenerating(targetChatId, true, "auto"); - } - try { - const result = await attemptGenerate(); - if (result.status === "error") { - const isRateLimit = result.name === "RateLimitError"; - if (!isRateLimit) { - await new Promise((resolve) => - setTimeout(resolve, TITLE_GENERATION_RETRY_DELAY_MS) - ); - const retryResult = await attemptGenerate(); - if (retryResult.status === "error") { - if (isMountedRef.current) { - toast.error("Failed to generate chat name"); - } - } - } else { - if (isMountedRef.current) { - toast.error(result.message || "Rate limit reached. Try again later."); - } - } - } - } finally { - if (isMountedRef.current) { - setTitleGenerating(targetChatId, false); - } - } - })(); - } - } } catch (err) { const parsedError = err instanceof Error ? err : new Error("Unknown error"); setError(parsedError); @@ -706,13 +634,12 @@ export function usePersistentChat({ } } }, - [ - convexUserId, isUserLoading, user?.id, chatId, messages, models, - activeProvider, webSearchEnabled, chatTitleLength, - setTitleGenerating, createChat, sendMessages, updateTitle, generateTitle, startBackgroundStream, - cleanupStaleJobs, - ], - ); + [ + convexUserId, isUserLoading, user?.id, chatId, messages, models, + activeProvider, webSearchEnabled, createChat, sendMessages, + startBackgroundStream, cleanupStaleJobs, + ], + ); const editMessage = useCallback( async (messageId: string, newContent: string) => { diff --git a/apps/web/src/lib/redis.ts b/apps/web/src/lib/redis.ts index 49755c3f..720ff546 100644 --- a/apps/web/src/lib/redis.ts +++ b/apps/web/src/lib/redis.ts @@ -1,59 +1,11 @@ -/** - * Redis client for real-time streaming - * - * Uses Redis Streams for durable, resumable chat token streaming. - * Works with any Redis (self-hosted, Upstash, etc.) - */ +import type { Redis as UpstashRedis } from "@upstash/redis"; +import { upstashRedis } from "@/lib/upstash"; -import { createClient } from "redis"; -import type {RedisClientType} from "redis"; - -let redisClient: RedisClientType | null = null; -let isConnected = false; -let connectionPromise: Promise | null = null; - -const REDIS_URL = process.env.REDIS_URL; - -export function getRedisClient(): RedisClientType | null { - if (!REDIS_URL) { - return null; - } - - if (!redisClient) { - redisClient = createClient({ url: REDIS_URL }); - redisClient.on("error", (err: Error) => { - console.error("[Redis] Error:", err); - isConnected = false; - }); - redisClient.on("connect", () => { - console.log("[Redis] Connected"); - isConnected = true; - }); - redisClient.on("disconnect", () => { - console.log("[Redis] Disconnected"); - isConnected = false; - }); - connectionPromise = redisClient.connect().then(() => {}).catch((err: Error) => { - console.error("[Redis] Connection failed:", err); - isConnected = false; - }); - } - - return redisClient; -} - -export async function ensureRedisConnected(): Promise { - if (!REDIS_URL) return false; - getRedisClient(); - if (connectionPromise) { - await connectionPromise; - } - return isConnected; -} - -export function isRedisAvailable(): boolean { - return !!REDIS_URL && isConnected; -} +const STREAM_TTL_SECONDS = 3600; +const STREAM_ERROR_TTL_SECONDS = 600; +const TYPING_TTL_SECONDS = 3; +const PRESENCE_TTL_SECONDS = 60; +const ONLINE_WINDOW_MS = 60_000; const keys = { stream: (chatId: string) => `chat:${chatId}:stream`, @@ -80,14 +32,38 @@ export interface StreamMeta { error?: string; } -async function getConnectedClient(): Promise { - const client = getRedisClient(); - if (!client) return null; - if (connectionPromise) { - await connectionPromise; +export function getRedisClient(): UpstashRedis | null { + return upstashRedis; +} + +export function isRedisAvailable(): boolean { + return upstashRedis !== null; +} + +export async function ensureRedisConnected(): Promise { + if (!upstashRedis) return false; + try { + await upstashRedis.ping(); + return true; + } catch (error) { + console.error("[Upstash Redis] Ping failed:", error); + return false; + } +} + +async function getConnectedClient(): Promise { + if (!upstashRedis) return null; + const isReady = await ensureRedisConnected(); + return isReady ? upstashRedis : null; +} + +function parseStreamMeta(value: unknown): StreamMeta | null { + if (typeof value !== "string") return null; + try { + return JSON.parse(value) as StreamMeta; + } catch { + return null; } - if (!isConnected) return null; - return client; } export async function initStream( @@ -106,8 +82,9 @@ export async function initStream( startedAt: Date.now(), }; - await client.set(keys.meta(chatId), JSON.stringify(meta), { EX: 3600 }); - console.log("[Redis] Stream initialized for chat:", chatId); + await client.set(keys.meta(chatId), JSON.stringify(meta), { + ex: STREAM_TTL_SECONDS, + }); return true; } @@ -120,14 +97,12 @@ export async function appendToken( if (!client) return null; const streamKey = keys.stream(chatId); - const entryId = await client.xAdd(streamKey, "*", { + const entryId = await client.xadd(streamKey, "*", { text, type, ts: Date.now().toString(), }); - - await client.expire(streamKey, 3600); - + await client.expire(streamKey, STREAM_TTL_SECONDS); return entryId; } @@ -138,14 +113,14 @@ export async function completeStream(chatId: string): Promise { await appendToken(chatId, "", "done"); const metaKey = keys.meta(chatId); - const metaStr = await client.get(metaKey); - if (metaStr) { - const meta: StreamMeta = JSON.parse(metaStr); - meta.status = "completed"; - meta.completedAt = Date.now(); - await client.set(metaKey, JSON.stringify(meta), { EX: 3600 }); - } - console.log("[Redis] Stream completed for chat:", chatId); + const meta = parseStreamMeta(await client.get(metaKey)); + if (!meta) return; + + meta.status = "completed"; + meta.completedAt = Date.now(); + await client.set(metaKey, JSON.stringify(meta), { + ex: STREAM_TTL_SECONDS, + }); } export async function errorStream(chatId: string, error: string): Promise { @@ -155,14 +130,15 @@ export async function errorStream(chatId: string, error: string): Promise await appendToken(chatId, error, "error"); const metaKey = keys.meta(chatId); - const metaStr = await client.get(metaKey); - if (metaStr) { - const meta: StreamMeta = JSON.parse(metaStr); - meta.status = "error"; - meta.error = error; - meta.completedAt = Date.now(); - await client.set(metaKey, JSON.stringify(meta), { EX: 600 }); - } + const meta = parseStreamMeta(await client.get(metaKey)); + if (!meta) return; + + meta.status = "error"; + meta.error = error; + meta.completedAt = Date.now(); + await client.set(metaKey, JSON.stringify(meta), { + ex: STREAM_ERROR_TTL_SECONDS, + }); } export async function readStream( @@ -172,25 +148,32 @@ export async function readStream( const client = await getConnectedClient(); if (!client) return []; - const streamKey = keys.stream(chatId); - const entries = await client.xRange(streamKey, lastId === "0" ? "-" : `(${lastId}`, "+"); - - return entries.map((entry: { id: string; message: Record }) => ({ - id: entry.id, - text: entry.message.text || "", - type: (entry.message.type as StreamToken["type"]), - timestamp: parseInt(entry.message.ts || "0", 10), + const entries = await client.xrange( + keys.stream(chatId), + lastId === "0" ? "-" : `(${lastId}`, + "+", + ); + + return Object.entries(entries).map(([id, message]) => ({ + id, + text: typeof message.text === "string" ? message.text : "", + type: + message.type === "reasoning" || + message.type === "done" || + message.type === "error" + ? message.type + : "text", + timestamp: + typeof message.ts === "string" + ? Number.parseInt(message.ts, 10) + : Date.now(), })); } export async function getStreamMeta(chatId: string): Promise { const client = await getConnectedClient(); if (!client) return null; - - const metaStr = await client.get(keys.meta(chatId)); - if (!metaStr) return null; - - return JSON.parse(metaStr); + return parseStreamMeta(await client.get(keys.meta(chatId))); } export async function hasActiveStream(chatId: string): Promise { @@ -208,10 +191,10 @@ export async function setTyping( const key = keys.typing(chatId, userId); if (isTyping) { - await client.set(key, "1", { EX: 3 }); - } else { - await client.del(key); + await client.set(key, "1", { ex: TYPING_TTL_SECONDS }); + return; } + await client.del(key); } export async function getTypingUsers(chatId: string): Promise> { @@ -219,60 +202,64 @@ export async function getTypingUsers(chatId: string): Promise> { if (!client) return []; const pattern = `chat:${chatId}:typing:*`; - const foundKeys: Array = []; - for await (const scanKeys of client.scanIterator({ MATCH: pattern, COUNT: 100 })) { - if (Array.isArray(scanKeys)) { - foundKeys.push(...scanKeys); - } else { - foundKeys.push(scanKeys); + const users = new Set(); + let cursor: string | number = "0"; + + do { + const [nextCursor, keysFound]: [string, Array] = await client.scan(cursor, { + match: pattern, + count: 100, + }); + cursor = nextCursor; + for (const key of keysFound) { + const userId = key.split(":").pop(); + if (userId) { + users.add(userId); + } } - } + } while (String(cursor) !== "0"); - return foundKeys.map((key) => key.split(":").pop() || ""); + return [...users]; } export async function updatePresence(userId: string): Promise { const client = await getConnectedClient(); if (!client) return; - - await client.set(keys.presence(userId), Date.now().toString(), { EX: 60 }); + await client.set(keys.presence(userId), Date.now().toString(), { + ex: PRESENCE_TTL_SECONDS, + }); } export async function isUserOnline(userId: string): Promise { const client = await getConnectedClient(); if (!client) return false; - const lastSeen = await client.get(keys.presence(userId)); + const lastSeen = await client.get(keys.presence(userId)); if (!lastSeen) return false; - - return Date.now() - parseInt(lastSeen, 10) < 60000; + return Date.now() - Number.parseInt(lastSeen, 10) < ONLINE_WINDOW_MS; } export async function incrementUnread(userId: string, chatId: string): Promise { const client = await getConnectedClient(); if (!client) return; - - await client.hIncrBy(keys.unread(userId), chatId, 1); + await client.hincrby(keys.unread(userId), chatId, 1); } export async function clearUnread(userId: string, chatId: string): Promise { const client = await getConnectedClient(); if (!client) return; - - await client.hDel(keys.unread(userId), chatId); + await client.hdel(keys.unread(userId), chatId); } export async function getUnreadCounts(userId: string): Promise> { const client = await getConnectedClient(); if (!client) return {}; - const counts = await client.hGetAll(keys.unread(userId)); + const counts = await client.hgetall>(keys.unread(userId)); const result: Record = {}; - - for (const [chatId, count] of Object.entries(counts)) { - result[chatId] = parseInt(count, 10); + for (const [chatId, count] of Object.entries(counts ?? {})) { + result[chatId] = Number.parseInt(count, 10); } - return result; } diff --git a/apps/web/src/lib/server-auth.test.ts b/apps/web/src/lib/server-auth.test.ts new file mode 100644 index 00000000..076e8007 --- /dev/null +++ b/apps/web/src/lib/server-auth.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +async function loadModule() { + vi.resetModules(); + return import("@/lib/server-auth"); +} + +function requestWithCookie(cookie: string): Request { + return { + headers: { + get: (name: string) => (name.toLowerCase() === "cookie" ? cookie : null), + }, + } as unknown as Request; +} + +describe("server-auth.getConvexAuthToken", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("uses better-auth.convex_jwt cookie fallback when Convex token endpoint is unavailable", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", ""); + vi.stubEnv("CONVEX_SITE_URL", ""); + + const { getConvexAuthToken } = await loadModule(); + const request = requestWithCookie("better-auth.convex_jwt=abc%2E123%2Exyz; other=value"); + + await expect(getConvexAuthToken(request)).resolves.toBe("abc.123.xyz"); + }); + + it("prefers token endpoint when it returns a valid token", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); + vi.stubEnv("CONVEX_SITE_URL", "https://example.convex.site"); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ token: "endpoint-token" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const { getConvexAuthToken } = await loadModule(); + const request = requestWithCookie("better-auth.convex_jwt=fallback-token"); + + await expect(getConvexAuthToken(request)).resolves.toBe("endpoint-token"); + }); + + it("falls back to cookie token when endpoint request throws", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); + vi.stubEnv("CONVEX_SITE_URL", "https://example.convex.site"); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); + + const { getConvexAuthToken } = await loadModule(); + const request = requestWithCookie("better-auth.convex_jwt=fallback-token"); + + await expect(getConvexAuthToken(request)).resolves.toBe("fallback-token"); + }); +}); diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index b9fd3062..e990b356 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -17,17 +17,41 @@ type AuthSessionResponse = { const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; +function getCookieValue(cookieHeader: string, name: string): string | null { + const target = `${name}=`; + for (const part of cookieHeader.split(";")) { + const trimmed = part.trim(); + if (!trimmed.startsWith(target)) continue; + const rawValue = trimmed.slice(target.length); + if (!rawValue) return null; + try { + return decodeURIComponent(rawValue); + } catch { + return rawValue; + } + } + return null; +} + export async function getConvexAuthToken(request: Request): Promise { - if (!CONVEX_SITE_URL) return null; const cookie = request.headers.get("cookie"); if (!cookie) return null; - const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { - headers: { cookie }, - }); - if (!response.ok) return null; - const data = (await response.json()) as { token?: string } | null; - return data?.token ?? null; + if (CONVEX_SITE_URL) { + try { + const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { + headers: { cookie }, + }); + if (response.ok) { + const data = (await response.json()) as { token?: string } | null; + if (data?.token) return data.token; + } + } catch { + // Fall through to cookie fallback. + } + } + + return getCookieValue(cookie, "better-auth.convex_jwt"); } /** diff --git a/apps/web/src/lib/title-generation.ts b/apps/web/src/lib/title-generation.ts new file mode 100644 index 00000000..8c5ba138 --- /dev/null +++ b/apps/web/src/lib/title-generation.ts @@ -0,0 +1,57 @@ +export type GenerateTitleResponse = { + queued?: boolean; + saved?: boolean; + reason?: string; + error?: string; +}; + +export function shouldAttemptAutoTitle({ + existingMessageCount, + seedText, +}: { + existingMessageCount: number; + seedText: string; +}): boolean { + return existingMessageCount === 0 && seedText.trim().length > 0; +} + +export function shouldTriggerAutoTitle({ + startedWithoutChatId, + existingMessageCount, + seedText, +}: { + startedWithoutChatId: boolean; + existingMessageCount: number; + seedText: string; +}): boolean { + const normalizedSeed = seedText.trim(); + if (!normalizedSeed) return false; + return ( + startedWithoutChatId || + shouldAttemptAutoTitle({ + existingMessageCount, + seedText: normalizedSeed, + }) + ); +} + +export function formatTitleGenerationError(payload: GenerateTitleResponse | null): string { + if (!payload) return "Failed to generate chat name"; + if (payload.error) return payload.error; + if (payload.reason?.startsWith("llm_status_")) { + return "The title model request failed. Please try again."; + } + + switch (payload.reason) { + case "missing_openrouter_key": + return "Connect your OpenRouter API key to generate titles with this provider."; + case "generation_failed": + return "Could not generate a chat title right now. Try again."; + case "empty_seed": + return "Not enough message content to generate a title."; + case "empty_title": + return "Model returned an empty title. Try again."; + default: + return "Failed to generate chat name"; + } +} diff --git a/apps/web/src/lib/upstash.ts b/apps/web/src/lib/upstash.ts new file mode 100644 index 00000000..e4900441 --- /dev/null +++ b/apps/web/src/lib/upstash.ts @@ -0,0 +1,51 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { Client as WorkflowClient } from "@upstash/workflow"; + +const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL?.trim(); +const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); +const QSTASH_URL = process.env.QSTASH_URL?.trim(); +const QSTASH_TOKEN = process.env.QSTASH_TOKEN?.trim(); + +export const upstashRedis = + UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN + ? new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + }) + : null; + +function createSlidingWindowRatelimit( + limit: number, + window: `${number} ${"ms" | "s" | "m" | "h" | "d"}`, + prefix: string, +) { + if (!upstashRedis) return null; + return new Ratelimit({ + redis: upstashRedis, + limiter: Ratelimit.slidingWindow(limit, window), + prefix, + }); +} + +export const chatUserRatelimit = createSlidingWindowRatelimit(30, "60 s", "ratelimit:chat:user"); +export const chatIpRatelimit = createSlidingWindowRatelimit(60, "60 s", "ratelimit:chat:ip"); +export const chatRatelimit = chatUserRatelimit; +export const uploadRatelimit = createSlidingWindowRatelimit(20, "60 s", "ratelimit:upload:user"); +export const exportRatelimit = createSlidingWindowRatelimit(10, "60 s", "ratelimit:export:user"); +export const authRatelimit = createSlidingWindowRatelimit(20, "60 s", "ratelimit:auth:user"); + +export const workflowClient = QSTASH_TOKEN + ? new WorkflowClient({ + baseUrl: QSTASH_URL, + token: QSTASH_TOKEN, + }) + : null; + +export function isUpstashRedisConfigured(): boolean { + return upstashRedis !== null; +} + +export function isQstashConfigured(): boolean { + return workflowClient !== null; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index bb75cc76..ef486a16 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,7 +20,11 @@ import { Route as AuthSignInRouteImport } from './routes/auth/sign-in' import { Route as AuthCallbackRouteImport } from './routes/auth/callback' import { Route as ApiTypingRouteImport } from './routes/api/typing' import { Route as ApiOpenrouterKeyRouteImport } from './routes/api/openrouter-key' -import { Route as ApiChatRouteImport } from './routes/api/chat' +import { Route as ApiModelsRouteImport } from './routes/api/models' +import { Route as ApiWorkflowGenerateTitleRouteImport } from './routes/api/workflow/generate-title' +import { Route as ApiWorkflowExportChatRouteImport } from './routes/api/workflow/export-chat' +import { Route as ApiWorkflowDeleteAccountRouteImport } from './routes/api/workflow/delete-account' +import { Route as ApiWorkflowCleanupRouteImport } from './routes/api/workflow/cleanup' const TermsRoute = TermsRouteImport.update({ id: '/terms', @@ -77,9 +81,31 @@ const ApiOpenrouterKeyRoute = ApiOpenrouterKeyRouteImport.update({ path: '/api/openrouter-key', getParentRoute: () => rootRouteImport, } as any) -const ApiChatRoute = ApiChatRouteImport.update({ - id: '/api/chat', - path: '/api/chat', +const ApiModelsRoute = ApiModelsRouteImport.update({ + id: '/api/models', + path: '/api/models', + getParentRoute: () => rootRouteImport, +} as any) +const ApiWorkflowGenerateTitleRoute = + ApiWorkflowGenerateTitleRouteImport.update({ + id: '/api/workflow/generate-title', + path: '/api/workflow/generate-title', + getParentRoute: () => rootRouteImport, + } as any) +const ApiWorkflowExportChatRoute = ApiWorkflowExportChatRouteImport.update({ + id: '/api/workflow/export-chat', + path: '/api/workflow/export-chat', + getParentRoute: () => rootRouteImport, +} as any) +const ApiWorkflowDeleteAccountRoute = + ApiWorkflowDeleteAccountRouteImport.update({ + id: '/api/workflow/delete-account', + path: '/api/workflow/delete-account', + getParentRoute: () => rootRouteImport, + } as any) +const ApiWorkflowCleanupRoute = ApiWorkflowCleanupRouteImport.update({ + id: '/api/workflow/cleanup', + path: '/api/workflow/cleanup', getParentRoute: () => rootRouteImport, } as any) @@ -89,13 +115,17 @@ export interface FileRoutesByFullPath { '/privacy': typeof PrivacyRoute '/settings': typeof SettingsRoute '/terms': typeof TermsRoute - '/api/chat': typeof ApiChatRoute + '/api/models': typeof ApiModelsRoute '/api/openrouter-key': typeof ApiOpenrouterKeyRoute '/api/typing': typeof ApiTypingRoute '/auth/callback': typeof AuthCallbackRoute '/auth/sign-in': typeof AuthSignInRoute '/c/$chatId': typeof CChatIdRoute '/openrouter/callback': typeof OpenrouterCallbackRoute + '/api/workflow/cleanup': typeof ApiWorkflowCleanupRoute + '/api/workflow/delete-account': typeof ApiWorkflowDeleteAccountRoute + '/api/workflow/export-chat': typeof ApiWorkflowExportChatRoute + '/api/workflow/generate-title': typeof ApiWorkflowGenerateTitleRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -103,13 +133,17 @@ export interface FileRoutesByTo { '/privacy': typeof PrivacyRoute '/settings': typeof SettingsRoute '/terms': typeof TermsRoute - '/api/chat': typeof ApiChatRoute + '/api/models': typeof ApiModelsRoute '/api/openrouter-key': typeof ApiOpenrouterKeyRoute '/api/typing': typeof ApiTypingRoute '/auth/callback': typeof AuthCallbackRoute '/auth/sign-in': typeof AuthSignInRoute '/c/$chatId': typeof CChatIdRoute '/openrouter/callback': typeof OpenrouterCallbackRoute + '/api/workflow/cleanup': typeof ApiWorkflowCleanupRoute + '/api/workflow/delete-account': typeof ApiWorkflowDeleteAccountRoute + '/api/workflow/export-chat': typeof ApiWorkflowExportChatRoute + '/api/workflow/generate-title': typeof ApiWorkflowGenerateTitleRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -118,13 +152,17 @@ export interface FileRoutesById { '/privacy': typeof PrivacyRoute '/settings': typeof SettingsRoute '/terms': typeof TermsRoute - '/api/chat': typeof ApiChatRoute + '/api/models': typeof ApiModelsRoute '/api/openrouter-key': typeof ApiOpenrouterKeyRoute '/api/typing': typeof ApiTypingRoute '/auth/callback': typeof AuthCallbackRoute '/auth/sign-in': typeof AuthSignInRoute '/c/$chatId': typeof CChatIdRoute '/openrouter/callback': typeof OpenrouterCallbackRoute + '/api/workflow/cleanup': typeof ApiWorkflowCleanupRoute + '/api/workflow/delete-account': typeof ApiWorkflowDeleteAccountRoute + '/api/workflow/export-chat': typeof ApiWorkflowExportChatRoute + '/api/workflow/generate-title': typeof ApiWorkflowGenerateTitleRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -134,13 +172,17 @@ export interface FileRouteTypes { | '/privacy' | '/settings' | '/terms' - | '/api/chat' + | '/api/models' | '/api/openrouter-key' | '/api/typing' | '/auth/callback' | '/auth/sign-in' | '/c/$chatId' | '/openrouter/callback' + | '/api/workflow/cleanup' + | '/api/workflow/delete-account' + | '/api/workflow/export-chat' + | '/api/workflow/generate-title' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -148,13 +190,17 @@ export interface FileRouteTypes { | '/privacy' | '/settings' | '/terms' - | '/api/chat' + | '/api/models' | '/api/openrouter-key' | '/api/typing' | '/auth/callback' | '/auth/sign-in' | '/c/$chatId' | '/openrouter/callback' + | '/api/workflow/cleanup' + | '/api/workflow/delete-account' + | '/api/workflow/export-chat' + | '/api/workflow/generate-title' id: | '__root__' | '/' @@ -162,13 +208,17 @@ export interface FileRouteTypes { | '/privacy' | '/settings' | '/terms' - | '/api/chat' + | '/api/models' | '/api/openrouter-key' | '/api/typing' | '/auth/callback' | '/auth/sign-in' | '/c/$chatId' | '/openrouter/callback' + | '/api/workflow/cleanup' + | '/api/workflow/delete-account' + | '/api/workflow/export-chat' + | '/api/workflow/generate-title' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -177,13 +227,17 @@ export interface RootRouteChildren { PrivacyRoute: typeof PrivacyRoute SettingsRoute: typeof SettingsRoute TermsRoute: typeof TermsRoute - ApiChatRoute: typeof ApiChatRoute + ApiModelsRoute: typeof ApiModelsRoute ApiOpenrouterKeyRoute: typeof ApiOpenrouterKeyRoute ApiTypingRoute: typeof ApiTypingRoute AuthCallbackRoute: typeof AuthCallbackRoute AuthSignInRoute: typeof AuthSignInRoute CChatIdRoute: typeof CChatIdRoute OpenrouterCallbackRoute: typeof OpenrouterCallbackRoute + ApiWorkflowCleanupRoute: typeof ApiWorkflowCleanupRoute + ApiWorkflowDeleteAccountRoute: typeof ApiWorkflowDeleteAccountRoute + ApiWorkflowExportChatRoute: typeof ApiWorkflowExportChatRoute + ApiWorkflowGenerateTitleRoute: typeof ApiWorkflowGenerateTitleRoute } declare module '@tanstack/react-router' { @@ -265,11 +319,39 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpenrouterKeyRouteImport parentRoute: typeof rootRouteImport } - '/api/chat': { - id: '/api/chat' - path: '/api/chat' - fullPath: '/api/chat' - preLoaderRoute: typeof ApiChatRouteImport + '/api/models': { + id: '/api/models' + path: '/api/models' + fullPath: '/api/models' + preLoaderRoute: typeof ApiModelsRouteImport + parentRoute: typeof rootRouteImport + } + '/api/workflow/generate-title': { + id: '/api/workflow/generate-title' + path: '/api/workflow/generate-title' + fullPath: '/api/workflow/generate-title' + preLoaderRoute: typeof ApiWorkflowGenerateTitleRouteImport + parentRoute: typeof rootRouteImport + } + '/api/workflow/export-chat': { + id: '/api/workflow/export-chat' + path: '/api/workflow/export-chat' + fullPath: '/api/workflow/export-chat' + preLoaderRoute: typeof ApiWorkflowExportChatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/workflow/delete-account': { + id: '/api/workflow/delete-account' + path: '/api/workflow/delete-account' + fullPath: '/api/workflow/delete-account' + preLoaderRoute: typeof ApiWorkflowDeleteAccountRouteImport + parentRoute: typeof rootRouteImport + } + '/api/workflow/cleanup': { + id: '/api/workflow/cleanup' + path: '/api/workflow/cleanup' + fullPath: '/api/workflow/cleanup' + preLoaderRoute: typeof ApiWorkflowCleanupRouteImport parentRoute: typeof rootRouteImport } } @@ -281,13 +363,17 @@ const rootRouteChildren: RootRouteChildren = { PrivacyRoute: PrivacyRoute, SettingsRoute: SettingsRoute, TermsRoute: TermsRoute, - ApiChatRoute: ApiChatRoute, + ApiModelsRoute: ApiModelsRoute, ApiOpenrouterKeyRoute: ApiOpenrouterKeyRoute, ApiTypingRoute: ApiTypingRoute, AuthCallbackRoute: AuthCallbackRoute, AuthSignInRoute: AuthSignInRoute, CChatIdRoute: CChatIdRoute, OpenrouterCallbackRoute: OpenrouterCallbackRoute, + ApiWorkflowCleanupRoute: ApiWorkflowCleanupRoute, + ApiWorkflowDeleteAccountRoute: ApiWorkflowDeleteAccountRoute, + ApiWorkflowExportChatRoute: ApiWorkflowExportChatRoute, + ApiWorkflowGenerateTitleRoute: ApiWorkflowGenerateTitleRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/api/chat.ts b/apps/web/src/routes/api/chat.ts deleted file mode 100644 index 6d1324e7..00000000 --- a/apps/web/src/routes/api/chat.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { json } from "@tanstack/react-start"; -import { - convertToModelMessages, - generateId, - stepCountIs, - streamText, -} from "ai"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; -import { webSearch } from "@valyu/ai-sdk"; -import { api } from "@server/convex/_generated/api"; -import type { Id } from "@server/convex/_generated/dataModel"; -import { getRedisClient, redis } from "@/lib/redis"; -import { decryptSecret } from "@/lib/server-crypto"; -import { getAuthUser, getConvexClientForRequest, getConvexUserId, isSameOrigin } from "@/lib/server-auth"; - -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; -const VALYU_API_KEY = process.env.VALYU_API_KEY; -const CHAT_RATE_LIMIT_WINDOW_SECONDS = 60; -const CHAT_RATE_LIMIT_MAX_USER = 30; -const CHAT_RATE_LIMIT_MAX_IP = 60; - -/** - * Controls whether to trust proxy-forwarded headers for client IP extraction. - * Set to "cloudflare" to trust cf-connecting-ip only - * Set to "true" or "1" to trust x-forwarded-for and x-real-ip - * Leave unset or empty to not trust any forwarding headers (direct requests only) - */ -const TRUST_PROXY_FORWARDED = process.env.TRUST_PROXY_FORWARDED; - -/** - * Extracts the client IP address from the request. - * - * SECURITY: This function only honors proxy headers when explicitly configured - * via TRUST_PROXY_FORWARDED env var. This prevents attackers from spoofing - * their IP by setting x-forwarded-for headers on direct requests. - * - * @param request - The incoming HTTP request - * @returns The client IP address or null if it cannot be determined - */ -function getRequestIp(request: Request): string | null { - // Only trust Cloudflare headers when explicitly configured - if (TRUST_PROXY_FORWARDED?.toLowerCase() === "cloudflare") { - const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); - if (cfConnectingIp) { - return cfConnectingIp; - } - // Fall through - no trusted IP available - return null; - } - - // Only trust standard proxy headers when explicitly enabled - if (TRUST_PROXY_FORWARDED?.toLowerCase() === "true" || TRUST_PROXY_FORWARDED === "1") { - // x-forwarded-for may contain a comma-separated list of IPs - // The first IP is the original client, subsequent ones are proxies - const forwardedFor = request.headers.get("x-forwarded-for"); - if (forwardedFor) { - const firstIp = forwardedFor.split(",")[0]?.trim(); - if (firstIp) { - return firstIp; - } - } - - // x-real-ip is typically set by reverse proxies (nginx, etc.) - const realIp = request.headers.get("x-real-ip")?.trim(); - if (realIp) { - return realIp; - } - - // true-client-ip is used by some CDNs (Akamai, etc.) - const trueClientIp = request.headers.get("true-client-ip")?.trim(); - if (trueClientIp) { - return trueClientIp; - } - } - - // No trusted forwarding headers configured or available - // In production behind a proxy, this means IP-based rate limiting will be ineffective - // but user-based rate limiting (enforced first) will still protect against abuse - return null; -} - -/** - * Enforces rate limiting on chat requests. - * - * Rate limiting strategy (defense in depth): - * 1. USER-BASED (primary): Limits requests per authenticated user - cannot be spoofed - * 2. IP-BASED (secondary): Additional layer when trusted proxy is configured - * - * The user-based limit is always enforced first and is the primary protection. - * IP-based limiting is only effective when TRUST_PROXY_FORWARDED is properly - * configured for your deployment environment (e.g., "cloudflare" or "true"). - * - * @param request - The incoming HTTP request - * @param userId - The authenticated user's ID (from session/token, not spoofable) - * @returns 429 response if rate limited, null otherwise - */ -async function enforceRateLimit(request: Request, userId: string): Promise { - const redisReady = await redis.ensureConnected(); - if (!redisReady) return null; - const client = getRedisClient(); - if (!client) return null; - - const ip = getRequestIp(request); - const userKey = `rate:chat:user:${userId}`; - const userCount = await client.incr(userKey); - if (userCount === 1) { - await client.expire(userKey, CHAT_RATE_LIMIT_WINDOW_SECONDS); - } - if (userCount > CHAT_RATE_LIMIT_MAX_USER) { - return json( - { error: "Rate limit exceeded. Please try again shortly." }, - { status: 429 }, - ); - } - - if (ip) { - const ipKey = `rate:chat:ip:${ip}`; - const ipCount = await client.incr(ipKey); - if (ipCount === 1) { - await client.expire(ipKey, CHAT_RATE_LIMIT_WINDOW_SECONDS); - } - if (ipCount > CHAT_RATE_LIMIT_MAX_IP) { - return json( - { error: "Rate limit exceeded. Please try again shortly." }, - { status: 429 }, - ); - } - } - - return null; -} - - -export const Route = createFileRoute("/api/chat")({ - server: { - handlers: { - GET: async ({ request }) => { - if (!isSameOrigin(request)) { - return json({ error: "Invalid origin" }, { status: 403 }); - } - const authUser = await getAuthUser(request); - if (!authUser) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - const userId = await getConvexUserId(authUser, request); - if (!userId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - - const url = new URL(request.url); - const chatId = url.searchParams.get("chatId"); - const lastId = url.searchParams.get("lastId") || "0"; - - if (!chatId) { - return json({ error: "chatId is required" }, { status: 400 }); - } - - const redisReady = await redis.ensureConnected(); - if (!redisReady) { - console.log("[Chat API GET] Redis not available"); - return json({ error: "Redis not configured" }, { status: 503 }); - } - - console.log("[Chat API GET] Reading stream for chat:", chatId); - const meta = await redis.stream.getMeta(chatId); - if (!meta) { - console.log("[Chat API GET] No stream metadata found"); - return new Response(null, { status: 204 }); - } - if (meta.userId !== userId) { - return json({ error: "Unauthorized" }, { status: 403 }); - } - console.log("[Chat API GET] Stream meta:", meta.status); - - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - let currentLastId = lastId; - let isComplete = false; - - while (!isComplete) { - const tokens = await redis.stream.read(chatId, currentLastId); - - for (const token of tokens) { - if (token.type === "done") { - isComplete = true; - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: "done" })}\n\n`), - ); - break; - } - - if (token.type === "error") { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: "error", text: token.text })}\n\n`, - ), - ); - isComplete = true; - break; - } - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: token.type, text: token.text, id: token.id })}\n\n`, - ), - ); - currentLastId = token.id; - } - - if (!isComplete && tokens.length === 0) { - const currentMeta = await redis.stream.getMeta(chatId); - if (currentMeta?.status !== "streaming") { - isComplete = true; - } else { - await new Promise((r) => setTimeout(r, 50)); - } - } - } - - controller.close(); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); - }, - - POST: async ({ request }) => { - const abortSignal = request.signal; - if (!isSameOrigin(request)) { - return json({ error: "Invalid origin" }, { status: 403 }); - } - const authUser = await getAuthUser(request); - if (!authUser) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - const convexUserId = await getConvexUserId(authUser, request); - if (!convexUserId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - - const rateLimitResponse = await enforceRateLimit(request, convexUserId); - if (rateLimitResponse) return rateLimitResponse; - const redisReady = await redis.ensureConnected(); - console.log("[Chat API POST] Redis ready:", redisReady); - - try { - const body = await request.json(); - const { - messages, - model, - provider = "osschat", - enableWebSearch = false, - reasoningEffort, - maxSteps = 5, - chatId, - } = body; - - if (!messages || !Array.isArray(messages)) { - return json({ error: "messages is required and must be an array" }, { status: 400 }); - } - - if (!model || typeof model !== "string") { - return json({ error: "model is required and must be a string" }, { status: 400 }); - } - - if (provider === "osschat" && !OPENROUTER_API_KEY) { - return json( - { error: "OSSChat Cloud is not configured on this server" }, - { status: 500 }, - ); - } - - for (const message of messages) { - if (!message.role) { - return json({ error: "Each message must have a role property" }, { status: 400 }); - } - if (!["user", "assistant", "system"].includes(message.role)) { - return json( - { error: "Message role must be one of: user, assistant, system" }, - { status: 400 }, - ); - } - if (!message.parts && !message.content) { - return json({ error: "Each message must have parts or content" }, { status: 400 }); - } - } - - let openrouterKey: string | null = null; - if (provider === "osschat") { - openrouterKey = OPENROUTER_API_KEY ?? null; - } else { - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - const encryptedKey = await convexClient.query(api.users.getOpenRouterKey, { - userId: convexUserId, - }); - openrouterKey = encryptedKey ? decryptSecret(encryptedKey) : null; - } - if (!openrouterKey) { - return json({ error: "OpenRouter API key not configured" }, { status: 400 }); - } - const openRouter = createOpenRouter({ apiKey: openrouterKey }); - const aiModel = openRouter(model); - - const modelMessages = await convertToModelMessages(messages); - - const streamOptions: Parameters[0] = { - model: aiModel as Parameters[0]["model"], - messages: modelMessages, - abortSignal, - }; - - if (enableWebSearch && VALYU_API_KEY) { - streamOptions.tools = { - webSearch: webSearch({ apiKey: VALYU_API_KEY }), - } as any; - } - - const hasTools = enableWebSearch && VALYU_API_KEY; - const stepLimit = hasTools ? Math.max(1, Math.min(10, maxSteps)) : 1; - streamOptions.stopWhen = stepCountIs(stepLimit); - - if (reasoningEffort && reasoningEffort !== "none") { - const effortValue = reasoningEffort as "low" | "medium" | "high"; - streamOptions.providerOptions = { - ...streamOptions.providerOptions, - openrouter: { - reasoning: { - effort: effortValue, - }, - }, - }; - } - - const messageId = generateId(); - const streamId = `${chatId}-${messageId}`; - - if (chatId && redisReady) { - console.log("[Chat API POST] Initializing Redis stream for chat:", chatId); - await redis.stream.init(chatId, convexUserId, messageId); - } - - if (chatId) { - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { - console.error("[Chat API] Convex auth token unavailable"); - } else { - try { - console.log("[Chat API] Setting active stream:", streamId); - await convexClient.mutation(api.chats.setActiveStream, { - chatId: chatId as Id<"chats">, - userId: convexUserId, - streamId, - }); - console.log("[Chat API] Active stream set successfully"); - } catch (err) { - console.error("[Chat API] Failed to set active stream:", err); - } - } - } - - const result = streamText(streamOptions); - - const encoder = new TextEncoder(); - let fullContent = ""; - let fullReasoning = ""; - - const stream = new ReadableStream({ - async start(controller) { - const markStreamInterrupted = async () => { - if (chatId && redisReady) { - await redis.stream.complete(chatId); - } - }; - - try { - for await (const part of result.fullStream) { - if (abortSignal.aborted) { - console.log("[Chat API POST] Client disconnected, marking stream interrupted"); - await markStreamInterrupted(); - controller.close(); - return; - } - - if (part.type === "text-delta") { - const text = part.text; - fullContent += text; - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: "text", text })}\n\n`, - ), - ); - - if (chatId && redisReady) { - await redis.stream.append(chatId, text, "text"); - } - } else if (part.type === "reasoning-delta") { - const text = (part as { type: "reasoning-delta"; text: string }).text; - fullReasoning += text; - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: "reasoning", text })}\n\n`, - ), - ); - - if (chatId && redisReady) { - await redis.stream.append(chatId, text, "reasoning"); - } - } - } - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - type: "done", - content: fullContent, - reasoning: fullReasoning, - messageId, - })}\n\n`, - ), - ); - - if (chatId && redisReady) { - await redis.stream.complete(chatId); - console.log("[Chat API POST] Redis stream completed"); - } - - if (chatId) { - const convexClient = await getConvexClientForRequest(request); - if (convexClient) { - await convexClient.mutation(api.chats.setActiveStream, { - chatId: chatId as Id<"chats">, - userId: convexUserId, - streamId: null, - }); - } - } - - controller.close(); - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - console.log("[Chat API POST] Stream aborted, marking stream interrupted"); - await markStreamInterrupted(); - controller.close(); - return; - } - - const errorMessage = "Stream failed"; - console.error("[Chat API] Stream error", err); - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: "error", text: errorMessage })}\n\n`, - ), - ); - - if (chatId && redisReady) { - await redis.stream.error(chatId, errorMessage); - } - - controller.close(); - } - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "X-Message-Id": messageId, - }, - }); - } catch (error) { - console.error("[Chat API Error]", error); - - if (error instanceof SyntaxError) { - return json({ error: "Invalid JSON in request body" }, { status: 400 }); - } - - if (error instanceof Error) { - if (error.message.includes("401") || error.message.includes("Unauthorized")) { - return json( - { error: "Invalid API key. Please check your OpenRouter API key." }, - { status: 401 }, - ); - } - - if (error.message.includes("429") || error.message.includes("rate limit")) { - return json( - { error: "Rate limit exceeded. Please try again later." }, - { status: 429 }, - ); - } - - if (error.message.includes("model") || error.message.includes("Model")) { - return json({ error: "Model error. Please try a different model." }, { status: 400 }); - } - } - - return json({ error: "Request failed. Please try again." }, { status: 500 }); - } - }, - }, - }, -}); diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts new file mode 100644 index 00000000..9246b7f1 --- /dev/null +++ b/apps/web/src/routes/api/models.ts @@ -0,0 +1,73 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { upstashRedis } from "@/lib/upstash"; + +const MODELS_CACHE_KEY = "openchat:models"; +const MODELS_CACHE_TTL_SECONDS = 60 * 60 * 4; +const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; + +async function fetchModelsFromOpenRouter(): Promise { + const response = await fetch(OPENROUTER_MODELS_URL, { + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + return json( + { error: `OpenRouter API error: ${response.status}` }, + { status: response.status }, + ); + } + + const payload = await response.text(); + + if (upstashRedis) { + try { + await upstashRedis.set(MODELS_CACHE_KEY, payload, { + ex: MODELS_CACHE_TTL_SECONDS, + }); + } catch (error) { + console.warn("[Models API] Failed to write cache:", error); + } + } + + return new Response(payload, { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "X-Models-Cache": "MISS", + }, + }); +} + +export const Route = createFileRoute("/api/models")({ + server: { + handlers: { + GET: async () => { + if (!upstashRedis) { + return fetchModelsFromOpenRouter(); + } + + try { + const cached = await upstashRedis.get(MODELS_CACHE_KEY); + if (cached) { + return new Response(cached, { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "X-Models-Cache": "HIT", + }, + }); + } + } catch (error) { + console.warn("[Models API] Failed to read cache:", error); + } + + return fetchModelsFromOpenRouter(); + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/openrouter-key.ts b/apps/web/src/routes/api/openrouter-key.ts index d8f568d9..cd191461 100644 --- a/apps/web/src/routes/api/openrouter-key.ts +++ b/apps/web/src/routes/api/openrouter-key.ts @@ -3,6 +3,7 @@ import { json } from "@tanstack/react-start"; import { api } from "@server/convex/_generated/api"; import { encryptSecret } from "@/lib/server-crypto"; import { getAuthUser, getConvexClientForRequest, getConvexUserId, getConvexUserIdReadOnly, isSameOrigin } from "@/lib/server-auth"; +import { authRatelimit } from "@/lib/upstash"; export const Route = createFileRoute("/api/openrouter-key")({ server: { @@ -60,13 +61,22 @@ export const Route = createFileRoute("/api/openrouter-key")({ return json({ error: "apiKey is required" }, { status: 400 }); } - const convexUserId = await getConvexUserId(authUser, request); - if (!convexUserId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { + const convexUserId = await getConvexUserId(authUser, request); + if (!convexUserId) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (authRatelimit) { + const rate = await authRatelimit.limit(`openrouter-key:post:${convexUserId}`); + if (!rate.success) { + return json( + { error: "Too many key update attempts. Please try again shortly." }, + { status: 429 }, + ); + } + } + + const convexClient = await getConvexClientForRequest(request); + if (!convexClient) { return json({ error: "Unauthorized" }, { status: 401 }); } @@ -93,13 +103,22 @@ export const Route = createFileRoute("/api/openrouter-key")({ return json({ error: "Unauthorized" }, { status: 401 }); } - const convexUserId = await getConvexUserId(authUser, request); - if (!convexUserId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { + const convexUserId = await getConvexUserId(authUser, request); + if (!convexUserId) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (authRatelimit) { + const rate = await authRatelimit.limit(`openrouter-key:delete:${convexUserId}`); + if (!rate.success) { + return json( + { error: "Too many key update attempts. Please try again shortly." }, + { status: 429 }, + ); + } + } + + const convexClient = await getConvexClientForRequest(request); + if (!convexClient) { return json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/apps/web/src/routes/api/typing.ts b/apps/web/src/routes/api/typing.ts index 797737ff..ad4d232a 100644 --- a/apps/web/src/routes/api/typing.ts +++ b/apps/web/src/routes/api/typing.ts @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { json } from "@tanstack/react-start"; -import { redis } from "@/lib/redis"; import { getAuthUser, getConvexUserId, isSameOrigin } from "@/lib/server-auth"; +import { upstashRedis } from "@/lib/upstash"; export const Route = createFileRoute("/api/typing")({ server: { @@ -27,14 +27,19 @@ export const Route = createFileRoute("/api/typing")({ return json({ error: "chatId required" }, { status: 400 }); } - if (!redis.isAvailable()) { - return json({ ok: true }); - } + if (!upstashRedis) { + return json({ ok: true }); + } - await redis.typing.set(chatId, convexUserId, !!isTyping); - return json({ ok: true }); - } catch { - return json({ error: "Failed to update typing status" }, { status: 500 }); + const key = `chat:${chatId}:typing:${convexUserId}`; + if (isTyping) { + await upstashRedis.set(key, "1", { ex: 3 }); + } else { + await upstashRedis.del(key); + } + return json({ ok: true }); + } catch { + return json({ error: "Failed to update typing status" }, { status: 500 }); } }, @@ -53,13 +58,29 @@ export const Route = createFileRoute("/api/typing")({ return json({ error: "chatId required" }, { status: 400 }); } - if (!redis.isAvailable()) { - return json({ users: [] }); - } + if (!upstashRedis) { + return json({ users: [] }); + } + + const users = new Set(); + const pattern = `chat:${chatId}:typing:*`; + let cursor: string | number = "0"; + do { + const [nextCursor, keys]: [string, Array] = await upstashRedis.scan(cursor, { + match: pattern, + count: 100, + }); + cursor = nextCursor; + for (const key of keys) { + const userId = key.split(":").pop(); + if (userId) { + users.add(userId); + } + } + } while (String(cursor) !== "0"); - const users = await redis.typing.getUsers(chatId); - return json({ users }); + return json({ users: [...users] }); + }, }, }, - }, }); diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts new file mode 100644 index 00000000..f484d1b6 --- /dev/null +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -0,0 +1,163 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { serve } from "@upstash/workflow/tanstack"; +import { api } from "@server/convex/_generated/api"; +import { createConvexServerClient } from "@/lib/convex-server"; +import { workflowClient } from "@/lib/upstash"; + +type CleanupPayload = { + retentionDays?: number; + batchSize?: number; +}; + +function isLocalWorkflowRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +async function runCleanupInline(payload: CleanupPayload): Promise<{ + success: boolean; + batches: number; + totalDeleted: number; +}> { + const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN; + if (!workflowToken) { + throw new Error("WORKFLOW_CLEANUP_TOKEN is not configured"); + } + + const convexClient = createConvexServerClient(); + const retentionDays = payload.retentionDays ?? 90; + const batchSize = payload.batchSize ?? 100; + + let batches = 0; + let totalDeleted = 0; + + while (true) { + batches += 1; + const preview = await convexClient.action(api.crons.runCleanupBatchForWorkflow, { + workflowToken, + retentionDays, + batchSize, + dryRun: true, + }); + if (preview.deleted <= 0) { + break; + } + + const deletedBatch = await convexClient.action(api.crons.runCleanupBatchForWorkflow, { + workflowToken, + retentionDays, + batchSize, + dryRun: false, + }); + totalDeleted += deletedBatch.deleted; + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + return { + success: true, + batches, + totalDeleted, + }; +} + +const workflow = serve(async (context) => { + const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN; + if (!workflowToken) { + throw new Error("WORKFLOW_CLEANUP_TOKEN is not configured"); + } + + const convexClient = createConvexServerClient(); + const retentionDays = context.requestPayload.retentionDays ?? 90; + const batchSize = context.requestPayload.batchSize ?? 100; + + let batches = 0; + let totalDeleted = 0; + + while (true) { + batches += 1; + const preview = await context.run(`query-batch-${batches}`, async () => { + return convexClient.action(api.crons.runCleanupBatchForWorkflow, { + workflowToken, + retentionDays, + batchSize, + dryRun: true, + }); + }); + + if (preview.deleted <= 0) { + break; + } + + const deletedBatch = await context.run(`delete-batch-${batches}`, async () => { + return convexClient.action(api.crons.runCleanupBatchForWorkflow, { + workflowToken, + retentionDays, + batchSize, + dryRun: false, + }); + }); + + totalDeleted += deletedBatch.deleted; + await context.sleep(`sleep-${batches}`, "1s"); + } + + return context.run("log-completion", async () => { + return { + success: true, + batches, + totalDeleted, + }; + }); +}); + +export const Route = createFileRoute("/api/workflow/cleanup")({ + server: { + handlers: { + POST: async ({ request }) => { + const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); + if (isWorkflowCallback) { + return workflow.POST({ request }); + } + + let payload: CleanupPayload; + try { + payload = (await request.json()) as CleanupPayload; + } catch { + return json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + if (isLocalWorkflowRequest(request)) { + try { + const result = await runCleanupInline(payload); + return json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Cleanup failed"; + return json({ error: message }, { status: 500 }); + } + } + + if (!workflowClient) { + return json( + { error: "Workflow queue is not configured (missing QSTASH_TOKEN)" }, + { status: 500 }, + ); + } + + try { + const { workflowRunId } = await workflowClient.trigger({ + url: request.url, + body: payload, + headers: { + "Content-Type": "application/json", + }, + }); + return json({ queued: true, workflowRunId }, { status: 202 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to queue workflow"; + return json({ error: message }, { status: 500 }); + } + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts new file mode 100644 index 00000000..b5caf306 --- /dev/null +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -0,0 +1,263 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { serve } from "@upstash/workflow/tanstack"; +import { api } from "@server/convex/_generated/api"; +import type { Id } from "@server/convex/_generated/dataModel"; +import { createConvexServerClient } from "@/lib/convex-server"; +import { upstashRedis, workflowClient } from "@/lib/upstash"; + +const CONVEX_SITE_URL = + process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; + +type DeleteAccountPayload = { + userId: string; + externalId: string; + batchSize?: number; +}; + +type DeleteStep = + | "delete-stream-jobs" + | "delete-messages" + | "delete-chats" + | "delete-files"; + +async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { + if (!CONVEX_SITE_URL) return null; + + const cookie = headers.get("cookie"); + if (!cookie) return null; + + const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { + headers: { cookie }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { token?: string } | null; + return data?.token ?? null; +} + +async function clearRedisUserKeys(userId: string): Promise { + if (!upstashRedis) return 0; + + const directKeys = [ + `user:${userId}:unread`, + `presence:${userId}`, + `rate:chat:user:${userId}`, + ]; + const patternKeys = [ + `chat:*:typing:${userId}`, + `openchat:usage:${userId}:*`, + `ratelimit:*:${userId}`, + ]; + + let deleted = 0; + for (const key of directKeys) { + deleted += await upstashRedis.del(key); + } + + for (const pattern of patternKeys) { + let cursor: string | number = "0"; + do { + const [nextCursor, keys]: [string, Array] = await upstashRedis.scan(cursor, { + match: pattern, + count: 100, + }); + cursor = nextCursor; + if (keys.length > 0) { + deleted += await upstashRedis.del(...keys); + } + } while (String(cursor) !== "0"); + } + + return deleted; +} + +function isLocalWorkflowRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function getWorkflowTriggerHeaders(headers: Headers): Record { + const triggerHeaders: Record = { + "Content-Type": "application/json", + }; + const cookie = headers.get("cookie"); + if (cookie) { + triggerHeaders.cookie = cookie; + } + return triggerHeaders; +} + +async function runDeleteAccountInline( + payload: DeleteAccountPayload, + headers: Headers, +): Promise<{ + success: boolean; + deleted: { streamJobs: number; messages: number; chats: number; files: number; redisKeys: number }; +}> { + const { userId, externalId, batchSize } = payload; + const authToken = await getAuthTokenFromWorkflowHeaders(headers); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const convexUserId = userId as Id<"users">; + + const runBatchStep = async (step: DeleteStep) => { + let totalDeleted = 0; + + while (true) { + const result = await convexClient.action(api.users.deleteAccountWorkflowStep, { + userId: convexUserId, + externalId, + step, + batchSize, + }); + + totalDeleted += result.deleted; + if (!result.hasMore) { + break; + } + } + + return totalDeleted; + }; + + const deletedStreamJobs = await runBatchStep("delete-stream-jobs"); + const deletedMessages = await runBatchStep("delete-messages"); + const deletedChats = await runBatchStep("delete-chats"); + const deletedFiles = await runBatchStep("delete-files"); + const deleteUserResult = await convexClient.action(api.users.deleteAccountWorkflowStep, { + userId: convexUserId, + externalId, + step: "delete-user", + }); + const deletedRedisKeys = await clearRedisUserKeys(userId); + + return { + success: deleteUserResult.success === true, + deleted: { + streamJobs: deletedStreamJobs, + messages: deletedMessages, + chats: deletedChats, + files: deletedFiles, + redisKeys: deletedRedisKeys, + }, + }; +} + +const workflow = serve(async (context) => { + const { userId, externalId, batchSize } = context.requestPayload; + const authToken = await context.run("resolve-auth", async () => { + return getAuthTokenFromWorkflowHeaders(context.headers); + }); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const convexUserId = userId as Id<"users">; + + const runBatchStep = async (step: DeleteStep, stepName: string) => { + let totalDeleted = 0; + let iteration = 0; + + while (true) { + iteration += 1; + const result = await context.run(`${stepName}-${iteration}`, async () => { + return convexClient.action(api.users.deleteAccountWorkflowStep, { + userId: convexUserId, + externalId, + step, + batchSize, + }); + }); + + totalDeleted += result.deleted; + if (!result.hasMore) { + break; + } + } + + return totalDeleted; + }; + + const deletedStreamJobs = await runBatchStep("delete-stream-jobs", "delete-stream-jobs"); + const deletedMessages = await runBatchStep("delete-messages", "delete-messages"); + const deletedChats = await runBatchStep("delete-chats", "delete-chats"); + const deletedFiles = await runBatchStep("delete-files", "delete-files"); + + const deleteUserResult = await context.run("delete-user", async () => { + return convexClient.action(api.users.deleteAccountWorkflowStep, { + userId: convexUserId, + externalId, + step: "delete-user", + }); + }); + + const deletedRedisKeys = await context.run("cleanup-redis", async () => { + return clearRedisUserKeys(userId); + }); + + return { + success: deleteUserResult.success === true, + deleted: { + streamJobs: deletedStreamJobs, + messages: deletedMessages, + chats: deletedChats, + files: deletedFiles, + redisKeys: deletedRedisKeys, + }, + }; +}); + +export const Route = createFileRoute("/api/workflow/delete-account")({ + server: { + handlers: { + POST: async ({ request }) => { + const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); + if (isWorkflowCallback) { + return workflow.POST({ request }); + } + + let payload: DeleteAccountPayload; + try { + payload = (await request.json()) as DeleteAccountPayload; + } catch { + return json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + if (isLocalWorkflowRequest(request)) { + try { + const result = await runDeleteAccountInline(payload, request.headers); + return json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete account"; + const status = message === "Unauthorized" ? 401 : 500; + return json({ error: message }, { status }); + } + } + + if (!workflowClient) { + return json( + { error: "Workflow queue is not configured (missing QSTASH_TOKEN)" }, + { status: 500 }, + ); + } + + try { + const { workflowRunId } = await workflowClient.trigger({ + url: request.url, + body: payload, + headers: getWorkflowTriggerHeaders(request.headers), + }); + return json({ queued: true, workflowRunId }, { status: 202 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to queue workflow"; + return json({ error: message }, { status: 500 }); + } + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts new file mode 100644 index 00000000..61a3cf17 --- /dev/null +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -0,0 +1,235 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { serve } from "@upstash/workflow/tanstack"; +import { api } from "@server/convex/_generated/api"; +import type { Id } from "@server/convex/_generated/dataModel"; +import { createConvexServerClient } from "@/lib/convex-server"; +import { workflowClient } from "@/lib/upstash"; + +const CONVEX_SITE_URL = + process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; + +type ExportFormat = "markdown" | "json"; +type ExportChatPayload = { + chatId: string; + userId: string; + format?: ExportFormat; +}; + +type ChatExportData = { + chat: { + _id: string; + title: string; + createdAt: number; + updatedAt: number; + }; + messages: Array<{ + _id: string; + role: string; + content: string; + createdAt: number; + reasoning?: string; + attachments?: Array<{ + filename: string; + contentType: string; + size: number; + uploadedAt: number; + url?: string; + }>; + }>; +}; + +async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { + if (!CONVEX_SITE_URL) return null; + + const cookie = headers.get("cookie"); + if (!cookie) return null; + + const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { + headers: { cookie }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { token?: string } | null; + return data?.token ?? null; +} + +function isLocalWorkflowRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function getWorkflowTriggerHeaders(headers: Headers): Record { + const triggerHeaders: Record = { + "Content-Type": "application/json", + }; + const cookie = headers.get("cookie"); + if (cookie) { + triggerHeaders.cookie = cookie; + } + return triggerHeaders; +} + +function formatExportMarkdown(data: ChatExportData): string { + const lines: string[] = []; + lines.push(`# ${data.chat.title}`); + lines.push(""); + lines.push(`- Chat ID: ${data.chat._id}`); + lines.push(`- Created: ${new Date(data.chat.createdAt).toISOString()}`); + lines.push(`- Updated: ${new Date(data.chat.updatedAt).toISOString()}`); + lines.push(""); + lines.push("---"); + lines.push(""); + + for (const message of data.messages) { + const role = message.role === "assistant" ? "Assistant" : "User"; + lines.push(`## ${role} (${new Date(message.createdAt).toISOString()})`); + lines.push(""); + lines.push(message.content || "_No content_"); + lines.push(""); + + if (message.reasoning) { + lines.push("### Reasoning"); + lines.push(""); + lines.push(message.reasoning); + lines.push(""); + } + + if (message.attachments && message.attachments.length > 0) { + lines.push("### Attachments"); + for (const attachment of message.attachments) { + lines.push(`- ${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`); + } + lines.push(""); + } + } + + return lines.join("\n"); +} + +async function runExportChatInline( + payload: ExportChatPayload, + headers: Headers, +): Promise<{ downloadUrl: string; byteLength: number; fileName: string }> { + const { chatId, userId, format = "markdown" } = payload; + const authToken = await getAuthTokenFromWorkflowHeaders(headers); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const chatExportData = await convexClient.query(api.chats.getChatExportData, { + chatId: chatId as Id<"chats">, + userId: userId as Id<"users">, + }); + if (!chatExportData) { + throw new Error("Chat not found"); + } + + const formattedExport = + format === "json" + ? JSON.stringify(chatExportData, null, 2) + : formatExportMarkdown(chatExportData); + const mimeType = format === "json" ? "application/json" : "text/markdown"; + const base64 = Buffer.from(formattedExport, "utf8").toString("base64"); + + return { + downloadUrl: `data:${mimeType};base64,${base64}`, + byteLength: Buffer.byteLength(formattedExport, "utf8"), + fileName: `chat-export-${chatId}.${format === "json" ? "json" : "md"}`, + }; +} + +const workflow = serve(async (context) => { + const { chatId, userId, format = "markdown" } = context.requestPayload; + + const authToken = await context.run("resolve-auth", async () => { + return getAuthTokenFromWorkflowHeaders(context.headers); + }); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const chatExportData = await context.run("gather-messages", async () => { + return convexClient.query(api.chats.getChatExportData, { + chatId: chatId as Id<"chats">, + userId: userId as Id<"users">, + }); + }); + if (!chatExportData) { + throw new Error("Chat not found"); + } + + const formattedExport = await context.run("format-export", async () => { + if (format === "json") { + return JSON.stringify(chatExportData, null, 2); + } + return formatExportMarkdown(chatExportData); + }); + + const uploadResult = await context.run("upload", async () => { + const mimeType = format === "json" ? "application/json" : "text/markdown"; + const base64 = Buffer.from(formattedExport, "utf8").toString("base64"); + return { + downloadUrl: `data:${mimeType};base64,${base64}`, + byteLength: Buffer.byteLength(formattedExport, "utf8"), + fileName: `chat-export-${chatId}.${format === "json" ? "json" : "md"}`, + }; + }); + + return context.run("notify", async () => { + return uploadResult; + }); +}); + +export const Route = createFileRoute("/api/workflow/export-chat")({ + server: { + handlers: { + POST: async ({ request }) => { + const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); + if (isWorkflowCallback) { + return workflow.POST({ request }); + } + + let payload: ExportChatPayload; + try { + payload = (await request.json()) as ExportChatPayload; + } catch { + return json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + if (isLocalWorkflowRequest(request)) { + try { + const result = await runExportChatInline(payload, request.headers); + return json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to export chat"; + const status = + message === "Unauthorized" ? 401 : message === "Chat not found" ? 404 : 500; + return json({ error: message }, { status }); + } + } + + if (!workflowClient) { + return json( + { error: "Workflow queue is not configured (missing QSTASH_TOKEN)" }, + { status: 500 }, + ); + } + + try { + const { workflowRunId } = await workflowClient.trigger({ + url: request.url, + body: payload, + headers: getWorkflowTriggerHeaders(request.headers), + }); + return json({ queued: true, workflowRunId }, { status: 202 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to queue workflow"; + return json({ error: message }, { status: 500 }); + } + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts new file mode 100644 index 00000000..c1aa6073 --- /dev/null +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -0,0 +1,300 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { serve } from "@upstash/workflow/tanstack"; +import { api } from "@server/convex/_generated/api"; +import type { Id } from "@server/convex/_generated/dataModel"; +import { createConvexServerClient } from "@/lib/convex-server"; +import { decryptSecret } from "@/lib/server-crypto"; +import { getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; +import { workflowClient } from "@/lib/upstash"; + +const CONVEX_SITE_URL = + process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; + +const TITLE_MODEL_ID = "google/gemini-2.5-flash-lite"; +const TITLE_MAX_LENGTH = 200; + +type TitleLength = "short" | "standard" | "long"; +type TitleProvider = "osschat" | "openrouter"; + +type GenerateTitlePayload = { + chatId: string; + userId: string; + seedText?: string; + length: TitleLength; + provider: TitleProvider; + mode?: "auto" | "manual"; +}; + +const TITLE_STYLE_PROMPTS: Record = { + short: "Use 2-4 words.", + standard: "Use 4-6 words.", + long: "Use 7-10 words.", +}; + +function sanitizeGeneratedTitle(input: string): string { + let title = input.trim(); + if ( + (title.startsWith("\"") && title.endsWith("\"")) || + (title.startsWith("'") && title.endsWith("'")) + ) { + title = title.slice(1, -1).trim(); + } + + title = title.replace(/\s+/g, " ").replace(/[.?!]+$/, "").trim(); + if (!title) return ""; + return title.slice(0, TITLE_MAX_LENGTH); +} + +async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { + if (!CONVEX_SITE_URL) return null; + + const cookie = headers.get("cookie"); + if (!cookie) return null; + + const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { + headers: { cookie }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { token?: string } | null; + return data?.token ?? null; +} + +function isLocalWorkflowRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function getWorkflowTriggerHeaders(headers: Headers): Record { + const triggerHeaders: Record = { + "Content-Type": "application/json", + }; + const cookie = headers.get("cookie"); + if (cookie) { + triggerHeaders.cookie = cookie; + } + return triggerHeaders; +} + +async function runGenerateTitleInline( + payload: GenerateTitlePayload, + authToken: string, +): Promise<{ saved: boolean; title?: string; reason?: string }> { + const { chatId, userId, seedText = "", length, provider, mode = "auto" } = payload; + const convexClient = createConvexServerClient(authToken); + const convexUserId = userId as Id<"users">; + const convexChatId = chatId as Id<"chats">; + + const candidate = seedText.trim().slice(0, 300); + const firstUserMessage = candidate + ? null + : await convexClient.query(api.messages.getFirstUserMessage, { + chatId: convexChatId, + userId: convexUserId, + }); + const normalizedSeed = candidate || firstUserMessage?.trim().slice(0, 300) || ""; + if (!normalizedSeed) { + return { saved: false, reason: "empty_seed" }; + } + + const generatedTitle = await convexClient.action(api.chats.generateTitle, { + userId: convexUserId, + seedText: normalizedSeed, + length, + provider, + }); + if (!generatedTitle) { + if (provider === "openrouter") { + const hasKey = await convexClient.query(api.users.hasOpenRouterKey, { + userId: convexUserId, + }); + if (!hasKey) { + return { saved: false, reason: "missing_openrouter_key" }; + } + } + return { saved: false, reason: "generation_failed" }; + } + + const sanitizedTitle = sanitizeGeneratedTitle(generatedTitle); + if (!sanitizedTitle) { + return { saved: false, reason: "empty_title" }; + } + + await convexClient.mutation(api.chats.setGeneratedTitle, { + chatId: convexChatId, + userId: convexUserId, + title: sanitizedTitle, + force: mode === "manual", + }); + + return { + saved: true, + title: sanitizedTitle, + }; +} + +const workflow = serve(async (context) => { + const { chatId, userId, seedText = "", length, provider, mode = "auto" } = context.requestPayload; + const authToken = await context.run("resolve-auth", async () => { + const forwardedToken = context.headers.get("x-convex-token"); + if (forwardedToken) return forwardedToken; + return getAuthTokenFromWorkflowHeaders(context.headers); + }); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const convexUserId = userId as Id<"users">; + const convexChatId = chatId as Id<"chats">; + + const normalizedSeed = await context.run("get-messages", async () => { + const candidate = seedText.trim().slice(0, 300); + if (candidate) return candidate; + const firstUserMessage = await convexClient.query(api.messages.getFirstUserMessage, { + chatId: convexChatId, + userId: convexUserId, + }); + return firstUserMessage?.trim().slice(0, 300) ?? ""; + }); + if (!normalizedSeed) { + return { saved: false, reason: "empty_seed" } as const; + } + + const openRouterKey = await context.run("resolve-openrouter-key", async () => { + if (provider === "osschat") { + return process.env.OPENROUTER_API_KEY ?? null; + } + + const encryptedKey = await convexClient.query(api.users.getOpenRouterKey, { + userId: convexUserId, + }); + return encryptedKey ? decryptSecret(encryptedKey) : null; + }); + if (!openRouterKey) { + return { saved: false, reason: "missing_openrouter_key" } as const; + } + + const systemPrompt = [ + "Create a specific, useful chat title.", + "Return only the title in Title Case; no quotes, no trailing punctuation.", + "Focus on the core topic or task; avoid filler words like and, with, about.", + TITLE_STYLE_PROMPTS[length], + ].join(" "); + + const llmResponse = await context.call<{ + choices?: Array<{ message?: { content?: string } }>; + }>("call-llm", { + url: "https://openrouter.ai/api/v1/chat/completions", + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openRouterKey}`, + "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", + "X-Title": "OSSChat", + }, + body: JSON.stringify({ + model: TITLE_MODEL_ID, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: normalizedSeed }, + ], + temperature: 0.2, + max_tokens: 32, + }), + retries: 2, + timeout: "30s", + }); + if (llmResponse.status < 200 || llmResponse.status >= 300) { + return { + saved: false, + reason: `llm_status_${llmResponse.status}`, + } as const; + } + + const rawTitle = llmResponse.body?.choices?.[0]?.message?.content ?? ""; + const sanitizedTitle = sanitizeGeneratedTitle(rawTitle); + if (!sanitizedTitle) { + return { saved: false, reason: "empty_title" } as const; + } + + await context.run("save-title", async () => { + await convexClient.mutation(api.chats.setGeneratedTitle, { + chatId: convexChatId, + userId: convexUserId, + title: sanitizedTitle, + force: mode === "manual", + }); + }); + + return { + saved: true, + title: sanitizedTitle, + }; +}); + +export const Route = createFileRoute("/api/workflow/generate-title")({ + server: { + handlers: { + POST: async ({ request }) => { + const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); + if (isWorkflowCallback) { + return workflow.POST({ request }); + } + + if (!isSameOrigin(request)) { + return json({ error: "Invalid origin" }, { status: 403 }); + } + + let payload: GenerateTitlePayload; + try { + payload = (await request.json()) as GenerateTitlePayload; + } catch { + return json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + const authToken = await getConvexAuthToken(request); + if (!authToken) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + if (isLocalWorkflowRequest(request)) { + try { + const result = await runGenerateTitleInline(payload, authToken); + if (!result.saved) { + const status = result.reason === "missing_openrouter_key" ? 400 : 409; + return json(result, { status }); + } + return json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to generate title"; + const status = message === "Unauthorized" ? 401 : 500; + return json({ error: message }, { status }); + } + } + + if (!workflowClient) { + return json( + { error: "Workflow queue is not configured (missing QSTASH_TOKEN)" }, + { status: 500 }, + ); + } + + try { + const headers = getWorkflowTriggerHeaders(request.headers); + headers["x-convex-token"] = authToken; + const { workflowRunId } = await workflowClient.trigger({ + url: request.url, + body: payload, + headers, + }); + return json({ queued: true, workflowRunId }, { status: 202 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to queue workflow"; + return json({ error: message }, { status: 500 }); + } + }, + }, + }, +}); diff --git a/apps/web/src/stores/model.ts b/apps/web/src/stores/model.ts index d7a6aab2..8823e148 100644 --- a/apps/web/src/stores/model.ts +++ b/apps/web/src/stores/model.ts @@ -329,7 +329,7 @@ async function fetchAllModels(): Promise> { cache.promise = (async () => { try { - const response = await fetch("https://openrouter.ai/api/v1/models", { + const response = await fetch("/api/models", { headers: { "Content-Type": "application/json" }, }); diff --git a/bun.lock b/bun.lock index 5f74d59c..9e0fb213 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@vitest/coverage-v8": "^4.0.18", + "baseline-browser-mapping": "^2.9.19", "convex": "^1.31.7", "oxlint": "^1.43.0", "turbo": "^2.8.3", @@ -36,7 +37,6 @@ "name": "server", "dependencies": { "@convex-dev/better-auth": "^0.10.10", - "@convex-dev/persistent-text-streaming": "^0.3.0", "@convex-dev/rate-limiter": "^0.3.2", "@openrouter/ai-sdk-provider": "2.1.1", "@valyu/ai-sdk": "^1.0.3", @@ -64,6 +64,9 @@ "@tanstack/react-router-ssr-query": "^1.158.1", "@tanstack/react-start": "^1.158.3", "@tanstack/router-plugin": "^1.158.1", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.36.2", + "@upstash/workflow": "^1.1.0", "@valyu/ai-sdk": "^1.1.0", "@xyflow/react": "^12.10.0", "ai": "6.0.75", @@ -82,7 +85,6 @@ "posthog-js": "^1.342.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "redis": "^5.10.0", "resumable-stream": "^2.2.10", "server": "workspace:*", "shadcn": "^3.8.4", @@ -104,6 +106,7 @@ "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", + "baseline-browser-mapping": "^2.9.19", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "^5.9.3", @@ -116,6 +119,7 @@ }, "overrides": { "@peculiar/asn1-schema": "2.6.0", + "baseline-browser-mapping": "2.9.19", }, "packages": { "@1natsu/wait-element": ["@1natsu/wait-element@4.1.2", "", { "dependencies": { "defu": "^6.1.4", "many-keys-map": "^2.0.1" } }, "sha512-qWxSJD+Q5b8bKOvESFifvfZ92DuMsY+03SBNjTO34ipJLP6mZ9yK4bQz/vlh48aEQXoJfaZBqUwKL5BdI5iiWw=="], @@ -226,8 +230,6 @@ "@convex-dev/better-auth": ["@convex-dev/better-auth@0.10.10", "", { "dependencies": { "@better-auth/passkey": "1.4.9", "@better-fetch/fetch": "^1.1.18", "common-tags": "^1.8.2", "convex-helpers": "^0.1.95", "jose": "^6.1.0", "remeda": "^2.32.0", "semver": "^7.7.3", "type-fest": "^4.39.1", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "1.4.9", "convex": "^1.25.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-BpwQ2kph43O7hmtGQAJ+ie3KrjONp83659QDjKDdH+X8yIdGevgehaqS5GHB0iJo7zQTtvs687GnAeLZ4Xx3/w=="], - "@convex-dev/persistent-text-streaming": ["@convex-dev/persistent-text-streaming@0.3.0", "", { "peerDependencies": { "convex": "^1.24.8", "react": "~18.3.1 || ^19.0.0", "react-dom": "~18.3.1 || ^19.0.0" } }, "sha512-y7CteewFHrBKhVSoLxTMEwWPEmc/3J+BTJ+x+8pvh5DCUlwN80eWmfojpmGOQr7xSc6UC/c7DxlZXQPN7dVlKg=="], - "@convex-dev/rate-limiter": ["@convex-dev/rate-limiter@0.3.2", "", { "peerDependencies": { "convex": "^1.24.8", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-+oBPsBfFbzdxiF/9XaaTQmVnvDlvEfg/c69/v8LxTbw4VLuiflIKlfnPQL8OS0azXQQ11hcPWHmU8ytFmHKDXA=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], @@ -664,16 +666,6 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@redis/bloom": ["@redis/bloom@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A=="], - - "@redis/client": ["@redis/client@5.10.0", "", { "dependencies": { "cluster-key-slot": "1.1.2" } }, "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA=="], - - "@redis/json": ["@redis/json@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA=="], - - "@redis/search": ["@redis/search@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg=="], - - "@redis/time-series": ["@redis/time-series@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], @@ -902,6 +894,16 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="], + + "@upstash/qstash": ["@upstash/qstash@2.9.0-rc", "", { "dependencies": { "crypto-js": ">=4.2.0", "jose": "^5.2.3", "neverthrow": "^7.0.1" } }, "sha512-T2VGOuK/XOFe+kppsANdiaXxpqPvnXl8XWmZzAtqZKsHWkBXPYqAvr/Pek8TZEKeOLAgc7wrl4Rm0KNqec9Fww=="], + + "@upstash/ratelimit": ["@upstash/ratelimit@2.0.8", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w=="], + + "@upstash/redis": ["@upstash/redis@1.36.2", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-C0Yt8hc12vLaQYRG1fMci8iPrLtnTdbJG0HR5T8vKnvEP/1RdMMblsOJs5/jp0JXZJ1oSzMnQz4J9EVezNpI6A=="], + + "@upstash/workflow": ["@upstash/workflow@1.1.0", "", { "dependencies": { "@upstash/qstash": "2.9.0-rc" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-WM3VHEMtbCkg0aNeGmsnZgJ27lgCgqfp/ZrwBcPAfZTy9La4TNBhNu3Df5ugypxU4amVmXWNaHXKJtiy5wa1LQ=="], + "@valyu/ai-sdk": ["@valyu/ai-sdk@1.1.0", "", { "dependencies": { "dotenv": "^17.2.3", "zod": "^3.22.0" }, "peerDependencies": { "@ai-sdk/openai": "^2.0.65", "ai": "^5.0.83 || ^6.0.0" } }, "sha512-uZwf+W4CGnYLDqCPabyhjDQ/xJffU2jmRRkZWtDSqvULCClhZfP32kmKyFfqUl/0rIg9+kYYYGQsaWKHWYUjAA=="], "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], @@ -1004,7 +1006,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.8.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], "better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="], @@ -1104,8 +1106,6 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], - "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], @@ -1164,6 +1164,8 @@ "crossws": ["crossws@0.4.4", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg=="], + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], @@ -1840,6 +1842,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neverthrow": ["neverthrow@7.2.0", "", {}, "sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw=="], + "next": ["next@16.0.10", "", { "dependencies": { "@next/env": "16.0.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.10", "@next/swc-darwin-x64": "16.0.10", "@next/swc-linux-arm64-gnu": "16.0.10", "@next/swc-linux-arm64-musl": "16.0.10", "@next/swc-linux-x64-gnu": "16.0.10", "@next/swc-linux-x64-musl": "16.0.10", "@next/swc-win32-arm64-msvc": "16.0.10", "@next/swc-win32-x64-msvc": "16.0.10", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA=="], "nf3": ["nf3@0.3.10", "", {}, "sha512-UlqmHkZiHGgSkRj17yrOXEsSu5ECvtlJ3Xm1W5WsWrTKgu9m7OjrMZh9H/ME2LcWrTlMD0/vmmNVpyBG4yRdGg=="], @@ -2044,8 +2048,6 @@ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], - "redis": ["redis@5.10.0", "", { "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", "@redis/json": "5.10.0", "@redis/search": "5.10.0", "@redis/time-series": "5.10.0" } }, "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw=="], - "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], @@ -2344,6 +2346,8 @@ "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -2610,6 +2614,8 @@ "@ts-morph/common/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "@upstash/qstash/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "@valyu/ai-sdk/dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "@valyu/ai-sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/package.json b/package.json index ae651ff9..a612b186 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,21 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@vitest/coverage-v8": "^4.0.18", + "baseline-browser-mapping": "^2.9.19", "convex": "^1.31.7", "oxlint": "^1.43.0", "turbo": "^2.8.3", "vitest": "^4.0.18" }, "overrides": { - "@peculiar/asn1-schema": "2.6.0" + "@peculiar/asn1-schema": "2.6.0", + "baseline-browser-mapping": "2.9.19" }, "packageManager": "bun@1.3.0", "private": true, "scripts": { "check": "turbo lint", - "dev": "bun ./scripts/check-redis.ts && turbo -F web -F server dev", + "dev": "bun ./scripts/dev.ts", "build": "turbo -F server build", "build:web": "turbo -F web build", "deploy": "bun ./scripts/deploy.ts", diff --git a/scripts/check-redis.ts b/scripts/check-redis.ts index 411b99eb..0507eed5 100644 --- a/scripts/check-redis.ts +++ b/scripts/check-redis.ts @@ -1,42 +1,90 @@ -import { createClient } from "redis"; - -const url = process.env.REDIS_URL ?? "redis://localhost:6379"; - -async function withTimeout(promise: Promise, ms: number): Promise { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error("Redis connection timed out")); - }, ms); - promise - .then((value) => { - clearTimeout(timer); - resolve(value); - }) - .catch((err) => { - clearTimeout(timer); - reject(err); - }); - }); +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +function parseEnvFile(content: string): Record { + const parsed: Record = {}; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const equalsIndex = trimmed.indexOf("="); + if (equalsIndex <= 0) continue; + + const key = trimmed.slice(0, equalsIndex).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + + let value = trimmed.slice(equalsIndex + 1).trim(); + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + parsed[key] = value; + } + + return parsed; +} + +function loadLocalEnvDefaults(): void { + const envFiles = [ + join(process.cwd(), "apps/web/.env.local"), + join(process.cwd(), "apps/server/.env.local"), + join(process.cwd(), ".env.local"), + ]; + + for (const filePath of envFiles) { + if (!existsSync(filePath)) continue; + const parsed = parseEnvFile(readFileSync(filePath, "utf8")); + for (const [key, value] of Object.entries(parsed)) { + if (!process.env[key]) { + process.env[key] = value; + } + } + } } async function main() { - const client = createClient({ url }); + loadLocalEnvDefaults(); + + const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); + const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); + + if (!url || !token) { + process.stdout.write( + "[check-redis] UPSTASH_REDIS_REST_URL/TOKEN not set. Redis-backed features are disabled.\n", + ); + return; + } + try { - await withTimeout(client.connect(), 2000); - const pong = await withTimeout(client.ping(), 2000); - if (pong !== "PONG") { + const response = await fetch(`${url}/pipeline`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([["PING"]]), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`HTTP ${response.status} ${body}`); + } + + const payload = (await response.json()) as Array<{ result?: string }>; + const result = payload[0]?.result; + if (result !== "PONG") { throw new Error("Redis ping failed"); } - process.env.REDIS_URL = url; - await client.quit(); } catch (error) { - try { - await client.quit(); - } catch { - } const message = error instanceof Error ? error.message : String(error); process.stderr.write(`Redis is not reachable at ${url}. ${message}\n`); - process.stderr.write("Start Redis locally (docker run -d -p 6379:6379 redis:alpine) or set REDIS_URL.\n"); + process.stderr.write( + "Check UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.\n", + ); process.exit(1); } } diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 00000000..742a67fe --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,53 @@ +type ExitCode = number | null; + +function sanitizeNodeOptions(nodeOptions: string | undefined): string | undefined { + if (!nodeOptions) return nodeOptions; + + const tokens = nodeOptions.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + const sanitized: string[] = []; + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + if (token === "--localstorage-file") { + index += 1; + continue; + } + if (token.startsWith("--localstorage-file=")) { + const value = token.slice("--localstorage-file=".length).trim(); + if (!value) continue; + } + sanitized.push(token); + } + + return sanitized.join(" ").trim(); +} + +async function run(command: string[], env: NodeJS.ProcessEnv): Promise { + const process = Bun.spawn({ + cmd: command, + env, + stdio: ["inherit", "inherit", "inherit"], + }); + return process.exited; +} + +const env: NodeJS.ProcessEnv = { + ...process.env, + BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA: + process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA ?? "true", + BROWSERSLIST_IGNORE_OLD_DATA: process.env.BROWSERSLIST_IGNORE_OLD_DATA ?? "true", + NODE_NO_WARNINGS: process.env.NODE_NO_WARNINGS ?? "1", +}; + +const sanitizedNodeOptions = sanitizeNodeOptions(process.env.NODE_OPTIONS); +env.NODE_OPTIONS = [sanitizedNodeOptions, "--disable-warning=localstorage-file"] + .filter((value) => value && value.length > 0) + .join(" "); + +const checkRedisExit = await run(["bun", "./scripts/check-redis.ts"], env); +if (checkRedisExit !== 0) { + process.exit(checkRedisExit ?? 1); +} + +const devExit = await run(["bunx", "turbo", "-F", "web", "-F", "server", "dev"], env); +process.exit(devExit ?? 1); From a626c437e9748448b351e72d4f344f7358b1ecc6 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 11:30:45 -0500 Subject: [PATCH 02/40] Investigate Convex Upstash warnings --- .env.example | 19 +- apps/server/convex/_generated/api.d.ts | 41 +- apps/server/convex/backgroundStream.ts | 237 ++-- apps/server/convex/chats.test.ts | 191 +++- apps/server/convex/chats.ts | 393 ++++++- apps/server/convex/convex.config.ts | 2 - apps/server/convex/crons.ts | 34 +- apps/server/convex/http.ts | 15 - apps/server/convex/lib/redisRest.ts | 238 ---- apps/server/convex/lib/upstashUsage.ts | 90 ++ apps/server/convex/schema.ts | 2 +- apps/server/convex/streaming.ts | 1003 ----------------- apps/server/convex/users.ts | 264 ++++- apps/server/package.json | 3 +- apps/web/package.json | 7 +- apps/web/src/components/app-sidebar.tsx | 45 +- .../src/components/delete-account-modal.tsx | 20 +- .../web/src/hooks/use-persistent-chat.test.ts | 75 ++ apps/web/src/hooks/use-persistent-chat.ts | 78 +- apps/web/src/lib/redis.ts | 231 ++-- apps/web/src/lib/server-auth.test.ts | 63 ++ apps/web/src/lib/server-auth.ts | 38 +- apps/web/src/lib/title-generation.ts | 57 + apps/web/src/lib/upstash.ts | 51 + apps/web/src/routeTree.gen.ts | 120 +- apps/web/src/routes/api/chat.ts | 518 --------- apps/web/src/routes/api/models.ts | 73 ++ apps/web/src/routes/api/openrouter-key.ts | 47 +- apps/web/src/routes/api/workflow/cleanup.ts | 163 +++ .../src/routes/api/workflow/delete-account.ts | 263 +++++ .../src/routes/api/workflow/export-chat.ts | 235 ++++ .../src/routes/api/workflow/generate-title.ts | 300 +++++ apps/web/src/stores/model.ts | 2 +- bun.lock | 44 +- package.json | 6 +- scripts/check-redis.ts | 108 +- scripts/dev.ts | 53 + 37 files changed, 2781 insertions(+), 2348 deletions(-) delete mode 100644 apps/server/convex/lib/redisRest.ts create mode 100644 apps/server/convex/lib/upstashUsage.ts delete mode 100644 apps/server/convex/streaming.ts create mode 100644 apps/web/src/hooks/use-persistent-chat.test.ts create mode 100644 apps/web/src/lib/server-auth.test.ts create mode 100644 apps/web/src/lib/title-generation.ts create mode 100644 apps/web/src/lib/upstash.ts delete mode 100644 apps/web/src/routes/api/chat.ts create mode 100644 apps/web/src/routes/api/models.ts create mode 100644 apps/web/src/routes/api/workflow/cleanup.ts create mode 100644 apps/web/src/routes/api/workflow/delete-account.ts create mode 100644 apps/web/src/routes/api/workflow/export-chat.ts create mode 100644 apps/web/src/routes/api/workflow/generate-title.ts create mode 100644 scripts/dev.ts diff --git a/.env.example b/.env.example index bd4384e5..627e8cbe 100644 --- a/.env.example +++ b/.env.example @@ -67,15 +67,20 @@ VITE_CONVEX_SITE_URL=http://localhost:3210 # VITE_POSTHOG_HOST=https://us.i.posthog.com # ============================================================================== -# OPTIONAL: Redis (Resumable Streams) +# OPTIONAL: Upstash Redis + QStash (Rate limiting, workflows, caching) # ============================================================================== -# Local Redis (recommended for resumable streams in dev) -# REDIS_URL=redis://localhost:6379 - -# Upstash Redis (for resumable streams in production) -# Get credentials from: https://console.upstash.com/redis +# Get Redis credentials from: https://console.upstash.com/redis # UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io -# UPSTASH_REDIS_REST_TOKEN=your_token_here +# UPSTASH_REDIS_REST_TOKEN=your_redis_token_here +# +# Get QStash credentials from: https://console.upstash.com/qstash +# QSTASH_URL=https://qstash-us-east-1.upstash.io +# QSTASH_TOKEN=your_qstash_token_here +# QSTASH_CURRENT_SIGNING_KEY=sig_current_key_here +# QSTASH_NEXT_SIGNING_KEY=sig_next_key_here +# +# Protects maintenance workflow endpoints +# WORKFLOW_CLEANUP_TOKEN=generate_a_long_random_secret # ============================================================================== # DEV ONLY: Auth Bypass diff --git a/apps/server/convex/_generated/api.d.ts b/apps/server/convex/_generated/api.d.ts index d26b8854..036493c6 100644 --- a/apps/server/convex/_generated/api.d.ts +++ b/apps/server/convex/_generated/api.d.ts @@ -26,15 +26,14 @@ import type * as lib_origins from "../lib/origins.js"; import type * as lib_profiles from "../lib/profiles.js"; import type * as lib_rateLimitUtils from "../lib/rateLimitUtils.js"; import type * as lib_rateLimiter from "../lib/rateLimiter.js"; -import type * as lib_redisRest from "../lib/redisRest.js"; import type * as lib_sanitize from "../lib/sanitize.js"; +import type * as lib_upstashUsage from "../lib/upstashUsage.js"; import type * as messages from "../messages.js"; import type * as migrations from "../migrations.js"; import type * as previewSeed from "../previewSeed.js"; import type * as promptTemplates from "../promptTemplates.js"; import type * as search from "../search.js"; import type * as stats from "../stats.js"; -import type * as streaming from "../streaming.js"; import type * as users from "../users.js"; import type { @@ -62,15 +61,14 @@ declare const fullApi: ApiFromModules<{ "lib/profiles": typeof lib_profiles; "lib/rateLimitUtils": typeof lib_rateLimitUtils; "lib/rateLimiter": typeof lib_rateLimiter; - "lib/redisRest": typeof lib_redisRest; "lib/sanitize": typeof lib_sanitize; + "lib/upstashUsage": typeof lib_upstashUsage; messages: typeof messages; migrations: typeof migrations; previewSeed: typeof previewSeed; promptTemplates: typeof promptTemplates; search: typeof search; stats: typeof stats; - streaming: typeof streaming; users: typeof users; }>; @@ -2212,39 +2210,4 @@ export declare const components: { getServerTime: FunctionReference<"mutation", "internal", {}, number>; }; }; - persistentTextStreaming: { - lib: { - addChunk: FunctionReference< - "mutation", - "internal", - { final: boolean; streamId: string; text: string }, - any - >; - createStream: FunctionReference<"mutation", "internal", {}, any>; - getStreamStatus: FunctionReference< - "query", - "internal", - { streamId: string }, - "pending" | "streaming" | "done" | "error" | "timeout" - >; - getStreamText: FunctionReference< - "query", - "internal", - { streamId: string }, - { - status: "pending" | "streaming" | "done" | "error" | "timeout"; - text: string; - } - >; - setStreamStatus: FunctionReference< - "mutation", - "internal", - { - status: "pending" | "streaming" | "done" | "error" | "timeout"; - streamId: string; - }, - any - >; - }; - }; }; diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index 281562f5..c99d5d04 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -11,6 +11,10 @@ import { calculateUsageCents, } from "./lib/billingUtils"; import type { UsagePayload } from "./lib/billingUtils"; +import { + getDailyUsageFromUpstash, + incrementDailyUsageInUpstash, +} from "./lib/upstashUsage"; import { requireAuthUserId } from "./lib/auth"; import { decryptSecret } from "./lib/crypto"; @@ -26,6 +30,16 @@ const chainOfThoughtPartValidator = v.object({ errorText: v.optional(v.string()), }); +const streamOptionsValidator = v.object({ + enableReasoning: v.optional(v.boolean()), + reasoningEffort: v.optional(v.string()), + enableWebSearch: v.optional(v.boolean()), + supportsToolCalls: v.optional(v.boolean()), + maxSteps: v.optional(v.number()), + dynamicPrompt: v.optional(v.boolean()), + jonMode: v.optional(v.boolean()), +}); + type ChainOfThoughtPart = { type: "reasoning" | "tool"; index: number; @@ -44,32 +58,6 @@ const MAX_SEARCH_SNIPPET_CHARS = 1000; const MAX_SEARCH_CONTEXT_CHARS = 8000; const MAX_COMBINED_SEARCH_CONTEXT_CHARS = 12000; const MAX_PREFETCH_SEARCHES = 5; -const DYNAMIC_PROMPT_BASE = "You are a helpful assistant."; -const JON_MODE_SYSTEM_PROMPT = `Write like a real person, not a language model. -Voice: -- Always use contractions (don't, can't, it's, won't, we're). -- Vary sentence length dramatically. Short ones. Then longer ones that take their time. -- Have opinions. "I think" and "honestly" are fine. Don't hedge everything into mush. -- Match the user's energy. Casual if they're casual, precise if they're technical. -- Choose unexpected words over obvious defaults. Use colloquialisms and idioms where they fit. -Never do: -- No sycophancy ("Great question!", "Absolutely!", "I'd be happy to", "Of course!"). -- No meta-commentary ("It's important to note", "Let me explain", "I hope this helps"). -- No hedging chains ("could potentially perhaps maybe"). -- No rhetorical questions or "not only...but also" constructions. -- No rule-of-three lists ("fast, reliable, and scalable"). -- No summarizing conclusions or formulaic transitions ("Furthermore", "Moreover", "In summary"). -- No em dashes. Don't open by restating what the user said. -Word choice: -- If a simpler everyday word exists, use it. "Use" not "utilize". "Is" not "serves as". "Has" not "boasts". -- Avoid words that sound like marketing copy, corporate speak, or LinkedIn posts. If it would fit in a press release or TED talk title, pick a different word. -- Avoid vague abstract nouns where a concrete one works. Avoid inflated verbs where a plain one works. -Structure: -- Skip intro-body-conclusion. Start wherever makes sense, sometimes mid-thought. -- Irregular paragraph lengths. One sentence alone is fine. -- Use concrete details over generic statements. -- Stop when done. No wrap-up. No "hope that helps." -- Leave some thoughts slightly unpolished. Perfect structure feels algorithmic.`; function usageFromLanguageModelUsage(usage: { inputTokens?: number; @@ -115,6 +103,20 @@ function isWebSearchToolName(toolName: string | undefined): boolean { return normalized === "websearch" || normalized === "web_search"; } +function getLatestUserSeedText( + messages: Array<{ role: string; content: string }>, +): string | null { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role !== "user") continue; + const normalized = message.content.trim().slice(0, 300); + if (normalized.length > 0) { + return normalized; + } + } + return null; +} + function truncateText(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, Math.max(0, maxChars - 1))}...`; @@ -291,34 +293,26 @@ export const startStream = mutation({ role: v.string(), content: v.string(), })), - options: v.optional(v.object({ - enableReasoning: v.optional(v.boolean()), - reasoningEffort: v.optional(v.string()), - enableWebSearch: v.optional(v.boolean()), - supportsToolCalls: v.optional(v.boolean()), - maxSteps: v.optional(v.number()), - jonMode: v.optional(v.boolean()), - dynamicPrompt: v.optional(v.boolean()), - })), + options: v.optional(streamOptionsValidator), }, returns: v.id("streamJobs"), - handler: async (ctx, args) => { + handler: async (ctx, args) => { const userId = await requireAuthUserId(ctx, args.userId); const chat = await ctx.db.get(args.chatId); if (!chat || chat.userId !== userId) { throw new Error("Chat not found or unauthorized"); } - if (args.provider === "osschat") { + if (args.provider === "osschat") { + const currentDate = getCurrentDateKey(); const user = await ctx.db.get(userId); if (!user) { throw new Error("User not found"); } - const currentDate = getCurrentDateKey(); - const usedCents = + const persistedUsageCents = user.aiUsageDate === currentDate ? (user.aiUsageCents ?? 0) : 0; - if (usedCents >= DAILY_AI_LIMIT_CENTS) { + if (persistedUsageCents >= DAILY_AI_LIMIT_CENTS) { throw new Error("Daily usage limit reached. Connect your OpenRouter account to continue."); } } @@ -371,6 +365,21 @@ export const startStream = mutation({ jobId, }); + const shouldGenerateAutoTitle = + (chat.title === "New Chat" || !chat.title) && + (chat.messageCount ?? 0) <= 1; + const seedText = shouldGenerateAutoTitle ? getLatestUserSeedText(args.messages) : null; + if (seedText) { + await ctx.scheduler.runAfter(0, internal.chats.generateAndSetTitleInternal, { + chatId: args.chatId, + userId, + seedText, + length: "standard", + provider: args.provider === "openrouter" ? "openrouter" : "osschat", + force: false, + }); + } + return jobId; }, }); @@ -386,15 +395,7 @@ export const getStreamJob = query({ status: v.string(), model: v.string(), provider: v.string(), - options: v.optional(v.object({ - enableReasoning: v.optional(v.boolean()), - reasoningEffort: v.optional(v.string()), - enableWebSearch: v.optional(v.boolean()), - supportsToolCalls: v.optional(v.boolean()), - maxSteps: v.optional(v.number()), - jonMode: v.optional(v.boolean()), - dynamicPrompt: v.optional(v.boolean()), - })), + options: v.optional(streamOptionsValidator), content: v.string(), reasoning: v.optional(v.string()), chainOfThoughtParts: v.optional(v.array(chainOfThoughtPartValidator)), @@ -452,15 +453,7 @@ export const getActiveStreamJob = query({ status: v.string(), model: v.string(), provider: v.string(), - options: v.optional(v.object({ - enableReasoning: v.optional(v.boolean()), - reasoningEffort: v.optional(v.string()), - enableWebSearch: v.optional(v.boolean()), - supportsToolCalls: v.optional(v.boolean()), - maxSteps: v.optional(v.number()), - jonMode: v.optional(v.boolean()), - dynamicPrompt: v.optional(v.boolean()), - })), + options: v.optional(streamOptionsValidator), content: v.string(), reasoning: v.optional(v.string()), chainOfThoughtParts: v.optional(v.array(chainOfThoughtPartValidator)), @@ -803,9 +796,21 @@ export const executeStream = internalAction({ jobId: args.jobId }); - if (!job) { - return; - } + if (!job) { + return; + } + + if (job.provider === "osschat") { + const currentDate = getCurrentDateKey(); + const redisUsageCents = await getDailyUsageFromUpstash(job.userId, currentDate); + if (redisUsageCents !== null && redisUsageCents >= DAILY_AI_LIMIT_CENTS) { + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: "Daily usage limit reached. Connect your OpenRouter account to continue.", + }); + return; + } + } await ctx.runMutation(internal.backgroundStream.updateStreamContent, { jobId: args.jobId, @@ -913,44 +918,14 @@ export const executeStream = internalAction({ ]; }; - const addJonModeSystemInstruction = ( - messages: Array<{ role: "user" | "assistant" | "system"; content: string }> - ) => { - return [ - { role: "system" as const, content: JON_MODE_SYSTEM_PROMPT }, - ...messages, - ]; - }; - - const addDynamicPromptSystemInstruction = ( - messages: Array<{ role: "user" | "assistant" | "system"; content: string }> - ) => { - return [ - { - role: "system" as const, - content: `${DYNAMIC_PROMPT_BASE} Your model name is ${job.model}.`, - }, - ...messages, - ]; - }; - let webSearchMode: "none" | "tool" | "unavailable" = "none"; const getFinalMessages = ( messages: Array<{ role: "user" | "assistant" | "system"; content: string }>, webSearchEnabled: boolean, ) => { - let result = messages; - if (job.options?.jonMode) { - result = addJonModeSystemInstruction(result); - } - if (job.options?.dynamicPrompt ?? true) { - result = addDynamicPromptSystemInstruction(result); - } - if (webSearchEnabled) { - result = addWebSearchSystemInstruction(result); - } - return result; + if (!webSearchEnabled) return messages; + return addWebSearchSystemInstruction(messages); }; const getThinkingTimeSec = () => { @@ -1099,7 +1074,6 @@ export const executeStream = internalAction({ const supportsToolCalls = job.options?.supportsToolCalls !== false; let webSearchUnavailableReason: string | null = null; let availableSearches = 0; - let finalMessagesApplied = false; if (webSearchRequested) { const searchLimit = await ctx.runQuery(internal.search.checkSearchLimitInternal, { @@ -1164,17 +1138,12 @@ export const executeStream = internalAction({ toolPart.state = "input-available"; pendingUpdateCounter++; - try { - // Increment FIRST — this mutation is serializable in Convex, - // so it will throw if the limit is already reached. - // This prevents the TOCTOU race condition where two concurrent - // requests could both pass the initial check and exceed the limit. + try { + const rawOutput = await execute({ query: searchQuery }); + const compactOutput = compactWebSearchOutput(rawOutput); await ctx.runMutation(internal.search.incrementSearchUsageInternal, { userId: job.userId, }); - - const rawOutput = await execute({ query: searchQuery }); - const compactOutput = compactWebSearchOutput(rawOutput); toolPart.output = compactOutput; toolPart.state = "output-available"; pendingUpdateCounter++; @@ -1189,23 +1158,15 @@ export const executeStream = internalAction({ : chunk; contextChunks.push(trimmedChunk); remainingContextChars -= trimmedChunk.length; + } } - } - } catch (error) { - const errorText = - error instanceof Error ? error.message : "Web search failed"; - // If increment threw due to limit, stop searching - if (errorText.includes("Daily search limit reached")) { - toolPart.errorText = "Daily search limit reached"; + } catch (error) { + const errorText = + error instanceof Error ? error.message : "Web search failed"; + toolPart.errorText = errorText; toolPart.state = "output-error"; pendingUpdateCounter++; - await persistProgress(true); - break; } - toolPart.errorText = errorText; - toolPart.state = "output-error"; - pendingUpdateCounter++; - } await persistProgress(true); } @@ -1226,23 +1187,11 @@ export const executeStream = internalAction({ false, )), ]; - finalMessagesApplied = true; } } - - if (!finalMessagesApplied) { - streamOptions.messages = getFinalMessages( - streamOptions.messages as Array<{ role: "user" | "assistant" | "system"; content: string }>, - webSearchRequested && webSearchMode !== "tool" && webSearchMode !== "unavailable", - ); - } - const configuredMaxSteps = - typeof job.options?.maxSteps === "number" - && Number.isFinite(job.options.maxSteps) - && job.options.maxSteps > 0 - ? Math.floor(job.options.maxSteps) - : undefined; - const stepLimit = Math.max(1, Math.min(configuredMaxSteps ?? 1, 10)); + const configuredMaxSteps = + typeof job.options?.maxSteps === "number" ? job.options.maxSteps : undefined; + const stepLimit = Math.max(1, Math.min(configuredMaxSteps ?? 1, 10)); streamOptions.stopWhen = stepCountIs(stepLimit); const result = streamText(streamOptions); @@ -1405,17 +1354,23 @@ export const executeStream = internalAction({ job.messages, fullContent, ); - if (usageCents && usageCents > 0) { - for (let attempt = 0; attempt < 2; attempt++) { - try { - await ctx.runMutation(internal.users.incrementAiUsage, { - userId: job.userId, - usageCents, - }); - break; - } catch { - // No-op; retried below. - } + if (usageCents && usageCents > 0) { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await ctx.runMutation(internal.users.incrementAiUsage, { + userId: job.userId, + usageCents, + skipRedisSync: true, + }); + await incrementDailyUsageInUpstash( + job.userId, + getCurrentDateKey(), + usageCents, + ); + break; + } catch { + // No-op; retried below. + } } } } diff --git a/apps/server/convex/chats.test.ts b/apps/server/convex/chats.test.ts index e910d20a..e3db2148 100644 --- a/apps/server/convex/chats.test.ts +++ b/apps/server/convex/chats.test.ts @@ -11,10 +11,10 @@ * - Edge cases and error handling */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { convexTest } from 'convex-test'; import schema from './schema'; -import { api } from './_generated/api'; +import { api, internal } from './_generated/api'; import type { Id } from './_generated/dataModel'; import { modules, rateLimiter } from './testSetup.test'; @@ -1111,3 +1111,190 @@ describe('chats.checkExportRateLimit', () => { expect(result2.ok).toBe(true); }); }); + +describe('chats.setGeneratedTitle', () => { + let t: ReturnType; + let userId: Id<'users'>; + + beforeEach(async () => { + t = createConvexTest(); + + userId = await t.run(async (ctx) => { + return await ctx.db.insert('users', { + externalId: 'test-user', + email: 'test@example.com', + name: 'Test User', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + }); + + it('should update title when chat still has default title', async () => { + const chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'New Chat', + createdAt: 1, + updatedAt: 1, + }); + }); + + await asExternalId(t, 'test-user').mutation(api.chats.setGeneratedTitle, { + chatId, + userId, + title: 'Generated Title', + force: false, + }); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.title).toBe('Generated Title'); + }); + + it('should bump updatedAt for generated title updates', async () => { + const chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'New Chat', + createdAt: 1, + updatedAt: 1, + }); + }); + + await asExternalId(t, 'test-user').mutation(api.chats.setGeneratedTitle, { + chatId, + userId, + title: 'Fresh Title', + force: false, + }); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.updatedAt).toBeGreaterThan(1); + }); + + it('should not overwrite a custom title without force', async () => { + const chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'Already Custom', + createdAt: 1, + updatedAt: 1, + }); + }); + + await asExternalId(t, 'test-user').mutation(api.chats.setGeneratedTitle, { + chatId, + userId, + title: 'Should Not Apply', + force: false, + }); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.title).toBe('Already Custom'); + }); + + it('should overwrite a custom title when force=true', async () => { + const chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'Already Custom', + createdAt: 1, + updatedAt: 1, + }); + }); + + await asExternalId(t, 'test-user').mutation(api.chats.setGeneratedTitle, { + chatId, + userId, + title: 'Forced Replace', + force: true, + }); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.title).toBe('Forced Replace'); + }); +}); + +describe('chats.generateAndSetTitleInternal', () => { + let t: ReturnType; + let userId: Id<'users'>; + let chatId: Id<'chats'>; + const originalApiKey = process.env.OPENROUTER_API_KEY; + + beforeEach(async () => { + t = createConvexTest(); + process.env.OPENROUTER_API_KEY = 'test-openrouter-key'; + vi.restoreAllMocks(); + + userId = await t.run(async (ctx) => { + return await ctx.db.insert('users', { + externalId: 'internal-title-user', + email: 'internal-title@test.com', + name: 'Internal Title User', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + + chatId = await t.run(async (ctx) => { + return await ctx.db.insert('chats', { + userId, + title: 'New Chat', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + }); + + afterEach(() => { + process.env.OPENROUTER_API_KEY = originalApiKey; + vi.restoreAllMocks(); + }); + + it('generates and persists a title for default-title chats', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + choices: [{ message: { content: 'Helpful Testing Title' } }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + + const result = await t.action(internal.chats.generateAndSetTitleInternal, { + chatId, + userId, + seedText: 'this is the first user message', + length: 'standard', + provider: 'osschat', + force: false, + }); + + expect(result.saved).toBe(true); + expect(result.title).toBe('Helpful Testing Title'); + + const chat = await t.run(async (ctx) => await ctx.db.get(chatId)); + expect(chat?.title).toBe('Helpful Testing Title'); + }); + + it('does not overwrite existing custom titles unless forced', async () => { + await t.run(async (ctx) => { + await ctx.db.patch(chatId, { title: 'Custom Existing Title' }); + }); + + const result = await t.action(internal.chats.generateAndSetTitleInternal, { + chatId, + userId, + seedText: 'first message', + length: 'standard', + provider: 'osschat', + force: false, + }); + + expect(result.saved).toBe(false); + expect(result.reason).toBe('title_already_set'); + }); +}); diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index 3863ad3f..a69a9fee 100644 --- a/apps/server/convex/chats.ts +++ b/apps/server/convex/chats.ts @@ -1,6 +1,6 @@ import type { Id } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; -import { action, internalMutation, mutation, query } from "./_generated/server"; +import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server"; +import { action, internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { internal } from "./_generated/api"; import { v } from "convex/values"; import { incrementStat, STAT_KEYS } from "./lib/dbStats"; @@ -526,6 +526,84 @@ const TITLE_STYLE_PROMPTS: Record<"short" | "standard" | "long", string> = { long: "Use 7-10 words.", }; +type TitleLength = "short" | "standard" | "long"; +type TitleProvider = "osschat" | "openrouter"; + +async function resolveOpenRouterKey( + ctx: ActionCtx, + userId: Id<"users">, + provider: TitleProvider, +): Promise { + if (provider === "osschat") { + return process.env.OPENROUTER_API_KEY ?? null; + } + + const encryptedKey = await ctx.runQuery(internal.users.getOpenRouterKeyInternal, { + userId, + }); + return encryptedKey ? await decryptSecret(encryptedKey) : null; +} + +async function generateTitleFromSeed( + seedText: string, + length: TitleLength, + openRouterKey: string, +): Promise { + const systemPrompt = [ + "Create a specific, useful chat title.", + "Return only the title in Title Case; no quotes, no trailing punctuation.", + "Focus on the core topic or task; avoid filler words like 'and', 'with', 'about'.", + TITLE_STYLE_PROMPTS[length], + ].join(" "); + + try { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openRouterKey}`, + "HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io", + "X-Title": "OSSChat", + }, + body: JSON.stringify({ + model: TITLE_MODEL_ID, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: seedText }, + ], + temperature: 0.2, + max_tokens: 32, + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.warn("[Chat Title] OpenRouter error:", response.status, errorBody); + return null; + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content; + if (!content) return null; + + let title = content.trim(); + if ( + (title.startsWith("\"") && title.endsWith("\"")) || + (title.startsWith("'") && title.endsWith("'")) + ) { + title = title.slice(1, -1).trim(); + } + + const sanitizedTitle = sanitizeTitle(title, TITLE_MAX_LENGTH); + return sanitizedTitle || null; + } catch (error) { + console.warn("[Chat Title] Failed to generate title:", error); + return null; + } +} + export const generateTitle = action({ args: { userId: v.id("users"), @@ -543,70 +621,74 @@ export const generateTitle = action({ const seedText = args.seedText.trim(); if (!seedText) return null; - let openRouterKey: string | null = null; - if (args.provider === "osschat") { - openRouterKey = process.env.OPENROUTER_API_KEY ?? null; - } else { - const encryptedKey = await _ctx.runQuery(internal.users.getOpenRouterKeyInternal, { - userId, - }); - openRouterKey = encryptedKey ? await decryptSecret(encryptedKey) : null; - } + const openRouterKey = await resolveOpenRouterKey(_ctx, userId, args.provider); if (!openRouterKey) return null; - const systemPrompt = [ - "Create a specific, useful chat title.", - "Return only the title in Title Case; no quotes, no trailing punctuation.", - "Focus on the core topic or task; avoid filler words like 'and', 'with', 'about'.", - TITLE_STYLE_PROMPTS[args.length], - ].join(" "); - - try { - const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${openRouterKey}`, - "HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io", - "X-Title": "OSSChat", - }, - body: JSON.stringify({ - model: TITLE_MODEL_ID, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: seedText }, - ], - temperature: 0.2, - max_tokens: 32, - }), - }); + return generateTitleFromSeed(seedText, args.length, openRouterKey); + }, +}); - if (!response.ok) { - const errorBody = await response.text(); - console.warn("[Chat Title] OpenRouter error:", response.status, errorBody); - return null; - } +export const generateAndSetTitleInternal = internalAction({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + seedText: v.string(), + length: v.union(v.literal("short"), v.literal("standard"), v.literal("long")), + provider: v.union(v.literal("osschat"), v.literal("openrouter")), + force: v.optional(v.boolean()), + }, + returns: v.object({ + saved: v.boolean(), + title: v.optional(v.string()), + reason: v.optional(v.string()), + }), + handler: async (ctx, args) => { + const chat = await ctx.runQuery(internal.chats.getChatForTitleGenerationInternal, { + chatId: args.chatId, + userId: args.userId, + }); + if (!chat) { + return { saved: false, reason: "chat_not_found" }; + } - const data = (await response.json()) as { - choices?: Array<{ message?: { content?: string } }>; - }; - const content = data.choices?.[0]?.message?.content; - if (!content) return null; - - let title = content.trim(); - if ( - (title.startsWith("\"") && title.endsWith("\"")) || - (title.startsWith("'") && title.endsWith("'")) - ) { - title = title.slice(1, -1).trim(); - } + if (!args.force && chat.title && chat.title !== "New Chat") { + return { saved: false, reason: "title_already_set" }; + } - const sanitizedTitle = sanitizeTitle(title, TITLE_MAX_LENGTH); - return sanitizedTitle || null; - } catch (error) { - console.warn("[Chat Title] Failed to generate title:", error); - return null; + const seedText = args.seedText.trim(); + if (!seedText) { + return { saved: false, reason: "empty_seed" }; + } + + await ctx.runMutation(internal.chats.enforceTitleRateLimit, { + userId: args.userId, + }); + + const openRouterKey = await resolveOpenRouterKey(ctx, args.userId, args.provider); + if (!openRouterKey) { + return { saved: false, reason: "missing_openrouter_key" }; + } + + const generatedTitle = await generateTitleFromSeed( + seedText, + args.length, + openRouterKey, + ); + if (!generatedTitle) { + return { saved: false, reason: "generation_failed" }; } + + await ctx.runMutation(internal.chats.setGeneratedTitleInternal, { + chatId: args.chatId, + userId: args.userId, + title: generatedTitle, + force: args.force, + }); + + return { + saved: true, + title: generatedTitle, + }; }, }); @@ -658,6 +740,98 @@ export const updateTitle = mutation({ }, }); +export const setGeneratedTitle = mutation({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + title: v.string(), + force: v.optional(v.boolean()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await requireAuthUserId(ctx, args.userId); + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== userId || chat.deletedAt) { + return null; + } + + const sanitizedTitle = sanitizeTitle(args.title, TITLE_MAX_LENGTH); + if (!sanitizedTitle) return null; + + const shouldForce = args.force === true; + if (!shouldForce && chat.title !== "New Chat" && chat.title) { + return null; + } + + await ctx.db.patch(args.chatId, { + title: sanitizedTitle, + updatedAt: Date.now(), + }); + + return null; + }, +}); + +export const getChatForTitleGenerationInternal = internalQuery({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + }, + returns: v.union( + v.object({ + _id: v.id("chats"), + userId: v.id("users"), + title: v.string(), + deletedAt: v.optional(v.number()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== args.userId || chat.deletedAt) { + return null; + } + + return { + _id: chat._id, + userId: chat.userId, + title: chat.title, + deletedAt: chat.deletedAt, + }; + }, +}); + +export const setGeneratedTitleInternal = internalMutation({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + title: v.string(), + force: v.optional(v.boolean()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== args.userId || chat.deletedAt) { + return null; + } + + const sanitizedTitle = sanitizeTitle(args.title, TITLE_MAX_LENGTH); + if (!sanitizedTitle) return null; + + const shouldForce = args.force === true; + if (!shouldForce && chat.title !== "New Chat" && chat.title) { + return null; + } + + await ctx.db.patch(args.chatId, { + title: sanitizedTitle, + updatedAt: Date.now(), + }); + + return null; + }, +}); + /** * Force set a chat title (used for manual regeneration). */ @@ -687,6 +861,105 @@ export const setTitle = mutation({ }, }); +export const getChatExportData = query({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + }, + returns: v.union( + v.object({ + chat: v.object({ + _id: v.id("chats"), + title: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + }), + messages: v.array( + v.object({ + _id: v.id("messages"), + role: v.string(), + content: v.string(), + createdAt: v.number(), + modelId: v.optional(v.string()), + provider: v.optional(v.string()), + reasoning: v.optional(v.string()), + attachments: v.optional( + v.array( + v.object({ + filename: v.string(), + contentType: v.string(), + size: v.number(), + uploadedAt: v.number(), + url: v.optional(v.string()), + }), + ), + ), + }), + ), + }), + v.null(), + ), + handler: async (ctx, args) => { + const userId = await requireAuthUserId(ctx, args.userId); + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== userId || chat.deletedAt) { + return null; + } + + const messages = await ctx.db + .query("messages") + .withIndex("by_chat_not_deleted", (q) => + q.eq("chatId", args.chatId).eq("deletedAt", undefined), + ) + .order("asc") + .collect(); + + const storageIds = new Set>(); + for (const message of messages) { + for (const attachment of message.attachments ?? []) { + storageIds.add(attachment.storageId); + } + } + + const urlMap = new Map, string | null>(); + await Promise.all( + [...storageIds].map(async (storageId) => { + try { + const url = await ctx.storage.getUrl(storageId); + urlMap.set(storageId, url); + } catch { + urlMap.set(storageId, null); + } + }), + ); + + return { + chat: { + _id: chat._id, + title: chat.title, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + }, + messages: messages.map((message) => ({ + _id: message._id, + role: message.role, + content: message.content, + createdAt: message.createdAt, + modelId: message.modelId, + provider: message.provider, + reasoning: message.reasoning, + attachments: message.attachments?.map((attachment) => ({ + filename: attachment.filename, + contentType: attachment.contentType, + size: attachment.size, + uploadedAt: attachment.uploadedAt, + url: urlMap.get(attachment.storageId) ?? undefined, + })), + })), + }; + }, +}); + export const setActiveStream = mutation({ args: { chatId: v.id("chats"), diff --git a/apps/server/convex/convex.config.ts b/apps/server/convex/convex.config.ts index 77070733..3a4cb26a 100644 --- a/apps/server/convex/convex.config.ts +++ b/apps/server/convex/convex.config.ts @@ -1,11 +1,9 @@ import { defineApp } from "convex/server"; import betterAuth from "@convex-dev/better-auth/convex.config"; import rateLimiter from "@convex-dev/rate-limiter/convex.config"; -import persistentTextStreaming from "@convex-dev/persistent-text-streaming/convex.config"; const app = defineApp(); app.use(betterAuth); app.use(rateLimiter); -app.use(persistentTextStreaming); export default app; diff --git a/apps/server/convex/crons.ts b/apps/server/convex/crons.ts index 4ecffac2..e18a3f8c 100644 --- a/apps/server/convex/crons.ts +++ b/apps/server/convex/crons.ts @@ -13,7 +13,8 @@ */ import { cronJobs } from "convex/server"; -import { internalMutation } from "./_generated/server"; +import { action, internalMutation } from "./_generated/server"; +import { internal } from "./_generated/api"; import { v } from "convex/values"; import { decrementStat, getStats, STAT_KEYS } from "./lib/dbStats"; import { createLogger } from "./lib/logger"; @@ -156,6 +157,37 @@ export const cleanupSoftDeletedRecords = internalMutation({ }, }); +export const runCleanupBatchForWorkflow = action({ + args: { + workflowToken: v.string(), + retentionDays: v.optional(v.number()), + batchSize: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + }, + returns: v.object({ + success: v.boolean(), + deleted: v.number(), + dryRun: v.boolean(), + cutoffDate: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ success: boolean; deleted: number; dryRun: boolean; cutoffDate: string }> => { + const expectedToken = process.env.WORKFLOW_CLEANUP_TOKEN; + if (!expectedToken || args.workflowToken !== expectedToken) { + throw new Error("Unauthorized"); + } + + const result = await ctx.runMutation(internal.crons.cleanupSoftDeletedRecords, { + retentionDays: args.retentionDays, + batchSize: args.batchSize, + dryRun: args.dryRun, + }); + return result; + }, +}); + /** * Cleanup expired rate limit buckets * diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index 71174bd0..7a3db9be 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -1,7 +1,6 @@ import "./polyfills"; import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; -import { streamLLM } from "./streaming"; import { api } from "./_generated/api"; import { authComponent, createAuth } from "./auth"; import { getAllowedOrigins, getCorsOrigin } from "./lib/origins"; @@ -36,20 +35,6 @@ http.route({ }), }); -// LLM streaming endpoint - runs on Convex infrastructure for persistence -http.route({ - path: "/stream-llm", - method: "POST", - handler: streamLLM, -}); - -// Handle CORS preflight for streaming endpoint -http.route({ - path: "/stream-llm", - method: "OPTIONS", - handler: streamLLM, -}); - // Public stats endpoint for sign-in page // SECURITY: This endpoint exposes only aggregate, non-sensitive stats (counts, stars). // If sensitive data is ever added, ensure CORS remains restricted to getAllowedOrigins(). diff --git a/apps/server/convex/lib/redisRest.ts b/apps/server/convex/lib/redisRest.ts deleted file mode 100644 index f017b4a9..00000000 --- a/apps/server/convex/lib/redisRest.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Upstash Redis REST API client for Convex httpActions - * - * Convex httpActions run on Convex infrastructure and cannot use npm packages - * like @upstash/redis directly. This module uses the Upstash REST API via fetch. - * - * ARCHITECTURE: - * 1. Tokens are pushed to Redis streams during LLM streaming - * 2. Client connects to Next.js SSE endpoint (/api/stream/[id]) for real-time tokens - * 3. On completion: single write to Convex with full message - * 4. Redis streams auto-expire after TTL - * - * UPSTASH REST API: - * - POST https:///pipeline - Execute multiple commands - * - Headers: Authorization: Bearer - * - Body: Array of command arrays - * - * @see https://upstash.com/docs/redis/sdks/ts/pipelining - */ - -import { createLogger } from "./logger"; - -const logger = createLogger("RedisRest"); - -// Key prefixes for namespacing -const KEY_PREFIX = { - stream: "stream:", - meta: "meta:", - restoration: "restoration:", -} as const; - -/** - * Stream metadata structure - */ -export interface StreamMetadata { - status: "streaming" | "completed" | "error"; - totalTokens?: number; - finalContent?: string; - error?: string; - completedAt?: number; -} - -/** - * Check if Redis streaming is available by checking environment variables - */ -export function isRedisAvailable(): boolean { - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; - return Boolean(url && token); -} - -/** - * Get Redis credentials with validation - */ -function getRedisCredentials(): { url: string; token: string } | null { - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; - - if (!url || !token) { - return null; - } - - return { url, token }; -} - -/** - * Execute multiple Redis commands in a pipeline - */ -async function executePipeline( - commands: (string | number)[][] -): Promise { - const creds = getRedisCredentials(); - if (!creds) { - throw new Error("Redis credentials not configured"); - } - - const pipelineUrl = `${creds.url}/pipeline`; - - const response = await fetch(pipelineUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${creds.token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(commands), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Redis pipeline failed: ${response.status} - ${errorText}` - ); - } - - const results = await response.json(); - // Pipeline returns array of { result } objects - return results.map((r: { result: unknown }) => r.result) as T; -} - -/** - * Generate a unique stream ID - */ -export function generateStreamId(): string { - const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).substring(2, 10); - return `${timestamp}-${random}`; -} - -/** - * Initialize a new Redis stream for token streaming - * - * @param restorationId - User-facing restoration ID (e.g., message ID) - * @param ttlSeconds - Time to live (default 1 hour) - * @returns Stream ID for internal use - */ -export async function initializeStream( - restorationId: string, - ttlSeconds: number = 3600 -): Promise { - const streamId = generateStreamId(); - - const metaKey = `${KEY_PREFIX.meta}${streamId}`; - const restorationKey = `${KEY_PREFIX.restoration}${restorationId}`; - - const metadata: StreamMetadata = { status: "streaming" }; - - // Pipeline: SET metadata + SET restoration mapping - await executePipeline([ - ["SET", metaKey, JSON.stringify(metadata), "EX", ttlSeconds], - ["SET", restorationKey, streamId, "EX", ttlSeconds], - ]); - - logger.debug("Stream initialized", { streamId, restorationId }); - return streamId; -} - -/** - * Append tokens to Redis stream (batched) - * - * @param streamId - Internal stream ID - * @param tokens - Array of tokens to append - */ -export async function appendTokenBatch( - streamId: string, - tokens: string[] -): Promise { - if (tokens.length === 0) return; - - const streamKey = `${KEY_PREFIX.stream}${streamId}`; - const now = Date.now(); - - // Build XADD commands for each token - const commands = tokens.map((token, i) => [ - "XADD", - streamKey, - "*", - "delta", - token, - "ts", - (now + i).toString(), - ]); - - await executePipeline(commands); -} - -/** - * Finalize stream with completed status - * - * @param streamId - Internal stream ID - * @param finalContent - Complete message content - * @param totalTokens - Total token count - * @param ttlSeconds - TTL for completed stream (default 1 hour) - */ -export async function finalizeStream( - streamId: string, - finalContent: string, - totalTokens: number, - ttlSeconds: number = 3600 -): Promise { - const streamKey = `${KEY_PREFIX.stream}${streamId}`; - const metaKey = `${KEY_PREFIX.meta}${streamId}`; - - const metadata: StreamMetadata = { - status: "completed", - totalTokens, - finalContent, - completedAt: Date.now(), - }; - - // Pipeline: SET metadata + EXPIRE stream + EXPIRE metadata - await executePipeline([ - ["SET", metaKey, JSON.stringify(metadata), "EX", ttlSeconds], - ["EXPIRE", streamKey, ttlSeconds], - ]); - - logger.debug("Stream finalized", { streamId, totalTokens }); -} - -/** - * Mark stream as errored - * - * @param streamId - Internal stream ID - * @param error - Error message - * @param ttlSeconds - TTL for error record (default 10 minutes) - */ -export async function errorStream( - streamId: string, - error: string, - ttlSeconds: number = 600 -): Promise { - const streamKey = `${KEY_PREFIX.stream}${streamId}`; - const metaKey = `${KEY_PREFIX.meta}${streamId}`; - - const metadata: StreamMetadata = { - status: "error", - error, - completedAt: Date.now(), - }; - - await executePipeline([ - ["SET", metaKey, JSON.stringify(metadata), "EX", ttlSeconds], - ["EXPIRE", streamKey, ttlSeconds], - ]); - - logger.debug("Stream errored", { streamId, error }); -} - -/** - * Redis streaming operations for use in httpActions - */ -export const redisStreamOps = { - isAvailable: isRedisAvailable, - generateStreamId, - initializeStream, - appendTokenBatch, - finalizeStream, - errorStream, -}; diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts new file mode 100644 index 00000000..a52beccc --- /dev/null +++ b/apps/server/convex/lib/upstashUsage.ts @@ -0,0 +1,90 @@ +const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL?.trim(); +const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); + +type PipelineResult = Array<{ result: unknown }>; + +function isConfigured(): boolean { + return Boolean(UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN); +} + +function shouldLogUpstashUsageErrors(): boolean { + return process.env.LOG_UPSTASH_USAGE_ERRORS === "true"; +} + +function usageCounterKey(userId: string, dateKey: string): string { + return `openchat:usage:${userId}:${dateKey}`; +} + +function getMidnightUtcEpochSeconds(dateKey: string): number { + const [year, month, day] = dateKey.split("-").map((segment) => Number.parseInt(segment, 10)); + const expiresAtMs = Date.UTC(year, month - 1, day + 1, 0, 0, 0, 0); + return Math.floor(expiresAtMs / 1000); +} + +async function executePipeline(commands: Array>): Promise { + if (!isConfigured()) return null; + + const response = await fetch(`${UPSTASH_REDIS_REST_URL}/pipeline`, { + method: "POST", + headers: { + Authorization: `Bearer ${UPSTASH_REDIS_REST_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(commands), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Upstash pipeline failed: ${response.status} ${body}`); + } + + return (await response.json()) as PipelineResult; +} + +export async function getDailyUsageFromUpstash( + userId: string, + dateKey: string, +): Promise { + if (!isConfigured()) return null; + + try { + const key = usageCounterKey(userId, dateKey); + const result = await executePipeline([["GET", key]]); + const raw = result?.[0]?.result; + if (raw === null || raw === undefined) return null; + if (typeof raw === "number") return raw; + if (typeof raw === "string") { + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + } catch (error) { + if (shouldLogUpstashUsageErrors()) { + console.warn("[Usage] Upstash GET failed", error); + } + return null; + } +} + +export async function incrementDailyUsageInUpstash( + userId: string, + dateKey: string, + usageCents: number, +): Promise { + if (!isConfigured()) return; + if (!Number.isFinite(usageCents) || usageCents <= 0) return; + + const key = usageCounterKey(userId, dateKey); + const expiresAt = getMidnightUtcEpochSeconds(dateKey); + + try { + await executePipeline([ + ["INCRBY", key, Math.floor(usageCents)], + ["EXPIREAT", key, expiresAt], + ]); + } catch (error) { + if (shouldLogUpstashUsageErrors()) { + console.warn("[Usage] Upstash INCRBY failed", error); + } + } +} diff --git a/apps/server/convex/schema.ts b/apps/server/convex/schema.ts index 43604420..948cdf29 100644 --- a/apps/server/convex/schema.ts +++ b/apps/server/convex/schema.ts @@ -262,8 +262,8 @@ export default defineSchema({ enableWebSearch: v.optional(v.boolean()), supportsToolCalls: v.optional(v.boolean()), maxSteps: v.optional(v.number()), - jonMode: v.optional(v.boolean()), dynamicPrompt: v.optional(v.boolean()), + jonMode: v.optional(v.boolean()), })), content: v.string(), reasoning: v.optional(v.string()), diff --git a/apps/server/convex/streaming.ts b/apps/server/convex/streaming.ts deleted file mode 100644 index 1959de1f..00000000 --- a/apps/server/convex/streaming.ts +++ /dev/null @@ -1,1003 +0,0 @@ -import { v } from "convex/values"; -import { mutation, query, httpAction, internalMutation, internalQuery } from "./_generated/server"; -import { api, components, internal } from "./_generated/api"; -import { - PersistentTextStreaming, - StreamIdValidator, - type StreamId, -} from "@convex-dev/persistent-text-streaming"; -import type { Id } from "./_generated/dataModel"; -import { redisStreamOps } from "./lib/redisRest"; -import { createLogger } from "./lib/logger"; -import { requireAuthUserId } from "./lib/auth"; -import { getCorsOrigin } from "./lib/origins"; -import { decryptSecret } from "./lib/crypto"; - -// Initialize the persistent text streaming component -export const persistentTextStreaming = new PersistentTextStreaming( - components.persistentTextStreaming -); - -// Re-export StreamId type and validator for use in other files -export { StreamIdValidator, type StreamId }; - -const logger = createLogger("StreamingLLM"); - -/** - * Create a new stream and associate it with a message - * Called before starting the LLM generation - */ -export const createStream = mutation({ - args: { - chatId: v.id("chats"), - userId: v.id("users"), - clientMessageId: v.optional(v.string()), - }, - returns: v.object({ - streamId: StreamIdValidator, - messageId: v.id("messages"), - }), - handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); - // Verify user owns the chat - const chat = await ctx.db.get(args.chatId); - if (!chat || chat.userId !== userId || chat.deletedAt) { - throw new Error("Chat not found or access denied"); - } - - // Create the persistent stream - const streamId = await persistentTextStreaming.createStream(ctx); - - // Create the assistant message with the stream ID - const now = Date.now(); - const messageId = await ctx.db.insert("messages", { - chatId: args.chatId, - clientMessageId: args.clientMessageId, - role: "assistant", - content: "", // Will be populated by stream - status: "streaming", - streamId: streamId as string, - createdAt: now, - userId, - }); - - // Update chat's lastMessageAt - await ctx.db.patch(args.chatId, { - lastMessageAt: now, - updatedAt: now, - }); - - return { streamId, messageId }; - }, -}); - -/** - * Get the current body/content of a stream - * Used by useStream hook for database fallback when not driving - * IMPORTANT: Must return { text, status } format for useStream hook compatibility - * - * STREAM RECONNECTION: This query handles the case where a user reloads during streaming. - * The persistent stream component may have cleared its body, but: - * 1. The message has content from periodic saves - * 2. The message status tells us if streaming is still in progress - * We use the message's status (not "done") to determine the correct streaming state. - * - * SECURITY: Requires authentication and verifies ownership of the stream. - */ -export const getStreamBody = internalQuery({ - args: { - streamId: StreamIdValidator, - userId: v.id("users"), - }, - handler: async (ctx, args) => { - // Authenticate the user - const userId = await requireAuthUserId(ctx, args.userId); - - // Always fetch the message to check ownership, actual status, and get reasoning - const message = await ctx.db - .query("messages") - .withIndex("by_stream_id", (q) => q.eq("streamId", args.streamId as string)) - .unique(); - - // Verify ownership - message must belong to the authenticated user - if (!message || message.userId !== userId) { - throw new Error("Stream not found or access denied"); - } - - // Get stream body from persistent streaming - returns { text, status } - const streamBody = await persistentTextStreaming.getStreamBody(ctx, args.streamId as StreamId); - - // If stream body has content and is still streaming, return it with reasoning from message - if (streamBody && typeof streamBody === "object" && streamBody.text) { - return { - ...streamBody, - reasoning: message.reasoning ?? null, - thinkingTimeMs: message.thinkingTimeMs ?? null, - }; - } - - // If stream body is empty but message has content, use message content - // CRITICAL: Use the message's status, not hardcoded "done" - // This allows stream reconnection to show content while still detecting streaming - if (message.content) { - // Determine the correct status based on message status - const status = message.status === "streaming" ? "streaming" : "done"; - return { - text: message.content, - status: status as "streaming" | "done", - reasoning: message.reasoning ?? null, - thinkingTimeMs: message.thinkingTimeMs ?? null, - }; - } - - // Fallback: return stream body with null reasoning (may be empty/null) - return streamBody ? { ...streamBody, reasoning: null, thinkingTimeMs: null } : streamBody; - }, -}); - -export const getStreamMessage = internalQuery({ - args: { - messageId: v.id("messages"), - streamId: v.string(), - }, - returns: v.union( - v.object({ - userId: v.id("users"), - }), - v.null(), - ), - handler: async (ctx, args) => { - const message = await ctx.db.get(args.messageId); - if (!message || message.streamId !== args.streamId || !message.userId) return null; - return { userId: message.userId }; - }, -}); - -/** - * Get stream status and content by querying the associated message - * - * SECURITY: Requires authentication and verifies ownership of the stream. - */ -export const getStreamStatus = internalQuery({ - args: { - streamId: v.string(), - userId: v.id("users"), - }, - returns: v.object({ - body: v.union(v.string(), v.null()), - status: v.union(v.literal("streaming"), v.literal("completed"), v.literal("error"), v.null()), - reasoning: v.optional(v.union(v.string(), v.null())), - thinkingTimeMs: v.optional(v.union(v.number(), v.null())), - }), - handler: async (ctx, args) => { - // Authenticate the user - const userId = await requireAuthUserId(ctx, args.userId); - - // Find the message associated with this stream first - // We need this to check ownership, if stream completed, and fall back to message content - const message = await ctx.db - .query("messages") - .withIndex("by_stream_id", (q) => q.eq("streamId", args.streamId)) - .unique(); - - // Verify ownership - message must belong to the authenticated user - if (!message || message.userId !== userId) { - throw new Error("Stream not found or access denied"); - } - - // Get the stream body - returns {status, text} object - const rawBody = await persistentTextStreaming.getStreamBody(ctx, args.streamId as StreamId); - - // Extract text from the StreamBody object - let body: string | null = null; - if (rawBody && typeof rawBody === "object" && "text" in rawBody) { - body = (rawBody as { text: string }).text || null; - } else if (typeof rawBody === "string") { - body = rawBody; - } - - // STREAM RECONNECTION FIX: If stream body is empty but message has content, - // use the message content. This handles the case where: - // 1. Stream completed while user was reloading - // 2. Persistent streaming cleared the body - // 3. But message.content has the final content - if (!body && message.content) { - body = message.content; - } - - return { - body, - status: (message.status as "streaming" | "completed" | "error") ?? null, - reasoning: message.reasoning ?? null, - thinkingTimeMs: message.thinkingTimeMs ?? null, - }; - }, -}); - -/** - * Cancel an active stream - * Called by the client when user clicks stop button - * The streamLLM action will check for this status and stop streaming - */ -export const cancelStream = mutation({ - args: { - streamId: v.string(), - userId: v.id("users"), - }, - returns: v.object({ - success: v.boolean(), - }), - handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); - // Find the message associated with this stream - const message = await ctx.db - .query("messages") - .withIndex("by_stream_id", (q) => q.eq("streamId", args.streamId)) - .unique(); - - if (!message) { - return { success: false }; - } - - // Verify user owns the message - if (message.userId !== userId) { - return { success: false }; - } - - // Only cancel if currently streaming - if (message.status !== "streaming") { - return { success: false }; - } - - // Mark as cancelled - await ctx.db.patch(message._id, { - status: "cancelled", - }); - - // Also reset the chat status - const chat = await ctx.db.get(message.chatId); - if (chat && chat.status === "streaming") { - await ctx.db.patch(message.chatId, { - status: "idle", - updatedAt: Date.now(), - }); - } - - return { success: true }; - }, -}); - -/** - * Check if a stream has been cancelled (internal query for streamLLM) - */ -export const isStreamCancelled = internalQuery({ - args: { - streamId: v.string(), - }, - returns: v.boolean(), - handler: async (ctx, args) => { - const message = await ctx.db - .query("messages") - .withIndex("by_stream_id", (q) => q.eq("streamId", args.streamId)) - .unique(); - - return message?.status === "cancelled"; - }, -}); - -/** - * Mark a stream as complete and update the message content - * Internal mutation - called from HTTP action after streaming completes - */ -export const completeStream = internalMutation({ - args: { - messageId: v.id("messages"), - content: v.string(), - reasoning: v.optional(v.string()), - thinkingTimeMs: v.optional(v.number()), - reasoningRequested: v.optional(v.boolean()), - }, - handler: async (ctx, args) => { - // Get the message to find the chatId - const message = await ctx.db.get(args.messageId); - - if (!message) { - throw new Error("Message not found"); - } - - await ctx.db.patch(args.messageId, { - content: args.content, - reasoning: args.reasoning, - thinkingTimeMs: args.thinkingTimeMs, - reasoningRequested: args.reasoningRequested, - status: "completed", - }); - - // Reset chat status to idle when streaming completes - await ctx.db.patch(message.chatId, { - status: "idle", - updatedAt: Date.now(), - }); - }, -}); - -/** - * Update message content during streaming (periodic saves) - * Internal mutation - called from HTTP action during streaming - */ -export const updateStreamContent = internalMutation({ - args: { - messageId: v.id("messages"), - content: v.string(), - reasoning: v.optional(v.string()), - }, - handler: async (ctx, args) => { - await ctx.db.patch(args.messageId, { - content: args.content, - reasoning: args.reasoning, - }); - }, -}); - -/** - * Fix stuck streaming messages that never completed - * This can happen when a stream is interrupted (server crash, timeout, etc.) - * Marks stuck messages as "error" status to allow UI to recover - */ -export const fixStuckStreamingMessages = mutation({ - args: { - userId: v.id("users"), - }, - returns: v.object({ - fixedCount: v.number(), - fixedMessageIds: v.array(v.id("messages")), - }), - handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); - // Find all streaming messages for this user - const streamingMessages = await ctx.db - .query("messages") - .withIndex("by_user_status", (q) => - q.eq("userId", userId).eq("status", "streaming") - ) - .collect(); - - const fixedIds: Id<"messages">[] = []; - - for (const message of streamingMessages) { - // Mark as error status - the stream never completed - await ctx.db.patch(message._id, { - status: "error", - }); - fixedIds.push(message._id); - - // Also reset the chat status if it's stuck - const chat = await ctx.db.get(message.chatId); - if (chat && chat.status === "streaming") { - await ctx.db.patch(message.chatId, { - status: "idle", - updatedAt: Date.now(), - }); - } - } - - return { - fixedCount: fixedIds.length, - fixedMessageIds: fixedIds, - }; - }, -}); - -/** - * Admin-only: Fix ALL stuck streaming messages across all users - * Use with caution - should only be run by admins - */ -export const fixAllStuckStreamingMessages = internalMutation({ - args: {}, - returns: v.object({ - fixedCount: v.number(), - }), - handler: async (ctx) => { - // Find all streaming messages - const streamingMessages = await ctx.db - .query("messages") - .filter((q) => q.eq(q.field("status"), "streaming")) - .collect(); - - let fixedCount = 0; - - for (const message of streamingMessages) { - await ctx.db.patch(message._id, { - status: "error", - }); - fixedCount++; - - // Also reset the chat status if stuck - const chat = await ctx.db.get(message.chatId); - if (chat && chat.status === "streaming") { - await ctx.db.patch(message.chatId, { - status: "idle", - updatedAt: Date.now(), - }); - } - } - - return { fixedCount }; - }, -}); - -/** - * Admin-only: Soft-delete broken error messages (empty content with error status) - * These messages were never completed and have no useful content - */ -export const cleanupBrokenErrorMessages = internalMutation({ - args: {}, - returns: v.object({ - deletedCount: v.number(), - }), - handler: async (ctx) => { - // Find all error messages with empty content - const errorMessages = await ctx.db - .query("messages") - .filter((q) => q.eq(q.field("status"), "error")) - .collect(); - - let deletedCount = 0; - const now = Date.now(); - - for (const message of errorMessages) { - // Only delete if content is empty or very short (broken) - if (!message.content || message.content.trim().length < 10) { - await ctx.db.patch(message._id, { - deletedAt: now, - }); - deletedCount++; - } - } - - return { deletedCount }; - }, -}); - -/** - * Prepare a chat for streaming - creates user message, stream, and assistant message - * Called by the client before starting the stream - */ -export const prepareChat = mutation({ - args: { - chatId: v.id("chats"), - userId: v.id("users"), - userContent: v.string(), - userMessageId: v.optional(v.string()), - assistantMessageId: v.optional(v.string()), - }, - returns: v.object({ - streamId: StreamIdValidator, - userMessageId: v.id("messages"), - assistantMessageId: v.id("messages"), - }), - handler: async (ctx, args) => { - const userId = await requireAuthUserId(ctx, args.userId); - // Verify user owns the chat - const chat = await ctx.db.get(args.chatId); - if (!chat || chat.userId !== userId || chat.deletedAt) { - throw new Error("Chat not found or access denied"); - } - - const now = Date.now(); - - // Create the user message - const userMsgId = await ctx.db.insert("messages", { - chatId: args.chatId, - clientMessageId: args.userMessageId, - role: "user", - content: args.userContent, - status: "completed", - createdAt: now, - userId, - }); - - // Create the persistent stream for assistant response - const streamId = await persistentTextStreaming.createStream(ctx); - - // Create the assistant message placeholder with the stream ID - const assistantMsgId = await ctx.db.insert("messages", { - chatId: args.chatId, - clientMessageId: args.assistantMessageId, - role: "assistant", - content: "", // Will be populated by stream - status: "streaming", - streamId: streamId as string, - createdAt: now + 1, // Slightly after user message - userId, - }); - - // Update chat's lastMessageAt and set status to streaming - // This allows the sidebar to show a streaming indicator - await ctx.db.patch(args.chatId, { - lastMessageAt: now, - updatedAt: now, - status: "streaming", - }); - - return { - streamId, - userMessageId: userMsgId, - assistantMessageId: assistantMsgId, - }; - }, -}); - -// Types for the stream request -type StreamRequestBody = { - streamId: string; - messageId: string; - modelId: string; - apiKey?: string; - messages: Array<{ - role: string; - content: string; - }>; - provider?: "osschat" | "openrouter"; - reasoningConfig?: { - enabled: boolean; - effort?: "low" | "medium" | "high"; - max_tokens?: number; - }; -}; - -// Redis streaming configuration -const REDIS_TOKEN_BATCH_SIZE = 10; // Flush every 10 tokens -const REDIS_TOKEN_BATCH_INTERVAL_MS = 50; // Or every 50ms - -/** - * HTTP Action for streaming LLM responses - * This runs on Convex infrastructure and continues even if client disconnects - * - * REDIS STREAMING: - * When Redis is available (UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN set), - * tokens are also pushed to Redis for low-latency client streaming via SSE. - * Client can connect to /api/stream/[redisStreamId] for real-time tokens. - * The X-Stream-Id header contains the Redis stream ID for client connection. - */ -export const streamLLM = httpAction(async (ctx, request) => { - const origin = request.headers.get("origin"); - const corsOrigin = getCorsOrigin(origin); - const withCorsHeaders = (base?: HeadersInit) => { - const headers = new Headers(base); - if (corsOrigin) { - headers.set("Access-Control-Allow-Origin", corsOrigin); - headers.set("Vary", "Origin"); - } - return headers; - }; - // Handle CORS preflight - if (request.method === "OPTIONS") { - if (origin && !corsOrigin) { - return new Response(null, { status: 403 }); - } - return new Response(null, { - status: 204, - headers: withCorsHeaders({ - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", - }), - }); - } - - try { - if (origin && !corsOrigin) { - return new Response(JSON.stringify({ error: "Invalid origin" }), { - status: 403, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - const body = (await request.json()) as StreamRequestBody; - const { streamId, messageId, modelId, messages, reasoningConfig, provider } = body; - - if (!streamId || !messageId || !modelId || !messages) { - return new Response( - JSON.stringify({ error: "Missing required fields" }), - { - status: 400, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - } - ); - } - - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - const user = await ctx.runQuery(internal.users.getByExternalIdInternal, { - externalId: identity.subject, - }); - if (!user) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - const message = await ctx.runQuery(internal.streaming.getStreamMessage, { - messageId: messageId as Id<"messages">, - streamId, - }); - if (!message || message.userId !== user._id) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 403, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - const requestProvider = provider ?? "osschat"; - let apiKey: string | null = null; - if (requestProvider === "osschat") { - apiKey = process.env.OPENROUTER_API_KEY ?? null; - } else { - const encryptedKey = await ctx.runQuery(api.users.getOpenRouterKey, { - userId: user._id, - }); - apiKey = encryptedKey ? await decryptSecret(encryptedKey) : null; - } - if (!apiKey) { - return new Response(JSON.stringify({ error: "API key not configured" }), { - status: 400, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - }); - } - - // Build the OpenRouter request - const openRouterMessages = messages.map((m) => ({ - role: m.role as "user" | "assistant" | "system", - content: m.content, - })); - - // Determine if reasoning is enabled - // Trust the config - UI only shows reasoning controls for models that support it - const hasReasoning = reasoningConfig?.enabled; - - let fullContent = ""; - let fullReasoning = ""; - let reasoningStartTime: number | null = null; - let reasoningEndTime: number | null = null; - let chunkCount = 0; - let reasoningChunkCount = 0; - let isCancelled = false; - let lastCancellationCheck = Date.now(); - const CHUNKS_PER_UPDATE = 10; // More frequent saves for responsive UI - const REASONING_CHUNKS_PER_UPDATE = 1; // Real-time streaming: save on every reasoning chunk - const CANCELLATION_CHECK_INTERVAL_MS = 200; // Check for cancellation every 200ms for faster response - - // Redis streaming state - const useRedisStreaming = redisStreamOps.isAvailable(); - let redisStreamId: string | null = null; - let pendingTokenBatch: string[] = []; - let lastBatchFlush = Date.now(); - - // Initialize Redis stream if available - if (useRedisStreaming) { - try { - // Use messageId as restoration ID for client reconnection - redisStreamId = await redisStreamOps.initializeStream(messageId); - logger.debug("Redis stream initialized", { redisStreamId, messageId }); - } catch (error) { - logger.error("Failed to initialize Redis stream, falling back to Convex-only streaming", error); - redisStreamId = null; - } - } - - // Helper to flush token batch to Redis - const flushTokenBatch = async () => { - if (pendingTokenBatch.length === 0 || !redisStreamId) return; - const batch = pendingTokenBatch; - pendingTokenBatch = []; - lastBatchFlush = Date.now(); - try { - await redisStreamOps.appendTokenBatch(redisStreamId, batch); - } catch (error) { - logger.error("Failed to append token batch to Redis", error); - // Don't fail the stream on Redis errors - content is still saved to Convex - } - }; - - // Generator function for the stream - // This function runs to completion even if client disconnects - // Using 'any' for actionCtx to avoid type conflicts between httpAction ctx and stream() expected type - const generateResponse = async ( - actionCtx: any, - _request: Request, - _streamId: StreamId, - chunkAppender: (chunk: string) => Promise - ) => { - const openRouterUrl = process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1/chat/completions"; - - const requestBody: Record = { - model: modelId, - messages: openRouterMessages, - stream: true, - max_tokens: 4096, - }; - - // Add reasoning config if enabled - if (hasReasoning && reasoningConfig) { - requestBody.reasoning = { - effort: reasoningConfig.effort || "medium", - ...(reasoningConfig.max_tokens && { max_tokens: reasoningConfig.max_tokens }), - }; - } - - const response = await fetch(openRouterUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "HTTP-Referer": process.env.CONVEX_SITE_URL || "https://openchat.dev", - "X-Title": "OpenChat", - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - // Mark Redis stream as errored if initialized - if (redisStreamId) { - try { - await redisStreamOps.errorStream(redisStreamId, `OpenRouter error: ${response.status}`); - } catch (e) { - logger.error("Failed to mark Redis stream as errored", e); - } - } - throw new Error(`OpenRouter error: ${response.status} - ${errorText}`); - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error("No response body"); - } - - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - // Check for cancellation periodically (every CANCELLATION_CHECK_INTERVAL_MS) - const now = Date.now(); - if (now - lastCancellationCheck >= CANCELLATION_CHECK_INTERVAL_MS) { - lastCancellationCheck = now; - const cancelled = await actionCtx.runQuery(internal.streaming.isStreamCancelled, { - streamId: streamId, - }); - if (cancelled) { - isCancelled = true; - logger.debug("Stream cancelled by user", { streamId }); - // Close the reader to stop receiving data - await reader.cancel(); - break; - } - } - - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const data = line.slice(6); - if (data === "[DONE]") continue; - - try { - const parsed = JSON.parse(data); - const delta = parsed.choices?.[0]?.delta; - - if (delta?.content) { - fullContent += delta.content; - await chunkAppender(delta.content); - - // Redis streaming: batch tokens for efficient network usage - if (redisStreamId) { - pendingTokenBatch.push(delta.content); - const now = Date.now(); - // Flush batch when size threshold reached or time interval elapsed (with non-empty check) - if ( - pendingTokenBatch.length >= REDIS_TOKEN_BATCH_SIZE || - (pendingTokenBatch.length > 0 && now - lastBatchFlush >= REDIS_TOKEN_BATCH_INTERVAL_MS) - ) { - try { - await flushTokenBatch(); - } catch (error) { - logger.error("Failed to flush token batch", error); - // Don't rethrow - continue streaming - } - } - } - - // Always do periodic database saves for reliable streaming - // This ensures content is persisted regardless of Redis status - chunkCount++; - if (chunkCount % CHUNKS_PER_UPDATE === 0) { - await actionCtx.runMutation(internal.streaming.updateStreamContent, { - messageId: messageId as Id<"messages">, - content: fullContent, - reasoning: fullReasoning.length > 0 ? fullReasoning : undefined, - }); - } - } - - // Handle reasoning content from OpenRouter - // OpenRouter returns reasoning in different formats depending on model: - // - delta.reasoning_details: array of objects - type varies by provider: - // - Anthropic: { type: "reasoning.text", text: "..." } - // - Others: { type: "text", text: "..." } - // - delta.reasoning: string format (some providers) - // - choice.reasoning_details: array at choice level (non-streaming) - let reasoningChunk: string | null = null; - - // Check for reasoning_details array (OpenRouter's primary format) - if (delta?.reasoning_details && Array.isArray(delta.reasoning_details)) { - // Extract text from reasoning_details array - // Handle multiple type formats: "text", "reasoning.text", "reasoning", etc. - reasoningChunk = delta.reasoning_details - .filter((item: { type?: string; text?: string }) => { - // Accept any type that contains "text" or "reasoning" and has text content - const itemType = item.type?.toLowerCase() || ""; - const hasValidType = itemType.includes("text") || itemType.includes("reasoning") || itemType === ""; - return hasValidType && item.text; - }) - .map((item: { type?: string; text?: string }) => item.text) - .join(""); - } - // Fallback: check for reasoning string - else if (delta?.reasoning && typeof delta.reasoning === "string") { - reasoningChunk = delta.reasoning; - } - - if (reasoningChunk) { - if (!reasoningStartTime) { - reasoningStartTime = Date.now(); - } - fullReasoning += reasoningChunk; - reasoningEndTime = Date.now(); - - // Also stream reasoning to Redis with prefix - if (redisStreamId) { - pendingTokenBatch.push(`[reasoning]${reasoningChunk}`); - const now = Date.now(); - // Flush batch when size threshold reached or time interval elapsed (with non-empty check) - if ( - pendingTokenBatch.length >= REDIS_TOKEN_BATCH_SIZE || - (pendingTokenBatch.length > 0 && now - lastBatchFlush >= REDIS_TOKEN_BATCH_INTERVAL_MS) - ) { - try { - await flushTokenBatch(); - } catch (error) { - logger.error("Failed to flush token batch", error); - // Don't rethrow - continue streaming - } - } - } - - // CRITICAL: Also save reasoning to DB for getStreamBody query - // Reasoning comes before content, so we need frequent saves here - // to enable real-time reasoning display via Convex queries - reasoningChunkCount++; - if (reasoningChunkCount % REASONING_CHUNKS_PER_UPDATE === 0) { - await actionCtx.runMutation(internal.streaming.updateStreamContent, { - messageId: messageId as Id<"messages">, - content: fullContent, - reasoning: fullReasoning, - }); - } - } - } catch (parseError) { - // Log JSON parse errors for debugging OpenRouter protocol issues - logger.warn("Failed to parse streaming chunk", { - data: data.slice(0, 200), // Truncate for logging - error: parseError instanceof Error ? parseError.message : String(parseError), - }); - } - } - } - - // Flush any remaining tokens to Redis - if (redisStreamId) { - try { - await flushTokenBatch(); - } catch (error) { - logger.error("Failed to flush final token batch", error); - // Don't rethrow - continue to finalize stream - } - } - - // Calculate thinking time - const thinkingTimeMs = reasoningStartTime && reasoningEndTime - ? reasoningEndTime - reasoningStartTime - : undefined; - - // Finalize Redis stream - if (redisStreamId) { - try { - const totalTokens = fullContent.split(/\s+/).length; // Simple word-based estimate - await redisStreamOps.finalizeStream(redisStreamId, fullContent, totalTokens); - logger.debug("Redis stream finalized", { redisStreamId, totalTokens }); - } catch (error) { - logger.error("Failed to finalize Redis stream", error); - // Don't fail - Convex write is the source of truth - } - } - - // CRITICAL: Update the message record with final content - // This runs even if the client disconnects - ensuring content is saved - // Skip if cancelled - the cancelStream mutation already set the status - if (isCancelled) { - // Just save the partial content, status is already "cancelled" - await actionCtx.runMutation(internal.streaming.updateStreamContent, { - messageId: messageId as Id<"messages">, - content: fullContent, - reasoning: fullReasoning.length > 0 ? fullReasoning : undefined, - }); - logger.debug("Stream cancelled, saved partial content", { - streamId, - contentLength: fullContent.length, - reasoningLength: fullReasoning.length - }); - return; - } - - await actionCtx.runMutation(internal.streaming.completeStream, { - messageId: messageId as Id<"messages">, - content: fullContent, - reasoning: fullReasoning.length > 0 ? fullReasoning : undefined, - thinkingTimeMs, - reasoningRequested: hasReasoning, - }); - }; - - // Use the persistent streaming component - // Type assertion needed because httpAction ctx is GenericActionCtx - // but stream() expects GenericActionCtx - const streamResponse = await persistentTextStreaming.stream( - ctx as Parameters[0], - request, - streamId as StreamId, - generateResponse - ); - - // Add CORS headers - const headers = new Headers(streamResponse.headers); - if (corsOrigin) { - headers.set("Access-Control-Allow-Origin", corsOrigin); - headers.set("Vary", "Origin"); - } - - // Add Redis stream ID header for client SSE connection - if (redisStreamId) { - headers.set("X-Stream-Id", redisStreamId); - headers.set("Access-Control-Expose-Headers", "X-Stream-Id"); - } - - return new Response(streamResponse.body, { - status: streamResponse.status, - headers, - }); - } catch (error) { - logger.error("Stream error", error); - return new Response( - JSON.stringify({ - error: "Stream failed", - }), - { - status: 500, - headers: withCorsHeaders({ "Content-Type": "application/json" }), - } - ); - } -}); diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 6aa7ab28..86b62c83 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -1,11 +1,11 @@ -import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; +import { action, internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { incrementStat, decrementStat, STAT_KEYS } from "./lib/dbStats"; import { rateLimiter } from "./lib/rateLimiter"; import { throwRateLimitError } from "./lib/rateLimitUtils"; import { getProfileByUserId, getOrCreateProfile } from "./lib/profiles"; -import { components } from "./_generated/api"; -import { requireAuthUserId } from "./lib/auth"; +import { components, internal } from "./_generated/api"; +import { requireAuthUserId, requireAuthUserIdFromAction } from "./lib/auth"; // User with profile data (for backwards-compatible responses) // Includes merged profile data that prefers profile over user during migration @@ -313,6 +313,7 @@ export const incrementAiUsage = internalMutation({ args: { userId: v.id("users"), usageCents: v.number(), + skipRedisSync: v.optional(v.boolean()), }, returns: v.object({ usedCents: v.number(), @@ -367,6 +368,10 @@ export const incrementAiUsage = internalMutation({ updatedAt: Date.now(), }); + // NOTE: Redis sync is performed from action callsites (e.g. backgroundStream) + // to avoid HTTP side effects inside mutations. + void args.skipRedisSync; + return { usedCents: nextCents, remainingCents: Math.max(0, DAILY_AI_LIMIT_CENTS - nextCents), @@ -598,6 +603,259 @@ export const updateName = mutation({ }, }); +const DELETE_BATCH_SIZE_DEFAULT = 100; +const DELETE_BATCH_SIZE_MAX = 500; + +function normalizeBatchSize(value?: number): number { + if (!value || !Number.isFinite(value) || value <= 0) { + return DELETE_BATCH_SIZE_DEFAULT; + } + return Math.min(Math.floor(value), DELETE_BATCH_SIZE_MAX); +} + +const deletionBatchResult = v.object({ + deleted: v.number(), + hasMore: v.boolean(), +}); + +export const deleteUserStreamJobs = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const streamJobs = await ctx.db + .query("streamJobs") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const job of streamJobs) { + await ctx.db.delete(job._id); + } + + return { + deleted: streamJobs.length, + hasMore: streamJobs.length === batchSize, + }; + }, +}); + +export const deleteUserMessages = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const messages = await ctx.db + .query("messages") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const message of messages) { + await ctx.db.delete(message._id); + } + + return { + deleted: messages.length, + hasMore: messages.length === batchSize, + }; + }, +}); + +export const deleteUserChats = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const chats = await ctx.db + .query("chats") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const chat of chats) { + await ctx.db.delete(chat._id); + } + + return { + deleted: chats.length, + hasMore: chats.length === batchSize, + }; + }, +}); + +export const deleteUserFiles = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const files = await ctx.db + .query("fileUploads") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const file of files) { + try { + await ctx.storage.delete(file.storageId); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (!message.toLowerCase().includes("not found")) { + console.error("Unexpected error deleting storage file:", file.storageId, message); + } + } + await ctx.db.delete(file._id); + } + + return { + deleted: files.length, + hasMore: files.length === batchSize, + }; + }, +}); + +export const deleteUserRecord = internalMutation({ + args: { + userId: v.id("users"), + externalId: v.string(), + }, + returns: v.object({ success: v.boolean() }), + handler: async (ctx, args) => { + const user = await ctx.db.get(args.userId); + if (!user || user.externalId !== args.externalId) { + return { success: false }; + } + + await ctx.runMutation(components.betterAuth.adapter.deleteMany, { + input: { + model: "session", + where: [{ field: "userId", operator: "eq", value: args.externalId }], + }, + paginationOpts: { cursor: null, numItems: 1000 }, + }); + + await ctx.runMutation(components.betterAuth.adapter.deleteMany, { + input: { + model: "account", + where: [{ field: "userId", operator: "eq", value: args.externalId }], + }, + paginationOpts: { cursor: null, numItems: 100 }, + }); + + await ctx.runMutation(components.betterAuth.adapter.deleteMany, { + input: { + model: "user", + where: [{ field: "_id", operator: "eq", value: args.externalId }], + }, + paginationOpts: { cursor: null, numItems: 1 }, + }); + + const readStatuses = await ctx.db + .query("chatReadStatus") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .collect(); + for (const status of readStatuses) { + await ctx.db.delete(status._id); + } + + const templates = await ctx.db + .query("promptTemplates") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .collect(); + for (const template of templates) { + await ctx.db.delete(template._id); + } + + const profile = await ctx.db + .query("profiles") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .unique(); + if (profile) { + await ctx.db.delete(profile._id); + } + + await ctx.db.delete(args.userId); + await decrementStat(ctx, STAT_KEYS.USERS_TOTAL); + + return { success: true }; + }, +}); + +export const deleteAccountWorkflowStep = action({ + args: { + userId: v.id("users"), + externalId: v.string(), + step: v.union( + v.literal("delete-stream-jobs"), + v.literal("delete-messages"), + v.literal("delete-chats"), + v.literal("delete-files"), + v.literal("delete-user"), + ), + batchSize: v.optional(v.number()), + }, + returns: v.object({ + deleted: v.number(), + hasMore: v.boolean(), + success: v.optional(v.boolean()), + }), + handler: async ( + ctx, + args, + ): Promise<{ deleted: number; hasMore: boolean; success?: boolean }> => { + const userId = await requireAuthUserIdFromAction(ctx, args.userId); + const identity = await ctx.auth.getUserIdentity(); + if (!identity || identity.subject !== args.externalId) { + throw new Error("Unauthorized"); + } + + switch (args.step) { + case "delete-stream-jobs": + return await ctx.runMutation(internal.users.deleteUserStreamJobs, { + userId, + batchSize: args.batchSize, + }); + case "delete-messages": + return await ctx.runMutation(internal.users.deleteUserMessages, { + userId, + batchSize: args.batchSize, + }); + case "delete-chats": + return await ctx.runMutation(internal.users.deleteUserChats, { + userId, + batchSize: args.batchSize, + }); + case "delete-files": + return await ctx.runMutation(internal.users.deleteUserFiles, { + userId, + batchSize: args.batchSize, + }); + case "delete-user": { + const result: { success: boolean } = await ctx.runMutation( + internal.users.deleteUserRecord, + { + userId, + externalId: args.externalId, + }, + ); + return { + deleted: result.success ? 1 : 0, + hasMore: false, + success: result.success, + }; + } + } + }, +}); + /** * Permanently delete a user account and all associated data. * This is an irreversible action that removes: diff --git a/apps/server/package.json b/apps/server/package.json index f0519c85..9556c7a6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "convex dev", - "build": "if [ \"$DEPLOYMENT\" = \"preview\" ]; then convex deploy --yes --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL --cmd 'cd ../web && bunx vite build' --preview-run previewSeed --preview-create \"$VERCEL_GIT_COMMIT_REF\"; else convex deploy --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL --cmd 'cd ../web && bunx vite build'; fi", + "build": "if [ \"$DEPLOYMENT\" = \"preview\" ]; then convex deploy --yes --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL --cmd 'cd ../web && bunx vite build' --preview-run previewSeed --preview-create $VERCEL_GIT_COMMIT_REF; else convex deploy --yes --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL --cmd 'cd ../web && bunx vite build'; fi", "deploy": "bunx --bun convex deploy", "deploy:prod": "./scripts/deploy-prod.sh", "lint": "oxlint", @@ -13,7 +13,6 @@ }, "dependencies": { "@convex-dev/better-auth": "^0.10.10", - "@convex-dev/persistent-text-streaming": "^0.3.0", "@convex-dev/rate-limiter": "^0.3.2", "better-auth": "1.4.18", "@openrouter/ai-sdk-provider": "2.1.1", diff --git a/apps/web/package.json b/apps/web/package.json index 3406d6cd..27e4eaea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "node": ">=22" }, "scripts": { - "dev": "vite dev --port 3000", + "dev": "NODE_NO_WARNINGS=1 BROWSERSLIST_IGNORE_OLD_DATA=true BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA=true vite dev --port 3000", "build": "vite build", "start": "node .output/server/index.mjs", "preview": "vite preview", @@ -30,6 +30,9 @@ "@tanstack/react-router-ssr-query": "^1.158.1", "@tanstack/react-start": "^1.158.3", "@tanstack/router-plugin": "^1.158.1", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.36.2", + "@upstash/workflow": "^1.1.0", "@valyu/ai-sdk": "^1.1.0", "@xyflow/react": "^12.10.0", "ai": "6.0.75", @@ -48,7 +51,6 @@ "posthog-js": "^1.342.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "redis": "^5.10.0", "resumable-stream": "^2.2.10", "server": "workspace:*", "shadcn": "^3.8.4", @@ -70,6 +72,7 @@ "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", + "baseline-browser-mapping": "^2.9.19", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "^5.9.3", diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 35ee43e5..e5390b75 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -422,34 +422,39 @@ export function AppSidebar({ return; } - const generatedTitle = await convexClient.action(api.chats.generateTitle, { - userId: convexUser._id, - seedText: seedText.trim().slice(0, 300), - length: chatTitleLength, - provider: activeProvider, - }); - + const generatedTitle = await convexClient.action(api.chats.generateTitle, { + userId: convexUser._id, + seedText: seedText.trim().slice(0, 300), + length: chatTitleLength, + provider: activeProvider, + }); if (!generatedTitle) { - toast.error("Unable to generate a new chat name."); + if (activeProvider === "openrouter") { + const hasOpenRouterKey = await convexClient.query(api.users.hasOpenRouterKey, { + userId: convexUser._id, + }); + if (!hasOpenRouterKey) { + toast.error("Connect your OpenRouter API key to generate titles with this provider."); + return; + } + } + toast.error("Could not generate a chat title right now. Try again."); return; } - await convexClient.mutation(api.chats.setTitle, { + await convexClient.mutation(api.chats.setGeneratedTitle, { chatId: chatId as Id<"chats">, userId: convexUser._id, title: generatedTitle, - updateUpdatedAt: false, + force: true, }); - } catch (error) { - console.warn("[Chat] Title regeneration failed:", error); - if (error instanceof Error && error.name === "RateLimitError") { - toast.error(error.message); - } else { - toast.error("Failed to regenerate chat name"); - } - } finally { - setTitleGenerating(chatId, false); - } + } catch (error) { + console.warn("[Chat] Title regeneration failed:", error); + const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; + toast.error(message); + } finally { + setTitleGenerating(chatId, false); + } }; const handleDeleteChat = useCallback( diff --git a/apps/web/src/components/delete-account-modal.tsx b/apps/web/src/components/delete-account-modal.tsx index 366e8b0f..7ede7cd6 100644 --- a/apps/web/src/components/delete-account-modal.tsx +++ b/apps/web/src/components/delete-account-modal.tsx @@ -1,6 +1,4 @@ import { useState } from "react"; -import { useMutation } from "convex/react"; -import { api } from "@server/convex/_generated/api"; import { AlertTriangleIcon } from "lucide-react"; import type { Id } from "@server/convex/_generated/dataModel"; import { signOut } from "@/lib/auth-client"; @@ -34,8 +32,6 @@ export function DeleteAccountModal({ const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(null); - const deleteAccount = useMutation(api.users.deleteAccount); - const isConfirmValid = confirmText === "DELETE"; const handleDelete = async () => { @@ -45,10 +41,20 @@ export function DeleteAccountModal({ setError(null); try { - await deleteAccount({ - userId, - externalId, + const response = await fetch("/api/workflow/delete-account", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId, + externalId, + }), }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { error?: string }; + throw new Error(payload.error || "Failed to delete account"); + } // Account deleted successfully - sign out and redirect // Even if signOut fails, redirect to sign-in since the account is gone diff --git a/apps/web/src/hooks/use-persistent-chat.test.ts b/apps/web/src/hooks/use-persistent-chat.test.ts new file mode 100644 index 00000000..84f9d5af --- /dev/null +++ b/apps/web/src/hooks/use-persistent-chat.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + formatTitleGenerationError, + shouldAttemptAutoTitle, + shouldTriggerAutoTitle, +} from "@/lib/title-generation"; + +describe("shouldAttemptAutoTitle", () => { + it("returns true for the first user message with non-empty seed", () => { + const result = shouldAttemptAutoTitle({ + existingMessageCount: 0, + seedText: "hello world", + }); + + expect(result).toBe(true); + }); + + it("returns false when chat already has prior messages", () => { + const result = shouldAttemptAutoTitle({ + existingMessageCount: 1, + seedText: "hello world", + }); + + expect(result).toBe(false); + }); + + it("returns false when seed text is empty/whitespace", () => { + const result = shouldAttemptAutoTitle({ + existingMessageCount: 0, + seedText: " ", + }); + + expect(result).toBe(false); + }); +}); + +describe("formatTitleGenerationError", () => { + it("returns payload error string when present", () => { + expect(formatTitleGenerationError({ error: "Unauthorized" })).toBe("Unauthorized"); + }); + + it("maps known reason codes", () => { + expect(formatTitleGenerationError({ reason: "missing_openrouter_key" })).toContain( + "OpenRouter API key", + ); + expect(formatTitleGenerationError({ reason: "empty_seed" })).toContain( + "Not enough message content", + ); + expect(formatTitleGenerationError({ reason: "generation_failed" })).toContain( + "Could not generate", + ); + }); +}); + +describe("shouldTriggerAutoTitle", () => { + it("returns true when started without a chat id", () => { + expect( + shouldTriggerAutoTitle({ + startedWithoutChatId: true, + existingMessageCount: 5, + seedText: "hello", + }), + ).toBe(true); + }); + + it("returns false for empty seed text", () => { + expect( + shouldTriggerAutoTitle({ + startedWithoutChatId: true, + existingMessageCount: 0, + seedText: " ", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/use-persistent-chat.ts b/apps/web/src/hooks/use-persistent-chat.ts index 55ccf7be..6950a173 100644 --- a/apps/web/src/hooks/use-persistent-chat.ts +++ b/apps/web/src/hooks/use-persistent-chat.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useAction, useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "convex/react"; import { api } from "@server/convex/_generated/api"; import { toast } from "sonner"; import type { Id } from "@server/convex/_generated/dataModel"; @@ -7,7 +7,6 @@ import type { UIMessage } from "ai"; import { useAuth } from "@/lib/auth-client"; import { getModelById, getModelCapabilities, useModelStore, useModels } from "@/stores/model"; import { useProviderStore } from "@/stores/provider"; -import { useChatTitleStore } from "@/stores/chat-title"; import { useStreamStore } from "@/stores/stream"; import { useUIStore } from "@/stores/ui"; import { analytics } from "@/lib/analytics"; @@ -98,8 +97,6 @@ function getReasoningText(part: unknown): string | undefined { return isReasoningPart(part) ? part.text : undefined; } -const TITLE_GENERATION_RETRY_DELAY_MS = 1500; - /** * Maps internal error messages to user-friendly descriptions. * This prevents exposing implementation details like API structures, @@ -302,8 +299,6 @@ export function usePersistentChat({ const webSearchEnabled = useProviderStore((s) => s.webSearchEnabled); const jonMode = useUIStore((s) => s.jonMode); const dynamicPrompt = useUIStore((s) => s.dynamicPrompt); - const chatTitleLength = useChatTitleStore((s) => s.length); - const setTitleGenerating = useChatTitleStore((s) => s.setGenerating); const [messages, setMessages] = useState>([]); const [status, setStatus] = useState<"ready" | "submitted" | "streaming" | "error">("ready"); @@ -353,8 +348,6 @@ export function usePersistentChat({ const editAndRegenerate = useMutation(api.messages.editAndRegenerate); const retryMessageMut = useMutation(api.messages.retryMessage); const forkChatMut = useMutation(api.chats.fork); - const updateTitle = useMutation(api.chats.updateTitle); - const generateTitle = useAction(api.chats.generateTitle); const startBackgroundStream = useMutation(api.backgroundStream.startStream); const cleanupStaleJobs = useMutation(api.backgroundStream.cleanupStaleJobs); @@ -663,71 +656,6 @@ export function usePersistentChat({ }, ]); - if (!chatId) { - const seedText = message.text.trim().slice(0, 300); - if (seedText) { - void (async () => { - if (!targetChatId) return; - - const attemptGenerate = async () => { - try { - const generatedTitle = await generateTitle({ - userId: convexUserId, - seedText, - length: chatTitleLength, - provider: activeProvider, - }); - - if (generatedTitle) { - await updateTitle({ - chatId: targetChatId as Id<"chats">, - userId: convexUserId, - title: generatedTitle, - }); - return { status: "success" } as const; - } - return { status: "empty" } as const; - } catch (err) { - const parsedError = err instanceof Error ? err : new Error(String(err)); - return { - status: "error", - message: parsedError.message, - name: parsedError.name, - } as const; - } - }; - - if (isMountedRef.current) { - setTitleGenerating(targetChatId, true, "auto"); - } - try { - const result = await attemptGenerate(); - if (result.status === "error") { - const isRateLimit = result.name === "RateLimitError"; - if (!isRateLimit) { - await new Promise((resolve) => - setTimeout(resolve, TITLE_GENERATION_RETRY_DELAY_MS) - ); - const retryResult = await attemptGenerate(); - if (retryResult.status === "error") { - if (isMountedRef.current) { - toast.error("Failed to generate chat name"); - } - } - } else { - if (isMountedRef.current) { - toast.error(result.message || "Rate limit reached. Try again later."); - } - } - } - } finally { - if (isMountedRef.current) { - setTitleGenerating(targetChatId, false); - } - } - })(); - } - } } catch (err) { const parsedError = err instanceof Error ? err : new Error("Unknown error"); setError(parsedError); @@ -763,9 +691,9 @@ export function usePersistentChat({ }, [ convexUserId, isUserLoading, user?.id, chatId, messages, models, - activeProvider, webSearchEnabled, chatTitleLength, + activeProvider, webSearchEnabled, jonMode, dynamicPrompt, - setTitleGenerating, createChat, sendMessages, updateTitle, generateTitle, startBackgroundStream, + createChat, sendMessages, startBackgroundStream, cleanupStaleJobs, ], ); diff --git a/apps/web/src/lib/redis.ts b/apps/web/src/lib/redis.ts index 49755c3f..720ff546 100644 --- a/apps/web/src/lib/redis.ts +++ b/apps/web/src/lib/redis.ts @@ -1,59 +1,11 @@ -/** - * Redis client for real-time streaming - * - * Uses Redis Streams for durable, resumable chat token streaming. - * Works with any Redis (self-hosted, Upstash, etc.) - */ +import type { Redis as UpstashRedis } from "@upstash/redis"; +import { upstashRedis } from "@/lib/upstash"; -import { createClient } from "redis"; -import type {RedisClientType} from "redis"; - -let redisClient: RedisClientType | null = null; -let isConnected = false; -let connectionPromise: Promise | null = null; - -const REDIS_URL = process.env.REDIS_URL; - -export function getRedisClient(): RedisClientType | null { - if (!REDIS_URL) { - return null; - } - - if (!redisClient) { - redisClient = createClient({ url: REDIS_URL }); - redisClient.on("error", (err: Error) => { - console.error("[Redis] Error:", err); - isConnected = false; - }); - redisClient.on("connect", () => { - console.log("[Redis] Connected"); - isConnected = true; - }); - redisClient.on("disconnect", () => { - console.log("[Redis] Disconnected"); - isConnected = false; - }); - connectionPromise = redisClient.connect().then(() => {}).catch((err: Error) => { - console.error("[Redis] Connection failed:", err); - isConnected = false; - }); - } - - return redisClient; -} - -export async function ensureRedisConnected(): Promise { - if (!REDIS_URL) return false; - getRedisClient(); - if (connectionPromise) { - await connectionPromise; - } - return isConnected; -} - -export function isRedisAvailable(): boolean { - return !!REDIS_URL && isConnected; -} +const STREAM_TTL_SECONDS = 3600; +const STREAM_ERROR_TTL_SECONDS = 600; +const TYPING_TTL_SECONDS = 3; +const PRESENCE_TTL_SECONDS = 60; +const ONLINE_WINDOW_MS = 60_000; const keys = { stream: (chatId: string) => `chat:${chatId}:stream`, @@ -80,14 +32,38 @@ export interface StreamMeta { error?: string; } -async function getConnectedClient(): Promise { - const client = getRedisClient(); - if (!client) return null; - if (connectionPromise) { - await connectionPromise; +export function getRedisClient(): UpstashRedis | null { + return upstashRedis; +} + +export function isRedisAvailable(): boolean { + return upstashRedis !== null; +} + +export async function ensureRedisConnected(): Promise { + if (!upstashRedis) return false; + try { + await upstashRedis.ping(); + return true; + } catch (error) { + console.error("[Upstash Redis] Ping failed:", error); + return false; + } +} + +async function getConnectedClient(): Promise { + if (!upstashRedis) return null; + const isReady = await ensureRedisConnected(); + return isReady ? upstashRedis : null; +} + +function parseStreamMeta(value: unknown): StreamMeta | null { + if (typeof value !== "string") return null; + try { + return JSON.parse(value) as StreamMeta; + } catch { + return null; } - if (!isConnected) return null; - return client; } export async function initStream( @@ -106,8 +82,9 @@ export async function initStream( startedAt: Date.now(), }; - await client.set(keys.meta(chatId), JSON.stringify(meta), { EX: 3600 }); - console.log("[Redis] Stream initialized for chat:", chatId); + await client.set(keys.meta(chatId), JSON.stringify(meta), { + ex: STREAM_TTL_SECONDS, + }); return true; } @@ -120,14 +97,12 @@ export async function appendToken( if (!client) return null; const streamKey = keys.stream(chatId); - const entryId = await client.xAdd(streamKey, "*", { + const entryId = await client.xadd(streamKey, "*", { text, type, ts: Date.now().toString(), }); - - await client.expire(streamKey, 3600); - + await client.expire(streamKey, STREAM_TTL_SECONDS); return entryId; } @@ -138,14 +113,14 @@ export async function completeStream(chatId: string): Promise { await appendToken(chatId, "", "done"); const metaKey = keys.meta(chatId); - const metaStr = await client.get(metaKey); - if (metaStr) { - const meta: StreamMeta = JSON.parse(metaStr); - meta.status = "completed"; - meta.completedAt = Date.now(); - await client.set(metaKey, JSON.stringify(meta), { EX: 3600 }); - } - console.log("[Redis] Stream completed for chat:", chatId); + const meta = parseStreamMeta(await client.get(metaKey)); + if (!meta) return; + + meta.status = "completed"; + meta.completedAt = Date.now(); + await client.set(metaKey, JSON.stringify(meta), { + ex: STREAM_TTL_SECONDS, + }); } export async function errorStream(chatId: string, error: string): Promise { @@ -155,14 +130,15 @@ export async function errorStream(chatId: string, error: string): Promise await appendToken(chatId, error, "error"); const metaKey = keys.meta(chatId); - const metaStr = await client.get(metaKey); - if (metaStr) { - const meta: StreamMeta = JSON.parse(metaStr); - meta.status = "error"; - meta.error = error; - meta.completedAt = Date.now(); - await client.set(metaKey, JSON.stringify(meta), { EX: 600 }); - } + const meta = parseStreamMeta(await client.get(metaKey)); + if (!meta) return; + + meta.status = "error"; + meta.error = error; + meta.completedAt = Date.now(); + await client.set(metaKey, JSON.stringify(meta), { + ex: STREAM_ERROR_TTL_SECONDS, + }); } export async function readStream( @@ -172,25 +148,32 @@ export async function readStream( const client = await getConnectedClient(); if (!client) return []; - const streamKey = keys.stream(chatId); - const entries = await client.xRange(streamKey, lastId === "0" ? "-" : `(${lastId}`, "+"); - - return entries.map((entry: { id: string; message: Record }) => ({ - id: entry.id, - text: entry.message.text || "", - type: (entry.message.type as StreamToken["type"]), - timestamp: parseInt(entry.message.ts || "0", 10), + const entries = await client.xrange( + keys.stream(chatId), + lastId === "0" ? "-" : `(${lastId}`, + "+", + ); + + return Object.entries(entries).map(([id, message]) => ({ + id, + text: typeof message.text === "string" ? message.text : "", + type: + message.type === "reasoning" || + message.type === "done" || + message.type === "error" + ? message.type + : "text", + timestamp: + typeof message.ts === "string" + ? Number.parseInt(message.ts, 10) + : Date.now(), })); } export async function getStreamMeta(chatId: string): Promise { const client = await getConnectedClient(); if (!client) return null; - - const metaStr = await client.get(keys.meta(chatId)); - if (!metaStr) return null; - - return JSON.parse(metaStr); + return parseStreamMeta(await client.get(keys.meta(chatId))); } export async function hasActiveStream(chatId: string): Promise { @@ -208,10 +191,10 @@ export async function setTyping( const key = keys.typing(chatId, userId); if (isTyping) { - await client.set(key, "1", { EX: 3 }); - } else { - await client.del(key); + await client.set(key, "1", { ex: TYPING_TTL_SECONDS }); + return; } + await client.del(key); } export async function getTypingUsers(chatId: string): Promise> { @@ -219,60 +202,64 @@ export async function getTypingUsers(chatId: string): Promise> { if (!client) return []; const pattern = `chat:${chatId}:typing:*`; - const foundKeys: Array = []; - for await (const scanKeys of client.scanIterator({ MATCH: pattern, COUNT: 100 })) { - if (Array.isArray(scanKeys)) { - foundKeys.push(...scanKeys); - } else { - foundKeys.push(scanKeys); + const users = new Set(); + let cursor: string | number = "0"; + + do { + const [nextCursor, keysFound]: [string, Array] = await client.scan(cursor, { + match: pattern, + count: 100, + }); + cursor = nextCursor; + for (const key of keysFound) { + const userId = key.split(":").pop(); + if (userId) { + users.add(userId); + } } - } + } while (String(cursor) !== "0"); - return foundKeys.map((key) => key.split(":").pop() || ""); + return [...users]; } export async function updatePresence(userId: string): Promise { const client = await getConnectedClient(); if (!client) return; - - await client.set(keys.presence(userId), Date.now().toString(), { EX: 60 }); + await client.set(keys.presence(userId), Date.now().toString(), { + ex: PRESENCE_TTL_SECONDS, + }); } export async function isUserOnline(userId: string): Promise { const client = await getConnectedClient(); if (!client) return false; - const lastSeen = await client.get(keys.presence(userId)); + const lastSeen = await client.get(keys.presence(userId)); if (!lastSeen) return false; - - return Date.now() - parseInt(lastSeen, 10) < 60000; + return Date.now() - Number.parseInt(lastSeen, 10) < ONLINE_WINDOW_MS; } export async function incrementUnread(userId: string, chatId: string): Promise { const client = await getConnectedClient(); if (!client) return; - - await client.hIncrBy(keys.unread(userId), chatId, 1); + await client.hincrby(keys.unread(userId), chatId, 1); } export async function clearUnread(userId: string, chatId: string): Promise { const client = await getConnectedClient(); if (!client) return; - - await client.hDel(keys.unread(userId), chatId); + await client.hdel(keys.unread(userId), chatId); } export async function getUnreadCounts(userId: string): Promise> { const client = await getConnectedClient(); if (!client) return {}; - const counts = await client.hGetAll(keys.unread(userId)); + const counts = await client.hgetall>(keys.unread(userId)); const result: Record = {}; - - for (const [chatId, count] of Object.entries(counts)) { - result[chatId] = parseInt(count, 10); + for (const [chatId, count] of Object.entries(counts ?? {})) { + result[chatId] = Number.parseInt(count, 10); } - return result; } diff --git a/apps/web/src/lib/server-auth.test.ts b/apps/web/src/lib/server-auth.test.ts new file mode 100644 index 00000000..076e8007 --- /dev/null +++ b/apps/web/src/lib/server-auth.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +async function loadModule() { + vi.resetModules(); + return import("@/lib/server-auth"); +} + +function requestWithCookie(cookie: string): Request { + return { + headers: { + get: (name: string) => (name.toLowerCase() === "cookie" ? cookie : null), + }, + } as unknown as Request; +} + +describe("server-auth.getConvexAuthToken", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("uses better-auth.convex_jwt cookie fallback when Convex token endpoint is unavailable", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", ""); + vi.stubEnv("CONVEX_SITE_URL", ""); + + const { getConvexAuthToken } = await loadModule(); + const request = requestWithCookie("better-auth.convex_jwt=abc%2E123%2Exyz; other=value"); + + await expect(getConvexAuthToken(request)).resolves.toBe("abc.123.xyz"); + }); + + it("prefers token endpoint when it returns a valid token", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); + vi.stubEnv("CONVEX_SITE_URL", "https://example.convex.site"); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ token: "endpoint-token" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const { getConvexAuthToken } = await loadModule(); + const request = requestWithCookie("better-auth.convex_jwt=fallback-token"); + + await expect(getConvexAuthToken(request)).resolves.toBe("endpoint-token"); + }); + + it("falls back to cookie token when endpoint request throws", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); + vi.stubEnv("CONVEX_SITE_URL", "https://example.convex.site"); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); + + const { getConvexAuthToken } = await loadModule(); + const request = requestWithCookie("better-auth.convex_jwt=fallback-token"); + + await expect(getConvexAuthToken(request)).resolves.toBe("fallback-token"); + }); +}); diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index b9fd3062..e990b356 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -17,17 +17,41 @@ type AuthSessionResponse = { const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; +function getCookieValue(cookieHeader: string, name: string): string | null { + const target = `${name}=`; + for (const part of cookieHeader.split(";")) { + const trimmed = part.trim(); + if (!trimmed.startsWith(target)) continue; + const rawValue = trimmed.slice(target.length); + if (!rawValue) return null; + try { + return decodeURIComponent(rawValue); + } catch { + return rawValue; + } + } + return null; +} + export async function getConvexAuthToken(request: Request): Promise { - if (!CONVEX_SITE_URL) return null; const cookie = request.headers.get("cookie"); if (!cookie) return null; - const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { - headers: { cookie }, - }); - if (!response.ok) return null; - const data = (await response.json()) as { token?: string } | null; - return data?.token ?? null; + if (CONVEX_SITE_URL) { + try { + const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { + headers: { cookie }, + }); + if (response.ok) { + const data = (await response.json()) as { token?: string } | null; + if (data?.token) return data.token; + } + } catch { + // Fall through to cookie fallback. + } + } + + return getCookieValue(cookie, "better-auth.convex_jwt"); } /** diff --git a/apps/web/src/lib/title-generation.ts b/apps/web/src/lib/title-generation.ts new file mode 100644 index 00000000..8c5ba138 --- /dev/null +++ b/apps/web/src/lib/title-generation.ts @@ -0,0 +1,57 @@ +export type GenerateTitleResponse = { + queued?: boolean; + saved?: boolean; + reason?: string; + error?: string; +}; + +export function shouldAttemptAutoTitle({ + existingMessageCount, + seedText, +}: { + existingMessageCount: number; + seedText: string; +}): boolean { + return existingMessageCount === 0 && seedText.trim().length > 0; +} + +export function shouldTriggerAutoTitle({ + startedWithoutChatId, + existingMessageCount, + seedText, +}: { + startedWithoutChatId: boolean; + existingMessageCount: number; + seedText: string; +}): boolean { + const normalizedSeed = seedText.trim(); + if (!normalizedSeed) return false; + return ( + startedWithoutChatId || + shouldAttemptAutoTitle({ + existingMessageCount, + seedText: normalizedSeed, + }) + ); +} + +export function formatTitleGenerationError(payload: GenerateTitleResponse | null): string { + if (!payload) return "Failed to generate chat name"; + if (payload.error) return payload.error; + if (payload.reason?.startsWith("llm_status_")) { + return "The title model request failed. Please try again."; + } + + switch (payload.reason) { + case "missing_openrouter_key": + return "Connect your OpenRouter API key to generate titles with this provider."; + case "generation_failed": + return "Could not generate a chat title right now. Try again."; + case "empty_seed": + return "Not enough message content to generate a title."; + case "empty_title": + return "Model returned an empty title. Try again."; + default: + return "Failed to generate chat name"; + } +} diff --git a/apps/web/src/lib/upstash.ts b/apps/web/src/lib/upstash.ts new file mode 100644 index 00000000..e4900441 --- /dev/null +++ b/apps/web/src/lib/upstash.ts @@ -0,0 +1,51 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { Client as WorkflowClient } from "@upstash/workflow"; + +const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL?.trim(); +const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); +const QSTASH_URL = process.env.QSTASH_URL?.trim(); +const QSTASH_TOKEN = process.env.QSTASH_TOKEN?.trim(); + +export const upstashRedis = + UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN + ? new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + }) + : null; + +function createSlidingWindowRatelimit( + limit: number, + window: `${number} ${"ms" | "s" | "m" | "h" | "d"}`, + prefix: string, +) { + if (!upstashRedis) return null; + return new Ratelimit({ + redis: upstashRedis, + limiter: Ratelimit.slidingWindow(limit, window), + prefix, + }); +} + +export const chatUserRatelimit = createSlidingWindowRatelimit(30, "60 s", "ratelimit:chat:user"); +export const chatIpRatelimit = createSlidingWindowRatelimit(60, "60 s", "ratelimit:chat:ip"); +export const chatRatelimit = chatUserRatelimit; +export const uploadRatelimit = createSlidingWindowRatelimit(20, "60 s", "ratelimit:upload:user"); +export const exportRatelimit = createSlidingWindowRatelimit(10, "60 s", "ratelimit:export:user"); +export const authRatelimit = createSlidingWindowRatelimit(20, "60 s", "ratelimit:auth:user"); + +export const workflowClient = QSTASH_TOKEN + ? new WorkflowClient({ + baseUrl: QSTASH_URL, + token: QSTASH_TOKEN, + }) + : null; + +export function isUpstashRedisConfigured(): boolean { + return upstashRedis !== null; +} + +export function isQstashConfigured(): boolean { + return workflowClient !== null; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index bb75cc76..ef486a16 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,7 +20,11 @@ import { Route as AuthSignInRouteImport } from './routes/auth/sign-in' import { Route as AuthCallbackRouteImport } from './routes/auth/callback' import { Route as ApiTypingRouteImport } from './routes/api/typing' import { Route as ApiOpenrouterKeyRouteImport } from './routes/api/openrouter-key' -import { Route as ApiChatRouteImport } from './routes/api/chat' +import { Route as ApiModelsRouteImport } from './routes/api/models' +import { Route as ApiWorkflowGenerateTitleRouteImport } from './routes/api/workflow/generate-title' +import { Route as ApiWorkflowExportChatRouteImport } from './routes/api/workflow/export-chat' +import { Route as ApiWorkflowDeleteAccountRouteImport } from './routes/api/workflow/delete-account' +import { Route as ApiWorkflowCleanupRouteImport } from './routes/api/workflow/cleanup' const TermsRoute = TermsRouteImport.update({ id: '/terms', @@ -77,9 +81,31 @@ const ApiOpenrouterKeyRoute = ApiOpenrouterKeyRouteImport.update({ path: '/api/openrouter-key', getParentRoute: () => rootRouteImport, } as any) -const ApiChatRoute = ApiChatRouteImport.update({ - id: '/api/chat', - path: '/api/chat', +const ApiModelsRoute = ApiModelsRouteImport.update({ + id: '/api/models', + path: '/api/models', + getParentRoute: () => rootRouteImport, +} as any) +const ApiWorkflowGenerateTitleRoute = + ApiWorkflowGenerateTitleRouteImport.update({ + id: '/api/workflow/generate-title', + path: '/api/workflow/generate-title', + getParentRoute: () => rootRouteImport, + } as any) +const ApiWorkflowExportChatRoute = ApiWorkflowExportChatRouteImport.update({ + id: '/api/workflow/export-chat', + path: '/api/workflow/export-chat', + getParentRoute: () => rootRouteImport, +} as any) +const ApiWorkflowDeleteAccountRoute = + ApiWorkflowDeleteAccountRouteImport.update({ + id: '/api/workflow/delete-account', + path: '/api/workflow/delete-account', + getParentRoute: () => rootRouteImport, + } as any) +const ApiWorkflowCleanupRoute = ApiWorkflowCleanupRouteImport.update({ + id: '/api/workflow/cleanup', + path: '/api/workflow/cleanup', getParentRoute: () => rootRouteImport, } as any) @@ -89,13 +115,17 @@ export interface FileRoutesByFullPath { '/privacy': typeof PrivacyRoute '/settings': typeof SettingsRoute '/terms': typeof TermsRoute - '/api/chat': typeof ApiChatRoute + '/api/models': typeof ApiModelsRoute '/api/openrouter-key': typeof ApiOpenrouterKeyRoute '/api/typing': typeof ApiTypingRoute '/auth/callback': typeof AuthCallbackRoute '/auth/sign-in': typeof AuthSignInRoute '/c/$chatId': typeof CChatIdRoute '/openrouter/callback': typeof OpenrouterCallbackRoute + '/api/workflow/cleanup': typeof ApiWorkflowCleanupRoute + '/api/workflow/delete-account': typeof ApiWorkflowDeleteAccountRoute + '/api/workflow/export-chat': typeof ApiWorkflowExportChatRoute + '/api/workflow/generate-title': typeof ApiWorkflowGenerateTitleRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -103,13 +133,17 @@ export interface FileRoutesByTo { '/privacy': typeof PrivacyRoute '/settings': typeof SettingsRoute '/terms': typeof TermsRoute - '/api/chat': typeof ApiChatRoute + '/api/models': typeof ApiModelsRoute '/api/openrouter-key': typeof ApiOpenrouterKeyRoute '/api/typing': typeof ApiTypingRoute '/auth/callback': typeof AuthCallbackRoute '/auth/sign-in': typeof AuthSignInRoute '/c/$chatId': typeof CChatIdRoute '/openrouter/callback': typeof OpenrouterCallbackRoute + '/api/workflow/cleanup': typeof ApiWorkflowCleanupRoute + '/api/workflow/delete-account': typeof ApiWorkflowDeleteAccountRoute + '/api/workflow/export-chat': typeof ApiWorkflowExportChatRoute + '/api/workflow/generate-title': typeof ApiWorkflowGenerateTitleRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -118,13 +152,17 @@ export interface FileRoutesById { '/privacy': typeof PrivacyRoute '/settings': typeof SettingsRoute '/terms': typeof TermsRoute - '/api/chat': typeof ApiChatRoute + '/api/models': typeof ApiModelsRoute '/api/openrouter-key': typeof ApiOpenrouterKeyRoute '/api/typing': typeof ApiTypingRoute '/auth/callback': typeof AuthCallbackRoute '/auth/sign-in': typeof AuthSignInRoute '/c/$chatId': typeof CChatIdRoute '/openrouter/callback': typeof OpenrouterCallbackRoute + '/api/workflow/cleanup': typeof ApiWorkflowCleanupRoute + '/api/workflow/delete-account': typeof ApiWorkflowDeleteAccountRoute + '/api/workflow/export-chat': typeof ApiWorkflowExportChatRoute + '/api/workflow/generate-title': typeof ApiWorkflowGenerateTitleRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -134,13 +172,17 @@ export interface FileRouteTypes { | '/privacy' | '/settings' | '/terms' - | '/api/chat' + | '/api/models' | '/api/openrouter-key' | '/api/typing' | '/auth/callback' | '/auth/sign-in' | '/c/$chatId' | '/openrouter/callback' + | '/api/workflow/cleanup' + | '/api/workflow/delete-account' + | '/api/workflow/export-chat' + | '/api/workflow/generate-title' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -148,13 +190,17 @@ export interface FileRouteTypes { | '/privacy' | '/settings' | '/terms' - | '/api/chat' + | '/api/models' | '/api/openrouter-key' | '/api/typing' | '/auth/callback' | '/auth/sign-in' | '/c/$chatId' | '/openrouter/callback' + | '/api/workflow/cleanup' + | '/api/workflow/delete-account' + | '/api/workflow/export-chat' + | '/api/workflow/generate-title' id: | '__root__' | '/' @@ -162,13 +208,17 @@ export interface FileRouteTypes { | '/privacy' | '/settings' | '/terms' - | '/api/chat' + | '/api/models' | '/api/openrouter-key' | '/api/typing' | '/auth/callback' | '/auth/sign-in' | '/c/$chatId' | '/openrouter/callback' + | '/api/workflow/cleanup' + | '/api/workflow/delete-account' + | '/api/workflow/export-chat' + | '/api/workflow/generate-title' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -177,13 +227,17 @@ export interface RootRouteChildren { PrivacyRoute: typeof PrivacyRoute SettingsRoute: typeof SettingsRoute TermsRoute: typeof TermsRoute - ApiChatRoute: typeof ApiChatRoute + ApiModelsRoute: typeof ApiModelsRoute ApiOpenrouterKeyRoute: typeof ApiOpenrouterKeyRoute ApiTypingRoute: typeof ApiTypingRoute AuthCallbackRoute: typeof AuthCallbackRoute AuthSignInRoute: typeof AuthSignInRoute CChatIdRoute: typeof CChatIdRoute OpenrouterCallbackRoute: typeof OpenrouterCallbackRoute + ApiWorkflowCleanupRoute: typeof ApiWorkflowCleanupRoute + ApiWorkflowDeleteAccountRoute: typeof ApiWorkflowDeleteAccountRoute + ApiWorkflowExportChatRoute: typeof ApiWorkflowExportChatRoute + ApiWorkflowGenerateTitleRoute: typeof ApiWorkflowGenerateTitleRoute } declare module '@tanstack/react-router' { @@ -265,11 +319,39 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpenrouterKeyRouteImport parentRoute: typeof rootRouteImport } - '/api/chat': { - id: '/api/chat' - path: '/api/chat' - fullPath: '/api/chat' - preLoaderRoute: typeof ApiChatRouteImport + '/api/models': { + id: '/api/models' + path: '/api/models' + fullPath: '/api/models' + preLoaderRoute: typeof ApiModelsRouteImport + parentRoute: typeof rootRouteImport + } + '/api/workflow/generate-title': { + id: '/api/workflow/generate-title' + path: '/api/workflow/generate-title' + fullPath: '/api/workflow/generate-title' + preLoaderRoute: typeof ApiWorkflowGenerateTitleRouteImport + parentRoute: typeof rootRouteImport + } + '/api/workflow/export-chat': { + id: '/api/workflow/export-chat' + path: '/api/workflow/export-chat' + fullPath: '/api/workflow/export-chat' + preLoaderRoute: typeof ApiWorkflowExportChatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/workflow/delete-account': { + id: '/api/workflow/delete-account' + path: '/api/workflow/delete-account' + fullPath: '/api/workflow/delete-account' + preLoaderRoute: typeof ApiWorkflowDeleteAccountRouteImport + parentRoute: typeof rootRouteImport + } + '/api/workflow/cleanup': { + id: '/api/workflow/cleanup' + path: '/api/workflow/cleanup' + fullPath: '/api/workflow/cleanup' + preLoaderRoute: typeof ApiWorkflowCleanupRouteImport parentRoute: typeof rootRouteImport } } @@ -281,13 +363,17 @@ const rootRouteChildren: RootRouteChildren = { PrivacyRoute: PrivacyRoute, SettingsRoute: SettingsRoute, TermsRoute: TermsRoute, - ApiChatRoute: ApiChatRoute, + ApiModelsRoute: ApiModelsRoute, ApiOpenrouterKeyRoute: ApiOpenrouterKeyRoute, ApiTypingRoute: ApiTypingRoute, AuthCallbackRoute: AuthCallbackRoute, AuthSignInRoute: AuthSignInRoute, CChatIdRoute: CChatIdRoute, OpenrouterCallbackRoute: OpenrouterCallbackRoute, + ApiWorkflowCleanupRoute: ApiWorkflowCleanupRoute, + ApiWorkflowDeleteAccountRoute: ApiWorkflowDeleteAccountRoute, + ApiWorkflowExportChatRoute: ApiWorkflowExportChatRoute, + ApiWorkflowGenerateTitleRoute: ApiWorkflowGenerateTitleRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/api/chat.ts b/apps/web/src/routes/api/chat.ts deleted file mode 100644 index 6d1324e7..00000000 --- a/apps/web/src/routes/api/chat.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { json } from "@tanstack/react-start"; -import { - convertToModelMessages, - generateId, - stepCountIs, - streamText, -} from "ai"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; -import { webSearch } from "@valyu/ai-sdk"; -import { api } from "@server/convex/_generated/api"; -import type { Id } from "@server/convex/_generated/dataModel"; -import { getRedisClient, redis } from "@/lib/redis"; -import { decryptSecret } from "@/lib/server-crypto"; -import { getAuthUser, getConvexClientForRequest, getConvexUserId, isSameOrigin } from "@/lib/server-auth"; - -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; -const VALYU_API_KEY = process.env.VALYU_API_KEY; -const CHAT_RATE_LIMIT_WINDOW_SECONDS = 60; -const CHAT_RATE_LIMIT_MAX_USER = 30; -const CHAT_RATE_LIMIT_MAX_IP = 60; - -/** - * Controls whether to trust proxy-forwarded headers for client IP extraction. - * Set to "cloudflare" to trust cf-connecting-ip only - * Set to "true" or "1" to trust x-forwarded-for and x-real-ip - * Leave unset or empty to not trust any forwarding headers (direct requests only) - */ -const TRUST_PROXY_FORWARDED = process.env.TRUST_PROXY_FORWARDED; - -/** - * Extracts the client IP address from the request. - * - * SECURITY: This function only honors proxy headers when explicitly configured - * via TRUST_PROXY_FORWARDED env var. This prevents attackers from spoofing - * their IP by setting x-forwarded-for headers on direct requests. - * - * @param request - The incoming HTTP request - * @returns The client IP address or null if it cannot be determined - */ -function getRequestIp(request: Request): string | null { - // Only trust Cloudflare headers when explicitly configured - if (TRUST_PROXY_FORWARDED?.toLowerCase() === "cloudflare") { - const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); - if (cfConnectingIp) { - return cfConnectingIp; - } - // Fall through - no trusted IP available - return null; - } - - // Only trust standard proxy headers when explicitly enabled - if (TRUST_PROXY_FORWARDED?.toLowerCase() === "true" || TRUST_PROXY_FORWARDED === "1") { - // x-forwarded-for may contain a comma-separated list of IPs - // The first IP is the original client, subsequent ones are proxies - const forwardedFor = request.headers.get("x-forwarded-for"); - if (forwardedFor) { - const firstIp = forwardedFor.split(",")[0]?.trim(); - if (firstIp) { - return firstIp; - } - } - - // x-real-ip is typically set by reverse proxies (nginx, etc.) - const realIp = request.headers.get("x-real-ip")?.trim(); - if (realIp) { - return realIp; - } - - // true-client-ip is used by some CDNs (Akamai, etc.) - const trueClientIp = request.headers.get("true-client-ip")?.trim(); - if (trueClientIp) { - return trueClientIp; - } - } - - // No trusted forwarding headers configured or available - // In production behind a proxy, this means IP-based rate limiting will be ineffective - // but user-based rate limiting (enforced first) will still protect against abuse - return null; -} - -/** - * Enforces rate limiting on chat requests. - * - * Rate limiting strategy (defense in depth): - * 1. USER-BASED (primary): Limits requests per authenticated user - cannot be spoofed - * 2. IP-BASED (secondary): Additional layer when trusted proxy is configured - * - * The user-based limit is always enforced first and is the primary protection. - * IP-based limiting is only effective when TRUST_PROXY_FORWARDED is properly - * configured for your deployment environment (e.g., "cloudflare" or "true"). - * - * @param request - The incoming HTTP request - * @param userId - The authenticated user's ID (from session/token, not spoofable) - * @returns 429 response if rate limited, null otherwise - */ -async function enforceRateLimit(request: Request, userId: string): Promise { - const redisReady = await redis.ensureConnected(); - if (!redisReady) return null; - const client = getRedisClient(); - if (!client) return null; - - const ip = getRequestIp(request); - const userKey = `rate:chat:user:${userId}`; - const userCount = await client.incr(userKey); - if (userCount === 1) { - await client.expire(userKey, CHAT_RATE_LIMIT_WINDOW_SECONDS); - } - if (userCount > CHAT_RATE_LIMIT_MAX_USER) { - return json( - { error: "Rate limit exceeded. Please try again shortly." }, - { status: 429 }, - ); - } - - if (ip) { - const ipKey = `rate:chat:ip:${ip}`; - const ipCount = await client.incr(ipKey); - if (ipCount === 1) { - await client.expire(ipKey, CHAT_RATE_LIMIT_WINDOW_SECONDS); - } - if (ipCount > CHAT_RATE_LIMIT_MAX_IP) { - return json( - { error: "Rate limit exceeded. Please try again shortly." }, - { status: 429 }, - ); - } - } - - return null; -} - - -export const Route = createFileRoute("/api/chat")({ - server: { - handlers: { - GET: async ({ request }) => { - if (!isSameOrigin(request)) { - return json({ error: "Invalid origin" }, { status: 403 }); - } - const authUser = await getAuthUser(request); - if (!authUser) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - const userId = await getConvexUserId(authUser, request); - if (!userId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - - const url = new URL(request.url); - const chatId = url.searchParams.get("chatId"); - const lastId = url.searchParams.get("lastId") || "0"; - - if (!chatId) { - return json({ error: "chatId is required" }, { status: 400 }); - } - - const redisReady = await redis.ensureConnected(); - if (!redisReady) { - console.log("[Chat API GET] Redis not available"); - return json({ error: "Redis not configured" }, { status: 503 }); - } - - console.log("[Chat API GET] Reading stream for chat:", chatId); - const meta = await redis.stream.getMeta(chatId); - if (!meta) { - console.log("[Chat API GET] No stream metadata found"); - return new Response(null, { status: 204 }); - } - if (meta.userId !== userId) { - return json({ error: "Unauthorized" }, { status: 403 }); - } - console.log("[Chat API GET] Stream meta:", meta.status); - - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - let currentLastId = lastId; - let isComplete = false; - - while (!isComplete) { - const tokens = await redis.stream.read(chatId, currentLastId); - - for (const token of tokens) { - if (token.type === "done") { - isComplete = true; - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: "done" })}\n\n`), - ); - break; - } - - if (token.type === "error") { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: "error", text: token.text })}\n\n`, - ), - ); - isComplete = true; - break; - } - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: token.type, text: token.text, id: token.id })}\n\n`, - ), - ); - currentLastId = token.id; - } - - if (!isComplete && tokens.length === 0) { - const currentMeta = await redis.stream.getMeta(chatId); - if (currentMeta?.status !== "streaming") { - isComplete = true; - } else { - await new Promise((r) => setTimeout(r, 50)); - } - } - } - - controller.close(); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); - }, - - POST: async ({ request }) => { - const abortSignal = request.signal; - if (!isSameOrigin(request)) { - return json({ error: "Invalid origin" }, { status: 403 }); - } - const authUser = await getAuthUser(request); - if (!authUser) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - const convexUserId = await getConvexUserId(authUser, request); - if (!convexUserId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - - const rateLimitResponse = await enforceRateLimit(request, convexUserId); - if (rateLimitResponse) return rateLimitResponse; - const redisReady = await redis.ensureConnected(); - console.log("[Chat API POST] Redis ready:", redisReady); - - try { - const body = await request.json(); - const { - messages, - model, - provider = "osschat", - enableWebSearch = false, - reasoningEffort, - maxSteps = 5, - chatId, - } = body; - - if (!messages || !Array.isArray(messages)) { - return json({ error: "messages is required and must be an array" }, { status: 400 }); - } - - if (!model || typeof model !== "string") { - return json({ error: "model is required and must be a string" }, { status: 400 }); - } - - if (provider === "osschat" && !OPENROUTER_API_KEY) { - return json( - { error: "OSSChat Cloud is not configured on this server" }, - { status: 500 }, - ); - } - - for (const message of messages) { - if (!message.role) { - return json({ error: "Each message must have a role property" }, { status: 400 }); - } - if (!["user", "assistant", "system"].includes(message.role)) { - return json( - { error: "Message role must be one of: user, assistant, system" }, - { status: 400 }, - ); - } - if (!message.parts && !message.content) { - return json({ error: "Each message must have parts or content" }, { status: 400 }); - } - } - - let openrouterKey: string | null = null; - if (provider === "osschat") { - openrouterKey = OPENROUTER_API_KEY ?? null; - } else { - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - const encryptedKey = await convexClient.query(api.users.getOpenRouterKey, { - userId: convexUserId, - }); - openrouterKey = encryptedKey ? decryptSecret(encryptedKey) : null; - } - if (!openrouterKey) { - return json({ error: "OpenRouter API key not configured" }, { status: 400 }); - } - const openRouter = createOpenRouter({ apiKey: openrouterKey }); - const aiModel = openRouter(model); - - const modelMessages = await convertToModelMessages(messages); - - const streamOptions: Parameters[0] = { - model: aiModel as Parameters[0]["model"], - messages: modelMessages, - abortSignal, - }; - - if (enableWebSearch && VALYU_API_KEY) { - streamOptions.tools = { - webSearch: webSearch({ apiKey: VALYU_API_KEY }), - } as any; - } - - const hasTools = enableWebSearch && VALYU_API_KEY; - const stepLimit = hasTools ? Math.max(1, Math.min(10, maxSteps)) : 1; - streamOptions.stopWhen = stepCountIs(stepLimit); - - if (reasoningEffort && reasoningEffort !== "none") { - const effortValue = reasoningEffort as "low" | "medium" | "high"; - streamOptions.providerOptions = { - ...streamOptions.providerOptions, - openrouter: { - reasoning: { - effort: effortValue, - }, - }, - }; - } - - const messageId = generateId(); - const streamId = `${chatId}-${messageId}`; - - if (chatId && redisReady) { - console.log("[Chat API POST] Initializing Redis stream for chat:", chatId); - await redis.stream.init(chatId, convexUserId, messageId); - } - - if (chatId) { - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { - console.error("[Chat API] Convex auth token unavailable"); - } else { - try { - console.log("[Chat API] Setting active stream:", streamId); - await convexClient.mutation(api.chats.setActiveStream, { - chatId: chatId as Id<"chats">, - userId: convexUserId, - streamId, - }); - console.log("[Chat API] Active stream set successfully"); - } catch (err) { - console.error("[Chat API] Failed to set active stream:", err); - } - } - } - - const result = streamText(streamOptions); - - const encoder = new TextEncoder(); - let fullContent = ""; - let fullReasoning = ""; - - const stream = new ReadableStream({ - async start(controller) { - const markStreamInterrupted = async () => { - if (chatId && redisReady) { - await redis.stream.complete(chatId); - } - }; - - try { - for await (const part of result.fullStream) { - if (abortSignal.aborted) { - console.log("[Chat API POST] Client disconnected, marking stream interrupted"); - await markStreamInterrupted(); - controller.close(); - return; - } - - if (part.type === "text-delta") { - const text = part.text; - fullContent += text; - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: "text", text })}\n\n`, - ), - ); - - if (chatId && redisReady) { - await redis.stream.append(chatId, text, "text"); - } - } else if (part.type === "reasoning-delta") { - const text = (part as { type: "reasoning-delta"; text: string }).text; - fullReasoning += text; - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: "reasoning", text })}\n\n`, - ), - ); - - if (chatId && redisReady) { - await redis.stream.append(chatId, text, "reasoning"); - } - } - } - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - type: "done", - content: fullContent, - reasoning: fullReasoning, - messageId, - })}\n\n`, - ), - ); - - if (chatId && redisReady) { - await redis.stream.complete(chatId); - console.log("[Chat API POST] Redis stream completed"); - } - - if (chatId) { - const convexClient = await getConvexClientForRequest(request); - if (convexClient) { - await convexClient.mutation(api.chats.setActiveStream, { - chatId: chatId as Id<"chats">, - userId: convexUserId, - streamId: null, - }); - } - } - - controller.close(); - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - console.log("[Chat API POST] Stream aborted, marking stream interrupted"); - await markStreamInterrupted(); - controller.close(); - return; - } - - const errorMessage = "Stream failed"; - console.error("[Chat API] Stream error", err); - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: "error", text: errorMessage })}\n\n`, - ), - ); - - if (chatId && redisReady) { - await redis.stream.error(chatId, errorMessage); - } - - controller.close(); - } - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "X-Message-Id": messageId, - }, - }); - } catch (error) { - console.error("[Chat API Error]", error); - - if (error instanceof SyntaxError) { - return json({ error: "Invalid JSON in request body" }, { status: 400 }); - } - - if (error instanceof Error) { - if (error.message.includes("401") || error.message.includes("Unauthorized")) { - return json( - { error: "Invalid API key. Please check your OpenRouter API key." }, - { status: 401 }, - ); - } - - if (error.message.includes("429") || error.message.includes("rate limit")) { - return json( - { error: "Rate limit exceeded. Please try again later." }, - { status: 429 }, - ); - } - - if (error.message.includes("model") || error.message.includes("Model")) { - return json({ error: "Model error. Please try a different model." }, { status: 400 }); - } - } - - return json({ error: "Request failed. Please try again." }, { status: 500 }); - } - }, - }, - }, -}); diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts new file mode 100644 index 00000000..9246b7f1 --- /dev/null +++ b/apps/web/src/routes/api/models.ts @@ -0,0 +1,73 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { upstashRedis } from "@/lib/upstash"; + +const MODELS_CACHE_KEY = "openchat:models"; +const MODELS_CACHE_TTL_SECONDS = 60 * 60 * 4; +const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; + +async function fetchModelsFromOpenRouter(): Promise { + const response = await fetch(OPENROUTER_MODELS_URL, { + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + return json( + { error: `OpenRouter API error: ${response.status}` }, + { status: response.status }, + ); + } + + const payload = await response.text(); + + if (upstashRedis) { + try { + await upstashRedis.set(MODELS_CACHE_KEY, payload, { + ex: MODELS_CACHE_TTL_SECONDS, + }); + } catch (error) { + console.warn("[Models API] Failed to write cache:", error); + } + } + + return new Response(payload, { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "X-Models-Cache": "MISS", + }, + }); +} + +export const Route = createFileRoute("/api/models")({ + server: { + handlers: { + GET: async () => { + if (!upstashRedis) { + return fetchModelsFromOpenRouter(); + } + + try { + const cached = await upstashRedis.get(MODELS_CACHE_KEY); + if (cached) { + return new Response(cached, { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "X-Models-Cache": "HIT", + }, + }); + } + } catch (error) { + console.warn("[Models API] Failed to read cache:", error); + } + + return fetchModelsFromOpenRouter(); + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/openrouter-key.ts b/apps/web/src/routes/api/openrouter-key.ts index d8f568d9..cd191461 100644 --- a/apps/web/src/routes/api/openrouter-key.ts +++ b/apps/web/src/routes/api/openrouter-key.ts @@ -3,6 +3,7 @@ import { json } from "@tanstack/react-start"; import { api } from "@server/convex/_generated/api"; import { encryptSecret } from "@/lib/server-crypto"; import { getAuthUser, getConvexClientForRequest, getConvexUserId, getConvexUserIdReadOnly, isSameOrigin } from "@/lib/server-auth"; +import { authRatelimit } from "@/lib/upstash"; export const Route = createFileRoute("/api/openrouter-key")({ server: { @@ -60,13 +61,22 @@ export const Route = createFileRoute("/api/openrouter-key")({ return json({ error: "apiKey is required" }, { status: 400 }); } - const convexUserId = await getConvexUserId(authUser, request); - if (!convexUserId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { + const convexUserId = await getConvexUserId(authUser, request); + if (!convexUserId) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (authRatelimit) { + const rate = await authRatelimit.limit(`openrouter-key:post:${convexUserId}`); + if (!rate.success) { + return json( + { error: "Too many key update attempts. Please try again shortly." }, + { status: 429 }, + ); + } + } + + const convexClient = await getConvexClientForRequest(request); + if (!convexClient) { return json({ error: "Unauthorized" }, { status: 401 }); } @@ -93,13 +103,22 @@ export const Route = createFileRoute("/api/openrouter-key")({ return json({ error: "Unauthorized" }, { status: 401 }); } - const convexUserId = await getConvexUserId(authUser, request); - if (!convexUserId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { + const convexUserId = await getConvexUserId(authUser, request); + if (!convexUserId) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (authRatelimit) { + const rate = await authRatelimit.limit(`openrouter-key:delete:${convexUserId}`); + if (!rate.success) { + return json( + { error: "Too many key update attempts. Please try again shortly." }, + { status: 429 }, + ); + } + } + + const convexClient = await getConvexClientForRequest(request); + if (!convexClient) { return json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts new file mode 100644 index 00000000..f484d1b6 --- /dev/null +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -0,0 +1,163 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { serve } from "@upstash/workflow/tanstack"; +import { api } from "@server/convex/_generated/api"; +import { createConvexServerClient } from "@/lib/convex-server"; +import { workflowClient } from "@/lib/upstash"; + +type CleanupPayload = { + retentionDays?: number; + batchSize?: number; +}; + +function isLocalWorkflowRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +async function runCleanupInline(payload: CleanupPayload): Promise<{ + success: boolean; + batches: number; + totalDeleted: number; +}> { + const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN; + if (!workflowToken) { + throw new Error("WORKFLOW_CLEANUP_TOKEN is not configured"); + } + + const convexClient = createConvexServerClient(); + const retentionDays = payload.retentionDays ?? 90; + const batchSize = payload.batchSize ?? 100; + + let batches = 0; + let totalDeleted = 0; + + while (true) { + batches += 1; + const preview = await convexClient.action(api.crons.runCleanupBatchForWorkflow, { + workflowToken, + retentionDays, + batchSize, + dryRun: true, + }); + if (preview.deleted <= 0) { + break; + } + + const deletedBatch = await convexClient.action(api.crons.runCleanupBatchForWorkflow, { + workflowToken, + retentionDays, + batchSize, + dryRun: false, + }); + totalDeleted += deletedBatch.deleted; + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + return { + success: true, + batches, + totalDeleted, + }; +} + +const workflow = serve(async (context) => { + const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN; + if (!workflowToken) { + throw new Error("WORKFLOW_CLEANUP_TOKEN is not configured"); + } + + const convexClient = createConvexServerClient(); + const retentionDays = context.requestPayload.retentionDays ?? 90; + const batchSize = context.requestPayload.batchSize ?? 100; + + let batches = 0; + let totalDeleted = 0; + + while (true) { + batches += 1; + const preview = await context.run(`query-batch-${batches}`, async () => { + return convexClient.action(api.crons.runCleanupBatchForWorkflow, { + workflowToken, + retentionDays, + batchSize, + dryRun: true, + }); + }); + + if (preview.deleted <= 0) { + break; + } + + const deletedBatch = await context.run(`delete-batch-${batches}`, async () => { + return convexClient.action(api.crons.runCleanupBatchForWorkflow, { + workflowToken, + retentionDays, + batchSize, + dryRun: false, + }); + }); + + totalDeleted += deletedBatch.deleted; + await context.sleep(`sleep-${batches}`, "1s"); + } + + return context.run("log-completion", async () => { + return { + success: true, + batches, + totalDeleted, + }; + }); +}); + +export const Route = createFileRoute("/api/workflow/cleanup")({ + server: { + handlers: { + POST: async ({ request }) => { + const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); + if (isWorkflowCallback) { + return workflow.POST({ request }); + } + + let payload: CleanupPayload; + try { + payload = (await request.json()) as CleanupPayload; + } catch { + return json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + if (isLocalWorkflowRequest(request)) { + try { + const result = await runCleanupInline(payload); + return json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Cleanup failed"; + return json({ error: message }, { status: 500 }); + } + } + + if (!workflowClient) { + return json( + { error: "Workflow queue is not configured (missing QSTASH_TOKEN)" }, + { status: 500 }, + ); + } + + try { + const { workflowRunId } = await workflowClient.trigger({ + url: request.url, + body: payload, + headers: { + "Content-Type": "application/json", + }, + }); + return json({ queued: true, workflowRunId }, { status: 202 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to queue workflow"; + return json({ error: message }, { status: 500 }); + } + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts new file mode 100644 index 00000000..b5caf306 --- /dev/null +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -0,0 +1,263 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { serve } from "@upstash/workflow/tanstack"; +import { api } from "@server/convex/_generated/api"; +import type { Id } from "@server/convex/_generated/dataModel"; +import { createConvexServerClient } from "@/lib/convex-server"; +import { upstashRedis, workflowClient } from "@/lib/upstash"; + +const CONVEX_SITE_URL = + process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; + +type DeleteAccountPayload = { + userId: string; + externalId: string; + batchSize?: number; +}; + +type DeleteStep = + | "delete-stream-jobs" + | "delete-messages" + | "delete-chats" + | "delete-files"; + +async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { + if (!CONVEX_SITE_URL) return null; + + const cookie = headers.get("cookie"); + if (!cookie) return null; + + const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { + headers: { cookie }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { token?: string } | null; + return data?.token ?? null; +} + +async function clearRedisUserKeys(userId: string): Promise { + if (!upstashRedis) return 0; + + const directKeys = [ + `user:${userId}:unread`, + `presence:${userId}`, + `rate:chat:user:${userId}`, + ]; + const patternKeys = [ + `chat:*:typing:${userId}`, + `openchat:usage:${userId}:*`, + `ratelimit:*:${userId}`, + ]; + + let deleted = 0; + for (const key of directKeys) { + deleted += await upstashRedis.del(key); + } + + for (const pattern of patternKeys) { + let cursor: string | number = "0"; + do { + const [nextCursor, keys]: [string, Array] = await upstashRedis.scan(cursor, { + match: pattern, + count: 100, + }); + cursor = nextCursor; + if (keys.length > 0) { + deleted += await upstashRedis.del(...keys); + } + } while (String(cursor) !== "0"); + } + + return deleted; +} + +function isLocalWorkflowRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function getWorkflowTriggerHeaders(headers: Headers): Record { + const triggerHeaders: Record = { + "Content-Type": "application/json", + }; + const cookie = headers.get("cookie"); + if (cookie) { + triggerHeaders.cookie = cookie; + } + return triggerHeaders; +} + +async function runDeleteAccountInline( + payload: DeleteAccountPayload, + headers: Headers, +): Promise<{ + success: boolean; + deleted: { streamJobs: number; messages: number; chats: number; files: number; redisKeys: number }; +}> { + const { userId, externalId, batchSize } = payload; + const authToken = await getAuthTokenFromWorkflowHeaders(headers); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const convexUserId = userId as Id<"users">; + + const runBatchStep = async (step: DeleteStep) => { + let totalDeleted = 0; + + while (true) { + const result = await convexClient.action(api.users.deleteAccountWorkflowStep, { + userId: convexUserId, + externalId, + step, + batchSize, + }); + + totalDeleted += result.deleted; + if (!result.hasMore) { + break; + } + } + + return totalDeleted; + }; + + const deletedStreamJobs = await runBatchStep("delete-stream-jobs"); + const deletedMessages = await runBatchStep("delete-messages"); + const deletedChats = await runBatchStep("delete-chats"); + const deletedFiles = await runBatchStep("delete-files"); + const deleteUserResult = await convexClient.action(api.users.deleteAccountWorkflowStep, { + userId: convexUserId, + externalId, + step: "delete-user", + }); + const deletedRedisKeys = await clearRedisUserKeys(userId); + + return { + success: deleteUserResult.success === true, + deleted: { + streamJobs: deletedStreamJobs, + messages: deletedMessages, + chats: deletedChats, + files: deletedFiles, + redisKeys: deletedRedisKeys, + }, + }; +} + +const workflow = serve(async (context) => { + const { userId, externalId, batchSize } = context.requestPayload; + const authToken = await context.run("resolve-auth", async () => { + return getAuthTokenFromWorkflowHeaders(context.headers); + }); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const convexUserId = userId as Id<"users">; + + const runBatchStep = async (step: DeleteStep, stepName: string) => { + let totalDeleted = 0; + let iteration = 0; + + while (true) { + iteration += 1; + const result = await context.run(`${stepName}-${iteration}`, async () => { + return convexClient.action(api.users.deleteAccountWorkflowStep, { + userId: convexUserId, + externalId, + step, + batchSize, + }); + }); + + totalDeleted += result.deleted; + if (!result.hasMore) { + break; + } + } + + return totalDeleted; + }; + + const deletedStreamJobs = await runBatchStep("delete-stream-jobs", "delete-stream-jobs"); + const deletedMessages = await runBatchStep("delete-messages", "delete-messages"); + const deletedChats = await runBatchStep("delete-chats", "delete-chats"); + const deletedFiles = await runBatchStep("delete-files", "delete-files"); + + const deleteUserResult = await context.run("delete-user", async () => { + return convexClient.action(api.users.deleteAccountWorkflowStep, { + userId: convexUserId, + externalId, + step: "delete-user", + }); + }); + + const deletedRedisKeys = await context.run("cleanup-redis", async () => { + return clearRedisUserKeys(userId); + }); + + return { + success: deleteUserResult.success === true, + deleted: { + streamJobs: deletedStreamJobs, + messages: deletedMessages, + chats: deletedChats, + files: deletedFiles, + redisKeys: deletedRedisKeys, + }, + }; +}); + +export const Route = createFileRoute("/api/workflow/delete-account")({ + server: { + handlers: { + POST: async ({ request }) => { + const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); + if (isWorkflowCallback) { + return workflow.POST({ request }); + } + + let payload: DeleteAccountPayload; + try { + payload = (await request.json()) as DeleteAccountPayload; + } catch { + return json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + if (isLocalWorkflowRequest(request)) { + try { + const result = await runDeleteAccountInline(payload, request.headers); + return json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete account"; + const status = message === "Unauthorized" ? 401 : 500; + return json({ error: message }, { status }); + } + } + + if (!workflowClient) { + return json( + { error: "Workflow queue is not configured (missing QSTASH_TOKEN)" }, + { status: 500 }, + ); + } + + try { + const { workflowRunId } = await workflowClient.trigger({ + url: request.url, + body: payload, + headers: getWorkflowTriggerHeaders(request.headers), + }); + return json({ queued: true, workflowRunId }, { status: 202 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to queue workflow"; + return json({ error: message }, { status: 500 }); + } + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts new file mode 100644 index 00000000..61a3cf17 --- /dev/null +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -0,0 +1,235 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { serve } from "@upstash/workflow/tanstack"; +import { api } from "@server/convex/_generated/api"; +import type { Id } from "@server/convex/_generated/dataModel"; +import { createConvexServerClient } from "@/lib/convex-server"; +import { workflowClient } from "@/lib/upstash"; + +const CONVEX_SITE_URL = + process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; + +type ExportFormat = "markdown" | "json"; +type ExportChatPayload = { + chatId: string; + userId: string; + format?: ExportFormat; +}; + +type ChatExportData = { + chat: { + _id: string; + title: string; + createdAt: number; + updatedAt: number; + }; + messages: Array<{ + _id: string; + role: string; + content: string; + createdAt: number; + reasoning?: string; + attachments?: Array<{ + filename: string; + contentType: string; + size: number; + uploadedAt: number; + url?: string; + }>; + }>; +}; + +async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { + if (!CONVEX_SITE_URL) return null; + + const cookie = headers.get("cookie"); + if (!cookie) return null; + + const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { + headers: { cookie }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { token?: string } | null; + return data?.token ?? null; +} + +function isLocalWorkflowRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function getWorkflowTriggerHeaders(headers: Headers): Record { + const triggerHeaders: Record = { + "Content-Type": "application/json", + }; + const cookie = headers.get("cookie"); + if (cookie) { + triggerHeaders.cookie = cookie; + } + return triggerHeaders; +} + +function formatExportMarkdown(data: ChatExportData): string { + const lines: string[] = []; + lines.push(`# ${data.chat.title}`); + lines.push(""); + lines.push(`- Chat ID: ${data.chat._id}`); + lines.push(`- Created: ${new Date(data.chat.createdAt).toISOString()}`); + lines.push(`- Updated: ${new Date(data.chat.updatedAt).toISOString()}`); + lines.push(""); + lines.push("---"); + lines.push(""); + + for (const message of data.messages) { + const role = message.role === "assistant" ? "Assistant" : "User"; + lines.push(`## ${role} (${new Date(message.createdAt).toISOString()})`); + lines.push(""); + lines.push(message.content || "_No content_"); + lines.push(""); + + if (message.reasoning) { + lines.push("### Reasoning"); + lines.push(""); + lines.push(message.reasoning); + lines.push(""); + } + + if (message.attachments && message.attachments.length > 0) { + lines.push("### Attachments"); + for (const attachment of message.attachments) { + lines.push(`- ${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`); + } + lines.push(""); + } + } + + return lines.join("\n"); +} + +async function runExportChatInline( + payload: ExportChatPayload, + headers: Headers, +): Promise<{ downloadUrl: string; byteLength: number; fileName: string }> { + const { chatId, userId, format = "markdown" } = payload; + const authToken = await getAuthTokenFromWorkflowHeaders(headers); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const chatExportData = await convexClient.query(api.chats.getChatExportData, { + chatId: chatId as Id<"chats">, + userId: userId as Id<"users">, + }); + if (!chatExportData) { + throw new Error("Chat not found"); + } + + const formattedExport = + format === "json" + ? JSON.stringify(chatExportData, null, 2) + : formatExportMarkdown(chatExportData); + const mimeType = format === "json" ? "application/json" : "text/markdown"; + const base64 = Buffer.from(formattedExport, "utf8").toString("base64"); + + return { + downloadUrl: `data:${mimeType};base64,${base64}`, + byteLength: Buffer.byteLength(formattedExport, "utf8"), + fileName: `chat-export-${chatId}.${format === "json" ? "json" : "md"}`, + }; +} + +const workflow = serve(async (context) => { + const { chatId, userId, format = "markdown" } = context.requestPayload; + + const authToken = await context.run("resolve-auth", async () => { + return getAuthTokenFromWorkflowHeaders(context.headers); + }); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const chatExportData = await context.run("gather-messages", async () => { + return convexClient.query(api.chats.getChatExportData, { + chatId: chatId as Id<"chats">, + userId: userId as Id<"users">, + }); + }); + if (!chatExportData) { + throw new Error("Chat not found"); + } + + const formattedExport = await context.run("format-export", async () => { + if (format === "json") { + return JSON.stringify(chatExportData, null, 2); + } + return formatExportMarkdown(chatExportData); + }); + + const uploadResult = await context.run("upload", async () => { + const mimeType = format === "json" ? "application/json" : "text/markdown"; + const base64 = Buffer.from(formattedExport, "utf8").toString("base64"); + return { + downloadUrl: `data:${mimeType};base64,${base64}`, + byteLength: Buffer.byteLength(formattedExport, "utf8"), + fileName: `chat-export-${chatId}.${format === "json" ? "json" : "md"}`, + }; + }); + + return context.run("notify", async () => { + return uploadResult; + }); +}); + +export const Route = createFileRoute("/api/workflow/export-chat")({ + server: { + handlers: { + POST: async ({ request }) => { + const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); + if (isWorkflowCallback) { + return workflow.POST({ request }); + } + + let payload: ExportChatPayload; + try { + payload = (await request.json()) as ExportChatPayload; + } catch { + return json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + if (isLocalWorkflowRequest(request)) { + try { + const result = await runExportChatInline(payload, request.headers); + return json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to export chat"; + const status = + message === "Unauthorized" ? 401 : message === "Chat not found" ? 404 : 500; + return json({ error: message }, { status }); + } + } + + if (!workflowClient) { + return json( + { error: "Workflow queue is not configured (missing QSTASH_TOKEN)" }, + { status: 500 }, + ); + } + + try { + const { workflowRunId } = await workflowClient.trigger({ + url: request.url, + body: payload, + headers: getWorkflowTriggerHeaders(request.headers), + }); + return json({ queued: true, workflowRunId }, { status: 202 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to queue workflow"; + return json({ error: message }, { status: 500 }); + } + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts new file mode 100644 index 00000000..c1aa6073 --- /dev/null +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -0,0 +1,300 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { serve } from "@upstash/workflow/tanstack"; +import { api } from "@server/convex/_generated/api"; +import type { Id } from "@server/convex/_generated/dataModel"; +import { createConvexServerClient } from "@/lib/convex-server"; +import { decryptSecret } from "@/lib/server-crypto"; +import { getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; +import { workflowClient } from "@/lib/upstash"; + +const CONVEX_SITE_URL = + process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; + +const TITLE_MODEL_ID = "google/gemini-2.5-flash-lite"; +const TITLE_MAX_LENGTH = 200; + +type TitleLength = "short" | "standard" | "long"; +type TitleProvider = "osschat" | "openrouter"; + +type GenerateTitlePayload = { + chatId: string; + userId: string; + seedText?: string; + length: TitleLength; + provider: TitleProvider; + mode?: "auto" | "manual"; +}; + +const TITLE_STYLE_PROMPTS: Record = { + short: "Use 2-4 words.", + standard: "Use 4-6 words.", + long: "Use 7-10 words.", +}; + +function sanitizeGeneratedTitle(input: string): string { + let title = input.trim(); + if ( + (title.startsWith("\"") && title.endsWith("\"")) || + (title.startsWith("'") && title.endsWith("'")) + ) { + title = title.slice(1, -1).trim(); + } + + title = title.replace(/\s+/g, " ").replace(/[.?!]+$/, "").trim(); + if (!title) return ""; + return title.slice(0, TITLE_MAX_LENGTH); +} + +async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { + if (!CONVEX_SITE_URL) return null; + + const cookie = headers.get("cookie"); + if (!cookie) return null; + + const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { + headers: { cookie }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { token?: string } | null; + return data?.token ?? null; +} + +function isLocalWorkflowRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function getWorkflowTriggerHeaders(headers: Headers): Record { + const triggerHeaders: Record = { + "Content-Type": "application/json", + }; + const cookie = headers.get("cookie"); + if (cookie) { + triggerHeaders.cookie = cookie; + } + return triggerHeaders; +} + +async function runGenerateTitleInline( + payload: GenerateTitlePayload, + authToken: string, +): Promise<{ saved: boolean; title?: string; reason?: string }> { + const { chatId, userId, seedText = "", length, provider, mode = "auto" } = payload; + const convexClient = createConvexServerClient(authToken); + const convexUserId = userId as Id<"users">; + const convexChatId = chatId as Id<"chats">; + + const candidate = seedText.trim().slice(0, 300); + const firstUserMessage = candidate + ? null + : await convexClient.query(api.messages.getFirstUserMessage, { + chatId: convexChatId, + userId: convexUserId, + }); + const normalizedSeed = candidate || firstUserMessage?.trim().slice(0, 300) || ""; + if (!normalizedSeed) { + return { saved: false, reason: "empty_seed" }; + } + + const generatedTitle = await convexClient.action(api.chats.generateTitle, { + userId: convexUserId, + seedText: normalizedSeed, + length, + provider, + }); + if (!generatedTitle) { + if (provider === "openrouter") { + const hasKey = await convexClient.query(api.users.hasOpenRouterKey, { + userId: convexUserId, + }); + if (!hasKey) { + return { saved: false, reason: "missing_openrouter_key" }; + } + } + return { saved: false, reason: "generation_failed" }; + } + + const sanitizedTitle = sanitizeGeneratedTitle(generatedTitle); + if (!sanitizedTitle) { + return { saved: false, reason: "empty_title" }; + } + + await convexClient.mutation(api.chats.setGeneratedTitle, { + chatId: convexChatId, + userId: convexUserId, + title: sanitizedTitle, + force: mode === "manual", + }); + + return { + saved: true, + title: sanitizedTitle, + }; +} + +const workflow = serve(async (context) => { + const { chatId, userId, seedText = "", length, provider, mode = "auto" } = context.requestPayload; + const authToken = await context.run("resolve-auth", async () => { + const forwardedToken = context.headers.get("x-convex-token"); + if (forwardedToken) return forwardedToken; + return getAuthTokenFromWorkflowHeaders(context.headers); + }); + if (!authToken) { + throw new Error("Unauthorized"); + } + + const convexClient = createConvexServerClient(authToken); + const convexUserId = userId as Id<"users">; + const convexChatId = chatId as Id<"chats">; + + const normalizedSeed = await context.run("get-messages", async () => { + const candidate = seedText.trim().slice(0, 300); + if (candidate) return candidate; + const firstUserMessage = await convexClient.query(api.messages.getFirstUserMessage, { + chatId: convexChatId, + userId: convexUserId, + }); + return firstUserMessage?.trim().slice(0, 300) ?? ""; + }); + if (!normalizedSeed) { + return { saved: false, reason: "empty_seed" } as const; + } + + const openRouterKey = await context.run("resolve-openrouter-key", async () => { + if (provider === "osschat") { + return process.env.OPENROUTER_API_KEY ?? null; + } + + const encryptedKey = await convexClient.query(api.users.getOpenRouterKey, { + userId: convexUserId, + }); + return encryptedKey ? decryptSecret(encryptedKey) : null; + }); + if (!openRouterKey) { + return { saved: false, reason: "missing_openrouter_key" } as const; + } + + const systemPrompt = [ + "Create a specific, useful chat title.", + "Return only the title in Title Case; no quotes, no trailing punctuation.", + "Focus on the core topic or task; avoid filler words like and, with, about.", + TITLE_STYLE_PROMPTS[length], + ].join(" "); + + const llmResponse = await context.call<{ + choices?: Array<{ message?: { content?: string } }>; + }>("call-llm", { + url: "https://openrouter.ai/api/v1/chat/completions", + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openRouterKey}`, + "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", + "X-Title": "OSSChat", + }, + body: JSON.stringify({ + model: TITLE_MODEL_ID, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: normalizedSeed }, + ], + temperature: 0.2, + max_tokens: 32, + }), + retries: 2, + timeout: "30s", + }); + if (llmResponse.status < 200 || llmResponse.status >= 300) { + return { + saved: false, + reason: `llm_status_${llmResponse.status}`, + } as const; + } + + const rawTitle = llmResponse.body?.choices?.[0]?.message?.content ?? ""; + const sanitizedTitle = sanitizeGeneratedTitle(rawTitle); + if (!sanitizedTitle) { + return { saved: false, reason: "empty_title" } as const; + } + + await context.run("save-title", async () => { + await convexClient.mutation(api.chats.setGeneratedTitle, { + chatId: convexChatId, + userId: convexUserId, + title: sanitizedTitle, + force: mode === "manual", + }); + }); + + return { + saved: true, + title: sanitizedTitle, + }; +}); + +export const Route = createFileRoute("/api/workflow/generate-title")({ + server: { + handlers: { + POST: async ({ request }) => { + const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); + if (isWorkflowCallback) { + return workflow.POST({ request }); + } + + if (!isSameOrigin(request)) { + return json({ error: "Invalid origin" }, { status: 403 }); + } + + let payload: GenerateTitlePayload; + try { + payload = (await request.json()) as GenerateTitlePayload; + } catch { + return json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + const authToken = await getConvexAuthToken(request); + if (!authToken) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + if (isLocalWorkflowRequest(request)) { + try { + const result = await runGenerateTitleInline(payload, authToken); + if (!result.saved) { + const status = result.reason === "missing_openrouter_key" ? 400 : 409; + return json(result, { status }); + } + return json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to generate title"; + const status = message === "Unauthorized" ? 401 : 500; + return json({ error: message }, { status }); + } + } + + if (!workflowClient) { + return json( + { error: "Workflow queue is not configured (missing QSTASH_TOKEN)" }, + { status: 500 }, + ); + } + + try { + const headers = getWorkflowTriggerHeaders(request.headers); + headers["x-convex-token"] = authToken; + const { workflowRunId } = await workflowClient.trigger({ + url: request.url, + body: payload, + headers, + }); + return json({ queued: true, workflowRunId }, { status: 202 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to queue workflow"; + return json({ error: message }, { status: 500 }); + } + }, + }, + }, +}); diff --git a/apps/web/src/stores/model.ts b/apps/web/src/stores/model.ts index d7a6aab2..8823e148 100644 --- a/apps/web/src/stores/model.ts +++ b/apps/web/src/stores/model.ts @@ -329,7 +329,7 @@ async function fetchAllModels(): Promise> { cache.promise = (async () => { try { - const response = await fetch("https://openrouter.ai/api/v1/models", { + const response = await fetch("/api/models", { headers: { "Content-Type": "application/json" }, }); diff --git a/bun.lock b/bun.lock index 0b601fd3..dfd82c82 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@vitest/coverage-v8": "^4.0.18", + "baseline-browser-mapping": "^2.9.19", "convex": "^1.31.7", "oxlint": "^1.43.0", "turbo": "^2.8.3", @@ -35,7 +36,6 @@ "name": "server", "dependencies": { "@convex-dev/better-auth": "^0.10.10", - "@convex-dev/persistent-text-streaming": "^0.3.0", "@convex-dev/rate-limiter": "^0.3.2", "@openrouter/ai-sdk-provider": "2.1.1", "@valyu/ai-sdk": "^1.0.3", @@ -63,6 +63,9 @@ "@tanstack/react-router-ssr-query": "^1.158.1", "@tanstack/react-start": "^1.158.3", "@tanstack/router-plugin": "^1.158.1", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.36.2", + "@upstash/workflow": "^1.1.0", "@valyu/ai-sdk": "^1.1.0", "@xyflow/react": "^12.10.0", "ai": "6.0.75", @@ -81,7 +84,6 @@ "posthog-js": "^1.342.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "redis": "^5.10.0", "resumable-stream": "^2.2.10", "server": "workspace:*", "shadcn": "^3.8.4", @@ -103,6 +105,7 @@ "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", + "baseline-browser-mapping": "^2.9.19", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "^5.9.3", @@ -115,6 +118,7 @@ }, "overrides": { "@peculiar/asn1-schema": "2.6.0", + "baseline-browser-mapping": "2.9.19", }, "packages": { "@1natsu/wait-element": ["@1natsu/wait-element@4.1.2", "", { "dependencies": { "defu": "^6.1.4", "many-keys-map": "^2.0.1" } }, "sha512-qWxSJD+Q5b8bKOvESFifvfZ92DuMsY+03SBNjTO34ipJLP6mZ9yK4bQz/vlh48aEQXoJfaZBqUwKL5BdI5iiWw=="], @@ -225,8 +229,6 @@ "@convex-dev/better-auth": ["@convex-dev/better-auth@0.10.10", "", { "dependencies": { "@better-auth/passkey": "1.4.9", "@better-fetch/fetch": "^1.1.18", "common-tags": "^1.8.2", "convex-helpers": "^0.1.95", "jose": "^6.1.0", "remeda": "^2.32.0", "semver": "^7.7.3", "type-fest": "^4.39.1", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "1.4.9", "convex": "^1.25.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-BpwQ2kph43O7hmtGQAJ+ie3KrjONp83659QDjKDdH+X8yIdGevgehaqS5GHB0iJo7zQTtvs687GnAeLZ4Xx3/w=="], - "@convex-dev/persistent-text-streaming": ["@convex-dev/persistent-text-streaming@0.3.0", "", { "peerDependencies": { "convex": "^1.24.8", "react": "~18.3.1 || ^19.0.0", "react-dom": "~18.3.1 || ^19.0.0" } }, "sha512-y7CteewFHrBKhVSoLxTMEwWPEmc/3J+BTJ+x+8pvh5DCUlwN80eWmfojpmGOQr7xSc6UC/c7DxlZXQPN7dVlKg=="], - "@convex-dev/rate-limiter": ["@convex-dev/rate-limiter@0.3.2", "", { "peerDependencies": { "convex": "^1.24.8", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-+oBPsBfFbzdxiF/9XaaTQmVnvDlvEfg/c69/v8LxTbw4VLuiflIKlfnPQL8OS0azXQQ11hcPWHmU8ytFmHKDXA=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], @@ -663,16 +665,6 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@redis/bloom": ["@redis/bloom@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A=="], - - "@redis/client": ["@redis/client@5.10.0", "", { "dependencies": { "cluster-key-slot": "1.1.2" } }, "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA=="], - - "@redis/json": ["@redis/json@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA=="], - - "@redis/search": ["@redis/search@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg=="], - - "@redis/time-series": ["@redis/time-series@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], @@ -901,6 +893,16 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="], + + "@upstash/qstash": ["@upstash/qstash@2.9.0-rc", "", { "dependencies": { "crypto-js": ">=4.2.0", "jose": "^5.2.3", "neverthrow": "^7.0.1" } }, "sha512-T2VGOuK/XOFe+kppsANdiaXxpqPvnXl8XWmZzAtqZKsHWkBXPYqAvr/Pek8TZEKeOLAgc7wrl4Rm0KNqec9Fww=="], + + "@upstash/ratelimit": ["@upstash/ratelimit@2.0.8", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w=="], + + "@upstash/redis": ["@upstash/redis@1.36.2", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-C0Yt8hc12vLaQYRG1fMci8iPrLtnTdbJG0HR5T8vKnvEP/1RdMMblsOJs5/jp0JXZJ1oSzMnQz4J9EVezNpI6A=="], + + "@upstash/workflow": ["@upstash/workflow@1.1.0", "", { "dependencies": { "@upstash/qstash": "2.9.0-rc" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-WM3VHEMtbCkg0aNeGmsnZgJ27lgCgqfp/ZrwBcPAfZTy9La4TNBhNu3Df5ugypxU4amVmXWNaHXKJtiy5wa1LQ=="], + "@valyu/ai-sdk": ["@valyu/ai-sdk@1.1.0", "", { "dependencies": { "dotenv": "^17.2.3", "zod": "^3.22.0" }, "peerDependencies": { "@ai-sdk/openai": "^2.0.65", "ai": "^5.0.83 || ^6.0.0" } }, "sha512-uZwf+W4CGnYLDqCPabyhjDQ/xJffU2jmRRkZWtDSqvULCClhZfP32kmKyFfqUl/0rIg9+kYYYGQsaWKHWYUjAA=="], "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], @@ -1003,7 +1005,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.8.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], "better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="], @@ -1103,8 +1105,6 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], - "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], @@ -1163,6 +1163,8 @@ "crossws": ["crossws@0.4.4", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg=="], + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], @@ -1839,6 +1841,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neverthrow": ["neverthrow@7.2.0", "", {}, "sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw=="], + "next": ["next@16.0.10", "", { "dependencies": { "@next/env": "16.0.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.10", "@next/swc-darwin-x64": "16.0.10", "@next/swc-linux-arm64-gnu": "16.0.10", "@next/swc-linux-arm64-musl": "16.0.10", "@next/swc-linux-x64-gnu": "16.0.10", "@next/swc-linux-x64-musl": "16.0.10", "@next/swc-win32-arm64-msvc": "16.0.10", "@next/swc-win32-x64-msvc": "16.0.10", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA=="], "nf3": ["nf3@0.3.10", "", {}, "sha512-UlqmHkZiHGgSkRj17yrOXEsSu5ECvtlJ3Xm1W5WsWrTKgu9m7OjrMZh9H/ME2LcWrTlMD0/vmmNVpyBG4yRdGg=="], @@ -2043,8 +2047,6 @@ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], - "redis": ["redis@5.10.0", "", { "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", "@redis/json": "5.10.0", "@redis/search": "5.10.0", "@redis/time-series": "5.10.0" } }, "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw=="], - "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], @@ -2343,6 +2345,8 @@ "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -2609,6 +2613,8 @@ "@ts-morph/common/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "@upstash/qstash/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "@valyu/ai-sdk/dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "@valyu/ai-sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/package.json b/package.json index d516593c..042ab835 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,21 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@vitest/coverage-v8": "^4.0.18", + "baseline-browser-mapping": "^2.9.19", "convex": "^1.31.7", "oxlint": "^1.43.0", "turbo": "^2.8.3", "vitest": "^4.0.18" }, "overrides": { - "@peculiar/asn1-schema": "2.6.0" + "@peculiar/asn1-schema": "2.6.0", + "baseline-browser-mapping": "2.9.19" }, "packageManager": "bun@1.3.5", "private": true, "scripts": { "check": "turbo lint", - "dev": "bun ./scripts/check-redis.ts && turbo -F web -F server dev", + "dev": "bun ./scripts/dev.ts", "build": "turbo -F server build", "build:web": "turbo -F web build", "deploy": "bun ./scripts/deploy.ts", diff --git a/scripts/check-redis.ts b/scripts/check-redis.ts index 411b99eb..0507eed5 100644 --- a/scripts/check-redis.ts +++ b/scripts/check-redis.ts @@ -1,42 +1,90 @@ -import { createClient } from "redis"; - -const url = process.env.REDIS_URL ?? "redis://localhost:6379"; - -async function withTimeout(promise: Promise, ms: number): Promise { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error("Redis connection timed out")); - }, ms); - promise - .then((value) => { - clearTimeout(timer); - resolve(value); - }) - .catch((err) => { - clearTimeout(timer); - reject(err); - }); - }); +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +function parseEnvFile(content: string): Record { + const parsed: Record = {}; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const equalsIndex = trimmed.indexOf("="); + if (equalsIndex <= 0) continue; + + const key = trimmed.slice(0, equalsIndex).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + + let value = trimmed.slice(equalsIndex + 1).trim(); + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + parsed[key] = value; + } + + return parsed; +} + +function loadLocalEnvDefaults(): void { + const envFiles = [ + join(process.cwd(), "apps/web/.env.local"), + join(process.cwd(), "apps/server/.env.local"), + join(process.cwd(), ".env.local"), + ]; + + for (const filePath of envFiles) { + if (!existsSync(filePath)) continue; + const parsed = parseEnvFile(readFileSync(filePath, "utf8")); + for (const [key, value] of Object.entries(parsed)) { + if (!process.env[key]) { + process.env[key] = value; + } + } + } } async function main() { - const client = createClient({ url }); + loadLocalEnvDefaults(); + + const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); + const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); + + if (!url || !token) { + process.stdout.write( + "[check-redis] UPSTASH_REDIS_REST_URL/TOKEN not set. Redis-backed features are disabled.\n", + ); + return; + } + try { - await withTimeout(client.connect(), 2000); - const pong = await withTimeout(client.ping(), 2000); - if (pong !== "PONG") { + const response = await fetch(`${url}/pipeline`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([["PING"]]), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`HTTP ${response.status} ${body}`); + } + + const payload = (await response.json()) as Array<{ result?: string }>; + const result = payload[0]?.result; + if (result !== "PONG") { throw new Error("Redis ping failed"); } - process.env.REDIS_URL = url; - await client.quit(); } catch (error) { - try { - await client.quit(); - } catch { - } const message = error instanceof Error ? error.message : String(error); process.stderr.write(`Redis is not reachable at ${url}. ${message}\n`); - process.stderr.write("Start Redis locally (docker run -d -p 6379:6379 redis:alpine) or set REDIS_URL.\n"); + process.stderr.write( + "Check UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.\n", + ); process.exit(1); } } diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 00000000..742a67fe --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,53 @@ +type ExitCode = number | null; + +function sanitizeNodeOptions(nodeOptions: string | undefined): string | undefined { + if (!nodeOptions) return nodeOptions; + + const tokens = nodeOptions.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + const sanitized: string[] = []; + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + if (token === "--localstorage-file") { + index += 1; + continue; + } + if (token.startsWith("--localstorage-file=")) { + const value = token.slice("--localstorage-file=".length).trim(); + if (!value) continue; + } + sanitized.push(token); + } + + return sanitized.join(" ").trim(); +} + +async function run(command: string[], env: NodeJS.ProcessEnv): Promise { + const process = Bun.spawn({ + cmd: command, + env, + stdio: ["inherit", "inherit", "inherit"], + }); + return process.exited; +} + +const env: NodeJS.ProcessEnv = { + ...process.env, + BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA: + process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA ?? "true", + BROWSERSLIST_IGNORE_OLD_DATA: process.env.BROWSERSLIST_IGNORE_OLD_DATA ?? "true", + NODE_NO_WARNINGS: process.env.NODE_NO_WARNINGS ?? "1", +}; + +const sanitizedNodeOptions = sanitizeNodeOptions(process.env.NODE_OPTIONS); +env.NODE_OPTIONS = [sanitizedNodeOptions, "--disable-warning=localstorage-file"] + .filter((value) => value && value.length > 0) + .join(" "); + +const checkRedisExit = await run(["bun", "./scripts/check-redis.ts"], env); +if (checkRedisExit !== 0) { + process.exit(checkRedisExit ?? 1); +} + +const devExit = await run(["bunx", "turbo", "-F", "web", "-F", "server", "dev"], env); +process.exit(devExit ?? 1); From 9162dc0989a1df65a0dff19f7eb781671abd0c14 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 12:03:17 -0500 Subject: [PATCH 03/40] Apply Cubic AI review suggestions Co-authored-by: cubic[bot] --- apps/server/convex/backgroundStream.ts | 142 +++++++++--------- apps/server/convex/chats.ts | 11 +- apps/server/convex/lib/upstashUsage.ts | 21 +-- apps/server/convex/users.ts | 5 - apps/web/src/components/app-sidebar.tsx | 18 +-- apps/web/src/hooks/use-persistent-chat.ts | 116 +++++++++----- apps/web/src/lib/redis.ts | 7 +- apps/web/src/lib/server-auth.test.ts | 16 ++ apps/web/src/lib/server-auth.ts | 2 + .../title-generation.test.ts} | 0 apps/web/src/routes/api/models.ts | 6 +- apps/web/src/routes/api/openrouter-key.ts | 20 +-- apps/web/src/routes/api/typing.ts | 18 +-- apps/web/src/routes/api/workflow/cleanup.ts | 92 +++++++++++- .../src/routes/api/workflow/delete-account.ts | 76 +++++++++- .../src/routes/api/workflow/export-chat.ts | 77 ++++++++-- .../src/routes/api/workflow/generate-title.ts | 95 +++++++++++- apps/web/src/stores/model.ts | 6 +- bun.lock | 1 + scripts/dev.ts | 3 +- 20 files changed, 538 insertions(+), 194 deletions(-) rename apps/web/src/{hooks/use-persistent-chat.test.ts => lib/title-generation.test.ts} (100%) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index c99d5d04..99f2a346 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -793,24 +793,24 @@ export const executeStream = internalAction({ }, handler: async (ctx, args) => { const job = await ctx.runQuery(internal.backgroundStream.getJobInternal, { - jobId: args.jobId + jobId: args.jobId, }); - if (!job) { - return; - } + if (!job) { + return; + } - if (job.provider === "osschat") { - const currentDate = getCurrentDateKey(); - const redisUsageCents = await getDailyUsageFromUpstash(job.userId, currentDate); - if (redisUsageCents !== null && redisUsageCents >= DAILY_AI_LIMIT_CENTS) { - await ctx.runMutation(internal.backgroundStream.failStream, { - jobId: args.jobId, - error: "Daily usage limit reached. Connect your OpenRouter account to continue.", - }); - return; - } + if (job.provider === "osschat") { + const currentDate = getCurrentDateKey(); + const redisUsageCents = await getDailyUsageFromUpstash(job.userId, currentDate); + if (redisUsageCents !== null && redisUsageCents >= DAILY_AI_LIMIT_CENTS) { + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: "Daily usage limit reached. Connect your OpenRouter account to continue.", + }); + return; } + } await ctx.runMutation(internal.backgroundStream.updateStreamContent, { jobId: args.jobId, @@ -855,10 +855,10 @@ export const executeStream = internalAction({ let reasoningChunkCount = 0; let reasoningStartTime: number | null = null; let reasoningEndTime: number | null = null; - let streamCompletedTime: number | null = null; - let firstTextDeltaTime: number | null = null; - let pendingUpdateCounter = 0; - let usageSummary: UsagePayload | null = null; + let streamCompletedTime: number | null = null; + let firstTextDeltaTime: number | null = null; + let pendingUpdateCounter = 0; + let usageSummary: UsagePayload | null = null; const upsertReasoningPart = (id: string): ChainOfThoughtPart => { const existingIndex = reasoningPartById.get(id); @@ -1336,77 +1336,79 @@ export const executeStream = internalAction({ }); } - clearTimeout(timeoutId); + clearTimeout(timeoutId); - // Compute analytics for message persistence - const totalDurationMs = streamCompletedTime - job.createdAt; - const timeToFirstTokenMs = firstTextDeltaTime - ? firstTextDeltaTime - job.createdAt - : undefined; - const completionTokens = usageSummary?.completionTokens ?? 0; - const tokensPerSecond = totalDurationMs > 0 && completionTokens > 0 - ? Math.round((completionTokens / (totalDurationMs / 1000)) * 100) / 100 - : undefined; + // Compute analytics for message persistence + const totalDurationMs = streamCompletedTime - job.createdAt; + const timeToFirstTokenMs = firstTextDeltaTime + ? firstTextDeltaTime - job.createdAt + : undefined; + const completionTokens = usageSummary?.completionTokens ?? 0; + const tokensPerSecond = + totalDurationMs > 0 && completionTokens > 0 + ? Math.round((completionTokens / (totalDurationMs / 1000)) * 100) / 100 + : undefined; - if (job.provider === "osschat") { + if (job.provider === "osschat") { const usageCents = calculateUsageCents( usageSummary, job.messages, fullContent, ); - if (usageCents && usageCents > 0) { - for (let attempt = 0; attempt < 2; attempt++) { - try { - await ctx.runMutation(internal.users.incrementAiUsage, { - userId: job.userId, - usageCents, - skipRedisSync: true, - }); - await incrementDailyUsageInUpstash( - job.userId, - getCurrentDateKey(), - usageCents, - ); - break; - } catch { - // No-op; retried below. - } + if (usageCents && usageCents > 0) { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await ctx.runMutation(internal.users.incrementAiUsage, { + userId: job.userId, + usageCents, + }); + await incrementDailyUsageInUpstash( + job.userId, + getCurrentDateKey(), + usageCents, + ); + break; + } catch { + // No-op; retried below. + } } } } - const thinkingTimeMs = getThinkingTimeMs(); - const thinkingTimeSec = getThinkingTimeSec(); - const toolMetrics = getToolMetrics(); + const thinkingTimeMs = getThinkingTimeMs(); + const thinkingTimeSec = getThinkingTimeSec(); + const toolMetrics = getToolMetrics(); await ctx.runMutation(internal.backgroundStream.completeStream, { - jobId: args.jobId, - content: fullContent, - reasoning: reasoningRequested ? fullReasoning || undefined : undefined, - chainOfThoughtParts: getPersistableChainOfThoughtParts(), - thinkingTimeMs, - thinkingTimeSec, + jobId: args.jobId, + content: fullContent, + reasoning: reasoningRequested ? fullReasoning || undefined : undefined, + chainOfThoughtParts: getPersistableChainOfThoughtParts(), + thinkingTimeMs, + thinkingTimeSec, reasoningCharCount: reasoningRequested ? fullReasoning.length : undefined, reasoningChunkCount: reasoningRequested ? reasoningChunkCount : undefined, reasoningTokenCount: getReasoningTokenCount(), reasoningRequested, webSearchUsed: toolMetrics.webSearchUsed, webSearchCallCount: toolMetrics.webSearchCallCount, - toolCallCount: toolMetrics.toolCallCount, - tokensPerSecond, - timeToFirstTokenMs, - totalDurationMs, - tokenUsage: usageSummary ? { - promptTokens: usageSummary.promptTokens ?? 0, - completionTokens: usageSummary.completionTokens ?? 0, - totalTokens: usageSummary.totalTokens ?? 0, - } : undefined, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - await ctx.runMutation(internal.backgroundStream.failStream, { - jobId: args.jobId, - error: errorMessage, + toolCallCount: toolMetrics.toolCallCount, + tokensPerSecond, + timeToFirstTokenMs, + totalDurationMs, + tokenUsage: usageSummary + ? { + promptTokens: usageSummary.promptTokens ?? 0, + completionTokens: usageSummary.completionTokens ?? 0, + totalTokens: usageSummary.totalTokens ?? 0, + } + : undefined, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: errorMessage, partialContent: fullContent, }); } diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index a69a9fee..7ab75b8a 100644 --- a/apps/server/convex/chats.ts +++ b/apps/server/convex/chats.ts @@ -549,6 +549,9 @@ async function generateTitleFromSeed( length: TitleLength, openRouterKey: string, ): Promise { + const normalizedSeed = seedText.trim().slice(0, 500); + if (!normalizedSeed) return null; + const systemPrompt = [ "Create a specific, useful chat title.", "Return only the title in Title Case; no quotes, no trailing punctuation.", @@ -567,10 +570,10 @@ async function generateTitleFromSeed( }, body: JSON.stringify({ model: TITLE_MODEL_ID, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: seedText }, - ], + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: normalizedSeed }, + ], temperature: 0.2, max_tokens: 32, }), diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index a52beccc..e7841e5a 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -1,10 +1,10 @@ -const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL?.trim(); -const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); - type PipelineResult = Array<{ result: unknown }>; -function isConfigured(): boolean { - return Boolean(UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN); +function getConfig(): { url: string; token: string } | null { + const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); + const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); + if (!url || !token) return null; + return { url, token }; } function shouldLogUpstashUsageErrors(): boolean { @@ -22,12 +22,13 @@ function getMidnightUtcEpochSeconds(dateKey: string): number { } async function executePipeline(commands: Array>): Promise { - if (!isConfigured()) return null; + const config = getConfig(); + if (!config) return null; - const response = await fetch(`${UPSTASH_REDIS_REST_URL}/pipeline`, { + const response = await fetch(`${config.url}/pipeline`, { method: "POST", headers: { - Authorization: `Bearer ${UPSTASH_REDIS_REST_TOKEN}`, + Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", }, body: JSON.stringify(commands), @@ -45,7 +46,7 @@ export async function getDailyUsageFromUpstash( userId: string, dateKey: string, ): Promise { - if (!isConfigured()) return null; + if (!getConfig()) return null; try { const key = usageCounterKey(userId, dateKey); @@ -71,7 +72,7 @@ export async function incrementDailyUsageInUpstash( dateKey: string, usageCents: number, ): Promise { - if (!isConfigured()) return; + if (!getConfig()) return; if (!Number.isFinite(usageCents) || usageCents <= 0) return; const key = usageCounterKey(userId, dateKey); diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 86b62c83..8ca01058 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -313,7 +313,6 @@ export const incrementAiUsage = internalMutation({ args: { userId: v.id("users"), usageCents: v.number(), - skipRedisSync: v.optional(v.boolean()), }, returns: v.object({ usedCents: v.number(), @@ -368,10 +367,6 @@ export const incrementAiUsage = internalMutation({ updatedAt: Date.now(), }); - // NOTE: Redis sync is performed from action callsites (e.g. backgroundStream) - // to avoid HTTP side effects inside mutations. - void args.skipRedisSync; - return { usedCents: nextCents, remainingCents: Math.max(0, DAILY_AI_LIMIT_CENTS - nextCents), diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index e5390b75..2576f4aa 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -442,20 +442,20 @@ export function AppSidebar({ return; } - await convexClient.mutation(api.chats.setGeneratedTitle, { - chatId: chatId as Id<"chats">, - userId: convexUser._id, - title: generatedTitle, - force: true, - }); + await convexClient.mutation(api.chats.setGeneratedTitle, { + chatId: chatId as Id<"chats">, + userId: convexUser._id, + title: generatedTitle, + force: true, + }); } catch (error) { console.warn("[Chat] Title regeneration failed:", error); - const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; - toast.error(message); + const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; + toast.error(message); } finally { setTitleGenerating(chatId, false); } - }; + }; const handleDeleteChat = useCallback( async (chatId: string) => { diff --git a/apps/web/src/hooks/use-persistent-chat.ts b/apps/web/src/hooks/use-persistent-chat.ts index 6950a173..75a6bc32 100644 --- a/apps/web/src/hooks/use-persistent-chat.ts +++ b/apps/web/src/hooks/use-persistent-chat.ts @@ -10,6 +10,7 @@ import { useProviderStore } from "@/stores/provider"; import { useStreamStore } from "@/stores/stream"; import { useUIStore } from "@/stores/ui"; import { analytics } from "@/lib/analytics"; +import { shouldTriggerAutoTitle } from "@/lib/title-generation"; interface ChatFileAttachment { type: "file"; @@ -509,24 +510,24 @@ export function usePersistentChat({ if (previousHash === nextHash) return prev; const updated = [...prev]; - updated[idx] = { - ...updated[idx], - parts, - metadata: { - thinkingTimeSec: activeStreamJob.thinkingTimeSec, - reasoningRequested: jobReasoningRequested, - reasoningTokenCount: activeStreamJob.reasoningTokenCount, - modelId: activeStreamJob.model, - provider: activeStreamJob.provider, - reasoningEffort: activeStreamJob.options?.reasoningEffort, - webSearchEnabled: activeStreamJob.options?.enableWebSearch, - webSearchUsed: activeStreamJob.webSearchUsed, - webSearchCallCount: activeStreamJob.webSearchCallCount, - toolCallCount: activeStreamJob.toolCallCount, - resumedFromActiveStream: true, - }, - }; - return updated; + updated[idx] = { + ...updated[idx], + parts, + metadata: { + thinkingTimeSec: activeStreamJob.thinkingTimeSec, + reasoningRequested: jobReasoningRequested, + reasoningTokenCount: activeStreamJob.reasoningTokenCount, + modelId: activeStreamJob.model, + provider: activeStreamJob.provider, + reasoningEffort: activeStreamJob.options?.reasoningEffort, + webSearchEnabled: activeStreamJob.options?.enableWebSearch, + webSearchUsed: activeStreamJob.webSearchUsed, + webSearchCallCount: activeStreamJob.webSearchCallCount, + toolCallCount: activeStreamJob.toolCallCount, + resumedFromActiveStream: true, + }, + }; + return updated; }); } }, [activeStreamJob, status]); @@ -566,6 +567,8 @@ export function usePersistentChat({ } let targetChatId = chatIdRef.current; + const startedWithoutChatId = !targetChatId; + const existingMessageCount = messagesResult?.length ?? messages.length; if (!targetChatId) { try { @@ -575,10 +578,10 @@ export function usePersistentChat({ setCurrentChatId(targetChatId); analytics.chatCreated(); onChatCreatedRef.current?.(targetChatId); - } catch { - toast.error("Failed to create chat"); - return; - } + } catch { + toast.error("Failed to create chat"); + return; + } } const userMsgId = crypto.randomUUID(); @@ -619,15 +622,15 @@ export function usePersistentChat({ model: runtimeModelId, provider: activeProvider, messages: allMsgs, - options: { - enableReasoning: runtimeReasoningEnabled, - reasoningEffort: runtimeReasoningEffort, - enableWebSearch: webSearchEnabled, - jonMode, - dynamicPrompt, - supportsToolCalls: runtimeSupportsToolCalls, - }, - }); + options: { + enableReasoning: runtimeReasoningEnabled, + reasoningEffort: runtimeReasoningEffort, + enableWebSearch: webSearchEnabled, + jonMode, + dynamicPrompt, + supportsToolCalls: runtimeSupportsToolCalls, + }, + }); setStatus("streaming"); streamingRef.current = { id: assistantMsgId, content: "", reasoning: "", chainHash: "[]" }; @@ -635,7 +638,7 @@ export function usePersistentChat({ const initialParts: UIMessage["parts"] = []; if (runtimeReasoningEffort !== "none") { const reasoningPart: ReasoningPartWithState = { type: "reasoning", text: "", state: "streaming" }; - initialParts.push(reasoningPart as UIMessage["parts"][number]); + initialParts.push(reasoningPart as UIMessage["parts"][number]); } initialParts.push({ type: "text", text: "", state: "streaming" }); @@ -655,6 +658,38 @@ export function usePersistentChat({ }, }, ]); + const shouldQueueAutoTitle = shouldTriggerAutoTitle({ + startedWithoutChatId, + existingMessageCount, + seedText: message.text, + }); + if (shouldQueueAutoTitle) { + void fetch("/api/workflow/generate-title", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chatId: targetChatId, + userId: convexUserId, + seedText: message.text.trim().slice(0, 300), + length: "standard", + provider: activeProvider, + mode: "auto", + }), + }) + .then(async (response) => { + if (response.ok) return; + const payload = await response.json().catch(() => null); + console.warn("[AutoTitle] Workflow request failed", { + status: response.status, + payload, + }); + }) + .catch((autoTitleError) => { + console.warn("[AutoTitle] Workflow request failed", autoTitleError); + }); + } } catch (err) { const parsedError = err instanceof Error ? err : new Error("Unknown error"); @@ -690,10 +725,19 @@ export function usePersistentChat({ } }, [ - convexUserId, isUserLoading, user?.id, chatId, messages, models, - activeProvider, webSearchEnabled, - jonMode, dynamicPrompt, - createChat, sendMessages, startBackgroundStream, + convexUserId, + isUserLoading, + user?.id, + messages, + messagesResult, + models, + activeProvider, + webSearchEnabled, + jonMode, + dynamicPrompt, + createChat, + sendMessages, + startBackgroundStream, cleanupStaleJobs, ], ); diff --git a/apps/web/src/lib/redis.ts b/apps/web/src/lib/redis.ts index 720ff546..b1eeb564 100644 --- a/apps/web/src/lib/redis.ts +++ b/apps/web/src/lib/redis.ts @@ -52,9 +52,7 @@ export async function ensureRedisConnected(): Promise { } async function getConnectedClient(): Promise { - if (!upstashRedis) return null; - const isReady = await ensureRedisConnected(); - return isReady ? upstashRedis : null; + return upstashRedis; } function parseStreamMeta(value: unknown): StreamMeta | null { @@ -153,6 +151,9 @@ export async function readStream( lastId === "0" ? "-" : `(${lastId}`, "+", ); + if (!entries || typeof entries !== "object") { + return []; + } return Object.entries(entries).map(([id, message]) => ({ id, diff --git a/apps/web/src/lib/server-auth.test.ts b/apps/web/src/lib/server-auth.test.ts index 076e8007..d9fb7b5c 100644 --- a/apps/web/src/lib/server-auth.test.ts +++ b/apps/web/src/lib/server-auth.test.ts @@ -60,4 +60,20 @@ describe("server-auth.getConvexAuthToken", () => { await expect(getConvexAuthToken(request)).resolves.toBe("fallback-token"); }); + + it("returns null when token endpoint explicitly rejects the request", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); + vi.stubEnv("CONVEX_SITE_URL", "https://example.convex.site"); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + + const { getConvexAuthToken } = await loadModule(); + const request = requestWithCookie("better-auth.convex_jwt=fallback-token"); + + await expect(getConvexAuthToken(request)).resolves.toBeNull(); + }); }); diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index e990b356..51002e80 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -46,6 +46,8 @@ export async function getConvexAuthToken(request: Request): Promise(MODELS_CACHE_KEY); + const cached = await upstashRedis.get>(MODELS_CACHE_KEY); if (cached) { - return new Response(cached, { + const body = + typeof cached === "string" ? cached : JSON.stringify(cached); + return new Response(body, { status: 200, headers: { "Content-Type": "application/json", diff --git a/apps/web/src/routes/api/openrouter-key.ts b/apps/web/src/routes/api/openrouter-key.ts index cd191461..7a6b1e1f 100644 --- a/apps/web/src/routes/api/openrouter-key.ts +++ b/apps/web/src/routes/api/openrouter-key.ts @@ -57,9 +57,9 @@ export const Route = createFileRoute("/api/openrouter-key")({ const body = await request.json(); const apiKey = typeof body?.apiKey === "string" ? body.apiKey.trim() : ""; - if (!apiKey) { - return json({ error: "apiKey is required" }, { status: 400 }); - } + if (!apiKey) { + return json({ error: "apiKey is required" }, { status: 400 }); + } const convexUserId = await getConvexUserId(authUser, request); if (!convexUserId) { @@ -77,8 +77,8 @@ export const Route = createFileRoute("/api/openrouter-key")({ const convexClient = await getConvexClientForRequest(request); if (!convexClient) { - return json({ error: "Unauthorized" }, { status: 401 }); - } + return json({ error: "Unauthorized" }, { status: 401 }); + } const encryptedKey = encryptSecret(apiKey); await convexClient.mutation(api.users.saveOpenRouterKey, { @@ -99,9 +99,9 @@ export const Route = createFileRoute("/api/openrouter-key")({ return json({ error: "Invalid origin" }, { status: 403 }); } const authUser = await getAuthUser(request); - if (!authUser) { - return json({ error: "Unauthorized" }, { status: 401 }); - } + if (!authUser) { + return json({ error: "Unauthorized" }, { status: 401 }); + } const convexUserId = await getConvexUserId(authUser, request); if (!convexUserId) { @@ -119,8 +119,8 @@ export const Route = createFileRoute("/api/openrouter-key")({ const convexClient = await getConvexClientForRequest(request); if (!convexClient) { - return json({ error: "Unauthorized" }, { status: 401 }); - } + return json({ error: "Unauthorized" }, { status: 401 }); + } await convexClient.mutation(api.users.removeOpenRouterKey, { userId: convexUserId, diff --git a/apps/web/src/routes/api/typing.ts b/apps/web/src/routes/api/typing.ts index fd76aa98..7d06df82 100644 --- a/apps/web/src/routes/api/typing.ts +++ b/apps/web/src/routes/api/typing.ts @@ -51,11 +51,11 @@ export const Route = createFileRoute("/api/typing")({ return json({ ok: true }); } - await redis.typing.set(chatId, convexUserId, !!isTyping); - return json({ ok: true }); - } catch (error) { - console.error("[Typing API POST] Error:", error); - return json({ error: "Failed to update typing status" }, { status: 500 }); + await redis.typing.set(chatId, convexUserId, !!isTyping); + return json({ ok: true }); + } catch (error) { + console.error("[Typing API POST] Error:", error); + return json({ error: "Failed to update typing status" }, { status: 500 }); } }, @@ -99,10 +99,10 @@ export const Route = createFileRoute("/api/typing")({ const users = await redis.typing.getUsers(chatId); return json({ users }); - } catch (error) { - console.error("[Typing API GET] Error:", error); - return json({ error: "Failed to get typing status" }, { status: 500 }); - } + } catch (error) { + console.error("[Typing API GET] Error:", error); + return json({ error: "Failed to get typing status" }, { status: 500 }); + } }, }, }, diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index f484d1b6..eae80bf5 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -3,6 +3,7 @@ import { json } from "@tanstack/react-start"; import { serve } from "@upstash/workflow/tanstack"; import { api } from "@server/convex/_generated/api"; import { createConvexServerClient } from "@/lib/convex-server"; +import { getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { workflowClient } from "@/lib/upstash"; type CleanupPayload = { @@ -10,11 +11,59 @@ type CleanupPayload = { batchSize?: number; }; +const MAX_CLEANUP_BATCHES = 1_000; + function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; } +function parseCleanupPayload(raw: unknown): CleanupPayload | null { + if (!raw || typeof raw !== "object") return null; + + const payload = raw as Record; + const parsed: CleanupPayload = {}; + + if (payload.retentionDays !== undefined) { + if ( + typeof payload.retentionDays !== "number" || + !Number.isFinite(payload.retentionDays) || + payload.retentionDays < 1 || + payload.retentionDays > 3650 + ) { + return null; + } + parsed.retentionDays = Math.floor(payload.retentionDays); + } + + if (payload.batchSize !== undefined) { + if ( + typeof payload.batchSize !== "number" || + !Number.isFinite(payload.batchSize) || + payload.batchSize < 1 || + payload.batchSize > 1_000 + ) { + return null; + } + parsed.batchSize = Math.floor(payload.batchSize); + } + + return parsed; +} + +function hasValidCleanupToken(headers: Headers): boolean { + const expectedToken = process.env.WORKFLOW_CLEANUP_TOKEN?.trim(); + if (!expectedToken) return false; + + const bearer = headers.get("authorization"); + if (bearer?.startsWith("Bearer ")) { + return bearer.slice("Bearer ".length).trim() === expectedToken; + } + + const workflowHeader = headers.get("x-workflow-cleanup-token"); + return workflowHeader?.trim() === expectedToken; +} + async function runCleanupInline(payload: CleanupPayload): Promise<{ success: boolean; batches: number; @@ -32,7 +81,7 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ let batches = 0; let totalDeleted = 0; - while (true) { + while (batches < MAX_CLEANUP_BATCHES) { batches += 1; const preview = await convexClient.action(api.crons.runCleanupBatchForWorkflow, { workflowToken, @@ -54,6 +103,10 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ await new Promise((resolve) => setTimeout(resolve, 1_000)); } + if (batches >= MAX_CLEANUP_BATCHES) { + throw new Error(`Cleanup exceeded maximum batches (${MAX_CLEANUP_BATCHES})`); + } + return { success: true, batches, @@ -74,7 +127,7 @@ const workflow = serve(async (context) => { let batches = 0; let totalDeleted = 0; - while (true) { + while (batches < MAX_CLEANUP_BATCHES) { batches += 1; const preview = await context.run(`query-batch-${batches}`, async () => { return convexClient.action(api.crons.runCleanupBatchForWorkflow, { @@ -102,6 +155,10 @@ const workflow = serve(async (context) => { await context.sleep(`sleep-${batches}`, "1s"); } + if (batches >= MAX_CLEANUP_BATCHES) { + throw new Error(`Cleanup exceeded maximum batches (${MAX_CLEANUP_BATCHES})`); + } + return context.run("log-completion", async () => { return { success: true, @@ -120,12 +177,27 @@ export const Route = createFileRoute("/api/workflow/cleanup")({ return workflow.POST({ request }); } - let payload: CleanupPayload; + const hasCleanupToken = hasValidCleanupToken(request.headers); + if (!hasCleanupToken) { + if (!isSameOrigin(request)) { + return json({ error: "Invalid origin" }, { status: 403 }); + } + const authToken = await getConvexAuthToken(request); + if (!authToken) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + } + + let payloadRaw: unknown; try { - payload = (await request.json()) as CleanupPayload; + payloadRaw = await request.json(); } catch { return json({ error: "Invalid JSON payload" }, { status: 400 }); } + const payload = parseCleanupPayload(payloadRaw); + if (!payload) { + return json({ error: "Invalid cleanup payload" }, { status: 400 }); + } if (isLocalWorkflowRequest(request)) { try { @@ -145,12 +217,18 @@ export const Route = createFileRoute("/api/workflow/cleanup")({ } try { + const triggerHeaders: Record = { + "Content-Type": "application/json", + }; + const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN?.trim(); + if (workflowToken) { + triggerHeaders.authorization = `Bearer ${workflowToken}`; + } + const { workflowRunId } = await workflowClient.trigger({ url: request.url, body: payload, - headers: { - "Content-Type": "application/json", - }, + headers: triggerHeaders, }); return json({ queued: true, workflowRunId }, { status: 202 }); } catch (error) { diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index b5caf306..a136256c 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -4,6 +4,7 @@ import { serve } from "@upstash/workflow/tanstack"; import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; +import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { upstashRedis, workflowClient } from "@/lib/upstash"; const CONVEX_SITE_URL = @@ -22,6 +23,9 @@ type DeleteStep = | "delete-files"; async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { + const forwardedToken = headers.get("x-convex-token"); + if (forwardedToken) return forwardedToken; + if (!CONVEX_SITE_URL) return null; const cookie = headers.get("cookie"); @@ -36,6 +40,37 @@ async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise; + if (typeof payload.userId !== "string" || payload.userId.trim().length === 0) { + return null; + } + if (typeof payload.externalId !== "string" || payload.externalId.trim().length === 0) { + return null; + } + + const parsed: DeleteAccountPayload = { + userId: payload.userId.trim(), + externalId: payload.externalId.trim(), + }; + + if (payload.batchSize !== undefined) { + if ( + typeof payload.batchSize !== "number" || + !Number.isFinite(payload.batchSize) || + payload.batchSize < 1 || + payload.batchSize > 1_000 + ) { + return null; + } + parsed.batchSize = Math.floor(payload.batchSize); + } + + return parsed; +} + async function clearRedisUserKeys(userId: string): Promise { if (!upstashRedis) return 0; @@ -221,16 +256,45 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ return workflow.POST({ request }); } - let payload: DeleteAccountPayload; + if (!isSameOrigin(request)) { + return json({ error: "Invalid origin" }, { status: 403 }); + } + + const authToken = await getConvexAuthToken(request); + if (!authToken) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + const authUser = await getAuthUser(request); + if (!authUser) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + const authConvexClient = createConvexServerClient(authToken); + const authConvexUser = await authConvexClient.query(api.users.getByExternalId, { + externalId: authUser.id, + }); + if (!authConvexUser?._id) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + let payloadRaw: unknown; try { - payload = (await request.json()) as DeleteAccountPayload; + payloadRaw = await request.json(); } catch { return json({ error: "Invalid JSON payload" }, { status: 400 }); } + const payload = parseDeletePayload(payloadRaw); + if (!payload) { + return json({ error: "Invalid delete-account payload" }, { status: 400 }); + } + const normalizedPayload: DeleteAccountPayload = { + ...payload, + userId: authConvexUser._id, + externalId: authUser.id, + }; if (isLocalWorkflowRequest(request)) { try { - const result = await runDeleteAccountInline(payload, request.headers); + const result = await runDeleteAccountInline(normalizedPayload, request.headers); return json(result, { status: 200 }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete account"; @@ -247,10 +311,12 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ } try { + const triggerHeaders = getWorkflowTriggerHeaders(request.headers); + triggerHeaders["x-convex-token"] = authToken; const { workflowRunId } = await workflowClient.trigger({ url: request.url, - body: payload, - headers: getWorkflowTriggerHeaders(request.headers), + body: normalizedPayload, + headers: triggerHeaders, }); return json({ queued: true, workflowRunId }, { status: 202 }); } catch (error) { diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 61a3cf17..d9265678 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -4,6 +4,7 @@ import { serve } from "@upstash/workflow/tanstack"; import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; +import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { workflowClient } from "@/lib/upstash"; const CONVEX_SITE_URL = @@ -40,6 +41,9 @@ type ChatExportData = { }; async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { + const forwardedToken = headers.get("x-convex-token"); + if (forwardedToken) return forwardedToken; + if (!CONVEX_SITE_URL) return null; const cookie = headers.get("cookie"); @@ -54,6 +58,32 @@ async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise; + if (typeof payload.chatId !== "string" || payload.chatId.trim().length === 0) { + return null; + } + if (typeof payload.userId !== "string" || payload.userId.trim().length === 0) { + return null; + } + + let format: ExportFormat | undefined; + if (payload.format !== undefined) { + if (payload.format !== "markdown" && payload.format !== "json") { + return null; + } + format = payload.format; + } + + return { + chatId: payload.chatId.trim(), + userId: payload.userId.trim(), + format, + }; +} + function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; @@ -109,14 +139,9 @@ function formatExportMarkdown(data: ChatExportData): string { async function runExportChatInline( payload: ExportChatPayload, - headers: Headers, + authToken: string, ): Promise<{ downloadUrl: string; byteLength: number; fileName: string }> { const { chatId, userId, format = "markdown" } = payload; - const authToken = await getAuthTokenFromWorkflowHeaders(headers); - if (!authToken) { - throw new Error("Unauthorized"); - } - const convexClient = createConvexServerClient(authToken); const chatExportData = await convexClient.query(api.chats.getChatExportData, { chatId: chatId as Id<"chats">, @@ -192,16 +217,44 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ return workflow.POST({ request }); } - let payload: ExportChatPayload; + if (!isSameOrigin(request)) { + return json({ error: "Invalid origin" }, { status: 403 }); + } + + const authToken = await getConvexAuthToken(request); + if (!authToken) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + const authUser = await getAuthUser(request); + if (!authUser) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + const authConvexClient = createConvexServerClient(authToken); + const authConvexUser = await authConvexClient.query(api.users.getByExternalId, { + externalId: authUser.id, + }); + if (!authConvexUser?._id) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + let payloadRaw: unknown; try { - payload = (await request.json()) as ExportChatPayload; + payloadRaw = await request.json(); } catch { return json({ error: "Invalid JSON payload" }, { status: 400 }); } + const payload = parseExportPayload(payloadRaw); + if (!payload) { + return json({ error: "Invalid export payload" }, { status: 400 }); + } + const normalizedPayload: ExportChatPayload = { + ...payload, + userId: authConvexUser._id, + }; if (isLocalWorkflowRequest(request)) { try { - const result = await runExportChatInline(payload, request.headers); + const result = await runExportChatInline(normalizedPayload, authToken); return json(result, { status: 200 }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to export chat"; @@ -219,10 +272,12 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ } try { + const triggerHeaders = getWorkflowTriggerHeaders(request.headers); + triggerHeaders["x-convex-token"] = authToken; const { workflowRunId } = await workflowClient.trigger({ url: request.url, - body: payload, - headers: getWorkflowTriggerHeaders(request.headers), + body: normalizedPayload, + headers: triggerHeaders, }); return json({ queued: true, workflowRunId }, { status: 202 }); } catch (error) { diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index c1aa6073..10d6569e 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -5,7 +5,7 @@ import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; import { decryptSecret } from "@/lib/server-crypto"; -import { getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; +import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { workflowClient } from "@/lib/upstash"; const CONVEX_SITE_URL = @@ -26,6 +26,50 @@ type GenerateTitlePayload = { mode?: "auto" | "manual"; }; +function parseGenerateTitlePayload(raw: unknown): GenerateTitlePayload | null { + if (!raw || typeof raw !== "object") return null; + + const payload = raw as Record; + if (typeof payload.chatId !== "string" || payload.chatId.trim().length === 0) { + return null; + } + if (typeof payload.userId !== "string" || payload.userId.trim().length === 0) { + return null; + } + if ( + payload.length !== "short" && + payload.length !== "standard" && + payload.length !== "long" + ) { + return null; + } + if (payload.provider !== "osschat" && payload.provider !== "openrouter") { + return null; + } + if ( + payload.mode !== undefined && + payload.mode !== "auto" && + payload.mode !== "manual" + ) { + return null; + } + if ( + payload.seedText !== undefined && + typeof payload.seedText !== "string" + ) { + return null; + } + + return { + chatId: payload.chatId.trim(), + userId: payload.userId.trim(), + seedText: payload.seedText, + length: payload.length, + provider: payload.provider, + mode: payload.mode, + }; +} + const TITLE_STYLE_PROMPTS: Record = { short: "Use 2-4 words.", standard: "Use 4-6 words.", @@ -135,7 +179,22 @@ async function runGenerateTitleInline( } const workflow = serve(async (context) => { - const { chatId, userId, seedText = "", length, provider, mode = "auto" } = context.requestPayload; + const payload = await context.run("load-payload", async () => { + const parsed = parseGenerateTitlePayload(context.requestPayload); + if (!parsed) { + throw new Error("Invalid payload"); + } + return parsed; + }); + const { + chatId, + userId, + seedText = "", + length, + provider, + mode = "auto", + } = payload; + const authToken = await context.run("resolve-auth", async () => { const forwardedToken = context.headers.get("x-convex-token"); if (forwardedToken) return forwardedToken; @@ -194,7 +253,7 @@ const workflow = serve(async (context) => { "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", "X-Title": "OSSChat", }, - body: JSON.stringify({ + body: { model: TITLE_MODEL_ID, messages: [ { role: "system", content: systemPrompt }, @@ -202,7 +261,7 @@ const workflow = serve(async (context) => { ], temperature: 0.2, max_tokens: 32, - }), + }, retries: 2, timeout: "30s", }); @@ -247,21 +306,41 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ return json({ error: "Invalid origin" }, { status: 403 }); } - let payload: GenerateTitlePayload; + let payloadRaw: unknown; try { - payload = (await request.json()) as GenerateTitlePayload; + payloadRaw = await request.json(); } catch { return json({ error: "Invalid JSON payload" }, { status: 400 }); } + const payload = parseGenerateTitlePayload(payloadRaw); + if (!payload) { + return json({ error: "Invalid title payload" }, { status: 400 }); + } const authToken = await getConvexAuthToken(request); if (!authToken) { return json({ error: "Unauthorized" }, { status: 401 }); } + const authUser = await getAuthUser(request); + if (!authUser) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + const authConvexClient = createConvexServerClient(authToken); + const authConvexUser = await authConvexClient.query(api.users.getByExternalId, { + externalId: authUser.id, + }); + if (!authConvexUser?._id) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + const normalizedPayload: GenerateTitlePayload = { + ...payload, + userId: authConvexUser._id, + }; if (isLocalWorkflowRequest(request)) { try { - const result = await runGenerateTitleInline(payload, authToken); + const result = await runGenerateTitleInline(normalizedPayload, authToken); if (!result.saved) { const status = result.reason === "missing_openrouter_key" ? 400 : 409; return json(result, { status }); @@ -286,7 +365,7 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ headers["x-convex-token"] = authToken; const { workflowRunId } = await workflowClient.trigger({ url: request.url, - body: payload, + body: normalizedPayload, headers, }); return json({ queued: true, workflowRunId }, { status: 202 }); diff --git a/apps/web/src/stores/model.ts b/apps/web/src/stores/model.ts index 8823e148..b16c02c6 100644 --- a/apps/web/src/stores/model.ts +++ b/apps/web/src/stores/model.ts @@ -333,9 +333,9 @@ async function fetchAllModels(): Promise> { headers: { "Content-Type": "application/json" }, }); - if (!response.ok) { - throw new Error(`OpenRouter API error: ${response.status}`); - } + if (!response.ok) { + throw new Error(`Models API error: ${response.status}`); + } const data = await response.json(); const rawModels = data.data || []; diff --git a/bun.lock b/bun.lock index dfd82c82..9e0fb213 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "openchat", diff --git a/scripts/dev.ts b/scripts/dev.ts index 742a67fe..4b23c9a0 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -13,8 +13,7 @@ function sanitizeNodeOptions(nodeOptions: string | undefined): string | undefine continue; } if (token.startsWith("--localstorage-file=")) { - const value = token.slice("--localstorage-file=".length).trim(); - if (!value) continue; + continue; } sanitized.push(token); } From 9393e14c31dd41ea5e50dae115097f404d2ae09d Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 12:10:46 -0500 Subject: [PATCH 04/40] Address Cubic follow-up findings Co-authored-by: cubic[bot] --- apps/web/src/components/app-sidebar.tsx | 12 ++++++------ apps/web/src/lib/server-auth.test.ts | 16 ++++++++++++++++ apps/web/src/lib/server-auth.ts | 4 +++- .../src/routes/api/workflow/delete-account.ts | 9 ++------- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 2576f4aa..ac4a70aa 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -442,12 +442,12 @@ export function AppSidebar({ return; } - await convexClient.mutation(api.chats.setGeneratedTitle, { - chatId: chatId as Id<"chats">, - userId: convexUser._id, - title: generatedTitle, - force: true, - }); + await convexClient.mutation(api.chats.setGeneratedTitle, { + chatId: chatId as Id<"chats">, + userId: convexUser._id, + title: generatedTitle, + force: true, + }); } catch (error) { console.warn("[Chat] Title regeneration failed:", error); const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; diff --git a/apps/web/src/lib/server-auth.test.ts b/apps/web/src/lib/server-auth.test.ts index d9fb7b5c..6a45c0f6 100644 --- a/apps/web/src/lib/server-auth.test.ts +++ b/apps/web/src/lib/server-auth.test.ts @@ -76,4 +76,20 @@ describe("server-auth.getConvexAuthToken", () => { await expect(getConvexAuthToken(request)).resolves.toBeNull(); }); + + it("falls back to cookie token when token endpoint has a server error", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); + vi.stubEnv("CONVEX_SITE_URL", "https://example.convex.site"); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "server-error" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ); + + const { getConvexAuthToken } = await loadModule(); + const request = requestWithCookie("better-auth.convex_jwt=fallback-token"); + + await expect(getConvexAuthToken(request)).resolves.toBe("fallback-token"); + }); }); diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index 51002e80..d1e77378 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -47,7 +47,9 @@ export async function getConvexAuthToken(request: Request): Promise= 400 && response.status < 500) { + return null; + } } catch { // Fall through to cookie fallback. } diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index a136256c..a1872469 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -125,17 +125,12 @@ function getWorkflowTriggerHeaders(headers: Headers): Record { async function runDeleteAccountInline( payload: DeleteAccountPayload, - headers: Headers, + authToken: string, ): Promise<{ success: boolean; deleted: { streamJobs: number; messages: number; chats: number; files: number; redisKeys: number }; }> { const { userId, externalId, batchSize } = payload; - const authToken = await getAuthTokenFromWorkflowHeaders(headers); - if (!authToken) { - throw new Error("Unauthorized"); - } - const convexClient = createConvexServerClient(authToken); const convexUserId = userId as Id<"users">; @@ -294,7 +289,7 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ if (isLocalWorkflowRequest(request)) { try { - const result = await runDeleteAccountInline(normalizedPayload, request.headers); + const result = await runDeleteAccountInline(normalizedPayload, authToken); return json(result, { status: 200 }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete account"; From 7cb6eddca395f3774670b96c0ee4b44b74f08da3 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 12:58:07 -0500 Subject: [PATCH 05/40] fix(security): harden workflow endpoints and rate-limit checks - cleanup.ts: require token unconditionally, timing-safe compare, off-by-one fix - crons.ts: timing-safe token comparison - generate-title.ts: remove x-convex-token forwarding to QStash - delete-account.ts: remove x-convex-token forwarding and trust - export-chat.ts: remove x-convex-token forwarding and trust - backgroundStream.ts: restore increment-before-search TOCTOU fix, strict maxSteps validation - server-auth.ts: don't fall through to cookie on 5xx errors --- apps/server/convex/backgroundStream.ts | 33 +++++++++++---- apps/server/convex/crons.ts | 15 ++++++- apps/web/src/lib/server-auth.ts | 7 +--- apps/web/src/routes/api/workflow/cleanup.ts | 41 ++++++++++++------- .../src/routes/api/workflow/delete-account.ts | 4 -- .../src/routes/api/workflow/export-chat.ts | 4 -- .../src/routes/api/workflow/generate-title.ts | 3 -- 7 files changed, 68 insertions(+), 39 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index 99f2a346..6db509be 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -1139,11 +1139,16 @@ export const executeStream = internalAction({ pendingUpdateCounter++; try { - const rawOutput = await execute({ query: searchQuery }); - const compactOutput = compactWebSearchOutput(rawOutput); + // Increment FIRST — this mutation is serializable in Convex, + // so it will throw if the limit is already reached. + // This prevents the TOCTOU race condition where two concurrent + // requests could both pass the initial check and exceed the limit. await ctx.runMutation(internal.search.incrementSearchUsageInternal, { userId: job.userId, }); + + const rawOutput = await execute({ query: searchQuery }); + const compactOutput = compactWebSearchOutput(rawOutput); toolPart.output = compactOutput; toolPart.state = "output-available"; pendingUpdateCounter++; @@ -1158,15 +1163,23 @@ export const executeStream = internalAction({ : chunk; contextChunks.push(trimmedChunk); remainingContextChars -= trimmedChunk.length; - } } - } catch (error) { - const errorText = - error instanceof Error ? error.message : "Web search failed"; - toolPart.errorText = errorText; + } + } catch (error) { + const errorText = + error instanceof Error ? error.message : "Web search failed"; + // If increment threw due to limit, stop searching + if (errorText.includes("Daily search limit reached")) { + toolPart.errorText = "Daily search limit reached"; toolPart.state = "output-error"; pendingUpdateCounter++; + await persistProgress(true); + break; } + toolPart.errorText = errorText; + toolPart.state = "output-error"; + pendingUpdateCounter++; + } await persistProgress(true); } @@ -1190,7 +1203,11 @@ export const executeStream = internalAction({ } } const configuredMaxSteps = - typeof job.options?.maxSteps === "number" ? job.options.maxSteps : undefined; + typeof job.options?.maxSteps === "number" + && Number.isFinite(job.options.maxSteps) + && job.options.maxSteps > 0 + ? Math.floor(job.options.maxSteps) + : undefined; const stepLimit = Math.max(1, Math.min(configuredMaxSteps ?? 1, 10)); streamOptions.stopWhen = stepCountIs(stepLimit); diff --git a/apps/server/convex/crons.ts b/apps/server/convex/crons.ts index e18a3f8c..83b132f1 100644 --- a/apps/server/convex/crons.ts +++ b/apps/server/convex/crons.ts @@ -175,7 +175,20 @@ export const runCleanupBatchForWorkflow = action({ args, ): Promise<{ success: boolean; deleted: number; dryRun: boolean; cutoffDate: string }> => { const expectedToken = process.env.WORKFLOW_CLEANUP_TOKEN; - if (!expectedToken || args.workflowToken !== expectedToken) { + if (!expectedToken) { + throw new Error("Unauthorized"); + } + // Constant-time comparison to prevent timing attacks + const a = args.workflowToken; + const b = expectedToken; + if (a.length !== b.length) { + throw new Error("Unauthorized"); + } + let mismatch = 0; + for (let i = 0; i < a.length; i++) { + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + if (mismatch !== 0) { throw new Error("Unauthorized"); } diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index d1e77378..f2a85186 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -46,12 +46,9 @@ export async function getConvexAuthToken(request: Request): Promise= 400 && response.status < 500) { - return null; - } + return null; } catch { - // Fall through to cookie fallback. + // Network error — fall through to cookie fallback for resilience. } } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index eae80bf5..b6471fb1 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -1,9 +1,9 @@ +import { timingSafeEqual } from "node:crypto"; import { createFileRoute } from "@tanstack/react-router"; import { json } from "@tanstack/react-start"; import { serve } from "@upstash/workflow/tanstack"; import { api } from "@server/convex/_generated/api"; import { createConvexServerClient } from "@/lib/convex-server"; -import { getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { workflowClient } from "@/lib/upstash"; type CleanupPayload = { @@ -51,17 +51,23 @@ function parseCleanupPayload(raw: unknown): CleanupPayload | null { return parsed; } +function safeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + function hasValidCleanupToken(headers: Headers): boolean { const expectedToken = process.env.WORKFLOW_CLEANUP_TOKEN?.trim(); if (!expectedToken) return false; const bearer = headers.get("authorization"); if (bearer?.startsWith("Bearer ")) { - return bearer.slice("Bearer ".length).trim() === expectedToken; + return safeCompare(bearer.slice("Bearer ".length).trim(), expectedToken); } const workflowHeader = headers.get("x-workflow-cleanup-token"); - return workflowHeader?.trim() === expectedToken; + if (!workflowHeader) return false; + return safeCompare(workflowHeader.trim(), expectedToken); } async function runCleanupInline(payload: CleanupPayload): Promise<{ @@ -80,6 +86,7 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ let batches = 0; let totalDeleted = 0; + let hitBatchLimit = false; while (batches < MAX_CLEANUP_BATCHES) { batches += 1; @@ -100,10 +107,16 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ dryRun: false, }); totalDeleted += deletedBatch.deleted; + + if (batches >= MAX_CLEANUP_BATCHES) { + hitBatchLimit = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); } - if (batches >= MAX_CLEANUP_BATCHES) { + if (hitBatchLimit) { throw new Error(`Cleanup exceeded maximum batches (${MAX_CLEANUP_BATCHES})`); } @@ -126,6 +139,7 @@ const workflow = serve(async (context) => { let batches = 0; let totalDeleted = 0; + let hitBatchLimit = false; while (batches < MAX_CLEANUP_BATCHES) { batches += 1; @@ -152,10 +166,16 @@ const workflow = serve(async (context) => { }); totalDeleted += deletedBatch.deleted; + + if (batches >= MAX_CLEANUP_BATCHES) { + hitBatchLimit = true; + break; + } + await context.sleep(`sleep-${batches}`, "1s"); } - if (batches >= MAX_CLEANUP_BATCHES) { + if (hitBatchLimit) { throw new Error(`Cleanup exceeded maximum batches (${MAX_CLEANUP_BATCHES})`); } @@ -177,15 +197,8 @@ export const Route = createFileRoute("/api/workflow/cleanup")({ return workflow.POST({ request }); } - const hasCleanupToken = hasValidCleanupToken(request.headers); - if (!hasCleanupToken) { - if (!isSameOrigin(request)) { - return json({ error: "Invalid origin" }, { status: 403 }); - } - const authToken = await getConvexAuthToken(request); - if (!authToken) { - return json({ error: "Unauthorized" }, { status: 401 }); - } + if (!hasValidCleanupToken(request.headers)) { + return json({ error: "Unauthorized — cleanup token required" }, { status: 401 }); } let payloadRaw: unknown; diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index a1872469..8ec67767 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -23,9 +23,6 @@ type DeleteStep = | "delete-files"; async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { - const forwardedToken = headers.get("x-convex-token"); - if (forwardedToken) return forwardedToken; - if (!CONVEX_SITE_URL) return null; const cookie = headers.get("cookie"); @@ -307,7 +304,6 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ try { const triggerHeaders = getWorkflowTriggerHeaders(request.headers); - triggerHeaders["x-convex-token"] = authToken; const { workflowRunId } = await workflowClient.trigger({ url: request.url, body: normalizedPayload, diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index d9265678..b4691ff8 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -41,9 +41,6 @@ type ChatExportData = { }; async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { - const forwardedToken = headers.get("x-convex-token"); - if (forwardedToken) return forwardedToken; - if (!CONVEX_SITE_URL) return null; const cookie = headers.get("cookie"); @@ -273,7 +270,6 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ try { const triggerHeaders = getWorkflowTriggerHeaders(request.headers); - triggerHeaders["x-convex-token"] = authToken; const { workflowRunId } = await workflowClient.trigger({ url: request.url, body: normalizedPayload, diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 10d6569e..bd959936 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -196,8 +196,6 @@ const workflow = serve(async (context) => { } = payload; const authToken = await context.run("resolve-auth", async () => { - const forwardedToken = context.headers.get("x-convex-token"); - if (forwardedToken) return forwardedToken; return getAuthTokenFromWorkflowHeaders(context.headers); }); if (!authToken) { @@ -362,7 +360,6 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ try { const headers = getWorkflowTriggerHeaders(request.headers); - headers["x-convex-token"] = authToken; const { workflowRunId } = await workflowClient.trigger({ url: request.url, body: normalizedPayload, From 3b59d07c6efda213db276deeac9d36c6aaa11cdd Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 13:04:59 -0500 Subject: [PATCH 06/40] fix(security): address remaining review findings - cleanup.ts: compare buffer byte lengths in safeCompare (non-ASCII safety) - server-auth.ts: only return null on 4xx, let 5xx fall through to cookie - crons.ts: avoid leaking token length in constant-time comparison --- apps/server/convex/crons.ts | 11 ++++------- apps/web/src/lib/server-auth.ts | 4 +++- apps/web/src/routes/api/workflow/cleanup.ts | 6 ++++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/server/convex/crons.ts b/apps/server/convex/crons.ts index 83b132f1..837cb746 100644 --- a/apps/server/convex/crons.ts +++ b/apps/server/convex/crons.ts @@ -178,15 +178,12 @@ export const runCleanupBatchForWorkflow = action({ if (!expectedToken) { throw new Error("Unauthorized"); } - // Constant-time comparison to prevent timing attacks const a = args.workflowToken; const b = expectedToken; - if (a.length !== b.length) { - throw new Error("Unauthorized"); - } - let mismatch = 0; - for (let i = 0; i < a.length; i++) { - mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + let mismatch = a.length ^ b.length; + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + mismatch |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); } if (mismatch !== 0) { throw new Error("Unauthorized"); diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index f2a85186..bf8f34ce 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -46,7 +46,9 @@ export async function getConvexAuthToken(request: Request): Promise= 400 && response.status < 500) { + return null; + } } catch { // Network error — fall through to cookie fallback for resilience. } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index b6471fb1..61b469cd 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -52,8 +52,10 @@ function parseCleanupPayload(raw: unknown): CleanupPayload | null { } function safeCompare(a: string, b: string): boolean { - if (a.length !== b.length) return false; - return timingSafeEqual(Buffer.from(a), Buffer.from(b)); + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); } function hasValidCleanupToken(headers: Headers): boolean { From 1ba14544ec7ce359cb649860f50fe47d8dec26a5 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 13:15:57 -0500 Subject: [PATCH 07/40] fix(security): address all remaining Cubic + Tembo review findings - cleanup.ts: constant-time safeCompare without length leak, stop forwarding WORKFLOW_CLEANUP_TOKEN to QStash - delete-account.ts: bounded batch loops (MAX_DELETE_BATCHES=500), rate limiting - export-chat.ts: apply exportRatelimit before processing - generate-title.ts: add rate limiting before workflow trigger - upstashUsage.ts: validate dateKey to prevent NaN/forever-TTL keys - check-redis.ts: add 5s fetch timeout - server/package.json: quote $VERCEL_GIT_COMMIT_REF - upstash.ts: remove unused chatIpRatelimit/chatRatelimit exports --- apps/server/convex/lib/upstashUsage.ts | 3 +++ apps/server/package.json | 2 +- apps/web/src/lib/upstash.ts | 2 -- apps/web/src/routes/api/workflow/cleanup.ts | 13 ++++++------- .../src/routes/api/workflow/delete-account.ts | 17 ++++++++++++++--- apps/web/src/routes/api/workflow/export-chat.ts | 9 ++++++++- .../src/routes/api/workflow/generate-title.ts | 9 ++++++++- scripts/check-redis.ts | 1 + 8 files changed, 41 insertions(+), 15 deletions(-) diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index e7841e5a..86cee0b1 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -18,6 +18,9 @@ function usageCounterKey(userId: string, dateKey: string): string { function getMidnightUtcEpochSeconds(dateKey: string): number { const [year, month, day] = dateKey.split("-").map((segment) => Number.parseInt(segment, 10)); const expiresAtMs = Date.UTC(year, month - 1, day + 1, 0, 0, 0, 0); + if (!Number.isFinite(expiresAtMs)) { + throw new Error(`Invalid dateKey: ${dateKey}`); + } return Math.floor(expiresAtMs / 1000); } diff --git a/apps/server/package.json b/apps/server/package.json index 9556c7a6..6b125427 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "convex dev", - "build": "if [ \"$DEPLOYMENT\" = \"preview\" ]; then convex deploy --yes --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL --cmd 'cd ../web && bunx vite build' --preview-run previewSeed --preview-create $VERCEL_GIT_COMMIT_REF; else convex deploy --yes --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL --cmd 'cd ../web && bunx vite build'; fi", + "build": "if [ \"$DEPLOYMENT\" = \"preview\" ]; then convex deploy --yes --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL --cmd 'cd ../web && bunx vite build' --preview-run previewSeed --preview-create \"$VERCEL_GIT_COMMIT_REF\"; else convex deploy --yes --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL --cmd 'cd ../web && bunx vite build'; fi", "deploy": "bunx --bun convex deploy", "deploy:prod": "./scripts/deploy-prod.sh", "lint": "oxlint", diff --git a/apps/web/src/lib/upstash.ts b/apps/web/src/lib/upstash.ts index e4900441..b260ef12 100644 --- a/apps/web/src/lib/upstash.ts +++ b/apps/web/src/lib/upstash.ts @@ -29,8 +29,6 @@ function createSlidingWindowRatelimit( } export const chatUserRatelimit = createSlidingWindowRatelimit(30, "60 s", "ratelimit:chat:user"); -export const chatIpRatelimit = createSlidingWindowRatelimit(60, "60 s", "ratelimit:chat:ip"); -export const chatRatelimit = chatUserRatelimit; export const uploadRatelimit = createSlidingWindowRatelimit(20, "60 s", "ratelimit:upload:user"); export const exportRatelimit = createSlidingWindowRatelimit(10, "60 s", "ratelimit:export:user"); export const authRatelimit = createSlidingWindowRatelimit(20, "60 s", "ratelimit:auth:user"); diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index 61b469cd..dd221463 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -1,4 +1,3 @@ -import { timingSafeEqual } from "node:crypto"; import { createFileRoute } from "@tanstack/react-router"; import { json } from "@tanstack/react-start"; import { serve } from "@upstash/workflow/tanstack"; @@ -54,8 +53,12 @@ function parseCleanupPayload(raw: unknown): CleanupPayload | null { function safeCompare(a: string, b: string): boolean { const bufA = Buffer.from(a); const bufB = Buffer.from(b); - if (bufA.length !== bufB.length) return false; - return timingSafeEqual(bufA, bufB); + let mismatch = bufA.length ^ bufB.length; + const len = Math.max(bufA.length, bufB.length); + for (let i = 0; i < len; i++) { + mismatch |= (bufA[i] || 0) ^ (bufB[i] || 0); + } + return mismatch === 0; } function hasValidCleanupToken(headers: Headers): boolean { @@ -235,10 +238,6 @@ export const Route = createFileRoute("/api/workflow/cleanup")({ const triggerHeaders: Record = { "Content-Type": "application/json", }; - const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN?.trim(); - if (workflowToken) { - triggerHeaders.authorization = `Bearer ${workflowToken}`; - } const { workflowRunId } = await workflowClient.trigger({ url: request.url, diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 8ec67767..4571c176 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -5,7 +5,7 @@ import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; -import { upstashRedis, workflowClient } from "@/lib/upstash"; +import { authRatelimit, upstashRedis, workflowClient } from "@/lib/upstash"; const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; @@ -22,6 +22,8 @@ type DeleteStep = | "delete-chats" | "delete-files"; +const MAX_DELETE_BATCHES = 500; + async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { if (!CONVEX_SITE_URL) return null; @@ -133,8 +135,10 @@ async function runDeleteAccountInline( const runBatchStep = async (step: DeleteStep) => { let totalDeleted = 0; + let iteration = 0; - while (true) { + while (iteration < MAX_DELETE_BATCHES) { + iteration += 1; const result = await convexClient.action(api.users.deleteAccountWorkflowStep, { userId: convexUserId, externalId, @@ -190,7 +194,7 @@ const workflow = serve(async (context) => { let totalDeleted = 0; let iteration = 0; - while (true) { + while (iteration < MAX_DELETE_BATCHES) { iteration += 1; const result = await context.run(`${stepName}-${iteration}`, async () => { return convexClient.action(api.users.deleteAccountWorkflowStep, { @@ -268,6 +272,13 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ return json({ error: "Unauthorized" }, { status: 401 }); } + if (authRatelimit) { + const rl = await authRatelimit.limit(`delete-account:${authConvexUser._id}`); + if (!rl.success) { + return json({ error: "Rate limit exceeded" }, { status: 429 }); + } + } + let payloadRaw: unknown; try { payloadRaw = await request.json(); diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index b4691ff8..c813e8bd 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -5,7 +5,7 @@ import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; -import { workflowClient } from "@/lib/upstash"; +import { exportRatelimit, workflowClient } from "@/lib/upstash"; const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; @@ -234,6 +234,13 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ return json({ error: "Unauthorized" }, { status: 401 }); } + if (exportRatelimit) { + const rl = await exportRatelimit.limit(`export-chat:${authConvexUser._id}`); + if (!rl.success) { + return json({ error: "Rate limit exceeded" }, { status: 429 }); + } + } + let payloadRaw: unknown; try { payloadRaw = await request.json(); diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index bd959936..bead7263 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -6,7 +6,7 @@ import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; import { decryptSecret } from "@/lib/server-crypto"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; -import { workflowClient } from "@/lib/upstash"; +import { authRatelimit, workflowClient } from "@/lib/upstash"; const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; @@ -331,6 +331,13 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ return json({ error: "Unauthorized" }, { status: 401 }); } + if (authRatelimit) { + const rl = await authRatelimit.limit(`generate-title:${authConvexUser._id}`); + if (!rl.success) { + return json({ error: "Rate limit exceeded" }, { status: 429 }); + } + } + const normalizedPayload: GenerateTitlePayload = { ...payload, userId: authConvexUser._id, diff --git a/scripts/check-redis.ts b/scripts/check-redis.ts index 0507eed5..320bac06 100644 --- a/scripts/check-redis.ts +++ b/scripts/check-redis.ts @@ -67,6 +67,7 @@ async function main() { "Content-Type": "application/json", }, body: JSON.stringify([["PING"]]), + signal: AbortSignal.timeout(5_000), }); if (!response.ok) { From 6b80816ea14a308a853072acfc74fd14838334d6 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 13:21:10 -0500 Subject: [PATCH 08/40] fix(security): use HMAC+timingSafeEqual for cleanup token comparison Replace hand-rolled XOR loop with crypto.createHmac('sha256') + timingSafeEqual for verified constant-time comparison in Node.js runtime. --- apps/web/src/routes/api/workflow/cleanup.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index dd221463..84bef41f 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -1,3 +1,4 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; import { createFileRoute } from "@tanstack/react-router"; import { json } from "@tanstack/react-start"; import { serve } from "@upstash/workflow/tanstack"; @@ -51,14 +52,10 @@ function parseCleanupPayload(raw: unknown): CleanupPayload | null { } function safeCompare(a: string, b: string): boolean { - const bufA = Buffer.from(a); - const bufB = Buffer.from(b); - let mismatch = bufA.length ^ bufB.length; - const len = Math.max(bufA.length, bufB.length); - for (let i = 0; i < len; i++) { - mismatch |= (bufA[i] || 0) ^ (bufB[i] || 0); - } - return mismatch === 0; + const key = "cleanup-token-compare"; + const hmacA = createHmac("sha256", key).update(a).digest(); + const hmacB = createHmac("sha256", key).update(b).digest(); + return timingSafeEqual(hmacA, hmacB); } function hasValidCleanupToken(headers: Headers): boolean { From ee54eec4d2f091442c709f544d2474e4f260bc3e Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 13:47:06 -0500 Subject: [PATCH 09/40] fix(security): extract cleanup action to node runtime, fix auth bypass and error handling - Move runCleanupBatchForWorkflow to cleanupAction.ts with 'use node' for crypto.timingSafeEqual (fixes hand-rolled XOR timing side-channel in crons.ts) - Keep as public action() so ConvexHttpClient can call it (token-gated via HMAC) - Fix server-auth.ts: return null when token endpoint returns 200 OK with no token (prevents auth bypass via cookie fallback) - Move getMidnightUtcEpochSeconds inside try/catch in upstashUsage.ts - Update cleanup.ts workflow to reference new api.cleanupAction path - Remove stale comment from crons.ts --- apps/server/convex/_generated/api.d.ts | 2 + apps/server/convex/cleanupAction.ts | 44 +++++++++++++++++++++ apps/server/convex/crons.ts | 44 +-------------------- apps/server/convex/lib/upstashUsage.ts | 2 +- apps/web/src/lib/server-auth.ts | 1 + apps/web/src/routes/api/workflow/cleanup.ts | 8 ++-- 6 files changed, 53 insertions(+), 48 deletions(-) create mode 100644 apps/server/convex/cleanupAction.ts diff --git a/apps/server/convex/_generated/api.d.ts b/apps/server/convex/_generated/api.d.ts index 036493c6..83b34577 100644 --- a/apps/server/convex/_generated/api.d.ts +++ b/apps/server/convex/_generated/api.d.ts @@ -11,6 +11,7 @@ import type * as auth from "../auth.js"; import type * as backgroundStream from "../backgroundStream.js"; import type * as chats from "../chats.js"; +import type * as cleanupAction from "../cleanupAction.js"; import type * as config_constants from "../config/constants.js"; import type * as crons from "../crons.js"; import type * as env from "../env.js"; @@ -46,6 +47,7 @@ declare const fullApi: ApiFromModules<{ auth: typeof auth; backgroundStream: typeof backgroundStream; chats: typeof chats; + cleanupAction: typeof cleanupAction; "config/constants": typeof config_constants; crons: typeof crons; env: typeof env; diff --git a/apps/server/convex/cleanupAction.ts b/apps/server/convex/cleanupAction.ts new file mode 100644 index 00000000..b016a8cb --- /dev/null +++ b/apps/server/convex/cleanupAction.ts @@ -0,0 +1,44 @@ +"use node"; + +import { createHmac, timingSafeEqual } from "node:crypto"; +import { action } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { v } from "convex/values"; + +function safeCompare(a: string, b: string): boolean { + const key = "cleanup-token-compare"; + const hmacA = createHmac("sha256", key).update(a).digest(); + const hmacB = createHmac("sha256", key).update(b).digest(); + return timingSafeEqual(hmacA, hmacB); +} + +export const runCleanupBatchForWorkflow = action({ + args: { + workflowToken: v.string(), + retentionDays: v.optional(v.number()), + batchSize: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + }, + returns: v.object({ + success: v.boolean(), + deleted: v.number(), + dryRun: v.boolean(), + cutoffDate: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ success: boolean; deleted: number; dryRun: boolean; cutoffDate: string }> => { + const expectedToken = process.env.WORKFLOW_CLEANUP_TOKEN; + if (!expectedToken || !safeCompare(args.workflowToken, expectedToken)) { + throw new Error("Unauthorized"); + } + + const result = await ctx.runMutation(internal.crons.cleanupSoftDeletedRecords, { + retentionDays: args.retentionDays, + batchSize: args.batchSize, + dryRun: args.dryRun, + }); + return result; + }, +}); diff --git a/apps/server/convex/crons.ts b/apps/server/convex/crons.ts index 837cb746..4ecffac2 100644 --- a/apps/server/convex/crons.ts +++ b/apps/server/convex/crons.ts @@ -13,8 +13,7 @@ */ import { cronJobs } from "convex/server"; -import { action, internalMutation } from "./_generated/server"; -import { internal } from "./_generated/api"; +import { internalMutation } from "./_generated/server"; import { v } from "convex/values"; import { decrementStat, getStats, STAT_KEYS } from "./lib/dbStats"; import { createLogger } from "./lib/logger"; @@ -157,47 +156,6 @@ export const cleanupSoftDeletedRecords = internalMutation({ }, }); -export const runCleanupBatchForWorkflow = action({ - args: { - workflowToken: v.string(), - retentionDays: v.optional(v.number()), - batchSize: v.optional(v.number()), - dryRun: v.optional(v.boolean()), - }, - returns: v.object({ - success: v.boolean(), - deleted: v.number(), - dryRun: v.boolean(), - cutoffDate: v.string(), - }), - handler: async ( - ctx, - args, - ): Promise<{ success: boolean; deleted: number; dryRun: boolean; cutoffDate: string }> => { - const expectedToken = process.env.WORKFLOW_CLEANUP_TOKEN; - if (!expectedToken) { - throw new Error("Unauthorized"); - } - const a = args.workflowToken; - const b = expectedToken; - let mismatch = a.length ^ b.length; - const len = Math.max(a.length, b.length); - for (let i = 0; i < len; i++) { - mismatch |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); - } - if (mismatch !== 0) { - throw new Error("Unauthorized"); - } - - const result = await ctx.runMutation(internal.crons.cleanupSoftDeletedRecords, { - retentionDays: args.retentionDays, - batchSize: args.batchSize, - dryRun: args.dryRun, - }); - return result; - }, -}); - /** * Cleanup expired rate limit buckets * diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index 86cee0b1..11683bc2 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -79,9 +79,9 @@ export async function incrementDailyUsageInUpstash( if (!Number.isFinite(usageCents) || usageCents <= 0) return; const key = usageCounterKey(userId, dateKey); - const expiresAt = getMidnightUtcEpochSeconds(dateKey); try { + const expiresAt = getMidnightUtcEpochSeconds(dateKey); await executePipeline([ ["INCRBY", key, Math.floor(usageCents)], ["EXPIREAT", key, expiresAt], diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index bf8f34ce..9f7627b0 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -45,6 +45,7 @@ export async function getConvexAuthToken(request: Request): Promise= 400 && response.status < 500) { return null; diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index 84bef41f..e416c2ce 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -92,7 +92,7 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ while (batches < MAX_CLEANUP_BATCHES) { batches += 1; - const preview = await convexClient.action(api.crons.runCleanupBatchForWorkflow, { + const preview = await convexClient.action(api.cleanupAction.runCleanupBatchForWorkflow, { workflowToken, retentionDays, batchSize, @@ -102,7 +102,7 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ break; } - const deletedBatch = await convexClient.action(api.crons.runCleanupBatchForWorkflow, { + const deletedBatch = await convexClient.action(api.cleanupAction.runCleanupBatchForWorkflow, { workflowToken, retentionDays, batchSize, @@ -146,7 +146,7 @@ const workflow = serve(async (context) => { while (batches < MAX_CLEANUP_BATCHES) { batches += 1; const preview = await context.run(`query-batch-${batches}`, async () => { - return convexClient.action(api.crons.runCleanupBatchForWorkflow, { + return convexClient.action(api.cleanupAction.runCleanupBatchForWorkflow, { workflowToken, retentionDays, batchSize, @@ -159,7 +159,7 @@ const workflow = serve(async (context) => { } const deletedBatch = await context.run(`delete-batch-${batches}`, async () => { - return convexClient.action(api.crons.runCleanupBatchForWorkflow, { + return convexClient.action(api.cleanupAction.runCleanupBatchForWorkflow, { workflowToken, retentionDays, batchSize, From 83024165a5585ef5d2272b8936b5f67b3b03a1f3 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 14:00:57 -0500 Subject: [PATCH 10/40] fix(security): stop forwarding cookies to QStash, add rate limiting and bounds validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove session cookie forwarding from all 3 workflow trigger headers (generate-title, export-chat, delete-account) — cookies no longer sent to third-party QStash infrastructure - Pass pre-resolved auth token via payload instead of cookie headers - Remove getAuthTokenFromWorkflowHeaders from all workflow files - Add IP-based rate limiting to /api/models endpoint (30 req/60s) - Add 10s fetch timeout to OpenRouter models call - Fix OpenRouter proxy: use Accept header instead of Content-Type on GET, return 502 for upstream errors instead of forwarding status codes - Fix backgroundStream.ts double-counting: separate Convex mutation retry from Upstash increment to prevent billing double-charge on retry - Add bounds validation to cleanupAction.ts (retentionDays 1-3650, batchSize 1-1000) - Use env-based HMAC key in cleanupAction.ts safeCompare - Log warning when Upstash Redis is not configured (rate limiting disabled) --- apps/server/convex/backgroundStream.ts | 36 ++++++++++--------- apps/server/convex/cleanupAction.ts | 16 +++++++-- apps/web/src/lib/upstash.ts | 4 +++ apps/web/src/routes/api/models.ts | 31 ++++++++++++---- .../src/routes/api/workflow/delete-account.ts | 35 ++++-------------- .../src/routes/api/workflow/export-chat.ts | 35 ++++-------------- .../src/routes/api/workflow/generate-title.ts | 35 ++++-------------- 7 files changed, 80 insertions(+), 112 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index 6db509be..ccfc141c 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -1372,24 +1372,28 @@ export const executeStream = internalAction({ job.messages, fullContent, ); - if (usageCents && usageCents > 0) { - for (let attempt = 0; attempt < 2; attempt++) { - try { - await ctx.runMutation(internal.users.incrementAiUsage, { - userId: job.userId, - usageCents, - }); - await incrementDailyUsageInUpstash( - job.userId, - getCurrentDateKey(), - usageCents, - ); - break; - } catch { - // No-op; retried below. - } + if (usageCents && usageCents > 0) { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await ctx.runMutation(internal.users.incrementAiUsage, { + userId: job.userId, + usageCents, + }); + break; + } catch { + if (attempt === 1) break; } } + try { + await incrementDailyUsageInUpstash( + job.userId, + getCurrentDateKey(), + usageCents, + ); + } catch { + // Upstash usage tracking is best-effort; don't fail the stream. + } + } } const thinkingTimeMs = getThinkingTimeMs(); diff --git a/apps/server/convex/cleanupAction.ts b/apps/server/convex/cleanupAction.ts index b016a8cb..f0242239 100644 --- a/apps/server/convex/cleanupAction.ts +++ b/apps/server/convex/cleanupAction.ts @@ -6,7 +6,7 @@ import { internal } from "./_generated/api"; import { v } from "convex/values"; function safeCompare(a: string, b: string): boolean { - const key = "cleanup-token-compare"; + const key = process.env.WORKFLOW_CLEANUP_TOKEN ?? "cleanup-token-compare"; const hmacA = createHmac("sha256", key).update(a).digest(); const hmacB = createHmac("sha256", key).update(b).digest(); return timingSafeEqual(hmacA, hmacB); @@ -34,9 +34,19 @@ export const runCleanupBatchForWorkflow = action({ throw new Error("Unauthorized"); } + // Bounds validation — prevent accidental full-purge or oversized batches + const retentionDays = args.retentionDays ?? 90; + if (retentionDays < 1 || retentionDays > 3650) { + throw new Error("retentionDays must be between 1 and 3650"); + } + const batchSize = args.batchSize ?? 100; + if (batchSize < 1 || batchSize > 1000) { + throw new Error("batchSize must be between 1 and 1000"); + } + const result = await ctx.runMutation(internal.crons.cleanupSoftDeletedRecords, { - retentionDays: args.retentionDays, - batchSize: args.batchSize, + retentionDays, + batchSize, dryRun: args.dryRun, }); return result; diff --git a/apps/web/src/lib/upstash.ts b/apps/web/src/lib/upstash.ts index b260ef12..60ce6540 100644 --- a/apps/web/src/lib/upstash.ts +++ b/apps/web/src/lib/upstash.ts @@ -40,6 +40,10 @@ export const workflowClient = QSTASH_TOKEN }) : null; +if (!upstashRedis) { + console.warn("[Upstash] Redis not configured — rate limiting is disabled"); +} + export function isUpstashRedisConfigured(): boolean { return upstashRedis !== null; } diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 4500aa0f..1e9afdc7 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -1,3 +1,4 @@ +import { Ratelimit } from "@upstash/ratelimit"; import { createFileRoute } from "@tanstack/react-router"; import { json } from "@tanstack/react-start"; import { upstashRedis } from "@/lib/upstash"; @@ -5,18 +6,28 @@ import { upstashRedis } from "@/lib/upstash"; const MODELS_CACHE_KEY = "openchat:models"; const MODELS_CACHE_TTL_SECONDS = 60 * 60 * 4; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; +const OPENROUTER_FETCH_TIMEOUT_MS = 10_000; + +const modelsIpRatelimit = upstashRedis + ? new Ratelimit({ + redis: upstashRedis, + limiter: Ratelimit.slidingWindow(30, "60 s"), + prefix: "ratelimit:models:ip", + }) + : null; async function fetchModelsFromOpenRouter(): Promise { const response = await fetch(OPENROUTER_MODELS_URL, { headers: { - "Content-Type": "application/json", + Accept: "application/json", }, + signal: AbortSignal.timeout(OPENROUTER_FETCH_TIMEOUT_MS), }); if (!response.ok) { return json( - { error: `OpenRouter API error: ${response.status}` }, - { status: response.status }, + { error: "Upstream service error" }, + { status: 502 }, ); } @@ -45,10 +56,18 @@ async function fetchModelsFromOpenRouter(): Promise { export const Route = createFileRoute("/api/models")({ server: { handlers: { - GET: async () => { - if (!upstashRedis) { - return fetchModelsFromOpenRouter(); + GET: async ({ request }) => { + if (modelsIpRatelimit) { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + const rl = await modelsIpRatelimit.limit(ip); + if (!rl.success) { + return json({ error: "Rate limit exceeded" }, { status: 429 }); } + } + + if (!upstashRedis) { + return fetchModelsFromOpenRouter(); + } try { const cached = await upstashRedis.get>(MODELS_CACHE_KEY); diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 4571c176..7143ffba 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -7,12 +7,10 @@ import { createConvexServerClient } from "@/lib/convex-server"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { authRatelimit, upstashRedis, workflowClient } from "@/lib/upstash"; -const CONVEX_SITE_URL = - process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; - type DeleteAccountPayload = { userId: string; externalId: string; + authToken?: string; batchSize?: number; }; @@ -24,21 +22,6 @@ type DeleteStep = const MAX_DELETE_BATCHES = 500; -async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { - if (!CONVEX_SITE_URL) return null; - - const cookie = headers.get("cookie"); - if (!cookie) return null; - - const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { - headers: { cookie }, - }); - if (!response.ok) return null; - - const data = (await response.json()) as { token?: string } | null; - return data?.token ?? null; -} - function parseDeletePayload(raw: unknown): DeleteAccountPayload | null { if (!raw || typeof raw !== "object") return null; @@ -111,15 +94,10 @@ function isLocalWorkflowRequest(request: Request): boolean { return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; } -function getWorkflowTriggerHeaders(headers: Headers): Record { - const triggerHeaders: Record = { +function getWorkflowTriggerHeaders(): Record { + return { "Content-Type": "application/json", }; - const cookie = headers.get("cookie"); - if (cookie) { - triggerHeaders.cookie = cookie; - } - return triggerHeaders; } async function runDeleteAccountInline( @@ -180,9 +158,7 @@ async function runDeleteAccountInline( const workflow = serve(async (context) => { const { userId, externalId, batchSize } = context.requestPayload; - const authToken = await context.run("resolve-auth", async () => { - return getAuthTokenFromWorkflowHeaders(context.headers); - }); + const authToken = context.requestPayload.authToken; if (!authToken) { throw new Error("Unauthorized"); } @@ -293,6 +269,7 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ ...payload, userId: authConvexUser._id, externalId: authUser.id, + authToken, }; if (isLocalWorkflowRequest(request)) { @@ -314,7 +291,7 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ } try { - const triggerHeaders = getWorkflowTriggerHeaders(request.headers); + const triggerHeaders = getWorkflowTriggerHeaders(); const { workflowRunId } = await workflowClient.trigger({ url: request.url, body: normalizedPayload, diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index c813e8bd..6ee2e3ad 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -7,13 +7,11 @@ import { createConvexServerClient } from "@/lib/convex-server"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { exportRatelimit, workflowClient } from "@/lib/upstash"; -const CONVEX_SITE_URL = - process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; - type ExportFormat = "markdown" | "json"; type ExportChatPayload = { chatId: string; userId: string; + authToken?: string; format?: ExportFormat; }; @@ -40,21 +38,6 @@ type ChatExportData = { }>; }; -async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { - if (!CONVEX_SITE_URL) return null; - - const cookie = headers.get("cookie"); - if (!cookie) return null; - - const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { - headers: { cookie }, - }); - if (!response.ok) return null; - - const data = (await response.json()) as { token?: string } | null; - return data?.token ?? null; -} - function parseExportPayload(raw: unknown): ExportChatPayload | null { if (!raw || typeof raw !== "object") return null; @@ -86,15 +69,10 @@ function isLocalWorkflowRequest(request: Request): boolean { return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; } -function getWorkflowTriggerHeaders(headers: Headers): Record { - const triggerHeaders: Record = { +function getWorkflowTriggerHeaders(): Record { + return { "Content-Type": "application/json", }; - const cookie = headers.get("cookie"); - if (cookie) { - triggerHeaders.cookie = cookie; - } - return triggerHeaders; } function formatExportMarkdown(data: ChatExportData): string { @@ -165,9 +143,7 @@ async function runExportChatInline( const workflow = serve(async (context) => { const { chatId, userId, format = "markdown" } = context.requestPayload; - const authToken = await context.run("resolve-auth", async () => { - return getAuthTokenFromWorkflowHeaders(context.headers); - }); + const authToken = context.requestPayload.authToken; if (!authToken) { throw new Error("Unauthorized"); } @@ -254,6 +230,7 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ const normalizedPayload: ExportChatPayload = { ...payload, userId: authConvexUser._id, + authToken, }; if (isLocalWorkflowRequest(request)) { @@ -276,7 +253,7 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ } try { - const triggerHeaders = getWorkflowTriggerHeaders(request.headers); + const triggerHeaders = getWorkflowTriggerHeaders(); const { workflowRunId } = await workflowClient.trigger({ url: request.url, body: normalizedPayload, diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index bead7263..5c59f86e 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -8,9 +8,6 @@ import { decryptSecret } from "@/lib/server-crypto"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { authRatelimit, workflowClient } from "@/lib/upstash"; -const CONVEX_SITE_URL = - process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; - const TITLE_MODEL_ID = "google/gemini-2.5-flash-lite"; const TITLE_MAX_LENGTH = 200; @@ -20,6 +17,7 @@ type TitleProvider = "osschat" | "openrouter"; type GenerateTitlePayload = { chatId: string; userId: string; + authToken?: string; seedText?: string; length: TitleLength; provider: TitleProvider; @@ -90,35 +88,15 @@ function sanitizeGeneratedTitle(input: string): string { return title.slice(0, TITLE_MAX_LENGTH); } -async function getAuthTokenFromWorkflowHeaders(headers: Headers): Promise { - if (!CONVEX_SITE_URL) return null; - - const cookie = headers.get("cookie"); - if (!cookie) return null; - - const response = await fetch(`${CONVEX_SITE_URL}/api/auth/convex/token`, { - headers: { cookie }, - }); - if (!response.ok) return null; - - const data = (await response.json()) as { token?: string } | null; - return data?.token ?? null; -} - function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; } -function getWorkflowTriggerHeaders(headers: Headers): Record { - const triggerHeaders: Record = { +function getWorkflowTriggerHeaders(): Record { + return { "Content-Type": "application/json", }; - const cookie = headers.get("cookie"); - if (cookie) { - triggerHeaders.cookie = cookie; - } - return triggerHeaders; } async function runGenerateTitleInline( @@ -195,9 +173,7 @@ const workflow = serve(async (context) => { mode = "auto", } = payload; - const authToken = await context.run("resolve-auth", async () => { - return getAuthTokenFromWorkflowHeaders(context.headers); - }); + const authToken = payload.authToken; if (!authToken) { throw new Error("Unauthorized"); } @@ -341,6 +317,7 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ const normalizedPayload: GenerateTitlePayload = { ...payload, userId: authConvexUser._id, + authToken, }; if (isLocalWorkflowRequest(request)) { @@ -366,7 +343,7 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ } try { - const headers = getWorkflowTriggerHeaders(request.headers); + const headers = getWorkflowTriggerHeaders(); const { workflowRunId } = await workflowClient.trigger({ url: request.url, body: normalizedPayload, From 4f97319e012af299d642712933dd8783913b6cf9 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 14:10:22 -0500 Subject: [PATCH 11/40] fix(security): fix parser passthrough, remove hardcoded HMAC keys, handle fetch errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix P0: all 3 workflow payload parsers now pass through authToken field (generate-title, export-chat, delete-account) — prevents 'Unauthorized' on every workflow execution - Remove hardcoded HMAC key from safeCompare in cleanup.ts and cleanupAction.ts — key is now passed as parameter from the validated env var, eliminating static key in source code - Wrap fetchModelsFromOpenRouter in try/catch to handle timeout and network errors gracefully (returns 502 instead of unhandled 500) - Use Math.ceil for sub-cent usageCents in upstashUsage.ts to prevent silent usage loss from Math.floor(0.5) = 0 --- apps/server/convex/cleanupAction.ts | 9 ++++----- apps/server/convex/lib/upstashUsage.ts | 4 +++- apps/web/src/routes/api/models.ts | 17 +++++++++++------ apps/web/src/routes/api/workflow/cleanup.ts | 11 +++++------ .../src/routes/api/workflow/delete-account.ts | 1 + apps/web/src/routes/api/workflow/export-chat.ts | 1 + .../src/routes/api/workflow/generate-title.ts | 1 + 7 files changed, 26 insertions(+), 18 deletions(-) diff --git a/apps/server/convex/cleanupAction.ts b/apps/server/convex/cleanupAction.ts index f0242239..60e6401a 100644 --- a/apps/server/convex/cleanupAction.ts +++ b/apps/server/convex/cleanupAction.ts @@ -5,10 +5,9 @@ import { action } from "./_generated/server"; import { internal } from "./_generated/api"; import { v } from "convex/values"; -function safeCompare(a: string, b: string): boolean { - const key = process.env.WORKFLOW_CLEANUP_TOKEN ?? "cleanup-token-compare"; - const hmacA = createHmac("sha256", key).update(a).digest(); - const hmacB = createHmac("sha256", key).update(b).digest(); +function safeCompare(a: string, b: string, hmacKey: string): boolean { + const hmacA = createHmac("sha256", hmacKey).update(a).digest(); + const hmacB = createHmac("sha256", hmacKey).update(b).digest(); return timingSafeEqual(hmacA, hmacB); } @@ -30,7 +29,7 @@ export const runCleanupBatchForWorkflow = action({ args, ): Promise<{ success: boolean; deleted: number; dryRun: boolean; cutoffDate: string }> => { const expectedToken = process.env.WORKFLOW_CLEANUP_TOKEN; - if (!expectedToken || !safeCompare(args.workflowToken, expectedToken)) { + if (!expectedToken || !safeCompare(args.workflowToken, expectedToken, expectedToken)) { throw new Error("Unauthorized"); } diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index 11683bc2..ab5f0290 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -77,13 +77,15 @@ export async function incrementDailyUsageInUpstash( ): Promise { if (!getConfig()) return; if (!Number.isFinite(usageCents) || usageCents <= 0) return; + const roundedCents = Math.ceil(usageCents); + if (roundedCents <= 0) return; const key = usageCounterKey(userId, dateKey); try { const expiresAt = getMidnightUtcEpochSeconds(dateKey); await executePipeline([ - ["INCRBY", key, Math.floor(usageCents)], + ["INCRBY", key, roundedCents], ["EXPIREAT", key, expiresAt], ]); } catch (error) { diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 1e9afdc7..4f623c75 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -17,12 +17,17 @@ const modelsIpRatelimit = upstashRedis : null; async function fetchModelsFromOpenRouter(): Promise { - const response = await fetch(OPENROUTER_MODELS_URL, { - headers: { - Accept: "application/json", - }, - signal: AbortSignal.timeout(OPENROUTER_FETCH_TIMEOUT_MS), - }); + let response: globalThis.Response; + try { + response = await fetch(OPENROUTER_MODELS_URL, { + headers: { + Accept: "application/json", + }, + signal: AbortSignal.timeout(OPENROUTER_FETCH_TIMEOUT_MS), + }); + } catch { + return json({ error: "Upstream service unavailable" }, { status: 502 }); + } if (!response.ok) { return json( diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index e416c2ce..b7b8fe0e 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -51,10 +51,9 @@ function parseCleanupPayload(raw: unknown): CleanupPayload | null { return parsed; } -function safeCompare(a: string, b: string): boolean { - const key = "cleanup-token-compare"; - const hmacA = createHmac("sha256", key).update(a).digest(); - const hmacB = createHmac("sha256", key).update(b).digest(); +function safeCompare(a: string, b: string, hmacKey: string): boolean { + const hmacA = createHmac("sha256", hmacKey).update(a).digest(); + const hmacB = createHmac("sha256", hmacKey).update(b).digest(); return timingSafeEqual(hmacA, hmacB); } @@ -64,12 +63,12 @@ function hasValidCleanupToken(headers: Headers): boolean { const bearer = headers.get("authorization"); if (bearer?.startsWith("Bearer ")) { - return safeCompare(bearer.slice("Bearer ".length).trim(), expectedToken); + return safeCompare(bearer.slice("Bearer ".length).trim(), expectedToken, expectedToken); } const workflowHeader = headers.get("x-workflow-cleanup-token"); if (!workflowHeader) return false; - return safeCompare(workflowHeader.trim(), expectedToken); + return safeCompare(workflowHeader.trim(), expectedToken, expectedToken); } async function runCleanupInline(payload: CleanupPayload): Promise<{ diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 7143ffba..dd57029a 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -36,6 +36,7 @@ function parseDeletePayload(raw: unknown): DeleteAccountPayload | null { const parsed: DeleteAccountPayload = { userId: payload.userId.trim(), externalId: payload.externalId.trim(), + authToken: typeof payload.authToken === "string" ? payload.authToken : undefined, }; if (payload.batchSize !== undefined) { diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 6ee2e3ad..32e1fd7c 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -60,6 +60,7 @@ function parseExportPayload(raw: unknown): ExportChatPayload | null { return { chatId: payload.chatId.trim(), userId: payload.userId.trim(), + authToken: typeof payload.authToken === "string" ? payload.authToken : undefined, format, }; } diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 5c59f86e..964f501d 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -61,6 +61,7 @@ function parseGenerateTitlePayload(raw: unknown): GenerateTitlePayload | null { return { chatId: payload.chatId.trim(), userId: payload.userId.trim(), + authToken: typeof payload.authToken === "string" ? payload.authToken : undefined, seedText: payload.seedText, length: payload.length, provider: payload.provider, From 04de6101d4ac6299cfeb9edcfa89de3fa2039261 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 14:21:28 -0500 Subject: [PATCH 12/40] fix(security): add NaN guard to bounds validation, log fetch errors - Add Number.isFinite() checks to retentionDays and batchSize validation in cleanupAction.ts (NaN bypasses comparison operators) - Log OpenRouter fetch failures in models.ts catch block for observability --- apps/server/convex/cleanupAction.ts | 4 ++-- apps/web/src/routes/api/models.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/convex/cleanupAction.ts b/apps/server/convex/cleanupAction.ts index 60e6401a..9e2588ed 100644 --- a/apps/server/convex/cleanupAction.ts +++ b/apps/server/convex/cleanupAction.ts @@ -35,11 +35,11 @@ export const runCleanupBatchForWorkflow = action({ // Bounds validation — prevent accidental full-purge or oversized batches const retentionDays = args.retentionDays ?? 90; - if (retentionDays < 1 || retentionDays > 3650) { + if (!Number.isFinite(retentionDays) || retentionDays < 1 || retentionDays > 3650) { throw new Error("retentionDays must be between 1 and 3650"); } const batchSize = args.batchSize ?? 100; - if (batchSize < 1 || batchSize > 1000) { + if (!Number.isFinite(batchSize) || batchSize < 1 || batchSize > 1000) { throw new Error("batchSize must be between 1 and 1000"); } diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 4f623c75..74caf7f8 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -25,7 +25,8 @@ async function fetchModelsFromOpenRouter(): Promise { }, signal: AbortSignal.timeout(OPENROUTER_FETCH_TIMEOUT_MS), }); - } catch { + } catch (error) { + console.warn("[Models API] OpenRouter fetch failed:", error); return json({ error: "Upstream service unavailable" }, { status: 502 }); } From 76c4e047d458db6d0d6fea26c1f5f8c9badc5310 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 15:15:32 -0500 Subject: [PATCH 13/40] fix(security): remove workflow token persistence and harden cleanup/model endpoints - Replace QStash payload authToken with short-lived authTokenRef keys stored in Upstash Redis for generate-title/export-chat/delete-account workflows - Make cleanup batch executor internalAction and add Convex HTTP bridge endpoint to avoid public action exposure - Harden /api/models with trusted IP extraction, missing-IP rejection, and fully wrapped upstream fetch/body handling - Fix stream meta parsing for Upstash auto-deserialized objects and batch delete readStatus/promptTemplates in users cleanup - Add workflow payload validation, IPv6 localhost support, and clean indentation issues flagged by reviews --- apps/server/convex/cleanupAction.ts | 4 +- apps/server/convex/http.ts | 44 ++++++++- apps/server/convex/users.ts | 38 +++++--- apps/web/src/components/app-sidebar.tsx | 16 +-- apps/web/src/lib/redis.ts | 27 +++++- apps/web/src/lib/workflow-auth-token.ts | 26 +++++ apps/web/src/routes/api/models.ts | 97 ++++++++++++------- apps/web/src/routes/api/openrouter-key.ts | 76 +++++++-------- apps/web/src/routes/api/workflow/cleanup.ts | 58 ++++++++--- .../src/routes/api/workflow/delete-account.ts | 66 +++++++++---- .../src/routes/api/workflow/export-chat.ts | 53 ++++++++-- .../src/routes/api/workflow/generate-title.ts | 38 ++++++-- 12 files changed, 392 insertions(+), 151 deletions(-) create mode 100644 apps/web/src/lib/workflow-auth-token.ts diff --git a/apps/server/convex/cleanupAction.ts b/apps/server/convex/cleanupAction.ts index 9e2588ed..700a326e 100644 --- a/apps/server/convex/cleanupAction.ts +++ b/apps/server/convex/cleanupAction.ts @@ -1,7 +1,7 @@ "use node"; import { createHmac, timingSafeEqual } from "node:crypto"; -import { action } from "./_generated/server"; +import { internalAction } from "./_generated/server"; import { internal } from "./_generated/api"; import { v } from "convex/values"; @@ -11,7 +11,7 @@ function safeCompare(a: string, b: string, hmacKey: string): boolean { return timingSafeEqual(hmacA, hmacB); } -export const runCleanupBatchForWorkflow = action({ +export const runCleanupBatchForWorkflow = internalAction({ args: { workflowToken: v.string(), retentionDays: v.optional(v.number()), diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index 7a3db9be..a14f4120 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -1,7 +1,7 @@ import "./polyfills"; import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { api, internal } from "./_generated/api"; import { authComponent, createAuth } from "./auth"; import { getAllowedOrigins, getCorsOrigin } from "./lib/origins"; @@ -65,4 +65,46 @@ http.route({ }), }); +http.route({ + path: "/workflow/cleanup-batch", + method: "POST", + handler: httpAction(async (ctx, request) => { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON payload" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const payload = body as { + workflowToken?: unknown; + retentionDays?: unknown; + batchSize?: unknown; + dryRun?: unknown; + }; + + if (typeof payload.workflowToken !== "string" || payload.workflowToken.trim().length === 0) { + return new Response(JSON.stringify({ error: "workflowToken is required" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const result = await ctx.runAction(internal.cleanupAction.runCleanupBatchForWorkflow, { + workflowToken: payload.workflowToken.trim(), + retentionDays: typeof payload.retentionDays === "number" ? payload.retentionDays : undefined, + batchSize: typeof payload.batchSize === "number" ? payload.batchSize : undefined, + dryRun: typeof payload.dryRun === "boolean" ? payload.dryRun : undefined, + }); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), +}); + export default http; diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 8ca01058..8c15f6e5 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -753,20 +753,30 @@ export const deleteUserRecord = internalMutation({ paginationOpts: { cursor: null, numItems: 1 }, }); - const readStatuses = await ctx.db - .query("chatReadStatus") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .collect(); - for (const status of readStatuses) { - await ctx.db.delete(status._id); + while (true) { + const readStatuses = await ctx.db + .query("chatReadStatus") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(100); + if (readStatuses.length === 0) { + break; + } + for (const status of readStatuses) { + await ctx.db.delete(status._id); + } } - const templates = await ctx.db - .query("promptTemplates") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .collect(); - for (const template of templates) { - await ctx.db.delete(template._id); + while (true) { + const templates = await ctx.db + .query("promptTemplates") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(100); + if (templates.length === 0) { + break; + } + for (const template of templates) { + await ctx.db.delete(template._id); + } } const profile = await ctx.db @@ -837,8 +847,8 @@ export const deleteAccountWorkflowStep = action({ const result: { success: boolean } = await ctx.runMutation( internal.users.deleteUserRecord, { - userId, - externalId: args.externalId, + userId, + externalId: args.externalId, }, ); return { diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index ac4a70aa..de35a1a8 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -448,14 +448,14 @@ export function AppSidebar({ title: generatedTitle, force: true, }); - } catch (error) { - console.warn("[Chat] Title regeneration failed:", error); - const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; - toast.error(message); - } finally { - setTitleGenerating(chatId, false); - } - }; + } catch (error) { + console.warn("[Chat] Title regeneration failed:", error); + const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; + toast.error(message); + } finally { + setTitleGenerating(chatId, false); + } + }; const handleDeleteChat = useCallback( async (chatId: string) => { diff --git a/apps/web/src/lib/redis.ts b/apps/web/src/lib/redis.ts index b1eeb564..0fd5c441 100644 --- a/apps/web/src/lib/redis.ts +++ b/apps/web/src/lib/redis.ts @@ -56,12 +56,29 @@ async function getConnectedClient(): Promise { } function parseStreamMeta(value: unknown): StreamMeta | null { - if (typeof value !== "string") return null; - try { - return JSON.parse(value) as StreamMeta; - } catch { - return null; + if (!value) return null; + if (typeof value === "string") { + try { + return JSON.parse(value) as StreamMeta; + } catch { + return null; + } + } + if (typeof value === "object") { + const candidate = value as Partial; + if ( + (candidate.status === "streaming" || + candidate.status === "completed" || + candidate.status === "error") && + typeof candidate.chatId === "string" && + typeof candidate.userId === "string" && + typeof candidate.messageId === "string" && + typeof candidate.startedAt === "number" + ) { + return candidate as StreamMeta; + } } + return null; } export async function initStream( diff --git a/apps/web/src/lib/workflow-auth-token.ts b/apps/web/src/lib/workflow-auth-token.ts new file mode 100644 index 00000000..9859dbbf --- /dev/null +++ b/apps/web/src/lib/workflow-auth-token.ts @@ -0,0 +1,26 @@ +import { upstashRedis } from "@/lib/upstash"; + +const WORKFLOW_AUTH_TOKEN_PREFIX = "workflow:auth-token"; +const WORKFLOW_AUTH_TOKEN_TTL_SECONDS = 60 * 60; + +function createWorkflowAuthTokenKey(): string { + return `${WORKFLOW_AUTH_TOKEN_PREFIX}:${crypto.randomUUID()}`; +} + +export async function storeWorkflowAuthToken(authToken: string): Promise { + if (!upstashRedis) return null; + const key = createWorkflowAuthTokenKey(); + await upstashRedis.set(key, authToken, { + ex: WORKFLOW_AUTH_TOKEN_TTL_SECONDS, + }); + return key; +} + +export async function getWorkflowAuthToken(key: string): Promise { + if (!upstashRedis) return null; + const value = await upstashRedis.get(key); + if (typeof value !== "string" || value.length === 0) { + return null; + } + return value; +} diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 74caf7f8..f025adfd 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -17,69 +17,92 @@ const modelsIpRatelimit = upstashRedis : null; async function fetchModelsFromOpenRouter(): Promise { - let response: globalThis.Response; try { - response = await fetch(OPENROUTER_MODELS_URL, { + const response = await fetch(OPENROUTER_MODELS_URL, { headers: { Accept: "application/json", }, signal: AbortSignal.timeout(OPENROUTER_FETCH_TIMEOUT_MS), }); + + if (!response.ok) { + return json( + { error: "Upstream service error" }, + { status: 502 }, + ); + } + + const payload = await response.text(); + + if (upstashRedis) { + try { + await upstashRedis.set(MODELS_CACHE_KEY, payload, { + ex: MODELS_CACHE_TTL_SECONDS, + }); + } catch (error) { + console.warn("[Models API] Failed to write cache:", error); + } + } + + return new Response(payload, { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "X-Models-Cache": "MISS", + }, + }); } catch (error) { console.warn("[Models API] OpenRouter fetch failed:", error); return json({ error: "Upstream service unavailable" }, { status: 502 }); } +} - if (!response.ok) { - return json( - { error: "Upstream service error" }, - { status: 502 }, - ); - } +function getClientIp(request: Request): string | null { + const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); + if (cfConnectingIp) return cfConnectingIp; - const payload = await response.text(); + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; - if (upstashRedis) { - try { - await upstashRedis.set(MODELS_CACHE_KEY, payload, { - ex: MODELS_CACHE_TTL_SECONDS, - }); - } catch (error) { - console.warn("[Models API] Failed to write cache:", error); - } + const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim(); + if (vercelForwardedFor) { + const first = vercelForwardedFor.split(",")[0]?.trim(); + if (first) return first; } - return new Response(payload, { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store", - "X-Models-Cache": "MISS", - }, - }); + const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); + if (forwardedFor) { + const first = forwardedFor.split(",")[0]?.trim(); + if (first) return first; + } + + return null; } export const Route = createFileRoute("/api/models")({ server: { handlers: { - GET: async ({ request }) => { - if (modelsIpRatelimit) { - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; - const rl = await modelsIpRatelimit.limit(ip); - if (!rl.success) { - return json({ error: "Rate limit exceeded" }, { status: 429 }); + GET: async ({ request }) => { + if (modelsIpRatelimit) { + const ip = getClientIp(request); + if (!ip) { + return json({ error: "Unable to identify client" }, { status: 400 }); + } + const rl = await modelsIpRatelimit.limit(ip); + if (!rl.success) { + return json({ error: "Rate limit exceeded" }, { status: 429 }); + } } - } - if (!upstashRedis) { - return fetchModelsFromOpenRouter(); - } + if (!upstashRedis) { + return fetchModelsFromOpenRouter(); + } try { const cached = await upstashRedis.get>(MODELS_CACHE_KEY); if (cached) { - const body = - typeof cached === "string" ? cached : JSON.stringify(cached); + const body = typeof cached === "string" ? cached : JSON.stringify(cached); return new Response(body, { status: 200, headers: { diff --git a/apps/web/src/routes/api/openrouter-key.ts b/apps/web/src/routes/api/openrouter-key.ts index 7a6b1e1f..3cb62495 100644 --- a/apps/web/src/routes/api/openrouter-key.ts +++ b/apps/web/src/routes/api/openrouter-key.ts @@ -57,28 +57,28 @@ export const Route = createFileRoute("/api/openrouter-key")({ const body = await request.json(); const apiKey = typeof body?.apiKey === "string" ? body.apiKey.trim() : ""; - if (!apiKey) { - return json({ error: "apiKey is required" }, { status: 400 }); - } + if (!apiKey) { + return json({ error: "apiKey is required" }, { status: 400 }); + } - const convexUserId = await getConvexUserId(authUser, request); - if (!convexUserId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - if (authRatelimit) { - const rate = await authRatelimit.limit(`openrouter-key:post:${convexUserId}`); - if (!rate.success) { - return json( - { error: "Too many key update attempts. Please try again shortly." }, - { status: 429 }, - ); - } + const convexUserId = await getConvexUserId(authUser, request); + if (!convexUserId) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (authRatelimit) { + const rate = await authRatelimit.limit(`openrouter-key:post:${convexUserId}`); + if (!rate.success) { + return json( + { error: "Too many key update attempts. Please try again shortly." }, + { status: 429 }, + ); } + } - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { - return json({ error: "Unauthorized" }, { status: 401 }); - } + const convexClient = await getConvexClientForRequest(request); + if (!convexClient) { + return json({ error: "Unauthorized" }, { status: 401 }); + } const encryptedKey = encryptSecret(apiKey); await convexClient.mutation(api.users.saveOpenRouterKey, { @@ -99,28 +99,28 @@ export const Route = createFileRoute("/api/openrouter-key")({ return json({ error: "Invalid origin" }, { status: 403 }); } const authUser = await getAuthUser(request); - if (!authUser) { - return json({ error: "Unauthorized" }, { status: 401 }); - } + if (!authUser) { + return json({ error: "Unauthorized" }, { status: 401 }); + } - const convexUserId = await getConvexUserId(authUser, request); - if (!convexUserId) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - if (authRatelimit) { - const rate = await authRatelimit.limit(`openrouter-key:delete:${convexUserId}`); - if (!rate.success) { - return json( - { error: "Too many key update attempts. Please try again shortly." }, - { status: 429 }, - ); - } + const convexUserId = await getConvexUserId(authUser, request); + if (!convexUserId) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (authRatelimit) { + const rate = await authRatelimit.limit(`openrouter-key:delete:${convexUserId}`); + if (!rate.success) { + return json( + { error: "Too many key update attempts. Please try again shortly." }, + { status: 429 }, + ); } + } - const convexClient = await getConvexClientForRequest(request); - if (!convexClient) { - return json({ error: "Unauthorized" }, { status: 401 }); - } + const convexClient = await getConvexClientForRequest(request); + if (!convexClient) { + return json({ error: "Unauthorized" }, { status: 401 }); + } await convexClient.mutation(api.users.removeOpenRouterKey, { userId: convexUserId, diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index b7b8fe0e..264aa093 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -2,8 +2,6 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { createFileRoute } from "@tanstack/react-router"; import { json } from "@tanstack/react-start"; import { serve } from "@upstash/workflow/tanstack"; -import { api } from "@server/convex/_generated/api"; -import { createConvexServerClient } from "@/lib/convex-server"; import { workflowClient } from "@/lib/upstash"; type CleanupPayload = { @@ -12,10 +10,14 @@ type CleanupPayload = { }; const MAX_CLEANUP_BATCHES = 1_000; +const CONVEX_SITE_URL = + process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; - return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" + ); } function parseCleanupPayload(raw: unknown): CleanupPayload | null { @@ -81,7 +83,6 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ throw new Error("WORKFLOW_CLEANUP_TOKEN is not configured"); } - const convexClient = createConvexServerClient(); const retentionDays = payload.retentionDays ?? 90; const batchSize = payload.batchSize ?? 100; @@ -91,7 +92,7 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ while (batches < MAX_CLEANUP_BATCHES) { batches += 1; - const preview = await convexClient.action(api.cleanupAction.runCleanupBatchForWorkflow, { + const preview = await runCleanupBatch({ workflowToken, retentionDays, batchSize, @@ -101,7 +102,7 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ break; } - const deletedBatch = await convexClient.action(api.cleanupAction.runCleanupBatchForWorkflow, { + const deletedBatch = await runCleanupBatch({ workflowToken, retentionDays, batchSize, @@ -128,15 +129,50 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ }; } +async function runCleanupBatch(args: { + workflowToken: string; + retentionDays: number; + batchSize: number; + dryRun: boolean; +}): Promise<{ success: boolean; deleted: number; dryRun: boolean; cutoffDate: string }> { + if (!CONVEX_SITE_URL) { + throw new Error("CONVEX_SITE_URL is not configured"); + } + + const response = await fetch(`${CONVEX_SITE_URL}/workflow/cleanup-batch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(args), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Cleanup batch failed (${response.status})`); + } + + return (await response.json()) as { + success: boolean; + deleted: number; + dryRun: boolean; + cutoffDate: string; + }; +} + const workflow = serve(async (context) => { + const payload = parseCleanupPayload(context.requestPayload); + if (!payload) { + throw new Error("Invalid cleanup payload"); + } + const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN; if (!workflowToken) { throw new Error("WORKFLOW_CLEANUP_TOKEN is not configured"); } - const convexClient = createConvexServerClient(); - const retentionDays = context.requestPayload.retentionDays ?? 90; - const batchSize = context.requestPayload.batchSize ?? 100; + const retentionDays = payload.retentionDays ?? 90; + const batchSize = payload.batchSize ?? 100; let batches = 0; let totalDeleted = 0; @@ -145,7 +181,7 @@ const workflow = serve(async (context) => { while (batches < MAX_CLEANUP_BATCHES) { batches += 1; const preview = await context.run(`query-batch-${batches}`, async () => { - return convexClient.action(api.cleanupAction.runCleanupBatchForWorkflow, { + return runCleanupBatch({ workflowToken, retentionDays, batchSize, @@ -158,7 +194,7 @@ const workflow = serve(async (context) => { } const deletedBatch = await context.run(`delete-batch-${batches}`, async () => { - return convexClient.action(api.cleanupAction.runCleanupBatchForWorkflow, { + return runCleanupBatch({ workflowToken, retentionDays, batchSize, diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index dd57029a..1213b5fa 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -6,11 +6,12 @@ import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { authRatelimit, upstashRedis, workflowClient } from "@/lib/upstash"; +import { getWorkflowAuthToken, storeWorkflowAuthToken } from "@/lib/workflow-auth-token"; type DeleteAccountPayload = { - userId: string; - externalId: string; - authToken?: string; + userId?: string; + externalId?: string; + authTokenRef?: string; batchSize?: number; }; @@ -22,21 +23,27 @@ type DeleteStep = const MAX_DELETE_BATCHES = 500; +const EMPTY_DELETE_RESULT = { + success: false, + deleted: { + streamJobs: 0, + messages: 0, + chats: 0, + files: 0, + redisKeys: 0, + }, +}; + function parseDeletePayload(raw: unknown): DeleteAccountPayload | null { if (!raw || typeof raw !== "object") return null; const payload = raw as Record; - if (typeof payload.userId !== "string" || payload.userId.trim().length === 0) { - return null; - } - if (typeof payload.externalId !== "string" || payload.externalId.trim().length === 0) { - return null; - } const parsed: DeleteAccountPayload = { - userId: payload.userId.trim(), - externalId: payload.externalId.trim(), - authToken: typeof payload.authToken === "string" ? payload.authToken : undefined, + userId: typeof payload.userId === "string" ? payload.userId.trim() : undefined, + externalId: + typeof payload.externalId === "string" ? payload.externalId.trim() : undefined, + authTokenRef: typeof payload.authTokenRef === "string" ? payload.authTokenRef : undefined, }; if (payload.batchSize !== undefined) { @@ -92,7 +99,9 @@ async function clearRedisUserKeys(userId: string): Promise { function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; - return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" + ); } function getWorkflowTriggerHeaders(): Record { @@ -109,6 +118,9 @@ async function runDeleteAccountInline( deleted: { streamJobs: number; messages: number; chats: number; files: number; redisKeys: number }; }> { const { userId, externalId, batchSize } = payload; + if (!userId || !externalId) { + throw new Error("Invalid delete-account payload"); + } const convexClient = createConvexServerClient(authToken); const convexUserId = userId as Id<"users">; @@ -159,9 +171,19 @@ async function runDeleteAccountInline( const workflow = serve(async (context) => { const { userId, externalId, batchSize } = context.requestPayload; - const authToken = context.requestPayload.authToken; + if (!userId || !externalId) { + return EMPTY_DELETE_RESULT; + } + const authTokenRef = context.requestPayload.authTokenRef; + if (!authTokenRef) { + return EMPTY_DELETE_RESULT; + } + + const authToken = await context.run("resolve-auth", async () => { + return getWorkflowAuthToken(authTokenRef); + }); if (!authToken) { - throw new Error("Unauthorized"); + return EMPTY_DELETE_RESULT; } const convexClient = createConvexServerClient(authToken); @@ -270,7 +292,6 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ ...payload, userId: authConvexUser._id, externalId: authUser.id, - authToken, }; if (isLocalWorkflowRequest(request)) { @@ -292,10 +313,21 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ } try { + const authTokenRef = await storeWorkflowAuthToken(authToken); + if (!authTokenRef) { + return json( + { error: "Workflow auth cache is not configured" }, + { status: 500 }, + ); + } + const triggerHeaders = getWorkflowTriggerHeaders(); const { workflowRunId } = await workflowClient.trigger({ url: request.url, - body: normalizedPayload, + body: { + ...normalizedPayload, + authTokenRef, + }, headers: triggerHeaders, }); return json({ queued: true, workflowRunId }, { status: 202 }); diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 32e1fd7c..f3575d03 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -6,12 +6,13 @@ import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { exportRatelimit, workflowClient } from "@/lib/upstash"; +import { getWorkflowAuthToken, storeWorkflowAuthToken } from "@/lib/workflow-auth-token"; type ExportFormat = "markdown" | "json"; type ExportChatPayload = { chatId: string; userId: string; - authToken?: string; + authTokenRef?: string; format?: ExportFormat; }; @@ -60,14 +61,25 @@ function parseExportPayload(raw: unknown): ExportChatPayload | null { return { chatId: payload.chatId.trim(), userId: payload.userId.trim(), - authToken: typeof payload.authToken === "string" ? payload.authToken : undefined, + authTokenRef: typeof payload.authTokenRef === "string" ? payload.authTokenRef : undefined, format, }; } function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; - return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" + ); +} + + +function formatRole(role: string): string { + if (role === "assistant") return "Assistant"; + if (role === "system") return "System"; + if (role === "tool") return "Tool"; + if (role === "user") return "User"; + return "Message"; } function getWorkflowTriggerHeaders(): Record { @@ -88,7 +100,7 @@ function formatExportMarkdown(data: ChatExportData): string { lines.push(""); for (const message of data.messages) { - const role = message.role === "assistant" ? "Assistant" : "User"; + const role = formatRole(message.role); lines.push(`## ${role} (${new Date(message.createdAt).toISOString()})`); lines.push(""); lines.push(message.content || "_No content_"); @@ -144,9 +156,20 @@ async function runExportChatInline( const workflow = serve(async (context) => { const { chatId, userId, format = "markdown" } = context.requestPayload; - const authToken = context.requestPayload.authToken; + const authTokenRef = context.requestPayload.authTokenRef; + if (!authTokenRef) { + return { + error: "Unauthorized", + }; + } + + const authToken = await context.run("resolve-auth", async () => { + return getWorkflowAuthToken(authTokenRef); + }); if (!authToken) { - throw new Error("Unauthorized"); + return { + error: "Unauthorized", + }; } const convexClient = createConvexServerClient(authToken); @@ -157,7 +180,9 @@ const workflow = serve(async (context) => { }); }); if (!chatExportData) { - throw new Error("Chat not found"); + return { + error: "Chat not found", + }; } const formattedExport = await context.run("format-export", async () => { @@ -231,7 +256,6 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ const normalizedPayload: ExportChatPayload = { ...payload, userId: authConvexUser._id, - authToken, }; if (isLocalWorkflowRequest(request)) { @@ -254,10 +278,21 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ } try { + const authTokenRef = await storeWorkflowAuthToken(authToken); + if (!authTokenRef) { + return json( + { error: "Workflow auth cache is not configured" }, + { status: 500 }, + ); + } + const triggerHeaders = getWorkflowTriggerHeaders(); const { workflowRunId } = await workflowClient.trigger({ url: request.url, - body: normalizedPayload, + body: { + ...normalizedPayload, + authTokenRef, + }, headers: triggerHeaders, }); return json({ queued: true, workflowRunId }, { status: 202 }); diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 964f501d..b223d605 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -7,6 +7,7 @@ import { createConvexServerClient } from "@/lib/convex-server"; import { decryptSecret } from "@/lib/server-crypto"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { authRatelimit, workflowClient } from "@/lib/upstash"; +import { getWorkflowAuthToken, storeWorkflowAuthToken } from "@/lib/workflow-auth-token"; const TITLE_MODEL_ID = "google/gemini-2.5-flash-lite"; const TITLE_MAX_LENGTH = 200; @@ -17,7 +18,7 @@ type TitleProvider = "osschat" | "openrouter"; type GenerateTitlePayload = { chatId: string; userId: string; - authToken?: string; + authTokenRef?: string; seedText?: string; length: TitleLength; provider: TitleProvider; @@ -61,7 +62,7 @@ function parseGenerateTitlePayload(raw: unknown): GenerateTitlePayload | null { return { chatId: payload.chatId.trim(), userId: payload.userId.trim(), - authToken: typeof payload.authToken === "string" ? payload.authToken : undefined, + authTokenRef: typeof payload.authTokenRef === "string" ? payload.authTokenRef : undefined, seedText: payload.seedText, length: payload.length, provider: payload.provider, @@ -91,7 +92,9 @@ function sanitizeGeneratedTitle(input: string): string { function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; - return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" + ); } function getWorkflowTriggerHeaders(): Record { @@ -174,9 +177,16 @@ const workflow = serve(async (context) => { mode = "auto", } = payload; - const authToken = payload.authToken; + const authTokenRef = payload.authTokenRef; + if (!authTokenRef) { + return { saved: false, reason: "unauthorized" } as const; + } + + const authToken = await context.run("resolve-auth", async () => { + return getWorkflowAuthToken(authTokenRef); + }); if (!authToken) { - throw new Error("Unauthorized"); + return { saved: false, reason: "unauthorized" } as const; } const convexClient = createConvexServerClient(authToken); @@ -228,7 +238,7 @@ const workflow = serve(async (context) => { "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", "X-Title": "OSSChat", }, - body: { + body: JSON.stringify({ model: TITLE_MODEL_ID, messages: [ { role: "system", content: systemPrompt }, @@ -236,7 +246,7 @@ const workflow = serve(async (context) => { ], temperature: 0.2, max_tokens: 32, - }, + }), retries: 2, timeout: "30s", }); @@ -318,7 +328,6 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ const normalizedPayload: GenerateTitlePayload = { ...payload, userId: authConvexUser._id, - authToken, }; if (isLocalWorkflowRequest(request)) { @@ -344,10 +353,21 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ } try { + const authTokenRef = await storeWorkflowAuthToken(authToken); + if (!authTokenRef) { + return json( + { error: "Workflow auth cache is not configured" }, + { status: 500 }, + ); + } + const headers = getWorkflowTriggerHeaders(); const { workflowRunId } = await workflowClient.trigger({ url: request.url, - body: normalizedPayload, + body: { + ...normalizedPayload, + authTokenRef, + }, headers, }); return json({ queued: true, workflowRunId }, { status: 202 }); From 7c103dcd7905fc1f8dfe27e962b11bff6808cd34 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 15:29:44 -0500 Subject: [PATCH 14/40] fix(security): consume workflow auth refs once and harden error/rate handling - Encrypt workflow auth tokens at rest and consume via getdel with strict key-prefix validation - Harden Convex cleanup batch HTTP bridge with typed error status mapping - Require full QStash config (URL+token) and add production misconfig warning - Tighten models IP derivation with trust-proxy gating and Retry-After on 429 - Add provisional atomic usage reservation/adjustment path to reduce daily-limit race window - Normalize IPv6 localhost checks and finish remaining formatting/alignment fixes --- apps/server/convex/backgroundStream.ts | 80 ++++++++++++------- apps/server/convex/http.ts | 27 +++++-- apps/server/convex/lib/upstashUsage.ts | 59 ++++++++++++++ apps/server/convex/users.ts | 4 +- apps/web/src/components/app-sidebar.tsx | 16 ++-- apps/web/src/lib/upstash.ts | 6 +- apps/web/src/lib/workflow-auth-token.ts | 15 +++- apps/web/src/routes/api/models.ts | 45 +++++++---- apps/web/src/routes/api/workflow/cleanup.ts | 2 +- .../src/routes/api/workflow/delete-account.ts | 2 +- .../src/routes/api/workflow/export-chat.ts | 3 +- .../src/routes/api/workflow/generate-title.ts | 2 +- 12 files changed, 193 insertions(+), 68 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index ccfc141c..bb3e4b71 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -12,8 +12,10 @@ import { } from "./lib/billingUtils"; import type { UsagePayload } from "./lib/billingUtils"; import { + adjustDailyUsageInUpstash, getDailyUsageFromUpstash, incrementDailyUsageInUpstash, + reserveDailyUsageInUpstash, } from "./lib/upstashUsage"; import { requireAuthUserId } from "./lib/auth"; import { decryptSecret } from "./lib/crypto"; @@ -36,8 +38,6 @@ const streamOptionsValidator = v.object({ enableWebSearch: v.optional(v.boolean()), supportsToolCalls: v.optional(v.boolean()), maxSteps: v.optional(v.number()), - dynamicPrompt: v.optional(v.boolean()), - jonMode: v.optional(v.boolean()), }); type ChainOfThoughtPart = { @@ -800,15 +800,32 @@ export const executeStream = internalAction({ return; } + let reservedUsageCents = 0; + let reservedDateKey: string | null = null; + if (job.provider === "osschat") { const currentDate = getCurrentDateKey(); - const redisUsageCents = await getDailyUsageFromUpstash(job.userId, currentDate); - if (redisUsageCents !== null && redisUsageCents >= DAILY_AI_LIMIT_CENTS) { - await ctx.runMutation(internal.backgroundStream.failStream, { - jobId: args.jobId, - error: "Daily usage limit reached. Connect your OpenRouter account to continue.", - }); - return; + const reservedTotal = await reserveDailyUsageInUpstash(job.userId, currentDate, 1); + if (reservedTotal !== null) { + reservedUsageCents = 1; + reservedDateKey = currentDate; + if (reservedTotal > DAILY_AI_LIMIT_CENTS) { + await adjustDailyUsageInUpstash(job.userId, currentDate, -reservedUsageCents); + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: "Daily usage limit reached. Connect your OpenRouter account to continue.", + }); + return; + } + } else { + const redisUsageCents = await getDailyUsageFromUpstash(job.userId, currentDate); + if (redisUsageCents !== null && redisUsageCents >= DAILY_AI_LIMIT_CENTS) { + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: "Daily usage limit reached. Connect your OpenRouter account to continue.", + }); + return; + } } } @@ -1372,28 +1389,32 @@ export const executeStream = internalAction({ job.messages, fullContent, ); - if (usageCents && usageCents > 0) { - for (let attempt = 0; attempt < 2; attempt++) { - try { - await ctx.runMutation(internal.users.incrementAiUsage, { - userId: job.userId, + if (usageCents && usageCents > 0) { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await ctx.runMutation(internal.users.incrementAiUsage, { + userId: job.userId, + usageCents, + }); + break; + } catch { + if (attempt === 1) break; + } + } + + if (reservedDateKey && reservedUsageCents > 0) { + const adjustment = Math.ceil(usageCents) - reservedUsageCents; + if (adjustment !== 0) { + await adjustDailyUsageInUpstash(job.userId, reservedDateKey, adjustment); + } + } else { + await incrementDailyUsageInUpstash( + job.userId, + getCurrentDateKey(), usageCents, - }); - break; - } catch { - if (attempt === 1) break; + ); } } - try { - await incrementDailyUsageInUpstash( - job.userId, - getCurrentDateKey(), - usageCents, - ); - } catch { - // Upstash usage tracking is best-effort; don't fail the stream. - } - } } const thinkingTimeMs = getThinkingTimeMs(); @@ -1426,6 +1447,9 @@ export const executeStream = internalAction({ : undefined, }); } catch (error) { + if (reservedDateKey && reservedUsageCents > 0) { + await adjustDailyUsageInUpstash(job.userId, reservedDateKey, -reservedUsageCents); + } const errorMessage = error instanceof Error ? error.message : "Unknown error"; await ctx.runMutation(internal.backgroundStream.failStream, { jobId: args.jobId, diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index a14f4120..868278bb 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -93,12 +93,27 @@ http.route({ }); } - const result = await ctx.runAction(internal.cleanupAction.runCleanupBatchForWorkflow, { - workflowToken: payload.workflowToken.trim(), - retentionDays: typeof payload.retentionDays === "number" ? payload.retentionDays : undefined, - batchSize: typeof payload.batchSize === "number" ? payload.batchSize : undefined, - dryRun: typeof payload.dryRun === "boolean" ? payload.dryRun : undefined, - }); + let result; + try { + result = await ctx.runAction(internal.cleanupAction.runCleanupBatchForWorkflow, { + workflowToken: payload.workflowToken.trim(), + retentionDays: typeof payload.retentionDays === "number" ? payload.retentionDays : undefined, + batchSize: typeof payload.batchSize === "number" ? payload.batchSize : undefined, + dryRun: typeof payload.dryRun === "boolean" ? payload.dryRun : undefined, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Cleanup batch failed"; + const status = + message === "Unauthorized" + ? 401 + : message.includes("must be between") || message.includes("Invalid") + ? 400 + : 500; + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "content-type": "application/json" }, + }); + } return new Response(JSON.stringify(result), { status: 200, diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index ab5f0290..19910a5d 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -94,3 +94,62 @@ export async function incrementDailyUsageInUpstash( } } } + +export async function reserveDailyUsageInUpstash( + userId: string, + dateKey: string, + reserveCents: number, +): Promise { + if (!getConfig()) return null; + if (!Number.isFinite(reserveCents) || reserveCents <= 0) return null; + const roundedCents = Math.ceil(reserveCents); + if (roundedCents <= 0) return null; + + const key = usageCounterKey(userId, dateKey); + + try { + const expiresAt = getMidnightUtcEpochSeconds(dateKey); + const result = await executePipeline([ + ["INCRBY", key, roundedCents], + ["EXPIREAT", key, expiresAt], + ]); + const total = result?.[0]?.result; + if (typeof total === "number") return total; + if (typeof total === "string") { + const parsed = Number.parseInt(total, 10); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + } catch (error) { + if (shouldLogUpstashUsageErrors()) { + console.warn("[Usage] Upstash reserve failed", error); + } + return null; + } +} + +export async function adjustDailyUsageInUpstash( + userId: string, + dateKey: string, + usageCentsDelta: number, +): Promise { + if (!getConfig()) return; + if (!Number.isFinite(usageCentsDelta) || usageCentsDelta === 0) return; + const roundedDelta = + usageCentsDelta > 0 ? Math.ceil(usageCentsDelta) : -Math.ceil(Math.abs(usageCentsDelta)); + if (roundedDelta === 0) return; + + const key = usageCounterKey(userId, dateKey); + + try { + const expiresAt = getMidnightUtcEpochSeconds(dateKey); + await executePipeline([ + ["INCRBY", key, roundedDelta], + ["EXPIREAT", key, expiresAt], + ]); + } catch (error) { + if (shouldLogUpstashUsageErrors()) { + console.warn("[Usage] Upstash adjust failed", error); + } + } +} diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 8c15f6e5..91155cf2 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -753,7 +753,7 @@ export const deleteUserRecord = internalMutation({ paginationOpts: { cursor: null, numItems: 1 }, }); - while (true) { + for (let batch = 0; batch < 1000; batch++) { const readStatuses = await ctx.db .query("chatReadStatus") .withIndex("by_user", (q) => q.eq("userId", args.userId)) @@ -766,7 +766,7 @@ export const deleteUserRecord = internalMutation({ } } - while (true) { + for (let batch = 0; batch < 1000; batch++) { const templates = await ctx.db .query("promptTemplates") .withIndex("by_user", (q) => q.eq("userId", args.userId)) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index de35a1a8..cf1d8f89 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -448,14 +448,14 @@ export function AppSidebar({ title: generatedTitle, force: true, }); - } catch (error) { - console.warn("[Chat] Title regeneration failed:", error); - const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; - toast.error(message); - } finally { - setTitleGenerating(chatId, false); - } - }; + } catch (error) { + console.warn("[Chat] Title regeneration failed:", error); + const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; + toast.error(message); + } finally { + setTitleGenerating(chatId, false); + } + }; const handleDeleteChat = useCallback( async (chatId: string) => { diff --git a/apps/web/src/lib/upstash.ts b/apps/web/src/lib/upstash.ts index 60ce6540..e45189a2 100644 --- a/apps/web/src/lib/upstash.ts +++ b/apps/web/src/lib/upstash.ts @@ -33,7 +33,7 @@ export const uploadRatelimit = createSlidingWindowRatelimit(20, "60 s", "ratelim export const exportRatelimit = createSlidingWindowRatelimit(10, "60 s", "ratelimit:export:user"); export const authRatelimit = createSlidingWindowRatelimit(20, "60 s", "ratelimit:auth:user"); -export const workflowClient = QSTASH_TOKEN +export const workflowClient = QSTASH_URL && QSTASH_TOKEN ? new WorkflowClient({ baseUrl: QSTASH_URL, token: QSTASH_TOKEN, @@ -44,6 +44,10 @@ if (!upstashRedis) { console.warn("[Upstash] Redis not configured — rate limiting is disabled"); } +if (process.env.NODE_ENV === "production" && !upstashRedis) { + console.error("[Upstash] Redis not configured in production"); +} + export function isUpstashRedisConfigured(): boolean { return upstashRedis !== null; } diff --git a/apps/web/src/lib/workflow-auth-token.ts b/apps/web/src/lib/workflow-auth-token.ts index 9859dbbf..1e77abaa 100644 --- a/apps/web/src/lib/workflow-auth-token.ts +++ b/apps/web/src/lib/workflow-auth-token.ts @@ -1,4 +1,5 @@ import { upstashRedis } from "@/lib/upstash"; +import { decryptSecret, encryptSecret } from "@/lib/server-crypto"; const WORKFLOW_AUTH_TOKEN_PREFIX = "workflow:auth-token"; const WORKFLOW_AUTH_TOKEN_TTL_SECONDS = 60 * 60; @@ -10,7 +11,8 @@ function createWorkflowAuthTokenKey(): string { export async function storeWorkflowAuthToken(authToken: string): Promise { if (!upstashRedis) return null; const key = createWorkflowAuthTokenKey(); - await upstashRedis.set(key, authToken, { + const encrypted = encryptSecret(authToken); + await upstashRedis.set(key, encrypted, { ex: WORKFLOW_AUTH_TOKEN_TTL_SECONDS, }); return key; @@ -18,9 +20,16 @@ export async function storeWorkflowAuthToken(authToken: string): Promise { if (!upstashRedis) return null; - const value = await upstashRedis.get(key); + if (!key.startsWith(`${WORKFLOW_AUTH_TOKEN_PREFIX}:`)) { + return null; + } + const value = await upstashRedis.getdel(key); if (typeof value !== "string" || value.length === 0) { return null; } - return value; + try { + return decryptSecret(value); + } catch { + return null; + } } diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index f025adfd..eca2b6f6 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -7,6 +7,7 @@ const MODELS_CACHE_KEY = "openchat:models"; const MODELS_CACHE_TTL_SECONDS = 60 * 60 * 4; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const OPENROUTER_FETCH_TIMEOUT_MS = 10_000; +const TRUST_PROXY = process.env.TRUST_PROXY === "true"; const modelsIpRatelimit = upstashRedis ? new Ratelimit({ @@ -59,23 +60,25 @@ async function fetchModelsFromOpenRouter(): Promise { } function getClientIp(request: Request): string | null { - const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); - if (cfConnectingIp) return cfConnectingIp; - - const realIp = request.headers.get("x-real-ip")?.trim(); - if (realIp) return realIp; + if (TRUST_PROXY) { + const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); + if (cfConnectingIp) return cfConnectingIp; + + const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim(); + if (vercelForwardedFor) { + const first = vercelForwardedFor.split(",")[0]?.trim(); + if (first) return first; + } - const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim(); - if (vercelForwardedFor) { - const first = vercelForwardedFor.split(",")[0]?.trim(); - if (first) return first; + const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); + if (forwardedFor) { + const first = forwardedFor.split(",")[0]?.trim(); + if (first) return first; + } } - const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); - if (forwardedFor) { - const first = forwardedFor.split(",")[0]?.trim(); - if (first) return first; - } + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; return null; } @@ -91,7 +94,19 @@ export const Route = createFileRoute("/api/models")({ } const rl = await modelsIpRatelimit.limit(ip); if (!rl.success) { - return json({ error: "Rate limit exceeded" }, { status: 429 }); + const retryAfterSeconds = Math.max( + 1, + Math.ceil((rl.reset - Date.now()) / 1000), + ); + return json( + { error: "Rate limit exceeded" }, + { + status: 429, + headers: { + "Retry-After": String(retryAfterSeconds), + }, + }, + ); } } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index 264aa093..c18306a3 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -16,7 +16,7 @@ const CONVEX_SITE_URL = function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; return ( - hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" ); } diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 1213b5fa..45d77aac 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -100,7 +100,7 @@ async function clearRedisUserKeys(userId: string): Promise { function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; return ( - hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" ); } diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index f3575d03..fe4e1c80 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -69,11 +69,10 @@ function parseExportPayload(raw: unknown): ExportChatPayload | null { function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; return ( - hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" ); } - function formatRole(role: string): string { if (role === "assistant") return "Assistant"; if (role === "system") return "System"; diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index b223d605..0f31d869 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -93,7 +93,7 @@ function sanitizeGeneratedTitle(input: string): string { function isLocalWorkflowRequest(request: Request): boolean { const hostname = new URL(request.url).hostname; return ( - hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" ); } From 8e696e56bc2731f76d79c855666b19278deebfa1 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 16:11:25 -0500 Subject: [PATCH 15/40] fix(security): harden fail-closed limits and workflow retries --- apps/server/convex/backgroundStream.ts | 29 +++- apps/server/convex/http.ts | 6 +- apps/server/convex/lib/upstashUsage.ts | 32 +++- apps/server/convex/users.ts | 153 ++++++++++-------- apps/web/src/lib/upstash.ts | 43 ++++- apps/web/src/lib/workflow-auth-token.ts | 2 +- apps/web/src/routes/api/models.ts | 12 +- apps/web/src/routes/api/workflow/cleanup.ts | 2 + .../src/routes/api/workflow/delete-account.ts | 11 +- .../src/routes/api/workflow/export-chat.ts | 10 +- .../src/routes/api/workflow/generate-title.ts | 10 +- 11 files changed, 227 insertions(+), 83 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index bb3e4b71..3b72ccf1 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -38,6 +38,8 @@ const streamOptionsValidator = v.object({ enableWebSearch: v.optional(v.boolean()), supportsToolCalls: v.optional(v.boolean()), maxSteps: v.optional(v.number()), + dynamicPrompt: v.optional(v.boolean()), + jonMode: v.optional(v.boolean()), }); type ChainOfThoughtPart = { @@ -819,7 +821,17 @@ export const executeStream = internalAction({ } } else { const redisUsageCents = await getDailyUsageFromUpstash(job.userId, currentDate); - if (redisUsageCents !== null && redisUsageCents >= DAILY_AI_LIMIT_CENTS) { + const fallbackUsageCents = + redisUsageCents ?? + (await ctx.runQuery( + internal.backgroundStream.getPersistedDailyUsageForDateInternal, + { + userId: job.userId, + dateKey: currentDate, + }, + )); + + if (fallbackUsageCents >= DAILY_AI_LIMIT_CENTS) { await ctx.runMutation(internal.backgroundStream.failStream, { jobId: args.jobId, error: "Daily usage limit reached. Connect your OpenRouter account to continue.", @@ -1460,6 +1472,21 @@ export const executeStream = internalAction({ }, }); +export const getPersistedDailyUsageForDateInternal = internalQuery({ + args: { + userId: v.id("users"), + dateKey: v.string(), + }, + returns: v.number(), + handler: async (ctx, args) => { + const user = await ctx.db.get(args.userId); + if (!user || user.aiUsageDate !== args.dateKey) { + return 0; + } + return user.aiUsageCents ?? 0; + }, +}); + export const getJobInternal = internalQuery({ args: { jobId: v.id("streamJobs"), diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index 868278bb..0bc8099a 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -109,7 +109,11 @@ http.route({ : message.includes("must be between") || message.includes("Invalid") ? 400 : 500; - return new Response(JSON.stringify({ error: message }), { + if (status === 500) { + console.error("[cleanup-batch] Internal error", error); + } + const safeMessage = status === 500 ? "Cleanup batch failed" : message; + return new Response(JSON.stringify({ error: safeMessage }), { status, headers: { "content-type": "application/json" }, }); diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index 19910a5d..eb9cc8b8 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -1,4 +1,9 @@ -type PipelineResult = Array<{ result: unknown }>; +type PipelineEntry = { + result?: unknown; + error?: string; +}; + +type PipelineResult = Array; function getConfig(): { url: string; token: string } | null { const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); @@ -42,7 +47,30 @@ async function executePipeline(commands: Array>): Promise throw new Error(`Upstash pipeline failed: ${response.status} ${body}`); } - return (await response.json()) as PipelineResult; + const json = (await response.json()) as PipelineResult; + + if (!Array.isArray(json)) { + throw new Error("Upstash pipeline returned invalid response"); + } + + if (json.length !== commands.length) { + throw new Error("Upstash pipeline returned unexpected command count"); + } + + for (let index = 0; index < json.length; index++) { + const entry = json[index]; + if (!entry || typeof entry !== "object") { + throw new Error(`Upstash pipeline returned invalid command result at index ${index}`); + } + if (typeof entry.error === "string" && entry.error.length > 0) { + const commandName = String(commands[index]?.[0] ?? "UNKNOWN"); + throw new Error( + `Upstash pipeline command failed (${commandName} at index ${index}): ${entry.error}`, + ); + } + } + + return json; } export async function getDailyUsageFromUpstash( diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 91155cf2..85c7bd97 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -600,6 +600,7 @@ export const updateName = mutation({ const DELETE_BATCH_SIZE_DEFAULT = 100; const DELETE_BATCH_SIZE_MAX = 500; +const MAX_DELETE_BATCH_LOOPS = 1_000; function normalizeBatchSize(value?: number): number { if (!value || !Number.isFinite(value) || value <= 0) { @@ -717,6 +718,54 @@ export const deleteUserFiles = internalMutation({ }, }); +export const deleteUserChatReadStatuses = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const statuses = await ctx.db + .query("chatReadStatus") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const status of statuses) { + await ctx.db.delete(status._id); + } + + return { + deleted: statuses.length, + hasMore: statuses.length === batchSize, + }; + }, +}); + +export const deleteUserPromptTemplates = internalMutation({ + args: { + userId: v.id("users"), + batchSize: v.optional(v.number()), + }, + returns: deletionBatchResult, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const templates = await ctx.db + .query("promptTemplates") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .take(batchSize); + + for (const template of templates) { + await ctx.db.delete(template._id); + } + + return { + deleted: templates.length, + hasMore: templates.length === batchSize, + }; + }, +}); + export const deleteUserRecord = internalMutation({ args: { userId: v.id("users"), @@ -753,30 +802,22 @@ export const deleteUserRecord = internalMutation({ paginationOpts: { cursor: null, numItems: 1 }, }); - for (let batch = 0; batch < 1000; batch++) { - const readStatuses = await ctx.db - .query("chatReadStatus") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .take(100); - if (readStatuses.length === 0) { + for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { + const result = await ctx.runMutation(internal.users.deleteUserChatReadStatuses, { + userId: args.userId, + }); + if (!result.hasMore) { break; } - for (const status of readStatuses) { - await ctx.db.delete(status._id); - } } - for (let batch = 0; batch < 1000; batch++) { - const templates = await ctx.db - .query("promptTemplates") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .take(100); - if (templates.length === 0) { + for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { + const result = await ctx.runMutation(internal.users.deleteUserPromptTemplates, { + userId: args.userId, + }); + if (!result.hasMore) { break; } - for (const template of templates) { - await ctx.db.delete(template._id); - } } const profile = await ctx.db @@ -921,67 +962,51 @@ export const deleteAccount = mutation({ }); // 4. Delete streamJobs - const streamJobs = await ctx.db - .query("streamJobs") - .withIndex("by_user", (q) => q.eq("userId", userId)) - .collect(); - for (const job of streamJobs) { - await ctx.db.delete(job._id); + for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { + const result = await ctx.runMutation(internal.users.deleteUserStreamJobs, { + userId, + }); + if (!result.hasMore) break; } // 5. Delete chatReadStatus - const readStatuses = await ctx.db - .query("chatReadStatus") - .withIndex("by_user", (q) => q.eq("userId", userId)) - .collect(); - for (const status of readStatuses) { - await ctx.db.delete(status._id); + for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { + const result = await ctx.runMutation(internal.users.deleteUserChatReadStatuses, { + userId, + }); + if (!result.hasMore) break; } // 6. Delete fileUploads AND storage blobs - const fileUploads = await ctx.db - .query("fileUploads") - .withIndex("by_user", (q) => q.eq("userId", userId)) - .collect(); - for (const file of fileUploads) { - // Delete the actual file from Convex storage - try { - await ctx.storage.delete(file.storageId); - } catch (e) { - // Only log unexpected errors, not "not found" which is expected if file was already deleted - const message = e instanceof Error ? e.message : String(e); - if (!message.toLowerCase().includes("not found")) { - console.error("Unexpected error deleting storage file:", file.storageId, message); - } - } - await ctx.db.delete(file._id); + for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { + const result = await ctx.runMutation(internal.users.deleteUserFiles, { + userId, + }); + if (!result.hasMore) break; } // 7. Delete messages (all messages for all user's chats) - const messages = await ctx.db - .query("messages") - .withIndex("by_user", (q) => q.eq("userId", userId)) - .collect(); - for (const message of messages) { - await ctx.db.delete(message._id); + for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { + const result = await ctx.runMutation(internal.users.deleteUserMessages, { + userId, + }); + if (!result.hasMore) break; } // 8. Delete chats - const chats = await ctx.db - .query("chats") - .withIndex("by_user", (q) => q.eq("userId", userId)) - .collect(); - for (const chat of chats) { - await ctx.db.delete(chat._id); + for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { + const result = await ctx.runMutation(internal.users.deleteUserChats, { + userId, + }); + if (!result.hasMore) break; } // 9. Delete promptTemplates - const templates = await ctx.db - .query("promptTemplates") - .withIndex("by_user", (q) => q.eq("userId", userId)) - .collect(); - for (const template of templates) { - await ctx.db.delete(template._id); + for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { + const result = await ctx.runMutation(internal.users.deleteUserPromptTemplates, { + userId, + }); + if (!result.hasMore) break; } // 10. Delete profile diff --git a/apps/web/src/lib/upstash.ts b/apps/web/src/lib/upstash.ts index e45189a2..6e6e816a 100644 --- a/apps/web/src/lib/upstash.ts +++ b/apps/web/src/lib/upstash.ts @@ -6,6 +6,19 @@ const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL?.trim(); const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); const QSTASH_URL = process.env.QSTASH_URL?.trim(); const QSTASH_TOKEN = process.env.QSTASH_TOKEN?.trim(); +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + +type RatelimitDecision = { + success: boolean; + limit: number; + remaining: number; + reset: number; + pending: Promise; +}; + +type RatelimitLike = { + limit: (identifier: string) => Promise; +}; export const upstashRedis = UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN @@ -19,8 +32,20 @@ function createSlidingWindowRatelimit( limit: number, window: `${number} ${"ms" | "s" | "m" | "h" | "d"}`, prefix: string, -) { - if (!upstashRedis) return null; +): RatelimitLike | null { + if (!upstashRedis) { + if (!IS_PRODUCTION) return null; + const reset = Date.now() + 60_000; + return { + limit: async () => ({ + success: false, + limit: 0, + remaining: 0, + reset, + pending: Promise.resolve(), + }), + }; + } return new Ratelimit({ redis: upstashRedis, limiter: Ratelimit.slidingWindow(limit, window), @@ -41,11 +66,11 @@ export const workflowClient = QSTASH_URL && QSTASH_TOKEN : null; if (!upstashRedis) { - console.warn("[Upstash] Redis not configured — rate limiting is disabled"); -} - -if (process.env.NODE_ENV === "production" && !upstashRedis) { - console.error("[Upstash] Redis not configured in production"); + if (IS_PRODUCTION) { + console.error("[Upstash] Redis not configured in production; rate-limited endpoints fail closed"); + } else { + console.warn("[Upstash] Redis not configured — rate limiting is disabled"); + } } export function isUpstashRedisConfigured(): boolean { @@ -55,3 +80,7 @@ export function isUpstashRedisConfigured(): boolean { export function isQstashConfigured(): boolean { return workflowClient !== null; } + +export function shouldFailClosedForMissingUpstash(): boolean { + return IS_PRODUCTION && upstashRedis === null; +} diff --git a/apps/web/src/lib/workflow-auth-token.ts b/apps/web/src/lib/workflow-auth-token.ts index 1e77abaa..ac30801a 100644 --- a/apps/web/src/lib/workflow-auth-token.ts +++ b/apps/web/src/lib/workflow-auth-token.ts @@ -23,7 +23,7 @@ export async function getWorkflowAuthToken(key: string): Promise if (!key.startsWith(`${WORKFLOW_AUTH_TOKEN_PREFIX}:`)) { return null; } - const value = await upstashRedis.getdel(key); + const value = await upstashRedis.get(key); if (typeof value !== "string" || value.length === 0) { return null; } diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index eca2b6f6..cb52e238 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -1,7 +1,7 @@ import { Ratelimit } from "@upstash/ratelimit"; import { createFileRoute } from "@tanstack/react-router"; import { json } from "@tanstack/react-start"; -import { upstashRedis } from "@/lib/upstash"; +import { shouldFailClosedForMissingUpstash, upstashRedis } from "@/lib/upstash"; const MODELS_CACHE_KEY = "openchat:models"; const MODELS_CACHE_TTL_SECONDS = 60 * 60 * 4; @@ -75,10 +75,10 @@ function getClientIp(request: Request): string | null { const first = forwardedFor.split(",")[0]?.trim(); if (first) return first; } - } - const realIp = request.headers.get("x-real-ip")?.trim(); - if (realIp) return realIp; + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; + } return null; } @@ -87,6 +87,10 @@ export const Route = createFileRoute("/api/models")({ server: { handlers: { GET: async ({ request }) => { + if (shouldFailClosedForMissingUpstash()) { + return json({ error: "Service temporarily unavailable" }, { status: 503 }); + } + if (modelsIpRatelimit) { const ip = getClientIp(request); if (!ip) { diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index c18306a3..102f8922 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -10,6 +10,7 @@ type CleanupPayload = { }; const MAX_CLEANUP_BATCHES = 1_000; +const CLEANUP_BATCH_TIMEOUT_MS = 15_000; const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; @@ -145,6 +146,7 @@ async function runCleanupBatch(args: { "Content-Type": "application/json", }, body: JSON.stringify(args), + signal: AbortSignal.timeout(CLEANUP_BATCH_TIMEOUT_MS), }); if (!response.ok) { diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 45d77aac..11d8154c 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -5,7 +5,12 @@ import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; -import { authRatelimit, upstashRedis, workflowClient } from "@/lib/upstash"; +import { + authRatelimit, + shouldFailClosedForMissingUpstash, + upstashRedis, + workflowClient, +} from "@/lib/upstash"; import { getWorkflowAuthToken, storeWorkflowAuthToken } from "@/lib/workflow-auth-token"; type DeleteAccountPayload = { @@ -271,6 +276,10 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ return json({ error: "Unauthorized" }, { status: 401 }); } + if (shouldFailClosedForMissingUpstash()) { + return json({ error: "Service temporarily unavailable" }, { status: 503 }); + } + if (authRatelimit) { const rl = await authRatelimit.limit(`delete-account:${authConvexUser._id}`); if (!rl.success) { diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index fe4e1c80..556d0e9f 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -5,7 +5,11 @@ import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; -import { exportRatelimit, workflowClient } from "@/lib/upstash"; +import { + exportRatelimit, + shouldFailClosedForMissingUpstash, + workflowClient, +} from "@/lib/upstash"; import { getWorkflowAuthToken, storeWorkflowAuthToken } from "@/lib/workflow-auth-token"; type ExportFormat = "markdown" | "json"; @@ -235,6 +239,10 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ return json({ error: "Unauthorized" }, { status: 401 }); } + if (shouldFailClosedForMissingUpstash()) { + return json({ error: "Service temporarily unavailable" }, { status: 503 }); + } + if (exportRatelimit) { const rl = await exportRatelimit.limit(`export-chat:${authConvexUser._id}`); if (!rl.success) { diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 0f31d869..021eb40d 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -6,7 +6,11 @@ import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; import { decryptSecret } from "@/lib/server-crypto"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; -import { authRatelimit, workflowClient } from "@/lib/upstash"; +import { + authRatelimit, + shouldFailClosedForMissingUpstash, + workflowClient, +} from "@/lib/upstash"; import { getWorkflowAuthToken, storeWorkflowAuthToken } from "@/lib/workflow-auth-token"; const TITLE_MODEL_ID = "google/gemini-2.5-flash-lite"; @@ -318,6 +322,10 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ return json({ error: "Unauthorized" }, { status: 401 }); } + if (shouldFailClosedForMissingUpstash()) { + return json({ error: "Service temporarily unavailable" }, { status: 503 }); + } + if (authRatelimit) { const rl = await authRatelimit.limit(`generate-title:${authConvexUser._id}`); if (!rl.success) { From 537b548614e5027da11b03429678b28c5126829d Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:57:04 -0500 Subject: [PATCH 16/40] Fix: Broken access control in chat stream init (#621) * fix(web): validate chat ownership before Redis stream init Add Convex-based chat ownership validation in both GET and POST handlers of /api/chat to prevent unauthorized Redis stream access. Previously, an authenticated user could use another user's chatId to overwrite stream metadata and read streaming tokens. - GET handler: validate ownership via api.chats.get before reading stream - POST handler: validate ownership via api.chats.get before redis.stream.init Closes OSS-22 * fix(api): add chat ownership validation before redis stream operations * fix(chat): reuse convex client to eliminate redundant http request --------- Co-authored-by: Tembo Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com> --- apps/web/src/routes/api/chat.ts | 57 ++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/apps/web/src/routes/api/chat.ts b/apps/web/src/routes/api/chat.ts index 6d1324e7..0272eff0 100644 --- a/apps/web/src/routes/api/chat.ts +++ b/apps/web/src/routes/api/chat.ts @@ -156,6 +156,19 @@ export const Route = createFileRoute("/api/chat")({ return json({ error: "chatId is required" }, { status: 400 }); } + // Validate chat ownership via Convex (authoritative source) + const convexClient = await getConvexClientForRequest(request); + if (!convexClient) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + const chat = await convexClient.query(api.chats.get, { + chatId: chatId as Id<"chats">, + userId, + }); + if (!chat) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + const redisReady = await redis.ensureConnected(); if (!redisReady) { console.log("[Chat API GET] Redis not available"); @@ -345,27 +358,35 @@ export const Route = createFileRoute("/api/chat")({ const messageId = generateId(); const streamId = `${chatId}-${messageId}`; - if (chatId && redisReady) { - console.log("[Chat API POST] Initializing Redis stream for chat:", chatId); - await redis.stream.init(chatId, convexUserId, messageId); - } - + // Validate chat ownership before initializing Redis stream or setting active stream if (chatId) { const convexClient = await getConvexClientForRequest(request); if (!convexClient) { - console.error("[Chat API] Convex auth token unavailable"); - } else { - try { - console.log("[Chat API] Setting active stream:", streamId); - await convexClient.mutation(api.chats.setActiveStream, { - chatId: chatId as Id<"chats">, - userId: convexUserId, - streamId, - }); - console.log("[Chat API] Active stream set successfully"); - } catch (err) { - console.error("[Chat API] Failed to set active stream:", err); - } + return json({ error: "Unauthorized" }, { status: 401 }); + } + const ownedChat = await convexClient.query(api.chats.get, { + chatId: chatId as Id<"chats">, + userId: convexUserId, + }); + if (!ownedChat) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + + if (redisReady) { + console.log("[Chat API POST] Initializing Redis stream for chat:", chatId); + await redis.stream.init(chatId, convexUserId, messageId); + } + + try { + console.log("[Chat API] Setting active stream:", streamId); + await convexClient.mutation(api.chats.setActiveStream, { + chatId: chatId as Id<"chats">, + userId: convexUserId, + streamId, + }); + console.log("[Chat API] Active stream set successfully"); + } catch (err) { + console.error("[Chat API] Failed to set active stream:", err); } } From 8034851c1d48e5182e19823a8981fb043cbd535f Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 22:04:17 -0500 Subject: [PATCH 17/40] fix(security): close auth fallback and token replay gaps --- apps/web/src/lib/server-auth.ts | 12 ++++++++++-- apps/web/src/lib/upstash.ts | 3 +-- apps/web/src/lib/workflow-auth-token.ts | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index 9f7627b0..5e16a140 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -16,6 +16,7 @@ type AuthSessionResponse = { const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; function getCookieValue(cookieHeader: string, name: string): string | null { const target = `${name}=`; @@ -43,18 +44,25 @@ export async function getConvexAuthToken(request: Request): Promise= 400 && response.status < 500) { return null; } + if (IS_PRODUCTION) return null; } catch { - // Network error — fall through to cookie fallback for resilience. + if (IS_PRODUCTION) return null; } } + if (IS_PRODUCTION) return null; return getCookieValue(cookie, "better-auth.convex_jwt"); } diff --git a/apps/web/src/lib/upstash.ts b/apps/web/src/lib/upstash.ts index 6e6e816a..ccbcf63c 100644 --- a/apps/web/src/lib/upstash.ts +++ b/apps/web/src/lib/upstash.ts @@ -35,13 +35,12 @@ function createSlidingWindowRatelimit( ): RatelimitLike | null { if (!upstashRedis) { if (!IS_PRODUCTION) return null; - const reset = Date.now() + 60_000; return { limit: async () => ({ success: false, limit: 0, remaining: 0, - reset, + reset: Date.now() + 60_000, pending: Promise.resolve(), }), }; diff --git a/apps/web/src/lib/workflow-auth-token.ts b/apps/web/src/lib/workflow-auth-token.ts index ac30801a..1e77abaa 100644 --- a/apps/web/src/lib/workflow-auth-token.ts +++ b/apps/web/src/lib/workflow-auth-token.ts @@ -23,7 +23,7 @@ export async function getWorkflowAuthToken(key: string): Promise if (!key.startsWith(`${WORKFLOW_AUTH_TOKEN_PREFIX}:`)) { return null; } - const value = await upstashRedis.get(key); + const value = await upstashRedis.getdel(key); if (typeof value !== "string" || value.length === 0) { return null; } From 0f4e92f76333627088146a7d4428699aa886db54 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 22:12:50 -0500 Subject: [PATCH 18/40] fix(security): reduce workflow secret exposure and endpoint hazards --- apps/server/convex/http.ts | 10 ++++-- apps/server/convex/lib/upstashUsage.ts | 2 ++ apps/web/src/routes/api/models.ts | 34 ++++++++++--------- .../src/routes/api/workflow/delete-account.ts | 17 ++++++---- .../src/routes/api/workflow/export-chat.ts | 26 ++++++++++---- .../src/routes/api/workflow/generate-title.ts | 33 +++++++++++------- 6 files changed, 79 insertions(+), 43 deletions(-) diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index 0bc8099a..eeaa982d 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -97,8 +97,14 @@ http.route({ try { result = await ctx.runAction(internal.cleanupAction.runCleanupBatchForWorkflow, { workflowToken: payload.workflowToken.trim(), - retentionDays: typeof payload.retentionDays === "number" ? payload.retentionDays : undefined, - batchSize: typeof payload.batchSize === "number" ? payload.batchSize : undefined, + retentionDays: + typeof payload.retentionDays === "number" && Number.isFinite(payload.retentionDays) + ? payload.retentionDays + : undefined, + batchSize: + typeof payload.batchSize === "number" && Number.isFinite(payload.batchSize) + ? payload.batchSize + : undefined, dryRun: typeof payload.dryRun === "boolean" ? payload.dryRun : undefined, }); } catch (error) { diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index eb9cc8b8..439b334d 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -4,6 +4,7 @@ type PipelineEntry = { }; type PipelineResult = Array; +const UPSTASH_PIPELINE_TIMEOUT_MS = 10_000; function getConfig(): { url: string; token: string } | null { const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); @@ -40,6 +41,7 @@ async function executePipeline(commands: Array>): Promise "Content-Type": "application/json", }, body: JSON.stringify(commands), + signal: AbortSignal.timeout(UPSTASH_PIPELINE_TIMEOUT_MS), }); if (!response.ok) { diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index cb52e238..0cd049fe 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -60,26 +60,28 @@ async function fetchModelsFromOpenRouter(): Promise { } function getClientIp(request: Request): string | null { - if (TRUST_PROXY) { - const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); - if (cfConnectingIp) return cfConnectingIp; - - const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim(); - if (vercelForwardedFor) { - const first = vercelForwardedFor.split(",")[0]?.trim(); - if (first) return first; - } + if (!TRUST_PROXY) { + return "untrusted-proxy"; + } - const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); - if (forwardedFor) { - const first = forwardedFor.split(",")[0]?.trim(); - if (first) return first; - } + const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); + if (cfConnectingIp) return cfConnectingIp; - const realIp = request.headers.get("x-real-ip")?.trim(); - if (realIp) return realIp; + const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim(); + if (vercelForwardedFor) { + const first = vercelForwardedFor.split(",")[0]?.trim(); + if (first) return first; } + const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); + if (forwardedFor) { + const first = forwardedFor.split(",")[0]?.trim(); + if (first) return first; + } + + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; + return null; } diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 11d8154c..2561bd58 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -81,8 +81,8 @@ async function clearRedisUserKeys(userId: string): Promise { ]; let deleted = 0; - for (const key of directKeys) { - deleted += await upstashRedis.del(key); + if (directKeys.length > 0) { + deleted += await upstashRedis.del(...directKeys); } for (const pattern of patternKeys) { @@ -184,9 +184,7 @@ const workflow = serve(async (context) => { return EMPTY_DELETE_RESULT; } - const authToken = await context.run("resolve-auth", async () => { - return getWorkflowAuthToken(authTokenRef); - }); + const authToken = await getWorkflowAuthToken(authTokenRef); if (!authToken) { return EMPTY_DELETE_RESULT; } @@ -283,7 +281,14 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ if (authRatelimit) { const rl = await authRatelimit.limit(`delete-account:${authConvexUser._id}`); if (!rl.success) { - return json({ error: "Rate limit exceeded" }, { status: 429 }); + const retryAfterSeconds = Math.max( + 1, + Math.ceil((rl.reset - Date.now()) / 1000), + ); + return json( + { error: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": String(retryAfterSeconds) } }, + ); } } diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 556d0e9f..120adbe5 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -13,6 +13,8 @@ import { import { getWorkflowAuthToken, storeWorkflowAuthToken } from "@/lib/workflow-auth-token"; type ExportFormat = "markdown" | "json"; +const MAX_EXPORT_BYTES = 10 * 1024 * 1024; + type ExportChatPayload = { chatId: string; userId: string; @@ -146,12 +148,16 @@ async function runExportChatInline( format === "json" ? JSON.stringify(chatExportData, null, 2) : formatExportMarkdown(chatExportData); + const byteLength = Buffer.byteLength(formattedExport, "utf8"); + if (byteLength > MAX_EXPORT_BYTES) { + throw new Error("Export too large"); + } const mimeType = format === "json" ? "application/json" : "text/markdown"; const base64 = Buffer.from(formattedExport, "utf8").toString("base64"); return { downloadUrl: `data:${mimeType};base64,${base64}`, - byteLength: Buffer.byteLength(formattedExport, "utf8"), + byteLength, fileName: `chat-export-${chatId}.${format === "json" ? "json" : "md"}`, }; } @@ -166,9 +172,7 @@ const workflow = serve(async (context) => { }; } - const authToken = await context.run("resolve-auth", async () => { - return getWorkflowAuthToken(authTokenRef); - }); + const authToken = await getWorkflowAuthToken(authTokenRef); if (!authToken) { return { error: "Unauthorized", @@ -196,11 +200,15 @@ const workflow = serve(async (context) => { }); const uploadResult = await context.run("upload", async () => { + const byteLength = Buffer.byteLength(formattedExport, "utf8"); + if (byteLength > MAX_EXPORT_BYTES) { + throw new Error("Export too large"); + } const mimeType = format === "json" ? "application/json" : "text/markdown"; const base64 = Buffer.from(formattedExport, "utf8").toString("base64"); return { downloadUrl: `data:${mimeType};base64,${base64}`, - byteLength: Buffer.byteLength(formattedExport, "utf8"), + byteLength, fileName: `chat-export-${chatId}.${format === "json" ? "json" : "md"}`, }; }); @@ -272,7 +280,13 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ } catch (error) { const message = error instanceof Error ? error.message : "Failed to export chat"; const status = - message === "Unauthorized" ? 401 : message === "Chat not found" ? 404 : 500; + message === "Unauthorized" + ? 401 + : message === "Chat not found" + ? 404 + : message === "Export too large" + ? 413 + : 500; return json({ error: message }, { status }); } } diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 021eb40d..a6a5853e 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -4,7 +4,6 @@ import { serve } from "@upstash/workflow/tanstack"; import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; -import { decryptSecret } from "@/lib/server-crypto"; import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth"; import { authRatelimit, @@ -186,9 +185,7 @@ const workflow = serve(async (context) => { return { saved: false, reason: "unauthorized" } as const; } - const authToken = await context.run("resolve-auth", async () => { - return getWorkflowAuthToken(authTokenRef); - }); + const authToken = await getWorkflowAuthToken(authTokenRef); if (!authToken) { return { saved: false, reason: "unauthorized" } as const; } @@ -210,16 +207,11 @@ const workflow = serve(async (context) => { return { saved: false, reason: "empty_seed" } as const; } - const openRouterKey = await context.run("resolve-openrouter-key", async () => { - if (provider === "osschat") { - return process.env.OPENROUTER_API_KEY ?? null; - } + if (provider !== "osschat") { + return { saved: false, reason: "missing_openrouter_key" } as const; + } - const encryptedKey = await convexClient.query(api.users.getOpenRouterKey, { - userId: convexUserId, - }); - return encryptedKey ? decryptSecret(encryptedKey) : null; - }); + const openRouterKey = process.env.OPENROUTER_API_KEY ?? null; if (!openRouterKey) { return { saved: false, reason: "missing_openrouter_key" } as const; } @@ -338,6 +330,21 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ userId: authConvexUser._id, }; + if (normalizedPayload.provider === "openrouter") { + try { + const result = await runGenerateTitleInline(normalizedPayload, authToken); + if (!result.saved) { + const status = result.reason === "missing_openrouter_key" ? 400 : 409; + return json(result, { status }); + } + return json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to generate title"; + const status = message === "Unauthorized" ? 401 : 500; + return json({ error: message }, { status }); + } + } + if (isLocalWorkflowRequest(request)) { try { const result = await runGenerateTitleInline(normalizedPayload, authToken); From ec3519e9f9d38e6f23c3a58fb4171a7adb05954f Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 22:27:38 -0500 Subject: [PATCH 19/40] fix(security): stabilize workflow auth and endpoint safeguards --- apps/server/convex/backgroundStream.ts | 6 +++++- apps/server/convex/chats.test.ts | 6 +++++- apps/server/convex/http.ts | 7 +++++++ apps/web/src/lib/redis.ts | 2 ++ apps/web/src/lib/upstash.ts | 4 ++++ apps/web/src/routes/api/models.ts | 7 +++++-- apps/web/src/routes/api/openrouter-key.ts | 12 ++++++++++-- apps/web/src/routes/api/workflow/delete-account.ts | 4 +++- apps/web/src/routes/api/workflow/export-chat.ts | 4 +++- apps/web/src/routes/api/workflow/generate-title.ts | 4 +++- scripts/check-redis.ts | 5 +++-- 11 files changed, 50 insertions(+), 11 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index 3b72ccf1..130fbaf2 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -1460,7 +1460,11 @@ export const executeStream = internalAction({ }); } catch (error) { if (reservedDateKey && reservedUsageCents > 0) { - await adjustDailyUsageInUpstash(job.userId, reservedDateKey, -reservedUsageCents); + try { + await adjustDailyUsageInUpstash(job.userId, reservedDateKey, -reservedUsageCents); + } catch (adjustError) { + console.warn("[Usage] Upstash refund adjustment failed", adjustError); + } } const errorMessage = error instanceof Error ? error.message : "Unknown error"; await ctx.runMutation(internal.backgroundStream.failStream, { diff --git a/apps/server/convex/chats.test.ts b/apps/server/convex/chats.test.ts index e3db2148..c750edec 100644 --- a/apps/server/convex/chats.test.ts +++ b/apps/server/convex/chats.test.ts @@ -1247,7 +1247,11 @@ describe('chats.generateAndSetTitleInternal', () => { }); afterEach(() => { - process.env.OPENROUTER_API_KEY = originalApiKey; + if (originalApiKey === undefined) { + delete process.env.OPENROUTER_API_KEY; + } else { + process.env.OPENROUTER_API_KEY = originalApiKey; + } vi.restoreAllMocks(); }); diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index eeaa982d..bafc772e 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -79,6 +79,13 @@ http.route({ }); } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return new Response(JSON.stringify({ error: "Invalid JSON payload" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + const payload = body as { workflowToken?: unknown; retentionDays?: unknown; diff --git a/apps/web/src/lib/redis.ts b/apps/web/src/lib/redis.ts index 0fd5c441..0bda95b7 100644 --- a/apps/web/src/lib/redis.ts +++ b/apps/web/src/lib/redis.ts @@ -184,6 +184,8 @@ export async function readStream( timestamp: typeof message.ts === "string" ? Number.parseInt(message.ts, 10) + : typeof message.ts === "number" + ? message.ts : Date.now(), })); } diff --git a/apps/web/src/lib/upstash.ts b/apps/web/src/lib/upstash.ts index ccbcf63c..ad6bcfac 100644 --- a/apps/web/src/lib/upstash.ts +++ b/apps/web/src/lib/upstash.ts @@ -72,6 +72,10 @@ if (!upstashRedis) { } } +if (!workflowClient && IS_PRODUCTION) { + console.error("[Upstash] QStash not configured in production"); +} + export function isUpstashRedisConfigured(): boolean { return upstashRedis !== null; } diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 0cd049fe..a538e425 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -61,7 +61,7 @@ async function fetchModelsFromOpenRouter(): Promise { function getClientIp(request: Request): string | null { if (!TRUST_PROXY) { - return "untrusted-proxy"; + return null; } const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); @@ -96,7 +96,10 @@ export const Route = createFileRoute("/api/models")({ if (modelsIpRatelimit) { const ip = getClientIp(request); if (!ip) { - return json({ error: "Unable to identify client" }, { status: 400 }); + return json( + { error: "Rate limiting unavailable: configure TRUST_PROXY" }, + { status: 503 }, + ); } const rl = await modelsIpRatelimit.limit(ip); if (!rl.success) { diff --git a/apps/web/src/routes/api/openrouter-key.ts b/apps/web/src/routes/api/openrouter-key.ts index 3cb62495..c450f107 100644 --- a/apps/web/src/routes/api/openrouter-key.ts +++ b/apps/web/src/routes/api/openrouter-key.ts @@ -68,9 +68,13 @@ export const Route = createFileRoute("/api/openrouter-key")({ if (authRatelimit) { const rate = await authRatelimit.limit(`openrouter-key:post:${convexUserId}`); if (!rate.success) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((rate.reset - Date.now()) / 1000), + ); return json( { error: "Too many key update attempts. Please try again shortly." }, - { status: 429 }, + { status: 429, headers: { "Retry-After": String(retryAfterSeconds) } }, ); } } @@ -110,9 +114,13 @@ export const Route = createFileRoute("/api/openrouter-key")({ if (authRatelimit) { const rate = await authRatelimit.limit(`openrouter-key:delete:${convexUserId}`); if (!rate.success) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((rate.reset - Date.now()) / 1000), + ); return json( { error: "Too many key update attempts. Please try again shortly." }, - { status: 429 }, + { status: 429, headers: { "Retry-After": String(retryAfterSeconds) } }, ); } } diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 2561bd58..ab4c1ede 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -184,7 +184,9 @@ const workflow = serve(async (context) => { return EMPTY_DELETE_RESULT; } - const authToken = await getWorkflowAuthToken(authTokenRef); + const authToken = await context.run("resolve-auth", async () => { + return getWorkflowAuthToken(authTokenRef); + }); if (!authToken) { return EMPTY_DELETE_RESULT; } diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 120adbe5..6e67472f 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -172,7 +172,9 @@ const workflow = serve(async (context) => { }; } - const authToken = await getWorkflowAuthToken(authTokenRef); + const authToken = await context.run("resolve-auth", async () => { + return getWorkflowAuthToken(authTokenRef); + }); if (!authToken) { return { error: "Unauthorized", diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index a6a5853e..01c81b76 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -185,7 +185,9 @@ const workflow = serve(async (context) => { return { saved: false, reason: "unauthorized" } as const; } - const authToken = await getWorkflowAuthToken(authTokenRef); + const authToken = await context.run("resolve-auth", async () => { + return getWorkflowAuthToken(authTokenRef); + }); if (!authToken) { return { saved: false, reason: "unauthorized" } as const; } diff --git a/scripts/check-redis.ts b/scripts/check-redis.ts index 320bac06..7730c111 100644 --- a/scripts/check-redis.ts +++ b/scripts/check-redis.ts @@ -39,7 +39,7 @@ function loadLocalEnvDefaults(): void { if (!existsSync(filePath)) continue; const parsed = parseEnvFile(readFileSync(filePath, "utf8")); for (const [key, value] of Object.entries(parsed)) { - if (!process.env[key]) { + if (process.env[key] === undefined) { process.env[key] = value; } } @@ -60,7 +60,8 @@ async function main() { } try { - const response = await fetch(`${url}/pipeline`, { + const normalizedUrl = url.replace(/\/+$/, ""); + const response = await fetch(`${normalizedUrl}/pipeline`, { method: "POST", headers: { Authorization: `Bearer ${token}`, From 664efc2428fd48a3ac0a85af7a8e30385a274a35 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 22:34:19 -0500 Subject: [PATCH 20/40] fix(security): enforce signed workflow callbacks and safer parsing --- apps/server/convex/users.ts | 4 --- apps/web/src/lib/redis.ts | 9 ++++-- apps/web/src/routes/api/models.ts | 28 +++++++++++++------ apps/web/src/routes/api/workflow/cleanup.ts | 9 ++++++ .../src/routes/api/workflow/delete-account.ts | 9 ++++++ .../src/routes/api/workflow/export-chat.ts | 9 ++++++ .../src/routes/api/workflow/generate-title.ts | 9 ++++++ 7 files changed, 61 insertions(+), 16 deletions(-) diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 85c7bd97..8d244acb 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -858,10 +858,6 @@ export const deleteAccountWorkflowStep = action({ args, ): Promise<{ deleted: number; hasMore: boolean; success?: boolean }> => { const userId = await requireAuthUserIdFromAction(ctx, args.userId); - const identity = await ctx.auth.getUserIdentity(); - if (!identity || identity.subject !== args.externalId) { - throw new Error("Unauthorized"); - } switch (args.step) { case "delete-stream-jobs": diff --git a/apps/web/src/lib/redis.ts b/apps/web/src/lib/redis.ts index 0bda95b7..5838840b 100644 --- a/apps/web/src/lib/redis.ts +++ b/apps/web/src/lib/redis.ts @@ -183,10 +183,13 @@ export async function readStream( : "text", timestamp: typeof message.ts === "string" - ? Number.parseInt(message.ts, 10) - : typeof message.ts === "number" + ? (() => { + const parsed = Number.parseInt(message.ts, 10); + return Number.isFinite(parsed) ? parsed : Date.now(); + })() + : typeof message.ts === "number" && Number.isFinite(message.ts) ? message.ts - : Date.now(), + : Date.now(), })); } diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index a538e425..0828342b 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -7,7 +7,7 @@ const MODELS_CACHE_KEY = "openchat:models"; const MODELS_CACHE_TTL_SECONDS = 60 * 60 * 4; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const OPENROUTER_FETCH_TIMEOUT_MS = 10_000; -const TRUST_PROXY = process.env.TRUST_PROXY === "true"; +const TRUST_PROXY_MODE = process.env.TRUST_PROXY?.trim().toLowerCase(); const modelsIpRatelimit = upstashRedis ? new Ratelimit({ @@ -60,17 +60,26 @@ async function fetchModelsFromOpenRouter(): Promise { } function getClientIp(request: Request): string | null { - if (!TRUST_PROXY) { + if (!TRUST_PROXY_MODE) { return null; } - const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); - if (cfConnectingIp) return cfConnectingIp; + if (TRUST_PROXY_MODE === "cloudflare") { + const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); + return cfConnectingIp || null; + } - const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim(); - if (vercelForwardedFor) { - const first = vercelForwardedFor.split(",")[0]?.trim(); - if (first) return first; + if (TRUST_PROXY_MODE === "vercel") { + const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim(); + if (vercelForwardedFor) { + const first = vercelForwardedFor.split(",")[0]?.trim(); + if (first) return first; + } + return null; + } + + if (TRUST_PROXY_MODE !== "true") { + return null; } const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); @@ -96,8 +105,9 @@ export const Route = createFileRoute("/api/models")({ if (modelsIpRatelimit) { const ip = getClientIp(request); if (!ip) { + console.warn("[Models API] Trusted proxy IP unavailable for rate limit"); return json( - { error: "Rate limiting unavailable: configure TRUST_PROXY" }, + { error: "Service temporarily unavailable" }, { status: 503 }, ); } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index 102f8922..70aed694 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -74,6 +74,12 @@ function hasValidCleanupToken(headers: Headers): boolean { return safeCompare(workflowHeader.trim(), expectedToken, expectedToken); } +function hasWorkflowSigningKeysConfigured(): boolean { + return Boolean( + process.env.QSTASH_CURRENT_SIGNING_KEY && process.env.QSTASH_NEXT_SIGNING_KEY, + ); +} + async function runCleanupInline(payload: CleanupPayload): Promise<{ success: boolean; batches: number; @@ -233,6 +239,9 @@ export const Route = createFileRoute("/api/workflow/cleanup")({ POST: async ({ request }) => { const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); if (isWorkflowCallback) { + if (!hasWorkflowSigningKeysConfigured()) { + return json({ error: "Workflow signing not configured" }, { status: 500 }); + } return workflow.POST({ request }); } diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index ab4c1ede..eb9c9ace 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -115,6 +115,12 @@ function getWorkflowTriggerHeaders(): Record { }; } +function hasWorkflowSigningKeysConfigured(): boolean { + return Boolean( + process.env.QSTASH_CURRENT_SIGNING_KEY && process.env.QSTASH_NEXT_SIGNING_KEY, + ); +} + async function runDeleteAccountInline( payload: DeleteAccountPayload, authToken: string, @@ -253,6 +259,9 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ POST: async ({ request }) => { const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); if (isWorkflowCallback) { + if (!hasWorkflowSigningKeysConfigured()) { + return json({ error: "Workflow signing not configured" }, { status: 500 }); + } return workflow.POST({ request }); } diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 6e67472f..6e3225a1 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -93,6 +93,12 @@ function getWorkflowTriggerHeaders(): Record { }; } +function hasWorkflowSigningKeysConfigured(): boolean { + return Boolean( + process.env.QSTASH_CURRENT_SIGNING_KEY && process.env.QSTASH_NEXT_SIGNING_KEY, + ); +} + function formatExportMarkdown(data: ChatExportData): string { const lines: string[] = []; lines.push(`# ${data.chat.title}`); @@ -226,6 +232,9 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ POST: async ({ request }) => { const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); if (isWorkflowCallback) { + if (!hasWorkflowSigningKeysConfigured()) { + return json({ error: "Workflow signing not configured" }, { status: 500 }); + } return workflow.POST({ request }); } diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 01c81b76..77726146 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -106,6 +106,12 @@ function getWorkflowTriggerHeaders(): Record { }; } +function hasWorkflowSigningKeysConfigured(): boolean { + return Boolean( + process.env.QSTASH_CURRENT_SIGNING_KEY && process.env.QSTASH_NEXT_SIGNING_KEY, + ); +} + async function runGenerateTitleInline( payload: GenerateTitlePayload, authToken: string, @@ -282,6 +288,9 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ POST: async ({ request }) => { const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); if (isWorkflowCallback) { + if (!hasWorkflowSigningKeysConfigured()) { + return json({ error: "Workflow signing not configured" }, { status: 500 }); + } return workflow.POST({ request }); } From e535f6b97253c3a9922532b9fe6f68b3965cb607 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 22:57:33 -0500 Subject: [PATCH 21/40] fix(security): harden stream limits and workflow error surfaces --- apps/server/convex/backgroundStream.ts | 24 +++++++++++++++---- apps/server/convex/users.ts | 8 +++---- apps/web/src/routes/api/models.ts | 12 +--------- apps/web/src/routes/api/workflow/cleanup.ts | 8 +++---- .../src/routes/api/workflow/delete-account.ts | 9 ++++--- .../src/routes/api/workflow/export-chat.ts | 9 ++++--- .../src/routes/api/workflow/generate-title.ts | 14 +++++++---- 7 files changed, 50 insertions(+), 34 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index 130fbaf2..cef90ce5 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -19,6 +19,8 @@ import { } from "./lib/upstashUsage"; import { requireAuthUserId } from "./lib/auth"; import { decryptSecret } from "./lib/crypto"; +import { rateLimiter } from "./lib/rateLimiter"; +import { throwRateLimitError } from "./lib/rateLimitUtils"; const chainOfThoughtPartValidator = v.object({ type: v.union(v.literal("reasoning"), v.literal("tool")), @@ -300,6 +302,13 @@ export const startStream = mutation({ returns: v.id("streamJobs"), handler: async (ctx, args) => { const userId = await requireAuthUserId(ctx, args.userId); + const { ok, retryAfter } = await rateLimiter.limit(ctx, "messageSend", { + key: userId, + }); + if (!ok) { + throwRateLimitError("streams started", retryAfter); + } + const chat = await ctx.db.get(args.chatId); if (!chat || chat.userId !== userId) { throw new Error("Chat not found or unauthorized"); @@ -821,17 +830,24 @@ export const executeStream = internalAction({ } } else { const redisUsageCents = await getDailyUsageFromUpstash(job.userId, currentDate); + if (redisUsageCents === null) { + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: "Usage tracking temporarily unavailable. Please retry shortly.", + }); + return; + } const fallbackUsageCents = - redisUsageCents ?? - (await ctx.runQuery( + await ctx.runQuery( internal.backgroundStream.getPersistedDailyUsageForDateInternal, { userId: job.userId, dateKey: currentDate, }, - )); + ); + const enforcedUsageCents = Math.max(redisUsageCents, fallbackUsageCents); - if (fallbackUsageCents >= DAILY_AI_LIMIT_CENTS) { + if (enforcedUsageCents >= DAILY_AI_LIMIT_CENTS) { await ctx.runMutation(internal.backgroundStream.failStream, { jobId: args.jobId, error: "Daily usage limit reached. Connect your OpenRouter account to continue.", diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 8d244acb..1499e7a0 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -7,6 +7,8 @@ import { getProfileByUserId, getOrCreateProfile } from "./lib/profiles"; import { components, internal } from "./_generated/api"; import { requireAuthUserId, requireAuthUserIdFromAction } from "./lib/auth"; +const EMAIL_LINK_MIGRATION_DEADLINE_MS = Date.parse("2026-06-01T00:00:00.000Z"); + // User with profile data (for backwards-compatible responses) // Includes merged profile data that prefers profile over user during migration const userWithProfileDoc = v.object({ @@ -70,7 +72,7 @@ export const ensure = mutation({ // MIGRATION: Link WorkOS users to Better Auth by email // Uses .first() since duplicate emails may exist from prior migrations - if (!existing && args.email) { + if (!existing && args.email && Date.now() < EMAIL_LINK_MIGRATION_DEADLINE_MS) { const existingByEmail = await ctx.db .query("users") .withIndex("by_email", (q) => q.eq("email", args.email)) @@ -212,8 +214,6 @@ export const getByExternalId = query({ // Profile fields: prefer profile data, fall back to user data for migration name: profile?.name ?? user.name, avatarUrl: profile?.avatarUrl ?? user.avatarUrl, - encryptedOpenRouterKey: - profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey, fileUploadCount: profile?.fileUploadCount ?? user.fileUploadCount ?? 0, aiUsageCents: user.aiUsageCents, aiUsageDate: user.aiUsageDate, @@ -289,8 +289,6 @@ export const getByExternalIdInternal = internalQuery({ // Profile fields: prefer profile data, fall back to user data for migration name: profile?.name ?? user.name, avatarUrl: profile?.avatarUrl ?? user.avatarUrl, - encryptedOpenRouterKey: - profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey, fileUploadCount: profile?.fileUploadCount ?? user.fileUploadCount ?? 0, aiUsageCents: user.aiUsageCents, aiUsageDate: user.aiUsageDate, diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 0828342b..196ca49a 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -78,19 +78,9 @@ function getClientIp(request: Request): string | null { return null; } - if (TRUST_PROXY_MODE !== "true") { + if (TRUST_PROXY_MODE === "true") { return null; } - - const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); - if (forwardedFor) { - const first = forwardedFor.split(",")[0]?.trim(); - if (first) return first; - } - - const realIp = request.headers.get("x-real-ip")?.trim(); - if (realIp) return realIp; - return null; } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index 70aed694..6f8d81a6 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -265,8 +265,8 @@ export const Route = createFileRoute("/api/workflow/cleanup")({ const result = await runCleanupInline(payload); return json(result, { status: 200 }); } catch (error) { - const message = error instanceof Error ? error.message : "Cleanup failed"; - return json({ error: message }, { status: 500 }); + console.error("[Workflow][cleanup] Local execution failed", error); + return json({ error: "Internal server error" }, { status: 500 }); } } @@ -289,8 +289,8 @@ export const Route = createFileRoute("/api/workflow/cleanup")({ }); return json({ queued: true, workflowRunId }, { status: 202 }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to queue workflow"; - return json({ error: message }, { status: 500 }); + console.error("[Workflow][cleanup] Queue trigger failed", error); + return json({ error: "Internal server error" }, { status: 500 }); } }, }, diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index eb9c9ace..e9da6cfb 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -326,7 +326,10 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete account"; const status = message === "Unauthorized" ? 401 : 500; - return json({ error: message }, { status }); + if (status === 500) { + console.error("[Workflow][delete-account] Local execution failed", error); + } + return json({ error: status === 500 ? "Internal server error" : message }, { status }); } } @@ -357,8 +360,8 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ }); return json({ queued: true, workflowRunId }, { status: 202 }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to queue workflow"; - return json({ error: message }, { status: 500 }); + console.error("[Workflow][delete-account] Queue trigger failed", error); + return json({ error: "Internal server error" }, { status: 500 }); } }, }, diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 6e3225a1..7b57f154 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -298,7 +298,10 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ : message === "Export too large" ? 413 : 500; - return json({ error: message }, { status }); + if (status === 500) { + console.error("[Workflow][export-chat] Local execution failed", error); + } + return json({ error: status === 500 ? "Internal server error" : message }, { status }); } } @@ -329,8 +332,8 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ }); return json({ queued: true, workflowRunId }, { status: 202 }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to queue workflow"; - return json({ error: message }, { status: 500 }); + console.error("[Workflow][export-chat] Queue trigger failed", error); + return json({ error: "Internal server error" }, { status: 500 }); } }, }, diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 77726146..d4e2db24 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -352,7 +352,10 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ } catch (error) { const message = error instanceof Error ? error.message : "Failed to generate title"; const status = message === "Unauthorized" ? 401 : 500; - return json({ error: message }, { status }); + if (status === 500) { + console.error("[Workflow][generate-title] Inline generation failed", error); + } + return json({ error: status === 500 ? "Internal server error" : message }, { status }); } } @@ -367,7 +370,10 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ } catch (error) { const message = error instanceof Error ? error.message : "Failed to generate title"; const status = message === "Unauthorized" ? 401 : 500; - return json({ error: message }, { status }); + if (status === 500) { + console.error("[Workflow][generate-title] Local execution failed", error); + } + return json({ error: status === 500 ? "Internal server error" : message }, { status }); } } @@ -398,8 +404,8 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ }); return json({ queued: true, workflowRunId }, { status: 202 }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to queue workflow"; - return json({ error: message }, { status: 500 }); + console.error("[Workflow][generate-title] Queue trigger failed", error); + return json({ error: "Internal server error" }, { status: 500 }); } }, }, From 770d8226d35ef160c1c2d524698e92a7877e701c Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 23:03:22 -0500 Subject: [PATCH 22/40] fix(web): restore generic proxy IP extraction path --- apps/web/src/routes/api/models.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 196ca49a..38e41315 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -79,6 +79,19 @@ function getClientIp(request: Request): string | null { } if (TRUST_PROXY_MODE === "true") { + const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); + if (forwardedFor) { + const entries = forwardedFor + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + const last = entries.at(-1); + if (last) return last; + } + + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; + return null; } return null; From 795001a409c8e8fa80e9d34360fbd500a4553ae1 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 23:12:04 -0500 Subject: [PATCH 23/40] fix(security): fail closed when usage reserve is unavailable --- apps/server/convex/backgroundStream.ts | 43 +++++++++----------------- apps/web/src/routes/api/models.ts | 8 ++--- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index cef90ce5..589c0e33 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -13,7 +13,6 @@ import { import type { UsagePayload } from "./lib/billingUtils"; import { adjustDailyUsageInUpstash, - getDailyUsageFromUpstash, incrementDailyUsageInUpstash, reserveDailyUsageInUpstash, } from "./lib/upstashUsage"; @@ -829,31 +828,11 @@ export const executeStream = internalAction({ return; } } else { - const redisUsageCents = await getDailyUsageFromUpstash(job.userId, currentDate); - if (redisUsageCents === null) { - await ctx.runMutation(internal.backgroundStream.failStream, { - jobId: args.jobId, - error: "Usage tracking temporarily unavailable. Please retry shortly.", - }); - return; - } - const fallbackUsageCents = - await ctx.runQuery( - internal.backgroundStream.getPersistedDailyUsageForDateInternal, - { - userId: job.userId, - dateKey: currentDate, - }, - ); - const enforcedUsageCents = Math.max(redisUsageCents, fallbackUsageCents); - - if (enforcedUsageCents >= DAILY_AI_LIMIT_CENTS) { - await ctx.runMutation(internal.backgroundStream.failStream, { - jobId: args.jobId, - error: "Daily usage limit reached. Connect your OpenRouter account to continue.", - }); - return; - } + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: "Usage tracking temporarily unavailable. Please retry shortly.", + }); + return; } } @@ -1482,10 +1461,18 @@ export const executeStream = internalAction({ console.warn("[Usage] Upstash refund adjustment failed", adjustError); } } - const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const rawErrorMessage = error instanceof Error ? error.message : "Unknown error"; + const lowerErrorMessage = rawErrorMessage.toLowerCase(); + const safeErrorMessage = + lowerErrorMessage.includes("limit reached") || + lowerErrorMessage.includes("rate limit") || + lowerErrorMessage.includes("unauthorized") || + lowerErrorMessage.includes("not found") + ? rawErrorMessage + : "An error occurred while processing your request."; await ctx.runMutation(internal.backgroundStream.failStream, { jobId: args.jobId, - error: errorMessage, + error: safeErrorMessage, partialContent: fullContent, }); } diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 38e41315..ad0d15c9 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -81,12 +81,8 @@ function getClientIp(request: Request): string | null { if (TRUST_PROXY_MODE === "true") { const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); if (forwardedFor) { - const entries = forwardedFor - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - const last = entries.at(-1); - if (last) return last; + const first = forwardedFor.split(",")[0]?.trim(); + if (first) return first; } const realIp = request.headers.get("x-real-ip")?.trim(); From ebd3049a64aa2e096781c33e3fd59497c6058f78 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 23:23:16 -0500 Subject: [PATCH 24/40] fix(security): reject empty workflow auth tokens --- apps/web/src/lib/workflow-auth-token.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/workflow-auth-token.ts b/apps/web/src/lib/workflow-auth-token.ts index 1e77abaa..91ea5e1b 100644 --- a/apps/web/src/lib/workflow-auth-token.ts +++ b/apps/web/src/lib/workflow-auth-token.ts @@ -10,6 +10,7 @@ function createWorkflowAuthTokenKey(): string { export async function storeWorkflowAuthToken(authToken: string): Promise { if (!upstashRedis) return null; + if (authToken.trim().length === 0) return null; const key = createWorkflowAuthTokenKey(); const encrypted = encryptSecret(authToken); await upstashRedis.set(key, encrypted, { @@ -28,7 +29,8 @@ export async function getWorkflowAuthToken(key: string): Promise return null; } try { - return decryptSecret(value); + const token = decryptSecret(value); + return token.trim().length > 0 ? token : null; } catch { return null; } From 059948e3488740b148c493c51dd4ba1ebfe7e560 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 23:26:32 -0500 Subject: [PATCH 25/40] fix(security): harden workflow mode selection and model headers --- apps/web/src/routes/api/models.ts | 2 -- apps/web/src/routes/api/workflow/cleanup.ts | 9 +++------ apps/web/src/routes/api/workflow/delete-account.ts | 9 +++------ apps/web/src/routes/api/workflow/export-chat.ts | 9 +++------ apps/web/src/routes/api/workflow/generate-title.ts | 9 +++------ 5 files changed, 12 insertions(+), 26 deletions(-) diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index ad0d15c9..e1f17b40 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -50,7 +50,6 @@ async function fetchModelsFromOpenRouter(): Promise { headers: { "Content-Type": "application/json", "Cache-Control": "no-store", - "X-Models-Cache": "MISS", }, }); } catch (error) { @@ -141,7 +140,6 @@ export const Route = createFileRoute("/api/models")({ headers: { "Content-Type": "application/json", "Cache-Control": "no-store", - "X-Models-Cache": "HIT", }, }); } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index 6f8d81a6..d6584fe0 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -14,11 +14,8 @@ const CLEANUP_BATCH_TIMEOUT_MS = 15_000; const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; -function isLocalWorkflowRequest(request: Request): boolean { - const hostname = new URL(request.url).hostname; - return ( - hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" - ); +function isLocalWorkflowExecutionEnabled(): boolean { + return process.env.NODE_ENV !== "production"; } function parseCleanupPayload(raw: unknown): CleanupPayload | null { @@ -260,7 +257,7 @@ export const Route = createFileRoute("/api/workflow/cleanup")({ return json({ error: "Invalid cleanup payload" }, { status: 400 }); } - if (isLocalWorkflowRequest(request)) { + if (isLocalWorkflowExecutionEnabled()) { try { const result = await runCleanupInline(payload); return json(result, { status: 200 }); diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index e9da6cfb..6eee8efa 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -102,11 +102,8 @@ async function clearRedisUserKeys(userId: string): Promise { return deleted; } -function isLocalWorkflowRequest(request: Request): boolean { - const hostname = new URL(request.url).hostname; - return ( - hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" - ); +function isLocalWorkflowExecutionEnabled(): boolean { + return process.env.NODE_ENV !== "production"; } function getWorkflowTriggerHeaders(): Record { @@ -319,7 +316,7 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ externalId: authUser.id, }; - if (isLocalWorkflowRequest(request)) { + if (isLocalWorkflowExecutionEnabled()) { try { const result = await runDeleteAccountInline(normalizedPayload, authToken); return json(result, { status: 200 }); diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 7b57f154..3ecc2b7a 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -72,11 +72,8 @@ function parseExportPayload(raw: unknown): ExportChatPayload | null { }; } -function isLocalWorkflowRequest(request: Request): boolean { - const hostname = new URL(request.url).hostname; - return ( - hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" - ); +function isLocalWorkflowExecutionEnabled(): boolean { + return process.env.NODE_ENV !== "production"; } function formatRole(role: string): string { @@ -284,7 +281,7 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ userId: authConvexUser._id, }; - if (isLocalWorkflowRequest(request)) { + if (isLocalWorkflowExecutionEnabled()) { try { const result = await runExportChatInline(normalizedPayload, authToken); return json(result, { status: 200 }); diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index d4e2db24..ac1f64d3 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -93,11 +93,8 @@ function sanitizeGeneratedTitle(input: string): string { return title.slice(0, TITLE_MAX_LENGTH); } -function isLocalWorkflowRequest(request: Request): boolean { - const hostname = new URL(request.url).hostname; - return ( - hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" - ); +function isLocalWorkflowExecutionEnabled(): boolean { + return process.env.NODE_ENV !== "production"; } function getWorkflowTriggerHeaders(): Record { @@ -359,7 +356,7 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ } } - if (isLocalWorkflowRequest(request)) { + if (isLocalWorkflowExecutionEnabled()) { try { const result = await runGenerateTitleInline(normalizedPayload, authToken); if (!result.saved) { From d200d7c09e809a1b564a355ea72c23de9b9026df Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 23:28:58 -0500 Subject: [PATCH 26/40] fix(security): restrict cookie fallback and keep key off qstash --- apps/web/src/lib/server-auth.ts | 8 +-- .../src/routes/api/workflow/generate-title.ts | 54 +++++++++++-------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index 5e16a140..bf9bafdc 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -17,6 +17,8 @@ type AuthSessionResponse = { const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; const IS_PRODUCTION = process.env.NODE_ENV === "production"; +const IS_LOCAL_DEV = + !IS_PRODUCTION && !process.env.VERCEL && !process.env.CONVEX_CLOUD_URL; function getCookieValue(cookieHeader: string, name: string): string | null { const target = `${name}=`; @@ -56,13 +58,13 @@ export async function getConvexAuthToken(request: Request): Promise= 400 && response.status < 500) { return null; } - if (IS_PRODUCTION) return null; + if (!IS_LOCAL_DEV) return null; } catch { - if (IS_PRODUCTION) return null; + if (!IS_LOCAL_DEV) return null; } } - if (IS_PRODUCTION) return null; + if (!IS_LOCAL_DEV) return null; return getCookieValue(cookie, "better-auth.convex_jwt"); } diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index ac1f64d3..804dbd5f 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -14,6 +14,7 @@ import { getWorkflowAuthToken, storeWorkflowAuthToken } from "@/lib/workflow-aut const TITLE_MODEL_ID = "google/gemini-2.5-flash-lite"; const TITLE_MAX_LENGTH = 200; +const OPENROUTER_CALL_TIMEOUT_MS = 30_000; type TitleLength = "short" | "standard" | "long"; type TitleProvider = "osschat" | "openrouter"; @@ -228,28 +229,37 @@ const workflow = serve(async (context) => { TITLE_STYLE_PROMPTS[length], ].join(" "); - const llmResponse = await context.call<{ - choices?: Array<{ message?: { content?: string } }>; - }>("call-llm", { - url: "https://openrouter.ai/api/v1/chat/completions", - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${openRouterKey}`, - "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", - "X-Title": "OSSChat", - }, - body: JSON.stringify({ - model: TITLE_MODEL_ID, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: normalizedSeed }, - ], - temperature: 0.2, - max_tokens: 32, - }), - retries: 2, - timeout: "30s", + const llmResponse = await context.run("call-llm", async () => { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openRouterKey}`, + "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", + "X-Title": "OSSChat", + }, + body: JSON.stringify({ + model: TITLE_MODEL_ID, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: normalizedSeed }, + ], + temperature: 0.2, + max_tokens: 32, + }), + signal: AbortSignal.timeout(OPENROUTER_CALL_TIMEOUT_MS), + }); + + let body: { choices?: Array<{ message?: { content?: string } }> } | null = null; + if (response.ok) { + try { + body = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> }; + } catch { + body = null; + } + } + + return { status: response.status, body }; }); if (llmResponse.status < 200 || llmResponse.status >= 300) { return { From 48ca3c407039cb814b89715df6afa50806dba385 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 23:41:32 -0500 Subject: [PATCH 27/40] fix(security): tighten cleanup auth and usage safeguards --- apps/server/convex/backgroundStream.ts | 21 ++++----- apps/server/convex/crons.ts | 6 +++ apps/server/convex/http.ts | 12 ++++- apps/server/convex/lib/upstashUsage.ts | 19 ++++++-- apps/web/src/lib/server-auth.test.ts | 3 ++ apps/web/src/lib/server-auth.ts | 3 +- apps/web/src/routes/api/models.ts | 17 +++---- apps/web/src/routes/api/workflow/cleanup.ts | 22 ++++----- .../src/routes/api/workflow/generate-title.ts | 45 ++++++++++--------- 9 files changed, 87 insertions(+), 61 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index 589c0e33..b163b7c4 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -314,17 +314,10 @@ export const startStream = mutation({ } if (args.provider === "osschat") { - const currentDate = getCurrentDateKey(); const user = await ctx.db.get(userId); if (!user) { throw new Error("User not found"); } - - const persistedUsageCents = - user.aiUsageDate === currentDate ? (user.aiUsageCents ?? 0) : 0; - if (persistedUsageCents >= DAILY_AI_LIMIT_CENTS) { - throw new Error("Daily usage limit reached. Connect your OpenRouter account to continue."); - } } const existingActiveStream = await ctx.db @@ -1414,6 +1407,8 @@ export const executeStream = internalAction({ if (adjustment !== 0) { await adjustDailyUsageInUpstash(job.userId, reservedDateKey, adjustment); } + reservedUsageCents = 0; + reservedDateKey = null; } else { await incrementDailyUsageInUpstash( job.userId, @@ -1421,6 +1416,10 @@ export const executeStream = internalAction({ usageCents, ); } + } else if (reservedDateKey && reservedUsageCents > 0) { + await adjustDailyUsageInUpstash(job.userId, reservedDateKey, -reservedUsageCents); + reservedUsageCents = 0; + reservedDateKey = null; } } @@ -1462,12 +1461,10 @@ export const executeStream = internalAction({ } } const rawErrorMessage = error instanceof Error ? error.message : "Unknown error"; - const lowerErrorMessage = rawErrorMessage.toLowerCase(); const safeErrorMessage = - lowerErrorMessage.includes("limit reached") || - lowerErrorMessage.includes("rate limit") || - lowerErrorMessage.includes("unauthorized") || - lowerErrorMessage.includes("not found") + rawErrorMessage === "Daily usage limit reached. Connect your OpenRouter account to continue." || + rawErrorMessage === "Usage tracking temporarily unavailable. Please retry shortly." || + rawErrorMessage === "Chat not found or unauthorized" ? rawErrorMessage : "An error occurred while processing your request."; await ctx.runMutation(internal.backgroundStream.failStream, { diff --git a/apps/server/convex/crons.ts b/apps/server/convex/crons.ts index 4ecffac2..6f594b97 100644 --- a/apps/server/convex/crons.ts +++ b/apps/server/convex/crons.ts @@ -73,6 +73,12 @@ export const cleanupSoftDeletedRecords = internalMutation({ const retentionDays = args.retentionDays ?? 90; const batchSize = args.batchSize ?? 100; const dryRun = args.dryRun ?? false; + if (!Number.isFinite(retentionDays) || retentionDays < 1 || retentionDays > 3650) { + throw new Error("retentionDays must be between 1 and 3650"); + } + if (!Number.isFinite(batchSize) || batchSize < 1 || batchSize > 1000) { + throw new Error("batchSize must be between 1 and 1000"); + } // Calculate cutoff date const cutoffDate = Date.now() - retentionDays * 24 * 60 * 60 * 1000; diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index bafc772e..0e64d04e 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -93,7 +93,15 @@ http.route({ dryRun?: unknown; }; - if (typeof payload.workflowToken !== "string" || payload.workflowToken.trim().length === 0) { + const authorization = request.headers.get("authorization")?.trim(); + const bearerToken = authorization?.startsWith("Bearer ") + ? authorization.slice("Bearer ".length).trim() + : null; + const bodyWorkflowToken = + typeof payload.workflowToken === "string" ? payload.workflowToken.trim() : null; + const workflowToken = bearerToken || bodyWorkflowToken; + + if (!workflowToken) { return new Response(JSON.stringify({ error: "workflowToken is required" }), { status: 400, headers: { "content-type": "application/json" }, @@ -103,7 +111,7 @@ http.route({ let result; try { result = await ctx.runAction(internal.cleanupAction.runCleanupBatchForWorkflow, { - workflowToken: payload.workflowToken.trim(), + workflowToken, retentionDays: typeof payload.retentionDays === "number" && Number.isFinite(payload.retentionDays) ? payload.retentionDays diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index 439b334d..6312ebe3 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -165,16 +165,29 @@ export async function adjustDailyUsageInUpstash( ): Promise { if (!getConfig()) return; if (!Number.isFinite(usageCentsDelta) || usageCentsDelta === 0) return; - const roundedDelta = - usageCentsDelta > 0 ? Math.ceil(usageCentsDelta) : -Math.ceil(Math.abs(usageCentsDelta)); + const roundedDelta = Math.ceil(usageCentsDelta); if (roundedDelta === 0) return; const key = usageCounterKey(userId, dateKey); try { const expiresAt = getMidnightUtcEpochSeconds(dateKey); + let effectiveDelta = roundedDelta; + if (roundedDelta < 0) { + const current = await executePipeline([["GET", key]]); + const rawCurrent = current?.[0]?.result; + const parsedCurrent = + typeof rawCurrent === "number" + ? rawCurrent + : typeof rawCurrent === "string" + ? Number.parseInt(rawCurrent, 10) + : 0; + const safeCurrent = Number.isFinite(parsedCurrent) ? Math.max(0, parsedCurrent) : 0; + effectiveDelta = Math.max(roundedDelta, -safeCurrent); + if (effectiveDelta === 0) return; + } await executePipeline([ - ["INCRBY", key, roundedDelta], + ["INCRBY", key, effectiveDelta], ["EXPIREAT", key, expiresAt], ]); } catch (error) { diff --git a/apps/web/src/lib/server-auth.test.ts b/apps/web/src/lib/server-auth.test.ts index 6a45c0f6..60e67cc3 100644 --- a/apps/web/src/lib/server-auth.test.ts +++ b/apps/web/src/lib/server-auth.test.ts @@ -17,6 +17,9 @@ describe("server-auth.getConvexAuthToken", () => { beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); + vi.stubEnv("ALLOW_AUTH_COOKIE_FALLBACK", "true"); + vi.stubEnv("VERCEL", ""); + vi.stubEnv("CONVEX_CLOUD_URL", ""); }); afterEach(() => { diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index bf9bafdc..14e745c3 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -17,8 +17,9 @@ type AuthSessionResponse = { const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; const IS_PRODUCTION = process.env.NODE_ENV === "production"; +const ALLOW_AUTH_COOKIE_FALLBACK = process.env.ALLOW_AUTH_COOKIE_FALLBACK === "true"; const IS_LOCAL_DEV = - !IS_PRODUCTION && !process.env.VERCEL && !process.env.CONVEX_CLOUD_URL; + !IS_PRODUCTION && (ALLOW_AUTH_COOKIE_FALLBACK || process.env.NODE_ENV === "test"); function getCookieValue(cookieHeader: string, name: string): string | null { const target = `${name}=`; diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index e1f17b40..a480a6da 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -8,6 +8,7 @@ const MODELS_CACHE_TTL_SECONDS = 60 * 60 * 4; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const OPENROUTER_FETCH_TIMEOUT_MS = 10_000; const TRUST_PROXY_MODE = process.env.TRUST_PROXY?.trim().toLowerCase(); +const UNKNOWN_CLIENT_IP = "unknown"; const modelsIpRatelimit = upstashRedis ? new Ratelimit({ @@ -60,7 +61,7 @@ async function fetchModelsFromOpenRouter(): Promise { function getClientIp(request: Request): string | null { if (!TRUST_PROXY_MODE) { - return null; + return UNKNOWN_CLIENT_IP; } if (TRUST_PROXY_MODE === "cloudflare") { @@ -78,18 +79,10 @@ function getClientIp(request: Request): string | null { } if (TRUST_PROXY_MODE === "true") { - const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); - if (forwardedFor) { - const first = forwardedFor.split(",")[0]?.trim(); - if (first) return first; - } - - const realIp = request.headers.get("x-real-ip")?.trim(); - if (realIp) return realIp; - - return null; + return UNKNOWN_CLIENT_IP; } - return null; + console.warn("[Models API] Unrecognized TRUST_PROXY value, using shared rate-limit bucket"); + return UNKNOWN_CLIENT_IP; } export const Route = createFileRoute("/api/models")({ diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index d6584fe0..8bdd926d 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -81,6 +81,7 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ success: boolean; batches: number; totalDeleted: number; + limitReached: boolean; }> { const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN; if (!workflowToken) { @@ -122,14 +123,11 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ await new Promise((resolve) => setTimeout(resolve, 1_000)); } - if (hitBatchLimit) { - throw new Error(`Cleanup exceeded maximum batches (${MAX_CLEANUP_BATCHES})`); - } - return { - success: true, + success: !hitBatchLimit, batches, totalDeleted, + limitReached: hitBatchLimit, }; } @@ -147,8 +145,13 @@ async function runCleanupBatch(args: { method: "POST", headers: { "Content-Type": "application/json", + Authorization: `Bearer ${args.workflowToken}`, }, - body: JSON.stringify(args), + body: JSON.stringify({ + retentionDays: args.retentionDays, + batchSize: args.batchSize, + dryRun: args.dryRun, + }), signal: AbortSignal.timeout(CLEANUP_BATCH_TIMEOUT_MS), }); @@ -217,15 +220,12 @@ const workflow = serve(async (context) => { await context.sleep(`sleep-${batches}`, "1s"); } - if (hitBatchLimit) { - throw new Error(`Cleanup exceeded maximum batches (${MAX_CLEANUP_BATCHES})`); - } - return context.run("log-completion", async () => { return { - success: true, + success: !hitBatchLimit, batches, totalDeleted, + limitReached: hitBatchLimit, }; }); }); diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 804dbd5f..c5f71958 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -214,7 +214,7 @@ const workflow = serve(async (context) => { } if (provider !== "osschat") { - return { saved: false, reason: "missing_openrouter_key" } as const; + return { saved: false, reason: "unsupported_provider" } as const; } const openRouterKey = process.env.OPENROUTER_API_KEY ?? null; @@ -230,25 +230,30 @@ const workflow = serve(async (context) => { ].join(" "); const llmResponse = await context.run("call-llm", async () => { - const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${openRouterKey}`, - "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", - "X-Title": "OSSChat", - }, - body: JSON.stringify({ - model: TITLE_MODEL_ID, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: normalizedSeed }, - ], - temperature: 0.2, - max_tokens: 32, - }), - signal: AbortSignal.timeout(OPENROUTER_CALL_TIMEOUT_MS), - }); + let response: Response; + try { + response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openRouterKey}`, + "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", + "X-Title": "OSSChat", + }, + body: JSON.stringify({ + model: TITLE_MODEL_ID, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: normalizedSeed }, + ], + temperature: 0.2, + max_tokens: 32, + }), + signal: AbortSignal.timeout(OPENROUTER_CALL_TIMEOUT_MS), + }); + } catch { + return { status: 0, body: null }; + } let body: { choices?: Array<{ message?: { content?: string } }> } | null = null; if (response.ok) { From 7b5d72216166a9413519c931b9e14cdccb174c86 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 11 Feb 2026 23:51:07 -0500 Subject: [PATCH 28/40] fix(security): tighten workflow and error handling responses --- apps/server/convex/backgroundStream.ts | 9 +-------- apps/server/convex/http.ts | 2 +- apps/web/src/routes/api/models.ts | 1 + apps/web/src/routes/api/workflow/export-chat.ts | 9 ++++++++- apps/web/src/routes/api/workflow/generate-title.ts | 4 ++-- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index b163b7c4..637a775b 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -1460,16 +1460,9 @@ export const executeStream = internalAction({ console.warn("[Usage] Upstash refund adjustment failed", adjustError); } } - const rawErrorMessage = error instanceof Error ? error.message : "Unknown error"; - const safeErrorMessage = - rawErrorMessage === "Daily usage limit reached. Connect your OpenRouter account to continue." || - rawErrorMessage === "Usage tracking temporarily unavailable. Please retry shortly." || - rawErrorMessage === "Chat not found or unauthorized" - ? rawErrorMessage - : "An error occurred while processing your request."; await ctx.runMutation(internal.backgroundStream.failStream, { jobId: args.jobId, - error: safeErrorMessage, + error: "An error occurred while processing your request.", partialContent: fullContent, }); } diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index 0e64d04e..822981bf 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -127,7 +127,7 @@ http.route({ const status = message === "Unauthorized" ? 401 - : message.includes("must be between") || message.includes("Invalid") + : message.includes("must be between") ? 400 : 500; if (status === 500) { diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index a480a6da..887cafbd 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -79,6 +79,7 @@ function getClientIp(request: Request): string | null { } if (TRUST_PROXY_MODE === "true") { + console.warn("[Models API] TRUST_PROXY=true uses shared rate-limit bucket"); return UNKNOWN_CLIENT_IP; } console.warn("[Models API] Unrecognized TRUST_PROXY value, using shared rate-limit bucket"); diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 3ecc2b7a..c39cf16c 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -262,7 +262,14 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ if (exportRatelimit) { const rl = await exportRatelimit.limit(`export-chat:${authConvexUser._id}`); if (!rl.success) { - return json({ error: "Rate limit exceeded" }, { status: 429 }); + const retryAfterSeconds = Math.max( + 1, + Math.ceil((rl.reset - Date.now()) / 1000), + ); + return json( + { error: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": String(retryAfterSeconds) } }, + ); } } diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index c5f71958..ec972c3a 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -357,7 +357,7 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ try { const result = await runGenerateTitleInline(normalizedPayload, authToken); if (!result.saved) { - const status = result.reason === "missing_openrouter_key" ? 400 : 409; + const status = result.reason === "missing_openrouter_key" ? 400 : 422; return json(result, { status }); } return json(result, { status: 200 }); @@ -375,7 +375,7 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ try { const result = await runGenerateTitleInline(normalizedPayload, authToken); if (!result.saved) { - const status = result.reason === "missing_openrouter_key" ? 400 : 409; + const status = result.reason === "missing_openrouter_key" ? 400 : 422; return json(result, { status }); } return json(result, { status: 200 }); From 020fefa04640ebf450bbf2c4a38e12b08bcabe1b Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 00:12:21 -0500 Subject: [PATCH 29/40] fix(security): tighten workflow token and usage edge cases --- apps/server/convex/lib/upstashUsage.ts | 18 ++---------------- apps/web/src/routes/api/models.ts | 16 ++++++++++++++-- apps/web/src/routes/api/workflow/cleanup.ts | 4 ++-- .../src/routes/api/workflow/delete-account.ts | 2 ++ .../src/routes/api/workflow/generate-title.ts | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index 6312ebe3..7fd3f643 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -165,29 +165,15 @@ export async function adjustDailyUsageInUpstash( ): Promise { if (!getConfig()) return; if (!Number.isFinite(usageCentsDelta) || usageCentsDelta === 0) return; - const roundedDelta = Math.ceil(usageCentsDelta); + const roundedDelta = usageCentsDelta > 0 ? Math.ceil(usageCentsDelta) : Math.floor(usageCentsDelta); if (roundedDelta === 0) return; const key = usageCounterKey(userId, dateKey); try { const expiresAt = getMidnightUtcEpochSeconds(dateKey); - let effectiveDelta = roundedDelta; - if (roundedDelta < 0) { - const current = await executePipeline([["GET", key]]); - const rawCurrent = current?.[0]?.result; - const parsedCurrent = - typeof rawCurrent === "number" - ? rawCurrent - : typeof rawCurrent === "string" - ? Number.parseInt(rawCurrent, 10) - : 0; - const safeCurrent = Number.isFinite(parsedCurrent) ? Math.max(0, parsedCurrent) : 0; - effectiveDelta = Math.max(roundedDelta, -safeCurrent); - if (effectiveDelta === 0) return; - } await executePipeline([ - ["INCRBY", key, effectiveDelta], + ["INCRBY", key, roundedDelta], ["EXPIREAT", key, expiresAt], ]); } catch (error) { diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 887cafbd..94461e43 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -10,6 +10,19 @@ const OPENROUTER_FETCH_TIMEOUT_MS = 10_000; const TRUST_PROXY_MODE = process.env.TRUST_PROXY?.trim().toLowerCase(); const UNKNOWN_CLIENT_IP = "unknown"; +if (TRUST_PROXY_MODE === "true") { + console.warn("[Models API] TRUST_PROXY=true uses shared rate-limit bucket"); +} + +if ( + TRUST_PROXY_MODE && + TRUST_PROXY_MODE !== "cloudflare" && + TRUST_PROXY_MODE !== "vercel" && + TRUST_PROXY_MODE !== "true" +) { + console.warn("[Models API] Unrecognized TRUST_PROXY value, using shared rate-limit bucket"); +} + const modelsIpRatelimit = upstashRedis ? new Ratelimit({ redis: upstashRedis, @@ -79,10 +92,9 @@ function getClientIp(request: Request): string | null { } if (TRUST_PROXY_MODE === "true") { - console.warn("[Models API] TRUST_PROXY=true uses shared rate-limit bucket"); return UNKNOWN_CLIENT_IP; } - console.warn("[Models API] Unrecognized TRUST_PROXY value, using shared rate-limit bucket"); + return UNKNOWN_CLIENT_IP; } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index 8bdd926d..adc57e07 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -83,7 +83,7 @@ async function runCleanupInline(payload: CleanupPayload): Promise<{ totalDeleted: number; limitReached: boolean; }> { - const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN; + const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN?.trim(); if (!workflowToken) { throw new Error("WORKFLOW_CLEANUP_TOKEN is not configured"); } @@ -174,7 +174,7 @@ const workflow = serve(async (context) => { throw new Error("Invalid cleanup payload"); } - const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN; + const workflowToken = process.env.WORKFLOW_CLEANUP_TOKEN?.trim(); if (!workflowToken) { throw new Error("WORKFLOW_CLEANUP_TOKEN is not configured"); } diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 6eee8efa..66c6f238 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -184,6 +184,7 @@ const workflow = serve(async (context) => { } const authTokenRef = context.requestPayload.authTokenRef; if (!authTokenRef) { + console.error("[Workflow][delete-account] Missing auth token reference"); return EMPTY_DELETE_RESULT; } @@ -191,6 +192,7 @@ const workflow = serve(async (context) => { return getWorkflowAuthToken(authTokenRef); }); if (!authToken) { + console.error("[Workflow][delete-account] Failed to resolve auth token"); return EMPTY_DELETE_RESULT; } diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index ec972c3a..1f728d3d 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -219,7 +219,7 @@ const workflow = serve(async (context) => { const openRouterKey = process.env.OPENROUTER_API_KEY ?? null; if (!openRouterKey) { - return { saved: false, reason: "missing_openrouter_key" } as const; + return { saved: false, reason: "server_api_key_not_configured" } as const; } const systemPrompt = [ From 826a74b18fbadc026277a2dd884f9d26ec045b3b Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 00:19:17 -0500 Subject: [PATCH 30/40] fix(web): execute chat exports inline for reliable delivery --- .../src/routes/api/workflow/export-chat.ts | 76 ++++--------------- 1 file changed, 16 insertions(+), 60 deletions(-) diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index c39cf16c..4840fdd6 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -8,9 +8,8 @@ import { getAuthUser, getConvexAuthToken, isSameOrigin } from "@/lib/server-auth import { exportRatelimit, shouldFailClosedForMissingUpstash, - workflowClient, } from "@/lib/upstash"; -import { getWorkflowAuthToken, storeWorkflowAuthToken } from "@/lib/workflow-auth-token"; +import { getWorkflowAuthToken } from "@/lib/workflow-auth-token"; type ExportFormat = "markdown" | "json"; const MAX_EXPORT_BYTES = 10 * 1024 * 1024; @@ -72,10 +71,6 @@ function parseExportPayload(raw: unknown): ExportChatPayload | null { }; } -function isLocalWorkflowExecutionEnabled(): boolean { - return process.env.NODE_ENV !== "production"; -} - function formatRole(role: string): string { if (role === "assistant") return "Assistant"; if (role === "system") return "System"; @@ -84,12 +79,6 @@ function formatRole(role: string): string { return "Message"; } -function getWorkflowTriggerHeaders(): Record { - return { - "Content-Type": "application/json", - }; -} - function hasWorkflowSigningKeysConfigured(): boolean { return Boolean( process.env.QSTASH_CURRENT_SIGNING_KEY && process.env.QSTASH_NEXT_SIGNING_KEY, @@ -288,56 +277,23 @@ export const Route = createFileRoute("/api/workflow/export-chat")({ userId: authConvexUser._id, }; - if (isLocalWorkflowExecutionEnabled()) { - try { - const result = await runExportChatInline(normalizedPayload, authToken); - return json(result, { status: 200 }); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to export chat"; - const status = - message === "Unauthorized" - ? 401 - : message === "Chat not found" - ? 404 - : message === "Export too large" - ? 413 - : 500; - if (status === 500) { - console.error("[Workflow][export-chat] Local execution failed", error); - } - return json({ error: status === 500 ? "Internal server error" : message }, { status }); - } - } - - if (!workflowClient) { - return json( - { error: "Workflow queue is not configured (missing QSTASH_TOKEN)" }, - { status: 500 }, - ); - } - try { - const authTokenRef = await storeWorkflowAuthToken(authToken); - if (!authTokenRef) { - return json( - { error: "Workflow auth cache is not configured" }, - { status: 500 }, - ); - } - - const triggerHeaders = getWorkflowTriggerHeaders(); - const { workflowRunId } = await workflowClient.trigger({ - url: request.url, - body: { - ...normalizedPayload, - authTokenRef, - }, - headers: triggerHeaders, - }); - return json({ queued: true, workflowRunId }, { status: 202 }); + const result = await runExportChatInline(normalizedPayload, authToken); + return json(result, { status: 200 }); } catch (error) { - console.error("[Workflow][export-chat] Queue trigger failed", error); - return json({ error: "Internal server error" }, { status: 500 }); + const message = error instanceof Error ? error.message : "Failed to export chat"; + const status = + message === "Unauthorized" + ? 401 + : message === "Chat not found" + ? 404 + : message === "Export too large" + ? 413 + : 500; + if (status === 500) { + console.error("[Workflow][export-chat] Inline export failed", error); + } + return json({ error: status === 500 ? "Internal server error" : message }, { status }); } }, }, From 76f7360d7cf045e04f2ddebe1e1c93e9cdde7cdf Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 00:26:42 -0500 Subject: [PATCH 31/40] fix(web): tighten workflow validation and retry behavior --- apps/web/src/components/app-sidebar.tsx | 7 ++-- .../src/routes/api/workflow/delete-account.ts | 1 - .../src/routes/api/workflow/export-chat.ts | 8 ++--- .../src/routes/api/workflow/generate-title.ts | 33 +++++++++---------- 4 files changed, 22 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index cf1d8f89..17ca0f38 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -450,8 +450,11 @@ export function AppSidebar({ }); } catch (error) { console.warn("[Chat] Title regeneration failed:", error); - const message = error instanceof Error ? error.message : "Failed to regenerate chat name"; - toast.error(message); + if (error instanceof Error && error.name === "RateLimitError") { + toast.error(error.message); + } else { + toast.error("Failed to regenerate chat name"); + } } finally { setTitleGenerating(chatId, false); } diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 66c6f238..dee31fd0 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -77,7 +77,6 @@ async function clearRedisUserKeys(userId: string): Promise { const patternKeys = [ `chat:*:typing:${userId}`, `openchat:usage:${userId}:*`, - `ratelimit:*:${userId}`, ]; let deleted = 0; diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 4840fdd6..5f96c61b 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -16,7 +16,7 @@ const MAX_EXPORT_BYTES = 10 * 1024 * 1024; type ExportChatPayload = { chatId: string; - userId: string; + userId?: string; authTokenRef?: string; format?: ExportFormat; }; @@ -51,10 +51,6 @@ function parseExportPayload(raw: unknown): ExportChatPayload | null { if (typeof payload.chatId !== "string" || payload.chatId.trim().length === 0) { return null; } - if (typeof payload.userId !== "string" || payload.userId.trim().length === 0) { - return null; - } - let format: ExportFormat | undefined; if (payload.format !== undefined) { if (payload.format !== "markdown" && payload.format !== "json") { @@ -65,7 +61,7 @@ function parseExportPayload(raw: unknown): ExportChatPayload | null { return { chatId: payload.chatId.trim(), - userId: payload.userId.trim(), + userId: typeof payload.userId === "string" ? payload.userId.trim() : undefined, authTokenRef: typeof payload.authTokenRef === "string" ? payload.authTokenRef : undefined, format, }; diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 1f728d3d..7407af32 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -344,7 +344,14 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ if (authRatelimit) { const rl = await authRatelimit.limit(`generate-title:${authConvexUser._id}`); if (!rl.success) { - return json({ error: "Rate limit exceeded" }, { status: 429 }); + const retryAfterSeconds = Math.max( + 1, + Math.ceil((rl.reset - Date.now()) / 1000), + ); + return json( + { error: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": String(retryAfterSeconds) } }, + ); } } @@ -353,7 +360,7 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ userId: authConvexUser._id, }; - if (normalizedPayload.provider === "openrouter") { + const runInline = async (logLabel: string) => { try { const result = await runGenerateTitleInline(normalizedPayload, authToken); if (!result.saved) { @@ -365,28 +372,18 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ const message = error instanceof Error ? error.message : "Failed to generate title"; const status = message === "Unauthorized" ? 401 : 500; if (status === 500) { - console.error("[Workflow][generate-title] Inline generation failed", error); + console.error(`[Workflow][generate-title] ${logLabel} failed`, error); } return json({ error: status === 500 ? "Internal server error" : message }, { status }); } + }; + + if (normalizedPayload.provider === "openrouter") { + return runInline("Inline generation"); } if (isLocalWorkflowExecutionEnabled()) { - try { - const result = await runGenerateTitleInline(normalizedPayload, authToken); - if (!result.saved) { - const status = result.reason === "missing_openrouter_key" ? 400 : 422; - return json(result, { status }); - } - return json(result, { status: 200 }); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to generate title"; - const status = message === "Unauthorized" ? 401 : 500; - if (status === 500) { - console.error("[Workflow][generate-title] Local execution failed", error); - } - return json({ error: status === 500 ? "Internal server error" : message }, { status }); - } + return runInline("Local execution"); } if (!workflowClient) { From 37c88fb274ad8af11105959bdd362c585a818c45 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 00:34:02 -0500 Subject: [PATCH 32/40] fix(web): harden export workflow payload guards --- apps/web/src/components/app-sidebar.tsx | 6 +----- apps/web/src/routes/api/workflow/export-chat.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 17ca0f38..c3240f82 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -450,11 +450,7 @@ export function AppSidebar({ }); } catch (error) { console.warn("[Chat] Title regeneration failed:", error); - if (error instanceof Error && error.name === "RateLimitError") { - toast.error(error.message); - } else { - toast.error("Failed to regenerate chat name"); - } + toast.error("Failed to regenerate chat name"); } finally { setTitleGenerating(chatId, false); } diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 5f96c61b..2cf8370b 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -61,7 +61,10 @@ function parseExportPayload(raw: unknown): ExportChatPayload | null { return { chatId: payload.chatId.trim(), - userId: typeof payload.userId === "string" ? payload.userId.trim() : undefined, + userId: + typeof payload.userId === "string" && payload.userId.trim().length > 0 + ? payload.userId.trim() + : undefined, authTokenRef: typeof payload.authTokenRef === "string" ? payload.authTokenRef : undefined, format, }; @@ -168,6 +171,11 @@ const workflow = serve(async (context) => { error: "Unauthorized", }; } + if (!userId || userId.trim().length === 0) { + return { + error: "Unauthorized", + }; + } const convexClient = createConvexServerClient(authToken); const chatExportData = await context.run("gather-messages", async () => { From 4575997085aa64f8ee21ce95d7cf9ec290c31c96 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 00:39:14 -0500 Subject: [PATCH 33/40] fix(security): harden workflow callback and auth safeguards --- apps/server/convex/lib/upstashUsage.ts | 9 ++-- apps/web/src/lib/server-auth.test.ts | 8 ++-- apps/web/src/lib/server-auth.ts | 10 ++++- apps/web/src/routes/api/models.ts | 41 +++++++++---------- apps/web/src/routes/api/workflow/cleanup.ts | 20 ++++++++- .../src/routes/api/workflow/delete-account.ts | 17 +++++++- .../src/routes/api/workflow/generate-title.ts | 17 +++++++- 7 files changed, 88 insertions(+), 34 deletions(-) diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index 7fd3f643..a970c1d2 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -119,9 +119,12 @@ export async function incrementDailyUsageInUpstash( ["EXPIREAT", key, expiresAt], ]); } catch (error) { - if (shouldLogUpstashUsageErrors()) { - console.warn("[Usage] Upstash INCRBY failed", error); - } + console.error("[Usage] Upstash INCRBY failed — usage not recorded", { + userId, + dateKey, + usageCents, + error, + }); } } diff --git a/apps/web/src/lib/server-auth.test.ts b/apps/web/src/lib/server-auth.test.ts index 60e67cc3..b9562527 100644 --- a/apps/web/src/lib/server-auth.test.ts +++ b/apps/web/src/lib/server-auth.test.ts @@ -59,9 +59,9 @@ describe("server-auth.getConvexAuthToken", () => { vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); const { getConvexAuthToken } = await loadModule(); - const request = requestWithCookie("better-auth.convex_jwt=fallback-token"); + const request = requestWithCookie("better-auth.convex_jwt=fallback.token.value"); - await expect(getConvexAuthToken(request)).resolves.toBe("fallback-token"); + await expect(getConvexAuthToken(request)).resolves.toBe("fallback.token.value"); }); it("returns null when token endpoint explicitly rejects the request", async () => { @@ -91,8 +91,8 @@ describe("server-auth.getConvexAuthToken", () => { ); const { getConvexAuthToken } = await loadModule(); - const request = requestWithCookie("better-auth.convex_jwt=fallback-token"); + const request = requestWithCookie("better-auth.convex_jwt=fallback.token.value"); - await expect(getConvexAuthToken(request)).resolves.toBe("fallback-token"); + await expect(getConvexAuthToken(request)).resolves.toBe("fallback.token.value"); }); }); diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index 14e745c3..2f24e948 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -21,6 +21,10 @@ const ALLOW_AUTH_COOKIE_FALLBACK = process.env.ALLOW_AUTH_COOKIE_FALLBACK === "t const IS_LOCAL_DEV = !IS_PRODUCTION && (ALLOW_AUTH_COOKIE_FALLBACK || process.env.NODE_ENV === "test"); +if (IS_PRODUCTION && ALLOW_AUTH_COOKIE_FALLBACK) { + throw new Error("ALLOW_AUTH_COOKIE_FALLBACK must not be enabled in production"); +} + function getCookieValue(cookieHeader: string, name: string): string | null { const target = `${name}=`; for (const part of cookieHeader.split(";")) { @@ -66,7 +70,11 @@ export async function getConvexAuthToken(request: Request): Promise { function getClientIp(request: Request): string | null { if (!TRUST_PROXY_MODE) { - return UNKNOWN_CLIENT_IP; + return null; } if (TRUST_PROXY_MODE === "cloudflare") { @@ -92,7 +92,7 @@ function getClientIp(request: Request): string | null { } if (TRUST_PROXY_MODE === "true") { - return UNKNOWN_CLIENT_IP; + return null; } return UNKNOWN_CLIENT_IP; @@ -109,27 +109,24 @@ export const Route = createFileRoute("/api/models")({ if (modelsIpRatelimit) { const ip = getClientIp(request); if (!ip) { - console.warn("[Models API] Trusted proxy IP unavailable for rate limit"); - return json( - { error: "Service temporarily unavailable" }, - { status: 503 }, - ); - } - const rl = await modelsIpRatelimit.limit(ip); - if (!rl.success) { - const retryAfterSeconds = Math.max( - 1, - Math.ceil((rl.reset - Date.now()) / 1000), - ); - return json( - { error: "Rate limit exceeded" }, - { - status: 429, - headers: { - "Retry-After": String(retryAfterSeconds), + console.warn("[Models API] Trusted proxy IP unavailable, skipping rate limit"); + } else { + const rl = await modelsIpRatelimit.limit(ip); + if (!rl.success) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((rl.reset - Date.now()) / 1000), + ); + return json( + { error: "Rate limit exceeded" }, + { + status: 429, + headers: { + "Retry-After": String(retryAfterSeconds), + }, }, - }, - ); + ); + } } } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index adc57e07..5fd6ddbb 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -19,7 +19,7 @@ function isLocalWorkflowExecutionEnabled(): boolean { } function parseCleanupPayload(raw: unknown): CleanupPayload | null { - if (!raw || typeof raw !== "object") return null; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const payload = raw as Record; const parsed: CleanupPayload = {}; @@ -77,6 +77,17 @@ function hasWorkflowSigningKeysConfigured(): boolean { ); } +function getWorkflowCallbackUrl(request: Request): string | null { + const configuredBase = + process.env.VITE_CONVEX_SITE_URL || + process.env.CONVEX_SITE_URL || + process.env.VITE_APP_URL; + if (!configuredBase) return null; + + const pathname = new URL(request.url).pathname; + return new URL(pathname, configuredBase).toString(); +} + async function runCleanupInline(payload: CleanupPayload): Promise<{ success: boolean; batches: number; @@ -275,12 +286,17 @@ export const Route = createFileRoute("/api/workflow/cleanup")({ } try { + const callbackUrl = getWorkflowCallbackUrl(request); + if (!callbackUrl) { + return json({ error: "Workflow callback URL is not configured" }, { status: 500 }); + } + const triggerHeaders: Record = { "Content-Type": "application/json", }; const { workflowRunId } = await workflowClient.trigger({ - url: request.url, + url: callbackUrl, body: payload, headers: triggerHeaders, }); diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index dee31fd0..60b44d87 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -111,6 +111,17 @@ function getWorkflowTriggerHeaders(): Record { }; } +function getWorkflowCallbackUrl(request: Request): string | null { + const configuredBase = + process.env.VITE_CONVEX_SITE_URL || + process.env.CONVEX_SITE_URL || + process.env.VITE_APP_URL; + if (!configuredBase) return null; + + const pathname = new URL(request.url).pathname; + return new URL(pathname, configuredBase).toString(); +} + function hasWorkflowSigningKeysConfigured(): boolean { return Boolean( process.env.QSTASH_CURRENT_SIGNING_KEY && process.env.QSTASH_NEXT_SIGNING_KEY, @@ -346,10 +357,14 @@ export const Route = createFileRoute("/api/workflow/delete-account")({ { status: 500 }, ); } + const callbackUrl = getWorkflowCallbackUrl(request); + if (!callbackUrl) { + return json({ error: "Workflow callback URL is not configured" }, { status: 500 }); + } const triggerHeaders = getWorkflowTriggerHeaders(); const { workflowRunId } = await workflowClient.trigger({ - url: request.url, + url: callbackUrl, body: { ...normalizedPayload, authTokenRef, diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 7407af32..393b7803 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -104,6 +104,17 @@ function getWorkflowTriggerHeaders(): Record { }; } +function getWorkflowCallbackUrl(request: Request): string | null { + const configuredBase = + process.env.VITE_CONVEX_SITE_URL || + process.env.CONVEX_SITE_URL || + process.env.VITE_APP_URL; + if (!configuredBase) return null; + + const pathname = new URL(request.url).pathname; + return new URL(pathname, configuredBase).toString(); +} + function hasWorkflowSigningKeysConfigured(): boolean { return Boolean( process.env.QSTASH_CURRENT_SIGNING_KEY && process.env.QSTASH_NEXT_SIGNING_KEY, @@ -401,10 +412,14 @@ export const Route = createFileRoute("/api/workflow/generate-title")({ { status: 500 }, ); } + const callbackUrl = getWorkflowCallbackUrl(request); + if (!callbackUrl) { + return json({ error: "Workflow callback URL is not configured" }, { status: 500 }); + } const headers = getWorkflowTriggerHeaders(); const { workflowRunId } = await workflowClient.trigger({ - url: request.url, + url: callbackUrl, body: { ...normalizedPayload, authTokenRef, From ec1e22aa3557c57887b6fcc5a9a4dfad8abae9b2 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 00:46:22 -0500 Subject: [PATCH 34/40] fix(web): clean up workflow validation and rate-limit UX --- apps/server/convex/crons.ts | 4 +- apps/server/convex/lib/upstashUsage.ts | 2 +- apps/web/package.json | 2 +- apps/web/src/components/app-sidebar.tsx | 7 +- apps/web/src/routes/api/models.ts | 4 + .../src/routes/api/workflow/export-chat.ts | 79 ------------------- .../src/routes/api/workflow/generate-title.ts | 5 +- apps/web/src/stores/model.ts | 6 +- 8 files changed, 21 insertions(+), 88 deletions(-) diff --git a/apps/server/convex/crons.ts b/apps/server/convex/crons.ts index 6f594b97..e675c683 100644 --- a/apps/server/convex/crons.ts +++ b/apps/server/convex/crons.ts @@ -73,10 +73,10 @@ export const cleanupSoftDeletedRecords = internalMutation({ const retentionDays = args.retentionDays ?? 90; const batchSize = args.batchSize ?? 100; const dryRun = args.dryRun ?? false; - if (!Number.isFinite(retentionDays) || retentionDays < 1 || retentionDays > 3650) { + if (!Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 3650) { throw new Error("retentionDays must be between 1 and 3650"); } - if (!Number.isFinite(batchSize) || batchSize < 1 || batchSize > 1000) { + if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 1000) { throw new Error("batchSize must be between 1 and 1000"); } diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index a970c1d2..a2c366b0 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -168,7 +168,7 @@ export async function adjustDailyUsageInUpstash( ): Promise { if (!getConfig()) return; if (!Number.isFinite(usageCentsDelta) || usageCentsDelta === 0) return; - const roundedDelta = usageCentsDelta > 0 ? Math.ceil(usageCentsDelta) : Math.floor(usageCentsDelta); + const roundedDelta = Math.ceil(usageCentsDelta); if (roundedDelta === 0) return; const key = usageCounterKey(userId, dateKey); diff --git a/apps/web/package.json b/apps/web/package.json index 27e4eaea..d6009b81 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "node": ">=22" }, "scripts": { - "dev": "NODE_NO_WARNINGS=1 BROWSERSLIST_IGNORE_OLD_DATA=true BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA=true vite dev --port 3000", + "dev": "BROWSERSLIST_IGNORE_OLD_DATA=true BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA=true vite dev --port 3000", "build": "vite build", "start": "node .output/server/index.mjs", "preview": "vite preview", diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index c3240f82..5e450abc 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -450,7 +450,12 @@ export function AppSidebar({ }); } catch (error) { console.warn("[Chat] Title regeneration failed:", error); - toast.error("Failed to regenerate chat name"); + const message = error instanceof Error ? error.message : ""; + if (message.toLowerCase().includes("too many title generations")) { + toast.error(message); + } else { + toast.error("Failed to regenerate chat name"); + } } finally { setTitleGenerating(chatId, false); } diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 68c27aac..46134644 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -14,6 +14,10 @@ if (TRUST_PROXY_MODE === "true") { console.warn("[Models API] TRUST_PROXY=true uses shared rate-limit bucket"); } +if (!TRUST_PROXY_MODE) { + console.warn("[Models API] TRUST_PROXY is unset; models endpoint may use shared rate-limit behavior"); +} + if ( TRUST_PROXY_MODE && TRUST_PROXY_MODE !== "cloudflare" && diff --git a/apps/web/src/routes/api/workflow/export-chat.ts b/apps/web/src/routes/api/workflow/export-chat.ts index 2cf8370b..e5efffd9 100644 --- a/apps/web/src/routes/api/workflow/export-chat.ts +++ b/apps/web/src/routes/api/workflow/export-chat.ts @@ -1,6 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; import { json } from "@tanstack/react-start"; -import { serve } from "@upstash/workflow/tanstack"; import { api } from "@server/convex/_generated/api"; import type { Id } from "@server/convex/_generated/dataModel"; import { createConvexServerClient } from "@/lib/convex-server"; @@ -9,7 +8,6 @@ import { exportRatelimit, shouldFailClosedForMissingUpstash, } from "@/lib/upstash"; -import { getWorkflowAuthToken } from "@/lib/workflow-auth-token"; type ExportFormat = "markdown" | "json"; const MAX_EXPORT_BYTES = 10 * 1024 * 1024; @@ -78,12 +76,6 @@ function formatRole(role: string): string { return "Message"; } -function hasWorkflowSigningKeysConfigured(): boolean { - return Boolean( - process.env.QSTASH_CURRENT_SIGNING_KEY && process.env.QSTASH_NEXT_SIGNING_KEY, - ); -} - function formatExportMarkdown(data: ChatExportData): string { const lines: string[] = []; lines.push(`# ${data.chat.title}`); @@ -153,81 +145,10 @@ async function runExportChatInline( }; } -const workflow = serve(async (context) => { - const { chatId, userId, format = "markdown" } = context.requestPayload; - - const authTokenRef = context.requestPayload.authTokenRef; - if (!authTokenRef) { - return { - error: "Unauthorized", - }; - } - - const authToken = await context.run("resolve-auth", async () => { - return getWorkflowAuthToken(authTokenRef); - }); - if (!authToken) { - return { - error: "Unauthorized", - }; - } - if (!userId || userId.trim().length === 0) { - return { - error: "Unauthorized", - }; - } - - const convexClient = createConvexServerClient(authToken); - const chatExportData = await context.run("gather-messages", async () => { - return convexClient.query(api.chats.getChatExportData, { - chatId: chatId as Id<"chats">, - userId: userId as Id<"users">, - }); - }); - if (!chatExportData) { - return { - error: "Chat not found", - }; - } - - const formattedExport = await context.run("format-export", async () => { - if (format === "json") { - return JSON.stringify(chatExportData, null, 2); - } - return formatExportMarkdown(chatExportData); - }); - - const uploadResult = await context.run("upload", async () => { - const byteLength = Buffer.byteLength(formattedExport, "utf8"); - if (byteLength > MAX_EXPORT_BYTES) { - throw new Error("Export too large"); - } - const mimeType = format === "json" ? "application/json" : "text/markdown"; - const base64 = Buffer.from(formattedExport, "utf8").toString("base64"); - return { - downloadUrl: `data:${mimeType};base64,${base64}`, - byteLength, - fileName: `chat-export-${chatId}.${format === "json" ? "json" : "md"}`, - }; - }); - - return context.run("notify", async () => { - return uploadResult; - }); -}); - export const Route = createFileRoute("/api/workflow/export-chat")({ server: { handlers: { POST: async ({ request }) => { - const isWorkflowCallback = Boolean(request.headers.get("upstash-signature")); - if (isWorkflowCallback) { - if (!hasWorkflowSigningKeysConfigured()) { - return json({ error: "Workflow signing not configured" }, { status: 500 }); - } - return workflow.POST({ request }); - } - if (!isSameOrigin(request)) { return json({ error: "Invalid origin" }, { status: 403 }); } diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 393b7803..8641c5de 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -66,7 +66,10 @@ function parseGenerateTitlePayload(raw: unknown): GenerateTitlePayload | null { return { chatId: payload.chatId.trim(), userId: payload.userId.trim(), - authTokenRef: typeof payload.authTokenRef === "string" ? payload.authTokenRef : undefined, + authTokenRef: + typeof payload.authTokenRef === "string" && payload.authTokenRef.trim().length > 0 + ? payload.authTokenRef.trim() + : undefined, seedText: payload.seedText, length: payload.length, provider: payload.provider, diff --git a/apps/web/src/stores/model.ts b/apps/web/src/stores/model.ts index b16c02c6..255cc6f3 100644 --- a/apps/web/src/stores/model.ts +++ b/apps/web/src/stores/model.ts @@ -333,9 +333,9 @@ async function fetchAllModels(): Promise> { headers: { "Content-Type": "application/json" }, }); - if (!response.ok) { - throw new Error(`Models API error: ${response.status}`); - } + if (!response.ok) { + throw new Error(`Models API error: ${response.status}`); + } const data = await response.json(); const rawModels = data.data || []; From c048027bc8ccd454ce4541efcbc1fa7028ad47ff Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 00:54:23 -0500 Subject: [PATCH 35/40] fix(web): enforce models fallback limit and workflow callback host --- apps/web/src/routes/api/models.ts | 33 +++++++++---------- apps/web/src/routes/api/workflow/cleanup.ts | 5 ++- .../src/routes/api/workflow/delete-account.ts | 5 ++- .../src/routes/api/workflow/generate-title.ts | 5 ++- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 46134644..8f0abd14 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -113,24 +113,23 @@ export const Route = createFileRoute("/api/models")({ if (modelsIpRatelimit) { const ip = getClientIp(request); if (!ip) { - console.warn("[Models API] Trusted proxy IP unavailable, skipping rate limit"); - } else { - const rl = await modelsIpRatelimit.limit(ip); - if (!rl.success) { - const retryAfterSeconds = Math.max( - 1, - Math.ceil((rl.reset - Date.now()) / 1000), - ); - return json( - { error: "Rate limit exceeded" }, - { - status: 429, - headers: { - "Retry-After": String(retryAfterSeconds), - }, + console.warn("[Models API] Trusted proxy IP unavailable, using shared rate-limit bucket"); + } + const rl = await modelsIpRatelimit.limit(ip ?? UNKNOWN_CLIENT_IP); + if (!rl.success) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((rl.reset - Date.now()) / 1000), + ); + return json( + { error: "Rate limit exceeded" }, + { + status: 429, + headers: { + "Retry-After": String(retryAfterSeconds), }, - ); - } + }, + ); } } diff --git a/apps/web/src/routes/api/workflow/cleanup.ts b/apps/web/src/routes/api/workflow/cleanup.ts index 5fd6ddbb..6ed49531 100644 --- a/apps/web/src/routes/api/workflow/cleanup.ts +++ b/apps/web/src/routes/api/workflow/cleanup.ts @@ -79,9 +79,8 @@ function hasWorkflowSigningKeysConfigured(): boolean { function getWorkflowCallbackUrl(request: Request): string | null { const configuredBase = - process.env.VITE_CONVEX_SITE_URL || - process.env.CONVEX_SITE_URL || - process.env.VITE_APP_URL; + process.env.VITE_APP_URL || + (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined); if (!configuredBase) return null; const pathname = new URL(request.url).pathname; diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 60b44d87..0aae1f06 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -113,9 +113,8 @@ function getWorkflowTriggerHeaders(): Record { function getWorkflowCallbackUrl(request: Request): string | null { const configuredBase = - process.env.VITE_CONVEX_SITE_URL || - process.env.CONVEX_SITE_URL || - process.env.VITE_APP_URL; + process.env.VITE_APP_URL || + (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined); if (!configuredBase) return null; const pathname = new URL(request.url).pathname; diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 8641c5de..8fce483f 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -109,9 +109,8 @@ function getWorkflowTriggerHeaders(): Record { function getWorkflowCallbackUrl(request: Request): string | null { const configuredBase = - process.env.VITE_CONVEX_SITE_URL || - process.env.CONVEX_SITE_URL || - process.env.VITE_APP_URL; + process.env.VITE_APP_URL || + (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined); if (!configuredBase) return null; const pathname = new URL(request.url).pathname; From 3500ca5c736a71db107b04d27a912b3611dd99b8 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 01:05:43 -0500 Subject: [PATCH 36/40] fix(web): harden model IP gating and workflow title sanitization --- apps/server/convex/backgroundStream.ts | 4 ++ apps/server/convex/lib/sanitize.ts | 2 + apps/web/src/hooks/use-persistent-chat.ts | 4 +- apps/web/src/routes/api/models.ts | 21 ++++-- .../src/routes/api/workflow/generate-title.ts | 69 +++++++++---------- 5 files changed, 56 insertions(+), 44 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index 637a775b..320145d0 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -1453,6 +1453,10 @@ export const executeStream = internalAction({ : undefined, }); } catch (error) { + console.error("[BackgroundStream] executeStream failed", { + jobId: args.jobId, + error, + }); if (reservedDateKey && reservedUsageCents > 0) { try { await adjustDailyUsageInUpstash(job.userId, reservedDateKey, -reservedUsageCents); diff --git a/apps/server/convex/lib/sanitize.ts b/apps/server/convex/lib/sanitize.ts index 1cb9fea7..72e2b61d 100644 --- a/apps/server/convex/lib/sanitize.ts +++ b/apps/server/convex/lib/sanitize.ts @@ -41,6 +41,8 @@ export function sanitizeTitle( // Collapse multiple spaces into one sanitized = sanitized.replace(/\s+/g, " "); + sanitized = sanitized.replace(/<[^>]*>/g, "").trim(); + // Truncate to maximum length if (sanitized.length > maxLength) { sanitized = sanitized.slice(0, maxLength); diff --git a/apps/web/src/hooks/use-persistent-chat.ts b/apps/web/src/hooks/use-persistent-chat.ts index 75a6bc32..5c835f80 100644 --- a/apps/web/src/hooks/use-persistent-chat.ts +++ b/apps/web/src/hooks/use-persistent-chat.ts @@ -879,7 +879,7 @@ export function usePersistentChat({ setError(parsedError); setStatus("error"); toast.error("Failed to edit message", { - description: parsedError.message, + description: getUserFriendlyError(parsedError.message), }); } }, @@ -1029,7 +1029,7 @@ export function usePersistentChat({ setError(parsedError); setStatus("error"); toast.error("Failed to retry message", { - description: parsedError.message, + description: getUserFriendlyError(parsedError.message), }); } }, diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 8f0abd14..ba32e793 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -8,14 +8,13 @@ const MODELS_CACHE_TTL_SECONDS = 60 * 60 * 4; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const OPENROUTER_FETCH_TIMEOUT_MS = 10_000; const TRUST_PROXY_MODE = process.env.TRUST_PROXY?.trim().toLowerCase(); -const UNKNOWN_CLIENT_IP = "unknown"; if (TRUST_PROXY_MODE === "true") { - console.warn("[Models API] TRUST_PROXY=true uses shared rate-limit bucket"); + console.warn("[Models API] TRUST_PROXY=true requires x-forwarded-for for rate limiting"); } if (!TRUST_PROXY_MODE) { - console.warn("[Models API] TRUST_PROXY is unset; models endpoint may use shared rate-limit behavior"); + console.warn("[Models API] TRUST_PROXY is unset; models endpoint will reject requests when IP is unavailable"); } if ( @@ -24,7 +23,7 @@ if ( TRUST_PROXY_MODE !== "vercel" && TRUST_PROXY_MODE !== "true" ) { - console.warn("[Models API] Unrecognized TRUST_PROXY value, using shared rate-limit bucket"); + console.warn("[Models API] Unrecognized TRUST_PROXY value; models endpoint will reject requests when IP is unavailable"); } const modelsIpRatelimit = upstashRedis @@ -96,10 +95,15 @@ function getClientIp(request: Request): string | null { } if (TRUST_PROXY_MODE === "true") { + const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); + if (forwardedFor) { + const first = forwardedFor.split(",")[0]?.trim(); + if (first) return first; + } return null; } - return UNKNOWN_CLIENT_IP; + return null; } export const Route = createFileRoute("/api/models")({ @@ -113,9 +117,12 @@ export const Route = createFileRoute("/api/models")({ if (modelsIpRatelimit) { const ip = getClientIp(request); if (!ip) { - console.warn("[Models API] Trusted proxy IP unavailable, using shared rate-limit bucket"); + return json( + { error: "Unable to determine client IP for rate limiting" }, + { status: 400 }, + ); } - const rl = await modelsIpRatelimit.limit(ip ?? UNKNOWN_CLIENT_IP); + const rl = await modelsIpRatelimit.limit(ip); if (!rl.success) { const retryAfterSeconds = Math.max( 1, diff --git a/apps/web/src/routes/api/workflow/generate-title.ts b/apps/web/src/routes/api/workflow/generate-title.ts index 8fce483f..f83b842d 100644 --- a/apps/web/src/routes/api/workflow/generate-title.ts +++ b/apps/web/src/routes/api/workflow/generate-title.ts @@ -93,6 +93,7 @@ function sanitizeGeneratedTitle(input: string): string { } title = title.replace(/\s+/g, " ").replace(/[.?!]+$/, "").trim(); + title = title.replace(/<[^>]*>/g, "").trim(); if (!title) return ""; return title.slice(0, TITLE_MAX_LENGTH); } @@ -242,51 +243,49 @@ const workflow = serve(async (context) => { TITLE_STYLE_PROMPTS[length], ].join(" "); - const llmResponse = await context.run("call-llm", async () => { - let response: Response; - try { - response = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${openRouterKey}`, - "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", - "X-Title": "OSSChat", - }, - body: JSON.stringify({ - model: TITLE_MODEL_ID, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: normalizedSeed }, - ], - temperature: 0.2, - max_tokens: 32, - }), - signal: AbortSignal.timeout(OPENROUTER_CALL_TIMEOUT_MS), - }); - } catch { - return { status: 0, body: null }; - } - - let body: { choices?: Array<{ message?: { content?: string } }> } | null = null; + let llmResponseStatus = 0; + let llmResponseBody: { choices?: Array<{ message?: { content?: string } }> } | null = null; + try { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openRouterKey}`, + "HTTP-Referer": process.env.VITE_CONVEX_SITE_URL || "https://osschat.io", + "X-Title": "OSSChat", + }, + body: JSON.stringify({ + model: TITLE_MODEL_ID, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: normalizedSeed }, + ], + temperature: 0.2, + max_tokens: 32, + }), + signal: AbortSignal.timeout(OPENROUTER_CALL_TIMEOUT_MS), + }); + llmResponseStatus = response.status; if (response.ok) { try { - body = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> }; + llmResponseBody = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; } catch { - body = null; + llmResponseBody = null; } } - - return { status: response.status, body }; - }); - if (llmResponse.status < 200 || llmResponse.status >= 300) { + } catch { + llmResponseStatus = 0; + } + if (llmResponseStatus < 200 || llmResponseStatus >= 300) { return { saved: false, - reason: `llm_status_${llmResponse.status}`, + reason: `llm_status_${llmResponseStatus}`, } as const; } - const rawTitle = llmResponse.body?.choices?.[0]?.message?.content ?? ""; + const rawTitle = llmResponseBody?.choices?.[0]?.message?.content ?? ""; const sanitizedTitle = sanitizeGeneratedTitle(rawTitle); if (!sanitizedTitle) { return { saved: false, reason: "empty_title" } as const; From bb0e9993c5dcb8c29c48ac1f0626972ff937363f Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 01:13:19 -0500 Subject: [PATCH 37/40] fix(security): tighten workflow auth fallback and usage counters --- apps/server/convex/lib/upstashUsage.ts | 17 +++++++++++++++-- apps/web/src/lib/server-auth.test.ts | 23 +++++++++++++++++------ apps/web/src/lib/server-auth.ts | 22 +++++++++++++++++++--- apps/web/src/lib/workflow-auth-token.ts | 2 +- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/apps/server/convex/lib/upstashUsage.ts b/apps/server/convex/lib/upstashUsage.ts index a2c366b0..ed98b1c4 100644 --- a/apps/server/convex/lib/upstashUsage.ts +++ b/apps/server/convex/lib/upstashUsage.ts @@ -46,7 +46,7 @@ async function executePipeline(commands: Array>): Promise if (!response.ok) { const body = await response.text(); - throw new Error(`Upstash pipeline failed: ${response.status} ${body}`); + throw new Error(`Upstash pipeline failed: ${response.status} ${body.slice(0, 200)}`); } const json = (await response.json()) as PipelineResult; @@ -175,10 +175,23 @@ export async function adjustDailyUsageInUpstash( try { const expiresAt = getMidnightUtcEpochSeconds(dateKey); - await executePipeline([ + const result = await executePipeline([ ["INCRBY", key, roundedDelta], ["EXPIREAT", key, expiresAt], ]); + const total = result?.[0]?.result; + const parsedTotal = + typeof total === "number" + ? total + : typeof total === "string" + ? Number.parseInt(total, 10) + : null; + if (typeof parsedTotal === "number" && Number.isFinite(parsedTotal) && parsedTotal < 0) { + await executePipeline([ + ["SET", key, 0], + ["EXPIREAT", key, expiresAt], + ]); + } } catch (error) { if (shouldLogUpstashUsageErrors()) { console.warn("[Usage] Upstash adjust failed", error); diff --git a/apps/web/src/lib/server-auth.test.ts b/apps/web/src/lib/server-auth.test.ts index b9562527..603f04d6 100644 --- a/apps/web/src/lib/server-auth.test.ts +++ b/apps/web/src/lib/server-auth.test.ts @@ -13,6 +13,14 @@ function requestWithCookie(cookie: string): Request { } as unknown as Request; } +function createJwt(expSecondsFromNow = 3600): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expSecondsFromNow }), + ).toString("base64url"); + return `${header}.${payload}.signature`; +} + describe("server-auth.getConvexAuthToken", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -32,9 +40,10 @@ describe("server-auth.getConvexAuthToken", () => { vi.stubEnv("CONVEX_SITE_URL", ""); const { getConvexAuthToken } = await loadModule(); - const request = requestWithCookie("better-auth.convex_jwt=abc%2E123%2Exyz; other=value"); + const fallbackJwt = createJwt(); + const request = requestWithCookie(`better-auth.convex_jwt=${fallbackJwt}; other=value`); - await expect(getConvexAuthToken(request)).resolves.toBe("abc.123.xyz"); + await expect(getConvexAuthToken(request)).resolves.toBe(fallbackJwt); }); it("prefers token endpoint when it returns a valid token", async () => { @@ -59,9 +68,10 @@ describe("server-auth.getConvexAuthToken", () => { vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); const { getConvexAuthToken } = await loadModule(); - const request = requestWithCookie("better-auth.convex_jwt=fallback.token.value"); + const fallbackJwt = createJwt(); + const request = requestWithCookie(`better-auth.convex_jwt=${fallbackJwt}`); - await expect(getConvexAuthToken(request)).resolves.toBe("fallback.token.value"); + await expect(getConvexAuthToken(request)).resolves.toBe(fallbackJwt); }); it("returns null when token endpoint explicitly rejects the request", async () => { @@ -91,8 +101,9 @@ describe("server-auth.getConvexAuthToken", () => { ); const { getConvexAuthToken } = await loadModule(); - const request = requestWithCookie("better-auth.convex_jwt=fallback.token.value"); + const fallbackJwt = createJwt(); + const request = requestWithCookie(`better-auth.convex_jwt=${fallbackJwt}`); - await expect(getConvexAuthToken(request)).resolves.toBe("fallback.token.value"); + await expect(getConvexAuthToken(request)).resolves.toBe(fallbackJwt); }); }); diff --git a/apps/web/src/lib/server-auth.ts b/apps/web/src/lib/server-auth.ts index 2f24e948..60aa1e65 100644 --- a/apps/web/src/lib/server-auth.ts +++ b/apps/web/src/lib/server-auth.ts @@ -18,8 +18,7 @@ const CONVEX_SITE_URL = process.env.VITE_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL; const IS_PRODUCTION = process.env.NODE_ENV === "production"; const ALLOW_AUTH_COOKIE_FALLBACK = process.env.ALLOW_AUTH_COOKIE_FALLBACK === "true"; -const IS_LOCAL_DEV = - !IS_PRODUCTION && (ALLOW_AUTH_COOKIE_FALLBACK || process.env.NODE_ENV === "test"); +const IS_LOCAL_DEV = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test"; if (IS_PRODUCTION && ALLOW_AUTH_COOKIE_FALLBACK) { throw new Error("ALLOW_AUTH_COOKIE_FALLBACK must not be enabled in production"); @@ -41,6 +40,23 @@ function getCookieValue(cookieHeader: string, name: string): string | null { return null; } +function isJwtNotExpired(jwt: string): boolean { + const segments = jwt.split("."); + if (segments.length !== 3) return false; + const payloadSegment = segments[1]; + if (!payloadSegment) return false; + + try { + const payload = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8")) as { + exp?: unknown; + }; + if (typeof payload.exp !== "number") return false; + return payload.exp * 1000 > Date.now(); + } catch { + return false; + } +} + export async function getConvexAuthToken(request: Request): Promise { const cookie = request.headers.get("cookie"); if (!cookie) return null; @@ -71,7 +87,7 @@ export async function getConvexAuthToken(request: Request): Promise Date: Thu, 12 Feb 2026 17:25:09 -0500 Subject: [PATCH 38/40] fix: resolve all Cubic review findings - Remove dead jonMode/dynamicPrompt fields from validator, schema, client store, hooks, and settings UI - Use exact-match error classification in http.ts instead of fragile substring matching - Fix inconsistent indentation in chats.ts JSON.stringify - Promote chatReadStatuses/promptTemplates deletion to workflow steps for proper transaction isolation - Deprecate deleteAccount mutation in favor of workflow-based deleteAccountWorkflowStep - Use static error message in app-sidebar toast instead of raw error.message - Fix DELETE rate-limit message in openrouter-key.ts --- apps/server/convex/backgroundStream.ts | 2 - apps/server/convex/chats.ts | 18 +- apps/server/convex/http.ts | 6 +- apps/server/convex/schema.ts | 2 - apps/server/convex/users.ts | 47 +-- apps/web/src/components/app-sidebar.tsx | 2 +- apps/web/src/hooks/use-persistent-chat.ts | 381 +++++++++--------- apps/web/src/routes/api/openrouter-key.ts | 2 +- .../src/routes/api/workflow/delete-account.ts | 8 +- apps/web/src/routes/settings.tsx | 40 -- apps/web/src/stores/ui.ts | 34 +- 11 files changed, 233 insertions(+), 309 deletions(-) diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts index 320145d0..2d7ef3b6 100644 --- a/apps/server/convex/backgroundStream.ts +++ b/apps/server/convex/backgroundStream.ts @@ -39,8 +39,6 @@ const streamOptionsValidator = v.object({ enableWebSearch: v.optional(v.boolean()), supportsToolCalls: v.optional(v.boolean()), maxSteps: v.optional(v.number()), - dynamicPrompt: v.optional(v.boolean()), - jonMode: v.optional(v.boolean()), }); type ChainOfThoughtPart = { diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index 7ab75b8a..b5bd4e92 100644 --- a/apps/server/convex/chats.ts +++ b/apps/server/convex/chats.ts @@ -568,15 +568,15 @@ async function generateTitleFromSeed( "HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io", "X-Title": "OSSChat", }, - body: JSON.stringify({ - model: TITLE_MODEL_ID, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: normalizedSeed }, - ], - temperature: 0.2, - max_tokens: 32, - }), + body: JSON.stringify({ + model: TITLE_MODEL_ID, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: normalizedSeed }, + ], + temperature: 0.2, + max_tokens: 32, + }), }); if (!response.ok) { diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index 822981bf..b67f4112 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -124,10 +124,14 @@ http.route({ }); } catch (error) { const message = error instanceof Error ? error.message : "Cleanup batch failed"; + const KNOWN_VALIDATION_ERRORS = [ + "retentionDays must be between 1 and 3650", + "batchSize must be between 1 and 1000", + ]; const status = message === "Unauthorized" ? 401 - : message.includes("must be between") + : KNOWN_VALIDATION_ERRORS.includes(message) ? 400 : 500; if (status === 500) { diff --git a/apps/server/convex/schema.ts b/apps/server/convex/schema.ts index 948cdf29..26f4b936 100644 --- a/apps/server/convex/schema.ts +++ b/apps/server/convex/schema.ts @@ -262,8 +262,6 @@ export default defineSchema({ enableWebSearch: v.optional(v.boolean()), supportsToolCalls: v.optional(v.boolean()), maxSteps: v.optional(v.number()), - dynamicPrompt: v.optional(v.boolean()), - jonMode: v.optional(v.boolean()), })), content: v.string(), reasoning: v.optional(v.string()), diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 1499e7a0..ad522561 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -800,23 +800,10 @@ export const deleteUserRecord = internalMutation({ paginationOpts: { cursor: null, numItems: 1 }, }); - for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { - const result = await ctx.runMutation(internal.users.deleteUserChatReadStatuses, { - userId: args.userId, - }); - if (!result.hasMore) { - break; - } - } - - for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { - const result = await ctx.runMutation(internal.users.deleteUserPromptTemplates, { - userId: args.userId, - }); - if (!result.hasMore) { - break; - } - } + // chatReadStatuses and promptTemplates are now deleted in separate + // workflow steps (delete-chat-read-statuses / delete-prompt-templates) + // via deleteAccountWorkflowStep, ensuring each batch runs in its own + // transaction and avoids hitting Convex per-transaction write limits. const profile = await ctx.db .query("profiles") @@ -842,6 +829,8 @@ export const deleteAccountWorkflowStep = action({ v.literal("delete-messages"), v.literal("delete-chats"), v.literal("delete-files"), + v.literal("delete-chat-read-statuses"), + v.literal("delete-prompt-templates"), v.literal("delete-user"), ), batchSize: v.optional(v.number()), @@ -878,6 +867,14 @@ export const deleteAccountWorkflowStep = action({ userId, batchSize: args.batchSize, }); + case "delete-chat-read-statuses": + return await ctx.runMutation(internal.users.deleteUserChatReadStatuses, { + userId, + }); + case "delete-prompt-templates": + return await ctx.runMutation(internal.users.deleteUserPromptTemplates, { + userId, + }); case "delete-user": { const result: { success: boolean } = await ctx.runMutation( internal.users.deleteUserRecord, @@ -897,17 +894,11 @@ export const deleteAccountWorkflowStep = action({ }); /** - * Permanently delete a user account and all associated data. - * This is an irreversible action that removes: - * - All chats and messages - * - All file uploads and storage blobs - * - All prompt templates - * - User profile and settings - * - Better Auth sessions and account links - * - * Note: For users with very large amounts of data, this mutation may approach - * Convex timeout limits. Consider breaking into scheduled jobs for production - * use with high-volume users. + * @deprecated Use deleteAccountWorkflowStep (action) instead. + * This mutation runs all deletes in a single transaction, which can hit + * Convex per-transaction write limits for users with large amounts of data. + * The workflow-based approach (deleteAccountWorkflowStep) isolates each + * deletion step into its own transaction. */ export const deleteAccount = mutation({ args: { diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 5e450abc..8b32717b 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -452,7 +452,7 @@ export function AppSidebar({ console.warn("[Chat] Title regeneration failed:", error); const message = error instanceof Error ? error.message : ""; if (message.toLowerCase().includes("too many title generations")) { - toast.error(message); + toast.error("Too many title generations. Please try again later."); } else { toast.error("Failed to regenerate chat name"); } diff --git a/apps/web/src/hooks/use-persistent-chat.ts b/apps/web/src/hooks/use-persistent-chat.ts index 5c835f80..daecd7dd 100644 --- a/apps/web/src/hooks/use-persistent-chat.ts +++ b/apps/web/src/hooks/use-persistent-chat.ts @@ -8,7 +8,6 @@ import { useAuth } from "@/lib/auth-client"; import { getModelById, getModelCapabilities, useModelStore, useModels } from "@/stores/model"; import { useProviderStore } from "@/stores/provider"; import { useStreamStore } from "@/stores/stream"; -import { useUIStore } from "@/stores/ui"; import { analytics } from "@/lib/analytics"; import { shouldTriggerAutoTitle } from "@/lib/title-generation"; @@ -298,8 +297,6 @@ export function usePersistentChat({ const { models } = useModels(); const activeProvider = useProviderStore((s) => s.activeProvider); const webSearchEnabled = useProviderStore((s) => s.webSearchEnabled); - const jonMode = useUIStore((s) => s.jonMode); - const dynamicPrompt = useUIStore((s) => s.dynamicPrompt); const [messages, setMessages] = useState>([]); const [status, setStatus] = useState<"ready" | "submitted" | "streaming" | "error">("ready"); @@ -615,22 +612,20 @@ export function usePersistentChat({ }); allMsgs.push({ role: "user", content: message.text }); - await startBackgroundStream({ - chatId: targetChatId as Id<"chats">, - userId: convexUserId, - messageId: assistantMsgId, - model: runtimeModelId, - provider: activeProvider, - messages: allMsgs, - options: { - enableReasoning: runtimeReasoningEnabled, - reasoningEffort: runtimeReasoningEffort, - enableWebSearch: webSearchEnabled, - jonMode, - dynamicPrompt, - supportsToolCalls: runtimeSupportsToolCalls, - }, - }); + await startBackgroundStream({ + chatId: targetChatId as Id<"chats">, + userId: convexUserId, + messageId: assistantMsgId, + model: runtimeModelId, + provider: activeProvider, + messages: allMsgs, + options: { + enableReasoning: runtimeReasoningEnabled, + reasoningEffort: runtimeReasoningEffort, + enableWebSearch: webSearchEnabled, + supportsToolCalls: runtimeSupportsToolCalls, + }, + }); setStatus("streaming"); streamingRef.current = { id: assistantMsgId, content: "", reasoning: "", chainHash: "[]" }; @@ -724,23 +719,21 @@ export function usePersistentChat({ } } }, - [ - convexUserId, - isUserLoading, - user?.id, - messages, - messagesResult, - models, - activeProvider, - webSearchEnabled, - jonMode, - dynamicPrompt, - createChat, - sendMessages, - startBackgroundStream, - cleanupStaleJobs, - ], - ); + [ + convexUserId, + isUserLoading, + user?.id, + messages, + messagesResult, + models, + activeProvider, + webSearchEnabled, + createChat, + sendMessages, + startBackgroundStream, + cleanupStaleJobs, + ], + ); const editMessage = useCallback( async (messageId: string, newContent: string) => { @@ -831,72 +824,68 @@ export function usePersistentChat({ return { role: m.role, content: textPart?.text || "" }; }); - await startBackgroundStream({ - chatId: targetChatId, - userId: convexUserId, - messageId: assistantMsgId, - model: runtimeModelId, - provider: activeProvider, - messages: allMsgs, - options: { - enableReasoning: runtimeReasoningEnabled, - reasoningEffort: runtimeReasoningEffort, - enableWebSearch: webSearchEnabled, - jonMode, - dynamicPrompt, - supportsToolCalls: runtimeSupportsToolCalls, - }, - }); + await startBackgroundStream({ + chatId: targetChatId, + userId: convexUserId, + messageId: assistantMsgId, + model: runtimeModelId, + provider: activeProvider, + messages: allMsgs, + options: { + enableReasoning: runtimeReasoningEnabled, + reasoningEffort: runtimeReasoningEffort, + enableWebSearch: webSearchEnabled, + supportsToolCalls: runtimeSupportsToolCalls, + }, + }); - const initialParts: UIMessage["parts"] = []; - if (runtimeReasoningEffort !== "none") { - const reasoningPart: ReasoningPartWithState = { type: "reasoning", text: "", state: "streaming" }; - initialParts.push(reasoningPart as UIMessage["parts"][number]); - } - initialParts.push({ type: "text", text: "", state: "streaming" }); + const initialParts: UIMessage["parts"] = []; + if (runtimeReasoningEffort !== "none") { + const reasoningPart: ReasoningPartWithState = { type: "reasoning", text: "", state: "streaming" }; + initialParts.push(reasoningPart as UIMessage["parts"][number]); + } + initialParts.push({ type: "text", text: "", state: "streaming" }); - setMessages((prev) => [ - ...prev, - { - id: assistantMsgId, - role: "assistant", - parts: initialParts, - metadata: { - reasoningRequested: runtimeReasoningEffort !== "none", - modelId: runtimeModelId, - provider: activeProvider, - reasoningEffort: runtimeReasoningEffort, - webSearchEnabled, - resumedFromActiveStream: false, - }, + setMessages((prev) => [ + ...prev, + { + id: assistantMsgId, + role: "assistant", + parts: initialParts, + metadata: { + reasoningRequested: runtimeReasoningEffort !== "none", + modelId: runtimeModelId, + provider: activeProvider, + reasoningEffort: runtimeReasoningEffort, + webSearchEnabled, + resumedFromActiveStream: false, }, - ]); + }, + ]); - setStatus("streaming"); - streamingRef.current = { id: assistantMsgId, content: "", reasoning: "", chainHash: "[]" }; - } catch (err) { - const parsedError = err instanceof Error ? err : new Error("Unknown error"); - setError(parsedError); - setStatus("error"); - toast.error("Failed to edit message", { - description: getUserFriendlyError(parsedError.message), - }); - } - }, - [ - convexUserId, - messages, - messagesResult, - models, - activeProvider, - webSearchEnabled, - jonMode, - dynamicPrompt, - editAndRegenerate, - startBackgroundStream, - cleanupStaleJobs, - ], - ); + setStatus("streaming"); + streamingRef.current = { id: assistantMsgId, content: "", reasoning: "", chainHash: "[]" }; + } catch (err) { + const parsedError = err instanceof Error ? err : new Error("Unknown error"); + setError(parsedError); + setStatus("error"); + toast.error("Failed to edit message", { + description: getUserFriendlyError(parsedError.message), + }); + } + }, + [ + convexUserId, + messages, + messagesResult, + models, + activeProvider, + webSearchEnabled, + editAndRegenerate, + startBackgroundStream, + cleanupStaleJobs, + ], +); const retryMessage = useCallback( async (messageId: string, overrideModelId?: string) => { @@ -981,72 +970,68 @@ export function usePersistentChat({ return { role: m.role, content: textPart?.text || "" }; }); - await startBackgroundStream({ - chatId: targetChatId, - userId: convexUserId, - messageId: assistantMsgId, - model: runtimeModelId, - provider: activeProvider, - messages: allMsgs, - options: { - enableReasoning: runtimeReasoningEnabled, - reasoningEffort: runtimeReasoningEffort, - enableWebSearch: webSearchEnabled, - jonMode, - dynamicPrompt, - supportsToolCalls: runtimeSupportsToolCalls, - }, - }); + await startBackgroundStream({ + chatId: targetChatId, + userId: convexUserId, + messageId: assistantMsgId, + model: runtimeModelId, + provider: activeProvider, + messages: allMsgs, + options: { + enableReasoning: runtimeReasoningEnabled, + reasoningEffort: runtimeReasoningEffort, + enableWebSearch: webSearchEnabled, + supportsToolCalls: runtimeSupportsToolCalls, + }, + }); - const initialParts: UIMessage["parts"] = []; - if (runtimeReasoningEffort !== "none") { - const reasoningPart: ReasoningPartWithState = { type: "reasoning", text: "", state: "streaming" }; - initialParts.push(reasoningPart as UIMessage["parts"][number]); - } - initialParts.push({ type: "text", text: "", state: "streaming" }); + const initialParts: UIMessage["parts"] = []; + if (runtimeReasoningEffort !== "none") { + const reasoningPart: ReasoningPartWithState = { type: "reasoning", text: "", state: "streaming" }; + initialParts.push(reasoningPart as UIMessage["parts"][number]); + } + initialParts.push({ type: "text", text: "", state: "streaming" }); - setMessages((prev) => [ - ...prev, - { - id: assistantMsgId, - role: "assistant", - parts: initialParts, - metadata: { - reasoningRequested: runtimeReasoningEffort !== "none", - modelId: runtimeModelId, - provider: activeProvider, - reasoningEffort: runtimeReasoningEffort, - webSearchEnabled, - resumedFromActiveStream: false, - }, + setMessages((prev) => [ + ...prev, + { + id: assistantMsgId, + role: "assistant", + parts: initialParts, + metadata: { + reasoningRequested: runtimeReasoningEffort !== "none", + modelId: runtimeModelId, + provider: activeProvider, + reasoningEffort: runtimeReasoningEffort, + webSearchEnabled, + resumedFromActiveStream: false, }, - ]); + }, + ]); - setStatus("streaming"); - streamingRef.current = { id: assistantMsgId, content: "", reasoning: "", chainHash: "[]" }; - } catch (err) { - const parsedError = err instanceof Error ? err : new Error("Unknown error"); - setError(parsedError); - setStatus("error"); - toast.error("Failed to retry message", { - description: getUserFriendlyError(parsedError.message), - }); - } - }, - [ - convexUserId, - messages, - messagesResult, - models, - activeProvider, - webSearchEnabled, - jonMode, - dynamicPrompt, - retryMessageMut, - startBackgroundStream, - cleanupStaleJobs, - ], - ); + setStatus("streaming"); + streamingRef.current = { id: assistantMsgId, content: "", reasoning: "", chainHash: "[]" }; + } catch (err) { + const parsedError = err instanceof Error ? err : new Error("Unknown error"); + setError(parsedError); + setStatus("error"); + toast.error("Failed to retry message", { + description: getUserFriendlyError(parsedError.message), + }); + } + }, + [ + convexUserId, + messages, + messagesResult, + models, + activeProvider, + webSearchEnabled, + retryMessageMut, + startBackgroundStream, + cleanupStaleJobs, + ], +); const forkMessage = useCallback( async (messageId: string, overrideModelId?: string) => { @@ -1118,47 +1103,43 @@ export function usePersistentChat({ await cleanupStaleJobs({ userId: convexUserId }).catch(() => {}); - const assistantMsgId = crypto.randomUUID(); - await startBackgroundStream({ - chatId: newChatId, - userId: convexUserId, - messageId: assistantMsgId, - model: runtimeModelId, - provider: activeProvider, - messages: msgsUpToFork, - options: { - enableReasoning: runtimeReasoningEnabled, - reasoningEffort: runtimeReasoningEffort, - enableWebSearch: webSearchEnabled, - jonMode, - dynamicPrompt, - supportsToolCalls: runtimeSupportsToolCalls, - }, - }); + const assistantMsgId = crypto.randomUUID(); + await startBackgroundStream({ + chatId: newChatId, + userId: convexUserId, + messageId: assistantMsgId, + model: runtimeModelId, + provider: activeProvider, + messages: msgsUpToFork, + options: { + enableReasoning: runtimeReasoningEnabled, + reasoningEffort: runtimeReasoningEffort, + enableWebSearch: webSearchEnabled, + supportsToolCalls: runtimeSupportsToolCalls, + }, + }); - return newChatId; - } catch (err) { - const parsedError = err instanceof Error ? err : new Error("Unknown error"); - toast.error("Failed to branch off", { - description: parsedError.message, - }); - return undefined; - } - }, - [ - convexUserId, - messages, - models, - activeProvider, - webSearchEnabled, - jonMode, - dynamicPrompt, - forkChatMut, - cleanupStaleJobs, - startBackgroundStream, - messagesResult, - ], - ); + return newChatId; + } catch (err) { + const parsedError = err instanceof Error ? err : new Error("Unknown error"); + toast.error("Failed to branch off", { + description: parsedError.message, + }); + return undefined; + } + }, + [ + convexUserId, + messages, + models, + activeProvider, + webSearchEnabled, + forkChatMut, + cleanupStaleJobs, + startBackgroundStream, + messagesResult, + ], +); const stop = useCallback(() => { setStatus("ready"); diff --git a/apps/web/src/routes/api/openrouter-key.ts b/apps/web/src/routes/api/openrouter-key.ts index c450f107..176a6ede 100644 --- a/apps/web/src/routes/api/openrouter-key.ts +++ b/apps/web/src/routes/api/openrouter-key.ts @@ -119,7 +119,7 @@ export const Route = createFileRoute("/api/openrouter-key")({ Math.ceil((rate.reset - Date.now()) / 1000), ); return json( - { error: "Too many key update attempts. Please try again shortly." }, + { error: "Too many key deletion attempts. Please try again shortly." }, { status: 429, headers: { "Retry-After": String(retryAfterSeconds) } }, ); } diff --git a/apps/web/src/routes/api/workflow/delete-account.ts b/apps/web/src/routes/api/workflow/delete-account.ts index 0aae1f06..6fe4790d 100644 --- a/apps/web/src/routes/api/workflow/delete-account.ts +++ b/apps/web/src/routes/api/workflow/delete-account.ts @@ -24,7 +24,9 @@ type DeleteStep = | "delete-stream-jobs" | "delete-messages" | "delete-chats" - | "delete-files"; + | "delete-files" + | "delete-chat-read-statuses" + | "delete-prompt-templates"; const MAX_DELETE_BATCHES = 500; @@ -167,6 +169,8 @@ async function runDeleteAccountInline( const deletedMessages = await runBatchStep("delete-messages"); const deletedChats = await runBatchStep("delete-chats"); const deletedFiles = await runBatchStep("delete-files"); + await runBatchStep("delete-chat-read-statuses"); + await runBatchStep("delete-prompt-templates"); const deleteUserResult = await convexClient.action(api.users.deleteAccountWorkflowStep, { userId: convexUserId, externalId, @@ -236,6 +240,8 @@ const workflow = serve(async (context) => { const deletedMessages = await runBatchStep("delete-messages", "delete-messages"); const deletedChats = await runBatchStep("delete-chats", "delete-chats"); const deletedFiles = await runBatchStep("delete-files", "delete-files"); + await runBatchStep("delete-chat-read-statuses", "delete-chat-read-statuses"); + await runBatchStep("delete-prompt-templates", "delete-prompt-templates"); const deleteUserResult = await context.run("delete-user", async () => { return convexClient.action(api.users.deleteAccountWorkflowStep, { diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 7701d232..38723afa 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -614,10 +614,6 @@ function ChatSection() { const setLength = useChatTitleStore((s) => s.setLength); const confirmDelete = useChatTitleStore((s) => s.confirmDelete); const setConfirmDelete = useChatTitleStore((s) => s.setConfirmDelete); - const jonMode = useUIStore((s) => s.jonMode); - const setJonMode = useUIStore((s) => s.setJonMode); - const dynamicPrompt = useUIStore((s) => s.dynamicPrompt); - const setDynamicPrompt = useUIStore((s) => s.setDynamicPrompt); const currentIndex = TITLE_LENGTH_OPTIONS.indexOf(length); const percentage = (currentIndex / (TITLE_LENGTH_OPTIONS.length - 1)) * 100; @@ -746,43 +742,7 @@ function ChatSection() { -

- AI Writing Style -

-
-
-
-

Jon Mode

-

- Makes AI responses sound more natural and human-like -

-

- Warning: This uses significantly more tokens. -

-
- -
-
-
-
-
-

Dynamic prompt

-

- Adds a system prompt with model identity for each response. -

-
- -
-
); diff --git a/apps/web/src/stores/ui.ts b/apps/web/src/stores/ui.ts index 88116ad4..df643936 100644 --- a/apps/web/src/stores/ui.ts +++ b/apps/web/src/stores/ui.ts @@ -17,8 +17,6 @@ interface UIState { // Model selector filter display filterStyle: FilterStyle; - jonMode: boolean; - dynamicPrompt: boolean; // Actions toggleSidebar: () => void; @@ -27,20 +25,16 @@ interface UIState { toggleCommandPalette: () => void; setCommandPaletteOpen: (open: boolean) => void; setFilterStyle: (style: FilterStyle) => void; - setJonMode: (enabled: boolean) => void; - setDynamicPrompt: (enabled: boolean) => void; } export const useUIStore = create()( devtools( persist( (set) => ({ - sidebarOpen: true, - sidebarCollapsed: false, - commandPaletteOpen: false, - filterStyle: "model" as FilterStyle, - jonMode: false, - dynamicPrompt: true, + sidebarOpen: true, + sidebarCollapsed: false, + commandPaletteOpen: false, + filterStyle: "model" as FilterStyle, toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen }), false, "ui/toggleSidebar"), @@ -62,22 +56,14 @@ export const useUIStore = create()( setFilterStyle: (style) => set({ filterStyle: style }, false, "ui/setFilterStyle"), - - setJonMode: (enabled) => - set({ jonMode: enabled }, false, "ui/setJonMode"), - - setDynamicPrompt: (enabled) => - set({ dynamicPrompt: enabled }, false, "ui/setDynamicPrompt"), }), { - name: "ui-store", - partialize: (state) => ({ - sidebarCollapsed: state.sidebarCollapsed, - filterStyle: state.filterStyle, - jonMode: state.jonMode, - dynamicPrompt: state.dynamicPrompt, - }), - }, + name: "ui-store", + partialize: (state) => ({ + sidebarCollapsed: state.sidebarCollapsed, + filterStyle: state.filterStyle, + }), + }, ), { name: "ui-store" }, ), From 6722ceb3c6ac55b4ee6c0eaf00733227efb6b76d Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 18:07:13 -0500 Subject: [PATCH 39/40] fix(server): retain deprecated jonMode/dynamicPrompt in schema for existing documents Existing production streamJobs documents contain these fields. Convex schema validation rejects documents with extra fields, so they must remain in the schema until a migration removes them from all documents. --- apps/server/convex/schema.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/convex/schema.ts b/apps/server/convex/schema.ts index 26f4b936..50ac0bb5 100644 --- a/apps/server/convex/schema.ts +++ b/apps/server/convex/schema.ts @@ -262,6 +262,9 @@ export default defineSchema({ enableWebSearch: v.optional(v.boolean()), supportsToolCalls: v.optional(v.boolean()), maxSteps: v.optional(v.number()), + // Deprecated: retained in schema for backward compatibility with existing documents + dynamicPrompt: v.optional(v.boolean()), + jonMode: v.optional(v.boolean()), })), content: v.string(), reasoning: v.optional(v.string()), From d8c183a7747f69afcaad36232a34099db580f686 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:09:21 +0000 Subject: [PATCH 40/40] fix(auth): require email verification to prevent account takeover during migration --- apps/server/convex/auth.ts | 16 +++++++++++++++- apps/server/convex/users.ts | 10 ++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/server/convex/auth.ts b/apps/server/convex/auth.ts index 8417ac33..1e945755 100644 --- a/apps/server/convex/auth.ts +++ b/apps/server/convex/auth.ts @@ -85,11 +85,25 @@ export const createAuth = ( // Use Convex site URL as baseURL so OAuth callbacks work correctly baseURL: convexSiteUrl, database: authComponent.adapter(ctx), - // TODO: add email verification (requireEmailVerification + sendVerificationEmail) emailAndPassword: { enabled: true, minPasswordLength: 8, maxPasswordLength: 128, + // SECURITY: Require verified email to prevent account takeover via + // unverified email registration exploiting email-based migration linking (OSS-37) + requireEmailVerification: true, + sendResetPassword: async ({ user, url }: { user: { email: string }; url: string }) => { + // TODO: integrate with email provider (e.g., Resend, SendGrid) + console.log(`[Auth] Password reset requested for ${user.email}: ${url}`); + }, + }, + emailVerification: { + sendOnSignUp: true, + sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => { + // TODO: integrate with email provider (e.g., Resend, SendGrid) + // For now, log the verification URL for development/debugging + console.log(`[Auth] Verification email for ${user.email}: ${url}`); + }, }, socialProviders: { // Only include GitHub OAuth if credentials are configured diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index ad522561..7a822e3b 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -72,7 +72,10 @@ export const ensure = mutation({ // MIGRATION: Link WorkOS users to Better Auth by email // Uses .first() since duplicate emails may exist from prior migrations - if (!existing && args.email && Date.now() < EMAIL_LINK_MIGRATION_DEADLINE_MS) { + // SECURITY: Only link if the caller's email is verified to prevent account takeover + // via unverified email registration (see OSS-37) + const isEmailVerified = identity.emailVerified ?? false; + if (!existing && args.email && isEmailVerified && Date.now() < EMAIL_LINK_MIGRATION_DEADLINE_MS) { const existingByEmail = await ctx.db .query("users") .withIndex("by_email", (q) => q.eq("email", args.email)) @@ -85,8 +88,11 @@ export const ensure = mutation({ updatedAt: Date.now(), }); existing = existingByEmail; - console.log(`[Auth Migration] Linked user ${args.email} from WorkOS to Better Auth`); + console.log(`[Auth Migration] Linked user ${args.email} from WorkOS to Better Auth (email verified)`); } + } else if (!existing && args.email && !isEmailVerified && Date.now() < EMAIL_LINK_MIGRATION_DEADLINE_MS) { + // Log attempts to link with unverified email for security monitoring + console.warn(`[Auth Migration] Blocked linking for unverified email ${args.email} (potential account takeover attempt)`); } const now = Date.now();