Files
WRNexusJS/packages/rpc/src/server.ts
T
ClintchizandClaude Opus 5 ce68803471 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>
2026-08-05 20:10:20 +05:30

108 lines
4.2 KiB
TypeScript

import { RPC_ERROR_CODES, failure, success } from "./errors.ts";
import { importSubjectContext, type SubjectContext } from "./identity.ts";
import type {
AnyProcedures,
InferProcedureInput,
InferProcedureOutput,
ServiceContract,
ServiceResult,
} from "./types.ts";
export interface HandlerContext {
subject?: SubjectContext;
}
export type ServiceHandlers<Procedures extends AnyProcedures> = {
[K in keyof Procedures]: (
input: InferProcedureInput<Procedures[K]>,
ctx: HandlerContext,
) => Promise<InferProcedureOutput<Procedures[K]>> | InferProcedureOutput<Procedures[K]>;
};
export interface ImplementOptions {
selfApp: string;
checkPermission?: (permission: string, subject?: SubjectContext) => Promise<boolean> | boolean;
}
export interface ServiceImplementation<Procedures extends AnyProcedures = AnyProcedures> {
contract: ServiceContract<Procedures>;
invoke(procedure: string, payload: unknown, identity?: string): Promise<ServiceResult>;
}
export function implement<Procedures extends AnyProcedures>(
contract: ServiceContract<Procedures>,
handlers: ServiceHandlers<Procedures>,
options: ImplementOptions,
): ServiceImplementation<Procedures> {
// A declared procedure with no own handler would otherwise only surface at
// invoke time as RPC_UNKNOWN — a silent, permanent 404. Catch it now.
for (const procedureName of Object.keys(contract.procedures)) {
if (!Object.hasOwn(handlers, procedureName)) {
throw new Error(
`WRN-RPC-HANDLER: service "${contract.name}" declares procedure "${procedureName}" ` +
`but implement() was not given a handler for it.`,
);
}
}
return {
contract,
async invoke(procedureName, payload, identity) {
// Object.hasOwn, not plain indexing: "constructor", "toString" and every
// other Object.prototype member otherwise resolve as truthy, and a
// prototype member carries no `permission`, so the gate below is skipped
// entirely and an unintended function runs with attacker-controlled input.
const known =
Object.hasOwn(contract.procedures, procedureName) && Object.hasOwn(handlers, procedureName);
const definition = known ? contract.procedures[procedureName as keyof Procedures] : undefined;
const handler = known ? handlers[procedureName as keyof Procedures] : undefined;
if (!definition || !handler) return failure(RPC_ERROR_CODES.unknown, "Unknown procedure");
let subject: SubjectContext | undefined;
if (identity !== undefined) {
try {
subject = await importSubjectContext(identity, options.selfApp);
} catch {
return failure(RPC_ERROR_CODES.identity, "Invalid identity");
}
}
if (definition.permission) {
if (!options.checkPermission) return failure(RPC_ERROR_CODES.denied, "Forbidden");
try {
if (!(await options.checkPermission(definition.permission, subject))) {
return failure(RPC_ERROR_CODES.denied, "Forbidden");
}
} catch {
return failure(RPC_ERROR_CODES.denied, "Forbidden");
}
}
let input: unknown = payload;
if (definition.input) {
// InputSchema is structural: any custom or wrapped schema may throw
// instead of returning { ok: false }. A throw must not escape invoke()
// with its raw message — that text can carry internals — so it is
// caught the same way the permission check above is.
let parsed: { ok: boolean; value?: unknown };
try {
parsed = definition.input.parse(payload as Record<string, unknown>);
} catch (error) {
console.error(`[wrnexus] RPC input schema threw for ${String(procedureName)}`, error);
return failure(RPC_ERROR_CODES.invalid, "Invalid input");
}
if (!parsed.ok) return failure(RPC_ERROR_CODES.invalid, "Invalid input");
input = parsed.value;
}
try {
const value = await (handler as (value: unknown, ctx: HandlerContext) => unknown)(input, {
subject,
});
return success(value);
} catch {
return failure(RPC_ERROR_CODES.handler, "Internal error");
}
},
};
}