Files
WRNexusJS/docs/plans/2026-08-05-inter-app-comms-implementation.md
T
ClintchizandClaude Opus 5 fcf4ed3039 docs: close a prototype-chain authorization bypass in the plan
server.ts looked procedures up with plain property indexing, so every
Object.prototype member resolved as truthy. A prototype member carries no
`permission`, so the permission gate was skipped entirely.

Verified: with a contract whose only procedure declares a permission and a
checkPermission that always denies, invoke("add") correctly returns
RPC_DENIED, while invoke("constructor") returns {"ok":true,"value":{"a":2}}
and the gate never runs.

Reachable over the wire as POST /__wrnexus/rpc/<service>/constructor by
anything that clears the internal-caller check - i.e. any workspace app.

Fixed at both layers: Object.hasOwn for the procedure and handler lookups,
and a character-class guard on the path segments before they are used as
lookup keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:42:49 +05:30

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 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:

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: 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:

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 wire result. The message is replaced unless explicitly
   * exposed: a handler's error text routinely names tables, hosts, or
   * credentials, and this value crosses an app boundary.
   */
  toResult(options: ToResultOptions = {}): ServiceResult<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, 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:

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, 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:

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: 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:

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: 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:

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 wire", async () => {
    // "3" arrives as a string over JSON from a sloppy caller; the schema coerces.
    const result = await impl().invoke("add", { a: 1, b: "3" });
    expect(result).toEqual({ ok: true, value: { sum: 4 } });
  });

  test("rejects input that fails the schema, without invoking the handler", async () => {
    const result = await impl().invoke("add", { a: 1 });
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.code).toBe(RPC_ERROR_CODES.invalid);
      expect(result.retryable).toBe(false);
    }
  });

  test("an unknown procedure is refused", async () => {
    const result = await impl().invoke("nope", {});
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.unknown);
  });

  test("a handler throw becomes an opaque failure", async () => {
    const result = await impl().invoke("boom", {});
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.code).toBe(RPC_ERROR_CODES.handler);
      expect(result.message).not.toContain("hunter2");
    }
  });

  test("a declared permission is ENFORCED before the handler runs", async () => {
    let ran = false;
    const guarded = implement(
      demo,
      {
        async add() {
          return { sum: 0 };
        },
        async guarded() {
          ran = true;
          return { ok: true as const };
        },
        async boom() {
          throw new Error("unused");
        },
      },
      { selfApp: "demo-app", checkPermission: async () => false },
    );
    const result = await guarded.invoke("guarded", {});
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.denied);
    expect(ran).toBe(false);
  });

  test("a permission check that throws denies rather than escaping", async () => {
    const result = await impl(async () => {
      throw new Error("store down");
    }).invoke("guarded", {});
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.denied);
  });

  test("a guarded procedure with NO permission checker configured denies", async () => {
    // Fail closed: a contract that declares a permission must not run
    // unguarded just because the app forgot to wire a checker.
    const result = await impl(undefined).invoke("guarded", {});
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.denied);
  });

  test("a valid identity token reaches the handler as a subject", async () => {
    process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
    process.env.WRNEXUS_APP_NAME = "web";
    const ctx = { user: { id: "u1" }, tenant: { id: "acme" }, locals: {} } as unknown as Context;
    const token = await exportSubjectContext(ctx, "demo-app");

    let seen: unknown;
    const service = implement(
      demo,
      {
        async add(_input, handlerCtx) {
          seen = handlerCtx.subject;
          return { sum: 0 };
        },
        async guarded() {
          return { ok: true as const };
        },
        async boom() {
          throw new Error("unused");
        },
      },
      { selfApp: "demo-app" },
    );
    await service.invoke("add", { a: 0, b: 0 }, token);
    expect(seen).toEqual({ subjectId: "u1", tenantId: "acme", callerApp: "web" });
  });

  test("a bad identity token is refused, not ignored", async () => {
    process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
    process.env.WRNEXUS_APP_NAME = "web";
    const result = await impl().invoke("add", { a: 1, b: 1 }, "not.a.token");
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.code).toBe(RPC_ERROR_CODES.identity);
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/rpc/test/server.test.ts Expected: FAIL — cannot resolve ../src/server.ts

  • Step 3: Write the implementation

Create packages/rpc/src/server.ts:

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. Wire this to
   * `@wrnexus/authz`. A procedure that DECLARES a permission but finds no
   * checker configured is denied — never run unguarded.
   */
  checkPermission?: (permission: string, subject?: SubjectContext) => Promise<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, 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:

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, 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:

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 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:

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_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:

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: Wire the dispatcher into the request pipeline

In packages/dev-server/src/runtime.ts, inside the main fetch handler, call handleRpcRequest BEFORE normal page/API routing and return its result when non-null. Build the Map<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 wire(allowed: boolean) {
  const service = implement(
    billing,
    {
      async createInvoice({ amountCents }, ctx) {
        return { invoiceId: `inv_${amountCents}`, forSubject: ctx.subject?.subjectId ?? "anon" };
      },
    },
    { selfApp: "billing", checkPermission: async () => allowed },
  );
  return inProcessTransport({
    "billing/createInvoice": (payload, identity) =>
      service.invoke("createInvoice", payload, identity),
  });
}

describe("end to end", () => {
  test("identity flows from caller to callee and the handler sees the subject", async () => {
    process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
    process.env.WRNEXUS_APP_NAME = "web";
    const client = serviceClient(billing, {
      app: "billing",
      as: { user: { id: "u1" }, tenant: { id: "acme" }, locals: {} } as unknown as Context,
      transport: wire(true),
    });
    expect(await client.createInvoice({ amountCents: 250 })).toEqual({
      invoiceId: "inv_250",
      forSubject: "u1",
    });
  });

  test("the callee's permission check refuses even though the caller allowed it", async () => {
    process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
    process.env.WRNEXUS_APP_NAME = "web";
    const client = serviceClient(billing, {
      app: "billing",
      as: { user: { id: "u1" }, locals: {} } as unknown as Context,
      transport: wire(false),
    });
    try {
      await client.createInvoice({ amountCents: 1 });
      throw new Error("should not reach here");
    } catch (error) {
      expect((error as { code: string }).code).toBe(RPC_ERROR_CODES.denied);
    }
  });

  test("invalid input never reaches the handler", async () => {
    const client = serviceClient(billing, { app: "billing", transport: wire(true) });
    try {
      await client.createInvoice({ amountCents: "abc" } as never);
      throw new Error("should not reach here");
    } catch (error) {
      expect((error as { code: string }).code).toBe(RPC_ERROR_CODES.invalid);
    }
  });
});
  • Step 2: Run it

Run: bun test packages/rpc/test/integration.test.ts Expected: PASS, 3 tests. Any failure is a real integration defect — fix the module, not the test.

  • Step 3: Add the example service

Create examples/auth-showcase/app/services/greeter.ts:

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.