fix: harden workflow console rpc boundary

This commit is contained in:
lda
2026-07-02 14:28:25 +07:00 Verified
parent f112f73a0e
commit bcf31583af
20 changed files with 890 additions and 465 deletions
+40 -3
View File
@@ -1,11 +1,15 @@
import { describe, it, expect, vi } from "vitest";
import { createApp, type RunOperation } from "./app.js";
import type { OperationExchange } from "@lda/workflow-rpc";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const makeExchange = (
overrides: Partial<OperationExchange> = {},
): OperationExchange => ({
operation: "workflow.health",
target: "http://127.0.0.1:8765/rpc",
label: "Health check",
interpreted: { status: "ok", store_root: "/tmp/store" },
exchange: { request: {}, response: { status: "ok" } },
@@ -15,13 +19,16 @@ const makeExchange = (
});
const okRunner: RunOperation = vi.fn(async (operation) =>
makeExchange({ operation }),
makeExchange({ operation, target: "http://127.0.0.1:8765/rpc" }),
);
const failRunner =
(code: string, message: string): RunOperation =>
async () => {
throw Object.assign(new Error(message), { _tag: code });
throw Object.assign(new Error(message), {
_tag: code,
exchange: { request: { method: "x" }, response: { error: message } },
});
};
const app = createApp({ runOperation: okRunner });
@@ -46,7 +53,7 @@ describe("POST /api/connect", () => {
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.connection.status).toBe("connected");
expect(body.connection.target).toBe("http://127.0.0.1:8000/rpc");
expect(body.connection.target).toBe("http://127.0.0.1:8765/rpc");
expect(body.connection.serverStatus).toBe("ok");
expect(okRunner).toHaveBeenCalledWith(
"workflow.health",
@@ -139,6 +146,10 @@ describe("error mapping", () => {
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("upstream_timeout");
expect(body.exchange).toEqual({
request: { method: "x" },
response: { error: "timed out" },
});
expect(body.error.stack).toBeUndefined();
});
@@ -238,3 +249,29 @@ describe("error mapping", () => {
}
});
});
describe("static console routes", () => {
it("serves the SPA and keeps unknown API paths as JSON 404", async () => {
const consoleRoot = fs.mkdtempSync(path.join(os.tmpdir(), "wf-console-"));
fs.mkdirSync(path.join(consoleRoot, "assets"));
fs.writeFileSync(path.join(consoleRoot, "index.html"), "<main>console</main>");
fs.writeFileSync(path.join(consoleRoot, "assets", "app.js"), "console.log('ok')");
try {
const staticApp = createApp({ runOperation: okRunner, consoleRoot });
const index = await staticApp.request("/workflows");
expect(index.status).toBe(200);
expect(await index.text()).toContain("console");
const asset = await staticApp.request("/assets/app.js");
expect(asset.status).toBe(200);
expect(await asset.text()).toContain("ok");
const unknownApi = await staticApp.request("/api/nope");
expect(unknownApi.status).toBe(404);
expect(await unknownApi.json()).toEqual({ error: "not found" });
} finally {
fs.rmSync(consoleRoot, { recursive: true, force: true });
}
});
});
+35 -5
View File
@@ -2,6 +2,7 @@ import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { OperationExchange, OperationName } from "@lda/workflow-rpc";
import { addStaticRoutes, validateConsoleRoot } from "./static.js";
export type RunOperation = (
operation: OperationName,
@@ -51,8 +52,9 @@ const mapErrorToStatus = (
export function createApp(dependencies: {
readonly runOperation: RunOperation;
readonly consoleRoot?: string;
}): Hono {
const { runOperation } = dependencies;
const { runOperation, consoleRoot } = dependencies;
const app = new Hono();
app.get("/api/health", (c) =>
@@ -94,10 +96,12 @@ export function createApp(dependencies: {
ok: true,
connection: {
status: "connected",
target: body.target,
target: exchange.target,
serverStatus: "ok",
storeRoot: (
exchange.interpreted as { store_root?: string }
exchange.interpreted as { storeRoot?: string; store_root?: string }
).storeRoot ?? (
exchange.interpreted as { storeRoot?: string; store_root?: string }
).store_root ?? "",
durationMs: exchange.durationMs,
},
@@ -115,7 +119,7 @@ export function createApp(dependencies: {
{
ok: false,
error: { code, message: msg },
exchange: { request: null, response: null },
exchange: exchangeFromError(e),
},
status,
);
@@ -193,12 +197,38 @@ export function createApp(dependencies: {
{
ok: false,
error: { code, message: msg },
exchange: { request: null, response: null },
exchange: exchangeFromError(e),
},
status,
);
}
});
if (consoleRoot) {
validateConsoleRoot(consoleRoot);
addStaticRoutes(app, { consoleRoot });
}
return app;
}
const exchangeFromError = (
error: unknown,
): { readonly request: unknown | null; readonly response: unknown | null } => {
if (error && typeof error === "object" && "exchange" in error) {
const exchange = (error as { readonly exchange?: unknown }).exchange;
if (exchange && typeof exchange === "object") {
return {
request:
"request" in exchange
? (exchange as { readonly request?: unknown }).request ?? null
: null,
response:
"response" in exchange
? (exchange as { readonly response?: unknown }).response ?? null
: null,
};
}
}
return { request: null, response: null };
};
+4 -2
View File
@@ -1,5 +1,6 @@
import { Effect, Layer } from "effect";
import { serve } from "@hono/node-server";
import { fileURLToPath } from "node:url";
import {
WorkflowRpc,
makeWorkflowRpcLayer,
@@ -16,7 +17,8 @@ if (Number.isNaN(port) || port < 1 || port > 65535) {
const hostname = process.env.WEB_HOST ?? "127.0.0.1";
const liveLayer = makeWorkflowRpcLayer;
const liveLayer = makeWorkflowRpcLayer();
const consoleRoot = fileURLToPath(new URL("../../console/dist", import.meta.url));
const runOperation: RunOperation = async (
operation: OperationName,
@@ -28,7 +30,7 @@ const runOperation: RunOperation = async (
return yield* execute(operation, target, params);
}).pipe(Effect.provide(liveLayer), Effect.runPromise);
const app = createApp({ runOperation });
const app = createApp({ runOperation, consoleRoot });
serve({
fetch: app.fetch,
+3 -14
View File
@@ -23,19 +23,8 @@ export function addStaticRoutes(
): void {
const { consoleRoot } = options;
app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
app.use("/assets/*", serveStatic({ root: consoleRoot }));
app.get("*", (c) => {
if (c.req.path.startsWith("/api/")) {
return c.json({ error: "not found" }, 404);
}
return serveStatic({ root: consoleRoot, path: "index.html" })(c);
});
app.all("*", (c) => {
if (c.req.path.startsWith("/api/")) {
return c.json({ error: "not found" }, 404);
}
return c.json({ error: "not found" }, 404);
});
app.get("*", serveStatic({ root: consoleRoot, path: "index.html" }));
app.all("*", (c) => c.json({ error: "not found" }, 404));
}