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:
2026-05-17 19:17:22 -07:00
parent 955aeed6ce
commit 6b8862ef2b
86 changed files with 6772 additions and 2817 deletions

View 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;
}

View 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 };

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

View 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;
}
}

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

View 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 };
}
}

View 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;
}