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
187 lines
4.8 KiB
TypeScript
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) };
|
|
}
|