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:
@@ -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>
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user