diff --git a/apps/web/migrations/0006_api_key_origins.sql b/apps/web/migrations/0006_api_key_origins.sql new file mode 100644 index 0000000..15fddc0 --- /dev/null +++ b/apps/web/migrations/0006_api_key_origins.sql @@ -0,0 +1,7 @@ +-- Per-key CORS allowlist for browser-originated requests. +-- NULL = unrestricted (server-to-server use, default). +-- '[]' = locked (no browser may use this key). +-- '["https://docs.example.com", ...]' = browser may use this key only from these origins. +-- +-- Server-to-server requests (no Origin header) ignore this column entirely. +ALTER TABLE api_keys ADD COLUMN allowed_origins TEXT; diff --git a/apps/web/src/app/api/keys/[id]/route.ts b/apps/web/src/app/api/keys/[id]/route.ts index a07c93b..fdd7152 100644 --- a/apps/web/src/app/api/keys/[id]/route.ts +++ b/apps/web/src/app/api/keys/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { auth } from "@/auth"; import { revokeApiKey } from "@/lib/apiKeys"; +import { validateOriginList } from "@/lib/cors"; // DELETE /api/keys/[id] - Revoke an API key @@ -36,3 +37,74 @@ export async function DELETE( ); } } + +/** + * PATCH /api/keys/[id] — owner can update mutable fields on their key. + * Body: { name?: string | null, allowedOrigins?: string[] | null } + * - allowedOrigins: + * null → unrestricted (server-to-server) + * [] → locked (no browser may use this key) + * [...] → only listed origins may use this key from a browser + * ["*"] → any browser origin + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { id } = await params; + const { env } = getCloudflareContext(); + + const body = (await request.json().catch(() => ({}))) as { + name?: string | null; + allowedOrigins?: string[] | null; + }; + + const owns = await env.DB + .prepare(`SELECT id FROM api_keys WHERE id = ? AND user_id = ? AND revoked_at IS NULL`) + .bind(id, session.user.id) + .first(); + if (!owns) { + return NextResponse.json({ error: "API key not found" }, { status: 404 }); + } + + const updates: string[] = []; + const values: unknown[] = []; + if (body.name !== undefined) { + updates.push("name = ?"); + values.push(body.name === null ? null : String(body.name).trim() || null); + } + if (body.allowedOrigins !== undefined) { + let serialised: string | null; + if (body.allowedOrigins === null) { + serialised = null; + } else { + try { + serialised = JSON.stringify(validateOriginList(body.allowedOrigins)); + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 400 }); + } + } + updates.push("allowed_origins = ?"); + values.push(serialised); + } + if (updates.length === 0) { + return NextResponse.json({ error: "No fields to update" }, { status: 400 }); + } + + values.push(id); + await env.DB + .prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = ?`) + .bind(...values) + .run(); + + return NextResponse.json({ ok: true }); + } catch (e) { + console.error("PATCH /api/keys/[id] 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 6ce4719..ccd0ea0 100644 --- a/apps/web/src/app/api/v1/chat/completions/route.ts +++ b/apps/web/src/app/api/v1/chat/completions/route.ts @@ -13,6 +13,7 @@ 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 { evaluateCors, preflightResponse } from "@/lib/cors"; import type { ChatCompletionRequest, ChatCompletionResponse, @@ -68,6 +69,14 @@ export async function POST(request: NextRequest) { const start = Date.now(); const { env } = getCloudflareContext(); + // Permissive CORS for pre-auth errors — we don't know the key yet, so we + // reflect Origin to keep the browser happy while still returning the real + // status code and body. + const reqOrigin = request.headers.get("Origin"); + const preAuthCors: Record = reqOrigin + ? { "Access-Control-Allow-Origin": reqOrigin, "Access-Control-Allow-Credentials": "true", Vary: "Origin" } + : { Vary: "Origin" }; + // Auth — same flow as /api/v1/tools/* and /mcp. const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) { @@ -75,9 +84,15 @@ export async function POST(request: NextRequest) { "Authentication required. Pass a valid `Authorization: Bearer arb_...` header.", "invalid_api_key", 401, + undefined, + preAuthCors, ); } + // CORS allowlist enforcement (browser-only). + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; + // Rate limit — per-key for arb_ keys, per-user for session auth, bypass for admin. const subj = subjectFor(auth); let rlHeaders: Record = {}; @@ -92,27 +107,31 @@ export async function POST(request: NextRequest) { "rate_limit_exceeded", 429, undefined, - rlHeaders, + { ...rlHeaders, ...cors.headers }, ); } } + // Merge CORS headers into the rate-limit header bag for the success path. + rlHeaders = { ...rlHeaders, ...cors.headers }; // Parse body. let body: ChatCompletionRequest; try { body = (await request.json()) as ChatCompletionRequest; } catch { - return errorResponse("Invalid JSON body.", "invalid_request_error", 400); + return errorResponse("Invalid JSON body.", "invalid_request_error", 400, undefined, rlHeaders); } if (!Array.isArray(body.messages) || body.messages.length === 0) { return errorResponse( "Missing required field 'messages' (must be a non-empty array).", "invalid_request_error", 400, + undefined, + rlHeaders, ); } if (!env.OPENROUTER_API_KEY) { - return errorResponse("OpenRouter not configured on this server.", "internal_error", 500); + return errorResponse("OpenRouter not configured on this server.", "internal_error", 500, undefined, rlHeaders); } const toolEnv: ToolEnv = { @@ -215,18 +234,10 @@ export async function POST(request: NextRequest) { if (apiKeyId) { await logChatUsage(env.DB, apiKeyId, [], 0, Date.now() - start, false, msg); } - return errorResponse(msg, "internal_error", 500); + return errorResponse(msg, "internal_error", 500, undefined, rlHeaders); } } -// CORS preflight. -export async function OPTIONS() { - return new NextResponse(null, { - status: 204, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - }, - }); +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); } 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 70ca04d..bd75ced 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 @@ -3,6 +3,7 @@ import { askBridging, type AskBridgingInput } from "@/lib/tools/askBridging"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; if (!env.OPENROUTER_API_KEY) { return NextResponse.json( @@ -39,7 +42,7 @@ export async function POST(request: NextRequest) { } ); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in askBridging:", message, error); @@ -49,3 +52,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 e2e7b61..aaf2b33 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 @@ -3,6 +3,7 @@ import { askOrbit, type AskOrbitInput } from "@/lib/tools/askOrbit"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; if (!env.OPENROUTER_API_KEY) { return NextResponse.json( @@ -38,7 +41,7 @@ export async function POST(request: NextRequest) { } ); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in askOrbit:", message, error); @@ -48,3 +51,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 70732b8..e88ada1 100644 --- a/apps/web/src/app/api/v1/tools/ask/route.ts +++ b/apps/web/src/app/api/v1/tools/ask/route.ts @@ -3,6 +3,7 @@ import { askStylus, type AskStylusInput } from "@/lib/tools/askStylus"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { @@ -15,6 +16,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; // Check for OpenRouter API key if (!env.OPENROUTER_API_KEY) { @@ -46,7 +49,7 @@ export async function POST(request: NextRequest) { } ); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in askStylus:", message, error); @@ -56,3 +59,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 e17fa27..39599b0 100644 --- a/apps/web/src/app/api/v1/tools/backend/route.ts +++ b/apps/web/src/app/api/v1/tools/backend/route.ts @@ -3,6 +3,7 @@ import { generateBackend } from "@/lib/tools/generateBackend"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { prompt?: string; @@ -33,7 +36,7 @@ export async function POST(request: NextRequest) { features: body.features, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateBackend:", message, error); @@ -43,3 +46,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 3bbe72e..3cce7af 100644 --- a/apps/web/src/app/api/v1/tools/bridge/route.ts +++ b/apps/web/src/app/api/v1/tools/bridge/route.ts @@ -3,6 +3,7 @@ import { generateBridgeCode } from "@/lib/tools/generateBridgeCode"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { bridgeType?: string; @@ -33,7 +36,7 @@ export async function POST(request: NextRequest) { destinationAddress: body.destinationAddress, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateBridgeCode:", message, error); @@ -43,3 +46,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 1761b1b..0161c97 100644 --- a/apps/web/src/app/api/v1/tools/context/route.ts +++ b/apps/web/src/app/api/v1/tools/context/route.ts @@ -3,6 +3,7 @@ import { getStylusContext, type GetStylusContextInput } from "@/lib/tools/getSty import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { @@ -15,6 +16,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; // Parse request body const body = (await request.json()) as GetStylusContextInput; @@ -34,7 +37,7 @@ export async function POST(request: NextRequest) { rerank: body.rerank ?? true, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in getStylusContext:", message, error); @@ -44,3 +47,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 9b78a63..71c8a24 100644 --- a/apps/web/src/app/api/v1/tools/frontend/route.ts +++ b/apps/web/src/app/api/v1/tools/frontend/route.ts @@ -3,6 +3,7 @@ import { generateFrontend } from "@/lib/tools/generateFrontend"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { prompt?: string; @@ -33,7 +36,7 @@ export async function POST(request: NextRequest) { template: body.template as "base" | "dashboard" | "token" | undefined, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateFrontend:", message, error); @@ -43,3 +46,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 c42cefd..ffde52e 100644 --- a/apps/web/src/app/api/v1/tools/generate/route.ts +++ b/apps/web/src/app/api/v1/tools/generate/route.ts @@ -3,6 +3,7 @@ import { generateStylusCode, type GenerateStylusCodeInput } from "@/lib/tools/ge import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { @@ -15,6 +16,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; // Check for OpenRouter API key if (!env.OPENROUTER_API_KEY) { @@ -48,7 +51,7 @@ export async function POST(request: NextRequest) { } ); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateStylusCode:", message, error); @@ -58,3 +61,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 0023e2a..0914c2b 100644 --- a/apps/web/src/app/api/v1/tools/indexer/route.ts +++ b/apps/web/src/app/api/v1/tools/indexer/route.ts @@ -3,6 +3,7 @@ import { generateIndexer } from "@/lib/tools/generateIndexer"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { contractAddress?: string; @@ -35,7 +38,7 @@ export async function POST(request: NextRequest) { network: body.network, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateIndexer:", message, error); @@ -45,3 +48,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 3f94ac6..081645f 100644 --- a/apps/web/src/app/api/v1/tools/messaging/route.ts +++ b/apps/web/src/app/api/v1/tools/messaging/route.ts @@ -3,6 +3,7 @@ import { generateMessagingCode } from "@/lib/tools/generateMessagingCode"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { messageType?: string; @@ -29,7 +32,7 @@ export async function POST(request: NextRequest) { includeExample: body.includeExample, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateMessagingCode:", message, error); @@ -39,3 +42,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 8055823..0b76978 100644 --- a/apps/web/src/app/api/v1/tools/oracle/route.ts +++ b/apps/web/src/app/api/v1/tools/oracle/route.ts @@ -3,6 +3,7 @@ import { generateOracle } from "@/lib/tools/generateOracle"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { oracleType?: string; @@ -31,7 +34,7 @@ export async function POST(request: NextRequest) { feeds: body.feeds, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateOracle:", message, error); @@ -41,3 +44,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 e09360f..7c6f6c2 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 @@ -3,6 +3,7 @@ import { generateOrbitConfig } from "@/lib/tools/generateOrbitConfig"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { prompt?: string; @@ -37,7 +40,7 @@ export async function POST(request: NextRequest) { parentChain: body.parentChain as Parameters[0]["parentChain"], }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateOrbitConfig:", message, error); @@ -47,3 +50,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 ac5319c..b804bbf 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 @@ -3,6 +3,7 @@ import { generateOrbitDeployment } from "@/lib/tools/generateOrbitDeployment"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { prompt?: string; @@ -45,7 +48,7 @@ export async function POST(request: NextRequest) { rollupAddress: body.rollupAddress, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateOrbitDeployment:", message, error); @@ -55,3 +58,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 49f6263..c32ad23 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 @@ -3,6 +3,7 @@ import { generateValidatorSetup } from "@/lib/tools/generateValidatorSetup"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { prompt?: string; @@ -39,7 +42,7 @@ export async function POST(request: NextRequest) { parentChain: body.parentChain as Parameters[0]["parentChain"], }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateValidatorSetup:", message, error); @@ -49,3 +52,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 08030cf..ed11310 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 @@ -3,6 +3,7 @@ import { orchestrateOrbit } from "@/lib/tools/orchestrateOrbit"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { prompt?: string; @@ -41,7 +44,7 @@ export async function POST(request: NextRequest) { batchPosters: body.batchPosters, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in orchestrateOrbit:", message, error); @@ -51,3 +54,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 2c5f156..18b7ee4 100644 --- a/apps/web/src/app/api/v1/tools/orchestrate/route.ts +++ b/apps/web/src/app/api/v1/tools/orchestrate/route.ts @@ -3,6 +3,7 @@ import { orchestrateDapp } from "@/lib/tools/orchestrateDapp"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { try { @@ -11,6 +12,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; const body = (await request.json()) as { prompt?: string; @@ -35,7 +38,7 @@ export async function POST(request: NextRequest) { contractAbi: body.contractAbi, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in orchestrateDapp:", message, error); @@ -45,3 +48,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 0a7e099..549607c 100644 --- a/apps/web/src/app/api/v1/tools/tests/route.ts +++ b/apps/web/src/app/api/v1/tools/tests/route.ts @@ -3,6 +3,7 @@ import { generateTests, type GenerateTestsInput } from "@/lib/tools/generateTest import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { @@ -15,6 +16,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; // Check for OpenRouter API key if (!env.OPENROUTER_API_KEY) { @@ -42,7 +45,7 @@ export async function POST(request: NextRequest) { coverageFocus: body.coverageFocus, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in generateTests:", message, error); @@ -52,3 +55,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} 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 9086cdf..8f4a1b6 100644 --- a/apps/web/src/app/api/v1/tools/workflow/route.ts +++ b/apps/web/src/app/api/v1/tools/workflow/route.ts @@ -3,6 +3,7 @@ import { getWorkflow, type GetWorkflowInput } from "@/lib/tools/getWorkflow"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { checkToolRateLimit } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function POST(request: NextRequest) { @@ -15,6 +16,8 @@ export async function POST(request: NextRequest) { if (!auth.success) return auth.response; const rl = await checkToolRateLimit(env.KV, auth); if ("response" in rl) return rl.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; // Parse request body const body = (await request.json()) as GetWorkflowInput; @@ -40,7 +43,7 @@ export async function POST(request: NextRequest) { includeTroubleshooting: body.includeTroubleshooting ?? true, }); - return NextResponse.json(result, { headers: rl.headers }); + return NextResponse.json(result, { headers: { ...rl.headers, ...cors.headers } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error in getWorkflow:", message, error); @@ -50,3 +53,7 @@ export async function POST(request: NextRequest) { ); } } + +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "POST, OPTIONS"); +} diff --git a/apps/web/src/app/api/v1/usage/route.ts b/apps/web/src/app/api/v1/usage/route.ts index c2a2e9a..1064750 100644 --- a/apps/web/src/app/api/v1/usage/route.ts +++ b/apps/web/src/app/api/v1/usage/route.ts @@ -16,6 +16,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { validateRequest } from "@/lib/auth/validateRequest"; import { peekUsage, subjectFor } from "@/lib/rateLimit"; +import { evaluateCors, preflightResponse } from "@/lib/cors"; export async function GET(request: NextRequest) { try { @@ -24,6 +25,9 @@ export async function GET(request: NextRequest) { const auth = await validateRequest(request, env.DB, env.AUTH_SECRET); if (!auth.success) return auth.response; + const cors = evaluateCors(request, auth.allowedOrigins); + if (!cors.ok) return cors.response; + const subj = subjectFor(auth); const tier = subj?.tier ?? (auth.isAdmin ? "unlimited" : "free"); @@ -37,7 +41,7 @@ export async function GET(request: NextRequest) { chat: { minute: placeholder.minute, day: placeholder.day }, tool: { minute: placeholderTool.minute, day: placeholderTool.day }, recent: null, - }); + }, { headers: cors.headers }); } const [chatUsage, toolUsage] = await Promise.all([ @@ -78,7 +82,7 @@ export async function GET(request: NextRequest) { chat: { minute: chatUsage.minute, day: chatUsage.day }, tool: { minute: toolUsage.minute, day: toolUsage.day }, recent, - }); + }, { headers: cors.headers }); } catch (e) { console.error("usage endpoint failed:", e); return NextResponse.json( @@ -88,13 +92,6 @@ export async function GET(request: NextRequest) { } } -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", - }, - }); +export async function OPTIONS(request: NextRequest) { + return preflightResponse(request, "GET, OPTIONS"); } diff --git a/apps/web/src/app/dashboard/keys/page.tsx b/apps/web/src/app/dashboard/keys/page.tsx index 879dd5c..40fc978 100644 --- a/apps/web/src/app/dashboard/keys/page.tsx +++ b/apps/web/src/app/dashboard/keys/page.tsx @@ -9,6 +9,8 @@ interface ApiKey { createdAt: string; lastUsedAt: string | null; rateLimitTier?: string; + /** Raw JSON string from API; parsed in render. */ + allowedOrigins?: string | null; } interface WindowState { @@ -311,10 +313,15 @@ export default function ApiKeysPage() { Last used: {formatDate(key.lastUsedAt)} {usage[key.id] && } + @@ -373,3 +380,131 @@ function Bar({ window: w, used, limit }: { window: "min" | "day"; used: number; ); } + +function parseOrigins(raw: string | null | undefined): string[] | null { + if (raw == null) return null; + try { + const v = JSON.parse(raw); + return Array.isArray(v) ? v.filter((s) => typeof s === "string") : null; + } catch { + return null; + } +} + +function OriginsEditor({ + keyId, + raw, + onSaved, +}: { + keyId: string; + raw: string | null | undefined; + onSaved: () => void; +}) { + const initial = parseOrigins(raw); + const [editing, setEditing] = useState(false); + const [text, setText] = useState((initial ?? []).join("\n")); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + + const restricted = initial !== null; + const origins = initial ?? []; + + async function save(allowedOrigins: string[] | null) { + setSaving(true); + setErr(null); + try { + const res = await fetch(`/api/keys/${keyId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allowedOrigins }), + }); + const data = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + setEditing(false); + onSaved(); + } catch (e) { + setErr((e as Error).message); + } finally { + setSaving(false); + } + } + + if (!editing) { + return ( +
+ Allowed origins: + {!restricted ? ( + Unrestricted (server-to-server only) + ) : origins.length === 0 ? ( + Locked — no browser may use this key + ) : ( + origins.map((o) => ( + + {o} + + )) + )} + +
+ ); + } + + return ( +
+
Allowed origins (one per line, or `*` for any):
+