diff --git a/bun.lock b/bun.lock index 4bc2af59..59a150b1 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", "@wrnexus/core": "workspace:*", + "@wrnexus/rpc": "workspace:*", "@wrnexus/validation": "workspace:*", }, "devDependencies": { @@ -245,6 +246,7 @@ "@wrnexus/pubsub": "workspace:*", "@wrnexus/pwa": "workspace:*", "@wrnexus/router": "workspace:*", + "@wrnexus/rpc": "workspace:*", "@wrnexus/security": "workspace:*", "@wrnexus/ssr": "workspace:*", "@wrnexus/store": "workspace:*", @@ -444,6 +446,21 @@ "@wrnexus/core": "workspace:*", }, }, + "packages/rpc": { + "name": "@wrnexus/rpc", + "version": "0.8.4", + "dependencies": { + "@wrnexus/authz": "workspace:*", + "@wrnexus/core": "workspace:*", + "@wrnexus/helpers": "workspace:*", + "@wrnexus/jwt": "workspace:*", + "@wrnexus/validation": "workspace:*", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2", + }, + }, "packages/security": { "name": "@wrnexus/security", "version": "0.8.4", @@ -887,6 +904,8 @@ "@wrnexus/router": ["@wrnexus/router@workspace:packages/router"], + "@wrnexus/rpc": ["@wrnexus/rpc@workspace:packages/rpc"], + "@wrnexus/security": ["@wrnexus/security@workspace:packages/security"], "@wrnexus/ssr": ["@wrnexus/ssr@workspace:packages/ssr"], diff --git a/docs/plans/2026-08-05-inter-app-comms-design.md b/docs/plans/2026-08-05-inter-app-comms-design.md index 3597d22d..3445c5e2 100644 --- a/docs/plans/2026-08-05-inter-app-comms-design.md +++ b/docs/plans/2026-08-05-inter-app-comms-design.md @@ -1,7 +1,7 @@ # Inter-app communication design (`@wrnexus/rpc`) Date: 2026-08-05 -Status: approved, not yet implemented +Status: phase 1 implemented (2026-08-05); phases 2–4 remain deferred Depends on: the permissions system (`@wrnexus/authz`), merged 2026-08-05 ## Problem diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index bae0aca9..f62afbac 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -8,6 +8,8 @@ **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). +**Status:** Implemented 2026-08-05. The deferred phases at the end of this document remain out of scope. + ## Global Constraints - Every `@wrnexus/*` package is version `0.8.4`. Do not change versions. @@ -22,6 +24,7 @@ - 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//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. @@ -83,9 +86,11 @@ import type { InferInput, ProcedureDef, ServiceContract } from "../src/types.ts" describe("rpc types", () => { test("InferInput extracts the validated shape from a schema", () => { - const schema = v.object({ userId: v.string(), amountCents: v.number() }); + // 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 = { userId: "u1", amountCents: 10 }; + const value: InferInput = { userId: "u1", amountCents: 10 }; expect(value.userId).toBe("u1"); }); @@ -178,8 +183,9 @@ export interface ProcedureDef { * 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.) */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyProcedures = Record>; export interface ServiceContract { @@ -281,16 +287,34 @@ describe("rpc errors", () => { }); test("only transport failures are retryable", () => { - // 5xx and 429 are the callee saying "try again"; everything else is final. + // 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); @@ -340,6 +364,12 @@ export const RPC_ERROR_CODES = { 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]; @@ -350,11 +380,17 @@ function retryableFor(code: string): boolean { } /** - * 5xx and 429 mean "the callee could not answer, try later". A 4xx is the - * callee saying no — retrying it just repeats the same rejection. + * 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 { - return status >= 500 || status === 429; + if (status === 408 || status === 429) return true; + return status >= 500 && status <= 599; } export function success(value: T): ServiceResult { @@ -486,6 +522,20 @@ describe("defineService", () => { ).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(); @@ -527,7 +577,14 @@ export class ProcedureBuilder { } input>(schema: S): ProcedureBuilder, Output> { - return new ProcedureBuilder, Output>({ ...this.def, input: schema }); + // The cast is required for the same reason .output() needs one: the + // phantom __input/__output markers make ProcedureDef invariant, so + // spreading a ProcedureDef into a ProcedureDef, + // Output> is not assignable without it. No runtime effect. + return new ProcedureBuilder, Output>({ + ...this.def, + input: schema, + } as ProcedureDef, Output>); } output(): ProcedureBuilder { @@ -568,7 +625,18 @@ export function defineService(def: { ); } } - return Object.freeze({ name: def.name, procedures: Object.freeze({ ...def.procedures }) }); + // 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 = {}; + 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, + }); } ``` @@ -603,7 +671,7 @@ git commit -m "feat(rpc): add defineService and the immutable procedure builder" **Interfaces:** - Consumes: `signJwt`, `verifyJwt`, `JwtError` from `@wrnexus/jwt`; `Context` type from `@wrnexus/core` -- Produces: `exportSubjectContext(ctx, target, options?): Promise`, `importSubjectContext(token, selfApp, options?): Promise`, `interface SubjectContext { subjectId: string; tenantId?: string; callerApp: string }`, `rpcSecret(): string`, `RPC_IDENTITY_HEADER` +- Produces: `exportSubjectContext(ctx, target, options?): Promise`, `importSubjectContext(token, selfApp, options?): Promise`, `interface ImportOptions { maxAgeSeconds?: number }`, `interface SubjectContext { subjectId: string; tenantId?: string; callerApp: string }`, `rpcSecret(): string`, `RPC_IDENTITY_HEADER` - [ ] **Step 1: Write the failing test** @@ -703,6 +771,44 @@ describe("subject context token", () => { ).rejects.toThrow(); }); + test("an empty selfApp is refused rather than disabling the audience check", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); + // verifyJwt skips the audience check when audience is undefined, so this + // would otherwise accept every token from every app. + for (const bad of [undefined, "", null]) { + await expect(importSubjectContext(token!, bad as never)).rejects.toThrow(/selfApp/); + } + }); + + test("a token with a huge ttl is still rejected once it exceeds maxAge", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing", { + ttlSeconds: 31_536_000, + }); + await expect(importSubjectContext(token!, "billing", { maxAgeSeconds: -1 })).rejects.toThrow(); + }); + + test("an array targetApp is refused, so no token is valid at two apps", async () => { + configure(); + await expect( + exportSubjectContext(ctxFor({ id: "u1" }), ["billing", "reports"] as never), + ).rejects.toThrow(/targetApp/); + }); + + test("a non-string tenant id is refused rather than silently dropped", async () => { + configure(); + // Silently dropping it leaves the callee reading "no tenant" as "global". + for (const tenant of [42, {}, ""]) { + await expect( + exportSubjectContext( + { user: { id: "u1" }, tenant: { id: tenant }, locals: {} } as unknown as Context, + "billing", + ), + ).rejects.toThrow(/tenant/i); + } + }); + 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; @@ -737,11 +843,17 @@ export const RPC_IDENTITY_HEADER = "x-wrnexus-rpc-identity"; const MIN_SECRET_LENGTH = 32; const DEFAULT_TTL_SECONDS = 60; +/** Upper bound on accepted token age, whatever the token's own exp says. */ +const DEFAULT_MAX_AGE_SECONDS = 300; export interface SubjectContext { subjectId: string; tenantId?: string; - /** The app that minted the token. */ + /** + * The app that CLAIMS to have minted the token. Self-asserted: the signing + * secret is workspace-wide, so any app can set this to any name. Useful for + * logs and tracing; NEVER an authorization input. + */ callerApp: string; } @@ -749,6 +861,15 @@ export interface ExportOptions { ttlSeconds?: number; } +export interface ImportOptions { + /** + * Reject a token older than this regardless of its own `exp`, so a caller + * that mints with a huge ttlSeconds cannot create a long-lived + * impersonation credential the callee will honour. Defaults to 300s. + */ + maxAgeSeconds?: number; +} + /** * The workspace-wide RPC signing secret. * @@ -805,9 +926,25 @@ export async function exportSubjectContext( "WRN-RPC-SUBJECT: subject id must be a non-empty string; coerce numeric ids with String(id).", ); } - const tenantId = ctx.tenant?.id; + // An array here would mint one token valid at SEVERAL apps, defeating the + // audience binding that stops app B replaying A's token against app C. + if (typeof targetApp !== "string" || targetApp === "") { + throw new Error("WRN-RPC-AUDIENCE: targetApp must be a non-empty string."); + } + // A numeric tenant id is the common DB-backed case. Dropping it silently + // would leave the callee reading "no tenant" as "global", which is a + // cross-tenant exposure — so refuse it the same way a bad subject is refused. + // ctx.tenant ABSENT means untenanted. ctx.tenant present with a null id + // means tenancy was expected and the id is missing, which must not silently + // widen scope to global while still issuing an authenticated credential. + const rawTenant: unknown = ctx.tenant === undefined ? undefined : ctx.tenant.id; + if (rawTenant !== undefined && (typeof rawTenant !== "string" || rawTenant === "")) { + throw new Error( + "WRN-RPC-TENANT: tenant id must be a non-empty string; coerce numeric ids with String(id).", + ); + } return signJwt( - { sub: rawId, ...(typeof tenantId === "string" && tenantId ? { tenant: tenantId } : {}) }, + { sub: rawId, ...(rawTenant ? { tenant: rawTenant as string } : {}) }, rpcSecret(), { issuer: callerAppName(), @@ -826,21 +963,57 @@ export async function exportSubjectContext( export async function importSubjectContext( token: string, selfApp: string, + options: ImportOptions = {}, ): Promise { - const claims = await verifyJwt<{ sub?: string; tenant?: string; iss?: string }>( - token, - rpcSecret(), - { audience: selfApp }, - ); + // verifyJwt SKIPS the audience check entirely when audience is undefined, so + // an empty selfApp would disable the only cross-app binding in the system and + // accept every token from every app. currentAppName() returns + // `string | undefined`, which is exactly how that gets passed by accident. + if (typeof selfApp !== "string" || selfApp === "") { + throw new Error("WRN-RPC-AUDIENCE: selfApp must be a non-empty string."); + } + const claims = await verifyJwt<{ + sub?: string; + tenant?: unknown; + iss?: string; + exp?: number; + iat?: number; + aud?: unknown; + }>(token, rpcSecret(), { + audience: selfApp, + maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS, + }); + // verifyJwt only checks exp when it is present, so a token minted without + // one never expires. Require it. + if (typeof claims.exp !== "number") { + throw new Error("WRN-RPC-IDENTITY: token has no expiry."); + } + // Same shape one level down: verifyJwt's maxAge check is gated on iat being + // a number, so a token minted without iat silently defeats the age bound at + // ANY maxAgeSeconds. A future-dated iat yields a negative age and does the + // same. Both must be refused for maxAge to mean anything. + const now = Math.floor(Date.now() / 1000); + if (typeof claims.iat !== "number" || claims.iat > now + 60) { + throw new Error("WRN-RPC-IDENTITY: token has no usable issued-at."); + } 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."); } + // verifyJwt accepts an array aud via includes(), so a multi-audience token + // verifies at several apps. Refusing it here makes the mint-side guard's + // invariant true where it is actually enforced. + if (claims.aud !== selfApp) { + throw new Error("WRN-RPC-IDENTITY: token is addressed to more than this app."); + } + if (claims.tenant !== undefined && (typeof claims.tenant !== "string" || claims.tenant === "")) { + throw new Error("WRN-RPC-IDENTITY: token carries an unusable tenant."); + } return { subjectId: claims.sub, - tenantId: typeof claims.tenant === "string" && claims.tenant ? claims.tenant : undefined, + tenantId: claims.tenant as string | undefined, callerApp: claims.iss, }; } @@ -855,7 +1028,7 @@ export { importSubjectContext, rpcSecret, } from "./identity.ts"; -export type { ExportOptions, SubjectContext } from "./identity.ts"; +export type { ExportOptions, ImportOptions, SubjectContext } from "./identity.ts"; ``` - [ ] **Step 4: Run test to verify it passes** @@ -1287,8 +1460,14 @@ export function implement( contract, async invoke(procedureName, payload, identity) { - const def = contract.procedures[procedureName as keyof Procedures]; - const handler = handlers[procedureName as keyof Procedures]; + // Object.hasOwn, not plain indexing: "constructor", "toString" and every + // other Object.prototype member otherwise resolve as truthy, and a + // prototype member carries no `permission`, so the gate below is skipped + // entirely and an unintended function runs with attacker-controlled input. + const known = + Object.hasOwn(contract.procedures, procedureName) && Object.hasOwn(handlers, procedureName); + const def = known ? contract.procedures[procedureName as keyof Procedures] : undefined; + const handler = known ? handlers[procedureName as keyof Procedures] : undefined; if (!def || !handler) { return failure(RPC_ERROR_CODES.unknown, `No procedure '${contract.name}/${procedureName}'`); } @@ -1768,6 +1947,8 @@ export function httpTransport(options: HttpTransportOptions = {}): Transport { } 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, @@ -1780,13 +1961,9 @@ export function httpTransport(options: HttpTransportOptions = {}): Transport { 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. - return { - ok: false, - code: RPC_ERROR_CODES.transport, - message: "Malformed service response", - retryable: false, - }; + // 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"); } }, }; @@ -2142,8 +2319,12 @@ export async function handleRpcRequest( } const [, , , serviceName, procedureName] = url.pathname.split("/"); - const service = serviceName ? services.get(serviceName) : undefined; - if (!service || !procedureName) { + // Constrain the segment before it is used as a lookup key, so a prototype + // member can never be reached even if a future implement() regresses. + const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/; + const service = + serviceName && SAFE_SEGMENT.test(serviceName) ? services.get(serviceName) : undefined; + if (!service || !procedureName || !SAFE_SEGMENT.test(procedureName)) { return json({ ok: false, code: "RPC_UNKNOWN", message: "Unknown procedure", retryable: false }); } diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index 67398c01..a043b7ed 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -2372,6 +2372,54 @@ "sortRoutes" ] }, + "@wrnexus/rpc": { + ".": [ + "AnyProcedures", + "CallOptions", + "ExportOptions", + "HandlerContext", + "HttpTransportOptions", + "ImplementOptions", + "ImportOptions", + "InProcessHandler", + "InferInput", + "InferProcedureInput", + "InferProcedureOutput", + "InputSchema", + "ProcedureBuilder", + "ProcedureDef", + "RPC_ERROR_CODES", + "RPC_IDENTITY_HEADER", + "RPC_INTERNAL_HEADER", + "RPC_PATH_PREFIX", + "RpcErrorCode", + "RpcTarget", + "ServiceClient", + "ServiceClientOptions", + "ServiceContract", + "ServiceError", + "ServiceHandlers", + "ServiceImplementation", + "ServiceResult", + "SubjectContext", + "ToResultOptions", + "Transport", + "defineService", + "exportSubjectContext", + "failure", + "httpTransport", + "implement", + "importSubjectContext", + "inProcessTransport", + "isRetryableStatus", + "procedure", + "resolveAppOrigin", + "rpcPath", + "rpcSecret", + "serviceClient", + "success" + ] + }, "@wrnexus/security": { ".": [ "RequestHardeningOptions", diff --git a/examples/auth-showcase/app/services/greeter-client.ts b/examples/auth-showcase/app/services/greeter-client.ts new file mode 100644 index 00000000..6816ac49 --- /dev/null +++ b/examples/auth-showcase/app/services/greeter-client.ts @@ -0,0 +1,7 @@ +import { httpTransport, serviceClient } from "@wrnexus/rpc"; +import { greeter } from "./greeter.ts"; + +/** A caller-side helper using the shared service contract. */ +export function greeterClient() { + return serviceClient(greeter, { app: "auth-showcase", transport: httpTransport() }); +} diff --git a/examples/auth-showcase/app/services/greeter.ts b/examples/auth-showcase/app/services/greeter.ts new file mode 100644 index 00000000..61cddb0e --- /dev/null +++ b/examples/auth-showcase/app/services/greeter.ts @@ -0,0 +1,23 @@ +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, + { + greet: async ({ name }, ctx) => ({ + message: `Hello, ${name}`, + subject: ctx.subject?.subjectId ?? "anonymous", + }), + }, + { selfApp: "auth-showcase" }, +); diff --git a/examples/auth-showcase/package.json b/examples/auth-showcase/package.json index 2c455272..618376ee 100644 --- a/examples/auth-showcase/package.json +++ b/examples/auth-showcase/package.json @@ -13,6 +13,7 @@ "dependencies": { "@wrnexus/auth": "workspace:*", "@wrnexus/authz": "workspace:*", + "@wrnexus/rpc": "workspace:*", "@wrnexus/captcha": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/validation": "workspace:*" diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 7f72ae3d..f4b73416 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -644,6 +644,17 @@ applyAuthzManifestEarly([${authzSetupEntries}]); .join(", "); if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`); + // RPC services are server-only modules. Production statically imports them + // so the runtime can dispatch private calls without filesystem discovery. + const servicesLit = router.services + .map((service) => { + const v = `s${counter++}`; + imports.push(`import * as ${v} from ${JSON.stringify(fwd(service.file))};`); + return `{ name: ${JSON.stringify(service.name)}, mod: ${v} }`; + }) + .join(", "); + if (router.services.length) console.log(`✓ RPC services: ${router.services.length}`); + // Authorization declarations again, this time for ProdOptions.authz — a // SEPARATE set of static imports of the exact same files (harmless; ES // modules are evaluated once and shared across every importer), statically @@ -678,6 +689,7 @@ await createProductionServer( middleware: [${mwVars.join(", ")}], components: [${componentsLit}], layouts: [${layoutsLit}], + services: [${servicesLit}], }, { reactivePath: join(import.meta.dir, "reactive.js"), diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index da910339..0969b7ff 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@wrnexus/authz": "workspace:*", + "@wrnexus/rpc": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/dev-toolbar": "workspace:*", "@wrnexus/router": "workspace:*", diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index e5cf7da8..491d91d2 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -13,6 +13,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX } from "@wrnexus/rpc"; import { RESTART_EXIT_CODE } from "./restart.ts"; export type GatewayForwardAuth = ( @@ -432,6 +433,22 @@ export function gatewayProxyHeaders( return headers; } +/** Remove headers that only a direct workspace-to-app request may supply. */ +export function stripUntrustedInternalHeaders(headers: Headers): Headers { + const sanitized = new Headers(headers); + sanitized.delete(RPC_INTERNAL_HEADER); + return sanitized; +} + +/** + * The reserved inter-app RPC namespace is refused at the gateway edge, before + * any proxying — it is only ever mounted by a child app's own dev-server and + * must never be reachable from outside the workspace. + */ +export function isRpcGatewayPath(pathname: string): boolean { + return pathname === RPC_PATH_PREFIX || pathname.startsWith(`${RPC_PATH_PREFIX}/`); +} + /** Boot every app as a child process, then route by Host on one gateway port. */ export async function startGateway(opts: GatewayOptions): Promise { const port = opts.port ?? 3000; @@ -444,6 +461,14 @@ export async function startGateway(opts: GatewayOptions): Promise [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]), ), ); + // Loopback-only origins, computed up front (ports are assigned by index + // before any child spawns) so every child can reach every other child + // directly — bypassing the gateway, which 404s the RPC prefix by design. + const internalOriginsEnv: Readonly> = Object.freeze( + Object.fromEntries( + opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]), + ), + ); const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname; // When the CLI is executed directly from a framework checkout, keep child // apps on that same source tree. Resolving the package name from an external @@ -481,6 +506,7 @@ export async function startGateway(opts: GatewayOptions): Promise ({ + name: service.name, + file: `service:${service.name}`, + })), matchPage: optimizedMatcher(pages), matchApi: optimizedMatcher(api), matchRealtime: optimizedMatcher(realtime), diff --git a/packages/dev-server/src/rpc-dispatch.ts b/packages/dev-server/src/rpc-dispatch.ts new file mode 100644 index 00000000..600ba36c --- /dev/null +++ b/packages/dev-server/src/rpc-dispatch.ts @@ -0,0 +1,52 @@ +import { + RPC_IDENTITY_HEADER, + RPC_INTERNAL_HEADER, + RPC_PATH_PREFIX, + type ServiceImplementation, +} from "@wrnexus/rpc"; + +export { RPC_INTERNAL_HEADER }; +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}/`); +} + +export function isInternalCaller(req: Request): boolean { + return ( + req.headers.get(RPC_INTERNAL_HEADER) === "1" && + !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" } }); +} + +export async function handleRpcRequest( + req: Request, + url: URL, + services: Map, +): Promise { + if (!isRpcPath(url.pathname)) return null; + if (!isInternalCaller(req)) return new Response("Not found", { status: 404 }); + if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }); + const segments = url.pathname.split("/"); + const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/; + const serviceName = segments[3]; + const procedure = segments[4]; + const service = + serviceName && SAFE_SEGMENT.test(serviceName) ? services.get(serviceName) : undefined; + if (!service || !procedure || !SAFE_SEGMENT.test(procedure) || segments.length !== 5) { + 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 }); + } + return json( + await service.invoke(procedure, payload, req.headers.get(RPC_IDENTITY_HEADER) ?? undefined), + ); +} diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index e8534bc2..ca362db2 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -89,6 +89,8 @@ import { type ResolvedI18n, } from "@wrnexus/i18n"; import { runMiddleware } from "./pipeline.ts"; +import { handleRpcRequest, isRpcPath } from "./rpc-dispatch.ts"; +import type { ServiceImplementation } from "@wrnexus/rpc"; import type { HmrHub } from "./hmr.ts"; import type { DevToolbarConfig, @@ -882,6 +884,43 @@ export function createHandlers(deps: RuntimeDeps): Handlers { ...(await getMiddleware()), ]; const maxBodyBytes = deps.maxBodyBytes ?? 10 * 1024 * 1024; // 10 MB default + // Not memoized across failures: a single bad file in app/services/ (e.g. a + // co-located helper with no default export) must not permanently break + // every route in the app. Only a SUCCESSFUL load is cached; a failed + // attempt logs loudly and is retried on the next RPC request. + let servicesPromise: Promise> | undefined; + const loadServices = (): Promise> => { + if (!servicesPromise) { + servicesPromise = (async () => { + const services = new Map(); + for (const entry of router.services) { + const imported = await loadModule(entry.file); + const implementation = imported.default as ServiceImplementation | undefined; + if (!implementation || typeof implementation.invoke !== "function") { + throw new Error(`RPC service ${entry.file} must default-export implement(...)`); + } + if (implementation.contract.name !== entry.name) { + throw new Error( + `RPC service file ${entry.file} is mounted as "${entry.name}" (its filename) ` + + `but its contract is named "${implementation.contract.name}". Rename the file to ` + + `match the contract, or rename the contract to match the file.`, + ); + } + services.set(entry.name, implementation); + } + return services; + })().catch((error) => { + servicesPromise = undefined; + const app = process.env.WRNEXUS_APP_NAME ?? "app"; + console.error( + `[wrnexus] failed to load RPC services (${app}):`, + error instanceof Error ? (error.stack ?? error.message) : error, + ); + throw error; + }); + } + return servicesPromise; + }; // Server-side realtime room manager (shared by every `defineRoom` connection). const realtime = createRealtimeRegistry(); @@ -948,6 +987,25 @@ export function createHandlers(deps: RuntimeDeps): Handlers { const secure = (res: Response): Response => withSecurityHeaders(req, res, mode, runtimeSecurity, nonce); + if (isRpcPath(url.pathname)) { + let services: Map; + try { + services = await loadServices(); + } catch (error) { + const app = process.env.WRNEXUS_APP_NAME ?? "app"; + const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error(`[wrnexus] RPC request failed to load services (${app})\n${detail}`); + return secure( + Response.json( + { ok: false, code: "RPC_UNKNOWN", message: "Service unavailable", retryable: false }, + { headers: { "cache-control": "private, no-store" } }, + ), + ); + } + const rpcResponse = await handleRpcRequest(req, url, services); + if (rpcResponse) return secure(rpcResponse); + } + const preflight = createCorsPreflightResponse(req, deps.security); if (preflight) return secure(preflight); diff --git a/packages/dev-server/test/gateway.test.ts b/packages/dev-server/test/gateway.test.ts index e553ef39..1bde0bbc 100644 --- a/packages/dev-server/test/gateway.test.ts +++ b/packages/dev-server/test/gateway.test.ts @@ -1,11 +1,14 @@ import { expect, test } from "bun:test"; +import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX } from "@wrnexus/rpc"; import { defaultGatewayHostname, forwardAuthFailure, forwardAuthHeaders, gatewayProxyHeaders, + stripUntrustedInternalHeaders, gatewayRestartDelay, internalError, + isRpcGatewayPath, stripInternalError, } from "../src/gateway.ts"; import { resolveProductionHostname } from "../src/prod.ts"; @@ -34,6 +37,16 @@ test("gateway disables compression for its internal proxy hop", () => { expect(headers.get("x-forwarded-for")).toBe("127.0.0.1"); }); +test("gateway proxy headers do not preserve the RPC internal marker", () => { + const request = new Request("http://localhost:3000/path", { + headers: { "x-wrnexus-internal": "1" }, + }); + const headers = stripUntrustedInternalHeaders( + gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true), + ); + expect(headers.has("x-wrnexus-internal")).toBe(false); +}); + test("forward auth preserves intentional verifier redirects", () => { const redirected = forwardAuthFailure( new Response(null, { status: 302, headers: { location: "/login?returnTo=%2Fadmin" } }), @@ -133,6 +146,29 @@ test("nested SSO proxy keeps the protected app's original request headers", () = expect(proxied.get("x-original-uri")).toBe("/settings"); }); +test("the reserved RPC prefix is refused at the gateway before any proxying", () => { + expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(true); + expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true); + expect(isRpcGatewayPath("/api/billing")).toBe(false); + expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false); +}); + +test("an inbound internal-marker header from outside is stripped regardless of casing", () => { + for (const name of [ + RPC_INTERNAL_HEADER, + RPC_INTERNAL_HEADER.toUpperCase(), + "X-WrNexus-Internal", + ]) { + const request = new Request("http://localhost:3000/path", { + headers: { [name]: "1" }, + }); + const headers = stripUntrustedInternalHeaders( + gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true), + ); + expect(headers.has(RPC_INTERNAL_HEADER)).toBe(false); + } +}); + test("gateway respawns development apps after an HMR restart exit", () => { expect(gatewayRestartDelay("development", 97, null)).toBe(0); expect(gatewayRestartDelay("development", 1, null)).toBe(1200); diff --git a/packages/dev-server/test/observability-runtime.test.ts b/packages/dev-server/test/observability-runtime.test.ts index 154b48f1..febd89c1 100644 --- a/packages/dev-server/test/observability-runtime.test.ts +++ b/packages/dev-server/test/observability-runtime.test.ts @@ -14,6 +14,7 @@ function runtime(health: HealthRegistry, trustProxy = false) { stores: [], schemas: [], authz: [], + services: [], matchPage: () => null, matchApi: () => null, matchRealtime: () => null, diff --git a/packages/dev-server/test/rpc-endpoint.test.ts b/packages/dev-server/test/rpc-endpoint.test.ts new file mode 100644 index 00000000..10bf04fa --- /dev/null +++ b/packages/dev-server/test/rpc-endpoint.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { defineService, implement, procedure } from "@wrnexus/rpc"; +import { v } from "@wrnexus/validation"; +import { handleRpcRequest, isInternalCaller, isRpcPath } from "../src/rpc-dispatch.ts"; +import { createProductionHandlers, type ProdManifest } from "../src/prod.ts"; + +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, { add: async ({ a }) => ({ a }) }, { selfApp: "demo-app" })], +]); + +function request(path: string, headers: Record = {}) { + return new Request(`http://demo.test${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ a: 2 }), + }); +} + +describe("RPC endpoint", () => { + test("only matches its reserved prefix", () => { + expect(isRpcPath("/__wrnexus/rpc/demo/add")).toBe(true); + expect(isRpcPath("/__wrnexus/rpcx/demo/add")).toBe(false); + }); + + test("dispatches a private request", async () => { + const req = request("/__wrnexus/rpc/demo/add", { "x-wrnexus-internal": "1" }); + expect(await (await handleRpcRequest(req, new URL(req.url), services))!.json()).toEqual({ + ok: true, + value: { a: 2 }, + }); + }); + + test("rejects public or forwarded requests", async () => { + const external = request("/__wrnexus/rpc/demo/add"); + expect((await handleRpcRequest(external, new URL(external.url), services))!.status).toBe(404); + const forwarded = request("/__wrnexus/rpc/demo/add", { + "x-wrnexus-internal": "1", + "x-forwarded-for": "203.0.113.1", + }); + expect(isInternalCaller(forwarded)).toBe(false); + expect((await handleRpcRequest(forwarded, new URL(forwarded.url), services))!.status).toBe(404); + }); + + describe("C1: prototype-chain procedure names cannot bypass the permission gate", () => { + const PROTO_NAMES = [ + "constructor", + "toString", + "valueOf", + "hasOwnProperty", + "__proto__", + "isPrototypeOf", + ]; + + for (const name of PROTO_NAMES) { + test(`"${name}" in the URL path yields RPC_UNKNOWN`, async () => { + const req = request(`/__wrnexus/rpc/demo/${name}`, { "x-wrnexus-internal": "1" }); + const res = await handleRpcRequest(req, new URL(req.url), services); + expect(await res!.json()).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); + }); + } + + test(`"constructor" as the SERVICE segment also yields RPC_UNKNOWN`, async () => { + const req = request("/__wrnexus/rpc/constructor/add", { "x-wrnexus-internal": "1" }); + const res = await handleRpcRequest(req, new URL(req.url), services); + expect(await res!.json()).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); + }); + }); + + test("production manifests load and dispatch service implementations", async () => { + const manifest: ProdManifest = { + pages: [], + api: [], + realtime: [], + middleware: [], + components: [], + layouts: [], + services: [{ name: "demo", mod: { default: services.get("demo") } }], + }; + const handlers = createProductionHandlers(manifest, {}); + const req = request("/__wrnexus/rpc/demo/add", { "x-wrnexus-internal": "1" }); + const response = await handlers.fetch(req, {} as never); + expect(await response!.json()).toEqual({ ok: true, value: { a: 2 } }); + }); +}); diff --git a/packages/dev-server/test/rpc-services-loading.test.ts b/packages/dev-server/test/rpc-services-loading.test.ts new file mode 100644 index 00000000..b839e8fa --- /dev/null +++ b/packages/dev-server/test/rpc-services-loading.test.ts @@ -0,0 +1,170 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildRouter } from "@wrnexus/router"; +import { defineService, implement, procedure } from "@wrnexus/rpc"; +import { v } from "@wrnexus/validation"; +import { createHandlers, type RuntimeDeps } from "../src/runtime.ts"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); + +const greeter = defineService({ + name: "greeter", + procedures: { + greet: procedure + .input(v.object({ name: v.string() })) + .output<{ message: string }>() + .build(), + }, +}); + +function makeHandlers(app: string, loadModule: RuntimeDeps["loadModule"]) { + return createHandlers({ + mode: "production", + hmr: false, + router: buildRouter(app), + loadModule, + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); +} + +test("a bad file under app/services does not break unrelated routes", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-bad-service-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + mkdirSync(join(app, "services"), { recursive: true }); + writeFileSync(join(app, "pages/home.ts"), "export default () => '';\n"); + // A co-located helper with no default export — the natural thing a + // developer drops next to a real service. + writeFileSync(join(app, "services/types.ts"), "export type Foo = string;\n"); + + const handlers = makeHandlers(app, async (file) => { + const normalized = file.replace(/\\/g, "/"); + if (normalized.endsWith("services/types.ts")) return {}; // no default export + if (normalized.endsWith("pages/home.ts")) return { default: () => "

home

" }; + throw new Error(`unexpected module: ${file}`); + }); + + const res = await handlers.fetch(new Request("https://example.test/home"), { + upgrade: () => false, + }); + expect(await res!.text()).toContain("home"); +}); + +test("the RPC path returns a structured failure instead of throwing when service load fails", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-load-fail-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "services"), { recursive: true }); + writeFileSync(join(app, "services/types.ts"), "export type Foo = string;\n"); + + const handlers = makeHandlers(app, async () => ({})); // no default export + + const res = await handlers.fetch( + new Request("https://example.test/__wrnexus/rpc/types/greet", { + method: "POST", + headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, + body: "{}", + }), + { upgrade: () => false }, + ); + expect(res).toBeDefined(); + expect(res!.status).toBeLessThan(500); + const body = await res!.json(); + expect(body).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); +}); + +test("a request after a load failure re-attempts rather than serving a cached rejection", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-retry-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "services"), { recursive: true }); + writeFileSync(join(app, "services/greeter.ts"), "export default {};\n"); + + let attempt = 0; + const handlers = makeHandlers(app, async () => { + attempt += 1; + if (attempt === 1) return {}; // fails: no default export + return { + default: implement( + greeter, + { greet: async ({ name }) => ({ message: `Hi ${name}` }) }, + { + selfApp: "greeter", + }, + ), + }; + }); + + const call = () => + handlers.fetch( + new Request("https://example.test/__wrnexus/rpc/greeter/greet", { + method: "POST", + headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, + body: JSON.stringify({ name: "Ada" }), + }), + { upgrade: () => false }, + ); + + const first = await call(); + expect(await first!.json()).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); + + const second = await call(); + expect(await second!.json()).toEqual({ ok: true, value: { message: "Hi Ada" } }); + expect(attempt).toBe(2); +}); + +test("a contract name that does not match its mounted filename fails loudly, naming both", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-name-mismatch-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "services"), { recursive: true }); + writeFileSync(join(app, "services/greeter.ts"), "export default {};\n"); + + const billing = defineService({ + name: "billing", + procedures: { + createInvoice: procedure + .input(v.object({ amountCents: v.number() })) + .output<{ invoiceId: string }>() + .build(), + }, + }); + const service = implement( + billing, + { createInvoice: async ({ amountCents }) => ({ invoiceId: `inv_${amountCents}` }) }, + { selfApp: "billing" }, + ); + + const handlers = makeHandlers(app, async () => ({ default: service })); + + // The typed client would call /__wrnexus/rpc/billing/... (contract name), + // but the file mounts as "greeter" — the client's request 404s as unknown. + const res = await handlers.fetch( + new Request("https://example.test/__wrnexus/rpc/billing/createInvoice", { + method: "POST", + headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, + body: JSON.stringify({ amountCents: 5 }), + }), + { upgrade: () => false }, + ); + const body = await res!.json(); + expect(body).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); + + // And the actual mounted name ("greeter") also fails: the implementation's + // contract does not match the filename it was mounted under. + const res2 = await handlers.fetch( + new Request("https://example.test/__wrnexus/rpc/greeter/createInvoice", { + method: "POST", + headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, + body: JSON.stringify({ amountCents: 5 }), + }), + { upgrade: () => false }, + ); + const body2 = await res2!.json(); + expect(body2).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); +}); diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 9060e178..79dd26fa 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -61,6 +61,8 @@ export interface Router { schemas: ComponentRef[]; /** Authorization declarations (`app/authz/.ts`) merged into the catalog. */ authz: ComponentRef[]; + /** Service implementations (`app/services/.ts`) mounted for inter-app calls. */ + services: ComponentRef[]; matchPage(pathname: string): RouteMatch | null; matchApi(pathname: string): RouteMatch | null; matchRealtime(pathname: string): RouteMatch | null; @@ -302,6 +304,31 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { authz.push({ name, file: f.file }); } + const services: ComponentRef[] = []; + const serviceFilesByName = new Map(); + for (const f of scanDir(join(appDir, "services"), [".js"])) { + if (!/\.(ts|js)$/.test(f.file) || /[.]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; + } + // A service name is a routable identity (/__wrnexus/rpc//...), so a + // collision is a configuration error, not something to resolve by + // directory-walk precedence like `components` silently does. Fail loudly, + // naming both files, instead of letting whichever file is visited last + // win non-deterministically. + const existing = serviceFilesByName.get(name); + if (existing) { + throw new Error( + `WRN-SERVICE-COLLISION: two services are both named "${name}": ${existing} and ${f.file}. ` + + `Rename one of the files — service names must be unique across app/services.`, + ); + } + serviceFilesByName.set(name, f.file); + services.push({ name, file: f.file }); + } + return { pages, api, @@ -312,6 +339,7 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { stores, schemas, authz, + services, matchPage: (p) => matchRoute(pages, p), matchApi: (p) => matchRoute(api, p), matchRealtime: (p) => matchRoute(realtime, p), diff --git a/packages/router/test/services-discovery.test.ts b/packages/router/test/services-discovery.test.ts new file mode 100644 index 00000000..e178f029 --- /dev/null +++ b/packages/router/test/services-discovery.test.ts @@ -0,0 +1,74 @@ +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 makeApp(): string { + const base = mkdtempSync(join(root, "app-")); + mkdirSync(join(base, "app", "pages"), { recursive: true }); + return join(base, "app"); +} + +describe("service discovery", () => { + test("discovers source services and skips generated files", () => { + const appDir = makeApp(); + const services = join(appDir, "services"); + mkdirSync(services, { recursive: true }); + writeFileSync(join(services, "billing.ts"), "export default {};"); + writeFileSync(join(services, "types.gen.ts"), "export type T = string;"); + expect(buildRouter(appDir).services.map((service) => service.name)).toEqual(["billing"]); + }); + + test("throws naming both files when two services collide on name", () => { + const appDir = makeApp(); + const services = join(appDir, "services"); + const nested = join(services, "legacy"); + mkdirSync(nested, { recursive: true }); + const top = join(services, "billing.ts"); + const shadow = join(nested, "billing.ts"); + writeFileSync(top, "export default {};"); + writeFileSync(shadow, "export default {};"); + expect(() => buildRouter(appDir)).toThrow(/WRN-SERVICE-COLLISION/); + try { + buildRouter(appDir); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain(top); + expect(message).toContain(shadow); + } + }); + + test("unsafe service names are skipped with a warning", () => { + const appDir = makeApp(); + const services = join(appDir, "services"); + mkdirSync(services, { recursive: true }); + writeFileSync(join(services, "bad name.ts"), "export default {};"); + const originalWarn = console.warn; + let warned = false; + console.warn = (...args: unknown[]) => { + if (String(args[0]).includes("skipping service with unsafe name")) warned = true; + }; + try { + expect(buildRouter(appDir).services).toEqual([]); + expect(warned).toBe(true); + } finally { + console.warn = originalWarn; + } + }); + + test("a missing services directory yields []", () => { + const appDir = makeApp(); + expect(buildRouter(appDir).services).toEqual([]); + }); + + test("discovers .js service files", () => { + const appDir = makeApp(); + const services = join(appDir, "services"); + mkdirSync(services, { recursive: true }); + writeFileSync(join(services, "reports.js"), "export default {};"); + expect(buildRouter(appDir).services.map((service) => service.name)).toEqual(["reports"]); + }); +}); diff --git a/packages/rpc/README.md b/packages/rpc/README.md new file mode 100644 index 00000000..ca6744f6 --- /dev/null +++ b/packages/rpc/README.md @@ -0,0 +1,73 @@ +# `@wrnexus/rpc` + +Define a service contract in a shared workspace package, then import that same contract from the caller and callee. + +```ts +import { + defineService, + implement, + inProcessTransport, + procedure, + serviceClient, +} from "@wrnexus/rpc"; +import { v } from "@wrnexus/validation"; + +const greeter = defineService({ + name: "greeter", + procedures: { + greet: procedure + .input(v.object({ name: v.string() })) + .output<{ message: string }>() + .build(), + }, +}); + +const service = implement( + greeter, + { greet: async ({ name }) => ({ message: `Hello, ${name}` }) }, + { selfApp: "greeter" }, +); + +const client = serviceClient(greeter, { + app: "greeter", + transport: inProcessTransport({ + "greeter/greet": (input, identity) => service.invoke("greet", input, identity), + }), +}); +await client.greet({ name: "Ada" }); +``` + +Service files default-export `implement(...)` from `app/services`. The development server mounts them under the private `/__wrnexus/rpc` prefix. + +Pass `{ as: ctx }` to `serviceClient` to propagate the subject. The signed token contains only subject and tenant identifiers; permissions are always checked by the callee. Set `WRNEXUS_RPC_SECRET` in every app, use at least 32 characters, and never reuse the session secret. + +Calls time out by default. Retrying is intentionally deferred; when introduced, only procedures marked `.idempotent()` may be retried. + +## Deployment requirement: apps must be unreachable except through the gateway + +`/__wrnexus/rpc/*` is authenticated by TWO signals together: a marker header +(`x-wrnexus-internal: 1`) AND the absence of any `X-Forwarded-*` header. The +WrNexus gateway satisfies this by construction — it strips any inbound +marker header from the public request, and it always adds `X-Forwarded-*` +when proxying to an app. A direct loopback call from a sibling app process +carries the marker and no forwarded headers, so it passes; anything that +came through the gateway carries forwarded headers, so it's rejected even if +it also carries the marker. + +**This check only works if the app process is unreachable except through the +gateway.** If an app's port is exposed directly, or if a reverse proxy sits +in front of it WITHOUT setting `X-Forwarded-*` (a bare `proxy_pass` with no +`proxy_set_header X-Forwarded-For`/`X-Forwarded-Host`/`X-Forwarded-Proto`), +then an external caller can set the marker header itself, arrive with no +forwarded headers, and reach `/__wrnexus/rpc/*` as if it were an internal +call — bypassing the gateway's edge block entirely. + +Requirements for any deployment: + +- App processes must bind to a private/loopback interface and be reachable + ONLY through the gateway (or an equivalent trusted front door) — never + exposed directly to the internet or an untrusted network. +- Any reverse proxy placed in front of an app (nginx, a load balancer, etc.) + MUST set `X-Forwarded-For`, `X-Forwarded-Host`, and `X-Forwarded-Proto` on + every request it forwards. Omitting these silently reopens the private RPC + namespace to anyone who can reach the proxy. diff --git a/packages/rpc/package.json b/packages/rpc/package.json new file mode 100644 index 00000000..b2b2b4a5 --- /dev/null +++ b/packages/rpc/package.json @@ -0,0 +1,31 @@ +{ + "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/core": "workspace:*", + "@wrnexus/jwt": "workspace:*", + "@wrnexus/helpers": "workspace:*", + "@wrnexus/validation": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2" + } +} diff --git a/packages/rpc/src/client.ts b/packages/rpc/src/client.ts new file mode 100644 index 00000000..246a4f67 --- /dev/null +++ b/packages/rpc/src/client.ts @@ -0,0 +1,86 @@ +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 { + app?: string; + transport: Transport; + as?: Context; + timeoutMs?: number; +} + +export type ServiceClient = { + [K in keyof Procedures]: ( + input: InferProcedureInput, + ) => Promise>; +}; + +const DEFAULT_TIMEOUT_MS = 10_000; + +export function serviceClient( + contract: ServiceContract, + options: ServiceClientOptions, +): ServiceClient { + const app = options.app ?? contract.name; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return new Proxy({} as ServiceClient, { + get(_target, property) { + // Every declared procedure is a string key; anything else (including + // `then`/`catch`/`finally`) is not one of ours. Returning a function for + // those makes `await client` or `return client` from an async function + // read the proxy as thenable — the runtime then calls `then(resolve, + // reject)`, which throws "Unknown procedure". Returning undefined lets + // the caller be treated as a plain (non-thenable) object instead. + if (typeof property !== "string" || !Object.hasOwn(contract.procedures, property)) { + return undefined; + } + return async (input: unknown) => { + let identity: string | undefined; + if (options.as) { + try { + identity = await exportSubjectContext(options.as, app); + } catch (error) { + // A missing/misconfigured WRNEXUS_RPC_SECRET otherwise rejects with + // a bare Error, so a caller matching on ServiceError treats + // misconfiguration as a crash instead of a handled RPC failure. + // The operator-facing message names no secret value, so it is safe + // to preserve. + const message = error instanceof Error ? error.message : String(error); + throw new ServiceError(RPC_ERROR_CODES.identity, message); + } + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const timeout = new Promise((_, reject) => { + controller.signal.addEventListener("abort", () => { + reject(new ServiceError(RPC_ERROR_CODES.transport, "Call timed out")); + }); + }); + const callPromise = options.transport.call( + { app, service: contract.name, procedure: property }, + input, + { signal: controller.signal, ...(identity ? { identity } : {}) }, + ); + try { + const result = await Promise.race([callPromise, timeout]); + if (result.ok) return result.value; + throw new ServiceError(result.code, result.message, result.retryable); + } finally { + clearTimeout(timer); + // If the timeout won the race, the transport call may still settle + // later — a transport that ignores the abort signal keeps running. + // Nothing awaits it again, so swallow a late rejection here rather + // than let it surface as an unhandled promise rejection. + callPromise.catch(() => {}); + } + }; + }, + }); +} diff --git a/packages/rpc/src/contract.ts b/packages/rpc/src/contract.ts new file mode 100644 index 00000000..e632d8cf --- /dev/null +++ b/packages/rpc/src/contract.ts @@ -0,0 +1,77 @@ +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 { + private constructor(private readonly def: ProcedureDef) {} + + static create(): ProcedureBuilder { + return new ProcedureBuilder({}); + } + + input>(schema: S): ProcedureBuilder, Output> { + return new ProcedureBuilder, Output>({ + ...this.def, + input: schema, + } as ProcedureDef, Output>); + } + + output(): ProcedureBuilder { + return new ProcedureBuilder({ ...this.def } as ProcedureDef); + } + + permission(id: string): ProcedureBuilder { + return new ProcedureBuilder({ ...this.def, permission: id }); + } + + /** Mark safe to retry. Anything not marked is never retried. */ + idempotent(): ProcedureBuilder { + return new ProcedureBuilder({ ...this.def, idempotent: true }); + } + + build(): ProcedureDef { + return Object.freeze({ ...this.def }); + } +} + +export const procedure = ProcedureBuilder.create(); + +export function defineService(def: { + name: string; + procedures: Procedures; +}): ServiceContract { + 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 = {}; + 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, + }); +} diff --git a/packages/rpc/src/errors.ts b/packages/rpc/src/errors.ts new file mode 100644 index 00000000..303a1e90 --- /dev/null +++ b/packages/rpc/src/errors.ts @@ -0,0 +1,90 @@ +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(value: T): ServiceResult { + return { ok: true, value }; +} + +export function failure(code: string, message: string): ServiceResult { + 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; + + /** + * `retryable` defaults to the code-derived value for callers that + * construct a `ServiceError` directly. Pass it explicitly when relaying a + * wire result: the transport already computed the authoritative value + * (e.g. a bounded HTTP-status check), and recomputing it here from the + * code alone would silently flip it — `RPC_TRANSPORT` derives to `true`, + * even for a non-retryable 403. + */ + constructor(code: string, message: string, retryable?: boolean) { + super(message); + this.name = "ServiceError"; + this.code = code; + this.retryable = 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 { + return { + ok: false, + code: this.code, + message: options.exposeMessage ? this.message : "Internal error", + retryable: this.retryable, + }; + } +} diff --git a/packages/rpc/src/http.ts b/packages/rpc/src/http.ts new file mode 100644 index 00000000..a473975b --- /dev/null +++ b/packages/rpc/src/http.ts @@ -0,0 +1,108 @@ +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"; + +export const RPC_PATH_PREFIX = "/__wrnexus/rpc"; +export const RPC_INTERNAL_HEADER = "x-wrnexus-internal"; + +export function rpcPath(service: string, procedure: string): string { + return `${RPC_PATH_PREFIX}/${service}/${procedure}`; +} + +function parseOriginMap(value: string | undefined): Record { + if (!value) return {}; + try { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return parsed as Record; + } catch { + return {}; + } +} + +/** + * Resolve the origin an RPC call to `app` should target. + * + * Prefer `WRNEXUS_INTERNAL_ORIGINS` (loopback origins the gateway hands each + * child before spawning it) over `appOrigin`, which resolves the app's + * PUBLIC origin. The public origin is the wrong target for RPC: the gateway + * unconditionally 404s the reserved `/__wrnexus/rpc` prefix on anything that + * arrives at a public origin — that block is the whole point, it is what + * keeps inter-app calls off the public internet. Falling back to `appOrigin` + * when no internal-origin map is present keeps single-app and test setups + * (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working. + */ +export function resolveAppOrigin(app: string): string { + const internalOrigin = parseOriginMap(process.env.WRNEXUS_INTERNAL_ORIGINS)[app]; + if (internalOrigin) { + try { + return new URL(internalOrigin).origin; + } catch { + // Malformed internal-origin entry — fall through to the public origin. + } + } + return appOrigin(app); +} + +export interface HttpTransportOptions { + resolveOrigin?: (app: string) => string; + fetch?: typeof fetch; +} + +function isServiceResult(value: unknown): value is ServiceResult { + if (!value || typeof value !== "object" || !("ok" in value)) return false; + const result = value as Record; + return ( + result.ok === true || + (result.ok === false && + typeof result.code === "string" && + typeof result.message === "string" && + typeof result.retryable === "boolean") + ); +} + +export function httpTransport(options: HttpTransportOptions = {}): Transport { + const resolveOrigin = options.resolveOrigin ?? resolveAppOrigin; + const doFetch = options.fetch ?? fetch; + return { + async call(target: RpcTarget, payload: unknown, callOptions: CallOptions) { + let response: Response; + try { + const headers: Record = { + "content-type": "application/json", + [RPC_INTERNAL_HEADER]: "1", + }; + 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 { + return failure(RPC_ERROR_CODES.transport, "Service unreachable"); + } + if (!response.ok) { + return { + ok: false, + code: RPC_ERROR_CODES.transport, + message: `Service returned ${response.status}`, + retryable: isRetryableStatus(response.status), + }; + } + try { + const result: unknown = await response.json(); + return isServiceResult(result) + ? result + : failure(RPC_ERROR_CODES.malformed, "Malformed service response"); + } catch { + return failure(RPC_ERROR_CODES.malformed, "Malformed service response"); + } + }, + }; +} diff --git a/packages/rpc/src/identity.ts b/packages/rpc/src/identity.ts new file mode 100644 index 00000000..0ce356b5 --- /dev/null +++ b/packages/rpc/src/identity.ts @@ -0,0 +1,183 @@ +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; +/** Upper bound on accepted token age, whatever the token's own exp says. */ +const DEFAULT_MAX_AGE_SECONDS = 300; + +export interface SubjectContext { + subjectId: string; + tenantId?: string; + /** + * The app that CLAIMS to have minted the token. Self-asserted: the signing + * secret is workspace-wide, so any app can set this to any name. Useful for + * logs and tracing; NEVER an authorization input. + */ + callerApp: string; +} + +export interface ExportOptions { + ttlSeconds?: number; +} + +export interface ImportOptions { + /** + * Reject a token older than this regardless of its own `exp`, so a caller + * that mints with a huge ttlSeconds cannot create a long-lived + * impersonation credential the callee will honour. Defaults to 300s. + */ + maxAgeSeconds?: 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 { + 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).", + ); + } + // An array here would mint one token valid at SEVERAL apps, defeating the + // audience binding that stops app B replaying A's token against app C. + if (typeof targetApp !== "string" || targetApp === "") { + throw new Error("WRN-RPC-AUDIENCE: targetApp must be a non-empty string."); + } + // A numeric tenant id is the common DB-backed case. Dropping it silently + // would leave the callee reading "no tenant" as "global", which is a + // cross-tenant exposure — so refuse it the same way a bad subject is refused. + // Absent ctx.tenant means untenanted (fine); a PRESENT tenant with an + // unusable id (including null) is an error, not a silent downgrade — unlike + // rawId === null, which mints no token at all, a bad tenant must not issue + // an authenticated credential with silently widened scope. + const rawTenant: unknown = ctx.tenant === undefined ? undefined : ctx.tenant.id; + if (rawTenant !== undefined && (typeof rawTenant !== "string" || rawTenant === "")) { + throw new Error( + "WRN-RPC-TENANT: tenant id must be a non-empty string; coerce numeric ids with String(id).", + ); + } + return signJwt( + { sub: rawId, ...(rawTenant ? { tenant: rawTenant as string } : {}) }, + 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, + options: ImportOptions = {}, +): Promise { + // verifyJwt SKIPS the audience check entirely when audience is undefined, so + // an empty selfApp would disable the only cross-app binding in the system and + // accept every token from every app. currentAppName() returns + // `string | undefined`, which is exactly how that gets passed by accident. + if (typeof selfApp !== "string" || selfApp === "") { + throw new Error("WRN-RPC-AUDIENCE: selfApp must be a non-empty string."); + } + const claims = await verifyJwt<{ + sub?: string; + tenant?: unknown; + iss?: string; + exp?: number; + iat?: number; + aud?: unknown; + }>(token, rpcSecret(), { + audience: selfApp, + maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS, + }); + // verifyJwt only checks exp when it is present, so a token minted without + // one never expires. Require it. + if (typeof claims.exp !== "number") { + throw new Error("WRN-RPC-IDENTITY: token has no expiry."); + } + // Same shape one level down: verifyJwt's maxAge check is gated on iat being + // a number, so a token minted without iat silently defeats the age bound at + // ANY maxAgeSeconds. A future-dated iat yields a negative age and does the + // same. Both must be refused for maxAge to mean anything. + const now = Math.floor(Date.now() / 1000); + if (typeof claims.iat !== "number" || claims.iat > now + 60) { + throw new Error("WRN-RPC-IDENTITY: token has no usable issued-at."); + } + 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."); + } + // verifyJwt compares audience with includes(), so a token signed with + // audience: ["billing", "reports"] verifies at BOTH — exactly what I3 + // exists to prevent. Require an exact single-audience match. + if (claims.aud !== selfApp) { + throw new Error("WRN-RPC-IDENTITY: token is addressed to more than this app."); + } + if (claims.tenant !== undefined && (typeof claims.tenant !== "string" || claims.tenant === "")) { + throw new Error("WRN-RPC-IDENTITY: token carries an unusable tenant."); + } + return { + subjectId: claims.sub, + tenantId: claims.tenant as string | undefined, + callerApp: claims.iss, + }; +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts new file mode 100644 index 00000000..15d15c72 --- /dev/null +++ b/packages/rpc/src/index.ts @@ -0,0 +1,52 @@ +/** + * @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"; + +export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } from "./errors.ts"; +export type { RpcErrorCode, ToResultOptions } from "./errors.ts"; + +export { defineService, procedure, ProcedureBuilder } from "./contract.ts"; + +export { + RPC_IDENTITY_HEADER, + exportSubjectContext, + importSubjectContext, + rpcSecret, +} from "./identity.ts"; +export type { ExportOptions, ImportOptions, SubjectContext } from "./identity.ts"; + +export { inProcessTransport } from "./transport.ts"; +export type { CallOptions, InProcessHandler, RpcTarget, Transport } from "./transport.ts"; +export { implement } from "./server.ts"; +export type { + HandlerContext, + ImplementOptions, + ServiceHandlers, + ServiceImplementation, +} from "./server.ts"; +export { serviceClient } from "./client.ts"; +export type { ServiceClient, ServiceClientOptions } from "./client.ts"; +export { + RPC_INTERNAL_HEADER, + RPC_PATH_PREFIX, + httpTransport, + resolveAppOrigin, + rpcPath, +} from "./http.ts"; +export type { HttpTransportOptions } from "./http.ts"; diff --git a/packages/rpc/src/server.ts b/packages/rpc/src/server.ts new file mode 100644 index 00000000..bea8c50f --- /dev/null +++ b/packages/rpc/src/server.ts @@ -0,0 +1,107 @@ +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 { + subject?: SubjectContext; +} + +export type ServiceHandlers = { + [K in keyof Procedures]: ( + input: InferProcedureInput, + ctx: HandlerContext, + ) => Promise> | InferProcedureOutput; +}; + +export interface ImplementOptions { + selfApp: string; + checkPermission?: (permission: string, subject?: SubjectContext) => Promise | boolean; +} + +export interface ServiceImplementation { + contract: ServiceContract; + invoke(procedure: string, payload: unknown, identity?: string): Promise; +} + +export function implement( + contract: ServiceContract, + handlers: ServiceHandlers, + options: ImplementOptions, +): ServiceImplementation { + // A declared procedure with no own handler would otherwise only surface at + // invoke time as RPC_UNKNOWN — a silent, permanent 404. Catch it now. + for (const procedureName of Object.keys(contract.procedures)) { + if (!Object.hasOwn(handlers, procedureName)) { + throw new Error( + `WRN-RPC-HANDLER: service "${contract.name}" declares procedure "${procedureName}" ` + + `but implement() was not given a handler for it.`, + ); + } + } + return { + contract, + async invoke(procedureName, payload, identity) { + // Object.hasOwn, not plain indexing: "constructor", "toString" and every + // other Object.prototype member otherwise resolve as truthy, and a + // prototype member carries no `permission`, so the gate below is skipped + // entirely and an unintended function runs with attacker-controlled input. + const known = + Object.hasOwn(contract.procedures, procedureName) && Object.hasOwn(handlers, procedureName); + const definition = known ? contract.procedures[procedureName as keyof Procedures] : undefined; + const handler = known ? handlers[procedureName as keyof Procedures] : undefined; + if (!definition || !handler) return failure(RPC_ERROR_CODES.unknown, "Unknown procedure"); + + let subject: SubjectContext | undefined; + if (identity !== undefined) { + try { + subject = await importSubjectContext(identity, options.selfApp); + } catch { + return failure(RPC_ERROR_CODES.identity, "Invalid identity"); + } + } + + if (definition.permission) { + if (!options.checkPermission) return failure(RPC_ERROR_CODES.denied, "Forbidden"); + try { + if (!(await options.checkPermission(definition.permission, subject))) { + return failure(RPC_ERROR_CODES.denied, "Forbidden"); + } + } catch { + return failure(RPC_ERROR_CODES.denied, "Forbidden"); + } + } + + let input: unknown = payload; + if (definition.input) { + // InputSchema is structural: any custom or wrapped schema may throw + // instead of returning { ok: false }. A throw must not escape invoke() + // with its raw message — that text can carry internals — so it is + // caught the same way the permission check above is. + let parsed: { ok: boolean; value?: unknown }; + try { + parsed = definition.input.parse(payload as Record); + } catch (error) { + console.error(`[wrnexus] RPC input schema threw for ${String(procedureName)}`, error); + return failure(RPC_ERROR_CODES.invalid, "Invalid input"); + } + if (!parsed.ok) return failure(RPC_ERROR_CODES.invalid, "Invalid input"); + input = parsed.value; + } + + try { + const value = await (handler as (value: unknown, ctx: HandlerContext) => unknown)(input, { + subject, + }); + return success(value); + } catch { + return failure(RPC_ERROR_CODES.handler, "Internal error"); + } + }, + }; +} diff --git a/packages/rpc/src/transport.ts b/packages/rpc/src/transport.ts new file mode 100644 index 00000000..cda9ef8d --- /dev/null +++ b/packages/rpc/src/transport.ts @@ -0,0 +1,46 @@ +import { RPC_ERROR_CODES, failure } from "./errors.ts"; +import type { ServiceResult } from "./types.ts"; + +export interface RpcTarget { + app: string; + service: string; + procedure: string; +} + +export interface CallOptions { + signal?: AbortSignal; + identity?: string; +} + +export interface Transport { + call(target: RpcTarget, payload: unknown, options: CallOptions): Promise; +} + +export type InProcessHandler = ( + payload: unknown, + identity?: string, +) => Promise | ServiceResult; + +/** Direct transport for tests and local integration harnesses. */ +export function inProcessTransport(handlers: Record): Transport { + return { + async call(target, payload, options) { + // Checked only at entry: this in-process transport does no I/O, so + // nothing yields between here and the handler call below, and a signal + // that aborts mid-flight is never observed. In-process tests therefore + // cannot exercise a mid-call timeout path — that needs a real transport. + if (options.signal?.aborted) return failure(RPC_ERROR_CODES.transport, "Call aborted"); + const key = `${target.service}/${target.procedure}`; + // Object.hasOwn, not plain indexing: a prototype-inherited key (e.g. + // "constructor/toString") would otherwise resolve to a function that + // is not one of our handlers. + const handler = Object.hasOwn(handlers, key) ? handlers[key] : undefined; + if (!handler) return failure(RPC_ERROR_CODES.unknown, "Unknown procedure"); + try { + return await handler(payload, options.identity); + } catch { + return failure(RPC_ERROR_CODES.handler, "Internal error"); + } + }, + }; +} diff --git a/packages/rpc/src/types.ts b/packages/rpc/src/types.ts new file mode 100644 index 00000000..56179707 --- /dev/null +++ b/packages/rpc/src/types.ts @@ -0,0 +1,50 @@ +import type { ObjectSchema } from "@wrnexus/validation"; + +/** Extract the validated value type from a `v.object(...)` schema. */ +export type InferInput = S extends ObjectSchema ? 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): { + ok: boolean; + value: unknown; + errors: Record; + }; +} + +export interface ProcedureDef { + 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>; + +export interface ServiceContract { + /** Stable service id, used in the mounted path. */ + name: string; + procedures: Procedures; +} + +export type InferProcedureInput

= P extends ProcedureDef ? I : never; +export type InferProcedureOutput

= P extends ProcedureDef ? O : never; + +/** What a transport returns: either a value or a structured failure. */ +export type ServiceResult = + { ok: true; value: T } | { ok: false; code: string; message: string; retryable: boolean }; diff --git a/packages/rpc/test/client.test.ts b/packages/rpc/test/client.test.ts new file mode 100644 index 00000000..4d3ea68d --- /dev/null +++ b/packages/rpc/test/client.test.ts @@ -0,0 +1,217 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { v } from "@wrnexus/validation"; +import { serviceClient } from "../src/client.ts"; +import { defineService, procedure } from "../src/contract.ts"; +import { RPC_ERROR_CODES, ServiceError, failure, success } from "../src/errors.ts"; +import type { RpcTarget } from "../src/transport.ts"; + +const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; +const originalAppName = process.env.WRNEXUS_APP_NAME; +afterEach(() => { + if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; + else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; + if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; + else process.env.WRNEXUS_APP_NAME = originalAppName; +}); + +function configure(appName = "web") { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = appName; +} + +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, { + transport: { call: async () => success({ invoiceId: "inv_1" }) }, + }); + expect(await client.createInvoice({ amountCents: 1 })).toEqual({ invoiceId: "inv_1" }); + }); + + test("throws a ServiceError carrying code and retryable on failure", async () => { + const client = serviceClient(billing, { + transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") }, + }); + let caught: unknown; + try { + await client.createInvoice({ amountCents: 1 }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ServiceError); + expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: true }); + }); + + test("attaches an identity token when given a context and omits it for an anonymous context", async () => { + configure("web"); + let seenIdentity: string | undefined = "unset"; + const client = serviceClient(billing, { + as: { user: { id: "u1" }, locals: {} } as unknown as Context, + transport: { + call: async (_target, _payload, options) => { + seenIdentity = options.identity; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + await client.createInvoice({ amountCents: 1 }); + expect(seenIdentity).toBeTypeOf("string"); + + let seenAnon: unknown = "unset"; + const anonClient = serviceClient(billing, { + transport: { + call: async (_target, _payload, options) => { + seenAnon = options.identity; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + await anonClient.createInvoice({ amountCents: 1 }); + expect(seenAnon).toBeUndefined(); + }); + + test("an undeclared procedure is undefined rather than a function that throws", async () => { + let called = false; + const client = serviceClient(billing, { + transport: { + call: async () => { + called = true; + return success({}); + }, + }, + }); + const proxy = client as unknown as Record; + expect(proxy.deleteEverything).toBeUndefined(); + expect(called).toBe(false); + }); + + test("the proxy has no `then` escape — await/return does not trigger a call", async () => { + let called = false; + const client = serviceClient(billing, { + transport: { + call: async () => { + called = true; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + const proxy = client as unknown as Record; + expect(proxy.then).toBeUndefined(); + expect(proxy.catch).toBeUndefined(); + expect(proxy.finally).toBeUndefined(); + // `await client` must resolve to the proxy object itself, not reject. + const awaited = await client; + expect(awaited).toBe(client); + expect(called).toBe(false); + }); + + test("the signal arrives at the transport", async () => { + let seenSignal: AbortSignal | undefined; + const client = serviceClient(billing, { + transport: { + call: async (_target, _payload, options) => { + seenSignal = options.signal; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + await client.createInvoice({ amountCents: 1 }); + expect(seenSignal).toBeInstanceOf(AbortSignal); + }); + + test("the call aborts at timeoutMs when the transport ignores the signal", async () => { + const client = serviceClient(billing, { + timeoutMs: 20, + transport: { + call: () => new Promise(() => {}), // never resolves; ignores the signal + }, + }); + let caught: unknown; + const start = Date.now(); + try { + await client.createInvoice({ amountCents: 1 }); + } catch (err) { + caught = err; + } + const elapsed = Date.now() - start; + expect(caught).toBeInstanceOf(ServiceError); + expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport }); + expect(elapsed).toBeLessThan(500); + }); + + test("the timer is cleared on both success and failure", async () => { + const originalClearTimeout = globalThis.clearTimeout; + let clearCount = 0; + globalThis.clearTimeout = ((...args: Parameters) => { + clearCount++; + return originalClearTimeout(...args); + }) as typeof clearTimeout; + try { + const okClient = serviceClient(billing, { + transport: { call: async () => success({ invoiceId: "inv_1" }) }, + }); + await okClient.createInvoice({ amountCents: 1 }); + expect(clearCount).toBe(1); + + const failClient = serviceClient(billing, { + transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") }, + }); + await expect(failClient.createInvoice({ amountCents: 1 })).rejects.toBeInstanceOf( + ServiceError, + ); + expect(clearCount).toBe(2); + } finally { + globalThis.clearTimeout = originalClearTimeout; + } + }); + + test("the app defaults to the service name", async () => { + let seenTarget: RpcTarget | undefined; + const client = serviceClient(billing, { + transport: { + call: async (target) => { + seenTarget = target; + return success({ invoiceId: "inv_1" }); + }, + }, + }); + await client.createInvoice({ amountCents: 1 }); + expect(seenTarget?.app).toBe("billing"); + expect(seenTarget?.service).toBe("billing"); + }); + + test("a wire-level non-retryable failure (e.g. a 403) surfaces at the client as retryable: false", async () => { + // RPC_TRANSPORT recomputes to retryable: true from the code alone — that + // is correct for a 5xx, but the transport already determined a 403 is + // NOT retryable via the bounded status check. The client must pass that + // wire value through rather than recompute it from the code. + const client = serviceClient(billing, { + transport: { + call: async () => ({ + ok: false, + code: RPC_ERROR_CODES.transport, + message: "Service returned 403", + retryable: false, + }), + }, + }); + let caught: unknown; + try { + await client.createInvoice({ amountCents: 1 }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ServiceError); + expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: false }); + }); +}); diff --git a/packages/rpc/test/contract.test.ts b/packages/rpc/test/contract.test.ts new file mode 100644 index 00000000..79b781fd --- /dev/null +++ b/packages/rpc/test/contract.test.ts @@ -0,0 +1,73 @@ +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"); + }); +}); diff --git a/packages/rpc/test/errors.test.ts b/packages/rpc/test/errors.test.ts new file mode 100644 index 00000000..61687880 --- /dev/null +++ b/packages/rpc/test/errors.test.ts @@ -0,0 +1,71 @@ +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 and 429 are the callee saying "try again"; everything else is final. + expect(isRetryableStatus(500)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(429)).toBe(true); + expect(isRetryableStatus(599)).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", () => { + for (const status of [600, 1000, 0, -1, Number.NaN]) { + expect(isRetryableStatus(status)).toBe(false); + } + }); + + test("a malformed response is never retryable", () => { + const f = failure(RPC_ERROR_CODES.malformed, "Unexpected response shape"); + expect(f.ok).toBe(false); + if (!f.ok) { + expect(f.code).toBe("RPC_MALFORMED"); + 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"); + } + }); +}); diff --git a/packages/rpc/test/http.test.ts b/packages/rpc/test/http.test.ts new file mode 100644 index 00000000..4d6b6eb8 --- /dev/null +++ b/packages/rpc/test/http.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import { RPC_ERROR_CODES, isRetryableStatus } from "../src/errors.ts"; +import { RPC_IDENTITY_HEADER } from "../src/identity.ts"; +import { RPC_INTERNAL_HEADER, httpTransport, rpcPath } from "../src/http.ts"; + +const target = { app: "billing", service: "billing", procedure: "createInvoice" }; + +function transportWith(handler: (request: Request) => Response | Promise) { + return httpTransport({ + resolveOrigin: () => "http://billing.test", + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => + handler(new Request(input, init))) as typeof fetch, + }); +} + +describe("httpTransport", () => { + test("posts to the private endpoint with identity", async () => { + const transport = transportWith(async (request) => { + expect(request.url).toBe("http://billing.test/__wrnexus/rpc/billing/createInvoice"); + expect(request.headers.get(RPC_IDENTITY_HEADER)).toBe("token"); + expect(request.headers.get("x-wrnexus-internal")).toBe("1"); + expect(await request.json()).toEqual({ amountCents: 5 }); + return Response.json({ ok: true, value: { invoiceId: "inv_1" } }); + }); + expect(await transport.call(target, { amountCents: 5 }, { identity: "token" })).toEqual({ + ok: true, + value: { invoiceId: "inv_1" }, + }); + }); + + test("classifies unavailable and malformed responses safely", async () => { + const unavailable = transportWith(() => new Response("busy", { status: 503 })); + const failed = await unavailable.call(target, {}, {}); + expect(failed).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: true }); + const malformed = transportWith(() => Response.json({ hello: "world" })); + expect(await malformed.call(target, {}, {})).toMatchObject({ + code: RPC_ERROR_CODES.malformed, + retryable: false, + }); + }); + + test("uses the stable reserved path", () => { + expect(rpcPath("billing", "createInvoice")).toBe("/__wrnexus/rpc/billing/createInvoice"); + }); + + test("omits the identity header entirely for an anonymous call", async () => { + const transport = transportWith(async (request) => { + expect(request.headers.has(RPC_IDENTITY_HEADER)).toBe(false); + return Response.json({ ok: true, value: {} }); + }); + await transport.call(target, {}, {}); + }); + + test("sets the internal-marker header", async () => { + const transport = transportWith(async (request) => { + expect(request.headers.get(RPC_INTERNAL_HEADER)).toBe("1"); + return Response.json({ ok: true, value: {} }); + }); + await transport.call(target, {}, {}); + }); + + test("the full retryable-status sweep matches isRetryableStatus", async () => { + // 600 is covered directly on isRetryableStatus in errors.test.ts — the + // Fetch API cannot construct a Response with a status outside 200–599. + const statuses = [200, 400, 403, 404, 408, 409, 429, 500, 503, 599]; + for (const status of statuses) { + if (status === 200) continue; // handled by the success-path test above + const transport = transportWith(() => new Response("x", { status })); + const result = await transport.call(target, {}, {}); + expect(result).toMatchObject({ + ok: false, + code: RPC_ERROR_CODES.transport, + retryable: isRetryableStatus(status), + }); + } + }); + + test("a network throw yields a structured failure with no host/address surviving", async () => { + const transport = httpTransport({ + resolveOrigin: () => "http://internal-billing-host.private:4821", + fetch: (async () => { + throw new TypeError("fetch failed: connect ECONNREFUSED 10.0.0.7:4821"); + }) as unknown as typeof fetch, + }); + const result = await transport.call(target, {}, {}); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.transport }); + if (!result.ok) { + expect(result.message).not.toContain("10.0.0.7"); + expect(result.message).not.toContain("internal-billing-host"); + } + }); + + test("a malformed JSON body yields a structured failure with no body content surviving", async () => { + const transport = transportWith(() => new Response("{not json", { status: 200 })); + const result = await transport.call(target, {}, {}); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.malformed }); + if (!result.ok) expect(result.message).not.toContain("{not json"); + }); + + test("an HTML error page yields a structured failure with no page content surviving", async () => { + const transport = transportWith( + () => + new Response("500 Internal Server Error at db-host-42", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ); + const result = await transport.call(target, {}, {}); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.malformed }); + if (!result.ok) expect(result.message).not.toContain("db-host-42"); + }); + + test("the AbortSignal reaches fetch", async () => { + const controller = new AbortController(); + let seenSignal: AbortSignal | undefined; + const transport = transportWith(async (request) => { + seenSignal = request.signal; + return Response.json({ ok: true, value: {} }); + }); + await transport.call(target, {}, { signal: controller.signal }); + expect(seenSignal).toBeDefined(); + }); +}); diff --git a/packages/rpc/test/identity.test.ts b/packages/rpc/test/identity.test.ts new file mode 100644 index 00000000..cce68f32 --- /dev/null +++ b/packages/rpc/test/identity.test.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { signJwt } from "@wrnexus/jwt"; +import { exportSubjectContext, importSubjectContext } from "../src/identity.ts"; + +const SECRET = "test-rpc-secret-at-least-32-chars-long"; +const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; +const originalAppName = process.env.WRNEXUS_APP_NAME; + +afterEach(() => { + if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; + else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; + if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; + else process.env.WRNEXUS_APP_NAME = originalAppName; +}); + +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 forged token with no exp is refused", async () => { + configure(); + // verifyJwt only checks exp when present, so a token minted without one + // never expires unless importSubjectContext requires it explicitly. + const token = await signJwt({ sub: "u1" }, SECRET, { issuer: "web", audience: "billing" }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(/expiry/i); + }); + + test("a forged token with no iat is refused", async () => { + configure(); + // signJwt always sets iat unless the payload explicitly overrides it with + // undefined (JSON.stringify then drops the key). verifyJwt's maxAge check + // is gated on iat being a number, so this would otherwise defeat the age + // bound at ANY maxAgeSeconds. + const token = await signJwt({ sub: "u1", iat: undefined }, SECRET, { + issuer: "web", + audience: "billing", + expiresIn: 60, + }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(/issued-at/i); + }); + + 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("an empty selfApp is refused rather than disabling the audience check", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); + // verifyJwt skips the audience check when audience is undefined, so this + // would otherwise accept every token from every app. + for (const bad of [undefined, "", null]) { + await expect(importSubjectContext(token!, bad as never)).rejects.toThrow(/selfApp/); + } + }); + + test("a token with a huge ttl is still rejected once it exceeds maxAge", async () => { + configure(); + const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing", { + ttlSeconds: 31_536_000, + }); + await expect(importSubjectContext(token!, "billing", { maxAgeSeconds: -1 })).rejects.toThrow(); + }); + + test("a backdated iat is refused under the default max age, and accepted once maxAgeSeconds is raised", async () => { + configure(); + // Age 400s, older than DEFAULT_MAX_AGE_SECONDS (300s). Calling with NO + // options (the real default) must reject -- unlike the huge-ttl test + // above, which always passes an explicit maxAgeSeconds: -1 and so never + // actually exercises the 300s constant itself. + const now = Math.floor(Date.now() / 1000); + const token = await signJwt({ sub: "u1" }, SECRET, { + issuer: "web", + audience: "billing", + expiresIn: 1000, + now: now - 400, + }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(); + // Same token, wider explicit bound: proves maxAgeSeconds is actually + // wired through rather than the guard being a hardcoded rejection. + await expect( + importSubjectContext(token, "billing", { maxAgeSeconds: 1000 }), + ).resolves.toBeDefined(); + }); + + test("an array targetApp is refused, so no token is valid at two apps", async () => { + configure(); + await expect( + exportSubjectContext(ctxFor({ id: "u1" }), ["billing", "reports"] as never), + ).rejects.toThrow(/targetApp/); + }); + + test("a token signed with an array audience is refused on import", async () => { + configure(); + // verifyJwt compares audience with includes(), so a token signed with + // audience: ["billing", "reports"] would verify at BOTH apps -- exactly + // what the audience binding exists to prevent -- unless importSubjectContext + // requires an exact single-audience match itself. + const token = await signJwt({ sub: "u1" }, SECRET, { + issuer: "web", + audience: ["billing", "reports"], + expiresIn: 60, + }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(); + }); + + test("a non-string tenant id is refused rather than silently dropped", async () => { + configure(); + // Silently dropping it leaves the callee reading "no tenant" as "global". + for (const tenant of [42, {}, ""]) { + await expect( + exportSubjectContext( + { user: { id: "u1" }, tenant: { id: tenant }, locals: {} } as unknown as Context, + "billing", + ), + ).rejects.toThrow(/tenant/i); + } + }); + + test("ctx.tenant = { id: null } is refused at mint rather than minting untenanted", async () => { + configure(); + // Unlike rawId === null (mints no token at all), a present tenant with an + // unusable id must not silently issue an authenticated credential with + // widened (untenanted/global) scope. + await expect( + exportSubjectContext( + { user: { id: "u1" }, tenant: { id: null }, locals: {} } as unknown as Context, + "billing", + ), + ).rejects.toThrow(/tenant/i); + }); + + test("a forged token whose tenant claim is a number is refused on import", async () => { + configure(); + const token = await signJwt({ sub: "u1", tenant: 42 }, SECRET, { + issuer: "web", + audience: "billing", + expiresIn: 60, + }); + await expect(importSubjectContext(token, "billing")).rejects.toThrow(/tenant/i); + }); + + 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/); + }); +}); diff --git a/packages/rpc/test/integration.test.ts b/packages/rpc/test/integration.test.ts new file mode 100644 index 00000000..77d28dbb --- /dev/null +++ b/packages/rpc/test/integration.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as http from "node:http"; +import type { Context } from "@wrnexus/core"; +import { v } from "@wrnexus/validation"; +import { serviceClient } from "../src/client.ts"; +import { defineService, procedure } from "../src/contract.ts"; +import { RPC_ERROR_CODES } from "../src/errors.ts"; +import { httpTransport } from "../src/http.ts"; +import { implement } from "../src/server.ts"; +import { inProcessTransport } from "../src/transport.ts"; +import { handleRpcRequest } from "../../dev-server/src/rpc-dispatch.ts"; + +const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; +const originalAppName = process.env.WRNEXUS_APP_NAME; +afterEach(() => { + if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; + else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; + if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; + else process.env.WRNEXUS_APP_NAME = originalAppName; +}); + +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, + { + createInvoice: async ({ amountCents }, ctx) => ({ + invoiceId: `inv_${amountCents}`, + forSubject: ctx.subject?.subjectId ?? "anon", + }), + }, + { selfApp: "billing", checkPermission: async () => allowed }, + ); + return inProcessTransport({ + "billing/createInvoice": (payload, identity) => + service.invoke("createInvoice", payload, identity), + }); +} + +function httpWire(allowed: boolean) { + const service = implement( + billing, + { + createInvoice: async ({ amountCents }, ctx) => ({ + invoiceId: `inv_${amountCents}`, + forSubject: ctx.subject?.subjectId ?? "anon", + }), + }, + { selfApp: "billing", checkPermission: async () => allowed }, + ); + return httpTransport({ + resolveOrigin: () => "http://billing.internal", + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + return (await handleRpcRequest( + request, + new URL(request.url), + new Map([["billing", service]]), + ))!; + }) as typeof fetch, + }); +} + +// packages/csr's actions.test.ts deletes globalThis.fetch (and restores its +// own captured copy) around each of its tests, and packages/csr's +// reactive.test.ts's "cache invalidation refetches matching client Async +// boundaries" test replaces globalThis.fetch with a mock and never restores +// it at all. bun test runs test files sequentially — importing a file and +// running its tests before moving to the next — rather than importing every +// file up front, so a module-level `const realFetch = fetch` captured here +// would already observe whatever packages/csr left behind by the time this +// file (which sorts after csr) is loaded. The variable under test below is +// `resolveOrigin`, not `fetch`, so instead of depending on the shared +// `globalThis.fetch` at all, this makes a real request over a real socket +// using node:http directly. That keeps the assertion about +// httpTransport()'s default origin resolution intact regardless of what any +// other suite does to the global `fetch` binding. +function realFetch(url: string, init: RequestInit): Promise { + return new Promise((resolve, reject) => { + const target = new URL(url); + const req = http.request( + { + hostname: target.hostname, + port: target.port, + path: `${target.pathname}${target.search}`, + method: init.method ?? "GET", + headers: init.headers as Record, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("error", reject); + res.on("end", () => { + resolve( + new Response(Buffer.concat(chunks), { + status: res.statusCode ?? 500, + }), + ); + }); + }, + ); + req.on("error", reject); + if (typeof init.body === "string") req.write(init.body); + req.end(); + }); +} + +describe("RPC integration", () => { + test("propagates identity and validates the callee permission", 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("denies when the callee permission check refuses", 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), + }); + await expect(client.createInvoice({ amountCents: 1 })).rejects.toMatchObject({ + code: RPC_ERROR_CODES.denied, + }); + }); + + test("uses the real HTTP transport and private dispatcher end to end", 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: httpWire(true), + }); + expect(await client.createInvoice({ amountCents: 7 })).toEqual({ + invoiceId: "inv_7", + forSubject: "u1", + }); + await expect(client.createInvoice({ amountCents: "bad" } as never)).rejects.toMatchObject({ + code: RPC_ERROR_CODES.invalid, + }); + }); + + test("the default httpTransport reaches the callee over a real socket via the internal origin", async () => { + const service = implement( + billing, + { + createInvoice: async ({ amountCents }, ctx) => ({ + invoiceId: `inv_${amountCents}`, + forSubject: ctx.subject?.subjectId ?? "anon", + }), + }, + { selfApp: "billing", checkPermission: async () => true }, + ); + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const url = new URL(req.url); + const res = await handleRpcRequest(req, url, new Map([["billing", service]])); + return res ?? new Response("Not found", { status: 404 }); + }, + }); + const originalWorkspace = process.env.WRNEXUS_WORKSPACE_ORIGINS; + const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS; + try { + // The workspace (public) origin deliberately points somewhere that + // cannot serve the RPC — the gateway 404s the RPC prefix on any + // request that arrives at a public origin. Only the internal-origin + // map points at the real server. If httpTransport() ever falls back + // to the public origin by default again, this call fails. + process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({ + billing: "http://127.0.0.1:1", // unroutable — nothing listens here + }); + process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({ + billing: `http://127.0.0.1:${server.port}`, + }); + const client = serviceClient(billing, { + app: "billing", + // no resolveOrigin override — uses the real default; fetch is pinned + // to the node:http-backed implementation above (see comment there). + transport: httpTransport({ fetch: realFetch as unknown as typeof fetch }), + }); + expect(await client.createInvoice({ amountCents: 42 })).toEqual({ + invoiceId: "inv_42", + forSubject: "anon", + }); + } finally { + server.stop(true); + if (originalWorkspace === undefined) delete process.env.WRNEXUS_WORKSPACE_ORIGINS; + else process.env.WRNEXUS_WORKSPACE_ORIGINS = originalWorkspace; + if (originalInternal === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS; + else process.env.WRNEXUS_INTERNAL_ORIGINS = originalInternal; + } + }); +}); diff --git a/packages/rpc/test/server.test.ts b/packages/rpc/test/server.test.ts new file mode 100644 index 00000000..03bd8e03 --- /dev/null +++ b/packages/rpc/test/server.test.ts @@ -0,0 +1,247 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { v } from "@wrnexus/validation"; +import { defineService, procedure } from "../src/contract.ts"; +import { RPC_ERROR_CODES } from "../src/errors.ts"; +import { exportSubjectContext } from "../src/identity.ts"; +import { implement } from "../src/server.ts"; + +const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; +const originalAppName = process.env.WRNEXUS_APP_NAME; +afterEach(() => { + if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; + else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; + if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; + else process.env.WRNEXUS_APP_NAME = originalAppName; +}); + +function configure(appName = "web") { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = appName; +} + +const openContract = defineService({ + name: "demo", + procedures: { + add: procedure + .input(v.object({ a: v.number() })) + .output<{ a: number }>() + .build(), + }, +}); + +const guardedContract = defineService({ + name: "billing", + procedures: { + createInvoice: procedure + .input(v.object({ amountCents: v.number() })) + .output<{ invoiceId: string }>() + .permission("invoice:create") + .build(), + }, +}); + +describe("implement()", () => { + test("invokes with validated input", async () => { + const service = implement( + openContract, + { add: async ({ a }) => ({ a: a + 1 }) }, + { selfApp: "demo" }, + ); + const result = await service.invoke("add", { a: 1 }); + expect(result).toEqual({ ok: true, value: { a: 2 } }); + }); + + test("coerces through the schema", async () => { + let received: unknown; + const coercing = defineService({ + name: "demo2", + procedures: { + add: procedure + .input(v.object({ a: v.number() })) + .output<{ a: number }>() + .build(), + }, + }); + const service = implement( + coercing, + { + add: async (input) => { + received = input; + return { a: (input as { a: number }).a }; + }, + }, + { selfApp: "demo2" }, + ); + await service.invoke("add", { a: "3" }); + expect(received).toEqual({ a: 3 }); + }); + + test("rejects schema-invalid input without invoking the handler", async () => { + let called = false; + const service = implement( + openContract, + { + add: async ({ a }) => { + called = true; + return { a }; + }, + }, + { selfApp: "demo" }, + ); + const result = await service.invoke("add", { a: "not-a-number" }); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.invalid }); + }); + + test("unknown procedure refused", async () => { + const service = implement(openContract, { add: async ({ a }) => ({ a }) }, { selfApp: "demo" }); + const result = await service.invoke("subtract", {}); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.unknown }); + }); + + test("handler throw becomes opaque; a secret in the message does not survive", async () => { + const secret = "sk-super-secret-db-password"; + const service = implement( + openContract, + { + add: async () => { + throw new Error(`db error using ${secret}`); + }, + }, + { selfApp: "demo" }, + ); + const result = await service.invoke("add", { a: 1 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(RPC_ERROR_CODES.handler); + expect(result.message).not.toContain(secret); + } + }); + + test("a declared permission is enforced before the handler runs", async () => { + let called = false; + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }) => { + called = true; + return { invoiceId: `inv_${amountCents}` }; + }, + }, + { selfApp: "billing", checkPermission: async () => false }, + ); + const result = await service.invoke("createInvoice", { amountCents: 5 }); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.denied }); + }); + + test("a permission check that throws denies", async () => { + let called = false; + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }) => { + called = true; + return { invoiceId: `inv_${amountCents}` }; + }, + }, + { + selfApp: "billing", + checkPermission: async () => { + throw new Error("permission store unavailable"); + }, + }, + ); + const result = await service.invoke("createInvoice", { amountCents: 5 }); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.denied }); + }); + + test("a declared permission with no checkPermission configured denies (fail closed)", async () => { + let called = false; + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }) => { + called = true; + return { invoiceId: `inv_${amountCents}` }; + }, + }, + { selfApp: "billing" }, + ); + const result = await service.invoke("createInvoice", { amountCents: 5 }); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.denied }); + }); + + test("a valid identity token reaches the handler as a subject", async () => { + configure("web"); + const token = await exportSubjectContext( + { user: { id: "u1" }, locals: {} } as never, + "billing", + ); + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }, ctx) => ({ + invoiceId: `${ctx.subject?.subjectId}_${amountCents}`, + }), + }, + { selfApp: "billing", checkPermission: async () => true }, + ); + const result = await service.invoke("createInvoice", { amountCents: 5 }, token); + expect(result).toEqual({ ok: true, value: { invoiceId: "u1_5" } }); + }); + + test("a bad identity token is refused rather than downgraded to anonymous", async () => { + configure("web"); + let called = false; + const service = implement( + openContract, + { + add: async ({ a }, ctx) => { + called = true; + expect(ctx.subject).toBeUndefined(); + return { a }; + }, + }, + { selfApp: "demo" }, + ); + const result = await service.invoke("add", { a: 1 }, "garbage-token"); + expect(called).toBe(false); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.identity }); + }); + + describe("C1: prototype-chain procedure names cannot bypass the permission gate", () => { + const PROTO_NAMES = [ + "constructor", + "toString", + "valueOf", + "hasOwnProperty", + "__proto__", + "isPrototypeOf", + ]; + + for (const name of PROTO_NAMES) { + test(`"${name}" resolves as unknown, not as a handler`, async () => { + let permissionChecked = false; + const service = implement( + guardedContract, + { + createInvoice: async ({ amountCents }) => ({ invoiceId: `inv_${amountCents}` }), + }, + { + selfApp: "billing", + checkPermission: async () => { + permissionChecked = true; + return false; + }, + }, + ); + const result = await service.invoke(name, { amountCents: 1 }); + expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.unknown }); + expect(permissionChecked).toBe(false); + }); + } + }); +}); diff --git a/packages/rpc/test/transport.test.ts b/packages/rpc/test/transport.test.ts new file mode 100644 index 00000000..43ad6ed6 --- /dev/null +++ b/packages/rpc/test/transport.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { RPC_ERROR_CODES, success } from "../src/errors.ts"; +import { inProcessTransport } from "../src/transport.ts"; + +describe("inProcessTransport", () => { + test("routes to the right handler and passes identity through", async () => { + let seenIdentity: string | undefined; + const transport = inProcessTransport({ + "billing/createInvoice": (payload, identity) => { + seenIdentity = identity; + return success({ echoed: payload }); + }, + }); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "createInvoice" }, + { amountCents: 1 }, + { identity: "tok-123" }, + ); + expect(result).toEqual({ ok: true, value: { echoed: { amountCents: 1 } } }); + expect(seenIdentity).toBe("tok-123"); + }); + + test("an unregistered procedure yields non-retryable RPC_UNKNOWN", async () => { + const transport = inProcessTransport({}); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "nope" }, + {}, + {}, + ); + expect(result).toEqual({ + ok: false, + code: RPC_ERROR_CODES.unknown, + message: "Unknown procedure", + retryable: false, + }); + }); + + test("an already-aborted signal fails without invoking the handler", async () => { + let called = false; + const transport = inProcessTransport({ + "billing/createInvoice": () => { + called = true; + return success({}); + }, + }); + const controller = new AbortController(); + controller.abort(); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "createInvoice" }, + {}, + { signal: controller.signal }, + ); + expect(called).toBe(false); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ code: RPC_ERROR_CODES.transport }); + }); + + test("a handler that throws becomes an opaque failure with no leaked message text", async () => { + const secret = "sk-super-secret-database-password-xyz"; + const transport = inProcessTransport({ + "billing/createInvoice": () => { + throw new Error(`connection failed with credential ${secret}`); + }, + }); + const result = await transport.call( + { app: "billing", service: "billing", procedure: "createInvoice" }, + {}, + {}, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(RPC_ERROR_CODES.handler); + expect(result.message).not.toContain(secret); + expect(result.message).not.toContain("connection failed"); + } + }); +}); diff --git a/packages/rpc/test/types.test.ts b/packages/rpc/test/types.test.ts new file mode 100644 index 00000000..b6707d06 --- /dev/null +++ b/packages/rpc/test/types.test.ts @@ -0,0 +1,23 @@ +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 = { 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"]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 5f00913d..d902e4b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -75,6 +75,7 @@ "@wrnexus/ui/component-reference.json": ["./packages/ui/component-reference.json"], "@wrnexus/ui/ui.css": ["./packages/ui/ui.css"], "@wrnexus/validation": ["./packages/validation/src/index.ts"], + "@wrnexus/rpc": ["./packages/rpc/src/index.ts"], "@wrnexus/ai/*": ["./packages/ai/src/*.ts"], "@wrnexus/authz/*": ["./packages/authz/src/*.ts"], "@wrnexus/cli/*": ["./packages/cli/src/*.ts"],