91 KiB
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 implements 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 version0.8.4. Do not change versions. - Zero runtime npm dependencies. Bun, WebCrypto, and
node:builtins only. @wrnexus/coreMUST NOT import@wrnexus/rpc.@wrnexus/rpcmay import types only from@wrnexus/core.- Any
@wrnexus/*package imported anywhere under a package'ssrc/MUST be declared in that package'spackage.jsondependencies. 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
subandtenantonly. 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.tsand useimport { describe, expect, test } from "bun:test". bun teststrips type-only imports before resolution, so a "verify it fails" step does NOT reproduce for a test whose only import from the new module isimport 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 hascore.autocrlf=true; a stash round-trip rewrites files to CRLF and failsformat: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
.tsextensions. - Full gate:
bun run check:productionmust 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 apathsentry) - Test:
packages/rpc/test/types.test.ts
Interfaces:
-
Consumes:
ObjectSchemafrom@wrnexus/validation -
Produces:
ProcedureDef,ServiceContract,InferInput<S>,InferOutput<P>,ServiceResult<T> -
Step 1: Write the failing test
Create packages/rpc/test/types.test.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:
{
"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:
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:
/**
* @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:
"@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
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:
ServiceResultfrom./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:
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:
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:
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
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,InferInputfrom./types.ts -
Produces:
defineService(def): ServiceContract,procedure(aProcedureBuilder<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:
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:
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:
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
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,JwtErrorfrom@wrnexus/jwt;Contexttype 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:
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:
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:
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
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:
ServiceResultfrom./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:
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:
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:
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
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:
ServiceContractfrom./types.ts;RPC_ERROR_CODES,failure,success,ServiceErrorfrom./errors.ts;importSubjectContext,SubjectContextfrom./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:
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:
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:
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
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,InferProcedureOutputfrom./types.ts;Transport,CallOptionsfrom./transport.ts;ServiceError,RPC_ERROR_CODESfrom./errors.ts;exportSubjectContextfrom./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:
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:
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:
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
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,CallOptionsfrom./transport.ts;RPC_ERROR_CODES,failure,isRetryableStatusfrom./errors.ts;RPC_IDENTITY_HEADERfrom./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:
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:
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:
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
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 theauthzscan added earlier) - Modify:
packages/router/package.json(no change expected — verify) - Test:
packages/router/test/services-discovery.test.ts
Interfaces:
-
Consumes:
scanDir,isSafeIslandName,ComponentRefalready inpackages/router/src/index.ts -
Produces:
Router.services: ComponentRef[] -
Step 1: Write the failing test
Create packages/router/test/services-discovery.test.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:
/** 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:
// 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
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_HEADERfrom@wrnexus/rpc;ServiceImplementationfrom@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:
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:
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
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:
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:
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
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.