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:
@@ -36,7 +36,33 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
|
||||
|
||||
export { createAuth };
|
||||
|
||||
export interface AuthUserView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authenticated user to the canonical web view. The returned `id`
|
||||
* is the Convex `identity.tokenIdentifier`, the stable authenticated identity
|
||||
* used across organization/conversation/Signal ownership. Returns null when no
|
||||
* identity is present or the Better Auth user row cannot be resolved.
|
||||
*/
|
||||
export const getCurrentUser = query({
|
||||
args: {},
|
||||
handler: (ctx) => authComponent.safeGetAuthUser(ctx),
|
||||
handler: async (ctx): Promise<AuthUserView | null> => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
const authUser = await authComponent.safeGetAuthUser(ctx);
|
||||
if (!authUser) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
email: authUser.email,
|
||||
id: identity.tokenIdentifier,
|
||||
name: authUser.name,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface AuthContext {
|
||||
};
|
||||
}
|
||||
|
||||
export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
|
||||
export const requireAuthUserId = async (ctx: AuthContext): Promise<string> => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new ConvexError("Authentication required");
|
||||
@@ -28,7 +28,7 @@ export const requireOrganizationMember = async (
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
organizationId: Id<"organizations">
|
||||
): Promise<string> => {
|
||||
const userId = await requireOwnerId(ctx);
|
||||
const userId = await requireAuthUserId(ctx);
|
||||
const membership = await ctx.db
|
||||
.query("organizationMembers")
|
||||
.withIndex("by_organizationId_and_userId", (q) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireAuthUserId } from "./authz";
|
||||
|
||||
interface OrganizationView {
|
||||
readonly _id: Id<"organizations">;
|
||||
@@ -31,7 +31,7 @@ const toView = (org: Doc<"organizations">): OrganizationView => ({
|
||||
export const ensurePersonalOrganization = mutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<OrganizationView> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
@@ -70,7 +70,7 @@ export const ensurePersonalOrganization = mutation({
|
||||
export const getCurrent = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<OrganizationView | null> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireAuthUserId } from "./authz"
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
return [];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ConvexError, v } from "convex/values";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireAuthUserId } from "./authz"
|
||||
|
||||
const requireProjectOwner = async (
|
||||
ctx: MutationCtx,
|
||||
@@ -24,7 +24,7 @@ export const create = mutation({
|
||||
title: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
await requireProjectOwner(ctx, args.projectId, ownerId);
|
||||
const title = args.title.trim();
|
||||
const body = args.body.trim();
|
||||
@@ -81,7 +81,7 @@ export const create = mutation({
|
||||
export const begin = mutation({
|
||||
args: { issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
@@ -109,7 +109,7 @@ export const begin = mutation({
|
||||
export const markDispatchFailed = mutation({
|
||||
args: { error: v.string(), issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
@@ -133,7 +133,7 @@ export const markDispatchFailed = mutation({
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
return [];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireAuthUserId } from "./authz"
|
||||
|
||||
const gitHubRepositorySchema = z.object({
|
||||
default_branch: z.string(),
|
||||
@@ -47,7 +47,7 @@ const readGitHubRepository = (value: unknown) => {
|
||||
export const connectGitHub = action({
|
||||
args: { repository: v.string() },
|
||||
handler: async (ctx, args): Promise<Id<"projects">> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const { owner, repo } = parseRepository(args.repository);
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
@@ -157,7 +157,7 @@ export const storeGitHubProject = internalMutation({
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
return await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
|
||||
@@ -169,7 +169,7 @@ export const list = query({
|
||||
export const get = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
return project?.ownerId === ownerId ? project : null;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user