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

@@ -30,24 +30,31 @@ interface ProjectStagePillProps {
}
type PillState =
| { kind: "build_failed"; reason: string }
| { kind: "deploying"; reason: string }
| { kind: "down"; reason: string }
| { kind: "live"; reason: string }
| { kind: "empty"; reason: string };
| { kind: "build_failed"; reason: string }
| { kind: "deploying"; reason: string }
| { kind: "down"; reason: string }
| { kind: "live"; reason: string }
| { kind: "empty"; reason: string };
export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillProps) {
export function ProjectStagePill({
projectId,
fallbackStage,
}: ProjectStagePillProps) {
const [anatomyPollMs, setAnatomyPollMs] = useState(0);
const { anatomy, loading } = useAnatomy(projectId, { pollMs: anatomyPollMs });
useEffect(() => {
if (!anatomy) {
setAnatomyPollMs(0);
// Don't call setState here if not needed
if (anatomyPollMs !== 0) setAnatomyPollMs(0);
return;
}
const s = derivePillState(anatomy);
setAnatomyPollMs(s.kind === "live" || s.kind === "empty" ? 0 : 8000);
}, [anatomy]);
const targetPollMs = s.kind === "live" || s.kind === "empty" ? 0 : 8000;
if (anatomyPollMs !== targetPollMs) {
setAnatomyPollMs(targetPollMs);
}
}, [anatomy, anatomyPollMs]);
const state = useMemo<PillState | null>(() => {
if (!anatomy) return null;
@@ -56,11 +63,25 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
if (loading && !anatomy) {
const f = FALLBACK_PRESETS[fallbackStage];
return <Pill label={f.label} color={f.color} bg={f.bg} title="Loading project status…" />;
return (
<Pill
label={f.label}
color={f.color}
bg={f.bg}
title="Loading project status…"
/>
);
}
if (!state) {
const f = FALLBACK_PRESETS[fallbackStage];
return <Pill label={f.label} color={f.color} bg={f.bg} title="Project status unavailable." />;
return (
<Pill
label={f.label}
color={f.color}
bg={f.bg}
title="Project status unavailable."
/>
);
}
const visual = VISUALS[state.kind];
@@ -110,9 +131,13 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
rel="noreferrer"
title={`Open Coolify build logs in a new tab`}
style={{
display: "inline-flex", alignItems: "center", gap: 3,
fontSize: "0.68rem", color: logsLinkColor,
textDecoration: "none", opacity: 0.8,
display: "inline-flex",
alignItems: "center",
gap: 3,
fontSize: "0.68rem",
color: logsLinkColor,
textDecoration: "none",
opacity: 0.8,
}}
>
Logs <ExternalLink size={9} />
@@ -137,7 +162,12 @@ function classifyAppStatus(raw?: string): AppPhase {
if (!s || s === "unknown") return "unknown";
if (/^(running|healthy)/.test(s)) return "up";
if (/healthy/.test(s) && !/unhealthy/.test(s)) return "up";
if (/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(s)) return "transient";
if (
/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(
s,
)
)
return "transient";
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
// Default to transient for anything unrecognised — Coolify occasionally
// emits novel phases during upgrades; better to wait than mis-flag red.
@@ -149,7 +179,10 @@ function derivePillState(a: Anatomy): PillState {
const live = a.hosting?.live ?? [];
if (live.length === 0) {
return { kind: "empty", reason: "No apps deployed yet. Use the chat to spin one up." };
return {
kind: "empty",
reason: "No apps deployed yet. Use the chat to spin one up.",
};
}
// 1. Active build in flight — highest priority signal.
@@ -157,12 +190,17 @@ function derivePillState(a: Anatomy): PillState {
if (deploying.length > 0) {
const names = deploying.map((l) => l.name).join(", ");
const stage = deploying[0].inFlightBuild?.status ?? "in progress";
return { kind: "deploying", reason: `Deploying ${names}\nCoolify status: ${stage}` };
return {
kind: "deploying",
reason: `Deploying ${names}\nCoolify status: ${stage}`,
};
}
// 2. Container is currently booting (starting / restarting). Surface
// as "Deploying" since to the user this is the same wait state.
const transient = live.filter((l) => classifyAppStatus(l.status) === "transient");
const transient = live.filter(
(l) => classifyAppStatus(l.status) === "transient",
);
if (transient.length > 0) {
const lines = transient.map((l) => `${l.name}: ${l.status}`);
return {
@@ -180,9 +218,14 @@ function derivePillState(a: Anatomy): PillState {
const lines = failed.map(
(l) =>
`${l.name}: ${l.lastBuild?.status}` +
(l.lastBuild?.finishedAt ? ` · ${relTime(l.lastBuild.finishedAt)}` : ""),
(l.lastBuild?.finishedAt
? ` · ${relTime(l.lastBuild.finishedAt)}`
: ""),
);
return { kind: "build_failed", reason: `Last deploy failed:\n${lines.join("\n")}` };
return {
kind: "build_failed",
reason: `Last deploy failed:\n${lines.join("\n")}`,
};
}
const phases = live.map((l) => classifyAppStatus(l.status));
@@ -196,10 +239,16 @@ function derivePillState(a: Anatomy): PillState {
};
}
if (upCount > 0) {
return { kind: "live", reason: `${upCount}/${live.length} services running.` };
return {
kind: "live",
reason: `${upCount}/${live.length} services running.`,
};
}
if (downCount > 0) {
const sample = live.slice(0, 3).map((l) => `${l.name}: ${l.status}`).join("\n");
const sample = live
.slice(0, 3)
.map((l) => `${l.name}: ${l.status}`)
.join("\n");
return { kind: "down", reason: `Apps are not running.\n${sample}` };
}
@@ -213,42 +262,69 @@ function derivePillState(a: Anatomy): PillState {
// ──────────────────────────────────────────────────
const VISUALS: Record<PillState["kind"], { label: string; color: string; bg: string }> = {
const VISUALS: Record<
PillState["kind"],
{ label: string; color: string; bg: string }
> = {
build_failed: { label: "Build failed", color: "#c5392b", bg: "#c5392b14" },
deploying: { label: "Deploying", color: "#3d5afe", bg: "#3d5afe10" },
down: { label: "Down", color: "#c5392b", bg: "#c5392b14" },
live: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
empty: { label: "Empty", color: "#7c7770", bg: "#a09a9014" },
deploying: { label: "Deploying", color: "#3d5afe", bg: "#3d5afe10" },
down: { label: "Down", color: "#c5392b", bg: "#c5392b14" },
live: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
empty: { label: "Empty", color: "#7c7770", bg: "#a09a9014" },
};
const FALLBACK_PRESETS: Record<
"discovery" | "architecture" | "building" | "active",
{ label: string; color: string; bg: string }
> = {
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" },
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" },
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
};
function Pill({
label, color, bg, title, spinning,
}: { label: string; color: string; bg: string; title?: string; spinning?: boolean }) {
label,
color,
bg,
title,
spinning,
}: {
label: string;
color: string;
bg: string;
title?: string;
spinning?: boolean;
}) {
return (
<span
title={title}
style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "4px 10px", borderRadius: 4,
fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.02em",
color, background: bg, whiteSpace: "nowrap",
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 10px",
borderRadius: 4,
fontSize: "0.7rem",
fontWeight: 600,
letterSpacing: "0.02em",
color,
background: bg,
whiteSpace: "nowrap",
cursor: title ? "help" : "default",
}}
>
{spinning ? (
<Loader2 size={9} className="animate-spin" style={{ color }} />
) : (
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: color,
}}
/>
)}
{label}
</span>

View File

@@ -9,10 +9,20 @@
import { useEffect, useState } from "react";
export interface Anatomy {
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
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 }>;
codebases: Array<{
id: string;
label: string;
path: string;
hint?: string;
}>;
images: Array<{
uuid: string;
name: string;
@@ -104,7 +114,10 @@ export interface UseAnatomyOptions {
pollMs?: number;
}
export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}): UseAnatomyResult {
export function useAnatomy(
projectId: string,
options: UseAnatomyOptions = {},
): UseAnatomyResult {
const { pollMs } = options;
const [anatomy, setAnatomy] = useState<Anatomy | null>(null);
const [loading, setLoading] = useState(true);
@@ -131,22 +144,30 @@ export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}):
fetch(`/api/projects/${projectId}/anatomy`, {
credentials: "include",
signal: controller.signal,
cache: "no-store",
})
.then(async r => {
.then(async (r) => {
let body: unknown = {};
try { body = await r.json(); } catch { /* keep {} */ }
try {
body = await r.json();
} catch {
/* keep {} */
}
if (!r.ok) {
const msg = (body as { error?: string }).error || `HTTP ${r.status} ${r.statusText}`.trim();
const msg =
(body as { error?: string }).error ||
`HTTP ${r.status} ${r.statusText}`.trim();
throw new Error(msg);
}
return body as Anatomy;
})
.then(data => {
.then((data) => {
if (!cancelled) setAnatomy(data);
})
.catch(err => {
.catch((err) => {
if (cancelled) return;
if (err?.name === "AbortError") setError("Request timed out after 10s.");
if (err?.name === "AbortError")
setError("Request timed out after 10s.");
else setError(err?.message || "Failed to load project anatomy");
})
.finally(() => {
@@ -161,5 +182,5 @@ export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}):
};
}, [projectId, tick]);
return { anatomy, loading, error, reload: () => setTick(t => t + 1) };
return { anatomy, loading, error, reload: () => setTick((t) => t + 1) };
}