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
This commit is contained in:
Miniputer
2026-07-27 18:47:43 +05:30
parent cc47007fa9
commit 1855735245
24 changed files with 340 additions and 108 deletions

View File

@@ -6,8 +6,8 @@ SITE_URL=http://localhost:5173
NATIVE_APP_URL=code:// NATIVE_APP_URL=code://
# Browser and native public endpoints # Browser and native public endpoints
VITE_AUTH_URL=http://localhost:5173
VITE_CONVEX_URL=https://example.convex.cloud 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. # For phone testing, replace localhost with this machine's Tailscale IPv4 address.
VITE_FLUE_URL=http://localhost:3583 VITE_FLUE_URL=http://localhost:3583
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud

View File

@@ -1,7 +1,7 @@
import type { Config } from "@react-router/dev/config"; import type { Config } from "@react-router/dev/config";
export default { export default {
// Desktop addons package static web assets; SSR output cannot be bundled
ssr: false,
appDirectory: "src", appDirectory: "src",
ssr: true,
} satisfies Config; } satisfies Config;

View File

@@ -90,6 +90,30 @@ const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => {
); );
}; };
const ConversationLoading = () => (
<output
aria-label="Loading conversation"
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
>
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
<span className="sr-only">Loading conversation</span>
</output>
);
const ConversationEmptyState = () => (
<div className="grid min-h-[55vh] place-items-center text-center">
<div>
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
Describe an outcome or problem. Casual conversation stays conversation.
</p>
</div>
</div>
);
export const SliceOnePage = () => { export const SliceOnePage = () => {
const slice = useSliceOne(); const slice = useSliceOne();
const viewportStyle = useVisualViewportStyle(); const viewportStyle = useVisualViewportStyle();
@@ -233,19 +257,11 @@ export const SliceOnePage = () => {
</header> </header>
<Conversation className="min-h-0 flex-1"> <Conversation className="min-h-0 flex-1">
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6"> <ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
{!slice.agent.historyReady && timeline.length === 0 ? (
<ConversationLoading />
) : null}
{slice.agent.historyReady && timeline.length === 0 ? ( {slice.agent.historyReady && timeline.length === 0 ? (
<div className="grid min-h-[55vh] place-items-center text-center"> <ConversationEmptyState />
<div>
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
<h1 className="mt-4 text-xl font-semibold">
What should move forward?
</h1>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
Describe an outcome or problem. Casual conversation stays
conversation.
</p>
</div>
</div>
) : null} ) : null}
{timeline.map((item) => { {timeline.map((item) => {
if (item.kind === "work") { if (item.kind === "work") {

View File

@@ -1,19 +1,20 @@
import { useConvexAccessToken } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api"; import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel"; import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useFlueClient } from "@flue/react"; import { useAction, useQuery } from "convex/react";
import { useAction, useMutation, useQuery } from "convex/react";
import { useState } from "react"; import { useState } from "react";
import { import {
buildProjectLoopView, buildProjectLoopView,
summarizeProjectIssues, summarizeProjectIssues,
} from "@/lib/projects/project-evidence"; } from "@/lib/projects/project-evidence";
import { requestProjectIssue } from "@/lib/projects/project-requests";
const errorMessage = (error: unknown) => const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
export const useProjectWorkspace = () => { export const useProjectWorkspace = () => {
const flueClient = useFlueClient(); const resolveAccessToken = useConvexAccessToken();
const projects = useQuery(api.projects.list); const projects = useQuery(api.projects.list);
const [selectedProjectId, setSelectedProjectId] = const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null); useState<Id<"projects"> | null>(null);
@@ -25,10 +26,7 @@ export const useProjectWorkspace = () => {
const [pendingAction, setPendingAction] = useState<string | null>(null); const [pendingAction, setPendingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const importPublicGit = useAction(api.projects.importPublicGit); 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 = const activeProjectId =
selectedProjectId ?? selectedProjectId ??
(projects?.[0]?.id as unknown as Id<"projects">) ?? (projects?.[0]?.id as unknown as Id<"projects">) ??
@@ -92,12 +90,20 @@ export const useProjectWorkspace = () => {
setPendingAction("issue"); setPendingAction("issue");
setError(null); setError(null);
try { try {
const issueId = await createIssue({ const accessToken = await resolveAccessToken();
body: nextBody, if (!accessToken) {
projectId: activeProjectId, throw new Error("Authentication required");
title: nextTitle, }
const outcome = await requestProjectIssue({
accessToken,
request: {
body: nextBody,
kind: "explicit",
projectId: activeProjectId,
title: nextTitle,
},
}); });
setSelectedIssueId(issueId); setSelectedIssueId(outcome.issueId as Id<"projectIssues">);
setIssueTitle(""); setIssueTitle("");
setIssueBody(""); setIssueBody("");
} catch (caughtError) { } catch (caughtError) {
@@ -111,8 +117,15 @@ export const useProjectWorkspace = () => {
setPendingAction(`signal:${signalId}`); setPendingAction(`signal:${signalId}`);
setError(null); setError(null);
try { try {
const outcome = await createIssueFromSignal({ signalId }); const accessToken = await resolveAccessToken();
setSelectedIssueId(outcome.issueId); if (!accessToken) {
throw new Error("Authentication required");
}
const outcome = await requestProjectIssue({
accessToken,
request: { kind: "signal", signalId },
});
setSelectedIssueId(outcome.issueId as Id<"projectIssues">);
} catch (caughtError) { } catch (caughtError) {
setError(errorMessage(caughtError)); setError(errorMessage(caughtError));
} finally { } finally {
@@ -120,23 +133,21 @@ export const useProjectWorkspace = () => {
} }
}; };
const startIssue = async ( const startIssue = async (issueId: Id<"projectIssues">) => {
issueId: Id<"projectIssues">,
issueNumber: number,
title: string
) => {
const actionKey = `issue:${issueId}`; const actionKey = `issue:${issueId}`;
setPendingAction(actionKey); setPendingAction(actionKey);
setError(null); setError(null);
try { try {
await beginIssue({ issueId }); const accessToken = await resolveAccessToken();
await flueClient.agents.send("project-manager", String(issueId), { if (!accessToken) {
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`, throw new Error("Authentication required");
}
await requestProjectIssue({
accessToken,
request: { issueId, kind: "existing" },
}); });
} catch (caughtError) { } catch (caughtError) {
const message = errorMessage(caughtError); setError(errorMessage(caughtError));
await markDispatchFailed({ error: message, issueId });
setError(message);
} finally { } finally {
setPendingAction(null); setPendingAction(null);
} }
@@ -148,7 +159,7 @@ export const useProjectWorkspace = () => {
setError("That work unit is no longer available"); setError("That work unit is no longer available");
return; return;
} }
await startIssue(issue._id, issue.number, issue.title); await startIssue(issue._id);
}; };
const selectedProject = const selectedProject =
projects?.find( projects?.find(

View File

@@ -75,6 +75,19 @@ html.slice-one-viewport-lock body {
min-height: 1.75rem; 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 { @keyframes response-dot {
0%, 0%,
60%, 60%,

View File

@@ -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<AuthLoaderData> => {
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<AuthLoaderData> => {
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<AuthLoaderData> => {
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;
};

View File

@@ -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<Error> => {
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<ProjectIssueRequestResultValue> => {
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()
);
};

View File

@@ -2,9 +2,10 @@ import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
import { env } from "@code/env/web"; import { env } from "@code/env/web";
import { Toaster } from "@code/ui/components/sonner"; import { Toaster } from "@code/ui/components/sonner";
import { FlueProvider } from "@flue/react"; import { FlueProvider } from "@flue/react";
import { createFlueClient } from "@flue/sdk";
import "./index.css"; import "./index.css";
import { createFlueClient } from "@flue/sdk"; import { LoaderCircle } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
isRouteErrorResponse, isRouteErrorResponse,
@@ -13,12 +14,16 @@ import {
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
useNavigation,
} from "react-router"; } from "react-router";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import { ThemeProvider } from "./components/theme-provider"; import { ThemeProvider } from "./components/theme-provider";
import { loadAuthToken } from "./lib/auth.server";
import { createFlueFetch } from "./lib/flue-transport"; import { createFlueFetch } from "./lib/flue-transport";
export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request);
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ href: "https://fonts.googleapis.com", rel: "preconnect" }, { href: "https://fonts.googleapis.com", rel: "preconnect" },
{ {
@@ -79,8 +84,31 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
</html> </html>
); );
const App = () => ( const RouteProgress = () => {
<WebAuthProvider> const navigation = useNavigation();
if (!navigation.location) {
return null;
}
return (
<output
aria-label="Loading page"
className="fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden bg-[#d7d3c7]"
>
<div className="route-progress-bar h-full w-1/3 bg-[#7f9130]" />
</output>
);
};
export const HydrateFallback = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] text-[#69675f]">
<div className="flex items-center gap-2 text-sm">
<LoaderCircle className="size-4 animate-spin" /> Loading Zopu
</div>
</main>
);
const App = ({ loaderData }: Route.ComponentProps) => (
<WebAuthProvider initialToken={loaderData.token}>
<AuthenticatedFlueProvider> <AuthenticatedFlueProvider>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
@@ -89,6 +117,7 @@ const App = () => (
disableTransitionOnChange disableTransitionOnChange
storageKey="vite-ui-theme" storageKey="vite-ui-theme"
> >
<RouteProgress />
<Outlet /> <Outlet />
<Toaster richColors /> <Toaster richColors />
</ThemeProvider> </ThemeProvider>

View File

@@ -2,8 +2,6 @@ import { index, layout, route } from "@react-router/dev/routes";
import type { RouteConfig } from "@react-router/dev/routes"; import type { RouteConfig } from "@react-router/dev/routes";
export default [ 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", [ layout("./routes/auth/layout.tsx", [
route("login", "./routes/auth/login/page.tsx"), route("login", "./routes/auth/login/page.tsx"),
route("signup", "./routes/auth/signup/page.tsx"), route("signup", "./routes/auth/signup/page.tsx"),

View File

@@ -1,29 +1,12 @@
import { useWebAuth } from "@code/auth/web"; import { Outlet } from "react-router";
import { Navigate, Outlet, useLocation } 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() { export default function AppLayout() {
const auth = useWebAuth();
const location = useLocation();
if (auth.status === "loading") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
}
if (auth.status === "inconsistent") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Your session could not be verified
</div>
);
}
if (auth.status === "unauthenticated") {
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
}
return <Outlet />; return <Outlet />;
} }

View File

@@ -1,21 +1,13 @@
import { useWebAuth } from "@code/auth/web"; import { Outlet } from "react-router";
import { Navigate, 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() { export default function AuthLayout() {
const auth = useWebAuth();
if (auth.status === "loading") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
}
if (auth.status === "authenticated") {
return <Navigate replace to="/" />;
}
return ( return (
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10"> <main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">

View File

@@ -1,5 +1,5 @@
import { LoginForm } from "@code/auth/web"; import { LoginForm } from "@code/auth/web";
import { useNavigate } from "react-router"; import { useNavigate, useSearchParams } from "react-router";
import type { Route } from "./+types/page"; import type { Route } from "./+types/page";
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
export default function LoginRoute() { export default function LoginRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo");
return <LoginForm onSuccess={() => navigate("/", { replace: true })} />; return (
<LoginForm
onSuccess={() =>
navigate(returnTo?.startsWith("/") ? returnTo : "/", { replace: true })
}
/>
);
} }

View File

@@ -12,10 +12,11 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CONVEX_URL=https://your-deployment.convex.cloud CONVEX_URL=https://your-deployment.convex.cloud
CONVEX_SITE_URL=https://your-deployment.convex.site 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_URL=https://your-deployment.convex.cloud
VITE_CONVEX_SITE_URL=https://your-deployment.convex.site
VITE_FLUE_URL=http://localhost:3583 VITE_FLUE_URL=http://localhost:3583
VITE_ZOPU_SERVER_URL=http://localhost:3590
# Self-hosted Convex origins used by convex/docker-compose.yml # Self-hosted Convex origins used by convex/docker-compose.yml
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud

View File

@@ -1,13 +1,21 @@
# Caddyfile — Public reverse proxy for the Zopu web + API. # Caddyfile — Public reverse proxy for the Zopu web + API.
# #
# The web frontend (port 5173) is served at the root, and the Flue agent/API # The web frontend (port 5173) is served at the root, Better Auth is proxied
# (port 3585) is mounted under /api so the browser talks same-origin. # first-party under /api/auth, and Flue is mounted under /api/flue.
zopu.cheaptricks.puter.wtf { zopu.cheaptricks.puter.wtf {
bind 135.181.82.179 2a01:4f9:c013:4a64::1 bind 135.181.82.179 2a01:4f9:c013:4a64::1
encode zstd gzip 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 reverse_proxy 127.0.0.1:3585
} }

View File

@@ -1,6 +1,5 @@
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime"; import { defineAgent } from "@flue/runtime";
import type { AgentRouteHandler } from "@flue/runtime";
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle"; import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
import { agentOs } from "../sandboxes/agent-os"; import { agentOs } from "../sandboxes/agent-os";
@@ -9,8 +8,6 @@ import { createProjectTools } from "../tools/project";
export const description = export const description =
"Works one repository issue inside an isolated AgentOS workspace."; "Works one repository issue inside an isolated AgentOS workspace.";
export const route: AgentRouteHandler = (_context, next) => next();
export default defineAgent(({ env, id }) => { export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env); const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);

View File

@@ -31,6 +31,11 @@ const createIssueFromSignal = makeFunctionReference<
{ readonly signalId: string }, { readonly signalId: string },
{ readonly issueId: string; readonly projectId: string } { readonly issueId: string; readonly projectId: string }
>("projectIssues:createFromSignal"); >("projectIssues:createFromSignal");
const getIssue = makeFunctionReference<
"query",
{ readonly issueId: string },
{ readonly issueId: string; readonly projectId: string } | null
>("projectIssues:getForDispatch");
const beginIssue = makeFunctionReference< const beginIssue = makeFunctionReference<
"mutation", "mutation",
@@ -75,6 +80,14 @@ const createIssueForRequest = async (
client: ConvexHttpClient, client: ConvexHttpClient,
request: Awaited<ReturnType<typeof decodeRequest>> request: Awaited<ReturnType<typeof decodeRequest>>
): Promise<{ readonly issueId: string; readonly projectId: string }> => { ): 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") { if (request.kind === "signal") {
return client.mutation(createIssueFromSignal, { return client.mutation(createIssueFromSignal, {
signalId: request.signalId, signalId: request.signalId,

View File

@@ -1,8 +1,8 @@
import { env } from "@code/env/web"; 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"; import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: env.VITE_CONVEX_SITE_URL, baseURL: env.VITE_AUTH_URL,
plugins: [convexClient(), crossDomainClient()], plugins: [convexClient()],
}); });

View File

@@ -7,8 +7,18 @@ import { authClient } from "./auth-client";
const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true }); const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true });
export const WebAuthProvider = ({ children }: { children: ReactNode }) => ( export const WebAuthProvider = ({
<ConvexBetterAuthProvider authClient={authClient} client={convex}> children,
initialToken,
}: {
readonly children: ReactNode;
readonly initialToken?: string | null;
}) => (
<ConvexBetterAuthProvider
authClient={authClient}
client={convex}
initialToken={initialToken}
>
{children} {children}
</ConvexBetterAuthProvider> </ConvexBetterAuthProvider>
); );

View File

@@ -2,7 +2,7 @@ import { expo } from "@better-auth/expo";
import { env } from "@code/env/convex"; import { env } from "@code/env/convex";
import { createClient } from "@convex-dev/better-auth"; import { createClient } from "@convex-dev/better-auth";
import type { GenericCtx } 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 { betterAuth } from "better-auth/minimal";
import { components } from "./_generated/api"; import { components } from "./_generated/api";
@@ -25,7 +25,6 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
}, },
plugins: [ plugins: [
expo(), expo(),
crossDomain({ siteUrl }),
convex({ convex({
authConfig, authConfig,
jwksRotateOnTokenGenerationError: true, jwksRotateOnTokenGenerationError: true,

View File

@@ -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({ export const begin = mutation({
args: { issueId: v.id("projectIssues") }, args: { issueId: v.id("projectIssues") },
handler: async (ctx, args) => { handler: async (ctx, args) => {

View File

@@ -1,6 +1,6 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly [key: string]: string | number | boolean | undefined; 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_FLUE_URL: string;
readonly VITE_CONVEX_URL: string; readonly VITE_CONVEX_URL: string;
} }

View File

@@ -8,10 +8,9 @@ const convexUrlSchema = (exampleHost: string) =>
export const env = createEnv({ export const env = createEnv({
client: { client: {
VITE_CONVEX_SITE_URL: convexUrlSchema("example.convex.site"), VITE_AUTH_URL: z.url(),
VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"), VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
VITE_FLUE_URL: z.url(), VITE_FLUE_URL: z.url(),
VITE_ZOPU_SERVER_URL: z.url(),
}, },
clientPrefix: "VITE_", clientPrefix: "VITE_",
emptyStringAsUndefined: true, emptyStringAsUndefined: true,

View File

@@ -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", () => { it("rejects invalid issue drafts before persistence", () => {
const error = Effect.runSync( const error = Effect.runSync(
Effect.flip( Effect.flip(

View File

@@ -35,6 +35,11 @@ export const ProjectIssueDraft = Schema.Struct({
}); });
export type ProjectIssueDraft = typeof ProjectIssueDraft.Type; export type ProjectIssueDraft = typeof ProjectIssueDraft.Type;
export const ExistingProjectIssueRequest = Schema.Struct({
issueId: ProjectIssueId,
kind: Schema.Literal("existing"),
});
export const ExplicitProjectIssueRequest = Schema.Struct({ export const ExplicitProjectIssueRequest = Schema.Struct({
body: Schema.String, body: Schema.String,
kind: Schema.Literal("explicit"), kind: Schema.Literal("explicit"),
@@ -48,6 +53,7 @@ export const SignalProjectIssueRequest = Schema.Struct({
}); });
export const ProjectIssueRequest = Schema.Union([ export const ProjectIssueRequest = Schema.Union([
ExistingProjectIssueRequest,
ExplicitProjectIssueRequest, ExplicitProjectIssueRequest,
SignalProjectIssueRequest, SignalProjectIssueRequest,
]); ]);
@@ -186,7 +192,7 @@ export const decodeProjectIssueRequest = (
() => () =>
new ProjectIssueRequestError({ new ProjectIssueRequestError({
message: 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", reason: "InvalidInput",
}) })
) )