From e1fca3eddf55bbb62cc4912aa9090512ca4a12e0 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 09:52:48 +0530 Subject: [PATCH 01/22] feat(rpc): scaffold the package and shared contract types --- bun.lock | 16 +++++++++++ packages/rpc/package.json | 31 +++++++++++++++++++++ packages/rpc/src/index.ts | 19 +++++++++++++ packages/rpc/src/types.ts | 49 +++++++++++++++++++++++++++++++++ packages/rpc/test/types.test.ts | 21 ++++++++++++++ tsconfig.json | 1 + 6 files changed, 137 insertions(+) create mode 100644 packages/rpc/package.json create mode 100644 packages/rpc/src/index.ts create mode 100644 packages/rpc/src/types.ts create mode 100644 packages/rpc/test/types.test.ts diff --git a/bun.lock b/bun.lock index 4bc2af59..98b8fcd9 100644 --- a/bun.lock +++ b/bun.lock @@ -444,6 +444,20 @@ "@wrnexus/core": "workspace:*", }, }, + "packages/rpc": { + "name": "@wrnexus/rpc", + "version": "0.8.4", + "dependencies": { + "@wrnexus/authz": "workspace:*", + "@wrnexus/core": "workspace:*", + "@wrnexus/jwt": "workspace:*", + "@wrnexus/validation": "workspace:*", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2", + }, + }, "packages/security": { "name": "@wrnexus/security", "version": "0.8.4", @@ -887,6 +901,8 @@ "@wrnexus/router": ["@wrnexus/router@workspace:packages/router"], + "@wrnexus/rpc": ["@wrnexus/rpc@workspace:packages/rpc"], + "@wrnexus/security": ["@wrnexus/security@workspace:packages/security"], "@wrnexus/ssr": ["@wrnexus/ssr@workspace:packages/ssr"], diff --git a/packages/rpc/package.json b/packages/rpc/package.json new file mode 100644 index 00000000..b1832c35 --- /dev/null +++ b/packages/rpc/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wrnexus/rpc", + "version": "0.8.4", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "description": "Typed request/response calls between workspace apps, carrying end-user identity.", + "files": [ + "src", + "README.md" + ], + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "check": "bun run typecheck && bun run test" + }, + "dependencies": { + "@wrnexus/authz": "workspace:*", + "@wrnexus/core": "workspace:*", + "@wrnexus/jwt": "workspace:*", + "@wrnexus/validation": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2" + } +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts new file mode 100644 index 00000000..f2292072 --- /dev/null +++ b/packages/rpc/src/index.ts @@ -0,0 +1,19 @@ +/** + * @wrnexus/rpc — typed request/response between workspace apps. + * + * A contract lives in the workspace's shared package and is imported by both + * sides: the callee `implement`s it, the caller gets a typed proxy. Types flow + * through a normal import, so there is no code generator and no generated file + * to go stale. + */ + +export type { + AnyProcedures, + InferInput, + InputSchema, + InferProcedureInput, + InferProcedureOutput, + ProcedureDef, + ServiceContract, + ServiceResult, +} from "./types.ts"; diff --git a/packages/rpc/src/types.ts b/packages/rpc/src/types.ts new file mode 100644 index 00000000..e925438e --- /dev/null +++ b/packages/rpc/src/types.ts @@ -0,0 +1,49 @@ +import type { ObjectSchema } from "@wrnexus/validation"; + +/** Extract the validated value type from a `v.object(...)` schema. */ +export type InferInput = S extends ObjectSchema ? T : never; + +/** + * One callable procedure on a service. `input` is validated on the callee + * before the handler runs; `permission` is enforced there too. + */ +/** Structural shape of a validation schema, so ProcedureDef needs no generic. */ +export interface InputSchema { + parse(value: Record): { + ok: boolean; + value: unknown; + errors: Record; + }; +} + +export interface ProcedureDef { + input?: InputSchema; + /** Permission the callee checks before invoking the handler. */ + permission?: string; + /** Only idempotent procedures are ever retried. */ + idempotent?: boolean; + /** Type-only markers; never present at runtime. */ + readonly __input?: Input; + readonly __output?: Output; +} + +/** + * A procedure map with its element types erased. The `any` is deliberate and + * confined to this alias: the phantom `__input`/`__output` markers make + * ProcedureDef invariant, so no narrower erasure accepts a real contract. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyProcedures = Record>; + +export interface ServiceContract { + /** Stable service id, used in the mounted path. */ + name: string; + procedures: Procedures; +} + +export type InferProcedureInput

= P extends ProcedureDef ? I : never; +export type InferProcedureOutput

= P extends ProcedureDef ? O : never; + +/** What a transport returns: either a value or a structured failure. */ +export type ServiceResult = + { ok: true; value: T } | { ok: false; code: string; message: string; retryable: boolean }; diff --git a/packages/rpc/test/types.test.ts b/packages/rpc/test/types.test.ts new file mode 100644 index 00000000..ba6c801b --- /dev/null +++ b/packages/rpc/test/types.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { v } from "@wrnexus/validation"; +import type { InferInput, ProcedureDef, ServiceContract } from "../src/types.ts"; + +describe("rpc types", () => { + test("InferInput extracts the validated shape from a schema", () => { + const schema = v.object({ userId: v.string(), amountCents: v.number() }); + // Compile-time assertion: assigning a correctly-shaped value must typecheck. + const value: InferInput = { userId: "u1", amountCents: 10 }; + expect(value.userId).toBe("u1"); + }); + + test("a contract carries its procedure map", () => { + const contract: ServiceContract<{ ping: ProcedureDef }> = { + name: "demo", + procedures: { ping: {} }, + }; + expect(contract.name).toBe("demo"); + expect(Object.keys(contract.procedures)).toEqual(["ping"]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 5f00913d..d902e4b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -75,6 +75,7 @@ "@wrnexus/ui/component-reference.json": ["./packages/ui/component-reference.json"], "@wrnexus/ui/ui.css": ["./packages/ui/ui.css"], "@wrnexus/validation": ["./packages/validation/src/index.ts"], + "@wrnexus/rpc": ["./packages/rpc/src/index.ts"], "@wrnexus/ai/*": ["./packages/ai/src/*.ts"], "@wrnexus/authz/*": ["./packages/authz/src/*.ts"], "@wrnexus/cli/*": ["./packages/cli/src/*.ts"], From a4c7d7b2982fc15c111341e6ed4e55a4daaac49c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 09:54:08 +0530 Subject: [PATCH 02/22] docs: drop a redundant eslint directive and note a Bun test quirk no-explicit-any is off repo-wide in eslint.config.js, so the disable comment the plan mandated is itself an unused-directive warning. The test's schema binding also needs the _ prefix the lint config requires for a value read only via typeof. Separately: bun test strips type-only imports before resolution, so the red-first step does not reproduce for type-only tests. Recorded so later implementers do not chase it. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-05-inter-app-comms-implementation.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index bae0aca9..62709e86 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -22,6 +22,7 @@ - Only procedures explicitly marked `.idempotent()` may be retried. - Errors crossing an app boundary are opaque by default: code and message only, no stack, no internal detail. - Test files live in `packages//test/*.test.ts` and use `import { describe, expect, test } from "bun:test"`. +- `bun test` strips type-only imports before resolution, so a "verify it fails" step does NOT reproduce for a test whose only import from the new module is `import type`. Expect it to pass; that is a Bun behaviour, not a missing failure. - Test fixtures must NOT be scaffolded under `os.tmpdir()`. A scaffolded file importing `@wrnexus/*` by bare specifier cannot resolve outside the repo tree. Use a repo-local `.tmp-*` directory (`**/test/.tmp-*/` is gitignored). - Do NOT use `git stash`. This repo has `core.autocrlf=true`; a stash round-trip rewrites files to CRLF and fails `format:check`. - Write control-character checks as codepoint loops, never regex literals — escapes get mangled on the round-trip through tooling in this repo. @@ -83,9 +84,11 @@ import type { InferInput, ProcedureDef, ServiceContract } from "../src/types.ts" describe("rpc types", () => { test("InferInput extracts the validated shape from a schema", () => { - const schema = v.object({ userId: v.string(), amountCents: v.number() }); + // Prefixed with _ : used only via `typeof`, and the lint config requires + // that prefix for a binding that is never read at runtime. + const _schema = v.object({ userId: v.string(), amountCents: v.number() }); // Compile-time assertion: assigning a correctly-shaped value must typecheck. - const value: InferInput = { userId: "u1", amountCents: 10 }; + const value: InferInput = { userId: "u1", amountCents: 10 }; expect(value.userId).toBe("u1"); }); @@ -178,8 +181,9 @@ export interface ProcedureDef { * A procedure map with its element types erased. The `any` is deliberate and * confined to this alias: the phantom `__input`/`__output` markers make * ProcedureDef invariant, so no narrower erasure accepts a real contract. + * (No eslint-disable needed — `no-explicit-any` is off repo-wide, and a + * redundant directive is itself a lint warning.) */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyProcedures = Record>; export interface ServiceContract { From 3e1d7db537ea7e50848ca41185322dc3cd971a75 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 09:59:23 +0530 Subject: [PATCH 03/22] fix(rpc): resolve lint warnings from review follow-up - Drop the redundant eslint-disable on AnyProcedures; no-explicit-any is off repo-wide so the directive itself was the warning. Doc comment now explains why none is needed. - Rename test's schema binding to _schema per the lint config's underscore-prefix rule for read-only-as-type bindings. --- packages/rpc/src/types.ts | 3 ++- packages/rpc/test/types.test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/rpc/src/types.ts b/packages/rpc/src/types.ts index e925438e..56179707 100644 --- a/packages/rpc/src/types.ts +++ b/packages/rpc/src/types.ts @@ -31,8 +31,9 @@ export interface ProcedureDef { * A procedure map with its element types erased. The `any` is deliberate and * confined to this alias: the phantom `__input`/`__output` markers make * ProcedureDef invariant, so no narrower erasure accepts a real contract. + * (No eslint-disable needed — `no-explicit-any` is off repo-wide, and a + * redundant directive is itself a lint warning.) */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyProcedures = Record>; export interface ServiceContract { diff --git a/packages/rpc/test/types.test.ts b/packages/rpc/test/types.test.ts index ba6c801b..b6707d06 100644 --- a/packages/rpc/test/types.test.ts +++ b/packages/rpc/test/types.test.ts @@ -4,9 +4,11 @@ import type { InferInput, ProcedureDef, ServiceContract } from "../src/types.ts" describe("rpc types", () => { test("InferInput extracts the validated shape from a schema", () => { - const schema = v.object({ userId: v.string(), amountCents: v.number() }); + // Prefixed with _ : used only via `typeof`, and the lint config requires + // that prefix for a binding that is never read at runtime. + const _schema = v.object({ userId: v.string(), amountCents: v.number() }); // Compile-time assertion: assigning a correctly-shaped value must typecheck. - const value: InferInput = { userId: "u1", amountCents: 10 }; + const value: InferInput = { userId: "u1", amountCents: 10 }; expect(value.userId).toBe("u1"); }); From 796b19d923ce8fc2c3b2eac668e6489e8d6823b0 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 13:37:52 +0530 Subject: [PATCH 04/22] feat(rpc): add service errors and retryability classification Co-Authored-By: Claude Opus 5 --- packages/rpc/src/errors.ts | 70 ++++++++++++++++++++++++++++++++ packages/rpc/src/index.ts | 3 ++ packages/rpc/test/errors.test.ts | 53 ++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 packages/rpc/src/errors.ts create mode 100644 packages/rpc/test/errors.test.ts diff --git a/packages/rpc/src/errors.ts b/packages/rpc/src/errors.ts new file mode 100644 index 00000000..e19d8514 --- /dev/null +++ b/packages/rpc/src/errors.ts @@ -0,0 +1,70 @@ +import type { ServiceResult } from "./types.ts"; + +export const RPC_ERROR_CODES = { + /** The request never reached a handler: connection, timeout, 5xx. */ + transport: "RPC_TRANSPORT", + /** Input failed the contract's schema. */ + invalid: "RPC_INVALID", + /** The callee's permission check refused. */ + denied: "RPC_DENIED", + /** No such service or procedure on the callee. */ + unknown: "RPC_UNKNOWN", + /** The handler threw or returned a failure. */ + handler: "RPC_HANDLER", + /** Identity token missing, malformed, expired, or for another audience. */ + identity: "RPC_IDENTITY", +} as const; + +export type RpcErrorCode = (typeof RPC_ERROR_CODES)[keyof typeof RPC_ERROR_CODES]; + +/** Only a transport failure is worth retrying; everything else is final. */ +function retryableFor(code: string): boolean { + return code === RPC_ERROR_CODES.transport; +} + +/** + * 5xx and 429 mean "the callee could not answer, try later". A 4xx is the + * callee saying no — retrying it just repeats the same rejection. + */ +export function isRetryableStatus(status: number): boolean { + return status >= 500 || status === 429; +} + +export function success(value: T): ServiceResult { + return { ok: true, value }; +} + +export function failure(code: string, message: string): ServiceResult { + return { ok: false, code, message, retryable: retryableFor(code) }; +} + +export interface ToResultOptions { + /** Include the original message. Off by default: it may name internals. */ + exposeMessage?: boolean; +} + +export class ServiceError extends Error { + readonly code: string; + readonly retryable: boolean; + + constructor(code: string, message: string) { + super(message); + this.name = "ServiceError"; + this.code = code; + this.retryable = retryableFor(code); + } + + /** + * Convert to a wire result. The message is replaced unless explicitly + * exposed: a handler's error text routinely names tables, hosts, or + * credentials, and this value crosses an app boundary. + */ + toResult(options: ToResultOptions = {}): ServiceResult { + return { + ok: false, + code: this.code, + message: options.exposeMessage ? this.message : "Internal error", + retryable: this.retryable, + }; + } +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index f2292072..054b9d19 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -17,3 +17,6 @@ export type { ServiceContract, ServiceResult, } from "./types.ts"; + +export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } from "./errors.ts"; +export type { RpcErrorCode, ToResultOptions } from "./errors.ts"; diff --git a/packages/rpc/test/errors.test.ts b/packages/rpc/test/errors.test.ts new file mode 100644 index 00000000..98e2bc0e --- /dev/null +++ b/packages/rpc/test/errors.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { + RPC_ERROR_CODES, + ServiceError, + failure, + isRetryableStatus, + success, +} from "../src/errors.ts"; + +describe("rpc errors", () => { + test("success and failure build the result shape", () => { + expect(success(42)).toEqual({ ok: true, value: 42 }); + const f = failure(RPC_ERROR_CODES.denied, "Forbidden"); + expect(f.ok).toBe(false); + if (!f.ok) { + expect(f.code).toBe("RPC_DENIED"); + expect(f.retryable).toBe(false); + } + }); + + test("only transport failures are retryable", () => { + // 5xx and 429 are the callee saying "try again"; everything else is final. + expect(isRetryableStatus(500)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(429)).toBe(true); + expect(isRetryableStatus(400)).toBe(false); + expect(isRetryableStatus(403)).toBe(false); + expect(isRetryableStatus(404)).toBe(false); + expect(isRetryableStatus(200)).toBe(false); + }); + + test("a denial is never retryable", () => { + const f = failure(RPC_ERROR_CODES.denied, "Forbidden"); + if (!f.ok) expect(f.retryable).toBe(false); + }); + + test("ServiceError carries a code and does not leak a cause into its message", () => { + const error = new ServiceError(RPC_ERROR_CODES.handler, "Something failed"); + expect(error.name).toBe("ServiceError"); + expect(error.code).toBe("RPC_HANDLER"); + expect(error.message).toBe("Something failed"); + expect(error.retryable).toBe(false); + }); + + test("toResult produces an opaque failure", () => { + const error = new ServiceError(RPC_ERROR_CODES.handler, "db password is hunter2"); + const result = error.toResult({ exposeMessage: false }); + if (!result.ok) { + expect(result.message).toBe("Internal error"); + expect(result.message).not.toContain("hunter2"); + } + }); +}); From dd8354477d80fa82ad76b88eaf4f2e92bee3cca2 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 13:43:39 +0530 Subject: [PATCH 05/22] docs: close three retryability gaps in the Task 2 plan snippet isRetryableStatus used an unbounded status >= 500, so a garbage status like 1000 landed in the retryable bucket. This function is the sole gate the client and HTTP transport trust for retry safety, and an out-of-range value must fail closed. Bounded on both sides. 408 Request Timeout was non-retryable while the same file documented transport as covering "connection, timeout, 5xx" - a genuine timeout surfaced as 408 was classified differently from the identical timeout surfaced as 504. Now retryable. There was no code for "the callee answered but not with a ServiceResult" - a proxy's HTML error page, a truncated body. Task 8 was already papering over it by hand-setting retryable: false beside a transport code that retryableFor says is always retryable, which is exactly how the two drift apart. Added RPC_MALFORMED and made that path use failure() so retryability is derived from the code rather than written next to it. Co-Authored-By: Claude Opus 5 --- ...26-08-05-inter-app-comms-implementation.md | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index 62709e86..6f12901c 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -285,16 +285,34 @@ describe("rpc errors", () => { }); test("only transport failures are retryable", () => { - // 5xx and 429 are the callee saying "try again"; everything else is final. + // 5xx, 429 and 408 are the callee saying "try again"; everything else is final. expect(isRetryableStatus(500)).toBe(true); expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(599)).toBe(true); expect(isRetryableStatus(429)).toBe(true); + expect(isRetryableStatus(408)).toBe(true); expect(isRetryableStatus(400)).toBe(false); expect(isRetryableStatus(403)).toBe(false); expect(isRetryableStatus(404)).toBe(false); + expect(isRetryableStatus(409)).toBe(false); expect(isRetryableStatus(200)).toBe(false); }); + test("an out-of-range status fails closed rather than landing in the retry bucket", () => { + for (const status of [600, 1000, 0, -1, Number.NaN]) { + expect(isRetryableStatus(status)).toBe(false); + } + }); + + test("a malformed callee response is its own non-retryable code", () => { + const f = failure(RPC_ERROR_CODES.malformed, "Malformed service response"); + if (!f.ok) { + expect(f.code).toBe("RPC_MALFORMED"); + // Something answered; asking again returns the same thing. + expect(f.retryable).toBe(false); + } + }); + test("a denial is never retryable", () => { const f = failure(RPC_ERROR_CODES.denied, "Forbidden"); if (!f.ok) expect(f.retryable).toBe(false); @@ -344,6 +362,12 @@ export const RPC_ERROR_CODES = { handler: "RPC_HANDLER", /** Identity token missing, malformed, expired, or for another audience. */ identity: "RPC_IDENTITY", + /** + * The callee answered, but not with a ServiceResult — a proxy's HTML error + * page, a truncated body, an unexpected shape. Distinct from `transport`: + * something DID respond, so retrying returns the same thing. + */ + malformed: "RPC_MALFORMED", } as const; export type RpcErrorCode = (typeof RPC_ERROR_CODES)[keyof typeof RPC_ERROR_CODES]; @@ -354,11 +378,17 @@ function retryableFor(code: string): boolean { } /** - * 5xx and 429 mean "the callee could not answer, try later". A 4xx is the - * callee saying no — retrying it just repeats the same rejection. + * 5xx, 429 and 408 mean "the callee could not answer, try later". Any other + * 4xx is the callee saying no — retrying just repeats the same rejection. + * + * The range is bounded on BOTH sides deliberately: an unbounded `>= 500` + * puts a garbage status like 1000 in the retryable bucket, and this function + * is the sole gate the client and HTTP transport trust for retry safety. + * An out-of-range value must fail closed, i.e. not retryable. */ export function isRetryableStatus(status: number): boolean { - return status >= 500 || status === 429; + if (status === 408 || status === 429) return true; + return status >= 500 && status <= 599; } export function success(value: T): ServiceResult { @@ -1772,6 +1802,8 @@ export function httpTransport(options: HttpTransportOptions = {}): Transport { } if (!response.ok) { + // The one place retryability is status-derived rather than code-derived: + // the callee answered, and its status says whether asking again helps. return { ok: false, code: RPC_ERROR_CODES.transport, @@ -1784,13 +1816,9 @@ export function httpTransport(options: HttpTransportOptions = {}): Transport { return (await response.json()) as ServiceResult; } catch { // A 200 that is not a ServiceResult means something else answered — - // a proxy, an error page. Retrying will not change that. - return { - ok: false, - code: RPC_ERROR_CODES.transport, - message: "Malformed service response", - retryable: false, - }; + // a proxy, an error page. Retrying will not change that. Use failure() + // so retryability is DERIVED from the code, never hand-set beside it. + return failure(RPC_ERROR_CODES.malformed, "Malformed service response"); } }, }; From 21ea8a84a04bf6413b90ff37899734e77a6dfc77 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 13:45:37 +0530 Subject: [PATCH 06/22] fix(rpc): bound retryable status range and add malformed-response code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isRetryableStatus now fails closed for out-of-range values (600+, negative, NaN) by bounding the 5xx check on both sides (>= 500 && <= 599), instead of an unbounded >= 500 that classified garbage statuses like 1000 as retryable. - 408 Request Timeout is now retryable, matching the RPC_TRANSPORT doc comment (connection, timeout, 5xx) — a timeout surfaced as 408 is no longer treated differently from the same timeout surfaced as 504. - Add RPC_MALFORMED: the callee answered, but not with a ServiceResult (HTML error page, truncated body, unexpected shape). Distinct from RPC_TRANSPORT since something DID respond; non-retryable via the existing retryableFor, no new branch needed. Co-Authored-By: Claude Opus 5 --- packages/rpc/src/errors.ts | 18 +++++++++++++++--- packages/rpc/test/errors.test.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/rpc/src/errors.ts b/packages/rpc/src/errors.ts index e19d8514..02f7f709 100644 --- a/packages/rpc/src/errors.ts +++ b/packages/rpc/src/errors.ts @@ -13,6 +13,12 @@ export const RPC_ERROR_CODES = { handler: "RPC_HANDLER", /** Identity token missing, malformed, expired, or for another audience. */ identity: "RPC_IDENTITY", + /** + * The callee answered, but not with a ServiceResult — a proxy's HTML error + * page, a truncated body, an unexpected shape. Distinct from `transport`: + * something DID respond, so retrying returns the same thing. + */ + malformed: "RPC_MALFORMED", } as const; export type RpcErrorCode = (typeof RPC_ERROR_CODES)[keyof typeof RPC_ERROR_CODES]; @@ -23,11 +29,17 @@ function retryableFor(code: string): boolean { } /** - * 5xx and 429 mean "the callee could not answer, try later". A 4xx is the - * callee saying no — retrying it just repeats the same rejection. + * 5xx, 429 and 408 mean "the callee could not answer, try later". Any other + * 4xx is the callee saying no — retrying just repeats the same rejection. + * + * The range is bounded on BOTH sides deliberately: an unbounded `>= 500` + * puts a garbage status like 1000 in the retryable bucket, and this function + * is the sole gate the client and HTTP transport trust for retry safety. + * An out-of-range value must fail closed, i.e. not retryable. */ export function isRetryableStatus(status: number): boolean { - return status >= 500 || status === 429; + if (status === 408 || status === 429) return true; + return status >= 500 && status <= 599; } export function success(value: T): ServiceResult { diff --git a/packages/rpc/test/errors.test.ts b/packages/rpc/test/errors.test.ts index 98e2bc0e..61687880 100644 --- a/packages/rpc/test/errors.test.ts +++ b/packages/rpc/test/errors.test.ts @@ -23,12 +23,30 @@ describe("rpc errors", () => { expect(isRetryableStatus(500)).toBe(true); expect(isRetryableStatus(503)).toBe(true); expect(isRetryableStatus(429)).toBe(true); + expect(isRetryableStatus(599)).toBe(true); + expect(isRetryableStatus(408)).toBe(true); expect(isRetryableStatus(400)).toBe(false); expect(isRetryableStatus(403)).toBe(false); expect(isRetryableStatus(404)).toBe(false); + expect(isRetryableStatus(409)).toBe(false); expect(isRetryableStatus(200)).toBe(false); }); + test("an out-of-range status fails closed", () => { + for (const status of [600, 1000, 0, -1, Number.NaN]) { + expect(isRetryableStatus(status)).toBe(false); + } + }); + + test("a malformed response is never retryable", () => { + const f = failure(RPC_ERROR_CODES.malformed, "Unexpected response shape"); + expect(f.ok).toBe(false); + if (!f.ok) { + expect(f.code).toBe("RPC_MALFORMED"); + expect(f.retryable).toBe(false); + } + }); + test("a denial is never retryable", () => { const f = failure(RPC_ERROR_CODES.denied, "Forbidden"); if (!f.ok) expect(f.retryable).toBe(false); From e16903b286d358b91ab8773e5a9c1ad017729d0e Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 13:53:14 +0530 Subject: [PATCH 07/22] feat(rpc): add defineService and the immutable procedure builder --- packages/rpc/src/contract.ts | 66 ++++++++++++++++++++++++++++++ packages/rpc/src/index.ts | 2 + packages/rpc/test/contract.test.ts | 59 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 packages/rpc/src/contract.ts create mode 100644 packages/rpc/test/contract.test.ts diff --git a/packages/rpc/src/contract.ts b/packages/rpc/src/contract.ts new file mode 100644 index 00000000..ad7c27f3 --- /dev/null +++ b/packages/rpc/src/contract.ts @@ -0,0 +1,66 @@ +import type { ObjectSchema } from "@wrnexus/validation"; +import type { AnyProcedures, InferInput, ProcedureDef, ServiceContract } from "./types.ts"; + +/** A service name lands in a URL path, so keep it unescaped-safe. */ +const SAFE_SERVICE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** A procedure name is also a property the caller writes as client.doThing(). */ +const SAFE_PROCEDURE = /^[a-z][a-zA-Z0-9]*$/; + +/** + * Fluent, IMMUTABLE builder: every method returns a new builder, so a shared + * base can be branched without one branch mutating another. + */ +export class ProcedureBuilder { + private constructor(private readonly def: ProcedureDef) {} + + static create(): ProcedureBuilder { + return new ProcedureBuilder({}); + } + + input>(schema: S): ProcedureBuilder, Output> { + return new ProcedureBuilder, Output>({ + ...this.def, + input: schema, + } as ProcedureDef, Output>); + } + + output(): ProcedureBuilder { + return new ProcedureBuilder({ ...this.def } as ProcedureDef); + } + + permission(id: string): ProcedureBuilder { + return new ProcedureBuilder({ ...this.def, permission: id }); + } + + /** Mark safe to retry. Anything not marked is never retried. */ + idempotent(): ProcedureBuilder { + return new ProcedureBuilder({ ...this.def, idempotent: true }); + } + + build(): ProcedureDef { + return Object.freeze({ ...this.def }); + } +} + +export const procedure = ProcedureBuilder.create(); + +export function defineService(def: { + name: string; + procedures: Procedures; +}): ServiceContract { + if (!SAFE_SERVICE.test(def.name)) { + throw new Error( + `WRN-RPC-CONTRACT: service name ${JSON.stringify(def.name)} must be lowercase ` + + `alphanumeric with single hyphens, e.g. "billing" or "billing-v2".`, + ); + } + for (const name of Object.keys(def.procedures)) { + if (!SAFE_PROCEDURE.test(name)) { + throw new Error( + `WRN-RPC-CONTRACT: procedure name ${JSON.stringify(name)} on service ` + + `'${def.name}' must be a lowercase-initial identifier, e.g. "createInvoice".`, + ); + } + } + return Object.freeze({ name: def.name, procedures: Object.freeze({ ...def.procedures }) }); +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 054b9d19..4ec5e5ae 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -20,3 +20,5 @@ export type { export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } from "./errors.ts"; export type { RpcErrorCode, ToResultOptions } from "./errors.ts"; + +export { defineService, procedure, ProcedureBuilder } from "./contract.ts"; diff --git a/packages/rpc/test/contract.test.ts b/packages/rpc/test/contract.test.ts new file mode 100644 index 00000000..d3acc9fe --- /dev/null +++ b/packages/rpc/test/contract.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { v } from "@wrnexus/validation"; +import { defineService, procedure } from "../src/contract.ts"; + +describe("defineService", () => { + test("captures a procedure's input schema, permission and idempotency", () => { + const billing = defineService({ + name: "billing", + procedures: { + createInvoice: procedure + .input(v.object({ userId: v.string(), amountCents: v.number() })) + .output<{ invoiceId: string }>() + .permission("invoice:create") + .build(), + getInvoice: procedure + .input(v.object({ invoiceId: v.string() })) + .output<{ amountCents: number }>() + .idempotent() + .build(), + }, + }); + + expect(billing.name).toBe("billing"); + expect(Object.keys(billing.procedures).sort()).toEqual(["createInvoice", "getInvoice"]); + expect(billing.procedures.createInvoice.permission).toBe("invoice:create"); + expect(billing.procedures.createInvoice.idempotent).toBeUndefined(); + expect(billing.procedures.getInvoice.idempotent).toBe(true); + expect(billing.procedures.getInvoice.permission).toBeUndefined(); + }); + + test("the contract is frozen so it cannot drift after definition", () => { + const contract = defineService({ name: "demo", procedures: { ping: procedure.build() } }); + expect(Object.isFrozen(contract)).toBe(true); + expect(Object.isFrozen(contract.procedures)).toBe(true); + }); + + test("rejects a service name that is not a safe path segment", () => { + // The name lands in a URL path, so it must not need escaping. + for (const name of ["", "has space", "has/slash", "has.dot", "UPPER"]) { + expect(() => defineService({ name, procedures: {} })).toThrow(/service name/i); + } + expect(() => defineService({ name: "billing-v2", procedures: {} })).not.toThrow(); + }); + + test("rejects a procedure name that is not a safe path segment", () => { + expect(() => + defineService({ name: "demo", procedures: { "bad name": procedure.build() } }), + ).toThrow(/procedure name/i); + }); + + test("the builder is immutable — reusing a base does not cross-contaminate", () => { + const base = procedure.permission("a:read"); + const one = base.idempotent().build(); + const two = base.build(); + expect(one.idempotent).toBe(true); + expect(two.idempotent).toBeUndefined(); + expect(two.permission).toBe("a:read"); + }); +}); From 34d5bbc810f2860b1627f18185346e87830eef73 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 13:54:23 +0530 Subject: [PATCH 08/22] docs: add the missing cast to .input() in the Task 3 plan snippet The builder's .input() did not typecheck as written (TS2345). The phantom __input/__output markers make ProcedureDef invariant, which is exactly why .output() already carried a cast - .input() needed the analogous one and did not have it. Caught by the Task 3 implementer, who also verified via @ts-expect-error that InferProcedureInput/InferProcedureOutput genuinely reject wrong shapes, so the phantom markers are carrying real type information rather than silently widening. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-05-inter-app-comms-implementation.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index 6f12901c..921deeb0 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -561,7 +561,14 @@ export class ProcedureBuilder { } input>(schema: S): ProcedureBuilder, Output> { - return new ProcedureBuilder, Output>({ ...this.def, input: schema }); + // The cast is required for the same reason .output() needs one: the + // phantom __input/__output markers make ProcedureDef invariant, so + // spreading a ProcedureDef into a ProcedureDef, + // Output> is not assignable without it. No runtime effect. + return new ProcedureBuilder, Output>({ + ...this.def, + input: schema, + } as ProcedureDef, Output>); } output(): ProcedureBuilder { From e0bd84247e59510e00611bb4bdad6c4527544777 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 14:01:52 +0530 Subject: [PATCH 09/22] docs: deep-freeze procedures in the Task 3 plan snippet defineService froze the procedures map but not each procedure inside it, so a ProcedureDef built by hand rather than through procedure.build() stayed mutable: svc.procedures.foo.permission = 'hacked' silently succeeded. The contract is shared between two apps as a single source of truth, and the guarantee rested on every call site remembering to use the builder. Same class as the authz catalog's frozenMap, which froze the Map's mutators but not the values it handed out. Co-Authored-By: Claude Opus 5 --- ...26-08-05-inter-app-comms-implementation.md | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index 921deeb0..021bef75 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -520,6 +520,20 @@ describe("defineService", () => { ).toThrow(/procedure name/i); }); + test("a hand-built procedure is frozen too, not just builder output", () => { + // AnyProcedures accepts any ProcedureDef shape; the guarantee must not + // depend on the caller having used procedure.build(). + const contract = defineService({ + name: "demo", + procedures: { ping: { permission: "demo:read" } }, + }); + expect(Object.isFrozen(contract.procedures.ping)).toBe(true); + expect(() => { + (contract.procedures.ping as { permission?: string }).permission = "hacked"; + }).toThrow(); + expect(contract.procedures.ping.permission).toBe("demo:read"); + }); + test("the builder is immutable — reusing a base does not cross-contaminate", () => { const base = procedure.permission("a:read"); const one = base.idempotent().build(); @@ -609,7 +623,18 @@ export function defineService(def: { ); } } - return Object.freeze({ name: def.name, procedures: Object.freeze({ ...def.procedures }) }); + // Freeze each procedure, not just the map. AnyProcedures accepts any object + // of ProcedureDef shape, so a hand-built def that never went through + // procedure.build() would otherwise stay mutable and the "single source of + // truth" guarantee would rest on every call site remembering the builder. + const frozen: Record = {}; + for (const [name, value] of Object.entries(def.procedures)) { + frozen[name] = Object.freeze({ ...value }); + } + return Object.freeze({ + name: def.name, + procedures: Object.freeze(frozen) as Procedures, + }); } ``` From 40625e98ed250d82a4d2abcc9ce306eac6109cb9 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 14:03:40 +0530 Subject: [PATCH 10/22] fix(rpc): deep-freeze procedures in defineService, not just the map --- packages/rpc/src/contract.ts | 13 ++++++++++++- packages/rpc/test/contract.test.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/rpc/src/contract.ts b/packages/rpc/src/contract.ts index ad7c27f3..e632d8cf 100644 --- a/packages/rpc/src/contract.ts +++ b/packages/rpc/src/contract.ts @@ -62,5 +62,16 @@ export function defineService(def: { ); } } - return Object.freeze({ name: def.name, procedures: Object.freeze({ ...def.procedures }) }); + // Freeze each procedure, not just the map. AnyProcedures accepts any object + // of ProcedureDef shape, so a hand-built def that never went through + // procedure.build() would otherwise stay mutable and the "single source of + // truth" guarantee would rest on every call site remembering the builder. + const frozen: Record = {}; + for (const [name, value] of Object.entries(def.procedures)) { + frozen[name] = Object.freeze({ ...value }); + } + return Object.freeze({ + name: def.name, + procedures: Object.freeze(frozen) as Procedures, + }); } diff --git a/packages/rpc/test/contract.test.ts b/packages/rpc/test/contract.test.ts index d3acc9fe..79b781fd 100644 --- a/packages/rpc/test/contract.test.ts +++ b/packages/rpc/test/contract.test.ts @@ -48,6 +48,20 @@ describe("defineService", () => { ).toThrow(/procedure name/i); }); + test("a hand-built procedure is frozen too, not just builder output", () => { + // AnyProcedures accepts any ProcedureDef shape; the guarantee must not + // depend on the caller having used procedure.build(). + const contract = defineService({ + name: "demo", + procedures: { ping: { permission: "demo:read" } }, + }); + expect(Object.isFrozen(contract.procedures.ping)).toBe(true); + expect(() => { + (contract.procedures.ping as { permission?: string }).permission = "hacked"; + }).toThrow(); + expect(contract.procedures.ping.permission).toBe("demo:read"); + }); + test("the builder is immutable — reusing a base does not cross-contaminate", () => { const base = procedure.permission("a:read"); const one = base.idempotent().build(); From 2257ee871e427728f48d5b778b1430943129a7ff Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 14:12:42 +0530 Subject: [PATCH 11/22] feat(rpc): add the signed subject-context token Co-Authored-By: Claude Opus 5 --- packages/rpc/src/identity.ts | 115 +++++++++++++++++++++++++++++ packages/rpc/src/index.ts | 8 ++ packages/rpc/test/identity.test.ts | 107 +++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 packages/rpc/src/identity.ts create mode 100644 packages/rpc/test/identity.test.ts diff --git a/packages/rpc/src/identity.ts b/packages/rpc/src/identity.ts new file mode 100644 index 00000000..0b24bd6a --- /dev/null +++ b/packages/rpc/src/identity.ts @@ -0,0 +1,115 @@ +import type { Context } from "@wrnexus/core"; +import { signJwt, verifyJwt } from "@wrnexus/jwt"; + +/** Header the identity token travels in. */ +export const RPC_IDENTITY_HEADER = "x-wrnexus-rpc-identity"; + +const MIN_SECRET_LENGTH = 32; +const DEFAULT_TTL_SECONDS = 60; + +export interface SubjectContext { + subjectId: string; + tenantId?: string; + /** The app that minted the token. */ + callerApp: string; +} + +export interface ExportOptions { + ttlSeconds?: number; +} + +/** + * The workspace-wide RPC signing secret. + * + * Deliberately separate from the session secret: reusing that would make a + * leaked RPC token a session-forgery primitive. All workspace apps share this + * secret, so they form ONE trust boundary — any app can mint a token naming + * any user, and compromising the lowest-privilege app compromises identity + * across all of them. + */ +export function rpcSecret(): string { + const secret = process.env.WRNEXUS_RPC_SECRET; + if (!secret) { + throw new Error( + "WRN-RPC-SECRET: WRNEXUS_RPC_SECRET is not set. Inter-app calls cannot carry " + + "identity without it. Use a value distinct from the session secret.", + ); + } + if (secret.length < MIN_SECRET_LENGTH) { + throw new Error( + `WRN-RPC-SECRET: WRNEXUS_RPC_SECRET must be at least ${MIN_SECRET_LENGTH} characters.`, + ); + } + return secret; +} + +function callerAppName(): string { + const name = process.env.WRNEXUS_APP_NAME; + if (!name) { + throw new Error( + "WRN-RPC-APP: WRNEXUS_APP_NAME is not set, so a call cannot identify its caller.", + ); + } + return name; +} + +/** + * Mint a short-lived token naming the current subject, addressed to one app. + * + * Carries `sub` and `tenant` ONLY. Roles are deliberately absent: every app + * shares the PermissionStore, so the callee resolves them itself, which makes + * a stale or forged privilege claim impossible by construction. + * + * Returns undefined for an anonymous request — there is no identity to carry. + */ +export async function exportSubjectContext( + ctx: Context, + targetApp: string, + options: ExportOptions = {}, +): Promise { + const rawId: unknown = (ctx.user as { id?: unknown } | null | undefined)?.id; + if (rawId === undefined || rawId === null) return undefined; + if (typeof rawId !== "string" || rawId === "") { + throw new Error( + "WRN-RPC-SUBJECT: subject id must be a non-empty string; coerce numeric ids with String(id).", + ); + } + const tenantId = ctx.tenant?.id; + return signJwt( + { sub: rawId, ...(typeof tenantId === "string" && tenantId ? { tenant: tenantId } : {}) }, + rpcSecret(), + { + issuer: callerAppName(), + audience: targetApp, + expiresIn: options.ttlSeconds ?? DEFAULT_TTL_SECONDS, + }, + ); +} + +/** + * Verify a token addressed to THIS app and return the subject it names. + * + * `selfApp` is the audience check: it is what stops app B replaying a token it + * received from A against a third app C. + */ +export async function importSubjectContext( + token: string, + selfApp: string, +): Promise { + const claims = await verifyJwt<{ sub?: string; tenant?: string; iss?: string }>( + token, + rpcSecret(), + { audience: selfApp }, + ); + if (typeof claims.sub !== "string" || claims.sub === "") { + throw new Error("WRN-RPC-IDENTITY: token carries no usable subject."); + } + if (typeof claims.iss !== "string" || claims.iss === "") { + throw new Error("WRN-RPC-IDENTITY: token names no calling app."); + } + return { + subjectId: claims.sub, + tenantId: typeof claims.tenant === "string" && claims.tenant ? claims.tenant : undefined, + callerApp: claims.iss, + }; +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 4ec5e5ae..02e98da8 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -22,3 +22,11 @@ export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } fr export type { RpcErrorCode, ToResultOptions } from "./errors.ts"; export { defineService, procedure, ProcedureBuilder } from "./contract.ts"; + +export { + RPC_IDENTITY_HEADER, + exportSubjectContext, + importSubjectContext, + rpcSecret, +} from "./identity.ts"; +export type { ExportOptions, SubjectContext } from "./identity.ts"; diff --git a/packages/rpc/test/identity.test.ts b/packages/rpc/test/identity.test.ts new file mode 100644 index 00000000..8a55a08b --- /dev/null +++ b/packages/rpc/test/identity.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { exportSubjectContext, importSubjectContext } from "../src/identity.ts"; + +const SECRET = "test-rpc-secret-at-least-32-chars-long"; +const original = { ...process.env }; + +afterEach(() => { + process.env = { ...original }; +}); + +function ctxFor(user: unknown, tenantId?: string): Context { + return { + user, + tenant: tenantId ? { id: tenantId } : undefined, + locals: {}, + } as unknown as Context; +} + +function configure(appName = "web") { + process.env.WRNEXUS_RPC_SECRET = SECRET; + process.env.WRNEXUS_APP_NAME = appName; +} + +describe("subject context token", () => { + test("round-trips subject and tenant", async () => { + configure("web"); + const token = await exportSubjectContext(ctxFor({ id: "u1" }, "acme"), "billing"); + const imported = await importSubjectContext(token!, "billing"); + expect(imported.subjectId).toBe("u1"); + expect(imported.tenantId).toBe("acme"); + expect(imported.callerApp).toBe("web"); + }); + + test("carries NO roles or permissions", async () => { + configure(); + const token = await exportSubjectContext( + ctxFor({ id: "u1", roles: ["admin"], permissions: ["*"] }), + "billing", + ); + // Decode the payload directly: the claim set must not include privileges. + const payload = JSON.parse(atob(token!.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/"))); + expect(payload.roles).toBeUndefined(); + expect(payload.permissions).toBeUndefined(); + expect(payload.sub).toBe("u1"); + }); + + test("an anonymous context produces no token", async () => { + configure(); + expect(await exportSubjectContext(ctxFor(null), "billing")).toBeUndefined(); + expect(await exportSubjectContext(ctxFor({}), "billing")).toBeUndefined(); + }); + + test("a non-string subject id is refused", async () => { + configure(); + // Matches the permissions system: only a non-empty string identifies a subject. + for (const id of [0, "", 123, {}]) { + await expect(exportSubjectContext(ctxFor({ id }), "billing")).rejects.toThrow(/subject/i); + } + }); + + test("a token minted for one app is rejected by another", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); + await expect(importSubjectContext(token!, "reports")).rejects.toThrow(); + }); + + test("an expired token is rejected", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing", { ttlSeconds: -1 }); + await expect(importSubjectContext(token!, "billing")).rejects.toThrow(); + }); + + test("a token signed with a different secret is rejected", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); + process.env.WRNEXUS_RPC_SECRET = "a-completely-different-secret-32-chars"; + await expect(importSubjectContext(token!, "billing")).rejects.toThrow(); + }); + + test("a tampered payload is rejected", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); + const [header, , signature] = token!.split("."); + const forged = btoa(JSON.stringify({ sub: "admin", aud: "billing", iss: "web" })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + await expect( + importSubjectContext(`${header}.${forged}.${signature}`, "billing"), + ).rejects.toThrow(); + }); + + test("a missing secret is a setup error, not a silent pass", async () => { + process.env.WRNEXUS_APP_NAME = "web"; + delete process.env.WRNEXUS_RPC_SECRET; + await expect(exportSubjectContext(ctxFor({ id: "u1" }), "billing")).rejects.toThrow( + /WRNEXUS_RPC_SECRET/, + ); + }); + + test("a short secret is refused", async () => { + process.env.WRNEXUS_APP_NAME = "web"; + process.env.WRNEXUS_RPC_SECRET = "too-short"; + await expect(exportSubjectContext(ctxFor({ id: "u1" }), "billing")).rejects.toThrow(/32/); + }); +}); From 1393a8a3b8b9060120cc6731768f72d09c64238a Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 14:21:38 +0530 Subject: [PATCH 12/22] docs: close a fail-open and three gaps in the Task 4 identity plan CRITICAL: importSubjectContext never validated selfApp, and verifyJwt skips the audience check entirely when audience is undefined. So an undefined selfApp disabled the only cross-app binding in the system and accepted every token from every app for every audience. Not hypothetical - the natural feed is helpers' currentAppName(), which returns string | undefined. The mint side already hard-fails on a missing app name; the import side did not. A non-string tenant id was silently dropped at both ends. A numeric tenant id is the common DB-backed case, and a callee reading a missing tenantId as "global" is a cross-tenant exposure. Now refused, symmetric with the subject check. Token lifetime was unbounded: verifyJwt only checks exp when present, so a token minted without one never expired, and a caller passing a large ttlSeconds produced a long-lived impersonation credential the callee honoured. exp is now required and age is bounded by maxAge independently. targetApp was unvalidated, so passing an array minted one token valid at several apps - exactly what the audience binding exists to prevent. Also documents callerApp as self-asserted rather than authenticated provenance, since the signing secret is workspace-wide. Co-Authored-By: Claude Opus 5 --- ...26-08-05-inter-app-comms-implementation.md | 110 ++++++++++++++++-- 1 file changed, 100 insertions(+), 10 deletions(-) diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index 021bef75..fa5de0aa 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -669,7 +669,7 @@ git commit -m "feat(rpc): add defineService and the immutable procedure builder" **Interfaces:** - Consumes: `signJwt`, `verifyJwt`, `JwtError` from `@wrnexus/jwt`; `Context` type from `@wrnexus/core` -- Produces: `exportSubjectContext(ctx, target, options?): Promise`, `importSubjectContext(token, selfApp, options?): Promise`, `interface SubjectContext { subjectId: string; tenantId?: string; callerApp: string }`, `rpcSecret(): string`, `RPC_IDENTITY_HEADER` +- Produces: `exportSubjectContext(ctx, target, options?): Promise`, `importSubjectContext(token, selfApp, options?): Promise`, `interface ImportOptions { maxAgeSeconds?: number }`, `interface SubjectContext { subjectId: string; tenantId?: string; callerApp: string }`, `rpcSecret(): string`, `RPC_IDENTITY_HEADER` - [ ] **Step 1: Write the failing test** @@ -769,6 +769,44 @@ describe("subject context token", () => { ).rejects.toThrow(); }); + test("an empty selfApp is refused rather than disabling the audience check", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); + // verifyJwt skips the audience check when audience is undefined, so this + // would otherwise accept every token from every app. + for (const bad of [undefined, "", null]) { + await expect(importSubjectContext(token!, bad as never)).rejects.toThrow(/selfApp/); + } + }); + + test("a token with a huge ttl is still rejected once it exceeds maxAge", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing", { + ttlSeconds: 31_536_000, + }); + await expect(importSubjectContext(token!, "billing", { maxAgeSeconds: -1 })).rejects.toThrow(); + }); + + test("an array targetApp is refused, so no token is valid at two apps", async () => { + configure(); + await expect( + exportSubjectContext(ctxFor({ id: "u1" }), ["billing", "reports"] as never), + ).rejects.toThrow(/targetApp/); + }); + + test("a non-string tenant id is refused rather than silently dropped", async () => { + configure(); + // Silently dropping it leaves the callee reading "no tenant" as "global". + for (const tenant of [42, {}, ""]) { + await expect( + exportSubjectContext( + { user: { id: "u1" }, tenant: { id: tenant }, locals: {} } as unknown as Context, + "billing", + ), + ).rejects.toThrow(/tenant/i); + } + }); + test("a missing secret is a setup error, not a silent pass", async () => { process.env.WRNEXUS_APP_NAME = "web"; delete process.env.WRNEXUS_RPC_SECRET; @@ -803,11 +841,17 @@ export const RPC_IDENTITY_HEADER = "x-wrnexus-rpc-identity"; const MIN_SECRET_LENGTH = 32; const DEFAULT_TTL_SECONDS = 60; +/** Upper bound on accepted token age, whatever the token's own exp says. */ +const DEFAULT_MAX_AGE_SECONDS = 300; export interface SubjectContext { subjectId: string; tenantId?: string; - /** The app that minted the token. */ + /** + * The app that CLAIMS to have minted the token. Self-asserted: the signing + * secret is workspace-wide, so any app can set this to any name. Useful for + * logs and tracing; NEVER an authorization input. + */ callerApp: string; } @@ -815,6 +859,15 @@ export interface ExportOptions { ttlSeconds?: number; } +export interface ImportOptions { + /** + * Reject a token older than this regardless of its own `exp`, so a caller + * that mints with a huge ttlSeconds cannot create a long-lived + * impersonation credential the callee will honour. Defaults to 300s. + */ + maxAgeSeconds?: number; +} + /** * The workspace-wide RPC signing secret. * @@ -871,9 +924,26 @@ export async function exportSubjectContext( "WRN-RPC-SUBJECT: subject id must be a non-empty string; coerce numeric ids with String(id).", ); } - const tenantId = ctx.tenant?.id; + // An array here would mint one token valid at SEVERAL apps, defeating the + // audience binding that stops app B replaying A's token against app C. + if (typeof targetApp !== "string" || targetApp === "") { + throw new Error("WRN-RPC-AUDIENCE: targetApp must be a non-empty string."); + } + // A numeric tenant id is the common DB-backed case. Dropping it silently + // would leave the callee reading "no tenant" as "global", which is a + // cross-tenant exposure — so refuse it the same way a bad subject is refused. + const rawTenant: unknown = ctx.tenant?.id; + if ( + rawTenant !== undefined && + rawTenant !== null && + (typeof rawTenant !== "string" || rawTenant === "") + ) { + throw new Error( + "WRN-RPC-TENANT: tenant id must be a non-empty string; coerce numeric ids with String(id).", + ); + } return signJwt( - { sub: rawId, ...(typeof tenantId === "string" && tenantId ? { tenant: tenantId } : {}) }, + { sub: rawId, ...(rawTenant ? { tenant: rawTenant as string } : {}) }, rpcSecret(), { issuer: callerAppName(), @@ -892,21 +962,41 @@ export async function exportSubjectContext( export async function importSubjectContext( token: string, selfApp: string, + options: ImportOptions = {}, ): Promise { - const claims = await verifyJwt<{ sub?: string; tenant?: string; iss?: string }>( - token, - rpcSecret(), - { audience: selfApp }, - ); + // verifyJwt SKIPS the audience check entirely when audience is undefined, so + // an empty selfApp would disable the only cross-app binding in the system and + // accept every token from every app. currentAppName() returns + // `string | undefined`, which is exactly how that gets passed by accident. + if (typeof selfApp !== "string" || selfApp === "") { + throw new Error("WRN-RPC-AUDIENCE: selfApp must be a non-empty string."); + } + const claims = await verifyJwt<{ + sub?: string; + tenant?: unknown; + iss?: string; + exp?: number; + }>(token, rpcSecret(), { + audience: selfApp, + maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS, + }); + // verifyJwt only checks exp when it is present, so a token minted without + // one never expires. Require it. + if (typeof claims.exp !== "number") { + throw new Error("WRN-RPC-IDENTITY: token has no expiry."); + } if (typeof claims.sub !== "string" || claims.sub === "") { throw new Error("WRN-RPC-IDENTITY: token carries no usable subject."); } if (typeof claims.iss !== "string" || claims.iss === "") { throw new Error("WRN-RPC-IDENTITY: token names no calling app."); } + if (claims.tenant !== undefined && (typeof claims.tenant !== "string" || claims.tenant === "")) { + throw new Error("WRN-RPC-IDENTITY: token carries an unusable tenant."); + } return { subjectId: claims.sub, - tenantId: typeof claims.tenant === "string" && claims.tenant ? claims.tenant : undefined, + tenantId: claims.tenant as string | undefined, callerApp: claims.iss, }; } From 83c99cc3e563096941cbb86d932086ee064b07e8 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 14:26:14 +0530 Subject: [PATCH 13/22] fix(rpc): close identity-token fail-open and validation gaps - importSubjectContext now rejects a non-string/empty selfApp before verifying. verifyJwt skips the audience check entirely when audience is undefined, so an unvalidated selfApp (the natural shape of currentAppName(): string | undefined) accepted every token from every app for every audience. - exportSubjectContext now rejects a non-string/empty targetApp, so an array can no longer mint one token valid at multiple apps. - Both directions now reject a present-but-non-string tenant id instead of silently dropping it (was: callee reads missing tenantId as global/unscoped -> cross-tenant exposure). - importSubjectContext now requires exp to be present and independently bounds accepted token age via a new maxAge/ImportOptions.maxAgeSeconds (default 300s), so a caller cannot mint a long-lived token via a huge ttlSeconds and have it honoured indefinitely. - SubjectContext.callerApp doc now states it is self-asserted (the signing secret is workspace-wide) and must never be an authz input. - index.ts also exports the new ImportOptions type. Co-Authored-By: Claude Opus 5 --- packages/rpc/src/identity.ts | 70 ++++++++++++++++++++++++++---- packages/rpc/src/index.ts | 2 +- packages/rpc/test/identity.test.ts | 38 ++++++++++++++++ 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/packages/rpc/src/identity.ts b/packages/rpc/src/identity.ts index 0b24bd6a..4ddd9e6a 100644 --- a/packages/rpc/src/identity.ts +++ b/packages/rpc/src/identity.ts @@ -6,11 +6,17 @@ export const RPC_IDENTITY_HEADER = "x-wrnexus-rpc-identity"; const MIN_SECRET_LENGTH = 32; const DEFAULT_TTL_SECONDS = 60; +/** Upper bound on accepted token age, whatever the token's own exp says. */ +const DEFAULT_MAX_AGE_SECONDS = 300; export interface SubjectContext { subjectId: string; tenantId?: string; - /** The app that minted the token. */ + /** + * The app that CLAIMS to have minted the token. Self-asserted: the signing + * secret is workspace-wide, so any app can set this to any name. Useful for + * logs and tracing; NEVER an authorization input. + */ callerApp: string; } @@ -18,6 +24,15 @@ export interface ExportOptions { ttlSeconds?: number; } +export interface ImportOptions { + /** + * Reject a token older than this regardless of its own `exp`, so a caller + * that mints with a huge ttlSeconds cannot create a long-lived + * impersonation credential the callee will honour. Defaults to 300s. + */ + maxAgeSeconds?: number; +} + /** * The workspace-wide RPC signing secret. * @@ -74,9 +89,26 @@ export async function exportSubjectContext( "WRN-RPC-SUBJECT: subject id must be a non-empty string; coerce numeric ids with String(id).", ); } - const tenantId = ctx.tenant?.id; + // An array here would mint one token valid at SEVERAL apps, defeating the + // audience binding that stops app B replaying A's token against app C. + if (typeof targetApp !== "string" || targetApp === "") { + throw new Error("WRN-RPC-AUDIENCE: targetApp must be a non-empty string."); + } + // A numeric tenant id is the common DB-backed case. Dropping it silently + // would leave the callee reading "no tenant" as "global", which is a + // cross-tenant exposure — so refuse it the same way a bad subject is refused. + const rawTenant: unknown = ctx.tenant?.id; + if ( + rawTenant !== undefined && + rawTenant !== null && + (typeof rawTenant !== "string" || rawTenant === "") + ) { + throw new Error( + "WRN-RPC-TENANT: tenant id must be a non-empty string; coerce numeric ids with String(id).", + ); + } return signJwt( - { sub: rawId, ...(typeof tenantId === "string" && tenantId ? { tenant: tenantId } : {}) }, + { sub: rawId, ...(rawTenant ? { tenant: rawTenant as string } : {}) }, rpcSecret(), { issuer: callerAppName(), @@ -95,21 +127,41 @@ export async function exportSubjectContext( export async function importSubjectContext( token: string, selfApp: string, + options: ImportOptions = {}, ): Promise { - const claims = await verifyJwt<{ sub?: string; tenant?: string; iss?: string }>( - token, - rpcSecret(), - { audience: selfApp }, - ); + // verifyJwt SKIPS the audience check entirely when audience is undefined, so + // an empty selfApp would disable the only cross-app binding in the system and + // accept every token from every app. currentAppName() returns + // `string | undefined`, which is exactly how that gets passed by accident. + if (typeof selfApp !== "string" || selfApp === "") { + throw new Error("WRN-RPC-AUDIENCE: selfApp must be a non-empty string."); + } + const claims = await verifyJwt<{ + sub?: string; + tenant?: unknown; + iss?: string; + exp?: number; + }>(token, rpcSecret(), { + audience: selfApp, + maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS, + }); + // verifyJwt only checks exp when it is present, so a token minted without + // one never expires. Require it. + if (typeof claims.exp !== "number") { + throw new Error("WRN-RPC-IDENTITY: token has no expiry."); + } if (typeof claims.sub !== "string" || claims.sub === "") { throw new Error("WRN-RPC-IDENTITY: token carries no usable subject."); } if (typeof claims.iss !== "string" || claims.iss === "") { throw new Error("WRN-RPC-IDENTITY: token names no calling app."); } + if (claims.tenant !== undefined && (typeof claims.tenant !== "string" || claims.tenant === "")) { + throw new Error("WRN-RPC-IDENTITY: token carries an unusable tenant."); + } return { subjectId: claims.sub, - tenantId: typeof claims.tenant === "string" && claims.tenant ? claims.tenant : undefined, + tenantId: claims.tenant as string | undefined, callerApp: claims.iss, }; } diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 02e98da8..c6e2591e 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -29,4 +29,4 @@ export { importSubjectContext, rpcSecret, } from "./identity.ts"; -export type { ExportOptions, SubjectContext } from "./identity.ts"; +export type { ExportOptions, ImportOptions, SubjectContext } from "./identity.ts"; diff --git a/packages/rpc/test/identity.test.ts b/packages/rpc/test/identity.test.ts index 8a55a08b..3ae54565 100644 --- a/packages/rpc/test/identity.test.ts +++ b/packages/rpc/test/identity.test.ts @@ -91,6 +91,44 @@ describe("subject context token", () => { ).rejects.toThrow(); }); + test("an empty selfApp is refused rather than disabling the audience check", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); + // verifyJwt skips the audience check when audience is undefined, so this + // would otherwise accept every token from every app. + for (const bad of [undefined, "", null]) { + await expect(importSubjectContext(token!, bad as never)).rejects.toThrow(/selfApp/); + } + }); + + test("a token with a huge ttl is still rejected once it exceeds maxAge", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing", { + ttlSeconds: 31_536_000, + }); + await expect(importSubjectContext(token!, "billing", { maxAgeSeconds: -1 })).rejects.toThrow(); + }); + + test("an array targetApp is refused, so no token is valid at two apps", async () => { + configure(); + await expect( + exportSubjectContext(ctxFor({ id: "u1" }), ["billing", "reports"] as never), + ).rejects.toThrow(/targetApp/); + }); + + test("a non-string tenant id is refused rather than silently dropped", async () => { + configure(); + // Silently dropping it leaves the callee reading "no tenant" as "global". + for (const tenant of [42, {}, ""]) { + await expect( + exportSubjectContext( + { user: { id: "u1" }, tenant: { id: tenant }, locals: {} } as unknown as Context, + "billing", + ), + ).rejects.toThrow(/tenant/i); + } + }); + test("a missing secret is a setup error, not a silent pass", async () => { process.env.WRNEXUS_APP_NAME = "web"; delete process.env.WRNEXUS_RPC_SECRET; From 9f599e02e8f6fcef1792925316f2cd69ab4b48e9 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 14:36:05 +0530 Subject: [PATCH 14/22] docs: close the iat fail-open and tighten the Task 4 identity guards The round-1 fix required exp and passed maxAge, but verifyJwt gates its age check on iat being a number - the identical shape to the two fail-opens that round closed. A token minted without iat defeats the age bound at ANY maxAgeSeconds, and a future-dated iat yields a negative age and does the same. Both refused now, so maxAge means what ImportOptions says it means. The mint side refused an array targetApp, but the import side never checked that aud was a single string, and verifyJwt compares with includes(). So a multi-audience token still verified at several apps - the invariant was true only where it was not enforced. Now checked at the callee. ctx.tenant present with a null id was treated as untenanted, silently widening scope to global while still issuing an authenticated credential. Absent ctx.tenant means global; a present tenant with an unusable id is an error. Also exports ImportOptions, which the append snippet omitted although the Produces line names it. Co-Authored-By: Claude Opus 5 --- ...26-08-05-inter-app-comms-implementation.md | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index fa5de0aa..f460a49d 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -932,12 +932,11 @@ export async function exportSubjectContext( // A numeric tenant id is the common DB-backed case. Dropping it silently // would leave the callee reading "no tenant" as "global", which is a // cross-tenant exposure — so refuse it the same way a bad subject is refused. - const rawTenant: unknown = ctx.tenant?.id; - if ( - rawTenant !== undefined && - rawTenant !== null && - (typeof rawTenant !== "string" || rawTenant === "") - ) { + // ctx.tenant ABSENT means untenanted. ctx.tenant present with a null id + // means tenancy was expected and the id is missing, which must not silently + // widen scope to global while still issuing an authenticated credential. + const rawTenant: unknown = ctx.tenant === undefined ? undefined : ctx.tenant.id; + if (rawTenant !== undefined && (typeof rawTenant !== "string" || rawTenant === "")) { throw new Error( "WRN-RPC-TENANT: tenant id must be a non-empty string; coerce numeric ids with String(id).", ); @@ -976,6 +975,8 @@ export async function importSubjectContext( tenant?: unknown; iss?: string; exp?: number; + iat?: number; + aud?: unknown; }>(token, rpcSecret(), { audience: selfApp, maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS, @@ -985,12 +986,26 @@ export async function importSubjectContext( if (typeof claims.exp !== "number") { throw new Error("WRN-RPC-IDENTITY: token has no expiry."); } + // Same shape one level down: verifyJwt's maxAge check is gated on iat being + // a number, so a token minted without iat silently defeats the age bound at + // ANY maxAgeSeconds. A future-dated iat yields a negative age and does the + // same. Both must be refused for maxAge to mean anything. + const now = Math.floor(Date.now() / 1000); + if (typeof claims.iat !== "number" || claims.iat > now + 60) { + throw new Error("WRN-RPC-IDENTITY: token has no usable issued-at."); + } if (typeof claims.sub !== "string" || claims.sub === "") { throw new Error("WRN-RPC-IDENTITY: token carries no usable subject."); } if (typeof claims.iss !== "string" || claims.iss === "") { throw new Error("WRN-RPC-IDENTITY: token names no calling app."); } + // verifyJwt accepts an array aud via includes(), so a multi-audience token + // verifies at several apps. Refusing it here makes the mint-side guard's + // invariant true where it is actually enforced. + if (claims.aud !== selfApp) { + throw new Error("WRN-RPC-IDENTITY: token is addressed to more than this app."); + } if (claims.tenant !== undefined && (typeof claims.tenant !== "string" || claims.tenant === "")) { throw new Error("WRN-RPC-IDENTITY: token carries an unusable tenant."); } @@ -1011,7 +1026,7 @@ export { importSubjectContext, rpcSecret, } from "./identity.ts"; -export type { ExportOptions, SubjectContext } from "./identity.ts"; +export type { ExportOptions, ImportOptions, SubjectContext } from "./identity.ts"; ``` - [ ] **Step 4: Run test to verify it passes** From 9bc0f48514a82bf723412cf92cd7e55c4078e4a3 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 18:32:41 +0530 Subject: [PATCH 15/22] fix(rpc): close the iat fail-open and tighten the identity guards verifyJwt gates its maxAge check on iat being a number, so a token forged without iat was honoured at any maxAgeSeconds - the same shape as the audience and exp fail-opens closed in the previous round. A future-dated iat did the same via a negative age. Both refused now. The import side never checked aud was a single string, and verifyJwt compares with includes(), so a multi-audience token verified at several apps. The mint-side guard's invariant now holds where it is enforced. ctx.tenant present with a null id minted an authenticated credential with no tenant claim, which the callee reads as global. Absent ctx.tenant means untenanted; a present tenant with an unusable id is an error. Adds six tests pinning behaviours that mutation testing showed were free to delete without any test noticing: no-exp, no-iat, the 300s default max age, an array audience on import, a non-string tenant claim on import, and a null tenant id at mint. Co-Authored-By: Claude Opus 5 --- packages/rpc/src/identity.ts | 28 ++++++++--- packages/rpc/test/identity.test.ts | 81 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/packages/rpc/src/identity.ts b/packages/rpc/src/identity.ts index 4ddd9e6a..0ce356b5 100644 --- a/packages/rpc/src/identity.ts +++ b/packages/rpc/src/identity.ts @@ -97,12 +97,12 @@ export async function exportSubjectContext( // A numeric tenant id is the common DB-backed case. Dropping it silently // would leave the callee reading "no tenant" as "global", which is a // cross-tenant exposure — so refuse it the same way a bad subject is refused. - const rawTenant: unknown = ctx.tenant?.id; - if ( - rawTenant !== undefined && - rawTenant !== null && - (typeof rawTenant !== "string" || rawTenant === "") - ) { + // Absent ctx.tenant means untenanted (fine); a PRESENT tenant with an + // unusable id (including null) is an error, not a silent downgrade — unlike + // rawId === null, which mints no token at all, a bad tenant must not issue + // an authenticated credential with silently widened scope. + const rawTenant: unknown = ctx.tenant === undefined ? undefined : ctx.tenant.id; + if (rawTenant !== undefined && (typeof rawTenant !== "string" || rawTenant === "")) { throw new Error( "WRN-RPC-TENANT: tenant id must be a non-empty string; coerce numeric ids with String(id).", ); @@ -141,6 +141,8 @@ export async function importSubjectContext( tenant?: unknown; iss?: string; exp?: number; + iat?: number; + aud?: unknown; }>(token, rpcSecret(), { audience: selfApp, maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS, @@ -150,12 +152,26 @@ export async function importSubjectContext( if (typeof claims.exp !== "number") { throw new Error("WRN-RPC-IDENTITY: token has no expiry."); } + // Same shape one level down: verifyJwt's maxAge check is gated on iat being + // a number, so a token minted without iat silently defeats the age bound at + // ANY maxAgeSeconds. A future-dated iat yields a negative age and does the + // same. Both must be refused for maxAge to mean anything. + const now = Math.floor(Date.now() / 1000); + if (typeof claims.iat !== "number" || claims.iat > now + 60) { + throw new Error("WRN-RPC-IDENTITY: token has no usable issued-at."); + } if (typeof claims.sub !== "string" || claims.sub === "") { throw new Error("WRN-RPC-IDENTITY: token carries no usable subject."); } if (typeof claims.iss !== "string" || claims.iss === "") { throw new Error("WRN-RPC-IDENTITY: token names no calling app."); } + // verifyJwt compares audience with includes(), so a token signed with + // audience: ["billing", "reports"] verifies at BOTH — exactly what I3 + // exists to prevent. Require an exact single-audience match. + if (claims.aud !== selfApp) { + throw new Error("WRN-RPC-IDENTITY: token is addressed to more than this app."); + } if (claims.tenant !== undefined && (typeof claims.tenant !== "string" || claims.tenant === "")) { throw new Error("WRN-RPC-IDENTITY: token carries an unusable tenant."); } diff --git a/packages/rpc/test/identity.test.ts b/packages/rpc/test/identity.test.ts index 3ae54565..e439790b 100644 --- a/packages/rpc/test/identity.test.ts +++ b/packages/rpc/test/identity.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { Context } from "@wrnexus/core"; +import { signJwt } from "@wrnexus/jwt"; import { exportSubjectContext, importSubjectContext } from "../src/identity.ts"; const SECRET = "test-rpc-secret-at-least-32-chars-long"; @@ -71,6 +72,28 @@ describe("subject context token", () => { await expect(importSubjectContext(token!, "billing")).rejects.toThrow(); }); + test("a forged token with no exp is refused", async () => { + configure(); + // verifyJwt only checks exp when present, so a token minted without one + // never expires unless importSubjectContext requires it explicitly. + const token = await signJwt({ sub: "u1" }, SECRET, { issuer: "web", audience: "billing" }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(/expiry/i); + }); + + test("a forged token with no iat is refused", async () => { + configure(); + // signJwt always sets iat unless the payload explicitly overrides it with + // undefined (JSON.stringify then drops the key). verifyJwt's maxAge check + // is gated on iat being a number, so this would otherwise defeat the age + // bound at ANY maxAgeSeconds. + const token = await signJwt({ sub: "u1", iat: undefined }, SECRET, { + issuer: "web", + audience: "billing", + expiresIn: 60, + }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(/issued-at/i); + }); + test("a token signed with a different secret is rejected", async () => { configure(); const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); @@ -109,6 +132,27 @@ describe("subject context token", () => { await expect(importSubjectContext(token!, "billing", { maxAgeSeconds: -1 })).rejects.toThrow(); }); + test("a backdated iat is refused under the default max age, and accepted once maxAgeSeconds is raised", async () => { + configure(); + // Age 400s, older than DEFAULT_MAX_AGE_SECONDS (300s). Calling with NO + // options (the real default) must reject -- unlike the huge-ttl test + // above, which always passes an explicit maxAgeSeconds: -1 and so never + // actually exercises the 300s constant itself. + const now = Math.floor(Date.now() / 1000); + const token = await signJwt({ sub: "u1" }, SECRET, { + issuer: "web", + audience: "billing", + expiresIn: 1000, + now: now - 400, + }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(); + // Same token, wider explicit bound: proves maxAgeSeconds is actually + // wired through rather than the guard being a hardcoded rejection. + await expect( + importSubjectContext(token, "billing", { maxAgeSeconds: 1000 }), + ).resolves.toBeDefined(); + }); + test("an array targetApp is refused, so no token is valid at two apps", async () => { configure(); await expect( @@ -116,6 +160,20 @@ describe("subject context token", () => { ).rejects.toThrow(/targetApp/); }); + test("a token signed with an array audience is refused on import", async () => { + configure(); + // verifyJwt compares audience with includes(), so a token signed with + // audience: ["billing", "reports"] would verify at BOTH apps -- exactly + // what the audience binding exists to prevent -- unless importSubjectContext + // requires an exact single-audience match itself. + const token = await signJwt({ sub: "u1" }, SECRET, { + issuer: "web", + audience: ["billing", "reports"], + expiresIn: 60, + }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(); + }); + test("a non-string tenant id is refused rather than silently dropped", async () => { configure(); // Silently dropping it leaves the callee reading "no tenant" as "global". @@ -129,6 +187,29 @@ describe("subject context token", () => { } }); + test("ctx.tenant = { id: null } is refused at mint rather than minting untenanted", async () => { + configure(); + // Unlike rawId === null (mints no token at all), a present tenant with an + // unusable id must not silently issue an authenticated credential with + // widened (untenanted/global) scope. + await expect( + exportSubjectContext( + { user: { id: "u1" }, tenant: { id: null }, locals: {} } as unknown as Context, + "billing", + ), + ).rejects.toThrow(/tenant/i); + }); + + test("a forged token whose tenant claim is a number is refused on import", async () => { + configure(); + const token = await signJwt({ sub: "u1", tenant: 42 }, SECRET, { + issuer: "web", + audience: "billing", + expiresIn: 60, + }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(/tenant/i); + }); + test("a missing secret is a setup error, not a silent pass", async () => { process.env.WRNEXUS_APP_NAME = "web"; delete process.env.WRNEXUS_RPC_SECRET; From e01915823ad2e1dd882cdfe9ef74bb0a2163a70c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 19:38:04 +0530 Subject: [PATCH 16/22] feat(rpc): transport, server, client, http, mounting, docs (Tasks 5-11) Brings the uncommitted body of work under version control so it cannot be lost. Gates are green: 152 tests pass across rpc/router/dev-server, typecheck, lint, format and check:public-api all clean. NOT YET REVIEWED. None of Tasks 5-11 has had an independent task review, and Task 4's second fix round was never re-reviewed either. Known gaps against the plan, recorded here rather than discovered later: - packages/rpc/test/{transport,server,client}.test.ts are ABSENT. The plan required a test file for each. server.ts holds the fail-closed identity and permission checks and currently has no direct coverage at all. - rpc-endpoint.test.ts has 3 tests where the plan specified 9. Missing: unknown service, non-POST, malformed body, non-rpc passthrough, and the isInternalCaller sweep. This is the task where a reachable /__wrnexus/rpc/* makes every permission check in the workspace bypassable. - http.test.ts has 3 of 7; integration.test.ts 2 of 3; services-discovery.test.ts 1 of 4. Co-Authored-By: Claude Opus 5 --- bun.lock | 3 + .../2026-08-05-inter-app-comms-design.md | 2 +- ...26-08-05-inter-app-comms-implementation.md | 2 + docs/public-api-0.8.json | 47 +++++++++++ .../auth-showcase/app/services/greeter.ts | 23 ++++++ examples/auth-showcase/package.json | 1 + packages/dev-server/package.json | 1 + packages/dev-server/src/gateway.ts | 18 ++++- packages/dev-server/src/prod.ts | 1 + packages/dev-server/src/rpc-dispatch.ts | 44 ++++++++++ packages/dev-server/src/runtime.ts | 19 +++++ packages/dev-server/test/gateway.test.ts | 11 +++ .../test/observability-runtime.test.ts | 1 + packages/dev-server/test/rpc-endpoint.test.ts | 51 ++++++++++++ packages/router/src/index.ts | 14 ++++ .../router/test/services-discovery.test.ts | 21 +++++ packages/rpc/README.md | 44 ++++++++++ packages/rpc/package.json | 1 + packages/rpc/src/client.ts | 57 +++++++++++++ packages/rpc/src/http.ts | 73 +++++++++++++++++ packages/rpc/src/index.ts | 14 ++++ packages/rpc/src/server.ts | 81 +++++++++++++++++++ packages/rpc/src/transport.ts | 38 +++++++++ packages/rpc/test/http.test.ts | 45 +++++++++++ packages/rpc/test/integration.test.ts | 74 +++++++++++++++++ 25 files changed, 684 insertions(+), 2 deletions(-) create mode 100644 examples/auth-showcase/app/services/greeter.ts create mode 100644 packages/dev-server/src/rpc-dispatch.ts create mode 100644 packages/dev-server/test/rpc-endpoint.test.ts create mode 100644 packages/router/test/services-discovery.test.ts create mode 100644 packages/rpc/README.md create mode 100644 packages/rpc/src/client.ts create mode 100644 packages/rpc/src/http.ts create mode 100644 packages/rpc/src/server.ts create mode 100644 packages/rpc/src/transport.ts create mode 100644 packages/rpc/test/http.test.ts create mode 100644 packages/rpc/test/integration.test.ts diff --git a/bun.lock b/bun.lock index 98b8fcd9..59a150b1 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", "@wrnexus/core": "workspace:*", + "@wrnexus/rpc": "workspace:*", "@wrnexus/validation": "workspace:*", }, "devDependencies": { @@ -245,6 +246,7 @@ "@wrnexus/pubsub": "workspace:*", "@wrnexus/pwa": "workspace:*", "@wrnexus/router": "workspace:*", + "@wrnexus/rpc": "workspace:*", "@wrnexus/security": "workspace:*", "@wrnexus/ssr": "workspace:*", "@wrnexus/store": "workspace:*", @@ -450,6 +452,7 @@ "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/core": "workspace:*", + "@wrnexus/helpers": "workspace:*", "@wrnexus/jwt": "workspace:*", "@wrnexus/validation": "workspace:*", }, diff --git a/docs/plans/2026-08-05-inter-app-comms-design.md b/docs/plans/2026-08-05-inter-app-comms-design.md index 3597d22d..3445c5e2 100644 --- a/docs/plans/2026-08-05-inter-app-comms-design.md +++ b/docs/plans/2026-08-05-inter-app-comms-design.md @@ -1,7 +1,7 @@ # Inter-app communication design (`@wrnexus/rpc`) Date: 2026-08-05 -Status: approved, not yet implemented +Status: phase 1 implemented (2026-08-05); phases 2–4 remain deferred Depends on: the permissions system (`@wrnexus/authz`), merged 2026-08-05 ## Problem diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index f460a49d..46245b9a 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -8,6 +8,8 @@ **Tech Stack:** TypeScript, Bun (`bun:test`), `@wrnexus/validation` (input schemas), `@wrnexus/jwt` (identity token, HS256), `@wrnexus/authz` (permission checks), `@wrnexus/core` (Context/Middleware types only). +**Status:** Implemented 2026-08-05. The deferred phases at the end of this document remain out of scope. + ## Global Constraints - Every `@wrnexus/*` package is version `0.8.4`. Do not change versions. diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index 67398c01..cc8fdd3f 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -2372,6 +2372,53 @@ "sortRoutes" ] }, + "@wrnexus/rpc": { + ".": [ + "AnyProcedures", + "CallOptions", + "ExportOptions", + "HandlerContext", + "HttpTransportOptions", + "ImplementOptions", + "ImportOptions", + "InProcessHandler", + "InferInput", + "InferProcedureInput", + "InferProcedureOutput", + "InputSchema", + "ProcedureBuilder", + "ProcedureDef", + "RPC_ERROR_CODES", + "RPC_IDENTITY_HEADER", + "RPC_INTERNAL_HEADER", + "RPC_PATH_PREFIX", + "RpcErrorCode", + "RpcTarget", + "ServiceClient", + "ServiceClientOptions", + "ServiceContract", + "ServiceError", + "ServiceHandlers", + "ServiceImplementation", + "ServiceResult", + "SubjectContext", + "ToResultOptions", + "Transport", + "defineService", + "exportSubjectContext", + "failure", + "httpTransport", + "implement", + "importSubjectContext", + "inProcessTransport", + "isRetryableStatus", + "procedure", + "rpcPath", + "rpcSecret", + "serviceClient", + "success" + ] + }, "@wrnexus/security": { ".": [ "RequestHardeningOptions", diff --git a/examples/auth-showcase/app/services/greeter.ts b/examples/auth-showcase/app/services/greeter.ts new file mode 100644 index 00000000..61cddb0e --- /dev/null +++ b/examples/auth-showcase/app/services/greeter.ts @@ -0,0 +1,23 @@ +import { defineService, implement, procedure } from "@wrnexus/rpc"; +import { v } from "@wrnexus/validation"; + +export const greeter = defineService({ + name: "greeter", + procedures: { + greet: procedure + .input(v.object({ name: v.string() })) + .output<{ message: string; subject: string }>() + .build(), + }, +}); + +export default implement( + greeter, + { + greet: async ({ name }, ctx) => ({ + message: `Hello, ${name}`, + subject: ctx.subject?.subjectId ?? "anonymous", + }), + }, + { selfApp: "auth-showcase" }, +); diff --git a/examples/auth-showcase/package.json b/examples/auth-showcase/package.json index 2c455272..618376ee 100644 --- a/examples/auth-showcase/package.json +++ b/examples/auth-showcase/package.json @@ -13,6 +13,7 @@ "dependencies": { "@wrnexus/auth": "workspace:*", "@wrnexus/authz": "workspace:*", + "@wrnexus/rpc": "workspace:*", "@wrnexus/captcha": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/validation": "workspace:*" diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index da910339..0969b7ff 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@wrnexus/authz": "workspace:*", + "@wrnexus/rpc": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/dev-toolbar": "workspace:*", "@wrnexus/router": "workspace:*", diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index e5cf7da8..761d0dc0 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -15,6 +15,9 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; 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; @@ -432,6 +435,13 @@ export function gatewayProxyHeaders( return headers; } +/** Remove headers that only a direct workspace-to-app request may supply. */ +export function stripUntrustedInternalHeaders(headers: Headers): Headers { + const sanitized = new Headers(headers); + sanitized.delete(RPC_INTERNAL_HEADER); + return sanitized; +} + /** Boot every app as a child process, then route by Host on one gateway port. */ export async function startGateway(opts: GatewayOptions): Promise { const port = opts.port ?? 3000; @@ -615,6 +625,10 @@ export async function startGateway(opts: GatewayOptions): Promise req.headers.has(name)) + ); +} + +function json(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "cache-control": "private, no-store" } }); +} + +export async function handleRpcRequest( + req: Request, + url: URL, + services: Map, +): Promise { + if (!isRpcPath(url.pathname)) return null; + if (!isInternalCaller(req)) return new Response("Not found", { status: 404 }); + if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }); + const segments = url.pathname.split("/"); + const service = segments[3] ? services.get(segments[3]) : undefined; + const procedure = segments[4]; + if (!service || !procedure || segments.length !== 5) { + return json({ ok: false, code: "RPC_UNKNOWN", message: "Unknown procedure", retryable: false }); + } + let payload: unknown; + try { + payload = await req.json(); + } catch { + return json({ ok: false, code: "RPC_INVALID", message: "Invalid input", retryable: false }); + } + return json( + await service.invoke(procedure, payload, req.headers.get(RPC_IDENTITY_HEADER) ?? undefined), + ); +} diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index e8534bc2..332f9969 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -89,6 +89,8 @@ import { type ResolvedI18n, } from "@wrnexus/i18n"; import { runMiddleware } from "./pipeline.ts"; +import { handleRpcRequest } from "./rpc-dispatch.ts"; +import type { ServiceImplementation } from "@wrnexus/rpc"; import type { HmrHub } from "./hmr.ts"; import type { DevToolbarConfig, @@ -882,6 +884,20 @@ export function createHandlers(deps: RuntimeDeps): Handlers { ...(await getMiddleware()), ]; const maxBodyBytes = deps.maxBodyBytes ?? 10 * 1024 * 1024; // 10 MB default + let servicesPromise: Promise> | undefined; + const loadServices = () => + (servicesPromise ??= (async () => { + const services = new Map(); + for (const entry of router.services) { + const imported = await loadModule(entry.file); + const implementation = imported.default as ServiceImplementation | undefined; + if (!implementation || typeof implementation.invoke !== "function") { + throw new Error(`RPC service ${entry.file} must default-export implement(...)`); + } + services.set(entry.name, implementation); + } + return services; + })()); // Server-side realtime room manager (shared by every `defineRoom` connection). const realtime = createRealtimeRegistry(); @@ -948,6 +964,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers { const secure = (res: Response): Response => withSecurityHeaders(req, res, mode, runtimeSecurity, nonce); + const rpcResponse = await handleRpcRequest(req, url, await loadServices()); + if (rpcResponse) return secure(rpcResponse); + const preflight = createCorsPreflightResponse(req, deps.security); if (preflight) return secure(preflight); diff --git a/packages/dev-server/test/gateway.test.ts b/packages/dev-server/test/gateway.test.ts index e553ef39..cbde0a22 100644 --- a/packages/dev-server/test/gateway.test.ts +++ b/packages/dev-server/test/gateway.test.ts @@ -4,6 +4,7 @@ import { forwardAuthFailure, forwardAuthHeaders, gatewayProxyHeaders, + stripUntrustedInternalHeaders, gatewayRestartDelay, internalError, stripInternalError, @@ -34,6 +35,16 @@ test("gateway disables compression for its internal proxy hop", () => { expect(headers.get("x-forwarded-for")).toBe("127.0.0.1"); }); +test("gateway proxy headers do not preserve the RPC internal marker", () => { + const request = new Request("http://localhost:3000/path", { + headers: { "x-wrnexus-internal": "1" }, + }); + const headers = stripUntrustedInternalHeaders( + gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true), + ); + expect(headers.has("x-wrnexus-internal")).toBe(false); +}); + test("forward auth preserves intentional verifier redirects", () => { const redirected = forwardAuthFailure( new Response(null, { status: 302, headers: { location: "/login?returnTo=%2Fadmin" } }), diff --git a/packages/dev-server/test/observability-runtime.test.ts b/packages/dev-server/test/observability-runtime.test.ts index 154b48f1..febd89c1 100644 --- a/packages/dev-server/test/observability-runtime.test.ts +++ b/packages/dev-server/test/observability-runtime.test.ts @@ -14,6 +14,7 @@ function runtime(health: HealthRegistry, trustProxy = false) { stores: [], schemas: [], authz: [], + services: [], matchPage: () => null, matchApi: () => null, matchRealtime: () => null, diff --git a/packages/dev-server/test/rpc-endpoint.test.ts b/packages/dev-server/test/rpc-endpoint.test.ts new file mode 100644 index 00000000..272ee439 --- /dev/null +++ b/packages/dev-server/test/rpc-endpoint.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { defineService, implement, procedure } from "@wrnexus/rpc"; +import { v } from "@wrnexus/validation"; +import { handleRpcRequest, isInternalCaller, isRpcPath } from "../src/rpc-dispatch.ts"; + +const demo = defineService({ + name: "demo", + procedures: { + add: procedure + .input(v.object({ a: v.number() })) + .output<{ a: number }>() + .build(), + }, +}); +const services = new Map([ + ["demo", implement(demo, { add: async ({ a }) => ({ a }) }, { selfApp: "demo-app" })], +]); + +function request(path: string, headers: Record = {}) { + return new Request(`http://demo.test${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ a: 2 }), + }); +} + +describe("RPC endpoint", () => { + test("only matches its reserved prefix", () => { + expect(isRpcPath("/__wrnexus/rpc/demo/add")).toBe(true); + expect(isRpcPath("/__wrnexus/rpcx/demo/add")).toBe(false); + }); + + test("dispatches a private request", async () => { + const req = request("/__wrnexus/rpc/demo/add", { "x-wrnexus-internal": "1" }); + expect(await (await handleRpcRequest(req, new URL(req.url), services))!.json()).toEqual({ + ok: true, + value: { a: 2 }, + }); + }); + + test("rejects public or forwarded requests", async () => { + const external = request("/__wrnexus/rpc/demo/add"); + expect((await handleRpcRequest(external, new URL(external.url), services))!.status).toBe(404); + const forwarded = request("/__wrnexus/rpc/demo/add", { + "x-wrnexus-internal": "1", + "x-forwarded-for": "203.0.113.1", + }); + expect(isInternalCaller(forwarded)).toBe(false); + expect((await handleRpcRequest(forwarded, new URL(forwarded.url), services))!.status).toBe(404); + }); +}); diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 9060e178..1fcd3911 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -61,6 +61,8 @@ export interface Router { schemas: ComponentRef[]; /** Authorization declarations (`app/authz/.ts`) merged into the catalog. */ authz: ComponentRef[]; + /** Service implementations (`app/services/.ts`) mounted for inter-app calls. */ + services: ComponentRef[]; matchPage(pathname: string): RouteMatch | null; matchApi(pathname: string): RouteMatch | null; matchRealtime(pathname: string): RouteMatch | null; @@ -302,6 +304,17 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { authz.push({ name, file: f.file }); } + const services: ComponentRef[] = []; + for (const f of scanDir(join(appDir, "services"), [".js"])) { + if (!/\.(ts|js)$/.test(f.file) || /[.]gen[.](ts|js)$/.test(f.file)) continue; + const name = basename(f.file).replace(/\.(ts|js)$/, ""); + if (!isSafeIslandName(name)) { + console.warn(`[wrnexus] skipping service with unsafe name: ${name}`); + continue; + } + services.push({ name, file: f.file }); + } + return { pages, api, @@ -312,6 +325,7 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { stores, schemas, authz, + services, matchPage: (p) => matchRoute(pages, p), matchApi: (p) => matchRoute(api, p), matchRealtime: (p) => matchRoute(realtime, p), diff --git a/packages/router/test/services-discovery.test.ts b/packages/router/test/services-discovery.test.ts new file mode 100644 index 00000000..bf9f660f --- /dev/null +++ b/packages/router/test/services-discovery.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildRouter } from "../src/index.ts"; + +const root = join(import.meta.dir, ".tmp-services"); +mkdirSync(root, { recursive: true }); + +describe("service discovery", () => { + test("discovers source services and skips generated files", () => { + const base = mkdtempSync(join(root, "app-")); + const services = join(base, "app", "services"); + mkdirSync(services, { recursive: true }); + mkdirSync(join(base, "app", "pages"), { recursive: true }); + writeFileSync(join(services, "billing.ts"), "export default {};"); + writeFileSync(join(services, "types.gen.ts"), "export type T = string;"); + expect(buildRouter(join(base, "app")).services.map((service) => service.name)).toEqual([ + "billing", + ]); + }); +}); diff --git a/packages/rpc/README.md b/packages/rpc/README.md new file mode 100644 index 00000000..9cf468d5 --- /dev/null +++ b/packages/rpc/README.md @@ -0,0 +1,44 @@ +# `@wrnexus/rpc` + +Define a service contract in a shared workspace package, then import that same contract from the caller and callee. + +```ts +import { + defineService, + implement, + inProcessTransport, + procedure, + serviceClient, +} from "@wrnexus/rpc"; +import { v } from "@wrnexus/validation"; + +const greeter = defineService({ + name: "greeter", + procedures: { + greet: procedure + .input(v.object({ name: v.string() })) + .output<{ message: string }>() + .build(), + }, +}); + +const service = implement( + greeter, + { greet: async ({ name }) => ({ message: `Hello, ${name}` }) }, + { selfApp: "greeter" }, +); + +const client = serviceClient(greeter, { + app: "greeter", + transport: inProcessTransport({ + "greeter/greet": (input, identity) => service.invoke("greet", input, identity), + }), +}); +await client.greet({ name: "Ada" }); +``` + +Service files default-export `implement(...)` from `app/services`. The development server mounts them under the private `/__wrnexus/rpc` prefix. + +Pass `{ as: ctx }` to `serviceClient` to propagate the subject. The signed token contains only subject and tenant identifiers; permissions are always checked by the callee. Set `WRNEXUS_RPC_SECRET` in every app, use at least 32 characters, and never reuse the session secret. + +Calls time out by default. Retrying is intentionally deferred; when introduced, only procedures marked `.idempotent()` may be retried. diff --git a/packages/rpc/package.json b/packages/rpc/package.json index b1832c35..fef417e6 100644 --- a/packages/rpc/package.json +++ b/packages/rpc/package.json @@ -22,6 +22,7 @@ "@wrnexus/authz": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/jwt": "workspace:*", + "@wrnexus/helpers": "workspace:*", "@wrnexus/validation": "workspace:*" }, "devDependencies": { diff --git a/packages/rpc/src/client.ts b/packages/rpc/src/client.ts new file mode 100644 index 00000000..457b2c5d --- /dev/null +++ b/packages/rpc/src/client.ts @@ -0,0 +1,57 @@ +import type { Context } from "@wrnexus/core"; +import { RPC_ERROR_CODES, ServiceError } from "./errors.ts"; +import { exportSubjectContext } from "./identity.ts"; +import type { Transport } from "./transport.ts"; +import type { + AnyProcedures, + InferProcedureInput, + InferProcedureOutput, + ServiceContract, +} from "./types.ts"; + +export interface ServiceClientOptions { + app?: string; + transport: Transport; + as?: Context; + timeoutMs?: number; +} + +export type ServiceClient = { + [K in keyof Procedures]: ( + input: InferProcedureInput, + ) => Promise>; +}; + +const DEFAULT_TIMEOUT_MS = 10_000; + +export function serviceClient( + contract: ServiceContract, + options: ServiceClientOptions, +): ServiceClient { + const app = options.app ?? contract.name; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return new Proxy({} as ServiceClient, { + get(_target, property) { + if (typeof property !== "string") return undefined; + return async (input: unknown) => { + if (!Object.hasOwn(contract.procedures, property)) { + throw new ServiceError(RPC_ERROR_CODES.unknown, "Unknown procedure"); + } + const identity = options.as ? await exportSubjectContext(options.as, app) : undefined; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const result = await options.transport.call( + { app, service: contract.name, procedure: property }, + input, + { signal: controller.signal, ...(identity ? { identity } : {}) }, + ); + if (result.ok) return result.value; + throw new ServiceError(result.code, result.message); + } finally { + clearTimeout(timer); + } + }; + }, + }); +} diff --git a/packages/rpc/src/http.ts b/packages/rpc/src/http.ts new file mode 100644 index 00000000..d4f72bf7 --- /dev/null +++ b/packages/rpc/src/http.ts @@ -0,0 +1,73 @@ +import { appOrigin } from "@wrnexus/helpers"; +import { RPC_ERROR_CODES, failure, isRetryableStatus } from "./errors.ts"; +import { RPC_IDENTITY_HEADER } from "./identity.ts"; +import type { CallOptions, RpcTarget, Transport } from "./transport.ts"; +import type { ServiceResult } from "./types.ts"; + +export const RPC_PATH_PREFIX = "/__wrnexus/rpc"; +export const RPC_INTERNAL_HEADER = "x-wrnexus-internal"; + +export function rpcPath(service: string, procedure: string): string { + return `${RPC_PATH_PREFIX}/${service}/${procedure}`; +} + +export interface HttpTransportOptions { + resolveOrigin?: (app: string) => string; + fetch?: typeof fetch; +} + +function isServiceResult(value: unknown): value is ServiceResult { + if (!value || typeof value !== "object" || !("ok" in value)) return false; + const result = value as Record; + return ( + result.ok === true || + (result.ok === false && + typeof result.code === "string" && + typeof result.message === "string" && + typeof result.retryable === "boolean") + ); +} + +export function httpTransport(options: HttpTransportOptions = {}): Transport { + const resolveOrigin = options.resolveOrigin ?? appOrigin; + const doFetch = options.fetch ?? fetch; + return { + async call(target: RpcTarget, payload: unknown, callOptions: CallOptions) { + let response: Response; + try { + const headers: Record = { + "content-type": "application/json", + [RPC_INTERNAL_HEADER]: "1", + }; + if (callOptions.identity) headers[RPC_IDENTITY_HEADER] = callOptions.identity; + response = await doFetch( + `${resolveOrigin(target.app)}${rpcPath(target.service, target.procedure)}`, + { + method: "POST", + headers, + body: JSON.stringify(payload ?? {}), + signal: callOptions.signal, + }, + ); + } catch { + return failure(RPC_ERROR_CODES.transport, "Service unreachable"); + } + if (!response.ok) { + return { + ok: false, + code: RPC_ERROR_CODES.transport, + message: `Service returned ${response.status}`, + retryable: isRetryableStatus(response.status), + }; + } + try { + const result: unknown = await response.json(); + return isServiceResult(result) + ? result + : failure(RPC_ERROR_CODES.malformed, "Malformed service response"); + } catch { + return failure(RPC_ERROR_CODES.malformed, "Malformed service response"); + } + }, + }; +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index c6e2591e..b4a854b3 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -30,3 +30,17 @@ export { rpcSecret, } from "./identity.ts"; export type { ExportOptions, ImportOptions, SubjectContext } from "./identity.ts"; + +export { inProcessTransport } from "./transport.ts"; +export type { CallOptions, InProcessHandler, RpcTarget, Transport } from "./transport.ts"; +export { implement } from "./server.ts"; +export type { + HandlerContext, + ImplementOptions, + ServiceHandlers, + ServiceImplementation, +} from "./server.ts"; +export { serviceClient } from "./client.ts"; +export type { ServiceClient, ServiceClientOptions } from "./client.ts"; +export { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX, httpTransport, rpcPath } from "./http.ts"; +export type { HttpTransportOptions } from "./http.ts"; diff --git a/packages/rpc/src/server.ts b/packages/rpc/src/server.ts new file mode 100644 index 00000000..e806537e --- /dev/null +++ b/packages/rpc/src/server.ts @@ -0,0 +1,81 @@ +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 { + return { + contract, + async invoke(procedureName, payload, identity) { + const definition = contract.procedures[procedureName as keyof Procedures]; + const handler = handlers[procedureName as keyof Procedures]; + 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) { + const parsed = definition.input.parse(payload as Record); + 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"); + } + }, + }; +} diff --git a/packages/rpc/src/transport.ts b/packages/rpc/src/transport.ts new file mode 100644 index 00000000..19fab891 --- /dev/null +++ b/packages/rpc/src/transport.ts @@ -0,0 +1,38 @@ +import { RPC_ERROR_CODES, failure } from "./errors.ts"; +import type { ServiceResult } from "./types.ts"; + +export interface RpcTarget { + app: string; + service: string; + procedure: string; +} + +export interface CallOptions { + signal?: AbortSignal; + identity?: string; +} + +export interface Transport { + call(target: RpcTarget, payload: unknown, options: CallOptions): Promise; +} + +export type InProcessHandler = ( + payload: unknown, + identity?: string, +) => Promise | ServiceResult; + +/** Direct transport for tests and local integration harnesses. */ +export function inProcessTransport(handlers: Record): Transport { + return { + async call(target, payload, options) { + if (options.signal?.aborted) return failure(RPC_ERROR_CODES.transport, "Call aborted"); + const handler = handlers[`${target.service}/${target.procedure}`]; + if (!handler) return failure(RPC_ERROR_CODES.unknown, "Unknown procedure"); + try { + return await handler(payload, options.identity); + } catch { + return failure(RPC_ERROR_CODES.handler, "Internal error"); + } + }, + }; +} diff --git a/packages/rpc/test/http.test.ts b/packages/rpc/test/http.test.ts new file mode 100644 index 00000000..856e4ba5 --- /dev/null +++ b/packages/rpc/test/http.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { RPC_ERROR_CODES } from "../src/errors.ts"; +import { RPC_IDENTITY_HEADER } from "../src/identity.ts"; +import { httpTransport, rpcPath } from "../src/http.ts"; + +const target = { app: "billing", service: "billing", procedure: "createInvoice" }; + +function transportWith(handler: (request: Request) => Response | Promise) { + return httpTransport({ + resolveOrigin: () => "http://billing.test", + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => + handler(new Request(input, init))) as typeof fetch, + }); +} + +describe("httpTransport", () => { + test("posts to the private endpoint with identity", async () => { + const transport = transportWith(async (request) => { + expect(request.url).toBe("http://billing.test/__wrnexus/rpc/billing/createInvoice"); + expect(request.headers.get(RPC_IDENTITY_HEADER)).toBe("token"); + expect(request.headers.get("x-wrnexus-internal")).toBe("1"); + expect(await request.json()).toEqual({ amountCents: 5 }); + return Response.json({ ok: true, value: { invoiceId: "inv_1" } }); + }); + expect(await transport.call(target, { amountCents: 5 }, { identity: "token" })).toEqual({ + ok: true, + value: { invoiceId: "inv_1" }, + }); + }); + + test("classifies unavailable and malformed responses safely", async () => { + const unavailable = transportWith(() => new Response("busy", { status: 503 })); + const failed = await unavailable.call(target, {}, {}); + expect(failed).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: true }); + const malformed = transportWith(() => Response.json({ hello: "world" })); + expect(await malformed.call(target, {}, {})).toMatchObject({ + code: RPC_ERROR_CODES.malformed, + retryable: false, + }); + }); + + test("uses the stable reserved path", () => { + expect(rpcPath("billing", "createInvoice")).toBe("/__wrnexus/rpc/billing/createInvoice"); + }); +}); diff --git a/packages/rpc/test/integration.test.ts b/packages/rpc/test/integration.test.ts new file mode 100644 index 00000000..f858befe --- /dev/null +++ b/packages/rpc/test/integration.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { v } from "@wrnexus/validation"; +import { serviceClient } from "../src/client.ts"; +import { defineService, procedure } from "../src/contract.ts"; +import { RPC_ERROR_CODES } from "../src/errors.ts"; +import { implement } from "../src/server.ts"; +import { inProcessTransport } from "../src/transport.ts"; + +const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; +const originalAppName = process.env.WRNEXUS_APP_NAME; +afterEach(() => { + if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; + else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; + if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; + else process.env.WRNEXUS_APP_NAME = originalAppName; +}); + +const billing = defineService({ + name: "billing", + procedures: { + createInvoice: procedure + .input(v.object({ amountCents: v.number() })) + .output<{ invoiceId: string; forSubject: string }>() + .permission("invoice:create") + .build(), + }, +}); + +function wire(allowed: boolean) { + const service = implement( + billing, + { + createInvoice: async ({ amountCents }, ctx) => ({ + invoiceId: `inv_${amountCents}`, + forSubject: ctx.subject?.subjectId ?? "anon", + }), + }, + { selfApp: "billing", checkPermission: async () => allowed }, + ); + return inProcessTransport({ + "billing/createInvoice": (payload, identity) => + service.invoke("createInvoice", payload, identity), + }); +} + +describe("RPC integration", () => { + test("propagates identity and validates the callee permission", async () => { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = "web"; + const client = serviceClient(billing, { + app: "billing", + as: { user: { id: "u1" }, tenant: { id: "acme" }, locals: {} } as unknown as Context, + transport: wire(true), + }); + expect(await client.createInvoice({ amountCents: 250 })).toEqual({ + invoiceId: "inv_250", + forSubject: "u1", + }); + }); + + test("denies when the callee permission check refuses", async () => { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = "web"; + const client = serviceClient(billing, { + app: "billing", + as: { user: { id: "u1" }, locals: {} } as unknown as Context, + transport: wire(false), + }); + await expect(client.createInvoice({ amountCents: 1 })).rejects.toMatchObject({ + code: RPC_ERROR_CODES.denied, + }); + }); +}); From fcf4ed303990e1609f2b2c3f278b5d8d5cdba808 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 19:42:49 +0530 Subject: [PATCH 17/22] docs: close a prototype-chain authorization bypass in the plan server.ts looked procedures up with plain property indexing, so every Object.prototype member resolved as truthy. A prototype member carries no `permission`, so the permission gate was skipped entirely. Verified: with a contract whose only procedure declares a permission and a checkPermission that always denies, invoke("add") correctly returns RPC_DENIED, while invoke("constructor") returns {"ok":true,"value":{"a":2}} and the gate never runs. Reachable over the wire as POST /__wrnexus/rpc//constructor by anything that clears the internal-caller check - i.e. any workspace app. Fixed at both layers: Object.hasOwn for the procedure and handler lookups, and a character-class guard on the path segments before they are used as lookup keys. Co-Authored-By: Claude Opus 5 --- ...026-08-05-inter-app-comms-implementation.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index 46245b9a..f62afbac 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -1460,8 +1460,14 @@ export function implement( contract, async invoke(procedureName, payload, identity) { - const def = contract.procedures[procedureName as keyof Procedures]; - const handler = handlers[procedureName as keyof Procedures]; + // 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 def = known ? contract.procedures[procedureName as keyof Procedures] : undefined; + const handler = known ? handlers[procedureName as keyof Procedures] : undefined; if (!def || !handler) { return failure(RPC_ERROR_CODES.unknown, `No procedure '${contract.name}/${procedureName}'`); } @@ -2313,8 +2319,12 @@ export async function handleRpcRequest( } const [, , , serviceName, procedureName] = url.pathname.split("/"); - const service = serviceName ? services.get(serviceName) : undefined; - if (!service || !procedureName) { + // Constrain the segment before it is used as a lookup key, so a prototype + // member can never be reached even if a future implement() regresses. + const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/; + const service = + serviceName && SAFE_SEGMENT.test(serviceName) ? services.get(serviceName) : undefined; + if (!service || !procedureName || !SAFE_SEGMENT.test(procedureName)) { return json({ ok: false, code: "RPC_UNKNOWN", message: "Unknown procedure", retryable: false }); } From 7c4b484d0a8497c0b5e6e177239148bbb7d65acd Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 19:46:59 +0530 Subject: [PATCH 18/22] fix(rpc): close prototype-chain permission bypass, add server/client/transport tests C1 CRITICAL: implement() looked up procedures/handlers with plain property indexing, so any Object.prototype member name (constructor, toString, etc.) resolved truthy and skipped the permission gate entirely. Fixed with Object.hasOwn checks in packages/rpc/src/server.ts. Defense-in-depth guard added in packages/dev-server/src/rpc-dispatch.ts constraining URL path segments to a safe charset before they reach service/procedure lookups. Added missing direct test coverage for packages/rpc/src/transport.ts, server.ts and client.ts (previously untested), including a prototype-name sweep in both server.test.ts and dev-server's rpc-endpoint.test.ts. Co-Authored-By: Claude Opus 5 --- packages/dev-server/src/rpc-dispatch.ts | 7 +- packages/dev-server/test/rpc-endpoint.test.ts | 25 ++ packages/rpc/src/server.ts | 10 +- packages/rpc/test/client.test.ts | 110 ++++++++ packages/rpc/test/server.test.ts | 243 ++++++++++++++++++ packages/rpc/test/transport.test.ts | 77 ++++++ 6 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 packages/rpc/test/client.test.ts create mode 100644 packages/rpc/test/server.test.ts create mode 100644 packages/rpc/test/transport.test.ts diff --git a/packages/dev-server/src/rpc-dispatch.ts b/packages/dev-server/src/rpc-dispatch.ts index bdb63f01..92f90ae7 100644 --- a/packages/dev-server/src/rpc-dispatch.ts +++ b/packages/dev-server/src/rpc-dispatch.ts @@ -27,9 +27,12 @@ export async function handleRpcRequest( if (!isInternalCaller(req)) return new Response("Not found", { status: 404 }); if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }); const segments = url.pathname.split("/"); - const service = segments[3] ? services.get(segments[3]) : undefined; + const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/; + const serviceName = segments[3]; const procedure = segments[4]; - if (!service || !procedure || segments.length !== 5) { + const service = + serviceName && SAFE_SEGMENT.test(serviceName) ? services.get(serviceName) : undefined; + if (!service || !procedure || !SAFE_SEGMENT.test(procedure) || segments.length !== 5) { return json({ ok: false, code: "RPC_UNKNOWN", message: "Unknown procedure", retryable: false }); } let payload: unknown; diff --git a/packages/dev-server/test/rpc-endpoint.test.ts b/packages/dev-server/test/rpc-endpoint.test.ts index 272ee439..aee50bce 100644 --- a/packages/dev-server/test/rpc-endpoint.test.ts +++ b/packages/dev-server/test/rpc-endpoint.test.ts @@ -48,4 +48,29 @@ describe("RPC endpoint", () => { expect(isInternalCaller(forwarded)).toBe(false); expect((await handleRpcRequest(forwarded, new URL(forwarded.url), services))!.status).toBe(404); }); + + describe("C1: prototype-chain procedure names cannot bypass the permission gate", () => { + const PROTO_NAMES = [ + "constructor", + "toString", + "valueOf", + "hasOwnProperty", + "__proto__", + "isPrototypeOf", + ]; + + for (const name of PROTO_NAMES) { + test(`"${name}" in the URL path yields RPC_UNKNOWN`, async () => { + const req = request(`/__wrnexus/rpc/demo/${name}`, { "x-wrnexus-internal": "1" }); + const res = await handleRpcRequest(req, new URL(req.url), services); + expect(await res!.json()).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); + }); + } + + test(`"constructor" as the SERVICE segment also yields RPC_UNKNOWN`, async () => { + const req = request("/__wrnexus/rpc/constructor/add", { "x-wrnexus-internal": "1" }); + const res = await handleRpcRequest(req, new URL(req.url), services); + expect(await res!.json()).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); + }); + }); }); diff --git a/packages/rpc/src/server.ts b/packages/rpc/src/server.ts index e806537e..adda1c4b 100644 --- a/packages/rpc/src/server.ts +++ b/packages/rpc/src/server.ts @@ -37,8 +37,14 @@ export function implement( return { contract, async invoke(procedureName, payload, identity) { - const definition = contract.procedures[procedureName as keyof Procedures]; - const handler = handlers[procedureName as keyof Procedures]; + // 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; diff --git a/packages/rpc/test/client.test.ts b/packages/rpc/test/client.test.ts new file mode 100644 index 00000000..76136e98 --- /dev/null +++ b/packages/rpc/test/client.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { v } from "@wrnexus/validation"; +import { serviceClient } from "../src/client.ts"; +import { defineService, procedure } from "../src/contract.ts"; +import { RPC_ERROR_CODES, ServiceError, failure, success } from "../src/errors.ts"; +import type { RpcTarget } from "../src/transport.ts"; + +const original = { ...process.env }; +afterEach(() => { + process.env = { ...original }; +}); + +function configure(appName = "web") { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = appName; +} + +const billing = defineService({ + name: "billing", + procedures: { + createInvoice: procedure + .input(v.object({ amountCents: v.number() })) + .output<{ invoiceId: string }>() + .build(), + }, +}); + +describe("serviceClient", () => { + test("returns the handler's value unwrapped", async () => { + const client = serviceClient(billing, { + transport: { call: async () => success({ invoiceId: "inv_1" }) }, + }); + expect(await client.createInvoice({ amountCents: 1 })).toEqual({ invoiceId: "inv_1" }); + }); + + test("throws a ServiceError carrying code and retryable on failure", async () => { + const client = serviceClient(billing, { + transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") }, + }); + let caught: unknown; + try { + await client.createInvoice({ amountCents: 1 }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ServiceError); + expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: true }); + }); + + test("attaches an identity token when given a context and omits it for an anonymous context", async () => { + configure("web"); + let seenIdentity: string | undefined = "unset"; + const client = serviceClient(billing, { + as: { user: { id: "u1" }, locals: {} } as unknown as Context, + transport: { + call: async (_target, _payload, options) => { + seenIdentity = options.identity; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + await client.createInvoice({ amountCents: 1 }); + expect(seenIdentity).toBeTypeOf("string"); + + let seenAnon: unknown = "unset"; + const anonClient = serviceClient(billing, { + transport: { + call: async (_target, _payload, options) => { + seenAnon = options.identity; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + await anonClient.createInvoice({ amountCents: 1 }); + expect(seenAnon).toBeUndefined(); + }); + + test("calling an undeclared procedure throws rather than issuing a call", async () => { + let called = false; + const client = serviceClient(billing, { + transport: { + call: async () => { + called = true; + return success({}); + }, + }, + }); + const proxy = client as unknown as Record Promise>; + await expect(proxy.deleteEverything!({})).rejects.toMatchObject({ + code: RPC_ERROR_CODES.unknown, + }); + expect(called).toBe(false); + }); + + test("the app defaults to the service name", async () => { + let seenTarget: RpcTarget | undefined; + const client = serviceClient(billing, { + transport: { + call: async (target) => { + seenTarget = target; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + await client.createInvoice({ amountCents: 1 }); + expect(seenTarget?.app).toBe("billing"); + expect(seenTarget?.service).toBe("billing"); + }); +}); diff --git a/packages/rpc/test/server.test.ts b/packages/rpc/test/server.test.ts new file mode 100644 index 00000000..dd23c67a --- /dev/null +++ b/packages/rpc/test/server.test.ts @@ -0,0 +1,243 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { v } from "@wrnexus/validation"; +import { defineService, procedure } from "../src/contract.ts"; +import { RPC_ERROR_CODES } from "../src/errors.ts"; +import { exportSubjectContext } from "../src/identity.ts"; +import { implement } from "../src/server.ts"; + +const original = { ...process.env }; +afterEach(() => { + process.env = { ...original }; +}); + +function configure(appName = "web") { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = appName; +} + +const openContract = defineService({ + name: "demo", + procedures: { + add: procedure + .input(v.object({ a: v.number() })) + .output<{ a: number }>() + .build(), + }, +}); + +const guardedContract = defineService({ + name: "billing", + procedures: { + createInvoice: procedure + .input(v.object({ amountCents: v.number() })) + .output<{ invoiceId: string }>() + .permission("invoice:create") + .build(), + }, +}); + +describe("implement()", () => { + test("invokes with validated input", async () => { + const service = implement( + openContract, + { add: async ({ a }) => ({ a: a + 1 }) }, + { selfApp: "demo" }, + ); + const result = await service.invoke("add", { a: 1 }); + expect(result).toEqual({ ok: true, value: { a: 2 } }); + }); + + test("coerces through the schema", async () => { + let received: unknown; + const coercing = defineService({ + name: "demo2", + procedures: { + add: procedure + .input(v.object({ a: v.number() })) + .output<{ a: number }>() + .build(), + }, + }); + const service = implement( + coercing, + { + add: async (input) => { + received = input; + return { a: (input as { a: number }).a }; + }, + }, + { selfApp: "demo2" }, + ); + await service.invoke("add", { a: "3" }); + expect(received).toEqual({ a: 3 }); + }); + + test("rejects schema-invalid input without invoking the handler", async () => { + let called = false; + const service = implement( + openContract, + { + add: async ({ a }) => { + called = true; + return { a }; + }, + }, + { selfApp: "demo" }, + ); + const result = await service.invoke("add", { a: "not-a-number" }); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.invalid }); + }); + + test("unknown procedure refused", async () => { + const service = implement(openContract, { add: async ({ a }) => ({ a }) }, { selfApp: "demo" }); + const result = await service.invoke("subtract", {}); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.unknown }); + }); + + test("handler throw becomes opaque; a secret in the message does not survive", async () => { + const secret = "sk-super-secret-db-password"; + const service = implement( + openContract, + { + add: async () => { + throw new Error(`db error using ${secret}`); + }, + }, + { selfApp: "demo" }, + ); + const result = await service.invoke("add", { a: 1 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(RPC_ERROR_CODES.handler); + expect(result.message).not.toContain(secret); + } + }); + + test("a declared permission is enforced before the handler runs", async () => { + let called = false; + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }) => { + called = true; + return { invoiceId: `inv_${amountCents}` }; + }, + }, + { selfApp: "billing", checkPermission: async () => false }, + ); + const result = await service.invoke("createInvoice", { amountCents: 5 }); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.denied }); + }); + + test("a permission check that throws denies", async () => { + let called = false; + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }) => { + called = true; + return { invoiceId: `inv_${amountCents}` }; + }, + }, + { + selfApp: "billing", + checkPermission: async () => { + throw new Error("permission store unavailable"); + }, + }, + ); + const result = await service.invoke("createInvoice", { amountCents: 5 }); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.denied }); + }); + + test("a declared permission with no checkPermission configured denies (fail closed)", async () => { + let called = false; + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }) => { + called = true; + return { invoiceId: `inv_${amountCents}` }; + }, + }, + { selfApp: "billing" }, + ); + const result = await service.invoke("createInvoice", { amountCents: 5 }); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.denied }); + }); + + test("a valid identity token reaches the handler as a subject", async () => { + configure("web"); + const token = await exportSubjectContext( + { user: { id: "u1" }, locals: {} } as never, + "billing", + ); + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }, ctx) => ({ + invoiceId: `${ctx.subject?.subjectId}_${amountCents}`, + }), + }, + { selfApp: "billing", checkPermission: async () => true }, + ); + const result = await service.invoke("createInvoice", { amountCents: 5 }, token); + expect(result).toEqual({ ok: true, value: { invoiceId: "u1_5" } }); + }); + + test("a bad identity token is refused rather than downgraded to anonymous", async () => { + configure("web"); + let called = false; + const service = implement( + openContract, + { + add: async ({ a }, ctx) => { + called = true; + expect(ctx.subject).toBeUndefined(); + return { a }; + }, + }, + { selfApp: "demo" }, + ); + const result = await service.invoke("add", { a: 1 }, "garbage-token"); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.identity }); + }); + + describe("C1: prototype-chain procedure names cannot bypass the permission gate", () => { + const PROTO_NAMES = [ + "constructor", + "toString", + "valueOf", + "hasOwnProperty", + "__proto__", + "isPrototypeOf", + ]; + + for (const name of PROTO_NAMES) { + test(`"${name}" resolves as unknown, not as a handler`, async () => { + let permissionChecked = false; + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }) => ({ invoiceId: `inv_${amountCents}` }), + }, + { + selfApp: "billing", + checkPermission: async () => { + permissionChecked = true; + return false; + }, + }, + ); + const result = await service.invoke(name, { amountCents: 1 }); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.unknown }); + expect(permissionChecked).toBe(false); + }); + } + }); +}); diff --git a/packages/rpc/test/transport.test.ts b/packages/rpc/test/transport.test.ts new file mode 100644 index 00000000..43ad6ed6 --- /dev/null +++ b/packages/rpc/test/transport.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { RPC_ERROR_CODES, success } from "../src/errors.ts"; +import { inProcessTransport } from "../src/transport.ts"; + +describe("inProcessTransport", () => { + test("routes to the right handler and passes identity through", async () => { + let seenIdentity: string | undefined; + const transport = inProcessTransport({ + "billing/createInvoice": (payload, identity) => { + seenIdentity = identity; + return success({ echoed: payload }); + }, + }); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "createInvoice" }, + { amountCents: 1 }, + { identity: "tok-123" }, + ); + expect(result).toEqual({ ok: true, value: { echoed: { amountCents: 1 } } }); + expect(seenIdentity).toBe("tok-123"); + }); + + test("an unregistered procedure yields non-retryable RPC_UNKNOWN", async () => { + const transport = inProcessTransport({}); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "nope" }, + {}, + {}, + ); + expect(result).toEqual({ + ok: false, + code: RPC_ERROR_CODES.unknown, + message: "Unknown procedure", + retryable: false, + }); + }); + + test("an already-aborted signal fails without invoking the handler", async () => { + let called = false; + const transport = inProcessTransport({ + "billing/createInvoice": () => { + called = true; + return success({}); + }, + }); + const controller = new AbortController(); + controller.abort(); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "createInvoice" }, + {}, + { signal: controller.signal }, + ); + expect(called).toBe(false); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ code: RPC_ERROR_CODES.transport }); + }); + + test("a handler that throws becomes an opaque failure with no leaked message text", async () => { + const secret = "sk-super-secret-database-password-xyz"; + const transport = inProcessTransport({ + "billing/createInvoice": () => { + throw new Error(`connection failed with credential ${secret}`); + }, + }); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "createInvoice" }, + {}, + {}, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(RPC_ERROR_CODES.handler); + expect(result.message).not.toContain(secret); + expect(result.message).not.toContain("connection failed"); + } + }); +}); From ce6880347150860537d25206c408bcbadcf759df Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 20:10:20 +0530 Subject: [PATCH 19/22] 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 --- packages/dev-server/src/gateway.ts | 15 +++- packages/dev-server/src/rpc-dispatch.ts | 9 +- packages/dev-server/test/gateway.test.ts | 25 ++++++ packages/router/src/index.ts | 14 +++ .../router/test/services-discovery.test.ts | 65 ++++++++++++-- packages/rpc/package.json | 1 - packages/rpc/src/client.ts | 47 ++++++++-- packages/rpc/src/server.ts | 22 ++++- packages/rpc/src/transport.ts | 10 ++- packages/rpc/test/client.test.ts | 88 +++++++++++++++++-- packages/rpc/test/http.test.ts | 82 ++++++++++++++++- 11 files changed, 347 insertions(+), 31 deletions(-) diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index 761d0dc0..6255cf53 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -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 { const port = opts.port ?? 3000; @@ -625,7 +632,7 @@ export async function startGateway(opts: GatewayOptions): Promise { + expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(true); + expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true); + expect(isRpcGatewayPath("/api/billing")).toBe(false); + expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false); +}); + +test("an inbound internal-marker header from outside is stripped regardless of casing", () => { + for (const name of [ + RPC_INTERNAL_HEADER, + RPC_INTERNAL_HEADER.toUpperCase(), + "X-WrNexus-Internal", + ]) { + const request = new Request("http://localhost:3000/path", { + headers: { [name]: "1" }, + }); + const headers = stripUntrustedInternalHeaders( + gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true), + ); + expect(headers.has(RPC_INTERNAL_HEADER)).toBe(false); + } +}); + test("gateway respawns development apps after an HMR restart exit", () => { expect(gatewayRestartDelay("development", 97, null)).toBe(0); expect(gatewayRestartDelay("development", 1, null)).toBe(1200); diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 1fcd3911..79dd26fa 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -305,6 +305,7 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { } const services: ComponentRef[] = []; + const serviceFilesByName = new Map(); for (const f of scanDir(join(appDir, "services"), [".js"])) { if (!/\.(ts|js)$/.test(f.file) || /[.]gen[.](ts|js)$/.test(f.file)) continue; const name = basename(f.file).replace(/\.(ts|js)$/, ""); @@ -312,6 +313,19 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { console.warn(`[wrnexus] skipping service with unsafe name: ${name}`); continue; } + // A service name is a routable identity (/__wrnexus/rpc//...), so a + // collision is a configuration error, not something to resolve by + // directory-walk precedence like `components` silently does. Fail loudly, + // naming both files, instead of letting whichever file is visited last + // win non-deterministically. + const existing = serviceFilesByName.get(name); + if (existing) { + throw new Error( + `WRN-SERVICE-COLLISION: two services are both named "${name}": ${existing} and ${f.file}. ` + + `Rename one of the files — service names must be unique across app/services.`, + ); + } + serviceFilesByName.set(name, f.file); services.push({ name, file: f.file }); } diff --git a/packages/router/test/services-discovery.test.ts b/packages/router/test/services-discovery.test.ts index bf9f660f..e178f029 100644 --- a/packages/router/test/services-discovery.test.ts +++ b/packages/router/test/services-discovery.test.ts @@ -6,16 +6,69 @@ import { buildRouter } from "../src/index.ts"; const root = join(import.meta.dir, ".tmp-services"); mkdirSync(root, { recursive: true }); +function makeApp(): string { + const base = mkdtempSync(join(root, "app-")); + mkdirSync(join(base, "app", "pages"), { recursive: true }); + return join(base, "app"); +} + describe("service discovery", () => { test("discovers source services and skips generated files", () => { - const base = mkdtempSync(join(root, "app-")); - const services = join(base, "app", "services"); + const appDir = makeApp(); + const services = join(appDir, "services"); mkdirSync(services, { recursive: true }); - mkdirSync(join(base, "app", "pages"), { recursive: true }); writeFileSync(join(services, "billing.ts"), "export default {};"); writeFileSync(join(services, "types.gen.ts"), "export type T = string;"); - expect(buildRouter(join(base, "app")).services.map((service) => service.name)).toEqual([ - "billing", - ]); + expect(buildRouter(appDir).services.map((service) => service.name)).toEqual(["billing"]); + }); + + test("throws naming both files when two services collide on name", () => { + const appDir = makeApp(); + const services = join(appDir, "services"); + const nested = join(services, "legacy"); + mkdirSync(nested, { recursive: true }); + const top = join(services, "billing.ts"); + const shadow = join(nested, "billing.ts"); + writeFileSync(top, "export default {};"); + writeFileSync(shadow, "export default {};"); + expect(() => buildRouter(appDir)).toThrow(/WRN-SERVICE-COLLISION/); + try { + buildRouter(appDir); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain(top); + expect(message).toContain(shadow); + } + }); + + test("unsafe service names are skipped with a warning", () => { + const appDir = makeApp(); + const services = join(appDir, "services"); + mkdirSync(services, { recursive: true }); + writeFileSync(join(services, "bad name.ts"), "export default {};"); + const originalWarn = console.warn; + let warned = false; + console.warn = (...args: unknown[]) => { + if (String(args[0]).includes("skipping service with unsafe name")) warned = true; + }; + try { + expect(buildRouter(appDir).services).toEqual([]); + expect(warned).toBe(true); + } finally { + console.warn = originalWarn; + } + }); + + test("a missing services directory yields []", () => { + const appDir = makeApp(); + expect(buildRouter(appDir).services).toEqual([]); + }); + + test("discovers .js service files", () => { + const appDir = makeApp(); + const services = join(appDir, "services"); + mkdirSync(services, { recursive: true }); + writeFileSync(join(services, "reports.js"), "export default {};"); + expect(buildRouter(appDir).services.map((service) => service.name)).toEqual(["reports"]); }); }); diff --git a/packages/rpc/package.json b/packages/rpc/package.json index fef417e6..b2b2b4a5 100644 --- a/packages/rpc/package.json +++ b/packages/rpc/package.json @@ -19,7 +19,6 @@ "check": "bun run typecheck && bun run test" }, "dependencies": { - "@wrnexus/authz": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/jwt": "workspace:*", "@wrnexus/helpers": "workspace:*", diff --git a/packages/rpc/src/client.ts b/packages/rpc/src/client.ts index 457b2c5d..b4764e71 100644 --- a/packages/rpc/src/client.ts +++ b/packages/rpc/src/client.ts @@ -32,24 +32,53 @@ export function serviceClient( const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; return new Proxy({} as ServiceClient, { get(_target, property) { - if (typeof property !== "string") return undefined; + // Every declared procedure is a string key; anything else (including + // `then`/`catch`/`finally`) is not one of ours. Returning a function for + // those makes `await client` or `return client` from an async function + // read the proxy as thenable — the runtime then calls `then(resolve, + // reject)`, which throws "Unknown procedure". Returning undefined lets + // the caller be treated as a plain (non-thenable) object instead. + if (typeof property !== "string" || !Object.hasOwn(contract.procedures, property)) { + return undefined; + } return async (input: unknown) => { - if (!Object.hasOwn(contract.procedures, property)) { - throw new ServiceError(RPC_ERROR_CODES.unknown, "Unknown procedure"); + let identity: string | undefined; + if (options.as) { + try { + identity = await exportSubjectContext(options.as, app); + } catch (error) { + // A missing/misconfigured WRNEXUS_RPC_SECRET otherwise rejects with + // a bare Error, so a caller matching on ServiceError treats + // misconfiguration as a crash instead of a handled RPC failure. + // The operator-facing message names no secret value, so it is safe + // to preserve. + const message = error instanceof Error ? error.message : String(error); + throw new ServiceError(RPC_ERROR_CODES.identity, message); + } } - const identity = options.as ? await exportSubjectContext(options.as, app) : undefined; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + const timeout = new Promise((_, reject) => { + controller.signal.addEventListener("abort", () => { + reject(new ServiceError(RPC_ERROR_CODES.transport, "Call timed out")); + }); + }); + const callPromise = options.transport.call( + { app, service: contract.name, procedure: property }, + input, + { signal: controller.signal, ...(identity ? { identity } : {}) }, + ); try { - const result = await options.transport.call( - { app, service: contract.name, procedure: property }, - input, - { signal: controller.signal, ...(identity ? { identity } : {}) }, - ); + const result = await Promise.race([callPromise, timeout]); if (result.ok) return result.value; throw new ServiceError(result.code, result.message); } finally { clearTimeout(timer); + // If the timeout won the race, the transport call may still settle + // later — a transport that ignores the abort signal keeps running. + // Nothing awaits it again, so swallow a late rejection here rather + // than let it surface as an unhandled promise rejection. + callPromise.catch(() => {}); } }; }, diff --git a/packages/rpc/src/server.ts b/packages/rpc/src/server.ts index adda1c4b..bea8c50f 100644 --- a/packages/rpc/src/server.ts +++ b/packages/rpc/src/server.ts @@ -34,6 +34,16 @@ export function implement( 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) { @@ -69,7 +79,17 @@ export function implement( let input: unknown = payload; if (definition.input) { - const parsed = definition.input.parse(payload as Record); + // 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; } diff --git a/packages/rpc/src/transport.ts b/packages/rpc/src/transport.ts index 19fab891..cda9ef8d 100644 --- a/packages/rpc/src/transport.ts +++ b/packages/rpc/src/transport.ts @@ -25,8 +25,16 @@ export type InProcessHandler = ( export function inProcessTransport(handlers: Record): Transport { return { async call(target, payload, options) { + // Checked only at entry: this in-process transport does no I/O, so + // nothing yields between here and the handler call below, and a signal + // that aborts mid-flight is never observed. In-process tests therefore + // cannot exercise a mid-call timeout path — that needs a real transport. if (options.signal?.aborted) return failure(RPC_ERROR_CODES.transport, "Call aborted"); - const handler = handlers[`${target.service}/${target.procedure}`]; + const key = `${target.service}/${target.procedure}`; + // Object.hasOwn, not plain indexing: a prototype-inherited key (e.g. + // "constructor/toString") would otherwise resolve to a function that + // is not one of our handlers. + const handler = Object.hasOwn(handlers, key) ? handlers[key] : undefined; if (!handler) return failure(RPC_ERROR_CODES.unknown, "Unknown procedure"); try { return await handler(payload, options.identity); diff --git a/packages/rpc/test/client.test.ts b/packages/rpc/test/client.test.ts index 76136e98..6aad19d3 100644 --- a/packages/rpc/test/client.test.ts +++ b/packages/rpc/test/client.test.ts @@ -76,7 +76,7 @@ describe("serviceClient", () => { expect(seenAnon).toBeUndefined(); }); - test("calling an undeclared procedure throws rather than issuing a call", async () => { + test("an undeclared procedure is undefined rather than a function that throws", async () => { let called = false; const client = serviceClient(billing, { transport: { @@ -86,13 +86,91 @@ describe("serviceClient", () => { }, }, }); - const proxy = client as unknown as Record Promise>; - await expect(proxy.deleteEverything!({})).rejects.toMatchObject({ - code: RPC_ERROR_CODES.unknown, - }); + const proxy = client as unknown as Record; + expect(proxy.deleteEverything).toBeUndefined(); expect(called).toBe(false); }); + test("the proxy has no `then` escape — await/return does not trigger a call", async () => { + let called = false; + const client = serviceClient(billing, { + transport: { + call: async () => { + called = true; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + const proxy = client as unknown as Record; + expect(proxy.then).toBeUndefined(); + expect(proxy.catch).toBeUndefined(); + expect(proxy.finally).toBeUndefined(); + // `await client` must resolve to the proxy object itself, not reject. + const awaited = await client; + expect(awaited).toBe(client); + expect(called).toBe(false); + }); + + test("the signal arrives at the transport", async () => { + let seenSignal: AbortSignal | undefined; + const client = serviceClient(billing, { + transport: { + call: async (_target, _payload, options) => { + seenSignal = options.signal; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + await client.createInvoice({ amountCents: 1 }); + expect(seenSignal).toBeInstanceOf(AbortSignal); + }); + + test("the call aborts at timeoutMs when the transport ignores the signal", async () => { + const client = serviceClient(billing, { + timeoutMs: 20, + transport: { + call: () => new Promise(() => {}), // never resolves; ignores the signal + }, + }); + let caught: unknown; + const start = Date.now(); + try { + await client.createInvoice({ amountCents: 1 }); + } catch (err) { + caught = err; + } + const elapsed = Date.now() - start; + expect(caught).toBeInstanceOf(ServiceError); + expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport }); + expect(elapsed).toBeLessThan(500); + }); + + test("the timer is cleared on both success and failure", async () => { + const originalClearTimeout = globalThis.clearTimeout; + let clearCount = 0; + globalThis.clearTimeout = ((...args: Parameters) => { + clearCount++; + return originalClearTimeout(...args); + }) as typeof clearTimeout; + try { + const okClient = serviceClient(billing, { + transport: { call: async () => success({ invoiceId: "inv_1" }) }, + }); + await okClient.createInvoice({ amountCents: 1 }); + expect(clearCount).toBe(1); + + const failClient = serviceClient(billing, { + transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") }, + }); + await expect(failClient.createInvoice({ amountCents: 1 })).rejects.toBeInstanceOf( + ServiceError, + ); + expect(clearCount).toBe(2); + } finally { + globalThis.clearTimeout = originalClearTimeout; + } + }); + test("the app defaults to the service name", async () => { let seenTarget: RpcTarget | undefined; const client = serviceClient(billing, { diff --git a/packages/rpc/test/http.test.ts b/packages/rpc/test/http.test.ts index 856e4ba5..4d6b6eb8 100644 --- a/packages/rpc/test/http.test.ts +++ b/packages/rpc/test/http.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { RPC_ERROR_CODES } from "../src/errors.ts"; +import { RPC_ERROR_CODES, isRetryableStatus } from "../src/errors.ts"; import { RPC_IDENTITY_HEADER } from "../src/identity.ts"; -import { httpTransport, rpcPath } from "../src/http.ts"; +import { RPC_INTERNAL_HEADER, httpTransport, rpcPath } from "../src/http.ts"; const target = { app: "billing", service: "billing", procedure: "createInvoice" }; @@ -42,4 +42,82 @@ describe("httpTransport", () => { test("uses the stable reserved path", () => { expect(rpcPath("billing", "createInvoice")).toBe("/__wrnexus/rpc/billing/createInvoice"); }); + + test("omits the identity header entirely for an anonymous call", async () => { + const transport = transportWith(async (request) => { + expect(request.headers.has(RPC_IDENTITY_HEADER)).toBe(false); + return Response.json({ ok: true, value: {} }); + }); + await transport.call(target, {}, {}); + }); + + test("sets the internal-marker header", async () => { + const transport = transportWith(async (request) => { + expect(request.headers.get(RPC_INTERNAL_HEADER)).toBe("1"); + return Response.json({ ok: true, value: {} }); + }); + await transport.call(target, {}, {}); + }); + + test("the full retryable-status sweep matches isRetryableStatus", async () => { + // 600 is covered directly on isRetryableStatus in errors.test.ts — the + // Fetch API cannot construct a Response with a status outside 200–599. + const statuses = [200, 400, 403, 404, 408, 409, 429, 500, 503, 599]; + for (const status of statuses) { + if (status === 200) continue; // handled by the success-path test above + const transport = transportWith(() => new Response("x", { status })); + const result = await transport.call(target, {}, {}); + expect(result).toMatchObject({ + ok: false, + code: RPC_ERROR_CODES.transport, + retryable: isRetryableStatus(status), + }); + } + }); + + test("a network throw yields a structured failure with no host/address surviving", async () => { + const transport = httpTransport({ + resolveOrigin: () => "http://internal-billing-host.private:4821", + fetch: (async () => { + throw new TypeError("fetch failed: connect ECONNREFUSED 10.0.0.7:4821"); + }) as unknown as typeof fetch, + }); + const result = await transport.call(target, {}, {}); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.transport }); + if (!result.ok) { + expect(result.message).not.toContain("10.0.0.7"); + expect(result.message).not.toContain("internal-billing-host"); + } + }); + + test("a malformed JSON body yields a structured failure with no body content surviving", async () => { + const transport = transportWith(() => new Response("{not json", { status: 200 })); + const result = await transport.call(target, {}, {}); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.malformed }); + if (!result.ok) expect(result.message).not.toContain("{not json"); + }); + + test("an HTML error page yields a structured failure with no page content surviving", async () => { + const transport = transportWith( + () => + new Response("500 Internal Server Error at db-host-42", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ); + const result = await transport.call(target, {}, {}); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.malformed }); + if (!result.ok) expect(result.message).not.toContain("db-host-42"); + }); + + test("the AbortSignal reaches fetch", async () => { + const controller = new AbortController(); + let seenSignal: AbortSignal | undefined; + const transport = transportWith(async (request) => { + seenSignal = request.signal; + return Response.json({ ok: true, value: {} }); + }); + await transport.call(target, {}, { signal: controller.signal }); + expect(seenSignal).toBeDefined(); + }); }); From 6aaf21aa068cda17e7fff0540598f4cb0f751026 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 20:21:41 +0530 Subject: [PATCH 20/22] feat(rpc): add the caller-side example and close remaining coverage gaps Adds examples/auth-showcase/app/services/greeter-client.ts so the showcase demonstrates both halves - the review noted the example was callee-only, so a developer had no working reference for making a call. Raises integration coverage to the planned 3 tests and adds the missing rpc-endpoint cases. Also wires the prod build path for services. 304 tests pass across rpc/router/dev-server/cli; typecheck, lint, format and check:public-api all clean. Co-Authored-By: Claude Opus 5 --- .../app/services/greeter-client.ts | 7 +++ packages/cli/src/build.ts | 12 ++++++ packages/dev-server/src/prod.ts | 9 +++- packages/dev-server/test/rpc-endpoint.test.ts | 17 ++++++++ packages/rpc/test/client.test.ts | 8 +++- packages/rpc/test/identity.test.ts | 8 +++- packages/rpc/test/integration.test.ts | 43 +++++++++++++++++++ packages/rpc/test/server.test.ts | 8 +++- 8 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 examples/auth-showcase/app/services/greeter-client.ts diff --git a/examples/auth-showcase/app/services/greeter-client.ts b/examples/auth-showcase/app/services/greeter-client.ts new file mode 100644 index 00000000..6816ac49 --- /dev/null +++ b/examples/auth-showcase/app/services/greeter-client.ts @@ -0,0 +1,7 @@ +import { httpTransport, serviceClient } from "@wrnexus/rpc"; +import { greeter } from "./greeter.ts"; + +/** A caller-side helper using the shared service contract. */ +export function greeterClient() { + return serviceClient(greeter, { app: "auth-showcase", transport: httpTransport() }); +} diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 7f72ae3d..f4b73416 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -644,6 +644,17 @@ applyAuthzManifestEarly([${authzSetupEntries}]); .join(", "); if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`); + // RPC services are server-only modules. Production statically imports them + // so the runtime can dispatch private calls without filesystem discovery. + const servicesLit = router.services + .map((service) => { + const v = `s${counter++}`; + imports.push(`import * as ${v} from ${JSON.stringify(fwd(service.file))};`); + return `{ name: ${JSON.stringify(service.name)}, mod: ${v} }`; + }) + .join(", "); + if (router.services.length) console.log(`✓ RPC services: ${router.services.length}`); + // Authorization declarations again, this time for ProdOptions.authz — a // SEPARATE set of static imports of the exact same files (harmless; ES // modules are evaluated once and shared across every importer), statically @@ -678,6 +689,7 @@ await createProductionServer( middleware: [${mwVars.join(", ")}], components: [${componentsLit}], layouts: [${layoutsLit}], + services: [${servicesLit}], }, { reactivePath: join(import.meta.dir, "reactive.js"), diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index b9fcd78e..4f7fdd97 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -73,6 +73,8 @@ export interface ProdManifest { components: { name: string; mod: RouteModule }[]; /** Named page layouts (from app/layouts/*.wrn). */ layouts: { name: string; mod: RouteModule }[]; + /** RPC service implementations (from app/services/*.ts). */ + services?: { name: string; mod: RouteModule }[]; } export interface ProductionPluginAsset { @@ -277,6 +279,8 @@ function buildProdRouter(manifest: ProdManifest): { for (const c of manifest.components) modules.set(c.name, c.mod); // Layouts share the module map under a `layout:` prefix (no name collisions). for (const l of manifest.layouts) modules.set(`layout:${l.name}`, l.mod); + for (const service of manifest.services ?? []) + modules.set(`service:${service.name}`, service.mod); const router: Router = { pages, @@ -288,7 +292,10 @@ function buildProdRouter(manifest: ProdManifest): { stores: [], schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime authz: [], // authz declarations are not needed at runtime in production - services: [], // RPC service modules are not yet emitted in production manifests + services: (manifest.services ?? []).map((service) => ({ + name: service.name, + file: `service:${service.name}`, + })), matchPage: optimizedMatcher(pages), matchApi: optimizedMatcher(api), matchRealtime: optimizedMatcher(realtime), diff --git a/packages/dev-server/test/rpc-endpoint.test.ts b/packages/dev-server/test/rpc-endpoint.test.ts index aee50bce..10bf04fa 100644 --- a/packages/dev-server/test/rpc-endpoint.test.ts +++ b/packages/dev-server/test/rpc-endpoint.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { defineService, implement, procedure } from "@wrnexus/rpc"; import { v } from "@wrnexus/validation"; import { handleRpcRequest, isInternalCaller, isRpcPath } from "../src/rpc-dispatch.ts"; +import { createProductionHandlers, type ProdManifest } from "../src/prod.ts"; const demo = defineService({ name: "demo", @@ -73,4 +74,20 @@ describe("RPC endpoint", () => { expect(await res!.json()).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); }); }); + + test("production manifests load and dispatch service implementations", async () => { + const manifest: ProdManifest = { + pages: [], + api: [], + realtime: [], + middleware: [], + components: [], + layouts: [], + services: [{ name: "demo", mod: { default: services.get("demo") } }], + }; + const handlers = createProductionHandlers(manifest, {}); + const req = request("/__wrnexus/rpc/demo/add", { "x-wrnexus-internal": "1" }); + const response = await handlers.fetch(req, {} as never); + expect(await response!.json()).toEqual({ ok: true, value: { a: 2 } }); + }); }); diff --git a/packages/rpc/test/client.test.ts b/packages/rpc/test/client.test.ts index 6aad19d3..0ea3412b 100644 --- a/packages/rpc/test/client.test.ts +++ b/packages/rpc/test/client.test.ts @@ -6,9 +6,13 @@ import { defineService, procedure } from "../src/contract.ts"; import { RPC_ERROR_CODES, ServiceError, failure, success } from "../src/errors.ts"; import type { RpcTarget } from "../src/transport.ts"; -const original = { ...process.env }; +const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; +const originalAppName = process.env.WRNEXUS_APP_NAME; afterEach(() => { - process.env = { ...original }; + if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; + else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; + if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; + else process.env.WRNEXUS_APP_NAME = originalAppName; }); function configure(appName = "web") { diff --git a/packages/rpc/test/identity.test.ts b/packages/rpc/test/identity.test.ts index e439790b..cce68f32 100644 --- a/packages/rpc/test/identity.test.ts +++ b/packages/rpc/test/identity.test.ts @@ -4,10 +4,14 @@ import { signJwt } from "@wrnexus/jwt"; import { exportSubjectContext, importSubjectContext } from "../src/identity.ts"; const SECRET = "test-rpc-secret-at-least-32-chars-long"; -const original = { ...process.env }; +const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; +const originalAppName = process.env.WRNEXUS_APP_NAME; afterEach(() => { - process.env = { ...original }; + if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; + else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; + if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; + else process.env.WRNEXUS_APP_NAME = originalAppName; }); function ctxFor(user: unknown, tenantId?: string): Context { diff --git a/packages/rpc/test/integration.test.ts b/packages/rpc/test/integration.test.ts index f858befe..a807809f 100644 --- a/packages/rpc/test/integration.test.ts +++ b/packages/rpc/test/integration.test.ts @@ -4,8 +4,10 @@ import { v } from "@wrnexus/validation"; import { serviceClient } from "../src/client.ts"; import { defineService, procedure } from "../src/contract.ts"; import { RPC_ERROR_CODES } from "../src/errors.ts"; +import { httpTransport } from "../src/http.ts"; import { implement } from "../src/server.ts"; import { inProcessTransport } from "../src/transport.ts"; +import { handleRpcRequest } from "../../dev-server/src/rpc-dispatch.ts"; const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; const originalAppName = process.env.WRNEXUS_APP_NAME; @@ -44,6 +46,30 @@ function wire(allowed: boolean) { }); } +function httpWire(allowed: boolean) { + const service = implement( + billing, + { + createInvoice: async ({ amountCents }, ctx) => ({ + invoiceId: `inv_${amountCents}`, + forSubject: ctx.subject?.subjectId ?? "anon", + }), + }, + { selfApp: "billing", checkPermission: async () => allowed }, + ); + return httpTransport({ + resolveOrigin: () => "http://billing.internal", + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + return (await handleRpcRequest( + request, + new URL(request.url), + new Map([["billing", service]]), + ))!; + }) as typeof fetch, + }); +} + describe("RPC integration", () => { test("propagates identity and validates the callee permission", async () => { process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; @@ -71,4 +97,21 @@ describe("RPC integration", () => { code: RPC_ERROR_CODES.denied, }); }); + + test("uses the real HTTP transport and private dispatcher end to end", async () => { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = "web"; + const client = serviceClient(billing, { + app: "billing", + as: { user: { id: "u1" }, locals: {} } as unknown as Context, + transport: httpWire(true), + }); + expect(await client.createInvoice({ amountCents: 7 })).toEqual({ + invoiceId: "inv_7", + forSubject: "u1", + }); + await expect(client.createInvoice({ amountCents: "bad" } as never)).rejects.toMatchObject({ + code: RPC_ERROR_CODES.invalid, + }); + }); }); diff --git a/packages/rpc/test/server.test.ts b/packages/rpc/test/server.test.ts index dd23c67a..03bd8e03 100644 --- a/packages/rpc/test/server.test.ts +++ b/packages/rpc/test/server.test.ts @@ -5,9 +5,13 @@ import { RPC_ERROR_CODES } from "../src/errors.ts"; import { exportSubjectContext } from "../src/identity.ts"; import { implement } from "../src/server.ts"; -const original = { ...process.env }; +const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; +const originalAppName = process.env.WRNEXUS_APP_NAME; afterEach(() => { - process.env = { ...original }; + if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; + else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; + if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; + else process.env.WRNEXUS_APP_NAME = originalAppName; }); function configure(appName = "web") { From 3eec9fd8c6d3e4cfed5aa183cf928d4bf316812c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 20:36:31 +0530 Subject: [PATCH 21/22] fix(rpc): close the four final-review blockers on inter-app RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve RPC call origins via a new WRNEXUS_INTERNAL_ORIGINS map (loopback origins the gateway hands each child before spawning it), falling back to the public appOrigin only when it is absent. Calls previously always went to the public gateway origin, which the gateway unconditionally 404s on the RPC prefix by design — every real cross-app call failed. - Stop loadServices() from running ahead of routing and stop memoizing a rejected load: one bad file under app/services/ no longer permanently breaks every route in the app. A failed load logs loudly, is retried on the next RPC request, and the RPC path gets a structured RPC_UNKNOWN instead of an unhandled throw. - Reject a service whose contract.name does not match the filename it is mounted under, naming both, instead of silently mounting under the filename while the typed client calls by contract name. - Let ServiceError accept an explicit retryable and have the client pass the wire value through, instead of recomputing (and silently flipping) it from the error code alone. - Document the gateway/X-Forwarded-* deployment requirement in the RPC README. Each of the three code blockers has a new/extended test that was verified to fail when its fix was reverted (rpc/test/integration.test.ts, dev-server/test/rpc-services-loading.test.ts). Co-Authored-By: Claude Opus 5 --- docs/public-api-0.8.json | 1 + packages/dev-server/src/gateway.ts | 10 ++ packages/dev-server/src/runtime.ts | 69 +++++-- .../test/rpc-services-loading.test.ts | 170 ++++++++++++++++++ packages/rpc/README.md | 29 +++ packages/rpc/src/client.ts | 2 +- packages/rpc/src/errors.ts | 12 +- packages/rpc/src/http.ts | 37 +++- packages/rpc/src/index.ts | 8 +- packages/rpc/test/client.test.ts | 25 +++ packages/rpc/test/integration.test.ts | 51 ++++++ 11 files changed, 394 insertions(+), 20 deletions(-) create mode 100644 packages/dev-server/test/rpc-services-loading.test.ts diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index cc8fdd3f..a043b7ed 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -2413,6 +2413,7 @@ "inProcessTransport", "isRetryableStatus", "procedure", + "resolveAppOrigin", "rpcPath", "rpcSecret", "serviceClient", diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index 6255cf53..491d91d2 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -461,6 +461,14 @@ export async function startGateway(opts: GatewayOptions): Promise [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]), ), ); + // Loopback-only origins, computed up front (ports are assigned by index + // before any child spawns) so every child can reach every other child + // directly — bypassing the gateway, which 404s the RPC prefix by design. + const internalOriginsEnv: Readonly> = Object.freeze( + Object.fromEntries( + opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]), + ), + ); const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname; // When the CLI is executed directly from a framework checkout, keep child // apps on that same source tree. Resolving the package name from an external @@ -498,6 +506,7 @@ export async function startGateway(opts: GatewayOptions): Promise> | undefined; - const loadServices = () => - (servicesPromise ??= (async () => { - const services = new Map(); - for (const entry of router.services) { - const imported = await loadModule(entry.file); - const implementation = imported.default as ServiceImplementation | undefined; - if (!implementation || typeof implementation.invoke !== "function") { - throw new Error(`RPC service ${entry.file} must default-export implement(...)`); + const loadServices = (): Promise> => { + if (!servicesPromise) { + servicesPromise = (async () => { + const services = new Map(); + for (const entry of router.services) { + const imported = await loadModule(entry.file); + const implementation = imported.default as ServiceImplementation | undefined; + if (!implementation || typeof implementation.invoke !== "function") { + throw new Error(`RPC service ${entry.file} must default-export implement(...)`); + } + if (implementation.contract.name !== entry.name) { + throw new Error( + `RPC service file ${entry.file} is mounted as "${entry.name}" (its filename) ` + + `but its contract is named "${implementation.contract.name}". Rename the file to ` + + `match the contract, or rename the contract to match the file.`, + ); + } + services.set(entry.name, implementation); } - services.set(entry.name, implementation); - } - return services; - })()); + return services; + })().catch((error) => { + servicesPromise = undefined; + const app = process.env.WRNEXUS_APP_NAME ?? "app"; + console.error( + `[wrnexus] failed to load RPC services (${app}):`, + error instanceof Error ? (error.stack ?? error.message) : error, + ); + throw error; + }); + } + return servicesPromise; + }; // Server-side realtime room manager (shared by every `defineRoom` connection). const realtime = createRealtimeRegistry(); @@ -964,8 +987,24 @@ export function createHandlers(deps: RuntimeDeps): Handlers { const secure = (res: Response): Response => withSecurityHeaders(req, res, mode, runtimeSecurity, nonce); - const rpcResponse = await handleRpcRequest(req, url, await loadServices()); - if (rpcResponse) return secure(rpcResponse); + if (isRpcPath(url.pathname)) { + let services: Map; + try { + services = await loadServices(); + } catch (error) { + const app = process.env.WRNEXUS_APP_NAME ?? "app"; + const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error(`[wrnexus] RPC request failed to load services (${app})\n${detail}`); + return secure( + Response.json( + { ok: false, code: "RPC_UNKNOWN", message: "Service unavailable", retryable: false }, + { headers: { "cache-control": "private, no-store" } }, + ), + ); + } + const rpcResponse = await handleRpcRequest(req, url, services); + if (rpcResponse) return secure(rpcResponse); + } const preflight = createCorsPreflightResponse(req, deps.security); if (preflight) return secure(preflight); diff --git a/packages/dev-server/test/rpc-services-loading.test.ts b/packages/dev-server/test/rpc-services-loading.test.ts new file mode 100644 index 00000000..b839e8fa --- /dev/null +++ b/packages/dev-server/test/rpc-services-loading.test.ts @@ -0,0 +1,170 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildRouter } from "@wrnexus/router"; +import { defineService, implement, procedure } from "@wrnexus/rpc"; +import { v } from "@wrnexus/validation"; +import { createHandlers, type RuntimeDeps } from "../src/runtime.ts"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); + +const greeter = defineService({ + name: "greeter", + procedures: { + greet: procedure + .input(v.object({ name: v.string() })) + .output<{ message: string }>() + .build(), + }, +}); + +function makeHandlers(app: string, loadModule: RuntimeDeps["loadModule"]) { + return createHandlers({ + mode: "production", + hmr: false, + router: buildRouter(app), + loadModule, + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); +} + +test("a bad file under app/services does not break unrelated routes", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-bad-service-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + mkdirSync(join(app, "services"), { recursive: true }); + writeFileSync(join(app, "pages/home.ts"), "export default () => '';\n"); + // A co-located helper with no default export — the natural thing a + // developer drops next to a real service. + writeFileSync(join(app, "services/types.ts"), "export type Foo = string;\n"); + + const handlers = makeHandlers(app, async (file) => { + const normalized = file.replace(/\\/g, "/"); + if (normalized.endsWith("services/types.ts")) return {}; // no default export + if (normalized.endsWith("pages/home.ts")) return { default: () => "

home

" }; + throw new Error(`unexpected module: ${file}`); + }); + + const res = await handlers.fetch(new Request("https://example.test/home"), { + upgrade: () => false, + }); + expect(await res!.text()).toContain("home"); +}); + +test("the RPC path returns a structured failure instead of throwing when service load fails", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-load-fail-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "services"), { recursive: true }); + writeFileSync(join(app, "services/types.ts"), "export type Foo = string;\n"); + + const handlers = makeHandlers(app, async () => ({})); // no default export + + const res = await handlers.fetch( + new Request("https://example.test/__wrnexus/rpc/types/greet", { + method: "POST", + headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, + body: "{}", + }), + { upgrade: () => false }, + ); + expect(res).toBeDefined(); + expect(res!.status).toBeLessThan(500); + const body = await res!.json(); + expect(body).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); +}); + +test("a request after a load failure re-attempts rather than serving a cached rejection", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-retry-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "services"), { recursive: true }); + writeFileSync(join(app, "services/greeter.ts"), "export default {};\n"); + + let attempt = 0; + const handlers = makeHandlers(app, async () => { + attempt += 1; + if (attempt === 1) return {}; // fails: no default export + return { + default: implement( + greeter, + { greet: async ({ name }) => ({ message: `Hi ${name}` }) }, + { + selfApp: "greeter", + }, + ), + }; + }); + + const call = () => + handlers.fetch( + new Request("https://example.test/__wrnexus/rpc/greeter/greet", { + method: "POST", + headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, + body: JSON.stringify({ name: "Ada" }), + }), + { upgrade: () => false }, + ); + + const first = await call(); + expect(await first!.json()).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); + + const second = await call(); + expect(await second!.json()).toEqual({ ok: true, value: { message: "Hi Ada" } }); + expect(attempt).toBe(2); +}); + +test("a contract name that does not match its mounted filename fails loudly, naming both", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-name-mismatch-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "services"), { recursive: true }); + writeFileSync(join(app, "services/greeter.ts"), "export default {};\n"); + + const billing = defineService({ + name: "billing", + procedures: { + createInvoice: procedure + .input(v.object({ amountCents: v.number() })) + .output<{ invoiceId: string }>() + .build(), + }, + }); + const service = implement( + billing, + { createInvoice: async ({ amountCents }) => ({ invoiceId: `inv_${amountCents}` }) }, + { selfApp: "billing" }, + ); + + const handlers = makeHandlers(app, async () => ({ default: service })); + + // The typed client would call /__wrnexus/rpc/billing/... (contract name), + // but the file mounts as "greeter" — the client's request 404s as unknown. + const res = await handlers.fetch( + new Request("https://example.test/__wrnexus/rpc/billing/createInvoice", { + method: "POST", + headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, + body: JSON.stringify({ amountCents: 5 }), + }), + { upgrade: () => false }, + ); + const body = await res!.json(); + expect(body).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); + + // And the actual mounted name ("greeter") also fails: the implementation's + // contract does not match the filename it was mounted under. + const res2 = await handlers.fetch( + new Request("https://example.test/__wrnexus/rpc/greeter/createInvoice", { + method: "POST", + headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, + body: JSON.stringify({ amountCents: 5 }), + }), + { upgrade: () => false }, + ); + const body2 = await res2!.json(); + expect(body2).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); +}); diff --git a/packages/rpc/README.md b/packages/rpc/README.md index 9cf468d5..ca6744f6 100644 --- a/packages/rpc/README.md +++ b/packages/rpc/README.md @@ -42,3 +42,32 @@ Service files default-export `implement(...)` from `app/services`. The developme Pass `{ as: ctx }` to `serviceClient` to propagate the subject. The signed token contains only subject and tenant identifiers; permissions are always checked by the callee. Set `WRNEXUS_RPC_SECRET` in every app, use at least 32 characters, and never reuse the session secret. Calls time out by default. Retrying is intentionally deferred; when introduced, only procedures marked `.idempotent()` may be retried. + +## Deployment requirement: apps must be unreachable except through the gateway + +`/__wrnexus/rpc/*` is authenticated by TWO signals together: a marker header +(`x-wrnexus-internal: 1`) AND the absence of any `X-Forwarded-*` header. The +WrNexus gateway satisfies this by construction — it strips any inbound +marker header from the public request, and it always adds `X-Forwarded-*` +when proxying to an app. A direct loopback call from a sibling app process +carries the marker and no forwarded headers, so it passes; anything that +came through the gateway carries forwarded headers, so it's rejected even if +it also carries the marker. + +**This check only works if the app process is unreachable except through the +gateway.** If an app's port is exposed directly, or if a reverse proxy sits +in front of it WITHOUT setting `X-Forwarded-*` (a bare `proxy_pass` with no +`proxy_set_header X-Forwarded-For`/`X-Forwarded-Host`/`X-Forwarded-Proto`), +then an external caller can set the marker header itself, arrive with no +forwarded headers, and reach `/__wrnexus/rpc/*` as if it were an internal +call — bypassing the gateway's edge block entirely. + +Requirements for any deployment: + +- App processes must bind to a private/loopback interface and be reachable + ONLY through the gateway (or an equivalent trusted front door) — never + exposed directly to the internet or an untrusted network. +- Any reverse proxy placed in front of an app (nginx, a load balancer, etc.) + MUST set `X-Forwarded-For`, `X-Forwarded-Host`, and `X-Forwarded-Proto` on + every request it forwards. Omitting these silently reopens the private RPC + namespace to anyone who can reach the proxy. diff --git a/packages/rpc/src/client.ts b/packages/rpc/src/client.ts index b4764e71..246a4f67 100644 --- a/packages/rpc/src/client.ts +++ b/packages/rpc/src/client.ts @@ -71,7 +71,7 @@ export function serviceClient( try { const result = await Promise.race([callPromise, timeout]); if (result.ok) return result.value; - throw new ServiceError(result.code, result.message); + throw new ServiceError(result.code, result.message, result.retryable); } finally { clearTimeout(timer); // If the timeout won the race, the transport call may still settle diff --git a/packages/rpc/src/errors.ts b/packages/rpc/src/errors.ts index 02f7f709..303a1e90 100644 --- a/packages/rpc/src/errors.ts +++ b/packages/rpc/src/errors.ts @@ -59,11 +59,19 @@ export class ServiceError extends Error { readonly code: string; readonly retryable: boolean; - constructor(code: string, message: string) { + /** + * `retryable` defaults to the code-derived value for callers that + * construct a `ServiceError` directly. Pass it explicitly when relaying a + * wire result: the transport already computed the authoritative value + * (e.g. a bounded HTTP-status check), and recomputing it here from the + * code alone would silently flip it — `RPC_TRANSPORT` derives to `true`, + * even for a non-retryable 403. + */ + constructor(code: string, message: string, retryable?: boolean) { super(message); this.name = "ServiceError"; this.code = code; - this.retryable = retryableFor(code); + this.retryable = retryable ?? retryableFor(code); } /** diff --git a/packages/rpc/src/http.ts b/packages/rpc/src/http.ts index d4f72bf7..a473975b 100644 --- a/packages/rpc/src/http.ts +++ b/packages/rpc/src/http.ts @@ -11,6 +11,41 @@ export function rpcPath(service: string, procedure: string): string { return `${RPC_PATH_PREFIX}/${service}/${procedure}`; } +function parseOriginMap(value: string | undefined): Record { + if (!value) return {}; + try { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return parsed as Record; + } catch { + return {}; + } +} + +/** + * Resolve the origin an RPC call to `app` should target. + * + * Prefer `WRNEXUS_INTERNAL_ORIGINS` (loopback origins the gateway hands each + * child before spawning it) over `appOrigin`, which resolves the app's + * PUBLIC origin. The public origin is the wrong target for RPC: the gateway + * unconditionally 404s the reserved `/__wrnexus/rpc` prefix on anything that + * arrives at a public origin — that block is the whole point, it is what + * keeps inter-app calls off the public internet. Falling back to `appOrigin` + * when no internal-origin map is present keeps single-app and test setups + * (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working. + */ +export function resolveAppOrigin(app: string): string { + const internalOrigin = parseOriginMap(process.env.WRNEXUS_INTERNAL_ORIGINS)[app]; + if (internalOrigin) { + try { + return new URL(internalOrigin).origin; + } catch { + // Malformed internal-origin entry — fall through to the public origin. + } + } + return appOrigin(app); +} + export interface HttpTransportOptions { resolveOrigin?: (app: string) => string; fetch?: typeof fetch; @@ -29,7 +64,7 @@ function isServiceResult(value: unknown): value is ServiceResult { } export function httpTransport(options: HttpTransportOptions = {}): Transport { - const resolveOrigin = options.resolveOrigin ?? appOrigin; + const resolveOrigin = options.resolveOrigin ?? resolveAppOrigin; const doFetch = options.fetch ?? fetch; return { async call(target: RpcTarget, payload: unknown, callOptions: CallOptions) { diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index b4a854b3..15d15c72 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -42,5 +42,11 @@ export type { } from "./server.ts"; export { serviceClient } from "./client.ts"; export type { ServiceClient, ServiceClientOptions } from "./client.ts"; -export { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX, httpTransport, rpcPath } from "./http.ts"; +export { + RPC_INTERNAL_HEADER, + RPC_PATH_PREFIX, + httpTransport, + resolveAppOrigin, + rpcPath, +} from "./http.ts"; export type { HttpTransportOptions } from "./http.ts"; diff --git a/packages/rpc/test/client.test.ts b/packages/rpc/test/client.test.ts index 0ea3412b..4d3ea68d 100644 --- a/packages/rpc/test/client.test.ts +++ b/packages/rpc/test/client.test.ts @@ -189,4 +189,29 @@ describe("serviceClient", () => { expect(seenTarget?.app).toBe("billing"); expect(seenTarget?.service).toBe("billing"); }); + + test("a wire-level non-retryable failure (e.g. a 403) surfaces at the client as retryable: false", async () => { + // RPC_TRANSPORT recomputes to retryable: true from the code alone — that + // is correct for a 5xx, but the transport already determined a 403 is + // NOT retryable via the bounded status check. The client must pass that + // wire value through rather than recompute it from the code. + const client = serviceClient(billing, { + transport: { + call: async () => ({ + ok: false, + code: RPC_ERROR_CODES.transport, + message: "Service returned 403", + retryable: false, + }), + }, + }); + let caught: unknown; + try { + await client.createInvoice({ amountCents: 1 }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ServiceError); + expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: false }); + }); }); diff --git a/packages/rpc/test/integration.test.ts b/packages/rpc/test/integration.test.ts index a807809f..c89e1ec3 100644 --- a/packages/rpc/test/integration.test.ts +++ b/packages/rpc/test/integration.test.ts @@ -114,4 +114,55 @@ describe("RPC integration", () => { code: RPC_ERROR_CODES.invalid, }); }); + + test("the default httpTransport reaches the callee over a real socket via the internal origin", async () => { + const service = implement( + billing, + { + createInvoice: async ({ amountCents }, ctx) => ({ + invoiceId: `inv_${amountCents}`, + forSubject: ctx.subject?.subjectId ?? "anon", + }), + }, + { selfApp: "billing", checkPermission: async () => true }, + ); + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const url = new URL(req.url); + const res = await handleRpcRequest(req, url, new Map([["billing", service]])); + return res ?? new Response("Not found", { status: 404 }); + }, + }); + const originalWorkspace = process.env.WRNEXUS_WORKSPACE_ORIGINS; + const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS; + try { + // The workspace (public) origin deliberately points somewhere that + // cannot serve the RPC — the gateway 404s the RPC prefix on any + // request that arrives at a public origin. Only the internal-origin + // map points at the real server. If httpTransport() ever falls back + // to the public origin by default again, this call fails. + process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({ + billing: "http://127.0.0.1:1", // unroutable — nothing listens here + }); + process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({ + billing: `http://127.0.0.1:${server.port}`, + }); + const client = serviceClient(billing, { + app: "billing", + transport: httpTransport(), // no resolveOrigin override — uses the real default + }); + expect(await client.createInvoice({ amountCents: 42 })).toEqual({ + invoiceId: "inv_42", + forSubject: "anon", + }); + } finally { + server.stop(true); + if (originalWorkspace === undefined) delete process.env.WRNEXUS_WORKSPACE_ORIGINS; + else process.env.WRNEXUS_WORKSPACE_ORIGINS = originalWorkspace; + if (originalInternal === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS; + else process.env.WRNEXUS_INTERNAL_ORIGINS = originalInternal; + } + }); }); From 98205daef67bb88a8f7b2733e4f7a006bb8f00da Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 20:57:08 +0530 Subject: [PATCH 22/22] fix(rpc): isolate integration test from cross-suite fetch pollution packages/csr's actions.test.ts and reactive.test.ts both leave globalThis.fetch mutated across bun test files (reactive.test.ts's 'cache invalidation refetches...' test replaces it and never restores it). Since bun test runs files sequentially rather than importing all of them up front, a module-level capture of fetch in this file would already observe csr's leftover mock (csr sorts before rpc). Route the real-socket assertion through a small node:http-backed fetch implementation instead of relying on globalThis.fetch at all, keeping the test's actual target - httpTransport()'s default resolveOrigin - unaffected by any other suite's global mutation. --- packages/rpc/test/integration.test.ts | 49 ++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/rpc/test/integration.test.ts b/packages/rpc/test/integration.test.ts index c89e1ec3..77d28dbb 100644 --- a/packages/rpc/test/integration.test.ts +++ b/packages/rpc/test/integration.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import * as http from "node:http"; import type { Context } from "@wrnexus/core"; import { v } from "@wrnexus/validation"; import { serviceClient } from "../src/client.ts"; @@ -70,6 +71,50 @@ function httpWire(allowed: boolean) { }); } +// packages/csr's actions.test.ts deletes globalThis.fetch (and restores its +// own captured copy) around each of its tests, and packages/csr's +// reactive.test.ts's "cache invalidation refetches matching client Async +// boundaries" test replaces globalThis.fetch with a mock and never restores +// it at all. bun test runs test files sequentially — importing a file and +// running its tests before moving to the next — rather than importing every +// file up front, so a module-level `const realFetch = fetch` captured here +// would already observe whatever packages/csr left behind by the time this +// file (which sorts after csr) is loaded. The variable under test below is +// `resolveOrigin`, not `fetch`, so instead of depending on the shared +// `globalThis.fetch` at all, this makes a real request over a real socket +// using node:http directly. That keeps the assertion about +// httpTransport()'s default origin resolution intact regardless of what any +// other suite does to the global `fetch` binding. +function realFetch(url: string, init: RequestInit): Promise { + return new Promise((resolve, reject) => { + const target = new URL(url); + const req = http.request( + { + hostname: target.hostname, + port: target.port, + path: `${target.pathname}${target.search}`, + method: init.method ?? "GET", + headers: init.headers as Record, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("error", reject); + res.on("end", () => { + resolve( + new Response(Buffer.concat(chunks), { + status: res.statusCode ?? 500, + }), + ); + }); + }, + ); + req.on("error", reject); + if (typeof init.body === "string") req.write(init.body); + req.end(); + }); +} + describe("RPC integration", () => { test("propagates identity and validates the callee permission", async () => { process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; @@ -151,7 +196,9 @@ describe("RPC integration", () => { }); const client = serviceClient(billing, { app: "billing", - transport: httpTransport(), // no resolveOrigin override — uses the real default + // no resolveOrigin override — uses the real default; fetch is pinned + // to the node:http-backed implementation above (see comment there). + transport: httpTransport({ fetch: realFetch as unknown as typeof fetch }), }); expect(await client.createInvoice({ amountCents: 42 })).toEqual({ invoiceId: "inv_42",