From 63e6148cdb63c545b63ce74100443e4434cb76a0 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 09:37:39 +0530 Subject: [PATCH] docs: implementation plan for the inter-app communication system Eleven TDD tasks covering phase 1: contract and immutable procedure builder, error classification, the signed subject-context token, the Transport seam with an in-process transport for tests, implement() with fail-closed identity and permission checks, the typed client proxy, the HTTP transport, router discovery of app/services, and the mounted endpoint with its two independent external-access guards. Phases 2-4 (retry and circuit breaking, app-to-app streaming, identity for pubsub and queue) are documented as deferred with the reason each needs its own design pass. Task 10 is called out as the highest-risk: if /__wrnexus/rpc/* is reachable from the public internet, every permission check in the workspace is bypassable, so the plan requires the gateway block and the app-side check to be verified as working independently of each other. Co-Authored-By: Claude Opus 5 --- ...26-08-05-inter-app-comms-implementation.md | 2362 +++++++++++++++++ 1 file changed, 2362 insertions(+) create mode 100644 docs/plans/2026-08-05-inter-app-comms-implementation.md diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md new file mode 100644 index 00000000..bae0aca9 --- /dev/null +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -0,0 +1,2362 @@ +# Inter-app Communication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Typed request/response calls between WRNexus workspace apps, carrying the end user's identity, over HTTP behind a pluggable transport seam. + +**Architecture:** A service 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. No code generation; types flow through a normal TypeScript import. Identity travels as a short-lived signed token carrying subject and tenant only, never roles: the callee resolves roles from the shared `PermissionStore` it already has. + +**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). + +## Global Constraints + +- Every `@wrnexus/*` package is version `0.8.4`. Do not change versions. +- Zero runtime npm dependencies. Bun, WebCrypto, and `node:` builtins only. +- `@wrnexus/core` MUST NOT import `@wrnexus/rpc`. `@wrnexus/rpc` may import **types only** from `@wrnexus/core`. +- Any `@wrnexus/*` package imported anywhere under a package's `src/` MUST be declared in that package's `package.json` dependencies. A missing declaration passes every in-repo gate and breaks the published package. +- Every failure path denies or errors. Never fail open. +- The identity token authenticates; it never authorizes. The callee always runs its own permission check. +- The token carries `sub` and `tenant` only. It MUST NOT carry roles or permissions. +- The RPC secret MUST NOT be the session secret. +- `/__wrnexus/rpc/*` must be unreachable from the public internet, enforced at the gateway AND verified at the app. +- 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"`. +- 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. +- Relative import specifiers use `.ts` extensions. +- Full gate: `bun run check:production` must exit 0. + +--- + +## File Structure + +**Created — new package `packages/rpc`:** + +| File | Responsibility | +| ------------------------------- | ------------------------------------------------------------------------------------------- | +| `packages/rpc/package.json` | Manifest; declares `@wrnexus/core`, `@wrnexus/validation`, `@wrnexus/jwt`, `@wrnexus/authz` | +| `packages/rpc/src/types.ts` | Shared types: `ServiceContract`, `ProcedureDef`, `ServiceResult`, inference helpers | +| `packages/rpc/src/errors.ts` | `ServiceError`, error codes, retryability classification | +| `packages/rpc/src/contract.ts` | `defineService()`, the `procedure` builder | +| `packages/rpc/src/identity.ts` | `exportSubjectContext()`, `importSubjectContext()`, secret resolution | +| `packages/rpc/src/transport.ts` | `Transport` interface, `inProcessTransport()` | +| `packages/rpc/src/http.ts` | `httpTransport()` | +| `packages/rpc/src/server.ts` | `implement()`, `handleRpcRequest()` | +| `packages/rpc/src/client.ts` | `serviceClient()` typed proxy | +| `packages/rpc/src/index.ts` | Public surface | + +**Modified:** + +| File | Change | +| ------------------------------------ | ---------------------------------------------------- | +| `tsconfig.json` | `paths` entry for `@wrnexus/rpc` | +| `packages/router/src/index.ts` | Discover `app/services/*.ts` into `router.services` | +| `packages/dev-server/src/runtime.ts` | Dispatch `/__wrnexus/rpc/*`, reject external callers | +| `packages/dev-server/src/gateway.ts` | Block `/__wrnexus/rpc/*` from outside | +| `docs/public-api-0.8.json` | Regenerated | + +--- + +## Task 1: Package scaffold and shared types + +**Files:** + +- Create: `packages/rpc/package.json`, `packages/rpc/src/types.ts`, `packages/rpc/src/index.ts` +- Modify: `tsconfig.json` (add a `paths` entry) +- Test: `packages/rpc/test/types.test.ts` + +**Interfaces:** + +- Consumes: `ObjectSchema` from `@wrnexus/validation` +- Produces: `ProcedureDef`, `ServiceContract`, `InferInput`, `InferOutput

`, `ServiceResult` + +- [ ] **Step 1: Write the failing test** + +Create `packages/rpc/test/types.test.ts`: + +```ts +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"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/rpc/test/types.test.ts` +Expected: FAIL — cannot resolve `../src/types.ts` + +- [ ] **Step 3: Write the manifest** + +Create `packages/rpc/package.json`: + +```json +{ + "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" + } +} +``` + +- [ ] **Step 4: Write the types** + +Create `packages/rpc/src/types.ts`: + +```ts +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 }; +``` + +Create `packages/rpc/src/index.ts`: + +```ts +/** + * @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"; +``` + +- [ ] **Step 5: Add the tsconfig path** + +In the root `tsconfig.json`, add to `compilerOptions.paths`, next to the other `@wrnexus/*` entries: + +```json + "@wrnexus/rpc": ["./packages/rpc/src/index.ts"], +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `bun install && bun test packages/rpc/test/types.test.ts && bun run typecheck` +Expected: PASS, 2 tests; typecheck clean + +- [ ] **Step 7: Commit** + +```bash +git add packages/rpc tsconfig.json bun.lock +git commit -m "feat(rpc): scaffold the package and shared contract types" +``` + +--- + +## Task 2: Errors and retryability + +**Files:** + +- Create: `packages/rpc/src/errors.ts` +- Modify: `packages/rpc/src/index.ts` (append exports) +- Test: `packages/rpc/test/errors.test.ts` + +**Interfaces:** + +- Consumes: `ServiceResult` from `./types.ts` +- Produces: `ServiceError`, `RPC_ERROR_CODES`, `isRetryableStatus(status: number): boolean`, `failure(code, message): ServiceResult`, `success(value: T): ServiceResult` + +- [ ] **Step 1: Write the failing test** + +Create `packages/rpc/test/errors.test.ts`: + +```ts +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"); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/rpc/test/errors.test.ts` +Expected: FAIL — cannot resolve `../src/errors.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/rpc/src/errors.ts`: + +```ts +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, + }; + } +} +``` + +Append to `packages/rpc/src/index.ts`: + +```ts +export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } from "./errors.ts"; +export type { RpcErrorCode, ToResultOptions } from "./errors.ts"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/rpc/test/errors.test.ts` +Expected: PASS, 5 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/rpc/src/errors.ts packages/rpc/src/index.ts packages/rpc/test/errors.test.ts +git commit -m "feat(rpc): add service errors and retryability classification" +``` + +--- + +## Task 3: Contract definition + +**Files:** + +- Create: `packages/rpc/src/contract.ts` +- Modify: `packages/rpc/src/index.ts` (append exports) +- Test: `packages/rpc/test/contract.test.ts` + +**Interfaces:** + +- Consumes: `ProcedureDef`, `ServiceContract`, `AnyProcedures`, `InferInput` from `./types.ts` +- Produces: `defineService(def): ServiceContract`, `procedure` (a `ProcedureBuilder`), `ProcedureBuilder` with `.input(schema)`, `.output()`, `.permission(id)`, `.idempotent()`, `.build()` + +- [ ] **Step 1: Write the failing test** + +Create `packages/rpc/test/contract.test.ts`: + +```ts +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"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/rpc/test/contract.test.ts` +Expected: FAIL — cannot resolve `../src/contract.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/rpc/src/contract.ts`: + +```ts +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 }); + } + + 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 }) }); +} +``` + +Append to `packages/rpc/src/index.ts`: + +```ts +export { defineService, procedure, ProcedureBuilder } from "./contract.ts"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/rpc/test/contract.test.ts && bun run typecheck` +Expected: PASS, 5 tests; typecheck clean + +- [ ] **Step 5: Commit** + +```bash +git add packages/rpc/src/contract.ts packages/rpc/src/index.ts packages/rpc/test/contract.test.ts +git commit -m "feat(rpc): add defineService and the immutable procedure builder" +``` + +--- + +## Task 4: Identity token + +**Files:** + +- Create: `packages/rpc/src/identity.ts` +- Modify: `packages/rpc/src/index.ts` (append exports) +- Test: `packages/rpc/test/identity.test.ts` + +**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` + +- [ ] **Step 1: Write the failing test** + +Create `packages/rpc/test/identity.test.ts`: + +```ts +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/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/rpc/test/identity.test.ts` +Expected: FAIL — cannot resolve `../src/identity.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/rpc/src/identity.ts`: + +```ts +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, + }; +} +``` + +Append to `packages/rpc/src/index.ts`: + +```ts +export { + RPC_IDENTITY_HEADER, + exportSubjectContext, + importSubjectContext, + rpcSecret, +} from "./identity.ts"; +export type { ExportOptions, SubjectContext } from "./identity.ts"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/rpc/test/identity.test.ts` +Expected: PASS, 10 tests + +- [ ] **Step 5: Verify each rejection discriminates** + +For the wrong-audience, expired, wrong-secret and tampered-payload tests in turn: temporarily weaken the corresponding guard (drop `audience` from the verify options; raise the TTL; skip the secret change; skip the signature), confirm that specific test FAILS, then restore. Report all four results. A rejection test that passes against a weakened guard proves nothing. + +- [ ] **Step 6: Commit** + +```bash +git add packages/rpc/src/identity.ts packages/rpc/src/index.ts packages/rpc/test/identity.test.ts +git commit -m "feat(rpc): add the signed subject-context token" +``` + +--- + +## Task 5: Transport interface and in-process transport + +**Files:** + +- Create: `packages/rpc/src/transport.ts` +- Modify: `packages/rpc/src/index.ts` (append exports) +- Test: `packages/rpc/test/transport.test.ts` + +**Interfaces:** + +- Consumes: `ServiceResult` from `./types.ts` +- Produces: `interface RpcTarget { app: string; service: string; procedure: string }`, `interface CallOptions { signal?: AbortSignal; identity?: string }`, `interface Transport { call(target, payload, options): Promise }`, `inProcessTransport(handlers): Transport`, `type InProcessHandler = (payload: unknown, identity?: string) => Promise` + +- [ ] **Step 1: Write the failing test** + +Create `packages/rpc/test/transport.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { inProcessTransport } from "../src/transport.ts"; +import { RPC_ERROR_CODES, success } from "../src/errors.ts"; + +describe("inProcessTransport", () => { + test("routes to the registered handler and passes identity through", async () => { + let seenIdentity: string | undefined; + const transport = inProcessTransport({ + "billing/createInvoice": async (payload, identity) => { + seenIdentity = identity; + return success({ echoed: payload }); + }, + }); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "createInvoice" }, + { amountCents: 10 }, + { identity: "token-abc" }, + ); + expect(result).toEqual({ ok: true, value: { echoed: { amountCents: 10 } } }); + expect(seenIdentity).toBe("token-abc"); + }); + + test("an unregistered procedure is a non-retryable unknown failure", async () => { + const transport = inProcessTransport({}); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "nope" }, + {}, + {}, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(RPC_ERROR_CODES.unknown); + expect(result.retryable).toBe(false); + } + }); + + test("an already-aborted signal fails without invoking the handler", async () => { + let invoked = false; + const transport = inProcessTransport({ + "billing/slow": async () => { + invoked = true; + return success(null); + }, + }); + const controller = new AbortController(); + controller.abort(); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "slow" }, + {}, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + expect(invoked).toBe(false); + }); + + test("a handler that throws becomes an opaque failure, not a rejection", async () => { + const transport = inProcessTransport({ + "billing/boom": async () => { + throw new Error("connection to 10.0.0.7 failed with password hunter2"); + }, + }); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "boom" }, + {}, + {}, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).not.toContain("hunter2"); + expect(result.message).not.toContain("10.0.0.7"); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/rpc/test/transport.test.ts` +Expected: FAIL — cannot resolve `../src/transport.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/rpc/src/transport.ts`: + +```ts +import { RPC_ERROR_CODES, failure } from "./errors.ts"; +import type { ServiceResult } from "./types.ts"; + +export interface RpcTarget { + /** Workspace app that owns the service. */ + app: string; + service: string; + procedure: string; +} + +export interface CallOptions { + signal?: AbortSignal; + /** Signed subject-context token, absent for an anonymous call. */ + identity?: string; +} + +export interface Transport { + call(target: RpcTarget, payload: unknown, options: CallOptions): Promise; +} + +export type InProcessHandler = ( + payload: unknown, + identity?: string, +) => Promise | ServiceResult; + +/** + * Calls a handler directly, with no network. Exists so contract, identity and + * error classification are testable without standing up two servers. + * Handlers are keyed `"/"`. + */ +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, + `No procedure '${target.service}/${target.procedure}'`, + ); + } + try { + return await handler(payload, options.identity); + } catch (error) { + // Never surface a handler's message across the boundary: it routinely + // names hosts, tables, or credentials. + console.error("[wrnexus:rpc] in-process handler threw", error); + return failure(RPC_ERROR_CODES.handler, "Internal error"); + } + }, + }; +} +``` + +Append to `packages/rpc/src/index.ts`: + +```ts +export { inProcessTransport } from "./transport.ts"; +export type { CallOptions, InProcessHandler, RpcTarget, Transport } from "./transport.ts"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/rpc/test/transport.test.ts` +Expected: PASS, 4 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/rpc/src/transport.ts packages/rpc/src/index.ts packages/rpc/test/transport.test.ts +git commit -m "feat(rpc): add the Transport interface and in-process transport" +``` + +--- + +## Task 6: Server — implement() and request handling + +**Files:** + +- Create: `packages/rpc/src/server.ts` +- Modify: `packages/rpc/src/index.ts` (append exports) +- Test: `packages/rpc/test/server.test.ts` + +**Interfaces:** + +- Consumes: `ServiceContract` from `./types.ts`; `RPC_ERROR_CODES`, `failure`, `success`, `ServiceError` from `./errors.ts`; `importSubjectContext`, `SubjectContext` from `./identity.ts` +- Produces: `implement(contract, handlers): ServiceImplementation`, `interface ServiceImplementation { contract; invoke(procedure, payload, identity?): Promise }`, `type ServiceHandlers`, `interface HandlerContext { subject?: SubjectContext }` + +- [ ] **Step 1: Write the failing test** + +Create `packages/rpc/test/server.test.ts`: + +```ts +import { afterEach, describe, expect, test } from "bun:test"; +import { v } from "@wrnexus/validation"; +import { defineService, procedure } from "../src/contract.ts"; +import { implement } from "../src/server.ts"; +import { RPC_ERROR_CODES } from "../src/errors.ts"; +import { exportSubjectContext } from "../src/identity.ts"; +import type { Context } from "@wrnexus/core"; + +const original = { ...process.env }; +afterEach(() => { + process.env = { ...original }; +}); + +const demo = defineService({ + name: "demo", + procedures: { + add: procedure + .input(v.object({ a: v.number(), b: v.number() })) + .output<{ sum: number }>() + .build(), + guarded: procedure.output<{ ok: true }>().permission("demo:read").build(), + boom: procedure.output().build(), + }, +}); + +function impl(checkPermission?: (permission: string, subject?: unknown) => Promise) { + return implement( + demo, + { + async add({ a, b }) { + return { sum: a + b }; + }, + async guarded() { + return { ok: true as const }; + }, + async boom() { + throw new Error("db password is hunter2"); + }, + }, + { selfApp: "demo-app", checkPermission }, + ); +} + +describe("implement", () => { + test("invokes a handler with validated input", async () => { + const result = await impl().invoke("add", { a: 1, b: 2 }); + expect(result).toEqual({ ok: true, value: { sum: 3 } }); + }); + + test("coerces through the schema rather than trusting the wire", async () => { + // "3" arrives as a string over JSON from a sloppy caller; the schema coerces. + const result = await impl().invoke("add", { a: 1, b: "3" }); + expect(result).toEqual({ ok: true, value: { sum: 4 } }); + }); + + test("rejects input that fails the schema, without invoking the handler", async () => { + const result = await impl().invoke("add", { a: 1 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(RPC_ERROR_CODES.invalid); + expect(result.retryable).toBe(false); + } + }); + + test("an unknown procedure is refused", async () => { + const result = await impl().invoke("nope", {}); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.unknown); + }); + + test("a handler throw becomes an opaque failure", async () => { + const result = await impl().invoke("boom", {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(RPC_ERROR_CODES.handler); + expect(result.message).not.toContain("hunter2"); + } + }); + + test("a declared permission is ENFORCED before the handler runs", async () => { + let ran = false; + const guarded = implement( + demo, + { + async add() { + return { sum: 0 }; + }, + async guarded() { + ran = true; + return { ok: true as const }; + }, + async boom() { + throw new Error("unused"); + }, + }, + { selfApp: "demo-app", checkPermission: async () => false }, + ); + const result = await guarded.invoke("guarded", {}); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.denied); + expect(ran).toBe(false); + }); + + test("a permission check that throws denies rather than escaping", async () => { + const result = await impl(async () => { + throw new Error("store down"); + }).invoke("guarded", {}); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.denied); + }); + + test("a guarded procedure with NO permission checker configured denies", async () => { + // Fail closed: a contract that declares a permission must not run + // unguarded just because the app forgot to wire a checker. + const result = await impl(undefined).invoke("guarded", {}); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.denied); + }); + + test("a valid identity token reaches the handler as a subject", async () => { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = "web"; + const ctx = { user: { id: "u1" }, tenant: { id: "acme" }, locals: {} } as unknown as Context; + const token = await exportSubjectContext(ctx, "demo-app"); + + let seen: unknown; + const service = implement( + demo, + { + async add(_input, handlerCtx) { + seen = handlerCtx.subject; + return { sum: 0 }; + }, + async guarded() { + return { ok: true as const }; + }, + async boom() { + throw new Error("unused"); + }, + }, + { selfApp: "demo-app" }, + ); + await service.invoke("add", { a: 0, b: 0 }, token); + expect(seen).toEqual({ subjectId: "u1", tenantId: "acme", callerApp: "web" }); + }); + + test("a bad identity token is refused, not ignored", async () => { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = "web"; + const result = await impl().invoke("add", { a: 1, b: 1 }, "not.a.token"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.identity); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/rpc/test/server.test.ts` +Expected: FAIL — cannot resolve `../src/server.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/rpc/src/server.ts`: + +```ts +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 { + /** Present only when the caller supplied a valid identity token. */ + subject?: SubjectContext; +} + +export type ServiceHandlers = { + [K in keyof Procedures]: ( + input: InferProcedureInput, + ctx: HandlerContext, + ) => Promise>; +}; + +export interface ImplementOptions { + /** This app's name, used as the identity token's expected audience. */ + selfApp: string; + /** + * Resolve whether the subject holds a permission. Wire this to + * `@wrnexus/authz`. A procedure that DECLARES a permission but finds no + * checker configured is denied — never run unguarded. + */ + checkPermission?: (permission: string, subject?: SubjectContext) => Promise; +} + +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 def = contract.procedures[procedureName as keyof Procedures]; + const handler = handlers[procedureName as keyof Procedures]; + if (!def || !handler) { + return failure(RPC_ERROR_CODES.unknown, `No procedure '${contract.name}/${procedureName}'`); + } + + // 1. Identity. A token that is present but bad is refused outright — an + // invalid token must never be silently downgraded to anonymous. + let subject: SubjectContext | undefined; + if (identity !== undefined) { + try { + subject = await importSubjectContext(identity, options.selfApp); + } catch (error) { + console.error("[wrnexus:rpc] rejecting a bad identity token", error); + return failure(RPC_ERROR_CODES.identity, "Invalid identity"); + } + } + + // 2. Permission, before any input is trusted or any handler runs. + if (def.permission) { + if (!options.checkPermission) { + console.error( + `[wrnexus:rpc] '${contract.name}/${procedureName}' declares permission ` + + `'${def.permission}' but no checkPermission was configured; denying.`, + ); + return failure(RPC_ERROR_CODES.denied, "Forbidden"); + } + let allowed = false; + try { + allowed = await options.checkPermission(def.permission, subject); + } catch (error) { + console.error("[wrnexus:rpc] permission check threw; denying", error); + return failure(RPC_ERROR_CODES.denied, "Forbidden"); + } + if (!allowed) return failure(RPC_ERROR_CODES.denied, "Forbidden"); + } + + // 3. Input validation. Coerced values are what the handler receives. + let input: unknown = payload; + if (def.input) { + const parsed = def.input.parse(payload as Record); + if (!parsed.ok) return failure(RPC_ERROR_CODES.invalid, "Invalid input"); + input = parsed.value; + } + + // 4. Handler. + try { + const value = await (handler as (input: unknown, ctx: HandlerContext) => Promise)( + input, + { subject }, + ); + return success(value); + } catch (error) { + console.error(`[wrnexus:rpc] '${contract.name}/${procedureName}' threw`, error); + return failure(RPC_ERROR_CODES.handler, "Internal error"); + } + }, + }; +} +``` + +Append to `packages/rpc/src/index.ts`: + +```ts +export { implement } from "./server.ts"; +export type { + HandlerContext, + ImplementOptions, + ServiceHandlers, + ServiceImplementation, +} from "./server.ts"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/rpc/test/server.test.ts` +Expected: PASS, 10 tests + +- [ ] **Step 5: Verify the fail-closed paths discriminate** + +For each of these three, revert the guard, confirm the corresponding test FAILS, restore, and report the result: the missing-`checkPermission` denial (make it skip the check instead); the bad-token refusal (make it fall through to anonymous); and the permission-throw denial (let it propagate). These are the properties that keep a misconfigured app from running unguarded. + +- [ ] **Step 6: Commit** + +```bash +git add packages/rpc/src/server.ts packages/rpc/src/index.ts packages/rpc/test/server.test.ts +git commit -m "feat(rpc): add implement() with fail-closed identity and permission checks" +``` + +--- + +## Task 7: Client — the typed proxy + +**Files:** + +- Create: `packages/rpc/src/client.ts` +- Modify: `packages/rpc/src/index.ts` (append exports) +- Test: `packages/rpc/test/client.test.ts` + +**Interfaces:** + +- Consumes: `ServiceContract`, `InferProcedureInput`, `InferProcedureOutput` from `./types.ts`; `Transport`, `CallOptions` from `./transport.ts`; `ServiceError`, `RPC_ERROR_CODES` from `./errors.ts`; `exportSubjectContext` from `./identity.ts` +- Produces: `serviceClient(contract, options): ServiceClient`, `interface ServiceClientOptions { app?: string; transport: Transport; as?: Context; timeoutMs?: number }` + +- [ ] **Step 1: Write the failing test** + +Create `packages/rpc/test/client.test.ts`: + +```ts +import { afterEach, describe, expect, test } from "bun:test"; +import { v } from "@wrnexus/validation"; +import type { Context } from "@wrnexus/core"; +import { defineService, procedure } from "../src/contract.ts"; +import { serviceClient } from "../src/client.ts"; +import { inProcessTransport } from "../src/transport.ts"; +import { RPC_ERROR_CODES, failure, success } from "../src/errors.ts"; + +const original = { ...process.env }; +afterEach(() => { + process.env = { ...original }; +}); + +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, { + app: "billing", + transport: inProcessTransport({ + "billing/createInvoice": async () => success({ invoiceId: "inv_1" }), + }), + }); + const invoice = await client.createInvoice({ amountCents: 100 }); + expect(invoice).toEqual({ invoiceId: "inv_1" }); + }); + + test("throws a ServiceError carrying the code on failure", async () => { + const client = serviceClient(billing, { + app: "billing", + transport: inProcessTransport({ + "billing/createInvoice": async () => failure(RPC_ERROR_CODES.denied, "Forbidden"), + }), + }); + await expect(client.createInvoice({ amountCents: 1 })).rejects.toThrow(/Forbidden/); + try { + await client.createInvoice({ amountCents: 1 }); + } catch (error) { + expect((error as { code: string }).code).toBe(RPC_ERROR_CODES.denied); + expect((error as { retryable: boolean }).retryable).toBe(false); + } + }); + + test("attaches an identity token when given a context", async () => { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = "web"; + let seen: string | undefined; + const client = serviceClient(billing, { + app: "billing", + as: { user: { id: "u1" }, locals: {} } as unknown as Context, + transport: inProcessTransport({ + "billing/createInvoice": async (_payload, identity) => { + seen = identity; + return success({ invoiceId: "inv_1" }); + }, + }), + }); + await client.createInvoice({ amountCents: 1 }); + expect(typeof seen).toBe("string"); + expect(seen!.split(".")).toHaveLength(3); + }); + + test("sends no identity for an anonymous context", async () => { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = "web"; + let seen: string | undefined = "sentinel"; + const client = serviceClient(billing, { + app: "billing", + as: { user: null, locals: {} } as unknown as Context, + transport: inProcessTransport({ + "billing/createInvoice": async (_payload, identity) => { + seen = identity; + return success({ invoiceId: "inv_1" }); + }, + }), + }); + await client.createInvoice({ amountCents: 1 }); + expect(seen).toBeUndefined(); + }); + + test("calling an undeclared procedure throws rather than silently calling", async () => { + const client = serviceClient(billing, { + app: "billing", + transport: inProcessTransport({}), + }); + await expect( + (client as unknown as Record Promise>).nope!(), + ).rejects.toThrow(/not declared/i); + }); + + test("the app defaults to the service name when not given", async () => { + let seenApp: string | undefined; + const client = serviceClient(billing, { + transport: { + async call(target) { + seenApp = target.app; + return success({ invoiceId: "x" }); + }, + }, + }); + await client.createInvoice({ amountCents: 1 }); + expect(seenApp).toBe("billing"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/rpc/test/client.test.ts` +Expected: FAIL — cannot resolve `../src/client.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/rpc/src/client.ts`: + +```ts +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 { + /** Workspace app hosting the service. Defaults to the service name. */ + app?: string; + transport: Transport; + /** Request context whose subject is carried to the callee. */ + as?: Context; + timeoutMs?: number; +} + +export type ServiceClient = { + [K in keyof Procedures]: ( + input: InferProcedureInput, + ) => Promise>; +}; + +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * A typed proxy over a contract. Each property is a procedure; the property + * name is checked against the contract, so a typo throws rather than issuing + * a call to a path the callee does not serve. + */ +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.prototype.hasOwnProperty.call(contract.procedures, property)) { + throw new ServiceError( + RPC_ERROR_CODES.unknown, + `'${property}' is not declared on service '${contract.name}'.`, + ); + } + 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 }, + ); + if (result.ok) return result.value; + throw new ServiceError(result.code, result.message); + } finally { + clearTimeout(timer); + } + }; + }, + }); +} +``` + +Append to `packages/rpc/src/index.ts`: + +```ts +export { serviceClient } from "./client.ts"; +export type { ServiceClient, ServiceClientOptions } from "./client.ts"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/rpc/test/client.test.ts && bun run typecheck` +Expected: PASS, 6 tests; typecheck clean + +- [ ] **Step 5: Commit** + +```bash +git add packages/rpc/src/client.ts packages/rpc/src/index.ts packages/rpc/test/client.test.ts +git commit -m "feat(rpc): add the typed service client proxy" +``` + +--- + +## Task 8: HTTP transport + +**Files:** + +- Create: `packages/rpc/src/http.ts` +- Modify: `packages/rpc/src/index.ts` (append exports) +- Test: `packages/rpc/test/http.test.ts` + +**Interfaces:** + +- Consumes: `Transport`, `RpcTarget`, `CallOptions` from `./transport.ts`; `RPC_ERROR_CODES`, `failure`, `isRetryableStatus` from `./errors.ts`; `RPC_IDENTITY_HEADER` from `./identity.ts` +- Produces: `httpTransport(options?): Transport`, `interface HttpTransportOptions { resolveOrigin?: (app: string) => string; fetch?: typeof fetch }`, `rpcPath(service, procedure): string` + +- [ ] **Step 1: Write the failing test** + +Create `packages/rpc/test/http.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { httpTransport, rpcPath } from "../src/http.ts"; +import { RPC_ERROR_CODES } from "../src/errors.ts"; +import { RPC_IDENTITY_HEADER } from "../src/identity.ts"; + +const target = { app: "billing", service: "billing", procedure: "createInvoice" }; + +function transportWith(handler: (request: Request) => Promise | Response) { + return httpTransport({ + resolveOrigin: () => "http://billing.test", + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => + handler(new Request(input as string, init))) as typeof fetch, + }); +} + +describe("httpTransport", () => { + test("posts to the reserved rpc path and returns the value", async () => { + let seenUrl = ""; + const transport = transportWith(async (request) => { + seenUrl = request.url; + expect(request.method).toBe("POST"); + expect(await request.json()).toEqual({ amountCents: 5 }); + return Response.json({ ok: true, value: { invoiceId: "inv_1" } }); + }); + const result = await transport.call(target, { amountCents: 5 }, {}); + expect(seenUrl).toBe("http://billing.test/__wrnexus/rpc/billing/createInvoice"); + expect(result).toEqual({ ok: true, value: { invoiceId: "inv_1" } }); + }); + + test("sends the identity token in its header when present, and omits it otherwise", async () => { + let seen: string | null = "sentinel"; + const transport = transportWith(async (request) => { + seen = request.headers.get(RPC_IDENTITY_HEADER); + return Response.json({ ok: true, value: null }); + }); + await transport.call(target, {}, { identity: "token-abc" }); + expect(seen).toBe("token-abc"); + await transport.call(target, {}, {}); + expect(seen).toBeNull(); + }); + + test("a 5xx is a retryable transport failure", async () => { + const transport = transportWith(() => new Response("boom", { status: 503 })); + const result = await transport.call(target, {}, {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(RPC_ERROR_CODES.transport); + expect(result.retryable).toBe(true); + } + }); + + test("a 4xx is NOT retryable", async () => { + const transport = transportWith(() => new Response("nope", { status: 403 })); + const result = await transport.call(target, {}, {}); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.retryable).toBe(false); + }); + + test("a network throw becomes a retryable transport failure, not a rejection", async () => { + const transport = transportWith(() => { + throw new TypeError("connect ECONNREFUSED 10.0.0.7:443"); + }); + const result = await transport.call(target, {}, {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.retryable).toBe(true); + // The remote address must not cross back to the caller. + expect(result.message).not.toContain("10.0.0.7"); + } + }); + + test("a malformed body is a non-retryable failure, not a crash", async () => { + const transport = transportWith(() => new Response("not json", { status: 200 })); + const result = await transport.call(target, {}, {}); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.retryable).toBe(false); + }); + + test("rpcPath is stable and needs no escaping", () => { + expect(rpcPath("billing", "createInvoice")).toBe("/__wrnexus/rpc/billing/createInvoice"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/rpc/test/http.test.ts` +Expected: FAIL — cannot resolve `../src/http.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/rpc/src/http.ts`: + +```ts +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"; + +/** Reserved prefix. Must never be reachable from the public internet. */ +export const RPC_PATH_PREFIX = "/__wrnexus/rpc"; + +export function rpcPath(service: string, procedure: string): string { + return `${RPC_PATH_PREFIX}/${service}/${procedure}`; +} + +export interface HttpTransportOptions { + /** Map an app name to its origin. Defaults to the workspace origin map. */ + resolveOrigin?: (app: string) => string; + fetch?: typeof fetch; +} + +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" }; + 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 (error) { + // A connection error's message names hosts and ports; keep it server-side. + console.error("[wrnexus:rpc] transport error", error); + 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 { + 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, + }; + } + }, + }; +} +``` + +Add `"@wrnexus/helpers": "workspace:*"` to `packages/rpc/package.json` dependencies. + +Append to `packages/rpc/src/index.ts`: + +```ts +export { RPC_PATH_PREFIX, httpTransport, rpcPath } from "./http.ts"; +export type { HttpTransportOptions } from "./http.ts"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/rpc/test/http.test.ts && bun run check:workspace` +Expected: PASS, 7 tests; workspace check clean + +- [ ] **Step 5: Commit** + +```bash +git add packages/rpc/src/http.ts packages/rpc/src/index.ts packages/rpc/package.json packages/rpc/test/http.test.ts bun.lock +git commit -m "feat(rpc): add the HTTP transport" +``` + +--- + +## Task 9: Router discovery of `app/services` + +**Files:** + +- Modify: `packages/router/src/index.ts` (mirror the `authz` scan added earlier) +- Modify: `packages/router/package.json` (no change expected — verify) +- Test: `packages/router/test/services-discovery.test.ts` + +**Interfaces:** + +- Consumes: `scanDir`, `isSafeIslandName`, `ComponentRef` already in `packages/router/src/index.ts` +- Produces: `Router.services: ComponentRef[]` + +- [ ] **Step 1: Write the failing test** + +Create `packages/router/test/services-discovery.test.ts`: + +```ts +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 }); + +function appWith(files: Record): string { + const base = mkdtempSync(join(root, "app-")); + const dir = join(base, "app", "services"); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(base, "app", "pages"), { recursive: true }); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, "utf8"); + return join(base, "app"); +} + +describe("app/services discovery", () => { + test("collects .ts and .js implementations by filename", () => { + const appDir = appWith({ + "billing.ts": "export default {};", + "reports.js": "export default {};", + }); + expect( + buildRouter(appDir) + .services.map((s) => s.name) + .sort(), + ).toEqual(["billing", "reports"]); + }); + + test("skips generated files quietly", () => { + const appDir = appWith({ + "billing.ts": "export default {};", + "types.gen.ts": "export type X = 1;", + }); + expect(buildRouter(appDir).services.map((s) => s.name)).toEqual(["billing"]); + }); + + test("skips unsafe names", () => { + const appDir = appWith({ "ok.ts": "export default {};", "bad name!.ts": "export default {};" }); + expect(buildRouter(appDir).services.map((s) => s.name)).toEqual(["ok"]); + }); + + test("an app with no services directory yields an empty list", () => { + const base = mkdtempSync(join(root, "empty-")); + mkdirSync(join(base, "app", "pages"), { recursive: true }); + expect(buildRouter(join(base, "app")).services).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/router/test/services-discovery.test.ts` +Expected: FAIL — `router.services` is undefined + +- [ ] **Step 3: Modify `packages/router/src/index.ts`** + +Add to the `Router` interface, immediately after the `authz` field: + +```ts + /** Service implementations (`app/services/.ts`) mounted for inter-app calls. */ + services: ComponentRef[]; +``` + +Add the scan immediately after the `authz` scan block, mirroring it exactly: + +```ts +// Service implementations: app/services/.{ts,js}, each default-exporting +// an implement() result. Mounted under /__wrnexus/rpc. +const services: ComponentRef[] = []; +for (const f of scanDir(join(appDir, "services"), [".js"])) { + if (!/\.(ts|js)$/.test(f.file)) continue; + if (/[.]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 }); +} +``` + +Add `services,` to the returned object, next to `authz,`. + +Update every other site that constructs a `Router` object literal — `packages/dev-server/src/prod.ts` and `packages/dev-server/test/observability-runtime.test.ts` both needed this for `authz` and will need it again. `bun run typecheck` finds them; report what you found. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/router && bun run typecheck` +Expected: PASS; typecheck clean + +- [ ] **Step 5: Commit** + +```bash +git add packages/router/src/index.ts packages/router/test/services-discovery.test.ts packages/dev-server/src/prod.ts packages/dev-server/test/observability-runtime.test.ts +git commit -m "feat(router): discover app/services implementations" +``` + +--- + +## Task 10: Mount the RPC endpoint and block external access + +**This is the highest-risk task in the plan.** If `/__wrnexus/rpc/*` is reachable from the public internet, every permission check in the workspace is bypassable by anyone who can forge or replay a token. + +**Files:** + +- Create: `packages/dev-server/src/rpc-dispatch.ts` +- Modify: `packages/dev-server/src/runtime.ts` (dispatch the reserved path) +- Modify: `packages/dev-server/src/gateway.ts` (reject the prefix from outside) +- Test: `packages/dev-server/test/rpc-endpoint.test.ts` + +**Interfaces:** + +- Consumes: `RPC_PATH_PREFIX`, `RPC_IDENTITY_HEADER` from `@wrnexus/rpc`; `ServiceImplementation` from `@wrnexus/rpc` +- Produces: `handleRpcRequest(req, url, services): Promise`, `isRpcPath(pathname): boolean`, `isInternalCaller(req): boolean` + +- [ ] **Step 1: Write the failing test** + +Create `packages/dev-server/test/rpc-endpoint.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { handleRpcRequest, isInternalCaller, isRpcPath } from "../src/rpc-dispatch.ts"; +import { defineService, implement, procedure } from "@wrnexus/rpc"; +import { v } from "@wrnexus/validation"; + +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, + { + async add({ a }) { + return { a }; + }, + }, + { selfApp: "demo-app" }, + ), + ], +]); + +function post(path: string, body: unknown, headers: Record = {}) { + return new Request(`http://demo.test${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +describe("rpc endpoint", () => { + test("isRpcPath matches only the reserved prefix", () => { + expect(isRpcPath("/__wrnexus/rpc/demo/add")).toBe(true); + expect(isRpcPath("/__wrnexus/rpcx/demo/add")).toBe(false); + expect(isRpcPath("/api/rpc/demo/add")).toBe(false); + expect(isRpcPath("/")).toBe(false); + }); + + test("dispatches to the implementation", async () => { + const req = post("/__wrnexus/rpc/demo/add", { a: 2 }, { "x-wrnexus-internal": "1" }); + const res = await handleRpcRequest(req, new URL(req.url), services); + expect(res).not.toBeNull(); + expect(await res!.json()).toEqual({ ok: true, value: { a: 2 } }); + }); + + test("REJECTS a request that is not from inside the workspace", async () => { + // No internal marker: this is the public-internet case. + const req = post("/__wrnexus/rpc/demo/add", { a: 2 }); + const res = await handleRpcRequest(req, new URL(req.url), services); + expect(res!.status).toBe(404); + // 404, not 403: do not confirm the endpoint exists to an outside prober. + expect(await res!.text()).not.toContain("rpc"); + }); + + test("rejects a forwarded request even with a well-formed body", async () => { + // X-Forwarded-For present means it traversed the public edge. + const req = post( + "/__wrnexus/rpc/demo/add", + { a: 2 }, + { + "x-wrnexus-internal": "1", + "x-forwarded-for": "203.0.113.9", + }, + ); + const res = await handleRpcRequest(req, new URL(req.url), services); + expect(res!.status).toBe(404); + }); + + test("an unknown service is refused without disclosing which exist", async () => { + const req = post("/__wrnexus/rpc/ghost/add", {}, { "x-wrnexus-internal": "1" }); + const res = await handleRpcRequest(req, new URL(req.url), services); + const body = (await res!.json()) as { ok: boolean; code: string }; + expect(body.ok).toBe(false); + expect(body.code).toBe("RPC_UNKNOWN"); + }); + + test("a non-POST method is refused", async () => { + const req = new Request("http://demo.test/__wrnexus/rpc/demo/add", { + method: "GET", + headers: { "x-wrnexus-internal": "1" }, + }); + const res = await handleRpcRequest(req, new URL(req.url), services); + expect(res!.status).toBe(405); + }); + + test("a malformed body is refused without crashing", async () => { + const req = new Request("http://demo.test/__wrnexus/rpc/demo/add", { + method: "POST", + headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, + body: "{not json", + }); + const res = await handleRpcRequest(req, new URL(req.url), services); + const body = (await res!.json()) as { ok: boolean; code: string }; + expect(body.ok).toBe(false); + expect(body.code).toBe("RPC_INVALID"); + }); + + test("a non-rpc path is not handled here", async () => { + const req = post("/api/hello", {}); + expect(await handleRpcRequest(req, new URL(req.url), services)).toBeNull(); + }); + + test("isInternalCaller requires the marker and no forwarded headers", () => { + const internal = new Request("http://x/", { headers: { "x-wrnexus-internal": "1" } }); + const forwarded = new Request("http://x/", { + headers: { "x-wrnexus-internal": "1", "x-forwarded-host": "evil.test" }, + }); + const bare = new Request("http://x/"); + expect(isInternalCaller(internal)).toBe(true); + expect(isInternalCaller(forwarded)).toBe(false); + expect(isInternalCaller(bare)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/dev-server/test/rpc-endpoint.test.ts` +Expected: FAIL — cannot resolve `../src/rpc-dispatch.ts` + +- [ ] **Step 3: Write the dispatcher** + +Create `packages/dev-server/src/rpc-dispatch.ts`: + +```ts +import { RPC_IDENTITY_HEADER, RPC_PATH_PREFIX, type ServiceImplementation } from "@wrnexus/rpc"; + +/** Marker the workspace's own transport sets; the gateway strips it inbound. */ +export const RPC_INTERNAL_HEADER = "x-wrnexus-internal"; + +/** + * Headers a public edge adds. Their presence means the request traversed the + * internet, so it is not an internal call however it is labelled. + */ +const EDGE_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded"]; + +export function isRpcPath(pathname: string): boolean { + return pathname === RPC_PATH_PREFIX || pathname.startsWith(`${RPC_PATH_PREFIX}/`); +} + +/** + * Second line of defence. The gateway blocks this prefix from outside, but a + * misconfigured deployment, a direct-to-app route, or a future adapter could + * bypass it — so the app verifies too. Either guard alone is a single point + * of failure for every permission check in the workspace. + */ +export function isInternalCaller(req: Request): boolean { + if (req.headers.get(RPC_INTERNAL_HEADER) !== "1") return false; + return !EDGE_HEADERS.some((name) => req.headers.has(name)); +} + +function json(body: unknown, status = 200): Response { + return Response.json(body, { + status, + headers: { "cache-control": "private, no-store" }, + }); +} + +/** + * Handle a call on the reserved prefix. Returns null when the path is not + * ours, so the caller can continue normal routing. + */ +export async function handleRpcRequest( + req: Request, + url: URL, + services: Map, +): Promise { + if (!isRpcPath(url.pathname)) return null; + + // Answer an outside prober exactly as a non-existent route would. + if (!isInternalCaller(req)) { + return new Response("Not found", { status: 404 }); + } + if (req.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + const [, , , serviceName, procedureName] = url.pathname.split("/"); + const service = serviceName ? services.get(serviceName) : undefined; + if (!service || !procedureName) { + 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 }); + } + + const identity = req.headers.get(RPC_IDENTITY_HEADER) ?? undefined; + return json(await service.invoke(procedureName, payload, identity)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/dev-server/test/rpc-endpoint.test.ts` +Expected: PASS, 9 tests + +- [ ] **Step 5: Wire the dispatcher into the request pipeline** + +In `packages/dev-server/src/runtime.ts`, inside the main `fetch` handler, call `handleRpcRequest` BEFORE normal page/API routing and return its result when non-null. Build the `Map` from `router.services` at boot, importing each module's default export. Follow the pattern `loadAppAuthzCatalog` uses for importing discovered modules, including routing hot-reload re-imports through `loadModule`. + +Have the HTTP transport set the internal marker: in `packages/rpc/src/http.ts`, add `headers["x-wrnexus-internal"] = "1"` alongside the content-type. + +- [ ] **Step 6: Block the prefix at the gateway** + +In `packages/dev-server/src/gateway.ts`, in the main fetch handler before any proxying, return a 404 for a request whose pathname is on the RPC prefix, and strip any inbound `x-wrnexus-internal` header so an outside caller cannot set it themselves. Add a test in `packages/dev-server/test/gateway.test.ts` asserting both: the prefix 404s at the gateway, and a request arriving with `x-wrnexus-internal: 1` reaches the app without it. + +- [ ] **Step 7: Verify both guards discriminate** + +Independently: (a) remove the gateway block, confirm the gateway test fails; (b) remove the `isInternalCaller` check, confirm the "REJECTS a request that is not from inside" test fails. Restore both. Report each result. Two independent guards are only worth having if each is verified to work alone. + +- [ ] **Step 8: Commit** + +```bash +git add packages/dev-server/src/rpc-dispatch.ts packages/dev-server/src/runtime.ts packages/dev-server/src/gateway.ts packages/rpc/src/http.ts packages/dev-server/test/rpc-endpoint.test.ts packages/dev-server/test/gateway.test.ts +git commit -m "feat(dev-server): mount the rpc endpoint and block it from outside" +``` + +--- + +## Task 11: Example, docs, and end-to-end integration + +**Files:** + +- Create: `packages/rpc/test/integration.test.ts` +- Create: `packages/rpc/README.md` +- Create: `examples/auth-showcase/app/services/greeter.ts` +- Modify: `docs/public-api-0.8.json` (regenerated) + +**Interfaces:** + +- Consumes: the full surface from Tasks 1-10 + +- [ ] **Step 1: Write the end-to-end integration test** + +Create `packages/rpc/test/integration.test.ts`: + +```ts +import { afterEach, describe, expect, test } from "bun:test"; +import { v } from "@wrnexus/validation"; +import type { Context } from "@wrnexus/core"; +import { defineService, procedure } from "../src/contract.ts"; +import { implement } from "../src/server.ts"; +import { serviceClient } from "../src/client.ts"; +import { inProcessTransport } from "../src/transport.ts"; +import { RPC_ERROR_CODES } from "../src/errors.ts"; + +const original = { ...process.env }; +afterEach(() => { + process.env = { ...original }; +}); + +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, + { + async createInvoice({ amountCents }, ctx) { + return { 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("end to end", () => { + test("identity flows from caller to callee and the handler sees the subject", 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("the callee's permission check refuses even though the caller allowed it", 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), + }); + try { + await client.createInvoice({ amountCents: 1 }); + throw new Error("should not reach here"); + } catch (error) { + expect((error as { code: string }).code).toBe(RPC_ERROR_CODES.denied); + } + }); + + test("invalid input never reaches the handler", async () => { + const client = serviceClient(billing, { app: "billing", transport: wire(true) }); + try { + await client.createInvoice({ amountCents: "abc" } as never); + throw new Error("should not reach here"); + } catch (error) { + expect((error as { code: string }).code).toBe(RPC_ERROR_CODES.invalid); + } + }); +}); +``` + +- [ ] **Step 2: Run it** + +Run: `bun test packages/rpc/test/integration.test.ts` +Expected: PASS, 3 tests. Any failure is a real integration defect — fix the module, not the test. + +- [ ] **Step 3: Add the example service** + +Create `examples/auth-showcase/app/services/greeter.ts`: + +```ts +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, + { + async greet({ name }, ctx) { + return { message: `Hello, ${name}`, subject: ctx.subject?.subjectId ?? "anonymous" }; + }, + }, + { selfApp: "auth-showcase" }, +); +``` + +Add `"@wrnexus/rpc": "workspace:*"` to `examples/auth-showcase/package.json` dependencies. + +- [ ] **Step 4: Write the README** + +Create `packages/rpc/README.md` covering: defining a contract in the shared package; implementing it in `app/services`; calling it with `serviceClient(contract, { as: ctx })`; that the token carries subject and tenant only and the callee always runs its own permission check; that only `.idempotent()` procedures are ever retried; and that `WRNEXUS_RPC_SECRET` must be set and must differ from the session secret. + +**Every code sample must actually run.** Extract each one and execute it, or write it as a test. Report in your notes how each was verified. + +- [ ] **Step 5: Regenerate the API baseline and run the full gate** + +Run: `bun run generate:public-api && bun run check:production` +Expected: baseline regenerated; `check:production` exit 0 + +- [ ] **Step 6: Commit** + +```bash +git add packages/rpc/test/integration.test.ts packages/rpc/README.md examples/auth-showcase docs/public-api-0.8.json bun.lock +git commit -m "test(rpc): end-to-end coverage, example service, and docs" +``` + +--- + +## Deferred phases + +Not in this plan. Each needs its own design pass. + +### Phase 2 — failure handling + +Timeouts are in the client already. Retry with backoff for `.idempotent()` procedures only, and a circuit breaker per target app. The retry policy belongs in the transport, not the client, so every transport inherits it. + +### Phase 3 — app-to-app streaming + +Chunked HTTP or SSE, exposed as an async iterable. `@wrnexus/realtime` covers browser↔server and is not a substitute. This is the largest deferred piece. + +### Phase 4 — identity for pubsub and queue, and the unified guide + +Carry the same subject-context token through `@wrnexus/pubsub` events and `@wrnexus/queue` jobs, so a job runs as the user who enqueued it. Then one document explaining which of the four shapes to reach for.