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
-1
View File
@@ -5,7 +5,6 @@
"type": "module",
"exports": {
"./native": "./src/native/index.ts",
"./server": "./src/server/index.ts",
"./web": "./src/web/index.ts"
},
"scripts": {
-1
View File
@@ -1 +0,0 @@
export { authComponent, createAuth } from "@code/backend/convex/auth";
+1 -1
View File
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { authClient } from "./auth-client";
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true });
export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
+2
View File
@@ -1,5 +1,7 @@
export { authClient } from "./auth-client";
export { WebAuthProvider } from "./auth-provider";
export { useConvexAccessToken } from "./hooks";
export { signOutWeb, useWebAuth } from "./use-web-auth";
export type { WebAuthState, WebAuthUser } from "./use-web-auth";
export { LoginForm } from "./login-form";
export { SignupForm } from "./signup-form";
+81
View File
@@ -0,0 +1,81 @@
import { api } from "@code/backend/convex/_generated/api";
import { useEffect, useRef } from "react";
import { useConvexAuth, useQuery } from "convex/react";
import { authClient } from "./auth-client";
export interface WebAuthUser {
readonly id: string;
readonly name: string;
readonly email: string;
}
export type WebAuthState =
| { readonly status: "loading" }
| { readonly status: "unauthenticated" }
| { readonly status: "authenticated"; readonly user: WebAuthUser }
| { readonly status: "inconsistent" };
/**
* Sign out of the web session. Clears the Better Auth session, which in turn
* invalidates the cached Convex JWT (the session-token resolver keys on the
* session id). Protected Convex queries after sign-out are denied.
*/
export const signOutWeb = async (): Promise<void> => {
await authClient.signOut({ fetchOptions: { throw: false } });
};
/**
* The single web-facing auth interface. Combines Convex auth state with the
* normalized {@link api.auth.getCurrentUser} projection, skipping the user
* query until Convex has authenticated. Reports `inconsistent` only when a JWT
* is accepted but the Better Auth user row cannot be resolved.
*/
export const useWebAuth = (): WebAuthState => {
const { isAuthenticated, isLoading } = useConvexAuth();
// Skip the user query until Convex has accepted the JWT. This keeps the
// status `loading` during token restoration and `unauthenticated` when no
// session exists, without firing a query that would return null.
const userQuery = useQuery(
api.auth.getCurrentUser,
isAuthenticated ? {} : "skip"
);
const signOutWebRef = useRef(signOutWeb);
const inconsistent =
isAuthenticated && userQuery !== undefined && userQuery === null;
// If Convex accepted the JWT but the Better Auth user row cannot be resolved,
// the session is inconsistent: sign out so the next sign-in starts clean.
useEffect(() => {
if (inconsistent) {
void signOutWebRef.current();
}
}, [inconsistent]);
if (isLoading) {
return { status: "loading" };
}
if (!isAuthenticated) {
return { status: "unauthenticated" };
}
if (userQuery === undefined) {
return { status: "loading" };
}
if (userQuery === null) {
return { status: "inconsistent" };
}
return {
status: "authenticated",
user: {
email: userQuery.email,
id: userQuery.id,
name: userQuery.name,
},
};
};