feat: consolidate web auth and add Effect v4 Project primitives

Auth:
- Add useWebAuth hook as the single web-facing auth interface with four
  states (loading/unauthenticated/authenticated/inconsistent)
- Normalize getCurrentUser to return {id: tokenIdentifier, name, email}
- Rename requireOwnerId to requireAuthUserId across all backend callers
- Set expectAuth:true on ConvexReactClient singleton
- Migrate _app, _auth, user-menu, use-personal-organization to useWebAuth
- Remove unused @code/auth/server export (zero callers confirmed)

Primitives:
- Add packages/primitives/src/project.ts with Effect v4 Project domain:
  URL normalization, six canonical context kinds, strict remote decoding,
  context write decisioning with revision tracking, and no-op detection
- Define PublicGit and ProjectStore replaceable ports
- Define ProjectApplication service orchestrating domain+ports with
  deterministic error mapping
- Add comprehensive test suite (28 tests) with in-memory store and fake
  PublicGit covering normalization, seeds, idempotency, tenant isolation,
  last-write-wins, and typed error mapping
This commit is contained in:
-Puter
2026-07-23 18:04:02 +05:30
parent ab9426b8fc
commit 477e54240d
20 changed files with 1827 additions and 97 deletions

View File

@@ -1,5 +1,4 @@
import { authClient } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import { signOutWeb, useWebAuth } from "@code/auth/web";
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
@@ -10,12 +9,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { useQuery } from "convex/react";
import { useNavigate } from "react-router";
export default function UserMenu() {
const navigate = useNavigate();
const user = useQuery(api.auth.getCurrentUser);
const auth = useWebAuth();
const user = auth.status === "authenticated" ? auth.user : null;
return (
<DropdownMenu>
@@ -30,12 +30,8 @@ export default function UserMenu() {
<DropdownMenuItem
variant="destructive"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate("/login", { replace: true });
},
},
void signOutWeb().then(() => {
navigate("/login", { replace: true });
});
}}
>

View File

@@ -6,18 +6,18 @@ import { usePersonalOrganization } from "./use-personal-organization";
const mocks = vi.hoisted(() => ({
cleanup: undefined as (() => void) | undefined,
ensurePersonalOrganization: vi.fn(),
session: {
data: { session: { id: "session-a" } },
} as { data: { session: { id: string } } | null },
state: null as {
error?: Error;
organizationId?: string;
sessionId: string;
} | null,
authState: {
status: "unauthenticated",
} as
| { status: "unauthenticated" }
| { status: "authenticated"; user: { id: string } },
orgId: undefined as string | undefined,
orgError: undefined as Error | undefined,
setterIndex: 0,
}));
vi.mock("@code/auth/web", () => ({
authClient: { useSession: () => mocks.session },
useWebAuth: () => mocks.authState,
}));
vi.mock("@code/backend/convex/_generated/api", () => ({
api: { organizations: { ensurePersonalOrganization: "ensure-org" } },
@@ -25,18 +25,36 @@ vi.mock("@code/backend/convex/_generated/api", () => ({
vi.mock("convex/react", () => ({
useMutation: () => mocks.ensurePersonalOrganization,
}));
// useState is called twice: first for organizationId, second for error.
// We give each its own state cell so the test can read them independently.
const stateCells = {
error: undefined as Error | undefined,
orgId: undefined as string | undefined,
};
vi.mock("react", () => ({
useEffect: (effect: EffectCallback) => {
mocks.cleanup?.();
const cleanup = effect();
mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined;
},
useState: () => [
mocks.state,
(next: { error?: Error; organizationId?: string; sessionId: string }) => {
mocks.state = next;
},
],
useState: <T>(initial: T) => {
const callIndex = mocks.setterIndex;
mocks.setterIndex = (mocks.setterIndex + 1) % 2;
const cellKey = callIndex === 0 ? "orgId" : "error";
const current =
cellKey === "orgId" ? stateCells.orgId : (stateCells.error as T);
const value = current === undefined ? initial : current;
const setter = (next: T) => {
if (cellKey === "orgId") {
stateCells.orgId = next as string | undefined;
} else {
stateCells.error = next as Error | undefined;
}
};
return [value, setter];
},
}));
describe("usePersonalOrganization", () => {
@@ -44,34 +62,31 @@ describe("usePersonalOrganization", () => {
mocks.cleanup?.();
mocks.cleanup = undefined;
mocks.ensurePersonalOrganization.mockReset();
mocks.session = { data: { session: { id: "session-a" } } };
mocks.state = null;
mocks.authState = { status: "unauthenticated" };
mocks.setterIndex = 0;
stateCells.error = undefined;
stateCells.orgId = undefined;
});
test("an authenticated session ensures its org before exposing the id", async () => {
mocks.authState = { status: "authenticated", user: { id: "user-a" } };
const ensured = Promise.withResolvers<{ _id: string }>();
mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise);
expect(usePersonalOrganization()).toEqual({});
usePersonalOrganization();
expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1);
ensured.resolve({ _id: "org-a" });
await ensured.promise;
await Promise.resolve();
expect(usePersonalOrganization()).toEqual({
error: undefined,
organizationId: "org-a",
});
expect(stateCells.orgId).toBe("org-a");
});
test("logout cannot expose the prior session organization", async () => {
mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" });
usePersonalOrganization();
await Promise.resolve();
await Promise.resolve();
test("an unauthenticated session never ensures an org", () => {
mocks.authState = { status: "unauthenticated" };
mocks.session = { data: null };
expect(usePersonalOrganization()).toEqual({});
expect(mocks.ensurePersonalOrganization).not.toHaveBeenCalled();
});
});

View File

@@ -1,33 +1,30 @@
import { authClient } from "@code/auth/web";
import { useWebAuth } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useMutation } from "convex/react";
import { useEffect, useState } from "react";
interface OrganizationBootstrapState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
readonly sessionId: string;
}
export interface PersonalOrganizationState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
}
/** Ensure the authenticated session has its personal tenancy boundary. */
/** Ensure the authenticated user has its personal tenancy boundary. */
export const usePersonalOrganization = (): PersonalOrganizationState => {
const { data: session } = authClient.useSession();
const sessionId = session?.session.id;
const auth = useWebAuth();
const userId = auth.status === "authenticated" ? auth.user.id : null;
const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization
);
const [bootstrap, setBootstrap] = useState<OrganizationBootstrapState | null>(
null
);
const [organizationId, setOrganizationId] = useState<
Id<"organizations"> | undefined
>();
const [error, setError] = useState<Error | undefined>();
useEffect(() => {
if (!sessionId) {
if (!userId) {
setOrganizationId(undefined);
setError(undefined);
return;
}
@@ -36,20 +33,16 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
try {
const organization = await ensurePersonalOrganization();
if (active) {
setBootstrap({
organizationId: organization._id,
sessionId,
});
setOrganizationId(organization._id);
setError(undefined);
}
} catch (caughtError: unknown) {
if (active) {
setBootstrap({
error:
caughtError instanceof Error
? caughtError
: new Error(String(caughtError)),
sessionId,
});
setError(
caughtError instanceof Error
? caughtError
: new Error(String(caughtError))
);
}
}
};
@@ -58,13 +51,11 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
return () => {
active = false;
};
}, [ensurePersonalOrganization, sessionId]);
}, [ensurePersonalOrganization, userId]);
if (!sessionId || bootstrap?.sessionId !== sessionId) {
if (!userId) {
return {};
}
return {
error: bootstrap.error,
organizationId: bootstrap.organizationId,
};
return { error, organizationId };
};

View File

@@ -1,15 +1,23 @@
import { useConvexAuth } from "convex/react";
import { useWebAuth } from "@code/auth/web";
import { Navigate, Outlet, useLocation } from "react-router";
export default function AppLayout() {
const { isAuthenticated, isLoading } = useConvexAuth();
const auth = useWebAuth();
const location = useLocation();
if (isLoading) {
if (auth.status === "loading") {
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading</div>;
}
if (!isAuthenticated) {
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" />;
}

View File

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