# 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). **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. - 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"`. - `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. - 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", () => { // 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 }; 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. * (No eslint-disable needed — `no-explicit-any` is off repo-wide, and a * redundant directive is itself a lint warning.) */ 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, 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); }); 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", /** * 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]; /** Only a transport failure is worth retrying; everything else is final. */ function retryableFor(code: string): boolean { return code === RPC_ERROR_CODES.transport; } /** * 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 { if (status === 408 || status === 429) return true; return status >= 500 && status <= 599; } 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 wrn 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("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(); 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> { // 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 { 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".`, ); } } // 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, }); } ``` 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 ImportOptions { maxAgeSeconds?: number }`, `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("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; 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; /** 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 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; } 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. * * 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).", ); } // 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. // 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).", ); } return signJwt( { sub: rawId, ...(rawTenant ? { tenant: rawTenant as string } : {}) }, 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, options: ImportOptions = {}, ): Promise { // 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; iat?: number; aud?: unknown; }>(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."); } // 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."); } return { subjectId: claims.sub, tenantId: claims.tenant as string | 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, ImportOptions, 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 wrn", 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 wrn 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. WrNexus 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) { // 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}'`); } // 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) { // 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, 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. Use failure() // so retryability is DERIVED from the code, never hand-set beside it. return failure(RPC_ERROR_CODES.malformed, "Malformed service response"); } }, }; } ``` 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("/"); // 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 }); } 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: WrNexus 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 wrn(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: wrn(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: wrn(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: wrn(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.