Skip to content

Implement Netlify deployment integration and payment templates#215

Open
Jackson57279 wants to merge 34 commits intomasterfrom
feat/roadmap-completeation
Open

Implement Netlify deployment integration and payment templates#215
Jackson57279 wants to merge 34 commits intomasterfrom
feat/roadmap-completeation

Conversation

@Jackson57279
Copy link
Owner

@Jackson57279 Jackson57279 commented Jan 19, 2026

  • Added support for deploying projects to Netlify, including authentication and deployment status tracking.
  • Introduced new API routes for managing Netlify domains, environment variables, and deployment logs.
  • Created payment integration templates for various frameworks (Angular, React, Next.js, Svelte, Vue) using Autumn and Stripe.
  • Updated environment example and README to include new Netlify and payment integration configurations.
  • Enhanced project UI with deployment dashboard and export options to GitHub.

This commit significantly expands the deployment capabilities and payment processing features of the application.


Summary by cubic

Implements Netlify deployments, GitHub export, a skills.sh‑compatible skill system with WebContainer previews, and Inngest‑powered agent streaming. Adds Anthropic OAuth with AES‑GCM token encryption, database/payment templates, color themes, and new SEO content; makes skill queries public for server‑side use.

  • New Features

    • Netlify: OAuth with signed state; deploy (preview/prod), status/logs, domains, env vars, rollback, CLI ZIPs; Convex deployment records.
    • GitHub export: Convex module + API; create or reuse repos, branches/commits; tracked status.
    • Skills: Convex CRUD + installations; core skills baked in; PrebuiltUI scraped/seeded; token‑budgeted prompt injection; public queries for server‑side access.
    • Preview/Sandbox: feature‑flagged WebContainers with build/lint and dev server; sandbox adapter to switch between E2B and WebContainer; scoped COOP/COEP for /preview; sandbox bridge route.
    • Orchestration: Inngest function + realtime channel; /api/agent/run streams via SSE.
    • Auth/Security: Anthropic OAuth; AES‑256‑GCM encrypted tokens; internal token queries/actions.
    • Data/Templates: persist project database provider; templates for Drizzle+Neon and Convex; payment templates (Autumn + Stripe); env examples.
    • UI/SEO: color theme provider/picker; compare and blog pages; updated sitemap, robots (AI crawler rules), RSS, and llms.txt.
  • Migration

    • Set NETLIFY_CLIENT_ID, NETLIFY_CLIENT_SECRET, NETLIFY_OAUTH_STATE_SECRET; ensure NEXT_PUBLIC_APP_URL.
    • Set OAUTH_ENCRYPTION_KEY (32‑byte hex).
    • Configure Anthropic: ANTHROPIC_CLIENT_ID, ANTHROPIC_CLIENT_SECRET, ANTHROPIC_OAUTH_STATE_SECRET; optional CLAUDE_CODE_ENABLED.
    • Optional: NEXT_PUBLIC_USE_WEBCONTAINERS=true to enable browser sandboxes.
    • Apply Convex schema changes; connect Netlify/GitHub in‑app; run scripts/seed-skills.ts; add env vars for chosen database/payment templates.

Written for commit a50e05f. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Full Netlify deployment flow (sites, previews, rollbacks, domains, env vars, logs, CLI), GitHub export, payment templates, Skills marketplace, WebContainer sandbox, color theme picker, and a deployment dashboard UI.
  • Integrations
    • OAuth flows for Netlify and Anthropic; GitHub and Figma integrations.
  • Improvements
    • Multi‑framework support, Convex real‑time backend, OpenRouter AI providers, Polar.sh billing templates, bun-based dev commands, and expanded docs/content (roadmap, comparisons, blog, RSS).

- Added support for deploying projects to Netlify, including authentication and deployment status tracking.
- Introduced new API routes for managing Netlify domains, environment variables, and deployment logs.
- Created payment integration templates for various frameworks (Angular, React, Next.js, Svelte, Vue) using Autumn and Stripe.
- Updated environment example and README to include new Netlify and payment integration configurations.
- Enhanced project UI with deployment dashboard and export options to GitHub.

This commit significantly expands the deployment capabilities and payment processing features of the application.
@cursor
Copy link

cursor bot commented Jan 19, 2026

You have run out of free Bugbot PR reviews for this billing cycle. This will reset on February 17.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

@vercel
Copy link

vercel bot commented Jan 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
zapdev Error Error Feb 14, 2026 7:51am

@codecapyai
Copy link

codecapyai bot commented Jan 19, 2026

CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎

Codebase Summary

ZapDev is an AI-powered development platform that lets users create web applications using real-time sandboxes, chat with AI agents to generate code, and manage projects via a rich UI built on Next.js, React, and other modern tools. The UI allows a split-pane view for file browsing, code preview, and interactive project development with deployment and payment functionalities integrated.

PR Changes

This PR implements Netlify deployment integration and adds support for payment integration templates using Stripe via Autumn. New API routes have been introduced for managing Netlify deployments, domains, environment variables, and deployment logs. The project UI now includes a deployment dashboard with export options to GitHub, and GitHub export functionality via OAuth. Additionally, payment integration templates for Angular, React, Next.js, Svelte, and Vue have been added along with corresponding prompt enhancements for payment integration.

Setup Instructions

  1. Install pnpm globally: sudo npm install -g pnpm
  2. Clone the repository and navigate into the repo directory.
  3. Run 'pnpm install' to install all dependencies.
  4. Start the development server with 'pnpm dev'.
  5. Open your browser and navigate to http://localhost:3000 to access the web app.
  6. Ensure necessary environment variables are set as per the updated README and env.example, including Netlify and payment integration settings.

Generated Test Cases

1: Netlify Deployment Flow in Deployment Dashboard ❗️❗️❗️

Description: Verifies that users can trigger a deployment from the project view. This ensures that the new Netlify deployment integration (deploy button, deployment status, and dashboard updates) works correctly.

Prerequisites:

  • User is logged in
  • User has an existing project with valid permissions
  • Netlify OAuth connection is configured (or simulate via test stubs)

Steps:

  1. Navigate to the project view in the application.
  2. Switch to the 'Deploy' tab in the project view.
  3. Verify that the deployment dashboard displays a deploy button and the current deployment status (e.g., Pending).
  4. Click on the 'Deploy to Netlify' button.
  5. Observe that a request is made, and a success toast appears indicating the deployment has started.
  6. Check that the deployment status updates (e.g., from Pending to Building) and that elements such as custom domain and environment variable actions become visible.

Expected Result: A new deployment is initiated on Netlify with updated status shown on the dashboard. Success messages (toasts) appear and actionable buttons such as 'Env Vars' and 'Custom Domains' are displayed.

2: GitHub Export Modal Functionality ❗️❗️❗️

Description: Checks the GitHub export workflow from the project header. Ensures users can connect GitHub, open the export modal, enter new repository details or select an existing repository, and complete the export process.

Prerequisites:

  • User is logged in
  • User has a project created
  • GitHub OAuth connection is configured or simulated

Steps:

  1. From the project header, click on the 'Export to GitHub' button.
  2. If GitHub is not connected, verify that the dialog prompts the user to connect and provides a link to initiate GitHub OAuth.
  3. If connected, verify the export modal opens.
  4. In the modal, select the 'New repository' mode, fill in the repository name and optional description, and submit the export.
  5. Alternatively, switch to 'Existing repository' mode, select a repository from the dropdown, adjust the branch and commit message if needed, and then submit.
  6. Observe that a success message appears, and the modal displays export details including repository URL, branch, commit summary, and an option to open the repository on GitHub.

Expected Result: The GitHub export modal functions correctly with toggling between new and existing repository options. Upon successful export, the user sees repository export details and can navigate to GitHub.

3: Payment Templates Tool Functionality ❗️❗️

Description: Tests the new payment integration templates feature which provides billing templates for various frameworks. It verifies that the tool returns the proper configuration details (including autumnConfigTemplate and paymentEnvExample) for the selected payment framework.

Prerequisites:

  • User is logged in
  • Access to the agent tools panel (or similar interface to execute agent tools)

Steps:

  1. Navigate to the Agents or tools section of the UI.
  2. Select the 'paymentTemplates' tool.
  3. Input a framework parameter (e.g., 'nextjs', 'react', 'vue', 'angular', or 'svelte').
  4. Execute the tool and wait for the result.
  5. Examine the output and verify it includes a JSON string with keys such as 'autumnConfigTemplate', 'paymentEnvExample' and framework-specific payment template details.

Expected Result: The paymentTemplates tool returns a JSON object containing the payment integration template, autumn configuration template, and a sample environment variables configuration snippet. The content should match the selected framework's expected template structure.

4: Project View Tabs Navigation and Layout Verification ❗️

Description: Ensures that the project view renders all tabs (Preview, Code, Deploy) correctly and that switching between them displays the appropriate content sections.

Prerequisites:

  • User is logged in
  • A project is loaded with files and deployment information available

Steps:

  1. Open the project view in the application.
  2. Verify that the header displays the project title and primary controls (download, export, deployment status).
  3. Click on the 'Preview' tab and confirm that the preview pane shows AI-generated previews.
  4. Switch to the 'Code' tab and verify that the code file explorer displays the project files with syntax highlighting.
  5. Switch to the 'Deploy' tab and ensure that the deployment dashboard (including deployment status, deploy button, preview deployments, deployment history) is rendered correctly.

Expected Result: All tabs are accessible, and the correct UI components for each section are displayed without layout issues. Visual indicators, such as active tab highlighting, should be consistent and user-friendly.

5: Environment Variables Dialog Functionality ❗️❗️

Description: Verifies that users can manage Netlify environment variables through the Env Vars Dialog in the deployment dashboard. This test ensures that users can add and remove environment variables.

Prerequisites:

  • User is logged in
  • A project exists with a successful Netlify deployment (siteId available)

Steps:

  1. From the deployment dashboard, click on the 'Env Vars' button.
  2. In the dialog that opens, enter a new environment variable key (e.g., TEST_VAR) and value (e.g., test_value), then click the 'Save' button.
  3. Observe that a success toast appears and the new variable is listed.
  4. Click the 'Remove' button next to the variable and verify that it is deleted, with a confirmation toast.

Expected Result: The Env Vars Dialog lets the user add a new environment variable and subsequently remove it. The UI updates to reflect the current list of environment variables, and toasts confirm actions.

6: Custom Domain Dialog Functionality ❗️❗️

Description: Checks the workflow for adding and removing custom domains via the Custom Domain Dialog in the deployment dashboard.

Prerequisites:

  • User is logged in
  • A project exists with an active Netlify deployment (siteId available)

Steps:

  1. Open the Custom Domain Dialog by clicking on the 'Custom Domains' button in the deployment dashboard.
  2. In the dialog, enter a valid domain name and click the 'Add Domain' button.
  3. Verify that a success message appears and the domain is listed in the dialog.
  4. Click the 'Remove' button for the added domain and confirm that it is deleted, with an appropriate success message displayed.

Expected Result: Users are able to add a custom domain which appears in the list, and once removed, the domain is no longer displayed. All actions show proper success or error toasts.

7: Deployment History Logs and Rollback Actions ❗️

Description: Tests the Deployment History section in the deployment dashboard, ensuring that users can view build logs and initiate a rollback for a previous deployment.

Prerequisites:

  • User is logged in
  • Multiple deployments exist for the project, including at least one with build logs available

Steps:

  1. Navigate to the deployment history section in the deployment dashboard.
  2. For a deployment entry that shows a 'Logs' button, click it.
  3. Verify that a dialog opens displaying the build logs.
  4. Close the logs dialog.
  5. Click the 'Rollback' button for a selected deployment.
  6. Confirm that a rollback request is sent and a success message toast appears.

Expected Result: The deployment history displays previous deployments. Upon clicking 'Logs', the build logs are shown in a dialog. The rollback action triggers a deployment rollback and confirms the action via a toast message.

8: Deployment Status Polling and View Site Action ❗️❗️

Description: Ensures that the Deployment Status component periodically polls for status updates and updates the UI accordingly, allowing the user to view the site once ready.

Prerequisites:

  • User is logged in
  • A deployment is in progress with a status of 'pending' or 'building'

Steps:

  1. Open the project view and navigate to the 'Deploy' tab.
  2. Observe the Deployment Status component displaying the current status (e.g., 'Pending' or 'Building').
  3. Wait for the polling interval (approximately 10 seconds) and simulate a status change on the backend to 'ready'.
  4. Verify that the Deployment Status component updates to show 'Ready'.
  5. Confirm that a 'View site' button appears and clicking it opens the live site in a new tab.

Expected Result: The Deployment Status component polls every 10 seconds and updates the displayed status. Once the deployment is ready, the UI shows a clickable link to view the deployed site.

Raw Changes Analyzed
File: README.md
Changes:
@@ -14,6 +14,7 @@ AI-powered development platform that lets you create web applications by chattin
 - 🔐 Authentication with Clerk
 - ⚙️ Background job processing with Inngest
 - 🗃️ Project management and persistence
+- 💰 Generated app billing templates (Stripe via Autumn)
 
 ## Tech Stack
 
@@ -200,6 +201,10 @@ npm run lint           # Run ESLint
 5. **File Management**: Users can browse generated files with syntax highlighting
 6. **Iteration**: Conversational development allows for refinements and additions
 
+## Generated App Payments
+
+ZapDev can generate payment-ready apps using Stripe through Autumn. Templates live in `src/lib/payment-templates/` and include checkout flows, billing portal endpoints, feature gates, and usage tracking helpers. Configure with environment variables from `paymentEnvExample` in the same folder.
+
 ---
 
 Created by [CodeWithAntonio](https://codewithantonio.com)

File: ROADMAP.md
Changes:
@@ -4,7 +4,7 @@
 
 ### Payments Integration
 
-**Status**: In Progress  
+**Status**: Finished
 **Priority**: High
 
 Currently, ZapDev uses Polar.sh for subscription billing. This roadmap item focuses on:
@@ -176,3 +176,52 @@ Allow users to choose their preferred database provider:
   - Cost optimization options
   - Regional data residency compliance
 
+
+### GitHub Export
+
+**Status**: Planned  
+**Priority**: High
+
+Enable users to export their generated projects directly to GitHub repositories for version control, collaboration, and deployment:
+
+- **Repository Creation**:
+  - One-click export to new GitHub repository
+  - Automatic repository initialization with generated code
+  - Support for public, private, and organization repositories
+  - Custom repository name and description
+  - Optional README generation with project details
+
+- **Export Features**:
+  - Full project structure export (all files and directories)
+  - Preserve file permissions and structure
+  - Include `.gitignore` and other configuration files
+  - Export project metadata and documentation
+  - Incremental updates to existing repositories
+
+- **GitHub Integration**:
+  - OAuth authentication with GitHub
+  - Secure token storage and management
+  - Support for GitHub App authentication
+  - Branch creation for project versions
+  - Commit history tracking
+
+- **Advanced Features**:
+  - Export to existing repositories (push to specific branch)
+  - Multiple repository export (fork to multiple locations)
+  - Automated initial commit with descriptive messages
+  - Tag creation for project versions
+  - GitHub Actions workflow templates inclusion
+
+- **User Experience**:
+  - Export progress indicator
+  - Error handling and retry logic
+  - Export history tracking
+  - Quick access to exported repositories
+  - One-click repository opening in GitHub
+
+- **Technical Implementation**:
+  - GitHub REST API integration
+  - File tree generation and upload
+  - Large file handling (GitHub LFS support)
+  - Rate limit management
+  - Background job processing for large exports

File: convex/_generated/api.d.ts
Changes:
@@ -10,6 +10,8 @@
 
 import type * as helpers from "../helpers.js";
 import type * as http from "../http.js";
+import type * as deployments from "../deployments.js";
+import type * as githubExports from "../githubExports.js";
 import type * as importData from "../importData.js";
 import type * as imports from "../imports.js";
 import type * as messages from "../messages.js";
@@ -31,6 +33,8 @@ import type {
 declare const fullApi: ApiFromModules<{
   helpers: typeof helpers;
   http: typeof http;
+  deployments: typeof deployments;
+  githubExports: typeof githubExports;
   importData: typeof importData;
   imports: typeof imports;
   messages: typeof messages;

File: convex/deployments.ts
Changes:
@@ -0,0 +1,176 @@
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+import { requireAuth } from "./helpers";
+
+const deploymentStatusEnum = v.union(
+  v.literal("pending"),
+  v.literal("building"),
+  v.literal("ready"),
+  v.literal("error")
+);
+
+export const createDeployment = mutation({
+  args: {
+    projectId: v.id("projects"),
+    platform: v.literal("netlify"),
+    siteId: v.string(),
+    siteUrl: v.string(),
+    deployId: v.optional(v.string()),
+    status: deploymentStatusEnum,
+    isPreview: v.optional(v.boolean()),
+    branch: v.optional(v.string()),
+    commitRef: v.optional(v.string()),
+  },
+  returns: v.id("deployments"),
+  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");
+    }
+
+    const latest = await ctx.db
+      .query("deployments")
+      .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+      .order("desc")
+      .first();
+
+    const nextDeployNumber = (latest?.deployNumber ?? 0) + 1;
+    const now = Date.now();
+
+    return await ctx.db.insert("deployments", {
+      projectId: args.projectId,
+      userId,
+      platform: args.platform,
+      siteId: args.siteId,
+      siteUrl: args.siteUrl,
+      deployId: args.deployId,
+      deployNumber: nextDeployNumber,
+      commitRef: args.commitRef,
+      branch: args.branch,
+      isPreview: args.isPreview ?? false,
+      status: args.status,
+      createdAt: now,
+      updatedAt: now,
+    });
+  },
+});
+
+export const updateDeployment = mutation({
+  args: {
+    deploymentId: v.id("deployments"),
+    status: v.optional(deploymentStatusEnum),
+    deployId: v.optional(v.string()),
+    error: v.optional(v.string()),
+    buildLog: v.optional(v.string()),
+    buildTime: v.optional(v.number()),
+  },
+  returns: v.id("deployments"),
+  handler: async (ctx, args) => {
+    const userId = await requireAuth(ctx);
+
+    const deployment = await ctx.db.get(args.deploymentId);
+    if (!deployment || deployment.userId !== userId) {
+      throw new Error("Unauthorized");
+    }
+
+    await ctx.db.patch(args.deploymentId, {
+      ...(args.status ? { status: args.status } : {}),
+      ...(args.deployId ? { deployId: args.deployId } : {}),
+      ...(args.error ? { error: args.error } : {}),
+      ...(args.buildLog ? { buildLog: args.buildLog } : {}),
+      ...(args.buildTime ? { buildTime: args.buildTime } : {}),
+      updatedAt: Date.now(),
+    });
+
+    return args.deploymentId;
+  },
+});
+
+export const getDeployment = query({
+  args: {
+    projectId: v.id("projects"),
+  },
+  returns: v.union(
+    v.null(),
+    v.object({
+      _id: v.id("deployments"),
+      _creationTime: v.number(),
+      projectId: v.id("projects"),
+      userId: v.string(),
+      platform: v.literal("netlify"),
+      siteId: v.string(),
+      siteUrl: v.string(),
+      deployId: v.optional(v.string()),
+      deployNumber: v.optional(v.number()),
+      commitRef: v.optional(v.string()),
+      branch: v.optional(v.string()),
+      isPreview: v.optional(v.boolean()),
+      buildLog: v.optional(v.string()),
+      buildTime: v.optional(v.number()),
+      previousDeployId: v.optional(v.id("deployments")),
+      status: deploymentStatusEnum,
+      error: v.optional(v.string()),
+      createdAt: v.number(),
+      updatedAt: v.number(),
+    })
+  ),
+  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");
+    }
+
+    return await ctx.db
+      .query("deployments")
+      .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+      .order("desc")
+      .first();
+  },
+});
+
+export const listDeployments = query({
+  args: {
+    projectId: v.id("projects"),
+  },
+  returns: v.array(
+    v.object({
+      _id: v.id("deployments"),
+      _creationTime: v.number(),
+      projectId: v.id("projects"),
+      userId: v.string(),
+      platform: v.literal("netlify"),
+      siteId: v.string(),
+      siteUrl: v.string(),
+      deployId: v.optional(v.string()),
+      deployNumber: v.optional(v.number()),
+      commitRef: v.optional(v.string()),
+      branch: v.optional(v.string()),
+      isPreview: v.optional(v.boolean()),
+      buildLog: v.optional(v.string()),
+      buildTime: v.optional(v.number()),
+      previousDeployId: v.optional(v.id("deployments")),
+      status: deploymentStatusEnum,
+      error: v.optional(v.string()),
+      createdAt: v.number(),
+      updatedAt: v.number(),
+    })
+  ),
+  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");
+    }
+
+    return await ctx.db
+      .query("deployments")
+      .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+      .order("desc")
+      .collect();
+  },
+});

File: convex/githubExports.ts
Changes:
@@ -0,0 +1,392 @@
+import { v } from "convex/values";
+import { action, mutation, query } from "./_generated/server";
+import { requireAuth } from "./helpers";
+import { githubExportStatusEnum } from "./schema";
+import { api } from "./_generated/api";
+import type { Doc, Id } from "./_generated/dataModel";
+import {
+  buildTreeEntries,
+  createBranchRef,
+  createCommit,
+  createTree,
+  getBranchRef,
+  getCommitTreeSha,
+  getRepository,
+  updateBranchRef,
+  withDefaultFiles,
+  type ProjectFramework,
+} from "../src/lib/github-api";
+import { filterFilesForDownload } from "../src/lib/filter-ai-files";
+
+const githubExportRecord = v.object({
+  _id: v.id("githubExports"),
+  _creationTime: v.number(),
+  projectId: v.id("projects"),
+  userId: v.string(),
+  repositoryName: v.string(),
+  repositoryUrl: v.string(),
+  repositoryFullName: v.string(),
+  branch: v.optional(v.string()),
+  commitSha: v.optional(v.string()),
+  status: githubExportStatusEnum,
+  error: v.optional(v.string()),
+  fileCount: v.optional(v.number()),
+  createdAt: v.number(),
+  updatedAt: v.number(),
+});
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+};
+
+const normalizeFiles = (value: unknown): Record<string, string> => {
+  if (!isRecord(value)) {
+    return {};
+  }
+
+  const files: Record<string, string> = {};
+  for (const [path, content] of Object.entries(value)) {
+    if (typeof content === "string") {
+      files[path] = content;
+    }
+  }
+
+  return files;
+};
+
+type MessageWithFragment = {
+  _id: Id<"messages">;
+  _creationTime: number;
+  Fragment: {
+    _id: Id<"fragments">;
+    files?: unknown;
+    framework: ProjectFramework;
+  } | null;
+};
+
+export const list = query({
+  args: {
+    projectId: v.id("projects"),
+  },
+  returns: v.array(githubExportRecord),
+  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");
+    }
+
+    return await ctx.db
+      .query("githubExports")
+      .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+      .order("desc")
+      .collect();
+  },
+});
+
+export const get = query({
+  args: {
+    exportId: v.id("githubExports"),
+  },
+  returns: githubExportRecord,
+  handler: async (ctx, args) => {
+    const userId = await requireAuth(ctx);
+    const exportRecord = await ctx.db.get(args.exportId);
+    if (!exportRecord) {
+      throw new Error("Export not found");
+    }
+    if (exportRecord.userId !== userId) {
+      throw new Error("Unauthorized");
+    }
+
+    return exportRecord;
+  },
+});
+
+export const getLatest = query({
+  args: {
+    projectId: v.id("projects"),
+  },
+  returns: v.union(githubExportRecord, v.null()),
+  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");
+    }
+
+    return await ctx.db
+      .query("githubExports")
+      .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+      .order("desc")
+      .first();
+  },
+});
+
+export const create = mutation({
+  args: {
+    projectId: v.id("projects"),
+    repositoryName: v.string(),
+    repositoryUrl: v.string(),
+    repositoryFullName: v.string(),
+    branch: v.optional(v.string()),
+  },
+  returns: v.id("githubExports"),
+  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");
+    }
+
+    const now = Date.now();
+    return await ctx.db.insert("githubExports", {
+      projectId: args.projectId,
+      userId,
+      repositoryName: args.repositoryName,
+      repositoryUrl: args.repositoryUrl,
+      repositoryFullName: args.repositoryFullName,
+      branch: args.branch,
+      status: "pending",
+      createdAt: now,
+      updatedAt: now,
+    });
+  },
+});
+
+export const updateStatus = mutation({
+  args: {
+    exportId: v.id("githubExports"),
+    status: githubExportStatusEnum,
+    error: v.optional(v.string()),
+  },
+  returns: v.id("githubExports"),
+  handler: async (ctx, args) => {
+    const userId = await requireAuth(ctx);
+    const exportRecord = await ctx.db.get(args.exportId);
+    if (!exportRecord) {
+      throw new Error("Export not found");
+    }
+    if (exportRecord.userId !== userId) {
+      throw new Error("Unauthorized");
+    }
+
+    await ctx.db.patch(args.exportId, {
+      status: args.status,
+      ...(args.error !== undefined && { error: args.error }),
+      updatedAt: Date.now(),
+    });
+
+    return args.exportId;
+  },
+});
+
+export const complete = mutation({
+  args: {
+    exportId: v.id("githubExports"),
+    commitSha: v.string(),
+    branch: v.string(),
+    fileCount: v.number(),
+  },
+  returns: v.id("githubExports"),
+  handler: async (ctx, args) => {
+    const userId = await requireAuth(ctx);
+    const exportRecord = await ctx.db.get(args.exportId);
+    if (!exportRecord) {
+      throw new Error("Export not found");
+    }
+    if (exportRecord.userId !== userId) {
+      throw new Error("Unauthorized");
+    }
+
+    await ctx.db.patch(args.exportId, {
+      commitSha: args.commitSha,
+      branch: args.branch,
+      fileCount: args.fileCount,
+      status: "complete",
+      updatedAt: Date.now(),
+    });
+
+    return args.exportId;
+  },
+});
+
+export const exportToGitHub = action({
+  args: {
+    exportId: v.id("githubExports"),
+    branch: v.optional(v.string()),
+    includeReadme: v.optional(v.boolean()),
+    includeGitignore: v.optional(v.boolean()),
+    commitMessage: v.optional(v.string()),
+  },
+  returns: v.object({
+    exportId: v.id("githubExports"),
+    repositoryUrl: v.string(),
+    repositoryFullName: v.string(),
+    branch: v.string(),
+    commitSha: v.string(),
+    fileCount: v.number(),
+  }),
+  handler: async (ctx, args) => {
+    const identity = await ctx.auth.getUserIdentity();
+    if (!identity?.subject) {
+      throw new Error("Unauthorized");
+    }
+
+    const exportRecord: Doc<"githubExports"> = await ctx.runQuery(
+      api.githubExports.get,
+      { exportId: args.exportId },
+    );
+
+    await ctx.runMutation(api.githubExports.updateStatus, {
+      exportId: args.exportId,
+      status: "processing",
+    });
+
+    try {
+      const project: Doc<"projects"> = await ctx.runQuery(api.projects.get, {
+        projectId: exportRecord.projectId,
+      });
+
+      const messages: Array<MessageWithFragment> = await ctx.runQuery(
+        api.messages.list,
+        { projectId: exportRecord.projectId },
+      );
+
+      const latestWithFragment = [...messages]
+        .reverse()
+        .find((message) => message.Fragment);
+
+      const fragment = latestWithFragment?.Fragment;
+      if (!fragment) {
+        throw new Error("No AI-generated files are ready to export.");
+      }
+
+      const normalized = normalizeFiles(fragment.files);
+      const filtered = filterFilesForDownload(normalized);
+      if (Object.keys(filtered).length === 0) {
+        throw new Error("No AI-generated files are ready to export.");
+      }
+
+      const includeReadme = args.includeReadme ?? true;
+      const includeGitignore = args.includeGitignore ?? true;
+      const files = withDefaultFiles(
+        filtered,
+        {
+          projectName: project.name,
+          framework: fragment.framework,
+        },
+        includeReadme,
+        includeGitignore,
+      );
+
+      const treeEntries = buildTreeEntries(files);
+      const accessToken = await ctx.runQuery(api.oauth.getGithubAccessToken, {});
+      if (!accessToken) {
+        throw new Error("GitHub connection not found. Please connect GitHub.");
+      }
+
+      const repository = await getRepository(
+        accessToken,
+        exportRecord.repositoryFullName,
+      );
+      const defaultBranch = repository.default_branch ?? "main";
+      const targetBranch = args.branch ?? exportRecord.branch ?? defaultBranch;
+
+      let baseCommitSha: string | null = null;
+      let baseTreeSha: string | undefined;
+      let needsCreateBranch = false;
+
+      try {
+        baseCommitSha = await getBranchRef(
+          accessToken,
+          repository.full_name,
+          targetBranch,
+        );
+        baseTreeSha = await getCommitTreeSha(
+          accessToken,
+          repository.full_name,
+          baseCommitSha,
+        );
+      } catch (error) {
+        const message = error instanceof Error ? error.message : "GitHub error";
+        if (
+          targetBranch !== defaultBranch &&
+          message.toLowerCase().includes("not found")
+        ) {
+          baseCommitSha = await getBranchRef(
+            accessToken,
+            repository.full_name,
+            defaultBranch,
+          );
+          baseTreeSha = await getCommitTreeSha(
+            accessToken,
+            repository.full_name,
+            baseCommitSha,
+          );
+          needsCreateBranch = true;
+        } else {
+          throw error;
+        }
+      }
+
+      if (!baseCommitSha) {
+        throw new Error("Unable to resolve base branch for export.");
+      }
+
+      const treeSha = await createTree(
+        accessToken,
+        repository.full_name,
+        treeEntries,
+        baseTreeSha,
+      );
+      const commitSha = await createCommit(
+        accessToken,
+        repository.full_name,
+        args.commitMessage ?? "Export project from ZapDev",
+        treeSha,
+        baseCommitSha ? [baseCommitSha] : [],
+      );
+
+      if (needsCreateBranch) {
+        await createBranchRef(
+          accessToken,
+          repository.full_name,
+          targetBranch,
+          commitSha,
+        );
+      } else {
+        await updateBranchRef(
+          accessToken,
+          repository.full_name,
+          targetBranch,
+          commitSha,
+        );
+      }
+
+      await ctx.runMutation(api.githubExports.complete, {
+        exportId: args.exportId,
+        commitSha,
+        branch: targetBranch,
+        fileCount: treeEntries.length,
+      });
+
+      return {
+        exportId: args.exportId,
+        repositoryUrl: exportRecord.repositoryUrl,
+        repositoryFullName: exportRecord.repositoryFullName,
+        branch: targetBranch,
+        commitSha,
+        fileCount: treeEntries.length,
+      };
+    } catch (error) {
+      const message = error instanceof Error ? error.message : "Export failed";
+      await ctx.runMutation(api.githubExports.updateStatus, {
+        exportId: args.exportId,
+        status: "failed",
+        error: message,
+      });
+      throw error;
+    }
+  },
+});

File: convex/oauth.ts
Changes:
@@ -70,6 +70,23 @@ export const getConnection = query({
   },
 });
 
+export const getGithubAccessToken = query({
+  args: {},
+  returns: v.union(v.string(), v.null()),
+  handler: async (ctx) => {
+    const userId = await requireAuth(ctx);
+
+    const connection = await ctx.db
+      .query("oauthConnections")
+      .withIndex("by_userId_provider", (q) =>
+        q.eq("userId", userId).eq("provider", "github"),
+      )
+      .first();
+
+    return connection?.accessToken ?? null;
+  },
+});
+
 // List all OAuth connections for user
 export const listConnections = query({
   handler: async (ctx) => {

File: convex/schema.ts
Changes:
@@ -39,7 +39,8 @@ export const importSourceEnum = v.union(
 
 export const oauthProviderEnum = v.union(
   v.literal("figma"),
-  v.literal("github")
+  v.literal("github"),
+  v.literal("netlify")
 );
 
 export const importStatusEnum = v.union(
@@ -49,6 +50,13 @@ export const importStatusEnum = v.union(
   v.literal("FAILED")
 );
 
+export const githubExportStatusEnum = v.union(
+  v.literal("pending"),
+  v.literal("processing"),
+  v.literal("complete"),
+  v.literal("failed")
+);
+
 export const sandboxStateEnum = v.union(
   v.literal("RUNNING"),
   v.literal("PAUSED"),
@@ -159,6 +167,35 @@ export default defineSchema({
     .index("by_userId", ["userId"])
     .index("by_userId_provider", ["userId", "provider"]),
 
+  deployments: defineTable({
+    projectId: v.id("projects"),
+    userId: v.string(),
+    platform: v.literal("netlify"),
+    siteId: v.string(),
+    siteUrl: v.string(),
+    deployId: v.optional(v.string()),
+    deployNumber: v.optional(v.number()),
+    commitRef: v.optional(v.string()),
+    branch: v.optional(v.string()),
+    isPreview: v.optional(v.boolean()),
+    buildLog: v.optional(v.string()),
+    buildTime: v.optional(v.number()),
+    previousDeployId: v.optional(v.id("deployments")),
+    status: v.union(
+      v.literal("pending"),
+      v.literal("building"),
+      v.literal("ready"),
+      v.literal("error")
+    ),
+    error: v.optional(v.string()),
+    createdAt: v.number(),
+    updatedAt: v.number(),
+  })
+    .index("by_projectId", ["projectId"])
+    .index("by_projectId_deployNumber", ["projectId", "deployNumber"])
+    .index("by_userId", ["userId"])
+    .index("by_siteId", ["siteId"]),
+
   imports: defineTable({
     userId: v.string(),
     projectId: v.id("projects"),
@@ -177,6 +214,24 @@ export default defineSchema({
     .index("by_projectId", ["projectId"])
     .index("by_status", ["status"]),
 
+  githubExports: defineTable({
+    projectId: v.id("projects"),
+    userId: v.string(),
+    repositoryName: v.string(),
+    repositoryUrl: v.string(),
+    repositoryFullName: v.string(),
+    branch: v.optional(v.string()),
+    commitSha: v.optional(v.string()),
+    status: githubExportStatusEnum,
+    error: v.optional(v.string()),
+    fileCount: v.optional(v.number()),
+    createdAt: v.number(),
+    updatedAt: v.number(),
+  })
+    .index("by_projectId", ["projectId"])
+    .index("by_userId", ["userId"])
+    .index("by_status", ["status"]),
+
   usage: defineTable({
     userId: v.string(),
     points: v.number(),

File: env.example
Changes:
@@ -27,6 +27,10 @@ CEREBRAS_API_KEY=""  # Get from https://cloud.cerebras.ai
 # Vercel AI Gateway (fallback for Cerebras rate limits)
 VERCEL_AI_GATEWAY_API_KEY=""  # Get from https://vercel.com/dashboard/ai-gateway
 
+# Netlify Deployment
+NETLIFY_CLIENT_ID=""
+NETLIFY_CLIENT_SECRET=""
+
 # Brave Search API (web search for subagent research - optional)
 BRAVE_SEARCH_API_KEY=""  # Get from https://api-dashboard.search.brave.com/app/keys
 

File: src/agents/tools.ts
Changes:
@@ -1,6 +1,11 @@
 import { tool } from "ai";
 import { z } from "zod";
 import { getSandbox, writeFilesBatch, readFileFast } from "./sandbox-utils";
+import {
+  autumnConfigTemplate,
+  getPaymentTemplate,
+  paymentEnvExample,
+} from "@/lib/payment-templates";
 import type { AgentState } from "./types";
 
 export interface ToolContext {
@@ -138,5 +143,20 @@ export function createAgentTools(context: ToolContext) {
         }
       },
     }),
+    paymentTemplates: tool({
+      description:
+        "Get Stripe + Autumn payment integration templates for a framework",
+      inputSchema: z.object({
+        framework: z.enum(["nextjs", "react", "vue", "angular", "svelte"]),
+      }),
+      execute: async ({ framework }) => {
+        const template = getPaymentTemplate(framework);
+        return JSON.stringify({
+          ...template,
+          autumnConfigTemplate,
+          paymentEnvExample,
+        });
+      },
+    }),
   };
 }

File: src/app/api/deploy/netlify/auth/route.ts
Changes:
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { getUser } from "@/lib/auth-server";
+
+const NETLIFY_CLIENT_ID = process.env.NETLIFY_CLIENT_ID;
+const NETLIFY_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/deploy/netlify/callback`;
+
+export async function GET() {
+  const user = await getUser();
+  if (!user?.id) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  if (!NETLIFY_CLIENT_ID) {
+    return NextResponse.json(
+      { error: "Netlify OAuth not configured" },
+      { status: 500 }
+    );
+  }
+
+  const state = Buffer.from(
+    JSON.stringify({ userId: user.id, timestamp: Date.now() })
+  ).toString("base64");
+
+  const params = new URLSearchParams({
+    client_id: NETLIFY_CLIENT_ID,
+    redirect_uri: NETLIFY_REDIRECT_URI,
+    response_type: "code",
+    state,
+  });
+
+  const netlifyAuthUrl = `https://app.netlify.com/authorize?${params.toString()}`;
+  return NextResponse.redirect(netlifyAuthUrl);
+}

File: src/app/api/deploy/netlify/callback/route.ts
Changes:
@@ -0,0 +1,141 @@
+import { NextResponse } from "next/server";
+import { getUser } from "@/lib/auth-server";
+import { fetchMutation } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+
+const NETLIFY_CLIENT_ID = process.env.NETLIFY_CLIENT_ID;
+const NETLIFY_CLIENT_SECRET = process.env.NETLIFY_CLIENT_SECRET;
+const NETLIFY_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/deploy/netlify/callback`;
+
+type NetlifyTokenResponse = {
+  access_token?: string;
+  token_type?: string;
+  scope?: string;
+};
+
+type NetlifyUserResponse = {
+  id?: string;
+  email?: string;
+  full_name?: string;
+  avatar_url?: string;
+};
+
+const parseTokenResponse = (value: unknown): NetlifyTokenResponse => {
+  if (!value || typeof value !== "object") {
+    return {};
+  }
+
+  const record = value as Record<string, unknown>;
+  return {
+    access_token: typeof record.access_token === "string" ? record.access_token : undefined,
+    token_type: typeof record.token_type === "string" ? record.token_type : undefined,
+    scope: typeof record.scope === "string" ? record.scope : undefined,
+  };
+};
+
+const parseUserResponse = (value: unknown): NetlifyUserResponse => {
+  if (!value || typeof value !== "object") {
+    return {};
+  }
+
+  const record = value as Record<string, unknown>;
+  return {
+    id: typeof record.id === "string" ? record.id : undefined,
+    email: typeof record.email === "string" ? record.email : undefined,
+    full_name: typeof record.full_name === "string" ? record.full_name : undefined,
+    avatar_url: typeof record.avatar_url === "string" ? record.avatar_url : undefined,
+  };
+};
+
+export async function GET(request: Request) {
+  const user = await getUser();
+  if (!user?.id) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const { searchParams } = new URL(request.url);
+  const code = searchParams.get("code");
+  const state = searchParams.get("state");
+  const error = searchParams.get("error");
+
+  if (error) {
+    return NextResponse.redirect(
+      new URL(`/projects?error=${encodeURIComponent(error)}`, request.url)
+    );
+  }
+
+  if (!code || !state) {
+    return NextResponse.redirect(
+      new URL("/projects?error=Missing+authorization+code", request.url)
+    );
+  }
+
+  if (!NETLIFY_CLIENT_ID || !NETLIFY_CLIENT_SECRET) {
+    return NextResponse.json(
+      { error: "Netlify OAuth not configured" },
+      { status: 500 }
+    );
+  }
+
+  try {
+    const decodedState = JSON.parse(Buffer.from(state, "base64").toString());
+    if (decodedState.userId !== user.id) {
+      throw new Error("State token mismatch");
+    }
+
+    const tokenParams = new URLSearchParams({
+      grant_type: "authorization_code",
+      client_id: NETLIFY_CLIENT_ID,
+      client_secret: NETLIFY_CLIENT_SECRET,
+      redirect_uri: NETLIFY_REDIRECT_URI,
+      code,
+    });
+
+    const tokenResponse = await fetch("https://api.netlify.com/oauth/token", {
+      method: "POST",
+      headers: { "Content-Type": "application/x-www-form-urlencoded" },
+      body: tokenParams.toString(),
+    });
+
+    if (!tokenResponse.ok) {
+      const errorText = await tokenResponse.text();
+      throw new Error(errorText || "Failed to exchange authorization code");
+    }
+
+    const tokenData = parseTokenResponse(await tokenResponse.json());
+    if (!tokenData.access_token) {
+      throw new Error("Missing Netlify access token");
+    }
+
+    const userResponse = await fetch("https://api.netlify.com/api/v1/user", {
+      headers: {
+        Authorization: `Bearer ${tokenData.access_token}`,
+      },
+    });
+
+    const userData = userResponse.ok
+      ? parseUserResponse(await userResponse.json())
+      : {};
+
+    await fetchMutation(api.oauth.storeConnection, {
+      provider: "netlify",
+      accessToken: tokenData.access_token,
+      scope: tokenData.scope || tokenData.token_type || "netlify",
+      metadata: {
+        netlifyId: userData.id,
+        netlifyEmail: userData.email,
+        netlifyName: userData.full_name,
+        netlifyAvatarUrl: userData.avatar_url,
+      },
+    });
+
+    return NextResponse.redirect(
+      new URL("/projects?netlify=connected", request.url)
+    );
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "OAuth failed";
+    return NextResponse.redirect(
+      new URL(`/projects?error=${encodeURIComponent(message)}`, request.url)
+    );
+  }
+}

File: src/app/api/deploy/netlify/deploy/route.ts
Changes:
@@ -0,0 +1,137 @@
+import JSZip from "jszip";
+import { NextResponse } from "next/server";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { getUser, getConvexClientWithAuth } from "@/lib/auth-server";
+import { filterFilesForDownload } from "@/lib/filter-ai-files";
+import { getNetlifyToml } from "@/lib/netlify-config";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type DeployRequest = {
+  projectId: string;
+  siteId?: string;
+  deployType?: "preview" | "production";
+  branch?: string;
+  commitRef?: string;
+};
+
+type MessageWithFragment = {
+  _id: Id<"messages">;
+  _creationTime: number;
+  Fragment: {
+    _id: Id<"fragments">;
+    files?: unknown;
+    framework: "NEXTJS" | "REACT" | "VUE" | "ANGULAR" | "SVELTE";
+  } | null;
+};
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const normalizeFiles = (value: unknown): Record<string, string> => {
+  if (!value || typeof value !== "object") {
+    return {};
+  }
+
+  const files: Record<string, string> = {};
+  for (const [path, content] of Object.entries(value)) {
+    if (typeof content === "string") {
+      files[path] = content;
+    }
+  }
+  return files;
+};
+
+const getLatestFragmentFiles = async (projectId: Id<"projects">) => {
+  const messages = await fetchQuery(api.messages.list, { projectId }) as MessageWithFragment[];
+  const latestWithFragment = [...messages].reverse().find((message) => message.Fragment);
+  const fragment = latestWithFragment?.Fragment;
+
+  if (!fragment) {
+    throw new Error("No AI-generated files are ready to deploy.");
+  }
+
+  const normalized = normalizeFiles(fragment.files);
+  const filtered = filterFilesForDownload(normalized);
+
+  if (Object.keys(filtered).length === 0) {
+    throw new Error("No AI-generated files are ready to deploy.");
+  }
+
+  return { files: filtered, framework: fragment.framework };
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found. Please connect your Netlify account.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function POST(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = (await request.json()) as DeployRequest;
+    if (!body.projectId) {
+      return NextResponse.json({ error: "Missing projectId" }, { status: 400 });
+    }
+
+    const projectId = body.projectId as Id<"projects">;
+    const convex = await getConvexClientWithAuth();
+    const project = await convex.query(api.projects.get, { projectId });
+
+    const { files, framework } = await getLatestFragmentFiles(projectId);
+    const netlifyToml = getNetlifyToml(framework);
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+
+    const zip = new JSZip();
+    for (const [filename, content] of Object.entries(files)) {
+      zip.file(filename, content);
+    }
+    zip.file("netlify.toml", netlifyToml);
+
+    const archive = await zip.generateAsync({ type: "arraybuffer" });
+    const archiveBlob = new Blob([archive], { type: "application/zip" });
+
+    const site =
+      body.siteId ? await netlifyClient.getSite(body.siteId) : await netlifyClient.createSite(project.name);
+
+    const deploy =
+      body.deployType === "preview"
+        ? await netlifyClient.createPreviewDeployment(site.id, archiveBlob)
+        : await netlifyClient.deploySite(site.id, archiveBlob);
+
+    await fetchMutation(api.deployments.createDeployment, {
+      projectId,
+      platform: "netlify",
+      siteId: site.id,
+      siteUrl: site.site_url || site.url,
+      deployId: deploy.id,
+      status: deploy.state || "pending",
+      isPreview: body.deployType === "preview",
+      branch: body.branch,
+      commitRef: body.commitRef,
+    });
+
+    return NextResponse.json({
+      siteId: site.id,
+      siteUrl: site.site_url || site.url,
+      deployId: deploy.id,
+      deployState: deploy.state,
+    });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Deployment failed";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/domains/route.ts
Changes:
@@ -0,0 +1,100 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+type DomainPayload = {
+  siteId: string;
+  domain: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const siteId = searchParams.get("siteId");
+    const domainId = searchParams.get("domainId");
+    if (!siteId) {
+      return NextResponse.json({ error: "Missing siteId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    if (domainId) {
+      const domain = await netlifyClient.verifyDomain(siteId, domainId);
+      return NextResponse.json(domain);
+    }
+
+    const domains = await netlifyClient.listDomains(siteId);
+    return NextResponse.json(domains);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch domains";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function POST(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = (await request.json()) as DomainPayload;
+    if (!body.siteId || !body.domain) {
+      return NextResponse.json({ error: "Missing siteId or domain" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const domain = await netlifyClient.addDomain(body.siteId, body.domain);
+
+    return NextResponse.json(domain);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to add domain";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function DELETE(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const siteId = searchParams.get("siteId");
+    const domainId = searchParams.get("domainId");
+    if (!siteId || !domainId) {
+      return NextResponse.json({ error: "Missing siteId or domainId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    await netlifyClient.deleteDomain(siteId, domainId);
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to delete domain";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/env-vars/route.ts
Changes:
@@ -0,0 +1,129 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+type EnvVarPayload = {
+  siteId: string;
+  key: string;
+  value?: string;
+  context?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const siteId = searchParams.get("siteId");
+    if (!siteId) {
+      return NextResponse.json({ error: "Missing siteId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const envVars = await netlifyClient.getEnvVars(siteId);
+
+    return NextResponse.json(envVars);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch env vars";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function POST(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = (await request.json()) as EnvVarPayload;
+    if (!body.siteId || !body.key || !body.value) {
+      return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const envVar = await netlifyClient.setEnvVar(
+      body.siteId,
+      body.key,
+      body.value,
+      body.context
+    );
+
+    return NextResponse.json(envVar);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to set env var";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function PUT(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = (await request.json()) as EnvVarPayload;
+    if (!body.siteId || !body.key || !body.value) {
+      return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const envVar = await netlifyClient.updateEnvVar(
+      body.siteId,
+      body.key,
+      body.value,
+      body.context
+    );
+
+    return NextResponse.json(envVar);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to update env var";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function DELETE(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const siteId = searchParams.get("siteId");
+    const key = searchParams.get("key");
+    if (!siteId || !key) {
+      return NextResponse.json({ error: "Missing siteId or key" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    await netlifyClient.deleteEnvVar(siteId, key);
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to delete env var";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/logs/route.ts
Changes:
@@ -0,0 +1,44 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const deployId = searchParams.get("deployId");
+    if (!deployId) {
+      return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const logs = await netlifyClient.getBuildLog(deployId);
+
+    return NextResponse.json({ logs });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch logs";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/preview/route.ts
Changes:
@@ -0,0 +1,44 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function DELETE(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const deployId = searchParams.get("deployId");
+    if (!deployId) {
+      return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    await netlifyClient.deletePreviewDeployment(deployId);
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to delete preview";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/rollback/route.ts
Changes:
@@ -0,0 +1,47 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+type RollbackPayload = {
+  deployId: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function POST(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = (await request.json()) as RollbackPayload;
+    if (!body.deployId) {
+      return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const rollback = await netlifyClient.rollbackDeployment(body.deployId);
+
+    return NextResponse.json(rollback);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Rollback failed";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/sites/route.ts
Changes:
@@ -0,0 +1,38 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET() {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const sites = await netlifyClient.listSites();
+
+    return NextResponse.json(sites);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch sites";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/status/route.ts
Changes:
@@ -0,0 +1,44 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const deployId = searchParams.get("deployId");
+    if (!deployId) {
+      return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const status = await netlifyClient.getDeploymentStatus(deployId);
+
+    return NextResponse.json(status);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch status";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/github/repositories/route.ts
Changes:
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { listRepositories } from "@/lib/github-api";
+
+export async function GET() {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const accessToken = await fetchQuery(api.oauth.getGithubAccessToken, {});
+    if (!accessToken) {
+      return NextResponse.json(
+        { error: "GitHub connection not found. Please connect GitHub." },
+        { status: 400 },
+      );
+    }
+
+    const repositories = await listRepositories(accessToken);
+
+    return NextResponse.json({
+      repositories: repositories.map((repo) => ({
+        id: repo.id,
+        name: repo.name,
+        fullName: repo.full_name,
+        url: repo.html_url,
+        isPrivate: repo.private,
+        defaultBranch: repo.default_branch ?? "main",
+      })),
+    });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to load repositories";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/projects/[projectId]/export/github/route.ts
Changes:
@@ -0,0 +1,127 @@
+import { NextResponse } from "next/server";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+import { z } from "zod";
+
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import { getConvexClientWithAuth, getUser } from "@/lib/auth-server";
+import {
+  createRepository,
+  getRepository,
+  type CreateRepositoryInput,
+} from "@/lib/github-api";
+
+const exportRequestSchema = z
+  .object({
+    repositoryName: z.string().trim().min(1).optional(),
+    repositoryFullName: z.string().trim().min(1).optional(),
+    description: z.string().trim().optional(),
+    isPrivate: z.boolean().optional(),
+    branch: z.string().trim().optional(),
+    includeReadme: z.boolean().optional(),
+    includeGitignore: z.boolean().optional(),
+    commitMessage: z.string().trim().optional(),
+  })
+  .refine((data) => data.repositoryFullName || data.repositoryName, {
+    message: "Repository name is required.",
+  });
+
+export async function POST(
+  request: Request,
+  { params }: { params: Promise<{ projectId: string }> },
+) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { projectId } = await params;
+    const body = exportRequestSchema.parse(await request.json());
+    const accessToken = await fetchQuery(api.oauth.getGithubAccessToken, {});
+
+    if (!accessToken) {
+      return NextResponse.json(
+        { error: "GitHub connection not found. Please connect GitHub." },
+        { status: 400 },
+      );
+    }
+
+    let repository;
+    if (body.repositoryFullName) {
+      repository = await getRepository(accessToken, body.repositoryFullName);
+    } else {
+      if (!body.repositoryName) {
+        return NextResponse.json(
+          { error: "Repository name is required." },
+          { status: 400 },
+        );
+      }
+
+      const input: CreateRepositoryInput = {
+        name: body.repositoryName,
+        description: body.description,
+        isPrivate: body.isPrivate ?? false,
+      };
+      repository = await createRepository(accessToken, input);
+    }
+
+    const branch = body.branch ?? repository.default_branch ?? "main";
+
+    const exportId = await fetchMutation(api.githubExports.create, {
+      projectId: projectId as Id<"projects">,
+      repositoryName: repository.name,
+      repositoryUrl: repository.html_url,
+      repositoryFullName: repository.full_name,
+      branch,
+    });
+
+    const convex = await getConvexClientWithAuth();
+    const result = await convex.action(api.githubExports.exportToGitHub, {
+      exportId,
+      branch,
+      includeReadme: body.includeReadme,
+      includeGitignore: body.includeGitignore,
+      commitMessage: body.commitMessage,
+    });
+
+    return NextResponse.json(result);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Export failed";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function GET(
+  request: Request,
+  { params }: { params: Promise<{ projectId: string }> },
+) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { projectId } = await params;
+    const { searchParams } = new URL(request.url);
+    const exportId = searchParams.get("exportId");
+
+    if (!exportId) {
+      return NextResponse.json({ error: "Missing exportId" }, { status: 400 });
+    }
+
+    const exportsList = await fetchQuery(api.githubExports.list, {
+      projectId: projectId as Id<"projects">,
+    });
+    const record = exportsList.find((item) => item._id === exportId);
+
+    if (!record) {
+      return NextResponse.json({ error: "Export not found" }, { status: 404 });
+    }
+
+    return NextResponse.json(record);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to load export";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/lib/github-api.ts
Changes:
@@ -0,0 +1,350 @@
+import { z } from "zod";
+
+const GITHUB_API_BASE_URL = "https://api.github.com";
+const GITHUB_API_VERSION = "2022-11-28";
+const MAX_TREE_CONTENT_BYTES = 100000;
+
+const githubErrorSchema = z.object({
+  message: z.string().optional(),
+});
+
+const githubUserSchema = z.object({
+  id: z.number(),
+  login: z.string(),
+  name: z.string().nullable().optional(),
+  email: z.string().nullable().optional(),
+  avatar_url: z.string().optional(),
+});
+
+const githubRepositorySchema = z.object({
+  id: z.number(),
+  name: z.string(),
+  full_name: z.string(),
+  html_url: z.string(),
+  private: z.boolean(),
+  default_branch: z.string().optional(),
+});
+
+const githubRefSchema = z.object({
+  object: z.object({
+    sha: z.string(),
+  }),
+});
+
+const githubTreeSchema = z.object({
+  sha: z.string(),
+});
+
+const githubCommitSchema = z.object({
+  sha: z.string(),
+  tree: z.object({
+    sha: z.string(),
+  }),
+});
+
+type GitHubRequestOptions = {
+  method?: "GET" | "POST" | "PATCH" | "PUT";
+  body?: unknown;
+  headers?: Record<string, string>;
+};
+
+export type GitHubUser = z.infer<typeof githubUserSchema>;
+export type GitHubRepository = z.infer<typeof githubRepositorySchema>;
+
+export type GitHubTreeEntry = {
+  path: string;
+  mode: "100644";
+  type: "blob";
+  content: string;
+};
+
+export type ProjectFramework = "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE";
+
+export type CreateRepositoryInput = {
+  name: string;
+  description?: string;
+  isPrivate: boolean;
+};
+
+export type ExportReadmeInput = {
+  projectName: string;
+  framework: ProjectFramework;
+  description?: string;
+};
+
+const parseGitHubError = (payload: unknown, status: number): string => {
+  const parsed = githubErrorSchema.safeParse(payload);
+  if (parsed.success && parsed.data.message) {
+    return parsed.data.message;
+  }
+
+  return `GitHub API error (${status})`;
+};
+
+const githubRequest = async (
+  path: string,
+  accessToken: string,
+  options: GitHubRequestOptions = {},
+): Promise<unknown> => {
+  const response = await fetch(`${GITHUB_API_BASE_URL}${path}`, {
+    method: options.method ?? "GET",
+    headers: {
+      Accept: "application/vnd.github+json",
+      Authorization: `Bearer ${accessToken}`,
+      "User-Agent": "ZapDev",
+      "X-GitHub-Api-Version": GITHUB_API_VERSION,
+      ...(options.headers ?? {}),
+    },
+    body: options.body ? JSON.stringify(options.body) : undefined,
+  });
+
+  const payload = await response.json().catch(() => null);
+
+  if (!response.ok) {
+    throw new Error(parseGitHubError(payload, response.status));
+  }
+
+  return payload;
+};
+
+export const getAuthenticatedUser = async (
+  accessToken: string,
+): Promise<GitHubUser> => {
+  const payload = await githubRequest("/user", accessToken);
+  return githubUserSchema.parse(payload);
+};
+
+export const listRepositories = async (
+  accessToken: string,
+): Promise<Array<GitHubRepository>> => {
+  const payload = await githubRequest("/user/repos?per_page=100&sort=updated", accessToken);
+  return z.array(githubRepositorySchema).parse(payload);
+};
+
+export const getRepository = async (
+  accessToken: string,
+  fullName: string,
+): Promise<GitHubRepository> => {
+  const payload = await githubRequest(`/repos/${fullName}`, accessToken);
+  return githubRepositorySchema.parse(payload);
+};
+
+export const createRepository = async (
+  accessToken: string,
+  input: CreateRepositoryInput,
+): Promise<GitHubRepository> => {
+  const payload = await githubRequest("/user/repos", accessToken, {
+    method: "POST",
+    body: {
+      name: input.name,
+      description: input.description ?? "",
+      private: input.isPrivate,
+      auto_init: true,
+    },
+  });
+  return githubRepositorySchema.parse(payload);
+};
+
+export const getBranchRef = async (
+  accessToken: string,
+  fullName: string,
+  branch: string,
+): Promise<string> => {
+  const payload = await githubRequest(
+    `/repos/${fullName}/git/ref/heads/${branch}`,
+    accessToken,
+  );
+  return githubRefSchema.parse(payload).object.sha;
+};
+
+export const getCommitTreeSha = async (
+  accessToken: string,
+  fullName: string,
+  commitSha: string,
+): Promise<string> => {
+  const payload = await githubRequest(
+    `/repos/${fullName}/git/commits/${commitSha}`,
+    accessToken,
+  );
+  return githubCommitSchema.parse(payload).tree.sha;
+};
+
+export const createTree = async (
+  accessToken: string,
+  fullName: string,
+  tree: Array<GitHubTreeEntry>,
+  baseTreeSha?: string,
+): Promise<string> => {
+  const payload = await githubRequest(`/repos/${fullName}/git/trees`, accessToken, {
+    method: "POST",
+    body: {
+      base_tree: baseTreeSha,
+      tree,
+    },
+  });
+  return githubTreeSchema.parse(payload).sha;
+};
+
+export const createCommit = async (
+  accessToken: string,
+  fullName: string,
+  message: string,
+  treeSha: string,
+  parents: Array<string>,
+): Promise<string> => {
+  const payload = await githubRequest(`/repos/${fullName}/git/commits`, accessToken, {
+    method: "POST",
+    body: {
+      message,
+      tree: treeSha,
+      parents,
+    },
+  });
+  return githubCommitSchema.parse(payload).sha;
+};
+
+export const createBranchRef = async (
+  accessToken: string,
+  fullName: string,
+  branch: string,
+  commitSha: string,
+): Promise<void> => {
+  await githubRequest(`/repos/${fullName}/git/refs`, accessToken, {
+    method: "POST",
+    body: {
+      ref: `refs/heads/${branch}`,
+      sha: commitSha,
+    },
+  });
+};
+
+export const updateBranchRef = async (
+  accessToken: string,
+  fullName: string,
+  branch: string,
+  commitSha: string,
+): Promise<void> => {
+  await githubRequest(`/repos/${fullName}/git/refs/heads/${branch}`, accessToken, {
+    method: "PATCH",
+    body: {
+      sha: commitSha,
+      force: false,
+    },
+  });
+};
+
+const sanitizePath = (value: string): string => {
+  return value.replace(/^\/+/, "").replace(/\\/g, "/");
+};
+
+export const buildTreeEntries = (
+  files: Record<string, string>,
+): Array<GitHubTreeEntry> => {
+  const entries: Array<GitHubTreeEntry> = [];
+  const encoder = new TextEncoder();
+
+  for (const [rawPath, content] of Object.entries(files)) {
+    const path = sanitizePath(rawPath);
+    if (!path) {
+      continue;
+    }
+
+    const byteLength = encoder.encode(content).length;
+    if (byteLength > MAX_TREE_CONTENT_BYTES) {
+      throw new Error(`File too large for GitHub export: ${path}`);
+    }
+
+    entries.push({
+      path,
+      mode: "100644",
+      type: "blob",
+      content,
+    });
+  }
+
+  return entries;
+};
+
+const getFrameworkLabel = (framework: ProjectFramework): string => {
+  switch (framework) {
+    case "NEXTJS":
+      return "Next.js";
+    case "ANGULAR":
+      return "Angular";
+    case "REACT":
+      return "React";
+    case "VUE":
+      return "Vue";
+    case "SVELTE":
+      return "Svelte";
+    default:
+      return framework;
+  }
+};
+
+export const generateReadme = (input: ExportReadmeInput): string => {
+  const frameworkLabel = getFrameworkLabel(input.framework);
+
+  const lines: Array<string> = [`# ${input.projectName}`, ""];
+
+  if (input.description) {
+    lines.push(input.description, "");
+  }
+
+  lines.push(
+    "Exported from ZapDev.",
+    "",
+    `Framework: ${frameworkLabel}`,
+    "",
+    "## Getting Started",
+    "",
+    "1. Install dependencies with `bun install`.",
+    "2. Start the dev server with `bun run dev`.",
+    "3. Build for production with `bun run build`.",
+  );
+
+  return lines.join("\n");
+};
+
+export const generateGitignore = (framework: ProjectFramework): string => {
+  const base = [
+    "node_modules",
+    ".env",
+    ".env.local",
+    ".env.*.local",
+    "dist",
+    "build",
+    ".cache",
+    ".DS_Store",
+  ];
+
+  const frameworkSpecific: Record<ProjectFramework, Array<string>> = {
+    NEXTJS: [".next", "out", "next-env.d.ts"],
+    REACT: ["coverage"],
+    VUE: ["dist", ".vite"],
+    ANGULAR: [".angular", "dist"],
+    SVELTE: [".svelte-kit"],
+  };
+
+  const entries = [...base, ...frameworkSpecific[framework]];
+  return entries.join("\n");
+};
+
+export const withDefaultFiles = (
+  files: Record<string, string>,
+  input: ExportReadmeInput,
+  includeReadme: boolean,
+  includeGitignore: boolean,
+): Record<string, string> => {
+  const updated: Record<string, string> = { ...files };
+
+  if (includeReadme && !updated["README.md"]) {
+    updated["README.md"] = generateReadme(input);
+  }
+
+  if (includeGitignore && !updated[".gitignore"]) {
+    updated[".gitignore"] = generateGitignore(input.framework);
+  }
+
+  return updated;
+};

File: src/lib/netlify-client.ts
Changes:
@@ -0,0 +1,238 @@
+type NetlifyRequestOptions = {
+  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+  headers?: Record<string, string>;
+  body?: BodyInit | null;
+};
+
+type NetlifySite = {
+  id: string;
+  name: string;
+  url: string;
+  site_url: string;
+  admin_url?: string;
+};
+
+type NetlifyDeploy = {
+  id: string;
+  state: string;
+  url?: string;
+  deploy_url?: string;
+  created_at?: string;
+  updated_at?: string;
+};
+
+type NetlifyEnvVar = {
+  key: string;
+  values?: Array<{
+    value: string;
+    context?: string;
+  }>;
+};
+
+type NetlifyDomain = {
+  id: string;
+  name: string;
+  ssl_status?: string;
+  verification?: {
+    status?: string;
+  };
+};
+
+const NETLIFY_API_BASE = "https://api.netlify.com/api/v1";
+
+const parseJson = async <T>(response: Response): Promise<T> => {
+  const text = await response.text();
+  if (!text) {
+    return {} as T;
+  }
+  return JSON.parse(text) as T;
+};
+
+const handleApiError = async (response: Response) => {
+  if (response.status === 429) {
+    const retryAfter = response.headers.get("retry-after");
+    throw new Error(`Netlify rate limit hit. Retry after ${retryAfter ?? "unknown"} seconds.`);
+  }
+
+  const errorBody = await response.text();
+  throw new Error(errorBody || `Netlify API error: ${response.status}`);
+};
+
+export const createNetlifyClient = (accessToken: string) => {
+  const request = async <T>(path: string, options: NetlifyRequestOptions = {}) => {
+    const response = await fetch(`${NETLIFY_API_BASE}${path}`, {
+      method: options.method ?? "GET",
+      headers: {
+        Authorization: `Bearer ${accessToken}`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ?? null,
+    });
+
+    if (!response.ok) {
+      await handleApiError(response);
+    }
+
+    return parseJson<T>(response);
+  };
+
+  return {
+    async createSite(name?: string): Promise<NetlifySite> {
+      return request<NetlifySite>("/sites", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(name ? { name } : {}),
+      });
+    },
+
+    async getSite(siteId: string): Promise<NetlifySite> {
+      return request<NetlifySite>(`/sites/${siteId}`);
+    },
+
+    async listSites(): Promise<NetlifySite[]> {
+      return request<NetlifySite[]>("/sites");
+    },
+
+    async updateSite(siteId: string, payload: Record<string, unknown>): Promise<NetlifySite> {
+      return request<NetlifySite>(`/sites/${siteId}`, {
+        method: "PATCH",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload),
+      });
+    },
+
+    async deleteSite(siteId: string): Promise<void> {
+      await request<void>(`/sites/${siteId}`, { method: "DELETE" });
+    },
+
+    async deploySite(siteId: string, zipBody: BodyInit, options?: { draft?: boolean }): Promise<NetlifyDeploy> {
+      const params = new URLSearchParams();
+      if (options?.draft) {
+        params.set("draft", "true");
+      }
+
+      const query = params.toString();
+      const path = query ? `/sites/${siteId}/deploys?${query}` : `/sites/${siteId}/deploys`;
+
+      return request<NetlifyDeploy>(path, {
+        method: "POST",
+        headers: { "Content-Type": "application/zip" },
+        body: zipBody,
+      });
+    },
+
+    async getDeploymentStatus(deployId: string): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/deploys/${deployId}`);
+    },
+
+    async listDeployments(siteId: string): Promise<NetlifyDeploy[]> {
+      return request<NetlifyDeploy[]>(`/sites/${siteId}/deploys`);
+    },
+
+    async getDeployment(deployId: string): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/deploys/${deployId}`);
+    },
+
+    async cancelDeployment(deployId: string): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/deploys/${deployId}/cancel`, { method: "POST" });
+    },
+
+    async rollbackDeployment(deployId: string): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/deploys/${deployId}/rollback`, { method: "POST" });
+    },
+
+    async getBuildLog(deployId: string): Promise<string> {
+      const response = await fetch(`${NETLIFY_API_BASE}/deploys/${deployId}/logs`, {
+        headers: { Authorization: `Bearer ${accessToken}` },
+      });
+
+      if (!response.ok) {
+        await handleApiError(response);
+      }
+
+      return response.text();
+    },
+
+    async getEnvVars(siteId: string): Promise<NetlifyEnvVar[]> {
+      return request<NetlifyEnvVar[]>(`/sites/${siteId}/env`);
+    },
+
+    async setEnvVar(siteId: string, key: string, value: string, context = "all"): Promise<NetlifyEnvVar> {
+      return request<NetlifyEnvVar>(`/sites/${siteId}/env`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          key,
+          values: [{ value, context }],
+        }),
+      });
+    },
+
+    async updateEnvVar(siteId: string, key: string, value: string, context = "all"): Promise<NetlifyEnvVar> {
+      return request<NetlifyEnvVar>(`/sites/${siteId}/env/${key}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          values: [{ value, context }],
+        }),
+      });
+    },
+
+    async deleteEnvVar(siteId: string, key: string): Promise<void> {
+      await request<void>(`/sites/${siteId}/env/${key}`, { method: "DELETE" });
+    },
+
+    async setBulkEnvVars(siteId: string, vars: Array<{ key: string; value: string; context?: string }>): Promise<NetlifyEnvVar[]> {
+      const payload = vars.map((entry) => ({
+        key: entry.key,
+        values: [{ value: entry.value, context: entry.context ?? "all" }],
+      }));
+
+      return request<NetlifyEnvVar[]>(`/sites/${siteId}/env`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload),
+      });
+    },
+
+    async listDomains(siteId: string): Promise<NetlifyDomain[]> {
+      return request<NetlifyDomain[]>(`/sites/${siteId}/domains`);
+    },
+
+    async addDomain(siteId: string, domain: string): Promise<NetlifyDomain> {
+      return request<NetlifyDomain>(`/sites/${siteId}/domains`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ name: domain }),
+      });
+    },
+
+    async deleteDomain(siteId: string, domainId: string): Promise<void> {
+      await request<void>(`/sites/${siteId}/domains/${domainId}`, { method: "DELETE" });
+    },
+
+    async verifyDomain(siteId: string, domainId: string): Promise<NetlifyDomain> {
+      return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+    },
+
+    async getDnsRecords(siteId: string, domainId: string): Promise<NetlifyDomain> {
+      return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+    },
+
+    async createPreviewDeployment(siteId: string, zipBody: BodyInit): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/sites/${siteId}/deploys?draft=true`, {
+        method: "POST",
+        headers: { "Content-Type": "application/zip" },
+        body: zipBody,
+      });
+    },
+
+    async listPreviewDeployments(siteId: string): Promise<NetlifyDeploy[]> {
+      return request<NetlifyDeploy[]>(`/sites/${siteId}/deploys?draft=true`);
+    },
+
+    async deletePreviewDeployment(deployId: string): Promise<void> {
+      await request<void>(`/deploys/${deployId}`, { method: "DELETE" });
+    },
+  };
+};

File: src/lib/netlify-config.ts
Changes:
@@ -0,0 +1,70 @@
+type FrameworkKey = "NEXTJS" | "REACT" | "VUE" | "ANGULAR" | "SVELTE";
+
+type NetlifyConfig = {
+  buildCommand: string;
+  publishDir: string;
+  plugins?: Array<string>;
+  env?: Record<string, string>;
+};
+
+const frameworkConfigMap: Record<FrameworkKey, NetlifyConfig> = {
+  NEXTJS: {
+    buildCommand: "bun run build",
+    publishDir: ".next",
+    plugins: ["@netlify/plugin-nextjs"],
+  },
+  REACT: {
+    buildCommand: "bun run build",
+    publishDir: "dist",
+  },
+  VUE: {
+    buildCommand: "bun run build",
+    publishDir: "dist",
+  },
+  ANGULAR: {
+    buildCommand: "bun run build",
+    publishDir: "dist",
+  },
+  SVELTE: {
+    buildCommand: "bun run build",
+    publishDir: "build",
+  },
+};
+
+const formatEnvBlock = (env?: Record<string, string>) => {
+  if (!env || Object.keys(env).length === 0) {
+    return "";
+  }
+
+  const lines = Object.entries(env).map(([key, value]) => `  ${key} = "${value}"`);
+  return `\n[build.environment]\n${lines.join("\n")}\n`;
+};
+
+export const getNetlifyToml = (framework: FrameworkKey) => {
+  const config = frameworkConfigMap[framework];
+  const pluginsBlock = config.plugins?.length
+    ? `\n[[plugins]]\n  package = "${config.plugins[0]}"\n`
+    : "";
+  const envBlock = formatEnvBlock(config.env);
+
+  return [
+    "[build]",
+    `  command = "${config.buildCommand}"`,
+    `  publish = "${config.publishDir}"`,
+    pluginsBlock.trimEnd(),
+    envBlock.trimEnd(),
+  ]
+    .filter((line) => line.length > 0)
+    .join("\n")
+    .trim()
+    .concat("\n");
+};
+
+export const getNetlifyBuildSettings = (framework: FrameworkKey) => {
+  const config = frameworkConfigMap[framework];
+  return {
+    buildCommand: config.buildCommand,
+    publishDir: config.publishDir,
+    plugins: config.plugins ?? [],
+  };
+};

File: src/lib/payment-provider.ts
Changes:
@@ -0,0 +1,211 @@
+export type BillingInterval = "monthly" | "yearly";
+
+export type SubscriptionStatus =
+  | "active"
+  | "trialing"
+  | "past_due"
+  | "canceled"
+  | "unpaid";
+
+export interface CheckoutSessionRequest {
+  customerId: string;
+  productId: string;
+  successUrl: string;
+  cancelUrl: string;
+  metadata?: Record<string, string>;
+}
+
+export interface CheckoutSession {
+  id: string;
+  url: string;
+}
+
+export interface SubscriptionLookup {
+  subscriptionId: string;
+}
+
+export interface SubscriptionSummary {
+  id: string;
+  customerId: string;
+  productId: string;
+  status: SubscriptionStatus;
+  interval: BillingInterval;
+  currentPeriodEnd: string;
+  cancelAtPeriodEnd: boolean;
+}
+
+export interface CancelSubscriptionRequest {
+  subscriptionId: string;
+  cancelAtPeriodEnd?: boolean;
+}
+
+export interface UpdateSubscriptionRequest {
+  subscriptionId: string;
+  productId: string;
+}
+
+export interface BillingPortalRequest {
+  customerId: string;
+  returnUrl: string;
+}
+
+export interface UsageEvent {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export interface FeatureCheckRequest {
+  customerId: string;
+  featureId: string;
+}
+
+export interface FeatureCheckResult {
+  allowed: boolean;
+  limit?: number;
+  used?: number;
+  remaining?: number;
+}
+
+export interface PaymentProvider {
+  createCheckoutSession(input: CheckoutSessionRequest): Promise<CheckoutSession>;
+  getSubscription(input: SubscriptionLookup): Promise<SubscriptionSummary | null>;
+  updateSubscription(input: UpdateSubscriptionRequest): Promise<SubscriptionSummary>;
+  cancelSubscription(input: CancelSubscriptionRequest): Promise<SubscriptionSummary>;
+  createBillingPortalSession(input: BillingPortalRequest): Promise<{ url: string }>;
+  trackUsage(input: UsageEvent): Promise<void>;
+  checkFeature(input: FeatureCheckRequest): Promise<FeatureCheckResult>;
+}
+
+interface AutumnConfig {
+  apiKey: string;
+  baseUrl?: string;
+}
+
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+export class AutumnStripeProvider implements PaymentProvider {
+  private apiKey: string;
+  private baseUrl: string;
+
+  constructor(config: AutumnConfig) {
+    this.apiKey = config.apiKey;
+    this.baseUrl = config.baseUrl ?? "https://api.useautumn.com";
+  }
+
+  async createCheckoutSession(
+    input: CheckoutSessionRequest
+  ): Promise<CheckoutSession> {
+    return this.request<CheckoutSession>("/v1/checkout", {
+      method: "POST",
+      body: {
+        customerId: input.customerId,
+        productId: input.productId,
+        successUrl: input.successUrl,
+        cancelUrl: input.cancelUrl,
+        metadata: input.metadata,
+      },
+    });
+  }
+
+  async getSubscription(
+    input: SubscriptionLookup
+  ): Promise<SubscriptionSummary | null> {
+    return this.request<SubscriptionSummary | null>(
+      `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}`,
+      { method: "GET" }
+    );
+  }
+
+  async updateSubscription(
+    input: UpdateSubscriptionRequest
+  ): Promise<SubscriptionSummary> {
+    return this.request<SubscriptionSummary>(
+      `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}`,
+      {
+        method: "PATCH",
+        body: {
+          productId: input.productId,
+        },
+      }
+    );
+  }
+
+  async cancelSubscription(
+    input: CancelSubscriptionRequest
+  ): Promise<SubscriptionSummary> {
+    return this.request<SubscriptionSummary>(
+      `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}/cancel`,
+      {
+        method: "POST",
+        body: {
+          cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? true,
+        },
+      }
+    );
+  }
+
+  async createBillingPortalSession(
+    input: BillingPortalRequest
+  ): Promise<{ url: string }> {
+    return this.request<{ url: string }>("/v1/portal", {
+      method: "POST",
+      body: {
+        customerId: input.customerId,
+        returnUrl: input.returnUrl,
+      },
+    });
+  }
+
+  async trackUsage(input: UsageEvent): Promise<void> {
+    await this.request<{ ok: boolean }>("/v1/usage", {
+      method: "POST",
+      body: {
+        customerId: input.customerId,
+        meterId: input.meterId,
+        quantity: input.quantity,
+      },
+    });
+  }
+
+  async checkFeature(input: FeatureCheckRequest): Promise<FeatureCheckResult> {
+    return this.request<FeatureCheckResult>("/v1/features/check", {
+      method: "POST",
+      body: {
+        customerId: input.customerId,
+        featureId: input.featureId,
+      },
+    });
+  }
+
+  private async request<T>(
+    path: string,
+    options: AutumnRequestOptions
+  ): Promise<T> {
+    const url = `${this.baseUrl}${path}`;
+    const response = await fetch(url, {
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${this.apiKey}`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ? JSON.stringify(options.body) : undefined,
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(
+        `Autumn API request failed: ${response.status} ${response.statusText} - ${errorText}`
+      );
+    }
+
+    if (response.status === 204) {
+      return undefined as T;
+    }
+
+    return (await response.json()) as T;
+  }
+}

File: src/lib/payment-templates/angular.ts
Changes:
@@ -0,0 +1,388 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const angularPaymentTemplate: PaymentTemplateBundle = {
+  framework: "angular",
+  description: "Angular payment integration with Autumn + Stripe",
+  files: {
+    "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+
+  const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+    const response = await fetch(\`\${baseUrl}\${path}\`, {
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: \`Bearer \${apiKey}\`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ? JSON.stringify(options.body) : undefined,
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+    }
+
+    if (response.status === 204) {
+      return undefined as T;
+    }
+
+    return (await response.json()) as T;
+  };
+
+  return { request };
+};
+`,
+    "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+  if (!isCheckoutRequest(req.body)) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+    method: "POST",
+    body: req.body,
+  });
+  res.json(checkout);
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+  const { customerId, returnUrl } = req.body as {
+    customerId?: string;
+    returnUrl?: string;
+  };
+  if (!customerId || !returnUrl) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const portal = await autumn.request<{ url: string }>("/v1/portal", {
+    method: "POST",
+    body: { customerId, returnUrl },
+  });
+  res.json(portal);
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+  const { subscriptionId, productId } = req.body as {
+    subscriptionId?: string;
+    productId?: string;
+  };
+  if (!subscriptionId || !productId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const updated = await autumn.request<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+    {
+      method: "PATCH",
+      body: { productId },
+    }
+  );
+  res.json(updated);
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+  const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+    subscriptionId?: string;
+    cancelAtPeriodEnd?: boolean;
+  };
+  if (!subscriptionId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const canceled = await autumn.request<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`,
+    {
+      method: "POST",
+      body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+    }
+  );
+  res.json(canceled);
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+  const { customerId, featureId } = req.body as {
+    customerId?: string;
+    featureId?: string;
+  };
+  if (!customerId || !featureId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const result = await autumn.request<unknown>("/v1/features/check", {
+    method: "POST",
+    body: { customerId, featureId },
+  });
+  res.json(result);
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+  const { customerId, meterId, quantity } = req.body as {
+    customerId?: string;
+    meterId?: string;
+    quantity?: number;
+  };
+  if (!customerId || !meterId || typeof quantity !== "number") {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  await autumn.request("/v1/usage", {
+    method: "POST",
+    body: { customerId, meterId, quantity },
+  });
+  res.json({ ok: true });
+});
+
+export default router;
+`,
+    "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    res.status(500).json({ error: "Missing webhook secret" });
+    return;
+  }
+  const signature = req.headers["autumn-signature"];
+  const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+  const rawBody = req.body as string;
+  if (!verifySignature(signatureValue, rawBody, secret)) {
+    res.status(401).json({ error: "Invalid signature" });
+    return;
+  }
+  const event = JSON.parse(rawBody) as { type: string; data: unknown };
+  switch (event.type) {
+    case "subscription.created":
+    case "subscription.updated":
+    case "subscription.canceled":
+    case "invoice.payment_failed":
+    case "invoice.payment_succeeded":
+      break;
+    default:
+      break;
+  }
+  res.json({ received: true });
+});
+
+export default router;
+`,
+    "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json());
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+  console.log(\`Billing API listening on \${port}\`);
+});
+`,
+    "src/app/services/billing.service.ts": `
+import { Injectable } from "@angular/core";
+
+interface CheckoutPayload {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+}
+
+@Injectable({ providedIn: "root" })
+export class BillingService {
+  async startCheckout(payload: CheckoutPayload): Promise<void> {
+    const response = await fetch("/api/billing/checkout", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(payload),
+    });
+    const data = (await response.json()) as { url?: string };
+    if (data.url) {
+      window.location.href = data.url;
+    }
+  }
+
+  async checkFeature(customerId: string, featureId: string): Promise<boolean> {
+    const response = await fetch("/api/billing/feature-check", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ customerId, featureId }),
+    });
+    const data = (await response.json()) as { allowed?: boolean };
+    return data.allowed === true;
+  }
+
+  async trackUsage(customerId: string, meterId: string, quantity: number): Promise<void> {
+    await fetch("/api/billing/usage", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ customerId, meterId, quantity }),
+    });
+  }
+}
+`,
+    "src/app/guards/feature.guard.ts": `
+import { Injectable } from "@angular/core";
+import type { CanActivateFn, ActivatedRouteSnapshot } from "@angular/router";
+import { BillingService } from "../services/billing.service";
+
+@Injectable({ providedIn: "root" })
+export class FeatureGuard {
+  constructor(private billingService: BillingService) {}
+
+  canActivate: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
+    const featureId = route.data?.["featureId"];
+    const customerId = route.data?.["customerId"];
+    if (typeof featureId !== "string" || typeof customerId !== "string") {
+      return false;
+    }
+    return this.billingService.checkFeature(customerId, featureId);
+  };
+}
+`,
+    "src/app/components/checkout-button/checkout-button.component.ts": `
+import { Component, Input } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { BillingService } from "../../services/billing.service";
+
+@Component({
+  selector: "app-checkout-button",
+  standalone: true,
+  imports: [CommonModule],
+  template: \`
+    <button
+      type="button"
+      class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+      [disabled]="loading"
+      (click)="startCheckout()"
+    >
+      {{ loading ? "Redirecting..." : (label ?? "Upgrade") }}
+    </button>
+  \`,
+})
+export class CheckoutButtonComponent {
+  @Input({ required: true }) productId = "";
+  @Input({ required: true }) customerId = "";
+  @Input({ required: true }) successUrl = "";
+  @Input({ required: true }) cancelUrl = "";
+  @Input() label?: string;
+
+  loading = false;
+
+  constructor(private billingService: BillingService) {}
+
+  async startCheckout() {
+    this.loading = true;
+    try {
+      await this.billingService.startCheckout({
+        productId: this.productId,
+        customerId: this.customerId,
+        successUrl: this.successUrl,
+        cancelUrl: this.cancelUrl,
+      });
+    } finally {
+      this.loading = false;
+    }
+  }
+}
+`,
+    "src/app/components/billing-success/billing-success.component.ts": `
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+
+@Component({
+  selector: "app-billing-success",
+  standalone: true,
+  imports: [CommonModule],
+  template: \`
+    <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 class="text-3xl font-semibold">Payment successful</h1>
+      <p class="text-muted-foreground">
+        Your subscription is active. You can return to the app and start using
+        your new plan immediately.
+      </p>
+      <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+        Return to app
+      </a>
+    </div>
+  \`,
+})
+export class BillingSuccessComponent {}
+`,
+    "src/app/components/billing-cancel/billing-cancel.component.ts": `
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+
+@Component({
+  selector: "app-billing-cancel",
+  standalone: true,
+  imports: [CommonModule],
+  template: \`
+    <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+      <p class="text-muted-foreground">
+        Your checkout was canceled. You can restart the process at any time.
+      </p>
+      <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+        Return to app
+      </a>
+    </div>
+  \`,
+})
+export class BillingCancelComponent {}
+`,
+  },
+};

File: src/lib/payment-templates/autumn-config.ts
Changes:
@@ -0,0 +1,49 @@
+export const autumnConfigTemplate = `
+export const autumnConfig = {
+  products: [
+    {
+      id: "free",
+      name: "Free",
+      description: "Starter access",
+      prices: [
+        {
+          id: "free-monthly",
+          amount: 0,
+          currency: "usd",
+          interval: "monthly",
+        },
+      ],
+      features: ["basic_generations"],
+    },
+    {
+      id: "pro",
+      name: "Pro",
+      description: "Pro plan with higher limits",
+      prices: [
+        {
+          id: "pro-monthly",
+          amount: 2900,
+          currency: "usd",
+          interval: "monthly",
+        },
+      ],
+      features: ["basic_generations", "priority_generations"],
+    },
+  ],
+  features: {
+    basic_generations: {
+      type: "metered",
+      meterId: "generations",
+      included: 5,
+    },
+    priority_generations: {
+      type: "boolean",
+    },
+  },
+  meters: {
+    generations: {
+      unit: "generation",
+    },
+  },
+} as const;
+`;

File: src/lib/payment-templates/env-example.ts
Changes:
@@ -0,0 +1,9 @@
+export const paymentEnvExample = `
+# Autumn + Stripe (user app billing)
+AUTUMN_API_KEY=""
+AUTUMN_API_BASE_URL="https://api.useautumn.com"
+AUTUMN_WEBHOOK_SECRET=""
+STRIPE_SECRET_KEY=""
+STRIPE_PUBLISHABLE_KEY=""
+NEXT_PUBLIC_APP_URL="http://localhost:3000"
+`;

File: src/lib/payment-templates/index.ts
Changes:
@@ -0,0 +1,24 @@
+import { angularPaymentTemplate } from "./angular";
+import { nextjsPaymentTemplate } from "./nextjs";
+import { reactPaymentTemplate } from "./react";
+import { sveltePaymentTemplate } from "./svelte";
+import { vuePaymentTemplate } from "./vue";
+import type { PaymentFramework, PaymentTemplateBundle } from "./types";
+import { autumnConfigTemplate } from "./autumn-config";
+import { paymentEnvExample } from "./env-example";
+
+const templates: Record<PaymentFramework, PaymentTemplateBundle> = {
+  nextjs: nextjsPaymentTemplate,
+  react: reactPaymentTemplate,
+  vue: vuePaymentTemplate,
+  angular: angularPaymentTemplate,
+  svelte: sveltePaymentTemplate,
+};
+
+export const paymentTemplates = templates;
+export { autumnConfigTemplate, paymentEnvExample };
+export type { PaymentFramework, PaymentTemplateBundle };
+
+export const getPaymentTemplate = (
+  framework: PaymentFramework
+): PaymentTemplateBundle => templates[framework];

File: src/lib/payment-templates/nextjs.ts
Changes:
@@ -0,0 +1,440 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const nextjsPaymentTemplate: PaymentTemplateBundle = {
+  framework: "nextjs",
+  description: "Next.js App Router payment integration with Autumn + Stripe",
+  files: {
+    "lib/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+const getAutumnConfig = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+  return { apiKey, baseUrl };
+};
+
+export async function autumnRequest<T>(
+  path: string,
+  options: AutumnRequestOptions
+): Promise<T> {
+  const { apiKey, baseUrl } = getAutumnConfig();
+  const response = await fetch(\`\${baseUrl}\${path}\`, {
+    ...options,
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: \`Bearer \${apiKey}\`,
+      ...(options.headers ?? {}),
+    },
+    body: options.body ? JSON.stringify(options.body) : undefined,
+  });
+
+  if (!response.ok) {
+    const errorText = await response.text();
+    throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+  }
+
+  if (response.status === 204) {
+    return undefined as T;
+  }
+
+  return (await response.json()) as T;
+}
+`,
+    "app/api/billing/checkout/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+export async function POST(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isCheckoutRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const checkout = await autumnRequest<{ url: string; id: string }>(
+    "/v1/checkout",
+    {
+      method: "POST",
+      body: {
+        productId: body.productId,
+        customerId: body.customerId,
+        successUrl: body.successUrl,
+        cancelUrl: body.cancelUrl,
+      },
+    }
+  );
+
+  return NextResponse.json(checkout);
+}
+`,
+    "app/api/billing/portal/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type PortalRequest = {
+  customerId: string;
+  returnUrl: string;
+};
+
+const isPortalRequest = (value: unknown): value is PortalRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return typeof data.customerId === "string" && typeof data.returnUrl === "string";
+};
+
+export async function POST(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isPortalRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const portal = await autumnRequest<{ url: string }>("/v1/portal", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      returnUrl: body.returnUrl,
+    },
+  });
+
+  return NextResponse.json(portal);
+}
+`,
+    "app/api/billing/subscription/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type UpdateRequest = {
+  subscriptionId: string;
+  productId: string;
+};
+
+type CancelRequest = {
+  subscriptionId: string;
+  cancelAtPeriodEnd?: boolean;
+};
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const subscriptionId = searchParams.get("subscriptionId");
+
+  if (!subscriptionId) {
+    return NextResponse.json({ error: "subscriptionId is required" }, { status: 400 });
+  }
+
+  const subscription = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+    { method: "GET" }
+  );
+
+  return NextResponse.json(subscription);
+}
+
+export async function PATCH(req: Request) {
+  const body = (await req.json()) as UpdateRequest;
+  if (!body.subscriptionId || !body.productId) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const updated = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}\`,
+    {
+      method: "PATCH",
+      body: { productId: body.productId },
+    }
+  );
+
+  return NextResponse.json(updated);
+}
+
+export async function DELETE(req: Request) {
+  const body = (await req.json()) as CancelRequest;
+  if (!body.subscriptionId) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const canceled = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}/cancel\`,
+    {
+      method: "POST",
+      body: { cancelAtPeriodEnd: body.cancelAtPeriodEnd ?? true },
+    }
+  );
+
+  return NextResponse.json(canceled);
+}
+`,
+    "app/api/billing/usage/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type UsageRequest = {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+};
+
+const isUsageRequest = (value: unknown): value is UsageRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.customerId === "string" &&
+    typeof data.meterId === "string" &&
+    typeof data.quantity === "number"
+  );
+};
+
+export async function POST(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isUsageRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  await autumnRequest("/v1/usage", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      meterId: body.meterId,
+      quantity: body.quantity,
+    },
+  });
+
+  return NextResponse.json({ ok: true });
+}
+`,
+    "app/api/billing/feature-check/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type FeatureCheckRequest = {
+  customerId: string;
+  featureId: string;
+};
+
+const isFeatureCheckRequest = (
+  value: unknown
+): value is FeatureCheckRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.customerId === "string" && typeof data.featureId === "string"
+  );
+};
+
+export async function POST(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isFeatureCheckRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const result = await autumnRequest<unknown>("/v1/features/check", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      featureId: body.featureId,
+    },
+  });
+
+  return NextResponse.json(result);
+}
+`,
+    "app/api/webhooks/autumn/route.ts": `
+import { NextResponse } from "next/server";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+export const runtime = "nodejs";
+
+const verifySignature = (
+  signature: string,
+  payload: string,
+  secret: string
+): boolean => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+export async function POST(req: Request) {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    return NextResponse.json({ error: "Missing webhook secret" }, { status: 500 });
+  }
+
+  const signature = req.headers.get("autumn-signature") ?? "";
+  const rawBody = await req.text();
+
+  if (!verifySignature(signature, rawBody, secret)) {
+    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
+  }
+
+  const event = JSON.parse(rawBody) as { type: string; data: unknown };
+
+  switch (event.type) {
+    case "subscription.created":
+    case "subscription.updated":
+    case "subscription.canceled": {
+      break;
+    }
+    case "invoice.payment_failed":
+    case "invoice.payment_succeeded": {
+      break;
+    }
+    default: {
+      break;
+    }
+  }
+
+  return NextResponse.json({ received: true });
+}
+`,
+    "components/billing/checkout-button.tsx": `
+"use client";
+
+import { useState } from "react";
+
+interface CheckoutButtonProps {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+  label?: string;
+}
+
+export function CheckoutButton({
+  productId,
+  customerId,
+  successUrl,
+  cancelUrl,
+  label = "Upgrade",
+}: CheckoutButtonProps) {
+  const [loading, setLoading] = useState(false);
+
+  const startCheckout = async () => {
+    setLoading(true);
+    try {
+      const response = await fetch("/api/billing/checkout", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          productId,
+          customerId,
+          successUrl,
+          cancelUrl,
+        }),
+      });
+      const data = (await response.json()) as { url?: string };
+      if (data.url) {
+        window.location.href = data.url;
+      }
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      onClick={startCheckout}
+      disabled={loading}
+      className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+    >
+      {loading ? "Redirecting..." : label}
+    </button>
+  );
+}
+`,
+    "components/billing/feature-gate.tsx": `
+import type { ReactNode } from "react";
+
+interface FeatureGateProps {
+  allowed: boolean;
+  fallback?: ReactNode;
+  children: ReactNode;
+}
+
+export function FeatureGate({ allowed, fallback, children }: FeatureGateProps) {
+  if (!allowed) {
+    return <>{fallback ?? null}</>;
+  }
+  return <>{children}</>;
+}
+`,
+    "lib/usage.ts": `
+interface UsagePayload {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export async function trackUsage(payload: UsagePayload): Promise<void> {
+  await fetch("/api/billing/usage", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(payload),
+  });
+}
+`,
+    "app/billing/success/page.tsx": `
+export default function BillingSuccessPage() {
+  return (
+    <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 className="text-3xl font-semibold">Payment successful</h1>
+      <p className="text-muted-foreground">
+        Your subscription is active. You can return to the app and start using
+        your new plan immediately.
+      </p>
+      <a
+        href="/"
+        className="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white"
+      >
+        Return to app
+      </a>
+    </div>
+  );
+}
+`,
+    "app/billing/cancel/page.tsx": `
+export default function BillingCancelPage() {
+  return (
+    <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 className="text-3xl font-semibold">Checkout canceled</h1>
+      <p className="text-muted-foreground">
+        Your checkout was canceled. You can restart the process at any time.
+      </p>
+      <a
+        href="/"
+        className="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2"
+      >
+        Return to app
+      </a>
+    </div>
+  );
+}
+`,
+  },
+};

File: src/lib/payment-templates/react.ts
Changes:
@@ -0,0 +1,354 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const reactPaymentTemplate: PaymentTemplateBundle = {
+  framework: "react",
+  description: "React (Vite) payment integration with Autumn + Stripe",
+  files: {
+    "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+
+  const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+    const response = await fetch(\`\${baseUrl}\${path}\`, {
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: \`Bearer \${apiKey}\`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ? JSON.stringify(options.body) : undefined,
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+    }
+
+    if (response.status === 204) {
+      return undefined as T;
+    }
+
+    return (await response.json()) as T;
+  };
+
+  return { request };
+};
+`,
+    "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+  if (!isCheckoutRequest(req.body)) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+
+  const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+    method: "POST",
+    body: req.body,
+  });
+
+  res.json(checkout);
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+  const { customerId, returnUrl } = req.body as {
+    customerId?: string;
+    returnUrl?: string;
+  };
+  if (!customerId || !returnUrl) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const portal = await autumn.request<{ url: string }>("/v1/portal", {
+    method: "POST",
+    body: { customerId, returnUrl },
+  });
+  res.json(portal);
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+  const { subscriptionId, productId } = req.body as {
+    subscriptionId?: string;
+    productId?: string;
+  };
+  if (!subscriptionId || !productId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const updated = await autumn.request<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+    {
+      method: "PATCH",
+      body: { productId },
+    }
+  );
+  res.json(updated);
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+  const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+    subscriptionId?: string;
+    cancelAtPeriodEnd?: boolean;
+  };
+  if (!subscriptionId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const canceled = await autumn.request<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`,
+    {
+      method: "POST",
+      body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+    }
+  );
+  res.json(canceled);
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+  const { customerId, featureId } = req.body as {
+    customerId?: string;
+    featureId?: string;
+  };
+  if (!customerId || !featureId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const result = await autumn.request<unknown>("/v1/features/check", {
+    method: "POST",
+    body: { customerId, featureId },
+  });
+  res.json(result);
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+  const { customerId, meterId, quantity } = req.body as {
+    customerId?: string;
+    meterId?: string;
+    quantity?: number;
+  };
+  if (!customerId || !meterId || typeof quantity !== "number") {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  await autumn.request("/v1/usage", {
+    method: "POST",
+    body: { customerId, meterId, quantity },
+  });
+  res.json({ ok: true });
+});
+
+export default router;
+`,
+    "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    res.status(500).json({ error: "Missing webhook secret" });
+    return;
+  }
+  const signature = req.headers["autumn-signature"];
+  const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+  const rawBody = req.body as string;
+  if (!verifySignature(signatureValue, rawBody, secret)) {
+    res.status(401).json({ error: "Invalid signature" });
+    return;
+  }
+  const event = JSON.parse(rawBody) as { type: string; data: unknown };
+  switch (event.type) {
+    case "subscription.created":
+    case "subscription.updated":
+    case "subscription.canceled":
+    case "invoice.payment_failed":
+    case "invoice.payment_succeeded":
+      break;
+    default:
+      break;
+  }
+  res.json({ received: true });
+});
+
+export default router;
+`,
+    "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json());
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+  console.log(\`Billing API listening on \${port}\`);
+});
+`,
+    "src/components/CheckoutButton.tsx": `
+import { useState } from "react";
+
+interface CheckoutButtonProps {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+  label?: string;
+}
+
+export function CheckoutButton({
+  productId,
+  customerId,
+  successUrl,
+  cancelUrl,
+  label = "Upgrade",
+}: CheckoutButtonProps) {
+  const [loading, setLoading] = useState(false);
+
+  const startCheckout = async () => {
+    setLoading(true);
+    try {
+      const response = await fetch("/api/billing/checkout", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }),
+      });
+      const data = (await response.json()) as { url?: string };
+      if (data.url) {
+        window.location.href = data.url;
+      }
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      onClick={startCheckout}
+      disabled={loading}
+      className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+    >
+      {loading ? "Redirecting..." : label}
+    </button>
+  );
+}
+`,
+    "src/components/FeatureGate.tsx": `
+import type { ReactNode } from "react";
+
+interface FeatureGateProps {
+  allowed: boolean;
+  fallback?: ReactNode;
+  children: ReactNode;
+}
+
+export function FeatureGate({ allowed, fallback, children }: FeatureGateProps) {
+  if (!allowed) {
+    return <>{fallback ?? null}</>;
+  }
+  return <>{children}</>;
+}
+`,
+    "src/lib/usage.ts": `
+interface UsagePayload {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export async function trackUsage(payload: UsagePayload): Promise<void> {
+  await fetch("/api/billing/usage", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(payload),
+  });
+}
+`,
+    "src/pages/BillingSuccess.tsx": `
+export function BillingSuccess() {
+  return (
+    <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 className="text-3xl font-semibold">Payment successful</h1>
+      <p className="text-muted-foreground">
+        Your subscription is active. You can return to the app and start using
+        your new plan immediately.
+      </p>
+      <a
+        href="/"
+        className="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white"
+      >
+        Return to app
+      </a>
+    </div>
+  );
+}
+`,
+    "src/pages/BillingCancel.tsx": `
+export function BillingCancel() {
+  return (
+    <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 className="text-3xl font-semibold">Checkout canceled</h1>
+      <p className="text-muted-foreground">
+        Your checkout was canceled. You can restart the process at any time.
+      </p>
+      <a
+        href="/"
+        className="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2"
+      >
+        Return to app
+      </a>
+    </div>
+  );
+}
+`,
+  },
+};

File: src/lib/payment-templates/svelte.ts
Changes:
@@ -0,0 +1,331 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const sveltePaymentTemplate: PaymentTemplateBundle = {
+  framework: "svelte",
+  description: "SvelteKit payment integration with Autumn + Stripe",
+  files: {
+    "src/lib/server/autumn.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+const getAutumnConfig = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+  return { apiKey, baseUrl };
+};
+
+export const autumnRequest = async <T>(
+  path: string,
+  options: AutumnRequestOptions
+): Promise<T> => {
+  const { apiKey, baseUrl } = getAutumnConfig();
+  const response = await fetch(\`\${baseUrl}\${path}\`, {
+    ...options,
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: \`Bearer \${apiKey}\`,
+      ...(options.headers ?? {}),
+    },
+    body: options.body ? JSON.stringify(options.body) : undefined,
+  });
+
+  if (!response.ok) {
+    const errorText = await response.text();
+    throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+  }
+
+  if (response.status === 204) {
+    return undefined as T;
+  }
+
+  return (await response.json()) as T;
+};
+`,
+    "src/routes/api/billing/checkout/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+export const POST: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as unknown;
+  if (!isCheckoutRequest(body)) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const checkout = await autumnRequest<{ url: string; id: string }>("/v1/checkout", {
+    method: "POST",
+    body,
+  });
+  return json(checkout);
+};
+`,
+    "src/routes/api/billing/portal/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    customerId?: string;
+    returnUrl?: string;
+  };
+  if (!body.customerId || !body.returnUrl) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const portal = await autumnRequest<{ url: string }>("/v1/portal", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      returnUrl: body.returnUrl,
+    },
+  });
+  return json(portal);
+};
+`,
+    "src/routes/api/billing/usage/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    customerId?: string;
+    meterId?: string;
+    quantity?: number;
+  };
+  if (!body.customerId || !body.meterId || typeof body.quantity !== "number") {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  await autumnRequest("/v1/usage", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      meterId: body.meterId,
+      quantity: body.quantity,
+    },
+  });
+  return json({ ok: true });
+};
+`,
+    "src/routes/api/billing/subscription/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const GET: RequestHandler = async ({ url }) => {
+  const subscriptionId = url.searchParams.get("subscriptionId");
+  if (!subscriptionId) {
+    return json({ error: "subscriptionId is required" }, { status: 400 });
+  }
+  const subscription = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+    { method: "GET" }
+  );
+  return json(subscription);
+};
+
+export const PATCH: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    subscriptionId?: string;
+    productId?: string;
+  };
+  if (!body.subscriptionId || !body.productId) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const updated = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}\`,
+    {
+      method: "PATCH",
+      body: { productId: body.productId },
+    }
+  );
+  return json(updated);
+};
+
+export const DELETE: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    subscriptionId?: string;
+    cancelAtPeriodEnd?: boolean;
+  };
+  if (!body.subscriptionId) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const canceled = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}/cancel\`,
+    {
+      method: "POST",
+      body: { cancelAtPeriodEnd: body.cancelAtPeriodEnd ?? true },
+    }
+  );
+  return json(canceled);
+};
+`,
+    "src/routes/api/billing/feature-check/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    customerId?: string;
+    featureId?: string;
+  };
+  if (!body.customerId || !body.featureId) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const result = await autumnRequest<unknown>("/v1/features/check", {
+    method: "POST",
+    body: { customerId: body.customerId, featureId: body.featureId },
+  });
+  return json(result);
+};
+`,
+    "src/routes/api/webhooks/autumn/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { createHmac, timingSafeEqual } from "node:crypto";
+import type { RequestHandler } from "./$types";
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+export const POST: RequestHandler = async ({ request }) => {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    return json({ error: "Missing webhook secret" }, { status: 500 });
+  }
+  const signature = request.headers.get("autumn-signature") ?? "";
+  const rawBody = await request.text();
+  if (!verifySignature(signature, rawBody, secret)) {
+    return json({ error: "Invalid signature" }, { status: 401 });
+  }
+  const event = JSON.parse(rawBody) as { type: string; data: unknown };
+  switch (event.type) {
+    case "subscription.created":
+    case "subscription.updated":
+    case "subscription.canceled":
+    case "invoice.payment_failed":
+    case "invoice.payment_succeeded":
+      break;
+    default:
+      break;
+  }
+  return json({ received: true });
+};
+`,
+    "src/lib/components/CheckoutButton.svelte": `
+<script lang="ts">
+  export let productId: string;
+  export let customerId: string;
+  export let successUrl: string;
+  export let cancelUrl: string;
+  export let label = "Upgrade";
+
+  let loading = false;
+
+  const startCheckout = async () => {
+    loading = true;
+    try {
+      const response = await fetch("/api/billing/checkout", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }),
+      });
+      const data = (await response.json()) as { url?: string };
+      if (data.url) {
+        window.location.href = data.url;
+      }
+    } finally {
+      loading = false;
+    }
+  };
+</script>
+
+<button
+  type="button"
+  class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+  disabled={loading}
+  on:click={startCheckout}
+>
+  {loading ? "Redirecting..." : label}
+</button>
+`,
+    "src/lib/components/FeatureGate.svelte": `
+<script lang="ts">
+  export let allowed = false;
+  export let fallback = "";
+</script>
+
+{#if allowed}
+  <slot />
+{:else}
+  {fallback}
+{/if}
+`,
+    "src/lib/usage.ts": `
+export interface UsagePayload {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export const trackUsage = async (payload: UsagePayload): Promise<void> => {
+  await fetch("/api/billing/usage", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(payload),
+  });
+};
+`,
+    "src/routes/billing/success/+page.svelte": `
+<div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+  <h1 class="text-3xl font-semibold">Payment successful</h1>
+  <p class="text-muted-foreground">
+    Your subscription is active. You can return to the app and start using
+    your new plan immediately.
+  </p>
+  <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+    Return to app
+  </a>
+</div>
+`,
+    "src/routes/billing/cancel/+page.svelte": `
+<div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+  <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+  <p class="text-muted-foreground">
+    Your checkout was canceled. You can restart the process at any time.
+  </p>
+  <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+    Return to app
+  </a>
+</div>
+`,
+  },
+};

File: src/lib/payment-templates/types.ts
Changes:
@@ -0,0 +1,7 @@
+export type PaymentFramework = "nextjs" | "react" | "vue" | "angular" | "svelte";
+
+export interface PaymentTemplateBundle {
+  framework: PaymentFramework;
+  description: string;
+  files: Record<string, string>;
+}

File: src/lib/payment-templates/vue.ts
Changes:
@@ -0,0 +1,342 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const vuePaymentTemplate: PaymentTemplateBundle = {
+  framework: "vue",
+  description: "Vue 3 payment integration with Autumn + Stripe",
+  files: {
+    "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+
+  const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+    const response = await fetch(\`\${baseUrl}\${path}\`, {
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: \`Bearer \${apiKey}\`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ? JSON.stringify(options.body) : undefined,
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+    }
+
+    if (response.status === 204) {
+      return undefined as T;
+    }
+
+    return (await response.json()) as T;
+  };
+
+  return { request };
+};
+`,
+    "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+  if (!isCheckoutRequest(req.body)) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+    method: "POST",
+    body: req.body,
+  });
+  res.json(checkout);
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+  const { customerId, returnUrl } = req.body as {
+    customerId?: string;
+    returnUrl?: string;
+  };
+  if (!customerId || !returnUrl) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const portal = await autumn.request<{ url: string }>("/v1/portal", {
+    method: "POST",
+    body: { customerId, returnUrl },
+  });
+  res.json(portal);
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+  const { subscriptionId, productId } = req.body as {
+    subscriptionId?: string;
+    productId?: string;
+  };
+  if (!subscriptionId || !productId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const updated = await autumn.request<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+    {
+      method: "PATCH",
+      body: { productId },
+    }
+  );
+  res.json(updated);
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+  const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+    subscriptionId?: string;
+    cancelAtPeriodEnd?: boolean;
+  };
+  if (!subscriptionId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const canceled = await autumn.request<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`,
+    {
+      method: "POST",
+      body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+    }
+  );
+  res.json(canceled);
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+  const { customerId, featureId } = req.body as {
+    customerId?: string;
+    featureId?: string;
+  };
+  if (!customerId || !featureId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const result = await autumn.request<unknown>("/v1/features/check", {
+    method: "POST",
+    body: { customerId, featureId },
+  });
+  res.json(result);
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+  const { customerId, meterId, quantity } = req.body as {
+    customerId?: string;
+    meterId?: string;
+    quantity?: number;
+  };
+  if (!customerId || !meterId || typeof quantity !== "number") {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  await autumn.request("/v1/usage", {
+    method: "POST",
+    body: { customerId, meterId, quantity },
+  });
+  res.json({ ok: true });
+});
+
+export default router;
+`,
+    "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    res.status(500).json({ error: "Missing webhook secret" });
+    return;
+  }
+  const signature = req.headers["autumn-signature"];
+  const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+  const rawBody = req.body as string;
+  if (!verifySignature(signatureValue, rawBody, secret)) {
+    res.status(401).json({ error: "Invalid signature" });
+    return;
+  }
+  const event = JSON.parse(rawBody) as { type: string; data: unknown };
+  switch (event.type) {
+    case "subscription.created":
+    case "subscription.updated":
+    case "subscription.canceled":
+    case "invoice.payment_failed":
+    case "invoice.payment_succeeded":
+      break;
+    default:
+      break;
+  }
+  res.json({ received: true });
+});
+
+export default router;
+`,
+    "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json());
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+  console.log(\`Billing API listening on \${port}\`);
+});
+`,
+    "src/components/CheckoutButton.vue": `
+<script setup lang="ts">
+import { ref } from "vue";
+
+const props = defineProps<{
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+  label?: string;
+}>();
+
+const loading = ref(false);
+
+const startCheckout = async () => {
+  loading.value = true;
+  try {
+    const response = await fetch("/api/billing/checkout", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        productId: props.productId,
+        customerId: props.customerId,
+        successUrl: props.successUrl,
+        cancelUrl: props.cancelUrl,
+      }),
+    });
+    const data = (await response.json()) as { url?: string };
+    if (data.url) {
+      window.location.href = data.url;
+    }
+  } finally {
+    loading.value = false;
+  }
+};
+</script>
+
+<template>
+  <button
+    type="button"
+    :disabled="loading"
+    class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+    @click="startCheckout"
+  >
+    {{ loading ? "Redirecting..." : props.label ?? "Upgrade" }}
+  </button>
+</template>
+`,
+    "src/components/FeatureGate.vue": `
+<script setup lang="ts">
+defineProps<{
+  allowed: boolean;
+  fallback?: string;
+}>();
+</script>
+
+<template>
+  <slot v-if="allowed" />
+  <span v-else>{{ fallback ?? "" }}</span>
+</template>
+`,
+    "src/composables/useUsage.ts": `
+export interface UsagePayload {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export const useUsage = () => {
+  const trackUsage = async (payload: UsagePayload): Promise<void> => {
+    await fetch("/api/billing/usage", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(payload),
+    });
+  };
+
+  return { trackUsage };
+};
+`,
+    "src/pages/BillingSuccess.vue": `
+<template>
+  <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+    <h1 class="text-3xl font-semibold">Payment successful</h1>
+    <p class="text-muted-foreground">
+      Your subscription is active. You can return to the app and start using
+      your new plan immediately.
+    </p>
+    <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+      Return to app
+    </a>
+  </div>
+</template>
+`,
+    "src/pages/BillingCancel.vue": `
+<template>
+  <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+    <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+    <p class="text-muted-foreground">
+      Your checkout was canceled. You can restart the process at any time.
+    </p>
+    <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+      Return to app
+    </a>
+  </div>
+</template>
+`,
+  },
+};

File: src/modules/projects/ui/components/custom-domain-dialog.tsx
Changes:
@@ -0,0 +1,140 @@
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+
+type NetlifyDomain = {
+  id: string;
+  name: string;
+  ssl_status?: string;
+  verification?: {
+    status?: string;
+  };
+};
+
+type CustomDomainDialogProps = {
+  siteId: string;
+};
+
+export const CustomDomainDialog = ({ siteId }: CustomDomainDialogProps) => {
+  const [domains, setDomains] = useState<NetlifyDomain[]>([]);
+  const [domainInput, setDomainInput] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+
+  const loadDomains = async () => {
+    setIsLoading(true);
+    try {
+      const response = await fetch(`/api/deploy/netlify/domains?siteId=${siteId}`);
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to load domains");
+      }
+      setDomains(Array.isArray(data) ? data : []);
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to load domains");
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  const handleAdd = async () => {
+    if (!domainInput) {
+      toast.error("Enter a domain");
+      return;
+    }
+
+    try {
+      const response = await fetch("/api/deploy/netlify/domains", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ siteId, domain: domainInput }),
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to add domain");
+      }
+      setDomainInput("");
+      await loadDomains();
+      toast.success("Domain added");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to add domain");
+    }
+  };
+
+  const handleDelete = async (domainId: string) => {
+    try {
+      const response = await fetch(
+        `/api/deploy/netlify/domains?siteId=${siteId}&domainId=${domainId}`,
+        { method: "DELETE" }
+      );
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to remove domain");
+      }
+      await loadDomains();
+      toast.success("Domain removed");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to remove domain");
+    }
+  };
+
+  useEffect(() => {
+    void loadDomains();
+  }, [siteId]);
+
+  return (
+    <Dialog>
+      <DialogTrigger asChild>
+        <Button variant="outline" size="sm">Custom Domains</Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Custom Domains</DialogTitle>
+          <DialogDescription>Manage domains and DNS verification.</DialogDescription>
+        </DialogHeader>
+        <div className="space-y-3">
+          <div className="space-y-2">
+            <Input
+              placeholder="yourdomain.com"
+              value={domainInput}
+              onChange={(event) => setDomainInput(event.target.value)}
+            />
+            <Button onClick={handleAdd} disabled={isLoading}>
+              Add Domain
+            </Button>
+          </div>
+          <div className="space-y-2">
+            {domains.length === 0 && !isLoading && (
+              <p className="text-sm text-muted-foreground">No domains configured</p>
+            )}
+            {domains.map((domain) => (
+              <div key={domain.id} className="flex items-center justify-between text-sm">
+                <div className="flex flex-col">
+                  <span>{domain.name}</span>
+                  <span className="text-xs text-muted-foreground">
+                    SSL: {domain.ssl_status ?? "unknown"} • Verification: {domain.verification?.status ?? "unknown"}
+                  </span>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => handleDelete(domain.id)}
+                >
+                  Remove
+                </Button>
+              </div>
+            ))}
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+};

File: src/modules/projects/ui/components/deploy-button.tsx
Changes:
@@ -0,0 +1,49 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import { NetlifyConnectDialog } from "./netlify-connect-dialog";
+
+type DeployButtonProps = {
+  projectId: string;
+};
+
+export const DeployButton = ({ projectId }: DeployButtonProps) => {
+  const connection = useQuery(api.oauth.getConnection, { provider: "netlify" });
+  const [isDeploying, setIsDeploying] = useState(false);
+
+  const handleDeploy = async () => {
+    if (isDeploying) return;
+    setIsDeploying(true);
+
+    try {
+      const response = await fetch("/api/deploy/netlify/deploy", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ projectId }),
+      });
+
+      const payload = await response.json();
+      if (!response.ok) {
+        throw new Error(payload.error || "Deployment failed");
+      }
+
+      toast.success(`Deployment started: ${payload.siteUrl}`);
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Deployment failed");
+    } finally {
+      setIsDeploying(false);
+    }
+  };
+
+  if (!connection) {
+    return <NetlifyConnectDialog />;
+  }
+
+  return (
+    <Button size="sm" onClick={handleDeploy} disabled={isDeploying}>
+      {isDeploying ? "Deploying..." : "Deploy to Netlify"}
+    </Button>
+  );
+};

File: src/modules/projects/ui/components/deployment-dashboard.tsx
Changes:
@@ -0,0 +1,45 @@
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { DeployButton } from "./deploy-button";
+import { DeploymentStatus } from "./deployment-status";
+import { EnvVarsDialog } from "./env-vars-dialog";
+import { CustomDomainDialog } from "./custom-domain-dialog";
+import { DeploymentHistory } from "./deployment-history";
+import { PreviewDeployments } from "./preview-deployments";
+
+type DeploymentDashboardProps = {
+  projectId: string;
+};
+
+export const DeploymentDashboard = ({ projectId }: DeploymentDashboardProps) => {
+  const deployment = useQuery(api.deployments.getDeployment, { projectId });
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <div className="space-y-1">
+          <h3 className="text-sm font-medium">Netlify Deployment</h3>
+          <DeploymentStatus projectId={projectId} />
+        </div>
+        <DeployButton projectId={projectId} />
+      </div>
+
+      {deployment?.siteId && (
+        <div className="flex flex-wrap items-center gap-2">
+          <EnvVarsDialog siteId={deployment.siteId} />
+          <CustomDomainDialog siteId={deployment.siteId} />
+        </div>
+      )}
+
+      <div>
+        <h4 className="text-sm font-medium mb-2">Preview Deployments</h4>
+        <PreviewDeployments projectId={projectId} />
+      </div>
+
+      <div>
+        <h4 className="text-sm font-medium mb-2">Deployment History</h4>
+        <DeploymentHistory projectId={projectId} />
+      </div>
+    </div>
+  );
+};

File: src/modules/projects/ui/components/deployment-history.tsx
Changes:
@@ -0,0 +1,107 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+
+type DeploymentHistoryProps = {
+  projectId: string;
+};
+
+export const DeploymentHistory = ({ projectId }: DeploymentHistoryProps) => {
+  const deployments = useQuery(api.deployments.listDeployments, { projectId });
+  const [logs, setLogs] = useState<string | null>(null);
+
+  const fetchLogs = async (deployId: string) => {
+    try {
+      const response = await fetch(`/api/deploy/netlify/logs?deployId=${deployId}`);
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to fetch logs");
+      }
+      setLogs(data.logs || "");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to fetch logs");
+    }
+  };
+
+  const handleRollback = async (deployId?: string) => {
+    if (!deployId) return;
+    try {
+      const response = await fetch("/api/deploy/netlify/rollback", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ deployId }),
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Rollback failed");
+      }
+      toast.success("Rollback initiated");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Rollback failed");
+    }
+  };
+
+  if (!deployments || deployments.length === 0) {
+    return <p className="text-sm text-muted-foreground">No deployments yet</p>;
+  }
+
+  return (
+    <div className="space-y-2">
+      {deployments.map((deployment) => (
+        <div
+          key={deployment._id}
+          className="flex items-center justify-between rounded-md border p-2 text-sm"
+        >
+          <div className="flex flex-col">
+            <span>Deploy #{deployment.deployNumber ?? "-"} • {deployment.status}</span>
+            <span className="text-xs text-muted-foreground">
+              {deployment.siteUrl}
+            </span>
+          </div>
+          <div className="flex items-center gap-2">
+            {deployment.deployId && (
+              <Dialog>
+                <DialogTrigger asChild>
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    onClick={() => void fetchLogs(deployment.deployId ?? "")}
+                  >
+                    Logs
+                  </Button>
+                </DialogTrigger>
+                <DialogContent>
+                  <DialogHeader>
+                    <DialogTitle>Build Logs</DialogTitle>
+                    <DialogDescription>Latest build output from Netlify.</DialogDescription>
+                  </DialogHeader>
+                  <pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
+                    {logs || "No logs available"}
+                  </pre>
+                </DialogContent>
+              </Dialog>
+            )}
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => handleRollback(deployment.deployId)}
+              disabled={!deployment.deployId}
+            >
+              Rollback
+            </Button>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+};

File: src/modules/projects/ui/components/deployment-status.tsx
Changes:
@@ -0,0 +1,85 @@
+import { useEffect, useMemo } from "react";
+import Link from "next/link";
+import { useMutation, useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+
+type DeploymentStatusProps = {
+  projectId: string;
+};
+
+type NetlifyStatusResponse = {
+  state?: string;
+};
+
+const statusLabelMap: Record<string, string> = {
+  pending: "Pending",
+  building: "Building",
+  ready: "Ready",
+  error: "Error",
+};
+
+export const DeploymentStatus = ({ projectId }: DeploymentStatusProps) => {
+  const deployment = useQuery(api.deployments.getDeployment, { projectId });
+  const updateDeployment = useMutation(api.deployments.updateDeployment);
+
+  const shouldPoll = useMemo(() => {
+    if (!deployment?.deployId) return false;
+    return deployment.status === "pending" || deployment.status === "building";
+  }, [deployment]);
+
+  useEffect(() => {
+    if (!shouldPoll || !deployment?.deployId) {
+      return;
+    }
+
+    let cancelled = false;
+    const pollStatus = async () => {
+      try {
+        const response = await fetch(`/api/deploy/netlify/status?deployId=${deployment.deployId}`);
+        if (!response.ok) {
+          return;
+        }
+
+        const data = (await response.json()) as NetlifyStatusResponse;
+        if (!data.state || cancelled) {
+          return;
+        }
+
+        await updateDeployment({
+          deploymentId: deployment._id,
+          status: data.state === "ready" ? "ready" : data.state === "error" ? "error" : "building",
+        });
+      } catch {
+        // ignore polling errors
+      }
+    };
+
+    const interval = setInterval(pollStatus, 10000);
+    pollStatus();
+
+    return () => {
+      cancelled = true;
+      clearInterval(interval);
+    };
+  }, [deployment, shouldPoll, updateDeployment]);
+
+  if (!deployment) {
+    return null;
+  }
+
+  const label = statusLabelMap[deployment.status] ?? deployment.status;
+
+  return (
+    <div className="flex items-center gap-2 text-xs text-muted-foreground">
+      <span>Netlify: {label}</span>
+      {deployment.siteUrl && deployment.status === "ready" && (
+        <Button asChild variant="link" size="sm" className="h-auto p-0">
+          <Link href={deployment.siteUrl} target="_blank" rel="noreferrer">
+            View site
+          </Link>
+        </Button>
+      )}
+    </div>
+  );
+};

File: src/modules/projects/ui/components/env-vars-dialog.tsx
Changes:
@@ -0,0 +1,137 @@
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+
+type EnvVar = {
+  key: string;
+};
+
+type EnvVarsDialogProps = {
+  siteId: string;
+};
+
+export const EnvVarsDialog = ({ siteId }: EnvVarsDialogProps) => {
+  const [envVars, setEnvVars] = useState<EnvVar[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const [newKey, setNewKey] = useState("");
+  const [newValue, setNewValue] = useState("");
+
+  const loadEnvVars = async () => {
+    setIsLoading(true);
+    try {
+      const response = await fetch(`/api/deploy/netlify/env-vars?siteId=${siteId}`);
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to load env vars");
+      }
+      setEnvVars(Array.isArray(data) ? data : []);
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to load env vars");
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  const handleAdd = async () => {
+    if (!newKey || !newValue) {
+      toast.error("Provide a key and value");
+      return;
+    }
+
+    try {
+      const response = await fetch("/api/deploy/netlify/env-vars", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ siteId, key: newKey, value: newValue }),
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to set env var");
+      }
+      setNewKey("");
+      setNewValue("");
+      await loadEnvVars();
+      toast.success("Env var saved");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to set env var");
+    }
+  };
+
+  const handleDelete = async (key: string) => {
+    try {
+      const response = await fetch(
+        `/api/deploy/netlify/env-vars?siteId=${siteId}&key=${encodeURIComponent(key)}`,
+        { method: "DELETE" }
+      );
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to delete env var");
+      }
+      await loadEnvVars();
+      toast.success("Env var deleted");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to delete env var");
+    }
+  };
+
+  useEffect(() => {
+    void loadEnvVars();
+  }, [siteId]);
+
+  return (
+    <Dialog>
+      <DialogTrigger asChild>
+        <Button variant="outline" size="sm">Env Vars</Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Environment Variables</DialogTitle>
+          <DialogDescription>Manage Netlify environment variables for this site.</DialogDescription>
+        </DialogHeader>
+        <div className="space-y-3">
+          <div className="space-y-2">
+            <Input
+              placeholder="KEY"
+              value={newKey}
+              onChange={(event) => setNewKey(event.target.value)}
+            />
+            <Input
+              placeholder="VALUE"
+              value={newValue}
+              onChange={(event) => setNewValue(event.target.value)}
+            />
+            <Button onClick={handleAdd} disabled={isLoading}>
+              Save
+            </Button>
+          </div>
+          <div className="space-y-2">
+            {envVars.length === 0 && !isLoading && (
+              <p className="text-sm text-muted-foreground">No variables set</p>
+            )}
+            {envVars.map((envVar) => (
+              <div key={envVar.key} className="flex items-center justify-between text-sm">
+                <span>{envVar.key}</span>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => handleDelete(envVar.key)}
+                >
+                  Remove
+                </Button>
+              </div>
+            ))}
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+};

File: src/modules/projects/ui/components/github-export-button.tsx
Changes:
@@ -0,0 +1,62 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { useQuery } from "convex/react";
+
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import { GitHubExportModal } from "./github-export-modal";
+
+type GitHubExportButtonProps = {
+  projectId: string;
+};
+
+export const GitHubExportButton = ({ projectId }: GitHubExportButtonProps) => {
+  const connection = useQuery(api.oauth.getConnection, { provider: "github" });
+  const [open, setOpen] = useState(false);
+
+  if (!connection) {
+    return (
+      <Dialog>
+        <DialogTrigger asChild>
+          <Button size="sm" variant="outline">
+            Connect GitHub
+          </Button>
+        </DialogTrigger>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>Connect GitHub</DialogTitle>
+            <DialogDescription>
+              Connect your GitHub account to export projects.
+            </DialogDescription>
+          </DialogHeader>
+          <Button asChild>
+            <Link href="/api/import/github/auth">Continue to GitHub</Link>
+          </Button>
+        </DialogContent>
+      </Dialog>
+    );
+  }
+
+  return (
+    <>
+      <Button size="sm" variant="outline" onClick={() => setOpen(true)}>
+        Export to GitHub
+      </Button>
+      <GitHubExportModal
+        projectId={projectId}
+        open={open}
+        onOpenChange={setOpen}
+      />
+    </>
+  );
+};

File: src/modules/projects/ui/components/github-export-modal.tsx
Changes:
@@ -0,0 +1,407 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { ExternalLinkIcon, Loader2Icon } from "lucide-react";
+import { z } from "zod";
+
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+
+type GitHubRepoOption = {
+  id: number;
+  name: string;
+  fullName: string;
+  url: string;
+  isPrivate: boolean;
+  defaultBranch: string;
+};
+
+type ExportResult = {
+  exportId: string;
+  repositoryUrl: string;
+  repositoryFullName: string;
+  branch: string;
+  commitSha: string;
+  fileCount: number;
+};
+
+type GitHubExportModalProps = {
+  projectId: string;
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+};
+
+const exportResultSchema = z.object({
+  exportId: z.string(),
+  repositoryUrl: z.string(),
+  repositoryFullName: z.string(),
+  branch: z.string(),
+  commitSha: z.string(),
+  fileCount: z.number(),
+});
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+  return typeof value === "object" && value !== null;
+};
+
+const isRepoOption = (value: unknown): value is GitHubRepoOption => {
+  if (!isRecord(value)) {
+    return false;
+  }
+
+  const record = value;
+  return (
+    typeof record.id === "number" &&
+    typeof record.name === "string" &&
+    typeof record.fullName === "string" &&
+    typeof record.url === "string" &&
+    typeof record.isPrivate === "boolean" &&
+    typeof record.defaultBranch === "string"
+  );
+};
+
+const parseRepositories = (value: unknown): Array<GitHubRepoOption> => {
+  if (!Array.isArray(value)) {
+    return [];
+  }
+
+  const repos: Array<GitHubRepoOption> = [];
+  for (const repo of value) {
+    if (isRepoOption(repo)) {
+      repos.push(repo);
+    }
+  }
+
+  return repos;
+};
+
+export const GitHubExportModal = ({
+  projectId,
+  open,
+  onOpenChange,
+}: GitHubExportModalProps) => {
+  const [mode, setMode] = useState<"new" | "existing">("new");
+  const [repoName, setRepoName] = useState("");
+  const [repoDescription, setRepoDescription] = useState("");
+  const [isPrivate, setIsPrivate] = useState(false);
+  const [repos, setRepos] = useState<Array<GitHubRepoOption>>([]);
+  const [selectedRepo, setSelectedRepo] = useState("");
+  const [branch, setBranch] = useState("");
+  const [includeReadme, setIncludeReadme] = useState(true);
+  const [includeGitignore, setIncludeGitignore] = useState(true);
+  const [commitMessage, setCommitMessage] = useState("");
+  const [isLoadingRepos, setIsLoadingRepos] = useState(false);
+  const [isExporting, setIsExporting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [result, setResult] = useState<ExportResult | null>(null);
+
+  const selectedRepoOption = useMemo(() => {
+    return repos.find((repo) => repo.fullName === selectedRepo) ?? null;
+  }, [repos, selectedRepo]);
+
+  useEffect(() => {
+    if (!open) {
+      setError(null);
+      setResult(null);
+      setIsExporting(false);
+    }
+  }, [open]);
+
+  useEffect(() => {
+    if (!open) {
+      return;
+    }
+
+    const loadRepositories = async () => {
+      setIsLoadingRepos(true);
+      setError(null);
+      try {
+        const response = await fetch("/api/github/repositories");
+        const payload = await response.json();
+        if (!response.ok) {
+          throw new Error(payload.error || "Failed to load repositories");
+        }
+
+        const parsedRepos = parseRepositories(payload.repositories);
+        setRepos(parsedRepos);
+        if (parsedRepos.length === 0) {
+          setError("No repositories found in this GitHub account.");
+        }
+      } catch (loadError) {
+        const message =
+          loadError instanceof Error ? loadError.message : "Failed to load repositories";
+        setError(message);
+      } finally {
+        setIsLoadingRepos(false);
+      }
+    };
+
+    void loadRepositories();
+  }, [open]);
+
+  useEffect(() => {
+    if (mode !== "existing" || !selectedRepoOption || branch) {
+      return;
+    }
+
+    setBranch(selectedRepoOption.defaultBranch);
+  }, [mode, selectedRepoOption, branch]);
+
+  const handleExport = async () => {
+    if (isExporting) {
+      return;
+    }
+
+    setIsExporting(true);
+    setError(null);
+
+    try {
+      const payload: Record<string, unknown> = {
+        branch: branch.trim() || undefined,
+        includeReadme,
+        includeGitignore,
+        commitMessage: commitMessage.trim() || undefined,
+      };
+
+      if (mode === "existing") {
+        if (!selectedRepo) {
+          throw new Error("Select a repository to export to.");
+        }
+        payload.repositoryFullName = selectedRepo;
+      } else {
+        const trimmedName = repoName.trim();
+        if (!trimmedName) {
+          throw new Error("Repository name is required.");
+        }
+        payload.repositoryName = trimmedName;
+        payload.description = repoDescription.trim() || undefined;
+        payload.isPrivate = isPrivate;
+      }
+
+      const response = await fetch(`/api/projects/${projectId}/export/github`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload),
+      });
+
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Export failed");
+      }
+
+      const parsedResult = exportResultSchema.safeParse(data);
+      if (!parsedResult.success) {
+        throw new Error("Unexpected export response.");
+      }
+
+      setResult(parsedResult.data);
+      toast.success("GitHub export complete");
+    } catch (exportError) {
+      const message =
+        exportError instanceof Error ? exportError.message : "Export failed";
+      setError(message);
+      toast.error(message);
+    } finally {
+      setIsExporting(false);
+    }
+  };
+
+  const isReady =
+    mode === "existing" ? selectedRepo.length > 0 : repoName.trim().length > 0;
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-xl">
+        <DialogHeader>
+          <DialogTitle>Export to GitHub</DialogTitle>
+          <DialogDescription>
+            Export your latest AI-generated files to a GitHub repository.
+          </DialogDescription>
+        </DialogHeader>
+
+        {error && (
+          <div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
+            {error}
+          </div>
+        )}
+
+        {result ? (
+          <div className="space-y-4">
+            <div className="rounded-md border p-3 text-sm">
+              <div className="font-medium">{result.repositoryFullName}</div>
+              <div className="text-muted-foreground">
+                Branch: {result.branch}
+              </div>
+              <div className="text-muted-foreground">
+                Files exported: {result.fileCount}
+              </div>
+              <div className="text-muted-foreground">
+                Commit: {result.commitSha.slice(0, 10)}
+              </div>
+            </div>
+            <div className="flex justify-end gap-2">
+              <Button variant="outline" onClick={() => onOpenChange(false)}>
+                Close
+              </Button>
+              <Button asChild>
+                <a href={result.repositoryUrl} target="_blank" rel="noreferrer">
+                  Open GitHub
+                  <ExternalLinkIcon className="ml-2 size-4" />
+                </a>
+              </Button>
+            </div>
+          </div>
+        ) : (
+          <div className="space-y-5">
+            <div className="space-y-2">
+              <Label>Repository</Label>
+              <RadioGroup
+                value={mode}
+                onValueChange={(value) => {
+                  if (value === "new" || value === "existing") {
+                    setMode(value);
+                  }
+                }}
+                className="flex gap-4"
+              >
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="new" id="github-export-new" />
+                  <Label htmlFor="github-export-new">New repository</Label>
+                </div>
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="existing" id="github-export-existing" />
+                  <Label htmlFor="github-export-existing">Existing repository</Label>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {mode === "new" ? (
+              <div className="space-y-3">
+                <div className="space-y-2">
+                  <Label htmlFor="repo-name">Repository name</Label>
+                  <Input
+                    id="repo-name"
+                    placeholder="zapdev-export"
+                    value={repoName}
+                    onChange={(event) => setRepoName(event.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="repo-description">Description</Label>
+                  <Input
+                    id="repo-description"
+                    placeholder="Optional description"
+                    value={repoDescription}
+                    onChange={(event) => setRepoDescription(event.target.value)}
+                  />
+                </div>
+                <div className="flex items-center justify-between rounded-md border px-3 py-2">
+                  <div>
+                    <p className="text-sm font-medium">Private repository</p>
+                    <p className="text-xs text-muted-foreground">
+                      Limit visibility to collaborators.
+                    </p>
+                  </div>
+                  <Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
+                </div>
+              </div>
+            ) : (
+              <div className="space-y-3">
+                <Label>Select repository</Label>
+                <Select
+                  value={selectedRepo}
+                  onValueChange={setSelectedRepo}
+                  disabled={isLoadingRepos}
+                >
+                  <SelectTrigger className="w-full">
+                    <SelectValue placeholder="Choose a repository" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {repos.map((repo) => (
+                      <SelectItem key={repo.fullName} value={repo.fullName}>
+                        {repo.fullName}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+            )}
+
+            <div className="grid gap-3 md:grid-cols-2">
+              <div className="space-y-2">
+                <Label htmlFor="branch">Branch</Label>
+                <Input
+                  id="branch"
+                  placeholder={selectedRepoOption?.defaultBranch ?? "main"}
+                  value={branch}
+                  onChange={(event) => setBranch(event.target.value)}
+                />
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="commit-message">Commit message</Label>
+                <Input
+                  id="commit-message"
+                  placeholder="Export project from ZapDev"
+                  value={commitMessage}
+                  onChange={(event) => setCommitMessage(event.target.value)}
+                />
+              </div>
+            </div>
+
+            <div className="space-y-3">
+              <div className="flex items-center justify-between rounded-md border px-3 py-2">
+                <div>
+                  <p className="text-sm font-medium">Include README</p>
+                  <p className="text-xs text-muted-foreground">
+                    Adds a basic project overview.
+                  </p>
+                </div>
+                <Switch checked={includeReadme} onCheckedChange={setIncludeReadme} />
+              </div>
+              <div className="flex items-center justify-between rounded-md border px-3 py-2">
+                <div>
+                  <p className="text-sm font-medium">Include .gitignore</p>
+                  <p className="text-xs text-muted-foreground">
+                    Adds framework defaults.
+                  </p>
+                </div>
+                <Switch
+                  checked={includeGitignore}
+                  onCheckedChange={setIncludeGitignore}
+                />
+              </div>
+            </div>
+
+            <div className="flex justify-end gap-2">
+              <Button variant="outline" onClick={() => onOpenChange(false)}>
+                Cancel
+              </Button>
+              <Button onClick={handleExport} disabled={!isReady || isExporting}>
+                {isExporting && <Loader2Icon className="mr-2 size-4 animate-spin" />}
+                {isExporting ? "Exporting..." : "Export to GitHub"}
+              </Button>
+            </div>
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  );
+};

File: src/modules/projects/ui/components/netlify-connect-dialog.tsx
Changes:
@@ -0,0 +1,43 @@
+import Link from "next/link";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+
+export const NetlifyConnectDialog = () => {
+  const connection = useQuery(api.oauth.getConnection, { provider: "netlify" });
+
+  if (connection) {
+    return (
+      <Button variant="secondary" size="sm" disabled>
+        Netlify Connected
+      </Button>
+    );
+  }
+
+  return (
+    <Dialog>
+      <DialogTrigger asChild>
+        <Button size="sm">Connect Netlify</Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Connect Netlify</DialogTitle>
+          <DialogDescription>
+            Connect your Netlify account to deploy projects directly from ZapDev.
+          </DialogDescription>
+        </DialogHeader>
+        <Button asChild>
+          <Link href="/api/deploy/netlify/auth">Continue to Netlify</Link>
+        </Button>
+      </DialogContent>
+    </Dialog>
+  );
+};

File: src/modules/projects/ui/components/preview-deployments.tsx
Changes:
@@ -0,0 +1,91 @@
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+
+type PreviewDeploymentsProps = {
+  projectId: string;
+};
+
+export const PreviewDeployments = ({ projectId }: PreviewDeploymentsProps) => {
+  const deployments = useQuery(api.deployments.listDeployments, { projectId });
+  const [isCreating, setIsCreating] = useState(false);
+
+  const previews = useMemo(
+    () => (deployments ?? []).filter((deployment) => deployment.isPreview),
+    [deployments]
+  );
+
+  const handleCreatePreview = async () => {
+    setIsCreating(true);
+    try {
+      const response = await fetch("/api/deploy/netlify/deploy", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ projectId, deployType: "preview" }),
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Preview deployment failed");
+      }
+      toast.success("Preview deployment started");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Preview deployment failed");
+    } finally {
+      setIsCreating(false);
+    }
+  };
+
+  const handleDeletePreview = async (deployId?: string) => {
+    if (!deployId) return;
+    try {
+      const response = await fetch(`/api/deploy/netlify/preview?deployId=${deployId}`, {
+        method: "DELETE",
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to delete preview");
+      }
+      toast.success("Preview deleted");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to delete preview");
+    }
+  };
+
+  return (
+    <div className="space-y-3">
+      <Button size="sm" onClick={handleCreatePreview} disabled={isCreating}>
+        {isCreating ? "Creating preview..." : "Create Preview Deployment"}
+      </Button>
+      {previews.length === 0 && (
+        <p className="text-sm text-muted-foreground">No preview deployments yet</p>
+      )}
+      {previews.map((deployment) => (
+        <div key={deployment._id} className="flex items-center justify-between text-sm">
+          <div className="flex flex-col">
+            <span>Preview #{deployment.deployNumber ?? "-"}</span>
+            <span className="text-xs text-muted-foreground">{deployment.status}</span>
+          </div>
+          <div className="flex items-center gap-2">
+            {deployment.siteUrl && (
+              <Button asChild variant="link" size="sm" className="h-auto p-0">
+                <a href={deployment.siteUrl} target="_blank" rel="noreferrer">
+                  Open
+                </a>
+              </Button>
+            )}
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => handleDeletePreview(deployment.deployId)}
+              disabled={!deployment.deployId}
+            >
+              Delete
+            </Button>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+};

File: src/modules/projects/ui/components/project-header.tsx
Changes:
@@ -13,6 +13,9 @@ import {
 import { useState } from "react";
 
 import { Button } from "@/components/ui/button";
+import { DeployButton } from "./deploy-button";
+import { DeploymentStatus } from "./deployment-status";
+import { GitHubExportButton } from "./github-export-button";
 import {
   DropdownMenu,
   DropdownMenuContent,
@@ -126,15 +129,20 @@ export const ProjectHeader = ({ projectId }: Props) => {
         </DropdownMenu>
       </div>
 
-      <Button
-        variant="ghost"
-        size="sm"
-        onClick={handleDownload}
-        disabled={isDownloading}
-      >
-        <DownloadIcon className="size-4 mr-2" />
-        {isDownloading ? 'Downloading...' : 'Download Code'}
-      </Button>
+      <div className="flex items-center gap-3">
+        <DeploymentStatus projectId={projectId} />
+        <DeployButton projectId={projectId} />
+        <GitHubExportButton projectId={projectId} />
+        <Button
+          variant="ghost"
+          size="sm"
+          onClick={handleDownload}
+          disabled={isDownloading}
+        >
+          <DownloadIcon className="size-4 mr-2" />
+          {isDownloading ? "Downloading..." : "Download Code"}
+        </Button>
+      </div>
     </header>
   );
 };

File: src/modules/projects/ui/views/project-view.tsx
Changes:
@@ -3,7 +3,7 @@
 import Link from "next/link";
 import dynamic from "next/dynamic";
 import { Suspense, useEffect, useMemo, useState } from "react";
-import { EyeIcon, CodeIcon, CrownIcon } from "lucide-react";
+import { EyeIcon, CodeIcon, CrownIcon, RocketIcon } from "lucide-react";
 import { useQuery } from "convex/react";
 import { api } from "@/convex/_generated/api";
 
@@ -18,6 +18,7 @@ import {
 
 import { ProjectHeader } from "../components/project-header";
 import { MessagesContainer } from "../components/messages-container";
+import { DeploymentDashboard } from "../components/deployment-dashboard";
 import { ErrorBoundary } from "react-error-boundary";
 import type { Doc } from "@/convex/_generated/dataModel";
 import { filterAIGeneratedFiles } from "@/lib/filter-ai-files";
@@ -42,7 +43,7 @@ export const ProjectView = ({ projectId }: Props) => {
   const hasProAccess = usage?.planType === "pro";
 
   const [activeFragment, setActiveFragment] = useState<Doc<"fragments"> | null>(null);
-  const [tabState, setTabState] = useState<"preview" | "code">("preview");
+  const [tabState, setTabState] = useState<"preview" | "code" | "deploy">("preview");
   const [streamingFiles, setStreamingFiles] = useState<Record<string, string>>({});
 
   const explorerFiles = useMemo(() => {
@@ -119,7 +120,7 @@ export const ProjectView = ({ projectId }: Props) => {
             className="h-full gap-y-0"
             defaultValue="preview"
             value={tabState}
-            onValueChange={(value) => setTabState(value as "preview" | "code")}
+            onValueChange={(value) => setTabState(value as "preview" | "code" | "deploy")}
           >
             <div className="w-full flex items-center p-2 border-b gap-x-2">
               <TabsList className="h-8 p-0 border rounded-md">
@@ -129,6 +130,9 @@ export const ProjectView = ({ projectId }: Props) => {
                 <TabsTrigger value="code" className="rounded-md">
                   <CodeIcon /> <span>Code</span>
                 </TabsTrigger>
+                <TabsTrigger value="deploy" className="rounded-md">
+                  <RocketIcon /> <span>Deploy</span>
+                </TabsTrigger>
               </TabsList>
               <div className="ml-auto flex items-center gap-x-2">
                 {!hasProAccess && (
@@ -149,6 +153,9 @@ export const ProjectView = ({ projectId }: Props) => {
                 <FileExplorer files={explorerFiles} />
               )}
             </TabsContent>
+            <TabsContent value="deploy" className="min-h-0 p-4">
+              <DeploymentDashboard projectId={projectId} />
+            </TabsContent>
           </Tabs>
         </ResizablePanel>
       </ResizablePanelGroup>

File: src/prompt.ts
Changes:
@@ -5,4 +5,5 @@ export { REACT_PROMPT } from "./prompts/react";
 export { VUE_PROMPT } from "./prompts/vue";
 export { SVELTE_PROMPT } from "./prompts/svelte";
 export { FRAMEWORK_SELECTOR_PROMPT } from "./prompts/framework-selector";
+export { PAYMENT_INTEGRATION_RULES } from "./prompts/payment-integration";
 export { NEXTJS_PROMPT as PROMPT } from "./prompts/nextjs";

File: src/prompts/angular.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const ANGULAR_PROMPT = `
 You are a senior software engineer working in a sandboxed Angular 19 environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 Angular Specific Environment:
 - Main component: src/app/app.component.ts

File: src/prompts/nextjs.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const NEXTJS_PROMPT = `
 You are a senior Next.js engineer in a sandboxed environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 Environment:
 - Framework: Next.js 15.3.3

File: src/prompts/payment-integration.ts
Changes:
@@ -0,0 +1,9 @@
+export const PAYMENT_INTEGRATION_RULES = `
+Payment Integration (Stripe via Autumn):
+- If the user asks for payments, billing, subscriptions, or checkout flows, implement Stripe through Autumn.
+- Use server-side routes for checkout, billing portal, usage tracking, and webhook handling.
+- Always validate request payloads and verify webhook signatures.
+- Store API keys and secrets in environment variables only (no hardcoding).
+- You may call external APIs for Autumn/Stripe only when payment features are explicitly requested.
+- Provide a FeatureGate component and a usage tracking helper.
+`;

File: src/prompts/react.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const REACT_PROMPT = `
 You are a senior software engineer working in a sandboxed React 18 + Vite environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 React + Vite Specific Environment:
 - Main file: src/App.tsx

File: src/prompts/svelte.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const SVELTE_PROMPT = `
 You are a senior software engineer working in a sandboxed SvelteKit environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 SvelteKit Specific Environment:
 - Main page: src/routes/+page.svelte

File: src/prompts/vue.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const VUE_PROMPT = `
 You are a senior software engineer working in a sandboxed Vue 3 + Vite environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 Vue + Vite Specific Environment:
 - Main component: src/App.vue

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds Convex-backed data models and OAuth/token encryption, Netlify deployment APIs and UI, Inngest orchestration, WebContainer sandbox adapter, a skills system with seeding/loader, payment & database template tooling, GitHub export client, extensive prompt and prompt-tooling updates, and broad site/content/SEO revisions.

Changes

Cohort / File(s) Summary
Docs & Site Copy
README.md, ROADMAP.md, public/llms.txt, src/app/*, CHANGELOG_NOVEMBER_DECEMBER_2025.md, CLERK_BILLING_*.md, public/robots.txt
Large content and SEO updates, roadmap reorganization, new blog/comparisons content, updated robots/sitemap entries, and removal of legacy billing/changelog docs.
Convex Schema & Data
convex/schema.ts, convex/deployments.ts, convex/githubExports.ts, convex/projects.ts, convex/oauthQueries.ts, convex/skills.ts
New enums and tables (deployments, githubExports, projectDeploymentCounters, skills, skillInstallations), project.databaseProvider field, and OAuth connection query/mutation surfaces.
OAuth & Token Security
convex/oauth.ts, convex/oauthQueries.ts, src/app/api/auth/anthropic/*, src/app/api/deploy/netlify/auth/*
AES-256-GCM token encryption/decryption, internal actions to decrypt tokens, and server-side OAuth flows (Anthropic, Netlify) with signed state handling.
Netlify Deployment APIs
src/app/api/deploy/netlify/*, src/app/api/deploy/netlify/cli/route.ts
New routes for auth/callback, deploy, cli packaging, sites, domains, env-vars, logs, preview, rollback, status; ZIP packaging, site resolution, and Convex deployment logging.
Netlify UI Components
src/modules/projects/ui/components/*, deployment-* components
Deployment dashboard and UI: DeployButton, NetlifyCLIDialog, EnvVarsDialog, CustomDomainDialog, Preview/History, DeploymentStatus with polling, logs and rollback UIs.
GitHub Export & Client
src/lib/github-api.ts, convex/githubExports.ts, src/app/api/projects/.../export/github/route.ts, src/app/api/github/repositories/route.ts
New GitHub client (trees/commits/branches), repo listing endpoints, export orchestration routes, and githubExports table.
Payments & Templates
src/lib/payment-provider.ts, src/lib/payment-templates/*, src/prompts/payment-integration.ts
Autumn/Polar payment provider abstraction, multi-framework payment template bundles, autumn config and env examples, and payment integration prompt rules.
Database Templates & Prompts
src/lib/database-templates/*, src/prompts/database-*, src/prompt.ts
Drizzle-Neon and Convex database template bundles, env examples, database selector/integration rules, and registration of databaseTemplates/tooling.
Skill System & Seeding
convex/skills.ts, src/agents/skill-loader.ts, src/lib/skill-yaml-parser.ts, src/data/core-skills/*, scripts/seed-skills.ts, src/modules/skills/server/procedures.ts
Convex-backed skill CRUD/search, internal upsert/seed, YAML parser, static core-skill fallback, agent loader with caching/token budgets, tRPC procedures and seeding script.
Sandbox Adapter & WebContainer
src/lib/sandbox-adapter.ts, src/lib/webcontainer*.ts, src/providers/webcontainer-provider.tsx, src/hooks/use-webcontainer.ts, tests/sandbox-adapter.test.ts, src/lib/sandbox-bridge.ts, src/lib/use-sandbox-executor.ts
ISandboxAdapter abstraction (Deferred & WebContainer adapters), WebContainer singleton, process/build/lint helpers, file mounting, provider/hook, bridge for pending requests, executor hook and tests.
Agent & Tools
src/agents/code-agent.ts, src/agents/tools.ts, src/agents/types.ts, src/agents/client.ts, src/agents/rate-limit.ts, src/agents/index.ts
Adapter-aware agent flows, database provider detection/persistence, new tools (payment/database templates, file ops), improved nested error rate-limit detection, model tweaks, and reduced sandbox-utils exports.
Inngest Orchestration
src/inngest/*, src/app/api/inngest/route.ts, src/app/api/agent/run/route.ts
Inngest client, typed event types and channel topics, code-agent Inngest function, Next.js route wiring, and run route replaced to emit/subscribe to events for SSE streaming.
Themes & UI
src/lib/themes.ts, src/components/color-theme-*.tsx, src/app/layout.tsx
New color themes, ColorThemeProvider and ColorThemePicker components, and layout integration.
Content, SEO & Comparisons
src/lib/seo.ts, src/app/sitemap.ts, src/app/robots.ts, src/app/blog/*, src/lib/comparisons.ts, src/app/compare/*
Structured data expanded (WebSite/WebPage), comparisons dataset and pages, sitemap updates, blog markdown renderer, and updated robots rules.
Framework & Copy Updates
src/app/frameworks/*, src/app/solutions/*, src/app/(home)/*, src/app/showcase/*, src/app/(home)/pricing/*
UX copy and SEO updates across frameworks, solutions, homepage, showcase and pricing pages.
Package, Config & Env
package.json, next.config.mjs, env.example, jest.config.js
New dependencies (Anthropic SDKs, Inngest realtime, WebContainer API, gray-matter, react-markdown, remark-gfm), Convex version bump, preview COOP/CORP header, env var additions (Anthropic, Netlify, WebContainers), and test-mapper removal.
Cleanup & Removals
.github/workflows/claude*.yml, various CLERK_BILLING_*.md, src/app/api/cron/cleanup-sandboxes/route.ts, src/app/api/transfer-sandbox/route.ts
Removed Claude workflows, multiple Clerk billing docs, and replaced or stubbed legacy sandbox/cleanup/transfer backend flows for WebContainer migration.

Sequence Diagram(s)

sequenceDiagram
  participant Browser as Client (Browser)
  participant Next as Next.js App Server
  participant Convex as Convex (DB/RPC)
  participant Netlify as Netlify API

  Browser->>Next: POST /api/deploy/netlify/deploy { projectId, deployType, branch... }
  Next->>Convex: api.messages.getLatestFragment + api.projects.get(projectId)
  Convex-->>Next: fragment files + project metadata (framework)
  Next->>Next: generate ZIP (files + netlify.toml)
  Next->>Netlify: createOrResolveSite / upload ZIP / create deploy
  Netlify-->>Next: { siteId, siteUrl, deployId, initialStatus }
  Next->>Convex: api.deployments.create(...) (record deploy metadata)
  Convex-->>Next: deploymentId
  Next-->>Browser: 200 { siteId, siteUrl, deployId, deployState }
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Added Exa Search API #211 — Overlapping changes to agent orchestration, gateway/subagent integrations, and rate-limit/adapter logic.
  • Changes" #209 — Related model/client selection and OpenRouter/Cerebras client adjustments.
  • changes #159 — Related Inngest integration and orchestration surface (events/functions/client).

Poem

🐰 I hopped through code with eager paws,

New schemas, deploys, and OAuth laws,
Skills and templates stitched in a row,
Adapters, Inngest, WebContainers go,
Rabbity cheers — merge and watch it grow!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'Implement Netlify deployment integration and payment templates' accurately summarizes the main change—adding Netlify deployment and payment integration features.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/roadmap-completeation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment on lines +63 to +70
const response = await fetch(`${NETLIFY_API_BASE}${path}`, {
method: options.method ?? "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
...(options.headers ?? {}),
},
body: options.body ?? null,
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI 17 days ago

General approach: Constrain and validate all user-controlled values that end up in the URL path (siteId, domainId, deployId, and domain) before using them to build the Netlify API path. Enforce a strict format (for example, Netlify’s typical ID pattern or at least an allow-list of safe characters) and explicitly reject values containing path traversal (.., /, \) or other disallowed characters. This keeps the origin fixed and prevents abusing the path to reach unintended endpoints, and it addresses all alert variants driven by these parameters.

Best concrete fix without changing functionality:

  1. Introduce small validation helpers in src/lib/netlify-client.ts:

    • A function to validate opaque Netlify IDs (for siteId, domainId, deployId), restricting them to a safe pattern (e.g. alphanumeric plus a few punctuation characters) and optionally a max length.
    • A function to validate domain names to ensure they are legitimate DNS hostnames (letters, digits, hyphens, dots) and reasonable length.
      These helpers should throw a clear error when validation fails.
  2. Call these validators at the very start of the relevant Netlify client methods:

    • listDomains(siteId)
    • addDomain(siteId, domain)
    • deleteDomain(siteId, domainId)
    • verifyDomain(siteId, domainId)
    • getDnsRecords(siteId, domainId)
    • createPreviewDeployment(siteId, ...)
    • listPreviewDeployments(siteId)
    • deletePreviewDeployment(deployId)
      This ensures that any tainted data is sanitized before it influences the path argument passed to request.
  3. Leave the API route files (domains/route.ts and preview/route.ts) largely unchanged with respect to this vulnerability; they will continue to pass through user-provided values, but those values are now vetted in the client. No extra imports beyond standard TypeScript/JS built-ins are needed.

File/region specifics:

  • File: src/lib/netlify-client.ts
    • Add two new helper functions just after the existing utility functions (e.g., after handleApiError and before createNetlifyClient):
      • validateNetlifyId(id: string, fieldName: string): string
      • validateDomainName(domain: string): string
    • Update methods around lines 202–239 to call these validators before constructing template literals for the paths:
      • listDomains(siteId: string) → validate siteId
      • addDomain(siteId: string, domain: string) → validate siteId and domain
      • deleteDomain(siteId: string, domainId: string) → validate both IDs
      • verifyDomain(siteId: string, domainId: string) → validate both
      • getDnsRecords(siteId: string, domainId: string) → validate both
      • createPreviewDeployment(siteId: string, ...) and listPreviewDeployments(siteId: string) → validate siteId
      • deletePreviewDeployment(deployId: string) → validate deployId

No other files need changes for this specific SSRF finding, since the path construction is centralized in netlify-client.ts.


Suggested changeset 1
src/lib/netlify-client.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/lib/netlify-client.ts b/src/lib/netlify-client.ts
--- a/src/lib/netlify-client.ts
+++ b/src/lib/netlify-client.ts
@@ -58,6 +58,27 @@
   throw new Error(errorBody || `Netlify API error: ${response.status}`);
 };
 
+const validateNetlifyId = (id: string, fieldName: string): string => {
+  // Allow common Netlify-style IDs: alphanumerics, '-', '_', and length up to 64.
+  // Reject anything containing path separators or suspicious characters.
+  const trimmed = id.trim();
+  const idPattern = /^[A-Za-z0-9_-]{1,64}$/;
+  if (!idPattern.test(trimmed)) {
+    throw new Error(`Invalid ${fieldName} format`);
+  }
+  return trimmed;
+};
+
+const validateDomainName = (domain: string): string => {
+  const trimmed = domain.trim().toLowerCase();
+  // Basic domain validation: labels of letters/digits/hyphens separated by dots, no leading/trailing dot.
+  const domainPattern = /^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
+  if (!domainPattern.test(trimmed)) {
+    throw new Error("Invalid domain format");
+  }
+  return trimmed;
+};
+
 export const createNetlifyClient = (accessToken: string) => {
   const request = async <T>(path: string, options: NetlifyRequestOptions = {}) => {
     const response = await fetch(`${NETLIFY_API_BASE}${path}`, {
@@ -200,31 +221,41 @@
     },
 
     async listDomains(siteId: string): Promise<NetlifyDomain[]> {
-      return request<NetlifyDomain[]>(`/sites/${siteId}/domains`);
+      const safeSiteId = validateNetlifyId(siteId, "siteId");
+      return request<NetlifyDomain[]>(`/sites/${safeSiteId}/domains`);
     },
 
     async addDomain(siteId: string, domain: string): Promise<NetlifyDomain> {
-      return request<NetlifyDomain>(`/sites/${siteId}/domains`, {
+      const safeSiteId = validateNetlifyId(siteId, "siteId");
+      const safeDomain = validateDomainName(domain);
+      return request<NetlifyDomain>(`/sites/${safeSiteId}/domains`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ name: domain }),
+        body: JSON.stringify({ name: safeDomain }),
       });
     },
 
     async deleteDomain(siteId: string, domainId: string): Promise<void> {
-      await request<void>(`/sites/${siteId}/domains/${domainId}`, { method: "DELETE" });
+      const safeSiteId = validateNetlifyId(siteId, "siteId");
+      const safeDomainId = validateNetlifyId(domainId, "domainId");
+      await request<void>(`/sites/${safeSiteId}/domains/${safeDomainId}`, { method: "DELETE" });
     },
 
     async verifyDomain(siteId: string, domainId: string): Promise<NetlifyDomain> {
-      return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+      const safeSiteId = validateNetlifyId(siteId, "siteId");
+      const safeDomainId = validateNetlifyId(domainId, "domainId");
+      return request<NetlifyDomain>(`/sites/${safeSiteId}/domains/${safeDomainId}`);
     },
 
     async getDnsRecords(siteId: string, domainId: string): Promise<NetlifyDomain> {
-      return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+      const safeSiteId = validateNetlifyId(siteId, "siteId");
+      const safeDomainId = validateNetlifyId(domainId, "domainId");
+      return request<NetlifyDomain>(`/sites/${safeSiteId}/domains/${safeDomainId}`);
     },
 
     async createPreviewDeployment(siteId: string, zipBody: BodyInit): Promise<NetlifyDeploy> {
-      return request<NetlifyDeploy>(`/sites/${siteId}/deploys?draft=true`, {
+      const safeSiteId = validateNetlifyId(siteId, "siteId");
+      return request<NetlifyDeploy>(`/sites/${safeSiteId}/deploys?draft=true`, {
         method: "POST",
         headers: { "Content-Type": "application/zip" },
         body: zipBody,
@@ -232,11 +245,13 @@
     },
 
     async listPreviewDeployments(siteId: string): Promise<NetlifyDeploy[]> {
-      return request<NetlifyDeploy[]>(`/sites/${siteId}/deploys?draft=true`);
+      const safeSiteId = validateNetlifyId(siteId, "siteId");
+      return request<NetlifyDeploy[]>(`/sites/${safeSiteId}/deploys?draft=true`);
     },
 
     async deletePreviewDeployment(deployId: string): Promise<void> {
-      await request<void>(`/deploys/${deployId}`, { method: "DELETE" });
+      const safeDeployId = validateNetlifyId(deployId, "deployId");
+      await request<void>(`/deploys/${safeDeployId}`, { method: "DELETE" });
     },
   };
 };
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +145 to +147
const response = await fetch(`${NETLIFY_API_BASE}/deploys/${deployId}/logs`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI 17 days ago

In general, to address SSRF-like issues where user input influences an outgoing request path, we should strictly validate and constrain the user input before it is used. In this case, deployId should be validated server-side to ensure it matches the expected format of a Netlify deploy ID (e.g., a 26-character URL-safe string or similar), and that it does not contain path separators or other unexpected characters. Optionally, we could further enforce that the deployment actually belongs to the current user by making an authenticated call (or using existing data) to verify ownership before fetching logs.

The minimal change that preserves existing functionality is to add a helper that validates deployId against a conservative pattern (for example, only allow alphanumeric, hyphen, and underscore, and a reasonable length), and then call this validator at the start of getBuildLog. If the validation fails, we throw an error before performing the fetch. This way the URL we pass to fetch is constrained to a safe, well-formed path segment and cannot contain /, ?, #, or other characters that might alter the request path or introduce query parameters. This change is localized to src/lib/netlify-client.ts and does not require changes to the route handler.

Concretely, in src/lib/netlify-client.ts, inside the factory returned by createNetlifyClient, we will define a small validateDeployId function (or similar) that throws on invalid values. Then we modify getBuildLog to call this validator before constructing the URL. Because we are only adding a small local helper and not using any external library, no new imports are needed.


Suggested changeset 1
src/lib/netlify-client.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/lib/netlify-client.ts b/src/lib/netlify-client.ts
--- a/src/lib/netlify-client.ts
+++ b/src/lib/netlify-client.ts
@@ -146,6 +146,11 @@
     },
 
     async getBuildLog(deployId: string): Promise<string> {
+      // Validate deployId to ensure it is a safe path segment and not attacker-crafted
+      if (!/^[A-Za-z0-9_-]{1,64}$/.test(deployId)) {
+        throw new Error("Invalid Netlify deploy ID");
+      }
+
       const response = await fetch(`${NETLIFY_API_BASE}/deploys/${deployId}/logs`, {
         headers: { Authorization: `Bearer ${accessToken}` },
       });
EOF
@@ -146,6 +146,11 @@
},

async getBuildLog(deployId: string): Promise<string> {
// Validate deployId to ensure it is a safe path segment and not attacker-crafted
if (!/^[A-Za-z0-9_-]{1,64}$/.test(deployId)) {
throw new Error("Invalid Netlify deploy ID");
}

const response = await fetch(`${NETLIFY_API_BASE}/deploys/${deployId}/logs`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
Copilot is powered by AI and may make mistakes. Always verify output.
@codecapyai
Copy link

codecapyai bot commented Jan 19, 2026

🚀 Launching Scrapybara desktop...

@codecapyai
Copy link

codecapyai bot commented Jan 19, 2026

❌ Something went wrong:

status_code: 500, body: {'detail': 'Error creating instance: HTTPSConnectionPool(host=\'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\', port=443): Max retries exceeded with url: /api/v1/namespaces/scrapybara-instances/services (Caused by NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x7f782938e710>: Failed to resolve \'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\' ([Errno -2] Name or service not known)"))'}

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

30 issues found across 53 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="src/modules/projects/ui/components/preview-deployments.tsx">

<violation number="1" location="src/modules/projects/ui/components/preview-deployments.tsx:40">
P2: Delete operation lacks loading state protection. Unlike `handleCreatePreview` which disables the button via `isCreating` during the API call, `handleDeletePreview` has no such safeguard. Users can spam-click delete, triggering multiple API requests. Consider tracking which deployment is being deleted (e.g., `const [deletingId, setDeletingId] = useState<string | null>(null)`) and disabling that specific button during the operation.</violation>
</file>

<file name="src/app/api/deploy/netlify/env-vars/route.ts">

<violation number="1" location="src/app/api/deploy/netlify/env-vars/route.ts:61">
P2: The validation `!body.value` rejects empty string values, which may be a legitimate use case for environment variables. Consider using `body.value === undefined` or adding a separate check for the value's type to allow empty strings while still requiring the field to be present.</violation>
</file>

<file name="convex/deployments.ts">

<violation number="1" location="convex/deployments.ts:83">
P2: Using truthy check for `buildTime` will silently ignore valid value `0`. A build time of 0ms is a meaningful value that would be incorrectly skipped. Use `args.buildTime !== undefined` instead.</violation>
</file>

<file name="convex/oauth.ts">

<violation number="1" location="convex/oauth.ts:86">
P2: Exposing OAuth access tokens directly to the client violates documented security conventions. Consider using a Convex action to make GitHub API calls server-side instead of returning the token to the client, which could be vulnerable to XSS attacks or unintentional logging.</violation>
</file>

<file name="src/lib/payment-templates/types.ts">

<violation number="1" location="src/lib/payment-templates/types.ts:1">
P2: Hardcoding framework slugs here duplicates the canonical list in `frameworks.ts`, which can drift over time. Derive the type from the shared frameworks metadata so updates stay centralized.</violation>
</file>

<file name="src/modules/projects/ui/components/github-export-modal.tsx">

<violation number="1" location="src/modules/projects/ui/components/github-export-modal.tsx:137">
P2: Missing AbortController for fetch request in useEffect. If the modal closes or component unmounts while the request is in flight, state updates may be attempted on an unmounted component or stale responses may override newer state. Add an AbortController with cleanup to cancel pending requests.</violation>
</file>

<file name="src/modules/projects/ui/components/deployment-history.tsx">

<violation number="1" location="src/modules/projects/ui/components/deployment-history.tsx:21">
P2: Shared `logs` state causes stale data to display across different deployment dialogs. When viewing logs for one deployment and then opening another, the previous deployment's logs briefly appear. Consider storing logs per deployment ID using a Map or moving the logs state into a dedicated child component for each deployment row.</violation>

<violation number="2" location="src/modules/projects/ui/components/deployment-history.tsx:89">
P2: No loading state when fetching logs. Users see 'No logs available' while logs are loading, which is misleading. Consider adding an `isLoading` state and displaying a loading indicator.</violation>
</file>

<file name="src/app/api/deploy/netlify/auth/route.ts">

<violation number="1" location="src/app/api/deploy/netlify/auth/route.ts:20">
P1: OAuth state parameter lacks cryptographic signature, making it vulnerable to CSRF attacks. Base64 encoding is not encryption - an attacker who knows a user's ID can forge a valid state. Use HMAC signing with a server-side secret to create a tamper-proof state, or use a cryptographically random token stored server-side.</violation>
</file>

<file name="src/lib/netlify-config.ts">

<violation number="1" location="src/lib/netlify-config.ts:45">
P2: Only the first Netlify plugin is written to netlify.toml. If `config.plugins` contains multiple entries, the extra plugins are silently omitted, causing missing plugin configuration in the generated file.</violation>
</file>

<file name="src/modules/projects/ui/components/github-export-button.tsx">

<violation number="1" location="src/modules/projects/ui/components/github-export-button.tsx:27">
P2: `useQuery` returns `undefined` while loading, so the falsy check shows the “Connect GitHub” dialog during the loading state. Add an explicit loading guard before treating the user as disconnected.</violation>
</file>

<file name="src/lib/payment-provider.ts">

<violation number="1" location="src/lib/payment-provider.ts:206">
P2: Unsafe type assertion: `undefined as T` bypasses type safety. When `request<SubscriptionSummary>` receives a 204 response, it returns `undefined` but TypeScript believes it's a valid `SubscriptionSummary`. This can cause runtime errors when callers access properties on the result. Consider using a discriminated return type or throwing for unexpected 204 responses.</violation>
</file>

<file name="src/lib/payment-templates/svelte.ts">

<violation number="1" location="src/lib/payment-templates/svelte.ts:261">
P1: The checkout request doesn't check `response.ok` or provide error feedback to users. If the checkout fails, users see no indication of the failure - the button silently returns to its default state. Payment flows require clear error feedback.</violation>
</file>

<file name="src/app/api/projects/[projectId]/export/github/route.ts">

<violation number="1" location="src/app/api/projects/[projectId]/export/github/route.ts:113">
P2: Inefficient query: fetches all exports and filters client-side instead of using `api.githubExports.get` which directly queries by `exportId`. This unnecessarily transfers and processes more data than needed.</violation>
</file>

<file name="src/modules/projects/ui/components/env-vars-dialog.tsx">

<violation number="1" location="src/modules/projects/ui/components/env-vars-dialog.tsx:86">
P2: Environment variables are fetched on component mount before the dialog is opened, causing unnecessary API calls. Consider using the Dialog's `onOpenChange` callback to fetch data only when the dialog is opened.</violation>

<violation number="2" location="src/modules/projects/ui/components/env-vars-dialog.tsx:88">
P2: Missing dependency in useEffect. The `loadEnvVars` function is used but not included in the dependency array. Either wrap `loadEnvVars` in `useCallback` with `[siteId]` as dependencies, or inline the fetch logic directly in the effect.</violation>

<violation number="3" location="src/modules/projects/ui/components/env-vars-dialog.tsx:123">
P2: The "Remove" button is not disabled during loading, unlike the "Save" button. This could cause race conditions if users click delete while another operation is in progress.</violation>
</file>

<file name="src/modules/projects/ui/components/deployment-status.tsx">

<violation number="1" location="src/modules/projects/ui/components/deployment-status.tsx:65">
P1: The `deployment` object in the dependency array causes the effect to re-run on every Convex query update, resetting the interval and immediately calling `pollStatus()`. This creates rapid polling instead of the intended 10-second intervals. Use specific primitive values as dependencies instead.</violation>
</file>

<file name="src/modules/projects/ui/components/deploy-button.tsx">

<violation number="1" location="src/modules/projects/ui/components/deploy-button.tsx:40">
P2: The `!connection` check doesn't distinguish between loading state (`undefined`) and no connection (`null`). This causes `NetlifyConnectDialog` to flash briefly while the query is loading. Consider handling the loading state explicitly.</violation>
</file>

<file name="src/lib/netlify-client.ts">

<violation number="1" location="src/lib/netlify-client.ts:172">
P1: The `key` parameter should be URL-encoded before being used in the path. Without encoding, keys containing special characters (like `/`, `.`, `%`) will cause incorrect API paths or potential path traversal issues.</violation>
</file>

<file name="src/app/api/deploy/netlify/sites/route.ts">

<violation number="1" location="src/app/api/deploy/netlify/sites/route.ts:14">
P1: Missing authentication token for Convex query. The `fetchQuery` call requires the Clerk token to be passed as a third argument for `requireAuth(ctx)` to work in the Convex query. Without this, the query will fail with an auth error.

Use either `getConvexClientWithAuth()` or pass the token to `fetchQuery`.</violation>
</file>

<file name="src/lib/payment-templates/react.ts">

<violation number="1" location="src/lib/payment-templates/react.ts:204">
P2: `JSON.parse(rawBody)` can throw on malformed JSON, crashing the webhook handler. This should be wrapped in a try-catch block to return a proper error response instead of an unhandled exception.</violation>

<violation number="2" location="src/lib/payment-templates/react.ts:264">
P2: Missing error handling in `startCheckout`: The function doesn't check `response.ok` and lacks a `catch` block. If the API returns an error or the request fails, users get no feedback - the button just stops loading without explanation.</violation>
</file>

<file name="src/lib/payment-templates/nextjs.ts">

<violation number="1" location="src/lib/payment-templates/nextjs.ts:350">
P2: Missing error handling in checkout flow. If the API returns an error (400/500), the user gets no feedback - the button just stops loading. Check `response.ok` and display an error message to the user.</violation>

<violation number="2" location="src/lib/payment-templates/nextjs.ts:394">
P2: The `trackUsage` function silently ignores API errors. Failed usage tracking could result in billing inaccuracies. Either throw on error or return a result indicating success/failure.</violation>
</file>

<file name="src/lib/payment-templates/angular.ts">

<violation number="1" location="src/lib/payment-templates/angular.ts:197">
P0: Webhook signature verification is broken. The `express.json()` middleware parses the body before it reaches this route, so `req.body` is already an object, not a raw string. The signature will be computed on `'[object Object]'` instead of the actual payload, and `JSON.parse(rawBody)` will throw. The webhook route needs raw body access - either exclude it from `express.json()` or use `express.raw()` specifically for webhooks.</violation>
</file>

<file name="src/lib/payment-templates/vue.ts">

<violation number="1" location="src/lib/payment-templates/vue.ts:71">
P1: Async route handlers lack try/catch. If `autumn.request()` throws, Express 4.x won't catch it automatically, resulting in unhandled promise rejections and hanging requests. Wrap async operations in try/catch and return appropriate error responses.</violation>

<violation number="2" location="src/lib/payment-templates/vue.ts:197">
P0: Webhook signature verification is broken. `req.body` is already parsed by `express.json()` middleware, so it's an object, not the raw string needed for HMAC verification. To fix this, webhook routes need raw body access - either exclude them from `express.json()` middleware, or use `express.raw()` for webhook routes specifically, storing the raw buffer for signature verification.</violation>

<violation number="3" location="src/lib/payment-templates/vue.ts:251">
P2: Missing error handling for failed checkout requests. If the API returns an error status, the user sees no feedback - the button just stops loading without explanation.</violation>
</file>

<file name="src/app/api/deploy/netlify/logs/route.ts">

<violation number="1" location="src/app/api/deploy/netlify/logs/route.ts:12">
P1: Missing authentication token in Convex `fetchQuery` call. The OAuth connection query runs without user identity context, which could return incorrect or unauthorized data.

Pass the auth token to scope the query to the authenticated user.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c6aed5763

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 19

🤖 Fix all issues with AI agents
In `@convex/deployments.ts`:
- Around line 33-41: The current code reads latest.deployNumber and computes
nextDeployNumber, which can race under concurrent mutations; instead, update the
mutation to obtain the deploy number atomically by either (A) maintaining a
per-project counter and incrementing it inside the same mutation (e.g., patch or
upsert a project counter document and use the returned incremented value as
deployNumber) or (B) stop assigning a separate deployNumber and derive
ordering/display from the record's _creationTime. Locate the code that queries
"deployments" (the latest variable / nextDeployNumber calculation) and replace
it with an atomic increment of a per-project counter (patched/created inside the
same mutation) or switch consumers to use _creationTime for ordering. Ensure the
counter update and deployment insert happen in the same mutation so duplicates
cannot occur.
- Around line 78-84: The current ctx.db.patch call uses truthy checks which skip
updates for empty strings or zero (e.g., error, buildLog, buildTime), so change
the conditional spread checks in the patch for args.status, args.deployId,
args.error, args.buildLog, and args.buildTime to test for !== undefined instead
of truthiness; keep the same spread pattern and updatedAt: Date.now() so fields
can be cleared by sending "" or 0 and still be persisted when present (refer to
ctx.db.patch and args.deploymentId to locate the code).

In `@convex/oauth.ts`:
- Around line 73-88: The getGithubAccessToken function currently exposes raw
OAuth tokens as a public query; change its registration from query to
internalQuery (preserving the handler name getGithubAccessToken and keeping
requireAuth and the oauthConnections lookup) so it is only callable server-side
(e.g., from exportToGitHub in convex/githubExports.ts) and update that file to
import and call the internalQuery variant; additionally, ensure accessToken
values in oauthConnections are stored encrypted and decrypt them only inside
this server-only handler (use your existing encryption/decryption helpers)
before returning.

In `@src/app/api/deploy/netlify/callback/route.ts`:
- Around line 80-85: The current state handling decodes base64 JSON (using
JSON.parse(Buffer.from(state,...)) and only checks decodedState.userId against
user.id, which is predictable; instead generate a cryptographically secure nonce
when starting the OAuth flow, store that nonce server-side (or in a
signed/Httponly cookie) with a short TTL, and verify the incoming state value
exactly matches the stored nonce and is not expired in the callback (then
delete/expire it). Replace the existing decodedState-based check in route.ts
(the JSON.parse(Buffer.from(state...)) block and the "State token mismatch"
error) with a verification against your server-side nonce store or verified
HMAC-signed token and return a clear error if missing/expired/mismatched.

In `@src/app/api/deploy/netlify/deploy/route.ts`:
- Around line 115-125: The call to
fetchMutation(api.deployments.createDeployment, ...) passes deploy.state (a
free-form string) into the typed status field which expects the union "pending"
| "building" | "ready" | "error"; create a normalizeDeploymentStatus(status:
string) helper that maps known Netlify states to those four values (e.g., map
"idle"/"created"→"pending", "building"→"building", "ready"→"ready", any
unknown/failed values→"error") and use normalizeDeploymentStatus(deploy.state)
for the status property in the fetchMutation call (referencing fetchMutation,
api.deployments.createDeployment, and deploy.state).
- Around line 11-17: The request handler in route.ts currently casts the
incoming body to DeployRequest without runtime checks (DeployRequest), so add a
Zod schema mirroring DeployRequest (required projectId string, optional siteId,
deployType enum "preview"|"production", optional branch and commitRef) and use
it to parse/validate the incoming JSON before using it; import zod, parse the
body with schema.parse or safeParse, return a 400 response on validation failure
with an error message, and then use the validated result for subsequent logic
instead of the unchecked cast.

In `@src/app/api/deploy/netlify/env-vars/route.ts`:
- Around line 43-47: The API currently returns full Netlify env var objects
(from netlifyClient.getEnvVars) including plaintext values, so update the
handler to filter each env var before sending with NextResponse.json: iterate
over the envVars response (the array returned by netlifyClient.getEnvVars) and
return only non-secret fields (e.g., key/name, id, context, created_at,
updated_at, and any metadata) while omitting or redacting the values
array/values field; ensure this transformation occurs server-side in the same
function that calls createNetlifyClient and getNetlifyAccessToken and that
NextResponse.json is passed the sanitized list instead of the raw envVars.

In `@src/lib/payment-provider.ts`:
- Around line 113-120: getSubscription currently calls this.request and promises
SubscriptionSummary | null but lets 404s bubble as exceptions; change
getSubscription (the async function named getSubscription taking
SubscriptionLookup) to catch errors from this.request and return null when the
HTTP status is 404 (or when the underlying error indicates a 404), otherwise
rethrow the error; alternatively, if you prefer, modify the request helper to
surface the response status (e.g., by returning an object with status and body)
and then have getSubscription return null when status === 404 and the
SubscriptionSummary when successful.

In `@src/lib/payment-templates/env-example.ts`:
- Around line 1-9: The exported template string paymentEnvExample should be
sanitized before reuse or persistence to avoid NULL-byte issues; wrap the
template value with sanitizeAnyForDatabase() when assigning/exporting
paymentEnvExample (i.e., replace the raw template string assigned to
paymentEnvExample with the result of sanitizeAnyForDatabase(yourTemplateString))
so all consumers receive a sanitized string; ensure sanitizeAnyForDatabase is
imported where paymentEnvExample is defined and that the exported identifier
remains paymentEnvExample.

In `@src/lib/payment-templates/nextjs.ts`:
- Around line 154-158: The PATCH handler casts req.json() to UpdateRequest
without validation, so add a type guard (e.g., isUpdateRequest) or reuse an
existing guard to validate the parsed body before using it in PATCH; locate the
PATCH function in this file, parse the body into a generic object, run the guard
(isUpdateRequest(body)) and return a 400 NextResponse.json when it fails,
otherwise proceed using the typed UpdateRequest.
- Around line 171-175: The DELETE handler casts req.json() directly to
CancelRequest without validating shape, so add a runtime type guard (e.g.,
isCancelRequest) and use it inside export async function DELETE(req: Request) to
check the parsed body before using it; if the guard fails return
NextResponse.json({ error: "Invalid payload" }, { status: 400 }) and otherwise
proceed with the validated CancelRequest values. Locate the DELETE function and
the CancelRequest type and either implement or reuse an existing type guard
helper (isCancelRequest or similar) to perform the check.

In `@src/lib/payment-templates/vue.ts`:
- Around line 189-214: The webhook handler router.post("/autumn") is verifying
an HMAC over req.body but the app uses express.json() so req.body is already
parsed; update the route to receive the raw request bytes and convert them to a
string before calling verifySignature and JSON.parse: register express.raw({
type: "application/json" }) for the /autumn path (or apply raw body middleware
to this route) so you can read the raw Buffer, set rawBody =
buffer.toString("utf8"), then use signatureValue and
process.env.AUTUMN_WEBHOOK_SECRET with verifySignature(rawBody, secret) and only
after verification call JSON.parse(rawBody) to produce the event object.

In `@src/modules/projects/ui/components/custom-domain-dialog.tsx`:
- Around line 27-31: The dialog allows duplicate add/remove clicks because
isLoading is only used for loadDomains; add a dedicated boolean state (e.g.,
isSubmitting) in CustomDomainDialog and use it in addDomain and removeDomain
handlers: at start of each handler return early if isSubmitting, then set
isSubmitting = true before the network call and reset it in a finally block (or
on both success/error) to ensure it's cleared; also disable the Add and Remove
buttons and any form inputs when isSubmitting is true so the UI cannot issue
concurrent POST/DELETE requests, and keep loadDomains using isLoading as before.

In `@src/modules/projects/ui/components/deployment-history.tsx`:
- Line 21: The shared logs state (logs, setLogs) in the DeploymentHistory
component causes stale logs to flash when opening a different deployment; update
the code so that when opening the logs dialog you either clear logs (call
setLogs(null) or an explicit loading sentinel) before fetching new logs in the
open handler (e.g., openLogs/openDeploymentDialog) or replace the single string
state with a map keyed by deployId (e.g., logsByDeployId:
Record<deployId,string|null>) and read/write via setLogsByDeployId(deployId,
...); ensure the fetch logic (where fetch/deploy logs are requested) uses the
deployId key and the dialog rendering reads the keyed value so previous
deployment logs are never shown while loading.
- Around line 15-17: The prop type for DeploymentHistoryProps is wrong: change
projectId from string to Convex's Id type (Id<"projects">) so it matches backend
expectations; update the DeploymentHistoryProps definition and any component
props or usages that type projectId (including the component signature and any
places passing projectId) to use Id<"projects"> and add the necessary import for
Id (from the Convex types module used in the repo) so the file compiles against
Convex.

In `@src/modules/projects/ui/components/deployment-status.tsx`:
- Around line 7-9: Change the DeploymentStatusProps type so projectId uses the
Convex Id type instead of string: replace the projectId: string declaration with
projectId: Id<"projects"> in the DeploymentStatusProps interface used by the
DeploymentStatus component (and update any other occurrences in this file, e.g.,
the other prop definitions around lines 22-23) so callers provide the correct
Convex project id type.

In `@src/modules/projects/ui/components/env-vars-dialog.tsx`:
- Around line 22-27: The component currently only tracks loading via isLoading
(set in loadEnvVars) so Save/Delete can be clicked multiple times; add a new
boolean state like isSubmitting (with setIsSubmitting) in EnvVarsDialog and use
it for mutation flows (e.g., in the save and remove handlers) by early-returning
if isSubmitting is true, setting isSubmitting = true before the POST/DELETE, and
setting it back to false in finally; also disable the Save/Remove buttons and
relevant inputs when isSubmitting is true. You can alternatively reuse isLoading
but ensure it is set around mutations (save/remove) too; update any handlers
named saveEnvVar/removeEnvVar (or similar) to reference this new state.

In `@src/modules/projects/ui/components/github-export-button.tsx`:
- Around line 23-48: The component GitHubExportButton currently treats
connection from useQuery(api.oauth.getConnection) the same when it's undefined
(loading) and null (no account), causing a flash; update the render logic to
explicitly handle the loading state by checking connection === undefined first
(e.g., return null, a spinner, or disabled Button) and only render the "Connect
GitHub" Dialog when connection === null; adjust any dependent state like
open/setOpen inside GitHubExportButton accordingly so the UI doesn't show the
connect dialog during the query's loading phase.

In `@src/modules/projects/ui/components/preview-deployments.tsx`:
- Around line 7-12: The prop type for PreviewDeployments is incorrect:
PreviewDeploymentsProps currently types projectId as string but
useQuery(api.deployments.listDeployments, { projectId }) requires Convex
Id<"projects">; change the prop type to projectId: Id<"projects"> in
PreviewDeploymentsProps (and update the PreviewDeployments signature if needed)
and then update all callers to pass the Convex Id type (or explicitly
convert/cast to Id<"projects"> where safe) so the call to
useQuery(api.deployments.listDeployments, { projectId }) satisfies the expected
type.
♻️ Duplicate comments (7)
src/prompts/svelte.ts (1)

2-8: Same external-API conflict as noted elsewhere.

This prompt also inherits the SHARED_RULES vs PAYMENT_INTEGRATION_RULES conflict regarding external API usage.

src/prompts/react.ts (1)

2-8: Same external-API conflict as noted elsewhere.

This prompt also inherits the SHARED_RULES vs PAYMENT_INTEGRATION_RULES conflict regarding external API usage.

src/prompts/vue.ts (1)

2-8: Same external-API conflict as noted elsewhere.

This prompt also inherits the SHARED_RULES vs PAYMENT_INTEGRATION_RULES conflict regarding external API usage.

src/prompts/angular.ts (1)

2-8: Same external-API conflict as noted elsewhere.

This prompt also inherits the SHARED_RULES vs PAYMENT_INTEGRATION_RULES conflict regarding external API usage.

src/app/api/deploy/netlify/preview/route.ts (1)

7-21: Duplicate code: NetlifyConnection and getNetlifyAccessToken.

Same duplication as noted in logs/route.ts. Extract to a shared module.

src/app/api/deploy/netlify/rollback/route.ts (1)

7-25: Duplicate code: NetlifyConnection and getNetlifyAccessToken.

Same duplication as noted in other route files. Extract to a shared module.

src/app/api/deploy/netlify/domains/route.ts (1)

7-26: Duplicate code: NetlifyConnection and getNetlifyAccessToken.

Same duplication as noted in other route files. Extract to a shared module.

🧹 Nitpick comments (29)
src/prompts/nextjs.ts (1)

2-8: Clarify the external-API exception for payments in the prompt.

SHARED_RULES prohibits external APIs, but PAYMENT_INTEGRATION_RULES instructs calling Autumn/Stripe. Once injected, the model gets conflicting directives. Consider adding an explicit exception in PAYMENT_INTEGRATION_RULES (or refining SHARED_RULES) so payment flows are unambiguous.

♻️ Suggested tweak in src/prompts/payment-integration.ts
 export const PAYMENT_INTEGRATION_RULES = `
 Payment Integration (Stripe via Autumn):
 - If the user asks for payments, billing, subscriptions, or checkout flows, implement Stripe through Autumn.
 - Use server-side routes for checkout, billing portal, usage tracking, and webhook handling.
 - Always validate request payloads and verify webhook signatures.
 - Store API keys and secrets in environment variables only (no hardcoding).
 - You may call external APIs for Autumn/Stripe only when payment features are explicitly requested.
+- This is the only exception to the "no external APIs" rule in SHARED_RULES.
 - Provide a FeatureGate component and a usage tracking helper.
 `;
ROADMAP.md (1)

179-227: Consider relocating ROADMAP.md to /explanations.

Documentation files should live under the explanations/ directory unless explicitly exempted.

As per coding guidelines, docs belong in /explanations.

src/lib/payment-templates/angular.ts (2)

1-1: Use @/lib/... import for internal lib types.

Keeps lib imports consistent and modular.

♻️ Proposed change
-import type { PaymentTemplateBundle } from "./types";
+import type { PaymentTemplateBundle } from "@/lib/payment-templates/types";
As per coding guidelines, internal lib imports should use `@/lib/...`.

306-314: Add focus-visible styles on interactive elements.

Buttons/links should include focus-visible:ring-ring/50 focus-visible:ring-[3px] (and border ring) per accessibility guidance.

As per coding guidelines, focus-visible styles are required for accessible UI.

Also applies to: 357-380

src/lib/payment-templates/react.ts (2)

1-1: Use @/lib/... import for internal lib types.

Keeps lib imports consistent and modular.

♻️ Proposed change
-import type { PaymentTemplateBundle } from "./types";
+import type { PaymentTemplateBundle } from "@/lib/payment-templates/types";
As per coding guidelines, internal lib imports should use `@/lib/...`.

274-281: Add focus-visible styles on interactive elements.

Buttons/links should include focus-visible:ring-ring/50 focus-visible:ring-[3px] (and border ring) per accessibility guidance.

As per coding guidelines, focus-visible styles are required for accessible UI.

Also applies to: 325-346

src/lib/netlify-config.ts (1)

43-61: Render all plugins instead of only the first entry.
plugins is an array but only plugins[0] is emitted, which will silently drop future additions.

♻️ Suggested update
-  const pluginsBlock = config.plugins?.length
-    ? `\n[[plugins]]\n  package = "${config.plugins[0]}"\n`
-    : "";
+  const pluginsBlock = config.plugins?.length
+    ? config.plugins
+        .map((plugin) => `\n[[plugins]]\n  package = "${plugin}"\n`)
+        .join("")
+    : "";
src/app/api/deploy/netlify/logs/route.ts (1)

7-21: Extract shared NetlifyConnection type and getNetlifyAccessToken helper.

This type and helper are duplicated across multiple route files (logs, preview, rollback, domains, sites). Extract them to a shared module (e.g., src/lib/netlify-auth.ts) to improve maintainability and reduce code duplication.

♻️ Suggested shared module

Create src/lib/netlify-auth.ts:

import { fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";

type NetlifyConnection = {
  accessToken?: string;
};

export const getNetlifyAccessToken = async (): Promise<string> => {
  const connection = await fetchQuery(api.oauth.getConnection, {
    provider: "netlify",
  }) as NetlifyConnection | null;

  if (!connection?.accessToken) {
    throw new Error("Netlify connection not found.");
  }

  return connection.accessToken;
};

Then import from this shared module in all route files.

src/app/api/deploy/netlify/rollback/route.ts (1)

34-37: Handle JSON parse errors explicitly.

If request.json() fails (e.g., invalid JSON body), it throws before reaching the validation check. Consider wrapping in a try-catch or validating more defensively.

♻️ Safer body parsing
-    const body = (await request.json()) as RollbackPayload;
-    if (!body.deployId) {
+    let body: RollbackPayload;
+    try {
+      body = (await request.json()) as RollbackPayload;
+    } catch {
+      return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+    }
+    if (!body?.deployId) {
       return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
     }
src/modules/projects/ui/components/deployment-status.tsx (1)

31-65: Optimize useEffect dependencies to avoid unnecessary re-runs.

Including the entire deployment object in the dependency array causes the effect to re-run on every status update from the Convex subscription, which can trigger redundant polling cycles. Extract only the needed values.

♻️ Suggested fix
+  const deployId = deployment?.deployId;
+  const deploymentId = deployment?._id;
+
   useEffect(() => {
-    if (!shouldPoll || !deployment?.deployId) {
+    if (!shouldPoll || !deployId || !deploymentId) {
       return;
     }

     let cancelled = false;
     const pollStatus = async () => {
       try {
-        const response = await fetch(`/api/deploy/netlify/status?deployId=${deployment.deployId}`);
+        const response = await fetch(`/api/deploy/netlify/status?deployId=${deployId}`);
         if (!response.ok) {
           return;
         }

         const data = (await response.json()) as NetlifyStatusResponse;
         if (!data.state || cancelled) {
           return;
         }

         await updateDeployment({
-          deploymentId: deployment._id,
+          deploymentId,
           status: data.state === "ready" ? "ready" : data.state === "error" ? "error" : "building",
         });
       } catch {
         // ignore polling errors
       }
     };

     const interval = setInterval(pollStatus, 10000);
     pollStatus();

     return () => {
       cancelled = true;
       clearInterval(interval);
     };
-  }, [deployment, shouldPoll, updateDeployment]);
+  }, [deployId, deploymentId, shouldPoll, updateDeployment]);
src/modules/projects/ui/components/deployment-history.tsx (1)

94-101: Consider adding a confirmation dialog for rollback.

Rollback is a potentially destructive operation that reverts the live deployment. A confirmation step would prevent accidental rollbacks.

src/app/api/deploy/netlify/env-vars/route.ts (1)

19-22: Avoid as assertions; validate payloads instead.
The as NetlifyConnection | null and as EnvVarPayload casts skip runtime validation and undercut strict typing; consider Zod parsing or explicit type guards to validate both Convex responses and request JSON. As per coding guidelines, please avoid as assertions and validate the shapes explicitly.

Also applies to: 60-61, 87-88

src/app/api/deploy/netlify/sites/route.ts (1)

12-15: Drop the as assertion and validate the Convex response.
The as NetlifyConnection | null cast bypasses runtime checks; prefer schema validation or a type guard so accessToken presence is verified explicitly. As per coding guidelines, please avoid as assertions and validate the shape.

convex/githubExports.ts (1)

300-331: Branch resolution logic handles edge cases well.

The fallback to default branch when target branch doesn't exist is a sensible approach. The error message check using toLowerCase().includes("not found") is reasonable but could be fragile if the GitHub API error format changes.

Consider checking for specific HTTP status codes (404) rather than parsing error messages, if the GitHub API client exposes them.

src/modules/projects/ui/components/deploy-button.tsx (1)

13-42: Consider a loading state for the Netlify connection.

useQuery returns undefined while loading; currently that path renders the connect dialog, which can flicker. Consider a brief disabled/loading state (e.g., animate-spin or animate-pulse).

src/agents/tools.ts (1)

146-156: Emit onToolCall for the new tool for consistent telemetry.

Other tools call onToolCall at the start; adding it here keeps tool logging consistent.

♻️ Suggested tweak
     paymentTemplates: tool({
       description:
         "Get Stripe + Autumn payment integration templates for a framework",
       inputSchema: z.object({
         framework: z.enum(["nextjs", "react", "vue", "angular", "svelte"]),
       }),
       execute: async ({ framework }) => {
+        onToolCall?.("paymentTemplates", { framework });
         const template = getPaymentTemplate(framework);
         return JSON.stringify({
           ...template,
src/lib/payment-templates/index.ts (1)

1-8: Use @/lib/... imports for lib modules.

Switching to alias imports improves consistency and follows the lib import rule.

♻️ Suggested refactor
-import { angularPaymentTemplate } from "./angular";
-import { nextjsPaymentTemplate } from "./nextjs";
-import { reactPaymentTemplate } from "./react";
-import { sveltePaymentTemplate } from "./svelte";
-import { vuePaymentTemplate } from "./vue";
-import type { PaymentFramework, PaymentTemplateBundle } from "./types";
-import { autumnConfigTemplate } from "./autumn-config";
-import { paymentEnvExample } from "./env-example";
+import { angularPaymentTemplate } from "@/lib/payment-templates/angular";
+import { nextjsPaymentTemplate } from "@/lib/payment-templates/nextjs";
+import { reactPaymentTemplate } from "@/lib/payment-templates/react";
+import { sveltePaymentTemplate } from "@/lib/payment-templates/svelte";
+import { vuePaymentTemplate } from "@/lib/payment-templates/vue";
+import type {
+  PaymentFramework,
+  PaymentTemplateBundle,
+} from "@/lib/payment-templates/types";
+import { autumnConfigTemplate } from "@/lib/payment-templates/autumn-config";
+import { paymentEnvExample } from "@/lib/payment-templates/env-example";

As per coding guidelines, use @/lib/... for internal lib imports.

src/modules/projects/ui/components/github-export-modal.tsx (2)

120-126: Effect resets state but doesn't reset form fields.

When the modal closes, only error, result, and isExporting are reset. Form fields like repoName, selectedRepo, branch, etc., retain their values. If the user reopens the modal, stale data will persist.

Consider resetting the form state when the modal closes:

♻️ Suggested fix
  useEffect(() => {
    if (!open) {
      setError(null);
      setResult(null);
      setIsExporting(false);
+     setRepoName("");
+     setRepoDescription("");
+     setIsPrivate(false);
+     setSelectedRepo("");
+     setBranch("");
+     setCommitMessage("");
+     setMode("new");
    }
  }, [open]);

128-158: Missing request cancellation could cause state updates on unmounted component.

If the modal is closed before the fetch completes, setRepos or setError may be called on an unmounted component. Consider using an AbortController to cancel the request.

♻️ Suggested fix
  useEffect(() => {
    if (!open) {
      return;
    }

+   const controller = new AbortController();
+
    const loadRepositories = async () => {
      setIsLoadingRepos(true);
      setError(null);
      try {
-       const response = await fetch("/api/github/repositories");
+       const response = await fetch("/api/github/repositories", {
+         signal: controller.signal,
+       });
        const payload = await response.json();
        if (!response.ok) {
          throw new Error(payload.error || "Failed to load repositories");
        }

        const parsedRepos = parseRepositories(payload.repositories);
        setRepos(parsedRepos);
        if (parsedRepos.length === 0) {
          setError("No repositories found in this GitHub account.");
        }
      } catch (loadError) {
+       if (loadError instanceof DOMException && loadError.name === "AbortError") {
+         return;
+       }
        const message =
          loadError instanceof Error ? loadError.message : "Failed to load repositories";
        setError(message);
      } finally {
        setIsLoadingRepos(false);
      }
    };

    void loadRepositories();
+
+   return () => {
+     controller.abort();
+   };
  }, [open]);
src/modules/projects/ui/views/project-view.tsx (1)

133-135: Consider adding icon sizing class for consistency.

Per coding guidelines, icons should use size-4 (16px) as the default size. The other tab icons (EyeIcon, CodeIcon) also don't have explicit sizing, so this is consistent within the file, but adding explicit sizing would align with the guidelines.

♻️ Optional fix
  <TabsTrigger value="deploy" className="rounded-md">
-   <RocketIcon /> <span>Deploy</span>
+   <RocketIcon className="size-4" /> <span>Deploy</span>
  </TabsTrigger>

Consider applying the same to EyeIcon and CodeIcon for full consistency.

src/lib/payment-templates/svelte.ts (1)

243-279: CheckoutButton component lacks error feedback to the user.

If the checkout API returns an error or a response without a URL, the user receives no feedback—the button simply stops loading. Consider adding error state handling.

♻️ Suggested improvement
 <script lang="ts">
   export let productId: string;
   export let customerId: string;
   export let successUrl: string;
   export let cancelUrl: string;
   export let label = "Upgrade";

   let loading = false;
+  let error = "";

   const startCheckout = async () => {
     loading = true;
+    error = "";
     try {
       const response = await fetch("/api/billing/checkout", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }),
       });
       const data = (await response.json()) as { url?: string };
       if (data.url) {
         window.location.href = data.url;
+      } else {
+        error = "Failed to start checkout";
       }
+    } catch {
+      error = "An error occurred";
     } finally {
       loading = false;
     }
   };
 </script>
+
+{`#if` error}
+  <p class="text-red-600 text-sm">{error}</p>
+{/if}
convex/schema.ts (2)

184-189: Consider extracting inline status enum to a named constant.

The deployment status enum is defined inline. For consistency and reusability (similar to githubExportStatusEnum), consider extracting it:

♻️ Suggested refactor
+export const deploymentStatusEnum = v.union(
+  v.literal("pending"),
+  v.literal("building"),
+  v.literal("ready"),
+  v.literal("error")
+);
+
 // In deployments table definition:
-    status: v.union(
-      v.literal("pending"),
-      v.literal("building"),
-      v.literal("ready"),
-      v.literal("error")
-    ),
+    status: deploymentStatusEnum,

53-58: Inconsistent enum casing with existing enums.

The new githubExportStatusEnum uses lowercase values ("pending", "processing", etc.), while existing enums like importStatusEnum (lines 46-51) use uppercase ("PENDING", "PROCESSING", etc.). Consider aligning the casing for consistency.

This inconsistency may cause confusion when working with both import and export statuses in the same codebase.

#!/bin/bash
# Verify existing enum casing patterns in the schema
rg -n 'v\.literal\("' convex/schema.ts | head -40
src/lib/payment-templates/nextjs.ts (1)

315-369: Consider using Shadcn/ui components for generated Next.js templates.

Based on learnings, Next.js code generation should use Shadcn components. The current CheckoutButton uses plain HTML with Tailwind. While functional, using Shadcn's Button component would provide better accessibility, consistent styling, and align with the project's conventions.

This is a template that generates code for users, so using Shadcn would give them a better starting point.

src/app/api/projects/[projectId]/export/github/route.ts (2)

39-48: Authentication context may be missing for the GitHub token query.

fetchQuery at line 41 is called without authentication context, but getConvexClientWithAuth is only used later at line 79. If api.oauth.getGithubAccessToken requires authentication to identify the user, it may return incorrect results or fail.

Consider using the authenticated client consistently:

♻️ Suggested fix
     const { projectId } = await params;
     const body = exportRequestSchema.parse(await request.json());
-    const accessToken = await fetchQuery(api.oauth.getGithubAccessToken, {});
+    const convex = await getConvexClientWithAuth();
+    const accessToken = await convex.query(api.oauth.getGithubAccessToken, {});

Then reuse convex for the action call at line 80.


54-59: Redundant validation check.

This condition is unreachable. The schema's refine at line 25 already ensures that if repositoryFullName is absent, repositoryName must be present. Consider removing this dead code.

♻️ Suggested simplification
     let repository;
     if (body.repositoryFullName) {
       repository = await getRepository(accessToken, body.repositoryFullName);
     } else {
-      if (!body.repositoryName) {
-        return NextResponse.json(
-          { error: "Repository name is required." },
-          { status: 400 },
-        );
-      }
-
       const input: CreateRepositoryInput = {
-        name: body.repositoryName,
+        name: body.repositoryName!, // Guaranteed by schema refinement
         description: body.description,
         isPrivate: body.isPrivate ?? false,
       };
src/app/api/deploy/netlify/deploy/route.ts (1)

107-108: Consider adding explicit error handling for invalid site IDs.

If body.siteId is provided but doesn't exist or the user lacks access, netlifyClient.getSite will throw a generic error. Consider catching this specifically to provide a clearer error message like "Site not found or access denied."

src/lib/payment-provider.ts (2)

183-210: Add request timeout to prevent indefinite hangs.

The fetch call has no timeout. If the Autumn API is slow or unresponsive, requests will hang indefinitely. Consider using AbortController with a timeout.

♻️ Suggested improvement
   private async request<T>(
     path: string,
-    options: AutumnRequestOptions
+    options: AutumnRequestOptions,
+    timeoutMs = 30000
   ): Promise<T> {
     const url = `${this.baseUrl}${path}`;
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
-    const response = await fetch(url, {
-      ...options,
-      headers: {
-        "Content-Type": "application/json",
-        Authorization: `Bearer ${this.apiKey}`,
-        ...(options.headers ?? {}),
-      },
-      body: options.body ? JSON.stringify(options.body) : undefined,
-    });
+    try {
+      const response = await fetch(url, {
+        ...options,
+        signal: controller.signal,
+        headers: {
+          "Content-Type": "application/json",
+          Authorization: `Bearer ${this.apiKey}`,
+          ...(options.headers ?? {}),
+        },
+        body: options.body ? JSON.stringify(options.body) : undefined,
+      });
+      // ... rest of response handling
+    } finally {
+      clearTimeout(timeoutId);
+    }

205-207: Type safety concern with undefined as T cast.

Returning undefined as T can cause runtime issues if T doesn't include undefined. Currently, only trackUsage expects void, but if other methods receive an unexpected 204, they'll get undefined when expecting an object.

Consider making the return type explicit for 204-capable endpoints or using a separate method for void responses.

- Refactored deployment creation to utilize a project deployment counter for better tracking of deployment numbers.
- Updated OAuth token handling to include encryption for access and refresh tokens, improving security.
- Modified GitHub export functionality to check for connection existence without requiring an access token.
- Improved error handling and validation in various API routes, ensuring more robust interactions with external services.
- Enhanced UI components for deployment history and environment variable management, adding loading states and better user feedback.

These changes significantly improve the deployment process and security of OAuth tokens, while also enhancing user experience in the application.
@codecapyai
Copy link

codecapyai bot commented Jan 19, 2026

CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎

Codebase Summary

ZapDev is an AI-powered development platform that lets users generate, preview and deploy web applications in real time. The application supports code generation, live preview, file management, and now includes integrated support for deploying projects to Netlify along with payment integration templates for multiple frameworks (Angular, React, Next.js, Svelte, Vue) using Autumn and Stripe. It also provides GitHub export options directly from the project UI.

PR Changes

This pull request introduces Netlify deployment integration including OAuth flow, deployment API endpoints (create, update, status, logs, domains, environment variables, preview and rollback), and a new deployment dashboard UI. In addition, new payment integration templates for multiple frameworks have been added, updating the README and code generation prompts to reflect billing templates (Stripe via Autumn). GitHub export functionality (using OAuth and repository export endpoints) has also been added with new modals and controls.

Setup Instructions

  1. Install pnpm globally (if not already installed):
    sudo npm install -g pnpm
  2. Clone the repository and change into the repo directory.
  3. Install dependencies by running: pnpm install
  4. Start the development server with: pnpm dev
  5. Open your web browser and navigate to http://localhost:3000 to access the application.

Generated Test Cases

1: Netlify OAuth Redirection Flow ❗️❗️❗️

Description: Tests the Netlify OAuth flow from the UI by verifying that clicking the Connect Netlify button redirects the user to the correct Netlify authorization URL with proper state encoding.

Prerequisites:

  • User is logged in
  • Netlify environment variables (NETLIFY_CLIENT_ID, NETLIFY_CLIENT_SECRET) configured

Steps:

  1. Navigate to a project overview page.
  2. Click the 'Connect Netlify' button (or trigger the NetlifyConnectDialog) if no Netlify connection exists.
  3. Observe that the browser is redirected to Netlify's OAuth authorization URL.
  4. Verify that the URL contains the correct client_id, redirect_uri, response_type, and a properly encoded state parameter.

Expected Result: The user is redirected to Netlify's OAuth page with parameters corresponding to the configured environment variables and a valid state token.

2: Deployment Dashboard Status and Interaction ❗️❗️❗️

Description: Ensures that the new deployment dashboard accurately displays the current Netlify deployment status and provides appropriate interactions for preview deployments, viewing logs, and rollback actions.

Prerequisites:

  • User is logged in
  • A project exists with at least one deployment record (either in preview or production mode)
  • Netlify deployment connection established

Steps:

  1. Navigate to an existing project in the UI.
  2. Switch to the 'Deploy' tab in the project view.
  3. Verify that the deployment dashboard displays the current deployment status (e.g. 'pending', 'building', 'ready', or 'error').
  4. Check that the dashboard shows sections for 'Preview Deployments' and 'Deployment History' with appropriate actions (e.g., view logs, rollback).
  5. Click on the 'Logs' button for a deployment to open the build logs dialog and verify logs are displayed.
  6. Optionally, click the 'Rollback' button to trigger a rollback action and validate that a success message is displayed.

Expected Result: The deployment dashboard correctly shows the latest deployment status with interactive buttons that load logs and allow rollback. Any status changes are polled and updated in real time.

3: GitHub Export Modal Workflow ❗️❗️❗️

Description: Validates the GitHub export functionality by ensuring the export modal allows the user to create a new repository (or select an existing one), configure branch and commit details, and then successfully export the project with feedback.

Prerequisites:

  • User is logged in
  • GitHub OAuth connection is configured (GitHub connection exists)
  • An existing project with AI-generated files is present

Steps:

  1. Navigate to a project that has been generated.
  2. Click on the 'Export to GitHub' button in the project header.
  3. In the modal, select the option for creating a new repository.
  4. Enter a repository name (and optionally a description), choose whether it should be private, and fill in branch and commit message details.
  5. Submit the export request.
  6. Verify that the modal displays a success message along with details like repository full name, branch, commit SHA, and number of files exported.
  7. Confirm that a link is provided to open the exported repository on GitHub.

Expected Result: The export modal processes the input correctly, displays the export result details and provides a working link to the repository on GitHub. Any misconfigurations or errors are reported to the user.

4: Payment Template Inclusion in README and Prompts ❗️❗️

Description: Checks that the README and code generation prompts have been updated to include payment integration information. This ensures that users are informed about the new generated app billing templates via Autumn and Stripe.

Prerequisites:

  • None (can be done without any login)
  • Review the project documentation

Steps:

  1. Open the README.md file in the repository.
  2. Scroll to the section titled 'Generated App Payments'.
  3. Verify that the payment integration section describes Stripe via Autumn and includes instructions to configure payment environment variables.
  4. Also, open various prompt files (e.g., nextjs.ts, angular.ts, react.ts, vue.ts, svelte.ts) and confirm that the PAYMENT_INTEGRATION_RULES are appended to the shared rules.

Expected Result: The README and prompt files clearly indicate the new payment integration features, instructing users on how to use generated billing templates and configure payment-related environment variables.

5: Payment Templates API Tool Execution ❗️❗️

Description: Verifies the functionality of the payment template tool integrated into the agent tools. When invoked, it should return a JSON object containing the actual payment integration templates including autumnConfigTemplate and paymentEnvExample.

Prerequisites:

  • User is logged in
  • Access to the AI agent sandbox where tools can be executed

Steps:

  1. Invoke the 'paymentTemplates' tool (this could be via the agent chat, or through a testing interface that calls the tool with a parameter indicating a framework (e.g., 'react' or 'nextjs')).
  2. Inspect the returned JSON response.
  3. Verify that the response contains the following keys: framework, description, files, and includes properties autumnConfigTemplate and paymentEnvExample.
  4. Confirm that the output structure matches the expected template bundle for the selected framework.

Expected Result: The paymentTemplates tool returns a valid JSON response with payment integration template details that include both the Autumn configuration template and the example environment variable configuration for Autumn and Stripe.

Raw Changes Analyzed
File: README.md
Changes:
@@ -14,6 +14,7 @@ AI-powered development platform that lets you create web applications by chattin
 - 🔐 Authentication with Clerk
 - ⚙️ Background job processing with Inngest
 - 🗃️ Project management and persistence
+- 💰 Generated app billing templates (Stripe via Autumn)
 
 ## Tech Stack
 
@@ -200,6 +201,10 @@ npm run lint           # Run ESLint
 5. **File Management**: Users can browse generated files with syntax highlighting
 6. **Iteration**: Conversational development allows for refinements and additions
 
+## Generated App Payments
+
+ZapDev can generate payment-ready apps using Stripe through Autumn. Templates live in `src/lib/payment-templates/` and include checkout flows, billing portal endpoints, feature gates, and usage tracking helpers. Configure with environment variables from `paymentEnvExample` in the same folder.
+
 ---
 
 Created by [CodeWithAntonio](https://codewithantonio.com)

File: ROADMAP.md
Changes:
@@ -4,7 +4,7 @@
 
 ### Payments Integration
 
-**Status**: In Progress  
+**Status**: Finished
 **Priority**: High
 
 Currently, ZapDev uses Polar.sh for subscription billing. This roadmap item focuses on:
@@ -176,3 +176,52 @@ Allow users to choose their preferred database provider:
   - Cost optimization options
   - Regional data residency compliance
 
+
+### GitHub Export
+
+**Status**: Planned  
+**Priority**: High
+
+Enable users to export their generated projects directly to GitHub repositories for version control, collaboration, and deployment:
+
+- **Repository Creation**:
+  - One-click export to new GitHub repository
+  - Automatic repository initialization with generated code
+  - Support for public, private, and organization repositories
+  - Custom repository name and description
+  - Optional README generation with project details
+
+- **Export Features**:
+  - Full project structure export (all files and directories)
+  - Preserve file permissions and structure
+  - Include `.gitignore` and other configuration files
+  - Export project metadata and documentation
+  - Incremental updates to existing repositories
+
+- **GitHub Integration**:
+  - OAuth authentication with GitHub
+  - Secure token storage and management
+  - Support for GitHub App authentication
+  - Branch creation for project versions
+  - Commit history tracking
+
+- **Advanced Features**:
+  - Export to existing repositories (push to specific branch)
+  - Multiple repository export (fork to multiple locations)
+  - Automated initial commit with descriptive messages
+  - Tag creation for project versions
+  - GitHub Actions workflow templates inclusion
+
+- **User Experience**:
+  - Export progress indicator
+  - Error handling and retry logic
+  - Export history tracking
+  - Quick access to exported repositories
+  - One-click repository opening in GitHub
+
+- **Technical Implementation**:
+  - GitHub REST API integration
+  - File tree generation and upload
+  - Large file handling (GitHub LFS support)
+  - Rate limit management
+  - Background job processing for large exports

File: convex/_generated/api.d.ts
Changes:
@@ -10,6 +10,8 @@
 
 import type * as helpers from "../helpers.js";
 import type * as http from "../http.js";
+import type * as deployments from "../deployments.js";
+import type * as githubExports from "../githubExports.js";
 import type * as importData from "../importData.js";
 import type * as imports from "../imports.js";
 import type * as messages from "../messages.js";
@@ -31,6 +33,8 @@ import type {
 declare const fullApi: ApiFromModules<{
   helpers: typeof helpers;
   http: typeof http;
+  deployments: typeof deployments;
+  githubExports: typeof githubExports;
   importData: typeof importData;
   imports: typeof imports;
   messages: typeof messages;

File: convex/deployments.ts
Changes:
@@ -0,0 +1,190 @@
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+import { requireAuth } from "./helpers";
+
+const deploymentStatusEnum = v.union(
+  v.literal("pending"),
+  v.literal("building"),
+  v.literal("ready"),
+  v.literal("error")
+);
+
+export const createDeployment = mutation({
+  args: {
+    projectId: v.id("projects"),
+    platform: v.literal("netlify"),
+    siteId: v.string(),
+    siteUrl: v.string(),
+    deployId: v.optional(v.string()),
+    status: deploymentStatusEnum,
+    isPreview: v.optional(v.boolean()),
+    branch: v.optional(v.string()),
+    commitRef: v.optional(v.string()),
+  },
+  returns: v.id("deployments"),
+  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");
+    }
+
+    const counter = await ctx.db
+      .query("projectDeploymentCounters")
+      .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+      .first();
+
+    const now = Date.now();
+    let nextDeployNumber: number;
+
+    if (counter) {
+      nextDeployNumber = counter.deployNumber + 1;
+      await ctx.db.patch(counter._id, {
+        deployNumber: nextDeployNumber,
+        updatedAt: now,
+      });
+    } else {
+      nextDeployNumber = 1;
+      await ctx.db.insert("projectDeploymentCounters", {
+        projectId: args.projectId,
+        deployNumber: nextDeployNumber,
+        updatedAt: now,
+      });
+    }
+
+    return await ctx.db.insert("deployments", {
+      projectId: args.projectId,
+      userId,
+      platform: args.platform,
+      siteId: args.siteId,
+      siteUrl: args.siteUrl,
+      deployId: args.deployId,
+      deployNumber: nextDeployNumber,
+      commitRef: args.commitRef,
+      branch: args.branch,
+      isPreview: args.isPreview ?? false,
+      status: args.status,
+      createdAt: now,
+      updatedAt: now,
+    });
+  },
+});
+
+export const updateDeployment = mutation({
+  args: {
+    deploymentId: v.id("deployments"),
+    status: v.optional(deploymentStatusEnum),
+    deployId: v.optional(v.string()),
+    error: v.optional(v.string()),
+    buildLog: v.optional(v.string()),
+    buildTime: v.optional(v.number()),
+  },
+  returns: v.id("deployments"),
+  handler: async (ctx, args) => {
+    const userId = await requireAuth(ctx);
+
+    const deployment = await ctx.db.get(args.deploymentId);
+    if (!deployment || deployment.userId !== userId) {
+      throw new Error("Unauthorized");
+    }
+
+    await ctx.db.patch(args.deploymentId, {
+      ...(args.status !== undefined ? { status: args.status } : {}),
+      ...(args.deployId !== undefined ? { deployId: args.deployId } : {}),
+      ...(args.error !== undefined ? { error: args.error } : {}),
+      ...(args.buildLog !== undefined ? { buildLog: args.buildLog } : {}),
+      ...(args.buildTime !== undefined ? { buildTime: args.buildTime } : {}),
+      updatedAt: Date.now(),
+    });
+
+    return args.deploymentId;
+  },
+});
+
+export const getDeployment = query({
+  args: {
+    projectId: v.id("projects"),
+  },
+  returns: v.union(
+    v.null(),
+    v.object({
+      _id: v.id("deployments"),
+      _creationTime: v.number(),
+      projectId: v.id("projects"),
+      userId: v.string(),
+      platform: v.literal("netlify"),
+      siteId: v.string(),
+      siteUrl: v.string(),
+      deployId: v.optional(v.string()),
+      deployNumber: v.optional(v.number()),
+      commitRef: v.optional(v.string()),
+      branch: v.optional(v.string()),
+      isPreview: v.optional(v.boolean()),
+      buildLog: v.optional(v.string()),
+      buildTime: v.optional(v.number()),
+      previousDeployId: v.optional(v.id("deployments")),
+      status: deploymentStatusEnum,
+      error: v.optional(v.string()),
+      createdAt: v.number(),
+      updatedAt: v.number(),
+    })
+  ),
+  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");
+    }
+
+    return await ctx.db
+      .query("deployments")
+      .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+      .order("desc")
+      .first();
+  },
+});
+
+export const listDeployments = query({
+  args: {
+    projectId: v.id("projects"),
+  },
+  returns: v.array(
+    v.object({
+      _id: v.id("deployments"),
+      _creationTime: v.number(),
+      projectId: v.id("projects"),
+      userId: v.string(),
+      platform: v.literal("netlify"),
+      siteId: v.string(),
+      siteUrl: v.string(),
+      deployId: v.optional(v.string()),
+      deployNumber: v.optional(v.number()),
+      commitRef: v.optional(v.string()),
+      branch: v.optional(v.string()),
+      isPreview: v.optional(v.boolean()),
+      buildLog: v.optional(v.string()),
+      buildTime: v.optional(v.number()),
+      previousDeployId: v.optional(v.id("deployments")),
+      status: deploymentStatusEnum,
+      error: v.optional(v.string()),
+      createdAt: v.number(),
+      updatedAt: v.number(),
+    })
+  ),
+  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");
+    }
+
+    return await ctx.db
+      .query("deployments")
+      .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+      .order("desc")
+      .collect();
+  },
+});

File: convex/githubExports.ts
Changes:
@@ -0,0 +1,396 @@
+import { v } from "convex/values";
+import { action, mutation, query } from "./_generated/server";
+import { requireAuth } from "./helpers";
+import { githubExportStatusEnum } from "./schema";
+import { api } from "./_generated/api";
+import type { Doc, Id } from "./_generated/dataModel";
+import {
+  buildTreeEntries,
+  createBranchRef,
+  createCommit,
+  createTree,
+  getBranchRef,
+  getCommitTreeSha,
+  getRepository,
+  updateBranchRef,
+  withDefaultFiles,
+  type ProjectFramework,
+} from "../src/lib/github-api";
+import { filterFilesForDownload } from "../src/lib/filter-ai-files";
+
+const githubExportRecord = v.object({
+  _id: v.id("githubExports"),
+  _creationTime: v.number(),
+  projectId: v.id("projects"),
+  userId: v.string(),
+  repositoryName: v.string(),
+  repositoryUrl: v.string(),
+  repositoryFullName: v.string(),
+  branch: v.optional(v.string()),
+  commitSha: v.optional(v.string()),
+  status: githubExportStatusEnum,
+  error: v.optional(v.string()),
+  fileCount: v.optional(v.number()),
+  createdAt: v.number(),
+  updatedAt: v.number(),
+});
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+};
+
+const normalizeFiles = (value: unknown): Record<string, string> => {
+  if (!isRecord(value)) {
+    return {};
+  }
+
+  const files: Record<string, string> = {};
+  for (const [path, content] of Object.entries(value)) {
+    if (typeof content === "string") {
+      files[path] = content;
+    }
+  }
+
+  return files;
+};
+
+type MessageWithFragment = {
+  _id: Id<"messages">;
+  _creationTime: number;
+  Fragment: {
+    _id: Id<"fragments">;
+    files?: unknown;
+    framework: ProjectFramework;
+  } | null;
+};
+
+export const list = query({
+  args: {
+    projectId: v.id("projects"),
+  },
+  returns: v.array(githubExportRecord),
+  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");
+    }
+
+    return await ctx.db
+      .query("githubExports")
+      .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+      .order("desc")
+      .collect();
+  },
+});
+
+export const get = query({
+  args: {
+    exportId: v.id("githubExports"),
+  },
+  returns: githubExportRecord,
+  handler: async (ctx, args) => {
+    const userId = await requireAuth(ctx);
+    const exportRecord = await ctx.db.get(args.exportId);
+    if (!exportRecord) {
+      throw new Error("Export not found");
+    }
+    if (exportRecord.userId !== userId) {
+      throw new Error("Unauthorized");
+    }
+
+    return exportRecord;
+  },
+});
+
+export const getLatest = query({
+  args: {
+    projectId: v.id("projects"),
+  },
+  returns: v.union(githubExportRecord, v.null()),
+  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");
+    }
+
+    return await ctx.db
+      .query("githubExports")
+      .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+      .order("desc")
+      .first();
+  },
+});
+
+export const create = mutation({
+  args: {
+    projectId: v.id("projects"),
+    repositoryName: v.string(),
+    repositoryUrl: v.string(),
+    repositoryFullName: v.string(),
+    branch: v.optional(v.string()),
+  },
+  returns: v.id("githubExports"),
+  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");
+    }
+
+    const now = Date.now();
+    return await ctx.db.insert("githubExports", {
+      projectId: args.projectId,
+      userId,
+      repositoryName: args.repositoryName,
+      repositoryUrl: args.repositoryUrl,
+      repositoryFullName: args.repositoryFullName,
+      branch: args.branch,
+      status: "pending",
+      createdAt: now,
+      updatedAt: now,
+    });
+  },
+});
+
+export const updateStatus = mutation({
+  args: {
+    exportId: v.id("githubExports"),
+    status: githubExportStatusEnum,
+    error: v.optional(v.string()),
+  },
+  returns: v.id("githubExports"),
+  handler: async (ctx, args) => {
+    const userId = await requireAuth(ctx);
+    const exportRecord = await ctx.db.get(args.exportId);
+    if (!exportRecord) {
+      throw new Error("Export not found");
+    }
+    if (exportRecord.userId !== userId) {
+      throw new Error("Unauthorized");
+    }
+
+    await ctx.db.patch(args.exportId, {
+      status: args.status,
+      ...(args.error !== undefined && { error: args.error }),
+      updatedAt: Date.now(),
+    });
+
+    return args.exportId;
+  },
+});
+
+export const complete = mutation({
+  args: {
+    exportId: v.id("githubExports"),
+    commitSha: v.string(),
+    branch: v.string(),
+    fileCount: v.number(),
+  },
+  returns: v.id("githubExports"),
+  handler: async (ctx, args) => {
+    const userId = await requireAuth(ctx);
+    const exportRecord = await ctx.db.get(args.exportId);
+    if (!exportRecord) {
+      throw new Error("Export not found");
+    }
+    if (exportRecord.userId !== userId) {
+      throw new Error("Unauthorized");
+    }
+
+    await ctx.db.patch(args.exportId, {
+      commitSha: args.commitSha,
+      branch: args.branch,
+      fileCount: args.fileCount,
+      status: "complete",
+      updatedAt: Date.now(),
+    });
+
+    return args.exportId;
+  },
+});
+
+export const exportToGitHub = action({
+  args: {
+    exportId: v.id("githubExports"),
+    branch: v.optional(v.string()),
+    includeReadme: v.optional(v.boolean()),
+    includeGitignore: v.optional(v.boolean()),
+    commitMessage: v.optional(v.string()),
+  },
+  returns: v.object({
+    exportId: v.id("githubExports"),
+    repositoryUrl: v.string(),
+    repositoryFullName: v.string(),
+    branch: v.string(),
+    commitSha: v.string(),
+    fileCount: v.number(),
+  }),
+  handler: async (ctx, args) => {
+    const identity = await ctx.auth.getUserIdentity();
+    if (!identity?.subject) {
+      throw new Error("Unauthorized");
+    }
+
+    const exportRecord: Doc<"githubExports"> = await ctx.runQuery(
+      api.githubExports.get,
+      { exportId: args.exportId },
+    );
+
+    await ctx.runMutation(api.githubExports.updateStatus, {
+      exportId: args.exportId,
+      status: "processing",
+    });
+
+    try {
+      const project: Doc<"projects"> = await ctx.runQuery(api.projects.get, {
+        projectId: exportRecord.projectId,
+      });
+
+      const messages: Array<MessageWithFragment> = await ctx.runQuery(
+        api.messages.list,
+        { projectId: exportRecord.projectId },
+      );
+
+      const latestWithFragment = [...messages]
+        .reverse()
+        .find((message) => message.Fragment);
+
+      const fragment = latestWithFragment?.Fragment;
+      if (!fragment) {
+        throw new Error("No AI-generated files are ready to export.");
+      }
+
+      const normalized = normalizeFiles(fragment.files);
+      const filtered = filterFilesForDownload(normalized);
+      if (Object.keys(filtered).length === 0) {
+        throw new Error("No AI-generated files are ready to export.");
+      }
+
+      const includeReadme = args.includeReadme ?? true;
+      const includeGitignore = args.includeGitignore ?? true;
+      const files = withDefaultFiles(
+        filtered,
+        {
+          projectName: project.name,
+          framework: fragment.framework,
+        },
+        includeReadme,
+        includeGitignore,
+      );
+
+      const treeEntries = buildTreeEntries(files);
+import { internal } from "./_generated/api";
+// ...
+      const accessToken = await ctx.runQuery(internal.oauth.getGithubAccessToken, {
+        userId: identity.subject,
+      });
+      if (!accessToken) {
+        throw new Error("GitHub connection not found. Please connect GitHub.");
+      }
+
+      const repository = await getRepository(
+        accessToken,
+        exportRecord.repositoryFullName,
+      );
+      const defaultBranch = repository.default_branch ?? "main";
+      const targetBranch = args.branch ?? exportRecord.branch ?? defaultBranch;
+
+      let baseCommitSha: string | null = null;
+      let baseTreeSha: string | undefined;
+      let needsCreateBranch = false;
+
+      try {
+        baseCommitSha = await getBranchRef(
+          accessToken,
+          repository.full_name,
+          targetBranch,
+        );
+        baseTreeSha = await getCommitTreeSha(
+          accessToken,
+          repository.full_name,
+          baseCommitSha,
+        );
+      } catch (error) {
+        const message = error instanceof Error ? error.message : "GitHub error";
+        if (
+          targetBranch !== defaultBranch &&
+          message.toLowerCase().includes("not found")
+        ) {
+          baseCommitSha = await getBranchRef(
+            accessToken,
+            repository.full_name,
+            defaultBranch,
+          );
+          baseTreeSha = await getCommitTreeSha(
+            accessToken,
+            repository.full_name,
+            baseCommitSha,
+          );
+          needsCreateBranch = true;
+        } else {
+          throw error;
+        }
+      }
+
+      if (!baseCommitSha) {
+        throw new Error("Unable to resolve base branch for export.");
+      }
+
+      const treeSha = await createTree(
+        accessToken,
+        repository.full_name,
+        treeEntries,
+        baseTreeSha,
+      );
+      const commitSha = await createCommit(
+        accessToken,
+        repository.full_name,
+        args.commitMessage ?? "Export project from ZapDev",
+        treeSha,
+        baseCommitSha ? [baseCommitSha] : [],
+      );
+
+      if (needsCreateBranch) {
+        await createBranchRef(
+          accessToken,
+          repository.full_name,
+          targetBranch,
+          commitSha,
+        );
+      } else {
+        await updateBranchRef(
+          accessToken,
+          repository.full_name,
+          targetBranch,
+          commitSha,
+        );
+      }
+
+      await ctx.runMutation(api.githubExports.complete, {
+        exportId: args.exportId,
+        commitSha,
+        branch: targetBranch,
+        fileCount: treeEntries.length,
+      });
+
+      return {
+        exportId: args.exportId,
+        repositoryUrl: exportRecord.repositoryUrl,
+        repositoryFullName: exportRecord.repositoryFullName,
+        branch: targetBranch,
+        commitSha,
+        fileCount: treeEntries.length,
+      };
+    } catch (error) {
+      const message = error instanceof Error ? error.message : "Export failed";
+      await ctx.runMutation(api.githubExports.updateStatus, {
+        exportId: args.exportId,
+        status: "failed",
+        error: message,
+      });
+      throw error;
+    }
+  },
+});

File: convex/oauth.ts
Changes:
@@ -1,7 +1,31 @@
-import { mutation, query } from "./_generated/server";
+import { mutation, query, internalQuery } from "./_generated/server";
 import { v } from "convex/values";
 import { oauthProviderEnum } from "./schema";
 import { requireAuth } from "./helpers";
+import crypto from "crypto";
+
+const ENCRYPTION_KEY = process.env.OAUTH_ENCRYPTION_KEY || "fallback-key-change-me-in-production";
+const ALGORITHM = "aes-256-gcm";
+
+function encryptToken(token: string): string {
+  const iv = crypto.randomBytes(16);
+  const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY.slice(0, 32), "utf8"), iv);
+  let encrypted = cipher.update(token, "utf8", "hex");
+  encrypted += cipher.final("hex");
+  const authTag = cipher.getAuthTag();
+  return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
+}
+
+function decryptToken(encryptedToken: string): string {
+  const [ivHex, authTagHex, encrypted] = encryptedToken.split(":");
+  const iv = Buffer.from(ivHex, "hex");
+  const authTag = Buffer.from(authTagHex, "hex");
+  const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY.slice(0, 32), "utf8"), iv);
+  decipher.setAuthTag(authTag);
+  let decrypted = decipher.update(encrypted, "hex", "utf8");
+  decrypted += decipher.final("utf8");
+  return decrypted;
+}
 
 // Store OAuth connection
 export const storeConnection = mutation({
@@ -26,11 +50,14 @@ export const storeConnection = mutation({
 
     const now = Date.now();
 
+    const encryptedAccessToken = encryptToken(args.accessToken);
+    const encryptedRefreshToken = args.refreshToken ? encryptToken(args.refreshToken) : undefined;
+
     if (existing) {
       // Update existing connection
       return await ctx.db.patch(existing._id, {
-        accessToken: args.accessToken,
-        refreshToken: args.refreshToken || existing.refreshToken,
+        accessToken: encryptedAccessToken,
+        refreshToken: encryptedRefreshToken || existing.refreshToken,
         expiresAt: args.expiresAt,
         scope: args.scope,
         metadata: args.metadata || existing.metadata,
@@ -42,8 +69,8 @@ export const storeConnection = mutation({
     return await ctx.db.insert("oauthConnections", {
       userId,
       provider: args.provider,
-      accessToken: args.accessToken,
-      refreshToken: args.refreshToken,
+      accessToken: encryptedAccessToken,
+      refreshToken: encryptedRefreshToken,
       expiresAt: args.expiresAt,
       scope: args.scope,
       metadata: args.metadata,
@@ -70,6 +97,28 @@ export const getConnection = query({
   },
 });
 
+export const getGithubAccessToken = internalQuery({
+  args: { userId: v.string() },
+  handler: async (ctx, args) => {
+    const connection = await ctx.db
+      .query("oauthConnections")
+      .withIndex("by_userId_provider", (q) =>
+        q.eq("userId", args.userId).eq("provider", "github"),
+      )
+      .first();
+
+    if (!connection?.accessToken) {
+      return null;
+    }
+
+    try {
+      return decryptToken(connection.accessToken);
+    } catch {
+      return null;
+    }
+  },
+});
+
 // List all OAuth connections for user
 export const listConnections = query({
   handler: async (ctx) => {

File: convex/schema.ts
Changes:
@@ -39,7 +39,8 @@ export const importSourceEnum = v.union(
 
 export const oauthProviderEnum = v.union(
   v.literal("figma"),
-  v.literal("github")
+  v.literal("github"),
+  v.literal("netlify")
 );
 
 export const importStatusEnum = v.union(
@@ -49,6 +50,13 @@ export const importStatusEnum = v.union(
   v.literal("FAILED")
 );
 
+export const githubExportStatusEnum = v.union(
+  v.literal("pending"),
+  v.literal("processing"),
+  v.literal("complete"),
+  v.literal("failed")
+);
+
 export const sandboxStateEnum = v.union(
   v.literal("RUNNING"),
   v.literal("PAUSED"),
@@ -159,6 +167,35 @@ export default defineSchema({
     .index("by_userId", ["userId"])
     .index("by_userId_provider", ["userId", "provider"]),
 
+  deployments: defineTable({
+    projectId: v.id("projects"),
+    userId: v.string(),
+    platform: v.literal("netlify"),
+    siteId: v.string(),
+    siteUrl: v.string(),
+    deployId: v.optional(v.string()),
+    deployNumber: v.optional(v.number()),
+    commitRef: v.optional(v.string()),
+    branch: v.optional(v.string()),
+    isPreview: v.optional(v.boolean()),
+    buildLog: v.optional(v.string()),
+    buildTime: v.optional(v.number()),
+    previousDeployId: v.optional(v.id("deployments")),
+    status: v.union(
+      v.literal("pending"),
+      v.literal("building"),
+      v.literal("ready"),
+      v.literal("error")
+    ),
+    error: v.optional(v.string()),
+    createdAt: v.number(),
+    updatedAt: v.number(),
+  })
+    .index("by_projectId", ["projectId"])
+    .index("by_projectId_deployNumber", ["projectId", "deployNumber"])
+    .index("by_userId", ["userId"])
+    .index("by_siteId", ["siteId"]),
+
   imports: defineTable({
     userId: v.string(),
     projectId: v.id("projects"),
@@ -177,6 +214,24 @@ export default defineSchema({
     .index("by_projectId", ["projectId"])
     .index("by_status", ["status"]),
 
+  githubExports: defineTable({
+    projectId: v.id("projects"),
+    userId: v.string(),
+    repositoryName: v.string(),
+    repositoryUrl: v.string(),
+    repositoryFullName: v.string(),
+    branch: v.optional(v.string()),
+    commitSha: v.optional(v.string()),
+    status: githubExportStatusEnum,
+    error: v.optional(v.string()),
+    fileCount: v.optional(v.number()),
+    createdAt: v.number(),
+    updatedAt: v.number(),
+  })
+    .index("by_projectId", ["projectId"])
+    .index("by_userId", ["userId"])
+    .index("by_status", ["status"]),
+
   usage: defineTable({
     userId: v.string(),
     points: v.number(),
@@ -266,4 +321,11 @@ export default defineSchema({
     .index("by_userId", ["userId"])
     .index("by_state", ["state"])
     .index("by_sandboxId", ["sandboxId"]),
+
+  projectDeploymentCounters: defineTable({
+    projectId: v.id("projects"),
+    deployNumber: v.number(),
+    updatedAt: v.number(),
+  })
+    .index("by_projectId", ["projectId"]),
 });

File: env.example
Changes:
@@ -27,6 +27,10 @@ CEREBRAS_API_KEY=""  # Get from https://cloud.cerebras.ai
 # Vercel AI Gateway (fallback for Cerebras rate limits)
 VERCEL_AI_GATEWAY_API_KEY=""  # Get from https://vercel.com/dashboard/ai-gateway
 
+# Netlify Deployment
+NETLIFY_CLIENT_ID=""
+NETLIFY_CLIENT_SECRET=""
+
 # Brave Search API (web search for subagent research - optional)
 BRAVE_SEARCH_API_KEY=""  # Get from https://api-dashboard.search.brave.com/app/keys
 

File: src/agents/tools.ts
Changes:
@@ -1,6 +1,11 @@
 import { tool } from "ai";
 import { z } from "zod";
 import { getSandbox, writeFilesBatch, readFileFast } from "./sandbox-utils";
+import {
+  autumnConfigTemplate,
+  getPaymentTemplate,
+  paymentEnvExample,
+} from "@/lib/payment-templates";
 import type { AgentState } from "./types";
 
 export interface ToolContext {
@@ -138,5 +143,20 @@ export function createAgentTools(context: ToolContext) {
         }
       },
     }),
+    paymentTemplates: tool({
+      description:
+        "Get Stripe + Autumn payment integration templates for a framework",
+      inputSchema: z.object({
+        framework: z.enum(["nextjs", "react", "vue", "angular", "svelte"]),
+      }),
+      execute: async ({ framework }) => {
+        const template = getPaymentTemplate(framework);
+        return JSON.stringify({
+          ...template,
+          autumnConfigTemplate,
+          paymentEnvExample,
+        });
+      },
+    }),
   };
 }

File: src/app/api/deploy/netlify/auth/route.ts
Changes:
@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+import { getUser } from "@/lib/auth-server";
+import crypto from "crypto";
+
+const NETLIFY_CLIENT_ID = process.env.NETLIFY_CLIENT_ID;
+const NETLIFY_OAUTH_STATE_SECRET = process.env.NETLIFY_OAUTH_STATE_SECRET || "fallback-secret-change-me";
+const NETLIFY_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/deploy/netlify/callback`;
+
+export async function GET() {
+  const user = await getUser();
+  if (!user?.id) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  if (!NETLIFY_CLIENT_ID) {
+    return NextResponse.json(
+      { error: "Netlify OAuth not configured" },
+      { status: 500 }
+    );
+  }
+
+  const payload = JSON.stringify({ userId: user.id, timestamp: Date.now() });
+  const signature = crypto
+    .createHmac("sha256", NETLIFY_OAUTH_STATE_SECRET)
+    .update(payload)
+    .digest("hex");
+
+  const state = Buffer.from(
+    JSON.stringify({ payload, signature })
+  ).toString("base64");
+
+  const params = new URLSearchParams({
+    client_id: NETLIFY_CLIENT_ID,
+    redirect_uri: NETLIFY_REDIRECT_URI,
+    response_type: "code",
+    state,
+  });
+
+  const netlifyAuthUrl = `https://app.netlify.com/authorize?${params.toString()}`;
+  return NextResponse.redirect(netlifyAuthUrl);
+}

File: src/app/api/deploy/netlify/callback/route.ts
Changes:
@@ -0,0 +1,177 @@
+import { NextResponse } from "next/server";
+import { getUser } from "@/lib/auth-server";
+import { fetchMutation } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import crypto from "crypto";
+
+const NETLIFY_CLIENT_ID = process.env.NETLIFY_CLIENT_ID;
+const NETLIFY_CLIENT_SECRET = process.env.NETLIFY_CLIENT_SECRET;
+const NETLIFY_OAUTH_STATE_SECRET = process.env.NETLIFY_OAUTH_STATE_SECRET || "fallback-secret-change-me";
+const NETLIFY_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/deploy/netlify/callback`;
+const STATE_TTL_MS = 10 * 60 * 1000;
+
+type NetlifyTokenResponse = {
+  access_token?: string;
+  token_type?: string;
+  scope?: string;
+};
+
+type NetlifyUserResponse = {
+  id?: string;
+  email?: string;
+  full_name?: string;
+  avatar_url?: string;
+};
+
+const parseTokenResponse = (value: unknown): NetlifyTokenResponse => {
+  if (!value || typeof value !== "object") {
+    return {};
+  }
+
+  const record = value as Record<string, unknown>;
+  return {
+    access_token: typeof record.access_token === "string" ? record.access_token : undefined,
+    token_type: typeof record.token_type === "string" ? record.token_type : undefined,
+    scope: typeof record.scope === "string" ? record.scope : undefined,
+  };
+};
+
+const parseUserResponse = (value: unknown): NetlifyUserResponse => {
+  if (!value || typeof value !== "object") {
+    return {};
+  }
+
+  const record = value as Record<string, unknown>;
+  return {
+    id: typeof record.id === "string" ? record.id : undefined,
+    email: typeof record.email === "string" ? record.email : undefined,
+    full_name: typeof record.full_name === "string" ? record.full_name : undefined,
+    avatar_url: typeof record.avatar_url === "string" ? record.avatar_url : undefined,
+  };
+};
+
+export async function GET(request: Request) {
+  const user = await getUser();
+  if (!user?.id) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const { searchParams } = new URL(request.url);
+  const code = searchParams.get("code");
+  const state = searchParams.get("state");
+  const error = searchParams.get("error");
+
+  if (error) {
+    return NextResponse.redirect(
+      new URL(`/projects?error=${encodeURIComponent(error)}`, request.url)
+    );
+  }
+
+  if (!code || !state) {
+    return NextResponse.redirect(
+      new URL("/projects?error=Missing+authorization+code", request.url)
+    );
+  }
+
+  if (!NETLIFY_CLIENT_ID || !NETLIFY_CLIENT_SECRET) {
+    return NextResponse.json(
+      { error: "Netlify OAuth not configured" },
+      { status: 500 }
+    );
+  }
+
+  try {
+    const decodedStateStr = Buffer.from(state, "base64").toString();
+    let decodedState: { payload?: string; signature?: string };
+    try {
+      decodedState = JSON.parse(decodedStateStr);
+    } catch {
+      throw new Error("Invalid state token format");
+    }
+
+    if (!decodedState.payload || !decodedState.signature) {
+      throw new Error("Invalid state token structure");
+    }
+
+    const expectedSignature = crypto
+      .createHmac("sha256", NETLIFY_OAUTH_STATE_SECRET)
+      .update(decodedState.payload)
+      .digest("hex");
+
+    if (!crypto.timingSafeEqual(
+      Buffer.from(decodedState.signature),
+      Buffer.from(expectedSignature)
+    )) {
+      throw new Error("State token signature mismatch");
+    }
+
+    const payload = JSON.parse(decodedState.payload) as { userId?: string; timestamp?: number };
+    if (!payload.userId || !payload.timestamp) {
+      throw new Error("Invalid state token payload");
+    }
+
+    if (payload.userId !== user.id) {
+      throw new Error("State token user mismatch");
+    }
+
+    const age = Date.now() - payload.timestamp;
+    if (age > STATE_TTL_MS || age < 0) {
+      throw new Error("State token expired");
+    }
+
+    const tokenParams = new URLSearchParams({
+      grant_type: "authorization_code",
+      client_id: NETLIFY_CLIENT_ID,
+      client_secret: NETLIFY_CLIENT_SECRET,
+      redirect_uri: NETLIFY_REDIRECT_URI,
+      code,
+    });
+
+    const tokenResponse = await fetch("https://api.netlify.com/oauth/token", {
+      method: "POST",
+      headers: { "Content-Type": "application/x-www-form-urlencoded" },
+      body: tokenParams.toString(),
+    });
+
+    if (!tokenResponse.ok) {
+      const errorText = await tokenResponse.text();
+      throw new Error(errorText || "Failed to exchange authorization code");
+    }
+
+    const tokenData = parseTokenResponse(await tokenResponse.json());
+    if (!tokenData.access_token) {
+      throw new Error("Missing Netlify access token");
+    }
+
+    const userResponse = await fetch("https://api.netlify.com/api/v1/user", {
+      headers: {
+        Authorization: `Bearer ${tokenData.access_token}`,
+      },
+    });
+
+    const userData = userResponse.ok
+      ? parseUserResponse(await userResponse.json())
+      : {};
+
+    await fetchMutation(api.oauth.storeConnection, {
+      provider: "netlify",
+      accessToken: tokenData.access_token,
+      scope: tokenData.scope || tokenData.token_type || "netlify",
+      metadata: {
+        netlifyId: userData.id,
+        netlifyEmail: userData.email,
+        netlifyName: userData.full_name,
+        netlifyAvatarUrl: userData.avatar_url,
+      },
+    });
+
+    return NextResponse.redirect(
+      new URL("/projects?netlify=connected", request.url)
+    );
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "OAuth failed";
+    return NextResponse.redirect(
+      new URL(`/projects?error=${encodeURIComponent(message)}`, request.url)
+    );
+  }
+}

File: src/app/api/deploy/netlify/deploy/route.ts
Changes:
@@ -0,0 +1,160 @@
+import JSZip from "jszip";
+import { NextResponse } from "next/server";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { getUser, getConvexClientWithAuth, getToken } from "@/lib/auth-server";
+import { filterFilesForDownload } from "@/lib/filter-ai-files";
+import { getNetlifyToml } from "@/lib/netlify-config";
+import { createNetlifyClient } from "@/lib/netlify-client";
+import { z } from "zod";
+
+const deployRequestSchema = z.object({
+  projectId: z.string(),
+  siteId: z.string().optional(),
+  deployType: z.enum(["preview", "production"]).optional(),
+  branch: z.string().optional(),
+  commitRef: z.string().optional(),
+});
+
+type DeployRequest = z.infer<typeof deployRequestSchema>;
+
+function normalizeDeploymentStatus(status: string): "pending" | "building" | "ready" | "error" {
+  const normalized = status.toLowerCase();
+  if (normalized === "idle" || normalized === "created") {
+    return "pending";
+  }
+  if (normalized === "building") {
+    return "building";
+  }
+  if (normalized === "ready" || normalized === "published") {
+    return "ready";
+  }
+  return "error";
+}
+
+type MessageWithFragment = {
+  _id: Id<"messages">;
+  _creationTime: number;
+  Fragment: {
+    _id: Id<"fragments">;
+    files?: unknown;
+    framework: "NEXTJS" | "REACT" | "VUE" | "ANGULAR" | "SVELTE";
+  } | null;
+};
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const normalizeFiles = (value: unknown): Record<string, string> => {
+  if (!value || typeof value !== "object") {
+    return {};
+  }
+
+  const files: Record<string, string> = {};
+  for (const [path, content] of Object.entries(value)) {
+    if (typeof content === "string") {
+      files[path] = content;
+    }
+  }
+  return files;
+};
+
+const getLatestFragmentFiles = async (projectId: Id<"projects">, token?: string) => {
+  const messages = await fetchQuery(api.messages.list, { projectId }, { token }) as MessageWithFragment[];
+  const latestWithFragment = [...messages].reverse().find((message) => message.Fragment);
+  const fragment = latestWithFragment?.Fragment;
+
+  if (!fragment) {
+    throw new Error("No AI-generated files are ready to deploy.");
+  }
+
+  const normalized = normalizeFiles(fragment.files);
+  const filtered = filterFilesForDownload(normalized);
+
+  if (Object.keys(filtered).length === 0) {
+    throw new Error("No AI-generated files are ready to deploy.");
+  }
+
+  return { files: filtered, framework: fragment.framework };
+};
+
+const getNetlifyAccessToken = async (token?: string): Promise<string> => {
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }, { token }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found. Please connect your Netlify account.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function POST(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const token = (await getToken()) ?? undefined;
+    const bodyUnknown = await request.json();
+    const parseResult = deployRequestSchema.safeParse(bodyUnknown);
+    if (!parseResult.success) {
+      return NextResponse.json(
+        { error: "Invalid request body", details: parseResult.error.errors },
+        { status: 400 }
+      );
+    }
+    const body = parseResult.data;
+
+    const projectId = body.projectId as Id<"projects">;
+    const convex = await getConvexClientWithAuth();
+    const project = await convex.query(api.projects.get, { projectId });
+
+    const { files, framework } = await getLatestFragmentFiles(projectId, token);
+    const netlifyToml = getNetlifyToml(framework);
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken(token));
+
+    const zip = new JSZip();
+    for (const [filename, content] of Object.entries(files)) {
+      zip.file(filename, content);
+    }
+    zip.file("netlify.toml", netlifyToml);
+
+    const archive = await zip.generateAsync({ type: "arraybuffer" });
+    const archiveBlob = new Blob([archive], { type: "application/zip" });
+
+    const site =
+      body.siteId ? await netlifyClient.getSite(body.siteId) : await netlifyClient.createSite(project.name);
+
+    const deploy =
+      body.deployType === "preview"
+        ? await netlifyClient.createPreviewDeployment(site.id, archiveBlob)
+        : await netlifyClient.deploySite(site.id, archiveBlob);
+
+    await fetchMutation(api.deployments.createDeployment, {
+      projectId,
+      platform: "netlify",
+      siteId: site.id,
+      siteUrl: site.site_url || site.url,
+      deployId: deploy.id,
+      status: normalizeDeploymentStatus(deploy.state || "pending"),
+      isPreview: body.deployType === "preview",
+      branch: body.branch,
+      commitRef: body.commitRef,
+    });
+
+    return NextResponse.json({
+      siteId: site.id,
+      siteUrl: site.site_url || site.url,
+      deployId: deploy.id,
+      deployState: deploy.state,
+    });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Deployment failed";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/domains/route.ts
Changes:
@@ -0,0 +1,101 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+type DomainPayload = {
+  siteId: string;
+  domain: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const token = await getToken();
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const siteId = searchParams.get("siteId");
+    const domainId = searchParams.get("domainId");
+    if (!siteId) {
+      return NextResponse.json({ error: "Missing siteId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    if (domainId) {
+      const domain = await netlifyClient.verifyDomain(siteId, domainId);
+      return NextResponse.json(domain);
+    }
+
+    const domains = await netlifyClient.listDomains(siteId);
+    return NextResponse.json(domains);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch domains";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function POST(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = (await request.json()) as DomainPayload;
+    if (!body.siteId || !body.domain) {
+      return NextResponse.json({ error: "Missing siteId or domain" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const domain = await netlifyClient.addDomain(body.siteId, body.domain);
+
+    return NextResponse.json(domain);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to add domain";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function DELETE(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const siteId = searchParams.get("siteId");
+    const domainId = searchParams.get("domainId");
+    if (!siteId || !domainId) {
+      return NextResponse.json({ error: "Missing siteId or domainId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    await netlifyClient.deleteDomain(siteId, domainId);
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to delete domain";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/env-vars/route.ts
Changes:
@@ -0,0 +1,135 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+type EnvVarPayload = {
+  siteId: string;
+  key: string;
+  value?: string;
+  context?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const token = await getToken();
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const siteId = searchParams.get("siteId");
+    if (!siteId) {
+      return NextResponse.json({ error: "Missing siteId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const envVars = await netlifyClient.getEnvVars(siteId);
+
+    const sanitizedEnvVars = Array.isArray(envVars) ? envVars.map((envVar) => {
+      const { values, ...rest } = envVar as { values?: unknown; [key: string]: unknown };
+      return rest;
+    }) : [];
+
+    return NextResponse.json(sanitizedEnvVars);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch env vars";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function POST(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = (await request.json()) as EnvVarPayload;
+    if (!body.siteId || !body.key || body.value === undefined) {
+      return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const envVar = await netlifyClient.setEnvVar(
+      body.siteId,
+      body.key,
+      body.value,
+      body.context
+    );
+
+    return NextResponse.json(envVar);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to set env var";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function PUT(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = (await request.json()) as EnvVarPayload;
+    if (!body.siteId || !body.key || body.value === undefined) {
+      return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const envVar = await netlifyClient.updateEnvVar(
+      body.siteId,
+      body.key,
+      body.value,
+      body.context
+    );
+
+    return NextResponse.json(envVar);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to update env var";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function DELETE(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const siteId = searchParams.get("siteId");
+    const key = searchParams.get("key");
+    if (!siteId || !key) {
+      return NextResponse.json({ error: "Missing siteId or key" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    await netlifyClient.deleteEnvVar(siteId, key);
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to delete env var";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/logs/route.ts
Changes:
@@ -0,0 +1,45 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const token = await getToken();
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const deployId = searchParams.get("deployId");
+    if (!deployId) {
+      return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const logs = await netlifyClient.getBuildLog(deployId);
+
+    return NextResponse.json({ logs });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch logs";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/preview/route.ts
Changes:
@@ -0,0 +1,45 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const token = await getToken();
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function DELETE(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const deployId = searchParams.get("deployId");
+    if (!deployId) {
+      return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    await netlifyClient.deletePreviewDeployment(deployId);
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to delete preview";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/rollback/route.ts
Changes:
@@ -0,0 +1,48 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+type RollbackPayload = {
+  deployId: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const token = await getToken();
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function POST(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = (await request.json()) as RollbackPayload;
+    if (!body.deployId) {
+      return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const rollback = await netlifyClient.rollbackDeployment(body.deployId);
+
+    return NextResponse.json(rollback);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Rollback failed";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/sites/route.ts
Changes:
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const token = await getToken();
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET() {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const sites = await netlifyClient.listSites();
+
+    return NextResponse.json(sites);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch sites";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/deploy/netlify/status/route.ts
Changes:
@@ -0,0 +1,45 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+  accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+  const token = await getToken();
+  const connection = await fetchQuery(api.oauth.getConnection, {
+    provider: "netlify",
+  }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+  if (!connection?.accessToken) {
+    throw new Error("Netlify connection not found.");
+  }
+
+  return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { searchParams } = new URL(request.url);
+    const deployId = searchParams.get("deployId");
+    if (!deployId) {
+      return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+    }
+
+    const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+    const status = await netlifyClient.getDeploymentStatus(deployId);
+
+    return NextResponse.json(status);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to fetch status";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/github/repositories/route.ts
Changes:
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { listRepositories } from "@/lib/github-api";
+
+export async function GET() {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const accessToken = await fetchQuery(api.oauth.getGithubAccessToken, {});
+    if (!accessToken) {
+      return NextResponse.json(
+        { error: "GitHub connection not found. Please connect GitHub." },
+        { status: 400 },
+      );
+    }
+
+    const repositories = await listRepositories(accessToken);
+
+    return NextResponse.json({
+      repositories: repositories.map((repo) => ({
+        id: repo.id,
+        name: repo.name,
+        fullName: repo.full_name,
+        url: repo.html_url,
+        isPrivate: repo.private,
+        defaultBranch: repo.default_branch ?? "main",
+      })),
+    });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to load repositories";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/app/api/projects/[projectId]/export/github/route.ts
Changes:
@@ -0,0 +1,129 @@
+import { NextResponse } from "next/server";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+import { z } from "zod";
+
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import { getConvexClientWithAuth, getUser } from "@/lib/auth-server";
+import {
+  createRepository,
+  getRepository,
+  type CreateRepositoryInput,
+} from "@/lib/github-api";
+
+const exportRequestSchema = z
+  .object({
+    repositoryName: z.string().trim().min(1).optional(),
+    repositoryFullName: z.string().trim().min(1).optional(),
+    description: z.string().trim().optional(),
+    isPrivate: z.boolean().optional(),
+    branch: z.string().trim().optional(),
+    includeReadme: z.boolean().optional(),
+    includeGitignore: z.boolean().optional(),
+    commitMessage: z.string().trim().optional(),
+  })
+  .refine((data) => data.repositoryFullName || data.repositoryName, {
+    message: "Repository name is required.",
+  });
+
+export async function POST(
+  request: Request,
+  { params }: { params: Promise<{ projectId: string }> },
+) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { projectId } = await params;
+    const body = exportRequestSchema.parse(await request.json());
+    
+    // We don't need the access token here anymore since the export action handles it
+    // But we still need to check if the connection exists
+    const connection = await fetchQuery(api.oauth.getConnection, { provider: "github" }, { token: (await getToken()) ?? undefined });
+
+    if (!connection) {
+      return NextResponse.json(
+        { error: "GitHub connection not found. Please connect GitHub." },
+        { status: 400 },
+      );
+    }
+
+    let repository;
+    if (body.repositoryFullName) {
+      repository = await getRepository(accessToken, body.repositoryFullName);
+    } else {
+      if (!body.repositoryName) {
+        return NextResponse.json(
+          { error: "Repository name is required." },
+          { status: 400 },
+        );
+      }
+
+      const input: CreateRepositoryInput = {
+        name: body.repositoryName,
+        description: body.description,
+        isPrivate: body.isPrivate ?? false,
+      };
+      repository = await createRepository(accessToken, input);
+    }
+
+    const branch = body.branch ?? repository.default_branch ?? "main";
+
+    const exportId = await fetchMutation(api.githubExports.create, {
+      projectId: projectId as Id<"projects">,
+      repositoryName: repository.name,
+      repositoryUrl: repository.html_url,
+      repositoryFullName: repository.full_name,
+      branch,
+    });
+
+    const convex = await getConvexClientWithAuth();
+    const result = await convex.action(api.githubExports.exportToGitHub, {
+      exportId,
+      branch,
+      includeReadme: body.includeReadme,
+      includeGitignore: body.includeGitignore,
+      commitMessage: body.commitMessage,
+    });
+
+    return NextResponse.json(result);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Export failed";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}
+
+export async function GET(
+  request: Request,
+  { params }: { params: Promise<{ projectId: string }> },
+) {
+  try {
+    const user = await getUser();
+    if (!user?.id) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { projectId } = await params;
+    const { searchParams } = new URL(request.url);
+    const exportId = searchParams.get("exportId");
+
+    if (!exportId) {
+      return NextResponse.json({ error: "Missing exportId" }, { status: 400 });
+    }
+
+    const record = await fetchQuery(api.githubExports.get, {
+      exportId: exportId as Id<"githubExports">,
+    });
+
+    if (!record) {
+      return NextResponse.json({ error: "Export not found" }, { status: 404 });
+    }
+
+    return NextResponse.json(record);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to load export";
+    return NextResponse.json({ error: message }, { status: 500 });
+  }
+}

File: src/lib/github-api.ts
Changes:
@@ -0,0 +1,350 @@
+import { z } from "zod";
+
+const GITHUB_API_BASE_URL = "https://api.github.com";
+const GITHUB_API_VERSION = "2022-11-28";
+const MAX_TREE_CONTENT_BYTES = 100000;
+
+const githubErrorSchema = z.object({
+  message: z.string().optional(),
+});
+
+const githubUserSchema = z.object({
+  id: z.number(),
+  login: z.string(),
+  name: z.string().nullable().optional(),
+  email: z.string().nullable().optional(),
+  avatar_url: z.string().optional(),
+});
+
+const githubRepositorySchema = z.object({
+  id: z.number(),
+  name: z.string(),
+  full_name: z.string(),
+  html_url: z.string(),
+  private: z.boolean(),
+  default_branch: z.string().optional(),
+});
+
+const githubRefSchema = z.object({
+  object: z.object({
+    sha: z.string(),
+  }),
+});
+
+const githubTreeSchema = z.object({
+  sha: z.string(),
+});
+
+const githubCommitSchema = z.object({
+  sha: z.string(),
+  tree: z.object({
+    sha: z.string(),
+  }),
+});
+
+type GitHubRequestOptions = {
+  method?: "GET" | "POST" | "PATCH" | "PUT";
+  body?: unknown;
+  headers?: Record<string, string>;
+};
+
+export type GitHubUser = z.infer<typeof githubUserSchema>;
+export type GitHubRepository = z.infer<typeof githubRepositorySchema>;
+
+export type GitHubTreeEntry = {
+  path: string;
+  mode: "100644";
+  type: "blob";
+  content: string;
+};
+
+export type ProjectFramework = "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE";
+
+export type CreateRepositoryInput = {
+  name: string;
+  description?: string;
+  isPrivate: boolean;
+};
+
+export type ExportReadmeInput = {
+  projectName: string;
+  framework: ProjectFramework;
+  description?: string;
+};
+
+const parseGitHubError = (payload: unknown, status: number): string => {
+  const parsed = githubErrorSchema.safeParse(payload);
+  if (parsed.success && parsed.data.message) {
+    return parsed.data.message;
+  }
+
+  return `GitHub API error (${status})`;
+};
+
+const githubRequest = async (
+  path: string,
+  accessToken: string,
+  options: GitHubRequestOptions = {},
+): Promise<unknown> => {
+  const response = await fetch(`${GITHUB_API_BASE_URL}${path}`, {
+    method: options.method ?? "GET",
+    headers: {
+      Accept: "application/vnd.github+json",
+      Authorization: `Bearer ${accessToken}`,
+      "User-Agent": "ZapDev",
+      "X-GitHub-Api-Version": GITHUB_API_VERSION,
+      ...(options.headers ?? {}),
+    },
+    body: options.body ? JSON.stringify(options.body) : undefined,
+  });
+
+  const payload = await response.json().catch(() => null);
+
+  if (!response.ok) {
+    throw new Error(parseGitHubError(payload, response.status));
+  }
+
+  return payload;
+};
+
+export const getAuthenticatedUser = async (
+  accessToken: string,
+): Promise<GitHubUser> => {
+  const payload = await githubRequest("/user", accessToken);
+  return githubUserSchema.parse(payload);
+};
+
+export const listRepositories = async (
+  accessToken: string,
+): Promise<Array<GitHubRepository>> => {
+  const payload = await githubRequest("/user/repos?per_page=100&sort=updated", accessToken);
+  return z.array(githubRepositorySchema).parse(payload);
+};
+
+export const getRepository = async (
+  accessToken: string,
+  fullName: string,
+): Promise<GitHubRepository> => {
+  const payload = await githubRequest(`/repos/${fullName}`, accessToken);
+  return githubRepositorySchema.parse(payload);
+};
+
+export const createRepository = async (
+  accessToken: string,
+  input: CreateRepositoryInput,
+): Promise<GitHubRepository> => {
+  const payload = await githubRequest("/user/repos", accessToken, {
+    method: "POST",
+    body: {
+      name: input.name,
+      description: input.description ?? "",
+      private: input.isPrivate,
+      auto_init: true,
+    },
+  });
+  return githubRepositorySchema.parse(payload);
+};
+
+export const getBranchRef = async (
+  accessToken: string,
+  fullName: string,
+  branch: string,
+): Promise<string> => {
+  const payload = await githubRequest(
+    `/repos/${fullName}/git/ref/heads/${branch}`,
+    accessToken,
+  );
+  return githubRefSchema.parse(payload).object.sha;
+};
+
+export const getCommitTreeSha = async (
+  accessToken: string,
+  fullName: string,
+  commitSha: string,
+): Promise<string> => {
+  const payload = await githubRequest(
+    `/repos/${fullName}/git/commits/${commitSha}`,
+    accessToken,
+  );
+  return githubCommitSchema.parse(payload).tree.sha;
+};
+
+export const createTree = async (
+  accessToken: string,
+  fullName: string,
+  tree: Array<GitHubTreeEntry>,
+  baseTreeSha?: string,
+): Promise<string> => {
+  const payload = await githubRequest(`/repos/${fullName}/git/trees`, accessToken, {
+    method: "POST",
+    body: {
+      base_tree: baseTreeSha,
+      tree,
+    },
+  });
+  return githubTreeSchema.parse(payload).sha;
+};
+
+export const createCommit = async (
+  accessToken: string,
+  fullName: string,
+  message: string,
+  treeSha: string,
+  parents: Array<string>,
+): Promise<string> => {
+  const payload = await githubRequest(`/repos/${fullName}/git/commits`, accessToken, {
+    method: "POST",
+    body: {
+      message,
+      tree: treeSha,
+      parents,
+    },
+  });
+  return githubCommitSchema.parse(payload).sha;
+};
+
+export const createBranchRef = async (
+  accessToken: string,
+  fullName: string,
+  branch: string,
+  commitSha: string,
+): Promise<void> => {
+  await githubRequest(`/repos/${fullName}/git/refs`, accessToken, {
+    method: "POST",
+    body: {
+      ref: `refs/heads/${branch}`,
+      sha: commitSha,
+    },
+  });
+};
+
+export const updateBranchRef = async (
+  accessToken: string,
+  fullName: string,
+  branch: string,
+  commitSha: string,
+): Promise<void> => {
+  await githubRequest(`/repos/${fullName}/git/refs/heads/${branch}`, accessToken, {
+    method: "PATCH",
+    body: {
+      sha: commitSha,
+      force: false,
+    },
+  });
+};
+
+const sanitizePath = (value: string): string => {
+  return value.replace(/^\/+/, "").replace(/\\/g, "/");
+};
+
+export const buildTreeEntries = (
+  files: Record<string, string>,
+): Array<GitHubTreeEntry> => {
+  const entries: Array<GitHubTreeEntry> = [];
+  const encoder = new TextEncoder();
+
+  for (const [rawPath, content] of Object.entries(files)) {
+    const path = sanitizePath(rawPath);
+    if (!path) {
+      continue;
+    }
+
+    const byteLength = encoder.encode(content).length;
+    if (byteLength > MAX_TREE_CONTENT_BYTES) {
+      throw new Error(`File too large for GitHub export: ${path}`);
+    }
+
+    entries.push({
+      path,
+      mode: "100644",
+      type: "blob",
+      content,
+    });
+  }
+
+  return entries;
+};
+
+const getFrameworkLabel = (framework: ProjectFramework): string => {
+  switch (framework) {
+    case "NEXTJS":
+      return "Next.js";
+    case "ANGULAR":
+      return "Angular";
+    case "REACT":
+      return "React";
+    case "VUE":
+      return "Vue";
+    case "SVELTE":
+      return "Svelte";
+    default:
+      return framework;
+  }
+};
+
+export const generateReadme = (input: ExportReadmeInput): string => {
+  const frameworkLabel = getFrameworkLabel(input.framework);
+
+  const lines: Array<string> = [`# ${input.projectName}`, ""];
+
+  if (input.description) {
+    lines.push(input.description, "");
+  }
+
+  lines.push(
+    "Exported from ZapDev.",
+    "",
+    `Framework: ${frameworkLabel}`,
+    "",
+    "## Getting Started",
+    "",
+    "1. Install dependencies with `bun install`.",
+    "2. Start the dev server with `bun run dev`.",
+    "3. Build for production with `bun run build`.",
+  );
+
+  return lines.join("\n");
+};
+
+export const generateGitignore = (framework: ProjectFramework): string => {
+  const base = [
+    "node_modules",
+    ".env",
+    ".env.local",
+    ".env.*.local",
+    "dist",
+    "build",
+    ".cache",
+    ".DS_Store",
+  ];
+
+  const frameworkSpecific: Record<ProjectFramework, Array<string>> = {
+    NEXTJS: [".next", "out", "next-env.d.ts"],
+    REACT: ["coverage"],
+    VUE: ["dist", ".vite"],
+    ANGULAR: [".angular", "dist"],
+    SVELTE: [".svelte-kit"],
+  };
+
+  const entries = [...base, ...frameworkSpecific[framework]];
+  return entries.join("\n");
+};
+
+export const withDefaultFiles = (
+  files: Record<string, string>,
+  input: ExportReadmeInput,
+  includeReadme: boolean,
+  includeGitignore: boolean,
+): Record<string, string> => {
+  const updated: Record<string, string> = { ...files };
+
+  if (includeReadme && !updated["README.md"]) {
+    updated["README.md"] = generateReadme(input);
+  }
+
+  if (includeGitignore && !updated[".gitignore"]) {
+    updated[".gitignore"] = generateGitignore(input.framework);
+  }
+
+  return updated;
+};

File: src/lib/netlify-client.ts
Changes:
@@ -0,0 +1,238 @@
+type NetlifyRequestOptions = {
+  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+  headers?: Record<string, string>;
+  body?: BodyInit | null;
+};
+
+type NetlifySite = {
+  id: string;
+  name: string;
+  url: string;
+  site_url: string;
+  admin_url?: string;
+};
+
+type NetlifyDeploy = {
+  id: string;
+  state: string;
+  url?: string;
+  deploy_url?: string;
+  created_at?: string;
+  updated_at?: string;
+};
+
+type NetlifyEnvVar = {
+  key: string;
+  values?: Array<{
+    value: string;
+    context?: string;
+  }>;
+};
+
+type NetlifyDomain = {
+  id: string;
+  name: string;
+  ssl_status?: string;
+  verification?: {
+    status?: string;
+  };
+};
+
+const NETLIFY_API_BASE = "https://api.netlify.com/api/v1";
+
+const parseJson = async <T>(response: Response): Promise<T> => {
+  const text = await response.text();
+  if (!text) {
+    return {} as T;
+  }
+  return JSON.parse(text) as T;
+};
+
+const handleApiError = async (response: Response) => {
+  if (response.status === 429) {
+    const retryAfter = response.headers.get("retry-after");
+    throw new Error(`Netlify rate limit hit. Retry after ${retryAfter ?? "unknown"} seconds.`);
+  }
+
+  const errorBody = await response.text();
+  throw new Error(errorBody || `Netlify API error: ${response.status}`);
+};
+
+export const createNetlifyClient = (accessToken: string) => {
+  const request = async <T>(path: string, options: NetlifyRequestOptions = {}) => {
+    const response = await fetch(`${NETLIFY_API_BASE}${path}`, {
+      method: options.method ?? "GET",
+      headers: {
+        Authorization: `Bearer ${accessToken}`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ?? null,
+    });
+
+    if (!response.ok) {
+      await handleApiError(response);
+    }
+
+    return parseJson<T>(response);
+  };
+
+  return {
+    async createSite(name?: string): Promise<NetlifySite> {
+      return request<NetlifySite>("/sites", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(name ? { name } : {}),
+      });
+    },
+
+    async getSite(siteId: string): Promise<NetlifySite> {
+      return request<NetlifySite>(`/sites/${siteId}`);
+    },
+
+    async listSites(): Promise<NetlifySite[]> {
+      return request<NetlifySite[]>("/sites");
+    },
+
+    async updateSite(siteId: string, payload: Record<string, unknown>): Promise<NetlifySite> {
+      return request<NetlifySite>(`/sites/${siteId}`, {
+        method: "PATCH",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload),
+      });
+    },
+
+    async deleteSite(siteId: string): Promise<void> {
+      await request<void>(`/sites/${siteId}`, { method: "DELETE" });
+    },
+
+    async deploySite(siteId: string, zipBody: BodyInit, options?: { draft?: boolean }): Promise<NetlifyDeploy> {
+      const params = new URLSearchParams();
+      if (options?.draft) {
+        params.set("draft", "true");
+      }
+
+      const query = params.toString();
+      const path = query ? `/sites/${siteId}/deploys?${query}` : `/sites/${siteId}/deploys`;
+
+      return request<NetlifyDeploy>(path, {
+        method: "POST",
+        headers: { "Content-Type": "application/zip" },
+        body: zipBody,
+      });
+    },
+
+    async getDeploymentStatus(deployId: string): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/deploys/${deployId}`);
+    },
+
+    async listDeployments(siteId: string): Promise<NetlifyDeploy[]> {
+      return request<NetlifyDeploy[]>(`/sites/${siteId}/deploys`);
+    },
+
+    async getDeployment(deployId: string): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/deploys/${deployId}`);
+    },
+
+    async cancelDeployment(deployId: string): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/deploys/${deployId}/cancel`, { method: "POST" });
+    },
+
+    async rollbackDeployment(deployId: string): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/deploys/${deployId}/rollback`, { method: "POST" });
+    },
+
+    async getBuildLog(deployId: string): Promise<string> {
+      const response = await fetch(`${NETLIFY_API_BASE}/deploys/${deployId}/logs`, {
+        headers: { Authorization: `Bearer ${accessToken}` },
+      });
+
+      if (!response.ok) {
+        await handleApiError(response);
+      }
+
+      return response.text();
+    },
+
+    async getEnvVars(siteId: string): Promise<NetlifyEnvVar[]> {
+      return request<NetlifyEnvVar[]>(`/sites/${siteId}/env`);
+    },
+
+    async setEnvVar(siteId: string, key: string, value: string, context = "all"): Promise<NetlifyEnvVar> {
+      return request<NetlifyEnvVar>(`/sites/${siteId}/env`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          key,
+          values: [{ value, context }],
+        }),
+      });
+    },
+
+    async updateEnvVar(siteId: string, key: string, value: string, context = "all"): Promise<NetlifyEnvVar> {
+      return request<NetlifyEnvVar>(`/sites/${siteId}/env/${encodeURIComponent(key)}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          values: [{ value, context }],
+        }),
+      });
+    },
+
+    async deleteEnvVar(siteId: string, key: string): Promise<void> {
+      await request<void>(`/sites/${siteId}/env/${encodeURIComponent(key)}`, { method: "DELETE" });
+    },
+
+    async setBulkEnvVars(siteId: string, vars: Array<{ key: string; value: string; context?: string }>): Promise<NetlifyEnvVar[]> {
+      const payload = vars.map((entry) => ({
+        key: entry.key,
+        values: [{ value: entry.value, context: entry.context ?? "all" }],
+      }));
+
+      return request<NetlifyEnvVar[]>(`/sites/${siteId}/env`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload),
+      });
+    },
+
+    async listDomains(siteId: string): Promise<NetlifyDomain[]> {
+      return request<NetlifyDomain[]>(`/sites/${siteId}/domains`);
+    },
+
+    async addDomain(siteId: string, domain: string): Promise<NetlifyDomain> {
+      return request<NetlifyDomain>(`/sites/${siteId}/domains`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ name: domain }),
+      });
+    },
+
+    async deleteDomain(siteId: string, domainId: string): Promise<void> {
+      await request<void>(`/sites/${siteId}/domains/${domainId}`, { method: "DELETE" });
+    },
+
+    async verifyDomain(siteId: string, domainId: string): Promise<NetlifyDomain> {
+      return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+    },
+
+    async getDnsRecords(siteId: string, domainId: string): Promise<NetlifyDomain> {
+      return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+    },
+
+    async createPreviewDeployment(siteId: string, zipBody: BodyInit): Promise<NetlifyDeploy> {
+      return request<NetlifyDeploy>(`/sites/${siteId}/deploys?draft=true`, {
+        method: "POST",
+        headers: { "Content-Type": "application/zip" },
+        body: zipBody,
+      });
+    },
+
+    async listPreviewDeployments(siteId: string): Promise<NetlifyDeploy[]> {
+      return request<NetlifyDeploy[]>(`/sites/${siteId}/deploys?draft=true`);
+    },
+
+    async deletePreviewDeployment(deployId: string): Promise<void> {
+      await request<void>(`/deploys/${deployId}`, { method: "DELETE" });
+    },
+  };
+};

File: src/lib/netlify-config.ts
Changes:
@@ -0,0 +1,70 @@
+type FrameworkKey = "NEXTJS" | "REACT" | "VUE" | "ANGULAR" | "SVELTE";
+
+type NetlifyConfig = {
+  buildCommand: string;
+  publishDir: string;
+  plugins?: Array<string>;
+  env?: Record<string, string>;
+};
+
+const frameworkConfigMap: Record<FrameworkKey, NetlifyConfig> = {
+  NEXTJS: {
+    buildCommand: "bun run build",
+    publishDir: ".next",
+    plugins: ["@netlify/plugin-nextjs"],
+  },
+  REACT: {
+    buildCommand: "bun run build",
+    publishDir: "dist",
+  },
+  VUE: {
+    buildCommand: "bun run build",
+    publishDir: "dist",
+  },
+  ANGULAR: {
+    buildCommand: "bun run build",
+    publishDir: "dist",
+  },
+  SVELTE: {
+    buildCommand: "bun run build",
+    publishDir: "build",
+  },
+};
+
+const formatEnvBlock = (env?: Record<string, string>) => {
+  if (!env || Object.keys(env).length === 0) {
+    return "";
+  }
+
+  const lines = Object.entries(env).map(([key, value]) => `  ${key} = "${value}"`);
+  return `\n[build.environment]\n${lines.join("\n")}\n`;
+};
+
+export const getNetlifyToml = (framework: FrameworkKey) => {
+  const config = frameworkConfigMap[framework];
+  const pluginsBlock = (config.plugins ?? [])
+    .map((plugin) => `[[plugins]]\n  package = "${plugin}"`)
+    .join("\n\n");
+  const envBlock = formatEnvBlock(config.env);
+
+  return [
+    "[build]",
+    `  command = "${config.buildCommand}"`,
+    `  publish = "${config.publishDir}"`,
+    pluginsBlock.trimEnd(),
+    envBlock.trimEnd(),
+  ]
+    .filter((line) => line.length > 0)
+    .join("\n")
+    .trim()
+    .concat("\n");
+};
+
+export const getNetlifyBuildSettings = (framework: FrameworkKey) => {
+  const config = frameworkConfigMap[framework];
+  return {
+    buildCommand: config.buildCommand,
+    publishDir: config.publishDir,
+    plugins: config.plugins ?? [],
+  };
+};

File: src/lib/payment-provider.ts
Changes:
@@ -0,0 +1,232 @@
+export type BillingInterval = "monthly" | "yearly";
+
+export type SubscriptionStatus =
+  | "active"
+  | "trialing"
+  | "past_due"
+  | "canceled"
+  | "unpaid";
+
+export interface CheckoutSessionRequest {
+  customerId: string;
+  productId: string;
+  successUrl: string;
+  cancelUrl: string;
+  metadata?: Record<string, string>;
+}
+
+export interface CheckoutSession {
+  id: string;
+  url: string;
+}
+
+export interface SubscriptionLookup {
+  subscriptionId: string;
+}
+
+export interface SubscriptionSummary {
+  id: string;
+  customerId: string;
+  productId: string;
+  status: SubscriptionStatus;
+  interval: BillingInterval;
+  currentPeriodEnd: string;
+  cancelAtPeriodEnd: boolean;
+}
+
+export interface CancelSubscriptionRequest {
+  subscriptionId: string;
+  cancelAtPeriodEnd?: boolean;
+}
+
+export interface UpdateSubscriptionRequest {
+  subscriptionId: string;
+  productId: string;
+}
+
+export interface BillingPortalRequest {
+  customerId: string;
+  returnUrl: string;
+}
+
+export interface UsageEvent {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export interface FeatureCheckRequest {
+  customerId: string;
+  featureId: string;
+}
+
+export interface FeatureCheckResult {
+  allowed: boolean;
+  limit?: number;
+  used?: number;
+  remaining?: number;
+}
+
+export interface PaymentProvider {
+  createCheckoutSession(input: CheckoutSessionRequest): Promise<CheckoutSession>;
+  getSubscription(input: SubscriptionLookup): Promise<SubscriptionSummary | null>;
+  updateSubscription(input: UpdateSubscriptionRequest): Promise<SubscriptionSummary>;
+  cancelSubscription(input: CancelSubscriptionRequest): Promise<SubscriptionSummary>;
+  createBillingPortalSession(input: BillingPortalRequest): Promise<{ url: string }>;
+  trackUsage(input: UsageEvent): Promise<void>;
+  checkFeature(input: FeatureCheckRequest): Promise<FeatureCheckResult>;
+}
+
+interface AutumnConfig {
+  apiKey: string;
+  baseUrl?: string;
+}
+
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+export class AutumnStripeProvider implements PaymentProvider {
+  private apiKey: string;
+  private baseUrl: string;
+
+  constructor(config: AutumnConfig) {
+    this.apiKey = config.apiKey;
+    this.baseUrl = config.baseUrl ?? "https://api.useautumn.com";
+  }
+
+  async createCheckoutSession(
+    input: CheckoutSessionRequest
+  ): Promise<CheckoutSession> {
+    return this.request<CheckoutSession>("/v1/checkout", {
+      method: "POST",
+      body: {
+        customerId: input.customerId,
+        productId: input.productId,
+        successUrl: input.successUrl,
+        cancelUrl: input.cancelUrl,
+        metadata: input.metadata,
+      },
+    });
+  }
+
+  async getSubscription(
+    input: SubscriptionLookup
+  ): Promise<SubscriptionSummary | null> {
+    const url = `${this.baseUrl}/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}`;
+    const response = await fetch(url, {
+      method: "GET",
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${this.apiKey}`,
+      },
+    });
+
+    if (response.status === 404) {
+      return null;
+    }
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(
+        `Autumn API request failed: ${response.status} ${response.statusText} - ${errorText}`
+      );
+    }
+
+    if (response.status === 204) {
+      return null;
+    }
+
+    return (await response.json()) as SubscriptionSummary;
+  }
+
+  async updateSubscription(
+    input: UpdateSubscriptionRequest
+  ): Promise<SubscriptionSummary> {
+    return this.request<SubscriptionSummary>(
+      `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}`,
+      {
+        method: "PATCH",
+        body: {
+          productId: input.productId,
+        },
+      }
+    );
+  }
+
+  async cancelSubscription(
+    input: CancelSubscriptionRequest
+  ): Promise<SubscriptionSummary> {
+    return this.request<SubscriptionSummary>(
+      `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}/cancel`,
+      {
+        method: "POST",
+        body: {
+          cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? true,
+        },
+      }
+    );
+  }
+
+  async createBillingPortalSession(
+    input: BillingPortalRequest
+  ): Promise<{ url: string }> {
+    return this.request<{ url: string }>("/v1/portal", {
+      method: "POST",
+      body: {
+        customerId: input.customerId,
+        returnUrl: input.returnUrl,
+      },
+    });
+  }
+
+  async trackUsage(input: UsageEvent): Promise<void> {
+    await this.request<{ ok: boolean }>("/v1/usage", {
+      method: "POST",
+      body: {
+        customerId: input.customerId,
+        meterId: input.meterId,
+        quantity: input.quantity,
+      },
+    });
+  }
+
+  async checkFeature(input: FeatureCheckRequest): Promise<FeatureCheckResult> {
+    return this.request<FeatureCheckResult>("/v1/features/check", {
+      method: "POST",
+      body: {
+        customerId: input.customerId,
+        featureId: input.featureId,
+      },
+    });
+  }
+
+  private async request<T>(
+    path: string,
+    options: AutumnRequestOptions
+  ): Promise<T> {
+    const url = `${this.baseUrl}${path}`;
+    const response = await fetch(url, {
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${this.apiKey}`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ? JSON.stringify(options.body) : undefined,
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(
+        `Autumn API request failed: ${response.status} ${response.statusText} - ${errorText}`
+      );
+    }
+
+    if (response.status === 204) {
+      return null as T;
+    }
+
+    return (await response.json()) as T;
+  }
+}

File: src/lib/payment-templates/angular.ts
Changes:
@@ -0,0 +1,428 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const angularPaymentTemplate: PaymentTemplateBundle = {
+  framework: "angular",
+  description: "Angular payment integration with Autumn + Stripe",
+  files: {
+    "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+
+  const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+    const response = await fetch(\`\${baseUrl}\${path}\`, {
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: \`Bearer \${apiKey}\`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ? JSON.stringify(options.body) : undefined,
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+    }
+
+    if (response.status === 204) {
+      return undefined as T;
+    }
+
+    return (await response.json()) as T;
+  };
+
+  return { request };
+};
+`,
+    "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+  try {
+    if (!isCheckoutRequest(req.body)) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+      method: "POST",
+      body: req.body,
+    });
+    res.json(checkout);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+  try {
+    const { customerId, returnUrl } = req.body as {
+      customerId?: string;
+      returnUrl?: string;
+    };
+    if (!customerId || !returnUrl) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const portal = await autumn.request<{ url: string }>("/v1/portal", {
+      method: "POST",
+      body: { customerId, returnUrl },
+    });
+    res.json(portal);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+  try {
+    const { subscriptionId, productId } = req.body as {
+      subscriptionId?: string;
+      productId?: string;
+    };
+    if (!subscriptionId || !productId) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const updated = await autumn.request<unknown>(
+      `/v1/subscriptions/${encodeURIComponent(subscriptionId)}`,
+      {
+        method: "PATCH",
+        body: { productId },
+      }
+    );
+    res.json(updated);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+  try {
+    const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+      subscriptionId?: string;
+      cancelAtPeriodEnd?: boolean;
+    };
+    if (!subscriptionId) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const canceled = await autumn.request<unknown>(
+      `/v1/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`,
+      {
+        method: "POST",
+        body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+      }
+    );
+    res.json(canceled);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+  try {
+    const { customerId, featureId } = req.body as {
+      customerId?: string;
+      featureId?: string;
+    };
+    if (!customerId || !featureId) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const result = await autumn.request<unknown>("/v1/features/check", {
+      method: "POST",
+      body: { customerId, featureId },
+    });
+    res.json(result);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+  try {
+    const { customerId, meterId, quantity } = req.body as {
+      customerId?: string;
+      meterId?: string;
+      quantity?: number;
+    };
+    if (!customerId || !meterId || typeof quantity !== "number") {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    await autumn.request("/v1/usage", {
+      method: "POST",
+      body: { customerId, meterId, quantity },
+    });
+    res.json({ ok: true });
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+export default router;
+`,
+    "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    res.status(500).json({ error: "Missing webhook secret" });
+    return;
+  }
+  const signature = req.headers["autumn-signature"];
+  const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+  const rawBody = (req as any).rawBody;
+  if (!rawBody || !verifySignature(signatureValue, rawBody, secret)) {
+    res.status(401).json({ error: "Invalid signature" });
+    return;
+  }
+  try {
+    const event = JSON.parse(rawBody) as { type: string; data: unknown };
+    switch (event.type) {
+      case "subscription.created":
+      case "subscription.updated":
+      case "subscription.canceled":
+      case "invoice.payment_failed":
+      case "invoice.payment_succeeded":
+        break;
+      default:
+        break;
+    }
+    res.json({ received: true });
+  } catch (err) {
+    res.status(400).json({ error: "Invalid JSON" });
+  }
+});
+
+export default router;
+`,
+    "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json({
+  verify: (req: any, res, buf) => {
+    req.rawBody = buf.toString();
+  }
+}));
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+  console.log(\`Billing API listening on \${port}\`);
+});
+`,
+    "src/app/services/billing.service.ts": `
+import { Injectable } from "@angular/core";
+
+interface CheckoutPayload {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+}
+
+@Injectable({ providedIn: "root" })
+export class BillingService {
+  async startCheckout(payload: CheckoutPayload): Promise<void> {
+    try {
+      const response = await fetch("/api/billing/checkout", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload),
+      });
+      if (!response.ok) {
+        const data = await response.json();
+        throw new Error(data.error || "Checkout failed");
+      }
+      const data = (await response.json()) as { url?: string };
+      if (data.url) {
+        window.location.href = data.url;
+      }
+    } catch (error) {
+      alert(error instanceof Error ? error.message : "Checkout failed");
+    }
+  }
+
+  async checkFeature(customerId: string, featureId: string): Promise<boolean> {
+    const response = await fetch("/api/billing/feature-check", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ customerId, featureId }),
+    });
+    const data = (await response.json()) as { allowed?: boolean };
+    return data.allowed === true;
+  }
+
+  async trackUsage(customerId: string, meterId: string, quantity: number): Promise<void> {
+    await fetch("/api/billing/usage", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ customerId, meterId, quantity }),
+    });
+  }
+}
+`,
+    "src/app/guards/feature.guard.ts": `
+import { Injectable } from "@angular/core";
+import type { CanActivateFn, ActivatedRouteSnapshot } from "@angular/router";
+import { BillingService } from "../services/billing.service";
+
+@Injectable({ providedIn: "root" })
+export class FeatureGuard {
+  constructor(private billingService: BillingService) {}
+
+  canActivate: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
+    const featureId = route.data?.["featureId"];
+    const customerId = route.data?.["customerId"];
+    if (typeof featureId !== "string" || typeof customerId !== "string") {
+      return false;
+    }
+    return this.billingService.checkFeature(customerId, featureId);
+  };
+}
+`,
+    "src/app/components/checkout-button/checkout-button.component.ts": `
+import { Component, Input } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { BillingService } from "../../services/billing.service";
+
+@Component({
+  selector: "app-checkout-button",
+  standalone: true,
+  imports: [CommonModule],
+  template: \`
+    <button
+      type="button"
+      class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+      [disabled]="loading"
+      (click)="startCheckout()"
+    >
+      {{ loading ? "Redirecting..." : (label ?? "Upgrade") }}
+    </button>
+  \`,
+})
+export class CheckoutButtonComponent {
+  @Input({ required: true }) productId = "";
+  @Input({ required: true }) customerId = "";
+  @Input({ required: true }) successUrl = "";
+  @Input({ required: true }) cancelUrl = "";
+  @Input() label?: string;
+
+  loading = false;
+
+  constructor(private billingService: BillingService) {}
+
+  async startCheckout() {
+    this.loading = true;
+    try {
+      await this.billingService.startCheckout({
+        productId: this.productId,
+        customerId: this.customerId,
+        successUrl: this.successUrl,
+        cancelUrl: this.cancelUrl,
+      });
+    } finally {
+      this.loading = false;
+    }
+  }
+}
+`,
+    "src/app/components/billing-success/billing-success.component.ts": `
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+
+@Component({
+  selector: "app-billing-success",
+  standalone: true,
+  imports: [CommonModule],
+  template: \`
+    <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 class="text-3xl font-semibold">Payment successful</h1>
+      <p class="text-muted-foreground">
+        Your subscription is active. You can return to the app and start using
+        your new plan immediately.
+      </p>
+      <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+        Return to app
+      </a>
+    </div>
+  \`,
+})
+export class BillingSuccessComponent {}
+`,
+    "src/app/components/billing-cancel/billing-cancel.component.ts": `
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+
+@Component({
+  selector: "app-billing-cancel",
+  standalone: true,
+  imports: [CommonModule],
+  template: \`
+    <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+      <p class="text-muted-foreground">
+        Your checkout was canceled. You can restart the process at any time.
+      </p>
+      <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+        Return to app
+      </a>
+    </div>
+  \`,
+})
+export class BillingCancelComponent {}
+`,
+  },
+};

File: src/lib/payment-templates/autumn-config.ts
Changes:
@@ -0,0 +1,49 @@
+export const autumnConfigTemplate = `
+export const autumnConfig = {
+  products: [
+    {
+      id: "free",
+      name: "Free",
+      description: "Starter access",
+      prices: [
+        {
+          id: "free-monthly",
+          amount: 0,
+          currency: "usd",
+          interval: "monthly",
+        },
+      ],
+      features: ["basic_generations"],
+    },
+    {
+      id: "pro",
+      name: "Pro",
+      description: "Pro plan with higher limits",
+      prices: [
+        {
+          id: "pro-monthly",
+          amount: 2900,
+          currency: "usd",
+          interval: "monthly",
+        },
+      ],
+      features: ["basic_generations", "priority_generations"],
+    },
+  ],
+  features: {
+    basic_generations: {
+      type: "metered",
+      meterId: "generations",
+      included: 5,
+    },
+    priority_generations: {
+      type: "boolean",
+    },
+  },
+  meters: {
+    generations: {
+      unit: "generation",
+    },
+  },
+} as const;
+`;

File: src/lib/payment-templates/env-example.ts
Changes:
@@ -0,0 +1,11 @@
+import { sanitizeAnyForDatabase } from "@/lib/utils";
+
+export const paymentEnvExample = sanitizeAnyForDatabase(`
+# Autumn + Stripe (user app billing)
+AUTUMN_API_KEY=""
+AUTUMN_API_BASE_URL="https://api.useautumn.com"
+AUTUMN_WEBHOOK_SECRET=""
+STRIPE_SECRET_KEY=""
+STRIPE_PUBLISHABLE_KEY=""
+NEXT_PUBLIC_APP_URL="http://localhost:3000"
+`);

File: src/lib/payment-templates/index.ts
Changes:
@@ -0,0 +1,24 @@
+import { angularPaymentTemplate } from "./angular";
+import { nextjsPaymentTemplate } from "./nextjs";
+import { reactPaymentTemplate } from "./react";
+import { sveltePaymentTemplate } from "./svelte";
+import { vuePaymentTemplate } from "./vue";
+import type { PaymentFramework, PaymentTemplateBundle } from "./types";
+import { autumnConfigTemplate } from "./autumn-config";
+import { paymentEnvExample } from "./env-example";
+
+const templates: Record<PaymentFramework, PaymentTemplateBundle> = {
+  nextjs: nextjsPaymentTemplate,
+  react: reactPaymentTemplate,
+  vue: vuePaymentTemplate,
+  angular: angularPaymentTemplate,
+  svelte: sveltePaymentTemplate,
+};
+
+export const paymentTemplates = templates;
+export { autumnConfigTemplate, paymentEnvExample };
+export type { PaymentFramework, PaymentTemplateBundle };
+
+export const getPaymentTemplate = (
+  framework: PaymentFramework
+): PaymentTemplateBundle => templates[framework];

File: src/lib/payment-templates/nextjs.ts
Changes:
@@ -0,0 +1,468 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const nextjsPaymentTemplate: PaymentTemplateBundle = {
+  framework: "nextjs",
+  description: "Next.js App Router payment integration with Autumn + Stripe",
+  files: {
+    "lib/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+const getAutumnConfig = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+  return { apiKey, baseUrl };
+};
+
+export async function autumnRequest<T>(
+  path: string,
+  options: AutumnRequestOptions
+): Promise<T> {
+  const { apiKey, baseUrl } = getAutumnConfig();
+  const response = await fetch(\`\${baseUrl}\${path}\`, {
+    ...options,
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: \`Bearer \${apiKey}\`,
+      ...(options.headers ?? {}),
+    },
+    body: options.body ? JSON.stringify(options.body) : undefined,
+  });
+
+  if (!response.ok) {
+    const errorText = await response.text();
+    throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+  }
+
+  if (response.status === 204) {
+    return undefined as T;
+  }
+
+  return (await response.json()) as T;
+}
+`,
+    "app/api/billing/checkout/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+export async function POST(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isCheckoutRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const checkout = await autumnRequest<{ url: string; id: string }>(
+    "/v1/checkout",
+    {
+      method: "POST",
+      body: {
+        productId: body.productId,
+        customerId: body.customerId,
+        successUrl: body.successUrl,
+        cancelUrl: body.cancelUrl,
+      },
+    }
+  );
+
+  return NextResponse.json(checkout);
+}
+`,
+    "app/api/billing/portal/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type PortalRequest = {
+  customerId: string;
+  returnUrl: string;
+};
+
+const isPortalRequest = (value: unknown): value is PortalRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return typeof data.customerId === "string" && typeof data.returnUrl === "string";
+};
+
+export async function POST(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isPortalRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const portal = await autumnRequest<{ url: string }>("/v1/portal", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      returnUrl: body.returnUrl,
+    },
+  });
+
+  return NextResponse.json(portal);
+}
+`,
+    "app/api/billing/subscription/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type UpdateRequest = {
+  subscriptionId: string;
+  productId: string;
+};
+
+type CancelRequest = {
+  subscriptionId: string;
+  cancelAtPeriodEnd?: boolean;
+};
+
+const isUpdateRequest = (value: unknown): value is UpdateRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.subscriptionId === "string" &&
+    typeof data.productId === "string"
+  );
+};
+
+const isCancelRequest = (value: unknown): value is CancelRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.subscriptionId === "string" &&
+    (data.cancelAtPeriodEnd === undefined || typeof data.cancelAtPeriodEnd === "boolean")
+  );
+};
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const subscriptionId = searchParams.get("subscriptionId");
+
+  if (!subscriptionId) {
+    return NextResponse.json({ error: "subscriptionId is required" }, { status: 400 });
+  }
+
+  const subscription = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+    { method: "GET" }
+  );
+
+  return NextResponse.json(subscription);
+}
+
+export async function PATCH(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isUpdateRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const updated = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}\`,
+    {
+      method: "PATCH",
+      body: { productId: body.productId },
+    }
+  );
+
+  return NextResponse.json(updated);
+}
+
+export async function DELETE(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isCancelRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const canceled = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}/cancel\`,
+    {
+      method: "POST",
+      body: { cancelAtPeriodEnd: body.cancelAtPeriodEnd ?? true },
+    }
+  );
+
+  return NextResponse.json(canceled);
+}
+`,
+    "app/api/billing/usage/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type UsageRequest = {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+};
+
+const isUsageRequest = (value: unknown): value is UsageRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.customerId === "string" &&
+    typeof data.meterId === "string" &&
+    typeof data.quantity === "number"
+  );
+};
+
+export async function POST(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isUsageRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  await autumnRequest("/v1/usage", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      meterId: body.meterId,
+      quantity: body.quantity,
+    },
+  });
+
+  return NextResponse.json({ ok: true });
+}
+`,
+    "app/api/billing/feature-check/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type FeatureCheckRequest = {
+  customerId: string;
+  featureId: string;
+};
+
+const isFeatureCheckRequest = (
+  value: unknown
+): value is FeatureCheckRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.customerId === "string" && typeof data.featureId === "string"
+  );
+};
+
+export async function POST(req: Request) {
+  const body = (await req.json()) as unknown;
+  if (!isFeatureCheckRequest(body)) {
+    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+  }
+
+  const result = await autumnRequest<unknown>("/v1/features/check", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      featureId: body.featureId,
+    },
+  });
+
+  return NextResponse.json(result);
+}
+`,
+    "app/api/webhooks/autumn/route.ts": `
+import { NextResponse } from "next/server";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+export const runtime = "nodejs";
+
+const verifySignature = (
+  signature: string,
+  payload: string,
+  secret: string
+): boolean => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+export async function POST(req: Request) {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    return NextResponse.json({ error: "Missing webhook secret" }, { status: 500 });
+  }
+
+  const signature = req.headers.get("autumn-signature") ?? "";
+  const rawBody = await req.text();
+
+  if (!verifySignature(signature, rawBody, secret)) {
+    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
+  }
+
+  const event = JSON.parse(rawBody) as { type: string; data: unknown };
+
+  switch (event.type) {
+    case "subscription.created":
+    case "subscription.updated":
+    case "subscription.canceled": {
+      break;
+    }
+    case "invoice.payment_failed":
+    case "invoice.payment_succeeded": {
+      break;
+    }
+    default: {
+      break;
+    }
+  }
+
+  return NextResponse.json({ received: true });
+}
+`,
+    "components/billing/checkout-button.tsx": `
+"use client";
+
+import { useState } from "react";
+
+interface CheckoutButtonProps {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+  label?: string;
+}
+
+export function CheckoutButton({
+  productId,
+  customerId,
+  successUrl,
+  cancelUrl,
+  label = "Upgrade",
+}: CheckoutButtonProps) {
+  const [loading, setLoading] = useState(false);
+
+  const startCheckout = async () => {
+    setLoading(true);
+    try {
+      const response = await fetch("/api/billing/checkout", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          productId,
+          customerId,
+          successUrl,
+          cancelUrl,
+        }),
+      });
+      if (!response.ok) {
+        const data = await response.json();
+        throw new Error(data.error || "Checkout failed");
+      }
+      const data = (await response.json()) as { url?: string };
+      if (data.url) {
+        window.location.href = data.url;
+      }
+    } catch (error) {
+      alert(error instanceof Error ? error.message : "Checkout failed");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      onClick={startCheckout}
+      disabled={loading}
+      className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+    >
+      {loading ? "Redirecting..." : label}
+    </button>
+  );
+}
+`,
+    "components/billing/feature-gate.tsx": `
+import type { ReactNode } from "react";
+
+interface FeatureGateProps {
+  allowed: boolean;
+  fallback?: ReactNode;
+  children: ReactNode;
+}
+
+export function FeatureGate({ allowed, fallback, children }: FeatureGateProps) {
+  if (!allowed) {
+    return <>{fallback ?? null}</>;
+  }
+  return <>{children}</>;
+}
+`,
+    "lib/usage.ts": `
+interface UsagePayload {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export async function trackUsage(payload: UsagePayload): Promise<void> {
+  const response = await fetch("/api/billing/usage", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(payload),
+  });
+  if (!response.ok) {
+    const data = await response.json();
+    throw new Error(data.error || "Failed to track usage");
+  }
+}
+`,
+    "app/billing/success/page.tsx": `
+export default function BillingSuccessPage() {
+  return (
+    <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 className="text-3xl font-semibold">Payment successful</h1>
+      <p className="text-muted-foreground">
+        Your subscription is active. You can return to the app and start using
+        your new plan immediately.
+      </p>
+      <a
+        href="/"
+        className="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white"
+      >
+        Return to app
+      </a>
+    </div>
+  );
+}
+`,
+    "app/billing/cancel/page.tsx": `
+export default function BillingCancelPage() {
+  return (
+    <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 className="text-3xl font-semibold">Checkout canceled</h1>
+      <p className="text-muted-foreground">
+        Your checkout was canceled. You can restart the process at any time.
+      </p>
+      <a
+        href="/"
+        className="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2"
+      >
+        Return to app
+      </a>
+    </div>
+  );
+}
+`,
+  },
+};

File: src/lib/payment-templates/react.ts
Changes:
@@ -0,0 +1,390 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const reactPaymentTemplate: PaymentTemplateBundle = {
+  framework: "react",
+  description: "React (Vite) payment integration with Autumn + Stripe",
+  files: {
+    "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+
+  const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+    const response = await fetch(\`\${baseUrl}\${path}\`, {
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: \`Bearer \${apiKey}\`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ? JSON.stringify(options.body) : undefined,
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+    }
+
+    if (response.status === 204) {
+      return undefined as T;
+    }
+
+    return (await response.json()) as T;
+  };
+
+  return { request };
+};
+`,
+    "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+  try {
+    if (!isCheckoutRequest(req.body)) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+      method: "POST",
+      body: req.body,
+    });
+    res.json(checkout);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+  try {
+    const { customerId, returnUrl } = req.body as {
+      customerId?: string;
+      returnUrl?: string;
+    };
+    if (!customerId || !returnUrl) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const portal = await autumn.request<{ url: string }>("/v1/portal", {
+      method: "POST",
+      body: { customerId, returnUrl },
+    });
+    res.json(portal);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+  try {
+    const { subscriptionId, productId } = req.body as {
+      subscriptionId?: string;
+      productId?: string;
+    };
+    if (!subscriptionId || !productId) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const updated = await autumn.request<unknown>(
+      `/v1/subscriptions/${encodeURIComponent(subscriptionId)}`,
+      {
+        method: "PATCH",
+        body: { productId },
+      }
+    );
+    res.json(updated);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+  try {
+    const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+      subscriptionId?: string;
+      cancelAtPeriodEnd?: boolean;
+    };
+    if (!subscriptionId) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const canceled = await autumn.request<unknown>(
+      `/v1/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`,
+      {
+        method: "POST",
+        body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+      }
+    );
+    res.json(canceled);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+  try {
+    const { customerId, featureId } = req.body as {
+      customerId?: string;
+      featureId?: string;
+    };
+    if (!customerId || !featureId) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const result = await autumn.request<unknown>("/v1/features/check", {
+      method: "POST",
+      body: { customerId, featureId },
+    });
+    res.json(result);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+  try {
+    const { customerId, meterId, quantity } = req.body as {
+      customerId?: string;
+      meterId?: string;
+      quantity?: number;
+    };
+    if (!customerId || !meterId || typeof quantity !== "number") {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    await autumn.request("/v1/usage", {
+      method: "POST",
+      body: { customerId, meterId, quantity },
+    });
+    res.json({ ok: true });
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+export default router;
+`,
+    "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    res.status(500).json({ error: "Missing webhook secret" });
+    return;
+  }
+  const signature = req.headers["autumn-signature"];
+  const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+  const rawBody = (req as any).rawBody;
+  if (!rawBody || !verifySignature(signatureValue, rawBody, secret)) {
+    res.status(401).json({ error: "Invalid signature" });
+    return;
+  }
+  try {
+    const event = JSON.parse(rawBody) as { type: string; data: unknown };
+    switch (event.type) {
+      case "subscription.created":
+      case "subscription.updated":
+      case "subscription.canceled":
+      case "invoice.payment_failed":
+      case "invoice.payment_succeeded":
+        break;
+      default:
+        break;
+    }
+    res.json({ received: true });
+  } catch (err) {
+    res.status(400).json({ error: "Invalid JSON" });
+  }
+});
+
+export default router;
+`,
+    "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json({
+  verify: (req: any, res, buf) => {
+    req.rawBody = buf.toString();
+  }
+}));
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+  console.log(\`Billing API listening on \${port}\`);
+});
+`,
+    "src/components/CheckoutButton.tsx": `
+import { useState } from "react";
+
+interface CheckoutButtonProps {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+  label?: string;
+}
+
+export function CheckoutButton({
+  productId,
+  customerId,
+  successUrl,
+  cancelUrl,
+  label = "Upgrade",
+}: CheckoutButtonProps) {
+  const [loading, setLoading] = useState(false);
+
+  const startCheckout = async () => {
+    setLoading(true);
+    try {
+      const response = await fetch("/api/billing/checkout", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }),
+      });
+      if (!response.ok) {
+        const data = await response.json();
+        throw new Error(data.error || "Checkout failed");
+      }
+      const data = (await response.json()) as { url?: string };
+      if (data.url) {
+        window.location.href = data.url;
+      }
+    } catch (error) {
+      alert(error instanceof Error ? error.message : "Checkout failed");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      onClick={startCheckout}
+      disabled={loading}
+      className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+    >
+      {loading ? "Redirecting..." : label}
+    </button>
+  );
+}
+`,
+    "src/components/FeatureGate.tsx": `
+import type { ReactNode } from "react";
+
+interface FeatureGateProps {
+  allowed: boolean;
+  fallback?: ReactNode;
+  children: ReactNode;
+}
+
+export function FeatureGate({ allowed, fallback, children }: FeatureGateProps) {
+  if (!allowed) {
+    return <>{fallback ?? null}</>;
+  }
+  return <>{children}</>;
+}
+`,
+    "src/lib/usage.ts": `
+interface UsagePayload {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export async function trackUsage(payload: UsagePayload): Promise<void> {
+  await fetch("/api/billing/usage", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(payload),
+  });
+}
+`,
+    "src/pages/BillingSuccess.tsx": `
+export function BillingSuccess() {
+  return (
+    <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 className="text-3xl font-semibold">Payment successful</h1>
+      <p className="text-muted-foreground">
+        Your subscription is active. You can return to the app and start using
+        your new plan immediately.
+      </p>
+      <a
+        href="/"
+        className="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white"
+      >
+        Return to app
+      </a>
+    </div>
+  );
+}
+`,
+    "src/pages/BillingCancel.tsx": `
+export function BillingCancel() {
+  return (
+    <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+      <h1 className="text-3xl font-semibold">Checkout canceled</h1>
+      <p className="text-muted-foreground">
+        Your checkout was canceled. You can restart the process at any time.
+      </p>
+      <a
+        href="/"
+        className="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2"
+      >
+        Return to app
+      </a>
+    </div>
+  );
+}
+`,
+  },
+};

File: src/lib/payment-templates/svelte.ts
Changes:
@@ -0,0 +1,337 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const sveltePaymentTemplate: PaymentTemplateBundle = {
+  framework: "svelte",
+  description: "SvelteKit payment integration with Autumn + Stripe",
+  files: {
+    "src/lib/server/autumn.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+const getAutumnConfig = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+  return { apiKey, baseUrl };
+};
+
+export const autumnRequest = async <T>(
+  path: string,
+  options: AutumnRequestOptions
+): Promise<T> => {
+  const { apiKey, baseUrl } = getAutumnConfig();
+  const response = await fetch(\`\${baseUrl}\${path}\`, {
+    ...options,
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: \`Bearer \${apiKey}\`,
+      ...(options.headers ?? {}),
+    },
+    body: options.body ? JSON.stringify(options.body) : undefined,
+  });
+
+  if (!response.ok) {
+    const errorText = await response.text();
+    throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+  }
+
+  if (response.status === 204) {
+    return undefined as T;
+  }
+
+  return (await response.json()) as T;
+};
+`,
+    "src/routes/api/billing/checkout/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+export const POST: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as unknown;
+  if (!isCheckoutRequest(body)) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const checkout = await autumnRequest<{ url: string; id: string }>("/v1/checkout", {
+    method: "POST",
+    body,
+  });
+  return json(checkout);
+};
+`,
+    "src/routes/api/billing/portal/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    customerId?: string;
+    returnUrl?: string;
+  };
+  if (!body.customerId || !body.returnUrl) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const portal = await autumnRequest<{ url: string }>("/v1/portal", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      returnUrl: body.returnUrl,
+    },
+  });
+  return json(portal);
+};
+`,
+    "src/routes/api/billing/usage/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    customerId?: string;
+    meterId?: string;
+    quantity?: number;
+  };
+  if (!body.customerId || !body.meterId || typeof body.quantity !== "number") {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  await autumnRequest("/v1/usage", {
+    method: "POST",
+    body: {
+      customerId: body.customerId,
+      meterId: body.meterId,
+      quantity: body.quantity,
+    },
+  });
+  return json({ ok: true });
+};
+`,
+    "src/routes/api/billing/subscription/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const GET: RequestHandler = async ({ url }) => {
+  const subscriptionId = url.searchParams.get("subscriptionId");
+  if (!subscriptionId) {
+    return json({ error: "subscriptionId is required" }, { status: 400 });
+  }
+  const subscription = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+    { method: "GET" }
+  );
+  return json(subscription);
+};
+
+export const PATCH: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    subscriptionId?: string;
+    productId?: string;
+  };
+  if (!body.subscriptionId || !body.productId) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const updated = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}\`,
+    {
+      method: "PATCH",
+      body: { productId: body.productId },
+    }
+  );
+  return json(updated);
+};
+
+export const DELETE: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    subscriptionId?: string;
+    cancelAtPeriodEnd?: boolean;
+  };
+  if (!body.subscriptionId) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const canceled = await autumnRequest<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}/cancel\`,
+    {
+      method: "POST",
+      body: { cancelAtPeriodEnd: body.cancelAtPeriodEnd ?? true },
+    }
+  );
+  return json(canceled);
+};
+`,
+    "src/routes/api/billing/feature-check/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+  const body = (await request.json()) as {
+    customerId?: string;
+    featureId?: string;
+  };
+  if (!body.customerId || !body.featureId) {
+    return json({ error: "Invalid payload" }, { status: 400 });
+  }
+  const result = await autumnRequest<unknown>("/v1/features/check", {
+    method: "POST",
+    body: { customerId: body.customerId, featureId: body.featureId },
+  });
+  return json(result);
+};
+`,
+    "src/routes/api/webhooks/autumn/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { createHmac, timingSafeEqual } from "node:crypto";
+import type { RequestHandler } from "./$types";
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+export const POST: RequestHandler = async ({ request }) => {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    return json({ error: "Missing webhook secret" }, { status: 500 });
+  }
+  const signature = request.headers.get("autumn-signature") ?? "";
+  const rawBody = await request.text();
+  if (!verifySignature(signature, rawBody, secret)) {
+    return json({ error: "Invalid signature" }, { status: 401 });
+  }
+  const event = JSON.parse(rawBody) as { type: string; data: unknown };
+  switch (event.type) {
+    case "subscription.created":
+    case "subscription.updated":
+    case "subscription.canceled":
+    case "invoice.payment_failed":
+    case "invoice.payment_succeeded":
+      break;
+    default:
+      break;
+  }
+  return json({ received: true });
+};
+`,
+    "src/lib/components/CheckoutButton.svelte": `
+<script lang="ts">
+  export let productId: string;
+  export let customerId: string;
+  export let successUrl: string;
+  export let cancelUrl: string;
+  export let label = "Upgrade";
+
+  let loading = false;
+
+    const startCheckout = async () => {
+    loading = true;
+    try {
+      const response = await fetch("/api/billing/checkout", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }),
+      });
+      if (!response.ok) {
+        const data = await response.json();
+        throw new Error(data.error || "Checkout failed");
+      }
+      const data = (await response.json()) as { url?: string };
+      if (data.url) {
+        window.location.href = data.url;
+      }
+    } catch (error) {
+      alert(error instanceof Error ? error.message : "Checkout failed");
+    } finally {
+      loading = false;
+    }
+  };
+</script>
+
+<button
+  type="button"
+  class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+  disabled={loading}
+  on:click={startCheckout}
+>
+  {loading ? "Redirecting..." : label}
+</button>
+`,
+    "src/lib/components/FeatureGate.svelte": `
+<script lang="ts">
+  export let allowed = false;
+  export let fallback = "";
+</script>
+
+{#if allowed}
+  <slot />
+{:else}
+  {fallback}
+{/if}
+`,
+    "src/lib/usage.ts": `
+export interface UsagePayload {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export const trackUsage = async (payload: UsagePayload): Promise<void> => {
+  await fetch("/api/billing/usage", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(payload),
+  });
+};
+`,
+    "src/routes/billing/success/+page.svelte": `
+<div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+  <h1 class="text-3xl font-semibold">Payment successful</h1>
+  <p class="text-muted-foreground">
+    Your subscription is active. You can return to the app and start using
+    your new plan immediately.
+  </p>
+  <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+    Return to app
+  </a>
+</div>
+`,
+    "src/routes/billing/cancel/+page.svelte": `
+<div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+  <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+  <p class="text-muted-foreground">
+    Your checkout was canceled. You can restart the process at any time.
+  </p>
+  <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+    Return to app
+  </a>
+</div>
+`,
+  },
+};

File: src/lib/payment-templates/types.ts
Changes:
@@ -0,0 +1,9 @@
+import { frameworks } from "../frameworks";
+
+export type PaymentFramework = keyof typeof frameworks;
+
+export interface PaymentTemplateBundle {
+  framework: PaymentFramework;
+  description: string;
+  files: Record<string, string>;
+}

File: src/lib/payment-templates/vue.ts
Changes:
@@ -0,0 +1,369 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const vuePaymentTemplate: PaymentTemplateBundle = {
+  framework: "vue",
+  description: "Vue 3 payment integration with Autumn + Stripe",
+  files: {
+    "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+  body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+  const apiKey = process.env.AUTUMN_API_KEY;
+  const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+  if (!apiKey) {
+    throw new Error("AUTUMN_API_KEY is required");
+  }
+
+  const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+    const response = await fetch(\`\${baseUrl}\${path}\`, {
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: \`Bearer \${apiKey}\`,
+        ...(options.headers ?? {}),
+      },
+      body: options.body ? JSON.stringify(options.body) : undefined,
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+    }
+
+    if (response.status === 204) {
+      return undefined as T;
+    }
+
+    return (await response.json()) as T;
+  };
+
+  return { request };
+};
+`,
+    "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+  if (!value || typeof value !== "object") return false;
+  const data = value as Record<string, unknown>;
+  return (
+    typeof data.productId === "string" &&
+    typeof data.customerId === "string" &&
+    typeof data.successUrl === "string" &&
+    typeof data.cancelUrl === "string"
+  );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+  try {
+    if (!isCheckoutRequest(req.body)) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+      method: "POST",
+      body: req.body,
+    });
+    res.json(checkout);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+  try {
+    const { customerId, returnUrl } = req.body as {
+      customerId?: string;
+      returnUrl?: string;
+    };
+    if (!customerId || !returnUrl) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const portal = await autumn.request<{ url: string }>("/v1/portal", {
+      method: "POST",
+      body: { customerId, returnUrl },
+    });
+    res.json(portal);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+  const { subscriptionId, productId } = req.body as {
+    subscriptionId?: string;
+    productId?: string;
+  };
+  if (!subscriptionId || !productId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const updated = await autumn.request<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+    {
+      method: "PATCH",
+      body: { productId },
+    }
+  );
+  res.json(updated);
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+  const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+    subscriptionId?: string;
+    cancelAtPeriodEnd?: boolean;
+  };
+  if (!subscriptionId) {
+    res.status(400).json({ error: "Invalid payload" });
+    return;
+  }
+  const canceled = await autumn.request<unknown>(
+    \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`,
+    {
+      method: "POST",
+      body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+    }
+  );
+  res.json(canceled);
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+  try {
+    const { customerId, featureId } = req.body as {
+      customerId?: string;
+      featureId?: string;
+    };
+    if (!customerId || !featureId) {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    const result = await autumn.request<unknown>("/v1/features/check", {
+      method: "POST",
+      body: { customerId, featureId },
+    });
+    res.json(result);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+  try {
+    const { customerId, meterId, quantity } = req.body as {
+      customerId?: string;
+      meterId?: string;
+      quantity?: number;
+    };
+    if (!customerId || !meterId || typeof quantity !== "number") {
+      res.status(400).json({ error: "Invalid payload" });
+      return;
+    }
+    await autumn.request("/v1/usage", {
+      method: "POST",
+      body: { customerId, meterId, quantity },
+    });
+    res.json({ ok: true });
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
+});
+
+export default router;
+`,
+    "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import express from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+  const digest = createHmac("sha256", secret).update(payload).digest("hex");
+  const signatureBuffer = Buffer.from(signature);
+  const digestBuffer = Buffer.from(digest);
+  if (signatureBuffer.length !== digestBuffer.length) {
+    return false;
+  }
+  return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", express.raw({ type: "application/json" }), async (req: Request, res: Response) => {
+  const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+  if (!secret) {
+    res.status(500).json({ error: "Missing webhook secret" });
+    return;
+  }
+  const signature = req.headers["autumn-signature"];
+  const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+  const rawBody = req.body instanceof Buffer ? req.body.toString("utf8") : String(req.body);
+  if (!verifySignature(signatureValue, rawBody, secret)) {
+    res.status(401).json({ error: "Invalid signature" });
+    return;
+  }
+  try {
+    const event = JSON.parse(rawBody) as { type: string; data: unknown };
+    switch (event.type) {
+      case "subscription.created":
+      case "subscription.updated":
+      case "subscription.canceled":
+      case "invoice.payment_failed":
+      case "invoice.payment_succeeded":
+        break;
+      default:
+        break;
+    }
+    res.json({ received: true });
+  } catch (err) {
+    res.status(400).json({ error: "Invalid JSON" });
+  }
+});
+
+export default router;
+`,
+    "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json());
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+  console.log(\`Billing API listening on \${port}\`);
+});
+`,
+    "src/components/CheckoutButton.vue": `
+<script setup lang="ts">
+import { ref } from "vue";
+
+const props = defineProps<{
+  productId: string;
+  customerId: string;
+  successUrl: string;
+  cancelUrl: string;
+  label?: string;
+}>();
+
+const loading = ref(false);
+
+const startCheckout = async () => {
+  loading.value = true;
+  try {
+    const response = await fetch("/api/billing/checkout", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        productId: props.productId,
+        customerId: props.customerId,
+        successUrl: props.successUrl,
+        cancelUrl: props.cancelUrl,
+      }),
+    });
+    if (!response.ok) {
+      const data = await response.json();
+      throw new Error(data.error || "Checkout failed");
+    }
+    const data = (await response.json()) as { url?: string };
+    if (data.url) {
+      window.location.href = data.url;
+    }
+  } catch (error) {
+    alert(error instanceof Error ? error.message : "Checkout failed");
+  } finally {
+    loading.value = false;
+  }
+};
+</script>
+
+<template>
+  <button
+    type="button"
+    :disabled="loading"
+    class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+    @click="startCheckout"
+  >
+    {{ loading ? "Redirecting..." : props.label ?? "Upgrade" }}
+  </button>
+</template>
+`,
+    "src/components/FeatureGate.vue": `
+<script setup lang="ts">
+defineProps<{
+  allowed: boolean;
+  fallback?: string;
+}>();
+</script>
+
+<template>
+  <slot v-if="allowed" />
+  <span v-else>{{ fallback ?? "" }}</span>
+</template>
+`,
+    "src/composables/useUsage.ts": `
+export interface UsagePayload {
+  customerId: string;
+  meterId: string;
+  quantity: number;
+}
+
+export const useUsage = () => {
+  const trackUsage = async (payload: UsagePayload): Promise<void> => {
+    await fetch("/api/billing/usage", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(payload),
+    });
+  };
+
+  return { trackUsage };
+};
+`,
+    "src/pages/BillingSuccess.vue": `
+<template>
+  <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+    <h1 class="text-3xl font-semibold">Payment successful</h1>
+    <p class="text-muted-foreground">
+      Your subscription is active. You can return to the app and start using
+      your new plan immediately.
+    </p>
+    <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+      Return to app
+    </a>
+  </div>
+</template>
+`,
+    "src/pages/BillingCancel.vue": `
+<template>
+  <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+    <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+    <p class="text-muted-foreground">
+      Your checkout was canceled. You can restart the process at any time.
+    </p>
+    <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+      Return to app
+    </a>
+  </div>
+</template>
+`,
+  },
+};

File: src/modules/projects/ui/components/custom-domain-dialog.tsx
Changes:
@@ -0,0 +1,155 @@
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+
+type NetlifyDomain = {
+  id: string;
+  name: string;
+  ssl_status?: string;
+  verification?: {
+    status?: string;
+  };
+};
+
+type CustomDomainDialogProps = {
+  siteId: string;
+};
+
+export const CustomDomainDialog = ({ siteId }: CustomDomainDialogProps) => {
+  const [domains, setDomains] = useState<NetlifyDomain[]>([]);
+  const [domainInput, setDomainInput] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  const loadDomains = async () => {
+    setIsLoading(true);
+    try {
+      const response = await fetch(`/api/deploy/netlify/domains?siteId=${siteId}`);
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to load domains");
+      }
+      setDomains(Array.isArray(data) ? data : []);
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to load domains");
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  const handleAdd = async () => {
+    if (!domainInput || isSubmitting) {
+      if (!domainInput) {
+        toast.error("Enter a domain");
+      }
+      return;
+    }
+
+    setIsSubmitting(true);
+    try {
+      const response = await fetch("/api/deploy/netlify/domains", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ siteId, domain: domainInput }),
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to add domain");
+      }
+      setDomainInput("");
+      await loadDomains();
+      toast.success("Domain added");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to add domain");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const handleDelete = async (domainId: string) => {
+    if (isSubmitting) {
+      return;
+    }
+
+    setIsSubmitting(true);
+    try {
+      const response = await fetch(
+        `/api/deploy/netlify/domains?siteId=${siteId}&domainId=${domainId}`,
+        { method: "DELETE" }
+      );
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to remove domain");
+      }
+      await loadDomains();
+      toast.success("Domain removed");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to remove domain");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  useEffect(() => {
+    void loadDomains();
+  }, [siteId]);
+
+  return (
+    <Dialog>
+      <DialogTrigger asChild>
+        <Button variant="outline" size="sm">Custom Domains</Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Custom Domains</DialogTitle>
+          <DialogDescription>Manage domains and DNS verification.</DialogDescription>
+        </DialogHeader>
+        <div className="space-y-3">
+          <div className="space-y-2">
+            <Input
+              placeholder="yourdomain.com"
+              value={domainInput}
+              onChange={(event) => setDomainInput(event.target.value)}
+              disabled={isSubmitting}
+            />
+            <Button onClick={handleAdd} disabled={isLoading || isSubmitting}>
+              Add Domain
+            </Button>
+          </div>
+          <div className="space-y-2">
+            {domains.length === 0 && !isLoading && (
+              <p className="text-sm text-muted-foreground">No domains configured</p>
+            )}
+            {domains.map((domain) => (
+              <div key={domain.id} className="flex items-center justify-between text-sm">
+                <div className="flex flex-col">
+                  <span>{domain.name}</span>
+                  <span className="text-xs text-muted-foreground">
+                    SSL: {domain.ssl_status ?? "unknown"} • Verification: {domain.verification?.status ?? "unknown"}
+                  </span>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => handleDelete(domain.id)}
+                  disabled={isSubmitting}
+                >
+                  Remove
+                </Button>
+              </div>
+            ))}
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+};

File: src/modules/projects/ui/components/deploy-button.tsx
Changes:
@@ -0,0 +1,49 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import { NetlifyConnectDialog } from "./netlify-connect-dialog";
+
+type DeployButtonProps = {
+  projectId: string;
+};
+
+export const DeployButton = ({ projectId }: DeployButtonProps) => {
+  const connection = useQuery(api.oauth.getConnection, { provider: "netlify" });
+  const [isDeploying, setIsDeploying] = useState(false);
+
+  const handleDeploy = async () => {
+    if (isDeploying) return;
+    setIsDeploying(true);
+
+    try {
+      const response = await fetch("/api/deploy/netlify/deploy", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ projectId }),
+      });
+
+      const payload = await response.json();
+      if (!response.ok) {
+        throw new Error(payload.error || "Deployment failed");
+      }
+
+      toast.success(`Deployment started: ${payload.siteUrl}`);
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Deployment failed");
+    } finally {
+      setIsDeploying(false);
+    }
+  };
+
+  if (!connection) {
+    return <NetlifyConnectDialog />;
+  }
+
+  return (
+    <Button size="sm" onClick={handleDeploy} disabled={isDeploying}>
+      {isDeploying ? "Deploying..." : "Deploy to Netlify"}
+    </Button>
+  );
+};

File: src/modules/projects/ui/components/deployment-dashboard.tsx
Changes:
@@ -0,0 +1,45 @@
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { DeployButton } from "./deploy-button";
+import { DeploymentStatus } from "./deployment-status";
+import { EnvVarsDialog } from "./env-vars-dialog";
+import { CustomDomainDialog } from "./custom-domain-dialog";
+import { DeploymentHistory } from "./deployment-history";
+import { PreviewDeployments } from "./preview-deployments";
+
+type DeploymentDashboardProps = {
+  projectId: string;
+};
+
+export const DeploymentDashboard = ({ projectId }: DeploymentDashboardProps) => {
+  const deployment = useQuery(api.deployments.getDeployment, { projectId });
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <div className="space-y-1">
+          <h3 className="text-sm font-medium">Netlify Deployment</h3>
+          <DeploymentStatus projectId={projectId} />
+        </div>
+        <DeployButton projectId={projectId} />
+      </div>
+
+      {deployment?.siteId && (
+        <div className="flex flex-wrap items-center gap-2">
+          <EnvVarsDialog siteId={deployment.siteId} />
+          <CustomDomainDialog siteId={deployment.siteId} />
+        </div>
+      )}
+
+      <div>
+        <h4 className="text-sm font-medium mb-2">Preview Deployments</h4>
+        <PreviewDeployments projectId={projectId} />
+      </div>
+
+      <div>
+        <h4 className="text-sm font-medium mb-2">Deployment History</h4>
+        <DeploymentHistory projectId={projectId} />
+      </div>
+    </div>
+  );
+};

File: src/modules/projects/ui/components/deployment-history.tsx
Changes:
@@ -0,0 +1,129 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { Button } from "@/components/ui/button";
+import { Loader2Icon } from "lucide-react";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+
+type DeploymentHistoryProps = {
+  projectId: Id<"projects">;
+};
+
+type DeploymentLogsDialogProps = {
+  deployId: string;
+};
+
+const DeploymentLogsDialog = ({ deployId }: DeploymentLogsDialogProps) => {
+  const [logsByDeployId, setLogsByDeployId] = useState<Record<string, string | null>>({});
+  const [isLoading, setIsLoading] = useState(false);
+
+  const fetchLogs = async () => {
+    setIsLoading(true);
+    setLogsByDeployId((prev) => ({ ...prev, [deployId]: null }));
+    try {
+      const response = await fetch(`/api/deploy/netlify/logs?deployId=${deployId}`);
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to fetch logs");
+      }
+      setLogsByDeployId((prev) => ({ ...prev, [deployId]: data.logs || "" }));
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to fetch logs");
+      setLogsByDeployId((prev) => ({ ...prev, [deployId]: null }));
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  const logs = logsByDeployId[deployId] ?? null;
+
+  return (
+    <Dialog onOpenChange={(open) => open && fetchLogs()}>
+      <DialogTrigger asChild>
+        <Button variant="outline" size="sm">
+          Logs
+        </Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Build Logs</DialogTitle>
+          <DialogDescription>Latest build output from Netlify.</DialogDescription>
+        </DialogHeader>
+        <div className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
+          {isLoading ? (
+            <div className="flex items-center justify-center py-4">
+              <Loader2Icon className="size-4 animate-spin" />
+              <span className="ml-2">Loading logs...</span>
+            </div>
+          ) : (
+            <pre className="whitespace-pre-wrap">{logs || "No logs available"}</pre>
+          )}
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+};
+
+export const DeploymentHistory = ({ projectId }: DeploymentHistoryProps) => {
+  const deployments = useQuery(api.deployments.listDeployments, { projectId });
+
+  const handleRollback = async (deployId?: string) => {
+    if (!deployId) return;
+    try {
+      const response = await fetch("/api/deploy/netlify/rollback", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ deployId }),
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Rollback failed");
+      }
+      toast.success("Rollback initiated");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Rollback failed");
+    }
+  };
+
+  if (!deployments || deployments.length === 0) {
+    return <p className="text-sm text-muted-foreground">No deployments yet</p>;
+  }
+
+  return (
+    <div className="space-y-2">
+      {deployments.map((deployment) => (
+        <div
+          key={deployment._id}
+          className="flex items-center justify-between rounded-md border p-2 text-sm"
+        >
+          <div className="flex flex-col">
+            <span>Deploy #{deployment.deployNumber ?? "-"} • {deployment.status}</span>
+            <span className="text-xs text-muted-foreground">
+              {deployment.siteUrl}
+            </span>
+          </div>
+          <div className="flex items-center gap-2">
+            {deployment.deployId && <DeploymentLogsDialog deployId={deployment.deployId} />}
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => handleRollback(deployment.deployId)}
+              disabled={!deployment.deployId}
+            >
+              Rollback
+            </Button>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+};

File: src/modules/projects/ui/components/deployment-status.tsx
Changes:
@@ -0,0 +1,86 @@
+import { useEffect, useMemo } from "react";
+import Link from "next/link";
+import { useMutation, useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { Button } from "@/components/ui/button";
+
+type DeploymentStatusProps = {
+  projectId: Id<"projects">;
+};
+
+type NetlifyStatusResponse = {
+  state?: string;
+};
+
+const statusLabelMap: Record<string, string> = {
+  pending: "Pending",
+  building: "Building",
+  ready: "Ready",
+  error: "Error",
+};
+
+export const DeploymentStatus = ({ projectId }: DeploymentStatusProps) => {
+  const deployment = useQuery(api.deployments.getDeployment, { projectId });
+  const updateDeployment = useMutation(api.deployments.updateDeployment);
+
+  const shouldPoll = useMemo(() => {
+    if (!deployment?.deployId) return false;
+    return deployment.status === "pending" || deployment.status === "building";
+  }, [deployment]);
+
+  useEffect(() => {
+    if (!shouldPoll || !deployment?.deployId) {
+      return;
+    }
+
+    let cancelled = false;
+    const pollStatus = async () => {
+      try {
+        const response = await fetch(`/api/deploy/netlify/status?deployId=${deployment.deployId}`);
+        if (!response.ok) {
+          return;
+        }
+
+        const data = (await response.json()) as NetlifyStatusResponse;
+        if (!data.state || cancelled) {
+          return;
+        }
+
+        await updateDeployment({
+          deploymentId: deployment._id,
+          status: data.state === "ready" ? "ready" : data.state === "error" ? "error" : "building",
+        });
+      } catch {
+        // ignore polling errors
+      }
+    };
+
+    const interval = setInterval(pollStatus, 10000);
+    void pollStatus();
+
+    return () => {
+      cancelled = true;
+      clearInterval(interval);
+    };
+  }, [deployment?._id, deployment?.deployId, shouldPoll, updateDeployment]);
+
+  if (!deployment) {
+    return null;
+  }
+
+  const label = statusLabelMap[deployment.status] ?? deployment.status;
+
+  return (
+    <div className="flex items-center gap-2 text-xs text-muted-foreground">
+      <span>Netlify: {label}</span>
+      {deployment.siteUrl && deployment.status === "ready" && (
+        <Button asChild variant="link" size="sm" className="h-auto p-0">
+          <Link href={deployment.siteUrl} target="_blank" rel="noreferrer">
+            View site
+          </Link>
+        </Button>
+      )}
+    </div>
+  );
+};

File: src/modules/projects/ui/components/env-vars-dialog.tsx
Changes:
@@ -0,0 +1,149 @@
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+
+type EnvVar = {
+  key: string;
+};
+
+type EnvVarsDialogProps = {
+  siteId: string;
+};
+
+export const EnvVarsDialog = ({ siteId }: EnvVarsDialogProps) => {
+  const [envVars, setEnvVars] = useState<EnvVar[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [newKey, setNewKey] = useState("");
+  const [newValue, setNewValue] = useState("");
+
+  const loadEnvVars = async () => {
+    setIsLoading(true);
+    try {
+      const response = await fetch(`/api/deploy/netlify/env-vars?siteId=${siteId}`);
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to load env vars");
+      }
+      setEnvVars(Array.isArray(data) ? data : []);
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to load env vars");
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  const handleAdd = async () => {
+    if (!newKey || !newValue || isSubmitting) {
+      if (!newKey || !newValue) {
+        toast.error("Provide a key and value");
+      }
+      return;
+    }
+
+    setIsSubmitting(true);
+    try {
+      const response = await fetch("/api/deploy/netlify/env-vars", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ siteId, key: newKey, value: newValue }),
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to set env var");
+      }
+      setNewKey("");
+      setNewValue("");
+      await loadEnvVars();
+      toast.success("Env var saved");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to set env var");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const handleDelete = async (key: string) => {
+    if (isSubmitting) {
+      return;
+    }
+
+    setIsSubmitting(true);
+    try {
+      const response = await fetch(
+        `/api/deploy/netlify/env-vars?siteId=${siteId}&key=${encodeURIComponent(key)}`,
+        { method: "DELETE" }
+      );
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to delete env var");
+      }
+      await loadEnvVars();
+      toast.success("Env var deleted");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to delete env var");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  return (
+    <Dialog onOpenChange={(open) => open && loadEnvVars()}>
+      <DialogTrigger asChild>
+        <Button variant="outline" size="sm">Env Vars</Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Environment Variables</DialogTitle>
+          <DialogDescription>Manage Netlify environment variables for this site.</DialogDescription>
+        </DialogHeader>
+        <div className="space-y-3">
+          <div className="space-y-2">
+            <Input
+              placeholder="KEY"
+              value={newKey}
+              onChange={(event) => setNewKey(event.target.value)}
+              disabled={isSubmitting}
+            />
+            <Input
+              placeholder="VALUE"
+              value={newValue}
+              onChange={(event) => setNewValue(event.target.value)}
+              disabled={isSubmitting}
+            />
+            <Button onClick={handleAdd} disabled={isLoading || isSubmitting}>
+              Save
+            </Button>
+          </div>
+          <div className="space-y-2">
+            {envVars.length === 0 && !isLoading && (
+              <p className="text-sm text-muted-foreground">No variables set</p>
+            )}
+            {envVars.map((envVar) => (
+              <div key={envVar.key} className="flex items-center justify-between text-sm">
+                <span>{envVar.key}</span>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => handleDelete(envVar.key)}
+                  disabled={isLoading || isSubmitting}
+                >
+                  Remove
+                </Button>
+              </div>
+            ))}
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+};

File: src/modules/projects/ui/components/github-export-button.tsx
Changes:
@@ -0,0 +1,70 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { useQuery } from "convex/react";
+
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import { GitHubExportModal } from "./github-export-modal";
+
+type GitHubExportButtonProps = {
+  projectId: string;
+};
+
+export const GitHubExportButton = ({ projectId }: GitHubExportButtonProps) => {
+  const connection = useQuery(api.oauth.getConnection, { provider: "github" });
+  const [open, setOpen] = useState(false);
+
+  if (connection === undefined) {
+    return (
+      <Button size="sm" variant="outline" disabled>
+        Loading...
+      </Button>
+    );
+  }
+
+  if (connection === null) {
+    return (
+      <Dialog>
+        <DialogTrigger asChild>
+          <Button size="sm" variant="outline">
+            Connect GitHub
+          </Button>
+        </DialogTrigger>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>Connect GitHub</DialogTitle>
+            <DialogDescription>
+              Connect your GitHub account to export projects.
+            </DialogDescription>
+          </DialogHeader>
+          <Button asChild>
+            <Link href="/api/import/github/auth">Continue to GitHub</Link>
+          </Button>
+        </DialogContent>
+      </Dialog>
+    );
+  }
+
+  return (
+    <>
+      <Button size="sm" variant="outline" onClick={() => setOpen(true)}>
+        Export to GitHub
+      </Button>
+      <GitHubExportModal
+        projectId={projectId}
+        open={open}
+        onOpenChange={setOpen}
+      />
+    </>
+  );
+};

File: src/modules/projects/ui/components/github-export-modal.tsx
Changes:
@@ -0,0 +1,414 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { ExternalLinkIcon, Loader2Icon } from "lucide-react";
+import { z } from "zod";
+
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+
+type GitHubRepoOption = {
+  id: number;
+  name: string;
+  fullName: string;
+  url: string;
+  isPrivate: boolean;
+  defaultBranch: string;
+};
+
+type ExportResult = {
+  exportId: string;
+  repositoryUrl: string;
+  repositoryFullName: string;
+  branch: string;
+  commitSha: string;
+  fileCount: number;
+};
+
+type GitHubExportModalProps = {
+  projectId: string;
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+};
+
+const exportResultSchema = z.object({
+  exportId: z.string(),
+  repositoryUrl: z.string(),
+  repositoryFullName: z.string(),
+  branch: z.string(),
+  commitSha: z.string(),
+  fileCount: z.number(),
+});
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+  return typeof value === "object" && value !== null;
+};
+
+const isRepoOption = (value: unknown): value is GitHubRepoOption => {
+  if (!isRecord(value)) {
+    return false;
+  }
+
+  const record = value;
+  return (
+    typeof record.id === "number" &&
+    typeof record.name === "string" &&
+    typeof record.fullName === "string" &&
+    typeof record.url === "string" &&
+    typeof record.isPrivate === "boolean" &&
+    typeof record.defaultBranch === "string"
+  );
+};
+
+const parseRepositories = (value: unknown): Array<GitHubRepoOption> => {
+  if (!Array.isArray(value)) {
+    return [];
+  }
+
+  const repos: Array<GitHubRepoOption> = [];
+  for (const repo of value) {
+    if (isRepoOption(repo)) {
+      repos.push(repo);
+    }
+  }
+
+  return repos;
+};
+
+export const GitHubExportModal = ({
+  projectId,
+  open,
+  onOpenChange,
+}: GitHubExportModalProps) => {
+  const [mode, setMode] = useState<"new" | "existing">("new");
+  const [repoName, setRepoName] = useState("");
+  const [repoDescription, setRepoDescription] = useState("");
+  const [isPrivate, setIsPrivate] = useState(false);
+  const [repos, setRepos] = useState<Array<GitHubRepoOption>>([]);
+  const [selectedRepo, setSelectedRepo] = useState("");
+  const [branch, setBranch] = useState("");
+  const [includeReadme, setIncludeReadme] = useState(true);
+  const [includeGitignore, setIncludeGitignore] = useState(true);
+  const [commitMessage, setCommitMessage] = useState("");
+  const [isLoadingRepos, setIsLoadingRepos] = useState(false);
+  const [isExporting, setIsExporting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [result, setResult] = useState<ExportResult | null>(null);
+
+  const selectedRepoOption = useMemo(() => {
+    return repos.find((repo) => repo.fullName === selectedRepo) ?? null;
+  }, [repos, selectedRepo]);
+
+  useEffect(() => {
+    if (!open) {
+      setError(null);
+      setResult(null);
+      setIsExporting(false);
+    }
+  }, [open]);
+
+  useEffect(() => {
+    if (!open) {
+      return;
+    }
+
+    const controller = new AbortController();
+    const loadRepositories = async () => {
+      setIsLoadingRepos(true);
+      setError(null);
+      try {
+        const response = await fetch("/api/github/repositories", {
+          signal: controller.signal,
+        });
+        const payload = await response.json();
+        if (!response.ok) {
+          throw new Error(payload.error || "Failed to load repositories");
+        }
+
+        const parsedRepos = parseRepositories(payload.repositories);
+        setRepos(parsedRepos);
+        if (parsedRepos.length === 0) {
+          setError("No repositories found in this GitHub account.");
+        }
+      } catch (loadError) {
+        const message =
+          loadError instanceof Error ? loadError.message : "Failed to load repositories";
+        setError(message);
+      } finally {
+        setIsLoadingRepos(false);
+      }
+    };
+
+    void loadRepositories();
+
+    return () => {
+      controller.abort();
+    };
+  }, [open]);
+
+  useEffect(() => {
+    if (mode !== "existing" || !selectedRepoOption || branch) {
+      return;
+    }
+
+    setBranch(selectedRepoOption.defaultBranch);
+  }, [mode, selectedRepoOption, branch]);
+
+  const handleExport = async () => {
+    if (isExporting) {
+      return;
+    }
+
+    setIsExporting(true);
+    setError(null);
+
+    try {
+      const payload: Record<string, unknown> = {
+        branch: branch.trim() || undefined,
+        includeReadme,
+        includeGitignore,
+        commitMessage: commitMessage.trim() || undefined,
+      };
+
+      if (mode === "existing") {
+        if (!selectedRepo) {
+          throw new Error("Select a repository to export to.");
+        }
+        payload.repositoryFullName = selectedRepo;
+      } else {
+        const trimmedName = repoName.trim();
+        if (!trimmedName) {
+          throw new Error("Repository name is required.");
+        }
+        payload.repositoryName = trimmedName;
+        payload.description = repoDescription.trim() || undefined;
+        payload.isPrivate = isPrivate;
+      }
+
+      const response = await fetch(`/api/projects/${projectId}/export/github`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload),
+      });
+
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Export failed");
+      }
+
+      const parsedResult = exportResultSchema.safeParse(data);
+      if (!parsedResult.success) {
+        throw new Error("Unexpected export response.");
+      }
+
+      setResult(parsedResult.data);
+      toast.success("GitHub export complete");
+    } catch (exportError) {
+      const message =
+        exportError instanceof Error ? exportError.message : "Export failed";
+      setError(message);
+      toast.error(message);
+    } finally {
+      setIsExporting(false);
+    }
+  };
+
+  const isReady =
+    mode === "existing" ? selectedRepo.length > 0 : repoName.trim().length > 0;
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-xl">
+        <DialogHeader>
+          <DialogTitle>Export to GitHub</DialogTitle>
+          <DialogDescription>
+            Export your latest AI-generated files to a GitHub repository.
+          </DialogDescription>
+        </DialogHeader>
+
+        {error && (
+          <div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
+            {error}
+          </div>
+        )}
+
+        {result ? (
+          <div className="space-y-4">
+            <div className="rounded-md border p-3 text-sm">
+              <div className="font-medium">{result.repositoryFullName}</div>
+              <div className="text-muted-foreground">
+                Branch: {result.branch}
+              </div>
+              <div className="text-muted-foreground">
+                Files exported: {result.fileCount}
+              </div>
+              <div className="text-muted-foreground">
+                Commit: {result.commitSha.slice(0, 10)}
+              </div>
+            </div>
+            <div className="flex justify-end gap-2">
+              <Button variant="outline" onClick={() => onOpenChange(false)}>
+                Close
+              </Button>
+              <Button asChild>
+                <a href={result.repositoryUrl} target="_blank" rel="noreferrer">
+                  Open GitHub
+                  <ExternalLinkIcon className="ml-2 size-4" />
+                </a>
+              </Button>
+            </div>
+          </div>
+        ) : (
+          <div className="space-y-5">
+            <div className="space-y-2">
+              <Label>Repository</Label>
+              <RadioGroup
+                value={mode}
+                onValueChange={(value) => {
+                  if (value === "new" || value === "existing") {
+                    setMode(value);
+                  }
+                }}
+                className="flex gap-4"
+              >
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="new" id="github-export-new" />
+                  <Label htmlFor="github-export-new">New repository</Label>
+                </div>
+                <div className="flex items-center space-x-2">
+                  <RadioGroupItem value="existing" id="github-export-existing" />
+                  <Label htmlFor="github-export-existing">Existing repository</Label>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {mode === "new" ? (
+              <div className="space-y-3">
+                <div className="space-y-2">
+                  <Label htmlFor="repo-name">Repository name</Label>
+                  <Input
+                    id="repo-name"
+                    placeholder="zapdev-export"
+                    value={repoName}
+                    onChange={(event) => setRepoName(event.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="repo-description">Description</Label>
+                  <Input
+                    id="repo-description"
+                    placeholder="Optional description"
+                    value={repoDescription}
+                    onChange={(event) => setRepoDescription(event.target.value)}
+                  />
+                </div>
+                <div className="flex items-center justify-between rounded-md border px-3 py-2">
+                  <div>
+                    <p className="text-sm font-medium">Private repository</p>
+                    <p className="text-xs text-muted-foreground">
+                      Limit visibility to collaborators.
+                    </p>
+                  </div>
+                  <Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
+                </div>
+              </div>
+            ) : (
+              <div className="space-y-3">
+                <Label>Select repository</Label>
+                <Select
+                  value={selectedRepo}
+                  onValueChange={setSelectedRepo}
+                  disabled={isLoadingRepos}
+                >
+                  <SelectTrigger className="w-full">
+                    <SelectValue placeholder="Choose a repository" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {repos.map((repo) => (
+                      <SelectItem key={repo.fullName} value={repo.fullName}>
+                        {repo.fullName}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+            )}
+
+            <div className="grid gap-3 md:grid-cols-2">
+              <div className="space-y-2">
+                <Label htmlFor="branch">Branch</Label>
+                <Input
+                  id="branch"
+                  placeholder={selectedRepoOption?.defaultBranch ?? "main"}
+                  value={branch}
+                  onChange={(event) => setBranch(event.target.value)}
+                />
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="commit-message">Commit message</Label>
+                <Input
+                  id="commit-message"
+                  placeholder="Export project from ZapDev"
+                  value={commitMessage}
+                  onChange={(event) => setCommitMessage(event.target.value)}
+                />
+              </div>
+            </div>
+
+            <div className="space-y-3">
+              <div className="flex items-center justify-between rounded-md border px-3 py-2">
+                <div>
+                  <p className="text-sm font-medium">Include README</p>
+                  <p className="text-xs text-muted-foreground">
+                    Adds a basic project overview.
+                  </p>
+                </div>
+                <Switch checked={includeReadme} onCheckedChange={setIncludeReadme} />
+              </div>
+              <div className="flex items-center justify-between rounded-md border px-3 py-2">
+                <div>
+                  <p className="text-sm font-medium">Include .gitignore</p>
+                  <p className="text-xs text-muted-foreground">
+                    Adds framework defaults.
+                  </p>
+                </div>
+                <Switch
+                  checked={includeGitignore}
+                  onCheckedChange={setIncludeGitignore}
+                />
+              </div>
+            </div>
+
+            <div className="flex justify-end gap-2">
+              <Button variant="outline" onClick={() => onOpenChange(false)}>
+                Cancel
+              </Button>
+              <Button onClick={handleExport} disabled={!isReady || isExporting}>
+                {isExporting && <Loader2Icon className="mr-2 size-4 animate-spin" />}
+                {isExporting ? "Exporting..." : "Export to GitHub"}
+              </Button>
+            </div>
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  );
+};

File: src/modules/projects/ui/components/netlify-connect-dialog.tsx
Changes:
@@ -0,0 +1,43 @@
+import Link from "next/link";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+
+export const NetlifyConnectDialog = () => {
+  const connection = useQuery(api.oauth.getConnection, { provider: "netlify" });
+
+  if (connection) {
+    return (
+      <Button variant="secondary" size="sm" disabled>
+        Netlify Connected
+      </Button>
+    );
+  }
+
+  return (
+    <Dialog>
+      <DialogTrigger asChild>
+        <Button size="sm">Connect Netlify</Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Connect Netlify</DialogTitle>
+          <DialogDescription>
+            Connect your Netlify account to deploy projects directly from ZapDev.
+          </DialogDescription>
+        </DialogHeader>
+        <Button asChild>
+          <Link href="/api/deploy/netlify/auth">Continue to Netlify</Link>
+        </Button>
+      </DialogContent>
+    </Dialog>
+  );
+};

File: src/modules/projects/ui/components/preview-deployments.tsx
Changes:
@@ -0,0 +1,96 @@
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { Button } from "@/components/ui/button";
+
+type PreviewDeploymentsProps = {
+  projectId: Id<"projects">;
+};
+
+export const PreviewDeployments = ({ projectId }: PreviewDeploymentsProps) => {
+  const deployments = useQuery(api.deployments.listDeployments, { projectId });
+  const [isCreating, setIsCreating] = useState(false);
+  const [deletingId, setDeletingId] = useState<string | null>(null);
+
+  const previews = useMemo(
+    () => (deployments ?? []).filter((deployment) => deployment.isPreview),
+    [deployments]
+  );
+
+  const handleCreatePreview = async () => {
+    setIsCreating(true);
+    try {
+      const response = await fetch("/api/deploy/netlify/deploy", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ projectId, deployType: "preview" }),
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Preview deployment failed");
+      }
+      toast.success("Preview deployment started");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Preview deployment failed");
+    } finally {
+      setIsCreating(false);
+    }
+  };
+
+  const handleDeletePreview = async (deployId?: string) => {
+    if (!deployId) return;
+    setDeletingId(deployId);
+    try {
+      const response = await fetch(`/api/deploy/netlify/preview?deployId=${deployId}`, {
+        method: "DELETE",
+      });
+      const data = await response.json();
+      if (!response.ok) {
+        throw new Error(data.error || "Failed to delete preview");
+      }
+      toast.success("Preview deleted");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to delete preview");
+    } finally {
+      setDeletingId(null);
+    }
+  };
+
+  return (
+    <div className="space-y-3">
+      <Button size="sm" onClick={handleCreatePreview} disabled={isCreating}>
+        {isCreating ? "Creating preview..." : "Create Preview Deployment"}
+      </Button>
+      {previews.length === 0 && (
+        <p className="text-sm text-muted-foreground">No preview deployments yet</p>
+      )}
+      {previews.map((deployment) => (
+        <div key={deployment._id} className="flex items-center justify-between text-sm">
+          <div className="flex flex-col">
+            <span>Preview #{deployment.deployNumber ?? "-"}</span>
+            <span className="text-xs text-muted-foreground">{deployment.status}</span>
+          </div>
+          <div className="flex items-center gap-2">
+            {deployment.siteUrl && (
+              <Button asChild variant="link" size="sm" className="h-auto p-0">
+                <a href={deployment.siteUrl} target="_blank" rel="noreferrer">
+                  Open
+                </a>
+              </Button>
+            )}
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => handleDeletePreview(deployment.deployId)}
+              disabled={!deployment.deployId || deletingId === deployment.deployId}
+            >
+              {deletingId === deployment.deployId ? "Deleting..." : "Delete"}
+            </Button>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+};

File: src/modules/projects/ui/components/project-header.tsx
Changes:
@@ -13,6 +13,9 @@ import {
 import { useState } from "react";
 
 import { Button } from "@/components/ui/button";
+import { DeployButton } from "./deploy-button";
+import { DeploymentStatus } from "./deployment-status";
+import { GitHubExportButton } from "./github-export-button";
 import {
   DropdownMenu,
   DropdownMenuContent,
@@ -126,15 +129,20 @@ export const ProjectHeader = ({ projectId }: Props) => {
         </DropdownMenu>
       </div>
 
-      <Button
-        variant="ghost"
-        size="sm"
-        onClick={handleDownload}
-        disabled={isDownloading}
-      >
-        <DownloadIcon className="size-4 mr-2" />
-        {isDownloading ? 'Downloading...' : 'Download Code'}
-      </Button>
+      <div className="flex items-center gap-3">
+        <DeploymentStatus projectId={projectId} />
+        <DeployButton projectId={projectId} />
+        <GitHubExportButton projectId={projectId} />
+        <Button
+          variant="ghost"
+          size="sm"
+          onClick={handleDownload}
+          disabled={isDownloading}
+        >
+          <DownloadIcon className="size-4 mr-2" />
+          {isDownloading ? "Downloading..." : "Download Code"}
+        </Button>
+      </div>
     </header>
   );
 };

File: src/modules/projects/ui/views/project-view.tsx
Changes:
@@ -3,7 +3,7 @@
 import Link from "next/link";
 import dynamic from "next/dynamic";
 import { Suspense, useEffect, useMemo, useState } from "react";
-import { EyeIcon, CodeIcon, CrownIcon } from "lucide-react";
+import { EyeIcon, CodeIcon, CrownIcon, RocketIcon } from "lucide-react";
 import { useQuery } from "convex/react";
 import { api } from "@/convex/_generated/api";
 
@@ -18,6 +18,7 @@ import {
 
 import { ProjectHeader } from "../components/project-header";
 import { MessagesContainer } from "../components/messages-container";
+import { DeploymentDashboard } from "../components/deployment-dashboard";
 import { ErrorBoundary } from "react-error-boundary";
 import type { Doc } from "@/convex/_generated/dataModel";
 import { filterAIGeneratedFiles } from "@/lib/filter-ai-files";
@@ -42,7 +43,7 @@ export const ProjectView = ({ projectId }: Props) => {
   const hasProAccess = usage?.planType === "pro";
 
   const [activeFragment, setActiveFragment] = useState<Doc<"fragments"> | null>(null);
-  const [tabState, setTabState] = useState<"preview" | "code">("preview");
+  const [tabState, setTabState] = useState<"preview" | "code" | "deploy">("preview");
   const [streamingFiles, setStreamingFiles] = useState<Record<string, string>>({});
 
   const explorerFiles = useMemo(() => {
@@ -119,7 +120,7 @@ export const ProjectView = ({ projectId }: Props) => {
             className="h-full gap-y-0"
             defaultValue="preview"
             value={tabState}
-            onValueChange={(value) => setTabState(value as "preview" | "code")}
+            onValueChange={(value) => setTabState(value as "preview" | "code" | "deploy")}
           >
             <div className="w-full flex items-center p-2 border-b gap-x-2">
               <TabsList className="h-8 p-0 border rounded-md">
@@ -129,6 +130,9 @@ export const ProjectView = ({ projectId }: Props) => {
                 <TabsTrigger value="code" className="rounded-md">
                   <CodeIcon /> <span>Code</span>
                 </TabsTrigger>
+                <TabsTrigger value="deploy" className="rounded-md">
+                  <RocketIcon /> <span>Deploy</span>
+                </TabsTrigger>
               </TabsList>
               <div className="ml-auto flex items-center gap-x-2">
                 {!hasProAccess && (
@@ -149,6 +153,9 @@ export const ProjectView = ({ projectId }: Props) => {
                 <FileExplorer files={explorerFiles} />
               )}
             </TabsContent>
+            <TabsContent value="deploy" className="min-h-0 p-4">
+              <DeploymentDashboard projectId={projectId} />
+            </TabsContent>
           </Tabs>
         </ResizablePanel>
       </ResizablePanelGroup>

File: src/prompt.ts
Changes:
@@ -5,4 +5,5 @@ export { REACT_PROMPT } from "./prompts/react";
 export { VUE_PROMPT } from "./prompts/vue";
 export { SVELTE_PROMPT } from "./prompts/svelte";
 export { FRAMEWORK_SELECTOR_PROMPT } from "./prompts/framework-selector";
+export { PAYMENT_INTEGRATION_RULES } from "./prompts/payment-integration";
 export { NEXTJS_PROMPT as PROMPT } from "./prompts/nextjs";

File: src/prompts/angular.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const ANGULAR_PROMPT = `
 You are a senior software engineer working in a sandboxed Angular 19 environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 Angular Specific Environment:
 - Main component: src/app/app.component.ts

File: src/prompts/nextjs.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const NEXTJS_PROMPT = `
 You are a senior Next.js engineer in a sandboxed environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 Environment:
 - Framework: Next.js 15.3.3

File: src/prompts/payment-integration.ts
Changes:
@@ -0,0 +1,9 @@
+export const PAYMENT_INTEGRATION_RULES = `
+Payment Integration (Stripe via Autumn):
+- If the user asks for payments, billing, subscriptions, or checkout flows, implement Stripe through Autumn.
+- Use server-side routes for checkout, billing portal, usage tracking, and webhook handling.
+- Always validate request payloads and verify webhook signatures.
+- Store API keys and secrets in environment variables only (no hardcoding).
+- You may call external APIs for Autumn/Stripe only when payment features are explicitly requested.
+- Provide a FeatureGate component and a usage tracking helper.
+`;

File: src/prompts/react.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const REACT_PROMPT = `
 You are a senior software engineer working in a sandboxed React 18 + Vite environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 React + Vite Specific Environment:
 - Main file: src/App.tsx

File: src/prompts/svelte.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const SVELTE_PROMPT = `
 You are a senior software engineer working in a sandboxed SvelteKit environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 SvelteKit Specific Environment:
 - Main page: src/routes/+page.svelte

File: src/prompts/vue.ts
Changes:
@@ -1,9 +1,11 @@
 import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
 
 export const VUE_PROMPT = `
 You are a senior software engineer working in a sandboxed Vue 3 + Vite environment.
 
 ${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
 
 Vue + Vite Specific Environment:
 - Main component: src/App.vue

@codecapyai
Copy link

codecapyai bot commented Jan 19, 2026

🚀 Launching Scrapybara desktop...

@codecapyai
Copy link

codecapyai bot commented Jan 19, 2026

❌ Something went wrong:

status_code: 500, body: {'detail': 'Error creating instance: HTTPSConnectionPool(host=\'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\', port=443): Max retries exceeded with url: /api/v1/namespaces/scrapybara-instances/services (Caused by NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x7f0028deea90>: Failed to resolve \'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\' ([Errno -2] Name or service not known)"))'}

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

8 issues found across 32 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="convex/schema.ts">

<violation number="1" location="convex/schema.ts:325">
P3: Missing `createdAt` field breaks schema consistency. All other tables with `updatedAt` also include `createdAt` (e.g., `deployments`, `subscriptions`, `sandboxSessions`). Consider adding `createdAt: v.number()` for debugging and audit purposes.</violation>
</file>

<file name="src/app/api/deploy/netlify/env-vars/route.ts">

<violation number="1" location="src/app/api/deploy/netlify/env-vars/route.ts:67">
P2: Validation allows `null` values which may cause issues downstream. Consider using `body.value == null` to reject both `null` and `undefined` while still allowing empty strings, or use `typeof body.value !== 'string'` for stricter type checking.</violation>
</file>

<file name="src/app/api/deploy/netlify/deploy/route.ts">

<violation number="1" location="src/app/api/deploy/netlify/deploy/route.ts:24">
P1: The fallback value `"pending"` is not handled by `normalizeDeploymentStatus`. When `deploy.state` is undefined, this will incorrectly return `"error"` instead of `"pending"`. Add `"pending"` to the first condition.</violation>
</file>

<file name="src/modules/projects/ui/components/github-export-modal.tsx">

<violation number="1" location="src/modules/projects/ui/components/github-export-modal.tsx:139">
P2: Aborting the fetch now triggers the catch path, which sets an error even though the abort is intentional. This can surface a misleading error when the modal closes/reopens. Guard against AbortError (or controller.signal.aborted) before calling setError.</violation>
</file>

<file name="src/lib/payment-templates/types.ts">

<violation number="1" location="src/lib/payment-templates/types.ts:3">
P1: Type safety regression: `keyof typeof frameworks` resolves to `string` because `frameworks` is typed as `Record<string, FrameworkData>`. This loses the specific union type that was previously enforced.

To preserve type safety while deriving from the source, either:
1. Keep the explicit union type as before, or
2. Change `frameworks.ts` to use `satisfies` instead of a type annotation:
   ```typescript
   export const frameworks = { ... } satisfies Record<string, FrameworkData>;
   ```</violation>
</file>

<file name="src/lib/payment-templates/vue.ts">

<violation number="1" location="src/lib/payment-templates/vue.ts:206">
P1: Webhook signature verification will always fail because `express.raw()` middleware is applied after the global `express.json()` has already parsed the body. The raw body is unavailable, so `String(req.body)` returns `"[object Object]"` instead of the actual payload.

To fix this, mount webhook routes BEFORE applying `express.json()`, or exclude the webhook path from JSON parsing:
```ts
app.use("/api/webhooks", webhookRoutes); // Mount before express.json()
app.use(express.json());
app.use("/api/billing", billingRoutes);
```</violation>
</file>

<file name="convex/oauth.ts">

<violation number="1" location="convex/oauth.ts:7">
P0: Critical security vulnerability: hardcoded fallback encryption key defeats the purpose of encryption. If `OAUTH_ENCRYPTION_KEY` env var is not set, the app silently uses a predictable key visible in source code, allowing anyone with codebase access to decrypt all OAuth tokens. Throw an error instead of using a fallback.</violation>
</file>

<file name="src/app/api/deploy/netlify/callback/route.ts">

<violation number="1" location="src/app/api/deploy/netlify/callback/route.ts:9">
P1: Using a hardcoded fallback secret for HMAC signature verification defeats CSRF protection. If `NETLIFY_OAUTH_STATE_SECRET` is unset, attackers can forge valid state tokens. Either throw an error when the secret is missing (like you do for `NETLIFY_CLIENT_ID`/`NETLIFY_CLIENT_SECRET`) or generate a secure random secret at startup.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In `@convex/githubExports.ts`:
- Around line 283-288: The import statement "import { internal }" is incorrectly
placed inside the action handler where
ctx.runQuery(internal.oauth.getGithubAccessToken, ...) is called; move the
import to the module's top-level import block with the other imports and delete
the in-function import lines so the handler uses the already-imported internal
symbol; ensure there are no duplicate imports and run a quick type/lint check to
confirm the module compiles.

In `@convex/oauth.ts`:
- Around line 7-8: Replace the insecure fallback for ENCRYPTION_KEY so the app
fails fast when OAUTH_ENCRYPTION_KEY is not set: remove the default
"fallback-key-change-me-in-production" and add a startup check that throws a
clear error (or exits) if process.env.OAUTH_ENCRYPTION_KEY is missing, leaving
ALGORITHM as-is; update any code that references ENCRYPTION_KEY to use the
validated/required value so encryption never silently falls back to an insecure
key.

In `@src/app/api/deploy/netlify/callback/route.ts`:
- Line 9: The code uses a hardcoded fallback for NETLIFY_OAUTH_STATE_SECRET
which weakens CSRF protection; remove the fallback and validate the secret in
the request handler (the module-level constant NETLIFY_OAUTH_STATE_SECRET and
the callback route handler) so that if the env var is missing you short-circuit
with an error response (e.g., return a 500 JSON error like "OAuth state secret
not configured") instead of continuing with a weak default; ensure the handler
checks NETLIFY_OAUTH_STATE_SECRET before using it.

In `@src/app/api/deploy/netlify/deploy/route.ts`:
- Around line 22-34: normalizeDeploymentStatus currently treats an input of
"pending" as an unknown status and returns "error", which causes fresh deploys
(where the caller may pass "pending" as a fallback) to be recorded as errors;
update normalizeDeploymentStatus to explicitly handle the "pending" value (e.g.,
check if normalized === "pending" and return "pending" before falling back to
"error"), ensuring you compare the lowercased (and optionally trimmed) status
string so callers that pass "pending" (such as the Netlify deploy route) are
recorded correctly.
- Around line 114-116: convex.query(api.projects.get, { projectId }) can return
null, so before dereferencing project.name update the handler around
getConvexClientWithAuth / convex.query to check if project is null and return a
404 response (or throw a NotFound error) when missing; specifically, after
calling convex.query(api.projects.get, { projectId }) verify the returned
project object and handle the missing case rather than accessing project.name
directly.

In `@src/app/api/projects/`[projectId]/export/github/route.ts:
- Line 44: The code calls getToken in the line creating connection (const
connection = await fetchQuery(api.oauth.getConnection, { provider: "github" }, {
token: (await getToken()) ?? undefined });) but getToken is not imported,
causing a runtime error; fix by adding getToken to the imports from
"@/lib/auth-server" (or the correct module that exports it) alongside other
imports so the function is available at runtime, and run a quick build/test to
ensure no other missing imports remain.
- Around line 53-70: The code uses an undefined accessToken when calling
getRepository and createRepository; either obtain the token before use or stop
passing it and delegate repository ops to the backend action that handles
tokens. Fix by (A) retrieving the token into accessToken (using the same
helper/flow used elsewhere in this route/module) before calling
getRepository/createRepository, or (B) refactor to call the Convex/ backend
action that encapsulates token handling and pass only repository details (i.e.,
remove accessToken from getRepository/createRepository calls and instead invoke
the action that performs repository creation/lookup). Ensure you update the
calls around getRepository, createRepository and the repository variable to
match the chosen approach.

In `@src/modules/projects/ui/components/github-export-modal.tsx`:
- Around line 133-156: The catch block in loadRepositories currently sets
setError for any thrown error including fetch aborts; update the catch to detect
an AbortError (check error.name === "AbortError") and skip calling setError when
aborted so the modal doesn't show a stale error after controller.abort(); keep
the rest of the error handling intact for non-abort errors and ensure
setIsLoadingRepos(false) still runs in finally.
♻️ Duplicate comments (4)
src/lib/payment-provider.ts (1)

204-231: Unsafe null as T type assertion bypasses type safety.

The change from undefined as T to null as T doesn't resolve the core issue: methods like createCheckoutSession, updateSubscription, and checkFeature declare non-nullable return types, but a 204 response would return null cast to those types, causing runtime errors when callers access properties.

Consider one of these approaches:

  1. Use a conditional return type or overloads to distinguish void-returning endpoints
  2. Throw an error for unexpected 204 responses on endpoints that require a body
  3. Return a discriminated union with a success flag
🔧 Suggested fix: Throw on unexpected 204 for non-void endpoints
   private async request<T>(
     path: string,
-    options: AutumnRequestOptions
+    options: AutumnRequestOptions & { allowEmpty?: boolean }
   ): Promise<T> {
     const url = `${this.baseUrl}${path}`;
     const response = await fetch(url, {
       ...options,
       headers: {
         "Content-Type": "application/json",
         Authorization: `Bearer ${this.apiKey}`,
         ...(options.headers ?? {}),
       },
       body: options.body ? JSON.stringify(options.body) : undefined,
     });

     if (!response.ok) {
       const errorText = await response.text();
       throw new Error(
         `Autumn API request failed: ${response.status} ${response.statusText} - ${errorText}`
       );
     }

     if (response.status === 204) {
+      if (!options.allowEmpty) {
+        throw new Error(`Unexpected 204 No Content response from ${path}`);
+      }
-      return null as T;
+      return undefined as unknown as T;
     }

     return (await response.json()) as T;
   }

Then for trackUsage, pass { allowEmpty: true }:

async trackUsage(input: UsageEvent): Promise<void> {
  await this.request<void>("/v1/usage", {
    method: "POST",
    body: { ... },
    allowEmpty: true,
  });
}
src/lib/payment-templates/vue.ts (1)

107-143: PATCH and DELETE routes missing try-catch blocks.

Unlike other routes in this template, PATCH and DELETE handlers don't wrap autumn.request() in try-catch. In Express 4.x, unhandled async errors cause hanging requests or unhandled promise rejections.

🐛 Proposed fix
 router.patch("/subscription", async (req: Request, res: Response) => {
+  try {
     const { subscriptionId, productId } = req.body as {
       subscriptionId?: string;
       productId?: string;
     };
     if (!subscriptionId || !productId) {
       res.status(400).json({ error: "Invalid payload" });
       return;
     }
     const updated = await autumn.request<unknown>(
       \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
       {
         method: "PATCH",
         body: { productId },
       }
     );
     res.json(updated);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
 });

 router.delete("/subscription", async (req: Request, res: Response) => {
+  try {
     const { subscriptionId, cancelAtPeriodEnd } = req.body as {
       subscriptionId?: string;
       cancelAtPeriodEnd?: boolean;
     };
     if (!subscriptionId) {
       res.status(400).json({ error: "Invalid payload" });
       return;
     }
     const canceled = await autumn.request<unknown>(
       \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`,
       {
         method: "POST",
         body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
       }
     );
     res.json(canceled);
+  } catch (error) {
+    res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+  }
 });
convex/deployments.ts (1)

111-131: Remove userId from public query responses.

The return schema exposes userId (Clerk ID) to clients. Per coding guidelines, Clerk user IDs should never be exposed in public APIs. This field should be removed from the return validators.

🔒 Proposed fix

Remove userId: v.string(), from lines 115 and 158 in the return validators.

convex/githubExports.ts (1)

21-36: Remove userId from public query response schema.

The githubExportRecord includes userId which exposes Clerk IDs to clients via the list, get, and getLatest queries. This violates the guideline to never expose Clerk user IDs in public APIs.

🧹 Nitpick comments (8)
src/lib/payment-templates/angular.ts (1)

298-314: Client methods lack error handling for failed requests.

checkFeature parses JSON without checking response.ok, which may throw on error responses. trackUsage silently ignores failures. For billing accuracy, consider adding error handling.

♻️ Suggested improvement
   async checkFeature(customerId: string, featureId: string): Promise<boolean> {
     const response = await fetch("/api/billing/feature-check", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ customerId, featureId }),
     });
+    if (!response.ok) {
+      return false;
+    }
     const data = (await response.json()) as { allowed?: boolean };
     return data.allowed === true;
   }

   async trackUsage(customerId: string, meterId: string, quantity: number): Promise<void> {
-    await fetch("/api/billing/usage", {
+    const response = await fetch("/api/billing/usage", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ customerId, meterId, quantity }),
     });
+    if (!response.ok) {
+      console.error("Failed to track usage");
+    }
   }
src/lib/payment-templates/react.ts (1)

344-350: Consider adding error handling to trackUsage.

This function silently ignores API failures, which could lead to billing inaccuracies going unnoticed.

♻️ Suggested improvement
 export async function trackUsage(payload: UsagePayload): Promise<void> {
-  await fetch("/api/billing/usage", {
+  const response = await fetch("/api/billing/usage", {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify(payload),
   });
+  if (!response.ok) {
+    console.error("Failed to track usage");
+  }
 }
src/lib/payment-templates/svelte.ts (1)

305-311: Consider adding error handling to trackUsage.

Similar to other templates, this function silently ignores API failures.

♻️ Suggested improvement
 export const trackUsage = async (payload: UsagePayload): Promise<void> => {
-  await fetch("/api/billing/usage", {
+  const response = await fetch("/api/billing/usage", {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify(payload),
   });
+  if (!response.ok) {
+    console.error("Failed to track usage");
+  }
 };
src/lib/payment-templates/nextjs.ts (1)

300-331: Consider wrapping JSON.parse in try-catch for robustness.

While signature verification should prevent malformed payloads, wrapping JSON.parse(rawBody) in try-catch would provide an additional safety layer and consistent error responses for edge cases.

♻️ Suggested improvement
   if (!verifySignature(signature, rawBody, secret)) {
     return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
   }

-  const event = JSON.parse(rawBody) as { type: string; data: unknown };
+  let event: { type: string; data: unknown };
+  try {
+    event = JSON.parse(rawBody) as { type: string; data: unknown };
+  } catch {
+    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+  }

   switch (event.type) {
convex/oauth.ts (1)

84-98: Consider excluding accessToken from public query response.

getConnection returns the full connection record including the encrypted accessToken field to the client. While encrypted, exposing this blob to the client is unnecessary and increases attack surface. Consider returning only the fields needed for UI display.

♻️ Proposed refactor
 export const getConnection = query({
   args: {
     provider: oauthProviderEnum,
   },
+  returns: v.union(
+    v.null(),
+    v.object({
+      _id: v.id("oauthConnections"),
+      provider: oauthProviderEnum,
+      scope: v.string(),
+      metadata: v.optional(v.any()),
+      createdAt: v.number(),
+      updatedAt: v.number(),
+    })
+  ),
   handler: async (ctx, args) => {
     const userId = await requireAuth(ctx);
 
-    return await ctx.db
+    const connection = await ctx.db
       .query("oauthConnections")
       .withIndex("by_userId_provider", (q) =>
         q.eq("userId", userId).eq("provider", args.provider)
       )
       .first();
+
+    if (!connection) return null;
+    
+    const { accessToken, refreshToken, ...safe } = connection;
+    return safe;
   },
 });
convex/schema.ts (1)

53-58: Case inconsistency with existing status enums.

The new githubExportStatusEnum uses lowercase values ("pending", "processing") while existing enums like importStatusEnum and messageStatusEnum use uppercase ("PENDING", "PROCESSING"). Consider aligning for consistency across the schema.

src/app/api/deploy/netlify/domains/route.ts (1)

64-70: Validate request JSON instead of casting to DomainPayload.
request.json() is untyped; the cast can hide malformed inputs. Prefer a type guard to validate siteId/domain before using them. As per coding guidelines, avoid as type assertions.

🛠️ Suggested refactor
 type DomainPayload = {
   siteId: string;
   domain: string;
 };

+const isRecord = (value: unknown): value is Record<string, unknown> =>
+  typeof value === "object" && value !== null;
+
+const isDomainPayload = (value: unknown): value is DomainPayload =>
+  isRecord(value) &&
+  typeof value.siteId === "string" &&
+  typeof value.domain === "string";
+
 export async function POST(request: Request) {
   try {
     const user = await getUser();
     if (!user?.id) {
       return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
     }

-    const body = (await request.json()) as DomainPayload;
-    if (!body.siteId || !body.domain) {
+    const body = await request.json();
+    if (!isDomainPayload(body)) {
       return NextResponse.json({ error: "Missing siteId or domain" }, { status: 400 });
     }
+    const { siteId, domain } = body;

     const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
-    const domain = await netlifyClient.addDomain(body.siteId, body.domain);
+    const domain = await netlifyClient.addDomain(siteId, domain);
src/app/api/deploy/netlify/deploy/route.ts (1)

113-113: Avoid as Id<"projects"> type assertion.

Prefer a validated/typed helper to produce the branded Id instead of asserting (e.g., a Convex-provided validator or a local wrapper). Please verify the recommended approach in your Convex/TS setup. As per coding guidelines, avoid as type assertions.

Add provider selection/templates and persist choices so generated projects get the right integration rules, plus add color theme selection and refresh the roadmap.
@codecapyai
Copy link

codecapyai bot commented Jan 19, 2026

🔍 Analyzing PR changes and preparing to run tests...

@codecapyai
Copy link

codecapyai bot commented Jan 19, 2026

🚀 Launching Scrapybara desktop...

@codecapyai
Copy link

codecapyai bot commented Jan 19, 2026

❌ Something went wrong:

status_code: 500, body: {'detail': 'Error creating instance: HTTPSConnectionPool(host=\'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\', port=443): Max retries exceeded with url: /api/v1/namespaces/scrapybara-instances/services (Caused by NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x7f7829741190>: Failed to resolve \'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\' ([Errno -2] Name or service not known)"))'}

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

6 issues found across 23 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="src/lib/database-templates/drizzle-neon/nextjs.ts">

<violation number="1" location="src/lib/database-templates/drizzle-neon/nextjs.ts:106">
P2: The `redirectTo` query parameter set by the middleware is never used. The form hardcodes `callbackURL: "/dashboard"` and `router.push("/dashboard")`, ignoring the intended redirect destination. Users won't be returned to their original protected route after signing in.</violation>
</file>

<file name="src/prompts/shared.ts">

<violation number="1" location="src/prompts/shared.ts:223">
P2: The shared prompt now mandates databaseTemplates for all frameworks, but the tool only supports Next.js. For non-Next.js frameworks this instruction will produce tool errors and leave the agent without templates. Clarify the Next.js-only limitation or add a fallback path in the prompt.</violation>
</file>

<file name="src/lib/database-templates/convex/nextjs.ts">

<violation number="1" location="src/lib/database-templates/convex/nextjs.ts:50">
P1: Incorrect usage of `toNextJsHandler`: it expects an auth instance directly, not an async callback function. This will cause authentication routes to fail. The auth instance should be created once at module level and passed directly to `toNextJsHandler(auth)`.</violation>
</file>

<file name="src/components/color-theme-picker.tsx">

<violation number="1" location="src/components/color-theme-picker.tsx:29">
P3: Add an explicit button type to avoid accidental form submissions when the picker is rendered inside a form.</violation>
</file>

<file name="src/components/color-theme-provider.tsx">

<violation number="1" location="src/components/color-theme-provider.tsx:52">
P2: Wrap `localStorage.getItem` in a try-catch block to prevent crashes in Safari private browsing mode or when localStorage is unavailable. This is a common source of runtime errors in production.</violation>

<violation number="2" location="src/components/color-theme-provider.tsx:70">
P2: Wrap `localStorage.setItem` in a try-catch block to prevent crashes when storage is unavailable or quota is exceeded.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ROADMAP.md (1)

1-179: Move ROADMAP.md to the /explanations/ directory.

As per coding guidelines, all .md documentation files (except CLAUDE.md and README.md) should be placed in the explanations/ directory, never in the project root.

📁 Relocation required

Move this file from:

ROADMAP.md

To:

explanations/ROADMAP.md
🤖 Fix all issues with AI agents
In `@ROADMAP.md`:
- Line 71: Replace the redundant phrase "SEO Optimization" in the ROADMAP bullet
point with a non-redundant term; update the list item that currently reads "SEO
Optimization: Structured data, meta tags, OpenGraph" to use either "SEO:
Structured data, meta tags, OpenGraph" or the unabbreviated "Search Engine
Optimization: Structured data, meta tags, OpenGraph" so the wording is not
repetitive.

In `@src/lib/database-templates/convex/nextjs.ts`:
- Around line 43-58: The template incorrectly constructs createAuth inline with
a ConvexHttpClient and uses a type assertion; instead, move the createAuth
construction into your Convex server code (e.g., a module that exports the
created auth and its handler), then import and pass auth.handler into
toNextJsHandler in the Next.js API file (removing the as Parameters<typeof
createAuth>[0] assertion). Also validate process.env.NEXT_PUBLIC_CONVEX_URL at
startup (throw a clear error if missing) rather than using a non-null assertion
so the ConvexHttpClient is only constructed with a guaranteed URL.

In `@src/lib/database-templates/index.ts`:
- Around line 50-56: isDatabaseSupported currently uses the `in` operator which
can return true for keys present with undefined values in the Partial<Record>
`templates`; change it to the same pattern as getDatabaseTemplate by looking up
`templates[key]` and returning a boolean based on the value (e.g.,
`!!templates[key]` or `templates[key] !== undefined && templates[key] !== null`)
so that TemplateKey presence only counts when the template value is defined;
update the isDatabaseSupported(provider, framework) implementation to compute
`const key: TemplateKey = \`${provider}-${framework}\`` then check
`templates[key]` instead of using `key in templates`.

In `@src/prompts/database-integration.ts`:
- Around line 34-37: Update the two npm install command occurrences that
currently read "npm install drizzle-orm `@neondatabase/serverless` better-auth"
and "npm install -D drizzle-kit" (and the repeated instance later) to include
the --yes flag (e.g., "npm install --yes drizzle-orm `@neondatabase/serverless`
better-auth" and "npm install -D --yes drizzle-kit") so the installs run
non-interactively and conform to tool policy.
♻️ Duplicate comments (1)
convex/schema.ts (1)

332-337: Missing createdAt field breaks schema consistency.

As noted in a previous review, all other tables with updatedAt also include createdAt (e.g., deployments, subscriptions, sandboxSessions). Adding createdAt would maintain consistency and aid debugging.

🧹 Nitpick comments (15)
src/prompts/database-selector.ts (1)

60-66: Centralize provider unions to avoid drift.

DatabaseSelection duplicates other provider unions in the repo (e.g., database templates/agent types). Consider sourcing this from a single shared type so adding a provider doesn’t require multiple updates.

src/lib/database-templates/types.ts (1)

1-4: Use @/lib/... for internal lib imports.

This file is under src/lib, so internal imports should use the @/lib alias for consistency. As per coding guidelines.

♻️ Proposed change
-import type { frameworks } from "../frameworks";
+import type { frameworks } from "@/lib/frameworks";
src/lib/database-templates/env-example.ts (1)

1-38: Sanitize large template strings before persistence.

These sizable env example strings live under src/lib. If they’re persisted (e.g., via databaseTemplates tooling), they should be passed through sanitizeAnyForDatabase() to prevent NULL-byte issues in Postgres. As per coding guidelines.

♻️ Proposed change (if you persist these values)
+import { sanitizeAnyForDatabase } from "@/lib/sanitizeAnyForDatabase";
+
-export const drizzleNeonEnvExample = `# Database (Neon PostgreSQL)
+export const drizzleNeonEnvExample = sanitizeAnyForDatabase(`# Database (Neon PostgreSQL)
 ...
-# GITHUB_CLIENT_SECRET="your-github-client-secret"
-`;
+# GITHUB_CLIENT_SECRET="your-github-client-secret"
+`);
 
-export const convexEnvExample = `# Convex
+export const convexEnvExample = sanitizeAnyForDatabase(`# Convex
 ...
-# GITHUB_CLIENT_SECRET="your-github-client-secret"
-`;
+# GITHUB_CLIENT_SECRET="your-github-client-secret"
+`);
src/lib/database-templates/drizzle-neon/nextjs.ts (1)

122-166: Consider adding focus-visible states for accessibility compliance.

Per coding guidelines, form inputs should include focus-visible:ring-ring/50 focus-visible:ring-[3px] classes. The current template uses basic border styling without visible focus indicators.

♻️ Suggested input styling enhancement
         <input
           id="email"
           type="email"
           value={email}
           onChange={(e) => setEmail(e.target.value)}
           required
-          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
+          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring"
           placeholder="you@example.com"
         />
src/lib/database-templates/convex/nextjs.ts (1)

339-377: Client-side dashboard differs from drizzle-neon's server-side pattern.

This template uses client-side auth checking with useEffect redirect, while the drizzle-neon template uses server-side session validation. Both patterns work, but the inconsistency may confuse users comparing templates. The client-side approach briefly shows content before redirect if unauthenticated.

Consider documenting this architectural difference or aligning both templates to use middleware-based protection consistently, as the drizzle-neon template includes middleware while this one doesn't.

src/lib/database-templates/index.ts (1)

28-48: Hardcoded frameworks array may drift from type definition.

The frameworks array duplicates the DatabaseFramework type values. If new frameworks are added to the type, this array must be manually updated.

Consider deriving from a single source of truth if the DatabaseFramework type is defined as a union of string literals that can be extracted, or add a comment noting the maintenance requirement.

src/lib/database-templates/convex/shared.ts (1)

34-71: Template queries missing returns validators per Convex guidelines.

The getCurrentUser and getAuthUserId queries in the template don't include returns validators. Per coding guidelines, all Convex functions should include argument and return validators.

♻️ Suggested template improvement
 export const getCurrentUser = query({
   args: {},
+  returns: v.union(v.object({ ... }), v.null()),
   handler: async (ctx) => {
     return authComponent.getAuthUser(ctx);
   },
 });

 export const getAuthUserId = query({
   args: {},
+  returns: v.union(v.id("users"), v.null()),
   handler: async (ctx) => {
     const user = await authComponent.getAuthUser(ctx);
     return user?._id ?? null;
   },
 });

Note: The exact return type depends on @convex-dev/better-auth's getAuthUser return type. Consider adding a TODO comment in the template for users to complete the validators.

src/lib/database-templates/drizzle-neon/shared.ts (1)

80-99: Non-null assertions on DATABASE_URL in templates.

Both drizzleDbClient (line 84) and drizzleConfig (line 96) use process.env.DATABASE_URL!. While common in templates, this will cause cryptic errors if the env var is missing.

Consider adding runtime validation in the db client template:

const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
  throw new Error("DATABASE_URL environment variable is required");
}
const sql = neon(databaseUrl);
convex/schema.ts (1)

59-64: Enum casing inconsistency with existing patterns.

The new githubExportStatusEnum uses lowercase values (pending, processing, complete, failed), while the similar importStatusEnum uses UPPERCASE (PENDING, PROCESSING, COMPLETE, FAILED). Consider aligning for consistency.

♻️ Suggested alignment with existing patterns
 export const githubExportStatusEnum = v.union(
-  v.literal("pending"),
-  v.literal("processing"),
-  v.literal("complete"),
-  v.literal("failed")
+  v.literal("PENDING"),
+  v.literal("PROCESSING"),
+  v.literal("COMPLETE"),
+  v.literal("FAILED")
 );

Alternatively, document the casing convention if lowercase is intentional for newer enums.

convex/projects.ts (2)

327-333: Inconsistent handling of optional field updates.

The databaseProvider field uses a truthiness check (args.databaseProvider && ...), which will correctly include all enum values ("NONE", "DRIZZLE_NEON", "CONVEX") since they're all truthy strings. However, this pattern differs from modelPreference which uses !== undefined. More critically, if a user wants to "unset" the databaseProvider (though the enum doesn't have an explicit unset value), the current pattern won't allow setting it to undefined.

Consider using a consistent pattern across all optional fields:

♻️ Suggested consistent pattern
     await ctx.db.patch(args.projectId, {
       ...(args.name && { name: args.name }),
       ...(args.framework && { framework: args.framework }),
-      ...(args.databaseProvider && { databaseProvider: args.databaseProvider }),
+      ...(args.databaseProvider !== undefined && { databaseProvider: args.databaseProvider }),
       ...(args.modelPreference !== undefined && { modelPreference: args.modelPreference }),
       updatedAt: Date.now(),
     });

562-568: Same inconsistency in updateForUser handler.

Apply the same fix here for consistency with the update mutation and the modelPreference field pattern.

♻️ Suggested fix
     await ctx.db.patch(args.projectId, {
       ...(args.name && { name: args.name }),
       ...(args.framework && { framework: args.framework }),
-      ...(args.databaseProvider && { databaseProvider: args.databaseProvider }),
+      ...(args.databaseProvider !== undefined && { databaseProvider: args.databaseProvider }),
       ...(args.modelPreference !== undefined && { modelPreference: args.modelPreference }),
       updatedAt: Date.now(),
     });
src/modules/projects/ui/components/project-header.tsx (1)

145-158: Consider adding text-muted-foreground to the download icon for consistency.

The DownloadIcon uses size-4 which is correct per guidelines, but other icons in the codebase typically include text-muted-foreground for consistent styling.

♻️ Optional styling improvement
-          <DownloadIcon className="size-4 mr-2" />
+          <DownloadIcon className="size-4 mr-2 text-muted-foreground" />

The overall integration of DeploymentStatus, DeployButton, and GitHubExportButton components is clean and follows the established patterns. The gap-3 spacing is consistent with the design system.

src/agents/types.ts (1)

4-4: Duplicate type definition detected.

DatabaseProvider is also defined in src/lib/database-templates/types.ts with identical values. This duplication could lead to type drift if one is updated without the other.

Consider importing from a single source of truth:

♻️ Consolidate type definition
-export type DatabaseProvider = "none" | "drizzle-neon" | "convex";
+export type { DatabaseProvider } from "@/lib/database-templates/types";

Alternatively, if the agents module should be the source of truth, update src/lib/database-templates/types.ts to import from here.

src/lib/themes.ts (1)

262-264: Consider using the DEFAULT_COLOR_THEME constant for fallback consistency.

The fallback uses COLOR_THEMES[0] directly, but using the constant would be more maintainable and explicit:

♻️ Use constant for fallback
 export function getColorTheme(id: string): ColorTheme {
-  return COLOR_THEMES.find((theme) => theme.id === id) || COLOR_THEMES[0];
+  return COLOR_THEMES.find((theme) => theme.id === id) ?? getColorTheme(DEFAULT_COLOR_THEME);
 }

Or to avoid potential recursion, use a non-null assertion since we control the data:

 export function getColorTheme(id: string): ColorTheme {
-  return COLOR_THEMES.find((theme) => theme.id === id) || COLOR_THEMES[0];
+  const theme = COLOR_THEMES.find((t) => t.id === id);
+  if (theme) return theme;
+  // Fallback to default theme - we know it exists
+  return COLOR_THEMES.find((t) => t.id === DEFAULT_COLOR_THEME)!;
 }

The current implementation works correctly since COLOR_THEMES[0] is the "default" theme, but tying it to the constant makes the relationship explicit.

src/agents/code-agent.ts (1)

1114-1130: Consider persisting database provider earlier, like framework detection.

The database provider is persisted at the end of a successful run, but framework is persisted immediately after detection (line 408). If the agent run fails partway through, framework will be saved but database provider won't be. Consider moving database persistence to after detection for consistency.

♻️ Suggested refactor

Move the persistence logic to after detection (around line 419-422):

     if (needsDatabaseDetection) {
       selectedDatabase = detectedDatabase;
       console.log("[INFO] Detected database provider:", selectedDatabase);
+
+      try {
+        await convex.mutation(api.projects.updateForUser, {
+          userId: project.userId,
+          projectId: projectId as Id<"projects">,
+          databaseProvider: databaseProviderToConvexEnum(selectedDatabase),
+        });
+        console.log("[INFO] Database provider saved to project");
+      } catch (error) {
+        console.warn("[WARN] Failed to save database provider to project:", error);
+      }
     }

Then remove or simplify lines 1114-1130 (the existing persistence at end of run).

- Added creation timestamps to project deployment counters for better tracking.
- Improved GitHub export functionality by integrating a new action to retrieve the current user's GitHub access token.
- Updated OAuth handling to ensure required environment variables are set, enhancing security and error management.
- Enhanced error handling in various API routes and improved user feedback in UI components.

These changes streamline deployment processes and strengthen OAuth security, contributing to a more robust application.
@codecapyai
Copy link

codecapyai bot commented Jan 20, 2026

🔍 Analyzing PR changes and preparing to run tests...

@codecapyai
Copy link

codecapyai bot commented Jan 20, 2026

🚀 Launching Scrapybara desktop...

@codecapyai
Copy link

codecapyai bot commented Feb 6, 2026

🚀 Launching Scrapybara desktop...

@codecapyai
Copy link

codecapyai bot commented Feb 6, 2026

❌ Something went wrong:

status_code: 500, body: {'detail': 'Error creating instance: HTTPSConnectionPool(host=\'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\', port=443): Max retries exceeded with url: /api/v1/namespaces/scrapybara-instances/services (Caused by NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x7f9aba318710>: Failed to resolve \'dd71ce9e4c14175cfb2d4b4d613159f4.sk1.us-west-1.eks.amazonaws.com\' ([Errno -2] Name or service not known)"))'}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@convex/oauth.ts`:
- Around line 100-112: Both getGithubAccessTokenForCurrentUser and
getAnthropicAccessTokenForCurrentUser are calling other actions via
ctx.runAction inside the same Node runtime; extract the shared token-fetching
logic into a plain async helper (e.g., async function
fetchAccessTokenForUser(userId, provider) or two small helpers
fetchGithubAccessTokenForUser / fetchAnthropicAccessTokenForUser) and have the
action handlers (getGithubAccessTokenForCurrentUser,
getAnthropicAccessTokenForCurrentUser) call that helper instead of
ctx.runAction; update any internal.oauth.getGithubAccessToken /
internal.oauth.getAnthropicAccessToken callers to delegate to the new helper or
remove those internal action wrappers if they aren’t used across runtimes,
ensuring you keep the same return shapes (string | null) and reuse
ctx.auth.getUserIdentity() to obtain identity.subject as before.
- Around line 92-96: The current try/catch around
decryptToken(connection.accessToken) silently swallows all errors; change the
catch to capture the error (catch (err)) and log it before returning null so
decryption failures are observable. Update the block in convex/oauth.ts where
decryptToken is called (reference decryptToken and connection.accessToken) to
call an available logger (e.g., console.error or the module's logger) with a
clear message including identifying context (e.g., connection id or user info)
and the error object, then return null as before.
- Around line 55-75: The public action currently returns the raw Convex document
id (returns: v.id("oauthConnections")) and exposes it from the handler (returns
connectionId); change the action to return v.null() and update the handler in
storeConnection so it does not return the stored document id — call
ctx.runMutation(internal.oauthQueries.storeConnectionInternal) as before to
persist the connection (keep variables like
encryptedAccessToken/encryptedRefreshToken and connectionId if needed for local
logic) but end the handler by returning null instead of connectionId to avoid
exposing internal document IDs.

In `@src/modules/projects/ui/components/netlify-cli-dialog.tsx`:
- Around line 36-38: The error handling currently calls response.json()
unguarded which can throw for non-JSON bodies; update the block that checks if
(!response.ok) to try parsing JSON in a try/catch (e.g., attempt await
response.json()), if parsing fails fall back to await response.text(), then
throw a new Error that includes response.status and the parsed body (or raw
text) so the HTTP status and response content are preserved; reference the
response variable and response.json()/response.text() usage when making the
change.
- Line 1: Add the Next.js client directive by inserting "use client" as the very
first line of src/modules/projects/ui/components/netlify-cli-dialog.tsx (before
the import of useState) so this component (which uses useState and browser APIs
like window.URL and document.createElement) is explicitly marked a client
component and won’t break if imported into a Server Component.
🧹 Nitpick comments (6)
convex/oauth.ts (1)

24-32: Use 12-byte IV for AES-GCM per NIST recommendation.

AES-GCM is designed for a 96-bit (12-byte) nonce. A 16-byte IV works but triggers an internal GHASH step that slightly reduces the security margin and adds overhead. NIST SP 800-38D recommends 12 bytes.

Suggested fix
-  const iv = crypto.randomBytes(16);
+  const iv = crypto.randomBytes(12);
src/modules/projects/ui/components/netlify-cli-dialog.tsx (5)

96-96: Icon sizing: prefer size-4 per coding guidelines.

All icons in this file use h-4 w-4 (lines 96, 129, 131, 149, 151, 171, 173, 191, 193, 203, 212, 270), but the project coding guidelines specify size-4 as the default icon size class. size-4 is the Tailwind shorthand equivalent (width + height in one utility).

Example fix for one instance:

-<Terminal className="mr-2 h-4 w-4" />
+<Terminal className="mr-2 size-4" />

Apply similarly to all icon usages.

As per coding guidelines, "Use lucide-react as the icon library with default size size-4 (16px)."


202-204: Consider adding a spinner for the loading state.

When isDownloading is true, the button text changes to "Preparing..." but there's no visual loading indicator. The project guidelines recommend using animate-spin for loading states. Consider showing a spinner icon:

-                <Download className="mr-2 size-4" />
-                {isDownloading ? "Preparing..." : "Download Project ZIP"}
+                {isDownloading ? (
+                  <Download className="mr-2 size-4 animate-spin" />
+                ) : (
+                  <Download className="mr-2 size-4" />
+                )}
+                {isDownloading ? "Preparing..." : "Download Project ZIP"}

Alternatively, import Loader2 from lucide-react for a dedicated spinner icon, which is more conventional.

As per coding guidelines, "Implement loading states with CSS animations: Spinner using animate-spin."


115-199: Repetitive command card pattern could be extracted.

The four command cards (install, login, init, deploy) follow an identical structure differing only in title, description, command text, and key. Consider extracting a reusable CommandCard component or mapping over an array of command definitions to reduce ~80 lines of repetition to ~15:

Sketch
const commands = [
  { key: "install", title: "1. Install Netlify CLI", command: installCommand },
  { key: "login", title: "2. Login to Netlify", command: loginCommand },
  { key: "init", title: "3. Initialize your site", command: initCommand, description: "Navigate to your project folder and run:" },
  { key: "deploy", title: "4. Deploy", command: deployCommand },
] as const;

// Then map:
{commands.map(({ key, title, command, description }) => (
  <div key={key} className="rounded-lg bg-muted p-4">
    <h4 className="mb-2 font-medium">{title}</h4>
    {description && <p className="mb-2 text-sm text-muted-foreground">{description}</p>}
    <div className="flex items-center gap-2">
      <code className="flex-1 rounded bg-background px-3 py-2 text-sm">{command}</code>
      <Button size="sm" variant="ghost" onClick={() => copyToClipboard(command, key)}>
        {copiedCommand === key ? <Check className="size-4" /> : <Copy className="size-4" />}
      </Button>
    </div>
  </div>
))}

219-260: Commands tab lacks copy-to-clipboard, unlike Quick Start.

The Quick Start tab provides copy buttons for each command, but the Commands tab shows similar CLI commands without copy functionality. Users will likely want to copy these as well. Consider reusing the same copy pattern here for a consistent experience.


269-272: Long project names may overflow the download button text.

Line 271 interpolates projectName directly into the button label. If the project name is long, the button text could overflow or wrap awkwardly. Consider truncating or capping the displayed name:

-{isDownloading ? "Preparing..." : `Download ${projectName}-netlify-ready.zip`}
+{isDownloading ? "Preparing..." : `Download ${projectName.length > 20 ? projectName.slice(0, 20) + "…" : projectName}-netlify-ready.zip`}

Or simply use a generic label like "Download Netlify-Ready ZIP" to avoid the issue entirely.

Comment on lines 55 to 75
returns: v.id("oauthConnections"),
handler: async (ctx, args) => {
const userId = await requireAuth(ctx);

// Check if connection already exists
const existing = await ctx.db
.query("oauthConnections")
.withIndex("by_userId_provider", (q) =>
q.eq("userId", userId).eq("provider", args.provider)
)
.first();

const now = Date.now();

if (existing) {
// Update existing connection
return await ctx.db.patch(existing._id, {
accessToken: args.accessToken,
refreshToken: args.refreshToken || existing.refreshToken,
expiresAt: args.expiresAt,
scope: args.scope,
metadata: args.metadata || existing.metadata,
updatedAt: now,
});
const identity = await ctx.auth.getUserIdentity();
if (!identity?.subject) {
throw new Error("Unauthorized");
}
const userId = identity.subject;

// Create new connection
return await ctx.db.insert("oauthConnections", {
const encryptedAccessToken = encryptToken(args.accessToken);
const encryptedRefreshToken = args.refreshToken ? encryptToken(args.refreshToken) : undefined;

const connectionId: Id<"oauthConnections"> = await ctx.runMutation(internal.oauthQueries.storeConnectionInternal, {
userId,
provider: args.provider,
accessToken: args.accessToken,
refreshToken: args.refreshToken,
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
expiresAt: args.expiresAt,
scope: args.scope,
metadata: args.metadata,
createdAt: now,
updatedAt: now,
});
return connectionId;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for all usages of storeConnection outside of oauth.ts
rg -n "storeConnection" --type=ts -C3 -g '!convex/oauth.ts'

Repository: Jackson57279/zapdev

Length of output: 2817


🏁 Script executed:

# Also check the full function definition to confirm it's public and understand the context
rg -B5 "storeConnection" convex/oauth.ts | head -40

Repository: Jackson57279/zapdev

Length of output: 551


Public action unnecessarily exposes internal document ID.

storeConnection is a public action that returns v.id("oauthConnections"), exposing the raw Convex document ID to all callers in OAuth callback routes. The returned ID is unused at all call sites (Netlify, Anthropic, GitHub, Figma callbacks). Return v.null() instead.

Suggested fix
-  returns: v.id("oauthConnections"),
+  returns: v.null(),
   handler: async (ctx, args) => {
     ...
-    return connectionId;
+    return null;
   },
🤖 Prompt for AI Agents
In `@convex/oauth.ts` around lines 55 - 75, The public action currently returns
the raw Convex document id (returns: v.id("oauthConnections")) and exposes it
from the handler (returns connectionId); change the action to return v.null()
and update the handler in storeConnection so it does not return the stored
document id — call
ctx.runMutation(internal.oauthQueries.storeConnectionInternal) as before to
persist the connection (keep variables like
encryptedAccessToken/encryptedRefreshToken and connectionId if needed for local
logic) but end the handler by returning null instead of connectionId to avoid
exposing internal document IDs.

Comment on lines +92 to +96
try {
return decryptToken(connection.accessToken);
} catch {
return null;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Silent catch swallows all decryption errors.

Returning null on decryption failure makes a key-rotation mistake, corrupted data, or a misconfigured env var indistinguishable from "no token exists." At minimum, log the error for observability.

Suggested fix
     try {
       return decryptToken(connection.accessToken);
-    } catch {
+    } catch (error) {
+      console.error("Failed to decrypt GitHub access token for user", args.userId, error);
       return null;
     }
📝 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
try {
return decryptToken(connection.accessToken);
} catch {
return null;
}
try {
return decryptToken(connection.accessToken);
} catch (error) {
console.error("Failed to decrypt GitHub access token for user", args.userId, error);
return null;
}
🤖 Prompt for AI Agents
In `@convex/oauth.ts` around lines 92 - 96, The current try/catch around
decryptToken(connection.accessToken) silently swallows all errors; change the
catch to capture the error (catch (err)) and log it before returning null so
decryption failures are observable. Update the block in convex/oauth.ts where
decryptToken is called (reference decryptToken and connection.accessToken) to
call an available logger (e.g., console.error or the module's logger) with a
clear message including identifying context (e.g., connection id or user info)
and the error object, then return null as before.

Comment on lines 36 to 38
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to prepare deployment package");
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

response.json() may throw on non-JSON error bodies.

If the server returns a non-JSON error response (e.g., 502 HTML page from a gateway), response.json() will reject and fall into the outer catch, losing the HTTP status context. Consider guarding the parse:

Suggested improvement
       if (!response.ok) {
-        const error = await response.json();
-        throw new Error(error.error || "Failed to prepare deployment package");
+        let message = "Failed to prepare deployment package";
+        try {
+          const error = await response.json();
+          message = error.error || message;
+        } catch {
+          // non-JSON response body (e.g. gateway HTML error page)
+        }
+        throw new Error(message);
       }
📝 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
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to prepare deployment package");
if (!response.ok) {
let message = "Failed to prepare deployment package";
try {
const error = await response.json();
message = error.error || message;
} catch {
// non-JSON response body (e.g. gateway HTML error page)
}
throw new Error(message);
}
🤖 Prompt for AI Agents
In `@src/modules/projects/ui/components/netlify-cli-dialog.tsx` around lines 36 -
38, The error handling currently calls response.json() unguarded which can throw
for non-JSON bodies; update the block that checks if (!response.ok) to try
parsing JSON in a try/catch (e.g., attempt await response.json()), if parsing
fails fall back to await response.text(), then throw a new Error that includes
response.status and the parsed body (or raw text) so the HTTP status and
response content are preserved; reference the response variable and
response.json()/response.text() usage when making the change.

@stormkit-io
Copy link

stormkit-io bot commented Feb 6, 2026

Deployment failed

This pull request failed while building automatically on Stormkit. You can preview the logs using the following link.
https://app.stormkit.io/app/16264/deployments/89230

@codecapyai
Copy link

codecapyai bot commented Feb 6, 2026

❌ Error while analyzing PR and generating tests:

Could not parse response content as the length limit was reached - CompletionUsage(completion_tokens=3924, prompt_tokens=196073, total_tokens=199997, completion_tokens_details=CompletionTokensDetails(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=1135, rejected_prediction_tokens=0), prompt_tokens_details=PromptTokensDetails(audio_tokens=0, cached_tokens=0))

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

6 issues found across 22 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="convex/oauth.ts">

<violation number="1" location="convex/oauth.ts:173">
P2: Significant code duplication exists between `getAccessTokenForCurrentUser`, `getGithubAccessTokenForCurrentUser`, and `getAnthropicAccessTokenForCurrentUser`.
All three actions implement the exact same logic (identity check, query connection, decrypt).
The explicit provider functions (`getGithub...`) now duplicate the logic instead of reusing the generic one or a shared helper.

**Recommendation**: 
Refactor the common logic into a shared (non-exported or internal) helper function, or deprecate the specific actions in favor of the generic one.</violation>

<violation number="2" location="convex/oauth.ts:173">
P0: Exposing decrypted OAuth access tokens (including sensitive ones like GitHub and Netlify) to the client is a critical security risk.
This public action allows any script running in the user's browser (including via XSS) to retrieve the raw token and compromise the user's third-party accounts.

**Risk**: Account takeover, data exfiltration, malicious deployments.
**Recommendation**: 
1. Make this action `internal` so it cannot be called by the client.
2. Refactor the deployment logic (currently in Next.js API routes) to run within Convex Actions. This allows the token to be retrieved and used server-side without ever exposing it to the client or Next.js server (unless necessary).
3. If Next.js must perform the deployment, use an internal query/action accessed via an Admin Key, rather than relying on the user's session which exposes the endpoint to the browser.</violation>
</file>

<file name="src/app/api/deploy/netlify/env-vars/route.ts">

<violation number="1" location="src/app/api/deploy/netlify/env-vars/route.ts:22">
P3: The error handling logic in the route handlers checks for the specific string "Netlify connection not found". This is fragile. Use a shared constant or a custom error subclass to ensure the throw and catch logic remain in sync, especially if you extract this helper to a shared file.</violation>
</file>

<file name="src/app/api/import/figma/files/route.ts">

<violation number="1" location="src/app/api/import/figma/files/route.ts:34">
P1: The Figma API endpoint `https://api.figma.com/v1/files` is invalid. Figma's REST API does not provide a root endpoint to list all files for a user. You must query by team or project (e.g., `GET /v1/projects/:project_id/files`) or ask the user for a specific file key. This request will likely fail with a 404.</violation>
</file>

<file name="src/app/api/agent/run/route.ts">

<violation number="1" location="src/app/api/agent/run/route.ts:48">
P2: The `safeWrite` function suppresses write errors (e.g., from client disconnects) but does not signal the failure to the caller. As a result, the subscription loop continues to process events and attempt writes until the agent run completes, wasting server resources on a disconnected client.

Update `safeWrite` to return a boolean indicating success, and check this return value in the subscription callback to abort early.</violation>
</file>

<file name="src/app/api/import/figma/process/route.ts">

<violation number="1" location="src/app/api/import/figma/process/route.ts:77">
P2: The `processFigmaImport` function is executed asynchronously without being awaited before the response is returned. In serverless environments (like Netlify or Vercel), the runtime may freeze or terminate the process immediately after the response is sent, causing the background import to fail.

Since this PR introduces Inngest, consider offloading this task to an Inngest function or using `waitUntil` to ensure the process completes.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

},
handler: async (ctx, args) => {
const userId = await requireAuth(ctx);
export const getAccessTokenForCurrentUser = action({
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 6, 2026

Choose a reason for hiding this comment

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

P0: Exposing decrypted OAuth access tokens (including sensitive ones like GitHub and Netlify) to the client is a critical security risk.
This public action allows any script running in the user's browser (including via XSS) to retrieve the raw token and compromise the user's third-party accounts.

Risk: Account takeover, data exfiltration, malicious deployments.
Recommendation:

  1. Make this action internal so it cannot be called by the client.
  2. Refactor the deployment logic (currently in Next.js API routes) to run within Convex Actions. This allows the token to be retrieved and used server-side without ever exposing it to the client or Next.js server (unless necessary).
  3. If Next.js must perform the deployment, use an internal query/action accessed via an Admin Key, rather than relying on the user's session which exposes the endpoint to the browser.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At convex/oauth.ts, line 173:

<comment>Exposing decrypted OAuth access tokens (including sensitive ones like GitHub and Netlify) to the client is a critical security risk.
This public action allows any script running in the user's browser (including via XSS) to retrieve the raw token and compromise the user's third-party accounts.

**Risk**: Account takeover, data exfiltration, malicious deployments.
**Recommendation**: 
1. Make this action `internal` so it cannot be called by the client.
2. Refactor the deployment logic (currently in Next.js API routes) to run within Convex Actions. This allows the token to be retrieved and used server-side without ever exposing it to the client or Next.js server (unless necessary).
3. If Next.js must perform the deployment, use an internal query/action accessed via an Admin Key, rather than relying on the user's session which exposes the endpoint to the browser.</comment>

<file context>
@@ -140,8 +152,46 @@ export const getAnthropicAccessTokenForCurrentUser = action({
+  },
+});
+
+export const getAccessTokenForCurrentUser = action({
+  args: { provider: oauthProviderEnum },
+  returns: v.union(v.string(), v.null()),
</file context>
Fix with Cubic

const response = await fetch("https://api.figma.com/v1/files", {
headers: {
Authorization: `Bearer ${connection.accessToken}`,
Authorization: `Bearer ${accessToken}`,
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 6, 2026

Choose a reason for hiding this comment

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

P1: The Figma API endpoint https://api.figma.com/v1/files is invalid. Figma's REST API does not provide a root endpoint to list all files for a user. You must query by team or project (e.g., GET /v1/projects/:project_id/files) or ask the user for a specific file key. This request will likely fail with a 404.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/import/figma/files/route.ts, line 34:

<comment>The Figma API endpoint `https://api.figma.com/v1/files` is invalid. Figma's REST API does not provide a root endpoint to list all files for a user. You must query by team or project (e.g., `GET /v1/projects/:project_id/files`) or ask the user for a specific file key. This request will likely fail with a 404.</comment>

<file context>
@@ -33,7 +31,7 @@ export async function GET() {
     const response = await fetch("https://api.figma.com/v1/files", {
       headers: {
-        Authorization: `Bearer ${connection.accessToken}`,
+        Authorization: `Bearer ${accessToken}`,
       },
     });
</file context>
Fix with Cubic

},
handler: async (ctx, args) => {
const userId = await requireAuth(ctx);
export const getAccessTokenForCurrentUser = action({
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 6, 2026

Choose a reason for hiding this comment

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

P2: Significant code duplication exists between getAccessTokenForCurrentUser, getGithubAccessTokenForCurrentUser, and getAnthropicAccessTokenForCurrentUser.
All three actions implement the exact same logic (identity check, query connection, decrypt).
The explicit provider functions (getGithub...) now duplicate the logic instead of reusing the generic one or a shared helper.

Recommendation:
Refactor the common logic into a shared (non-exported or internal) helper function, or deprecate the specific actions in favor of the generic one.

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

<comment>Significant code duplication exists between `getAccessTokenForCurrentUser`, `getGithubAccessTokenForCurrentUser`, and `getAnthropicAccessTokenForCurrentUser`.
All three actions implement the exact same logic (identity check, query connection, decrypt).
The explicit provider functions (`getGithub...`) now duplicate the logic instead of reusing the generic one or a shared helper.

**Recommendation**: 
Refactor the common logic into a shared (non-exported or internal) helper function, or deprecate the specific actions in favor of the generic one.</comment>

<file context>
@@ -140,8 +152,46 @@ export const getAnthropicAccessTokenForCurrentUser = action({
+  },
+});
+
+export const getAccessTokenForCurrentUser = action({
+  args: { provider: oauthProviderEnum },
+  returns: v.union(v.string(), v.null()),
</file context>
Fix with Cubic

let subscriptionStream: Awaited<ReturnType<typeof subscribe>> | null = null;
let writerClosed = false;

const safeWrite = async (data: Uint8Array) => {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 6, 2026

Choose a reason for hiding this comment

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

P2: The safeWrite function suppresses write errors (e.g., from client disconnects) but does not signal the failure to the caller. As a result, the subscription loop continues to process events and attempt writes until the agent run completes, wasting server resources on a disconnected client.

Update safeWrite to return a boolean indicating success, and check this return value in the subscription callback to abort early.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/agent/run/route.ts, line 48:

<comment>The `safeWrite` function suppresses write errors (e.g., from client disconnects) but does not signal the failure to the caller. As a result, the subscription loop continues to process events and attempt writes until the agent run completes, wasting server resources on a disconnected client.

Update `safeWrite` to return a boolean indicating success, and check this return value in the subscription callback to abort early.</comment>

<file context>
@@ -43,6 +43,26 @@ export async function POST(request: NextRequest) {
       let subscriptionStream: Awaited<ReturnType<typeof subscribe>> | null = null;
+      let writerClosed = false;
+
+      const safeWrite = async (data: Uint8Array) => {
+        if (writerClosed) return;
+        try {
</file context>
Fix with Cubic

projectId,
fileKey,
accessToken: connection.accessToken,
accessToken,
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 6, 2026

Choose a reason for hiding this comment

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

P2: The processFigmaImport function is executed asynchronously without being awaited before the response is returned. In serverless environments (like Netlify or Vercel), the runtime may freeze or terminate the process immediately after the response is sent, causing the background import to fail.

Since this PR introduces Inngest, consider offloading this task to an Inngest function or using waitUntil to ensure the process completes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/import/figma/process/route.ts, line 77:

<comment>The `processFigmaImport` function is executed asynchronously without being awaited before the response is returned. In serverless environments (like Netlify or Vercel), the runtime may freeze or terminate the process immediately after the response is sent, causing the background import to fail.

Since this PR introduces Inngest, consider offloading this task to an Inngest function or using `waitUntil` to ensure the process completes.</comment>

<file context>
@@ -71,7 +74,7 @@ export async function POST(request: Request) {
       projectId,
       fileKey,
-      accessToken: connection.accessToken,
+      accessToken,
     }).catch((error) => {
       console.error("[ERROR] Background Figma import failed:", error);
</file context>
Fix with Cubic

{ token: token ?? undefined },
) as string | null;

if (!accessToken) {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 6, 2026

Choose a reason for hiding this comment

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

P3: The error handling logic in the route handlers checks for the specific string "Netlify connection not found". This is fragile. Use a shared constant or a custom error subclass to ensure the throw and catch logic remain in sync, especially if you extract this helper to a shared file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/deploy/netlify/env-vars/route.ts, line 22:

<comment>The error handling logic in the route handlers checks for the specific string "Netlify connection not found". This is fragile. Use a shared constant or a custom error subclass to ensure the throw and catch logic remain in sync, especially if you extract this helper to a shared file.</comment>

<file context>
@@ -17,17 +13,17 @@ type EnvVarPayload = {
+  ) as string | null;
 
-  if (!connection?.accessToken) {
+  if (!accessToken) {
     throw new Error("Netlify connection not found.");
   }
</file context>
Fix with Cubic

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
src/app/api/agent/run/route.ts (2)

126-132: ⚠️ Potential issue | 🟡 Minor

Internal error details leaked to client.

Line 129 forwards the raw error.message to the caller. This can expose internal implementation details (dependency names, file paths, database errors). Return a generic message and log the details server-side only.

Proposed fix
     return NextResponse.json(
       {
         error: "Failed to process request",
-        details: error instanceof Error ? error.message : "Unknown error",
       },
       { status: 500 }
     );

16-37: ⚠️ Potential issue | 🔴 Critical

Add authentication check to prevent unauthenticated agent runs.

The /api/agent/run endpoint has no authentication, while getUser() is available and used in the sibling /api/agent/token route. Any caller can trigger expensive Inngest agent runs. Add const user = await getUser(); near the top of the POST handler and return a 401 if user is null, matching the pattern in /api/agent/token/route.ts.

src/app/api/import/github/process/route.ts (1)

59-75: ⚠️ Potential issue | 🟠 Major

fetchMutation is called without the auth token, unlike fetchAction above.

Line 28–32 passes { token: token ?? undefined } to fetchAction, but the fetchMutation on line 59 omits the token option entirely. This means the mutation runs unauthenticated, which will likely fail if createImport enforces requireAuth(ctx). Pass the token consistently.

Additionally, (api as any) violates the strict typing guidelines. If imports isn't on the generated API type, the type definition in convex-extended-api.d.ts needs updating rather than bypassing with any.

Proposed fix
-    const importRecord = await fetchMutation((api as any).imports.createImport, {
+    const importRecord = await fetchMutation(api.imports.createImport, {
       projectId,
       source: "GITHUB",
       sourceId: repoId.toString(),
       sourceName: repoName,
       sourceUrl: repoUrl || repoData.html_url,
       metadata: {
         githubRepoData: {
           fullName: repoData.full_name,
           description: repoData.description,
           language: repoData.language,
           defaultBranch: repoData.default_branch,
           topics: repoData.topics || [],
           isPrivate: repoData.private,
         },
       },
-    });
+    }, { token: token ?? undefined });

As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly." Based on learnings: "Use const userId = await requireAuth(ctx) at the start of protected handlers to enforce authentication" — if the mutation enforces auth, the missing token will cause a runtime failure.

src/app/api/import/figma/process/route.ts (1)

57-71: ⚠️ Potential issue | 🟠 Major

Same unauthenticated fetchMutation and as any cast as the GitHub route.

This is the same issue as in src/app/api/import/github/process/route.ts: the mutation is called without the auth token and uses (api as any) to bypass type safety. Pass the token and fix the type definition.

Proposed fix
-    const importRecord = await fetchMutation((api as any).imports.createImport, {
+    const importRecord = await fetchMutation(api.imports.createImport, {
       projectId,
       source: "FIGMA",
       sourceId: fileKey,
       sourceName: fileName,
       sourceUrl: fileUrl || `https://figma.com/file/${fileKey}`,
       metadata: {
         figmaFileData: {
           name: fileData.name,
           lastModified: fileData.lastModified,
           version: fileData.version,
           pages: fileData.pages?.length || 0,
         },
       },
-    });
+    }, { token: token ?? undefined });

As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly."

src/app/api/import/figma/files/route.ts (1)

31-47: ⚠️ Potential issue | 🔴 Critical

Replace the Figma API endpoint with a valid list endpoint.

The endpoint https://api.figma.com/v1/files does not exist in the Figma REST API. According to official documentation, there is no endpoint to list all files without a specific file key. To list files, use:

  • GET /v1/projects/:project_id/files — list files in a specific project
  • GET /v1/teams/:team_id/projects — list projects in a team

The current request will fail with a 404 error. You need to provide the project_id or team_id parameter and adjust the endpoint accordingly, or clarify which resource (projects vs. files) should be returned to the client.

🤖 Fix all issues with AI agents
In `@convex/oauth.ts`:
- Around line 24-32: The encryptToken function currently uses a 16-byte IV
(crypto.randomBytes(16)); change it to a 12-byte (96-bit) IV per NIST GCM
guidance by using crypto.randomBytes(12) so AES-GCM will use the recommended IV
size; ensure the corresponding decrypt function (wherever getAuthTag is
consumed) expects the same IV length and that the returned iv:authTag:encrypted
format still matches, and run/update any tests relying on IV length to reflect
the 12-byte IV change (refer to encryptToken, getEncryptionKey, ALGORITHM and
the decrypt counterpart).

In `@convex/oauthQueries.ts`:
- Around line 74-119: In storeConnectionInternal, the fallback logic uses ||
which wrongly treats empty strings or 0 as absent; change the two occurrences
where it does args.refreshToken || existing.refreshToken and args.metadata ||
existing.metadata to use nullish coalescing (args.refreshToken ??
existing.refreshToken and args.metadata ?? existing.metadata) so only
null/undefined fall back to the existing values.
- Around line 37-72: The public query getConnection (and similarly
listConnections) currently returns sensitive/internal fields (userId and
_creationTime and optional metadata); update the v.object return schema to
remove userId, _creationTime, and metadata, and in the handler for getConnection
destructure/drop connection.userId, connection._creationTime and
connection.metadata (in addition to accessToken/refreshToken) before returning
safeConnection; apply the same removal logic to listConnections so no Clerk IDs
or internal metadata are returned to clients and keep only public-safe fields
like provider, expiresAt, scope, createdAt, updatedAt.

In `@src/app/api/agent/run/route.ts`:
- Around line 67-94: The subscription is created after triggering the Inngest
run (inngest.send) causing a race where emitted events can be lost; modify the
flow to call subscribe(...) and set subscriptionStream first (using
agentChannel(runId)) and only after the subscribe promise resolves send the
event via inngest.send, keeping the same message payload (runId, projectId,
value, model) and retaining the same message handler logic (formatSSE,
safeWrite, cancel on "complete"/"error"); ensure any errors from subscribe or
send are caught and logged so the client can be closed cleanly.

In `@src/app/api/deploy/netlify/env-vars/route.ts`:
- Around line 62-97: The POST handler currently returns the raw Netlify response
from netlifyClient.setEnvVar (in POST) and the PUT handler returns
netlifyClient.updateEnvVar, which leak plaintext secrets; change both handlers
to sanitize the returned NetlifyEnvVar by removing or omitting the values array
and returning the same safe shape the GET handler uses, and replace the
unchecked cast of request.json() to EnvVarPayload with Zod input validation
(reuse the schema pattern from cli/route.ts) to validate siteId, key, value and
optional context before calling setEnvVar/updateEnvVar.
🧹 Nitpick comments (15)
src/modules/home/ui/components/project-form.tsx (2)

62-69: Consider making modelOptions type-safe without as assertions.

All entries use as ModelId, which defeats the purpose of the ModelId union type. A typed array would catch invalid IDs at compile time.

♻️ Suggested refactor
- const modelOptions = [
-   { id: "auto" as ModelId, name: "Auto", image: "/auto.svg", description: "Auto-selects the best model" },
-   { id: "anthropic/claude-haiku-4.5" as ModelId, name: "Claude Haiku 4.5", image: "/haiku.svg", description: "Fast and efficient" },
-   ...
+ const modelOptions: { id: ModelId; name: string; image: string; description: string }[] = [
+   { id: "auto", name: "Auto", image: "/auto.svg", description: "Auto-selects the best model" },
+   { id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5", image: "/haiku.svg", description: "Fast and efficient" },
+   ...

This way, any model ID not in the ModelId union will produce a type error immediately.

As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly."


68-68: Model ID "moonshotai/kimi-k2.5" is valid and properly configured.

The new model ID exists in MODEL_CONFIGS with full provider configuration, so runtime selection will work correctly.

As an optional improvement (though pre-existing across all model options), consider typing modelOptions directly as Array<{ id: ModelId; ... }> to enforce type safety on the id field without needing the as ModelId assertions on each entry. This would align with the guideline to avoid type assertions and resolve types properly.

src/agents/types.ts (2)

32-33: AgentProvider type is declared after its first use.

AgentProvider is referenced on line 19 (provider?: AgentProvider in AgentRunInput) but defined on line 32. While TypeScript hoists type aliases so this works at compile time, placing the definition before its usage improves readability and is consistent with how DatabaseProvider (line 4) is positioned before its usage on line 10.


4-4: Remove duplicate DatabaseProvider type definition.

DatabaseProvider is already defined and exported from src/lib/database-templates/types.ts. Instead of redefining it in src/agents/types.ts, import it from the shared source:

-export type DatabaseProvider = "none" | "drizzle-neon" | "convex";
+import type { DatabaseProvider } from "../lib/database-templates";

This eliminates the risk of the two definitions diverging if one is updated without the other.

src/app/api/agent/run/route.ts (1)

86-88: Avoid as StreamEvent type assertion.

message.data is typed as { type: string; data: unknown; timestamp?: number } from the "event" topic, which is structurally close to StreamEvent but not identical (type is string vs. a union). Consider narrowing with a runtime guard or tightening the topic type to eliminate the assertion.

As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly."

src/app/api/deploy/netlify/preview/route.ts (1)

7-20: getNetlifyAccessToken is duplicated across Netlify routes.

This exact helper appears identically in src/app/api/deploy/netlify/status/route.ts (and likely other Netlify routes). Extract it into a shared module (e.g., src/lib/netlify-auth.ts) to avoid duplication.

src/app/api/import/github/repos/route.ts (1)

44-52: GitHub repo listing is limited to 100 repositories with no pagination.

The per_page=100 parameter caps results at 100 repos. Users with more repositories won't see the rest. Consider adding page parameter support or documenting this limitation.

Also, line 65 uses as GitHubRepo[] on unvalidated external data. This is a mild type-safety gap — if GitHub's response shape changes, runtime errors would surface far from the assertion.

Also applies to: 65-65

src/modules/projects/ui/components/netlify-cli-dialog.tsx (2)

96-101: Icon sizing uses h-4 w-4 instead of the guideline-preferred size-4.

The coding guidelines specify size-4 (16px) as the default icon size for lucide-react icons. The current code uses h-4 w-4 throughout. While functionally equivalent, size-4 is the preferred convention per project guidelines.

As per coding guidelines: "Use lucide-react as the icon library with default size size-4 (16px)."

Also applies to: 131-134, 150-154, 173-177, 193-197


117-201: Consider extracting the repeated command block into a reusable component.

The Quick Start tab has four nearly identical command blocks (install, login, init, deploy), each with a label, code display, and copy button. Extracting a small CommandBlock component would reduce duplication and make additions easier.

Example extraction
const CommandBlock = ({ 
  step, label, command, commandKey, description 
}: { 
  step: number; label: string; command: string; commandKey: string; description?: string;
}) => (
  <div className="rounded-lg bg-muted p-4">
    <h4 className="mb-2 font-medium">{step}. {label}</h4>
    {description && <p className="mb-2 text-sm text-muted-foreground">{description}</p>}
    <div className="flex items-center gap-2">
      <code className="flex-1 rounded bg-background px-3 py-2 text-sm">{command}</code>
      <Button size="sm" variant="ghost" onClick={() => copyToClipboard(command, commandKey)}>
        {copiedCommand === commandKey ? <Check className="size-4" /> : <Copy className="size-4" />}
      </Button>
    </div>
  </div>
);
src/app/api/deploy/netlify/env-vars/route.ts (1)

14-27: Extract getNetlifyAccessToken into a shared utility.

This helper is duplicated verbatim across env-vars/route.ts, sites/route.ts, and domains/route.ts (and likely other Netlify routes). Extract it to a shared module (e.g., src/lib/netlify-auth.ts) to avoid drift and reduce duplication.

src/app/api/deploy/netlify/cli/route.ts (2)

58-77: Zod validates projectId as a string but the cast to Id<"projects"> is unchecked.

Line 76 casts the Zod-validated string to Id<"projects"> without verifying it's a valid Convex document ID. While fetchQuery on line 77 will fail gracefully (caught at line 113), consider using Convex's ID validator (v.id("projects")) or at minimum documenting this assumption.


25-37: normalizeFiles silently drops non-string entries; consider stricter schema validation or explicit error handling.

The schema allows files: v.any(), but the function only preserves string values—nested objects are silently discarded. The agent currently produces flat Record<string, string> (per webcontainer-sync.ts), but the schema doesn't enforce this. Consider either:

  • Validating files as a flat Record<string, string> at the schema level
  • Throwing an error in normalizeFiles for non-strings to catch upstream bugs early
  • Implementing recursive flattening if nested structures should be supported

Note: This function is duplicated across 4 routes (githubExports.ts, two netlify routes, and the download route)—consider extracting it to a shared utility.

src/app/api/deploy/netlify/sites/route.ts (1)

29-32: Consider sanitizing the Netlify sites response.

listSites() returns the full Netlify site objects which may include deploy keys, webhook URLs, and other internal metadata. If this data is consumed by a client component, filter to only the fields the UI needs (e.g., id, name, url, ssl_url, custom_domain).

src/app/api/deploy/netlify/domains/route.ts (1)

67-75: Weak body validation — prefer Zod schema for consistency.

The typeof parsedBody !== "object" check (line 68) passes for arrays and the as DomainPayload cast (line 72) skips runtime type verification. The sibling cli/route.ts uses Zod's safeParse for input validation. Standardize on Zod across all Netlify routes for consistent, type-safe input handling.

Suggested approach
+import { z } from "zod";
+
+const domainPayloadSchema = z.object({
+  siteId: z.string().min(1),
+  domain: z.string().min(1),
+});
+
 // In POST handler:
-    const parsedBody = await request.json();
-    if (!parsedBody || typeof parsedBody !== "object") {
-      return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
-    }
-
-    const body = parsedBody as DomainPayload;
-    if (!body.siteId || !body.domain) {
-      return NextResponse.json({ error: "Missing siteId or domain" }, { status: 400 });
-    }
+    const parseResult = domainPayloadSchema.safeParse(await request.json());
+    if (!parseResult.success) {
+      return NextResponse.json({ error: "Invalid request body", details: parseResult.error.issues }, { status: 400 });
+    }
+    const body = parseResult.data;
convex/oauthQueries.ts (1)

176-203: v.any() used for metadata across multiple functions.

Lines 21, 49, 82, and 179 use v.any() for the metadata field. The coding guidelines prohibit any in function signatures. Define a concrete validator (e.g., v.record(v.string(), v.string()) or a union of known metadata shapes) to preserve type safety.

As per coding guidelines: "DO NOT use any in function signatures; leverage Doc<"table"> or Id<"table"> for type safety".

Comment on lines +24 to +32
export function encryptToken(token: string): string {
const keyBuffer = getEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(token, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
Copy link
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

🌐 Web query:

NIST SP 800-38D AES-GCM IV length 12 bytes 96-bit recommendation

💡 Result:

NIST SP 800-38D (GCM/GMAC) recommends using a 96-bit (12-byte) IV:

  • Recommendation: “For IVs, it is recommended that implementations restrict support to the length of 96 bits … (interoperability, efficiency, simplicity).” [1]
  • Why 96 bits matters in the spec: When len(IV)=96, GCM forms the pre-counter block as J0 = IV || 0^31 || 1; for non-96-bit IVs, J0 is derived by hashing the IV with GHASH (more processing/complexity). [1]
  • Core requirement (regardless of length): IVs must be unique for each (key, IV) pair; reuse breaks GCM security assumptions. [1]

Sources:
[1] NIST SP 800-38D (Nov 2007), “supported lengths / IV recommendation” and “J0 formation” sections. (nvlpubs.nist.gov)

Citations:


Use NIST-recommended 12-byte IV for AES-GCM instead of 16 bytes.

Line 26 uses crypto.randomBytes(16) for the IV. NIST SP 800-38D recommends 96-bit (12-byte) IVs for GCM. With 96-bit IVs, the pre-counter block (J0) is formed directly; other lengths require an additional GHASH hashing step that adds processing overhead and complexity.

Proposed fix
-  const iv = crypto.randomBytes(16);
+  const iv = crypto.randomBytes(12);
📝 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 function encryptToken(token: string): string {
const keyBuffer = getEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(token, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
export function encryptToken(token: string): string {
const keyBuffer = getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(token, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
🤖 Prompt for AI Agents
In `@convex/oauth.ts` around lines 24 - 32, The encryptToken function currently
uses a 16-byte IV (crypto.randomBytes(16)); change it to a 12-byte (96-bit) IV
per NIST GCM guidance by using crypto.randomBytes(12) so AES-GCM will use the
recommended IV size; ensure the corresponding decrypt function (wherever
getAuthTag is consumed) expects the same IV length and that the returned
iv:authTag:encrypted format still matches, and run/update any tests relying on
IV length to reflect the 12-byte IV change (refer to encryptToken,
getEncryptionKey, ALGORITHM and the decrypt counterpart).

Comment on lines +37 to +72
export const getConnection = query({
args: {
provider: oauthProviderEnum,
},
returns: v.union(
v.object({
_id: v.id("oauthConnections"),
_creationTime: v.number(),
userId: v.string(),
provider: oauthProviderEnum,
expiresAt: v.optional(v.number()),
scope: v.string(),
metadata: v.optional(v.any()),
createdAt: v.number(),
updatedAt: v.number(),
}),
v.null()
),
handler: async (ctx, args) => {
const userId = await requireAuth(ctx);

const connection = await ctx.db
.query("oauthConnections")
.withIndex("by_userId_provider", (q) =>
q.eq("userId", userId).eq("provider", args.provider)
)
.first();

if (!connection) {
return null;
}

const { accessToken: _, refreshToken: _rt, ...safeConnection } = connection;
return safeConnection;
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Public query exposes userId (Clerk ID) and _creationTime to the client.

getConnection is a public query that returns userId and _creationTime in its response. Per project learnings, Clerk user IDs should not be exposed in public APIs, and internal metadata should be excluded from public return values. The same applies to listConnections (lines 141-148).

Proposed fix
   returns: v.union(
     v.object({
       _id: v.id("oauthConnections"),
-      _creationTime: v.number(),
-      userId: v.string(),
       provider: oauthProviderEnum,
       expiresAt: v.optional(v.number()),
       scope: v.string(),
-      metadata: v.optional(v.any()),
       createdAt: v.number(),
       updatedAt: v.number(),
     }),
     v.null()
   ),
   handler: async (ctx, args) => {
     const userId = await requireAuth(ctx);
     ...
-    const { accessToken: _, refreshToken: _rt, ...safeConnection } = connection;
-    return safeConnection;
+    return {
+      _id: connection._id,
+      provider: connection.provider,
+      expiresAt: connection.expiresAt,
+      scope: connection.scope,
+      createdAt: connection.createdAt,
+      updatedAt: connection.updatedAt,
+    };
   },

Based on learnings: "Never expose Clerk user IDs in public APIs — sanitize user identifiers before returning in API responses" and "NEVER expose internal IDs or sensitive metadata in public return values from Convex functions".

📝 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 getConnection = query({
args: {
provider: oauthProviderEnum,
},
returns: v.union(
v.object({
_id: v.id("oauthConnections"),
_creationTime: v.number(),
userId: v.string(),
provider: oauthProviderEnum,
expiresAt: v.optional(v.number()),
scope: v.string(),
metadata: v.optional(v.any()),
createdAt: v.number(),
updatedAt: v.number(),
}),
v.null()
),
handler: async (ctx, args) => {
const userId = await requireAuth(ctx);
const connection = await ctx.db
.query("oauthConnections")
.withIndex("by_userId_provider", (q) =>
q.eq("userId", userId).eq("provider", args.provider)
)
.first();
if (!connection) {
return null;
}
const { accessToken: _, refreshToken: _rt, ...safeConnection } = connection;
return safeConnection;
},
});
export const getConnection = query({
args: {
provider: oauthProviderEnum,
},
returns: v.union(
v.object({
_id: v.id("oauthConnections"),
provider: oauthProviderEnum,
expiresAt: v.optional(v.number()),
scope: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
}),
v.null()
),
handler: async (ctx, args) => {
const userId = await requireAuth(ctx);
const connection = await ctx.db
.query("oauthConnections")
.withIndex("by_userId_provider", (q) =>
q.eq("userId", userId).eq("provider", args.provider)
)
.first();
if (!connection) {
return null;
}
return {
_id: connection._id,
provider: connection.provider,
expiresAt: connection.expiresAt,
scope: connection.scope,
createdAt: connection.createdAt,
updatedAt: connection.updatedAt,
};
},
});
🤖 Prompt for AI Agents
In `@convex/oauthQueries.ts` around lines 37 - 72, The public query getConnection
(and similarly listConnections) currently returns sensitive/internal fields
(userId and _creationTime and optional metadata); update the v.object return
schema to remove userId, _creationTime, and metadata, and in the handler for
getConnection destructure/drop connection.userId, connection._creationTime and
connection.metadata (in addition to accessToken/refreshToken) before returning
safeConnection; apply the same removal logic to listConnections so no Clerk IDs
or internal metadata are returned to clients and keep only public-safe fields
like provider, expiresAt, scope, createdAt, updatedAt.

Comment on lines +74 to +119
export const storeConnectionInternal = internalMutation({
args: {
userId: v.string(),
provider: oauthProviderEnum,
accessToken: v.string(),
refreshToken: v.optional(v.string()),
expiresAt: v.optional(v.number()),
scope: v.string(),
metadata: v.optional(v.any()),
},
returns: v.id("oauthConnections"),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("oauthConnections")
.withIndex("by_userId_provider", (q) =>
q.eq("userId", args.userId).eq("provider", args.provider)
)
.first();

const now = Date.now();

if (existing) {
await ctx.db.patch(existing._id, {
accessToken: args.accessToken,
refreshToken: args.refreshToken || existing.refreshToken,
expiresAt: args.expiresAt,
scope: args.scope,
metadata: args.metadata || existing.metadata,
updatedAt: now,
});
return existing._id;
}

return await ctx.db.insert("oauthConnections", {
userId: args.userId,
provider: args.provider,
accessToken: args.accessToken,
refreshToken: args.refreshToken,
expiresAt: args.expiresAt,
scope: args.scope,
metadata: args.metadata,
createdAt: now,
updatedAt: now,
});
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Falsy || guards silently discard empty strings and 0 values.

Lines 98 and 101 use || to fall back to existing values. If args.refreshToken is intentionally set to "" (e.g., to clear it), the existing value is preserved instead. Use nullish coalescing (??) to only fall back on undefined/null.

Proposed fix
-        refreshToken: args.refreshToken || existing.refreshToken,
+        refreshToken: args.refreshToken ?? existing.refreshToken,
         expiresAt: args.expiresAt,
         scope: args.scope,
-        metadata: args.metadata || existing.metadata,
+        metadata: args.metadata ?? existing.metadata,
🤖 Prompt for AI Agents
In `@convex/oauthQueries.ts` around lines 74 - 119, In storeConnectionInternal,
the fallback logic uses || which wrongly treats empty strings or 0 as absent;
change the two occurrences where it does args.refreshToken ||
existing.refreshToken and args.metadata || existing.metadata to use nullish
coalescing (args.refreshToken ?? existing.refreshToken and args.metadata ??
existing.metadata) so only null/undefined fall back to the existing values.

Comment on lines +67 to +94
try {
await inngest.send({
name: "code-agent/run.requested",
data: {
runId,
projectId,
value,
model: model || "auto",
},
});

console.log("[Agent Run] Triggered Inngest event:", { runId, projectId });

subscriptionStream = await subscribe(
{
app: inngest,
channel: agentChannel(runId),
topics: ["event"],
},
async (message) => {
const event = message.data as StreamEvent;
await safeWrite(formatSSE(event));

if (event.type === "complete" || event.type === "error") {
await subscriptionStream?.cancel();
}
}
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Race condition: events may be lost because subscribe is called after inngest.send.

The Inngest function is triggered on line 68 before the realtime subscription is established on line 80. If the function starts executing and emits events in that window, those events will never reach the client. Subscribe first, then trigger the run.

Proposed fix — subscribe before send
       try {
+        // Subscribe FIRST to avoid missing early events
+        subscriptionStream = await subscribe(
+          {
+            app: inngest,
+            channel: agentChannel(runId),
+            topics: ["event"],
+          },
+          async (message) => {
+            const event = message.data as StreamEvent;
+            await safeWrite(formatSSE(event));
+
+            if (event.type === "complete" || event.type === "error") {
+              await subscriptionStream?.cancel();
+            }
+          }
+        );
+
+        // Emit initial status event
+        await safeWrite(formatSSE({ type: "status", data: "Starting agent run..." }));
+
+        // THEN trigger the Inngest function
         await inngest.send({
           name: "code-agent/run.requested",
           data: {
             runId,
             projectId,
             value,
             model: model || "auto",
           },
         });

         console.log("[Agent Run] Triggered Inngest event:", { runId, projectId });

-        subscriptionStream = await subscribe(
-          {
-            app: inngest,
-            channel: agentChannel(runId),
-            topics: ["event"],
-          },
-          async (message) => {
-            const event = message.data as StreamEvent;
-            await safeWrite(formatSSE(event));
-
-            if (event.type === "complete" || event.type === "error") {
-              await subscriptionStream?.cancel();
-            }
-          }
-        );
-
         await subscriptionStream;

Based on learnings: "Data streamed from /api/agent/run endpoint must emit SSE events in order: status, text, file-created, tool-call, complete."

🤖 Prompt for AI Agents
In `@src/app/api/agent/run/route.ts` around lines 67 - 94, The subscription is
created after triggering the Inngest run (inngest.send) causing a race where
emitted events can be lost; modify the flow to call subscribe(...) and set
subscriptionStream first (using agentChannel(runId)) and only after the
subscribe promise resolves send the event via inngest.send, keeping the same
message payload (runId, projectId, value, model) and retaining the same message
handler logic (formatSSE, safeWrite, cancel on "complete"/"error"); ensure any
errors from subscribe or send are caught and logged so the client can be closed
cleanly.

Comment on lines +62 to +97
export async function POST(request: Request) {
try {
const user = await getUser();
if (!user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = (await request.json()) as EnvVarPayload;
if (
!body.siteId ||
!body.key ||
typeof body.value !== "string" ||
body.value.length === 0
) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}

const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
const envVar = await netlifyClient.setEnvVar(
body.siteId,
body.key,
body.value,
body.context
);

return NextResponse.json(envVar);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to set env var";

if (message.includes("Netlify connection not found")) {
return NextResponse.json({ error: message }, { status: 401 });
}

return NextResponse.json({ error: message }, { status: 500 });
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

POST and PUT responses may leak newly-set env var values.

The Netlify setEnvVar and updateEnvVar methods return the full NetlifyEnvVar object (including the values array with plaintext secrets). The GET handler sanitizes this, but POST (line 87) and PUT (line 124) return the raw response directly. Apply the same sanitization.

Also, request.json() is cast via as EnvVarPayload without runtime validation. Consider using Zod (as done in cli/route.ts) for consistent input validation.

Proposed fix for POST (same pattern for PUT)
     const envVar = await netlifyClient.setEnvVar(
       body.siteId,
       body.key,
       body.value,
       body.context
     );
 
-    return NextResponse.json(envVar);
+    const { values, ...safeEnvVar } = envVar as { values?: unknown; [key: string]: unknown };
+    return NextResponse.json(safeEnvVar);

Also applies to: 99-134

🤖 Prompt for AI Agents
In `@src/app/api/deploy/netlify/env-vars/route.ts` around lines 62 - 97, The POST
handler currently returns the raw Netlify response from netlifyClient.setEnvVar
(in POST) and the PUT handler returns netlifyClient.updateEnvVar, which leak
plaintext secrets; change both handlers to sanitize the returned NetlifyEnvVar
by removing or omitting the values array and returning the same safe shape the
GET handler uses, and replace the unchecked cast of request.json() to
EnvVarPayload with Zod input validation (reuse the schema pattern from
cli/route.ts) to validate siteId, key, value and optional context before calling
setEnvVar/updateEnvVar.

@codecapyai
Copy link

codecapyai bot commented Feb 7, 2026

❌ Error while analyzing PR and generating tests:

Error code: 400 - {'error': {'message': "This model's maximum context length is 200000 tokens. However, your messages resulted in 209528 tokens (including 381 in the response_format schemas.). Please reduce the length of the messages or schemas.", 'type': 'invalid_request_error', 'param': 'messages', 'code': 'context_length_exceeded'}}

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 18 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="env.example">

<violation number="1" location="env.example:43">
P2: The env example enables WebContainers by default, but the project docs describe this as a feature‑flagged option with a default of false. This will flip the default sandbox behavior for new setups and contradicts the documented default. Keep the example aligned with the default flag.</violation>
</file>

<file name="src/agents/code-agent.ts">

<violation number="1" location="src/agents/code-agent.ts:510">
P2: Memory leak: `PENDING_SANDBOX_REQUESTS` entries are never cleaned up when the agent run completes. Each sandbox ID creates a permanent entry in the global Map. Consider adding cleanup in a `finally` block or at the end of the generator:
```typescript
PENDING_SANDBOX_REQUESTS.delete(adapter.id);
```</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

# E2B
E2B_API_KEY=""
# WebContainers (browser-based sandboxes)
NEXT_PUBLIC_USE_WEBCONTAINERS="true"
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 7, 2026

Choose a reason for hiding this comment

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

P2: The env example enables WebContainers by default, but the project docs describe this as a feature‑flagged option with a default of false. This will flip the default sandbox behavior for new setups and contradicts the documented default. Keep the example aligned with the default flag.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At env.example, line 43:

<comment>The env example enables WebContainers by default, but the project docs describe this as a feature‑flagged option with a default of false. This will flip the default sandbox behavior for new setups and contradicts the documented default. Keep the example aligned with the default flag.</comment>

<file context>
@@ -39,12 +39,8 @@ NETLIFY_CLIENT_SECRET=""
-# Set to "true" to enable WebContainer-based preview instead of E2B sandboxes
-NEXT_PUBLIC_USE_WEBCONTAINERS="false"
+# WebContainers (browser-based sandboxes)
+NEXT_PUBLIC_USE_WEBCONTAINERS="true"
 
 # Firecrawl
</file context>
Suggested change
NEXT_PUBLIC_USE_WEBCONTAINERS="true"
NEXT_PUBLIC_USE_WEBCONTAINERS="false"
Fix with Cubic

]);

adapterSandboxId = adapter.id;
PENDING_SANDBOX_REQUESTS.set(adapter.id, sandboxPendingRequests);
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 7, 2026

Choose a reason for hiding this comment

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

P2: Memory leak: PENDING_SANDBOX_REQUESTS entries are never cleaned up when the agent run completes. Each sandbox ID creates a permanent entry in the global Map. Consider adding cleanup in a finally block or at the end of the generator:

PENDING_SANDBOX_REQUESTS.delete(adapter.id);
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/code-agent.ts, line 510:

<comment>Memory leak: `PENDING_SANDBOX_REQUESTS` entries are never cleaned up when the agent run completes. Each sandbox ID creates a permanent entry in the global Map. Consider adding cleanup in a `finally` block or at the end of the generator:
```typescript
PENDING_SANDBOX_REQUESTS.delete(adapter.id);
```</comment>

<file context>
@@ -411,15 +479,35 @@ export async function* runCodeAgent(
     ]);
 
+    adapterSandboxId = adapter.id;
+    PENDING_SANDBOX_REQUESTS.set(adapter.id, sandboxPendingRequests);
     console.log("[DEBUG] Sandbox adapter created:", adapter.id);
 
</file context>
Fix with Cubic

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agents/sandbox-utils.ts (1)

39-45: ⚠️ Potential issue | 🔴 Critical

getFindCommand produces a malformed find command — all ignore patterns collapse into one -path argument.

The join embeds raw -not -path tokens inside a single-quoted shell string, so the shell sees one giant glob pattern instead of separate predicates. For ["node_modules", ".git", "dist", "build", ".next"] the output is:

find /home/user -type f -not -path '*/node_modules/* -not -path */.git/* … */build/* -not -path */.next/*' 2>/dev/null

Everything between the outer '…' is a single argument to the first -path. The remaining -not -path clauses are swallowed, so no directories are actually excluded.

Proposed fix
 export const getFindCommand = (framework: Framework): string => {
   const ignorePatterns = ["node_modules", ".git", "dist", "build"];
   if (framework === "nextjs") ignorePatterns.push(".next");
   if (framework === "svelte") ignorePatterns.push(".svelte-kit");
 
-  return `find /home/user -type f -not -path '*/${ignorePatterns.join('/* -not -path */')}/*' 2>/dev/null`;
+  const excludes = ignorePatterns
+    .map((p) => `-not -path '*/${p}/*'`)
+    .join(" ");
+  return `find /home/user -type f ${excludes} 2>/dev/null`;
 };
🤖 Fix all issues with AI agents
In `@src/agents/code-agent.ts`:
- Around line 134-152: The PENDING_SANDBOX_REQUESTS map entries are never
removed, causing a memory leak; modify runCodeAgent so the adapter-scoped entry
(the value assigned via PENDING_SANDBOX_REQUESTS.set(adapter.id,
sandboxPendingRequests)) is deleted when the agent finishes or errors — move or
hoist the adapter declaration so you can use a try/finally around the run logic
and call PENDING_SANDBOX_REQUESTS.delete(adapter.id) in the finally block (also
ensure any remaining per-request entries are cleaned if needed); keep
resolveSandboxResponse behavior unchanged but rely on the finally cleanup to
remove the outer map entry.
- Around line 103-111: The getFindCommand function builds a malformed shell
command by joining ignorePatterns into a single quoted segment so the -not -path
clauses don't apply; fix getFindCommand by mapping each ignorePatterns entry to
its own "-not -path '*/<pattern>/*'" clause and joining those clauses with
spaces (instead of injecting a joined string inside one quoted clause), then
interpolate that resulting string into the final find command (keep the search
root /home/user and preserve redirect 2>/dev/null); update the getFindCommand
implementation to use this mapped-and-joined clauses string so node_modules,
.git, .next, .svelte-kit, etc. are properly excluded during file discovery.
- Around line 1156-1161: The current code launches up to 500 concurrent
adapter.readFile calls (see Promise.all over filePaths.slice(0, 500) that builds
entries), which can overwhelm DeferredSandboxAdapter/SSE; change to a batched
read strategy: chunk the filePaths (e.g., batch size 20–50) and for each chunk
run Promise.all on that slice and await it before starting the next chunk,
collecting [fp, content] pairs into the same entries array; reference the
adapter.readFile calls, the entries variable, and the filePaths.slice(...) usage
when locating where to implement the chunking/loop to avoid flooding the SSE
channel.
- Around line 493-498: sendRequest creates a Promise stored in
sandboxPendingRequests that can hang forever if the client never responds; add a
timeout so the Promise is rejected and cleaned up after a configurable interval
(e.g., SANDBOX_REQUEST_TIMEOUT default 30s). Implement by starting a timer when
creating the promise in sendRequest, storing the timer alongside resolve/reject
in sandboxPendingRequests, and on timeout call reject(new Error(...)) and remove
the entry and emit any cleanup event; also ensure resolveSandboxResponse clears
the timer when resolving so it doesn't fire after a successful response. Update
code paths that access sandboxPendingRequests (sendRequest and
resolveSandboxResponse) to handle the timer lifecycle and use a configurable
timeout constant.
- Around line 1301-1310: The runErrorFix function currently throws
unconditionally which will crash any production caller (the API route handler
that calls runErrorFix); change runErrorFix to return a well-formed failure
result instead of throwing — e.g., return { success: false, message: "Error
fixing not supported with WebContainer backend; please regenerate the fragment
or fix manually.", summary?: undefined, remainingErrors?: undefined } — so
callers can handle the failure safely; ensure the exported function signature
remains the same and update any internal code paths in runErrorFix to use
try/catch and return structured error results rather than throwing.
- Around line 75-132: This file duplicates utilities from
src/agents/sandbox-utils.ts (isValidFilePath, getFindCommand,
AUTO_FIX_ERROR_PATTERNS, shouldTriggerAutoFix); remove the local copies and
import these symbols from sandbox-utils instead, update any local usages to
reference the imported names, and delete the duplicated ALLOWED_WORKSPACE_PATHS
constant; ensure the imported getFindCommand (which contains the corrected find
command) is used so the malformed find command bug is fixed in this module as
well, then run tests/lint to confirm no unresolved references remain.

In `@src/app/api/agent/sandbox-result/route.ts`:
- Around line 12-25: The POST handler currently accepts sandbox results without
authentication; update the POST(request: NextRequest) function to verify the
caller before using sandboxId/response: either validate a Clerk session from the
request (e.g., call a verifyClerkSession(request) helper and reject with 401) or
require and validate a per-sandbox HMAC token (e.g., read an Authorization or
X-Sandbox-HMAC header or a token field, compute/verify against a stored secret
for sandboxId via a validateSandboxHmac(sandboxId, token) helper). On failed
verification return NextResponse.json({ error: "Unauthorized" }, { status: 401
}) and only proceed to check response.requestId and process the result after
successful auth. Ensure the validation helpers are used in this module or
imported so POST enforces auth before any sandbox processing.

In `@src/lib/sandbox-adapter.ts`:
- Around line 494-497: The cleanup() method on WebContainerAdapter currently
unconditionally calls the shared teardownWebContainer(), which will destroy the
singleton webcontainer and break other adapters; change this to only teardown
when this adapter is the last owner: introduce or use a shared reference-count
(or an ownership flag) in the webcontainer management module and increment it
when an adapter creates/attaches to the container and decrement in cleanup();
only call teardownWebContainer() when the refcount reaches zero (or when this
adapter holds the ownership flag), and ensure WebContainerAdapter construction
and cleanup update that counter to avoid destroying the singleton while other
instances still need it.

In `@src/lib/sandbox-bridge.ts`:
- Around line 24-27: The sandbox-bridge module is dead code because
createPendingRequest() is never used, so resolveRequest() always fails; fix by
updating the /api route to use the real pending storage and resolver: replace
imports of resolveRequest (and the sandbox-bridge pending map) with
resolveSandboxResponse (the resolver that uses PENDING_SANDBOX_REQUESTS) from
the code-agent module, and update the call site in the sandbox-result route to
call resolveSandboxResponse(...) instead of resolveRequest(...); alternatively,
if you intend to keep sandbox-bridge as the single source of truth, remove
PENDING_SANDBOX_REQUESTS and ensure the agent handler calls
createPendingRequest(...) to populate the pending Map and that
resolveRequest(...) is used by the route—pick one approach and remove the unused
module or wire up createPendingRequest/resolveRequest consistently (symbols:
createPendingRequest, resolveRequest, pending, REQUEST_TIMEOUT_MS,
PENDING_SANDBOX_REQUESTS, resolveSandboxResponse).

In `@src/lib/use-sandbox-executor.ts`:
- Around line 86-94: The fetch in use-sandbox-executor.ts must detect non-OK
HTTP responses and propagate an error so failures aren't silently ignored: after
awaiting fetch("/api/agent/sandbox-result", ...), check the returned Response.ok
and if false read the response text and throw or console.error with the status
and body (instead of only catching network errors), so callers know the POST
failed; meanwhile, update the server-side sendRequest handler in code-agent.ts
(the DeferredSandboxAdapter pending-request logic / sendRequest callback) to
attach a timeout when creating the pending promise that rejects after a
configurable window (e.g., 10–30s) and cleans up the pending map entry, ensuring
the agent doesn't hang indefinitely if the client never successfully POSTs a
result.
🧹 Nitpick comments (7)
src/inngest/client.ts (1)

40-40: The catch-all event topic undermines the channel's type safety.

All other topics are strongly typed with specific discriminated type fields and structured data payloads. The event topic accepts type: string and data: unknown, which allows any arbitrary payload to bypass the typed topic system. If this is a deliberate escape hatch, consider documenting it. Otherwise, consider removing it or narrowing the type.

package.json (1)

22-24: Minor: stray blank lines left from dependency removal.

Lines 22 and 77 have blank lines that appear to be artifacts from removing @e2b/code-interpreter and possibly another package. Consider removing them to keep the manifest tidy.

src/app/api/agent/sandbox-result/route.ts (1)

14-18: Replace as type assertion with runtime validation.

The body as { sandboxId: string; response: SandboxResponse } cast bypasses type safety. The check on line 20 only validates presence of sandboxId and response.requestId, but doesn't validate the shape of response against the SandboxResponse union. Malformed payloads could propagate unexpected data to the agent.

Consider using a Zod schema to validate the incoming body. As per coding guidelines: "Avoid using 'as' type assertions … resolve types properly."

src/agents/sandbox-utils.ts (2)

17-33: isValidFilePath has a redundant branch on Line 32.

normalizedPath.startsWith("/home/user/") is already covered by ALLOWED_WORKSPACE_PATHS containing "/home/user" and the .startsWith(${basePath}/) check on Line 28. The redundancy isn't harmful but makes the logic harder to reason about.


88-89: runLintCheck is a permanent no-op — consider removing or adding a TODO.

A function that silently returns null could mask missing lint validation down the road. If lint checking is intentionally skipped, a brief inline comment explaining when it's expected to be reinstated would help.

src/lib/sandbox-adapter.ts (1)

86-89: framework field in StartDevServerRequest and GetPreviewUrlRequest is typed string instead of Framework.

The ISandboxAdapter interface declares startDevServer(framework: Framework) and getPreviewUrl(framework: Framework), but the request protocol types use a plain string. This weakens type safety and forces as Framework casts on the consumer side (visible in use-sandbox-executor.ts lines 49, 59).

Proposed fix
 export interface StartDevServerRequest extends BaseRequest {
   type: "start-dev-server";
-  framework: string;
+  framework: Framework;
 }
 ...
 export interface GetPreviewUrlRequest extends BaseRequest {
   type: "get-preview-url";
-  framework: string;
+  framework: Framework;
 }

Also applies to: 101-104

src/lib/use-sandbox-executor.ts (1)

48-51: as Framework type assertions are a downstream effect of the string typing in request protocols.

These casts violate the coding guideline to avoid as assertions. The root fix is to type framework as Framework in StartDevServerRequest and GetPreviewUrlRequest (flagged in sandbox-adapter.ts), which would eliminate the need for these casts.

Also applies to: 58-61

Comment on lines +75 to +132
const ALLOWED_WORKSPACE_PATHS = ["/home/user", "."];

const isValidFilePath = (filePath: string): boolean => {
if (!filePath || typeof filePath !== "string") return false;
const normalizedPath = filePath.trim();
if (normalizedPath.length === 0 || normalizedPath.length > 4096) return false;
if (normalizedPath.includes("..")) return false;
if (
normalizedPath.includes("\0") ||
normalizedPath.includes("\n") ||
normalizedPath.includes("\r")
)
return false;

const isInWorkspace = ALLOWED_WORKSPACE_PATHS.some(
(basePath) =>
normalizedPath === basePath ||
normalizedPath.startsWith(`${basePath}/`) ||
normalizedPath.startsWith(`./`)
);

return (
isInWorkspace ||
normalizedPath.startsWith("/home/user/") ||
!normalizedPath.startsWith("/")
);
};

const getFindCommand = (framework: Framework): string => {
const ignorePatterns = ["node_modules", ".git", "dist", "build"];
if (framework === "nextjs") ignorePatterns.push(".next");
if (framework === "svelte") ignorePatterns.push(".svelte-kit");

return `find /home/user -type f -not -path '*/${ignorePatterns.join(
"/* -not -path */"
)}/*' 2>/dev/null`;
};

const AUTO_FIX_ERROR_PATTERNS = [
/Error:/i,
/\[ERROR\]/i,
/ERROR/,
/Failed\b/i,
/failure\b/i,
/Exception\b/i,
/SyntaxError/i,
/TypeError/i,
/ReferenceError/i,
/Module not found/i,
/Cannot find module/i,
/Build failed/i,
/Compilation error/i,
];

const shouldTriggerAutoFix = (message?: string): boolean => {
if (!message) return false;
return AUTO_FIX_ERROR_PATTERNS.some((pattern) => pattern.test(message));
};
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Duplicated utility functions from sandbox-utils.ts — import instead of redefining.

isValidFilePath, getFindCommand, AUTO_FIX_ERROR_PATTERNS, and shouldTriggerAutoFix are exported from src/agents/sandbox-utils.ts and copy-pasted here with identical logic. This violates DRY and means the getFindCommand bug (malformed find command) exists in two places.

Proposed fix
+import {
+  isValidFilePath,
+  getFindCommand,
+  AUTO_FIX_ERROR_PATTERNS,
+  shouldTriggerAutoFix,
+} from "./sandbox-utils";
-
-const ALLOWED_WORKSPACE_PATHS = ["/home/user", "."];
-
-const isValidFilePath = (filePath: string): boolean => {
-  ...
-};
-
-const getFindCommand = (framework: Framework): string => {
-  ...
-};
-
-const AUTO_FIX_ERROR_PATTERNS = [
-  ...
-];
-
-const shouldTriggerAutoFix = (message?: string): boolean => {
-  ...
-};

Based on learnings: Use @/lib/[filename] for internal imports from the lib directory to maintain modularity — though for agent-internal imports, relative imports within src/agents/ are appropriate.

📝 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
const ALLOWED_WORKSPACE_PATHS = ["/home/user", "."];
const isValidFilePath = (filePath: string): boolean => {
if (!filePath || typeof filePath !== "string") return false;
const normalizedPath = filePath.trim();
if (normalizedPath.length === 0 || normalizedPath.length > 4096) return false;
if (normalizedPath.includes("..")) return false;
if (
normalizedPath.includes("\0") ||
normalizedPath.includes("\n") ||
normalizedPath.includes("\r")
)
return false;
const isInWorkspace = ALLOWED_WORKSPACE_PATHS.some(
(basePath) =>
normalizedPath === basePath ||
normalizedPath.startsWith(`${basePath}/`) ||
normalizedPath.startsWith(`./`)
);
return (
isInWorkspace ||
normalizedPath.startsWith("/home/user/") ||
!normalizedPath.startsWith("/")
);
};
const getFindCommand = (framework: Framework): string => {
const ignorePatterns = ["node_modules", ".git", "dist", "build"];
if (framework === "nextjs") ignorePatterns.push(".next");
if (framework === "svelte") ignorePatterns.push(".svelte-kit");
return `find /home/user -type f -not -path '*/${ignorePatterns.join(
"/* -not -path */"
)}/*' 2>/dev/null`;
};
const AUTO_FIX_ERROR_PATTERNS = [
/Error:/i,
/\[ERROR\]/i,
/ERROR/,
/Failed\b/i,
/failure\b/i,
/Exception\b/i,
/SyntaxError/i,
/TypeError/i,
/ReferenceError/i,
/Module not found/i,
/Cannot find module/i,
/Build failed/i,
/Compilation error/i,
];
const shouldTriggerAutoFix = (message?: string): boolean => {
if (!message) return false;
return AUTO_FIX_ERROR_PATTERNS.some((pattern) => pattern.test(message));
};
import {
isValidFilePath,
getFindCommand,
AUTO_FIX_ERROR_PATTERNS,
shouldTriggerAutoFix,
} from "./sandbox-utils";
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 75 - 132, This file duplicates
utilities from src/agents/sandbox-utils.ts (isValidFilePath, getFindCommand,
AUTO_FIX_ERROR_PATTERNS, shouldTriggerAutoFix); remove the local copies and
import these symbols from sandbox-utils instead, update any local usages to
reference the imported names, and delete the duplicated ALLOWED_WORKSPACE_PATHS
constant; ensure the imported getFindCommand (which contains the corrected find
command) is used so the malformed find command bug is fixed in this module as
well, then run tests/lint to confirm no unresolved references remain.

Comment on lines +103 to +111
const getFindCommand = (framework: Framework): string => {
const ignorePatterns = ["node_modules", ".git", "dist", "build"];
if (framework === "nextjs") ignorePatterns.push(".next");
if (framework === "svelte") ignorePatterns.push(".svelte-kit");

return `find /home/user -type f -not -path '*/${ignorePatterns.join(
"/* -not -path */"
)}/*' 2>/dev/null`;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

getFindCommand has the same malformed shell command bug as in sandbox-utils.ts.

The join produces a single-quoted string containing all -not -path clauses, making the find command match all files. See the detailed explanation in the sandbox-utils.ts review. Since this is the copy that actually executes in agent runs, it means file discovery in the sandbox will return all files including node_modules, leading to massive file reads and potential timeouts.

🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 103 - 111, The getFindCommand function
builds a malformed shell command by joining ignorePatterns into a single quoted
segment so the -not -path clauses don't apply; fix getFindCommand by mapping
each ignorePatterns entry to its own "-not -path '*/<pattern>/*'" clause and
joining those clauses with spaces (instead of injecting a joined string inside
one quoted clause), then interpolate that resulting string into the final find
command (keep the search root /home/user and preserve redirect 2>/dev/null);
update the getFindCommand implementation to use this mapped-and-joined clauses
string so node_modules, .git, .next, .svelte-kit, etc. are properly excluded
during file discovery.

Comment on lines +134 to +152
type PendingRequest = {
resolve: (response: SandboxResponse) => void;
reject: (error: Error) => void;
};

const PENDING_SANDBOX_REQUESTS = new Map<string, Map<string, PendingRequest>>();

export function resolveSandboxResponse(
sandboxId: string,
response: SandboxResponse
): boolean {
const pending = PENDING_SANDBOX_REQUESTS.get(sandboxId);
if (!pending) return false;
const entry = pending.get(response.requestId);
if (!entry) return false;
pending.delete(response.requestId);
entry.resolve(response);
return true;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

PENDING_SANDBOX_REQUESTS outer map entries are never cleaned up — memory leak across agent runs.

PENDING_SANDBOX_REQUESTS.set(adapter.id, sandboxPendingRequests) at Line 510 adds an entry, but there's no corresponding PENDING_SANDBOX_REQUESTS.delete(adapter.id) when the agent run completes or fails. Over many runs, this map grows unboundedly.

Proposed fix — clean up in a finally block

Add cleanup at the end of the runCodeAgent generator, e.g. in both the success path and the catch block:

+  } finally {
+    // Clean up pending sandbox request map
+    if (adapter?.id) {
+      PENDING_SANDBOX_REQUESTS.delete(adapter.id);
+    }
+  }

You'll need to hoist adapter declaration or restructure the try/finally scope accordingly.

Also applies to: 490-510

🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 134 - 152, The
PENDING_SANDBOX_REQUESTS map entries are never removed, causing a memory leak;
modify runCodeAgent so the adapter-scoped entry (the value assigned via
PENDING_SANDBOX_REQUESTS.set(adapter.id, sandboxPendingRequests)) is deleted
when the agent finishes or errors — move or hoist the adapter declaration so you
can use a try/finally around the run logic and call
PENDING_SANDBOX_REQUESTS.delete(adapter.id) in the finally block (also ensure
any remaining per-request entries are cleaned if needed); keep
resolveSandboxResponse behavior unchanged but rely on the finally cleanup to
remove the outer map entry.

Comment on lines +493 to +498
const sendRequest: SendRequestCallback = (request) => {
return new Promise((resolve, reject) => {
sandboxPendingRequests.set(request.id, { resolve, reject });
queueEvent({ type: "sandbox-request", data: { sandboxId: adapterSandboxId, request } });
});
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

sendRequest callback has no timeout — agent hangs forever if client never responds.

The promise created here is stored in sandboxPendingRequests and only resolved when the client POSTs a response via resolveSandboxResponse. If the client disconnects, crashes, or the network drops, this promise never settles, blocking the agent indefinitely.

Proposed fix — add a timeout to the pending promise
+const SANDBOX_REQUEST_TIMEOUT_MS = 60_000; // 60 seconds
+
 const sendRequest: SendRequestCallback = (request) => {
   return new Promise((resolve, reject) => {
     sandboxPendingRequests.set(request.id, { resolve, reject });
     queueEvent({ type: "sandbox-request", data: { sandboxId: adapterSandboxId, request } });
+
+    setTimeout(() => {
+      if (sandboxPendingRequests.has(request.id)) {
+        sandboxPendingRequests.delete(request.id);
+        reject(new Error(`Sandbox request ${request.id} timed out after ${SANDBOX_REQUEST_TIMEOUT_MS}ms`));
+      }
+    }, SANDBOX_REQUEST_TIMEOUT_MS);
   });
 };
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 493 - 498, sendRequest creates a
Promise stored in sandboxPendingRequests that can hang forever if the client
never responds; add a timeout so the Promise is rejected and cleaned up after a
configurable interval (e.g., SANDBOX_REQUEST_TIMEOUT default 30s). Implement by
starting a timer when creating the promise in sendRequest, storing the timer
alongside resolve/reject in sandboxPendingRequests, and on timeout call
reject(new Error(...)) and remove the entry and emit any cleanup event; also
ensure resolveSandboxResponse clears the timer when resolving so it doesn't fire
after a successful response. Update code paths that access
sandboxPendingRequests (sendRequest and resolveSandboxResponse) to handle the
timer lifecycle and use a configurable timeout constant.

Comment on lines +1156 to +1161
const entries = await Promise.all(
filePaths.slice(0, 500).map(async (fp) => {
const content = await adapter.readFile(fp);
return [fp, content] as const;
})
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

500 concurrent adapter.readFile calls via Promise.all may overwhelm the client.

For DeferredSandboxAdapter, each readFile call creates a sandbox request that must be serialized as an SSE event, executed by the client in WebContainer, and POSTed back. Firing 500 of these in parallel could flood the SSE channel and cause timeouts or dropped requests. Consider batching reads (e.g. 20-50 at a time).

Proposed batched read approach
-    const entries = await Promise.all(
-      filePaths.slice(0, 500).map(async (fp) => {
-        const content = await adapter.readFile(fp);
-        return [fp, content] as const;
-      })
-    );
+    const BATCH_SIZE = 30;
+    const entries: (readonly [string, string | null])[] = [];
+    const paths = filePaths.slice(0, 500);
+    for (let i = 0; i < paths.length; i += BATCH_SIZE) {
+      const batch = paths.slice(i, i + BATCH_SIZE);
+      const results = await Promise.all(
+        batch.map(async (fp) => {
+          const content = await adapter.readFile(fp);
+          return [fp, content] as const;
+        })
+      );
+      entries.push(...results);
+    }
📝 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
const entries = await Promise.all(
filePaths.slice(0, 500).map(async (fp) => {
const content = await adapter.readFile(fp);
return [fp, content] as const;
})
);
const BATCH_SIZE = 30;
const entries: (readonly [string, string | null])[] = [];
const paths = filePaths.slice(0, 500);
for (let i = 0; i < paths.length; i += BATCH_SIZE) {
const batch = paths.slice(i, i + BATCH_SIZE);
const results = await Promise.all(
batch.map(async (fp) => {
const content = await adapter.readFile(fp);
return [fp, content] as const;
})
);
entries.push(...results);
}
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 1156 - 1161, The current code launches
up to 500 concurrent adapter.readFile calls (see Promise.all over
filePaths.slice(0, 500) that builds entries), which can overwhelm
DeferredSandboxAdapter/SSE; change to a batched read strategy: chunk the
filePaths (e.g., batch size 20–50) and for each chunk run Promise.all on that
slice and await it before starting the next chunk, collecting [fp, content]
pairs into the same entries array; reference the adapter.readFile calls, the
entries variable, and the filePaths.slice(...) usage when locating where to
implement the chunking/loop to avoid flooding the SSE channel.

Comment on lines +1301 to 1310
export async function runErrorFix(_fragmentId: string): Promise<{
success: boolean;
message: string;
summary?: string;
remainingErrors?: string;
}> {
const fragment = await convex.query(api.messages.getFragmentById, {
fragmentId: fragmentId as Id<"fragments">,
});

if (!fragment) {
throw new Error("Fragment not found");
}

if (!fragment.sandboxId) {
throw new Error("Fragment has no active sandbox");
}

const message = await convex.query(api.messages.get, {
messageId: fragment.messageId as Id<"messages">,
});
if (!message) {
throw new Error("Message not found");
}

const project = await convex.query(api.projects.getForSystem, {
projectId: message.projectId as Id<"projects">,
});
if (!project) {
throw new Error("Project not found");
}

const fragmentFramework = (fragment.framework?.toLowerCase() ||
"nextjs") as Framework;
const sandboxId = fragment.sandboxId;

let sandbox: Sandbox;
try {
sandbox = await getSandbox(sandboxId);
} catch {
throw new Error("Sandbox is no longer active. Please refresh the fragment.");
}

const fragmentMetadata =
typeof fragment.metadata === "object" && fragment.metadata !== null
? (fragment.metadata as Record<string, unknown>)
: {};

const fragmentModel =
(fragmentMetadata.model as keyof typeof MODEL_CONFIGS) ||
"anthropic/claude-haiku-4.5";

// Skip lint check for speed - only run build validation
const buildErrors = await runBuildCheck(sandbox);

const validationErrors = buildErrors || "";

if (!validationErrors) {
return {
success: true,
message: "No errors detected",
};
}

const state: AgentState = {
summary: "",
files: fragment.files as Record<string, string>,
selectedFramework: fragmentFramework,
summaryRetryCount: 0,
};

const tools = createAgentTools({
sandboxId,
state,
updateFiles: (files) => {
state.files = files;
},
});

const frameworkPrompt = getFrameworkPrompt(fragmentFramework);
const modelConfig = MODEL_CONFIGS[fragmentModel];

const fixPrompt = `CRITICAL ERROR FIX REQUEST

The following errors were detected in the application and need to be fixed immediately:

${validationErrors}

REQUIRED ACTIONS:
1. Carefully analyze the error messages to identify the root cause
2. Check for common issues: missing imports, type errors, syntax errors, missing packages
3. Apply the necessary fixes to resolve ALL errors completely
4. Verify the fixes by ensuring the code is syntactically correct
5. Provide a <task_summary> explaining what was fixed`;

const result = await withRateLimitRetry(
() => generateText({
model: getClientForModel(fragmentModel).chat(fragmentModel),
system: frameworkPrompt,
messages: [{ role: "user", content: fixPrompt }],
tools,
stopWhen: stepCountIs(10),
temperature: modelConfig.temperature,
}),
{ context: "runErrorFix" }
throw new Error(
"Error fix is not supported with the WebContainer backend. " +
"Please regenerate the fragment or fix errors manually."
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "runErrorFix" --type ts -B 2 -A 2

Repository: Jackson57279/zapdev

Length of output: 3038


runErrorFix is a hard-fail stub with an active production caller — will crash at runtime.

The function unconditionally throws. Active code at src/app/api/fix-errors/route.ts:50 calls runErrorFix directly in a production API endpoint. Any client invoking this endpoint will receive a runtime error instead of error-fixing functionality.

🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 1301 - 1310, The runErrorFix function
currently throws unconditionally which will crash any production caller (the API
route handler that calls runErrorFix); change runErrorFix to return a
well-formed failure result instead of throwing — e.g., return { success: false,
message: "Error fixing not supported with WebContainer backend; please
regenerate the fragment or fix manually.", summary?: undefined,
remainingErrors?: undefined } — so callers can handle the failure safely; ensure
the exported function signature remains the same and update any internal code
paths in runErrorFix to use try/catch and return structured error results rather
than throwing.

Comment on lines +12 to +25
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { sandboxId, response } = body as {
sandboxId: string;
response: SandboxResponse;
};

if (!sandboxId || !response || !response.requestId) {
return NextResponse.json(
{ error: "Missing sandboxId or response with requestId" },
{ status: 400 }
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Missing authentication — endpoint is publicly accessible.

This endpoint allows any unauthenticated caller to resolve pending agent sandbox requests with arbitrary response data. An attacker who can guess or enumerate sandboxId and requestId values could inject malicious command outputs or file contents into the agent's execution flow.

Add authentication (e.g., Clerk session verification or a per-sandbox HMAC token) before processing the result.

🤖 Prompt for AI Agents
In `@src/app/api/agent/sandbox-result/route.ts` around lines 12 - 25, The POST
handler currently accepts sandbox results without authentication; update the
POST(request: NextRequest) function to verify the caller before using
sandboxId/response: either validate a Clerk session from the request (e.g., call
a verifyClerkSession(request) helper and reject with 401) or require and
validate a per-sandbox HMAC token (e.g., read an Authorization or X-Sandbox-HMAC
header or a token field, compute/verify against a stored secret for sandboxId
via a validateSandboxHmac(sandboxId, token) helper). On failed verification
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) and only
proceed to check response.requestId and process the result after successful
auth. Ensure the validation helpers are used in this module or imported so POST
enforces auth before any sandbox processing.

Comment on lines 494 to 497
async cleanup(): Promise<void> {
const { teardownWebContainer } = await import("@/lib/webcontainer");
teardownWebContainer();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

cleanup() calls the global teardownWebContainer(), which destroys the singleton — risky if multiple adapters coexist.

If two WebContainerAdapter instances are created (e.g. across tabs or re-init scenarios), calling cleanup() on one will tear down the shared WebContainer for both. Consider guarding against this or documenting the singleton constraint.

🤖 Prompt for AI Agents
In `@src/lib/sandbox-adapter.ts` around lines 494 - 497, The cleanup() method on
WebContainerAdapter currently unconditionally calls the shared
teardownWebContainer(), which will destroy the singleton webcontainer and break
other adapters; change this to only teardown when this adapter is the last
owner: introduce or use a shared reference-count (or an ownership flag) in the
webcontainer management module and increment it when an adapter creates/attaches
to the container and decrement in cleanup(); only call teardownWebContainer()
when the refcount reaches zero (or when this adapter holds the ownership flag),
and ensure WebContainerAdapter construction and cleanup update that counter to
avoid destroying the singleton while other instances still need it.

Comment on lines 86 to 94
try {
await fetch("/api/agent/sandbox-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sandboxId, response }),
});
} catch (postError) {
console.error("[sandbox-executor] Failed to POST result:", postError);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Agent can hang indefinitely if the result POST fails or returns a non-OK status.

The server-side DeferredSandboxAdapter blocks on a promise that is only resolved when the client POSTs the result back to /api/agent/sandbox-result. If this fetch fails (network error caught at Line 92) or returns a non-OK HTTP status (not checked), the corresponding promise on the server side is never resolved or rejected — the agent hangs forever.

At minimum, check response.ok and consider a timeout on the server-side pending request map to avoid unbounded waits.

Proposed fix (client side)
       try {
-        await fetch("/api/agent/sandbox-result", {
+        const res = await fetch("/api/agent/sandbox-result", {
           method: "POST",
           headers: { "Content-Type": "application/json" },
           body: JSON.stringify({ sandboxId, response }),
         });
+        if (!res.ok) {
+          console.error("[sandbox-executor] POST returned", res.status, await res.text());
+        }
       } catch (postError) {
         console.error("[sandbox-executor] Failed to POST result:", postError);
       }

Additionally, the server-side sendRequest callback in code-agent.ts (Line 493-498) should implement a timeout to reject the pending promise if no response arrives within a reasonable window, preventing the agent from blocking indefinitely.

📝 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
try {
await fetch("/api/agent/sandbox-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sandboxId, response }),
});
} catch (postError) {
console.error("[sandbox-executor] Failed to POST result:", postError);
}
try {
const res = await fetch("/api/agent/sandbox-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sandboxId, response }),
});
if (!res.ok) {
console.error("[sandbox-executor] POST returned", res.status, await res.text());
}
} catch (postError) {
console.error("[sandbox-executor] Failed to POST result:", postError);
}
🤖 Prompt for AI Agents
In `@src/lib/use-sandbox-executor.ts` around lines 86 - 94, The fetch in
use-sandbox-executor.ts must detect non-OK HTTP responses and propagate an error
so failures aren't silently ignored: after awaiting
fetch("/api/agent/sandbox-result", ...), check the returned Response.ok and if
false read the response text and throw or console.error with the status and body
(instead of only catching network errors), so callers know the POST failed;
meanwhile, update the server-side sendRequest handler in code-agent.ts (the
DeferredSandboxAdapter pending-request logic / sendRequest callback) to attach a
timeout when creating the pending promise that rejects after a configurable
window (e.g., 10–30s) and cleans up the pending map entry, ensuring the agent
doesn't hang indefinitely if the client never successfully POSTs a result.

@codecapyai
Copy link

codecapyai bot commented Feb 11, 2026

❌ Error while analyzing PR and generating tests:

Error code: 400 - {'error': {'message': "This model's maximum context length is 200000 tokens. However, your messages resulted in 219911 tokens (including 381 in the response_format schemas.). Please reduce the length of the messages or schemas.", 'type': 'invalid_request_error', 'param': 'messages', 'code': 'context_length_exceeded'}}

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

4 issues found across 10 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name=".sisyphus/boulder.json">

<violation number="1" location=".sisyphus/boulder.json:2">
P2: Avoid committing absolute filesystem paths; they break portability and violate the repository’s anti-pattern guidance. Store a repo-relative path instead.</violation>
</file>

<file name="src/agents/code-agent.ts">

<violation number="1" location="src/agents/code-agent.ts:937">
P2: The retry-with-tool-use flow generates new text, but fullText is never updated with retryResult.text. As a result, the summary generation can use the original (empty/no-tool) response instead of the retry output, producing inaccurate summaries for the files actually created.</violation>
</file>

<file name="src/agents/tools.ts">

<violation number="1" location="src/agents/tools.ts:185">
P1: Sanitize or safely quote the user-provided path before interpolating it into shell commands to avoid command injection.</violation>

<violation number="2" location="src/agents/tools.ts:213">
P1: Escape or validate pattern/path inputs before interpolating into a shell command to prevent command injection and invalid grep execution.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

try {
const searchPath = path || ".";
const includePattern = filePattern ? `--include="${filePattern}"` : "";
const command = `grep -r ${includePattern} -l "${pattern}" ${searchPath} 2>/dev/null | head -20`;
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 11, 2026

Choose a reason for hiding this comment

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

P1: Escape or validate pattern/path inputs before interpolating into a shell command to prevent command injection and invalid grep execution.

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

<comment>Escape or validate pattern/path inputs before interpolating into a shell command to prevent command injection and invalid grep execution.</comment>

<file context>
@@ -171,5 +171,158 @@ export function createAgentTools(context: ToolContext) {
+        try {
+          const searchPath = path || ".";
+          const includePattern = filePattern ? `--include="${filePattern}"` : "";
+          const command = `grep -r ${includePattern} -l "${pattern}" ${searchPath} 2>/dev/null | head -20`;
+          const result = await adapter.runCommand(command);
+          const files = result.stdout.split("\n").filter(f => f.trim());
</file context>
Fix with Cubic

console.log("[DEBUG] listFiles tool called for path:", path);
onToolCall?.("listFiles", { path, recursive });
try {
const command = recursive
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 11, 2026

Choose a reason for hiding this comment

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

P1: Sanitize or safely quote the user-provided path before interpolating it into shell commands to avoid command injection.

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

<comment>Sanitize or safely quote the user-provided path before interpolating it into shell commands to avoid command injection.</comment>

<file context>
@@ -171,5 +171,158 @@ export function createAgentTools(context: ToolContext) {
+        console.log("[DEBUG] listFiles tool called for path:", path);
+        onToolCall?.("listFiles", { path, recursive });
+        try {
+          const command = recursive 
+            ? `find ${path} -type f 2>/dev/null | head -50`
+            : `ls -la ${path} 2>/dev/null`;
</file context>
Fix with Cubic

@@ -0,0 +1,6 @@
{
"active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md",
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 11, 2026

Choose a reason for hiding this comment

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

P2: Avoid committing absolute filesystem paths; they break portability and violate the repository’s anti-pattern guidance. Store a repo-relative path instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .sisyphus/boulder.json, line 2:

<comment>Avoid committing absolute filesystem paths; they break portability and violate the repository’s anti-pattern guidance. Store a repo-relative path instead.</comment>

<file context>
@@ -0,0 +1,6 @@
+{
+  "active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md",
+  "started_at": "2026-02-08T06:29:01.522Z",
+  "session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"],
</file context>
Fix with Cubic

}
}

const retrySummary = extractSummaryText(retryResult.text || "");
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 11, 2026

Choose a reason for hiding this comment

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

P2: The retry-with-tool-use flow generates new text, but fullText is never updated with retryResult.text. As a result, the summary generation can use the original (empty/no-tool) response instead of the retry output, producing inaccurate summaries for the files actually created.

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

<comment>The retry-with-tool-use flow generates new text, but fullText is never updated with retryResult.text. As a result, the summary generation can use the original (empty/no-tool) response instead of the retry output, producing inaccurate summaries for the files actually created.</comment>

<file context>
@@ -892,6 +896,55 @@ export async function* runCodeAgent(
+          }
+        }
+
+        const retrySummary = extractSummaryText(retryResult.text || "");
+        if (retrySummary) {
+          state.summary = retrySummary;
</file context>
Suggested change
const retrySummary = extractSummaryText(retryResult.text || "");
const retryText = retryResult.text || "";
const retrySummary = extractSummaryText(retryText);
if (retryText) {
fullText = retryText;
}
Fix with Cubic

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In @.sisyphus/boulder.json:
- Around line 1-6: The .sisyphus/boulder.json currently contains an absolute
path in the "active_plan" field and should not be committed; change the
"active_plan" value to a relative path (e.g.,
".sisyphus/plans/export-to-polaris.md") in .sisyphus/boulder.json and add the
filename .sisyphus/boulder.json to .gitignore so the transient session state
(session_ids, started_at) is not tracked; update the file and commit the
.gitignore change.

In @.sisyphus/drafts/export-to-polaris.md:
- Around line 69-76: Add two new subsections under the "Data Scope: Everything
Including Chat History" area in .sisyphus/drafts/export-to-polaris.md: "Security
Considerations" and "Scalability Considerations". In "Security Considerations"
list encryption requirements (TLS in transit, AES-256 at rest), API
token/credential handling (scoped credentials, rotation, storage), temporary
token TTLs, PII/data classification and redaction rules for chat history, and
rate-limiting/abuse controls. In "Scalability Considerations" specify max
project size and file/attachment/message limits (or recommend defaults),
attachment size limits, chunked/streamed transfer for large projects,
timeout/heartbeat behavior, and strategies for retries, mid-transfer
rollback/consistency, and user notification on partial failures.
- Line 154: Populate the "Open Questions for Metis Review" section by listing
the seven specific decision points extracted from the document and providing
concise options for each so reviewers can pick or comment: 1) For Authentication
(refer to the three approaches noted around lines 118-132) enumerate pros/cons
and recommend one (OAuth with token exchange, API-key transfer, or
user-initiated SSO) and ask which to adopt; 2) For Data size limits state
concrete proposals for max project size, max file count, and message history
retention (e.g., 5GB / 10k files / 12 months) and ask for agreement or changes;
3) For Failure handling propose rollback vs retry semantics and an idempotent
resume mechanism and ask which behavior to standardize; 4) For Security specify
how to protect API keys/tokens/user data in transit and at rest (TLS, envelope
encryption, short-lived tokens) and request approval; 5) For Attachments ask
whether to include attachments, propose size/format limits and optional
on-demand transfer, and request decision; 6) For Performance propose
chunking/streaming strategies for large projects (e.g., chunk size, parallelism,
backpressure) and ask which to prefer; 7) For User experience propose requiring
pre-existing Polaris accounts vs auto-creating accounts with user consent and
ask which flow to implement. Keep each question as a short bullet-like decision
prompt so Metis can answer succinctly.
- Around line 118-132: Add a clear recommendation to the "Authentication"
section: choose Option 3 (Temporary Transfer Token) as the default for the
export flow because it best supports the "Export with Redirect" UX; explicitly
state that users do NOT need pre-existing accounts on both platforms, that
export history should be tied to the Polaris account created/claimed via the
token (or mapped if an existing account is claimed) to preserve auditability,
and that ZapDev should issue short-lived transfer tokens used in the redirect to
create or link a Polaris account; also update the "Open Questions for Metis
Review" to surface these three decisions (pre-existing accounts required, export
history identity mapping, and alignment with Export with Redirect).

In @.sisyphus/plans/export-to-polaris.md:
- Around line 190-198: The dependency matrix and Task 3/Task 4 descriptions are
inconsistent: decide whether Task 3 and Task 4 are parallel (Wave 2) or Task 4
truly blocks Task 3, then make both places match; if you choose parallel, remove
Task 4 from the "Blocks" column for Task 3 in the matrix and remove any "Blocked
By: Task 4" text in Task 3's description (ensure both list only Task 2 as
blocker), or if you choose that Task 4 blocks Task 3, update Task 3's "Blocked
By" section to include Task 4 and move Task 4 out of Wave 2 (or adjust waves so
they aren't parallel) so the matrix and task descriptions are consistent.

In `@convex/schema.ts`:
- Around line 100-104: The exported v.union named skillStatusEnum is declared
but unused; either remove the skillStatusEnum export or add a status field to
the skills table that references it. To fix, locate the skills table definition
(symbol: skills) and add a property status: skillStatusEnum to its schema if you
intend to track skill state, or delete the skillStatusEnum declaration/export if
it’s not needed; if you add the field, ensure default/optional behavior is
handled (e.g., required vs optional) and update any code that constructs or
reads skills to account for the new status property.
- Around line 351-357: The query that reads the projectDeploymentCounters row
should enforce a single-row-per-project invariant—replace the use of .first()
with .unique() on the query that selects from projectDeploymentCounters (the
call in convex/deployments.ts that currently uses .first()). Keep the existing
missing-row handling as-is; switching to .unique() will cause an error if
multiple rows exist, guaranteeing the one-counter-per-project constraint.

In `@src/agents/code-agent.ts`:
- Line 71: The constant MAX_AGENT_ITERATIONS is set to 12 which violates the
guideline limit of 8; change the constant declaration MAX_AGENT_ITERATIONS to 8
(e.g., const MAX_AGENT_ITERATIONS = 8) and ensure any code paths that rely on
this value (agent loop/iteration checks) continue to use this constant so the
agent enforces the 8-iteration max consistently.

In `@src/agents/tools.ts`:
- Around line 226-251: The installDependencies tool builds devFlag incorrectly
for pnpm: change the devFlag calculation in the installDependencies execute
function so that when dev is true you use the npm/pnpm flag "--save-dev" (or
"-D") and for other managers (e.g., bun if applicable) use the appropriate flag;
specifically update the line that sets devFlag (currently using pkgManager ===
"npm" ? "--save-dev" : "--dev") to use pkgManager === "npm" || pkgManager ===
"pnpm" ? "--save-dev" : "<other-manager-flag-or-empty>" (or "-D" for pnpm if you
prefer), and ensure the final command string (built via pkgManager and
packages.join(" ")) handles an empty devFlag cleanly. Reference:
installDependencies, pkgManager, devFlag, adapter.runCommand.
- Around line 253-297: The runBuildAndLint tool currently treats any fulfilled
Promise as success; update execute in runBuildAndLint to determine success by
checking fulfilled results' value.exitCode (treat exitCode === 0 as success) and
use value.stdout/value.stderr for outputs and errors, while handling rejected
promises as failures; also simplify the dead ternary for buildCmd (replace
framework === "nextjs" ? "npm run build" : "npm run build" with a single
assignment). Ensure you reference adapter.runCommand results (buildResult and
lintResult) and set results.build.success/results.lint.success based on
exitCode, and populate results.build.error/results.lint.error from stderr or
reason.message when appropriate.
🧹 Nitpick comments (8)
src/app/api/agent/sandbox-result/route.ts (1)

20-23: Avoid as type assertion; validate the request body properly.

The as cast bypasses type safety — if the client sends a malformed payload (e.g., response missing fields of the SandboxResponse union), it will silently pass the shallow !response check on line 25 and resolve a pending promise with an unexpected shape, potentially corrupting agent state.

Use runtime validation (e.g., Zod) to parse the body and reject invalid payloads at the boundary. As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly."

Proposed fix (sketch with Zod)
+import { z } from "zod";
+
+const SandboxResultSchema = z.object({
+  sandboxId: z.string().min(1),
+  response: z.object({
+    requestId: z.string().min(1),
+  }).passthrough(),  // allow additional SandboxResponse fields
+});
+
 export async function POST(request: NextRequest) {
   try {
     const body = await request.json();
-    const { sandboxId, response } = body as {
-      sandboxId: string;
-      response: SandboxResponse;
-    };
-
-    if (!sandboxId || !response || !response.requestId) {
-      return NextResponse.json(
-        { error: "Missing sandboxId or response with requestId" },
-        { status: 400 }
-      );
-    }
+    const parsed = SandboxResultSchema.safeParse(body);
+    if (!parsed.success) {
+      return NextResponse.json(
+        { error: "Invalid request body", details: parsed.error.flatten() },
+        { status: 400 }
+      );
+    }
+    const { sandboxId, response } = parsed.data as {
+      sandboxId: string;
+      response: SandboxResponse;
+    };
.sisyphus/drafts/export-to-polaris.md (1)

86-104: Add language identifier to fenced code block.

The data flow diagram is clear and helpful, but the fenced code block should specify a language for proper rendering and accessibility.

📝 Suggested fix
-```
+```text
 ZapDev                          Polaris
    |                                |
convex/schema.ts (3)

61-66: Inconsistent casing: githubExportStatusEnum uses lowercase while importStatusEnum uses UPPERCASE.

importStatusEnum (line 54) uses "PENDING", "PROCESSING", "COMPLETE", "FAILED", but githubExportStatusEnum uses "pending", "processing", "complete", "failed" for semantically identical statuses. Similarly, the inline deployments.status enum (line 209) also uses lowercase. Pick one convention to avoid confusion and bugs when comparing status values across tables.


209-214: Extract the deployment status enum into a named constant for consistency.

Every other status/type union in this schema is defined as a top-level exported const (e.g., importStatusEnum, githubExportStatusEnum). This inline union breaks that pattern and makes it harder to reuse in argument validators or other files.

♻️ Suggested refactor
+export const deploymentStatusEnum = v.union(
+  v.literal("pending"),
+  v.literal("building"),
+  v.literal("ready"),
+  v.literal("error")
+);

Then reference it in the table definition:

-    status: v.union(
-      v.literal("pending"),
-      v.literal("building"),
-      v.literal("ready"),
-      v.literal("error")
-    ),
+    status: deploymentStatusEnum,

124-124: importMetadata: v.optional(v.any()) bypasses type safety on a new field.

This is a newly added field. Per coding guidelines, any should be avoided. Define a typed validator (even a loose v.record(v.string(), v.any()) is better) or a specific object shape that captures the expected import metadata structure.

As per coding guidelines: "Enable TypeScript strict mode and never use any type" and "DO NOT use any in function signatures; leverage Doc<'table'> or Id<'table'> for type safety."

.sisyphus/plans/export-to-polaris.md (1)

149-160: Consider SHA-256 instead of MD5 for integrity checks.

Line 156 recommends comparing MD5 hashes for data integrity verification. While MD5 is fine for non-adversarial checksumming, SHA-256 is a better default — especially given the security-conscious nature of this integration (HMAC, API keys, replay protection). It's cheap to use the stronger hash from the start.

src/agents/tools.ts (2)

175-198: Shell arguments are interpolated without escaping — injection risk (sandbox-mitigated).

In listFiles (Line 186) and searchFiles (Line 213), user-provided path and pattern values are interpolated directly into shell commands. While the blast radius is contained within the E2B sandbox, an LLM-supplied path like ; rm -rf / or a pattern containing $(...) could cause unexpected behavior within the sandbox session.

Consider escaping or quoting arguments:

♻️ Example for listFiles
-          const command = recursive 
-            ? `find ${path} -type f 2>/dev/null | head -50`
-            : `ls -la ${path} 2>/dev/null`;
+          const safePath = path.replace(/'/g, "'\\''");
+          const command = recursive 
+            ? `find '${safePath}' -type f 2>/dev/null | head -50`
+            : `ls -la '${safePath}' 2>/dev/null`;

Also applies to: 200-224


299-326: JSON.parse on potentially malformed file content will discard all results.

Lines 313–314: if package.json or tsconfig.json contains invalid JSON, the JSON.parse throws, and the outer catch returns a generic error string — losing the directory listing and whichever file parsed successfully.

♻️ Safer parsing
+          const safeParse = (raw: string | null): unknown => {
+            try { return JSON.parse(raw || "{}"); } catch { return null; }
+          };
           const result = {
-            packageJson: packageJson.status === "fulfilled" ? JSON.parse(packageJson.value || "{}") : null,
-            tsconfigJson: tsconfigJson.status === "fulfilled" ? JSON.parse(tsconfigJson.value || "{}") : null,
+            packageJson: packageJson.status === "fulfilled" ? safeParse(packageJson.value) : null,
+            tsconfigJson: tsconfigJson.status === "fulfilled" ? safeParse(tsconfigJson.value) : null,
             rootFiles: dirListing.status === "fulfilled" ? dirListing.value.stdout : "",
           };

Comment on lines +1 to +6
{
"active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md",
"started_at": "2026-02-08T06:29:01.522Z",
"session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"],
"plan_name": "export-to-polaris"
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if .gitignore already has sisyphus entries
echo "=== .gitignore contents related to sisyphus ==="
rg -n "sisyphus" .gitignore 2>/dev/null || echo "No sisyphus entries in .gitignore (or .gitignore not found)"

echo ""
echo "=== Other .sisyphus files in the repo ==="
fd . .sisyphus --type f 2>/dev/null

echo ""
echo "=== Full .gitignore content ==="
cat .gitignore 2>/dev/null || echo ".gitignore not found"

Repository: Jackson57279/zapdev

Length of output: 1167


🏁 Script executed:

#!/bin/bash
# Check if boulder.json is tracked in git
echo "=== Is .sisyphus/boulder.json tracked by git? ==="
git ls-files .sisyphus/boulder.json && echo "YES - boulder.json is committed to the repository" || echo "NO - boulder.json is not tracked"

echo ""
echo "=== Current git status ==="
git status .sisyphus/boulder.json 2>/dev/null || echo "File status check result shown above"

Repository: Jackson57279/zapdev

Length of output: 279


Use a relative path and add .sisyphus/boulder.json to .gitignore.

The active_plan contains the absolute path /home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/... which is specific to one developer's machine and will not resolve for anyone else. Additionally, this file is ephemeral Sisyphus session state (session IDs, timestamps) and should not be committed to the repository.

Change to a relative path:

Proposed fix
 {
-  "active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md",
+  "active_plan": ".sisyphus/plans/export-to-polaris.md",
   "started_at": "2026-02-08T06:29:01.522Z",
   "session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"],
   "plan_name": "export-to-polaris"
 }

Also add .sisyphus/boulder.json to .gitignore since this is transient local state.

📝 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
{
"active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md",
"started_at": "2026-02-08T06:29:01.522Z",
"session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"],
"plan_name": "export-to-polaris"
}
{
"active_plan": ".sisyphus/plans/export-to-polaris.md",
"started_at": "2026-02-08T06:29:01.522Z",
"session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"],
"plan_name": "export-to-polaris"
}
🤖 Prompt for AI Agents
In @.sisyphus/boulder.json around lines 1 - 6, The .sisyphus/boulder.json
currently contains an absolute path in the "active_plan" field and should not be
committed; change the "active_plan" value to a relative path (e.g.,
".sisyphus/plans/export-to-polaris.md") in .sisyphus/boulder.json and add the
filename .sisyphus/boulder.json to .gitignore so the transient session state
(session_ids, started_at) is not tracked; update the file and commit the
.gitignore change.

Comment on lines +69 to +76
### Data Scope: **Everything Including Chat History**
Transfer to Polaris:
- ✅ Generated code files (from fragments)
- ✅ Project settings (framework, database provider, etc.)
- ✅ Full conversation/message history
- ✅ Message attachments (images, Figma files)
- ✅ Project metadata (name, created date)

Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add security and scalability considerations sections.

The design mentions transferring "everything including chat history" but lacks critical sections on security and scalability:

Security Considerations Needed:

  • How is data protected during transfer (encryption in transit/at rest)?
  • How are API tokens/credentials secured?
  • How long are temporary tokens valid?
  • What about PII or sensitive data in chat history?
  • Rate limiting to prevent abuse?

Scalability Considerations Needed:

  • Maximum project size (MB/GB)?
  • File count limits?
  • Message history limits (e.g., last 1000 messages)?
  • Attachment size limits?
  • Timeout handling for large transfers?
  • Should large projects use chunked/streamed transfer?

Error Handling:

  • What happens if transfer fails mid-way?
  • Retry logic?
  • Rollback mechanism?
  • User notification strategy?

These considerations are essential before implementation, especially given the PR comments mention existing security vulnerabilities in the broader codebase.

🤖 Prompt for AI Agents
In @.sisyphus/drafts/export-to-polaris.md around lines 69 - 76, Add two new
subsections under the "Data Scope: Everything Including Chat History" area in
.sisyphus/drafts/export-to-polaris.md: "Security Considerations" and
"Scalability Considerations". In "Security Considerations" list encryption
requirements (TLS in transit, AES-256 at rest), API token/credential handling
(scoped credentials, rotation, storage), temporary token TTLs, PII/data
classification and redaction rules for chat history, and rate-limiting/abuse
controls. In "Scalability Considerations" specify max project size and
file/attachment/message limits (or recommend defaults), attachment size limits,
chunked/streamed transfer for large projects, timeout/heartbeat behavior, and
strategies for retries, mid-transfer rollback/consistency, and user notification
on partial failures.

Comment on lines +118 to +132
### Authentication
Option 1: **API Keys**
- ZapDev has Polaris API key
- Simple but no user identity transfer

Option 2: **User Account Linking**
- Users have accounts on both platforms
- OAuth or token-based user mapping
- Better UX but more complex

Option 3: **Temporary Transfer Token**
- ZapDev generates temporary token
- Polaris creates account/project from token
- Email-based claim if no existing account

Copy link
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:

# First, check if the file exists and examine its content
if [ -f ".sisyphus/drafts/export-to-polaris.md" ]; then
  echo "=== File exists, checking line count ===" 
  wc -l ".sisyphus/drafts/export-to-polaris.md"
  echo "=== Lines 110-140 ===" 
  sed -n '110,140p' ".sisyphus/drafts/export-to-polaris.md"
else
  echo "File not found at .sisyphus/drafts/export-to-polaris.md"
  # Try to locate it
  find . -name "export-to-polaris.md" -type f 2>/dev/null
fi

Repository: Jackson57279/zapdev

Length of output: 1052


🏁 Script executed:

# Read the entire document to check for authentication decision
cat -n ".sisyphus/drafts/export-to-polaris.md"

Repository: Jackson57279/zapdev

Length of output: 8222


Clarify the authentication approach within this draft.

The three authentication options (lines 118-132) lack a decision. Since this is a design draft with an "Open Questions for Metis Review" section, consider adding a recommendation here that addresses:

  • Whether users must have accounts on both platforms
  • Whether to tie export history to user identity
  • Which approach aligns with the "Export with Redirect" UX strategy (lines 63-67)

Option 3 (Temporary Transfer Token) appears most aligned with the redirect flow, allowing new Polaris accounts to be created on-the-fly without requiring pre-existing user accounts.

🤖 Prompt for AI Agents
In @.sisyphus/drafts/export-to-polaris.md around lines 118 - 132, Add a clear
recommendation to the "Authentication" section: choose Option 3 (Temporary
Transfer Token) as the default for the export flow because it best supports the
"Export with Redirect" UX; explicitly state that users do NOT need pre-existing
accounts on both platforms, that export history should be tied to the Polaris
account created/claimed via the token (or mapped if an existing account is
claimed) to preserve auditability, and that ZapDev should issue short-lived
transfer tokens used in the redirect to create or link a Polaris account; also
update the "Open Questions for Metis Review" to surface these three decisions
(pre-existing accounts required, export history identity mapping, and alignment
with Export with Redirect).

- Maybe "Imported from ZapDev" badge on projects
- Welcome/onboarding for imported projects

## Open Questions for Metis Review
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Populate the "Open Questions for Metis Review" section.

This section is empty but critical for a design document. Based on the document content, several important questions need to be addressed:

  1. Authentication: Which of the three authentication approaches (lines 118-132) should be implemented?
  2. Data size limits: What are the maximum project size, file count, and message history limits?
  3. Failure handling: What happens if the import fails mid-transfer? Is there rollback or retry logic?
  4. Security: How is sensitive data (API keys, tokens, user data) protected during transfer?
  5. Attachments: Should message attachments (images, Figma files) be transferred? What's the size limit?
  6. Performance: How should large projects (e.g., 1000+ files) be handled? Chunking? Streaming?
  7. User experience: Should users be required to have Polaris accounts before export, or auto-create them?
🤖 Prompt for AI Agents
In @.sisyphus/drafts/export-to-polaris.md at line 154, Populate the "Open
Questions for Metis Review" section by listing the seven specific decision
points extracted from the document and providing concise options for each so
reviewers can pick or comment: 1) For Authentication (refer to the three
approaches noted around lines 118-132) enumerate pros/cons and recommend one
(OAuth with token exchange, API-key transfer, or user-initiated SSO) and ask
which to adopt; 2) For Data size limits state concrete proposals for max project
size, max file count, and message history retention (e.g., 5GB / 10k files / 12
months) and ask for agreement or changes; 3) For Failure handling propose
rollback vs retry semantics and an idempotent resume mechanism and ask which
behavior to standardize; 4) For Security specify how to protect API
keys/tokens/user data in transit and at rest (TLS, envelope encryption,
short-lived tokens) and request approval; 5) For Attachments ask whether to
include attachments, propose size/format limits and optional on-demand transfer,
and request decision; 6) For Performance propose chunking/streaming strategies
for large projects (e.g., chunk size, parallelism, backpressure) and ask which
to prefer; 7) For User experience propose requiring pre-existing Polaris
accounts vs auto-creating accounts with user consent and ask which flow to
implement. Keep each question as a short bullet-like decision prompt so Metis
can answer succinctly.

Comment on lines +190 to +198
| Task | Depends On | Blocks | Can Parallelize With |
|------|------------|--------|---------------------|
| 1 (Schema) | None | 2 | None |
| 2 (API) | 1 | 3, 4 | None |
| 3 (Export Service) | 2 | 5, 6 | 4 |
| 4 (Security) | 2 | 3, 5, 6 | 3 |
| 5 (UI) | 3, 4 | 7 | 6 |
| 6 (Error Handling) | 3, 4 | 7 | 5 |
| 7 (Testing) | 5, 6 | None | None |
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Dependency matrix is inconsistent with Task 3's description.

The matrix (Line 195) states Task 4 blocks Task 3, but Task 3's own "Blocked By" section (around Line 351) only lists Task 2. Meanwhile, both Task 3 and Task 4 are assigned to Wave 2 as parallel work. If they're truly parallel, Task 4 cannot block Task 3 — pick one interpretation and update both the matrix and the task descriptions to match.

🤖 Prompt for AI Agents
In @.sisyphus/plans/export-to-polaris.md around lines 190 - 198, The dependency
matrix and Task 3/Task 4 descriptions are inconsistent: decide whether Task 3
and Task 4 are parallel (Wave 2) or Task 4 truly blocks Task 3, then make both
places match; if you choose parallel, remove Task 4 from the "Blocks" column for
Task 3 in the matrix and remove any "Blocked By: Task 4" text in Task 3's
description (ensure both list only Task 2 as blocker), or if you choose that
Task 4 blocks Task 3, update Task 3's "Blocked By" section to include Task 4 and
move Task 4 out of Wave 2 (or adjust waves so they aren't parallel) so the
matrix and task descriptions are consistent.

Comment on lines +100 to +104
export const skillStatusEnum = v.union(
v.literal("active"),
v.literal("disabled"),
v.literal("draft")
);
Copy link
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:

rg -n 'skillStatusEnum' --type=ts

Repository: Jackson57279/zapdev

Length of output: 123


🏁 Script executed:

rg -A 20 'skills.*defineTable\(' convex/schema.ts --type=ts

Repository: Jackson57279/zapdev

Length of output: 699


Remove unused skillStatusEnum or add status field to skills table.

The skillStatusEnum (lines 100–104) is defined and exported but never referenced anywhere in the codebase. The skills table does not have a status field. Either remove the enum if it's not needed, or add status: skillStatusEnum to the skills table definition.

🤖 Prompt for AI Agents
In `@convex/schema.ts` around lines 100 - 104, The exported v.union named
skillStatusEnum is declared but unused; either remove the skillStatusEnum export
or add a status field to the skills table that references it. To fix, locate the
skills table definition (symbol: skills) and add a property status:
skillStatusEnum to its schema if you intend to track skill state, or delete the
skillStatusEnum declaration/export if it’s not needed; if you add the field,
ensure default/optional behavior is handled (e.g., required vs optional) and
update any code that constructs or reads skills to account for the new status
property.

Comment on lines +351 to +357
projectDeploymentCounters: defineTable({
projectId: v.id("projects"),
deployNumber: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_projectId", ["projectId"]),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n 'projectDeploymentCounters' --type=ts -C 5

Repository: Jackson57279/zapdev

Length of output: 1799


Use .unique() instead of .first() to enforce the single-counter-per-project constraint.

The projectDeploymentCounters table is designed with one row per project, but the query in convex/deployments.ts (line 36) uses .first(). Switch to .unique() to strictly enforce this invariant—it will throw if multiple rows exist, providing safety against data corruption. The current code properly handles the missing-row case, so only the query method needs updating.

🤖 Prompt for AI Agents
In `@convex/schema.ts` around lines 351 - 357, The query that reads the
projectDeploymentCounters row should enforce a single-row-per-project
invariant—replace the use of .first() with .unique() on the query that selects
from projectDeploymentCounters (the call in convex/deployments.ts that currently
uses .first()). Keep the existing missing-row handling as-is; switching to
.unique() will cause an error if multiple rows exist, guaranteeing the
one-counter-per-project constraint.


const AUTO_FIX_MAX_ATTEMPTS = 1;
const MAX_AGENT_ITERATIONS = 8;
const MAX_AGENT_ITERATIONS = 12;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

MAX_AGENT_ITERATIONS = 12 violates the configured limit of 8.

The coding guidelines specify "AI agent must respect MAX_AGENT_ITERATIONS = 8 as the maximum tool call rounds per request." This was increased to 12 without documented justification. Higher iteration counts increase latency, cost, and timeout risk.

🔧 Proposed fix
-const MAX_AGENT_ITERATIONS = 12;
+const MAX_AGENT_ITERATIONS = 8;

As per coding guidelines: "AI agent must respect MAX_AGENT_ITERATIONS = 8 as the maximum tool call rounds per request."

📝 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
const MAX_AGENT_ITERATIONS = 12;
const MAX_AGENT_ITERATIONS = 8;
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` at line 71, The constant MAX_AGENT_ITERATIONS is
set to 12 which violates the guideline limit of 8; change the constant
declaration MAX_AGENT_ITERATIONS to 8 (e.g., const MAX_AGENT_ITERATIONS = 8) and
ensure any code paths that rely on this value (agent loop/iteration checks)
continue to use this constant so the agent enforces the 8-iteration max
consistently.

Comment on lines +226 to +251
installDependencies: tool({
description: "Install npm/bun/pnpm dependencies. Automatically detects package manager from lock files.",
inputSchema: z.object({
packages: z.array(z.string()).describe("Package names to install"),
dev: z.boolean().optional().describe("If true, installs as dev dependencies"),
}),
execute: async ({ packages, dev }) => {
console.log("[DEBUG] installDependencies tool called for", packages.length, "packages");
onToolCall?.("installDependencies", { packages, dev });
try {
const pkgManagerCmd = await adapter.runCommand("test -f bun.lock && echo 'bun' || test -f pnpm-lock.yaml && echo 'pnpm' || echo 'npm'");
const pkgManager = pkgManagerCmd.stdout.trim();
const devFlag = dev ? (pkgManager === "npm" ? "--save-dev" : "--dev") : "";
const command = `${pkgManager} install ${devFlag} ${packages.join(" ")}`;
console.log("[INFO] Running:", command);
const result = await adapter.runCommand(command);
if (result.stdout) onToolOutput?.("stdout", result.stdout);
if (result.stderr) onToolOutput?.("stderr", result.stderr);
return `Installed ${packages.length} package(s) with ${pkgManager}`;
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
console.error("[ERROR] installDependencies failed:", errorMessage);
return `Error installing packages: ${errorMessage}`;
}
},
}),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

--dev is not valid for pnpm; use --save-dev or -D instead.

Line 238: when pkgManager is "pnpm", the --dev flag will cause an error. pnpm uses --save-dev (or -D), same as npm.

🐛 Proposed fix
-          const devFlag = dev ? (pkgManager === "npm" ? "--save-dev" : "--dev") : "";
+          const devFlag = dev ? (pkgManager === "bun" ? "--dev" : "--save-dev") : "";
🤖 Prompt for AI Agents
In `@src/agents/tools.ts` around lines 226 - 251, The installDependencies tool
builds devFlag incorrectly for pnpm: change the devFlag calculation in the
installDependencies execute function so that when dev is true you use the
npm/pnpm flag "--save-dev" (or "-D") and for other managers (e.g., bun if
applicable) use the appropriate flag; specifically update the line that sets
devFlag (currently using pkgManager === "npm" ? "--save-dev" : "--dev") to use
pkgManager === "npm" || pkgManager === "pnpm" ? "--save-dev" :
"<other-manager-flag-or-empty>" (or "-D" for pnpm if you prefer), and ensure the
final command string (built via pkgManager and packages.join(" ")) handles an
empty devFlag cleanly. Reference: installDependencies, pkgManager, devFlag,
adapter.runCommand.

Comment on lines +253 to +297
runBuildAndLint: tool({
description: "Run both build and lint checks in parallel. Returns combined results for faster validation.",
inputSchema: z.object({
framework: z.enum(["nextjs", "react", "vue", "angular", "svelte"]).describe("Framework to determine build command"),
}),
execute: async ({ framework }) => {
console.log("[DEBUG] runBuildAndLint tool called for", framework);
onToolCall?.("runBuildAndLint", { framework });
try {
const buildCmd = framework === "nextjs" ? "npm run build" : "npm run build";
const lintCmd = "npm run lint";

const [buildResult, lintResult] = await Promise.allSettled([
adapter.runCommand(buildCmd),
adapter.runCommand(lintCmd),
]);

const results = {
build: buildResult.status === "fulfilled"
? { success: true, output: buildResult.value.stdout, error: buildResult.value.stderr }
: { success: false, error: buildResult.reason?.message || "Build failed" },
lint: lintResult.status === "fulfilled"
? { success: true, output: lintResult.value.stdout, error: lintResult.value.stderr }
: { success: false, error: lintResult.reason?.message || "Lint failed" },
};

const hasErrors = !results.build.success || !results.lint.success;
if (hasErrors) {
const errors = [
results.build.error || "",
results.lint.error || "",
].filter(Boolean).join("\n");
console.error("[ERROR] Build/lint failed:", errors.substring(0, 200));
return `Build/Lint Errors:\n${errors}`;
}

console.log("[INFO] Build and lint completed successfully");
return "Build and lint checks passed successfully";
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
console.error("[ERROR] runBuildAndLint failed:", errorMessage);
return `Error: ${errorMessage}`;
}
},
}),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

runBuildAndLint never detects actual build/lint failures — exit code is not checked.

adapter.runCommand resolves (not rejects) on non-zero exit codes, returning { stdout, stderr, exitCode }. Since Promise.allSettled will always report "fulfilled", results.build.success and results.lint.success are always true, and hasErrors is always false. A failing build (exit code 1 with errors in stderr) will be reported as "Build and lint checks passed successfully."

Additionally, Line 262 has a dead ternary — both branches are "npm run build".

🐛 Proposed fix — check exitCode for actual failure
       execute: async ({ framework }) => {
         console.log("[DEBUG] runBuildAndLint tool called for", framework);
         onToolCall?.("runBuildAndLint", { framework });
         try {
-          const buildCmd = framework === "nextjs" ? "npm run build" : "npm run build";
+          const buildCmd = "npm run build";
           const lintCmd = "npm run lint";
           
           const [buildResult, lintResult] = await Promise.allSettled([
             adapter.runCommand(buildCmd),
             adapter.runCommand(lintCmd),
           ]);

           const results = {
             build: buildResult.status === "fulfilled" 
-              ? { success: true, output: buildResult.value.stdout, error: buildResult.value.stderr }
+              ? { success: buildResult.value.exitCode === 0, output: buildResult.value.stdout, error: buildResult.value.stderr }
               : { success: false, error: buildResult.reason?.message || "Build failed" },
             lint: lintResult.status === "fulfilled"
-              ? { success: true, output: lintResult.value.stdout, error: lintResult.value.stderr }
+              ? { success: lintResult.value.exitCode === 0, output: lintResult.value.stdout, error: lintResult.value.stderr }
               : { success: false, error: lintResult.reason?.message || "Lint failed" },
           };
🤖 Prompt for AI Agents
In `@src/agents/tools.ts` around lines 253 - 297, The runBuildAndLint tool
currently treats any fulfilled Promise as success; update execute in
runBuildAndLint to determine success by checking fulfilled results'
value.exitCode (treat exitCode === 0 as success) and use
value.stdout/value.stderr for outputs and errors, while handling rejected
promises as failures; also simplify the dead ternary for buildCmd (replace
framework === "nextjs" ? "npm run build" : "npm run build" with a single
assignment). Ensure you reference adapter.runCommand results (buildResult and
lintResult) and set results.build.success/results.lint.success based on
exitCode, and populate results.build.error/results.lint.error from stderr or
reason.message when appropriate.

@codecapyai
Copy link

codecapyai bot commented Feb 13, 2026

❌ Error while analyzing PR and generating tests:

Error code: 400 - {'error': {'message': "This model's maximum context length is 200000 tokens. However, your messages resulted in 219931 tokens (including 381 in the response_format schemas.). Please reduce the length of the messages or schemas.", 'type': 'invalid_request_error', 'param': 'messages', 'code': 'context_length_exceeded'}}

@codecapyai
Copy link

codecapyai bot commented Feb 14, 2026

❌ Error while analyzing PR and generating tests:

Error code: 400 - {'error': {'message': "This model's maximum context length is 200000 tokens. However, your messages resulted in 219938 tokens (including 381 in the response_format schemas.). Please reduce the length of the messages or schemas.", 'type': 'invalid_request_error', 'param': 'messages', 'code': 'context_length_exceeded'}}

@codecapyai
Copy link

codecapyai bot commented Feb 14, 2026

❌ Error while analyzing PR and generating tests:

Error code: 400 - {'error': {'message': "This model's maximum context length is 200000 tokens. However, your messages resulted in 220022 tokens (including 381 in the response_format schemas.). Please reduce the length of the messages or schemas.", 'type': 'invalid_request_error', 'param': 'messages', 'code': 'context_length_exceeded'}}

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 2 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="convex/skills.ts">

<violation number="1" location="convex/skills.ts:415">
P2: Security: `getCoreSkillContents` changed from `internalQuery` to public `query`, exposing all core skill content without authentication. Although core skills are global, this still unnecessarily increases the API surface. Prefer configuring `ConvexHttpClient` with a deploy key to call internal functions from server-side code.</violation>

<violation number="2" location="convex/skills.ts:605">
P1: Security: `getInstalledSkillContents` changed from `internalQuery` to public `query` without adding any authentication. Any unauthenticated client can now query any user's installed skills by supplying an arbitrary `userId`. Instead of making this public, configure `ConvexHttpClient` with a deploy key to call internal functions, or add `requireAuth(ctx)` and verify the caller matches the `userId` argument.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

* Returns name, slug, and content for each active installed skill.
* Public so server-side agents can call via ConvexHttpClient without deploy key.
*/
export const getInstalledSkillContents = query({
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

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

P1: Security: getInstalledSkillContents changed from internalQuery to public query without adding any authentication. Any unauthenticated client can now query any user's installed skills by supplying an arbitrary userId. Instead of making this public, configure ConvexHttpClient with a deploy key to call internal functions, or add requireAuth(ctx) and verify the caller matches the userId argument.

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

<comment>Security: `getInstalledSkillContents` changed from `internalQuery` to public `query` without adding any authentication. Any unauthenticated client can now query any user's installed skills by supplying an arbitrary `userId`. Instead of making this public, configure `ConvexHttpClient` with a deploy key to call internal functions, or add `requireAuth(ctx)` and verify the caller matches the `userId` argument.</comment>

<file context>
@@ -599,8 +600,9 @@ export const seedCoreSkills = internalMutation({
+ * Public so server-side agents can call via ConvexHttpClient without deploy key.
  */
-export const getInstalledSkillContents = internalQuery({
+export const getInstalledSkillContents = query({
   args: {
     projectId: v.optional(v.id("projects")),
</file context>
Fix with Cubic

* Returns an array of objects with name and content for each core skill.
* Public so server-side agents can call via ConvexHttpClient without deploy key.
*/
export const getCoreSkillContents = query({
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

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

P2: Security: getCoreSkillContents changed from internalQuery to public query, exposing all core skill content without authentication. Although core skills are global, this still unnecessarily increases the API surface. Prefer configuring ConvexHttpClient with a deploy key to call internal functions from server-side code.

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

<comment>Security: `getCoreSkillContents` changed from `internalQuery` to public `query`, exposing all core skill content without authentication. Although core skills are global, this still unnecessarily increases the API surface. Prefer configuring `ConvexHttpClient` with a deploy key to call internal functions from server-side code.</comment>

<file context>
@@ -410,8 +410,9 @@ export const getForSystem = internalQuery({
+ * Public so server-side agents can call via ConvexHttpClient without deploy key.
  */
-export const getCoreSkillContents = internalQuery({
+export const getCoreSkillContents = query({
   args: {},
   returns: v.array(
</file context>
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant