feat: 补齐 v03 用户管理 Web parity

This commit is contained in:
lyon
2026-06-14 11:04:52 +08:00
parent ee78ed9fdc
commit 7823bb7a95
19 changed files with 878 additions and 149 deletions
@@ -2,6 +2,7 @@ const GET_PROXY_PREFIXES = Object.freeze(["/v1/"]);
const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session", "/auth/bootstrap", "/auth/workspace-bootstrap"]);
const POST_PROXY_ROUTES = new Set([
"/auth/login",
"/auth/register",
"/auth/logout",
"/json-rpc",
"/v1/agent/chat",
@@ -18,6 +19,7 @@ const PUBLIC_PROXY_ROUTES = new Set([
"GET /auth/bootstrap",
"GET /auth/workspace-bootstrap",
"POST /auth/login",
"POST /auth/register",
"POST /auth/logout",
"POST /v1/agent/chat",
"POST /v1/agent/chat/cancel",
@@ -54,13 +56,16 @@ export function isCloudApiProxyRoute(method, pathname) {
isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) ||
isProviderProfileManagementProxyRoute(normalizedMethod, normalizedPath) ||
isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) ||
isAdminBillingWriteProxyRoute(normalizedMethod, normalizedPath) ||
normalizedPath.startsWith("/v1/rpc/") ||
normalizedPath.startsWith("/v1/agent/sessions/") ||
isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath);
}
if (normalizedMethod === "PATCH" || normalizedMethod === "PUT") {
return isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) ||
isAdminBillingWriteProxyRoute(normalizedMethod, normalizedPath) ||
isProviderProfileManagementProxyRoute(normalizedMethod, normalizedPath) ||
isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) ||
isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath) ||
isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath);
}
@@ -97,11 +102,22 @@ function isProviderProfileManagementProxyRoute(method, pathname) {
function isApiKeyWriteProxyRoute(method, pathname) {
if (pathname === "/v1/api-keys" && method === "POST") return true;
if (!pathname.startsWith("/v1/api-keys/")) return false;
if (method === "PATCH") return true;
if (method === "DELETE") return true;
if (method !== "POST") return false;
return pathname.endsWith("/regenerate");
}
function isAdminBillingWriteProxyRoute(method, pathname) {
const parts = pathname.split("/").filter(Boolean);
if (parts.length < 4 || parts[0] !== "v1" || parts[1] !== "admin" || parts[2] !== "billing" || parts[3] !== "users") return false;
if (method === "POST" && parts.length === 4) return true;
if (method === "PATCH" && parts.length === 5) return true;
if (method === "PATCH" && parts.length === 6 && parts[5] === "status") return true;
if (method === "POST" && parts.length === 7 && parts[5] === "credits" && parts[6] === "adjust") return true;
return false;
}
function isWorkbenchWorkspaceWriteProxyRoute(method, pathname) {
const parts = pathname.split("/").filter(Boolean);
if (parts.length < 4 || parts[0] !== "v1" || parts[1] !== "workbench" || parts[2] !== "workspace") return false;
@@ -67,6 +67,12 @@ test("cloud web exposes auth bootstrap as a public auth route", () => {
publicRoute: true,
routeKey: "GET /auth/workspace-bootstrap"
});
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/auth/register"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "POST /auth/register"
});
});
test("cloud web proxies authenticated API key management write routes", () => {
@@ -82,6 +88,12 @@ test("cloud web proxies authenticated API key management write routes", () => {
publicRoute: false,
routeKey: "POST /v1/api-keys/key_1/regenerate"
});
assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/api-keys/key_1"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "PATCH /v1/api-keys/key_1"
});
assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/api-keys/key_1"), {
proxy: true,
authRequired: true,
@@ -90,6 +102,33 @@ test("cloud web proxies authenticated API key management write routes", () => {
});
});
test("cloud web proxies authenticated admin billing user management write routes", () => {
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/billing/users"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "POST /v1/admin/billing/users"
});
assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/admin/billing/users/usr_alice"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "PATCH /v1/admin/billing/users/usr_alice"
});
assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/admin/billing/users/usr_alice/status"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "PATCH /v1/admin/billing/users/usr_alice/status"
});
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/billing/users/usr_alice/credits/adjust"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "POST /v1/admin/billing/users/usr_alice/credits/adjust"
});
});
test("cloud web proxies authenticated Admin Access write routes", () => {
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/access/check"), {
proxy: true,
+6 -31
View File
@@ -15,8 +15,6 @@ const readOnlyRpcMethods = new Set([
]);
const gzipAsync = promisify(gzip);
const GZIP_MIN_BYTES = 1024;
const DEFAULT_AUTH_USERNAME = "admin";
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
const DEFAULT_WORKBENCH_PROJECT_ID = "prj_hwpod_workbench";
const CLIENT_DISCONNECT_ERROR_CODES = new Set([
"ECONNRESET",
@@ -193,6 +191,10 @@ export async function handleCloudWebAuth(options) {
await proxyCloudApi(options);
return true;
}
if (url.pathname === "/auth/register" && request.method === "POST") {
await proxyCloudApi(options);
return true;
}
if (url.pathname === "/auth/logout" && request.method === "POST") {
await proxyCloudApi(options);
return true;
@@ -246,35 +248,8 @@ async function handleWorkspaceBootstrap(options) {
}
async function resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
const config = webAuthConfig();
if (config.mode !== "auto" || hasCookieHeader(request.headers.cookie)) {
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
if (session.ok && session.body?.authenticated === true) return withCookieHeader(request, session);
if (config.mode !== "auto") return withCookieHeader(request, session);
}
const login = await requestCloudApiJson({
request,
method: "POST",
pathname: "/auth/login",
body: JSON.stringify({ username: config.username, password: config.password }),
cloudApiBaseUrl,
cloudApiProxyTimeoutMs
});
return withCookieHeader(request, login);
}
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;
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
return withCookieHeader(request, session);
}
function authConfigValueFromSearch(value, fallback) {