Files
vibn-agent-runner/vibn-frontend/components/project/use-anatomy.ts
mawkone 6b8862ef2b 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
2026-05-17 19:17:22 -07:00

187 lines
4.8 KiB
TypeScript

"use client";
/**
* Single-fetch anatomy hook shared by the Product / Hosting tabs.
* Hardened against silent failure: 10s timeout, error surfacing, and
* graceful unmount.
*/
import { useEffect, useState } from "react";
export interface Anatomy {
project: {
id: string;
name: string;
gitea?: string;
coolifyProjectUuid?: string;
};
codebasesReason?: "no_repo" | "empty_repo";
product: {
codebases: Array<{
id: string;
label: string;
path: string;
hint?: string;
}>;
images: Array<{
uuid: string;
name: string;
image: string;
version: string;
serviceType?: string;
status?: string;
}>;
};
hosting: {
live: Array<{
uuid: string;
name: string;
source: "repo" | "image";
sourceLabel: string;
status: string;
fqdn?: string;
domains: string[];
branch?: string;
buildPack?: string;
lastBuild?: { status: string; finishedAt?: string; commit?: string };
inFlightBuild?: { status: string; finishedAt?: string; commit?: string };
}>;
previews: Array<{
id: string;
name: string;
command?: string;
port: number;
url: string;
state: string;
startedAt: string;
}>;
};
infrastructure: {
databases: Array<{
uuid: string;
name: string;
type: string;
status: string;
isPublic: boolean;
publicPort?: number;
internalAddress?: string;
consumerEnvKey: string;
}>;
providers: Array<{
id: string;
category: "auth" | "email" | "payments" | "llm" | "storage";
vendor: string;
attachments: Array<{
resourceUuid: string;
resourceName: string;
resourceKind: "app" | "service";
keys: string[];
}>;
}>;
bundledStorage: {
status: "ready" | "pending" | "partial" | "error" | "unprovisioned";
bucketName?: string;
hmacAccessId?: string;
region?: string;
errorMessage?: string;
};
secrets: {
total: number;
byResource: Array<{
resourceUuid: string;
resourceName: string;
resourceKind: "app" | "service";
count: number;
keys: string[];
}>;
};
};
}
export interface UseAnatomyResult {
anatomy: Anatomy | null;
loading: boolean;
error: string | null;
reload: () => void;
}
export interface UseAnatomyOptions {
/** When set, re-fetch anatomy every N ms while the component is
* mounted. Used by the project-header status pill so it surfaces
* Coolify build state transitions live (e.g. queued → in_progress
* → success) without the user having to refresh. Pass undefined or
* 0 to disable polling. */
pollMs?: number;
}
export function useAnatomy(
projectId: string,
options: UseAnatomyOptions = {},
): UseAnatomyResult {
const { pollMs } = options;
const [anatomy, setAnatomy] = useState<Anatomy | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
// Background poll. We bump `tick` on an interval, which re-runs the
// fetch effect below. Skipping the timer entirely when pollMs is
// zero/undefined keeps the default render path identical to before.
useEffect(() => {
if (!pollMs || pollMs <= 0) return;
const id = setInterval(() => setTick((t) => t + 1), pollMs);
return () => clearInterval(id);
}, [pollMs]);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
setLoading(true);
setError(null);
fetch(`/api/projects/${projectId}/anatomy`, {
credentials: "include",
signal: controller.signal,
cache: "no-store",
})
.then(async (r) => {
let body: unknown = {};
try {
body = await r.json();
} catch {
/* keep {} */
}
if (!r.ok) {
const msg =
(body as { error?: string }).error ||
`HTTP ${r.status} ${r.statusText}`.trim();
throw new Error(msg);
}
return body as Anatomy;
})
.then((data) => {
if (!cancelled) setAnatomy(data);
})
.catch((err) => {
if (cancelled) return;
if (err?.name === "AbortError")
setError("Request timed out after 10s.");
else setError(err?.message || "Failed to load project anatomy");
})
.finally(() => {
clearTimeout(timeout);
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
controller.abort();
clearTimeout(timeout);
};
}, [projectId, tick]);
return { anatomy, loading, error, reload: () => setTick((t) => t + 1) };
}