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
72 lines
2.8 KiB
TypeScript
72 lines
2.8 KiB
TypeScript
/**
|
|
* Structured logger for API handlers.
|
|
*
|
|
* Why this exists: `console.log` everywhere in 159 route files is impossible
|
|
* to grep through. With a request-scoped `turnId` (or any other correlation
|
|
* id), prod incidents go from "search by guess" to "ripgrep one ID, get the
|
|
* whole timeline."
|
|
*
|
|
* Output shape (single line, JSON, prod-friendly):
|
|
* {"ts":"2026-05-17T20:00:00.000Z","level":"info","route":"api.chat",
|
|
* "turnId":"…","projectId":"…","user":"mark@…","msg":"…","ctx":{…}}
|
|
*
|
|
* In dev, we prefix with a coloured tag and pretty-print `ctx` for eyeball-
|
|
* ability. In prod, single-line JSON so a log shipper can parse it.
|
|
*/
|
|
|
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
|
|
export interface LogContext {
|
|
route?: string;
|
|
turnId?: string;
|
|
projectId?: string;
|
|
workspaceSlug?: string;
|
|
user?: string;
|
|
userId?: string;
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
const isDev = process.env.NODE_ENV !== "production";
|
|
|
|
function emit(level: LogLevel, msg: string, ctx: LogContext = {}) {
|
|
const ts = new Date().toISOString();
|
|
const { route, turnId, projectId, workspaceSlug, user, userId, ...rest } = ctx;
|
|
const base = { ts, level, msg, route, turnId, projectId, workspaceSlug, user, userId };
|
|
if (isDev) {
|
|
const tag = level === "error" ? "✗" : level === "warn" ? "!" : level === "debug" ? "·" : "→";
|
|
const idStr = turnId ? ` [${turnId.slice(0, 8)}]` : "";
|
|
const routeStr = route ? ` ${route}` : "";
|
|
const extra = Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : "";
|
|
// eslint-disable-next-line no-console
|
|
console[level === "debug" ? "log" : level](`${tag}${routeStr}${idStr} ${msg}${extra}`);
|
|
return;
|
|
}
|
|
const line = JSON.stringify({ ...base, ...rest });
|
|
// eslint-disable-next-line no-console
|
|
(console[level === "debug" ? "log" : level] as (m: string) => void)(line);
|
|
}
|
|
|
|
/**
|
|
* Create a logger pre-bound to a request context. Pass through `info()` /
|
|
* `warn()` / `error()` / `debug()` and every line carries the same
|
|
* `turnId` / `projectId` / `route`.
|
|
*/
|
|
export function makeLogger(base: LogContext) {
|
|
return {
|
|
debug: (msg: string, ctx: LogContext = {}) => emit("debug", msg, { ...base, ...ctx }),
|
|
info: (msg: string, ctx: LogContext = {}) => emit("info", msg, { ...base, ...ctx }),
|
|
warn: (msg: string, ctx: LogContext = {}) => emit("warn", msg, { ...base, ...ctx }),
|
|
error: (msg: string, ctx: LogContext = {}) => emit("error", msg, { ...base, ...ctx }),
|
|
child(extra: LogContext) {
|
|
return makeLogger({ ...base, ...extra });
|
|
},
|
|
};
|
|
}
|
|
|
|
export const log = {
|
|
debug: (msg: string, ctx: LogContext = {}) => emit("debug", msg, ctx),
|
|
info: (msg: string, ctx: LogContext = {}) => emit("info", msg, ctx),
|
|
warn: (msg: string, ctx: LogContext = {}) => emit("warn", msg, ctx),
|
|
error: (msg: string, ctx: LogContext = {}) => emit("error", msg, ctx),
|
|
};
|