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",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": { "effect": "3.21.4" },
|
"dependencies": {
|
||||||
"devDependencies": { "vitest": "4.1.9" }
|
"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",
|
"moduleResolution": "NodeNext",
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+3
@@ -92,6 +92,9 @@ importers:
|
|||||||
specifier: 3.21.4
|
specifier: 3.21.4
|
||||||
version: 3.21.4
|
version: 3.21.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: 26.1.0
|
||||||
|
version: 26.1.0
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.9
|
specifier: 4.1.9
|
||||||
version: 4.1.9(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
|
version: 4.1.9(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
|
||||||
|
|||||||
Reference in New Issue
Block a user