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>
224 lines
10 KiB
Markdown
224 lines
10 KiB
Markdown
# 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 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/<service>/<procedure>`.
|
||
|
||
## 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<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.
|