diff --git a/CLAUDE.md b/CLAUDE.md index af61a78..d192324 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,6 +169,18 @@ ruff check . - **abi_extractor.py**: Regex-based ABI extraction from Stylus Rust code (no Docker needed) - **compiler_verifier.py**: Docker-based `cargo check` with structured error parsing and LLM fix loop +### Rate Limits (`apps/web/src/lib/rateLimit.ts`) +- Two-window enforcement (per-minute burst + per-day total), per-key (or per-user for session auth), per-category (chat and tool counters are independent). Whichever window is exhausted first triggers 429. +- KV keys: `rl:{subject}:{category}:m:{YYYY-MM-DDTHH:MM}` (TTL 120s) and `rl:{subject}:{category}:d:{YYYY-MM-DD}` (TTL 48h). +- Tiers (code-defined; only the name lives in `api_keys.rate_limit_tier`): + - `free` (default): 100/min, 1000/day + - `pro`: 500/min, 10000/day + - `unlimited`: 10K/min, 1M/day (effectively uncapped) +- Enforcement points: `/api/v1/chat/completions`, every `/api/v1/tools/*` route, and `tools/call` on `/mcp`. Admin requests (`AUTH_SECRET` Bearer) bypass. +- Headers on every response: bottleneck `X-RateLimit-Limit/-Remaining/-Reset`, plus per-window `-Minute` and `-Day` variants, plus `X-RateLimit-Tier`. 429 also carries `Retry-After` for the denying window. +- `GET /api/v1/usage` returns current counter state without incrementing — for client-side planning. +- Tier management: `GET/PATCH /api/admin/rate-limits` (admin secret), surfaced in `/dashboard/admin` under the "Rate Limits" tab. + ### Worker-Native Ingestion Pipeline (`apps/web/src/lib/`) - **scraper.ts**: Web documentation scraping via HTMLRewriter + regex HTML-to-markdown - **github.ts**: GitHub repo scraping via Trees API + Contents API (no tarball) diff --git a/README.md b/README.md index e910e23..10f880c 100644 --- a/README.md +++ b/README.md @@ -561,7 +561,7 @@ https://arbuilder.app/mcp - Requires `arb_` API key from dashboard - Usage tracked per API key -- Rate limited per free tier (100 calls/day) +- Rate limited per tier with two windows (burst per minute + total per day). Free tier: 100 req/min and 1000 req/day, applied separately to chat and tool calls. Admin can promote keys to `pro` (500/min, 10K/day) or `unlimited` from the admin dashboard. Every response carries `X-RateLimit-*` headers. ### Chat Completions API (OpenAI-compatible) diff --git a/apps/web/migrations/0005_rate_limits.sql b/apps/web/migrations/0005_rate_limits.sql new file mode 100644 index 0000000..7e3c3a8 --- /dev/null +++ b/apps/web/migrations/0005_rate_limits.sql @@ -0,0 +1,5 @@ +-- Add rate_limit_tier to api_keys for tier-based daily quotas. +-- Tiers: 'free' (default), 'pro', 'unlimited'. +-- Limits live in code (apps/web/src/lib/rateLimit.ts) so they can be tuned +-- without a migration; this column only carries the tier name. +ALTER TABLE api_keys ADD COLUMN rate_limit_tier TEXT NOT NULL DEFAULT 'free'; diff --git a/apps/web/src/app/api/admin/rate-limits/route.ts b/apps/web/src/app/api/admin/rate-limits/route.ts new file mode 100644 index 0000000..e0c8a4b --- /dev/null +++ b/apps/web/src/app/api/admin/rate-limits/route.ts @@ -0,0 +1,128 @@ +/** + * Admin API for managing per-key rate-limit tiers. + * + * GET /api/admin/rate-limits — list all api_keys with tier + recent usage + * PATCH /api/admin/rate-limits — body: { keyId, tier } — update tier on a key + * + * Headers: X-Admin-Secret: + * + * Tiers are validated against TIER_LIMITS in @/lib/rateLimit. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { getLimitsForTier, type RateLimitTier } from "@/lib/rateLimit"; + +const VALID_TIERS: RateLimitTier[] = ["free", "pro", "unlimited"]; + +function verifyAuth(request: NextRequest, authSecret: string): boolean { + return request.headers.get("X-Admin-Secret") === authSecret; +} + +interface KeyRow { + id: string; + user_id: string; + user_email: string | null; + key_prefix: string; + name: string | null; + rate_limit_tier: string; + created_at: string; + last_used_at: string | null; + revoked_at: string | null; + calls_24h: number; +} + +export async function GET(request: NextRequest) { + try { + const { env } = getCloudflareContext(); + if (!verifyAuth(request, env.AUTH_SECRET)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + const result = await env.DB + .prepare( + `SELECT k.id, k.user_id, u.email AS user_email, k.key_prefix, k.name, + k.rate_limit_tier, k.created_at, k.last_used_at, k.revoked_at, + COALESCE(c.cnt, 0) AS calls_24h + FROM api_keys k + LEFT JOIN users u ON u.id = k.user_id + LEFT JOIN ( + SELECT api_key_id, COUNT(*) AS cnt + FROM usage_logs + WHERE created_at >= ? + GROUP BY api_key_id + ) c ON c.api_key_id = k.id + ORDER BY k.created_at DESC + LIMIT 500`, + ) + .bind(since) + .all(); + + const keys = (result.results ?? []).map((r) => { + const lim = getLimitsForTier(r.rate_limit_tier); + return { + id: r.id, + userId: r.user_id, + userEmail: r.user_email, + keyPrefix: r.key_prefix, + name: r.name, + tier: r.rate_limit_tier, + limits: { perMinute: lim.perMinute, perDay: lim.perDay }, + createdAt: r.created_at, + lastUsedAt: r.last_used_at, + revokedAt: r.revoked_at, + calls24h: r.calls_24h, + }; + }); + + return NextResponse.json({ tiers: VALID_TIERS, keys }); + } catch (e) { + console.error("admin/rate-limits GET failed:", e); + return NextResponse.json({ error: (e as Error).message || String(e) }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + try { + const { env } = getCloudflareContext(); + if (!verifyAuth(request, env.AUTH_SECRET)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as { + keyId?: string; + tier?: string; + }; + if (!body.keyId || !body.tier) { + return NextResponse.json({ error: "Missing keyId or tier" }, { status: 400 }); + } + if (!VALID_TIERS.includes(body.tier as RateLimitTier)) { + return NextResponse.json( + { error: `Invalid tier. Must be one of: ${VALID_TIERS.join(", ")}` }, + { status: 400 }, + ); + } + + const r = await env.DB + .prepare(`UPDATE api_keys SET rate_limit_tier = ? WHERE id = ?`) + .bind(body.tier, body.keyId) + .run(); + + if (r.meta.changes === 0) { + return NextResponse.json({ error: "Key not found" }, { status: 404 }); + } + + const lim = getLimitsForTier(body.tier); + return NextResponse.json({ + ok: true, + keyId: body.keyId, + tier: body.tier, + limits: { perMinute: lim.perMinute, perDay: lim.perDay }, + }); + } catch (e) { + console.error("admin/rate-limits PATCH failed:", e); + return NextResponse.json({ error: (e as Error).message || String(e) }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/keys/usage/route.ts b/apps/web/src/app/api/keys/usage/route.ts new file mode 100644 index 0000000..cea6703 --- /dev/null +++ b/apps/web/src/app/api/keys/usage/route.ts @@ -0,0 +1,102 @@ +/** + * GET /api/keys/usage + * + * Session-auth only. Returns current rate-limit counters and 24h activity + * for every active key owned by the logged-in user. Used by /dashboard/keys + * to render per-key usage widgets without making the user paste their keys. + * + * Like /api/v1/usage, this does NOT increment any counter. + */ + +import { NextResponse } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { auth } from "@/auth"; +import { peekUsage, getLimitsForTier } from "@/lib/rateLimit"; + +interface KeyRow { + id: string; + rate_limit_tier: string; + last_used_at: string | null; +} + +interface UsageRow { + api_key_id: string; + total: number; + ok: number | null; + last: string | null; +} + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { env } = getCloudflareContext(); + + const keysRes = await env.DB + .prepare( + `SELECT id, rate_limit_tier, last_used_at + FROM api_keys + WHERE user_id = ? AND revoked_at IS NULL`, + ) + .bind(session.user.id) + .all(); + + const keys = keysRes.results ?? []; + if (keys.length === 0) return NextResponse.json({ usage: {} }); + + // Single 24h-window aggregate query for all of the user's keys. + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const placeholders = keys.map(() => "?").join(","); + const params = [...keys.map((k) => k.id), since]; + const usageRes = await env.DB + .prepare( + `SELECT api_key_id, + COUNT(*) AS total, + SUM(success) AS ok, + MAX(created_at) AS last + FROM usage_logs + WHERE api_key_id IN (${placeholders}) AND created_at >= ? + GROUP BY api_key_id`, + ) + .bind(...params) + .all(); + + const recentByKey = new Map(); + for (const r of usageRes.results ?? []) recentByKey.set(r.api_key_id, r); + + // Read live counters from KV per key + category in parallel. + const peeks = await Promise.all( + keys.flatMap((k) => [ + peekUsage(env.KV, `key:${k.id}`, "chat", k.rate_limit_tier).then((d) => ({ id: k.id, cat: "chat" as const, d })), + peekUsage(env.KV, `key:${k.id}`, "tool", k.rate_limit_tier).then((d) => ({ id: k.id, cat: "tool" as const, d })), + ]), + ); + + const usage: Record = {}; + for (const k of keys) { + const chat = peeks.find((p) => p.id === k.id && p.cat === "chat")!.d; + const tool = peeks.find((p) => p.id === k.id && p.cat === "tool")!.d; + const r = recentByKey.get(k.id); + const total = r?.total ?? 0; + usage[k.id] = { + tier: k.rate_limit_tier, + limits: getLimitsForTier(k.rate_limit_tier), + chat: { minute: chat.minute, day: chat.day }, + tool: { minute: tool.minute, day: tool.day }, + recent: { + calls24h: total, + lastCallAt: r?.last ?? null, + successRate: total > 0 ? (r?.ok ?? 0) / total : null, + }, + }; + } + + return NextResponse.json({ usage }); + } catch (e) { + console.error("/api/keys/usage failed:", e); + return NextResponse.json({ error: (e as Error).message || String(e) }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/v1/chat/completions/route.ts b/apps/web/src/app/api/v1/chat/completions/route.ts index 29b949d..6ce4719 100644 --- a/apps/web/src/app/api/v1/chat/completions/route.ts +++ b/apps/web/src/app/api/v1/chat/completions/route.ts @@ -12,6 +12,7 @@ import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { runAgentNonStreaming, runAgentStreaming } from "@/lib/chat/agent"; import { encodeSSEChunk, encodeSSEDone, encodeSSEError } from "@/lib/chat/streaming"; +import { enforceRateLimit, rateLimitHeaders, subjectFor } from "@/lib/rateLimit"; import type { ChatCompletionRequest, ChatCompletionResponse, @@ -26,9 +27,10 @@ function errorResponse( type: string, status: number, code?: string, + extraHeaders?: Record, ): NextResponse { const body: OpenAIErrorBody = { error: { message, type, code: code ?? null } }; - return NextResponse.json(body, { status }); + return NextResponse.json(body, { status, headers: extraHeaders }); } async function logChatUsage( @@ -76,6 +78,25 @@ export async function POST(request: NextRequest) { ); } + // Rate limit — per-key for arb_ keys, per-user for session auth, bypass for admin. + const subj = subjectFor(auth); + let rlHeaders: Record = {}; + if (subj) { + const decision = await enforceRateLimit(env.KV, subj.subject, "chat", subj.tier); + rlHeaders = rateLimitHeaders(decision); + if (!decision.allowed) { + const denyWindow = decision.exceededWindow === "minute" ? decision.minute : decision.day; + const label = decision.exceededWindow === "minute" ? "per-minute" : "per-day"; + return errorResponse( + `Chat rate limit exceeded (${label}: ${denyWindow.limit} on tier '${decision.tier}'). Try again in ${denyWindow.resetSeconds}s.`, + "rate_limit_exceeded", + 429, + undefined, + rlHeaders, + ); + } + } + // Parse body. let body: ChatCompletionRequest; try { @@ -159,6 +180,7 @@ export async function POST(request: NextRequest) { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", + ...rlHeaders, }, }); } @@ -187,7 +209,7 @@ export async function POST(request: NextRequest) { env.DB, apiKeyId, result.toolCallNames, result.usage.total_tokens, Date.now() - start, true, ); } - return NextResponse.json(response); + return NextResponse.json(response, { headers: rlHeaders }); } catch (e) { const msg = (e as Error).message || String(e); if (apiKeyId) { diff --git a/apps/web/src/app/api/v1/tools/ask-bridging/route.ts b/apps/web/src/app/api/v1/tools/ask-bridging/route.ts index 6ed91b0..70ca04d 100644 --- a/apps/web/src/app/api/v1/tools/ask-bridging/route.ts +++ b/apps/web/src/app/api/v1/tools/ask-bridging/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { askBridging, type AskBridgingInput } from "@/lib/tools/askBridging"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; if (!env.OPENROUTER_API_KEY) { return NextResponse.json( @@ -36,7 +39,7 @@ export async function POST(request: NextRequest) { } ); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in askBridging:", message, error); diff --git a/apps/web/src/app/api/v1/tools/ask-orbit/route.ts b/apps/web/src/app/api/v1/tools/ask-orbit/route.ts index d6b0d80..e2e7b61 100644 --- a/apps/web/src/app/api/v1/tools/ask-orbit/route.ts +++ b/apps/web/src/app/api/v1/tools/ask-orbit/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { askOrbit, type AskOrbitInput } from "@/lib/tools/askOrbit"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; if (!env.OPENROUTER_API_KEY) { return NextResponse.json( @@ -35,7 +38,7 @@ export async function POST(request: NextRequest) { } ); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in askOrbit:", message, error); diff --git a/apps/web/src/app/api/v1/tools/ask/route.ts b/apps/web/src/app/api/v1/tools/ask/route.ts index f91e3e8..70732b8 100644 --- a/apps/web/src/app/api/v1/tools/ask/route.ts +++ b/apps/web/src/app/api/v1/tools/ask/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { askStylus, type AskStylusInput } from "@/lib/tools/askStylus"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { @@ -12,6 +13,8 @@ export async function POST(request: NextRequest) { // Validate request (supports both user API keys and admin secret) const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; // Check for OpenRouter API key if (!env.OPENROUTER_API_KEY) { @@ -43,7 +46,7 @@ export async function POST(request: NextRequest) { } ); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in askStylus:", message, error); diff --git a/apps/web/src/app/api/v1/tools/backend/route.ts b/apps/web/src/app/api/v1/tools/backend/route.ts index 82b0e63..e17fa27 100644 --- a/apps/web/src/app/api/v1/tools/backend/route.ts +++ b/apps/web/src/app/api/v1/tools/backend/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateBackend } from "@/lib/tools/generateBackend"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { prompt?: string; @@ -30,7 +33,7 @@ export async function POST(request: NextRequest) { features: body.features, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateBackend:", message, error); diff --git a/apps/web/src/app/api/v1/tools/bridge/route.ts b/apps/web/src/app/api/v1/tools/bridge/route.ts index 398d86b..3bbe72e 100644 --- a/apps/web/src/app/api/v1/tools/bridge/route.ts +++ b/apps/web/src/app/api/v1/tools/bridge/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateBridgeCode } from "@/lib/tools/generateBridgeCode"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { bridgeType?: string; @@ -30,7 +33,7 @@ export async function POST(request: NextRequest) { destinationAddress: body.destinationAddress, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateBridgeCode:", message, error); diff --git a/apps/web/src/app/api/v1/tools/context/route.ts b/apps/web/src/app/api/v1/tools/context/route.ts index b14fc91..1761b1b 100644 --- a/apps/web/src/app/api/v1/tools/context/route.ts +++ b/apps/web/src/app/api/v1/tools/context/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getStylusContext, type GetStylusContextInput } from "@/lib/tools/getStylusContext"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { @@ -12,6 +13,8 @@ export async function POST(request: NextRequest) { // Validate request (supports both user API keys and admin secret) const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; // Parse request body const body = (await request.json()) as GetStylusContextInput; @@ -31,7 +34,7 @@ export async function POST(request: NextRequest) { rerank: body.rerank ?? true, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in getStylusContext:", message, error); diff --git a/apps/web/src/app/api/v1/tools/frontend/route.ts b/apps/web/src/app/api/v1/tools/frontend/route.ts index 704e89e..9b78a63 100644 --- a/apps/web/src/app/api/v1/tools/frontend/route.ts +++ b/apps/web/src/app/api/v1/tools/frontend/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateFrontend } from "@/lib/tools/generateFrontend"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { prompt?: string; @@ -30,7 +33,7 @@ export async function POST(request: NextRequest) { template: body.template as "base" | "dashboard" | "token" | undefined, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateFrontend:", message, error); diff --git a/apps/web/src/app/api/v1/tools/generate/route.ts b/apps/web/src/app/api/v1/tools/generate/route.ts index 89c3de3..c42cefd 100644 --- a/apps/web/src/app/api/v1/tools/generate/route.ts +++ b/apps/web/src/app/api/v1/tools/generate/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { generateStylusCode, type GenerateStylusCodeInput } from "@/lib/tools/generateStylusCode"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { @@ -12,6 +13,8 @@ export async function POST(request: NextRequest) { // Validate request (supports both user API keys and admin secret) const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; // Check for OpenRouter API key if (!env.OPENROUTER_API_KEY) { @@ -45,7 +48,7 @@ export async function POST(request: NextRequest) { } ); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateStylusCode:", message, error); diff --git a/apps/web/src/app/api/v1/tools/indexer/route.ts b/apps/web/src/app/api/v1/tools/indexer/route.ts index d250ffe..0023e2a 100644 --- a/apps/web/src/app/api/v1/tools/indexer/route.ts +++ b/apps/web/src/app/api/v1/tools/indexer/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateIndexer } from "@/lib/tools/generateIndexer"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { contractAddress?: string; @@ -32,7 +35,7 @@ export async function POST(request: NextRequest) { network: body.network, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateIndexer:", message, error); diff --git a/apps/web/src/app/api/v1/tools/messaging/route.ts b/apps/web/src/app/api/v1/tools/messaging/route.ts index d14d7e8..3f94ac6 100644 --- a/apps/web/src/app/api/v1/tools/messaging/route.ts +++ b/apps/web/src/app/api/v1/tools/messaging/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateMessagingCode } from "@/lib/tools/generateMessagingCode"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { messageType?: string; @@ -26,7 +29,7 @@ export async function POST(request: NextRequest) { includeExample: body.includeExample, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateMessagingCode:", message, error); diff --git a/apps/web/src/app/api/v1/tools/oracle/route.ts b/apps/web/src/app/api/v1/tools/oracle/route.ts index a88bb36..8055823 100644 --- a/apps/web/src/app/api/v1/tools/oracle/route.ts +++ b/apps/web/src/app/api/v1/tools/oracle/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateOracle } from "@/lib/tools/generateOracle"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { oracleType?: string; @@ -28,7 +31,7 @@ export async function POST(request: NextRequest) { feeds: body.feeds, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateOracle:", message, error); diff --git a/apps/web/src/app/api/v1/tools/orbit-config/route.ts b/apps/web/src/app/api/v1/tools/orbit-config/route.ts index f812c1b..e09360f 100644 --- a/apps/web/src/app/api/v1/tools/orbit-config/route.ts +++ b/apps/web/src/app/api/v1/tools/orbit-config/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateOrbitConfig } from "@/lib/tools/generateOrbitConfig"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { prompt?: string; @@ -34,7 +37,7 @@ export async function POST(request: NextRequest) { parentChain: body.parentChain as Parameters[0]["parentChain"], }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateOrbitConfig:", message, error); diff --git a/apps/web/src/app/api/v1/tools/orbit-deploy/route.ts b/apps/web/src/app/api/v1/tools/orbit-deploy/route.ts index 3a6800b..ac5319c 100644 --- a/apps/web/src/app/api/v1/tools/orbit-deploy/route.ts +++ b/apps/web/src/app/api/v1/tools/orbit-deploy/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateOrbitDeployment } from "@/lib/tools/generateOrbitDeployment"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { prompt?: string; @@ -42,7 +45,7 @@ export async function POST(request: NextRequest) { rollupAddress: body.rollupAddress, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateOrbitDeployment:", message, error); diff --git a/apps/web/src/app/api/v1/tools/orbit-validator/route.ts b/apps/web/src/app/api/v1/tools/orbit-validator/route.ts index 0807671..49f6263 100644 --- a/apps/web/src/app/api/v1/tools/orbit-validator/route.ts +++ b/apps/web/src/app/api/v1/tools/orbit-validator/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateValidatorSetup } from "@/lib/tools/generateValidatorSetup"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { prompt?: string; @@ -36,7 +39,7 @@ export async function POST(request: NextRequest) { parentChain: body.parentChain as Parameters[0]["parentChain"], }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateValidatorSetup:", message, error); diff --git a/apps/web/src/app/api/v1/tools/orchestrate-orbit/route.ts b/apps/web/src/app/api/v1/tools/orchestrate-orbit/route.ts index 9092879..08030cf 100644 --- a/apps/web/src/app/api/v1/tools/orchestrate-orbit/route.ts +++ b/apps/web/src/app/api/v1/tools/orchestrate-orbit/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { orchestrateOrbit } from "@/lib/tools/orchestrateOrbit"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { prompt?: string; @@ -38,7 +41,7 @@ export async function POST(request: NextRequest) { batchPosters: body.batchPosters, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in orchestrateOrbit:", message, error); diff --git a/apps/web/src/app/api/v1/tools/orchestrate/route.ts b/apps/web/src/app/api/v1/tools/orchestrate/route.ts index 78e2b3b..2c5f156 100644 --- a/apps/web/src/app/api/v1/tools/orchestrate/route.ts +++ b/apps/web/src/app/api/v1/tools/orchestrate/route.ts @@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { orchestrateDapp } from "@/lib/tools/orchestrateDapp"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { try { const { env } = getCloudflareContext(); const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; const body = (await request.json()) as { prompt?: string; @@ -32,7 +35,7 @@ export async function POST(request: NextRequest) { contractAbi: body.contractAbi, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in orchestrateDapp:", message, error); diff --git a/apps/web/src/app/api/v1/tools/tests/route.ts b/apps/web/src/app/api/v1/tools/tests/route.ts index 40611c0..0a7e099 100644 --- a/apps/web/src/app/api/v1/tools/tests/route.ts +++ b/apps/web/src/app/api/v1/tools/tests/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { generateTests, type GenerateTestsInput } from "@/lib/tools/generateTests"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { @@ -12,6 +13,8 @@ export async function POST(request: NextRequest) { // Validate request (supports both user API keys and admin secret) const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; // Check for OpenRouter API key if (!env.OPENROUTER_API_KEY) { @@ -39,7 +42,7 @@ export async function POST(request: NextRequest) { coverageFocus: body.coverageFocus, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateTests:", message, error); diff --git a/apps/web/src/app/api/v1/tools/workflow/route.ts b/apps/web/src/app/api/v1/tools/workflow/route.ts index bbeeefa..9086cdf 100644 --- a/apps/web/src/app/api/v1/tools/workflow/route.ts +++ b/apps/web/src/app/api/v1/tools/workflow/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getWorkflow, type GetWorkflowInput } from "@/lib/tools/getWorkflow"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; +import { checkToolRateLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { @@ -12,6 +13,8 @@ export async function POST(request: NextRequest) { // Validate request (supports both user API keys and admin secret) const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const rl = await checkToolRateLimit(env.KV, auth); + if ("response" in rl) return rl.response; // Parse request body const body = (await request.json()) as GetWorkflowInput; @@ -37,7 +40,7 @@ export async function POST(request: NextRequest) { includeTroubleshooting: body.includeTroubleshooting ?? true, }); - return NextResponse.json(result); + return NextResponse.json(result, { headers: rl.headers }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in getWorkflow:", message, error); diff --git a/apps/web/src/app/api/v1/usage/route.ts b/apps/web/src/app/api/v1/usage/route.ts new file mode 100644 index 0000000..c2a2e9a --- /dev/null +++ b/apps/web/src/app/api/v1/usage/route.ts @@ -0,0 +1,100 @@ +/** + * GET /api/v1/usage + * + * Returns the current rate-limit state for the authenticated key: + * - tier + * - chat counters (minute + day windows) + * - tool counters (minute + day windows) + * - recent activity summary (24h call count + last call time, from D1) + * + * This endpoint is itself rate-limit free (it does not increment any + * counters), so clients can poll it to plan around the limits without + * burning a slot. Auth is the same as every other v1 endpoint. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { validateRequest } from "@/lib/auth/validateRequest"; +import { peekUsage, subjectFor } from "@/lib/rateLimit"; + +export async function GET(request: NextRequest) { + try { + const { env } = getCloudflareContext(); + + const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); + if (!auth.success) return auth.response; + + const subj = subjectFor(auth); + const tier = subj?.tier ?? (auth.isAdmin ? "unlimited" : "free"); + + // Admin requests have no subject — return tier limits with zero usage. + if (!subj) { + const placeholder = await peekUsage(env.KV, "admin", "chat", tier); + const placeholderTool = await peekUsage(env.KV, "admin", "tool", tier); + return NextResponse.json({ + tier, + admin: true, + chat: { minute: placeholder.minute, day: placeholder.day }, + tool: { minute: placeholderTool.minute, day: placeholderTool.day }, + recent: null, + }); + } + + const [chatUsage, toolUsage] = await Promise.all([ + peekUsage(env.KV, subj.subject, "chat", tier), + peekUsage(env.KV, subj.subject, "tool", tier), + ]); + + // 24h activity summary from usage_logs (only for API-key auth — session + // auth doesn't have a key_id to filter on). + let recent: { + calls24h: number; + lastCallAt: string | null; + successRate: number | null; + } | null = null; + if (auth.keyId) { + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const row = await env.DB + .prepare( + `SELECT COUNT(*) AS total, + SUM(success) AS ok, + MAX(created_at) AS last + FROM usage_logs + WHERE api_key_id = ? AND created_at >= ?`, + ) + .bind(auth.keyId, since) + .first<{ total: number; ok: number | null; last: string | null }>(); + const total = row?.total ?? 0; + recent = { + calls24h: total, + lastCallAt: row?.last ?? null, + successRate: total > 0 ? (row?.ok ?? 0) / total : null, + }; + } + + return NextResponse.json({ + tier, + admin: false, + chat: { minute: chatUsage.minute, day: chatUsage.day }, + tool: { minute: toolUsage.minute, day: toolUsage.day }, + recent, + }); + } catch (e) { + console.error("usage endpoint failed:", e); + return NextResponse.json( + { error: (e as Error).message || String(e) }, + { status: 500 }, + ); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + }); +} diff --git a/apps/web/src/app/dashboard/admin/page.tsx b/apps/web/src/app/dashboard/admin/page.tsx index 6355d3d..96d85b8 100644 --- a/apps/web/src/app/dashboard/admin/page.tsx +++ b/apps/web/src/app/dashboard/admin/page.tsx @@ -44,6 +44,9 @@ export default function AdminPage() { const [adminSecret, setAdminSecret] = useState(""); const [isAuthed, setIsAuthed] = useState(false); + // View tabs + const [view, setView] = useState<"sources" | "rateLimits">("sources"); + // Filters const [categoryFilter, setCategoryFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); @@ -196,6 +199,34 @@ export default function AdminPage() { return (
+ {/* Tabs */} +
+ + +
+ + {view === "rateLimits" ? ( + + ) : ( + <> {/* Header */}
@@ -494,6 +525,193 @@ export default function AdminPage() { )}
)} + + )} +
+ ); +} + +interface RateLimitKey { + id: string; + userId: string; + userEmail: string | null; + keyPrefix: string; + name: string | null; + tier: string; + limits: { perMinute: number; perDay: number }; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + calls24h: number; +} + +function RateLimitsPanel({ adminSecret }: { adminSecret: string }) { + const [keys, setKeys] = useState([]); + const [tiers, setTiers] = useState(["free", "pro", "unlimited"]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [savingId, setSavingId] = useState(null); + const [filter, setFilter] = useState(""); + const [tierFilter, setTierFilter] = useState(""); + + const load = useCallback(async () => { + if (!adminSecret) return; + setLoading(true); + setError(null); + try { + const res = await fetch("/api/admin/rate-limits", { + headers: { "X-Admin-Secret": adminSecret }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as { tiers: string[]; keys: RateLimitKey[] }; + setKeys(data.keys); + setTiers(data.tiers); + } catch (e) { + setError(`Failed to load: ${e}`); + } finally { + setLoading(false); + } + }, [adminSecret]); + + useEffect(() => { + load(); + }, [load]); + + const updateTier = async (keyId: string, tier: string) => { + setSavingId(keyId); + try { + const res = await fetch("/api/admin/rate-limits", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-Admin-Secret": adminSecret, + }, + body: JSON.stringify({ keyId, tier }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as { limits: { perMinute: number; perDay: number } }; + setKeys((prev) => + prev.map((k) => (k.id === keyId ? { ...k, tier, limits: data.limits } : k)), + ); + } catch (e) { + setError(`Update failed: ${e}`); + } finally { + setSavingId(null); + } + }; + + const filtered = keys.filter((k) => { + if (tierFilter && k.tier !== tierFilter) return false; + if (!filter) return true; + const f = filter.toLowerCase(); + return ( + (k.userEmail ?? "").toLowerCase().includes(f) || + (k.name ?? "").toLowerCase().includes(f) || + k.keyPrefix.toLowerCase().includes(f) || + k.id.toLowerCase().includes(f) + ); + }); + + return ( +
+
+
+

Rate Limit Tiers

+

+ Per-key two-window quotas (burst per UTC minute + total per UTC day), applied separately to chat and tool calls. Tiers:{" "} + free = 100/min, 1000/day;{" "} + pro = 500/min, 10K/day;{" "} + unlimited = 10K/min, 1M/day. +

+
+ +
+ +
+ setFilter(e.target.value)} + placeholder="Filter by email, name, prefix, or id..." + className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg" + /> + +
+ + {error &&
{error}
} + + {loading ? ( +

Loading…

+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((k) => ( + + + + + + + + + ))} + {filtered.length === 0 && ( + + + + )} + +
User / KeyPrefixTier24h CallsLast UsedStatus
+
{k.userEmail || k.userId}
+
{k.name || "(unnamed key)"}
+
{k.keyPrefix}… + + {k.calls24h} + {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString() : "—"} + + {k.revokedAt ? ( + revoked + ) : ( + active + )} +
+ No keys match. +
+
+ )}
); } diff --git a/apps/web/src/app/dashboard/keys/page.tsx b/apps/web/src/app/dashboard/keys/page.tsx index 3c20ab5..879dd5c 100644 --- a/apps/web/src/app/dashboard/keys/page.tsx +++ b/apps/web/src/app/dashboard/keys/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; interface ApiKey { id: string; @@ -8,10 +8,27 @@ interface ApiKey { name: string | null; createdAt: string; lastUsedAt: string | null; + rateLimitTier?: string; +} + +interface WindowState { + limit: number; + remaining: number; + used: number; + resetSeconds: number; +} + +interface KeyUsage { + tier: string; + limits: { perMinute: number; perDay: number }; + chat: { minute: WindowState; day: WindowState }; + tool: { minute: WindowState; day: WindowState }; + recent: { calls24h: number; lastCallAt: string | null; successRate: number | null }; } export default function ApiKeysPage() { const [keys, setKeys] = useState([]); + const [usage, setUsage] = useState>({}); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [newKeyName, setNewKeyName] = useState(""); @@ -19,11 +36,7 @@ export default function ApiKeysPage() { const [error, setError] = useState(null); const [copied, setCopied] = useState(false); - useEffect(() => { - fetchKeys(); - }, []); - - async function fetchKeys() { + const fetchKeys = useCallback(async () => { try { const res = await fetch("/api/keys"); const data = (await res.json()) as { keys?: ApiKey[] }; @@ -33,7 +46,25 @@ export default function ApiKeysPage() { } finally { setLoading(false); } - } + }, []); + + const fetchUsage = useCallback(async () => { + try { + const res = await fetch("/api/keys/usage"); + if (!res.ok) return; + const data = (await res.json()) as { usage: Record }; + setUsage(data.usage || {}); + } catch { + // Non-fatal — widget just won't render until next poll succeeds. + } + }, []); + + useEffect(() => { + fetchKeys(); + fetchUsage(); + const id = setInterval(fetchUsage, 15_000); + return () => clearInterval(id); + }, [fetchKeys, fetchUsage]); async function createKey() { setCreating(true); @@ -52,6 +83,7 @@ export default function ApiKeysPage() { setNewKey(data.key); setNewKeyName(""); fetchKeys(); + fetchUsage(); } catch { setError("Failed to create API key"); } finally { @@ -66,6 +98,7 @@ export default function ApiKeysPage() { const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); if (!res.ok) throw new Error("Failed to revoke key"); fetchKeys(); + fetchUsage(); } catch { setError("Failed to revoke API key"); } @@ -258,11 +291,26 @@ export default function ApiKeysPage() { {key.name && ( {key.name} )} + {key.rateLimitTier && ( + + {key.rateLimitTier} + + )}
Created: {formatDate(key.createdAt)} Last used: {formatDate(key.lastUsedAt)}
+ {usage[key.id] && }