fix(rpc): close the service-collision fail-open and the fix-wave gaps

Critical:
- router: fail loudly (WRN-SERVICE-COLLISION) when two app/services files
  scan to the same service name, instead of silently letting directory-walk
  order pick a winner.

Important:
- server.ts: wrap a throwing input schema so its raw message cannot escape
  invoke(); returns RPC_INVALID and logs server-side instead.
- client.ts: race timeoutMs against transport.call so a stalled transport
  cannot hang the caller; rejects with a ServiceError(RPC_TRANSPORT).
- client.ts: the proxy returns undefined for undeclared properties (incl.
  then/catch/finally) instead of a function that throws, closing the
  await-client thenable trap.
- gateway.ts / rpc-dispatch.ts: import RPC_PATH_PREFIX / RPC_INTERNAL_HEADER
  from @wrnexus/rpc instead of hardcoding local copies.
- gateway.test.ts: cover the RPC-prefix edge block and internal-header
  stripping across casing variants.
- http.test.ts / client.test.ts: cover anonymous-call header omission, the
  internal marker, the retryable-status sweep, network/malformed/HTML
  failures, AbortSignal propagation, the timeout path, and timer cleanup.

Minor:
- transport.ts: Object.hasOwn for handler lookup; note the entry-only abort
  check.
- client.ts: wrap a missing/invalid WRNEXUS_RPC_SECRET as a ServiceError
  (RPC_IDENTITY) instead of a bare Error.
- rpc/package.json: drop the unused @wrnexus/authz dependency.
- server.ts: implement() now throws at construction time if a declared
  procedure has no own handler.

Verified: reverting the service-collision check and the client timeout race
each make their new test fail, then restore green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 20:10:20 +05:30
co-authored by Claude Opus 5
parent 7c4b484d0a
commit ce68803471
11 changed files with 347 additions and 31 deletions
+11 -4
View File
@@ -13,11 +13,9 @@ import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX } from "@wrnexus/rpc";
import { RESTART_EXIT_CODE } from "./restart.ts";
const RPC_PATH_PREFIX = "/__wrnexus/rpc";
const RPC_INTERNAL_HEADER = "x-wrnexus-internal";
export type GatewayForwardAuth = (
| {
url: string;
@@ -442,6 +440,15 @@ export function stripUntrustedInternalHeaders(headers: Headers): Headers {
return sanitized;
}
/**
* The reserved inter-app RPC namespace is refused at the gateway edge, before
* any proxying — it is only ever mounted by a child app's own dev-server and
* must never be reachable from outside the workspace.
*/
export function isRpcGatewayPath(pathname: string): boolean {
return pathname === RPC_PATH_PREFIX || pathname.startsWith(`${RPC_PATH_PREFIX}/`);
}
/** Boot every app as a child process, then route by Host on one gateway port. */
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
const port = opts.port ?? 3000;
@@ -625,7 +632,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
});
}
if (url.pathname === RPC_PATH_PREFIX || url.pathname.startsWith(`${RPC_PATH_PREFIX}/`)) {
if (isRpcGatewayPath(url.pathname)) {
return new Response("Not found", { status: 404 });
}
+7 -2
View File
@@ -1,6 +1,11 @@
import { RPC_IDENTITY_HEADER, RPC_PATH_PREFIX, type ServiceImplementation } from "@wrnexus/rpc";
import {
RPC_IDENTITY_HEADER,
RPC_INTERNAL_HEADER,
RPC_PATH_PREFIX,
type ServiceImplementation,
} from "@wrnexus/rpc";
export const RPC_INTERNAL_HEADER = "x-wrnexus-internal";
export { RPC_INTERNAL_HEADER };
const EDGE_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded"];
export function isRpcPath(pathname: string): boolean {