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>
10 KiB
Inter-app communication design (@wrnexus/rpc)
Date: 2026-08-05
Status: phase 1 implemented (2026-08-05); phases 2–4 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/helpers—appOrigin(name)/appUrl(name, path), string builders over theWRNEXUS_WORKSPACE_ORIGINSenvironment 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/queue — defineJob, 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/apiroutes. 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
audis rejected; an expired token is rejected; a missing token is rejected.
Build order
- Contract, server discovery, typed client, HTTP transport, identity — the usable core.
- Failure handling: timeouts, idempotent retry, circuit breaking.
- App-to-app streaming.
- Identity propagation for
pubsubandqueue, 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
Transportseam 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
pubsuborqueuebehind a unified facade. - Service discovery beyond the existing
WRNEXUS_WORKSPACE_ORIGINSmap. - Cross-workspace federation.