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"; import { v } from "convex/values";
export const ARTIFACT_PATHS = [ export const ARTIFACT_PATHS = [
"project.md",
"business.md",
"design.md",
"agent.md", "agent.md",
"work.md", "work.md",
"steps.md", "steps.md",
@@ -17,9 +14,6 @@ export const ARTIFACT_PATHS = [
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number]; export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
export const artifactPath = v.union( export const artifactPath = v.union(
v.literal("project.md"),
v.literal("business.md"),
v.literal("design.md"),
v.literal("agent.md"), v.literal("agent.md"),
v.literal("work.md"), v.literal("work.md"),
v.literal("steps.md"), v.literal("steps.md"),
@@ -30,36 +24,13 @@ export const artifactPath = v.union(
v.literal("card.md") v.literal("card.md")
); );
interface ProjectSeed { export const createInitialArtifacts = (): readonly {
readonly defaultBranch: string; path: ArtifactPath;
readonly description?: string; content: 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`, content:
path: "project.md", "# 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",
},
{
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", path: "agent.md",
}, },
{ {
@@ -88,13 +59,13 @@ export const createInitialArtifacts = (
path: "agent-manager.md", 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`, content:
"# Context\n\nCanonical project knowledge is staged under /workspace/context. Issue-specific facts belong below.\n",
path: "context.md", path: "context.md",
}, },
{ {
content: 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", "# 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", path: "card.md",
}, },
]; ];
};

View File

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

View File

@@ -7,9 +7,6 @@ import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import schema from "./schema"; 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 { declare global {
interface ImportMeta { interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>; readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
@@ -19,26 +16,45 @@ declare global {
const modules = import.meta.glob("./**/*.ts"); const modules = import.meta.glob("./**/*.ts");
const api = anyApi; 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 FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
const ID_A = "https://convex.test|user-a"; const ID_A = "https://convex.test|user-a";
const identityA = { tokenIdentifier: ID_A }; const identityA = { tokenIdentifier: ID_A };
const repository = { const SOURCE = {
defaultBranch: "main", host: "github.com",
id: 12345, projectName: "zopu",
name: "zopu", repositoryPath: "puter/zopu",
owner: "puter", normalizedUrl: "https://github.com/puter/zopu",
url: "https://github.com/puter/zopu", url: "https://github.com/puter/zopu",
}; };
// Read every projectEvents row for a project via the same indexes the control const REMOTE = {
// plane writes through, returning them oldest-first. Reads MUST go through the defaultBranch: "main",
// same test instance that performed the writes; a fresh `convexTest()` is an documents: [
// isolated in-memory backend with no shared state. {
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 ( const eventsForProject = async (
t: TestConvex<typeof schema>, t: TestConvex<typeof schema>,
projectId: Id<"projects"> projectId: Id<"projects">
@@ -56,10 +72,7 @@ const eventsForProject = async (
describe("projectEvents", () => { describe("projectEvents", () => {
test("project connect emits a project.connected event", async () => { test("project connect emits a project.connected event", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const events = await eventsForProject(t, projectId); const events = await eventsForProject(t, projectId);
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
@@ -67,18 +80,11 @@ describe("projectEvents", () => {
expect(events[0].projectId).toBe(projectId); expect(events[0].projectId).toBe(projectId);
expect(events[0].issueId).toBeUndefined(); expect(events[0].issueId).toBeUndefined();
expect(events[0].runId).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 () => { test("issue create and begin emit issue.created then issue.queued events", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const issueId = await t const issueId = await t
.withIdentity(identityA) .withIdentity(identityA)
@@ -119,10 +125,7 @@ describe("projectEvents", () => {
test("artifact update emits an artifact.updated event", async () => { test("artifact update emits an artifact.updated event", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const issueId = await t const issueId = await t
.withIdentity(identityA) .withIdentity(identityA)
.mutation(api.projectIssues.create, { .mutation(api.projectIssues.create, {
@@ -149,10 +152,7 @@ describe("projectEvents", () => {
test("agent status emits an agent.<status> event linked to the run", async () => { test("agent status emits an agent.<status> event linked to the run", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const issueId = await t const issueId = await t
.withIdentity(identityA) .withIdentity(identityA)
.mutation(api.projectIssues.create, { .mutation(api.projectIssues.create, {
@@ -192,10 +192,7 @@ describe("projectEvents", () => {
test("wrong agent token is rejected before any event is written", async () => { test("wrong agent token is rejected before any event is written", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const issueId = await t const issueId = await t
.withIdentity(identityA) .withIdentity(identityA)
.mutation(api.projectIssues.create, { .mutation(api.projectIssues.create, {

View File

@@ -2,20 +2,7 @@ import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server"; import { requireProjectMember } from "./authz";
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;
};
export const create = mutation({ export const create = mutation({
args: { args: {
@@ -24,8 +11,7 @@ export const create = mutation({
title: v.string(), title: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx); await requireProjectMember(ctx, args.projectId);
await requireProjectOwner(ctx, args.projectId, ownerId);
const title = args.title.trim(); const title = args.title.trim();
const body = args.body.trim(); const body = args.body.trim();
if (title.length < 3 || title.length > 160) { if (title.length < 3 || title.length > 160) {
@@ -81,12 +67,11 @@ export const create = mutation({
export const begin = mutation({ export const begin = mutation({
args: { issueId: v.id("projectIssues") }, args: { issueId: v.id("projectIssues") },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx);
const issue = await ctx.db.get("projectIssues", args.issueId); const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) { if (!issue) {
throw new ConvexError("Issue not found"); throw new ConvexError("Issue not found");
} }
await requireProjectOwner(ctx, issue.projectId, ownerId); await requireProjectMember(ctx, issue.projectId);
if (issue.status === "queued" || issue.status === "working") { if (issue.status === "queued" || issue.status === "working") {
return issue.status; return issue.status;
} }
@@ -109,12 +94,11 @@ export const begin = mutation({
export const markDispatchFailed = mutation({ export const markDispatchFailed = mutation({
args: { error: v.string(), issueId: v.id("projectIssues") }, args: { error: v.string(), issueId: v.id("projectIssues") },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx);
const issue = await ctx.db.get("projectIssues", args.issueId); const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) { if (!issue) {
throw new ConvexError("Issue not found"); throw new ConvexError("Issue not found");
} }
await requireProjectOwner(ctx, issue.projectId, ownerId); await requireProjectMember(ctx, issue.projectId);
const timestamp = Date.now(); const timestamp = Date.now();
await ctx.db.patch("projectIssues", issue._id, { await ctx.db.patch("projectIssues", issue._id, {
status: "failed", status: "failed",
@@ -133,9 +117,9 @@ export const markDispatchFailed = mutation({
export const list = query({ export const list = query({
args: { projectId: v.id("projects") }, args: { projectId: v.id("projects") },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const ownerId = await requireAuthUserId(ctx); try {
const project = await ctx.db.get("projects", args.projectId); await requireProjectMember(ctx, args.projectId);
if (!project || project.ownerId !== ownerId) { } catch {
return []; return [];
} }
return await ctx.db 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 { v } from "convex/values";
import { z } from "zod";
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 type { Id } from "./_generated/dataModel";
import { internal } from "./_generated/api";
import { action, internalMutation, query } from "./_generated/server"; import { action, internalMutation, query } from "./_generated/server";
import { requireCurrentOrganization } from "./authz";
import { createInitialArtifacts } from "./artifactModel"; import { createInitialArtifacts } from "./artifactModel";
import { requireAuthUserId } from "./authz" import { persistPublicGitImportTransaction } from "./projectStore";
const gitHubRepositorySchema = z.object({ // ---------------------------------------------------------------------------
default_branch: z.string(), // Internal: persist a public Git import in one transaction
description: z.string().nullable(), // ---------------------------------------------------------------------------
html_url: z.url(),
id: z.number(),
name: z.string(),
owner: z.object({ login: z.string() }),
});
const parseRepository = (value: string): { owner: string; repo: string } => { export const persistPublicGitImport = internalMutation({
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({
args: { args: {
ownerId: v.string(), userId: v.string(),
repository: v.object({ source: v.object({
defaultBranch: v.string(), host: v.string(),
description: v.optional(v.string()), projectName: v.string(),
id: v.number(), repositoryPath: v.string(),
name: v.string(), normalizedUrl: v.string(),
owner: v.string(),
url: 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) => { handler: async (ctx, args): Promise<ProjectImportOutcome> => {
const existing = await ctx.db const result = await persistPublicGitImportTransaction(
.query("projects") ctx,
.withIndex("by_owner_and_repository", (q) => args.userId,
q args.source as PreparedPublicGitSource,
.eq("ownerId", args.ownerId) args.remote as PublicGitImportResult
.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,
});
return existing._id;
}
const projectId = await ctx.db.insert("projects", { // Seed operational artifacts on first creation (detected by checking
createdAt: timestamp, // existing artifacts)
defaultBranch: args.repository.defaultBranch, const existingArtifacts = await ctx.db
description: args.repository.description, .query("projectArtifacts")
name: args.repository.name, .withIndex("by_project", (q) => q.eq("projectId", result.id as unknown as Id<"projects">))
ownerId: args.ownerId, .first();
provider: "github", if (!existingArtifacts) {
providerRepoId: args.repository.id, const now = Date.now();
repoName: args.repository.name, const artifacts = createInitialArtifacts();
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( await Promise.all(
artifacts.map(({ content, path }) => artifacts.map(({ content, path }) =>
ctx.db.insert("projectArtifacts", { ctx.db.insert("projectArtifacts", {
content, content,
createdAt: timestamp, createdAt: now,
path, path,
projectId, projectId: result.id as unknown as Id<"projects">,
revision: 1, revision: 1,
updatedAt: timestamp, updatedAt: now,
}) })
) )
); );
await ctx.db.insert("projectEvents", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: now,
data: { provider: "github", repository: args.repository.url }, data: { host: result.source.host, url: result.source.url },
kind: "project.connected", kind: "project.connected",
projectId, projectId: result.id as unknown as Id<"projects">,
}); });
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({ export const list = query({
args: {}, args: {},
handler: async (ctx) => { handler: async (ctx): Promise<ProjectView[]> => {
const ownerId = await requireAuthUserId(ctx); const { userId } = await requireCurrentOrganization(ctx);
return await ctx.db const { makeProjectQueryStore } = await import("./projectStore");
.query("projects") const store = makeProjectQueryStore(ctx);
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId)) return store.listProjects(userId);
.order("desc")
.take(50);
}, },
}); });
// ---------------------------------------------------------------------------
// Public query: get a project
// ---------------------------------------------------------------------------
export const get = query({ export const get = query({
args: { projectId: v.id("projects") }, args: { projectId: v.id("projects") },
handler: async (ctx, args) => { handler: async (ctx, args): Promise<ProjectView | null> => {
const ownerId = await requireAuthUserId(ctx); const { userId } = await requireCurrentOrganization(ctx);
const project = await ctx.db.get("projects", args.projectId); const { makeProjectQueryStore } = await import("./projectStore");
return project?.ownerId === ownerId ? project : null; 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_daemonId_and_createdAt", ["daemonId", "createdAt"])
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]), .index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
projects: defineTable({ projects: defineTable({
ownerId: v.string(), organizationId: v.id("organizations"),
provider: v.literal("github"),
providerRepoId: v.number(),
name: v.string(), name: v.string(),
description: v.optional(v.string()), description: v.optional(v.string()),
repoOwner: v.string(), createdAt: v.number(),
repoName: v.string(), updatedAt: v.number(),
repoUrl: v.string(), }).index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
defaultBranch: v.string(), 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(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}) })
.index("by_owner_and_createdAt", ["ownerId", "createdAt"]) .index("by_project", ["projectId"])
.index("by_owner_and_repository", [ .index("by_organization_and_normalizedUrl", [
"ownerId", "organizationId",
"provider", "normalizedUrl",
"repoOwner", ])
"repoName", .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({ projectArtifacts: defineTable({
projectId: v.id("projects"), projectId: v.id("projects"),
path: v.string(), path: v.string(),
@@ -165,6 +197,7 @@ export default defineSchema({
createdAt: v.number(), createdAt: v.number(),
}) })
.index("by_userId", ["userId"]) .index("by_userId", ["userId"])
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]), .index("by_organizationId_and_userId", ["organizationId", "userId"]),
// ----------------------------------------------------------------- // -----------------------------------------------------------------

View File

@@ -44,13 +44,6 @@ const problemStatement = {
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" }; 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. // Begin and admit one user message, returning its messageId.
const seedMessage = async ( const seedMessage = async (
@@ -79,13 +72,37 @@ const seedMessage = async (
const createProject = async ( const createProject = async (
t: TestConvex<typeof schema>, t: TestConvex<typeof schema>,
ownerId: string, ownerId: string
repoId = 12345
): Promise<Id<"projects">> => { ): Promise<Id<"projects">> => {
return await t.mutation(internal.projects.storeGitHubProject, { // Resolve or create the owner's personal organization
ownerId, const ownerIdentity = { tokenIdentifier: ownerId };
repository: { ...repository, id: repoId }, 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", () => { describe("signals", () => {

View File

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

View File

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