fix(dashboard): Playground Compare tab loading + HTTP method guard (#4024)

randomUUID non-HTTPS fallback + static CompareTab import; raw HTTP TRACE->405 method guard wired into dev + standalone servers. Integrated into release/v3.8.27.
This commit is contained in:
Randi
2026-06-16 17:46:09 -04:00
committed by GitHub
parent ad067a193e
commit e68cd47470
11 changed files with 310 additions and 30 deletions

View File

@@ -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:

View File

@@ -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"],

View File

@@ -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",

View File

@@ -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");

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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" && (
<ChatTab configState={configState} onMetricsUpdate={handleMetricsUpdate} />
)}
{effectiveTab === "compare" && (
<CompareTab configState={configState} />
)}
{effectiveTab === "api" && (
<ApiTab configState={configState} />
)}
{effectiveTab === "build" && (
<BuildTab configState={configState} />
)}
{effectiveTab === "compare" && <CompareTab configState={configState} />}
{effectiveTab === "api" && <ApiTab configState={configState} />}
{effectiveTab === "build" && <BuildTab configState={configState} />}
</div>
{/* Config pane — always visible, collapsible */}
{/* SLOT_PRESETS and SLOT_IMPROVE are inside StudioConfigPane */}
<StudioConfigPane
configState={configState}
setConfigState={setConfigState}
/>
<StudioConfigPane configState={configState} setConfigState={setConfigState} />
</div>
</div>
);

View File

@@ -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<ColumnState[]>(() => [
{
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<Record<string, unknown>> | undefined) ?? [];
const choices = (parsed["choices"] as Array<Record<string, unknown>> | undefined) ?? [];
const delta = choices[0]?.["delta"] as Record<string, unknown> | undefined;
const content = delta?.["content"];

View File

@@ -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<string, string>();
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/);
});

View File

@@ -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("<BuildWizard"), "BuildTab mounts BuildWizard");
assert.ok(tab.includes("runRequest") && tab.includes("sendToolResult"), "BuildTab preserves run/tool handlers");
assert.ok(
tab.includes("runRequest") && tab.includes("sendToolResult"),
"BuildTab preserves run/tool handlers"
);
});
test("playground build i18n: playground.build keys present with en/pt parity", () => {