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:
233
vibn-frontend/lib/server/api-handler.ts
Normal file
233
vibn-frontend/lib/server/api-handler.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* API route wrappers.
|
||||
*
|
||||
* Replaces the 8-line auth + ownership boilerplate that every route file
|
||||
* was reimplementing (and getting subtly wrong in 18 of them — see the
|
||||
* 2026-05-17 QA pass).
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* // Plain session-required handler.
|
||||
* export const GET = withAuth(async (req, ctx, { user }) => {
|
||||
* return NextResponse.json({ email: user.email });
|
||||
* });
|
||||
*
|
||||
* // Session + tenant-project ownership in one wrapper.
|
||||
* export const POST = withTenantProject(async (req, ctx, { user, project }) => {
|
||||
* // `project` is guaranteed to belong to `user`.
|
||||
* }, { paramName: 'projectId' });
|
||||
*
|
||||
* // Workspace-scoped (session OR vibn_sk_ api key OK).
|
||||
* export const POST = withWorkspace(async (req, ctx, { principal }) => {
|
||||
* // `principal.workspace` is guaranteed to be tenant-checked.
|
||||
* });
|
||||
*
|
||||
* // Admin secret (ops endpoint).
|
||||
* export const POST = withAdminSecret(async (req, ctx) => { … }, {
|
||||
* secretEnvVar: 'ADMIN_MIGRATE_SECRET',
|
||||
* });
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Session } from "next-auth";
|
||||
import { authSession } from "@/lib/auth/session-server";
|
||||
import { queryOne } from "@/lib/db-postgres";
|
||||
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
import { rateLimit, type RateLimitOpts } from "@/lib/server/rate-limit";
|
||||
|
||||
type WithAuthCtx = { user: NonNullable<Session["user"]> };
|
||||
type WithTenantProjectCtx = WithAuthCtx & { project: ProjectRow };
|
||||
type WithAdminCtx = { secret: string };
|
||||
|
||||
export interface ProjectRow {
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
type RouteHandler<TCtx = unknown, TParams = unknown> = (
|
||||
req: Request,
|
||||
ctx: { params: Promise<TParams> },
|
||||
extra: TCtx,
|
||||
) => Promise<Response> | Response;
|
||||
|
||||
// ─── withAuth ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function withAuth<TParams = unknown>(
|
||||
handler: RouteHandler<WithAuthCtx, TParams>,
|
||||
) {
|
||||
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return handler(req, ctx, { user: session.user });
|
||||
};
|
||||
}
|
||||
|
||||
// ─── withTenantProject ────────────────────────────────────────────────────
|
||||
|
||||
export interface WithTenantProjectOpts {
|
||||
/**
|
||||
* Where to find the project id. Default `'projectId'` in `params`.
|
||||
* - 'params:projectId' → ctx.params.projectId (default for `[projectId]` routes)
|
||||
* - 'search:projectId' → searchParams.projectId
|
||||
* - 'body:projectId' → body.projectId (consumes body via clone+json)
|
||||
*/
|
||||
source?: "params" | "search" | "body";
|
||||
paramName?: string;
|
||||
}
|
||||
|
||||
export function withTenantProject<TParams = Record<string, string>>(
|
||||
handler: RouteHandler<WithTenantProjectCtx, TParams>,
|
||||
opts: WithTenantProjectOpts = {},
|
||||
) {
|
||||
const source = opts.source ?? "params";
|
||||
const name = opts.paramName ?? "projectId";
|
||||
|
||||
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let projectId: string | undefined;
|
||||
if (source === "params") {
|
||||
const p = (await ctx.params) as Record<string, string>;
|
||||
projectId = p?.[name];
|
||||
} else if (source === "search") {
|
||||
projectId = new URL(req.url).searchParams.get(name) ?? undefined;
|
||||
} else if (source === "body") {
|
||||
try {
|
||||
const body = await req.clone().json();
|
||||
projectId = body?.[name];
|
||||
} catch {
|
||||
// fallthrough; caller will get 400 below
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: `${name} is required` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Ownership check: project must belong to the authenticated user.
|
||||
const row = await queryOne<ProjectRow>(
|
||||
`SELECT p.id::text AS id, p.data
|
||||
FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1::text AND u.data->>'email' = $2::text
|
||||
LIMIT 1`,
|
||||
[projectId, session.user.email],
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return handler(req, ctx, { user: session.user, project: row });
|
||||
};
|
||||
}
|
||||
|
||||
// ─── withWorkspace (re-export of existing helper with consistent shape) ──
|
||||
|
||||
type WorkspacePrincipal = Exclude<
|
||||
Awaited<ReturnType<typeof requireWorkspacePrincipal>>,
|
||||
NextResponse
|
||||
>;
|
||||
|
||||
export function withWorkspace<TParams = Record<string, string>>(
|
||||
handler: RouteHandler<{ principal: WorkspacePrincipal }, TParams>,
|
||||
opts: { paramName?: string } = {},
|
||||
) {
|
||||
const name = opts.paramName ?? "slug";
|
||||
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
||||
const params = (await ctx.params) as Record<string, string> | undefined;
|
||||
const targetSlug = params?.[name];
|
||||
const principal = await requireWorkspacePrincipal(req, { targetSlug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
return handler(req, ctx, { principal });
|
||||
};
|
||||
}
|
||||
|
||||
// ─── withAdminSecret ──────────────────────────────────────────────────────
|
||||
|
||||
export interface WithAdminSecretOpts {
|
||||
/** env var that holds the expected secret. */
|
||||
secretEnvVar: string;
|
||||
/** Header to read. Default `authorization` (expects `Bearer <secret>`). */
|
||||
header?: string;
|
||||
/** Alternate header that may also carry the secret (e.g. `x-admin-secret`). */
|
||||
altHeader?: string;
|
||||
}
|
||||
|
||||
export function withAdminSecret<TParams = unknown>(
|
||||
handler: RouteHandler<WithAdminCtx, TParams>,
|
||||
opts: WithAdminSecretOpts,
|
||||
) {
|
||||
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
||||
const expected = process.env[opts.secretEnvVar]?.trim() ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: `${opts.secretEnvVar} not configured — endpoint disabled` },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const header = (opts.header ?? "authorization").toLowerCase();
|
||||
const raw = req.headers.get(header) ?? "";
|
||||
const bearer = raw.toLowerCase().startsWith("bearer ")
|
||||
? raw.slice(7).trim()
|
||||
: "";
|
||||
const alt = opts.altHeader
|
||||
? (req.headers.get(opts.altHeader) ?? "").trim()
|
||||
: "";
|
||||
const incoming = bearer || alt;
|
||||
if (!incoming || !timingSafeStringEq(expected, incoming)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return handler(req, ctx, { secret: expected });
|
||||
};
|
||||
}
|
||||
|
||||
// ─── withRateLimit ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WithRateLimitOpts extends Omit<RateLimitOpts, "key"> {
|
||||
/**
|
||||
* How to derive the per-call key. Receives the bound auth context if any.
|
||||
* Default: client IP.
|
||||
*/
|
||||
keyFn?: (req: Request, extra: unknown) => string | Promise<string>;
|
||||
}
|
||||
|
||||
/** Wrap any other wrapper's handler. Composes neatly with withAuth/withWorkspace. */
|
||||
export function withRateLimit<THandler extends (...args: any[]) => any>(
|
||||
handler: THandler,
|
||||
opts: WithRateLimitOpts,
|
||||
): THandler {
|
||||
return (async (req: Request, ...rest: unknown[]) => {
|
||||
const extra = rest[1] ?? {};
|
||||
const key =
|
||||
(opts.keyFn ? await opts.keyFn(req, extra) : null) ??
|
||||
`ip:${req.headers.get("x-forwarded-for") ?? "unknown"}`;
|
||||
const rl = await rateLimit({
|
||||
key,
|
||||
limit: opts.limit,
|
||||
windowMs: opts.windowMs,
|
||||
});
|
||||
if (!rl.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Rate limit exceeded", retryAfterMs: rl.retryAfterMs },
|
||||
{
|
||||
status: 429,
|
||||
headers: rl.retryAfterMs
|
||||
? { "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)) }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
return handler(req, ...rest);
|
||||
}) as THandler;
|
||||
}
|
||||
145
vibn-frontend/lib/server/audit-log.ts
Normal file
145
vibn-frontend/lib/server/audit-log.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Workspace-scoped audit log of mutating operations.
|
||||
*
|
||||
* Closes BETA_LAUNCH_PLAN P4.7: "Per-workspace audit log of mutating MCP calls".
|
||||
*
|
||||
* Schema:
|
||||
* audit_log
|
||||
* id BIGSERIAL PK
|
||||
* ts TIMESTAMPTZ DEFAULT NOW()
|
||||
* workspace TEXT NOT NULL -- workspace slug
|
||||
* user_email TEXT -- caller; null for runner/system
|
||||
* source TEXT NOT NULL -- 'session' | 'api_key' | 'system' | 'webhook'
|
||||
* action TEXT NOT NULL -- 'apps.create' | 'databases.delete' | …
|
||||
* resource_type TEXT -- 'application' | 'database' | 'project' | …
|
||||
* resource_id TEXT -- coolify uuid / project id / etc.
|
||||
* ok BOOLEAN NOT NULL
|
||||
* params JSONB -- redacted call params
|
||||
* error TEXT
|
||||
* turn_id TEXT -- correlation id (chat turn etc.)
|
||||
*
|
||||
* Read via `SELECT … WHERE workspace = $1 ORDER BY ts DESC LIMIT N`.
|
||||
*
|
||||
* SECRETS: never write raw credentials into `params`. The helper redacts
|
||||
* the standard secret-shaped keys (`api_key`, `password`, `token`, `secret`,
|
||||
* `private_key`, `credential`). Callers are still responsible for not
|
||||
* passing sensitive blobs through unfiltered.
|
||||
*/
|
||||
import { getPool, query } from "@/lib/db-postgres";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
let tableReady = false;
|
||||
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
workspace TEXT NOT NULL,
|
||||
user_email TEXT,
|
||||
source TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
params JSONB,
|
||||
error TEXT,
|
||||
turn_id TEXT
|
||||
)
|
||||
`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS audit_log_workspace_ts_idx ON audit_log (workspace, ts DESC)`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS audit_log_action_ts_idx ON audit_log (action, ts DESC)`);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
const SECRET_KEYS = new Set([
|
||||
"api_key", "apiKey",
|
||||
"password",
|
||||
"token", "access_token", "refresh_token",
|
||||
"secret",
|
||||
"private_key", "privateKey",
|
||||
"credential", "credentials",
|
||||
"authorization",
|
||||
]);
|
||||
|
||||
function redact(obj: unknown, depth = 0): unknown {
|
||||
if (depth > 4) return "[deep]";
|
||||
if (obj == null) return obj;
|
||||
if (Array.isArray(obj)) return obj.map((x) => redact(x, depth + 1));
|
||||
if (typeof obj === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
out[k] = SECRET_KEYS.has(k) ? "[redacted]" : redact(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof obj === "string" && obj.length > 2048) return obj.slice(0, 2048) + "…";
|
||||
return obj;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
workspace: string;
|
||||
userEmail?: string | null;
|
||||
source: "session" | "api_key" | "system" | "webhook";
|
||||
action: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
ok: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
error?: string;
|
||||
turnId?: string;
|
||||
}
|
||||
|
||||
/** Best-effort: never throw out of the audit path. */
|
||||
export async function writeAuditLog(entry: AuditLogEntry): Promise<void> {
|
||||
try {
|
||||
await ensureTable();
|
||||
await query(
|
||||
`INSERT INTO audit_log
|
||||
(workspace, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)`,
|
||||
[
|
||||
entry.workspace,
|
||||
entry.userEmail ?? null,
|
||||
entry.source,
|
||||
entry.action,
|
||||
entry.resourceType ?? null,
|
||||
entry.resourceId ?? null,
|
||||
entry.ok,
|
||||
entry.params ? JSON.stringify(redact(entry.params)) : null,
|
||||
entry.error ?? null,
|
||||
entry.turnId ?? null,
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("audit-log write failed (non-fatal)", {
|
||||
action: entry.action,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAuditLog(opts: {
|
||||
workspace: string;
|
||||
limit?: number;
|
||||
action?: string;
|
||||
}): Promise<unknown[]> {
|
||||
await ensureTable();
|
||||
const limit = Math.min(500, Math.max(1, opts.limit ?? 100));
|
||||
if (opts.action) {
|
||||
return query(
|
||||
`SELECT id, ts, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id
|
||||
FROM audit_log WHERE workspace = $1 AND action = $2 ORDER BY ts DESC LIMIT $3`,
|
||||
[opts.workspace, opts.action, limit],
|
||||
);
|
||||
}
|
||||
return query(
|
||||
`SELECT id, ts, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id
|
||||
FROM audit_log WHERE workspace = $1 ORDER BY ts DESC LIMIT $2`,
|
||||
[opts.workspace, limit],
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export for tests / migration scripts
|
||||
export { getPool };
|
||||
40
vibn-frontend/lib/server/coolify-webhook.ts
Normal file
40
vibn-frontend/lib/server/coolify-webhook.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Coolify webhook signature verification.
|
||||
*
|
||||
* Coolify (≥ 4.0.0-beta.300) signs every webhook with HMAC-SHA256 of the
|
||||
* raw body using the per-app `webhook_secret`. The signature is sent in
|
||||
* the `X-Coolify-Signature-256` header as `sha256=<hex>`.
|
||||
*
|
||||
* If the per-app secret is not set, Coolify sends the body unsigned. In
|
||||
* that case we reject the call: every prod deploy MUST set a secret.
|
||||
*
|
||||
* Mirrors the pattern in `lib/gitea.ts:verifyWebhookSignature`.
|
||||
*/
|
||||
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function verifyCoolifySignature(
|
||||
body: string,
|
||||
signatureHeader: string | null,
|
||||
secret: string,
|
||||
): Promise<boolean> {
|
||||
if (!secret) return false;
|
||||
if (!signatureHeader?.startsWith("sha256=")) return false;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sigBytes = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
||||
const expected =
|
||||
"sha256=" +
|
||||
Array.from(new Uint8Array(sigBytes))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
return timingSafeStringEq(expected, signatureHeader);
|
||||
}
|
||||
140
vibn-frontend/lib/server/dev-server-state.ts
Normal file
140
vibn-frontend/lib/server/dev-server-state.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Persistent dev-server configuration store.
|
||||
* Closes BETA_LAUNCH_PLAN P6.B1.
|
||||
*
|
||||
* When `dev_server_start` succeeds, the MCP tool should call
|
||||
* `upsertDevServerConfig` so the project page can auto-resume the
|
||||
* server on next mount without requiring the user to re-type the
|
||||
* command (see P6.B2 for the auto-resume hook).
|
||||
*
|
||||
* Schema:
|
||||
* fs_project_dev_servers
|
||||
* project_id UUID PK → fs_projects.id
|
||||
* command TEXT NOT NULL e.g. "cd myapp && npm run dev"
|
||||
* port INT NOT NULL e.g. 3000
|
||||
* framework TEXT e.g. "nextjs", "vite", "express"
|
||||
* preview_url TEXT last known *.preview.vibnai.com URL
|
||||
* last_started_at TIMESTAMPTZ
|
||||
* status TEXT CHECK IN ('running','stopped','crashed')
|
||||
* updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
*/
|
||||
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
let tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS fs_project_dev_servers (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
command TEXT NOT NULL,
|
||||
port INT NOT NULL,
|
||||
framework TEXT,
|
||||
preview_url TEXT,
|
||||
last_started_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'stopped'
|
||||
CHECK (status IN ('running', 'stopped', 'crashed')),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
export interface DevServerConfig {
|
||||
projectId: string;
|
||||
command: string;
|
||||
port: number;
|
||||
framework?: string;
|
||||
previewUrl?: string;
|
||||
status: "running" | "stopped" | "crashed";
|
||||
}
|
||||
|
||||
/** Called by the MCP dev_server_start handler after a successful start. */
|
||||
export async function upsertDevServerConfig(
|
||||
cfg: DevServerConfig,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureTable();
|
||||
await query(
|
||||
`INSERT INTO fs_project_dev_servers
|
||||
(project_id, command, port, framework, preview_url, last_started_at, status, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), $6, NOW())
|
||||
ON CONFLICT (project_id) DO UPDATE SET
|
||||
command = EXCLUDED.command,
|
||||
port = EXCLUDED.port,
|
||||
framework = COALESCE(EXCLUDED.framework, fs_project_dev_servers.framework),
|
||||
preview_url = COALESCE(EXCLUDED.preview_url, fs_project_dev_servers.preview_url),
|
||||
last_started_at = NOW(),
|
||||
status = EXCLUDED.status,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
cfg.projectId,
|
||||
cfg.command,
|
||||
cfg.port,
|
||||
cfg.framework ?? null,
|
||||
cfg.previewUrl ?? null,
|
||||
cfg.status,
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("dev-server-state: upsert failed (non-fatal)", {
|
||||
projectId: cfg.projectId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Update just the status (e.g. on stop / crash). */
|
||||
export async function setDevServerStatus(
|
||||
projectId: string,
|
||||
status: "running" | "stopped" | "crashed",
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureTable();
|
||||
await query(
|
||||
`UPDATE fs_project_dev_servers
|
||||
SET status = $2, updated_at = NOW()
|
||||
WHERE project_id = $1`,
|
||||
[projectId, status],
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("dev-server-state: status update failed (non-fatal)", {
|
||||
projectId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the last-known dev server config for a project, or null. */
|
||||
export async function getDevServerConfig(
|
||||
projectId: string,
|
||||
): Promise<DevServerConfig | null> {
|
||||
try {
|
||||
await ensureTable();
|
||||
const rows = await query<{
|
||||
project_id: string;
|
||||
command: string;
|
||||
port: number;
|
||||
framework: string | null;
|
||||
preview_url: string | null;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT project_id, command, port, framework, preview_url, status
|
||||
FROM fs_project_dev_servers WHERE project_id = $1`,
|
||||
[projectId],
|
||||
);
|
||||
if (!rows[0]) return null;
|
||||
const r = rows[0];
|
||||
return {
|
||||
projectId: r.project_id,
|
||||
command: r.command,
|
||||
port: r.port,
|
||||
framework: r.framework ?? undefined,
|
||||
previewUrl: r.preview_url ?? undefined,
|
||||
status: r.status as "running" | "stopped" | "crashed",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
71
vibn-frontend/lib/server/logger.ts
Normal file
71
vibn-frontend/lib/server/logger.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
91
vibn-frontend/lib/server/rate-limit.ts
Normal file
91
vibn-frontend/lib/server/rate-limit.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Postgres-backed sliding-window rate limiter.
|
||||
*
|
||||
* Designed for "small N, simple shape": a few thousand keys/min across the
|
||||
* platform, single primary, no Redis dependency to keep beta infra tight.
|
||||
* If we outgrow this, swap the storage backend without changing call sites.
|
||||
*
|
||||
* Schema (auto-created):
|
||||
* rate_limit_log (key TEXT, ts TIMESTAMPTZ DEFAULT NOW())
|
||||
* index on (key, ts DESC)
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Cleanup older rows for this key (best-effort, capped).
|
||||
* 2. Count remaining rows in window.
|
||||
* 3. If under limit, INSERT a row and return {ok: true, remaining}.
|
||||
* 4. Else return {ok: false, retryAfterMs}.
|
||||
*
|
||||
* NOT race-free across nodes — that's deliberate for cost. If you need
|
||||
* hard quotas (e.g. billing-tier caps), use `lib/quotas.ts` instead.
|
||||
*/
|
||||
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
let tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_log (
|
||||
key TEXT NOT NULL,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS rate_limit_log_key_ts_idx ON rate_limit_log (key, ts DESC)`);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
export interface RateLimitOpts {
|
||||
/** Identity key — e.g. `chat:user@x.com`, `mcp:ws=mark:tool=apps_create`. Required. */
|
||||
key: string;
|
||||
/** Max calls inside the window. Default 60. */
|
||||
limit?: number;
|
||||
/** Window in ms. Default 60_000 (1 min). */
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
remaining: number;
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
export async function rateLimit(opts: RateLimitOpts): Promise<RateLimitResult> {
|
||||
const limit = opts.limit ?? 60;
|
||||
const windowMs = opts.windowMs ?? 60_000;
|
||||
try {
|
||||
await ensureTable();
|
||||
// 1. Sweep stale rows for this key (cheap; index is `(key, ts DESC)`).
|
||||
await query(
|
||||
`DELETE FROM rate_limit_log WHERE key = $1 AND ts < NOW() - $2::interval`,
|
||||
[opts.key, `${Math.ceil(windowMs / 1000)} seconds`],
|
||||
);
|
||||
// 2. Count remaining.
|
||||
const rows = await query<{ n: string }>(
|
||||
`SELECT COUNT(*)::text AS n FROM rate_limit_log WHERE key = $1`,
|
||||
[opts.key],
|
||||
);
|
||||
const used = Number(rows[0]?.n ?? "0");
|
||||
if (used >= limit) {
|
||||
// Find oldest row in window to compute retry-after.
|
||||
const oldest = await query<{ ts: string }>(
|
||||
`SELECT ts FROM rate_limit_log WHERE key = $1 ORDER BY ts ASC LIMIT 1`,
|
||||
[opts.key],
|
||||
);
|
||||
const oldestMs = oldest[0]?.ts ? new Date(oldest[0].ts).getTime() : Date.now();
|
||||
const retryAfterMs = Math.max(0, oldestMs + windowMs - Date.now());
|
||||
return { ok: false, remaining: 0, retryAfterMs };
|
||||
}
|
||||
await query(`INSERT INTO rate_limit_log (key) VALUES ($1)`, [opts.key]);
|
||||
return { ok: true, remaining: Math.max(0, limit - used - 1) };
|
||||
} catch (err) {
|
||||
// Fail-open on DB problems — better than locking everyone out of chat
|
||||
// when Postgres has a hiccup. The downside (unbounded calls during the
|
||||
// outage) is acceptable for beta scale.
|
||||
log.warn("rate-limit DB unavailable, failing open", {
|
||||
key: opts.key,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { ok: true, remaining: limit };
|
||||
}
|
||||
}
|
||||
26
vibn-frontend/lib/server/timing-safe.ts
Normal file
26
vibn-frontend/lib/server/timing-safe.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Constant-time string comparison.
|
||||
*
|
||||
* Use this for every admin-secret / bearer-token / HMAC comparison. Naive
|
||||
* `a === b` short-circuits on the first byte mismatch, leaking length
|
||||
* information that an attacker can use to slow-search the secret.
|
||||
*
|
||||
* `crypto.timingSafeEqual` requires equal-length buffers and runs in
|
||||
* constant time. We normalise to UTF-8 buffers, pad shorter to longer
|
||||
* with zero bytes so length mismatch is also constant-time, and OR a
|
||||
* length-mismatch flag at the end so different lengths can't return true.
|
||||
*/
|
||||
import { timingSafeEqual } from "crypto";
|
||||
|
||||
export function timingSafeStringEq(a: string, b: string): boolean {
|
||||
const aBuf = Buffer.from(a, "utf8");
|
||||
const bBuf = Buffer.from(b, "utf8");
|
||||
const max = Math.max(aBuf.length, bBuf.length);
|
||||
const aPadded = Buffer.alloc(max);
|
||||
const bPadded = Buffer.alloc(max);
|
||||
aBuf.copy(aPadded);
|
||||
bBuf.copy(bPadded);
|
||||
const equal = timingSafeEqual(aPadded, bPadded);
|
||||
// Length mismatch defeats the compare even if padded prefixes happen to match.
|
||||
return equal && aBuf.length === bBuf.length;
|
||||
}
|
||||
Reference in New Issue
Block a user