@zudojs/rpc 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Type-safe RPC — define procedures, apply middleware, dispatch calls, and serve them over your own transport.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-rpc](https://zudojs.oyinlola.site/docs/packages-rpc) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-rpc.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## When to use
6
12
 
7
13
  Import this when you need:
@@ -100,25 +106,38 @@ A failed call rejects with a typed error rebuilt from the wire code
100
106
  (`RPCTimeoutError`, `RPCCancelledError`, `RPCUnavailableError`, or an
101
107
  `RPCError` carrying the server's `code` and `details`).
102
108
 
103
- ### Middleware
109
+ ### Middleware and trusted identity
110
+
111
+ Everything in a frame, `metadata` included, is written by the caller: any
112
+ client can send `metadata: { userId: "admin" }`. Never authorise on it.
113
+ Identity your transport has verified (a checked bearer token, an mTLS peer,
114
+ a server-side session) goes in the second argument of `handle`, and reaches
115
+ middleware and handlers as the frozen `context.auth`:
104
116
 
105
117
  ```typescript
106
118
  import { RPCMiddlewareStack, RPCAuthenticationError } from "@zudojs/rpc";
107
119
 
108
120
  const stack = new RPCMiddlewareStack([
109
121
  async (context, next) => {
110
- if (context.metadata.userId === undefined) {
122
+ if (typeof context.auth?.userId !== "string") {
111
123
  throw new RPCAuthenticationError("Sign in first.");
112
124
  }
125
+ context.set("actor", context.auth.userId);
113
126
  return next();
114
127
  },
115
128
  ]);
116
129
 
117
130
  const server = new RPCServer(undefined, stack);
131
+
132
+ // In the transport, after verifying the caller's credentials yourself:
133
+ await server.handle(frame, { auth: { userId: verifiedUserId } });
118
134
  ```
119
135
 
120
136
  Each middleware may call `next()` once. Input validation runs before the
121
- stack, so middleware sees a payload the procedure's schema has accepted.
137
+ stack. `context.input` holds the payload as the procedure's schema parsed
138
+ it (unknown keys stripped, defaults applied, values coerced), so authorise
139
+ on `context.input`, not on `context.request.payload`, which stays the raw
140
+ frame value.
122
141
 
123
142
  ## Errors
124
143
 
package/dist/index.d.ts CHANGED
@@ -29,7 +29,7 @@ export { RPCError, RPCProcedureNotFoundError, RPCInvalidRequestError, RPCValidat
29
29
  export type { RPCHandler, RPCProcedure, RPCProcedureOptions, } from "./rpc/procedure/index.js";
30
30
  export { createRPCProcedure } from "./rpc/procedure/index.js";
31
31
  export { RPCProcedureRegistry, RPCProcedureRouter, } from "./rpc/procedure/index.js";
32
- export type { RPCContext } from "./rpc/context/index.js";
32
+ export type { RPCAuthContext, RPCContext, RPCContextOptions, } from "./rpc/context/index.js";
33
33
  export { createRPCContext } from "./rpc/context/index.js";
34
34
  export type { RPCMiddleware } from "./rpc/middleware/index.js";
35
35
  export { RPCMiddlewareStack } from "./rpc/middleware/index.js";
@@ -1,3 +1,7 @@
1
- export type { RPCContext } from "./rpcContext.type.js";
1
+ /**
2
+ * RPC execution context: per-call state, the caller's (untrusted) frame
3
+ * metadata, the transport's trusted `auth`, and the validated `input`.
4
+ */
5
+ export type { RPCAuthContext, RPCContext, RPCContextOptions, } from "./rpcContext.type.js";
2
6
  export { createRPCContext } from "./rpcContext.type.js";
3
7
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,6 @@
1
+ /**
2
+ * RPC execution context: per-call state, the caller's (untrusted) frame
3
+ * metadata, the transport's trusted `auth`, and the validated `input`.
4
+ */
1
5
  export { createRPCContext } from "./rpcContext.type.js";
2
6
  //# sourceMappingURL=index.js.map
@@ -1,18 +1,57 @@
1
1
  import type { RPCMetadata } from "../types/rpcMetadata.type.js";
2
2
  import type { RPCRequest } from "../types/rpcRequest.type.js";
3
+ /**
4
+ * Identity and other facts established by the transport (a verified
5
+ * bearer token, an mTLS peer, a session looked up server-side).
6
+ *
7
+ * Unlike frame `metadata`, which the caller writes, this is supplied by
8
+ * the server's own code through `RPCServer.handle(request, { auth })`, so
9
+ * it is the only context field safe to authorise on.
10
+ */
11
+ export type RPCAuthContext = Readonly<Record<string, unknown>>;
12
+ /**
13
+ * Extra, server-supplied values for {@link createRPCContext}.
14
+ */
15
+ export interface RPCContextOptions {
16
+ /** Trusted, transport-derived identity. See {@link RPCAuthContext}. */
17
+ readonly auth?: RPCAuthContext;
18
+ }
3
19
  /**
4
20
  * Context passed through the RPC execution pipeline.
5
21
  */
6
22
  export interface RPCContext {
7
23
  readonly request: RPCRequest;
24
+ /**
25
+ * Frame metadata exactly as the caller sent it. Untrusted: any client
26
+ * can set `userId`, `tenantId` or any other key. Authorise on
27
+ * {@link RPCContext.auth} instead.
28
+ */
8
29
  readonly metadata: RPCMetadata;
30
+ /**
31
+ * Trusted identity supplied by the transport through
32
+ * `RPCServer.handle(request, { auth })`; `undefined` when none was given.
33
+ */
34
+ readonly auth: RPCAuthContext | undefined;
35
+ /**
36
+ * The payload after the procedure's input schema has parsed it
37
+ * (stripped, defaulted, coerced), or the raw payload when the procedure
38
+ * declares no input schema. `undefined` until validation has run, i.e.
39
+ * in an interceptor before it calls `next()`. `request.payload` always
40
+ * stays the raw, unvalidated value.
41
+ */
42
+ readonly input: unknown;
9
43
  readonly signal: AbortSignal;
10
44
  readonly state: Map<string, unknown>;
11
45
  get<T>(key: string): T | undefined;
12
46
  set<T>(key: string, value: T): void;
13
47
  }
48
+ /**
49
+ * Records the validated input on a context. Called by the dispatcher
50
+ * once, after input validation and before the middleware stack runs.
51
+ */
52
+ export declare function bindRPCContextInput(context: RPCContext, input: unknown): void;
14
53
  /**
15
54
  * Creates a new RPC context.
16
55
  */
17
- export declare function createRPCContext(request: RPCRequest, signal: AbortSignal): RPCContext;
56
+ export declare function createRPCContext(request: RPCRequest, signal: AbortSignal, options?: RPCContextOptions): RPCContext;
18
57
  //# sourceMappingURL=rpcContext.type.d.ts.map
@@ -1,13 +1,26 @@
1
+ const inputs = new WeakMap();
2
+ /**
3
+ * Records the validated input on a context. Called by the dispatcher
4
+ * once, after input validation and before the middleware stack runs.
5
+ */
6
+ export function bindRPCContextInput(context, input) {
7
+ inputs.set(context, input);
8
+ }
1
9
  /**
2
10
  * Creates a new RPC context.
3
11
  */
4
- export function createRPCContext(request, signal) {
12
+ export function createRPCContext(request, signal, options = {}) {
5
13
  const state = new Map();
14
+ const auth = options.auth === undefined ? undefined : Object.freeze({ ...options.auth });
6
15
  const context = {
7
16
  request,
8
17
  // A frame decoded from JSON may omit `metadata`; middleware reads
9
18
  // `context.metadata.userId` and the like without guarding.
10
19
  metadata: request.metadata ?? {},
20
+ auth,
21
+ get input() {
22
+ return inputs.get(context);
23
+ },
11
24
  signal,
12
25
  state,
13
26
  get(key) {
@@ -3,6 +3,7 @@ import type { RPCResponse } from "../types/rpcResponse.type.js";
3
3
  import type { RPCProcedure } from "../procedure/rpcProcedure.type.js";
4
4
  import type { RPCMiddlewareStack } from "../middleware/rpcMiddleware.core.js";
5
5
  import type { RPCInterceptor } from "../interceptor/rpcInterceptor.type.js";
6
+ import type { RPCContextOptions } from "../context/rpcContext.type.js";
6
7
  /**
7
8
  * Options controlling dispatch.
8
9
  */
@@ -46,8 +47,12 @@ export declare class RPCDispatcher {
46
47
  }, middleware: RPCMiddlewareStack, options?: RPCDispatcherOptions);
47
48
  /**
48
49
  * Dispatches an RPC request.
50
+ *
51
+ * `trusted` carries server-supplied context (`auth`) that the caller
52
+ * cannot forge; it is exposed to middleware and handlers as
53
+ * `context.auth`.
49
54
  */
50
- dispatch(input: RPCRequest): Promise<RPCResponse>;
55
+ dispatch(input: RPCRequest, trusted?: RPCContextOptions): Promise<RPCResponse>;
51
56
  /**
52
57
  * Runs the interceptor chain around the dispatch.
53
58
  *
@@ -1,4 +1,4 @@
1
- import { createRPCContext } from "../context/rpcContext.type.js";
1
+ import { bindRPCContextInput, createRPCContext, } from "../context/rpcContext.type.js";
2
2
  import { createRPCResponse } from "../types/rpcResponse.type.js";
3
3
  import { RPCCancelledError } from "../errors/rpc.errors.js";
4
4
  import { DEFAULT_RPC_TIMEOUT } from "../constants/rpcConstants.core.js";
@@ -26,15 +26,19 @@ export class RPCDispatcher {
26
26
  }
27
27
  /**
28
28
  * Dispatches an RPC request.
29
+ *
30
+ * `trusted` carries server-supplied context (`auth`) that the caller
31
+ * cannot forge; it is exposed to middleware and handlers as
32
+ * `context.auth`.
29
33
  */
30
- async dispatch(input) {
34
+ async dispatch(input, trusted = {}) {
31
35
  // Tolerate a frame without `metadata`: the field is optional when a
32
36
  // request is built by hand or decoded from JSON, and everything below
33
37
  // — deadline reading, the context's `metadata` — reads it as an object.
34
38
  const request = input.metadata === undefined ? { ...input, metadata: {} } : input;
35
39
  const procedure = this.registry.require(request.procedure);
36
40
  const controller = new AbortController();
37
- const context = createRPCContext(request, controller.signal);
41
+ const context = createRPCContext(request, controller.signal, trusted);
38
42
  const timeoutMs = this.resolveTimeout(request, procedure);
39
43
  // A deadline already in the past is rejected before any work runs.
40
44
  if (this.options.honourDeadline ?? true) {
@@ -49,6 +53,7 @@ export class RPCDispatcher {
49
53
  const input = procedure.options?.input
50
54
  ? parseInput(procedure.options.input, request.payload, request.procedure)
51
55
  : request.payload;
56
+ bindRPCContextInput(context, input);
52
57
  const result = await this.middleware.execute(context, async () => {
53
58
  return procedure.handler(input, context);
54
59
  });
@@ -3,6 +3,7 @@ import type { RPCResponse } from "../types/rpcResponse.type.js";
3
3
  import type { RPCProcedure } from "../procedure/rpcProcedure.type.js";
4
4
  import { RPCProcedureRegistry } from "../procedure/rpcProcedureRegistry.core.js";
5
5
  import { RPCMiddlewareStack } from "../middleware/rpcMiddleware.core.js";
6
+ import type { RPCContextOptions } from "../context/rpcContext.type.js";
6
7
  import type { RPCDispatcherOptions } from "../dispatcher/rpcDispatcher.core.js";
7
8
  import type { RPCRequestLimits } from "../validation/rpcValidation.core.js";
8
9
  /**
@@ -49,8 +50,13 @@ export declare class RPCServer {
49
50
  * `onInternalError` and answered with a fixed message: internal
50
51
  * exception text can name hosts, paths, credentials or queries, and
51
52
  * the caller is an untrusted peer.
53
+ *
54
+ * Everything in `request` comes from that peer, including
55
+ * `request.metadata.userId`. Identity the transport has verified goes
56
+ * in `trusted.auth` and reaches middleware and handlers as
57
+ * `context.auth`; authorise on that, never on frame metadata.
52
58
  */
53
- handle(request: RPCRequest): Promise<RPCResponse>;
59
+ handle(request: RPCRequest, trusted?: RPCContextOptions): Promise<RPCResponse>;
54
60
  /**
55
61
  * Returns the procedure registry.
56
62
  */
@@ -65,8 +65,13 @@ export class RPCServer {
65
65
  * `onInternalError` and answered with a fixed message: internal
66
66
  * exception text can name hosts, paths, credentials or queries, and
67
67
  * the caller is an untrusted peer.
68
+ *
69
+ * Everything in `request` comes from that peer, including
70
+ * `request.metadata.userId`. Identity the transport has verified goes
71
+ * in `trusted.auth` and reaches middleware and handlers as
72
+ * `context.auth`; authorise on that, never on frame metadata.
68
73
  */
69
- async handle(request) {
74
+ async handle(request, trusted = {}) {
70
75
  const requestId = typeof request?.id === "string"
71
76
  ? request.id
72
77
  : "";
@@ -77,7 +82,7 @@ export class RPCServer {
77
82
  // reads it as an object, so a frame without it used to fail with a
78
83
  // TypeError reported as an internal error.
79
84
  const frame = request.metadata === undefined ? { ...request, metadata: {} } : request;
80
- return await this.dispatcher.dispatch(frame);
85
+ return await this.dispatcher.dispatch(frame, trusted);
81
86
  }
82
87
  catch (error) {
83
88
  const mapped = this.mapError(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/rpc",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Remote procedure call infrastructure for Zudojs applications.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,10 +27,10 @@
27
27
  "node": ">=24.0.0"
28
28
  },
29
29
  "dependencies": {
30
- "@zudojs/errors": "1.0.1",
31
- "@zudojs/constants": "1.0.1",
32
- "@zudojs/types": "1.0.0",
33
- "@zudojs/schema": "1.0.1"
30
+ "@zudojs/errors": "1.1.0",
31
+ "@zudojs/constants": "1.1.0",
32
+ "@zudojs/types": "1.1.0",
33
+ "@zudojs/schema": "1.1.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "7.0.2",