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 = { [K in keyof Procedures]: ( input: InferProcedureInput, ctx: HandlerContext, ) => Promise> | InferProcedureOutput; }; export interface ImplementOptions { selfApp: string; checkPermission?: (permission: string, subject?: SubjectContext) => Promise | boolean; } export interface ServiceImplementation { contract: ServiceContract; invoke(procedure: string, payload: unknown, identity?: string): Promise; } export function implement( contract: ServiceContract, handlers: ServiceHandlers, options: ImplementOptions, ): ServiceImplementation { // 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); } 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"); } }, }; }