feat(api): comprehensive QA hardening — security gates, chat improvements, beta scaffolds

Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07,
B-01..B-07, R-01..R-02, O-03.

Security (28 deletions + 10 auth gates):
- Delete 28 unauthenticated debug/cursor/firebase/test routes
- Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth
- Add HMAC-SHA256 signature verification to webhooks/coolify
- Switch all admin secret comparisons to timingSafeStringEq

Foundations (lib/server/*):
- api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit
- logger.ts: structured request-scoped logging with turnId
- audit-log.ts: writeAuditLog helper + audit_log table
- rate-limit.ts: Postgres sliding window rate limiter
- coolify-webhook.ts: verifyCoolifySignature
- timing-safe.ts: timingSafeStringEq

Chat hardening (chat/route.ts):
- MAX_TOOL_ROUNDS 15 → 8 (C-01)
- Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02)
- Add 6-consecutive-tool-call hard-break (C-02)
- Mode: respond first, act second prompt block (C-03)
- SSE heartbeat every 25s via setInterval (C-04)
- Per-tool 45s timeout via Promise.race (C-05)
- turnId per-turn UUID for log correlation (C-06)
- Recovery fires when roundsSinceText >= 4 (C-07)
- SSE plan event on plan_task_add/edit (B-05)

Beta features:
- invites table + GET/POST /api/invites (P4.8)
- invites/[token] validate + redeem (P4.8)
- fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1)
- fs_project_secrets table + CRUD routes (P6.D2)
- lib/integrations/brief-extract.ts (P3.7)

Documentation:
- app/api/ROUTES.md: full route map with auth + tenant
This commit is contained in:
2026-05-17 19:17:22 -07:00
parent 955aeed6ce
commit 6b8862ef2b
86 changed files with 6772 additions and 2817 deletions

View File

@@ -0,0 +1,159 @@
# API Route Map
> Generated 2026-05-17. Auth column: `session` = NextAuth cookie,
> `api_key` = `vibn_sk_…` bearer, `admin_secret` = env-var secret,
> `webhook_sig` = HMAC-SHA256, `public` = no auth.
>
> Tenant column: `workspace` = must belong to caller's workspace,
> `project` = must own project, `user` = must match session user,
> `global` = cross-workspace admin op.
## Chat
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/chat` | session | workspace | Main SSE chat with Gemini + tool loop |
| GET | `/api/chat/threads` | session | user | List threads |
| POST | `/api/chat/threads` | session | user | Create thread |
| GET | `/api/chat/threads/[id]` | session | user | Get thread + messages |
| PATCH | `/api/chat/threads/[id]` | session | user | Rename thread |
| DELETE | `/api/chat/threads/[id]` | session | user | Delete thread |
## AI (legacy, plan to deprecate)
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/ai/chat` | session | project | Old collector-mode chat (pre-tool) |
| GET | `/api/ai/conversation` | session | project | Fetch saved conversation history |
| DELETE | `/api/ai/conversation` | session | project | Wipe conversation history |
| POST | `/api/ai/conversation/reset` | session | project | Alias for DELETE |
## Projects
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET | `/api/projects` | session | user | List user's projects |
| POST | `/api/projects/create` | session | user | Create project (enforces quota) |
| POST | `/api/projects/delete` | session | project | Delete project |
| GET/PATCH | `/api/projects/[projectId]` | session | project | Get / update project |
| GET | `/api/projects/[projectId]/activity` | session | project | Activity feed |
| POST | `/api/projects/[projectId]/advisor` | session | project | AI advisor |
| GET/POST | `/api/projects/[projectId]/anatomy` | session | project | Anatomy read/update |
| GET/POST | `/api/projects/[projectId]/apps` | session | project | App list / create |
| GET/POST | `/api/projects/[projectId]/design-kit` | session | project | Design kit CRUD |
| GET/POST | `/api/projects/[projectId]/plan` | session | project | Plan read/update |
| POST | `/api/projects/[projectId]/plan/intelligent` | session | project | AI plan generation |
| POST | `/api/projects/[projectId]/plan/mvp` | session | project | MVP plan |
| POST | `/api/projects/[projectId]/plan/marketing` | session | project | Marketing plan |
| POST | `/api/projects/[projectId]/documents/upload` | session | project | Upload brief |
| GET/POST | `/api/projects/[projectId]/secrets` | session | project | List/set project secrets (B-06) |
| GET/DELETE | `/api/projects/[projectId]/secrets/[key]` | session | project | Reveal/delete secret (B-06) |
| GET | `/api/projects/[projectId]/knowledge` | session | project | Knowledge items |
| POST | `/api/projects/[projectId]/knowledge/batch-extract` | session | project | Batch extract knowledge |
| GET/POST | `/api/projects/[projectId]/agent/sessions` | session | project | Agent session CRUD |
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]` | session | project | Session state |
| POST | `/api/projects/[projectId]/agent/sessions/[sessionId]/approve` | session | project | Approve session commit |
| POST | `/api/projects/[projectId]/agent/sessions/[sessionId]/stop` | session | project | Stop agent |
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]/events` | session | project | Event list |
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]/events/stream` | session | project | SSE event tail |
## Workspaces
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET | `/api/workspaces` | session/api_key | user | List workspaces |
| POST | `/api/workspaces/delete` | session | user | Delete workspace |
| GET | `/api/workspaces/[slug]` | session/api_key | workspace | Get workspace |
| GET/POST | `/api/workspaces/[slug]/apps` | session/api_key | workspace | List/create apps |
| GET/PATCH/DELETE | `/api/workspaces/[slug]/apps/[uuid]` | session/api_key | workspace | App CRUD |
| POST | `/api/workspaces/[slug]/apps/[uuid]/deploy` | session/api_key | workspace | Trigger deploy |
| GET | `/api/workspaces/[slug]/apps/[uuid]/logs` | session/api_key | workspace | Runtime logs |
| GET/PATCH | `/api/workspaces/[slug]/apps/[uuid]/envs` | session/api_key | workspace | Env vars |
| POST | `/api/workspaces/[slug]/apps/[uuid]/exec` | session/api_key | workspace | Remote exec |
| GET/POST | `/api/workspaces/[slug]/databases` | session/api_key | workspace | Database CRUD |
| GET | `/api/workspaces/[slug]/domains` | session/api_key | workspace | Domain list |
| GET/POST | `/api/workspaces/[slug]/keys` | session/api_key | workspace | API keys |
| POST | `/api/workspaces/[slug]/provision` | session/api_key | workspace | Provision workspace |
## MCP
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/mcp` | session/api_key | workspace | All 40+ MCP tools |
| GET/POST/DELETE | `/api/mcp/generate-key` | session | user | Manage MCP API keys |
## Auth / Sessions
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET/POST | `/api/auth/[...nextauth]` | public | - | NextAuth handlers |
| GET | `/api/user/api-key` | session | user | Get/create user API key |
| GET | `/api/sessions` | session | user | Session history |
| POST | `/api/sessions/track` | session | user | Track session event |
| POST | `/api/sessions/associate-project` | session | user | Link session to project |
## GitHub Integrations
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET/POST/DELETE | `/api/github/connect` | session | user | Legacy GitHub OAuth connect |
| GET | `/api/github/repos` | session | user | List connected repos |
| GET | `/api/github/repo-tree` | session | user | Repo file tree |
| GET | `/api/github/file-content` | session | user | Single file content |
| POST | `/api/github/oauth/token` | public | - | OAuth token exchange |
| GET | `/api/integrations/github/connect` | session | user | New OAuth connect |
| GET | `/api/integrations/github/callback` | public | - | OAuth callback |
| POST | `/api/integrations/github/disconnect` | session | user | Disconnect GitHub |
| GET | `/api/integrations/github/repos` | session | user | New integration repos |
## Webhooks
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/webhooks/gitea` | webhook_sig | project | Gitea push events → context snapshot |
| POST | `/api/webhooks/coolify` | webhook_sig | project | Deploy status → context snapshot |
## Invites (P4.8)
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET/POST | `/api/invites` | admin_secret | global | Create / list invite tokens |
| GET | `/api/invites/[token]` | public | - | Validate token (used by auth page) |
| POST | `/api/invites/[token]` | session | user | Redeem token on signup |
## Admin / Ops
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/admin/migrate` | admin_secret | global | Run SQL migrations |
| GET | `/api/admin/path-b` | admin_secret | global | Path B kill-switch state |
| POST | `/api/admin/path-b/disable` | admin_secret | global | Disable Path B |
| POST | `/api/admin/path-b/enable` | admin_secret | global | Enable Path B |
| POST | `/api/admin/path-b/idle-sweep` | admin_secret | global | Suspend idle dev containers |
| POST | `/api/admin/path-b/autosave` | admin_secret | global | Autosave workspace |
| POST | `/api/admin/backfill-isolation` | admin_secret | global | Backfill tenant isolation |
| POST | `/api/admin/path-b` | admin_secret | global | Path B bulk status |
| GET | `/api/internal/infra-health` | admin_secret | global | Coolify + SSH probe |
## Utilities
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/context/summarize` | session | user | Gemini one-shot doc summary |
| GET | `/api/activity` | session | user | Workspace activity feed |
| GET | `/api/work-completed` | session | project | Work completed items |
| GET | `/api/stats` | session | user | Usage stats |
| GET | `/api/preview/embed` | session | user | HTML proxy for preview iframes |
| GET/POST | `/api/extension/link-project` | session | user | Browser extension project link |
| POST | `/api/vision/update` | session | project | Update project vision |
| GET | `/api/keys` | session | user | User key management |
| GET | `/api/design-systems/[id]/preview` | public | - | Design system preview HTML |
| GET | `/api/design-systems/[id]/showcase` | public | - | Design system showcase |
## Deprecated / V0
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| * | `/api/v0/*` | varies | varies | Legacy v0 integration (verify still used) |
| * | `/api/openai/*` | session | user | OpenAI GPT import (verify still used) |
| * | `/api/chatgpt/*` | session | user | ChatGPT conversation import |

View File

@@ -1,46 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('projectId');
const userId = searchParams.get('userId');
const adminDb = getAdminDb();
// Get all sessions for this user
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('userId', '==', userId)
.get();
const allSessions = sessionsSnapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
projectId: data.projectId || null,
workspacePath: data.workspacePath || null,
workspaceName: data.workspaceName || null,
needsProjectAssociation: data.needsProjectAssociation,
messageCount: data.messageCount,
conversationLength: data.conversation?.length || 0,
};
});
// Filter sessions that match this project
const matchingSessions = allSessions.filter(s => s.projectId === projectId);
return NextResponse.json({
totalSessions: allSessions.length,
matchingSessions: matchingSessions.length,
allSessions,
projectId,
userId,
});
} catch (error: any) {
console.error('[Admin Check Sessions] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -1,59 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function POST(request: Request) {
try {
const { projectId, workspacePath } = await request.json();
if (!projectId || !workspacePath) {
return NextResponse.json(
{ error: 'projectId and workspacePath required' },
{ status: 400 }
);
}
const adminDb = getAdminDb();
// Update project with workspacePath
await adminDb.collection('projects').doc(projectId).update({
workspacePath,
updatedAt: new Date(),
});
console.log(`[Fix Project] Set workspacePath for ${projectId}: ${workspacePath}`);
// Now find and link all matching sessions
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('workspacePath', '==', workspacePath)
.where('needsProjectAssociation', '==', true)
.get();
const batch = adminDb.batch();
let linkedCount = 0;
for (const sessionDoc of sessionsSnapshot.docs) {
batch.update(sessionDoc.ref, {
projectId,
needsProjectAssociation: false,
updatedAt: new Date(),
});
linkedCount++;
}
await batch.commit();
console.log(`[Fix Project] Linked ${linkedCount} sessions to project ${projectId}`);
return NextResponse.json({
success: true,
projectId,
workspacePath,
sessionsLinked: linkedCount,
});
} catch (error: any) {
console.error('[Fix Project] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -13,13 +13,17 @@ import { NextRequest, NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { readFileSync } from "fs";
import { join } from "path";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(req: NextRequest) {
const secret = process.env.ADMIN_MIGRATE_SECRET ?? "";
if (!secret) {
return NextResponse.json(
{ error: "ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled" },
{ status: 403 }
{
error:
"ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled",
},
{ status: 403 },
);
}
@@ -28,7 +32,7 @@ export async function POST(req: NextRequest) {
new URL(req.url).searchParams.get("secret") ??
"";
if (incoming !== secret) {
if (!incoming || !timingSafeStringEq(secret, incoming)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
@@ -299,9 +303,9 @@ export async function POST(req: NextRequest) {
}
}
const failed = results.filter(r => !r.ok);
const failed = results.filter((r) => !r.ok);
return NextResponse.json(
{ ok: failed.length === 0, results },
{ status: failed.length === 0 ? 200 : 207 }
{ status: failed.length === 0 ? 200 : 207 },
);
}

View File

@@ -18,22 +18,32 @@
* commits go through the `ship` tool.
*/
import { NextResponse } from 'next/server';
import { autosaveWorkspace } from '@/lib/dev-container';
import { query } from '@/lib/db-postgres';
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
import { NextResponse } from "next/server";
import { autosaveWorkspace } from "@/lib/dev-container";
import { query } from "@/lib/db-postgres";
import { getOrCreateProvisionedWorkspace } from "@/lib/workspaces";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: { projectId?: string; projectSlug?: string; sweep?: boolean };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Single-project mode.
@@ -44,15 +54,18 @@ export async function POST(request: Request) {
[projectId],
);
if (row.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const ws = await getOrCreateProvisionedWorkspace({
userId: row[0].data?.userId ?? '',
email: row[0].data?.ownerEmail ?? '',
userId: row[0].data?.userId ?? "",
email: row[0].data?.ownerEmail ?? "",
displayName: row[0].workspace,
}).catch(() => null);
if (!ws) {
return NextResponse.json({ error: 'Workspace not provisioned' }, { status: 503 });
return NextResponse.json(
{ error: "Workspace not provisioned" },
{ status: 503 },
);
}
const result = await autosaveWorkspace({
projectId,
@@ -76,8 +89,8 @@ export async function POST(request: Request) {
);
if (proj.length === 0) continue;
const ws = await getOrCreateProvisionedWorkspace({
userId: proj[0].data?.userId ?? '',
email: proj[0].data?.ownerEmail ?? '',
userId: proj[0].data?.userId ?? "",
email: proj[0].data?.ownerEmail ?? "",
displayName: r.workspace,
}).catch(() => null);
if (!ws) continue;
@@ -85,14 +98,17 @@ export async function POST(request: Request) {
projectId: r.project_id,
projectSlug: proj[0].slug,
workspace: ws,
}).catch(err => ({ ran: false, reason: err instanceof Error ? err.message : String(err) }));
}).catch((err) => ({
ran: false,
reason: err instanceof Error ? err.message : String(err),
}));
out.push({ projectId: r.project_id, ran: res.ran, reason: res.reason });
}
return NextResponse.json({ result: { swept: out.length, out } });
}
return NextResponse.json(
{ error: 'Provide either { projectId } or { sweep: true }' },
{ error: "Provide either { projectId } or { sweep: true }" },
{ status: 400 },
);
}

View File

@@ -1,17 +1,27 @@
import { NextResponse } from 'next/server';
import { setFlag } from '@/lib/feature-flags';
import { NextResponse } from "next/server";
import { setFlag } from "@/lib/feature-flags";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
await setFlag('path_b_disabled', true);
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await setFlag("path_b_disabled", true);
return NextResponse.json({
ok: true,
flag: 'path_b_disabled',
flag: "path_b_disabled",
value: true,
note: 'Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.',
note: "Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.",
});
}

View File

@@ -1,17 +1,27 @@
import { NextResponse } from 'next/server';
import { setFlag } from '@/lib/feature-flags';
import { NextResponse } from "next/server";
import { setFlag } from "@/lib/feature-flags";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
await setFlag('path_b_disabled', false);
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await setFlag("path_b_disabled", false);
return NextResponse.json({
ok: true,
flag: 'path_b_disabled',
flag: "path_b_disabled",
value: false,
note: 'Path B re-enabled.',
note: "Path B re-enabled.",
});
}

View File

@@ -17,18 +17,31 @@
* the next shell.exec call resumes the service in <5s.
*/
import { NextResponse } from 'next/server';
import { suspendIdleContainers } from '@/lib/dev-container';
import { NextResponse } from "next/server";
import { suspendIdleContainers } from "@/lib/dev-container";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const minStr = url.searchParams.get('minutes');
const minutes = minStr && Number.isFinite(Number(minStr)) ? Math.max(5, Number(minStr)) : 30;
const minStr = url.searchParams.get("minutes");
const minutes =
minStr && Number.isFinite(Number(minStr))
? Math.max(5, Number(minStr))
: 30;
const result = await suspendIdleContainers(minutes);
return NextResponse.json({ result, idleMinutes: minutes });
}

View File

@@ -20,19 +20,24 @@
* to every Vibn pod within ~10s of the SQL update.
*/
import { NextResponse } from 'next/server';
import { getFlag, setFlag } from '@/lib/feature-flags';
import { NextResponse } from "next/server";
import { getFlag } from "@/lib/feature-flags";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
function authorized(request: Request): boolean {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
return Boolean(bearer && process.env.NEXTAUTH_SECRET && bearer === process.env.NEXTAUTH_SECRET);
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) return false;
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
return Boolean(bearer && timingSafeStringEq(expected, bearer));
}
export async function GET(request: Request) {
if (!authorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const disabled = await getFlag<boolean>('path_b_disabled', false);
const disabled = await getFlag<boolean>("path_b_disabled", false);
return NextResponse.json({ disabled });
}

View File

@@ -1,44 +1,52 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import type { LlmClient } from '@/lib/ai/llm-client';
import { query } from '@/lib/db-postgres';
import { MODE_SYSTEM_PROMPTS, ChatMode } from '@/lib/ai/chat-modes';
import { resolveChatMode } from '@/lib/server/chat-mode-resolver';
import { NextResponse } from "next/server";
import { z } from "zod";
import { GeminiLlmClient } from "@/lib/ai/gemini-client";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
import type { LlmClient } from "@/lib/ai/llm-client";
import { query } from "@/lib/db-postgres";
import { MODE_SYSTEM_PROMPTS, ChatMode } from "@/lib/ai/chat-modes";
import { resolveChatMode } from "@/lib/server/chat-mode-resolver";
import {
buildProjectContextForChat,
determineArtifactsUsed,
formatContextForPrompt,
} from '@/lib/server/chat-context';
import { logProjectEvent } from '@/lib/server/logs';
import type { CollectorPhaseHandoff } from '@/lib/types/phase-handoff';
} from "@/lib/server/chat-context";
import { logProjectEvent } from "@/lib/server/logs";
import type { CollectorPhaseHandoff } from "@/lib/types/phase-handoff";
// Increase timeout for Gemini 3 Pro thinking mode (can take 1-2 minutes)
export const maxDuration = 180; // 3 minutes
export const dynamic = 'force-dynamic';
export const dynamic = "force-dynamic";
const ChatReplySchema = z.object({
reply: z.string(),
visionAnswers: z.object({
q1: z.string().optional(), // Answer to question 1
q2: z.string().optional(), // Answer to question 2
q3: z.string().optional(), // Answer to question 3
allAnswered: z.boolean().optional(), // True when all 3 are complete
}).optional(),
collectorHandoff: z.object({
hasDocuments: z.boolean().optional(),
documentCount: z.number().optional(),
githubConnected: z.boolean().optional(),
githubRepo: z.string().optional(),
extensionLinked: z.boolean().optional(),
extensionDeclined: z.boolean().optional(),
noGithubYet: z.boolean().optional(),
readyForExtraction: z.boolean().optional(),
}).optional(),
extractionReviewHandoff: z.object({
extractionApproved: z.boolean().optional(),
readyForVision: z.boolean().optional(),
}).optional(),
visionAnswers: z
.object({
q1: z.string().optional(), // Answer to question 1
q2: z.string().optional(), // Answer to question 2
q3: z.string().optional(), // Answer to question 3
allAnswered: z.boolean().optional(), // True when all 3 are complete
})
.optional(),
collectorHandoff: z
.object({
hasDocuments: z.boolean().optional(),
documentCount: z.number().optional(),
githubConnected: z.boolean().optional(),
githubRepo: z.string().optional(),
extensionLinked: z.boolean().optional(),
extensionDeclined: z.boolean().optional(),
noGithubYet: z.boolean().optional(),
readyForExtraction: z.boolean().optional(),
})
.optional(),
extractionReviewHandoff: z
.object({
extractionApproved: z.boolean().optional(),
readyForVision: z.boolean().optional(),
})
.optional(),
});
interface ChatRequestBody {
@@ -57,7 +65,7 @@ const ENSURE_CONV_TABLE = `
async function appendConversation(
projectId: string,
newMessages: Array<{ role: 'user' | 'assistant'; content: string }>,
newMessages: Array<{ role: "user" | "assistant"; content: string }>,
) {
await query(ENSURE_CONV_TABLE);
const now = new Date().toISOString();
@@ -69,52 +77,62 @@ async function appendConversation(
ON CONFLICT (project_id) DO UPDATE
SET messages = chat_conversations.messages || $2::jsonb,
updated_at = NOW()`,
[projectId, JSON.stringify(stamped)]
[projectId, JSON.stringify(stamped)],
);
}
export async function POST(request: Request) {
try {
const body = (await request.json()) as ChatRequestBody;
const projectId = body.projectId?.trim();
const message = body.message?.trim();
export const POST = withTenantProject(
async (request, _ctx, { project, user }) => {
try {
const body = (await request.json()) as ChatRequestBody;
const projectId = project.id;
const message = body.message?.trim();
if (!projectId || !message) {
return NextResponse.json({ error: 'projectId and message are required' }, { status: 400 });
}
if (!message) {
return NextResponse.json(
{ error: "message is required" },
{ status: 400 },
);
}
// Verify project exists in Postgres
const projectRows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (projectRows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const projectData = projectRows[0].data ?? {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const projectData = (project.data ?? {}) as any;
log.info("ai/chat: starting", {
route: "api.ai.chat",
projectId,
user: user.email,
});
// Resolve chat mode (uses new resolver)
const resolvedMode = body.overrideMode ?? await resolveChatMode(projectId);
console.log(`[AI Chat] Mode: ${resolvedMode}`);
// Resolve chat mode (uses new resolver)
const resolvedMode =
body.overrideMode ?? (await resolveChatMode(projectId));
console.log(`[AI Chat] Mode: ${resolvedMode}`);
// Build comprehensive context with vector retrieval
// Only include GitHub analysis for MVP generation (not needed for vision questions)
const context = await buildProjectContextForChat(projectId, resolvedMode, message, {
retrievalLimit: 10,
includeVectorSearch: true,
includeGitHubAnalysis: resolvedMode === 'mvp_mode', // Only load repo analysis when generating MVP
});
// Build comprehensive context with vector retrieval
// Only include GitHub analysis for MVP generation (not needed for vision questions)
const context = await buildProjectContextForChat(
projectId,
resolvedMode,
message,
{
retrievalLimit: 10,
includeVectorSearch: true,
includeGitHubAnalysis: resolvedMode === "mvp_mode", // Only load repo analysis when generating MVP
},
);
console.log(`[AI Chat] Context built: ${context.retrievedChunks.length} vector chunks retrieved`);
console.log(
`[AI Chat] Context built: ${context.retrievedChunks.length} vector chunks retrieved`,
);
// Get mode-specific system prompt
const systemPrompt = MODE_SYSTEM_PROMPTS[resolvedMode];
// Get mode-specific system prompt
const systemPrompt = MODE_SYSTEM_PROMPTS[resolvedMode];
// Format context for LLM
const contextSummary = formatContextForPrompt(context);
// Format context for LLM
const contextSummary = formatContextForPrompt(context);
// Prepare enhanced system prompt with context
const enhancedSystemPrompt = `${systemPrompt}
// Prepare enhanced system prompt with context
const enhancedSystemPrompt = `${systemPrompt}
## Current Project Context
@@ -126,208 +144,248 @@ You have access to:
- Project artifacts (product model, MVP plan, marketing plan)
- Knowledge items (${context.knowledgeSummary.totalCount} total)
- Extraction signals (${context.extractionSummary.totalCount} analyzed)
${context.retrievedChunks.length > 0 ? `- ${context.retrievedChunks.length} relevant chunks from vector search (most similar to user's query)` : ''}
${context.repositoryAnalysis ? `- GitHub repository analysis (${context.repositoryAnalysis.totalFiles} files)` : ''}
${context.sessionHistory.totalSessions > 0 ? `- Complete Cursor session history (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages in chronological order)` : ''}
${context.retrievedChunks.length > 0 ? `- ${context.retrievedChunks.length} relevant chunks from vector search (most similar to user's query)` : ""}
${context.repositoryAnalysis ? `- GitHub repository analysis (${context.repositoryAnalysis.totalFiles} files)` : ""}
${context.sessionHistory.totalSessions > 0 ? `- Complete Cursor session history (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages in chronological order)` : ""}
Use this context to provide specific, grounded responses. The session history shows your complete conversation history with the user - use it to understand what has been built and discussed.`;
// Load existing conversation history from Postgres
await query(ENSURE_CONV_TABLE);
const convRows = await query<{ messages: any[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[projectId]
);
const conversationHistory: any[] = convRows[0]?.messages ?? [];
// Load existing conversation history from Postgres
await query(ENSURE_CONV_TABLE);
const convRows = await query<{ messages: any[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[projectId],
);
const conversationHistory: any[] = convRows[0]?.messages ?? [];
// Build full message context (history + current message)
const messages = [
...conversationHistory.map((msg: any) => ({
role: msg.role as 'user' | 'assistant',
content: msg.content as string,
})),
{
role: 'user' as const,
content: message,
},
];
// Build full message context (history + current message)
const messages = [
...conversationHistory.map((msg: any) => ({
role: msg.role as "user" | "assistant",
content: msg.content as string,
})),
{
role: "user" as const,
content: message,
},
];
console.log(`[AI Chat] Sending ${messages.length} messages to LLM (${conversationHistory.length} from history + 1 new)`);
console.log(`[AI Chat] Mode: ${resolvedMode}, Phase: ${projectData.currentPhase}, Has extraction: ${!!context.phaseHandoffs?.extraction}`);
// Log system prompt length
console.log(`[AI Chat] System prompt length: ${enhancedSystemPrompt.length} chars (~${Math.ceil(enhancedSystemPrompt.length / 4)} tokens)`);
// Log each message length
messages.forEach((msg, i) => {
console.log(`[AI Chat] Message ${i + 1} (${msg.role}): ${msg.content.length} chars (~${Math.ceil(msg.content.length / 4)} tokens)`);
});
const totalInputChars = enhancedSystemPrompt.length + messages.reduce((sum, msg) => sum + msg.content.length, 0);
console.log(`[AI Chat] Total input: ${totalInputChars} chars (~${Math.ceil(totalInputChars / 4)} tokens)`);
// Log system prompt preview (first 500 chars)
console.log(`[AI Chat] System prompt preview: ${enhancedSystemPrompt.substring(0, 500)}...`);
// Log last user message
const lastUserMsg = messages[messages.length - 1];
console.log(`[AI Chat] User message: ${lastUserMsg.content}`);
// Safety check: extraction_review_mode requires extraction results
if (resolvedMode === 'extraction_review_mode' && !context.phaseHandoffs?.extraction) {
console.warn(`[AI Chat] WARNING: extraction_review_mode active but no extraction results found for project ${projectId}`);
}
console.log(
`[AI Chat] Sending ${messages.length} messages to LLM (${conversationHistory.length} from history + 1 new)`,
);
console.log(
`[AI Chat] Mode: ${resolvedMode}, Phase: ${projectData.currentPhase}, Has extraction: ${!!context.phaseHandoffs?.extraction}`,
);
const llm: LlmClient = new GeminiLlmClient();
// Log system prompt length
console.log(
`[AI Chat] System prompt length: ${enhancedSystemPrompt.length} chars (~${Math.ceil(enhancedSystemPrompt.length / 4)} tokens)`,
);
// Configure thinking mode based on task complexity
// Simple modes (collector, extraction_review) don't need deep thinking
// Complex modes (mvp, vision) benefit from extended reasoning
const needsThinking = resolvedMode === 'mvp_mode' || resolvedMode === 'vision_mode';
// Log each message length
messages.forEach((msg, i) => {
console.log(
`[AI Chat] Message ${i + 1} (${msg.role}): ${msg.content.length} chars (~${Math.ceil(msg.content.length / 4)} tokens)`,
);
});
const reply = await llm.structuredCall<{
reply: string;
visionAnswers?: {
q1?: string;
q2?: string;
q3?: string;
allAnswered?: boolean;
};
collectorHandoff?: {
hasDocuments?: boolean;
documentCount?: number;
githubConnected?: boolean;
githubRepo?: string;
extensionLinked?: boolean;
extensionDeclined?: boolean;
noGithubYet?: boolean;
readyForExtraction?: boolean;
};
extractionReviewHandoff?: {
extractionApproved?: boolean;
readyForVision?: boolean;
};
}>({
model: 'gemini',
systemPrompt: enhancedSystemPrompt,
messages: messages, // Full conversation history!
schema: ChatReplySchema,
temperature: 0.4,
thinking_config: needsThinking ? {
thinking_level: 'high',
include_thoughts: false,
} : undefined,
});
const totalInputChars =
enhancedSystemPrompt.length +
messages.reduce((sum, msg) => sum + msg.content.length, 0);
console.log(
`[AI Chat] Total input: ${totalInputChars} chars (~${Math.ceil(totalInputChars / 4)} tokens)`,
);
// Store all vision answers when provided
if (reply.visionAnswers) {
const updates: any = {};
if (reply.visionAnswers.q1) {
updates['visionAnswers.q1'] = reply.visionAnswers.q1;
console.log('[AI Chat] Storing vision answer Q1');
// Log system prompt preview (first 500 chars)
console.log(
`[AI Chat] System prompt preview: ${enhancedSystemPrompt.substring(0, 500)}...`,
);
// Log last user message
const lastUserMsg = messages[messages.length - 1];
console.log(`[AI Chat] User message: ${lastUserMsg.content}`);
// Safety check: extraction_review_mode requires extraction results
if (
resolvedMode === "extraction_review_mode" &&
!context.phaseHandoffs?.extraction
) {
console.warn(
`[AI Chat] WARNING: extraction_review_mode active but no extraction results found for project ${projectId}`,
);
}
if (reply.visionAnswers.q2) {
updates['visionAnswers.q2'] = reply.visionAnswers.q2;
console.log('[AI Chat] Storing vision answer Q2');
}
if (reply.visionAnswers.q3) {
updates['visionAnswers.q3'] = reply.visionAnswers.q3;
console.log('[AI Chat] Storing vision answer Q3');
}
// If all answers are complete, trigger MVP generation
if (reply.visionAnswers.allAnswered) {
updates['visionAnswers.allAnswered'] = true;
updates['readyForMVP'] = true;
console.log('[AI Chat] ✅ All 3 vision answers complete - ready for MVP generation');
}
if (Object.keys(updates).length > 0) {
updates['visionAnswers.updatedAt'] = new Date().toISOString();
await query(
`UPDATE fs_projects
const llm: LlmClient = new GeminiLlmClient();
// Configure thinking mode based on task complexity
// Simple modes (collector, extraction_review) don't need deep thinking
// Complex modes (mvp, vision) benefit from extended reasoning
const needsThinking =
resolvedMode === "mvp_mode" || resolvedMode === "vision_mode";
const reply = await llm.structuredCall<{
reply: string;
visionAnswers?: {
q1?: string;
q2?: string;
q3?: string;
allAnswered?: boolean;
};
collectorHandoff?: {
hasDocuments?: boolean;
documentCount?: number;
githubConnected?: boolean;
githubRepo?: string;
extensionLinked?: boolean;
extensionDeclined?: boolean;
noGithubYet?: boolean;
readyForExtraction?: boolean;
};
extractionReviewHandoff?: {
extractionApproved?: boolean;
readyForVision?: boolean;
};
}>({
model: "gemini",
systemPrompt: enhancedSystemPrompt,
messages: messages, // Full conversation history!
schema: ChatReplySchema,
temperature: 0.4,
thinking_config: needsThinking
? {
thinking_level: "high",
include_thoughts: false,
}
: undefined,
});
// Store all vision answers when provided
if (reply.visionAnswers) {
const updates: any = {};
if (reply.visionAnswers.q1) {
updates["visionAnswers.q1"] = reply.visionAnswers.q1;
console.log("[AI Chat] Storing vision answer Q1");
}
if (reply.visionAnswers.q2) {
updates["visionAnswers.q2"] = reply.visionAnswers.q2;
console.log("[AI Chat] Storing vision answer Q2");
}
if (reply.visionAnswers.q3) {
updates["visionAnswers.q3"] = reply.visionAnswers.q3;
console.log("[AI Chat] Storing vision answer Q3");
}
// If all answers are complete, trigger MVP generation
if (reply.visionAnswers.allAnswered) {
updates["visionAnswers.allAnswered"] = true;
updates["readyForMVP"] = true;
console.log(
"[AI Chat] ✅ All 3 vision answers complete - ready for MVP generation",
);
}
if (Object.keys(updates).length > 0) {
updates["visionAnswers.updatedAt"] = new Date().toISOString();
await query(
`UPDATE fs_projects
SET data = data || $1::jsonb
WHERE id = $2`,
[JSON.stringify({ visionAnswers: updates }), projectId]
).catch((error) => {
console.error('[ai/chat] Failed to store vision answers', error);
});
}
}
// Best-effort: append this turn to the persisted conversation history
appendConversation(projectId, [
{ role: 'user', content: message },
{ role: 'assistant', content: reply.reply },
]).catch((error) => {
console.error('[ai/chat] Failed to append conversation history', error);
});
// If in collector mode, always update handoff state based on actual project context
// This ensures the checklist updates even if AI doesn't return collectorHandoff
if (resolvedMode === 'collector_mode') {
// Derive handoff state from actual project context
const hasDocuments = (context.knowledgeSummary.bySourceType['imported_document'] ?? 0) > 0;
const documentCount = context.knowledgeSummary.bySourceType['imported_document'] ?? 0;
const githubConnected = !!context.project.githubRepo;
const extensionLinked = context.project.extensionLinked ?? false;
// Check if AI indicated readiness (from reply if provided, otherwise check reply text)
let readyForExtraction = reply.collectorHandoff?.readyForExtraction ?? false;
// Fallback: If AI says certain phrases, assume user confirmed readiness
// IMPORTANT: These phrases must be SPECIFIC to avoid false positives
if (!readyForExtraction && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for explicit analysis/digging phrases (not just "perfect!")
const analysisKeywords = ['analyze', 'analyzing', 'digging', 'extraction', 'processing'];
const hasAnalysisKeyword = analysisKeywords.some(keyword => replyLower.includes(keyword));
// Only trigger if AI mentions BOTH readiness AND analysis action
if (hasAnalysisKeyword) {
const confirmPhrases = [
'let me analyze what you',
'i\'ll start digging into',
'i\'m starting the analysis',
'running the extraction',
'processing what you\'ve shared',
];
readyForExtraction = confirmPhrases.some(phrase => replyLower.includes(phrase));
if (readyForExtraction) {
console.log(`[AI Chat] Detected readiness from AI reply text: "${reply.reply.substring(0, 100)}"`);
}
[JSON.stringify({ visionAnswers: updates }), projectId],
).catch((error) => {
console.error("[ai/chat] Failed to store vision answers", error);
});
}
}
const handoff: CollectorPhaseHandoff = {
phase: 'collector',
readyForNextPhase: readyForExtraction,
confidence: readyForExtraction ? 0.9 : 0.5,
confirmed: {
hasDocuments,
documentCount,
githubConnected,
githubRepo: context.project.githubRepo ?? undefined,
extensionLinked,
},
uncertain: {
extensionDeclined: reply.collectorHandoff?.extensionDeclined ?? false,
noGithubYet: reply.collectorHandoff?.noGithubYet ?? false,
},
missing: [],
questionsForUser: [],
sourceEvidence: [],
version: '1.0',
timestamp: new Date().toISOString(),
};
// Best-effort: append this turn to the persisted conversation history
appendConversation(projectId, [
{ role: "user", content: message },
{ role: "assistant", content: reply.reply },
]).catch((error) => {
console.error("[ai/chat] Failed to append conversation history", error);
});
// Persist to project phaseData in Postgres
await query(
`UPDATE fs_projects
// If in collector mode, always update handoff state based on actual project context
// This ensures the checklist updates even if AI doesn't return collectorHandoff
if (resolvedMode === "collector_mode") {
// Derive handoff state from actual project context
const hasDocuments =
(context.knowledgeSummary.bySourceType["imported_document"] ?? 0) > 0;
const documentCount =
context.knowledgeSummary.bySourceType["imported_document"] ?? 0;
const githubConnected = !!context.project.githubRepo;
const extensionLinked = context.project.extensionLinked ?? false;
// Check if AI indicated readiness (from reply if provided, otherwise check reply text)
let readyForExtraction =
reply.collectorHandoff?.readyForExtraction ?? false;
// Fallback: If AI says certain phrases, assume user confirmed readiness
// IMPORTANT: These phrases must be SPECIFIC to avoid false positives
if (!readyForExtraction && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for explicit analysis/digging phrases (not just "perfect!")
const analysisKeywords = [
"analyze",
"analyzing",
"digging",
"extraction",
"processing",
];
const hasAnalysisKeyword = analysisKeywords.some((keyword) =>
replyLower.includes(keyword),
);
// Only trigger if AI mentions BOTH readiness AND analysis action
if (hasAnalysisKeyword) {
const confirmPhrases = [
"let me analyze what you",
"i'll start digging into",
"i'm starting the analysis",
"running the extraction",
"processing what you've shared",
];
readyForExtraction = confirmPhrases.some((phrase) =>
replyLower.includes(phrase),
);
if (readyForExtraction) {
console.log(
`[AI Chat] Detected readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
);
}
}
}
const handoff: CollectorPhaseHandoff = {
phase: "collector",
readyForNextPhase: readyForExtraction,
confidence: readyForExtraction ? 0.9 : 0.5,
confirmed: {
hasDocuments,
documentCount,
githubConnected,
githubRepo: context.project.githubRepo ?? undefined,
extensionLinked,
},
uncertain: {
extensionDeclined:
reply.collectorHandoff?.extensionDeclined ?? false,
noGithubYet: reply.collectorHandoff?.noGithubYet ?? false,
},
missing: [],
questionsForUser: [],
sourceEvidence: [],
version: "1.0",
timestamp: new Date().toISOString(),
};
// Persist to project phaseData in Postgres
await query(
`UPDATE fs_projects
SET data = jsonb_set(
data,
'{phaseData,phaseHandoffs,collector}',
@@ -335,77 +393,93 @@ Use this context to provide specific, grounded responses. The session history sh
true
)
WHERE id = $2`,
[JSON.stringify(handoff), projectId]
).catch((error) => {
console.error('[ai/chat] Failed to persist collector handoff', error);
});
[JSON.stringify(handoff), projectId],
).catch((error) => {
console.error("[ai/chat] Failed to persist collector handoff", error);
});
console.log(`[AI Chat] Collector handoff persisted:`, {
hasDocuments: handoff.confirmed.hasDocuments,
githubConnected: handoff.confirmed.githubConnected,
extensionLinked: handoff.confirmed.extensionLinked,
readyForExtraction: handoff.readyForNextPhase,
});
console.log(`[AI Chat] Collector handoff persisted:`, {
hasDocuments: handoff.confirmed.hasDocuments,
githubConnected: handoff.confirmed.githubConnected,
extensionLinked: handoff.confirmed.extensionLinked,
readyForExtraction: handoff.readyForNextPhase,
});
// Auto-transition to extraction phase if ready
if (handoff.readyForNextPhase) {
console.log(`[AI Chat] Collector complete - triggering backend extraction`);
// Mark collector as complete
await query(
`UPDATE fs_projects
// Auto-transition to extraction phase if ready
if (handoff.readyForNextPhase) {
console.log(
`[AI Chat] Collector complete - triggering backend extraction`,
);
// Mark collector as complete
await query(
`UPDATE fs_projects
SET data = jsonb_set(data, '{phaseData,collectorCompletedAt}', $1::jsonb, true)
WHERE id = $2`,
[JSON.stringify(new Date().toISOString()), projectId]
).catch((error) => {
console.error('[ai/chat] Failed to mark collector complete', error);
});
// Trigger backend extraction (async - don't await)
import('@/lib/server/backend-extractor').then(({ runBackendExtractionForProject }) => {
runBackendExtractionForProject(projectId).catch((error) => {
console.error(`[AI Chat] Backend extraction failed for project ${projectId}:`, error);
[JSON.stringify(new Date().toISOString()), projectId],
).catch((error) => {
console.error("[ai/chat] Failed to mark collector complete", error);
});
});
}
}
// Handle extraction review → vision phase transition
if (resolvedMode === 'extraction_review_mode') {
// Check if AI indicated extraction is approved and ready for vision
let readyForVision = reply.extractionReviewHandoff?.readyForVision ?? false;
// Fallback: Check reply text for approval phrases
if (!readyForVision && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for vision transition phrases
const visionKeywords = ['vision', 'mvp', 'roadmap', 'plan'];
const hasVisionKeyword = visionKeywords.some(keyword => replyLower.includes(keyword));
if (hasVisionKeyword) {
const confirmPhrases = [
'ready to move to',
'ready for vision',
'let\'s move to vision',
'moving to vision',
'great! let\'s define',
'perfect! now let\'s',
];
readyForVision = confirmPhrases.some(phrase => replyLower.includes(phrase));
if (readyForVision) {
console.log(`[AI Chat] Detected vision readiness from AI reply text: "${reply.reply.substring(0, 100)}"`);
}
// Trigger backend extraction (async - don't await)
import("@/lib/server/backend-extractor").then(
({ runBackendExtractionForProject }) => {
runBackendExtractionForProject(projectId).catch((error) => {
console.error(
`[AI Chat] Backend extraction failed for project ${projectId}:`,
error,
);
});
},
);
}
}
if (readyForVision) {
console.log(`[AI Chat] Extraction review complete - transitioning to vision phase`);
// Mark extraction review as complete and transition to vision
await query(
`UPDATE fs_projects
// Handle extraction review → vision phase transition
if (resolvedMode === "extraction_review_mode") {
// Check if AI indicated extraction is approved and ready for vision
let readyForVision =
reply.extractionReviewHandoff?.readyForVision ?? false;
// Fallback: Check reply text for approval phrases
if (!readyForVision && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for vision transition phrases
const visionKeywords = ["vision", "mvp", "roadmap", "plan"];
const hasVisionKeyword = visionKeywords.some((keyword) =>
replyLower.includes(keyword),
);
if (hasVisionKeyword) {
const confirmPhrases = [
"ready to move to",
"ready for vision",
"let's move to vision",
"moving to vision",
"great! let's define",
"perfect! now let's",
];
readyForVision = confirmPhrases.some((phrase) =>
replyLower.includes(phrase),
);
if (readyForVision) {
console.log(
`[AI Chat] Detected vision readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
);
}
}
}
if (readyForVision) {
console.log(
`[AI Chat] Extraction review complete - transitioning to vision phase`,
);
// Mark extraction review as complete and transition to vision
await query(
`UPDATE fs_projects
SET data = data
|| '{"currentPhase":"vision","phaseStatus":"in_progress"}'::jsonb
|| jsonb_build_object('phaseData',
@@ -414,86 +488,99 @@ Use this context to provide specific, grounded responses. The session history sh
)
)
WHERE id = $2`,
[new Date().toISOString(), projectId]
).catch((error) => {
console.error('[ai/chat] Failed to transition to vision phase', error);
});
[new Date().toISOString(), projectId],
).catch((error) => {
console.error(
"[ai/chat] Failed to transition to vision phase",
error,
);
});
}
}
}
// Save conversation history to Postgres
await appendConversation(projectId, [
{ role: 'user', content: message },
{ role: 'assistant', content: reply.reply },
]).catch((error) => {
console.error('[ai/chat] Failed to save conversation history', error);
});
// Save conversation history to Postgres
await appendConversation(projectId, [
{ role: "user", content: message },
{ role: "assistant", content: reply.reply },
]).catch((error) => {
console.error("[ai/chat] Failed to save conversation history", error);
});
console.log(`[AI Chat] Conversation history saved (+2 messages)`);
console.log(`[AI Chat] Conversation history saved (+2 messages)`);
// Determine which artifacts were used
const artifactsUsed = determineArtifactsUsed(context);
// Determine which artifacts were used
const artifactsUsed = determineArtifactsUsed(context);
// Log successful interaction
logProjectEvent({
projectId,
userId: projectData.userId ?? null,
eventType: 'chat_interaction',
mode: resolvedMode,
phase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
vectorChunkCount: context.retrievedChunks.length,
promptVersion: '2.0', // Updated with vector search
modelUsed: process.env.VERTEX_AI_MODEL || 'gemini-3-pro-preview',
success: true,
errorMessage: null,
metadata: {
knowledgeCount: context.knowledgeSummary.totalCount,
extractionCount: context.extractionSummary.totalCount,
hasGithubRepo: !!context.repositoryAnalysis,
},
}).catch((err) => console.error('[ai/chat] Failed to log event:', err));
return NextResponse.json({
reply: reply.reply,
mode: resolvedMode,
projectPhase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
});
} catch (error) {
console.error('[ai/chat] Error handling chat request', error);
// Log error (best-effort) - extract projectId from request body if available
const errorProjectId = typeof (error as { projectId?: string })?.projectId === 'string'
? (error as { projectId: string }).projectId
: null;
if (errorProjectId) {
// Log successful interaction
logProjectEvent({
projectId: errorProjectId,
userId: null,
eventType: 'error',
mode: null,
phase: null,
artifactsUsed: [],
usedVectorSearch: false,
promptVersion: '2.0',
modelUsed: process.env.VERTEX_AI_MODEL || 'gemini-3-pro-preview',
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
}).catch((err) => console.error('[ai/chat] Failed to log error:', err));
projectId,
userId: projectData.userId ?? null,
eventType: "chat_interaction",
mode: resolvedMode,
phase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
vectorChunkCount: context.retrievedChunks.length,
promptVersion: "2.0", // Updated with vector search
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
success: true,
errorMessage: null,
metadata: {
knowledgeCount: context.knowledgeSummary.totalCount,
extractionCount: context.extractionSummary.totalCount,
hasGithubRepo: !!context.repositoryAnalysis,
},
}).catch((err) => console.error("[ai/chat] Failed to log event:", err));
return NextResponse.json({
reply: reply.reply,
mode: resolvedMode,
projectPhase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
});
} catch (error) {
console.error("[ai/chat] Error handling chat request", error);
// Log error (best-effort) - extract projectId from request body if available
const errorProjectId =
typeof (error as { projectId?: string })?.projectId === "string"
? (error as { projectId: string }).projectId
: null;
if (errorProjectId) {
logProjectEvent({
projectId: errorProjectId,
userId: null,
eventType: "error",
mode: null,
phase: null,
artifactsUsed: [],
usedVectorSearch: false,
promptVersion: "2.0",
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
}).catch((err) =>
log.error("ai/chat log failed", {
route: "api.ai.chat",
err: err instanceof Error ? err.message : String(err),
}),
);
}
log.error("ai/chat error", {
route: "api.ai.chat",
err: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: "Failed to process chat message",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
return NextResponse.json(
{
error: 'Failed to process chat message',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}
},
{ source: "body", paramName: "projectId" },
);

View File

@@ -1,37 +1,38 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
/**
* POST /api/ai/conversation/reset
* Body OR query: { projectId }
*
* Deletes the conversation history for a project the caller owns.
*
* Closes S-03. Also migrated off the legacy Firebase admin path onto Postgres
* to match `/api/ai/conversation`.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
export async function POST(request: Request) {
try {
const url = new URL(request.url);
const body = await request
.json()
.catch(() => ({ projectId: url.searchParams.get('projectId') }));
const projectId = (body?.projectId ?? url.searchParams.get('projectId') ?? '').trim();
if (!projectId) {
export const POST = withTenantProject(
async (_request, _ctx, { project }) => {
try {
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
project.id,
]);
return NextResponse.json({ success: true });
} catch (err) {
log.error("ai/conversation/reset failed", {
route: "api.ai.conversation.reset",
projectId: project.id,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{ error: 'projectId is required' },
{ status: 400 },
{
error: "Failed to reset conversation",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
const adminDb = getAdminDb();
const docRef = adminDb.collection('chat_conversations').doc(projectId);
await docRef.delete();
return NextResponse.json({ success: true });
} catch (error) {
console.error('[ai/conversation/reset] Failed to reset conversation', error);
return NextResponse.json(
{
error: 'Failed to reset conversation',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}
},
{ source: "body", paramName: "projectId" },
);

View File

@@ -1,5 +1,13 @@
import { NextResponse } from 'next/server';
import { query } from '@/lib/db-postgres';
/**
* GET /api/ai/conversation?projectId=… — fetch saved conversation
* DELETE /api/ai/conversation?projectId=… — wipe saved conversation
*
* Closes S-02: was completely unauthenticated and accepted any projectId.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
const ENSURE_TABLE = `
CREATE TABLE IF NOT EXISTS chat_conversations (
@@ -9,7 +17,7 @@ const ENSURE_TABLE = `
)
`;
type StoredMessageRole = 'user' | 'assistant';
type StoredMessageRole = "user" | "assistant";
type ConversationMessage = {
role: StoredMessageRole;
@@ -21,49 +29,48 @@ type ConversationResponse = {
messages: ConversationMessage[];
};
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const projectId = (url.searchParams.get('projectId') ?? '').trim();
if (!projectId) {
return NextResponse.json({ error: 'projectId is required' }, { status: 400 });
export const GET = withTenantProject(
async (request, _ctx, { project }) => {
try {
await query(ENSURE_TABLE);
const rows = await query<{ messages: ConversationMessage[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[project.id],
);
const messages: ConversationMessage[] = rows[0]?.messages ?? [];
const response: ConversationResponse = { messages };
return NextResponse.json(response);
} catch (err) {
log.error("ai/conversation GET failed", {
route: "api.ai.conversation",
projectId: project.id,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json({ messages: [] });
}
},
{ source: "search", paramName: "projectId" },
);
await query(ENSURE_TABLE);
const rows = await query<{ messages: ConversationMessage[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[projectId]
);
const messages: ConversationMessage[] = rows[0]?.messages ?? [];
const response: ConversationResponse = { messages };
return NextResponse.json(response);
} catch (error) {
console.error('[GET /api/ai/conversation] Error:', error);
return NextResponse.json({ messages: [] });
}
}
export async function DELETE(request: Request) {
try {
const url = new URL(request.url);
const projectId = (url.searchParams.get('projectId') ?? '').trim();
if (!projectId) {
return NextResponse.json({ error: 'projectId is required' }, { status: 400 });
export const DELETE = withTenantProject(
async (_request, _ctx, { project }) => {
try {
await query(ENSURE_TABLE);
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
project.id,
]);
return NextResponse.json({ ok: true });
} catch (err) {
log.error("ai/conversation DELETE failed", {
route: "api.ai.conversation",
projectId: project.id,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{ error: "Failed to reset conversation" },
{ status: 500 },
);
}
await query(ENSURE_TABLE);
await query(
`DELETE FROM chat_conversations WHERE project_id = $1`,
[projectId]
);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('[DELETE /api/ai/conversation] Error:', error);
return NextResponse.json({ error: 'Failed to reset conversation' }, { status: 500 });
}
}
},
{ source: "search", paramName: "projectId" },
);

View File

@@ -33,11 +33,10 @@ import { buildDesignKitPromptSection } from "@/lib/design-kits/for-ai";
import { buildCodebaseSummary } from "@/lib/ai/project-context/codebase-summary";
import type { ChatMessage, ToolCall } from "@/lib/ai/gemini-chat";
// Path B chains routinely fire 7-10 tool calls in one user turn. 18
// gives enough headroom for complex workflows (scaffold → install →
// configure → start) while still capping runaway loops. When the cap
// IS hit, we emit a recovery summary instead of silent tool pills.
const MAX_TOOL_ROUNDS = 15;
// C-01: Lowered from 15 → 8. Real workflows (scaffold → install →
// configure → start) rarely need more than 8 rounds when done correctly.
// If the cap IS hit the model gets a recovery summary, not silence.
const MAX_TOOL_ROUNDS = 8;
let chatTablesReady = false;
async function ensureChatTables() {
@@ -151,6 +150,20 @@ After every assistant turn, the harness automatically runs \`git add -A && git c
You're talking to the owner of the "${workspace}" workspace. They have admin access to their Gitea org, a fleet of Coolify projects, and a persistent dev container per project. You can read and write any of it.
## Mode: respond first, act second
Before calling any tool, decide: is the user asking a question, or telling you to do something?
**CONVERSATIONAL inputs — respond with text only, no tools:**
- One-word or greeting messages: "test", "hi", "ok", "thanks"
- Questions ending in "?": "are you able to…?", "what does X mean?", "how would you…?"
- Status checks: "is it deployed?", "what's running?" (one read-only tool MAX, then respond)
**ACTION inputs — tools allowed:**
- Imperatives: "deploy it", "build me X", "fix the navbar", "ship"
- Specific tasks with clear deliverables: "add Stripe to the pricing page"
If you are unsure which mode the user is in, **default to CONVERSATIONAL** and ask one clarifying sentence before acting. "Want me to actually deploy this to prod now, or were you just checking?" is always cheaper than a silent 16-tool spiral.
## Identity
You are a high-agency product engineer. You own the outcome. Continue until the user's goal is actually resolved unless you're blocked on missing info, proceeding would be unsafe, or the user changes direction. You are not answering questions; you are building with the user. Translate engineering complexity into product momentum.
@@ -530,6 +543,9 @@ export async function POST(request: Request) {
const stream = new ReadableStream({
async start(controller) {
let streamClosed = false;
// C-06: Per-turn correlation ID so prod logs are greppable.
const turnId = crypto.randomUUID();
function emit(chunk: object) {
if (streamClosed) return;
try {
@@ -544,11 +560,21 @@ export async function POST(request: Request) {
function safeClose() {
if (streamClosed) return;
streamClosed = true;
clearInterval(heartbeat);
try {
controller.close();
} catch {}
}
// C-04: SSE heartbeat every 25s keeps Cloudflare / proxies from
// dropping the connection during long Gemini thinking phases.
const heartbeat = setInterval(() => {
emit({ type: "ping", turnId });
}, 25_000);
// Emit turnId immediately so the client can log/correlate.
emit({ type: "turn_start", turnId });
let messages = [...history];
let round = 0;
let assistantText = "";
@@ -616,6 +642,29 @@ export async function POST(request: Request) {
return `${tc.name}:${argSig}`;
}
// ── Server-side conversational guard (C-03 enforcement) ───────────
// If the user's message looks conversational we withhold tools for
// round 1. The model MUST respond in text first. If its reply then
// expresses clear intent to act, tools become available from round 2.
// This is more reliable than a prompt rule against a "do-er" model.
function isConversational(msg: string): boolean {
const m = msg.trim();
if (m.length < 3) return true; // single word / emoji
if (m.endsWith("?")) return true; // explicit question
// Short phrases that are status checks or greetings
const conversationalPatterns = [
/^(hi|hey|hello|sup|test|ok|okay|thanks|ty|thx|lgtm|nice|cool|great|wow)\b/i,
/^(what|how|why|when|where|who|which|is |are |can |could |would |do |does |did |has |have |had |was |were )\S+.{0,60}$/i,
/^(are you able to|can you|could you|would you|is it possible)/i,
/^(what'?s |whats )(running|live|deployed|happening|wrong|broken|up)/i,
/^(is it|is that|is this|is there|is the)/i,
];
return conversationalPatterns.some((re) => re.test(m));
}
const firstMessageIsConversational =
mcp_token !== undefined && // tools available
isConversational(message.trim());
try {
// Tool-calling loop: use non-streaming so thought_signature is
// always present in the complete response (required by thinking models).
@@ -623,7 +672,12 @@ export async function POST(request: Request) {
if (aborted) break;
round++;
const toolDefs = mcp_token ? VIBN_TOOL_DEFINITIONS : [];
// On round 1, withhold tools if the message looks conversational.
// The model must answer in text first; tools unlock from round 2.
const toolDefs =
mcp_token && !(round === 1 && firstMessageIsConversational)
? VIBN_TOOL_DEFINITIONS
: [];
// Every 2 silent rounds or 5 tool calls, nudge the model to surface a one-liner
// status before continuing. This is the user's only signal of
@@ -637,6 +691,16 @@ export async function POST(request: Request) {
"on and why. The user is staring at silent tool pills."
: "";
// When withholding tools on round 1 (conversational guard), add a
// mandatory instruction so the model doesn't return empty text.
if (round === 1 && firstMessageIsConversational) {
extraSystem +=
"\n\n[MANDATORY] The user's message is a question or conversational input, " +
"not a command. You have NO tools available on this turn. " +
"Respond with PLAIN TEXT ONLY in 1-3 sentences answering their question. " +
"If they want you to take action, confirm intent and wait for a clear directive.";
}
if (MAX_TOOL_ROUNDS - round <= 3) {
extraSystem += `\n\n[WARNING] You only have ${MAX_TOOL_ROUNDS - round} tool calls left before you are forcefully terminated. Stop exploring, make your final edits, and write your final response to the user NOW.`;
}
@@ -713,14 +777,17 @@ export async function POST(request: Request) {
}
}
// Stage 1: Warning at 3 repeats
if (maxRepeats === 3) {
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} 3 times recently. Please wrap up this approach or try a completely different tool.`;
// C-02: Tightened. Hard-break at 3 identical fingerprints (was 5).
if (maxRepeats === 2) {
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} twice in a row. Try a different approach or surface what's blocking you to the user.`;
}
if (maxRepeats >= 3) {
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
}
// Stage 2: Hard Break at 5 repeats
if (maxRepeats >= 5) {
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
// C-02: Also hard-break after 6 consecutive tool calls with no text.
if (!loopBreakReason && toolCallsSinceText >= 6) {
loopBreakReason = `${toolCallsSinceText} consecutive tool calls with no assistant text`;
}
// Execute tool calls and add results. OpenAI-compatible APIs
@@ -730,15 +797,32 @@ export async function POST(request: Request) {
const recoveryLines: string[] = [];
for (const tc of resp.toolCalls) {
if (aborted) break;
const result = mcp_token
? await executeMcpTool(
// C-05: Per-tool timeout. A hung MCP call would freeze the whole turn.
const TOOL_TIMEOUT_MS = 45_000;
const toolTimeout = new Promise<string>((resolve) =>
setTimeout(
() =>
resolve(
JSON.stringify({
ok: false,
error: `Tool ${tc.name} timed out after ${TOOL_TIMEOUT_MS / 1000}s`,
}),
),
TOOL_TIMEOUT_MS,
),
);
const toolExec = mcp_token
? executeMcpTool(
tc.name,
tc.args,
mcp_token,
baseUrl,
activeProject?.id,
)
: JSON.stringify({ error: "No MCP token — read-only mode." });
: Promise.resolve(
JSON.stringify({ error: "No MCP token — read-only mode." }),
);
const result = await Promise.race([toolExec, toolTimeout]);
emit({
type: "tool_result",
@@ -756,6 +840,25 @@ export async function POST(request: Request) {
const recovery = detectKnownError(result);
if (recovery) recoveryLines.push(formatRecoveryMessage(recovery));
// B-05: SSE plan event — stream task state changes to the client
// so the Plan tab updates in real-time during a chat turn.
if (tc.name === "plan_task_add" || tc.name === "plan_task_edit") {
try {
const parsed = JSON.parse(result);
const task = parsed?.result?.task ?? parsed?.task;
if (task?.id) {
emit({
type: "plan",
taskId: task.id,
text: task.text ?? task.title ?? "",
status: task.status ?? "open",
});
}
} catch {
// non-JSON result — skip
}
}
}
for (const line of recoveryLines) {
messages.push({ role: "user", content: line });
@@ -787,12 +890,15 @@ export async function POST(request: Request) {
// 20 toolCalls, user had to re-prompt to get any answer.
const lastTurnHadTools =
messages.length > 0 && messages[messages.length - 1].role === "tool";
// C-07: Also recover when the model has been running tools without
// any text for >=4 rounds — the user is staring at silence.
const needsRecovery =
!aborted &&
lastTurnHadTools &&
(round >= MAX_TOOL_ROUNDS ||
!!loopBreakReason ||
assistantText.trim().length === 0 ||
roundsSinceText >= 4 ||
lastToolResultsHadFailure(messages));
if (needsRecovery) {
@@ -1072,9 +1178,9 @@ export async function POST(request: Request) {
}
},
cancel() {
// Browser disconnected (tab closed, navigated away). Nothing to
// do — the abort handler above already flipped the flag and the
// loop will bail at the next checkpoint.
// Browser disconnected (tab closed, navigated away). Clear the
// heartbeat so we stop writing to a closed stream.
// The abort handler above already flipped the flag so the loop bails.
},
});

View File

@@ -0,0 +1,25 @@
// Two-stage Loop detection (Fix 4 update)
// Sliding window of 10
const window = toolFingerprints.slice(-10);
const counts = new Map<string, number>();
for (const fp of window) counts.set(fp, (counts.get(fp) ?? 0) + 1);
// Find highest repeating tool call
let maxRepeats = 0;
let repeatedCmd = "";
for (const [fp, n] of counts.entries()) {
if (n > maxRepeats) {
maxRepeats = n;
repeatedCmd = fp.split("|")[0];
}
}
// Stage 1: Warning at 3 repeats
if (maxRepeats === 3) {
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} 3 times recently. Please wrap up this approach or try a completely different tool.`;
}
// Stage 2: Hard Break at 5 repeats
if (maxRepeats >= 5) {
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
}

View File

@@ -1,52 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
/**
* POST /api/context/summarize
* Body: { content: string, title?: string }
*
* Generates a short summary via Gemini. Closes S-04: now requires a
* signed-in user (rate-limit per user, not per-IP) so we don't burn Gemini
* quota on anonymous traffic.
*/
import { NextResponse } from "next/server";
import { withAuth, withRateLimit } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
const MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
const API_KEY = process.env.GOOGLE_API_KEY || '';
const MODEL = process.env.GEMINI_MODEL || "gemini-3.1-pro-preview";
const API_KEY = process.env.GOOGLE_API_KEY || "";
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
export async function POST(request: NextRequest) {
try {
const { content, title } = await request.json();
export const POST = withRateLimit(
withAuth(async (request, _ctx, { user }) => {
try {
const { content, title } = (await request.json()) as {
content?: string;
title?: string;
};
if (!content) {
return NextResponse.json({ error: "Content is required" }, { status: 400 });
}
if (!content || typeof content !== "string") {
return NextResponse.json(
{ error: "content is required" },
{ status: 400 },
);
}
const maxContentLength = 30000;
const truncatedContent = content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
: content;
const maxContentLength = 30000;
const truncatedContent =
content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
: content;
const prompt = `Read this document titled "${title}" and provide a concise 1-2 sentence summary that captures the main topic and key points. Be specific and actionable.
const prompt = `Read this document titled "${title ?? "(untitled)"}" and provide a concise 1-2 sentence summary that captures the main topic and key points. Be specific and actionable.
Document content:
${truncatedContent}
Summary:`;
const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3 },
}),
});
const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3 },
}),
});
if (!response.ok) {
throw new Error(`Gemini API error (${response.status}): ${await response.text()}`);
if (!response.ok) {
const text = await response.text();
log.warn("context/summarize gemini error", {
route: "api.context.summarize",
user: user.email,
status: response.status,
body: text.slice(0, 500),
});
return NextResponse.json(
{
error: `Gemini API error (${response.status})`,
details: text.slice(0, 500),
},
{ status: 502 },
);
}
const result = await response.json();
const summary =
result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ||
"Summary unavailable";
return NextResponse.json({ summary });
} catch (err) {
log.error("context/summarize failed", {
route: "api.context.summarize",
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{
error: "Failed to generate summary",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
const result = await response.json();
const summary = result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || 'Summary unavailable';
return NextResponse.json({ summary });
} catch (error) {
console.error("Error generating summary:", error);
return NextResponse.json(
{ error: "Failed to generate summary", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
}),
{
// 20 summaries / min / user — much higher than chat because they're cheap.
limit: 20,
windowMs: 60_000,
keyFn: (_req, extra) => {
const userEmail = (extra as { user?: { email?: string } })?.user?.email;
return `context-summarize:${userEmail ?? "anon"}`;
},
},
);

View File

@@ -1,229 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
export async function POST(request: NextRequest) {
try {
// Verify authentication using API key
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Unauthorized - Missing API key' },
{ status: 401 }
);
}
const apiKey = authHeader.substring(7);
// Look up user by API key
const apiKeysSnapshot = await adminDb
.collection('apiKeys')
.where('key', '==', apiKey)
.where('isActive', '==', true)
.limit(1)
.get();
if (apiKeysSnapshot.empty) {
return NextResponse.json(
{ error: 'Invalid API key' },
{ status: 401 }
);
}
const apiKeyDoc = apiKeysSnapshot.docs[0];
const apiKeyData = apiKeyDoc.data();
const userId = apiKeyData.userId;
if (!userId) {
return NextResponse.json(
{ error: 'API key not associated with user' },
{ status: 401 }
);
}
// Parse request body
const body = await request.json();
const {
projectId,
workspacePath,
githubUrl,
conversations
} = body;
if (!projectId || !conversations) {
return NextResponse.json(
{ error: 'Missing required fields: projectId, conversations' },
{ status: 400 }
);
}
// Verify user has access to the project
const projectRef = adminDb.collection('projects').doc(projectId);
const projectDoc = await projectRef.get();
if (!projectDoc.exists) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
const projectData = projectDoc.data();
if (projectData?.userId !== userId) {
return NextResponse.json(
{ error: 'Access denied to this project' },
{ status: 403 }
);
}
// Process and store conversations
const { composers, workspaceFiles, totalGenerations } = conversations;
let conversationCount = 0;
let totalMessagesWritten = 0;
// Determine filtering keywords based on project context
// TODO: Make this configurable per project
const projectKeywords = ['vibn', 'project', 'extension', 'collector', 'cursor-monitor'];
const excludeKeywords = ['nhl', 'hockey', 'market', 'transaction'];
// Store each composer (chat session) as a separate document
for (const composer of composers || []) {
if (composer.type !== 'head') continue; // Only process head composers
const conversationId = `cursor-${composer.composerId}`;
const conversationRef = adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.doc(conversationId);
const name = composer.name || 'Untitled Conversation';
const nameLower = name.toLowerCase();
// Simple relevance scoring
let relevanceScore = 0;
// Check for project keywords in name
for (const keyword of projectKeywords) {
if (nameLower.includes(keyword)) {
relevanceScore += 2;
}
}
// Penalize for exclude keywords
for (const keyword of excludeKeywords) {
if (nameLower.includes(keyword)) {
relevanceScore -= 3;
}
}
// Check if name mentions files from this workspace
if (workspaceFiles && Array.isArray(workspaceFiles)) {
for (const file of workspaceFiles) {
if (nameLower.includes(file.toLowerCase())) {
relevanceScore += 1;
}
}
}
// Count messages
let messageCount = 0;
if (composer.bubbles && Array.isArray(composer.bubbles)) {
messageCount = composer.bubbles.length;
}
const conversationData = {
userId,
projectId,
conversationId,
composerId: composer.composerId,
name,
createdAt: new Date(composer.createdAt).toISOString(),
lastUpdatedAt: new Date(composer.lastUpdatedAt).toISOString(),
unifiedMode: composer.unifiedMode || false,
forceMode: composer.forceMode || false,
workspacePath,
githubUrl: githubUrl || null,
importedAt: new Date().toISOString(),
relevanceScore, // For filtering
messageCount,
metadata: {
source: 'cursor-monitor-extension',
composerType: composer.type,
}
};
// Write conversation document first
await conversationRef.set(conversationData);
// Store messages in chunks to avoid Firestore batch limit (500 operations)
if (composer.bubbles && Array.isArray(composer.bubbles)) {
const BATCH_SIZE = 400; // Leave room for overhead
for (let i = 0; i < composer.bubbles.length; i += BATCH_SIZE) {
const batch = adminDb.batch();
const chunk = composer.bubbles.slice(i, i + BATCH_SIZE);
for (const bubble of chunk) {
const messageRef = conversationRef
.collection('messages')
.doc(bubble.bubbleId);
batch.set(messageRef, {
bubbleId: bubble.bubbleId,
type: bubble.type, // 1 = user, 2 = AI
role: bubble.type === 1 ? 'user' : bubble.type === 2 ? 'assistant' : 'unknown',
text: bubble.text || '',
createdAt: bubble.createdAt,
requestId: bubble.requestId,
attachedFiles: bubble.attachedFiles || []
});
}
await batch.commit();
totalMessagesWritten += chunk.length;
console.log(`✅ Wrote ${chunk.length} messages (${i + chunk.length}/${composer.bubbles.length}) for ${name}`);
}
}
conversationCount++;
}
// Store workspace metadata for reference
const workspaceMetaRef = adminDb
.collection('projects')
.doc(projectId)
.collection('cursorData')
.doc('workspace-meta');
await workspaceMetaRef.set({
workspacePath,
githubUrl,
workspaceFiles: workspaceFiles || [],
totalGenerations: totalGenerations || 0,
importedAt: new Date().toISOString(),
lastBatchImportedAt: new Date().toISOString(),
}, { merge: true });
console.log(`✅ Imported ${conversationCount} conversations to project ${projectId}`);
const workspaceFilesCount = conversations.workspaceFiles?.length || workspaceFiles?.length || 0;
const generationsCount = conversations.totalGenerations || totalGenerations || 0;
return NextResponse.json({
success: true,
conversationCount,
totalMessages: totalMessagesWritten,
workspaceFilesCount,
totalGenerations: generationsCount,
message: `Successfully imported ${conversationCount} conversations with ${totalMessagesWritten} messages`
});
} catch (error) {
console.error('Error importing Cursor conversations:', error);
return NextResponse.json(
{ error: 'Failed to import conversations', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,54 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
// TEMPORARY: For debugging/testing only - no auth required
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json(
{ error: 'Missing projectId' },
{ status: 400 }
);
}
// Delete all cursor conversations for this project
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
const batch = adminDb.batch();
conversationsSnapshot.docs.forEach((doc: FirebaseFirestore.QueryDocumentSnapshot) => {
batch.delete(doc.ref);
});
// Also delete the messages data document
const messagesRef = adminDb
.collection('projects')
.doc(projectId)
.collection('cursorData')
.doc('messages');
batch.delete(messagesRef);
await batch.commit();
return NextResponse.json({
success: true,
deletedCount: conversationsSnapshot.size,
message: 'All cursor conversations cleared'
});
} catch (error) {
console.error('Error clearing cursor conversations:', error);
return NextResponse.json(
{ error: 'Failed to clear conversations', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,192 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function POST(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
const sessionGapMinutes = parseInt(request.nextUrl.searchParams.get('gap') || '30'); // 30 min default
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations sorted by time
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'asc')
.get();
const conversations = conversationsSnapshot.docs.map((doc: FirebaseFirestore.QueryDocumentSnapshot) => {
const data = doc.data();
return {
id: doc.id,
ref: doc.ref,
name: data.name,
createdAt: new Date(data.createdAt),
relevanceScore: data.relevanceScore || 0
};
});
// Step 1: Group by date
const conversationsByDate: Record<string, typeof conversations> = {};
for (const conv of conversations) {
const dateKey = conv.createdAt.toISOString().split('T')[0]; // YYYY-MM-DD
if (!conversationsByDate[dateKey]) {
conversationsByDate[dateKey] = [];
}
conversationsByDate[dateKey].push(conv);
}
// Step 2: Within each date, create sessions based on time gaps
const sessions: any[] = [];
let sessionId = 0;
for (const [date, dayConversations] of Object.entries(conversationsByDate)) {
let currentSession: any = null;
for (const conv of dayConversations) {
if (!currentSession) {
// Start first session of the day
sessionId++;
currentSession = {
sessionId,
date,
startTime: conv.createdAt,
endTime: conv.createdAt,
conversations: [conv]
};
} else {
// Check time gap from last conversation
const gapMs = conv.createdAt.getTime() - currentSession.endTime.getTime();
const gapMinutes = gapMs / (1000 * 60);
if (gapMinutes <= sessionGapMinutes) {
// Same session
currentSession.conversations.push(conv);
currentSession.endTime = conv.createdAt;
} else {
// New session - close current and start new
sessions.push(currentSession);
sessionId++;
currentSession = {
sessionId,
date,
startTime: conv.createdAt,
endTime: conv.createdAt,
conversations: [conv]
};
}
}
}
// Add last session of the day
if (currentSession) {
sessions.push(currentSession);
}
}
// Step 3: Analyze each session and determine project
const projectKeywords = ['vibn', 'extension', 'collector', 'cursor-monitor'];
const excludeKeywords = ['nhl', 'hockey', 'market', 'transaction'];
const analyzedSessions = sessions.map(session => {
const allNames = session.conversations.map((c: any) => c.name.toLowerCase()).join(' ');
let projectTag = 'unknown';
let confidence = 'low';
// Check for strong exclude signals
for (const keyword of excludeKeywords) {
if (allNames.includes(keyword)) {
projectTag = 'other';
confidence = 'high';
break;
}
}
// If not excluded, check for vibn signals
if (projectTag === 'unknown') {
for (const keyword of projectKeywords) {
if (allNames.includes(keyword)) {
projectTag = 'vibn';
confidence = 'high';
break;
}
}
}
// If still unknown, check for generic "project" keyword
if (projectTag === 'unknown' && allNames.includes('project')) {
projectTag = 'vibn';
confidence = 'medium';
}
return {
...session,
projectTag,
confidence,
conversationCount: session.conversations.length
};
});
// Step 4: Update Firestore with session tags
const batch = adminDb.batch();
let updateCount = 0;
for (const session of analyzedSessions) {
for (const conv of session.conversations) {
batch.update(conv.ref, {
sessionId: session.sessionId,
sessionDate: session.date,
sessionProject: session.projectTag,
sessionConfidence: session.confidence
});
updateCount++;
}
}
await batch.commit();
// Return summary
const summary = {
totalConversations: conversations.length,
totalSessions: sessions.length,
sessionGapMinutes,
projectBreakdown: {
vibn: analyzedSessions.filter(s => s.projectTag === 'vibn').length,
other: analyzedSessions.filter(s => s.projectTag === 'other').length,
unknown: analyzedSessions.filter(s => s.projectTag === 'unknown').length
},
conversationBreakdown: {
vibn: analyzedSessions.filter(s => s.projectTag === 'vibn').reduce((sum, s) => sum + s.conversationCount, 0),
other: analyzedSessions.filter(s => s.projectTag === 'other').reduce((sum, s) => sum + s.conversationCount, 0),
unknown: analyzedSessions.filter(s => s.projectTag === 'unknown').reduce((sum, s) => sum + s.conversationCount, 0)
},
sampleSessions: analyzedSessions.slice(0, 10).map(s => ({
sessionId: s.sessionId,
date: s.date,
conversationCount: s.conversationCount,
projectTag: s.projectTag,
confidence: s.confidence,
conversationNames: s.conversations.slice(0, 3).map((c: any) => c.name)
}))
};
return NextResponse.json({
success: true,
updatedConversations: updateCount,
...summary
});
} catch (error) {
console.error('Error tagging sessions:', error);
return NextResponse.json(
{ error: 'Failed to tag sessions', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,63 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
import { FieldValue } from 'firebase-admin/firestore';
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => ({}));
const projectId = (body.projectId ?? '').trim();
if (!projectId) {
return NextResponse.json(
{ error: 'projectId is required' },
{ status: 400 },
);
}
const adminDb = getAdminDb();
const docRef = adminDb.collection('chat_conversations').doc(projectId);
await adminDb.runTransaction(async (tx) => {
const snapshot = await tx.get(docRef);
const existing = (snapshot.exists ? (snapshot.data()?.messages as unknown[]) : []) ?? [];
const now = new Date().toISOString();
const newMessages = [
{
role: 'user' as const,
content: '[debug] test user message',
createdAt: now,
},
{
role: 'assistant' as const,
content: '[debug] test assistant reply',
createdAt: now,
},
];
tx.set(
docRef,
{
projectId,
messages: [...existing, ...newMessages],
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('[debug/append-conversation] Failed to append messages', error);
return NextResponse.json(
{
error: 'Failed to append debug conversation messages',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,88 +0,0 @@
/**
* Debug API to check session links
*/
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Get all user's sessions
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('userId', '==', userId)
.get();
const linked: any[] = [];
const unlinked: any[] = [];
sessionsSnapshot.docs.forEach(doc => {
const data = doc.data();
const sessionInfo = {
id: doc.id,
workspaceName: data.workspaceName || 'Unknown',
workspacePath: data.workspacePath,
projectId: data.projectId,
needsProjectAssociation: data.needsProjectAssociation,
createdAt: data.createdAt?.toDate?.() || data.createdAt,
};
if (data.projectId) {
linked.push(sessionInfo);
} else {
unlinked.push(sessionInfo);
}
});
// Get all user's projects
const projectsSnapshot = await adminDb
.collection('projects')
.where('userId', '==', userId)
.get();
const projects = projectsSnapshot.docs.map(doc => ({
id: doc.id,
name: doc.data().productName || doc.data().name,
workspacePath: doc.data().workspacePath,
}));
return NextResponse.json({
summary: {
totalSessions: sessionsSnapshot.size,
linkedSessions: linked.length,
unlinkedSessions: unlinked.length,
totalProjects: projects.length,
},
linked,
unlinked,
projects,
});
} catch (error) {
console.error('Debug check error:', error);
return NextResponse.json(
{
error: 'Failed to check links',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,62 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json(
{ error: 'Missing projectId parameter' },
{ status: 400 }
);
}
// Check if project exists
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists) {
// List all projects to help debug
const allProjectsSnapshot = await adminDb.collection('projects').limit(20).get();
const allProjects = allProjectsSnapshot.docs.map(doc => ({
id: doc.id,
name: doc.data().name,
userId: doc.data().userId,
createdAt: doc.data().createdAt
}));
return NextResponse.json({
exists: false,
projectId,
message: 'Project not found',
availableProjects: allProjects
});
}
const projectData = projectDoc.data();
return NextResponse.json({
exists: true,
projectId,
project: {
name: projectData?.name,
userId: projectData?.userId,
createdAt: projectData?.createdAt,
githubRepo: projectData?.githubRepo
}
});
} catch (error) {
console.error('Error checking project:', error);
return NextResponse.json(
{ error: 'Failed to check project', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,44 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const projectId = url.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const adminDb = getAdminDb();
// Get contextSources subcollection
const contextSourcesRef = adminDb
.collection('projects')
.doc(projectId)
.collection('contextSources');
const snapshot = await contextSourcesRef.get();
const sources = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
}));
return NextResponse.json({
projectId,
count: sources.length,
sources,
});
} catch (error) {
console.error('[debug/context-sources] Error:', error);
return NextResponse.json(
{
error: 'Failed to fetch context sources',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,72 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get workspace metadata
const workspaceMetaDoc = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorData')
.doc('workspace-meta')
.get();
const workspaceMeta = workspaceMetaDoc.exists ? workspaceMetaDoc.data() : null;
// Get all conversations
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
// Analyze by score
const negative = conversationsSnapshot.docs
.map(doc => ({ name: doc.data().name, score: doc.data().relevanceScore || 0 }))
.filter(c => c.score < 0)
.sort((a, b) => a.score - b.score);
const positive = conversationsSnapshot.docs
.map(doc => ({ name: doc.data().name, score: doc.data().relevanceScore || 0 }))
.filter(c => c.score > 0)
.sort((a, b) => b.score - a.score);
const neutral = conversationsSnapshot.docs
.filter(doc => (doc.data().relevanceScore || 0) === 0)
.length;
return NextResponse.json({
workspacePath: workspaceMeta?.workspacePath,
githubUrl: workspaceMeta?.githubUrl,
workspaceFilesCount: workspaceMeta?.workspaceFiles?.length || 0,
workspaceFilesSample: (workspaceMeta?.workspaceFiles || []).slice(0, 20),
totalGenerations: workspaceMeta?.totalGenerations || 0,
totalConversations: conversationsSnapshot.size,
scoreBreakdown: {
negative: negative.length,
neutral,
positive: positive.length
},
negativeScoreConversations: negative,
topPositiveConversations: positive.slice(0, 10),
sampleNeutralConversations: conversationsSnapshot.docs
.filter(doc => (doc.data().relevanceScore || 0) === 0)
.slice(0, 10)
.map(doc => doc.data().name)
});
} catch (error) {
console.error('Error analyzing conversations:', error);
return NextResponse.json(
{ error: 'Failed to analyze', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,72 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get 5 random conversations with content
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.limit(5)
.get();
const samples = conversationsSnapshot.docs.map(doc => {
const data = doc.data();
return {
name: data.name,
messageCount: data.messageCount || 0,
promptCount: data.prompts?.length || 0,
generationCount: data.generations?.length || 0,
filesCount: data.files?.length || 0,
sampleFiles: (data.files || []).slice(0, 3),
samplePrompt: data.prompts?.[0]?.text?.substring(0, 100) || 'none',
hasContent: !!(data.prompts?.length || data.generations?.length)
};
});
// Get overall stats
const allConversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
let totalWithContent = 0;
let totalWithFiles = 0;
let totalMessages = 0;
allConversationsSnapshot.docs.forEach(doc => {
const data = doc.data();
if (data.prompts?.length || data.generations?.length) {
totalWithContent++;
}
if (data.files?.length) {
totalWithFiles++;
}
totalMessages += data.messageCount || 0;
});
return NextResponse.json({
totalConversations: allConversationsSnapshot.size,
totalWithContent,
totalWithFiles,
totalMessages,
samples
});
} catch (error) {
console.error('Error fetching content sample:', error);
return NextResponse.json(
{ error: 'Failed to fetch sample', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,55 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get cursor conversations for this project
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'desc')
.get();
const conversations = conversationsSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
// Get the messages data
const messagesDoc = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorData')
.doc('messages')
.get();
const messagesData = messagesDoc.exists ? messagesDoc.data() : null;
return NextResponse.json({
projectId,
conversationCount: conversations.length,
conversations,
messagesData: messagesData ? {
promptCount: messagesData.prompts?.length || 0,
generationCount: messagesData.generations?.length || 0,
importedAt: messagesData.importedAt
} : null
});
} catch (error) {
console.error('Error fetching cursor conversations:', error);
return NextResponse.json(
{ error: 'Failed to fetch conversations', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
const minScore = parseInt(request.nextUrl.searchParams.get('minScore') || '0');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
const conversations = conversationsSnapshot.docs
.map(doc => {
const data = doc.data();
return {
name: data.name,
relevanceScore: data.relevanceScore || 0,
createdAt: data.createdAt,
workspacePath: data.workspacePath
};
})
.filter(c => c.relevanceScore >= minScore)
.sort((a, b) => b.relevanceScore - a.relevanceScore);
// Group by score
const scoreGroups: Record<number, number> = {};
conversationsSnapshot.docs.forEach(doc => {
const score = doc.data().relevanceScore || 0;
scoreGroups[score] = (scoreGroups[score] || 0) + 1;
});
return NextResponse.json({
totalConversations: conversationsSnapshot.size,
filteredConversations: conversations.length,
minScore,
scoreDistribution: scoreGroups,
conversations: conversations.slice(0, 50) // First 50
});
} catch (error) {
console.error('Error fetching relevant conversations:', error);
return NextResponse.json(
{ error: 'Failed to fetch conversations', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get 10 conversations with their dates
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'desc')
.limit(10)
.get();
const samples = conversationsSnapshot.docs.map(doc => {
const data = doc.data();
return {
name: data.name,
createdAt: data.createdAt,
workspacePath: data.workspacePath
};
});
return NextResponse.json({ samples });
} catch (error) {
console.error('Error fetching samples:', error);
return NextResponse.json(
{ error: 'Failed to fetch samples', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,55 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations sorted by time
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'asc')
.get();
const conversations = conversationsSnapshot.docs.map(doc => {
const data = doc.data();
return {
name: data.name,
createdAt: new Date(data.createdAt),
score: data.relevanceScore || 0
};
});
// Find NHL work sessions (negative scores clustered in time)
const nhlConversations = conversations.filter(c => c.score < 0);
return NextResponse.json({
totalConversations: conversations.length,
nhlConversations: nhlConversations.length,
nhlDates: nhlConversations.map(c => ({
date: c.createdAt.toISOString().split('T')[0],
name: c.name
})),
// Find if NHL conversations cluster
nhlDateCounts: nhlConversations.reduce((acc: any, c) => {
const date = c.createdAt.toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {})
});
} catch (error) {
console.error('Error analyzing session summary:', error);
return NextResponse.json(
{ error: 'Failed to analyze', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,124 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
const sessionGapMinutes = parseInt(request.nextUrl.searchParams.get('gap') || '120'); // 2 hours default
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations sorted by time
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'asc')
.get();
const conversations = conversationsSnapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
name: data.name,
createdAt: new Date(data.createdAt),
relevanceScore: data.relevanceScore || 0
};
});
// Group into sessions based on time gaps
const sessions: any[] = [];
let currentSession: any = null;
for (const conv of conversations) {
if (!currentSession) {
// Start first session
currentSession = {
startTime: conv.createdAt,
endTime: conv.createdAt,
conversations: [conv],
relevanceScores: [conv.relevanceScore]
};
} else {
// Check time gap from last conversation
const gapMs = conv.createdAt.getTime() - currentSession.endTime.getTime();
const gapMinutes = gapMs / (1000 * 60);
if (gapMinutes <= sessionGapMinutes) {
// Same session
currentSession.conversations.push(conv);
currentSession.relevanceScores.push(conv.relevanceScore);
currentSession.endTime = conv.createdAt;
} else {
// New session - close current and start new
sessions.push(currentSession);
currentSession = {
startTime: conv.createdAt,
endTime: conv.createdAt,
conversations: [conv],
relevanceScores: [conv.relevanceScore]
};
}
}
}
// Add last session
if (currentSession) {
sessions.push(currentSession);
}
// Analyze each session
const analyzedSessions = sessions.map((session, idx) => {
const durationMinutes = Math.round((session.endTime.getTime() - session.startTime.getTime()) / (1000 * 60));
// Calculate session relevance score (average of all conversations)
const avgScore = session.relevanceScores.reduce((a: number, b: number) => a + b, 0) / session.relevanceScores.length;
// Count negative/positive conversations
const negative = session.relevanceScores.filter((s: number) => s < 0).length;
const positive = session.relevanceScores.filter((s: number) => s > 0).length;
const neutral = session.relevanceScores.filter((s: number) => s === 0).length;
// Determine likely project based on majority
let likelyProject = 'unknown';
if (negative > positive && negative > neutral) {
likelyProject = 'other (NHL/market)';
} else if (positive > negative && positive > neutral) {
likelyProject = 'vibn (likely)';
} else if (positive > 0 || avgScore > 0) {
likelyProject = 'vibn (mixed)';
} else {
likelyProject = 'unclear';
}
return {
sessionNumber: idx + 1,
startTime: session.startTime.toISOString(),
endTime: session.endTime.toISOString(),
durationMinutes,
conversationCount: session.conversations.length,
avgRelevanceScore: Math.round(avgScore * 100) / 100,
scoreBreakdown: { negative, neutral, positive },
likelyProject,
conversationNames: session.conversations.slice(0, 5).map((c: any) => c.name)
};
});
return NextResponse.json({
totalConversations: conversations.length,
totalSessions: sessions.length,
sessionGapMinutes,
sessions: analyzedSessions
});
} catch (error) {
console.error('Error analyzing sessions:', error);
return NextResponse.json(
{ error: 'Failed to analyze sessions', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,69 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
const conversations = conversationsSnapshot.docs.map(doc => doc.data());
// Find date range
const dates = conversations
.filter(c => c.createdAt)
.map(c => new Date(c.createdAt))
.sort((a, b) => a.getTime() - b.getTime());
if (dates.length === 0) {
return NextResponse.json({ error: 'No conversations with dates found' });
}
const earliest = dates[0];
const latest = dates[dates.length - 1];
const span = Math.floor((latest.getTime() - earliest.getTime()) / (1000 * 60 * 60 * 24));
// Find the actual conversation names for earliest and latest
const earliestConv = conversations.find(c =>
new Date(c.createdAt).getTime() === earliest.getTime()
);
const latestConv = conversations.find(c =>
new Date(c.createdAt).getTime() === latest.getTime()
);
return NextResponse.json({
totalConversations: conversations.length,
dateRange: {
earliest: earliest.toISOString(),
latest: latest.toISOString(),
spanDays: span
},
oldestConversation: {
name: earliestConv?.name || 'Unknown',
date: earliest.toISOString()
},
newestConversation: {
name: latestConv?.name || 'Unknown',
date: latest.toISOString()
}
});
} catch (error) {
console.error('Error fetching cursor stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,59 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all unknown conversations grouped by session
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.where('sessionProject', '==', 'unknown')
.get();
const sessionMap: Record<number, any[]> = {};
conversationsSnapshot.docs.forEach(doc => {
const data = doc.data();
const sessionId = data.sessionId;
if (!sessionMap[sessionId]) {
sessionMap[sessionId] = [];
}
sessionMap[sessionId].push({
name: data.name,
date: data.sessionDate,
createdAt: data.createdAt
});
});
// Convert to array and take sample
const sessions = Object.entries(sessionMap).map(([sessionId, conversations]) => ({
sessionId: parseInt(sessionId),
date: conversations[0].date,
conversationCount: conversations.length,
conversationNames: conversations.map(c => c.name)
}));
return NextResponse.json({
totalUnknownSessions: sessions.length,
totalUnknownConversations: conversationsSnapshot.size,
sample: sessions.slice(0, 30)
});
} catch (error) {
console.error('Error fetching unknown sessions:', error);
return NextResponse.json(
{ error: 'Failed to fetch', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,59 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
const conversations = conversationsSnapshot.docs.map(doc => doc.data());
// Group by workspace path
const workspaceGroups: Record<string, number> = {};
const githubGroups: Record<string, number> = {};
conversations.forEach(conv => {
const workspace = conv.workspacePath || 'unknown';
const github = conv.githubUrl || 'none';
workspaceGroups[workspace] = (workspaceGroups[workspace] || 0) + 1;
githubGroups[github] = (githubGroups[github] || 0) + 1;
});
// Sort by count
const workspaceList = Object.entries(workspaceGroups)
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count);
const githubList = Object.entries(githubGroups)
.map(([url, count]) => ({ url, count }))
.sort((a, b) => b.count - a.count);
return NextResponse.json({
totalConversations: conversations.length,
uniqueWorkspaces: workspaceList.length,
uniqueRepos: githubList.length,
workspaces: workspaceList,
repos: githubList
});
} catch (error) {
console.error('Error fetching workspace breakdown:', error);
return NextResponse.json(
{ error: 'Failed to fetch breakdown', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,18 +0,0 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
firebaseProjectId: process.env.FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
firebaseClientEmail: process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET',
firebasePrivateKey: process.env.FIREBASE_PRIVATE_KEY ? 'SET (length: ' + process.env.FIREBASE_PRIVATE_KEY.length + ')' : 'NOT SET',
publicApiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY ? 'SET' : 'NOT SET',
publicAuthDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN ? 'SET' : 'NOT SET',
publicProjectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
nodeEnv: process.env.NODE_ENV,
tip: 'If any Firebase vars show NOT SET, restart your dev server after updating .env.local'
});
}

View File

@@ -1,33 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET() {
try {
const adminDb = getAdminDb();
const snapshot = await adminDb.collection('projects').limit(1).get();
if (snapshot.empty) {
return NextResponse.json(
{ error: 'No projects found' },
{ status: 404 },
);
}
const doc = snapshot.docs[0];
return NextResponse.json({
id: doc.id,
data: doc.data(),
});
} catch (error) {
console.error('[debug/first-project] Failed to load project', error);
return NextResponse.json(
{
error: 'Failed to load project',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,43 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const projectId = url.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const adminDb = getAdminDb();
// Get knowledge_items collection
const knowledgeItemsRef = adminDb.collection('knowledge_items');
const snapshot = await knowledgeItemsRef
.where('projectId', '==', projectId)
.limit(20)
.get();
const items = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
}));
return NextResponse.json({
projectId,
count: items.length,
items,
});
} catch (error) {
console.error('[debug/knowledge-items] Error:', error);
return NextResponse.json(
{
error: 'Failed to fetch knowledge items',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,36 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const projectId = (url.searchParams.get('projectId') ?? '').trim();
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const adminDb = getAdminDb();
// Use a simple filter query without orderBy to avoid requiring a composite index.
const snapshot = await adminDb
.collection('knowledge_items')
.where('projectId', '==', projectId)
.get();
const items = snapshot.docs.map((doc) => doc.data());
return NextResponse.json({ count: items.length, items });
} catch (error) {
console.error('[debug/knowledge] Failed to list knowledge items', error);
return NextResponse.json(
{
error: 'Failed to list knowledge items',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,40 +0,0 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
/**
* Dev-only: verifies Prisma can connect (NextAuth adapter needs this after Google redirects back).
* Open GET /api/debug/prisma while running next dev.
*/
export async function GET() {
if (process.env.NODE_ENV !== "development") {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const hasUrl = Boolean(process.env.DATABASE_URL?.trim() || process.env.POSTGRES_URL?.trim());
const prisma = new PrismaClient();
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
ok: true,
databaseUrlConfigured: hasUrl,
hint: "Prisma connects; if auth still fails, check Google client id/secret and terminal [next-auth] logs.",
});
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
const publicHost = /Can't reach database server at `([\d.]+):(\d+)`/.exec(message);
const hint = publicHost
? `No TCP route to Postgres at ${publicHost[1]}:${publicHost[2]} from this machine. In Coolify: confirm the DB service publishes that host port and Postgres listens on 0.0.0.0. On the cloud firewall (e.g. GCP), allow inbound TCP ${publicHost[2]} from your IP (or use VPN). Test: nc -zv ${publicHost[1]} ${publicHost[2]} or psql. Then npm run db:push from vibn-frontend.`
: "If the URL uses a Coolify internal hostname, it only works inside Docker. Otherwise check DATABASE_URL, firewall, and run npm run db:push.";
return NextResponse.json(
{
ok: false,
databaseUrlConfigured: hasUrl,
message,
hint,
},
{ status: 500 }
);
} finally {
await prisma.$disconnect().catch(() => {});
}
}

View File

@@ -1,61 +0,0 @@
import { NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
const diagnostics: any = {
timestamp: new Date().toISOString(),
environment: {},
firebase: {},
token: {},
};
try {
// Check environment variables
diagnostics.environment = {
FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
FIREBASE_CLIENT_EMAIL: process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET',
FIREBASE_PRIVATE_KEY: process.env.FIREBASE_PRIVATE_KEY ? `SET (${process.env.FIREBASE_PRIVATE_KEY.length} chars)` : 'NOT SET',
NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID || 'NOT SET',
};
// Test Firebase Admin
try {
const testDoc = await adminDb.collection('test').doc('diagnostic').get();
diagnostics.firebase.adminDb = 'OK - Can access Firestore';
diagnostics.firebase.adminAuth = 'OK - Auth service initialized';
} catch (error: any) {
diagnostics.firebase.error = error.message;
}
// Try to verify a token if provided
const authHeader = request.headers.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
diagnostics.token.received = true;
diagnostics.token.length = token.length;
try {
const decodedToken = await adminAuth.verifyIdToken(token);
diagnostics.token.verification = 'SUCCESS';
diagnostics.token.uid = decodedToken.uid;
diagnostics.token.email = decodedToken.email;
} catch (error: any) {
diagnostics.token.verification = 'FAILED';
diagnostics.token.error = error.message;
diagnostics.token.errorCode = error.code;
}
} else {
diagnostics.token.received = false;
diagnostics.token.note = 'No token provided - add Authorization: Bearer <token> header to test';
}
return NextResponse.json(diagnostics, { status: 200 });
} catch (error: any) {
diagnostics.criticalError = {
message: error.message,
stack: error.stack,
};
return NextResponse.json(diagnostics, { status: 500 });
}
}

View File

@@ -1,58 +0,0 @@
import { NextResponse } from 'next/server';
import { adminDb, adminAuth } from '@/lib/firebase/admin';
export async function GET() {
try {
// Test 1: Check if Firebase Admin is initialized
if (!adminDb) {
return NextResponse.json(
{ error: 'Firebase Admin not initialized' },
{ status: 500 }
);
}
// Test 2: Try to access Firestore (this will verify credentials)
const testCollection = adminDb.collection('_healthcheck');
const timestamp = new Date().toISOString();
// Write a test document
const docRef = await testCollection.add({
message: 'Firebase connection test',
timestamp: timestamp,
});
// Read it back
const doc = await docRef.get();
const data = doc.data();
// Clean up
await docRef.delete();
// Test 3: Check Auth is working
const authCheck = adminAuth ? 'OK' : 'Failed';
return NextResponse.json({
success: true,
message: 'Firebase is connected successfully! 🎉',
tests: {
adminInit: 'OK',
firestoreWrite: 'OK',
firestoreRead: 'OK',
authInit: authCheck,
},
projectId: process.env.FIREBASE_PROJECT_ID,
testData: data,
});
} catch (error) {
console.error('Firebase test error:', error);
return NextResponse.json(
{
error: 'Firebase connection failed',
details: error instanceof Error ? error.message : String(error),
tip: 'Check your .env.local file for correct FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, and FIREBASE_PRIVATE_KEY',
},
{ status: 500 }
);
}
}

View File

@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest, NextResponse } from "next/server";
import {
isCoolifyInfraOperational,
runCoolifyInfraHealthProbe,
} from '@/lib/server/infra-coolify-health';
} from "@/lib/server/infra-coolify-health";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
/**
* Authenticated infrastructure probe for Coolify API + SSH→Docker.
@@ -23,20 +24,22 @@ export async function GET(req: NextRequest) {
{
ok: false,
error:
'INFRA_HEALTH_SECRET is not set — configure it on vibn-frontend to enable this probe.',
"INFRA_HEALTH_SECRET is not set — configure it on vibn-frontend to enable this probe.",
},
{ status: 503 },
);
}
const auth = req.headers.get('authorization');
const bearer =
auth?.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const headerSecret = req.headers.get('x-vibn-infra-secret')?.trim() ?? '';
const auth = req.headers.get("authorization");
const bearer = auth?.startsWith("Bearer ") ? auth.slice(7).trim() : "";
const headerSecret = req.headers.get("x-vibn-infra-secret")?.trim() ?? "";
const token = bearer || headerSecret;
if (token !== secret) {
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
if (!token || !timingSafeStringEq(secret, token)) {
return NextResponse.json(
{ ok: false, error: "Unauthorized" },
{ status: 401 },
);
}
try {

View File

@@ -0,0 +1,85 @@
/**
* GET /api/invites/[token] — validate (public, used by /auth page)
* POST /api/invites/[token]/redeem — consume (called on sign-up completion)
*/
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db-postgres";
import { authSession } from "@/lib/auth/session-server";
interface InviteRow {
token: string;
email: string | null;
max_uses: number;
use_count: number;
expires_at: string | null;
redeemed_by: string[];
}
async function validateInvite(
token: string,
): Promise<{ valid: boolean; reason?: string; row?: InviteRow }> {
const row = await queryOne<InviteRow>(
`SELECT token, email, max_uses, use_count, expires_at, redeemed_by
FROM invites WHERE token = $1`,
[token],
);
if (!row) return { valid: false, reason: "Token not found" };
if (row.use_count >= row.max_uses)
return { valid: false, reason: "Token already fully redeemed" };
if (row.expires_at && new Date(row.expires_at) < new Date())
return { valid: false, reason: "Token expired" };
return { valid: true, row };
}
/** GET /api/invites/[token] — check if a token is valid (used by auth page UI) */
export async function GET(
_req: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const { token } = await params;
const { valid, reason, row } = await validateInvite(token);
if (!valid) {
return NextResponse.json({ valid: false, reason }, { status: 400 });
}
return NextResponse.json({
valid: true,
email: row!.email ?? null,
usesRemaining: row!.max_uses - row!.use_count,
});
}
/** POST /api/invites/[token] — redeem (call after a user signs up) */
export async function POST(
_req: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { token } = await params;
const { valid, reason, row } = await validateInvite(token);
if (!valid) {
return NextResponse.json({ ok: false, reason }, { status: 400 });
}
const email = session.user.email;
if (row!.email && row!.email !== email) {
return NextResponse.json(
{ ok: false, reason: "Token is for a different email address" },
{ status: 403 },
);
}
if (row!.redeemed_by.includes(email)) {
return NextResponse.json(
{ ok: true, alreadyRedeemed: true },
);
}
await query(
`UPDATE invites
SET use_count = use_count + 1,
redeemed_by = array_append(redeemed_by, $2)
WHERE token = $1`,
[token, email],
);
return NextResponse.json({ ok: true, redeemed: true });
}

View File

@@ -0,0 +1,90 @@
/**
* POST /api/invites
*
* Admin-only. Creates an invite token and returns the invite URL.
* Closes BETA_LAUNCH_PLAN P4.8.
*
* Body: { email?: string, note?: string, maxUses?: number }
* Auth: Bearer ADMIN_MIGRATE_SECRET
*
* curl -X POST https://vibnai.com/api/invites \
* -H "x-admin-secret: $ADMIN_MIGRATE_SECRET" \
* -d '{"email":"friend@example.com","note":"beta tester"}'
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { withAdminSecret } from "@/lib/server/api-handler";
import { randomBytes } from "crypto";
let tableReady = false;
async function ensureTable() {
if (tableReady) return;
await query(`
CREATE TABLE IF NOT EXISTS invites (
token TEXT PRIMARY KEY,
email TEXT,
note TEXT,
max_uses INT NOT NULL DEFAULT 1,
use_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
redeemed_by TEXT[] NOT NULL DEFAULT '{}'
)
`);
await query(`CREATE INDEX IF NOT EXISTS invites_email_idx ON invites (email) WHERE email IS NOT NULL`);
tableReady = true;
}
export const POST = withAdminSecret(
async (request) => {
await ensureTable();
const body = await request.json().catch(() => ({})) as {
email?: string;
note?: string;
maxUses?: number;
expiresInDays?: number;
};
const token = randomBytes(20).toString("hex");
const maxUses = Math.max(1, Math.min(100, body.maxUses ?? 1));
const expiresAt = body.expiresInDays
? new Date(Date.now() + body.expiresInDays * 86_400_000).toISOString()
: null;
await query(
`INSERT INTO invites (token, email, note, max_uses, expires_at)
VALUES ($1, $2, $3, $4, $5)`,
[token, body.email ?? null, body.note ?? null, maxUses, expiresAt],
);
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL ||
process.env.NEXTAUTH_URL ||
"https://vibnai.com";
return NextResponse.json({
ok: true,
token,
inviteUrl: `${baseUrl}/auth?invite=${token}`,
email: body.email ?? null,
maxUses,
expiresAt,
});
},
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
);
export const GET = withAdminSecret(
async (request) => {
await ensureTable();
const { searchParams } = new URL(request.url);
const limit = Math.min(200, parseInt(searchParams.get("limit") ?? "50", 10));
const rows = await query(
`SELECT token, email, note, max_uses, use_count, created_at, expires_at, redeemed_by
FROM invites ORDER BY created_at DESC LIMIT $1`,
[limit],
);
return NextResponse.json({ invites: rows });
},
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
);

View File

@@ -0,0 +1,50 @@
/**
* DELETE /api/projects/[projectId]/secrets/[key]
* GET /api/projects/[projectId]/secrets/[key] — reveal (decrypted)
*
* Reveal is intentionally project-scoped (user must own the project).
* Never log the plaintext value.
*/
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db-postgres";
import { withTenantProject } from "@/lib/server/api-handler";
import { decryptSecret } from "@/lib/auth/secret-box";
import { log } from "@/lib/server/logger";
export const DELETE = withTenantProject(
async (_req, ctx, { project }) => {
const { key } = await (ctx.params as Promise<{ projectId: string; key: string }>);
await query(
`DELETE FROM fs_project_secrets WHERE project_id = $1 AND key = $2`,
[project.id, key],
);
log.info("project secret deleted", {
route: "api.projects.secrets.delete",
projectId: project.id,
key,
});
return NextResponse.json({ ok: true });
},
);
export const GET = withTenantProject(
async (_req, ctx, { project }) => {
const { key } = await (ctx.params as Promise<{ projectId: string; key: string }>);
const row = await queryOne<{ value_enc: string }>(
`SELECT value_enc FROM fs_project_secrets WHERE project_id = $1 AND key = $2`,
[project.id, key],
);
if (!row) {
return NextResponse.json({ error: "Secret not found" }, { status: 404 });
}
try {
const value = decryptSecret(row.value_enc);
return NextResponse.json({ key, value });
} catch {
return NextResponse.json(
{ error: "Decryption failed — VIBN_SECRETS_KEY may have rotated" },
{ status: 500 },
);
}
},
);

View File

@@ -0,0 +1,91 @@
/**
* Project-level encrypted secret scratchpad.
* Closes BETA_LAUNCH_PLAN P6.D2.
*
* GET /api/projects/[projectId]/secrets — list key names only (never values)
* POST /api/projects/[projectId]/secrets — set/update a secret { key, value }
*
* Values are encrypted at-rest with AES-256-GCM via the existing
* `lib/auth/secret-box.ts` (same envelope used for workspace API keys and
* Gitea bot PATs). The plaintext value is NEVER returned by the list route.
* Use a dedicated GET /secrets/[key] route (below) if you need to surface
* it to the AI.
*
* Table created lazily on first write.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { withTenantProject } from "@/lib/server/api-handler";
import { encryptSecret } from "@/lib/auth/secret-box";
import { log } from "@/lib/server/logger";
let tableReady = false;
async function ensureTable() {
if (tableReady) return;
await query(`
CREATE TABLE IF NOT EXISTS fs_project_secrets (
project_id TEXT NOT NULL,
key TEXT NOT NULL,
value_enc TEXT NOT NULL, -- AES-256-GCM encrypted, base64
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (project_id, key)
)
`);
await query(
`CREATE INDEX IF NOT EXISTS fs_project_secrets_project_idx ON fs_project_secrets (project_id)`,
);
tableReady = true;
}
/** GET — returns key names only, never values. */
export const GET = withTenantProject(
async (_req, _ctx, { project }) => {
await ensureTable();
const rows = await query<{ key: string; updated_at: string }>(
`SELECT key, updated_at FROM fs_project_secrets
WHERE project_id = $1 ORDER BY key`,
[project.id],
);
return NextResponse.json({ secrets: rows });
},
);
/** POST — upsert a secret. Body: { key: string, value: string } */
export const POST = withTenantProject(
async (req, _ctx, { project }) => {
const body = await req.json().catch(() => ({})) as {
key?: string;
value?: string;
};
if (!body.key || typeof body.key !== "string") {
return NextResponse.json({ error: "key is required" }, { status: 400 });
}
if (typeof body.value !== "string") {
return NextResponse.json({ error: "value is required" }, { status: 400 });
}
if (body.key.length > 200) {
return NextResponse.json(
{ error: "key must be ≤200 chars" },
{ status: 400 },
);
}
await ensureTable();
const valueEnc = encryptSecret(body.value);
await query(
`INSERT INTO fs_project_secrets (project_id, key, value_enc)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, key) DO UPDATE SET
value_enc = EXCLUDED.value_enc,
updated_at = NOW()`,
[project.id, body.key.trim(), valueEnc],
);
log.info("project secret upserted", {
route: "api.projects.secrets.post",
projectId: project.id,
key: body.key,
});
return NextResponse.json({ ok: true, key: body.key });
},
);

View File

@@ -1,21 +0,0 @@
/**
* Companion endpoint to /sentry-example-page. Throws on every call
* so the "Throw server error" button has something real to fail
* against. The thrown error propagates to instrumentation.ts'
* onRequestError hook and lands in Sentry as a server-side issue.
*/
export const dynamic = "force-dynamic";
class SentryExampleApiError extends Error {
constructor(message: string) {
super(message);
this.name = "SentryExampleApiError";
}
}
export function GET() {
throw new SentryExampleApiError(
"Sentry test (API route) — vibn-ai",
);
}

View File

@@ -1,37 +0,0 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/firebase/config';
import { adminAuth } from '@/lib/firebase/admin';
export async function GET() {
try {
// Get current user from client-side auth
const user = auth.currentUser;
if (!user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Get ID token
const token = await user.getIdToken();
console.log('Token length:', token.length);
console.log('User UID:', user.uid);
// Try to verify it with Admin SDK
const decodedToken = await adminAuth.verifyIdToken(token);
return NextResponse.json({
success: true,
clientUid: user.uid,
decodedUid: decodedToken.uid,
match: user.uid === decodedToken.uid,
});
} catch (error) {
console.error('Token verification error:', error);
return NextResponse.json({
error: 'Token verification failed',
details: error instanceof Error ? error.message : String(error),
}, { status: 500 });
}
}

View File

@@ -1,43 +1,90 @@
/**
* POST /api/webhooks/coolify?projectId={projectId}
*
* Receives deployment status events from Coolify.
* Updates the project's contextSnapshot.lastDeployment in Postgres.
* Receives deployment status events from Coolify and updates the project's
* contextSnapshot.lastDeployment in Postgres.
*
* Closes S-06: signature verification was missing. Coolify (≥4.0.0-beta.300)
* signs every webhook with HMAC-SHA256 of the raw body using the per-app
* `webhook_secret`. We now reject unsigned / mismatched requests.
*
* Configure: in Coolify, set the webhook target to
* https://vibnai.com/api/webhooks/coolify?projectId={projectId}
* AND set the per-app webhook secret to match `COOLIFY_WEBHOOK_SECRET`.
*/
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db-postgres';
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { verifyCoolifySignature } from "@/lib/server/coolify-webhook";
import { log } from "@/lib/server/logger";
const COOLIFY_WEBHOOK_SECRET = process.env.COOLIFY_WEBHOOK_SECRET ?? "";
export async function POST(request: NextRequest) {
const projectId = request.nextUrl.searchParams.get('projectId');
const projectId = request.nextUrl.searchParams.get("projectId");
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
return NextResponse.json({ error: "Missing projectId" }, { status: 400 });
}
let payload: any;
if (!COOLIFY_WEBHOOK_SECRET) {
log.error("coolify webhook: COOLIFY_WEBHOOK_SECRET not configured", {
route: "api.webhooks.coolify",
projectId,
});
return NextResponse.json(
{ error: "Webhook receiver not configured" },
{ status: 503 },
);
}
// We need the RAW body for HMAC verification — `.json()` would
// re-serialize and could change byte order. Read text first, then parse.
const rawBody = await request.text();
const signature = request.headers.get("x-coolify-signature-256");
const ok = await verifyCoolifySignature(
rawBody,
signature,
COOLIFY_WEBHOOK_SECRET,
);
if (!ok) {
log.warn("coolify webhook: invalid signature", {
route: "api.webhooks.coolify",
projectId,
hasHeader: !!signature,
});
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
let payload: Record<string, unknown>;
try {
payload = await request.json();
payload = JSON.parse(rawBody) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const rows = await query<{ id: string; data: any }>(
const rows = await query<{ id: string; data: Record<string, unknown> }>(
`SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
[projectId],
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const project = rows[0];
const existingSnapshot = project.data?.contextSnapshot ?? {};
const existingSnapshot =
(project.data?.contextSnapshot as Record<string, unknown>) ?? {};
// Coolify sends status events like: queued, in_progress, finished, failed, cancelled
const status = payload.status ?? payload.data?.status ?? 'unknown';
const applicationUuid = payload.application_uuid ?? payload.data?.application_uuid;
const deploymentUuid = payload.deployment_uuid ?? payload.data?.deployment_uuid;
const url = payload.fqdn ?? payload.data?.fqdn ?? null;
const data = (payload.data ?? {}) as Record<string, unknown>;
const status = (payload.status ?? data.status ?? "unknown") as string;
const applicationUuid = (payload.application_uuid ??
data.application_uuid) as string | undefined;
const deploymentUuid = (payload.deployment_uuid ?? data.deployment_uuid) as
| string
| undefined;
const url = (payload.fqdn ?? data.fqdn ?? null) as string | null;
const newSnapshot = {
...existingSnapshot,
@@ -51,12 +98,18 @@ export async function POST(request: NextRequest) {
updatedAt: new Date().toISOString(),
};
await query(`
UPDATE fs_projects
SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb)
WHERE id = $2
`, [JSON.stringify(newSnapshot), projectId]);
await query(
`UPDATE fs_projects
SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb)
WHERE id = $2`,
[JSON.stringify(newSnapshot), projectId],
);
console.log(`[webhook/coolify] deploy ${status} for project ${projectId}`);
log.info("coolify webhook received", {
route: "api.webhooks.coolify",
projectId,
status,
deploymentUuid,
});
return NextResponse.json({ ok: true, status, projectId });
}

View File

@@ -1,42 +1,65 @@
/**
* GET /api/work-completed?projectId=<uuid>&limit=<n>
*
* Returns the work_completed rows for a project the caller owns.
*
* Closes S-05: this route used to accept any projectId off the query string
* with no auth and silently fell back to `projectId = 1` on missing input.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import type { WorkCompleted } from "@/lib/types";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
export async function GET(request: Request) {
try {
export const GET = withTenantProject(
async (request) => {
const { searchParams } = new URL(request.url);
const projectId = searchParams.get("projectId");
const limit = searchParams.get("limit") || "20";
const workItems = await query<WorkCompleted>(
`SELECT
wc.*,
s.session_id,
s.primary_ai_model,
s.duration_minutes
FROM work_completed wc
LEFT JOIN sessions s ON wc.session_id = s.id
WHERE wc.project_id = $1
ORDER BY wc.completed_at DESC
LIMIT $2`,
[projectId || 1, limit],
if (!projectId) {
return NextResponse.json(
{ error: "projectId is required" },
{ status: 400 },
);
}
const limit = Math.min(
200,
Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10) || 20),
);
// Parse JSON fields
const parsedWork = workItems.map((item) => ({
...item,
files_modified:
typeof item.files_modified === "string"
? JSON.parse(item.files_modified as any)
: item.files_modified,
}));
return NextResponse.json(parsedWork);
} catch (error) {
console.error("Error fetching work completed:", error);
return NextResponse.json(
{ error: "Failed to fetch work completed" },
{ status: 500 },
);
}
}
try {
const workItems = await query<WorkCompleted>(
`SELECT
wc.*,
s.session_id,
s.primary_ai_model,
s.duration_minutes
FROM work_completed wc
LEFT JOIN sessions s ON wc.session_id = s.id
WHERE wc.project_id = $1
ORDER BY wc.completed_at DESC
LIMIT $2`,
[projectId, limit],
);
const parsedWork = workItems.map((item) => ({
...item,
files_modified:
typeof item.files_modified === "string"
? JSON.parse(item.files_modified)
: item.files_modified,
}));
return NextResponse.json(parsedWork);
} catch (err) {
log.error("work-completed: db error", {
route: "api.work-completed",
projectId,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{ error: "Failed to fetch work completed" },
{ status: 500 },
);
}
},
{ source: "search", paramName: "projectId" },
);