Files
WRNexusJS/docs/plans/2026-08-05-inter-app-comms-design.md
ClintchizandClaude Opus 5 e01915823a feat(rpc): transport, server, client, http, mounting, docs (Tasks 5-11)
Brings the uncommitted body of work under version control so it cannot be
lost. Gates are green: 152 tests pass across rpc/router/dev-server,
typecheck, lint, format and check:public-api all clean.

NOT YET REVIEWED. None of Tasks 5-11 has had an independent task review, and
Task 4's second fix round was never re-reviewed either.

Known gaps against the plan, recorded here rather than discovered later:
- packages/rpc/test/{transport,server,client}.test.ts are ABSENT. The plan
  required a test file for each. server.ts holds the fail-closed identity and
  permission checks and currently has no direct coverage at all.
- rpc-endpoint.test.ts has 3 tests where the plan specified 9. Missing:
  unknown service, non-POST, malformed body, non-rpc passthrough, and the
  isInternalCaller sweep. This is the task where a reachable
  /__wrnexus/rpc/* makes every permission check in the workspace bypassable.
- http.test.ts has 3 of 7; integration.test.ts 2 of 3;
  services-discovery.test.ts 1 of 4.

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

10 KiB
Raw Permalink Blame History

Inter-app communication design (@wrnexus/rpc)

Date: 2026-08-05 Status: phase 1 implemented (2026-08-05); phases 24 remain deferred Depends on: the permissions system (@wrnexus/authz), merged 2026-08-05

Problem

Workspace apps are separate OS processes behind a Host-routed gateway — full isolation, own database registry, own memory. They can currently reach each other in exactly two ways:

  • @wrnexus/helpersappOrigin(name) / appUrl(name, path), string builders over the WRNEXUS_WORKSPACE_ORIGINS environment map.
  • @wrnexus/pubsub — fire-and-forget events, cross-process via the Redis, NATS or Kafka drivers.

There is no request/response layer. An app that needs an answer from another hand-writes fetch(appUrl("billing", "/api/invoice")): no shared contract, no type safety, no timeout, no retry policy, and no way for the callee to learn who the end user is. The permissions system reserved a seam for that last part — exportSubjectContext / importSubjectContext — and left it unbuilt.

Scope

Of the four cross-app call shapes, three already have implementations:

Shape Status
Request/response Missing. This is the gap.
Fire-and-forget events @wrnexus/pubsub
Scheduled / deferred @wrnexus/queuedefineJob, durable store, workflows, cron
Streaming @wrnexus/realtime covers browser↔server; app↔app is not covered

This design builds request/response and app-to-app streaming. It does not wrap pubsub or queue in a new facade: they work, and an abstraction over working code leaks and needs keeping in sync. They instead gain identity propagation, and all four shapes are documented in one place so a developer knows what to reach for.

Approach

Service contracts live in the workspace's shared package and are imported by both sides. The callee implements a contract; the caller gets a typed proxy client. Types flow through a normal TypeScript import — no generator, no generated file to go stale, no build step. This matches the idiom the framework already uses: defineAuthz, defineJob, defineRbac, defineWorkflow.

Rejected alternatives:

  • Callee exports types, caller imports them. No new abstraction, but the callee still hand-writes and hand-mounts every route and nothing keeps the route path and the type in agreement.
  • Generate a client from the callee's app/api routes. Codegen over hand-written routes is brittle, and the permissions work has already demonstrated that a generated file with no consumer becomes dead weight.

Module layout

@wrnexus/rpc
  contract.ts    defineService(), the procedure builder
  server.ts      implement(); routes discovered from app/services/*.ts
  client.ts      serviceClient(contract, { as: ctx }) -> typed proxy
  transport.ts   Transport interface, httpTransport(), inProcessTransport()
  identity.ts    exportSubjectContext / importSubjectContext
  errors.ts      ServiceError and retryability classification

Contracts

// packages/shared/src/services/billing.ts — imported by both apps
import { defineService, procedure } from "@wrnexus/rpc";
import { v } from "@wrnexus/validation";

export const billing = defineService({
  name: "billing",
  procedures: {
    createInvoice: procedure
      .input(v.object({ userId: v.string(), amountCents: v.number().integer() }))
      .output<{ invoiceId: string }>()
      .permission("invoice:create"),
  },
});

Input validation reuses @wrnexus/validation's v, which already returns { ok, value, errors }. No new validator is introduced.

.permission(id) is enforced, not merely declarative: implement checks it against the callee's own catalog before invoking the handler, and a denial never reaches the handler. A handler may additionally perform finer-grained, resource-level checks — .permission() is the coarse gate, not the whole authorization story. Declaring it on the contract also means the caller can see, at the type level, what the call requires.

Server side

// app/services/billing.ts
export default implement(billing, {
  async createInvoice({ userId, amountCents }, ctx) {
    /* ... */
  },
});

buildRouter discovers app/services/*.ts exactly as it discovers app/authz and app/schemas, skipping *.gen.ts. Procedures mount at /__wrnexus/rpc/<service>/<procedure>.

Client side

const client = serviceClient(billing, { as: ctx });
const { invoiceId } = await client.createInvoice({ userId, amountCents });

{ as: ctx } carries identity: it calls exportSubjectContext(ctx) to mint a short-lived signed token.

Identity

The token carries sub and tenant only — never roles. Every app shares the PermissionStore, so the callee resolves roles itself. A stale or forged roles claim is therefore impossible by construction, the token stays small, and there is no path to injecting privileges through a claim.

Built on @wrnexus/jwt: alg pinned to HS256, signature verified before the payload is parsed, exp/nbf/aud/iss enforced.

Claim Purpose
iss calling app name
aud target app name, so B cannot replay A's token to C
sub subject id — a non-empty string, matching the permissions system's requirement
tenant tenant id, absent for global
exp 60 seconds

The token authenticates; it never authorizes. The callee runs its own permission checks against its own catalog. An app cannot grant access the callee's catalog does not allow.

Trust boundary

With a workspace-wide signing secret, any app can mint a token claiming to be any user. All workspace apps are therefore one trust boundary: compromising the lowest-privilege app compromises user identity across all of them. This is the standard trade-off and is acceptable for apps in one repository under one operator, but it must be documented rather than discovered. Per-app signing keys are the hardening path if apps ever run at differing trust levels.

The RPC secret MUST NOT be the session secret. Reusing it would make a leaked RPC token a session-forgery primitive.

The RPC path is a new attack surface

/__wrnexus/rpc/* must be unreachable from the public internet — blocked at the gateway and verified at the app, because either alone is a single point of failure. If that endpoint is reachable with a forged token, every permission check in the workspace is bypassable. This is the single most important property in the design.

Transport

interface Transport {
  call(
    target: { app: string; service: string; procedure: string },
    payload: unknown,
    options: { signal: AbortSignal; identity?: string },
  ): Promise<ServiceResult>;
}

httpTransport() is the default and the only production transport in v1. A gRPC transport, if ever needed, implements this interface and nothing else changes.

inProcessTransport() implements the same interface by calling the implementation directly with no network. It exists for tests.

Errors and retries

Errors classify into three kinds, because the classification decides whether a retry is safe:

Kind Retryable
Transport failure — connection refused, timeout, 5xx Yes
Application error — the procedure threw or returned a failure No
Rejected — validation failed, authorization denied, unknown procedure No

Only procedures explicitly marked .idempotent() are retried at all. Retrying a non-idempotent createInvoice because a response was slow is how a customer gets billed twice: the default is no retry, and opting in is a deliberate act recorded in the contract.

ServiceError crossing an app boundary is opaque by default — code and message only, no stack, no internal detail. An error that explains the callee's internals is an information leak, the same defect class as authorizeDecision's original 403 body.

Testing

  • In-process transport. Contract, identity round-trip and error classification are all testable without standing up two servers.
  • Contract conformance suite. An implementation must satisfy its contract at runtime, not only at compile time.
  • Security regressions, each proven by reverting the guard and confirming the test fails: the RPC path rejects an external request; a forged signature is rejected; a token with the wrong aud is rejected; an expired token is rejected; a missing token is rejected.

Build order

  1. Contract, server discovery, typed client, HTTP transport, identity — the usable core.
  2. Failure handling: timeouts, idempotent retry, circuit breaking.
  3. App-to-app streaming.
  4. Identity propagation for pubsub and queue, and the unified "how apps talk" guide.

Phase 1 is what makes the system usable; everything after is additive.

Out of scope

  • gRPC and Protocol Buffers. The Transport seam exists so this stays additive. gRPC's value is cross-language interop and streaming; workspace apps are all TypeScript in one repository sharing types through workspace packages, so a typed HTTP transport delivers end-to-end type safety without a code generator.
  • Wrapping pubsub or queue behind a unified facade.
  • Service discovery beyond the existing WRNEXUS_WORKSPACE_ORIGINS map.
  • Cross-workspace federation.