From 18557352454e18d25fc37eeddc7bd8bf574c60b0 Mon Sep 17 00:00:00 2001 From: Miniputer Date: Mon, 27 Jul 2026 18:47:43 +0530 Subject: [PATCH] Auth flow refactor, project requests, and routing updates - Add server-side auth token loader (auth.server.ts) with SSR cookie forwarding - Add project-requests client for explicit/signal/existing issue dispatch - Refactor auth layout and app layout for SSR auth gating - Update auth-client and auth-provider for convex token flow - Extend project-issue primitives with request result schema - Wire projectIssues backend mutation for request handling - Update slice-one page and project-workspace hook - Adjust routes (remove unused), Caddy config, and env templates --- .env.example | 2 +- apps/web/react-router.config.ts | 4 +- .../components/slice-one/slice-one-page.tsx | 40 ++++++++---- apps/web/src/hooks/use-project-workspace.ts | 63 ++++++++++-------- apps/web/src/index.css | 13 ++++ apps/web/src/lib/auth.server.ts | 64 +++++++++++++++++++ apps/web/src/lib/projects/project-requests.ts | 62 ++++++++++++++++++ apps/web/src/root.tsx | 35 +++++++++- apps/web/src/routes.ts | 2 - apps/web/src/routes/app/layout.tsx | 33 +++------- apps/web/src/routes/auth/layout.tsx | 24 +++---- apps/web/src/routes/auth/login/page.tsx | 12 +++- deploy/zopu-runtime/.env.template | 5 +- deploy/zopu-runtime/caddy/Caddyfile | 14 +++- packages/agents/src/agents/project-manager.ts | 3 - packages/agents/src/project-request.ts | 13 ++++ packages/auth/src/web/auth-client.ts | 6 +- packages/auth/src/web/auth-provider.tsx | 14 +++- packages/backend/convex/auth.ts | 3 +- packages/backend/convex/projectIssues.ts | 12 ++++ packages/env/src/vite-env.d.ts | 2 +- packages/env/src/web.ts | 3 +- packages/primitives/src/project-issue.test.ts | 11 ++++ packages/primitives/src/project-issue.ts | 8 ++- 24 files changed, 340 insertions(+), 108 deletions(-) create mode 100644 apps/web/src/lib/auth.server.ts create mode 100644 apps/web/src/lib/projects/project-requests.ts diff --git a/.env.example b/.env.example index ca400ee..873e048 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ SITE_URL=http://localhost:5173 NATIVE_APP_URL=code:// # Browser and native public endpoints +VITE_AUTH_URL=http://localhost:5173 VITE_CONVEX_URL=https://example.convex.cloud -VITE_CONVEX_SITE_URL=https://example.convex.site # For phone testing, replace localhost with this machine's Tailscale IPv4 address. VITE_FLUE_URL=http://localhost:3583 EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud diff --git a/apps/web/react-router.config.ts b/apps/web/react-router.config.ts index 4539207..72df924 100644 --- a/apps/web/react-router.config.ts +++ b/apps/web/react-router.config.ts @@ -1,7 +1,7 @@ import type { Config } from "@react-router/dev/config"; export default { - // Desktop addons package static web assets; SSR output cannot be bundled - ssr: false, appDirectory: "src", + + ssr: true, } satisfies Config; diff --git a/apps/web/src/components/slice-one/slice-one-page.tsx b/apps/web/src/components/slice-one/slice-one-page.tsx index 4388e6c..8792fe0 100644 --- a/apps/web/src/components/slice-one/slice-one-page.tsx +++ b/apps/web/src/components/slice-one/slice-one-page.tsx @@ -90,6 +90,30 @@ const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => { ); }; +const ConversationLoading = () => ( + + + + + Loading conversation… + +); + +const ConversationEmptyState = () => ( +
+
+ +

What should move forward?

+

+ Describe an outcome or problem. Casual conversation stays conversation. +

+
+
+); + export const SliceOnePage = () => { const slice = useSliceOne(); const viewportStyle = useVisualViewportStyle(); @@ -233,19 +257,11 @@ export const SliceOnePage = () => { + {!slice.agent.historyReady && timeline.length === 0 ? ( + + ) : null} {slice.agent.historyReady && timeline.length === 0 ? ( -
-
- -

- What should move forward? -

-

- Describe an outcome or problem. Casual conversation stays - conversation. -

-
-
+ ) : null} {timeline.map((item) => { if (item.kind === "work") { diff --git a/apps/web/src/hooks/use-project-workspace.ts b/apps/web/src/hooks/use-project-workspace.ts index 8e0c7b1..0d172c5 100644 --- a/apps/web/src/hooks/use-project-workspace.ts +++ b/apps/web/src/hooks/use-project-workspace.ts @@ -1,19 +1,20 @@ +import { useConvexAccessToken } from "@code/auth/web"; import { api } from "@code/backend/convex/_generated/api"; import type { Id } from "@code/backend/convex/_generated/dataModel"; -import { useFlueClient } from "@flue/react"; -import { useAction, useMutation, useQuery } from "convex/react"; +import { useAction, useQuery } from "convex/react"; import { useState } from "react"; import { buildProjectLoopView, summarizeProjectIssues, } from "@/lib/projects/project-evidence"; +import { requestProjectIssue } from "@/lib/projects/project-requests"; const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error); export const useProjectWorkspace = () => { - const flueClient = useFlueClient(); + const resolveAccessToken = useConvexAccessToken(); const projects = useQuery(api.projects.list); const [selectedProjectId, setSelectedProjectId] = useState | null>(null); @@ -25,10 +26,7 @@ export const useProjectWorkspace = () => { const [pendingAction, setPendingAction] = useState(null); const [error, setError] = useState(null); const importPublicGit = useAction(api.projects.importPublicGit); - const createIssue = useMutation(api.projectIssues.create); - const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal); - const beginIssue = useMutation(api.projectIssues.begin); - const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed); + const activeProjectId = selectedProjectId ?? (projects?.[0]?.id as unknown as Id<"projects">) ?? @@ -92,12 +90,20 @@ export const useProjectWorkspace = () => { setPendingAction("issue"); setError(null); try { - const issueId = await createIssue({ - body: nextBody, - projectId: activeProjectId, - title: nextTitle, + const accessToken = await resolveAccessToken(); + if (!accessToken) { + throw new Error("Authentication required"); + } + const outcome = await requestProjectIssue({ + accessToken, + request: { + body: nextBody, + kind: "explicit", + projectId: activeProjectId, + title: nextTitle, + }, }); - setSelectedIssueId(issueId); + setSelectedIssueId(outcome.issueId as Id<"projectIssues">); setIssueTitle(""); setIssueBody(""); } catch (caughtError) { @@ -111,8 +117,15 @@ export const useProjectWorkspace = () => { setPendingAction(`signal:${signalId}`); setError(null); try { - const outcome = await createIssueFromSignal({ signalId }); - setSelectedIssueId(outcome.issueId); + const accessToken = await resolveAccessToken(); + if (!accessToken) { + throw new Error("Authentication required"); + } + const outcome = await requestProjectIssue({ + accessToken, + request: { kind: "signal", signalId }, + }); + setSelectedIssueId(outcome.issueId as Id<"projectIssues">); } catch (caughtError) { setError(errorMessage(caughtError)); } finally { @@ -120,23 +133,21 @@ export const useProjectWorkspace = () => { } }; - const startIssue = async ( - issueId: Id<"projectIssues">, - issueNumber: number, - title: string - ) => { + const startIssue = async (issueId: Id<"projectIssues">) => { const actionKey = `issue:${issueId}`; setPendingAction(actionKey); setError(null); try { - await beginIssue({ issueId }); - await flueClient.agents.send("project-manager", String(issueId), { - message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`, + const accessToken = await resolveAccessToken(); + if (!accessToken) { + throw new Error("Authentication required"); + } + await requestProjectIssue({ + accessToken, + request: { issueId, kind: "existing" }, }); } catch (caughtError) { - const message = errorMessage(caughtError); - await markDispatchFailed({ error: message, issueId }); - setError(message); + setError(errorMessage(caughtError)); } finally { setPendingAction(null); } @@ -148,7 +159,7 @@ export const useProjectWorkspace = () => { setError("That work unit is no longer available"); return; } - await startIssue(issue._id, issue.number, issue.title); + await startIssue(issue._id); }; const selectedProject = projects?.find( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 89315e1..e6f7fa7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -75,6 +75,19 @@ html.slice-one-viewport-lock body { min-height: 1.75rem; } +.route-progress-bar { + animation: route-progress 900ms ease-in-out infinite; +} + +@keyframes route-progress { + from { + transform: translateX(-100%); + } + to { + transform: translateX(400%); + } +} + @keyframes response-dot { 0%, 60%, diff --git a/apps/web/src/lib/auth.server.ts b/apps/web/src/lib/auth.server.ts new file mode 100644 index 0000000..c18079b --- /dev/null +++ b/apps/web/src/lib/auth.server.ts @@ -0,0 +1,64 @@ +import { env } from "@code/env/web"; +import { redirect } from "react-router"; + +interface AuthLoaderData { + readonly token: string | null; +} + +const tokenUrl = new URL("/api/auth/convex/token", env.VITE_AUTH_URL); + +export const loadAuthToken = async ( + request: Request +): Promise => { + const cookie = request.headers.get("cookie"); + if (!cookie) { + return { token: null }; + } + + const headers = new Headers({ cookie }); + headers.set("host", tokenUrl.host); + const response = await fetch(tokenUrl, { headers }); + if (response.status === 401) { + return { token: null }; + } + if (!response.ok) { + throw new Response("Authentication service unavailable", { status: 503 }); + } + + const payload: unknown = await response.json(); + if ( + typeof payload !== "object" || + payload === null || + !("token" in payload) || + typeof payload.token !== "string" + ) { + throw new Response("Authentication service returned an invalid response", { + status: 502, + }); + } + return { token: payload.token }; +}; + +export const requireAuthToken = async ( + request: Request +): Promise => { + const auth = await loadAuthToken(request); + if (!auth.token) { + const requestUrl = new URL(request.url); + const returnTo = `${requestUrl.pathname}${requestUrl.search}`; + throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`); + } + return auth; +}; + +export const redirectAuthenticated = async ( + request: Request +): Promise => { + const auth = await loadAuthToken(request); + if (auth.token) { + const requestUrl = new URL(request.url); + const returnTo = requestUrl.searchParams.get("returnTo"); + throw redirect(returnTo?.startsWith("/") ? returnTo : "/"); + } + return auth; +}; diff --git a/apps/web/src/lib/projects/project-requests.ts b/apps/web/src/lib/projects/project-requests.ts new file mode 100644 index 0000000..788d7ed --- /dev/null +++ b/apps/web/src/lib/projects/project-requests.ts @@ -0,0 +1,62 @@ +import type { Id } from "@code/backend/convex/_generated/dataModel"; +import { env } from "@code/env/web"; +import { ProjectIssueRequestResult } from '@code/primitives/project-issue'; +import type { ProjectIssueRequestResult as ProjectIssueRequestResultValue } from '@code/primitives/project-issue'; +import { Schema } from "effect"; + +const projectRequestsUrl = new URL( + "project-requests", + `${env.VITE_FLUE_URL.replace(/\/+$/u, "")}/` +); + +interface RequestProjectIssueOptions { + readonly accessToken: string; + readonly request: + | { + readonly issueId: Id<"projectIssues">; + readonly kind: "existing"; + } + | { + readonly body: string; + readonly kind: "explicit"; + readonly projectId: Id<"projects">; + readonly title: string; + } + | { + readonly kind: "signal"; + readonly signalId: Id<"signals">; + }; +} + +const responseError = async (response: Response): Promise => { + const payload: unknown = await response.json().catch(() => null); + if ( + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof payload.error === "string" + ) { + return new Error(payload.error); + } + return new Error(`Project request failed (${response.status})`); +}; + +export const requestProjectIssue = async ({ + accessToken, + request, +}: RequestProjectIssueOptions): Promise => { + const response = await fetch(projectRequestsUrl, { + body: JSON.stringify(request), + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + }, + method: "POST", + }); + if (!response.ok) { + throw await responseError(response); + } + return Schema.decodeUnknownSync(ProjectIssueRequestResult)( + await response.json() + ); +}; diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index ee82462..91681f0 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -2,9 +2,10 @@ import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web"; import { env } from "@code/env/web"; import { Toaster } from "@code/ui/components/sonner"; import { FlueProvider } from "@flue/react"; +import { createFlueClient } from "@flue/sdk"; import "./index.css"; -import { createFlueClient } from "@flue/sdk"; +import { LoaderCircle } from "lucide-react"; import { useMemo } from "react"; import { isRouteErrorResponse, @@ -13,12 +14,16 @@ import { Outlet, Scripts, ScrollRestoration, + useNavigation, } from "react-router"; import type { Route } from "./+types/root"; import { ThemeProvider } from "./components/theme-provider"; +import { loadAuthToken } from "./lib/auth.server"; import { createFlueFetch } from "./lib/flue-transport"; +export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request); + export const links: Route.LinksFunction = () => [ { href: "https://fonts.googleapis.com", rel: "preconnect" }, { @@ -79,8 +84,31 @@ export const Layout = ({ children }: { children: React.ReactNode }) => ( ); -const App = () => ( - +const RouteProgress = () => { + const navigation = useNavigation(); + if (!navigation.location) { + return null; + } + return ( + +
+ + ); +}; + +export const HydrateFallback = () => ( +
+
+ Loading Zopu… +
+
+); + +const App = ({ loaderData }: Route.ComponentProps) => ( + ( disableTransitionOnChange storageKey="vite-ui-theme" > + diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index 40a690a..d4e3976 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -2,8 +2,6 @@ import { index, layout, route } from "@react-router/dev/routes"; import type { RouteConfig } from "@react-router/dev/routes"; export default [ - // Standalone chat page — no auth, mobile-first, talks to the zopu server. - route("chat", "./routes/standalone/chat/page.tsx"), layout("./routes/auth/layout.tsx", [ route("login", "./routes/auth/login/page.tsx"), route("signup", "./routes/auth/signup/page.tsx"), diff --git a/apps/web/src/routes/app/layout.tsx b/apps/web/src/routes/app/layout.tsx index 83e3a33..a3a434e 100644 --- a/apps/web/src/routes/app/layout.tsx +++ b/apps/web/src/routes/app/layout.tsx @@ -1,29 +1,12 @@ -import { useWebAuth } from "@code/auth/web"; -import { Navigate, Outlet, useLocation } from "react-router"; +import { Outlet } from "react-router"; + +import { requireAuthToken } from "@/lib/auth.server"; + +import type { Route } from "./+types/layout"; + +export const loader = ({ request }: Route.LoaderArgs) => + requireAuthToken(request); export default function AppLayout() { - const auth = useWebAuth(); - const location = useLocation(); - - if (auth.status === "loading") { - return ( -
- Loading… -
- ); - } - - if (auth.status === "inconsistent") { - return ( -
- Your session could not be verified -
- ); - } - - if (auth.status === "unauthenticated") { - return ; - } - return ; } diff --git a/apps/web/src/routes/auth/layout.tsx b/apps/web/src/routes/auth/layout.tsx index 8498887..dff1f20 100644 --- a/apps/web/src/routes/auth/layout.tsx +++ b/apps/web/src/routes/auth/layout.tsx @@ -1,21 +1,13 @@ -import { useWebAuth } from "@code/auth/web"; -import { Navigate, Outlet } from "react-router"; +import { Outlet } from "react-router"; + +import { redirectAuthenticated } from "@/lib/auth.server"; + +import type { Route } from "./+types/layout"; + +export const loader = ({ request }: Route.LoaderArgs) => + redirectAuthenticated(request); export default function AuthLayout() { - const auth = useWebAuth(); - - if (auth.status === "loading") { - return ( -
- Loading… -
- ); - } - - if (auth.status === "authenticated") { - return ; - } - return (
diff --git a/apps/web/src/routes/auth/login/page.tsx b/apps/web/src/routes/auth/login/page.tsx index 167dfcc..a658dca 100644 --- a/apps/web/src/routes/auth/login/page.tsx +++ b/apps/web/src/routes/auth/login/page.tsx @@ -1,5 +1,5 @@ import { LoginForm } from "@code/auth/web"; -import { useNavigate } from "react-router"; +import { useNavigate, useSearchParams } from "react-router"; import type { Route } from "./+types/page"; @@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [ export default function LoginRoute() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const returnTo = searchParams.get("returnTo"); - return navigate("/", { replace: true })} />; + return ( + + navigate(returnTo?.startsWith("/") ? returnTo : "/", { replace: true }) + } + /> + ); } diff --git a/deploy/zopu-runtime/.env.template b/deploy/zopu-runtime/.env.template index 48ce716..27cb39e 100644 --- a/deploy/zopu-runtime/.env.template +++ b/deploy/zopu-runtime/.env.template @@ -12,10 +12,11 @@ # --------------------------------------------------------------------------- CONVEX_URL=https://your-deployment.convex.cloud CONVEX_SITE_URL=https://your-deployment.convex.site -SITE_URL=http://localhost:5173 +SITE_URL=http://localhost:13100 +VITE_AUTH_URL=http://localhost:13100 VITE_CONVEX_URL=https://your-deployment.convex.cloud -VITE_CONVEX_SITE_URL=https://your-deployment.convex.site VITE_FLUE_URL=http://localhost:3583 +VITE_ZOPU_SERVER_URL=http://localhost:3590 # Self-hosted Convex origins used by convex/docker-compose.yml CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud diff --git a/deploy/zopu-runtime/caddy/Caddyfile b/deploy/zopu-runtime/caddy/Caddyfile index 42aa6b3..152573e 100644 --- a/deploy/zopu-runtime/caddy/Caddyfile +++ b/deploy/zopu-runtime/caddy/Caddyfile @@ -1,13 +1,21 @@ # Caddyfile — Public reverse proxy for the Zopu web + API. # -# The web frontend (port 5173) is served at the root, and the Flue agent/API -# (port 3585) is mounted under /api so the browser talks same-origin. +# The web frontend (port 5173) is served at the root, Better Auth is proxied +# first-party under /api/auth, and Flue is mounted under /api/flue. zopu.cheaptricks.puter.wtf { bind 135.181.82.179 2a01:4f9:c013:4a64::1 encode zstd gzip - handle_path /api/* { + handle /api/auth/* { + reverse_proxy https://befitting-dalmatian-161.convex.site { + header_up Host befitting-dalmatian-161.convex.site + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + } + } + + handle_path /api/flue/* { reverse_proxy 127.0.0.1:3585 } diff --git a/packages/agents/src/agents/project-manager.ts b/packages/agents/src/agents/project-manager.ts index e27008c..28f9cc7 100644 --- a/packages/agents/src/agents/project-manager.ts +++ b/packages/agents/src/agents/project-manager.ts @@ -1,6 +1,5 @@ import { parseAgentEnv } from "@code/env/agent"; import { defineAgent } from "@flue/runtime"; -import type { AgentRouteHandler } from "@flue/runtime"; import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle"; import { agentOs } from "../sandboxes/agent-os"; @@ -9,8 +8,6 @@ import { createProjectTools } from "../tools/project"; export const description = "Works one repository issue inside an isolated AgentOS workspace."; -export const route: AgentRouteHandler = (_context, next) => next(); - export default defineAgent(({ env, id }) => { const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env); diff --git a/packages/agents/src/project-request.ts b/packages/agents/src/project-request.ts index 2ad4a17..f190889 100644 --- a/packages/agents/src/project-request.ts +++ b/packages/agents/src/project-request.ts @@ -31,6 +31,11 @@ const createIssueFromSignal = makeFunctionReference< { readonly signalId: string }, { readonly issueId: string; readonly projectId: string } >("projectIssues:createFromSignal"); +const getIssue = makeFunctionReference< + "query", + { readonly issueId: string }, + { readonly issueId: string; readonly projectId: string } | null +>("projectIssues:getForDispatch"); const beginIssue = makeFunctionReference< "mutation", @@ -75,6 +80,14 @@ const createIssueForRequest = async ( client: ConvexHttpClient, request: Awaited> ): Promise<{ readonly issueId: string; readonly projectId: string }> => { + if (request.kind === "existing") { + const issue = await client.query(getIssue, { issueId: request.issueId }); + if (!issue) { + throw new Error("Project issue not found"); + } + return issue; + } + if (request.kind === "signal") { return client.mutation(createIssueFromSignal, { signalId: request.signalId, diff --git a/packages/auth/src/web/auth-client.ts b/packages/auth/src/web/auth-client.ts index aed599f..c8f3554 100644 --- a/packages/auth/src/web/auth-client.ts +++ b/packages/auth/src/web/auth-client.ts @@ -1,8 +1,8 @@ import { env } from "@code/env/web"; -import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins"; +import { convexClient } from "@convex-dev/better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ - baseURL: env.VITE_CONVEX_SITE_URL, - plugins: [convexClient(), crossDomainClient()], + baseURL: env.VITE_AUTH_URL, + plugins: [convexClient()], }); diff --git a/packages/auth/src/web/auth-provider.tsx b/packages/auth/src/web/auth-provider.tsx index 457ee24..a7ddca4 100644 --- a/packages/auth/src/web/auth-provider.tsx +++ b/packages/auth/src/web/auth-provider.tsx @@ -7,8 +7,18 @@ import { authClient } from "./auth-client"; const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true }); -export const WebAuthProvider = ({ children }: { children: ReactNode }) => ( - +export const WebAuthProvider = ({ + children, + initialToken, +}: { + readonly children: ReactNode; + readonly initialToken?: string | null; +}) => ( + {children} ); diff --git a/packages/backend/convex/auth.ts b/packages/backend/convex/auth.ts index bd2829b..48fa16f 100644 --- a/packages/backend/convex/auth.ts +++ b/packages/backend/convex/auth.ts @@ -2,7 +2,7 @@ import { expo } from "@better-auth/expo"; import { env } from "@code/env/convex"; import { createClient } from "@convex-dev/better-auth"; import type { GenericCtx } from "@convex-dev/better-auth"; -import { convex, crossDomain } from "@convex-dev/better-auth/plugins"; +import { convex } from "@convex-dev/better-auth/plugins"; import { betterAuth } from "better-auth/minimal"; import { components } from "./_generated/api"; @@ -25,7 +25,6 @@ const createAuth = (ctx: GenericCtx) => }, plugins: [ expo(), - crossDomain({ siteUrl }), convex({ authConfig, jwksRotateOnTokenGenerationError: true, diff --git a/packages/backend/convex/projectIssues.ts b/packages/backend/convex/projectIssues.ts index 89d110e..15ca5d8 100644 --- a/packages/backend/convex/projectIssues.ts +++ b/packages/backend/convex/projectIssues.ts @@ -118,6 +118,18 @@ export const createFromSignal = mutation({ }, }); +export const getForDispatch = query({ + args: { issueId: v.id("projectIssues") }, + handler: async (ctx, args) => { + const issue = await ctx.db.get("projectIssues", args.issueId); + if (!issue) { + return null; + } + await requireProjectMember(ctx, issue.projectId); + return { issueId: issue._id, projectId: issue.projectId }; + }, +}); + export const begin = mutation({ args: { issueId: v.id("projectIssues") }, handler: async (ctx, args) => { diff --git a/packages/env/src/vite-env.d.ts b/packages/env/src/vite-env.d.ts index b4c4fe5..243576c 100644 --- a/packages/env/src/vite-env.d.ts +++ b/packages/env/src/vite-env.d.ts @@ -1,6 +1,6 @@ interface ImportMetaEnv { readonly [key: string]: string | number | boolean | undefined; - readonly VITE_CONVEX_SITE_URL: string; + readonly VITE_AUTH_URL: string; readonly VITE_FLUE_URL: string; readonly VITE_CONVEX_URL: string; } diff --git a/packages/env/src/web.ts b/packages/env/src/web.ts index c72c430..1873b5e 100644 --- a/packages/env/src/web.ts +++ b/packages/env/src/web.ts @@ -8,10 +8,9 @@ const convexUrlSchema = (exampleHost: string) => export const env = createEnv({ client: { - VITE_CONVEX_SITE_URL: convexUrlSchema("example.convex.site"), + VITE_AUTH_URL: z.url(), VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"), VITE_FLUE_URL: z.url(), - VITE_ZOPU_SERVER_URL: z.url(), }, clientPrefix: "VITE_", emptyStringAsUndefined: true, diff --git a/packages/primitives/src/project-issue.test.ts b/packages/primitives/src/project-issue.test.ts index f99008a..514a72c 100644 --- a/packages/primitives/src/project-issue.test.ts +++ b/packages/primitives/src/project-issue.test.ts @@ -29,6 +29,17 @@ describe("project issue primitives", () => { }); }); + it("accepts an existing issue request without creating a duplicate", () => { + const request = Effect.runSync( + decodeProjectIssueRequest({ + issueId: "issue-1", + kind: "existing", + }) + ); + + expect(request).toEqual({ issueId: "issue-1", kind: "existing" }); + }); + it("rejects invalid issue drafts before persistence", () => { const error = Effect.runSync( Effect.flip( diff --git a/packages/primitives/src/project-issue.ts b/packages/primitives/src/project-issue.ts index 5ccd839..d85530a 100644 --- a/packages/primitives/src/project-issue.ts +++ b/packages/primitives/src/project-issue.ts @@ -35,6 +35,11 @@ export const ProjectIssueDraft = Schema.Struct({ }); export type ProjectIssueDraft = typeof ProjectIssueDraft.Type; +export const ExistingProjectIssueRequest = Schema.Struct({ + issueId: ProjectIssueId, + kind: Schema.Literal("existing"), +}); + export const ExplicitProjectIssueRequest = Schema.Struct({ body: Schema.String, kind: Schema.Literal("explicit"), @@ -48,6 +53,7 @@ export const SignalProjectIssueRequest = Schema.Struct({ }); export const ProjectIssueRequest = Schema.Union([ + ExistingProjectIssueRequest, ExplicitProjectIssueRequest, SignalProjectIssueRequest, ]); @@ -186,7 +192,7 @@ export const decodeProjectIssueRequest = ( () => new ProjectIssueRequestError({ message: - "Use a signal request or an explicit request with projectId, title, and body", + "Use an existing issue, signal request, or explicit request with projectId, title, and body", reason: "InvalidInput", }) )