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,9 +1,6 @@
import { v } from "convex/values";
export const ARTIFACT_PATHS = [
"project.md",
"business.md",
"design.md",
"agent.md",
"work.md",
"steps.md",
@@ -17,9 +14,6 @@ export const ARTIFACT_PATHS = [
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
export const artifactPath = v.union(
v.literal("project.md"),
v.literal("business.md"),
v.literal("design.md"),
v.literal("agent.md"),
v.literal("work.md"),
v.literal("steps.md"),
@@ -30,71 +24,48 @@ export const artifactPath = v.union(
v.literal("card.md")
);
interface ProjectSeed {
readonly defaultBranch: string;
readonly description?: string;
readonly name: string;
readonly repoName: string;
readonly repoOwner: string;
readonly repoUrl: string;
}
export const createInitialArtifacts = (
project: ProjectSeed
): readonly { path: ArtifactPath; content: string }[] => {
const repository = `${project.repoOwner}/${project.repoName}`;
const purpose = project.description ?? `Maintain and improve ${repository}.`;
return [
{
content: `# ${project.name}\n\nRepository: [${repository}](${project.repoUrl})\n\n## Project knowledge\n\n- [Business](business.md)\n- [Design](design.md)\n- [Agent](agent.md)\n\n## Delivery\n\n- [Work](work.md)\n- [Steps](steps.md)\n- [Artifacts](artifacts.md)\n`,
path: "project.md",
},
{
content: `# Business\n\n## Purpose\n\n${purpose}\n\n## Source of truth\n\nThe connected GitHub repository and its project issues define the current product work.\n`,
path: "business.md",
},
{
content: `# Design\n\n## Repository boundary\n\nWork targets \`${repository}\` on \`${project.defaultBranch}\`. Preserve its established architecture and conventions before introducing new patterns.\n\n## Decision record\n\nRecord issue-specific architecture decisions here when they affect later work.\n`,
path: "design.md",
},
{
content: `# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n`,
path: "agent.md",
},
{
content:
"# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
path: "work.md",
},
{
content:
"# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
path: "steps.md",
},
{
content:
"# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
path: "artifacts.md",
},
{
content:
"# Project events\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. These are durable project events and drive the web status.\n",
path: "signals.md",
},
{
content:
"# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
path: "agent-manager.md",
},
{
content: `# Context\n\n- Provider: GitHub\n- Repository: ${repository}\n- URL: ${project.repoUrl}\n- Default branch: ${project.defaultBranch}\n\nIssue-specific facts belong below this repository context.\n`,
path: "context.md",
},
{
content:
"# Project card\n\nThe web project card is the interface between UI and packages. It reads `projects.list`, `project-artifacts.list`, and `project-issues.list`; it writes through `projects.connectGitHub`, `project-issues.create`, and `project-issues.begin`. The issue-scoped Flue agent reads `agent-workspace.get`, publishes with `agent-workspace.updateArtifact`, and reports state with `agent-workspace.setStatus`. Filesystem and shell operations are executed by the AgentOS sandbox adapter through durable daemon commands.\n",
path: "card.md",
},
];
};
export const createInitialArtifacts = (): readonly {
path: ArtifactPath;
content: string;
}[] => [
{
content:
"# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n",
path: "agent.md",
},
{
content:
"# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
path: "work.md",
},
{
content:
"# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
path: "steps.md",
},
{
content:
"# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
path: "artifacts.md",
},
{
content:
"# Project events\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. These are durable project events and drive the web status.\n",
path: "signals.md",
},
{
content:
"# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
path: "agent-manager.md",
},
{
content:
"# Context\n\nCanonical project knowledge is staged under /workspace/context. Issue-specific facts belong below.\n",
path: "context.md",
},
{
content:
"# Project card\n\nThe web project workspace reads and writes Projects, context documents, artifacts, and issues through the generated Convex interface. Agent work uses the issue-scoped workspace and durable daemon commands.\n",
path: "card.md",
},
];

View File

@@ -40,3 +40,41 @@ export const requireOrganizationMember = async (
}
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 };
};

View File

@@ -1,14 +1,14 @@
import { v } from "convex/values";
import { query } from "./_generated/server";
import { requireAuthUserId } from "./authz"
import { requireProjectMember } from "./authz";
export const list = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx);
const project = await ctx.db.get("projects", args.projectId);
if (!project || project.ownerId !== ownerId) {
try {
await requireProjectMember(ctx, args.projectId);
} catch {
return [];
}
return await ctx.db

View File

@@ -7,9 +7,6 @@ import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
// has no type definition under the Convex tsconfig (which scopes `types` to
// node), so declare the minimal shape the test relies on.
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
@@ -19,26 +16,45 @@ declare global {
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
// `agentWorkspace` gates agent-controlled mutations on `env.FLUE_DB_TOKEN`.
// Vitest loads the repo `.env`, so resolve the real value from the env module
// rather than hardcoding a constant that would mismatch the loaded secret.
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
const ID_A = "https://convex.test|user-a";
const identityA = { tokenIdentifier: ID_A };
const repository = {
defaultBranch: "main",
id: 12345,
name: "zopu",
owner: "puter",
const SOURCE = {
host: "github.com",
projectName: "zopu",
repositoryPath: "puter/zopu",
normalizedUrl: "https://github.com/puter/zopu",
url: "https://github.com/puter/zopu",
};
// Read every projectEvents row for a project via the same indexes the control
// plane writes through, returning them oldest-first. Reads MUST go through the
// same test instance that performed the writes; a fresh `convexTest()` is an
// isolated in-memory backend with no shared state.
const REMOTE = {
defaultBranch: "main",
documents: [
{
kind: "readme" as const,
path: "README.md",
content: "# zopu\n\nRepository: https://github.com/puter/zopu\n",
},
],
warnings: [],
};
const createTestProject = async (t: TestConvex<typeof schema>): Promise<Id<"projects">> => {
// Ensure personal organization for the user
await t.withIdentity(identityA).mutation(api.organizations.ensurePersonalOrganization);
// Persist the public Git import
const outcome = await t
.withIdentity(identityA)
.mutation(internal.projects.persistPublicGitImport, {
userId: ID_A,
source: SOURCE,
remote: REMOTE,
});
return outcome.id as unknown as Id<"projects">;
};
const eventsForProject = async (
t: TestConvex<typeof schema>,
projectId: Id<"projects">
@@ -56,10 +72,7 @@ const eventsForProject = async (
describe("projectEvents", () => {
test("project connect emits a project.connected event", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const events = await eventsForProject(t, projectId);
expect(events).toHaveLength(1);
@@ -67,18 +80,11 @@ describe("projectEvents", () => {
expect(events[0].projectId).toBe(projectId);
expect(events[0].issueId).toBeUndefined();
expect(events[0].runId).toBeUndefined();
expect(events[0].data).toMatchObject({
provider: "github",
repository: repository.url,
});
});
test("issue create and begin emit issue.created then issue.queued events", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
@@ -119,10 +125,7 @@ describe("projectEvents", () => {
test("artifact update emits an artifact.updated event", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
@@ -149,10 +152,7 @@ describe("projectEvents", () => {
test("agent status emits an agent.<status> event linked to the run", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
@@ -192,10 +192,7 @@ describe("projectEvents", () => {
test("wrong agent token is rejected before any event is written", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {

View File

@@ -2,20 +2,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 { requireAuthUserId } from "./authz"
const requireProjectOwner = async (
ctx: MutationCtx,
projectId: Id<"projects">,
ownerId: string
) => {
const project = await ctx.db.get("projects", projectId);
if (!project || project.ownerId !== ownerId) {
throw new ConvexError("Project not found");
}
return project;
};
import { requireProjectMember } from "./authz";
export const create = mutation({
args: {
@@ -24,8 +11,7 @@ export const create = mutation({
title: v.string(),
},
handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx);
await requireProjectOwner(ctx, args.projectId, ownerId);
await requireProjectMember(ctx, args.projectId);
const title = args.title.trim();
const body = args.body.trim();
if (title.length < 3 || title.length > 160) {
@@ -81,12 +67,11 @@ export const create = mutation({
export const begin = mutation({
args: { issueId: v.id("projectIssues") },
handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx);
const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await requireProjectOwner(ctx, issue.projectId, ownerId);
await requireProjectMember(ctx, issue.projectId);
if (issue.status === "queued" || issue.status === "working") {
return issue.status;
}
@@ -109,12 +94,11 @@ export const begin = mutation({
export const markDispatchFailed = mutation({
args: { error: v.string(), issueId: v.id("projectIssues") },
handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx);
const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await requireProjectOwner(ctx, issue.projectId, ownerId);
await requireProjectMember(ctx, issue.projectId);
const timestamp = Date.now();
await ctx.db.patch("projectIssues", issue._id, {
status: "failed",
@@ -133,9 +117,9 @@ export const markDispatchFailed = mutation({
export const list = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx);
const project = await ctx.db.get("projects", args.projectId);
if (!project || project.ownerId !== ownerId) {
try {
await requireProjectMember(ctx, args.projectId);
} catch {
return [];
}
return await ctx.db

View File

@@ -0,0 +1,421 @@
import {
CONTEXT_KINDS,
contextPathForKind,
decideContextWrite,
makeInitialContext,
type ContextDocumentState,
type ContextKind,
type ContextWrite,
type PreparedPublicGitSource,
type ProjectImportOutcome,
type ProjectView,
type PublicGitImportResult,
} from "@code/primitives/project";
import type { Doc, Id } from "./_generated/dataModel";
import type {
ActionCtx,
MutationCtx,
QueryCtx,
} from "./_generated/server";
import { requireCurrentOrganization, requireProjectMember } from "./authz";
// ---------------------------------------------------------------------------
// View mappers
// ---------------------------------------------------------------------------
interface SourceRow extends Doc<"projectSources"> {}
const toContextDocumentState = (
doc: Doc<"projectContextDocuments">
): ContextDocumentState => ({
content: doc.content,
kind: doc.kind,
origin: doc.origin,
path: doc.path,
revision: doc.revision,
sourceUrl: doc.sourceUrl ?? undefined,
});
const toProjectView = async (
ctx: QueryCtx,
project: Doc<"projects">
): Promise<ProjectView> => {
const [sources, contextDocs] = await Promise.all([
ctx.db
.query("projectSources")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.collect(),
ctx.db
.query("projectContextDocuments")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.collect(),
]);
return {
contextDocuments: contextDocs.map(toContextDocumentState),
createdAt: project.createdAt as never,
id: project._id as never,
name: project.name,
organizationId: project.organizationId as never,
sources: sources.map((s) => ({
createdAt: s.createdAt as never,
defaultBranch: s.defaultBranch,
host: s.host,
kind: "git" as const,
normalizedUrl: s.normalizedUrl,
projectId: s.projectId as never,
repositoryPath: s.repositoryPath,
updatedAt: s.updatedAt as never,
url: s.url,
})),
updatedAt: project.updatedAt as never,
};
};
const toImportOutcome = async (
ctx: QueryCtx,
project: Doc<"projects">,
source: SourceRow
): Promise<ProjectImportOutcome> => {
const contextDocs = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.collect();
return {
contextDocuments: contextDocs.map(toContextDocumentState),
createdAt: project.createdAt as never,
id: project._id as never,
name: project.name,
organizationId: project.organizationId as never,
source: {
createdAt: source.createdAt as never,
defaultBranch: source.defaultBranch,
host: source.host,
kind: "git" as const,
normalizedUrl: source.normalizedUrl,
projectId: source.projectId as never,
repositoryPath: source.repositoryPath,
updatedAt: source.updatedAt as never,
url: source.url,
},
updatedAt: project.updatedAt as never,
};
};
// ---------------------------------------------------------------------------
// Query store — read-only ctx.db access
// ---------------------------------------------------------------------------
export const makeProjectQueryStore = (ctx: QueryCtx) => ({
listProjects: async (userId: string): Promise<ProjectView[]> => {
const { organizationId } = await resolveOrgForUser(ctx, userId);
const projects = await ctx.db
.query("projects")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", organizationId)
)
.order("desc")
.take(50);
return Promise.all(projects.map((p) => toProjectView(ctx, p)));
},
getProject: async (
userId: string,
projectId: Id<"projects">
): Promise<ProjectView | null> => {
const { organizationId } = await resolveOrgForUser(ctx, userId);
const project = await ctx.db.get(projectId);
if (!project || project.organizationId !== organizationId) {
return null;
}
return toProjectView(ctx, project);
},
});
// ---------------------------------------------------------------------------
// Mutation store — transactional ctx.db writes
// ---------------------------------------------------------------------------
export const makeProjectMutationStore = (ctx: MutationCtx) => ({
putContext: async (
userId: string,
projectId: Id<"projects">,
write: ContextWrite
): Promise<{ revision: number }> => {
const { organizationId } = await resolveProjectOrg(ctx, projectId, userId);
const path = contextPathForKind(write.kind);
const existing = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", projectId).eq("path", path)
)
.unique();
const result = await Effect.runPromise(
decideContextWrite({
existing: existing ? toContextDocumentState(existing) : undefined,
write,
})
);
const now = Date.now();
if (!result.changed) {
return { revision: result.document.revision };
}
if (existing) {
await ctx.db.patch(existing._id, {
content: result.document.content,
origin: result.document.origin,
revision: result.document.revision,
sourceUrl: result.document.sourceUrl,
updatedAt: now,
});
} else {
await ctx.db.insert("projectContextDocuments", {
content: result.document.content,
createdAt: now,
kind: result.document.kind,
organizationId,
origin: result.document.origin,
path: result.document.path,
projectId,
revision: result.document.revision,
sourceUrl: result.document.sourceUrl,
updatedAt: now,
});
}
return { revision: result.document.revision };
},
});
// ---------------------------------------------------------------------------
// Action store — calls narrowly scoped internal mutations/queries
// ---------------------------------------------------------------------------
export const makeProjectActionStore = (ctx: ActionCtx) => ({
getCurrentOrganization: async (userId: string) => {
return userId;
},
listProjects: async (userId: string) => {
return userId;
},
getProject: async (userId: string, _projectId: Id<"projects">) => {
return userId;
},
persistPublicGitImport: async (
_userId: string,
_source: PreparedPublicGitSource,
_remote: PublicGitImportResult
): Promise<ProjectImportOutcome> => {
throw new Error("persistPublicGitImport must be called via internal mutation");
},
putContext: async (
_userId: string,
_projectId: Id<"projects">,
_write: ContextWrite
): Promise<{ revision: number }> => {
throw new Error("putContext must be called via internal mutation");
},
});
// We need Effect for decideContextWrite at runtime.
import { Effect } from "effect";
// ---------------------------------------------------------------------------
// Internal mutation store — the actual transactional persistence
// ---------------------------------------------------------------------------
export const persistPublicGitImportTransaction = async (
ctx: MutationCtx,
userId: string,
source: PreparedPublicGitSource,
remote: PublicGitImportResult
): Promise<ProjectImportOutcome> => {
const { organizationId } = await resolveOrgForUser(ctx, userId);
// Idempotency: lookup by normalizedUrl within the organization
const existingSource = await ctx.db
.query("projectSources")
.withIndex("by_organization_and_normalizedUrl", (q) =>
q
.eq("organizationId", organizationId)
.eq("normalizedUrl", source.normalizedUrl)
)
.unique();
const now = Date.now();
if (existingSource) {
const project = await ctx.db.get(existingSource.projectId);
if (!project) {
throw new Error("Project not found for existing source");
}
// Update source metadata
await ctx.db.patch(existingSource._id, {
defaultBranch: remote.defaultBranch,
updatedAt: now,
});
// Apply remote documents as repository writes
for (const doc of remote.documents) {
await applyRepositoryWrite(ctx, project._id, organizationId, doc, source.url, now);
}
await ctx.db.patch(project._id, { updatedAt: now });
const updatedSource = await ctx.db.get(existingSource._id);
return toImportOutcome(ctx, project, updatedSource ?? existingSource);
}
// Create new project
const projectId = await ctx.db.insert("projects", {
createdAt: now,
name: source.projectName,
organizationId,
updatedAt: now,
});
// Create source
const sourceId = await ctx.db.insert("projectSources", {
createdAt: now,
defaultBranch: remote.defaultBranch,
host: source.host,
kind: "git",
normalizedUrl: source.normalizedUrl,
organizationId,
projectId,
repositoryPath: source.repositoryPath,
updatedAt: now,
url: source.url,
});
// Seed six canonical context documents
const seeds = makeInitialContext(source.projectName, source.url);
for (const seed of seeds) {
await ctx.db.insert("projectContextDocuments", {
content: seed.content,
createdAt: now,
kind: seed.kind,
organizationId,
origin: seed.origin,
path: seed.path,
projectId,
revision: seed.revision,
sourceUrl: seed.sourceUrl,
updatedAt: now,
});
}
// Apply remote documents (overrides seeds)
for (const doc of remote.documents) {
await applyRepositoryWrite(ctx, projectId, organizationId, doc, source.url, now);
}
const createdProject = await ctx.db.get(projectId);
const createdSource = await ctx.db.get(sourceId);
if (!createdProject || !createdSource) {
throw new Error("Failed to read created project");
}
return toImportOutcome(ctx, createdProject, createdSource);
};
const applyRepositoryWrite = async (
ctx: MutationCtx,
projectId: Id<"projects">,
organizationId: Id<"organizations">,
doc: { kind: ContextKind; path: string; content: string },
sourceUrl: string,
now: number
) => {
const path = contextPathForKind(doc.kind);
const existing = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", projectId).eq("path", path)
)
.unique();
const result = await Effect.runPromise(
decideContextWrite({
existing: existing ? toContextDocumentState(existing) : undefined,
write: {
_tag: "Repository" as const,
content: doc.content,
kind: doc.kind,
sourceUrl,
},
})
);
if (!result.changed) return;
if (existing) {
await ctx.db.patch(existing._id, {
content: result.document.content,
origin: result.document.origin,
revision: result.document.revision,
sourceUrl: result.document.sourceUrl,
updatedAt: now,
});
} else {
await ctx.db.insert("projectContextDocuments", {
content: result.document.content,
createdAt: now,
kind: result.document.kind,
organizationId,
origin: result.document.origin,
path: result.document.path,
projectId,
revision: result.document.revision,
sourceUrl: result.document.sourceUrl,
updatedAt: now,
});
}
};
// ---------------------------------------------------------------------------
// Organization resolvers
// ---------------------------------------------------------------------------
const resolveOrgForUser = async (
ctx: QueryCtx | MutationCtx,
userId: string
): Promise<{ organizationId: Id<"organizations"> }> => {
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 Error("Organization not found");
}
return { organizationId: organization._id };
};
const resolveProjectOrg = async (
ctx: QueryCtx | MutationCtx,
projectId: Id<"projects">,
userId: string
): Promise<{ organizationId: Id<"organizations"> }> => {
const project = await ctx.db.get(projectId);
if (!project) {
throw new Error("Project not found");
}
const { organizationId } = await resolveOrgForUser(ctx, userId);
if (project.organizationId !== organizationId) {
throw new Error("Project not found");
}
return { organizationId };
};
// Re-export for convenience
export {
CONTEXT_KINDS,
requireCurrentOrganization,
requireProjectMember,
};

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,
});
},
});

View File

@@ -63,25 +63,57 @@ export default defineSchema({
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
projects: defineTable({
ownerId: v.string(),
provider: v.literal("github"),
providerRepoId: v.number(),
organizationId: v.id("organizations"),
name: v.string(),
description: v.optional(v.string()),
repoOwner: v.string(),
repoName: v.string(),
repoUrl: v.string(),
defaultBranch: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
projectSources: defineTable({
organizationId: v.id("organizations"),
projectId: v.id("projects"),
kind: v.literal("git"),
url: v.string(),
normalizedUrl: v.string(),
host: v.string(),
repositoryPath: v.string(),
defaultBranch: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_owner_and_createdAt", ["ownerId", "createdAt"])
.index("by_owner_and_repository", [
"ownerId",
"provider",
"repoOwner",
"repoName",
]),
.index("by_project", ["projectId"])
.index("by_organization_and_normalizedUrl", [
"organizationId",
"normalizedUrl",
])
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
projectContextDocuments: defineTable({
organizationId: v.id("organizations"),
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")
),
path: v.string(),
content: v.string(),
revision: v.number(),
origin: v.union(
v.literal("repository"),
v.literal("paste"),
v.literal("upload"),
v.literal("public-url")
),
sourceUrl: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_project", ["projectId"])
.index("by_project_and_kind", ["projectId", "kind"])
.index("by_project_and_path", ["projectId", "path"]),
projectArtifacts: defineTable({
projectId: v.id("projects"),
path: v.string(),
@@ -165,6 +197,7 @@ export default defineSchema({
createdAt: v.number(),
})
.index("by_userId", ["userId"])
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
// -----------------------------------------------------------------

View File

@@ -44,13 +44,6 @@ const problemStatement = {
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
const repository = {
defaultBranch: "main",
id: 12345,
name: "zopu",
owner: "puter",
url: "https://github.com/puter/zopu",
};
// Begin and admit one user message, returning its messageId.
const seedMessage = async (
@@ -79,13 +72,37 @@ const seedMessage = async (
const createProject = async (
t: TestConvex<typeof schema>,
ownerId: string,
repoId = 12345
ownerId: string
): Promise<Id<"projects">> => {
return await t.mutation(internal.projects.storeGitHubProject, {
ownerId,
repository: { ...repository, id: repoId },
});
// Resolve or create the owner's personal organization
const ownerIdentity = { tokenIdentifier: ownerId };
await t
.withIdentity(ownerIdentity)
.mutation(api.organizations.ensurePersonalOrganization);
const outcome = await t
.withIdentity(ownerIdentity)
.mutation(internal.projects.persistPublicGitImport, {
userId: ownerId,
source: {
host: "github.com",
projectName: "test-repo",
repositoryPath: `owner-${ownerId.slice(-4)}/test-repo`,
normalizedUrl: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
url: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
},
remote: {
defaultBranch: "main",
documents: [
{
kind: "readme" as const,
path: "README.md",
content: "# test-repo\n\nTest.\n",
},
],
warnings: [],
},
});
return outcome.id as unknown as Id<"projects">;
};
describe("signals", () => {

View File

@@ -161,13 +161,9 @@ const resolveSources = async (
};
/**
* Resolve whether an optional project belongs to the same organization. Until
* projects carry an explicit `organizationId`, a project is attachable to an
* organization's signal only when the project owner is a member of that
* organization. The caller is already proven to be a member, so this is a
* security-preserving temporary mapping: an organization's project set is the
* intersection of (caller is a member) and (project owner is a member), which
* cannot weaken cross-tenant isolation.
* Verify a project belongs to the same organization. Projects now carry an
* explicit `organizationId`; the caller is already proven to be a member, so
* this is a direct invariant check with no cross-tenant leakage.
*/
const authorizeProject = async (
ctx: MutationCtx,
@@ -178,13 +174,7 @@ const authorizeProject = async (
if (!project) {
throw new ConvexError("Project not found");
}
const ownerMembership = await ctx.db
.query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) =>
q.eq("organizationId", organizationId).eq("userId", project.ownerId)
)
.unique();
if (!ownerMembership) {
if (project.organizationId !== organizationId) {
throw new ConvexError("Project does not belong to the target organization");
}
};

View File

@@ -7,6 +7,7 @@
"scripts": {
"dev": "convex dev --env-file ../../.env",
"dev:setup": "convex dev --env-file ../../.env --configure --until-success",
"check-types": "tsc --noEmit -p convex/tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},