Files
vibn-frontend/lib/server/rate-limit.ts
mawkone 5364bd8497 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

92 lines
3.2 KiB
TypeScript

/**
* 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 };
}
}