Files
WRNexusJS/docs/plans/2026-08-05-inter-app-comms-implementation.md
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

2544 lines
91 KiB
Markdown

# 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/<pkg>/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<S>`, `InferOutput<P>`, `ServiceResult<T>`
- [ ] **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<typeof _schema> = { 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> = S extends ObjectSchema<infer T> ? 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<string, unknown>): {
ok: boolean;
value: unknown;
errors: Record<string, string>;
};
}
export interface ProcedureDef<Input = unknown, Output = unknown> {
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<string, ProcedureDef<any, any>>;
export interface ServiceContract<Procedures extends AnyProcedures = AnyProcedures> {
/** Stable service id, used in the mounted path. */
name: string;
procedures: Procedures;
}
export type InferProcedureInput<P> = P extends ProcedureDef<infer I, unknown> ? I : never;
export type InferProcedureOutput<P> = P extends ProcedureDef<unknown, infer O> ? O : never;
/** What a transport returns: either a value or a structured failure. */
export type ServiceResult<T = unknown> =
{ 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<never>`, `success<T>(value: T): ServiceResult<T>`
- [ ] **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<T>(value: T): ServiceResult<T> {
return { ok: true, value };
}
export function failure(code: string, message: string): ServiceResult<never> {
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<never> {
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<void, void>`), `ProcedureBuilder<I, O>` with `.input(schema)`, `.output<T>()`, `.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<Input, Output> {
private constructor(private readonly def: ProcedureDef<Input, Output>) {}
static create(): ProcedureBuilder<void, void> {
return new ProcedureBuilder<void, void>({});
}
input<S extends ObjectSchema<object>>(schema: S): ProcedureBuilder<InferInput<S>, Output> {
// The cast is required for the same reason .output<T>() needs one: the
// phantom __input/__output markers make ProcedureDef invariant, so
// spreading a ProcedureDef<Input, Output> into a ProcedureDef<InferInput<S>,
// Output> is not assignable without it. No runtime effect.
return new ProcedureBuilder<InferInput<S>, Output>({
...this.def,
input: schema,
} as ProcedureDef<InferInput<S>, Output>);
}
output<T>(): ProcedureBuilder<Input, T> {
return new ProcedureBuilder<Input, T>({ ...this.def } as ProcedureDef<Input, T>);
}
permission(id: string): ProcedureBuilder<Input, Output> {
return new ProcedureBuilder<Input, Output>({ ...this.def, permission: id });
}
/** Mark safe to retry. Anything not marked is never retried. */
idempotent(): ProcedureBuilder<Input, Output> {
return new ProcedureBuilder<Input, Output>({ ...this.def, idempotent: true });
}
build(): ProcedureDef<Input, Output> {
return Object.freeze({ ...this.def });
}
}
export const procedure = ProcedureBuilder.create();
export function defineService<Procedures extends AnyProcedures>(def: {
name: string;
procedures: Procedures;
}): ServiceContract<Procedures> {
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<string, ProcedureDef> = {};
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<string | undefined>`, `importSubjectContext(token, selfApp, options?): Promise<SubjectContext>`, `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<string | undefined> {
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<SubjectContext> {
// 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<ServiceResult> }`, `inProcessTransport(handlers): Transport`, `type InProcessHandler = (payload: unknown, identity?: string) => Promise<ServiceResult>`
- [ ] **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<ServiceResult>;
}
export type InProcessHandler = (
payload: unknown,
identity?: string,
) => Promise<ServiceResult> | 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 `"<service>/<procedure>"`.
*/
export function inProcessTransport(handlers: Record<string, InProcessHandler>): 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<ServiceResult> }`, `type ServiceHandlers<C>`, `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<never>().build(),
},
});
function impl(checkPermission?: (permission: string, subject?: unknown) => Promise<boolean>) {
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<Procedures extends AnyProcedures> = {
[K in keyof Procedures]: (
input: InferProcedureInput<Procedures[K]>,
ctx: HandlerContext,
) => Promise<InferProcedureOutput<Procedures[K]>>;
};
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<boolean>;
}
export interface ServiceImplementation<Procedures extends AnyProcedures = AnyProcedures> {
contract: ServiceContract<Procedures>;
invoke(procedure: string, payload: unknown, identity?: string): Promise<ServiceResult>;
}
export function implement<Procedures extends AnyProcedures>(
contract: ServiceContract<Procedures>,
handlers: ServiceHandlers<Procedures>,
options: ImplementOptions,
): ServiceImplementation<Procedures> {
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<string, unknown>);
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<unknown>)(
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<Procedures>`, `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<string, () => Promise<unknown>>).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<Procedures extends AnyProcedures> = {
[K in keyof Procedures]: (
input: InferProcedureInput<Procedures[K]>,
) => Promise<InferProcedureOutput<Procedures[K]>>;
};
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<Procedures extends AnyProcedures>(
contract: ServiceContract<Procedures>,
options: ServiceClientOptions,
): ServiceClient<Procedures> {
const app = options.app ?? contract.name;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Proxy({} as ServiceClient<Procedures>, {
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> | 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("<html>not json</html>", { 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<string, string> = { "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, string>): 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/<name>.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/<name>.{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<Response | null>`, `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<string, string> = {}) {
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<string, ServiceImplementation>,
): Promise<Response | null> {
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<string, ServiceImplementation>` 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.