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
92 lines
3.2 KiB
TypeScript
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 };
|
|
}
|
|
}
|