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
+2 -2
View File
@@ -58,7 +58,7 @@ export const App = () => {
id: `sources-${Date.now()}`,
operation: "workflow.sources.list",
label: "Source inventory",
equivalentCli: "uv run wf sources list --limit 50",
equivalentCli: result.equivalentCli,
request: result.exchange.request,
response: result.exchange.response,
durationMs: result.durationMs,
@@ -72,7 +72,7 @@ export const App = () => {
id: `sources-${Date.now()}`,
operation: "workflow.sources.list",
label: "Source inventory",
equivalentCli: "uv run wf sources list --limit 50",
equivalentCli: "uv run wf source list --limit 50",
request: result.exchange.request,
response: result.exchange.response,
durationMs: 0,
+15 -5
View File
@@ -45,25 +45,35 @@ describe("initialState", () => {
expect(state.draftTarget).toBe("http://127.0.0.1:8765/rpc");
});
it("restores target from localStorage when available", () => {
it("restores target from sessionStorage when available", () => {
try {
localStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
sessionStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
} catch {
return; // skip if localStorage unavailable
return; // skip if sessionStorage unavailable
}
const state = initialState();
expect(state.draftTarget).toBe("http://custom:9999/rpc");
});
it("restored target does not trigger automatic connection", () => {
try {
sessionStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
} catch {
return; // skip if sessionStorage unavailable
}
const state = initialState();
expect(state.phase).toBe("not_configured");
expect(state.connectedTarget).toBeNull();
});
it("ignores localStorage so persistence is session-scoped", () => {
try {
localStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
} catch {
return; // skip if localStorage unavailable
}
const state = initialState();
expect(state.phase).toBe("not_configured");
expect(state.connectedTarget).toBeNull();
expect(state.draftTarget).toBe("http://127.0.0.1:8765/rpc");
});
});
+2 -10
View File
@@ -47,14 +47,6 @@ export type ConnectionState = {
export const STORAGE_KEY = "lda.workflowConsole.target";
const safeLocalStorage = (): Storage | null => {
try {
return typeof localStorage !== "undefined" ? localStorage : null;
} catch {
return null;
}
};
const safeSessionStorage = (): Storage | null => {
try {
return typeof sessionStorage !== "undefined" ? sessionStorage : null;
@@ -64,8 +56,8 @@ const safeSessionStorage = (): Storage | null => {
};
const getDefaultTarget = (): string => {
const ls = safeLocalStorage();
return ls?.getItem(STORAGE_KEY) ?? "http://127.0.0.1:8765/rpc";
const ss = safeSessionStorage();
return ss?.getItem(STORAGE_KEY) ?? "http://127.0.0.1:8765/rpc";
};
export const initialState = (): ConnectionState => ({
@@ -138,6 +138,14 @@ describe("error handling", () => {
).rejects.toThrow("empty response");
});
it("throws for structurally malformed JSON response", async () => {
mockFetch.mockReturnValue(jsonResponse({ ok: true, connection: {} }));
await expect(
connectToServer("http://127.0.0.1:8000/rpc"),
).rejects.toThrow("malformed response");
});
it("throws on network failure", async () => {
mockFetch.mockReturnValue(Promise.reject(new Error("network error")));
+25 -12
View File
@@ -3,8 +3,13 @@ import type {
RpcResponse,
OperationName,
} from "./contracts.js";
import { parseConnectResponse, parseRpcResponse } from "./contracts.js";
const fetchJson = async <T>(url: string, init?: RequestInit): Promise<T> => {
const fetchJson = async <T>(
url: string,
init: RequestInit,
parse: (data: unknown) => T,
): Promise<T> => {
const res = await fetch(url, init);
const text = await res.text();
if (!text) {
@@ -16,25 +21,33 @@ const fetchJson = async <T>(url: string, init?: RequestInit): Promise<T> => {
} catch {
throw new Error("malformed JSON response from server");
}
return data as T;
return parse(data);
};
export const connectToServer = async (
target: string,
): Promise<ConnectResponse> =>
fetchJson<ConnectResponse>("/api/connect", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target }),
});
fetchJson(
"/api/connect",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target }),
},
parseConnectResponse,
);
export const callOperation = async (
operation: OperationName,
target: string,
params: unknown = {},
): Promise<RpcResponse> =>
fetchJson<RpcResponse>("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ operation, target, params }),
});
fetchJson(
"/api/rpc",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ operation, target, params }),
},
parseRpcResponse,
);
+76 -43
View File
@@ -1,46 +1,79 @@
export type ConnectionSuccess = {
readonly ok: true;
readonly connection: {
readonly status: "connected";
readonly target: string;
readonly serverStatus: "ok";
readonly storeRoot: string;
readonly durationMs: number;
};
readonly exchange: { readonly request: unknown; readonly response: unknown };
readonly equivalentCli: string;
import * as v from "valibot";
const BrowserErrorCodeSchema = v.union([
v.literal("invalid_target"),
v.literal("unknown_operation"),
v.literal("upstream_unreachable"),
v.literal("upstream_timeout"),
v.literal("rpc_remote_error"),
v.literal("rpc_protocol_error"),
v.literal("rpc_decode_error"),
v.literal("response_too_large"),
]);
const ExchangeSchema = v.object({
request: v.nullish(v.unknown(), null),
response: v.nullish(v.unknown(), null),
});
const ApiFailureSchema = v.object({
ok: v.literal(false),
error: v.object({
code: BrowserErrorCodeSchema,
message: v.string(),
}),
exchange: ExchangeSchema,
});
const ConnectionSuccessSchema = v.object({
ok: v.literal(true),
connection: v.object({
status: v.literal("connected"),
target: v.string(),
serverStatus: v.literal("ok"),
storeRoot: v.string(),
durationMs: v.number(),
}),
exchange: ExchangeSchema,
equivalentCli: v.string(),
});
const OperationNameSchema = v.union([
v.literal("workflow.health"),
v.literal("workflow.sources.list"),
]);
const OperationSuccessSchema = v.object({
ok: v.literal(true),
operation: OperationNameSchema,
label: v.string(),
interpreted: v.unknown(),
exchange: ExchangeSchema,
equivalentCli: v.string(),
durationMs: v.number(),
});
const ConnectResponseSchema = v.union([ConnectionSuccessSchema, ApiFailureSchema]);
const RpcResponseSchema = v.union([OperationSuccessSchema, ApiFailureSchema]);
export type ConnectionSuccess = v.InferOutput<typeof ConnectionSuccessSchema>;
export type OperationSuccess = v.InferOutput<typeof OperationSuccessSchema>;
export type BrowserErrorCode = v.InferOutput<typeof BrowserErrorCodeSchema>;
export type ApiFailure = v.InferOutput<typeof ApiFailureSchema>;
export type ConnectResponse = v.InferOutput<typeof ConnectResponseSchema>;
export type RpcResponse = v.InferOutput<typeof RpcResponseSchema>;
export type OperationName = v.InferOutput<typeof OperationNameSchema>;
const parseDto = <T>(schema: v.GenericSchema<unknown, T>, data: unknown): T => {
try {
return v.parse(schema, data);
} catch {
throw new Error("malformed response from server");
}
};
export type OperationSuccess = {
readonly ok: true;
readonly operation: string;
readonly label: string;
readonly interpreted: unknown;
readonly exchange: { readonly request: unknown; readonly response: unknown };
readonly equivalentCli: string;
readonly durationMs: number;
};
export const parseConnectResponse = (data: unknown): ConnectResponse =>
parseDto(ConnectResponseSchema, data);
export type BrowserErrorCode =
| "invalid_target"
| "unknown_operation"
| "upstream_unreachable"
| "upstream_timeout"
| "rpc_remote_error"
| "rpc_protocol_error"
| "rpc_decode_error"
| "response_too_large";
export type ApiFailure = {
readonly ok: false;
readonly error: { readonly code: BrowserErrorCode; readonly message: string };
readonly exchange: {
readonly request: unknown | null;
readonly response: unknown | null;
};
};
export type ConnectResponse = ConnectionSuccess | ApiFailure;
export type RpcResponse = OperationSuccess | ApiFailure;
export type OperationName = "workflow.health" | "workflow.sources.list";
export const parseRpcResponse = (data: unknown): RpcResponse =>
parseDto(RpcResponseSchema, data);