Files
WRNexusJS/docs/plans/2026-08-05-inter-app-comms-implementation.md
T
ClintchizandClaude Opus 5 e0bd84247e docs: deep-freeze procedures in the Task 3 plan snippet
defineService froze the procedures map but not each procedure inside it, so a
ProcedureDef built by hand rather than through procedure.build() stayed
mutable: svc.procedures.foo.permission = 'hacked' silently succeeded. The
contract is shared between two apps as a single source of truth, and the
guarantee rested on every call site remembering to use the builder.

Same class as the authz catalog's frozenMap, which froze the Map's mutators
but not the values it handed out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:01:52 +05:30

85 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).

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 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("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;

export interface SubjectContext {
  subjectId: string;
  tenantId?: string;
  /** The app that minted the token. */
  callerApp: string;
}

export interface ExportOptions {
  ttlSeconds?: number;
}

/**
 * The workspace-wide RPC signing secret.
 *
 * Deliberately separate from the session secret: reusing that would make a
 * leaked RPC token a session-forgery primitive. All workspace apps share this
 * secret, so they form ONE trust boundary — any app can mint a token naming
 * any user, and compromising the lowest-privilege app compromises identity
 * across all of them.
 */
export function rpcSecret(): string {
  const secret = process.env.WRNEXUS_RPC_SECRET;
  if (!secret) {
    throw new Error(
      "WRN-RPC-SECRET: WRNEXUS_RPC_SECRET is not set. Inter-app calls cannot carry " +
        "identity without it. Use a value distinct from the session secret.",
    );
  }
  if (secret.length < MIN_SECRET_LENGTH) {
    throw new Error(
      `WRN-RPC-SECRET: WRNEXUS_RPC_SECRET must be at least ${MIN_SECRET_LENGTH} characters.`,
    );
  }
  return secret;
}

function callerAppName(): string {
  const name = process.env.WRNEXUS_APP_NAME;
  if (!name) {
    throw new Error(
      "WRN-RPC-APP: WRNEXUS_APP_NAME is not set, so a call cannot identify its caller.",
    );
  }
  return name;
}

/**
 * Mint a short-lived token naming the current subject, addressed to one app.
 *
 * Carries `sub` and `tenant` ONLY. Roles are deliberately absent: every app
 * shares the PermissionStore, so the callee resolves them itself, which makes
 * a stale or forged privilege claim impossible by construction.
 *
 * Returns undefined for an anonymous request — there is no identity to carry.
 */
export async function exportSubjectContext(
  ctx: Context,
  targetApp: string,
  options: ExportOptions = {},
): Promise<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).",
    );
  }
  const tenantId = ctx.tenant?.id;
  return signJwt(
    { sub: rawId, ...(typeof tenantId === "string" && tenantId ? { tenant: tenantId } : {}) },
    rpcSecret(),
    {
      issuer: callerAppName(),
      audience: targetApp,
      expiresIn: options.ttlSeconds ?? DEFAULT_TTL_SECONDS,
    },
  );
}

/**
 * Verify a token addressed to THIS app and return the subject it names.
 *
 * `selfApp` is the audience check: it is what stops app B replaying a token it
 * received from A against a third app C.
 */
export async function importSubjectContext(
  token: string,
  selfApp: string,
): Promise<SubjectContext> {
  const claims = await verifyJwt<{ sub?: string; tenant?: string; iss?: string }>(
    token,
    rpcSecret(),
    { audience: selfApp },
  );
  if (typeof claims.sub !== "string" || claims.sub === "") {
    throw new Error("WRN-RPC-IDENTITY: token carries no usable subject.");
  }
  if (typeof claims.iss !== "string" || claims.iss === "") {
    throw new Error("WRN-RPC-IDENTITY: token names no calling app.");
  }
  return {
    subjectId: claims.sub,
    tenantId: typeof claims.tenant === "string" && claims.tenant ? claims.tenant : undefined,
    callerApp: claims.iss,
  };
}

Append to packages/rpc/src/index.ts:

export {
  RPC_IDENTITY_HEADER,
  exportSubjectContext,
  importSubjectContext,
  rpcSecret,
} from "./identity.ts";
export type { ExportOptions, SubjectContext } from "./identity.ts";
  • Step 4: Run test to verify it passes

Run: bun test packages/rpc/test/identity.test.ts Expected: PASS, 10 tests

  • Step 5: Verify each rejection discriminates

For the wrong-audience, expired, wrong-secret and tampered-payload tests in turn: temporarily weaken the corresponding guard (drop audience from the verify options; raise the TTL; skip the secret change; skip the signature), confirm that specific test FAILS, then restore. Report all four results. A rejection test that passes against a weakened guard proves nothing.

  • Step 6: Commit
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) {
      const def = contract.procedures[procedureName as keyof Procedures];
      const handler = handlers[procedureName as keyof Procedures];
      if (!def || !handler) {
        return failure(RPC_ERROR_CODES.unknown, `No procedure '${contract.name}/${procedureName}'`);
      }

      // 1. Identity. A token that is present but bad is refused outright — an
      //    invalid token must never be silently downgraded to anonymous.
      let subject: SubjectContext | undefined;
      if (identity !== undefined) {
        try {
          subject = await importSubjectContext(identity, options.selfApp);
        } catch (error) {
          console.error("[wrnexus:rpc] rejecting a bad identity token", error);
          return failure(RPC_ERROR_CODES.identity, "Invalid identity");
        }
      }

      // 2. Permission, before any input is trusted or any handler runs.
      if (def.permission) {
        if (!options.checkPermission) {
          console.error(
            `[wrnexus:rpc] '${contract.name}/${procedureName}' declares permission ` +
              `'${def.permission}' but no checkPermission was configured; denying.`,
          );
          return failure(RPC_ERROR_CODES.denied, "Forbidden");
        }
        let allowed = false;
        try {
          allowed = await options.checkPermission(def.permission, subject);
        } catch (error) {
          console.error("[wrnexus:rpc] permission check threw; denying", error);
          return failure(RPC_ERROR_CODES.denied, "Forbidden");
        }
        if (!allowed) return failure(RPC_ERROR_CODES.denied, "Forbidden");
      }

      // 3. Input validation. Coerced values are what the handler receives.
      let input: unknown = payload;
      if (def.input) {
        const parsed = def.input.parse(payload as Record<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("/");
  const service = serviceName ? services.get(serviceName) : undefined;
  if (!service || !procedureName) {
    return json({ ok: false, code: "RPC_UNKNOWN", message: "Unknown procedure", retryable: false });
  }

  let payload: unknown;
  try {
    payload = await req.json();
  } catch {
    return json({ ok: false, code: "RPC_INVALID", message: "Invalid input", retryable: false });
  }

  const identity = req.headers.get(RPC_IDENTITY_HEADER) ?? undefined;
  return json(await service.invoke(procedureName, payload, identity));
}
  • Step 4: Run test to verify it passes

Run: bun test packages/dev-server/test/rpc-endpoint.test.ts Expected: PASS, 9 tests

  • Step 5: Wire the dispatcher into the request pipeline

In packages/dev-server/src/runtime.ts, inside the main fetch handler, call handleRpcRequest BEFORE normal page/API routing and return its result when non-null. Build the Map<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.