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
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
/**
|
|
* Workspace autosave trigger.
|
|
*
|
|
* POST /api/admin/path-b/autosave
|
|
* Headers: Authorization: Bearer <NEXTAUTH_SECRET>
|
|
* Body: { projectId: string, projectSlug: string }
|
|
*
|
|
* Pushes /workspace inside the project's dev container to a
|
|
* `vibn-autosave/main` branch in Gitea. Throttled to once per 5 min
|
|
* per project so we don't hammer Gitea on every chat turn.
|
|
*
|
|
* Two intended callers:
|
|
* 1. Chat post-turn hook (best-effort fire-and-forget).
|
|
* 2. Cron sweep every 5 min as a backstop.
|
|
*
|
|
* The autosave branch is force-pushed; never collides with `main`.
|
|
* Treat this as a recovery point, not history — the user's real
|
|
* 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 { timingSafeStringEq } from "@/lib/server/timing-safe";
|
|
|
|
export async function POST(request: Request) {
|
|
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 });
|
|
}
|
|
|
|
// Single-project mode.
|
|
if (body.projectId) {
|
|
const projectId = String(body.projectId);
|
|
const row = await query<{ slug: string; data: any; workspace: string }>(
|
|
`SELECT slug, data, workspace FROM fs_projects WHERE id = $1 LIMIT 1`,
|
|
[projectId],
|
|
);
|
|
if (row.length === 0) {
|
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
|
}
|
|
const ws = await getOrCreateProvisionedWorkspace({
|
|
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 },
|
|
);
|
|
}
|
|
const result = await autosaveWorkspace({
|
|
projectId,
|
|
projectSlug: row[0].slug,
|
|
workspace: ws,
|
|
});
|
|
return NextResponse.json({ result });
|
|
}
|
|
|
|
// Sweep mode: autosave every project with a running dev container.
|
|
if (body.sweep) {
|
|
const rows = await query<{ project_id: string; workspace: string }>(
|
|
`SELECT project_id, workspace FROM fs_project_dev_containers WHERE state = 'running'`,
|
|
[],
|
|
);
|
|
const out: Array<{ projectId: string; ran: boolean; reason: string }> = [];
|
|
for (const r of rows) {
|
|
const proj = await query<{ slug: string; data: any }>(
|
|
`SELECT slug, data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
|
[r.project_id],
|
|
);
|
|
if (proj.length === 0) continue;
|
|
const ws = await getOrCreateProvisionedWorkspace({
|
|
userId: proj[0].data?.userId ?? "",
|
|
email: proj[0].data?.ownerEmail ?? "",
|
|
displayName: r.workspace,
|
|
}).catch(() => null);
|
|
if (!ws) continue;
|
|
const res = await autosaveWorkspace({
|
|
projectId: r.project_id,
|
|
projectSlug: proj[0].slug,
|
|
workspace: ws,
|
|
}).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 }" },
|
|
{ status: 400 },
|
|
);
|
|
}
|