Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/web/migrations/0006_api_key_origins.sql
Original file line number Diff line number Diff line change
@@ -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;
72 changes: 72 additions & 0 deletions apps/web/src/app/api/keys/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
}
}
39 changes: 25 additions & 14 deletions apps/web/src/app/api/v1/chat/completions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -68,16 +69,30 @@ 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<string, string> = 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) {
return errorResponse(
"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<string, string> = {};
Expand All @@ -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 = {
Expand Down Expand Up @@ -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");
}
9 changes: 8 additions & 1 deletion apps/web/src/app/api/v1/tools/ask-bridging/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -49,3 +52,7 @@ export async function POST(request: NextRequest) {
);
}
}

export async function OPTIONS(request: NextRequest) {
return preflightResponse(request, "POST, OPTIONS");
}
9 changes: 8 additions & 1 deletion apps/web/src/app/api/v1/tools/ask-orbit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -48,3 +51,7 @@ export async function POST(request: NextRequest) {
);
}
}

export async function OPTIONS(request: NextRequest) {
return preflightResponse(request, "POST, OPTIONS");
}
9 changes: 8 additions & 1 deletion apps/web/src/app/api/v1/tools/ask/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -56,3 +59,7 @@ export async function POST(request: NextRequest) {
);
}
}

export async function OPTIONS(request: NextRequest) {
return preflightResponse(request, "POST, OPTIONS");
}
9 changes: 8 additions & 1 deletion apps/web/src/app/api/v1/tools/backend/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -43,3 +46,7 @@ export async function POST(request: NextRequest) {
);
}
}

export async function OPTIONS(request: NextRequest) {
return preflightResponse(request, "POST, OPTIONS");
}
9 changes: 8 additions & 1 deletion apps/web/src/app/api/v1/tools/bridge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -43,3 +46,7 @@ export async function POST(request: NextRequest) {
);
}
}

export async function OPTIONS(request: NextRequest) {
return preflightResponse(request, "POST, OPTIONS");
}
9 changes: 8 additions & 1 deletion apps/web/src/app/api/v1/tools/context/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -44,3 +47,7 @@ export async function POST(request: NextRequest) {
);
}
}

export async function OPTIONS(request: NextRequest) {
return preflightResponse(request, "POST, OPTIONS");
}
Loading