feat(rpc): add target policy, protocol schemas, and tagged errors
- Add normalizeLoopbackTarget() with loopback-only URL validation - Add JSON-RPC response decoder with schema validation - Define 8 tagged errors using Data.TaggedError - 20 tests covering target policy and protocol decoding - Re-export all public types from index.ts
This commit is contained in:
@@ -13,6 +13,11 @@
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": { "effect": "3.21.4" },
|
||||
"devDependencies": { "vitest": "4.1.9" }
|
||||
"dependencies": {
|
||||
"effect": "3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.0",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Data } from "effect";
|
||||
|
||||
export class InvalidTargetError extends Data.TaggedError("InvalidTargetError")<{
|
||||
readonly message: string;
|
||||
}> {}
|
||||
|
||||
export class UnknownOperationError extends Data.TaggedError(
|
||||
"UnknownOperationError",
|
||||
)<{
|
||||
readonly message: string;
|
||||
}> {}
|
||||
|
||||
export class UpstreamConnectionError extends Data.TaggedError(
|
||||
"UpstreamConnectionError",
|
||||
)<{
|
||||
readonly message: string;
|
||||
}> {}
|
||||
|
||||
export class UpstreamTimeoutError extends Data.TaggedError(
|
||||
"UpstreamTimeoutError",
|
||||
)<{
|
||||
readonly message: string;
|
||||
}> {}
|
||||
|
||||
export class UpstreamResponseTooLargeError extends Data.TaggedError(
|
||||
"UpstreamResponseTooLargeError",
|
||||
)<{
|
||||
readonly message: string;
|
||||
}> {}
|
||||
|
||||
export class RpcProtocolError extends Data.TaggedError("RpcProtocolError")<{
|
||||
readonly message: string;
|
||||
readonly evidence?: string;
|
||||
}> {}
|
||||
|
||||
export class RpcRemoteError extends Data.TaggedError("RpcRemoteError")<{
|
||||
readonly message: string;
|
||||
readonly code: number;
|
||||
readonly data?: string;
|
||||
}> {}
|
||||
|
||||
export class RpcDecodeError extends Data.TaggedError("RpcDecodeError")<{
|
||||
readonly message: string;
|
||||
}> {}
|
||||
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
InvalidTargetError,
|
||||
UnknownOperationError,
|
||||
UpstreamConnectionError,
|
||||
UpstreamTimeoutError,
|
||||
UpstreamResponseTooLargeError,
|
||||
RpcProtocolError,
|
||||
RpcRemoteError,
|
||||
RpcDecodeError,
|
||||
} from "./errors.js";
|
||||
|
||||
export { normalizeLoopbackTarget } from "./target-policy.js";
|
||||
|
||||
export { decodeRpcResponse } from "./protocol.js";
|
||||
|
||||
export type {
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
JsonRpcSuccess,
|
||||
JsonRpcFailure,
|
||||
} from "./protocol.js";
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RpcDecodeError, RpcProtocolError, decodeRpcResponse } from "./protocol.js";
|
||||
|
||||
describe("decodeRpcResponse", () => {
|
||||
it("decodes success envelope with matching string id", () => {
|
||||
const payload = {
|
||||
jsonrpc: "2.0",
|
||||
id: "req-1",
|
||||
result: { status: "ok" },
|
||||
};
|
||||
expect(decodeRpcResponse(payload, "req-1")).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: "req-1",
|
||||
result: { status: "ok" },
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes error envelope with matching string id", () => {
|
||||
const payload = {
|
||||
jsonrpc: "2.0",
|
||||
id: "req-1",
|
||||
error: { code: -32000, message: "server error" },
|
||||
};
|
||||
const response = decodeRpcResponse(payload, "req-1");
|
||||
expect(response).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: "req-1",
|
||||
error: { code: -32000, message: "server error" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects mismatched id", () => {
|
||||
const payload = {
|
||||
jsonrpc: "2.0",
|
||||
id: "other",
|
||||
result: { status: "ok" },
|
||||
};
|
||||
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(
|
||||
RpcProtocolError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects envelope with both result and error", () => {
|
||||
const payload = {
|
||||
jsonrpc: "2.0",
|
||||
id: "req-1",
|
||||
result: { status: "ok" },
|
||||
error: { code: -32000, message: "err" },
|
||||
};
|
||||
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
|
||||
});
|
||||
|
||||
it("rejects envelope with neither result nor error", () => {
|
||||
const payload = {
|
||||
jsonrpc: "2.0",
|
||||
id: "req-1",
|
||||
};
|
||||
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
|
||||
});
|
||||
|
||||
it("rejects wrong jsonrpc version", () => {
|
||||
const payload = {
|
||||
jsonrpc: "1.0",
|
||||
id: "req-1",
|
||||
result: {},
|
||||
};
|
||||
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
|
||||
});
|
||||
|
||||
it("rejects malformed error object", () => {
|
||||
const payload = {
|
||||
jsonrpc: "2.0",
|
||||
id: "req-1",
|
||||
error: { message: "missing code" },
|
||||
};
|
||||
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
|
||||
});
|
||||
|
||||
it("rejects non-object payload", () => {
|
||||
expect(() => decodeRpcResponse("not an object", "req-1")).toThrow(
|
||||
RpcDecodeError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Schema } from "effect";
|
||||
import { RpcDecodeError, RpcProtocolError } from "./errors.js";
|
||||
|
||||
export { RpcDecodeError, RpcProtocolError };
|
||||
|
||||
const JsonRpcVersion = Schema.Literal("2.0");
|
||||
|
||||
const JsonRpcErrorObject = Schema.Struct({
|
||||
code: Schema.Number,
|
||||
message: Schema.String,
|
||||
data: Schema.optional(Schema.String),
|
||||
});
|
||||
|
||||
const decodeJsonRpcVersion = Schema.decodeUnknownSync(JsonRpcVersion);
|
||||
const decodeErrorObject = Schema.decodeUnknownSync(JsonRpcErrorObject);
|
||||
|
||||
export type JsonRpcRequest = {
|
||||
readonly jsonrpc: "2.0";
|
||||
readonly id: string;
|
||||
readonly method: string;
|
||||
readonly params: unknown;
|
||||
};
|
||||
|
||||
export type JsonRpcSuccess = {
|
||||
readonly jsonrpc: "2.0";
|
||||
readonly id: string;
|
||||
readonly result: unknown;
|
||||
};
|
||||
|
||||
export type JsonRpcFailure = {
|
||||
readonly jsonrpc: "2.0";
|
||||
readonly id: string;
|
||||
readonly error: {
|
||||
readonly code: number;
|
||||
readonly message: string;
|
||||
readonly data?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type JsonRpcResponse = JsonRpcSuccess | JsonRpcFailure;
|
||||
|
||||
function throwRpcDecode(message: string): never {
|
||||
throw new RpcDecodeError({ message });
|
||||
}
|
||||
|
||||
export function decodeRpcResponse(
|
||||
value: unknown,
|
||||
expectedId: string,
|
||||
): JsonRpcResponse {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
throwRpcDecode("response must be a JSON object");
|
||||
}
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
|
||||
if (!("jsonrpc" in obj) || !("id" in obj)) {
|
||||
throwRpcDecode("response must contain 'jsonrpc' and 'id' fields");
|
||||
}
|
||||
|
||||
try {
|
||||
decodeJsonRpcVersion(obj.jsonrpc, { onExcessProperty: "error" });
|
||||
} catch {
|
||||
throwRpcDecode(`jsonrpc version must be "2.0", got ${JSON.stringify(obj.jsonrpc)}`);
|
||||
}
|
||||
|
||||
if (typeof obj.id !== "string") {
|
||||
throwRpcDecode(`response id must be a string, got ${typeof obj.id}`);
|
||||
}
|
||||
|
||||
if (obj.id !== expectedId) {
|
||||
throw new RpcProtocolError({
|
||||
message: `response id "${obj.id}" does not match expected id "${expectedId}"`,
|
||||
evidence: JSON.stringify(obj),
|
||||
});
|
||||
}
|
||||
|
||||
const hasResult = "result" in obj;
|
||||
const hasError = "error" in obj;
|
||||
|
||||
if (hasResult && hasError) {
|
||||
throwRpcDecode("response must not contain both 'result' and 'error'");
|
||||
}
|
||||
|
||||
if (!hasResult && !hasError) {
|
||||
throwRpcDecode("response must contain exactly one of 'result' or 'error'");
|
||||
}
|
||||
|
||||
if (hasResult) {
|
||||
return { jsonrpc: "2.0", id: obj.id, result: obj.result };
|
||||
}
|
||||
|
||||
if (typeof obj.error !== "object" || obj.error === null) {
|
||||
throwRpcDecode("'error' must be an object");
|
||||
}
|
||||
|
||||
let errorObj: { readonly code: number; readonly message: string; readonly data?: string | undefined };
|
||||
try {
|
||||
errorObj = decodeErrorObject(obj.error, { onExcessProperty: "error" });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throwRpcDecode(`invalid error object: ${msg}`);
|
||||
}
|
||||
|
||||
const error: { code: number; message: string; data?: string } = {
|
||||
code: errorObj.code,
|
||||
message: errorObj.message,
|
||||
};
|
||||
if (errorObj.data !== undefined) {
|
||||
error.data = errorObj.data;
|
||||
}
|
||||
|
||||
return { jsonrpc: "2.0", id: obj.id, error };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InvalidTargetError, normalizeLoopbackTarget } from "./target-policy.js";
|
||||
|
||||
describe("normalizeLoopbackTarget", () => {
|
||||
it.each([
|
||||
["http://127.0.0.1:8765/rpc", "http://127.0.0.1:8765/rpc"],
|
||||
["http://localhost:8765/rpc", "http://localhost:8765/rpc"],
|
||||
["http://[::1]:8765/rpc", "http://[::1]:8765/rpc"],
|
||||
])("accepts loopback target %s", (input, expected) => {
|
||||
expect(normalizeLoopbackTarget(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://127.0.0.1:8765/rpc",
|
||||
"http://example.com:8765/rpc",
|
||||
"http://user:[email protected]:8765/rpc",
|
||||
"http://127.0.0.1/rpc",
|
||||
"http://127.0.0.1:8765/rpc?x=1",
|
||||
"http://127.0.0.1:8765/rpc#fragment",
|
||||
])("rejects unsafe target %s", (input) => {
|
||||
expect(() => normalizeLoopbackTarget(input)).toThrow(InvalidTargetError);
|
||||
});
|
||||
|
||||
it("rejects invalid URL", () => {
|
||||
expect(() => normalizeLoopbackTarget("not a url")).toThrow(
|
||||
InvalidTargetError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects port 0", () => {
|
||||
expect(() => normalizeLoopbackTarget("http://127.0.0.1:0/rpc")).toThrow(
|
||||
InvalidTargetError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects port above 65535", () => {
|
||||
expect(
|
||||
() => normalizeLoopbackTarget("http://127.0.0.1:65536/rpc"),
|
||||
).toThrow(InvalidTargetError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { InvalidTargetError } from "./errors.js";
|
||||
|
||||
export { InvalidTargetError };
|
||||
|
||||
const ALLOWED_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
||||
|
||||
/**
|
||||
* Normalize and validate a loopback RPC target URL.
|
||||
*
|
||||
* Only `http://127.0.0.1`, `http://localhost`, and `http://[::1]` are accepted.
|
||||
* DNS names other than the literal `localhost` are rejected to avoid
|
||||
* DNS-rebinding ambiguity — a hostname that resolves to a non-loopback
|
||||
* address at resolution time would bypass this check.
|
||||
*/
|
||||
export function normalizeLoopbackTarget(raw: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new InvalidTargetError({ message: "invalid URL" });
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:") {
|
||||
throw new InvalidTargetError({ message: "only http: protocol is allowed" });
|
||||
}
|
||||
|
||||
if (!ALLOWED_HOSTNAMES.has(url.hostname)) {
|
||||
throw new InvalidTargetError({
|
||||
message: `hostname "${url.hostname}" is not an allowed loopback address`,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.username !== "" || url.password !== "") {
|
||||
throw new InvalidTargetError({ message: "credentials in URL are not allowed" });
|
||||
}
|
||||
|
||||
if (url.port === "") {
|
||||
throw new InvalidTargetError({ message: "explicit port is required" });
|
||||
}
|
||||
|
||||
const port = Number(url.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new InvalidTargetError({
|
||||
message: `port ${url.port} is outside valid range 1..65535`,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.search !== "") {
|
||||
throw new InvalidTargetError({ message: "query string is not allowed" });
|
||||
}
|
||||
|
||||
if (url.hash !== "") {
|
||||
throw new InvalidTargetError({ message: "fragment is not allowed" });
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
"moduleResolution": "NodeNext",
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user