Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .cursor/hooks/state/continual-learning.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"version": 1,
"lastRunAtMs": 0,
"turnsSinceLastRun": 1,
"lastTranscriptMtimeMs": null,
"lastProcessedGenerationId": "02f2727d-db77-45c6-bb2d-4163883b764f",
"trialStartedAtMs": null
}
Comment on lines +1 to +8
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

git rm .cursor/hooks/state/continual-learning.json

Repository: Zapdev-labs/zapdev

Length of output: 110


🏁 Script executed:

#!/bin/bash
# Description: Check .gitignore for .cursor/ entry and find other .cursor files

echo "=== Checking .gitignore for .cursor/ entry ==="
if [ -f .gitignore ]; then
  rg -n '\.cursor' .gitignore || echo "No .cursor entry found in .gitignore"
else
  echo ".gitignore file not found"
fi

echo -e "\n=== Finding all .cursor files in the repository ==="
fd -H -t f . .cursor 2>/dev/null || echo "No .cursor files found"

Repository: Zapdev-labs/zapdev

Length of output: 321


Add .cursor/hooks/state/ to .gitignore to prevent committing IDE state files.

The continual-learning.json file has been removed from the PR. However, to prevent similar auto-generated IDE state files from being accidentally committed in the future, add the following to .gitignore:

+# Cursor IDE state files
+.cursor/hooks/state/

Note: The other .cursor/ files (rules and configuration) appear to be intentional and should remain in the repository. Only the state files in .cursor/hooks/state/ should be excluded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.cursor/hooks/state/continual-learning.json around lines 1 - 8, Add a
gitignore rule to exclude the IDE/state files under .cursor/hooks/state/ by
appending an entry like `.cursor/hooks/state/` to .gitignore; ensure you only
ignore the state directory (not other .cursor files like rules or configuration)
and include a short comment explaining it's to prevent committing auto-generated
IDE state files so reviewers understand the intent.

16 changes: 8 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type * as oauth from "../oauth.js";
import type * as polar from "../polar.js";
import type * as projects from "../projects.js";
import type * as rateLimit from "../rateLimit.js";
import type * as schemaProposals from "../schemaProposals.js";
import type * as subscriptions from "../subscriptions.js";
import type * as usage from "../usage.js";
import type * as webhooks from "../webhooks.js";
Expand All @@ -39,6 +40,7 @@ declare const fullApi: ApiFromModules<{
polar: typeof polar;
projects: typeof projects;
rateLimit: typeof rateLimit;
schemaProposals: typeof schemaProposals;
subscriptions: typeof subscriptions;
usage: typeof usage;
webhooks: typeof webhooks;
Expand Down
6 changes: 6 additions & 0 deletions convex/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,8 @@ export const createFragmentForUser = mutation({
files: v.any(),
metadata: v.optional(v.any()),
framework: frameworkEnum,
hasBackend: v.optional(v.boolean()),
backendFiles: v.optional(v.any()),
},
handler: async (ctx, args) => {
const message = await ctx.db.get(args.messageId);
Expand All @@ -600,6 +602,8 @@ export const createFragmentForUser = mutation({
files: args.files,
metadata: args.metadata,
framework: args.framework,
...(args.hasBackend !== undefined && { hasBackend: args.hasBackend }),
...(args.backendFiles !== undefined && { backendFiles: args.backendFiles }),
updatedAt: now,
});
return existingFragment._id;
Expand All @@ -612,6 +616,8 @@ export const createFragmentForUser = mutation({
files: args.files,
metadata: args.metadata,
framework: args.framework,
...(args.hasBackend !== undefined && { hasBackend: args.hasBackend }),
...(args.backendFiles !== undefined && { backendFiles: args.backendFiles }),
createdAt: now,
updatedAt: now,
});
Expand Down
18 changes: 18 additions & 0 deletions convex/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,24 @@ export const createForUser = mutation({
},
});

export const setHasBackendForUser = mutation({
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This mutation is only called from Inngest but is exposed as a public mutation, allowing any unauthenticated client to set hasBackend on any project by supplying the owner's userId. Use internalMutation instead, which restricts access to server-side callers only.

Note: the existing createForUser mutation has the same problem, but this new code shouldn't perpetuate it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At convex/projects.ts, line 475:

<comment>This mutation is only called from Inngest but is exposed as a public `mutation`, allowing any unauthenticated client to set `hasBackend` on any project by supplying the owner's userId. Use `internalMutation` instead, which restricts access to server-side callers only.

Note: the existing `createForUser` mutation has the same problem, but this new code shouldn't perpetuate it.</comment>

<file context>
@@ -472,6 +472,24 @@ export const createForUser = mutation({
   },
 });
 
+export const setHasBackendForUser = mutation({
+  args: {
+    userId: v.string(),
</file context>
Fix with Cubic

args: {
userId: v.string(),
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Authorization check is bypassable — userId comes from the caller's args, not from ctx.auth.getUserIdentity(). Any client can call this public mutation with an arbitrary userId to pass the ownership check. Derive userId from the authenticated session instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At convex/projects.ts, line 477:

<comment>Authorization check is bypassable — `userId` comes from the caller's args, not from `ctx.auth.getUserIdentity()`. Any client can call this public mutation with an arbitrary `userId` to pass the ownership check. Derive `userId` from the authenticated session instead.</comment>

<file context>
@@ -472,6 +472,24 @@ export const createForUser = mutation({
 
+export const setHasBackendForUser = mutation({
+  args: {
+    userId: v.string(),
+    projectId: v.id("projects"),
+    hasBackend: v.boolean(),
</file context>
Fix with Cubic

projectId: v.id("projects"),
hasBackend: v.boolean(),
},
handler: async (ctx, args) => {
const project = await ctx.db.get(args.projectId);
if (!project || project.userId !== args.userId) {
throw new Error("Unauthorized");
}
await ctx.db.patch(args.projectId, {
hasBackend: args.hasBackend,
updatedAt: Date.now(),
});
},
Comment on lines +487 to +502
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Authorization is bypassable because identity is caller-controlled.

Line 477 takes userId from input, and Lines 483-484 trust it for auth without requireAuth(ctx). This permits spoofing ownership checks.

Suggested fix (bind auth to session, not args)
 export const setHasBackendForUser = mutation({
   args: {
-    userId: v.string(),
     projectId: v.id("projects"),
     hasBackend: v.boolean(),
   },
   handler: async (ctx, args) => {
+    const userId = await requireAuth(ctx);
     const project = await ctx.db.get(args.projectId);
-    if (!project || project.userId !== args.userId) {
+    if (!project || project.userId !== userId) {
       throw new Error("Unauthorized");
     }
     await ctx.db.patch(args.projectId, {
       hasBackend: args.hasBackend,
       updatedAt: Date.now(),
     });
   },
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const setHasBackendForUser = mutation({
args: {
userId: v.string(),
projectId: v.id("projects"),
hasBackend: v.boolean(),
},
handler: async (ctx, args) => {
const project = await ctx.db.get(args.projectId);
if (!project || project.userId !== args.userId) {
throw new Error("Unauthorized");
}
await ctx.db.patch(args.projectId, {
hasBackend: args.hasBackend,
updatedAt: Date.now(),
});
},
export const setHasBackendForUser = mutation({
args: {
projectId: v.id("projects"),
hasBackend: v.boolean(),
},
handler: async (ctx, args) => {
const userId = await requireAuth(ctx);
const project = await ctx.db.get(args.projectId);
if (!project || project.userId !== userId) {
throw new Error("Unauthorized");
}
await ctx.db.patch(args.projectId, {
hasBackend: args.hasBackend,
updatedAt: Date.now(),
});
},
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/projects.ts` around lines 475 - 490, The mutation setHasBackendForUser
currently trusts the caller-supplied userId arg for authorization which is
spoofable; update the handler to call requireAuth(ctx) (or otherwise read
ctx.auth.userId) and use the authenticated user id to authorize (compare
ctx.auth.userId to project.userId) instead of args.userId, and remove or ignore
the userId arg in args; also ensure you still fetch the project via
ctx.db.get(args.projectId) and throw Unauthorized if the authenticated user
isn't the owner before patching hasBackend/updatedAt.

});

/**
* Internal: Create a project for a specific user (for use from actions/background jobs)
*/
Expand Down
34 changes: 34 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export const subscriptionIntervalEnum = v.union(
v.literal("yearly")
);

export const schemaProposalStatusEnum = v.union(
v.literal("PENDING"),
v.literal("APPROVED"),
v.literal("REJECTED"),
v.literal("IMPLEMENTED")
);

const polarCustomers = defineTable({
userId: v.string(),
polarCustomerId: v.string(),
Expand All @@ -95,6 +102,7 @@ export default defineSchema({
userId: v.string(),
framework: frameworkEnum,
modelPreference: v.optional(v.string()),
hasBackend: v.optional(v.boolean()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
})
Expand All @@ -121,6 +129,8 @@ export default defineSchema({
files: v.any(),
metadata: v.optional(v.any()),
framework: frameworkEnum,
hasBackend: v.optional(v.boolean()),
backendFiles: v.optional(v.any()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
})
Expand All @@ -135,6 +145,29 @@ export default defineSchema({
})
.index("by_projectId", ["projectId"]),

schemaProposals: defineTable({
projectId: v.id("projects"),
messageId: v.id("messages"),
userId: v.string(),
proposal: v.string(),
status: schemaProposalStatusEnum,
parsedTables: v.optional(v.array(v.object({
name: v.string(),
purpose: v.string(),
fields: v.array(v.string()),
indexes: v.array(v.string()),
}))),
parsedRelationships: v.optional(v.array(v.string())),
approvedAt: v.optional(v.number()),
implementedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_projectId", ["projectId"])
.index("by_projectId_status", ["projectId", "status"])
.index("by_messageId", ["messageId"])
.index("by_userId", ["userId"]),

attachments: defineTable({
type: attachmentTypeEnum,
url: v.string(),
Expand Down Expand Up @@ -264,6 +297,7 @@ export default defineSchema({
claimedBy: v.optional(v.string()),
messageId: v.optional(v.id("messages")),
fragmentId: v.optional(v.id("fragments")),
schemaProposalId: v.optional(v.id("schemaProposals")),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
Expand Down
66 changes: 66 additions & 0 deletions convex/schemaProposals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { schemaProposalStatusEnum } from "./schema";

export const createForUser = mutation({
args: {
userId: v.string(),
projectId: v.id("projects"),
messageId: v.id("messages"),
proposal: v.string(),
parsedTables: v.optional(
v.array(
v.object({
name: v.string(),
purpose: v.string(),
fields: v.array(v.string()),
indexes: v.array(v.string()),
})
)
),
parsedRelationships: v.optional(v.array(v.string())),
status: schemaProposalStatusEnum,
},
handler: async (ctx, args) => {
const project = await ctx.db.get(args.projectId);
if (!project || project.userId !== args.userId) {
throw new Error("Unauthorized");
}
const message = await ctx.db.get(args.messageId);
if (!message || message.projectId !== args.projectId) {
throw new Error("Message not found");
}
const now = Date.now();
return await ctx.db.insert("schemaProposals", {
projectId: args.projectId,
messageId: args.messageId,
userId: args.userId,
proposal: args.proposal,
status: args.status,
parsedTables: args.parsedTables,
parsedRelationships: args.parsedRelationships,
createdAt: now,
updatedAt: now,
});
},
});

export const markImplementedForUser = mutation({
args: {
userId: v.string(),
schemaProposalId: v.id("schemaProposals"),
},
handler: async (ctx, args) => {
const row = await ctx.db.get(args.schemaProposalId);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing status guard allows a REJECTED proposal to be marked as IMPLEMENTED. The approvedAt ?? now fallback even backfills the approval timestamp, masking the invalid transition. Add a check that row.status is "APPROVED" (or at least not "REJECTED") before proceeding.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At convex/schemaProposals.ts, line 54:

<comment>Missing status guard allows a `REJECTED` proposal to be marked as `IMPLEMENTED`. The `approvedAt ?? now` fallback even backfills the approval timestamp, masking the invalid transition. Add a check that `row.status` is `"APPROVED"` (or at least not `"REJECTED"`) before proceeding.</comment>

<file context>
@@ -0,0 +1,66 @@
+    schemaProposalId: v.id("schemaProposals"),
+  },
+  handler: async (ctx, args) => {
+    const row = await ctx.db.get(args.schemaProposalId);
+    if (!row || row.userId !== args.userId) {
+      throw new Error("Unauthorized");
</file context>
Fix with Cubic

if (!row || row.userId !== args.userId) {
throw new Error("Unauthorized");
}
const now = Date.now();
await ctx.db.patch(args.schemaProposalId, {
status: "IMPLEMENTED",
approvedAt: row.approvedAt ?? now,
implementedAt: now,
updatedAt: now,
});
},
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"@uploadthing/react": "^7.3.3",
"@vercel/speed-insights": "^1.3.1",
"@webcontainer/api": "^1.6.1",
"ai": "^6.0.5",
"ai": "^6.0.146",
"class-variance-authority": "^0.7.1",
"claude": "^0.1.2",
"client-only": "^0.0.1",
Expand Down
107 changes: 107 additions & 0 deletions src/agents/backend-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { generateText } from "ai";
import { openrouter } from "./client";
import { CONVEX_BACKEND_PROMPT } from "@/prompts/backend/convex-backend";

const BACKEND_MODEL = "moonshotai/kimi-k2.5:nitro";

export interface BackendAgentResult {
files: Record<string, string>;
success: boolean;
error?: string;
summary?: string;
}

export async function runBackendImplementerAgent(
userPrompt: string,
schemaProposal: string,
plan?: string
): Promise<BackendAgentResult> {
console.log("[BACKEND] Starting implementation...");

try {
const augmentedPrompt = [
"## User Request",
userPrompt,
"",
"## Approved Schema Design",
schemaProposal,
"",
plan ? `## Implementation Plan\n${plan}\n` : "",
"## Your Task",
"Generate the complete Convex backend implementation based on the approved schema.",
"Create all necessary files with their full content.",
"",
"Output format:",
'1. First, output all file contents using <zapdev_file path="convex/schema.ts"> tags',
"2. Each file should contain the complete, production-ready code",
"3. End with a <task_summary> describing what was created",
"",
"Example:",
'<zapdev_file path="convex/schema.ts">',
"// schema content here",
"</zapdev_file>",
'<zapdev_file path="convex/tasks/queries.ts">',
"// queries content here",
"</zapdev_file>",
"",
"<task_summary>",
"Created Convex backend with schema and CRUD operations for tasks",
"</task_summary>",
].join("\n");

const { text } = await generateText({
model: openrouter(BACKEND_MODEL),
system: CONVEX_BACKEND_PROMPT,
prompt: augmentedPrompt,
temperature: 0.2,
maxOutputTokens: 8192,
});

const files = parseGeneratedFiles(text);

const summary = text.includes("<task_summary>")
? text.match(/<task_summary>([\s\S]*?)<\/task_summary>/)?.[1]?.trim()
: "Generated Convex backend files";

if (Object.keys(files).length === 0) {
return {
files,
success: false,
error: "No files were generated",
summary,
};
}

console.log("[BACKEND] Completed successfully");

return {
files,
success: true,
summary,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("[BACKEND] Error:", errorMessage);

return {
files: {},
success: false,
error: errorMessage,
};
}
}

function parseGeneratedFiles(text: string): Record<string, string> {
const files: Record<string, string> = {};

const fileRegex = /<zapdev_file path="([^"]+)">([\s\S]*?)<\/zapdev_file>/g;
let match;

while ((match = fileRegex.exec(text)) !== null) {
const path = match[1];
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: File paths parsed from AI output are not validated or sanitized. Since the user prompt influences the AI response, a crafted prompt could induce the model to emit paths containing .. segments or absolute paths, enabling writes outside the intended convex/ directory. Add path sanitization to reject traversal sequences and enforce an allowlist prefix.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/backend-agent.ts, line 101:

<comment>File paths parsed from AI output are not validated or sanitized. Since the user prompt influences the AI response, a crafted prompt could induce the model to emit paths containing `..` segments or absolute paths, enabling writes outside the intended `convex/` directory. Add path sanitization to reject traversal sequences and enforce an allowlist prefix.</comment>

<file context>
@@ -0,0 +1,107 @@
+  let match;
+  
+  while ((match = fileRegex.exec(text)) !== null) {
+    const path = match[1];
+    const content = match[2].trim();
+    files[path] = content;
</file context>
Fix with Cubic

const content = match[2].trim();
files[path] = content;
}
Comment on lines +100 to +104
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate generated file paths before accepting them.

path from model output is trusted as-is. A prompt-injected response can emit unexpected targets (../, hidden config paths, etc.). Restrict to an allowlist/prefix (for example convex/) and normalize/reject unsafe paths before adding them to files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agents/backend-agent.ts` around lines 100 - 104, The loop that consumes
model output (while ((match = fileRegex.exec(text)) ...) trusts match[1] as a
file path and writes it into files[path]; instead validate and normalize each
path before accepting: reject absolute paths or any path containing "..", null
bytes, or path separators that escape the intended workspace, enforce an
allowlist/prefix (e.g., must start with "convex/"), and normalize (using a path
normalization helper like validateAndNormalizePath or path.posix.normalize) to
collapse any "../" before adding to files; if validation fails, skip the entry
and log or surface an error instead of writing to files.


return files;
}
Loading
Loading