diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml
index cf6afb040f..6666c5fceb 100644
--- a/docs/reference/openapi.yaml
+++ b/docs/reference/openapi.yaml
@@ -1702,6 +1702,8 @@ paths:
type: array
items:
$ref: "#/components/schemas/ApiKey"
+ "401":
+ description: Authentication required
post:
tags: [API Keys]
summary: Create API key
@@ -1718,8 +1720,43 @@ paths:
responses:
"201":
description: Created API key (includes full key value)
+ "401":
+ description: Authentication required
/api/keys/{id}:
+ get:
+ tags: [API Keys]
+ summary: Get API key
+ parameters:
+ - $ref: "#/components/parameters/ResourceId"
+ responses:
+ "200":
+ description: API key metadata
+ "401":
+ description: Authentication required
+ "404":
+ description: Key not found
+ patch:
+ tags: [API Keys]
+ summary: Update API key
+ parameters:
+ - $ref: "#/components/parameters/ResourceId"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ responses:
+ "200":
+ description: API key settings updated
+ "400":
+ description: Invalid update request
+ "401":
+ description: Authentication required
+ "404":
+ description: Key not found
delete:
tags: [API Keys]
summary: Delete API key
@@ -1728,6 +1765,10 @@ paths:
responses:
"200":
description: Key deleted
+ "401":
+ description: Authentication required
+ "404":
+ description: Key not found
/api/combos:
get:
@@ -3444,11 +3485,18 @@ paths:
properties:
password:
type: string
+ minLength: 1
responses:
"200":
description: JWT token returned
+ "400":
+ description: Invalid login request
"401":
description: Invalid password
+ "403":
+ description: Password setup required
+ "429":
+ description: Too many failed attempts
/api/auth/logout:
post:
diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs
index a7afff2d7a..464d712045 100644
--- a/scripts/build/assembleStandalone.mjs
+++ b/scripts/build/assembleStandalone.mjs
@@ -125,6 +125,11 @@ const EXTRA_MODULE_ENTRIES = [
src: ["scripts", "dev", "peer-stamp.mjs"],
dest: ["peer-stamp.mjs"],
},
+ {
+ label: "HTTP method guard (server-ws.mjs dependency)",
+ src: ["scripts", "dev", "http-method-guard.cjs"],
+ dest: ["http-method-guard.cjs"],
+ },
{
label: "responses-ws-proxy (server-ws.mjs dependency)",
src: ["scripts", "dev", "responses-ws-proxy.mjs"],
diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts
index 499f91d657..5b7c66106f 100644
--- a/scripts/build/pack-artifact-policy.ts
+++ b/scripts/build/pack-artifact-policy.ts
@@ -35,6 +35,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
".env.example",
"BUILD_SHA",
"docs/reference/openapi.yaml",
+ "http-method-guard.cjs",
"open-sse/mcp-server/server.js",
// LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads
// (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server.
@@ -124,6 +125,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"dist/server-ws.mjs",
"dist/responses-ws-proxy.mjs",
"dist/peer-stamp.mjs",
+ "dist/http-method-guard.cjs",
"dist/webdav-handler.mjs",
"bin/cli/program.mjs",
"bin/mcp-server.mjs",
diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts
index 729a88a0f5..bd8612adfa 100644
--- a/scripts/build/prepublish.ts
+++ b/scripts/build/prepublish.ts
@@ -40,6 +40,7 @@ const ROOT = join(__dirname, "..", "..");
const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
const DIST_DIR = join(ROOT, "dist");
+const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n';
function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] {
let entries: string[] = [];
@@ -153,6 +154,20 @@ assembleStandalone({
});
console.log(" ✅ Standalone bundle assembled to dist/");
+const distServer = join(DIST_DIR, "server.js");
+const methodGuardSrc = join(ROOT, "scripts", "dev", "http-method-guard.cjs");
+const methodGuardDest = join(DIST_DIR, "http-method-guard.cjs");
+if (existsSync(methodGuardSrc)) {
+ cpSync(methodGuardSrc, methodGuardDest);
+}
+if (existsSync(distServer)) {
+ const serverSource = readFileSync(distServer, "utf8");
+ if (!serverSource.includes("installHttpMethodGuard")) {
+ writeFileSync(distServer, METHOD_GUARD_REQUIRE + serverSource);
+ console.log(" ✅ Patched dist/server.js with HTTP method guard.");
+ }
+}
+
// ── Step 8: Compile + copy MITM cert utilities ─────────────
const mitmSrc = join(ROOT, "src", "mitm");
const mitmDest = join(DIST_DIR, "src", "mitm");
diff --git a/scripts/dev/http-method-guard.cjs b/scripts/dev/http-method-guard.cjs
new file mode 100644
index 0000000000..f46051cae8
--- /dev/null
+++ b/scripts/dev/http-method-guard.cjs
@@ -0,0 +1,82 @@
+"use strict";
+
+const http = require("node:http");
+
+const HIGH_RISK_METHOD_RULES = [
+ [/^\/api\/auth\/login\/?$/, ["POST"]],
+ [/^\/api\/auth\/logout\/?$/, ["POST"]],
+ [/^\/api\/keys\/?$/, ["GET", "POST"]],
+ [/^\/api\/keys\/[^/]+\/?$/, ["GET", "PATCH", "DELETE"]],
+];
+
+let installed = false;
+
+function getPathname(req) {
+ const rawUrl = typeof req?.url === "string" && req.url ? req.url : "/";
+ try {
+ return new URL(rawUrl, "http://localhost").pathname;
+ } catch {
+ return rawUrl.split("?")[0] || "/";
+ }
+}
+
+function getAllowedMethods(pathname) {
+ for (const [pattern, methods] of HIGH_RISK_METHOD_RULES) {
+ if (pattern.test(pathname)) return methods;
+ }
+ return null;
+}
+
+function getAllowHeader(pathname) {
+ const methods = getAllowedMethods(pathname);
+ return methods ? methods.join(", ") : null;
+}
+
+function maybeHandleDisallowedMethod(req, res) {
+ const method = typeof req?.method === "string" ? req.method.toUpperCase() : "";
+ const pathname = getPathname(req);
+ const methods = getAllowedMethods(pathname);
+ if (!methods || method === "OPTIONS" || methods.includes(method)) return false;
+
+ res.statusCode = 405;
+ res.setHeader("Allow", methods.join(", "));
+ res.setHeader("Cache-Control", "no-store");
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(
+ JSON.stringify({
+ error: {
+ code: "METHOD_NOT_ALLOWED",
+ message: `${method || "Method"} is not allowed`,
+ },
+ })
+ );
+ return true;
+}
+
+function wrapRequestListenerWithMethodGuard(listener) {
+ return function methodGuardRequestHandler(req, res) {
+ if (maybeHandleDisallowedMethod(req, res)) return;
+ return listener.call(this, req, res);
+ };
+}
+
+function installHttpMethodGuard() {
+ if (installed) return;
+ installed = true;
+
+ const originalCreateServer = http.createServer.bind(http);
+ http.createServer = function createServerWithMethodGuard(...args) {
+ const lastFnIdx = args.map((arg) => typeof arg === "function").lastIndexOf(true);
+ if (lastFnIdx >= 0) {
+ args[lastFnIdx] = wrapRequestListenerWithMethodGuard(args[lastFnIdx]);
+ }
+ return originalCreateServer(...args);
+ };
+}
+
+module.exports = {
+ getAllowHeader,
+ maybeHandleDisallowedMethod,
+ wrapRequestListenerWithMethodGuard,
+ installHttpMethodGuard,
+};
diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs
index 23a6d326ed..84ecf3f0aa 100644
--- a/scripts/dev/run-next.mjs
+++ b/scripts/dev/run-next.mjs
@@ -9,9 +9,12 @@ import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mj
import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs";
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs";
+import methodGuard from "./http-method-guard.cjs";
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
import { randomUUID } from "node:crypto";
+const { maybeHandleDisallowedMethod } = methodGuard;
+
// Pre-read DATA_DIR from local .env before bootstrap resolves paths
if (!process.env.DATA_DIR) {
try {
@@ -83,6 +86,7 @@ async function start() {
});
const server = http.createServer((req, res) => {
+ if (maybeHandleDisallowedMethod(req, res)) return;
// Stamp the real TCP peer IP before Next sees the request, so the authz
// middleware can decide LOCAL_ONLY locality without trusting the Host header.
stampPeerIp(req);
diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs
index b12f2093cf..ceb0fde088 100644
--- a/scripts/dev/standalone-server-ws.mjs
+++ b/scripts/dev/standalone-server-ws.mjs
@@ -3,9 +3,11 @@ import { randomUUID } from "node:crypto";
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs";
import { maybeHandleWebdav } from "./webdav-handler.mjs";
+import methodGuard from "./http-method-guard.cjs";
const originalCreateServer = http.createServer.bind(http);
const proxiesByPort = new Map();
+const { wrapRequestListenerWithMethodGuard } = methodGuard;
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret proving the trusted peer-IP stamp came from this server.
@@ -71,9 +73,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
// createServer; wrap it so the real TCP peer IP is stamped before Next runs.
const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true);
if (lastFnIdx >= 0) {
- // WebDAV intercept wraps outermost (first to run), then peer-stamp, then Next.
- args[lastFnIdx] = wrapRequestListenerWithWebdav(
- wrapRequestListenerWithPeerStamp(args[lastFnIdx])
+ // Method guard runs before Next because Next 16 rejects TRACE while constructing requests.
+ args[lastFnIdx] = wrapRequestListenerWithMethodGuard(
+ wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx]))
);
}
@@ -89,7 +91,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
if (eventName === "request" && typeof listener === "function") {
return originalOn(
eventName,
- wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
+ wrapRequestListenerWithMethodGuard(
+ wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
+ )
);
}
return originalOn(eventName, listener);
@@ -102,7 +106,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
if (eventName === "request" && typeof listener === "function") {
return originalAddListener(
eventName,
- wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
+ wrapRequestListenerWithMethodGuard(
+ wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
+ )
);
}
return originalAddListener(eventName, listener);
diff --git a/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx b/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx
index 5796fc90fb..02365225fa 100644
--- a/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx
+++ b/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx
@@ -2,18 +2,18 @@
// src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx
-import { useState } from "react";
+import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
+import { useState } from "react";
+import type { StreamMetrics } from "@/shared/schemas/playground";
import StudioTopBar, { type StudioTab } from "./components/StudioTopBar";
import StudioConfigPane, { type ConfigState } from "./components/StudioConfigPane";
import { DEFAULT_PARAMS } from "./components/ParamSliders";
-import dynamic from "next/dynamic";
-import type { StreamMetrics } from "@/shared/schemas/playground";
+import CompareTab from "./components/tabs/CompareTab";
// Lazy-load tabs to reduce initial bundle size
const ChatTab = dynamic(() => import("./components/tabs/ChatTab"), { ssr: false });
const ApiTab = dynamic(() => import("./components/tabs/ApiTab"), { ssr: false });
-const CompareTab = dynamic(() => import("./components/tabs/CompareTab"), { ssr: false });
const BuildTab = dynamic(() => import("./components/tabs/BuildTab"), { ssr: false });
const INITIAL_METRICS: StreamMetrics = {
@@ -92,23 +92,14 @@ export function PlaygroundStudio() {
{effectiveTab === "chat" && (
)}
- {effectiveTab === "compare" && (
-
- )}
- {effectiveTab === "api" && (
-
- )}
- {effectiveTab === "build" && (
-
- )}
+ {effectiveTab === "compare" && }
+ {effectiveTab === "api" && }
+ {effectiveTab === "build" && }
{/* Config pane — always visible, collapsible */}
{/* SLOT_PRESETS and SLOT_IMPROVE are inside StudioConfigPane */}
-
+
);
diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
index 8f208b8460..a06b1a7cff 100644
--- a/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
@@ -31,6 +31,13 @@ const INITIAL_METRICS: StreamMetrics = {
costUsd: null,
};
+function createColumnId() {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
+ }
+ return `compare-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+}
+
/**
* ColumnMetricsTracker — plain (non-React) imperative object for tracking per-column stream metrics.
* Uses simple mutable state (no React hooks) so it can be stored in a ref and called safely
@@ -91,7 +98,7 @@ class ColumnMetricsTracker {
export default function CompareTab({ configState }: CompareTabProps) {
const [columns, setColumns] = useState(() => [
{
- id: crypto.randomUUID(),
+ id: createColumnId(),
model: configState.model,
status: "idle",
metrics: INITIAL_METRICS,
@@ -161,7 +168,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
function addColumn() {
if (columns.length >= MAX_COLUMNS) return;
const model = newModel.trim() || configState.model;
- const id = crypto.randomUUID();
+ const id = createColumnId();
setColumns((prev) => [
...prev,
{ id, model, status: "idle", metrics: INITIAL_METRICS, response: "" },
@@ -266,8 +273,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
continue;
}
- const choices =
- (parsed["choices"] as Array> | undefined) ?? [];
+ const choices = (parsed["choices"] as Array> | undefined) ?? [];
const delta = choices[0]?.["delta"] as Record | undefined;
const content = delta?.["content"];
diff --git a/tests/unit/dast-method-not-allowed.test.ts b/tests/unit/dast-method-not-allowed.test.ts
new file mode 100644
index 0000000000..4a7f16e0d1
--- /dev/null
+++ b/tests/unit/dast-method-not-allowed.test.ts
@@ -0,0 +1,96 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import test from "node:test";
+
+const require = createRequire(import.meta.url);
+const { maybeHandleDisallowedMethod } = require("../../scripts/dev/http-method-guard.cjs");
+
+test("raw HTTP guard rejects high-risk unsupported methods before Next.js handles them", () => {
+ const cases: Array<{
+ label: string;
+ method: string;
+ url: string;
+ allow: string;
+ }> = [
+ { label: "login TRACE", method: "TRACE", url: "/api/auth/login", allow: "POST" },
+ { label: "login QUERY", method: "QUERY", url: "/api/auth/login", allow: "POST" },
+ { label: "logout QUERY", method: "QUERY", url: "/api/auth/logout", allow: "POST" },
+ { label: "keys QUERY", method: "QUERY", url: "/api/keys", allow: "GET, POST" },
+ {
+ label: "key detail QUERY",
+ method: "QUERY",
+ url: "/api/keys/0",
+ allow: "GET, PATCH, DELETE",
+ },
+ ];
+
+ for (const testCase of cases) {
+ let body = "";
+ const headers = new Map();
+ const response = {
+ statusCode: 200,
+ setHeader(name: string, value: string) {
+ headers.set(name.toLowerCase(), value);
+ },
+ end(chunk: string) {
+ body += chunk;
+ },
+ };
+
+ const handled = maybeHandleDisallowedMethod(
+ { method: testCase.method, url: testCase.url },
+ response
+ );
+ assert.equal(handled, true, testCase.label);
+ assert.equal(response.statusCode, 405, testCase.label);
+ assert.equal(headers.get("allow"), testCase.allow, testCase.label);
+ assert.match(body, /METHOD_NOT_ALLOWED/, testCase.label);
+ }
+});
+
+test("raw HTTP guard allows documented methods through", () => {
+ const response = {
+ setHeader() {
+ throw new Error("allowed methods should not write headers");
+ },
+ end() {
+ throw new Error("allowed methods should not end the response");
+ },
+ };
+
+ assert.equal(
+ maybeHandleDisallowedMethod({ method: "POST", url: "/api/auth/login" }, response),
+ false
+ );
+ assert.equal(maybeHandleDisallowedMethod({ method: "GET", url: "/api/keys" }, response), false);
+ assert.equal(
+ maybeHandleDisallowedMethod({ method: "OPTIONS", url: "/api/keys" }, response),
+ false
+ );
+ assert.equal(
+ maybeHandleDisallowedMethod({ method: "QUERY", url: "/api/health/ping" }, response),
+ false
+ );
+});
+
+test("OpenAPI documents high-risk route auth and setup responses", () => {
+ const spec = readFileSync("docs/reference/openapi.yaml", "utf8");
+ const apiKeyDetailStart = spec.indexOf(" /api/keys/{id}:");
+ const apiKeyDetailEnd = spec.indexOf("\n /api/combos:", apiKeyDetailStart);
+ const apiKeyDetail = spec.slice(apiKeyDetailStart, apiKeyDetailEnd);
+
+ assert.match(apiKeyDetail, /\n get:/);
+ assert.match(apiKeyDetail, /\n patch:/);
+ assert.match(apiKeyDetail, /\n delete:/);
+ assert.match(apiKeyDetail, /"401":\n\s+description: Authentication required/);
+ assert.match(apiKeyDetail, /"404":\n\s+description: Key not found/);
+
+ const loginStart = spec.indexOf(" /api/auth/login:");
+ const loginEnd = spec.indexOf("\n /api/auth/logout:", loginStart);
+ const login = spec.slice(loginStart, loginEnd);
+ assert.match(login, /"400":\n\s+description: Invalid login request/);
+ assert.match(login, /"401":\n\s+description: Invalid password/);
+ assert.match(login, /"403":\n\s+description: Password setup required/);
+ assert.match(login, /"429":\n\s+description: Too many failed attempts/);
+});
diff --git a/tests/unit/v388-phase4-playground.test.ts b/tests/unit/v388-phase4-playground.test.ts
index 40530c5196..6f68428ee2 100644
--- a/tests/unit/v388-phase4-playground.test.ts
+++ b/tests/unit/v388-phase4-playground.test.ts
@@ -20,15 +20,40 @@ test("playground compare: prompt input + rAF throttle + user message in request"
assert.ok(src.includes("requestAnimationFrame"), "throttles stream updates via rAF");
assert.ok(/role:\s*"user"/.test(src), "request body includes a user message");
assert.ok(src.includes("setPrompt"), "has a prompt input control");
+ assert.ok(src.includes("createColumnId"), "uses a browser-compatible column id helper");
+ assert.ok(!src.includes("id: crypto.randomUUID()"), "does not call randomUUID inline");
+});
+
+test("playground compare: tab is not lazy-loaded behind a click-time chunk", () => {
+ const src = read("src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx");
+ assert.ok(
+ src.includes('import CompareTab from "./components/tabs/CompareTab"'),
+ "CompareTab is statically imported"
+ );
+ assert.ok(
+ !src.includes('dynamic(() => import("./components/tabs/CompareTab")'),
+ "CompareTab is not loaded with next/dynamic"
+ );
});
test("playground build: wizard with 3 modes reusing editors; BuildTab keeps handlers", () => {
- const wiz = read("src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx");
- assert.ok(wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'), "three modes");
- assert.ok(wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"), "reuses both editors");
+ const wiz = read(
+ "src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx"
+ );
+ assert.ok(
+ wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'),
+ "three modes"
+ );
+ assert.ok(
+ wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"),
+ "reuses both editors"
+ );
const tab = read("src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx");
assert.ok(tab.includes(" {