From 744890847a8b7017e46353f90d032fb0fa202f36 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 22 May 2026 08:58:25 +0000 Subject: [PATCH] fix: proxy cloud web read-only API --- scripts/dev-artifact-publish.mjs | 85 +++++++++++++++++++++++++++ web/hwlab-cloud-web/scripts/check.mjs | 10 +++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index a329d2a3..d9e36b43 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -308,6 +308,13 @@ const environment = process.env.HWLAB_ENVIRONMENT || "dev"; const runtimeKind = process.env.HWLAB_ARTIFACT_KIND || "health-placeholder"; const entrypoint = process.env.HWLAB_SERVICE_ENTRYPOINT || ""; const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "8080", 10); +const cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || ""; +const readOnlyRpcMethods = new Set([ + "system.health", + "cloud.adapter.describe", + "audit.event.query", + "evidence.record.query" +]); function sendJson(response, statusCode, body) { const payload = JSON.stringify(body, null, 2); @@ -340,6 +347,76 @@ function runNodeEntrypoint(file) { }); } +async function readRequestBody(request) { + let body = ""; + for await (const chunk of request) { + body += chunk; + } + return body; +} + +function copyHeaders(headers) { + const result = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + result[key] = value; + } + return result; +} + +async function proxyCloudApi(request, response, url) { + if (!cloudApiBaseUrl) { + sendJson(response, 503, { + error: "cloud_api_base_url_missing", + serviceId, + path: url.pathname + }); + return; + } + + let body = ""; + if (request.method !== "GET") { + body = await readRequestBody(request); + } + + if (url.pathname === "/json-rpc") { + try { + const envelope = body ? JSON.parse(body) : {}; + if (!readOnlyRpcMethods.has(envelope.method)) { + sendJson(response, 403, { + error: "readonly_rpc_required", + serviceId, + method: envelope.method || null + }); + return; + } + } catch (error) { + sendJson(response, 400, { + error: "invalid_json", + serviceId, + reason: error.message + }); + return; + } + } + + const target = new URL(url.pathname + url.search, cloudApiBaseUrl); + const upstream = await fetch(target, { + method: request.method, + headers: { + accept: request.headers.accept || "application/json", + "content-type": request.headers["content-type"] || "application/json" + }, + body: request.method === "GET" ? undefined : body + }); + const payload = Buffer.from(await upstream.arrayBuffer()); + response.writeHead(upstream.status, { + ...copyHeaders(Object.fromEntries(upstream.headers.entries())), + "content-length": payload.length + }); + response.end(payload); +} + async function serveCloudWeb() { const roots = ["/app/web/hwlab-cloud-web/dist", "/app/web/hwlab-cloud-web"]; const server = createServer(async (request, response) => { @@ -349,6 +426,14 @@ async function serveCloudWeb() { return; } + if ( + (request.method === "GET" && (url.pathname === "/v1" || url.pathname.startsWith("/v1/"))) || + (request.method === "POST" && url.pathname === "/json-rpc") + ) { + await proxyCloudApi(request, response, url); + return; + } + const relativePath = url.pathname === "/" ? "index.html" : url.pathname.slice(1); for (const root of roots) { const candidate = path.resolve(root, relativePath); diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index ee4d7680..d4198b73 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -19,7 +19,8 @@ for (const file of requiredFiles) { const html = fs.readFileSync(path.resolve(rootDir, "index.html"), "utf8"); const app = fs.readFileSync(path.resolve(rootDir, "app.mjs"), "utf8"); -const frontendSource = `${html}\n${app}`; +const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-artifact-publish.mjs"), "utf8"); +const frontendSource = `${html}\n${app}\n${artifactPublisher}`; assert.match(html, /HWLAB DEV Console/); assert.match(html, /M3 Diagnostics Console/); @@ -48,6 +49,13 @@ assert.match(app, /callRpc\("system\.health"\)/); assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/); assert.match(app, /callRpc\("audit\.event\.query"/); assert.match(app, /callRpc\("evidence\.record\.query"/); +assert.match(artifactPublisher, /HWLAB_API_BASE_URL/); +assert.match(artifactPublisher, /readOnlyRpcMethods/); +assert.match(artifactPublisher, /"system\.health"/); +assert.match(artifactPublisher, /"cloud\.adapter\.describe"/); +assert.match(artifactPublisher, /"audit\.event\.query"/); +assert.match(artifactPublisher, /"evidence\.record\.query"/); +assert.match(artifactPublisher, /readonly_rpc_required/); assert.match(app, /degraded/); assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/); assert.doesNotMatch(app, /callRpc\("hardware\./);