diff --git a/docs/plans/2026-08-05-inter-app-comms-design.md b/docs/plans/2026-08-05-inter-app-comms-design.md new file mode 100644 index 00000000..3597d22d --- /dev/null +++ b/docs/plans/2026-08-05-inter-app-comms-design.md @@ -0,0 +1,223 @@ +# Inter-app communication design (`@wrnexus/rpc`) + +Date: 2026-08-05 +Status: approved, not yet implemented +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 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/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 `implement`s 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 + +```ts +// 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 + +```ts +// 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//`. + +## Client side + +```ts +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 + +```ts +interface Transport { + call( + target: { app: string; service: string; procedure: string }, + payload: unknown, + options: { signal: AbortSignal; identity?: string }, + ): Promise; +} +``` + +`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.