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.
This commit is contained in:
-Puter
2026-07-23 18:16:41 +05:30
parent 477e54240d
commit 224e98d0bc
11 changed files with 839 additions and 332 deletions

View File

@@ -1,176 +1,231 @@
import { ConvexError, v } from "convex/values";
import { z } from "zod";
import { v } from "convex/values";
import {
preparePublicGitSource,
decodePublicGitImportResult,
type ProjectImportOutcome,
type ProjectView,
type PublicGitImportResult,
type PreparedPublicGitSource,
} from "@code/primitives/project";
import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { internal } from "./_generated/api";
import { action, internalMutation, query } from "./_generated/server";
import { requireCurrentOrganization } from "./authz";
import { createInitialArtifacts } from "./artifactModel";
import { requireAuthUserId } from "./authz"
import { persistPublicGitImportTransaction } from "./projectStore";
const gitHubRepositorySchema = z.object({
default_branch: z.string(),
description: z.string().nullable(),
html_url: z.url(),
id: z.number(),
name: z.string(),
owner: z.object({ login: z.string() }),
});
// ---------------------------------------------------------------------------
// Internal: persist a public Git import in one transaction
// ---------------------------------------------------------------------------
const parseRepository = (value: string): { owner: string; repo: string } => {
const normalized = value.trim().replace(/\.git$/u, "");
const match = normalized.match(
/^(?:https?:\/\/github\.com\/)?(?<owner>[^/\s]+)\/(?<repo>[^/\s]+)$/iu
);
if (!match?.groups?.owner || !match.groups.repo) {
throw new ConvexError("Use a GitHub repository in owner/name format");
}
return { owner: match.groups.owner, repo: match.groups.repo };
};
const readGitHubRepository = (value: unknown) => {
const result = gitHubRepositorySchema.safeParse(value);
if (!result.success) {
throw new ConvexError("GitHub returned incomplete repository metadata");
}
return {
defaultBranch: result.data.default_branch,
...(result.data.description === null
? {}
: { description: result.data.description }),
id: result.data.id,
name: result.data.name,
owner: result.data.owner.login,
url: result.data.html_url,
};
};
export const connectGitHub = action({
args: { repository: v.string() },
handler: async (ctx, args): Promise<Id<"projects">> => {
const ownerId = await requireAuthUserId(ctx);
const { owner, repo } = parseRepository(args.repository);
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "zopu-code",
"X-GitHub-Api-Version": "2022-11-28",
},
}
);
if (!response.ok) {
if (response.status === 404) {
throw new ConvexError(
"Repository not found. This first flow supports public GitHub repositories."
);
}
throw new ConvexError(`GitHub connection failed with ${response.status}`);
}
const repository = readGitHubRepository(await response.json());
const projectId: Id<"projects"> = await ctx.runMutation(
internal.projects.storeGitHubProject,
{ ownerId, repository }
);
return projectId;
},
});
export const storeGitHubProject = internalMutation({
export const persistPublicGitImport = internalMutation({
args: {
ownerId: v.string(),
repository: v.object({
defaultBranch: v.string(),
description: v.optional(v.string()),
id: v.number(),
name: v.string(),
owner: v.string(),
userId: v.string(),
source: v.object({
host: v.string(),
projectName: v.string(),
repositoryPath: v.string(),
normalizedUrl: v.string(),
url: v.string(),
}),
remote: v.object({
defaultBranch: v.optional(v.string()),
documents: v.array(
v.object({
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
path: v.string(),
content: v.string(),
})
),
warnings: v.array(
v.object({
path: v.string(),
message: v.string(),
})
),
}),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("projects")
.withIndex("by_owner_and_repository", (q) =>
q
.eq("ownerId", args.ownerId)
.eq("provider", "github")
.eq("repoOwner", args.repository.owner)
.eq("repoName", args.repository.name)
)
.unique();
const timestamp = Date.now();
if (existing) {
await ctx.db.patch("projects", existing._id, {
defaultBranch: args.repository.defaultBranch,
description: args.repository.description,
providerRepoId: args.repository.id,
repoUrl: args.repository.url,
updatedAt: timestamp,
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
const result = await persistPublicGitImportTransaction(
ctx,
args.userId,
args.source as PreparedPublicGitSource,
args.remote as PublicGitImportResult
);
// Seed operational artifacts on first creation (detected by checking
// existing artifacts)
const existingArtifacts = await ctx.db
.query("projectArtifacts")
.withIndex("by_project", (q) => q.eq("projectId", result.id as unknown as Id<"projects">))
.first();
if (!existingArtifacts) {
const now = Date.now();
const artifacts = createInitialArtifacts();
await Promise.all(
artifacts.map(({ content, path }) =>
ctx.db.insert("projectArtifacts", {
content,
createdAt: now,
path,
projectId: result.id as unknown as Id<"projects">,
revision: 1,
updatedAt: now,
})
)
);
await ctx.db.insert("projectEvents", {
createdAt: now,
data: { host: result.source.host, url: result.source.url },
kind: "project.connected",
projectId: result.id as unknown as Id<"projects">,
});
return existing._id;
}
const projectId = await ctx.db.insert("projects", {
createdAt: timestamp,
defaultBranch: args.repository.defaultBranch,
description: args.repository.description,
name: args.repository.name,
ownerId: args.ownerId,
provider: "github",
providerRepoId: args.repository.id,
repoName: args.repository.name,
repoOwner: args.repository.owner,
repoUrl: args.repository.url,
updatedAt: timestamp,
});
const artifacts = createInitialArtifacts({
defaultBranch: args.repository.defaultBranch,
description: args.repository.description,
name: args.repository.name,
repoName: args.repository.name,
repoOwner: args.repository.owner,
repoUrl: args.repository.url,
});
await Promise.all(
artifacts.map(({ content, path }) =>
ctx.db.insert("projectArtifacts", {
content,
createdAt: timestamp,
path,
projectId,
revision: 1,
updatedAt: timestamp,
})
)
);
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { provider: "github", repository: args.repository.url },
kind: "project.connected",
projectId,
});
return projectId;
return result;
},
});
// ---------------------------------------------------------------------------
// Internal: put a context document
// ---------------------------------------------------------------------------
export const putContextDocument = internalMutation({
args: {
userId: v.string(),
projectId: v.id("projects"),
write: v.union(
v.object({
_tag: v.literal("Repository"),
content: v.string(),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
sourceUrl: v.string(),
}),
v.object({
_tag: v.literal("PublicTextUrl"),
content: v.string(),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
sourceUrl: v.string(),
}),
v.object({
_tag: v.literal("UserText"),
content: v.string(),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
origin: v.union(v.literal("paste"), v.literal("upload")),
})
),
},
handler: async (ctx, args) => {
const { makeProjectMutationStore } = await import("./projectStore");
const store = makeProjectMutationStore(ctx);
return store.putContext(args.userId, args.projectId, args.write as never);
},
});
// ---------------------------------------------------------------------------
// Public query: list projects
// ---------------------------------------------------------------------------
export const list = query({
args: {},
handler: async (ctx) => {
const ownerId = await requireAuthUserId(ctx);
return await ctx.db
.query("projects")
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
.order("desc")
.take(50);
handler: async (ctx): Promise<ProjectView[]> => {
const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectQueryStore } = await import("./projectStore");
const store = makeProjectQueryStore(ctx);
return store.listProjects(userId);
},
});
// ---------------------------------------------------------------------------
// Public query: get a project
// ---------------------------------------------------------------------------
export const get = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx);
const project = await ctx.db.get("projects", args.projectId);
return project?.ownerId === ownerId ? project : null;
handler: async (ctx, args): Promise<ProjectView | null> => {
const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectQueryStore } = await import("./projectStore");
const store = makeProjectQueryStore(ctx);
return store.getProject(userId, args.projectId);
},
});
// ---------------------------------------------------------------------------
// Public action: import a public Git repository
// (daemon wiring added in the Daemon phase)
// ---------------------------------------------------------------------------
export const importPublicGit = action({
args: { repositoryUrl: v.string() },
handler: async (_ctx, args): Promise<ProjectImportOutcome> => {
// Normalize through the domain layer to validate the URL before the
// daemon adapter is wired.
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl));
// The daemon PublicGit adapter is wired in the Daemon phase.
throw new Error(
"PublicGit import is not yet wired to the daemon adapter"
);
},
});
// ---------------------------------------------------------------------------
// Public mutation: put context text (paste/upload)
// ---------------------------------------------------------------------------
export const putText = internalMutation({
args: {
projectId: v.id("projects"),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
content: v.string(),
origin: v.union(v.literal("paste"), v.literal("upload")),
},
handler: async (ctx, args) => {
const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectMutationStore } = await import("./projectStore");
const store = makeProjectMutationStore(ctx);
return store.putContext(userId, args.projectId, {
_tag: "UserText" as const,
content: args.content,
kind: args.kind,
origin: args.origin,
});
},
});