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>
108 lines
4.2 KiB
TypeScript
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");
|
|
}
|
|
},
|
|
};
|
|
}
|