Files
zopu-code/packages/backend/convex/authz.ts
-Puter 224e98d0bc feat: add Convex project backend with generic storage and org-scoped authz
Schema:
- Cut projects table to {organizationId, name, description, createdAt, updatedAt}
- Add projectSources table with git source metadata and normalized URL index
- Add projectContextDocuments table with six canonical kinds, origin, revision
- Add by_organizationId index for project enumeration
- Remove all GitHub-specific fields (ownerId, provider, repoOwner, etc.)

Backend:
- Add requireCurrentOrganization and requireProjectMember authz helpers
- Migrate signals.ts authorizeProject to organizationId invariant check
- Update artifactModel: 8 operational artifacts (removed project/business/design.md)
- Add projectStore.ts with query/mutation/action store adapters
- Rewrite projects.ts to thin generic application calls
- Update projectIssues/projectArtifacts to use requireProjectMember
- Update projectEvents.test.ts and signals.test.ts for new project creation

All 33 backend tests pass.
2026-07-23 18:16:41 +05:30

81 lines
2.6 KiB
TypeScript

import { ConvexError } from "convex/values";
import type { Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
export interface AuthContext {
readonly auth: {
readonly getUserIdentity: () => Promise<{
readonly tokenIdentifier: string;
} | null>;
};
}
export const requireAuthUserId = async (ctx: AuthContext): Promise<string> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError("Authentication required");
}
return identity.tokenIdentifier;
};
/**
* Resolve the authenticated identity and prove it is a member of the given
* organization. Throws on unauthenticated or non-member access. Returns the
* canonical user ID (the Better Auth `tokenIdentifier`).
*/
export const requireOrganizationMember = async (
ctx: QueryCtx | MutationCtx,
organizationId: Id<"organizations">
): Promise<string> => {
const userId = await requireAuthUserId(ctx);
const membership = await ctx.db
.query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) =>
q.eq("organizationId", organizationId).eq("userId", userId)
)
.unique();
if (!membership) {
throw new ConvexError("Organization membership required");
}
return userId;
};
/**
* Resolve the authenticated user's personal/current organization. Browsers
* never submit organization IDs for Project commands; this resolves the
* tenant boundary server-side through the authenticated identity.
*/
export const requireCurrentOrganization = async (
ctx: QueryCtx | MutationCtx
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
const userId = await requireAuthUserId(ctx);
const organization = await ctx.db
.query("organizations")
.withIndex("by_createdBy_and_kind", (q) =>
q.eq("createdBy", userId).eq("kind", "personal")
)
.unique();
if (!organization) {
throw new ConvexError("Organization not found");
}
return { organizationId: organization._id, userId };
};
/**
* Prove the authenticated user is a member of the project's organization.
* Projects are organization-scoped; unauthorized single-object operations
* throw "Project not found" to avoid leaking existence.
*/
export const requireProjectMember = async (
ctx: QueryCtx | MutationCtx,
projectId: Id<"projects">
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
const project = await ctx.db.get(projectId);
if (!project) {
throw new ConvexError("Project not found");
}
const userId = await requireOrganizationMember(ctx, project.organizationId);
return { organizationId: project.organizationId, userId };
};