From 3e86a495c478a5937dd272c5e51399e76f067a04 Mon Sep 17 00:00:00 2001 From: DIodide Date: Sun, 21 Jun 2026 13:57:21 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(dev):=20ENABLE=5FDEV=5FAUTH=20?= =?UTF-8?q?=E2=80=94=20run=20the=20whole=20stack=20without=20Clerk=20for?= =?UTF-8?q?=20local=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in, prod-safe flag that bypasses Clerk end-to-end so the app can be run loginless (e.g. to capture screenshots) without a Clerk project. Off by default and prod-safe BY CONSTRUCTION — when the flag is unset every layer behaves byte-for-byte as before: - Convex: a `getIdentity(ctx)` helper (convex/authDev.ts) that returns a fixed dev identity when the deployment env `ENABLE_DEV_AUTH=true`, else delegates to `ctx.auth.getUserIdentity()`. All 72 call sites across 17 files now call it. The 199-test suite verifies the delegate path; a new unit test covers both. - FastAPI: `verify_token` / `verify_token_optional` short-circuit to a fixed dev user when `settings.enable_dev_auth`. Covered by new pytest cases. - Frontend: a `@/lib/auth` wrapper re-exports Clerk's useAuth/useUser/useClerk/ useReverification, swapping to dev stubs only when `VITE_ENABLE_DEV_AUTH=true` (a build-time constant, so it's a pure re-export otherwise). 25 call sites import from the wrapper; `__root.tsx` skips ClerkProvider in dev and the sign-in/up routes don't mount Clerk components. The three layers share `DEV_USER_ID = "dev-user"`. Documented in all three .env.example files. Also adds scripts/dev-screenshots.py (Playwright) to capture product shots once the stack runs with dev auth. NOTE: the off-path is fully test-verified; the in-browser dev path (incl. the Convex client auth handshake) needs functional verification on a running stack. --- apps/web/.env.example | 5 + apps/web/src/components/chat/chat-input.tsx | 2 +- .../components/chat/conversation-kebab.tsx | 2 +- .../src/components/chat/settings-dialog.tsx | 2 +- apps/web/src/components/chat/share-dialog.tsx | 2 +- .../commands/global-commands.tsx | 2 +- .../components/harness-creation-assistant.tsx | 2 +- .../src/components/mcp-oauth-connect-row.tsx | 2 +- .../src/components/mcp-server-status.test.ts | 5 + apps/web/src/components/mcp-server-status.tsx | 2 +- .../src/components/princeton-connect-row.tsx | 2 +- apps/web/src/env.ts | 4 + apps/web/src/hooks/use-mcp-health-check.ts | 2 +- apps/web/src/hooks/use-rewind.ts | 2 +- .../src/hooks/use-workspace-credentials.ts | 2 +- apps/web/src/lib/auth.ts | 63 ++++++++ apps/web/src/lib/chat-stream-context.tsx | 2 +- apps/web/src/lib/use-agent-catalog.ts | 2 +- apps/web/src/lib/use-agent-session-config.ts | 2 +- apps/web/src/lib/use-chat-stream.ts | 2 +- apps/web/src/lib/use-follow-stream.ts | 2 +- apps/web/src/routes/__root.tsx | 140 +++++++++--------- apps/web/src/routes/app.tsx | 8 +- apps/web/src/routes/chat/index.tsx | 2 +- apps/web/src/routes/harnesses/$harnessId.tsx | 2 +- apps/web/src/routes/index.tsx | 2 +- apps/web/src/routes/onboarding.tsx | 2 +- apps/web/src/routes/sandboxes/$sandboxId.tsx | 2 +- .../src/routes/sandboxes/create_sandbox.tsx | 2 +- apps/web/src/routes/share/$token.tsx | 2 +- apps/web/src/routes/sign-in.tsx | 7 +- apps/web/src/routes/sign-up.tsx | 7 +- packages/convex-backend/.env.example | 7 +- .../convex-backend/convex/_generated/api.d.ts | 54 +++---- .../convex/_generated/dataModel.d.ts | 14 +- .../convex/_generated/server.d.ts | 18 +-- .../convex/_generated/server.js | 14 +- .../convex-backend/convex/agentCredentials.ts | 5 +- .../convex-backend/convex/agentUsage.test.ts | 77 ++++++++-- packages/convex-backend/convex/agentUsage.ts | 9 +- packages/convex-backend/convex/auth.config.ts | 24 +-- .../convex-backend/convex/authDev.test.ts | 54 +++++++ packages/convex-backend/convex/authDev.ts | 33 +++++ packages/convex-backend/convex/commands.ts | 5 +- packages/convex-backend/convex/compactions.ts | 5 +- .../convex-backend/convex/conversations.ts | 29 ++-- packages/convex-backend/convex/env.d.ts | 2 +- packages/convex-backend/convex/files.ts | 5 +- .../convex/harnessConfigRatings.ts | 5 +- .../convex-backend/convex/harnesses.test.ts | 23 +-- packages/convex-backend/convex/harnesses.ts | 35 +++-- .../convex-backend/convex/mcpOAuthTokens.ts | 7 +- packages/convex-backend/convex/messages.ts | 15 +- packages/convex-backend/convex/sandboxes.ts | 13 +- packages/convex-backend/convex/shares.ts | 18 +-- packages/convex-backend/convex/skills.ts | 33 ++--- packages/convex-backend/convex/tsconfig.json | 54 +++---- packages/convex-backend/convex/usage.test.ts | 35 +++-- packages/convex-backend/convex/usage.ts | 53 +++++-- .../convex/userSettings.test.ts | 8 +- .../convex-backend/convex/userSettings.ts | 11 +- .../convex/workspaceCredentials.ts | 14 +- packages/convex-backend/convex/workspaces.ts | 17 ++- packages/fastapi/.env.example | 10 +- packages/fastapi/app/auth.py | 9 ++ packages/fastapi/app/config.py | 6 + packages/fastapi/tests/test_auth.py | 28 ++++ scripts/dev-screenshots.py | 75 ++++++++++ 68 files changed, 762 insertions(+), 349 deletions(-) create mode 100644 apps/web/src/lib/auth.ts create mode 100644 packages/convex-backend/convex/authDev.test.ts create mode 100644 packages/convex-backend/convex/authDev.ts create mode 100644 scripts/dev-screenshots.py diff --git a/apps/web/.env.example b/apps/web/.env.example index 62028eb..99f543d 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -12,3 +12,8 @@ VITE_FASTAPI_URL=http://localhost:8000 # Arcjet (server-only rate limiting / shield — copy from Arcjet dashboard) ARCJET_KEY= + +# LOCAL DEVELOPMENT ONLY — bypass Clerk entirely and sign in as a fixed dev user. +# Pair with ENABLE_DEV_AUTH on the Convex deployment and the FastAPI gateway. +# NEVER set this in production. +# VITE_ENABLE_DEV_AUTH=true diff --git a/apps/web/src/components/chat/chat-input.tsx b/apps/web/src/components/chat/chat-input.tsx index 1e7035f..65ea816 100644 --- a/apps/web/src/components/chat/chat-input.tsx +++ b/apps/web/src/components/chat/chat-input.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; @@ -29,6 +28,7 @@ import React, { useState, } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { useFileAttachments } from "../../hooks/use-file-attachments"; import { AGENT_MODES, diff --git a/apps/web/src/components/chat/conversation-kebab.tsx b/apps/web/src/components/chat/conversation-kebab.tsx index f375581..69cdc79 100644 --- a/apps/web/src/components/chat/conversation-kebab.tsx +++ b/apps/web/src/components/chat/conversation-kebab.tsx @@ -1,4 +1,3 @@ -import { useUser } from "@clerk/tanstack-react-start"; import { useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; @@ -16,6 +15,7 @@ import { } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; +import { useUser } from "@/lib/auth"; import { buildShareUrl, copyToClipboard, diff --git a/apps/web/src/components/chat/settings-dialog.tsx b/apps/web/src/components/chat/settings-dialog.tsx index 5908231..7c7fe4e 100644 --- a/apps/web/src/components/chat/settings-dialog.tsx +++ b/apps/web/src/components/chat/settings-dialog.tsx @@ -1,9 +1,9 @@ -import { useClerk, useUser } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { LogOut, User } from "lucide-react"; +import { useClerk, useUser } from "@/lib/auth"; import { AgentConnections } from "../agent-connections"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Button } from "../ui/button"; diff --git a/apps/web/src/components/chat/share-dialog.tsx b/apps/web/src/components/chat/share-dialog.tsx index 2017b88..9372209 100644 --- a/apps/web/src/components/chat/share-dialog.tsx +++ b/apps/web/src/components/chat/share-dialog.tsx @@ -1,4 +1,3 @@ -import { useUser } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; @@ -15,6 +14,7 @@ import { } from "lucide-react"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; +import { useUser } from "@/lib/auth"; import { buildShareUrl, copyToClipboard, diff --git a/apps/web/src/components/command-palette/commands/global-commands.tsx b/apps/web/src/components/command-palette/commands/global-commands.tsx index 39c9bf7..0413bb7 100644 --- a/apps/web/src/components/command-palette/commands/global-commands.tsx +++ b/apps/web/src/components/command-palette/commands/global-commands.tsx @@ -1,4 +1,3 @@ -import { useAuth, useClerk } from "@clerk/tanstack-react-start"; import { convexQuery } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import { useQuery } from "@tanstack/react-query"; @@ -14,6 +13,7 @@ import { SlidersHorizontal, } from "lucide-react"; import { useMemo } from "react"; +import { useAuth, useClerk } from "@/lib/auth"; import { useRegisterCommands } from "../../../hooks/use-register-commands"; import type { Command } from "../../../lib/command-palette/types"; diff --git a/apps/web/src/components/harness-creation-assistant.tsx b/apps/web/src/components/harness-creation-assistant.tsx index 695973a..ef26e55 100644 --- a/apps/web/src/components/harness-creation-assistant.tsx +++ b/apps/web/src/components/harness-creation-assistant.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -15,6 +14,7 @@ import { } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { env } from "../env"; import { PRESET_MCPS, presetIdsToServerEntries } from "../lib/mcp"; import { MODELS } from "../lib/models"; diff --git a/apps/web/src/components/mcp-oauth-connect-row.tsx b/apps/web/src/components/mcp-oauth-connect-row.tsx index f5bc3ac..39dc5d9 100644 --- a/apps/web/src/components/mcp-oauth-connect-row.tsx +++ b/apps/web/src/components/mcp-oauth-connect-row.tsx @@ -1,8 +1,8 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { Server, Shield } from "lucide-react"; import { motion } from "motion/react"; import { useCallback, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { env } from "../env"; import type { McpServerEntry } from "../lib/mcp"; import { RoseCurveSpinner } from "./rose-curve-spinner"; diff --git a/apps/web/src/components/mcp-server-status.test.ts b/apps/web/src/components/mcp-server-status.test.ts index 7100f1a..b34353f 100644 --- a/apps/web/src/components/mcp-server-status.test.ts +++ b/apps/web/src/components/mcp-server-status.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it, vi } from "vitest"; // The module pulls in Clerk + convex query hooks at import time; mock the // heavier deps so the pure helper can be imported in isolation. vi.mock("@clerk/tanstack-react-start", () => ({ + // The `@/lib/auth` wrapper re-exports all of these from Clerk, so the mock + // must provide each one even though the component only uses useAuth. useAuth: () => ({ getToken: async () => null }), + useUser: () => ({ isLoaded: true, isSignedIn: true, user: null }), + useClerk: () => ({ signOut: async () => {} }), + useReverification: (fn: T) => fn, })); vi.mock("@convex-dev/react-query", () => ({ convexQuery: () => ({}), diff --git a/apps/web/src/components/mcp-server-status.tsx b/apps/web/src/components/mcp-server-status.tsx index caef86b..bfb2cd5 100644 --- a/apps/web/src/components/mcp-server-status.tsx +++ b/apps/web/src/components/mcp-server-status.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; @@ -17,6 +16,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { env } from "../env"; import { fetchCommandsFromApi, diff --git a/apps/web/src/components/princeton-connect-row.tsx b/apps/web/src/components/princeton-connect-row.tsx index dd69204..61d94ec 100644 --- a/apps/web/src/components/princeton-connect-row.tsx +++ b/apps/web/src/components/princeton-connect-row.tsx @@ -2,11 +2,11 @@ import { isClerkRuntimeError, isReverificationCancelledError, } from "@clerk/clerk-react/errors"; -import { useReverification, useUser } from "@clerk/tanstack-react-start"; import { GraduationCap, Mail } from "lucide-react"; import { motion } from "motion/react"; import { useCallback, useState } from "react"; import toast from "react-hot-toast"; +import { useReverification, useUser } from "@/lib/auth"; import type { McpServerEntry } from "../lib/mcp"; import { getPrincetonNetid } from "../lib/mcp"; import { RoseCurveSpinner } from "./rose-curve-spinner"; diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 7d85b12..d7f1a23 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -18,6 +18,10 @@ export const env = createEnv({ VITE_CONVEX_URL: z.string().url(), VITE_CLERK_PUBLISHABLE_KEY: z.string().min(1), VITE_FASTAPI_URL: z.string().url().optional(), + // LOCAL DEVELOPMENT ONLY: "true" bypasses Clerk entirely and signs you in + // as a fixed dev user. Pair with ENABLE_DEV_AUTH on the Convex deployment + // and the FastAPI gateway. Never set in production. + VITE_ENABLE_DEV_AUTH: z.enum(["true", "false"]).optional(), }, /** diff --git a/apps/web/src/hooks/use-mcp-health-check.ts b/apps/web/src/hooks/use-mcp-health-check.ts index ad3fff9..b9f31df 100644 --- a/apps/web/src/hooks/use-mcp-health-check.ts +++ b/apps/web/src/hooks/use-mcp-health-check.ts @@ -1,6 +1,6 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useAuth } from "@/lib/auth"; import type { HealthStatus } from "../components/mcp-server-status"; import { env } from "../env"; import type { McpAuthType } from "../lib/mcp"; diff --git a/apps/web/src/hooks/use-rewind.ts b/apps/web/src/hooks/use-rewind.ts index 7e25833..6727fe4 100644 --- a/apps/web/src/hooks/use-rewind.ts +++ b/apps/web/src/hooks/use-rewind.ts @@ -1,10 +1,10 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; import { useMutation } from "@tanstack/react-query"; import { useCallback } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { useChatStreamContext } from "../lib/chat-stream-context"; import { resetAgentSessionForRewind } from "../lib/rewind"; diff --git a/apps/web/src/hooks/use-workspace-credentials.ts b/apps/web/src/hooks/use-workspace-credentials.ts index c3e5dfb..bb82aa3 100644 --- a/apps/web/src/hooks/use-workspace-credentials.ts +++ b/apps/web/src/hooks/use-workspace-credentials.ts @@ -1,8 +1,8 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { useAuth } from "@/lib/auth"; import { env } from "../env"; const FASTAPI_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000"; diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..72d664d --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,63 @@ +import { + useAuth as clerkUseAuth, + useClerk as clerkUseClerk, + useReverification as clerkUseReverification, + useUser as clerkUseUser, +} from "@clerk/tanstack-react-start"; +import { env } from "../env"; + +/** + * Single switch for the loginless local-dev mode. When `VITE_ENABLE_DEV_AUTH` is + * "true", the app skips Clerk entirely and runs as a fixed dev user — pair it + * with `ENABLE_DEV_AUTH` on the Convex deployment and the FastAPI gateway. + * + * Off by default, and `DEV_AUTH` is a build-time constant, so the exported hooks + * below resolve to the REAL Clerk hooks in every normal build — this module is a + * pure pass-through unless a developer explicitly opts in. Import the auth hooks + * from here (`@/lib/auth`) instead of `@clerk/tanstack-react-start` so the + * bypass reaches every call site. + */ +export const DEV_AUTH = env.VITE_ENABLE_DEV_AUTH === "true"; +export const DEV_USER_ID = "dev-user"; + +// Dev stubs — shapes mirror the subset of each Clerk hook's return the app reads. +const devUseAuth = (() => ({ + isLoaded: true, + isSignedIn: true, + userId: DEV_USER_ID, + sessionId: "dev-session", + orgId: null, + orgRole: null, + getToken: async () => "dev-auth", + signOut: async () => {}, +})) as unknown as typeof clerkUseAuth; + +const devUseUser = (() => ({ + isLoaded: true, + isSignedIn: true, + user: { + id: DEV_USER_ID, + fullName: "Dev User", + firstName: "Dev", + lastName: "User", + primaryEmailAddress: { emailAddress: "dev@localhost" }, + imageUrl: "", + }, +})) as unknown as typeof clerkUseUser; + +const devUseClerk = (() => ({ + signOut: async () => {}, + openSignIn: () => {}, + openUserProfile: () => {}, +})) as unknown as typeof clerkUseClerk; + +// useReverification wraps an action that may need step-up auth; in dev, pass through. +const devUseReverification = ((fn: T) => + fn) as unknown as typeof clerkUseReverification; + +export const useAuth = DEV_AUTH ? devUseAuth : clerkUseAuth; +export const useUser = DEV_AUTH ? devUseUser : clerkUseUser; +export const useClerk = DEV_AUTH ? devUseClerk : clerkUseClerk; +export const useReverification = DEV_AUTH + ? devUseReverification + : clerkUseReverification; diff --git a/apps/web/src/lib/chat-stream-context.tsx b/apps/web/src/lib/chat-stream-context.tsx index a19a22a..99ad295 100644 --- a/apps/web/src/lib/chat-stream-context.tsx +++ b/apps/web/src/lib/chat-stream-context.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { useQueryClient } from "@tanstack/react-query"; import { createContext, @@ -12,6 +11,7 @@ import { useState, } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { type AgentPermissionRequest, type AgentQuestionAction, diff --git a/apps/web/src/lib/use-agent-catalog.ts b/apps/web/src/lib/use-agent-catalog.ts index 3df7aef..7d81204 100644 --- a/apps/web/src/lib/use-agent-catalog.ts +++ b/apps/web/src/lib/use-agent-catalog.ts @@ -1,5 +1,5 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/lib/auth"; import { env } from "../env"; import type { AgentMode } from "./agent-mode"; diff --git a/apps/web/src/lib/use-agent-session-config.ts b/apps/web/src/lib/use-agent-session-config.ts index 4119ba2..34d195e 100644 --- a/apps/web/src/lib/use-agent-session-config.ts +++ b/apps/web/src/lib/use-agent-session-config.ts @@ -1,6 +1,6 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; +import { useAuth } from "@/lib/auth"; import { type AgentCommand, type AgentConfigOption, diff --git a/apps/web/src/lib/use-chat-stream.ts b/apps/web/src/lib/use-chat-stream.ts index 494848a..8d2cd9d 100644 --- a/apps/web/src/lib/use-chat-stream.ts +++ b/apps/web/src/lib/use-chat-stream.ts @@ -1,5 +1,5 @@ -import { useAuth, useUser } from "@clerk/tanstack-react-start"; import { useCallback, useRef, useState } from "react"; +import { useAuth, useUser } from "@/lib/auth"; import { env } from "../env"; import { type AgentMode, diff --git a/apps/web/src/lib/use-follow-stream.ts b/apps/web/src/lib/use-follow-stream.ts index 6a63590..6c7fcb1 100644 --- a/apps/web/src/lib/use-follow-stream.ts +++ b/apps/web/src/lib/use-follow-stream.ts @@ -1,5 +1,5 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "@/lib/auth"; import { env } from "../env"; import { agentStatusLabel } from "./agent-mode"; import type { ConvoStreamState, StreamPart } from "./use-chat-stream"; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index f152e59..6bd474c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { ClerkProvider, useAuth } from "@clerk/tanstack-react-start"; +import { ClerkProvider } from "@clerk/tanstack-react-start"; import { auth } from "@clerk/tanstack-react-start/server"; import type { ConvexQueryClient } from "@convex-dev/react-query"; import { TanStackDevtools } from "@tanstack/react-devtools"; @@ -15,8 +15,8 @@ import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { createServerFn } from "@tanstack/react-start"; import type { ConvexReactClient } from "convex/react"; import { ConvexProviderWithClerk } from "convex/react-clerk"; - import { Toaster } from "react-hot-toast"; +import { DEV_AUTH, DEV_USER_ID, useAuth } from "@/lib/auth"; import { CommandPalette } from "../components/command-palette/command-palette"; import { GlobalCommands } from "../components/command-palette/commands/global-commands"; import { TooltipProvider } from "../components/ui/tooltip"; @@ -27,6 +27,11 @@ import appCss from "../styles.css?url"; const CHROMELESS_ROUTES = ["/", "/sign-in", "/onboarding"]; const fetchClerkAuth = createServerFn({ method: "GET" }).handler(async () => { + // LOCAL DEV: skip Clerk's server auth entirely and hand back a fixed dev + // identity. The Convex deployment + FastAPI must also run with ENABLE_DEV_AUTH. + if (DEV_AUTH) { + return { userId: DEV_USER_ID, token: "dev-auth" }; + } const { userId, getToken } = await auth(); const token = await getToken({ template: "convex" }); @@ -90,74 +95,77 @@ function RootComponent() { const isChromeless = CHROMELESS_ROUTES.includes(pathname) || pathname.startsWith("/share/"); - return ( - - - - - - {isChromeless ? ( - - ) : ( -
-
- -
+ const tree = ( + + + + + {isChromeless ? ( + + ) : ( +
+
+
- )} - - - - - - - - +
+ )} + + + +
+
+
+
); + + // LOCAL DEV (VITE_ENABLE_DEV_AUTH): run without Clerk at all — the dev + // useAuth stub feeds ConvexProviderWithClerk a fixed identity. + if (DEV_AUTH) return tree; + + return {tree}; } +const CLERK_APPEARANCE = { + variables: { + borderRadius: "0rem", + fontFamily: "'Geist', -apple-system, BlinkMacSystemFont, sans-serif", + colorBackground: "var(--background)", + colorText: "var(--foreground)", + colorPrimary: "var(--primary)", + colorTextOnPrimaryBackground: "var(--primary-foreground)", + colorDanger: "var(--destructive)", + colorInputBackground: "var(--background)", + colorInputText: "var(--foreground)", + }, + elements: { + modalContent: "rounded-none shadow-none border border-border", + modalCloseButton: "rounded-none", + card: "shadow-none rounded-none border-0", + navbar: "border-r border-border", + navbarButton: "rounded-none text-xs font-medium", + pageScrollBox: "p-6", + formButtonPrimary: + "rounded-none text-xs font-medium h-9 shadow-none bg-foreground text-background hover:bg-foreground/90", + formButtonReset: + "rounded-none text-xs font-medium h-9 shadow-none border border-border", + formFieldInput: "rounded-none border-border text-sm shadow-none", + formFieldLabel: "text-xs font-medium", + badge: "rounded-none text-[10px]", + dividerLine: "bg-border", + dividerText: "text-muted-foreground text-xs", + footerActionLink: "text-foreground hover:text-foreground/80", + socialButtonsBlockButton: "rounded-none border-border text-xs font-medium", + }, +}; + function RootDocument({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/web/src/routes/app.tsx b/apps/web/src/routes/app.tsx index fd53828..345da8b 100644 --- a/apps/web/src/routes/app.tsx +++ b/apps/web/src/routes/app.tsx @@ -7,13 +7,14 @@ // server-side userId present → fast server redirect; absent → render a // client gate that waits for Clerk's client state (authoritative) and // routes from there. -import { useAuth } from "@clerk/tanstack-react-start"; + import { convexQuery } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { useConvexAuth } from "convex/react"; import { useEffect } from "react"; +import { DEV_AUTH, useAuth } from "@/lib/auth"; import { RoseCurveSpinner } from "../components/rose-curve-spinner"; export const Route = createFileRoute("/app")({ @@ -49,9 +50,12 @@ function AuthGate() { const search = Route.useSearch(); const { isLoaded, isSignedIn } = useAuth(); const { isAuthenticated: convexReady } = useConvexAuth(); + // In dev-auth the fake token never completes the Convex auth handshake, but + // the backend resolves every call to the dev user anyway — so don't gate the + // settings fetch on convexReady. const { data: settings } = useQuery({ ...convexQuery(api.userSettings.get, {}), - enabled: convexReady, + enabled: convexReady || DEV_AUTH, }); useEffect(() => { diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 7f9f75a..52f8ec5 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; @@ -26,6 +25,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { countActiveAgents } from "../../components/chat/background-agents-panel"; import { ChatInput } from "../../components/chat/chat-input"; import { ChatMessages } from "../../components/chat/chat-messages"; diff --git a/apps/web/src/routes/harnesses/$harnessId.tsx b/apps/web/src/routes/harnesses/$harnessId.tsx index c474210..a208105 100644 --- a/apps/web/src/routes/harnesses/$harnessId.tsx +++ b/apps/web/src/routes/harnesses/$harnessId.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexAction, @@ -29,6 +28,7 @@ import { import { motion } from "motion/react"; import { type KeyboardEvent, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { AgentLoopPicker } from "../../components/agent-loop-picker"; import { OAuthConnectRow } from "../../components/mcp-oauth-connect-row"; import { PresetMcpGrid } from "../../components/preset-mcp-grid"; diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index bcce570..3a28198 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowRight, @@ -24,6 +23,7 @@ import { useTransform, } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useAuth } from "@/lib/auth"; import { ClaudeLogo, diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx index 16113c7..72697fa 100644 --- a/apps/web/src/routes/onboarding.tsx +++ b/apps/web/src/routes/onboarding.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexAction, @@ -32,6 +31,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { AgentLoopPicker } from "../components/agent-loop-picker"; import { HarnessCreationAssistant } from "../components/harness-creation-assistant"; import { HarnessMark } from "../components/harness-mark"; diff --git a/apps/web/src/routes/sandboxes/$sandboxId.tsx b/apps/web/src/routes/sandboxes/$sandboxId.tsx index dd5477a..a55e31f 100644 --- a/apps/web/src/routes/sandboxes/$sandboxId.tsx +++ b/apps/web/src/routes/sandboxes/$sandboxId.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import type { @@ -28,6 +27,7 @@ import { import { motion } from "motion/react"; import { useMemo, useState } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { SandboxPanel } from "../../components/sandbox/sandbox-panel"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; diff --git a/apps/web/src/routes/sandboxes/create_sandbox.tsx b/apps/web/src/routes/sandboxes/create_sandbox.tsx index 2af0a94..1e22cbd 100644 --- a/apps/web/src/routes/sandboxes/create_sandbox.tsx +++ b/apps/web/src/routes/sandboxes/create_sandbox.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/tanstack-react-start"; import { convexQuery } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import { useQuery } from "@tanstack/react-query"; @@ -6,6 +5,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { AlertCircle, ArrowLeft, Loader2 } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; +import { useAuth } from "@/lib/auth"; import { SandboxConfigForm } from "../../components/sandbox/sandbox-config-form"; import { Button } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; diff --git a/apps/web/src/routes/share/$token.tsx b/apps/web/src/routes/share/$token.tsx index 41d9a7f..a785696 100644 --- a/apps/web/src/routes/share/$token.tsx +++ b/apps/web/src/routes/share/$token.tsx @@ -1,4 +1,3 @@ -import { useAuth, useUser } from "@clerk/tanstack-react-start"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; @@ -9,6 +8,7 @@ import { GitFork, Loader2, Lock, PanelRight, Send, Square } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { type ComponentProps, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; +import { useAuth, useUser } from "@/lib/auth"; import { AgentPermissionCard } from "../../components/agent-permission-card"; import { AgentQuestionCard } from "../../components/agent-question-card"; import { countActiveAgents } from "../../components/chat/background-agents-panel"; diff --git a/apps/web/src/routes/sign-in.tsx b/apps/web/src/routes/sign-in.tsx index 19a0b78..16a8a57 100644 --- a/apps/web/src/routes/sign-in.tsx +++ b/apps/web/src/routes/sign-in.tsx @@ -1,7 +1,8 @@ -import { SignIn, useAuth } from "@clerk/tanstack-react-start"; +import { SignIn } from "@clerk/tanstack-react-start"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { motion } from "motion/react"; import { useEffect } from "react"; +import { DEV_AUTH, useAuth } from "@/lib/auth"; import { HarnessMark } from "../components/harness-mark"; @@ -33,6 +34,10 @@ function SignInPage() { } }, [isLoaded, isSignedIn, navigate, destination]); + // Dev-auth runs without a Clerk provider, so never mount ; the + // effect above redirects (dev is always signed in). + if (DEV_AUTH) return null; + return (
diff --git a/apps/web/src/routes/sign-up.tsx b/apps/web/src/routes/sign-up.tsx index 72bc0c3..5000535 100644 --- a/apps/web/src/routes/sign-up.tsx +++ b/apps/web/src/routes/sign-up.tsx @@ -1,7 +1,8 @@ -import { SignUp, useAuth } from "@clerk/tanstack-react-start"; +import { SignUp } from "@clerk/tanstack-react-start"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { motion } from "motion/react"; import { useEffect } from "react"; +import { DEV_AUTH, useAuth } from "@/lib/auth"; import { HarnessMark } from "../components/harness-mark"; @@ -21,6 +22,10 @@ function SignUpPage() { } }, [isLoaded, isSignedIn, navigate]); + // Dev-auth runs without a Clerk provider, so never mount ; the + // effect above redirects (dev is always signed in). + if (DEV_AUTH) return null; + return (
diff --git a/packages/convex-backend/.env.example b/packages/convex-backend/.env.example index f58f873..4d243e3 100644 --- a/packages/convex-backend/.env.example +++ b/packages/convex-backend/.env.example @@ -8,4 +8,9 @@ CONVEX_SITE_URL=https://my-project.convex.site # ON THE CONVEX DASHBOARD -CLERK_JWT_ISSUER_DOMAIN=https://your-clerk-domain.clerk.accounts.dev \ No newline at end of file +CLERK_JWT_ISSUER_DOMAIN=https://your-clerk-domain.clerk.accounts.dev +# LOCAL DEVELOPMENT ONLY — make every Convex function resolve to a fixed dev user +# instead of verifying a Clerk JWT. Set it ON THE DEPLOYMENT, not in this file: +# npx convex env set ENABLE_DEV_AUTH true +# Pair with the web app's VITE_ENABLE_DEV_AUTH and FastAPI's ENABLE_DEV_AUTH. +# NEVER enable in production. diff --git a/packages/convex-backend/convex/_generated/api.d.ts b/packages/convex-backend/convex/_generated/api.d.ts index c085f0c..25e9a2d 100644 --- a/packages/convex-backend/convex/_generated/api.d.ts +++ b/packages/convex-backend/convex/_generated/api.d.ts @@ -30,32 +30,32 @@ import type * as workspaceCredentials from "../workspaceCredentials.js"; import type * as workspaces from "../workspaces.js"; import type { - ApiFromModules, - FilterApi, - FunctionReference, + ApiFromModules, + FilterApi, + FunctionReference, } from "convex/server"; declare const fullApi: ApiFromModules<{ - agentCredentials: typeof agentCredentials; - agentUsage: typeof agentUsage; - commands: typeof commands; - compactions: typeof compactions; - conversations: typeof conversations; - files: typeof files; - harnessConfigRatings: typeof harnessConfigRatings; - harnesses: typeof harnesses; - mcpOAuthTokens: typeof mcpOAuthTokens; - messageParts: typeof messageParts; - messages: typeof messages; - migrations: typeof migrations; - sandboxes: typeof sandboxes; - seed: typeof seed; - shares: typeof shares; - skills: typeof skills; - usage: typeof usage; - userSettings: typeof userSettings; - workspaceCredentials: typeof workspaceCredentials; - workspaces: typeof workspaces; + agentCredentials: typeof agentCredentials; + agentUsage: typeof agentUsage; + commands: typeof commands; + compactions: typeof compactions; + conversations: typeof conversations; + files: typeof files; + harnessConfigRatings: typeof harnessConfigRatings; + harnesses: typeof harnesses; + mcpOAuthTokens: typeof mcpOAuthTokens; + messageParts: typeof messageParts; + messages: typeof messages; + migrations: typeof migrations; + sandboxes: typeof sandboxes; + seed: typeof seed; + shares: typeof shares; + skills: typeof skills; + usage: typeof usage; + userSettings: typeof userSettings; + workspaceCredentials: typeof workspaceCredentials; + workspaces: typeof workspaces; }>; /** @@ -67,8 +67,8 @@ declare const fullApi: ApiFromModules<{ * ``` */ export declare const api: FilterApi< - typeof fullApi, - FunctionReference + typeof fullApi, + FunctionReference >; /** @@ -80,8 +80,8 @@ export declare const api: FilterApi< * ``` */ export declare const internal: FilterApi< - typeof fullApi, - FunctionReference + typeof fullApi, + FunctionReference >; export declare const components: {}; diff --git a/packages/convex-backend/convex/_generated/dataModel.d.ts b/packages/convex-backend/convex/_generated/dataModel.d.ts index f97fd19..a12cf50 100644 --- a/packages/convex-backend/convex/_generated/dataModel.d.ts +++ b/packages/convex-backend/convex/_generated/dataModel.d.ts @@ -9,10 +9,10 @@ */ import type { - DataModelFromSchemaDefinition, - DocumentByName, - TableNamesInDataModel, - SystemTableNames, + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, } from "convex/server"; import type { GenericId } from "convex/values"; import schema from "../schema.js"; @@ -28,8 +28,8 @@ export type TableNames = TableNamesInDataModel; * @typeParam TableName - A string literal type of the table name (like "users"). */ export type Doc = DocumentByName< - DataModel, - TableName + DataModel, + TableName >; /** @@ -46,7 +46,7 @@ export type Doc = DocumentByName< * @typeParam TableName - A string literal type of the table name (like "users"). */ export type Id = - GenericId; + GenericId; /** * A type describing your Convex data model. diff --git a/packages/convex-backend/convex/_generated/server.d.ts b/packages/convex-backend/convex/_generated/server.d.ts index bec05e6..db61944 100644 --- a/packages/convex-backend/convex/_generated/server.d.ts +++ b/packages/convex-backend/convex/_generated/server.d.ts @@ -9,15 +9,15 @@ */ import { - ActionBuilder, - HttpActionBuilder, - MutationBuilder, - QueryBuilder, - GenericActionCtx, - GenericMutationCtx, - GenericQueryCtx, - GenericDatabaseReader, - GenericDatabaseWriter, + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, } from "convex/server"; import type { DataModel } from "./dataModel.js"; diff --git a/packages/convex-backend/convex/_generated/server.js b/packages/convex-backend/convex/_generated/server.js index bf3d25a..0523cb1 100644 --- a/packages/convex-backend/convex/_generated/server.js +++ b/packages/convex-backend/convex/_generated/server.js @@ -9,13 +9,13 @@ */ import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric, + actionGeneric, + httpActionGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, + mutationGeneric, + queryGeneric, } from "convex/server"; /** diff --git a/packages/convex-backend/convex/agentCredentials.ts b/packages/convex-backend/convex/agentCredentials.ts index 1c99167..7e7ba7c 100644 --- a/packages/convex-backend/convex/agentCredentials.ts +++ b/packages/convex-backend/convex/agentCredentials.ts @@ -5,6 +5,7 @@ import { mutation, query, } from "./_generated/server"; +import { getIdentity } from "./authDev"; /** * Per-user credentials for external ACP agents (Codex CLI, Claude Code, @@ -27,7 +28,7 @@ const KIND = v.union( /** All credentials for the current user (frontend — metadata, no secrets). */ export const listMine = query({ handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const rows = await ctx.db .query("agentCredentials") @@ -162,7 +163,7 @@ export const listForUser = internalQuery({ export const remove = mutation({ args: { credentialId: v.id("agentCredentials") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const row = await ctx.db.get(args.credentialId); if (!row || row.userId !== identity.subject) { diff --git a/packages/convex-backend/convex/agentUsage.test.ts b/packages/convex-backend/convex/agentUsage.test.ts index 6c2334c..6936582 100644 --- a/packages/convex-backend/convex/agentUsage.test.ts +++ b/packages/convex-backend/convex/agentUsage.test.ts @@ -30,7 +30,11 @@ function currentWeekKey(): string { } const thisWeek = currentWeekKey(); -async function seedCredential(raw: ReturnType["raw"], userId: string, label: string) { +async function seedCredential( + raw: ReturnType["raw"], + userId: string, + label: string, +) { return await raw.mutation(internal.agentCredentials.create, { userId, agent: "claude-code", @@ -40,7 +44,10 @@ async function seedCredential(raw: ReturnType["raw"], userId: stri }); } -async function seedConversation(raw: ReturnType["raw"], userId: string) { +async function seedConversation( + raw: ReturnType["raw"], + userId: string, +) { return await raw.run(async (ctx) => ctx.db.insert("conversations", { title: "t", @@ -76,7 +83,12 @@ describe("agentUsage.getMyAgentUsage", () => { await raw.mutation( internal.agentUsage.record, - turn({ agentCredentialId: credId, conversationId: convoId, model: "sonnet", turnKey: "s1:1" }), + turn({ + agentCredentialId: credId, + conversationId: convoId, + model: "sonnet", + turnKey: "s1:1", + }), ); await raw.mutation( internal.agentUsage.record, @@ -107,7 +119,11 @@ describe("agentUsage.getMyAgentUsage", () => { const { raw, asUser } = makeT(); const credId = await seedCredential(raw, "u-a", "work"); const convoId = await seedConversation(raw, "u-a"); - const t = turn({ agentCredentialId: credId, conversationId: convoId, turnKey: "s1:1" }); + const t = turn({ + agentCredentialId: credId, + conversationId: convoId, + turnKey: "s1:1", + }); await raw.mutation(internal.agentUsage.record, t); await raw.mutation(internal.agentUsage.record, t); // duplicate fire const rows = await asUser("u-a").query(api.agentUsage.getMyAgentUsage, {}); @@ -194,11 +210,21 @@ describe("agentUsage.getMyAgentUsage", () => { const convoId = await seedConversation(raw, "u-a"); await raw.mutation( internal.agentUsage.record, - turn({ agentCredentialId: work, conversationId: convoId, costUsd: 0.1, turnKey: "s1:1" }), + turn({ + agentCredentialId: work, + conversationId: convoId, + costUsd: 0.1, + turnKey: "s1:1", + }), ); await raw.mutation( internal.agentUsage.record, - turn({ agentCredentialId: personal, conversationId: convoId, costUsd: 0.02, turnKey: "s2:1" }), + turn({ + agentCredentialId: personal, + conversationId: convoId, + costUsd: 0.02, + turnKey: "s2:1", + }), ); const rows = await asUser("u-a").query(api.agentUsage.getMyAgentUsage, {}); @@ -209,7 +235,9 @@ describe("agentUsage.getMyAgentUsage", () => { expect(rows[1].label).toBe("personal"); // a different user sees nothing - expect(await asUser("u-b").query(api.agentUsage.getMyAgentUsage, {})).toEqual([]); + expect( + await asUser("u-b").query(api.agentUsage.getMyAgentUsage, {}), + ).toEqual([]); }); it("includes connected credentials with zero usage", async () => { @@ -243,7 +271,11 @@ describe("agentUsage.getMyAgentUsage", () => { // a current turn (default day=today, week=thisWeek) await raw.mutation( internal.agentUsage.record, - turn({ agentCredentialId: credId, conversationId: convoId, turnKey: "new:1" }), + turn({ + agentCredentialId: credId, + conversationId: convoId, + turnKey: "new:1", + }), ); const rows = await asUser("u-a").query(api.agentUsage.getMyAgentUsage, {}); expect(rows[0].turns).toBe(2); @@ -268,7 +300,11 @@ describe("agentUsage.getMyAgentUsage", () => { const convoId = await seedConversation(raw, "u-a"); await raw.mutation( internal.agentUsage.record, - turn({ agentCredentialId: claude, conversationId: convoId, turnKey: "c:1" }), + turn({ + agentCredentialId: claude, + conversationId: convoId, + turnKey: "c:1", + }), ); await raw.mutation( internal.agentUsage.record, @@ -290,7 +326,11 @@ describe("agentUsage.getMyAgentUsage", () => { const convoId = await seedConversation(raw, "u-a"); await raw.mutation( internal.agentUsage.record, - turn({ agentCredentialId: credId, conversationId: convoId, turnKey: "s1:1" }), + turn({ + agentCredentialId: credId, + conversationId: convoId, + turnKey: "s1:1", + }), ); const rows = await asUser("u-a").query(api.agentUsage.getMyAgentUsage, {}); expect(rows[0].perModel.map((m) => m.model)).toEqual(["unknown"]); @@ -313,7 +353,12 @@ describe("agentUsage.getMyAgentUsage", () => { ); await raw.mutation( internal.agentUsage.record, - turn({ agentCredentialId: credId, conversationId: convoId, model: "sonnet", turnKey: "s1:2" }), + turn({ + agentCredentialId: credId, + conversationId: convoId, + model: "sonnet", + turnKey: "s1:2", + }), ); const rows = await asUser("u-a").query(api.agentUsage.getMyAgentUsage, {}); expect(rows[0].lastModel).toBe("sonnet"); @@ -361,7 +406,11 @@ describe("agentUsage.getMyAgentUsage", () => { const convoId = await seedConversation(raw, "u-a"); await raw.mutation( internal.agentUsage.record, - turn({ agentCredentialId: credId, conversationId: convoId, turnKey: "s1:1" }), + turn({ + agentCredentialId: credId, + conversationId: convoId, + turnKey: "s1:1", + }), ); expect( await asUser("u-a").query(api.agentUsage.getMyAgentUsage, {}), @@ -370,7 +419,9 @@ describe("agentUsage.getMyAgentUsage", () => { credentialId: credId, }); // the ledger rows are gone, not just hidden - expect(await asUser("u-a").query(api.agentUsage.getMyAgentUsage, {})).toEqual([]); + expect( + await asUser("u-a").query(api.agentUsage.getMyAgentUsage, {}), + ).toEqual([]); const remaining = await raw.run(async (ctx) => ctx.db.query("agentUsageLedger").collect(), ); diff --git a/packages/convex-backend/convex/agentUsage.ts b/packages/convex-backend/convex/agentUsage.ts index 9945726..0b20c16 100644 --- a/packages/convex-backend/convex/agentUsage.ts +++ b/packages/convex-backend/convex/agentUsage.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { internalMutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; /** * Per-credential usage for ACP agents (Claude Code, Codex, Cursor). @@ -142,7 +143,7 @@ const MAX_LEDGER_ROWS = 8000; export const getMyAgentUsage = query({ args: {}, handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const userId = identity.subject; @@ -221,7 +222,11 @@ export const getMyAgentUsage = query({ // ledger's last seen value for back-compat. rateLimit: cred.lastRateLimit ?? acc.rateLimit, perModel: [...acc.perModel.entries()] - .map(([model, m]) => ({ model, tokens: m.tokens, costUsd: m.costUsd })) + .map(([model, m]) => ({ + model, + tokens: m.tokens, + costUsd: m.costUsd, + })) .sort((a, b) => b.costUsd - a.costUsd), }; }); diff --git a/packages/convex-backend/convex/auth.config.ts b/packages/convex-backend/convex/auth.config.ts index a1d8c5b..2f5dc83 100644 --- a/packages/convex-backend/convex/auth.config.ts +++ b/packages/convex-backend/convex/auth.config.ts @@ -1,14 +1,14 @@ -import { AuthConfig } from "convex/server"; +import type { AuthConfig } from "convex/server"; export default { - providers: [ - { - // Replace with your own Clerk Issuer URL from your "convex" JWT template - // or with `process.env.CLERK_JWT_ISSUER_DOMAIN` - // and configure CLERK_JWT_ISSUER_DOMAIN on the Convex Dashboard - // See https://docs.convex.dev/auth/clerk#configuring-dev-and-prod-instances - domain: process.env.CLERK_JWT_ISSUER_DOMAIN!, - applicationID: "convex", - }, - ] -} satisfies AuthConfig; \ No newline at end of file + providers: [ + { + // Replace with your own Clerk Issuer URL from your "convex" JWT template + // or with `process.env.CLERK_JWT_ISSUER_DOMAIN` + // and configure CLERK_JWT_ISSUER_DOMAIN on the Convex Dashboard + // See https://docs.convex.dev/auth/clerk#configuring-dev-and-prod-instances + domain: process.env.CLERK_JWT_ISSUER_DOMAIN!, + applicationID: "convex", + }, + ], +} satisfies AuthConfig; diff --git a/packages/convex-backend/convex/authDev.test.ts b/packages/convex-backend/convex/authDev.test.ts new file mode 100644 index 0000000..176619c --- /dev/null +++ b/packages/convex-backend/convex/authDev.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { DEV_USER_ID, getIdentity } from "./authDev"; + +const realIdentity = { + subject: "real-user", + issuer: "clerk", + tokenIdentifier: "clerk|real-user", +}; + +// Minimal ctx stub — getIdentity only touches ctx.auth.getUserIdentity(). +function makeCtx(onCall?: () => void) { + return { + auth: { + getUserIdentity: async () => { + onCall?.(); + return realIdentity; + }, + }, + } as unknown as Parameters[0]; +} + +afterEach(() => { + process.env.ENABLE_DEV_AUTH = undefined; +}); + +describe("getIdentity", () => { + it("delegates to ctx.auth.getUserIdentity() when ENABLE_DEV_AUTH is unset", async () => { + expect(await getIdentity(makeCtx())).toEqual(realIdentity); + }); + + it("delegates when ENABLE_DEV_AUTH is set to anything other than 'true'", async () => { + process.env.ENABLE_DEV_AUTH = "false"; + expect(await getIdentity(makeCtx())).toEqual(realIdentity); + }); + + it("returns a fixed dev identity when ENABLE_DEV_AUTH=true", async () => { + process.env.ENABLE_DEV_AUTH = "true"; + const id = await getIdentity(makeCtx()); + expect(id?.subject).toBe(DEV_USER_ID); + expect(id?.issuer).toBe("dev-auth"); + }); + + it("does not consult ctx.auth in dev mode", async () => { + process.env.ENABLE_DEV_AUTH = "true"; + let called = false; + const id = await getIdentity( + makeCtx(() => { + called = true; + }), + ); + expect(called).toBe(false); + expect(id?.subject).toBe(DEV_USER_ID); + }); +}); diff --git a/packages/convex-backend/convex/authDev.ts b/packages/convex-backend/convex/authDev.ts new file mode 100644 index 0000000..f601aa3 --- /dev/null +++ b/packages/convex-backend/convex/authDev.ts @@ -0,0 +1,33 @@ +import type { Auth, UserIdentity } from "convex/server"; + +/** + * Fixed identity used when ENABLE_DEV_AUTH is turned on for a deployment. Keep + * this in sync with the FastAPI gateway's dev user (`app.auth.DEV_USER_ID`) so a + * locally-run stack agrees on who "you" are. + */ +export const DEV_USER_ID = "dev-user"; + +/** + * Resolve the caller's identity. + * + * In production this is EXACTLY `ctx.auth.getUserIdentity()`. When the + * deployment env var `ENABLE_DEV_AUTH` is `"true"` (LOCAL DEVELOPMENT ONLY — set + * it with `npx convex env set ENABLE_DEV_AUTH true`), it instead returns a fixed + * fake identity so the whole app runs without Clerk for screenshots/local work. + * + * The flag is off by default, so every call site behaves identically to the raw + * `ctx.auth.getUserIdentity()` in any real deployment — this is a pure + * pass-through unless a developer explicitly opts in. + */ +export async function getIdentity(ctx: { + auth: Auth; +}): Promise { + if (process.env.ENABLE_DEV_AUTH === "true") { + return { + subject: DEV_USER_ID, + issuer: "dev-auth", + tokenIdentifier: `dev-auth|${DEV_USER_ID}`, + }; + } + return await ctx.auth.getUserIdentity(); +} diff --git a/packages/convex-backend/convex/commands.ts b/packages/convex-backend/convex/commands.ts index 632eb71..cfdce38 100644 --- a/packages/convex-backend/convex/commands.ts +++ b/packages/convex-backend/convex/commands.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; /** * Upsert commands for the authenticated user: insert new ones, update the @@ -20,7 +21,7 @@ export const upsert = mutation({ ), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const userId = identity.subject; @@ -63,7 +64,7 @@ export const upsert = mutation({ export const getByIds = query({ args: { ids: v.array(v.id("commands")) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); const userId = identity?.subject; const results = await Promise.all(args.ids.map((id) => ctx.db.get(id))); return results.filter( diff --git a/packages/convex-backend/convex/compactions.ts b/packages/convex-backend/convex/compactions.ts index ee0466b..cf47997 100644 --- a/packages/convex-backend/convex/compactions.ts +++ b/packages/convex-backend/convex/compactions.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { internalMutation, mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; import { resolveConversationRole } from "./shares"; // Clamp the stored summary so a pathological capture can't blow the Convex @@ -58,7 +59,7 @@ export const record = internalMutation({ export const listByConversation = query({ args: { conversationId: v.id("conversations") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const convo = await ctx.db.get(args.conversationId); if (!convo || convo.userId !== identity.subject) return []; @@ -86,7 +87,7 @@ export const cloneFromCompaction = mutation({ harnessId: v.optional(v.id("harnesses")), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const compaction = await ctx.db.get(args.compactionId); diff --git a/packages/convex-backend/convex/conversations.ts b/packages/convex-backend/convex/conversations.ts index 5f283cf..c7fa2ce 100644 --- a/packages/convex-backend/convex/conversations.ts +++ b/packages/convex-backend/convex/conversations.ts @@ -2,6 +2,7 @@ import { paginationOptsValidator } from "convex/server"; import { v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; import { contentFromParts } from "./messageParts"; import { getOrCreateDefaultWorkspace } from "./workspaces"; @@ -92,7 +93,7 @@ async function tolerateBackfill(run: () => Promise): Promise { export const list = query({ args: { workspaceId: v.optional(v.id("workspaces")) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; if (args.workspaceId) { const workspace = await ctx.db.get(args.workspaceId); @@ -149,7 +150,7 @@ export const list = query({ export const get = query({ args: { id: v.id("conversations") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return null; const convo = await ctx.db.get(args.id); if (!convo || convo.userId !== identity.subject) return null; @@ -166,7 +167,7 @@ export const get = query({ export const ensureInWorkspace = mutation({ args: { conversationId: v.id("conversations") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const convo = await ctx.db.get(args.conversationId); if (!convo || convo.userId !== identity.subject) { @@ -202,7 +203,7 @@ export const create = mutation({ workspaceId: v.optional(v.id("workspaces")), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const harness = await ctx.db.get(args.harnessId); @@ -232,7 +233,7 @@ export const create = mutation({ export const updateTitle = mutation({ args: { id: v.id("conversations"), title: v.string() }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const convo = await ctx.db.get(args.id); if (!convo || convo.userId !== identity.subject) { @@ -246,7 +247,7 @@ export const updateTitle = mutation({ export const setPinned = mutation({ args: { id: v.id("conversations"), pinned: v.boolean() }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const convo = await ctx.db.get(args.id); if (!convo || convo.userId !== identity.subject) { @@ -271,7 +272,7 @@ export const moveToWorkspace = mutation({ workspaceId: v.optional(v.id("workspaces")), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const convo = await ctx.db.get(args.id); if (!convo || convo.userId !== identity.subject) { @@ -316,7 +317,7 @@ export const fork = mutation({ truncateLastPartCount: v.optional(v.number()), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const convo = await ctx.db.get(args.conversationId); @@ -434,7 +435,7 @@ export const editForkAndSend = mutation({ harnessId: v.optional(v.id("harnesses")), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const convo = await ctx.db.get(args.conversationId); @@ -528,7 +529,7 @@ export const editForkAndSend = mutation({ export const remove = mutation({ args: { id: v.id("conversations") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const convo = await ctx.db.get(args.id); if (!convo || convo.userId !== identity.subject) { @@ -552,7 +553,7 @@ export const searchTitles = query({ paginationOpts: paginationOptsValidator, }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return { page: [], isDone: true, continueCursor: "" }; if (args.workspaceId) { const workspace = await ctx.db.get(args.workspaceId); @@ -582,7 +583,7 @@ export const searchContent = query({ paginationOpts: paginationOptsValidator, }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return { page: [], isDone: true, continueCursor: "" }; if (args.workspaceId) { const workspace = await ctx.db.get(args.workspaceId); @@ -668,7 +669,7 @@ export const searchContent = query({ export const searchTitlesCount = query({ args: { query: v.string(), workspaceId: v.optional(v.id("workspaces")) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return 0; if (args.workspaceId) { const workspace = await ctx.db.get(args.workspaceId); @@ -693,7 +694,7 @@ export const searchTitlesCount = query({ export const searchContentCount = query({ args: { query: v.string(), workspaceId: v.optional(v.id("workspaces")) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return 0; if (args.workspaceId) { const workspace = await ctx.db.get(args.workspaceId); diff --git a/packages/convex-backend/convex/env.d.ts b/packages/convex-backend/convex/env.d.ts index c9a189d..bbfcf33 100644 --- a/packages/convex-backend/convex/env.d.ts +++ b/packages/convex-backend/convex/env.d.ts @@ -1,3 +1,3 @@ declare const process: { - env: Record; + env: Record; }; diff --git a/packages/convex-backend/convex/files.ts b/packages/convex-backend/convex/files.ts index 30c40b6..92b97cd 100644 --- a/packages/convex-backend/convex/files.ts +++ b/packages/convex-backend/convex/files.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; /** * Generates a short-lived presigned upload URL for Convex file storage. @@ -8,7 +9,7 @@ import { mutation, query } from "./_generated/server"; */ export const generateUploadUrl = mutation({ handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); return await ctx.storage.generateUploadUrl(); }, @@ -22,7 +23,7 @@ export const generateUploadUrl = mutation({ export const getFileUrl = query({ args: { storageId: v.id("_storage") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return null; return await ctx.storage.getUrl(args.storageId); }, diff --git a/packages/convex-backend/convex/harnessConfigRatings.ts b/packages/convex-backend/convex/harnessConfigRatings.ts index b0c512f..af5a226 100644 --- a/packages/convex-backend/convex/harnessConfigRatings.ts +++ b/packages/convex-backend/convex/harnessConfigRatings.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; export const rate = mutation({ args: { @@ -17,7 +18,7 @@ export const rate = mutation({ ), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); return await ctx.db.insert("harnessConfigRatings", { userId: identity.subject, @@ -34,7 +35,7 @@ export const listByRating = query({ rating: v.union(v.literal("up"), v.literal("down")), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; return await ctx.db .query("harnessConfigRatings") diff --git a/packages/convex-backend/convex/harnesses.test.ts b/packages/convex-backend/convex/harnesses.test.ts index 2c4bb85..4d49d87 100644 --- a/packages/convex-backend/convex/harnesses.test.ts +++ b/packages/convex-backend/convex/harnesses.test.ts @@ -9,17 +9,18 @@ function makeT() { const raw = convexTest(schema, modules); return { raw, - asUser: (uid: string) => - raw.withIdentity({ subject: uid, issuer: "test" }), + asUser: (uid: string) => raw.withIdentity({ subject: uid, issuer: "test" }), }; } -const baseHarness = (overrides: Partial<{ - name: string; - model: string; - status: "started" | "stopped" | "draft"; - systemPrompt?: string; -}> = {}) => ({ +const baseHarness = ( + overrides: Partial<{ + name: string; + model: string; + status: "started" | "stopped" | "draft"; + systemPrompt?: string; + }> = {}, +) => ({ name: "test", model: "claude-opus-4.7", status: "stopped" as const, @@ -203,7 +204,11 @@ describe("harnesses agent/credential integrity", () => { agent: "claude-code", agentCredentialId: credId, }); - await a.mutation(api.harnesses.update, { id, agent: "claude-code", name: "renamed" }); + await a.mutation(api.harnesses.update, { + id, + agent: "claude-code", + name: "renamed", + }); const row = await a.query(api.harnesses.get, { id }); expect(row?.agentCredentialId).toBe(credId); }); diff --git a/packages/convex-backend/convex/harnesses.ts b/packages/convex-backend/convex/harnesses.ts index c372f3e..90c602f 100644 --- a/packages/convex-backend/convex/harnesses.ts +++ b/packages/convex-backend/convex/harnesses.ts @@ -1,12 +1,16 @@ import { v } from "convex/values"; import { internalQuery, mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; import { resolveConversationRole } from "./shares"; /** Match apps/web `SYSTEM_PROMPT_MAX_LENGTH` and FastAPI `HarnessConfig.system_prompt`. */ const SYSTEM_PROMPT_MAX_CHARS = 4000; function assertSystemPromptLength(systemPrompt: string | undefined) { - if (systemPrompt !== undefined && systemPrompt.length > SYSTEM_PROMPT_MAX_CHARS) { + if ( + systemPrompt !== undefined && + systemPrompt.length > SYSTEM_PROMPT_MAX_CHARS + ) { throw new Error( `System prompt must be at most ${SYSTEM_PROMPT_MAX_CHARS} characters`, ); @@ -15,14 +19,19 @@ function assertSystemPromptLength(systemPrompt: string | undefined) { const mcpServerValidator = v.object({ name: v.string(), url: v.string(), - authType: v.union(v.literal("none"), v.literal("bearer"), v.literal("oauth"), v.literal("tiger_junction")), + authType: v.union( + v.literal("none"), + v.literal("bearer"), + v.literal("oauth"), + v.literal("tiger_junction"), + ), authToken: v.optional(v.string()), commandIds: v.optional(v.array(v.id("commands"))), }); export const list = query({ handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; return await ctx.db .query("harnesses") @@ -34,7 +43,7 @@ export const list = query({ export const get = query({ args: { id: v.id("harnesses") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return null; const harness = await ctx.db.get(args.id); if (!harness || harness.userId !== identity.subject) return null; @@ -142,7 +151,7 @@ export const create = mutation({ ), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); assertSystemPromptLength(args.systemPrompt); return await ctx.db.insert("harnesses", { @@ -159,14 +168,12 @@ export const update = mutation({ name: v.optional(v.string()), model: v.optional(v.string()), status: v.optional( - v.union( - v.literal("started"), - v.literal("stopped"), - v.literal("draft"), - ), + v.union(v.literal("started"), v.literal("stopped"), v.literal("draft")), ), mcpServers: v.optional(v.array(mcpServerValidator)), - skills: v.optional(v.array(v.object({ name: v.string(), description: v.string() }))), + skills: v.optional( + v.array(v.object({ name: v.string(), description: v.string() })), + ), systemPrompt: v.optional(v.string()), suggestedPrompts: v.optional(v.array(v.string())), agent: v.optional(v.string()), @@ -193,7 +200,7 @@ export const update = mutation({ ), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const harness = await ctx.db.get(args.id); if (!harness || harness.userId !== identity.subject) { @@ -223,7 +230,7 @@ export const update = mutation({ export const duplicate = mutation({ args: { id: v.id("harnesses") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const harness = await ctx.db.get(args.id); if (!harness || harness.userId !== identity.subject) { @@ -253,7 +260,7 @@ export const duplicate = mutation({ export const remove = mutation({ args: { id: v.id("harnesses") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const harness = await ctx.db.get(args.id); if (!harness || harness.userId !== identity.subject) { diff --git a/packages/convex-backend/convex/mcpOAuthTokens.ts b/packages/convex-backend/convex/mcpOAuthTokens.ts index 34210ac..e11f3e0 100644 --- a/packages/convex-backend/convex/mcpOAuthTokens.ts +++ b/packages/convex-backend/convex/mcpOAuthTokens.ts @@ -5,6 +5,7 @@ import { mutation, query, } from "./_generated/server"; +import { getIdentity } from "./authDev"; /** * Get OAuth token status for a specific MCP server (frontend use). @@ -13,7 +14,7 @@ import { export const getStatus = query({ args: { mcpServerUrl: v.string() }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return null; const token = await ctx.db .query("mcpOAuthTokens") @@ -38,7 +39,7 @@ export const getStatus = query({ */ export const listStatuses = query({ handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const tokens = await ctx.db .query("mcpOAuthTokens") @@ -116,7 +117,7 @@ export const getTokens = internalQuery({ export const deleteTokens = mutation({ args: { mcpServerUrl: v.string() }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const token = await ctx.db .query("mcpOAuthTokens") diff --git a/packages/convex-backend/convex/messages.ts b/packages/convex-backend/convex/messages.ts index 30079f4..8398def 100644 --- a/packages/convex-backend/convex/messages.ts +++ b/packages/convex-backend/convex/messages.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { internalMutation, mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; import { contentFromParts } from "./messageParts"; import { authorizeConversationWrite, @@ -10,7 +11,7 @@ import { export const list = query({ args: { conversationId: v.id("conversations") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const convo = await ctx.db.get(args.conversationId); if (!convo || convo.userId !== identity.subject) return []; @@ -41,7 +42,7 @@ export const send = mutation({ ), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); if (args.content.length > MAX_MESSAGE_CONTENT_CHARS) { throw new Error("Message too long"); @@ -81,7 +82,7 @@ export const remove = mutation({ // conversation (owners pass nothing). Authorization is the active grant. args: { id: v.id("messages"), token: v.optional(v.string()) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const message = await ctx.db.get(args.id); @@ -109,7 +110,7 @@ export const removeFrom = mutation({ // from a shared conversation (owners pass nothing). args: { id: v.id("messages"), token: v.optional(v.string()) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const message = await ctx.db.get(args.id); @@ -150,7 +151,7 @@ export const removeFrom = mutation({ export const removeAfter = mutation({ args: { id: v.id("messages"), token: v.optional(v.string()) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const message = await ctx.db.get(args.id); @@ -200,7 +201,7 @@ export const truncatePart = mutation({ token: v.optional(v.string()), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const message = await ctx.db.get(args.id); @@ -300,7 +301,7 @@ export const saveInterruptedMessage = mutation({ model: v.optional(v.string()), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const convo = await authorizeConversationWrite( ctx, diff --git a/packages/convex-backend/convex/sandboxes.ts b/packages/convex-backend/convex/sandboxes.ts index deffd26..66e9c26 100644 --- a/packages/convex-backend/convex/sandboxes.ts +++ b/packages/convex-backend/convex/sandboxes.ts @@ -5,6 +5,7 @@ import { mutation, query, } from "./_generated/server"; +import { getIdentity } from "./authDev"; // Per-user sandbox cap. Mirrored on the frontend in // apps/web/src/lib/sandbox.ts and in the FastAPI gateway @@ -22,7 +23,7 @@ const sandboxLimitError = () => export const list = query({ handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; return await ctx.db .query("sandboxes") @@ -34,7 +35,7 @@ export const list = query({ export const get = query({ args: { id: v.id("sandboxes") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return null; const sandbox = await ctx.db.get(args.id); if (!sandbox || sandbox.userId !== identity.subject) return null; @@ -45,7 +46,7 @@ export const get = query({ export const getByHarness = query({ args: { harnessId: v.id("harnesses") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return null; const sandboxes = await ctx.db .query("sandboxes") @@ -82,7 +83,7 @@ export const create = mutation({ gitRepo: v.optional(v.string()), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const existing = await ctx.db .query("sandboxes") @@ -130,7 +131,7 @@ export const update = mutation({ lastAccessedAt: v.optional(v.number()), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const sandbox = await ctx.db.get(args.id); if (!sandbox || sandbox.userId !== identity.subject) { @@ -147,7 +148,7 @@ export const update = mutation({ export const remove = mutation({ args: { id: v.id("sandboxes") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const sandbox = await ctx.db.get(args.id); if (!sandbox || sandbox.userId !== identity.subject) { diff --git a/packages/convex-backend/convex/shares.ts b/packages/convex-backend/convex/shares.ts index 8c942a3..def0199 100644 --- a/packages/convex-backend/convex/shares.ts +++ b/packages/convex-backend/convex/shares.ts @@ -2,6 +2,7 @@ import { v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { internalQuery, mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; import { getOrCreateDefaultWorkspace } from "./workspaces"; /** @@ -54,7 +55,7 @@ async function assertOwnedConversation( ctx: MutationCtx | QueryCtx, conversationId: Id<"conversations">, ): Promise> { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const convo = await ctx.db.get(conversationId); if (!convo || convo.userId !== identity.subject) { @@ -241,7 +242,7 @@ export const sendShared = mutation({ ), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); if (args.content.length > MAX_MESSAGE_CONTENT_CHARS) { throw new Error("Message too long"); @@ -425,7 +426,7 @@ export const listShareGrants = query({ handler: async (ctx, args) => { // Returns [] (not throw) for non-owners so the manage panel simply // shows nothing rather than erroring. - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const convo = await ctx.db.get(args.conversationId); if (!convo || convo.userId !== identity.subject) return []; @@ -463,7 +464,7 @@ export const getSharedConversation = query({ // Reading identity here is fine — it's optional (null for anonymous), // so this stays a public query. viewerIsOwner lets the UI send the // owner to their own editable chat instead of the read-only view. - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); // Surface the agent loop the conversation's current harness runs on so an // editor's composer picks the default-loop vs ACP-agent send path. Just // the agent id (a non-secret label) — no harness internals leak. @@ -485,8 +486,7 @@ export const getSharedConversation = query({ sandboxId = harness.daytonaSandboxId; } } - const viewerIsOwner = - identity != null && convo.userId === identity.subject; + const viewerIsOwner = identity != null && convo.userId === identity.subject; return { conversationId: convo._id, title: convo.title, @@ -572,7 +572,7 @@ export const forkSharedConversation = mutation({ workspaceId: v.optional(v.id("workspaces")), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const grant = await grantForToken(ctx, args.token); @@ -625,9 +625,7 @@ export const forkSharedConversation = mutation({ // to the SHARED page (they don't own the original — navigating to it in // /chat would show an empty owner-gated conversation). Only public-link // grants carry a token; per-user grants don't. - ...(grant.publicToken - ? { forkedFromShareToken: grant.publicToken } - : {}), + ...(grant.publicToken ? { forkedFromShareToken: grant.publicToken } : {}), }); for (const msg of messagesToCopy) { diff --git a/packages/convex-backend/convex/skills.ts b/packages/convex-backend/convex/skills.ts index 441a0bb..430c06f 100644 --- a/packages/convex-backend/convex/skills.ts +++ b/packages/convex-backend/convex/skills.ts @@ -1,11 +1,12 @@ import { v } from "convex/values"; +import { internal } from "./_generated/api"; import { action, internalMutation, internalQuery, query, } from "./_generated/server"; -import { internal } from "./_generated/api"; +import { getIdentity } from "./authDev"; // ── skillDetails (full SKILL.md content, cached) ──────────────────── @@ -108,7 +109,6 @@ export const searchSkillsIndex = query({ }, }); - /** Upsert a batch of skills discovered from skills.sh search API */ export const upsertSkillsIndexBatch = internalMutation({ args: { @@ -254,7 +254,10 @@ async function fetchSkillMdFromRepo( const normalizedId = normalizeSkillId(skillId); const branches = ["main", "master"]; - const idsToTry = [skillId, ...(normalizedId !== skillId ? [normalizedId] : [])]; + const idsToTry = [ + skillId, + ...(normalizedId !== skillId ? [normalizedId] : []), + ]; // 1. Try direct paths (both branches) for (const branch of branches) { @@ -312,8 +315,7 @@ async function fetchSkillMdFromRepo( const dir = p.split("/").slice(-2, -1)[0] ?? ""; const normDir = dir.toLowerCase(); return ( - normalizedId.includes(normDir) || - normDir.includes(normalizedId) + normalizedId.includes(normDir) || normDir.includes(normalizedId) ); }); @@ -326,9 +328,7 @@ async function fetchSkillMdFromRepo( } // Check for a shallow SKILL.md (e.g. skill/SKILL.md at repo root) - const rootSkillMd = skillFiles.find( - (p) => p.split("/").length <= 2, - ); + const rootSkillMd = skillFiles.find((p) => p.split("/").length <= 2); if (rootSkillMd) { const mdResp = await fetch( `${ghRaw}/${source}/${branch}/${rootSkillMd}`, @@ -393,7 +393,7 @@ async function fetchSkillMd( export const ensureSkillDetails = action({ args: { names: v.array(v.string()) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); // Batch-fetch cached details and sources in two queries instead of 2N @@ -425,7 +425,7 @@ export const ensureSkillDetails = action({ // skillId is the portion of fullId after the source prefix skillId = name.startsWith(source + "/") ? name.slice(source.length + 1) - : name.split("/").pop() ?? name; + : (name.split("/").pop() ?? name); } else { const parts = name.split("/"); skillId = parts.pop() ?? name; @@ -536,8 +536,7 @@ export const discoverSkillsFromSearch = action({ ), }, handler: async (ctx, args): Promise => { - - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return 0; // Check which ones we already have const fullIds = args.skills.map((s) => s.fullId); @@ -547,9 +546,7 @@ export const discoverSkillsFromSearch = action({ ); const existingSet = new Set(existingIds); - const newSkills = args.skills.filter( - (s) => !existingSet.has(s.fullId), - ); + const newSkills = args.skills.filter((s) => !existingSet.has(s.fullId)); if (newSkills.length === 0) return 0; // Just insert index entries with empty descriptions — SKILL.md fetching @@ -572,13 +569,15 @@ export const discoverSkillsFromSearch = action({ export const searchForCreationAssistant = query({ args: { query: v.string() }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const [byName, byDesc] = await Promise.all([ ctx.db .query("skillsIndex") - .withSearchIndex("search_skills", (q) => q.search("skillId", args.query)) + .withSearchIndex("search_skills", (q) => + q.search("skillId", args.query), + ) .take(20), ctx.db .query("skillsIndex") diff --git a/packages/convex-backend/convex/tsconfig.json b/packages/convex-backend/convex/tsconfig.json index 265be46..0309a25 100644 --- a/packages/convex-backend/convex/tsconfig.json +++ b/packages/convex-backend/convex/tsconfig.json @@ -1,32 +1,24 @@ { - /* This TypeScript project config describes the environment that - * Convex functions run in and is used to typecheck them. - * You can modify it, but some settings are required to use Convex. - */ - "compilerOptions": { - /* These settings are not required by Convex and can be modified. */ - "allowJs": true, - "strict": true, - "moduleResolution": "Bundler", - "jsx": "react-jsx", - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - /* These compiler options are required by Convex */ - "target": "ESNext", - "lib": [ - "ES2021", - "dom" - ], - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "isolatedModules": true, - "noEmit": true - }, - "include": [ - "./**/*" - ], - "exclude": [ - "./_generated", - "./**/*.test.ts" - ] -} \ No newline at end of file + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings are required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2021", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated", "./**/*.test.ts"] +} diff --git a/packages/convex-backend/convex/usage.test.ts b/packages/convex-backend/convex/usage.test.ts index 1a80a27..8b2e490 100644 --- a/packages/convex-backend/convex/usage.test.ts +++ b/packages/convex-backend/convex/usage.test.ts @@ -9,8 +9,7 @@ function makeT() { const raw = convexTest(schema, modules); return { raw, - asUser: (uid: string) => - raw.withIdentity({ subject: uid, issuer: "test" }), + asUser: (uid: string) => raw.withIdentity({ subject: uid, issuer: "test" }), }; } @@ -63,15 +62,19 @@ describe("usage.checkBudget", () => { }); describe("usage.recordUsage", () => { - async function recordFrom(userId: string, conversationId: string, overrides: Partial<{ - cost: number; - totalTokens: number; - model: string; - day: string; - week: string; - harnessId?: string; - harnessName?: string; - }> = {}) { + async function recordFrom( + userId: string, + conversationId: string, + overrides: Partial<{ + cost: number; + totalTokens: number; + model: string; + day: string; + week: string; + harnessId?: string; + harnessName?: string; + }> = {}, + ) { return { userId, conversationId, @@ -111,7 +114,10 @@ describe("usage.recordUsage", () => { ]); const daily = budgets.find((b) => b.periodType === "daily")!; expect(daily.totalCostUsed).toBe(0.5); - expect(daily.perModelUsage[0]).toMatchObject({ model: "m", tokensUsed: 30 }); + expect(daily.perModelUsage[0]).toMatchObject({ + model: "m", + tokensUsed: 30, + }); }); }); @@ -262,10 +268,7 @@ describe("usage.adminSetLimits (internal)", () => { const daily = await ctx.db .query("usageBudgets") .withIndex("by_user_period", (q) => - q - .eq("userId", "u-a") - .eq("periodType", "daily") - .eq("period", today), + q.eq("userId", "u-a").eq("periodType", "daily").eq("period", today), ) .unique(); expect(daily!.costLimit).toBe(10); diff --git a/packages/convex-backend/convex/usage.ts b/packages/convex-backend/convex/usage.ts index de3b651..c2babce 100644 --- a/packages/convex-backend/convex/usage.ts +++ b/packages/convex-backend/convex/usage.ts @@ -1,6 +1,7 @@ import { v } from "convex/values"; -import { internalMutation, internalQuery, query } from "./_generated/server"; import type { MutationCtx } from "./_generated/server"; +import { internalMutation, internalQuery, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; // Single source of truth for default cost limits (USD). // Per-user overrides are stored in usageBudgets.costLimit via adminSetLimits. @@ -43,10 +44,13 @@ export const checkBudget = internalQuery({ const weeklyCostUsed = weekly?.totalCostUsed ?? 0; const weeklyCostLimit = weekly?.costLimit ?? DEFAULT_WEEKLY_COST_LIMIT; - const dailyPct = dailyCostLimit > 0 ? (dailyCostUsed / dailyCostLimit) * 100 : 0; - const weeklyPct = weeklyCostLimit > 0 ? (weeklyCostUsed / weeklyCostLimit) * 100 : 0; + const dailyPct = + dailyCostLimit > 0 ? (dailyCostUsed / dailyCostLimit) * 100 : 0; + const weeklyPct = + weeklyCostLimit > 0 ? (weeklyCostUsed / weeklyCostLimit) * 100 : 0; - const allowed = dailyCostUsed < dailyCostLimit && weeklyCostUsed < weeklyCostLimit; + const allowed = + dailyCostUsed < dailyCostLimit && weeklyCostUsed < weeklyCostLimit; return { allowed, @@ -182,7 +186,8 @@ async function upsertBudget( if (harnessIdx >= 0) { perHarnessUsage[harnessIdx] = { ...perHarnessUsage[harnessIdx], - tokensUsed: perHarnessUsage[harnessIdx].tokensUsed + params.totalTokens, + tokensUsed: + perHarnessUsage[harnessIdx].tokensUsed + params.totalTokens, costUsed: perHarnessUsage[harnessIdx].costUsed + params.cost, }; } else { @@ -205,7 +210,11 @@ async function upsertBudget( } else { // Create new budget document const perModelUsage = [ - { model: params.model, tokensUsed: params.totalTokens, costUsed: params.cost }, + { + model: params.model, + tokensUsed: params.totalTokens, + costUsed: params.cost, + }, ]; const perHarnessUsage = params.harnessId ? [ @@ -239,7 +248,7 @@ async function upsertBudget( export const getUserUsage = query({ args: {}, handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return null; const userId = identity.subject; @@ -266,8 +275,14 @@ export const getUserUsage = query({ const dailyCostUsed = daily?.totalCostUsed ?? 0; const weeklyCostUsed = weekly?.totalCostUsed ?? 0; - const dailyPct = dailyCostLimit > 0 ? Math.min((dailyCostUsed / dailyCostLimit) * 100, 100) : 0; - const weeklyPct = weeklyCostLimit > 0 ? Math.min((weeklyCostUsed / weeklyCostLimit) * 100, 100) : 0; + const dailyPct = + dailyCostLimit > 0 + ? Math.min((dailyCostUsed / dailyCostLimit) * 100, 100) + : 0; + const weeklyPct = + weeklyCostLimit > 0 + ? Math.min((weeklyCostUsed / weeklyCostLimit) * 100, 100) + : 0; // Per-model percentages (relative to total usage, not limit) const totalCost = daily?.totalCostUsed ?? 0; @@ -316,7 +331,7 @@ export const getUserUsage = query({ export const getConversationUsage = query({ args: { conversationId: v.id("conversations") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return null; const convo = await ctx.db.get(args.conversationId); @@ -366,7 +381,10 @@ export const adminSetLimits = internalMutation({ .unique(); if (daily) { - await ctx.db.patch(daily._id, { costLimit: args.dailyCostLimit, updatedAt: Date.now() }); + await ctx.db.patch(daily._id, { + costLimit: args.dailyCostLimit, + updatedAt: Date.now(), + }); } else { await ctx.db.insert("usageBudgets", { userId: args.userId, @@ -394,7 +412,10 @@ export const adminSetLimits = internalMutation({ .unique(); if (weekly) { - await ctx.db.patch(weekly._id, { costLimit: args.weeklyCostLimit, updatedAt: Date.now() }); + await ctx.db.patch(weekly._id, { + costLimit: args.weeklyCostLimit, + updatedAt: Date.now(), + }); } else { await ctx.db.insert("usageBudgets", { userId: args.userId, @@ -461,9 +482,13 @@ function formatDay(date: Date): string { * invisible to TypeScript queries. Test at year boundaries if modifying. */ function formatWeek(date: Date): string { - const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const d = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + const weekNo = Math.ceil( + ((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7, + ); return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; } diff --git a/packages/convex-backend/convex/userSettings.test.ts b/packages/convex-backend/convex/userSettings.test.ts index 0161a57..e7a8930 100644 --- a/packages/convex-backend/convex/userSettings.test.ts +++ b/packages/convex-backend/convex/userSettings.test.ts @@ -9,8 +9,7 @@ function makeT() { const raw = convexTest(schema, modules); return { raw, - asUser: (uid: string) => - raw.withIdentity({ subject: uid, issuer: "test" }), + asUser: (uid: string) => raw.withIdentity({ subject: uid, issuer: "test" }), }; } @@ -111,7 +110,10 @@ describe("userSettings.chatConfigScope legacy fallback", () => { modelSelectorMode: "session", }); }); - const settings = await asUser("legacy-user").query(api.userSettings.get, {}); + const settings = await asUser("legacy-user").query( + api.userSettings.get, + {}, + ); expect(settings.chatConfigScope).toBe("session"); }); diff --git a/packages/convex-backend/convex/userSettings.ts b/packages/convex-backend/convex/userSettings.ts index 1cc0f46..c84ef7e 100644 --- a/packages/convex-backend/convex/userSettings.ts +++ b/packages/convex-backend/convex/userSettings.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; const DEFAULTS = { autoSwitchHarness: true, @@ -12,7 +13,7 @@ const DEFAULTS = { export const get = query({ handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return DEFAULTS; const settings = await ctx.db @@ -45,11 +46,7 @@ export const update = mutation({ args: { autoSwitchHarness: v.optional(v.boolean()), displayMode: v.optional( - v.union( - v.literal("zen"), - v.literal("standard"), - v.literal("developer"), - ), + v.union(v.literal("zen"), v.literal("standard"), v.literal("developer")), ), modelSelectorMode: v.optional( v.union(v.literal("session"), v.literal("harness")), @@ -63,7 +60,7 @@ export const update = mutation({ rewindSeams: v.optional(v.boolean()), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const existing = await ctx.db diff --git a/packages/convex-backend/convex/workspaceCredentials.ts b/packages/convex-backend/convex/workspaceCredentials.ts index 73caf29..b9cc5d3 100644 --- a/packages/convex-backend/convex/workspaceCredentials.ts +++ b/packages/convex-backend/convex/workspaceCredentials.ts @@ -5,6 +5,7 @@ import { mutation, query, } from "./_generated/server"; +import { getIdentity } from "./authDev"; /** * Per-user named env-var credentials, assignable to workspaces and injected as @@ -23,7 +24,7 @@ import { /** All of the current user's credentials (frontend — metadata, no secrets). */ export const listMine = query({ handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const rows = await ctx.db .query("workspaceCredentials") @@ -120,7 +121,8 @@ export const getForWorkspace = internalQuery({ .query("credentialAssignments") .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) .collect(); - const out: { credentialId: string; name: string; ciphertext: string }[] = []; + const out: { credentialId: string; name: string; ciphertext: string }[] = + []; for (const a of assignments) { if (a.userId !== args.userId) continue; const cred = await ctx.db.get(a.credentialId); @@ -139,7 +141,7 @@ export const getForWorkspace = internalQuery({ export const listForWorkspace = query({ args: { workspaceId: v.id("workspaces") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const workspace = await ctx.db.get(args.workspaceId); if (!workspace || workspace.userId !== identity.subject) return []; @@ -165,7 +167,7 @@ export const assign = mutation({ workspaceId: v.id("workspaces"), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const cred = await ctx.db.get(args.credentialId); if (!cred || cred.userId !== identity.subject) { @@ -200,7 +202,7 @@ export const unassign = mutation({ workspaceId: v.id("workspaces"), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const existing = await ctx.db .query("credentialAssignments") @@ -221,7 +223,7 @@ export const unassign = mutation({ export const remove = mutation({ args: { credentialId: v.id("workspaceCredentials") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const row = await ctx.db.get(args.credentialId); if (!row || row.userId !== identity.subject) { diff --git a/packages/convex-backend/convex/workspaces.ts b/packages/convex-backend/convex/workspaces.ts index 578fed4..ad77a0b 100644 --- a/packages/convex-backend/convex/workspaces.ts +++ b/packages/convex-backend/convex/workspaces.ts @@ -2,6 +2,7 @@ import { v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import type { MutationCtx } from "./_generated/server"; import { internalQuery, mutation, query } from "./_generated/server"; +import { getIdentity } from "./authDev"; async function assertOwnedWorkspace( ctx: MutationCtx, @@ -33,7 +34,7 @@ function harnessSandboxToAdopt( export const list = query({ handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return []; const all = await ctx.db @@ -62,7 +63,7 @@ export const list = query({ export const reorder = mutation({ args: { orderedIds: v.array(v.id("workspaces")) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); // Defensive bound — a real account has a handful of workspaces. if (args.orderedIds.length > 1000) { @@ -102,7 +103,7 @@ export const reorder = mutation({ export const get = query({ args: { id: v.id("workspaces") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) return null; const workspace = await ctx.db.get(args.id); @@ -166,7 +167,7 @@ export async function getOrCreateDefaultWorkspace( export const ensureDefault = mutation({ args: { harnessId: v.optional(v.id("harnesses")) }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); return await getOrCreateDefaultWorkspace( ctx, @@ -184,7 +185,7 @@ export const create = mutation({ color: v.optional(v.string()), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const [harness, sandbox] = await Promise.all([ @@ -240,7 +241,7 @@ export const update = mutation({ color: v.optional(v.string()), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); await assertOwnedWorkspace(ctx, args.id, identity.subject); @@ -300,7 +301,7 @@ export const update = mutation({ export const touch = mutation({ args: { id: v.id("workspaces") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); await assertOwnedWorkspace(ctx, args.id, identity.subject); @@ -331,7 +332,7 @@ export const resolveSandboxInternal = internalQuery({ export const remove = mutation({ args: { id: v.id("workspaces") }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); + const identity = await getIdentity(ctx); if (!identity) throw new Error("Unauthenticated"); const workspace = await assertOwnedWorkspace( diff --git a/packages/fastapi/.env.example b/packages/fastapi/.env.example index 8bd3c6a..9cccd41 100644 --- a/packages/fastapi/.env.example +++ b/packages/fastapi/.env.example @@ -6,4 +6,12 @@ FRONTEND_URL=http://localhost:3001 # Daytona Sandbox (get API key from https://app.daytona.io) DAYTONA_API_KEY= DAYTONA_API_URL=https://app.daytona.io/api -DAYTONA_TARGET=us \ No newline at end of file +DAYTONA_TARGET=us + +# Encrypts per-user agent credentials (AES-256-GCM). Required to connect agents. +AGENT_CREDENTIALS_KEY= + +# LOCAL DEVELOPMENT ONLY — skip Clerk JWT verification; treat every request as a +# fixed dev user. Pair with the web app's VITE_ENABLE_DEV_AUTH and Convex's +# ENABLE_DEV_AUTH. NEVER set this in production. +# ENABLE_DEV_AUTH=true diff --git a/packages/fastapi/app/auth.py b/packages/fastapi/app/auth.py index 501f2c1..ea74616 100644 --- a/packages/fastapi/app/auth.py +++ b/packages/fastapi/app/auth.py @@ -10,6 +10,10 @@ _jwks_cache: dict | None = None +# Fixed identity used when settings.enable_dev_auth is on (LOCAL DEV ONLY). Keep +# in sync with the Convex DEV_USER_ID (packages/convex-backend/convex/authDev.ts). +DEV_USER_ID = "dev-user" + async def _get_jwks(client: httpx.AsyncClient, issuer: str) -> dict: """Fetch and cache Clerk's JWKS keys.""" @@ -29,6 +33,9 @@ async def verify_token(request: Request) -> dict: Returns the decoded token payload (contains 'sub' as user ID). """ + if settings.enable_dev_auth: + return {"sub": DEV_USER_ID} + auth_header = request.headers.get("authorization", "") if not auth_header.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing authorization token") @@ -88,6 +95,8 @@ async def verify_token_optional(request: Request) -> dict | None: """Like verify_token but returns None instead of 401 when there's no (or an invalid) bearer token — for endpoints that ALSO accept anonymous callers authorized another way (e.g. a share token on the live-follow stream).""" + if settings.enable_dev_auth: + return {"sub": DEV_USER_ID} if not request.headers.get("authorization", "").startswith("Bearer "): return None try: diff --git a/packages/fastapi/app/config.py b/packages/fastapi/app/config.py index 5d3d627..6600739 100644 --- a/packages/fastapi/app/config.py +++ b/packages/fastapi/app/config.py @@ -31,6 +31,12 @@ class Settings(BaseSettings): # Clerk JWT verification — pinned issuer prevents attacker-controlled JWKS. clerk_issuer: str = "" + # LOCAL DEVELOPMENT ONLY (env ENABLE_DEV_AUTH=true): skip Clerk JWT + # verification and treat every request as a fixed dev user. NEVER enable in + # production. Mirrors the Convex ENABLE_DEV_AUTH flag; both resolve to + # DEV_USER_ID so a locally-run stack agrees on who you are. + enable_dev_auth: bool = False + # Redis Streams bus for live token fan-out to passive viewers (owner's other # tabs, sharees). OPTIONAL: when unset, turns stream only to the initiating # client exactly as before (no regression) — the /follow endpoint just has diff --git a/packages/fastapi/tests/test_auth.py b/packages/fastapi/tests/test_auth.py index 14298ba..c9c6f8b 100644 --- a/packages/fastapi/tests/test_auth.py +++ b/packages/fastapi/tests/test_auth.py @@ -198,6 +198,34 @@ async def test_wrong_issuer_rejected(rsa_keypair, monkeypatch): assert exc.value.status_code == 401 +async def test_dev_auth_bypass_returns_fixed_user(monkeypatch): + """ENABLE_DEV_AUTH: no token required, returns the fixed dev user, no JWKS call.""" + monkeypatch.setattr(auth_module.settings, "enable_dev_auth", True) + async with httpx.AsyncClient() as client: + req = _fake_request(None, client) # no Authorization header at all + payload = await auth_module.verify_token(req) + assert payload["sub"] == auth_module.DEV_USER_ID + + +async def test_dev_auth_bypass_optional_returns_fixed_user(monkeypatch): + monkeypatch.setattr(auth_module.settings, "enable_dev_auth", True) + async with httpx.AsyncClient() as client: + req = _fake_request(None, client) + payload = await auth_module.verify_token_optional(req) + assert payload is not None + assert payload["sub"] == auth_module.DEV_USER_ID + + +async def test_dev_auth_off_by_default_still_enforces(monkeypatch): + """With the flag off (default), a missing token is still a 401 — no regression.""" + monkeypatch.setattr(auth_module.settings, "enable_dev_auth", False) + async with httpx.AsyncClient() as client: + req = _fake_request(None, client) + with pytest.raises(HTTPException) as exc: + await auth_module.verify_token(req) + assert exc.value.status_code == 401 + + @respx.mock async def test_jwks_is_cached_across_calls(rsa_keypair, monkeypatch): private_pem, jwk = rsa_keypair diff --git a/scripts/dev-screenshots.py b/scripts/dev-screenshots.py new file mode 100644 index 0000000..c4160c9 --- /dev/null +++ b/scripts/dev-screenshots.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Capture product screenshots of a locally-running Harness with dev auth on. + +Prereqs: + 1. Run the app loginless (no Clerk): + - apps/web/.env.local: VITE_ENABLE_DEV_AUTH=true + - FastAPI .env: ENABLE_DEV_AUTH=true + - Convex deployment: npx convex env set ENABLE_DEV_AUTH true + Then `turbo dev` (web on :3000, FastAPI on :8000, convex dev running). + 2. Install Playwright + a browser: + pip install playwright && playwright install chromium + +Run: + python3 scripts/dev-screenshots.py + # options: + BASE_URL=http://localhost:3000 OUT=assets/screenshots python3 scripts/dev-screenshots.py + +Notes: + - Screenshots reflect whatever data the dev user has. Create a couple of + workspaces / chats first so the shots aren't empty. + - Routes that need an id (a specific chat, harness, sandbox) are best captured + by adding their paths to ROUTES below once you know the ids. +""" + +import os +import pathlib +import sys + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:3000") +OUT = pathlib.Path(os.environ.get("OUT", "assets/screenshots")) +VIEWPORT = {"width": 1440, "height": 900} + +# (filename, path, full_page) +ROUTES = [ + ("landing.png", "/", True), + ("workspaces.png", "/workspaces", False), + ("chat.png", "/chat", False), + ("harnesses.png", "/harnesses", False), + ("sandboxes.png", "/sandboxes", False), +] + + +def main() -> int: + try: + from playwright.sync_api import sync_playwright + except ImportError: + print("Playwright not installed. Run: pip install playwright && playwright install chromium") + return 1 + + OUT.mkdir(parents=True, exist_ok=True) + print(f"Capturing {len(ROUTES)} routes from {BASE_URL} -> {OUT}/") + + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport=VIEWPORT, device_scale_factor=2) + captured = 0 + for name, path, full_page in ROUTES: + url = f"{BASE_URL}{path}" + try: + page.goto(url, wait_until="networkidle", timeout=30_000) + # Let realtime data + animations settle. + page.wait_for_timeout(1500) + page.screenshot(path=str(OUT / name), full_page=full_page) + print(f" ✓ {name} ({url})") + captured += 1 + except Exception as e: # noqa: BLE001 - best-effort capture loop + print(f" ✗ {name} ({url}): {e}") + browser.close() + + print(f"Done — {captured}/{len(ROUTES)} captured in {OUT}/") + return 0 if captured else 1 + + +if __name__ == "__main__": + sys.exit(main()) From e179f3c714a594fa25b6f4416125955d9c9d37b4 Mon Sep 17 00:00:00 2001 From: DIodide Date: Sun, 21 Jun 2026 14:29:51 -0400 Subject: [PATCH 2/3] fix(dev-auth): gate Clerk server middleware + use a null token (verified live) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stood the stack up against a real Convex dev deployment with ENABLE_DEV_AUTH and fixed two gaps that only surface at runtime: - start.ts registered Clerk's requestMiddleware globally, which throws "no secret key provided" on every request in loginless mode → skip it in dev-auth. - The dev identity handed ConvexProviderWithClerk / the SSR http client a fake "dev-auth" token, which Convex rejects as a malformed JWT (InvalidAuthHeader). Return null instead — unauthenticated calls resolve to the dev user via the backend getIdentity bypass. With these, the app renders fully loginless (workspaces, chats, the agent transcript, share dialog, command palette) — confirmed by screenshots. --- apps/web/src/lib/auth.ts | 4 +++- apps/web/src/routes/__root.tsx | 6 ++++-- apps/web/src/start.ts | 7 ++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 72d664d..1d60d9f 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -28,7 +28,9 @@ const devUseAuth = (() => ({ sessionId: "dev-session", orgId: null, orgRole: null, - getToken: async () => "dev-auth", + // Return null, not a fake token: Convex/FastAPI bypass auth via ENABLE_DEV_AUTH, + // and a non-JWT token fails Convex's header parse. + getToken: async () => null, signOut: async () => {}, })) as unknown as typeof clerkUseAuth; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 6bd474c..84ec515 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,9 +28,11 @@ const CHROMELESS_ROUTES = ["/", "/sign-in", "/onboarding"]; const fetchClerkAuth = createServerFn({ method: "GET" }).handler(async () => { // LOCAL DEV: skip Clerk's server auth entirely and hand back a fixed dev - // identity. The Convex deployment + FastAPI must also run with ENABLE_DEV_AUTH. + // identity with NO token — a fake token fails Convex's JWT parse. The Convex + // deployment + FastAPI run with ENABLE_DEV_AUTH, so unauthenticated calls + // still resolve to the dev user. if (DEV_AUTH) { - return { userId: DEV_USER_ID, token: "dev-auth" }; + return { userId: DEV_USER_ID, token: null as string | null }; } const { userId, getToken } = await auth(); const token = await getToken({ template: "convex" }); diff --git a/apps/web/src/start.ts b/apps/web/src/start.ts index 6cc3439..d0ea1c2 100644 --- a/apps/web/src/start.ts +++ b/apps/web/src/start.ts @@ -1,9 +1,14 @@ import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; import { createStart } from "@tanstack/react-start"; +// Build-time constant (read directly, not via ./lib/auth which pulls client +// hooks) so loginless dev mode skips Clerk's server middleware — it throws +// "no secret key provided" on every request when Clerk isn't configured. +const DEV_AUTH = import.meta.env.VITE_ENABLE_DEV_AUTH === "true"; + // https://clerk.com/docs/tanstack-react-start/getting-started/quickstart export const startInstance = createStart(() => { return { - requestMiddleware: [clerkMiddleware()], + requestMiddleware: DEV_AUTH ? [] : [clerkMiddleware()], }; }); From 49016cf47ed5ef806e3938a5f1bd00f9ca394313 Mon Sep 17 00:00:00 2001 From: DIodide Date: Sun, 21 Jun 2026 17:28:02 -0400 Subject: [PATCH 3/3] fix(dev-auth): route sandbox-panel hooks through the auth wrapper The Files/Terminal/Git/Agents sandbox panels imported useAuth from @clerk/clerk-react directly (a different package than the rest of the app's @clerk/tanstack-react-start), so the earlier wrapper swap missed them. Opening the panel in loginless dev mode crashed with "useAuth can only be used within ". Point all 8 at @/lib/auth like every other hook call site. Found by driving a real agent session with dev-auth and opening the Agents panel. --- apps/web/src/components/sandbox-result.tsx | 2 +- apps/web/src/components/sandbox/command-input.tsx | 2 +- apps/web/src/components/sandbox/file-context-menu.tsx | 2 +- apps/web/src/components/sandbox/file-explorer.tsx | 2 +- apps/web/src/components/sandbox/file-search.tsx | 2 +- apps/web/src/components/sandbox/file-viewer.tsx | 2 +- apps/web/src/components/sandbox/git-panel.tsx | 2 +- apps/web/src/components/sandbox/terminal.tsx | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/sandbox-result.tsx b/apps/web/src/components/sandbox-result.tsx index 3a302cf..66f8add 100644 --- a/apps/web/src/components/sandbox-result.tsx +++ b/apps/web/src/components/sandbox-result.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/clerk-react"; import { AlertTriangle, Check, @@ -15,6 +14,7 @@ import { X, } from "lucide-react"; import { useCallback, useState } from "react"; +import { useAuth } from "@/lib/auth"; import { env } from "../env"; import { useSandboxPanel } from "../lib/sandbox-panel-context"; import { detectLanguage, useHighlighted } from "../lib/syntax-highlight"; diff --git a/apps/web/src/components/sandbox/command-input.tsx b/apps/web/src/components/sandbox/command-input.tsx index 7bd17da..8decd9f 100644 --- a/apps/web/src/components/sandbox/command-input.tsx +++ b/apps/web/src/components/sandbox/command-input.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/clerk-react"; import { AlertCircle, ChevronRight, TerminalSquare } from "lucide-react"; import { useCallback, @@ -8,6 +7,7 @@ import { useRef, useState, } from "react"; +import { useAuth } from "@/lib/auth"; import { type CommandResponse, createSandboxApi } from "../../lib/sandbox-api"; import { useSandboxPanel } from "../../lib/sandbox-panel-context"; import { cn } from "../../lib/utils"; diff --git a/apps/web/src/components/sandbox/file-context-menu.tsx b/apps/web/src/components/sandbox/file-context-menu.tsx index 9f98ef0..4422e0b 100644 --- a/apps/web/src/components/sandbox/file-context-menu.tsx +++ b/apps/web/src/components/sandbox/file-context-menu.tsx @@ -1,6 +1,6 @@ -import { useAuth } from "@clerk/clerk-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { useAuth } from "@/lib/auth"; import { createSandboxApi, type SandboxFile } from "../../lib/sandbox-api"; import { useSandboxPanel } from "../../lib/sandbox-panel-context"; import { cn } from "../../lib/utils"; diff --git a/apps/web/src/components/sandbox/file-explorer.tsx b/apps/web/src/components/sandbox/file-explorer.tsx index cfaa75d..e3c280f 100644 --- a/apps/web/src/components/sandbox/file-explorer.tsx +++ b/apps/web/src/components/sandbox/file-explorer.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/clerk-react"; import { ChevronRight, File, @@ -8,6 +7,7 @@ import { Search, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/lib/auth"; import { createSandboxApi, type SandboxFile } from "../../lib/sandbox-api"; import { useSandboxPanel } from "../../lib/sandbox-panel-context"; import { cn } from "../../lib/utils"; diff --git a/apps/web/src/components/sandbox/file-search.tsx b/apps/web/src/components/sandbox/file-search.tsx index a309f4b..e84278e 100644 --- a/apps/web/src/components/sandbox/file-search.tsx +++ b/apps/web/src/components/sandbox/file-search.tsx @@ -1,6 +1,6 @@ -import { useAuth } from "@clerk/clerk-react"; import { FileText, Search, X } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; +import { useAuth } from "@/lib/auth"; import { createSandboxApi, type SearchMatch } from "../../lib/sandbox-api"; import { useSandboxPanel } from "../../lib/sandbox-panel-context"; import { RoseCurveSpinner } from "../rose-curve-spinner"; diff --git a/apps/web/src/components/sandbox/file-viewer.tsx b/apps/web/src/components/sandbox/file-viewer.tsx index 28d3b5a..df732f3 100644 --- a/apps/web/src/components/sandbox/file-viewer.tsx +++ b/apps/web/src/components/sandbox/file-viewer.tsx @@ -1,6 +1,6 @@ -import { useAuth } from "@clerk/clerk-react"; import { Check, Copy, Download, Pencil, Save, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAuth } from "@/lib/auth"; import { createSandboxApi } from "../../lib/sandbox-api"; import { useSandboxPanel } from "../../lib/sandbox-panel-context"; import { detectLanguage, useHighlighted } from "../../lib/syntax-highlight"; diff --git a/apps/web/src/components/sandbox/git-panel.tsx b/apps/web/src/components/sandbox/git-panel.tsx index 9255c38..99ef6a7 100644 --- a/apps/web/src/components/sandbox/git-panel.tsx +++ b/apps/web/src/components/sandbox/git-panel.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/clerk-react"; import { Check, ChevronRight, @@ -9,6 +8,7 @@ import { RefreshCw, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/lib/auth"; import { createSandboxApi, type GitCommit, diff --git a/apps/web/src/components/sandbox/terminal.tsx b/apps/web/src/components/sandbox/terminal.tsx index 90230f1..0f9c463 100644 --- a/apps/web/src/components/sandbox/terminal.tsx +++ b/apps/web/src/components/sandbox/terminal.tsx @@ -1,4 +1,4 @@ -import { useAuth } from "@clerk/clerk-react"; +import { useAuth } from "@/lib/auth"; import "@xterm/xterm/css/xterm.css"; import { RotateCcw, TerminalSquare } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react";