fix: prioritize v02 workspace first screen
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
const GET_PROXY_PREFIXES = Object.freeze(["/v1/"]);
|
||||
const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session"]);
|
||||
const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session", "/auth/bootstrap"]);
|
||||
const POST_PROXY_ROUTES = new Set([
|
||||
"/auth/login",
|
||||
"/auth/logout",
|
||||
@@ -15,6 +15,7 @@ const POST_PROXY_ROUTES = new Set([
|
||||
]);
|
||||
const PUBLIC_PROXY_ROUTES = new Set([
|
||||
"GET /auth/session",
|
||||
"GET /auth/bootstrap",
|
||||
"POST /auth/login",
|
||||
"POST /auth/logout",
|
||||
"POST /v1/agent/chat",
|
||||
|
||||
@@ -54,6 +54,15 @@ test("cloud web proxies public WebUI performance telemetry to cloud-api", () =>
|
||||
});
|
||||
});
|
||||
|
||||
test("cloud web exposes auth bootstrap as a public auth route", () => {
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/auth/bootstrap"), {
|
||||
proxy: true,
|
||||
authRequired: false,
|
||||
publicRoute: true,
|
||||
routeKey: "GET /auth/bootstrap"
|
||||
});
|
||||
});
|
||||
|
||||
test("cloud web proxies authenticated API key management write routes", () => {
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/api-keys"), {
|
||||
proxy: true,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createServer } from "node:http";
|
||||
import { createServer, request as httpRequest } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gzip } from "node:zlib";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { isCloudWebSseRoute, proxyCloudApiRequest } from "./cloud-web-proxy.mjs";
|
||||
import { isCloudWebSseRoute, proxyCloudApiRequest, upstreamRequestHeaders } from "./cloud-web-proxy.mjs";
|
||||
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
|
||||
|
||||
const readOnlyRpcMethods = new Set([
|
||||
@@ -15,6 +15,8 @@ const readOnlyRpcMethods = new Set([
|
||||
]);
|
||||
const gzipAsync = promisify(gzip);
|
||||
const GZIP_MIN_BYTES = 1024;
|
||||
const DEFAULT_AUTH_USERNAME = "admin";
|
||||
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
|
||||
|
||||
export async function serveCloudWeb(options) {
|
||||
const server = createCloudWebServer(options);
|
||||
@@ -86,6 +88,10 @@ export function createCloudWebServer({
|
||||
|
||||
export async function handleCloudWebAuth(options) {
|
||||
const { request, response, url } = options;
|
||||
if (url.pathname === "/auth/bootstrap" && request.method === "GET") {
|
||||
await handleAuthBootstrap(options);
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === "/auth/session" && request.method === "GET") {
|
||||
await proxyCloudApi(options);
|
||||
return true;
|
||||
@@ -103,6 +109,101 @@ export async function handleCloudWebAuth(options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleAuthBootstrap(options) {
|
||||
const { request, response, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson } = options;
|
||||
if (!cloudApiBaseUrl) {
|
||||
sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: "/auth/bootstrap" });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
||||
if (session.ok && session.body?.authenticated === true) {
|
||||
sendJsonWithForwardedCookies(response, session.statusCode, session.body, session.headers);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = webAuthConfig();
|
||||
if (config.mode !== "auto") {
|
||||
sendJsonWithForwardedCookies(response, session.statusCode || 200, session.body ?? { authenticated: false }, session.headers);
|
||||
return;
|
||||
}
|
||||
|
||||
const login = await requestCloudApiJson({
|
||||
request,
|
||||
method: "POST",
|
||||
pathname: "/auth/login",
|
||||
body: JSON.stringify({ username: config.username, password: config.password }),
|
||||
cloudApiBaseUrl,
|
||||
cloudApiProxyTimeoutMs
|
||||
});
|
||||
sendJsonWithForwardedCookies(response, login.statusCode || 502, login.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, login.headers);
|
||||
}
|
||||
|
||||
function webAuthConfig() {
|
||||
return {
|
||||
mode: authConfigValue("HWLAB_CLOUD_WEB_AUTH_MODE", "auto"),
|
||||
username: authConfigValue("HWLAB_CLOUD_WEB_AUTH_USERNAME", DEFAULT_AUTH_USERNAME),
|
||||
password: authConfigValue("HWLAB_CLOUD_WEB_AUTH_PASSWORD", DEFAULT_AUTH_PASSWORD)
|
||||
};
|
||||
}
|
||||
|
||||
function authConfigValue(name, fallback) {
|
||||
const value = process.env[name];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function requestCloudApiJson({ request, method, pathname, body = "", cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const target = new URL(pathname, cloudApiBaseUrl);
|
||||
const proxyRequest = { method, headers: upstreamRequestHeaders({ headers: request.headers }, body) };
|
||||
const upstream = httpRequest(target, { method, headers: proxyRequest.headers }, (upstreamResponse) => {
|
||||
const chunks = [];
|
||||
upstreamResponse.on("data", (chunk) => chunks.push(chunk));
|
||||
upstreamResponse.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
let parsed = null;
|
||||
try { parsed = rawBody ? JSON.parse(rawBody) : null; } catch { parsed = { error: "invalid_json", rawBody: rawBody.slice(0, 200) }; }
|
||||
resolve({ ok: (upstreamResponse.statusCode || 0) >= 200 && (upstreamResponse.statusCode || 0) < 300, statusCode: upstreamResponse.statusCode || 502, body: parsed, headers: upstreamResponse.headers });
|
||||
});
|
||||
upstreamResponse.on("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
|
||||
});
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
upstream.destroy();
|
||||
resolve({ ok: false, statusCode: 504, body: { authenticated: false, error: "auth_bootstrap_timeout" }, headers: {} });
|
||||
}, cloudApiProxyTimeoutMs);
|
||||
upstream.on("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
|
||||
});
|
||||
upstream.on("close", () => clearTimeout(timeout));
|
||||
if (body) upstream.write(body);
|
||||
upstream.end();
|
||||
});
|
||||
}
|
||||
|
||||
function sendJsonWithForwardedCookies(response, statusCode, body, headers = {}) {
|
||||
const payload = JSON.stringify(body);
|
||||
const setCookie = headers["set-cookie"];
|
||||
const responseHeaders = {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload)
|
||||
};
|
||||
if (setCookie) responseHeaders["set-cookie"] = setCookie;
|
||||
response.writeHead(statusCode, responseHeaders);
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
async function readRequestBody(request) {
|
||||
let body = "";
|
||||
for await (const chunk of request) {
|
||||
|
||||
@@ -47,6 +47,82 @@ test("cloud web auth requests are proxied once", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web auth bootstrap keeps an existing session without auto-login", async () => {
|
||||
const upstreamRequests = [];
|
||||
const upstream = createServer((request, response) => {
|
||||
upstreamRequests.push({ method: request.method, url: request.url, cookie: request.headers.cookie });
|
||||
request.resume();
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ authenticated: true, actor: { username: "alice" }, path: request.url }));
|
||||
});
|
||||
await listen(upstream);
|
||||
|
||||
const cloudWeb = createCloudWebServer({
|
||||
serviceId: "hwlab-cloud-web",
|
||||
cloudApiBaseUrl: serverUrl(upstream),
|
||||
cloudApiProxyTimeoutMs: 1000,
|
||||
healthPayload: () => ({ status: "ok" }),
|
||||
sendJson(response, statusCode, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
|
||||
response.end(payload);
|
||||
}
|
||||
});
|
||||
await listen(cloudWeb);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl(cloudWeb)}/auth/bootstrap`, { headers: { cookie: "hwlab_session=alice" } });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "alice" }, path: "/auth/session" });
|
||||
assert.deepEqual(upstreamRequests, [{ method: "GET", url: "/auth/session", cookie: "hwlab_session=alice" }]);
|
||||
} finally {
|
||||
await close(cloudWeb);
|
||||
await close(upstream);
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web auth bootstrap auto-logins only after an unauthenticated session", async () => {
|
||||
const upstreamRequests = [];
|
||||
const upstream = createServer(async (request, response) => {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
upstreamRequests.push({ method: request.method, url: request.url, body });
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json",
|
||||
...(request.url === "/auth/login" ? { "set-cookie": "hwlab_session=session-auto; Path=/; HttpOnly" } : {})
|
||||
});
|
||||
response.end(JSON.stringify(request.url === "/auth/login" ? { authenticated: true, actor: { username: "admin" } } : { authenticated: false }));
|
||||
});
|
||||
await listen(upstream);
|
||||
|
||||
const cloudWeb = createCloudWebServer({
|
||||
serviceId: "hwlab-cloud-web",
|
||||
cloudApiBaseUrl: serverUrl(upstream),
|
||||
cloudApiProxyTimeoutMs: 1000,
|
||||
healthPayload: () => ({ status: "ok" }),
|
||||
sendJson(response, statusCode, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
|
||||
response.end(payload);
|
||||
}
|
||||
});
|
||||
await listen(cloudWeb);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl(cloudWeb)}/auth/bootstrap`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("set-cookie"), "hwlab_session=session-auto; Path=/; HttpOnly");
|
||||
assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "admin" } });
|
||||
assert.deepEqual(upstreamRequests, [
|
||||
{ method: "GET", url: "/auth/session", body: "" },
|
||||
{ method: "POST", url: "/auth/login", body: JSON.stringify({ username: "admin", password: "hwlab2026" }) }
|
||||
]);
|
||||
} finally {
|
||||
await close(cloudWeb);
|
||||
await close(upstream);
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web proxies Admin Access write routes", async () => {
|
||||
const upstreamRequests = [];
|
||||
const upstream = createServer(async (request, response) => {
|
||||
|
||||
Reference in New Issue
Block a user