This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
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

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),
};