@procedures/client 0.8.0 → 0.8.1

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.
Files changed (188) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +95 -8
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts +4 -0
  5. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/client/transport.js +11 -1
  7. package/dist/adapters/http/client/transport.js.map +1 -1
  8. package/dist/adapters/http/client/types.d.ts +19 -0
  9. package/dist/adapters/http/client/types.d.ts.map +1 -1
  10. package/dist/adapters/http/server/cors.d.ts +64 -0
  11. package/dist/adapters/http/server/cors.d.ts.map +1 -0
  12. package/dist/adapters/http/server/cors.js +113 -0
  13. package/dist/adapters/http/server/cors.js.map +1 -0
  14. package/dist/adapters/http/server/handler.d.ts +136 -0
  15. package/dist/adapters/http/server/handler.d.ts.map +1 -0
  16. package/dist/adapters/http/server/handler.js +495 -0
  17. package/dist/adapters/http/server/handler.js.map +1 -0
  18. package/dist/adapters/http/server/strategies.d.ts +14 -5
  19. package/dist/adapters/http/server/strategies.d.ts.map +1 -1
  20. package/dist/adapters/http/server/strategies.js.map +1 -1
  21. package/dist/adapters/http/server/transport.d.ts +30 -46
  22. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  23. package/dist/adapters/http/server/transport.js +48 -432
  24. package/dist/adapters/http/server/transport.js.map +1 -1
  25. package/dist/adapters/http/server/types.d.ts +13 -0
  26. package/dist/adapters/http/server/types.d.ts.map +1 -1
  27. package/dist/adapters/websocket/server/transport.d.ts +46 -1
  28. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  29. package/dist/adapters/websocket/server/transport.js +152 -15
  30. package/dist/adapters/websocket/server/transport.js.map +1 -1
  31. package/dist/adapters/websocket/server/types.d.ts +10 -3
  32. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  33. package/dist/adapters/websocket/server/upgrade-router.d.ts +28 -0
  34. package/dist/adapters/websocket/server/upgrade-router.d.ts.map +1 -0
  35. package/dist/adapters/websocket/server/upgrade-router.js +62 -0
  36. package/dist/adapters/websocket/server/upgrade-router.js.map +1 -0
  37. package/dist/adapters/websocket/server/upgrade.d.ts +68 -0
  38. package/dist/adapters/websocket/server/upgrade.d.ts.map +1 -0
  39. package/dist/adapters/websocket/server/upgrade.js +49 -0
  40. package/dist/adapters/websocket/server/upgrade.js.map +1 -0
  41. package/dist/adapters/websocket/shared/rpc.d.ts +2 -0
  42. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  43. package/dist/adapters/websocket/shared/rpc.js +8 -2
  44. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  45. package/dist/browser.d.ts +12 -4
  46. package/dist/browser.d.ts.map +1 -1
  47. package/dist/browser.js +11 -2
  48. package/dist/browser.js.map +1 -1
  49. package/dist/client/client.d.ts +43 -0
  50. package/dist/client/client.d.ts.map +1 -1
  51. package/dist/client/client.js +69 -1
  52. package/dist/client/client.js.map +1 -1
  53. package/dist/events/publisher.d.ts +14 -0
  54. package/dist/events/publisher.d.ts.map +1 -1
  55. package/dist/events/publisher.js +42 -4
  56. package/dist/events/publisher.js.map +1 -1
  57. package/dist/events/rows.d.ts +31 -0
  58. package/dist/events/rows.d.ts.map +1 -0
  59. package/dist/events/rows.js +39 -0
  60. package/dist/events/rows.js.map +1 -0
  61. package/dist/index.d.ts +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/node.d.ts +14 -0
  66. package/dist/node.d.ts.map +1 -1
  67. package/dist/node.js +9 -0
  68. package/dist/node.js.map +1 -1
  69. package/dist/procedures/capability.d.ts +6 -0
  70. package/dist/procedures/capability.d.ts.map +1 -1
  71. package/dist/procedures/capability.js +25 -0
  72. package/dist/procedures/capability.js.map +1 -1
  73. package/dist/procedures/collection/changes.d.ts +94 -0
  74. package/dist/procedures/collection/changes.d.ts.map +1 -0
  75. package/dist/procedures/collection/changes.js +182 -0
  76. package/dist/procedures/collection/changes.js.map +1 -0
  77. package/dist/procedures/collection/index.d.ts +8 -3
  78. package/dist/procedures/collection/index.d.ts.map +1 -1
  79. package/dist/procedures/collection/index.js +6 -1
  80. package/dist/procedures/collection/index.js.map +1 -1
  81. package/dist/procedures/collection/procedures.d.ts +39 -2
  82. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  83. package/dist/procedures/collection/procedures.js +111 -8
  84. package/dist/procedures/collection/procedures.js.map +1 -1
  85. package/dist/procedures/collection/query.d.ts +246 -0
  86. package/dist/procedures/collection/query.d.ts.map +1 -0
  87. package/dist/procedures/collection/query.js +854 -0
  88. package/dist/procedures/collection/query.js.map +1 -0
  89. package/dist/procedures/collection/schemas.d.ts +50 -0
  90. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  91. package/dist/procedures/collection/schemas.js +144 -6
  92. package/dist/procedures/collection/schemas.js.map +1 -1
  93. package/dist/procedures/collection/storage.d.ts +58 -6
  94. package/dist/procedures/collection/storage.d.ts.map +1 -1
  95. package/dist/procedures/collection/storage.js +23 -0
  96. package/dist/procedures/collection/storage.js.map +1 -1
  97. package/dist/procedures/elicit.d.ts +190 -0
  98. package/dist/procedures/elicit.d.ts.map +1 -0
  99. package/dist/procedures/elicit.js +127 -0
  100. package/dist/procedures/elicit.js.map +1 -0
  101. package/dist/procedures/hints.d.ts +43 -0
  102. package/dist/procedures/hints.d.ts.map +1 -0
  103. package/dist/procedures/hints.js +74 -0
  104. package/dist/procedures/hints.js.map +1 -0
  105. package/dist/procedures/index.d.ts +9 -2
  106. package/dist/procedures/index.d.ts.map +1 -1
  107. package/dist/procedures/index.js +5 -1
  108. package/dist/procedures/index.js.map +1 -1
  109. package/dist/procedures/invoke.d.ts +20 -0
  110. package/dist/procedures/invoke.d.ts.map +1 -1
  111. package/dist/procedures/invoke.js +13 -0
  112. package/dist/procedures/invoke.js.map +1 -1
  113. package/dist/procedures/progress.d.ts +24 -0
  114. package/dist/procedures/progress.d.ts.map +1 -0
  115. package/dist/procedures/progress.js +11 -0
  116. package/dist/procedures/progress.js.map +1 -0
  117. package/dist/procedures/registry.d.ts.map +1 -1
  118. package/dist/procedures/registry.js +5 -0
  119. package/dist/procedures/registry.js.map +1 -1
  120. package/dist/procedures/types.d.ts +42 -0
  121. package/dist/procedures/types.d.ts.map +1 -1
  122. package/dist/procedures/types.js.map +1 -1
  123. package/dist/procedures/untrusted.d.ts +28 -0
  124. package/dist/procedures/untrusted.d.ts.map +1 -0
  125. package/dist/procedures/untrusted.js +32 -0
  126. package/dist/procedures/untrusted.js.map +1 -0
  127. package/dist/server/browser-guard.d.ts +12 -2
  128. package/dist/server/browser-guard.d.ts.map +1 -1
  129. package/dist/server/browser-guard.js +43 -6
  130. package/dist/server/browser-guard.js.map +1 -1
  131. package/dist/server/host-policy.d.ts +10 -1
  132. package/dist/server/host-policy.d.ts.map +1 -1
  133. package/dist/server/host-policy.js +5 -0
  134. package/dist/server/host-policy.js.map +1 -1
  135. package/dist/server/index.d.ts +1 -1
  136. package/dist/server/index.d.ts.map +1 -1
  137. package/dist/server/index.js.map +1 -1
  138. package/dist/server/procedure-server.d.ts +38 -6
  139. package/dist/server/procedure-server.d.ts.map +1 -1
  140. package/dist/server/procedure-server.js +65 -13
  141. package/dist/server/procedure-server.js.map +1 -1
  142. package/dist/server/request-auth.d.ts +51 -4
  143. package/dist/server/request-auth.d.ts.map +1 -1
  144. package/dist/server/request-auth.js +116 -5
  145. package/dist/server/request-auth.js.map +1 -1
  146. package/dist/server/types.d.ts +73 -5
  147. package/dist/server/types.d.ts.map +1 -1
  148. package/dist/server/types.js.map +1 -1
  149. package/package.json +5 -1
  150. package/src/adapters/http/client/transport.ts +11 -1
  151. package/src/adapters/http/client/types.ts +22 -0
  152. package/src/adapters/http/server/cors.ts +144 -0
  153. package/src/adapters/http/server/handler.ts +624 -0
  154. package/src/adapters/http/server/strategies.ts +16 -6
  155. package/src/adapters/http/server/transport.ts +55 -478
  156. package/src/adapters/http/server/types.ts +15 -0
  157. package/src/adapters/websocket/server/transport.ts +166 -16
  158. package/src/adapters/websocket/server/types.ts +11 -3
  159. package/src/adapters/websocket/server/upgrade-router.ts +77 -0
  160. package/src/adapters/websocket/server/upgrade.ts +88 -0
  161. package/src/adapters/websocket/shared/rpc.ts +9 -2
  162. package/src/browser.ts +79 -2
  163. package/src/client/client.ts +83 -1
  164. package/src/events/publisher.ts +48 -4
  165. package/src/events/rows.ts +48 -0
  166. package/src/index.ts +1 -1
  167. package/src/node.ts +21 -0
  168. package/src/procedures/capability.ts +21 -0
  169. package/src/procedures/collection/changes.ts +245 -0
  170. package/src/procedures/collection/index.ts +53 -1
  171. package/src/procedures/collection/procedures.ts +163 -7
  172. package/src/procedures/collection/query.ts +1009 -0
  173. package/src/procedures/collection/schemas.ts +196 -7
  174. package/src/procedures/collection/storage.ts +78 -6
  175. package/src/procedures/elicit.ts +320 -0
  176. package/src/procedures/hints.ts +91 -0
  177. package/src/procedures/index.ts +78 -2
  178. package/src/procedures/invoke.ts +29 -0
  179. package/src/procedures/progress.ts +25 -0
  180. package/src/procedures/registry.ts +4 -0
  181. package/src/procedures/types.ts +49 -0
  182. package/src/procedures/untrusted.ts +50 -0
  183. package/src/server/browser-guard.ts +48 -6
  184. package/src/server/host-policy.ts +13 -1
  185. package/src/server/index.ts +3 -0
  186. package/src/server/procedure-server.ts +82 -14
  187. package/src/server/request-auth.ts +128 -6
  188. package/src/server/types.ts +82 -5
@@ -5,11 +5,15 @@
5
5
  * Procedures are the foundation for typed RPC calls with schema validation.
6
6
  */
7
7
 
8
+ import type { Elicit } from "./elicit.js";
9
+ import type { ReportProgress } from "./progress.js";
10
+ import type { ToolHintOverride } from "./hints.js";
8
11
  import type { ZodErrorLike } from "../client/validation/types.js";
9
12
  import type { ProcedureSchema } from "./schema.js";
10
13
  import type { CollectionStorage } from "./collection/storage.js";
11
14
  import type { EventBus } from "../events/types.js";
12
15
  import type { HydrationEvent } from "./ref.js";
16
+ import type { Principal } from "../server/types.js";
13
17
 
14
18
  // =============================================================================
15
19
  // Procedure Path
@@ -83,6 +87,23 @@ export interface ProcedureMetadata {
83
87
  */
84
88
  stream?: { readonly unbounded?: boolean } | undefined;
85
89
 
90
+ /**
91
+ * The output fields that hold text from outside the host: a web page, a mail (CLI-10). The value
92
+ * of each field is a text, an `UntrustedText` with its source, or a list of them. A face that
93
+ * gives text to a model (MCP) shows these texts in envelopes: data, never instructions.
94
+ */
95
+ untrusted?: readonly string[] | undefined;
96
+
97
+ /** The short name of the procedure for people, for example the title of an MCP tool. */
98
+ title?: string | undefined;
99
+
100
+ /**
101
+ * The override of the tool hints of the effects (CLI-10). It can only make the tool look more
102
+ * dangerous: `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`,
103
+ * `openWorldHint: true`. The registry refuses each other value.
104
+ */
105
+ toolHints?: ToolHintOverride | undefined;
106
+
86
107
  /**
87
108
  * The rules of the procedure under a root of the files (roadmap R33): the inputs that name a
88
109
  * path, or the reason that a root cannot confine it. A host with a root (`--fs-root`,
@@ -329,6 +350,20 @@ export interface ProcedureContext {
329
350
  */
330
351
  onRef?: ((event: HydrationEvent) => void) | undefined;
331
352
 
353
+ /**
354
+ * The function that asks the person at the caller for input or a confirmation (CLI-12): MCP
355
+ * elicitation, or another face that can ask. Absent when no one can answer. The helpers
356
+ * `elicitInput` and `elicitConfirmation` then give the action `unavailable`. The nested calls
357
+ * get the same function.
358
+ */
359
+ elicit?: Elicit | undefined;
360
+
361
+ /**
362
+ * The function that tells the caller the progress of this call (an MCP progress notification).
363
+ * Absent when the caller asked for no progress. Only the call of the host gets it.
364
+ */
365
+ progress?: ReportProgress | undefined;
366
+
332
367
  /**
333
368
  * The capability of this invocation (CAPABILITIES-2026-10): its clearance, permitted effects
334
369
  * and deadline. The nested calls of `ctx.client` get it, or a narrower one. Frozen.
@@ -347,6 +382,20 @@ export interface ProcedureContext {
347
382
  * it. The root of the files reads it (roadmap R33).
348
383
  */
349
384
  caller?: AnyProcedure | undefined;
385
+
386
+ /**
387
+ * The principal of the call: who sends it. The server sets it from its authentication (the
388
+ * token, the request hook `principalOf`, `authenticate`). The nested calls get the same
389
+ * principal. An input or the metadata cannot change it. A call from this process has none.
390
+ */
391
+ principal?: Principal | undefined;
392
+
393
+ /**
394
+ * The output filter of the host for this call: its `admitItem`, with this invocation (roadmap
395
+ * CLI-15). A handler that sends the data of other writers later, for example a subscription,
396
+ * examines each row with it. Absent for a nested call, or when the host has no filter.
397
+ */
398
+ admit?: ((item: unknown) => Promise<boolean>) | undefined;
350
399
  }
351
400
 
352
401
  /**
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The text from outside a host: a web page, a mail, a file of a user (CLI-10).
3
+ *
4
+ * A procedure that gives such text declares the fields of its output that hold it
5
+ * (`metadata.untrusted`). A face that gives text to a model (MCP) then shows that text apart from
6
+ * the data. Envelopes tell the model that this text is data, not instructions. Another face gives
7
+ * the output as it is.
8
+ */
9
+
10
+ import type { AnyProcedure } from "./types.js";
11
+
12
+ /** The values of the source of a text: the attributes of its envelope. */
13
+ export type UntrustedSource = Readonly<Record<string, string | number | boolean | null | undefined>>;
14
+
15
+ /** A text from outside the host, with its source: for example the URL and the date of a page. */
16
+ export interface UntrustedText {
17
+ readonly text: string;
18
+ readonly source?: UntrustedSource | undefined;
19
+ }
20
+
21
+ /**
22
+ * The value of an untrusted field of an output: a text, a text with its source, or a list of
23
+ * them.
24
+ */
25
+ export type UntrustedValue = string | UntrustedText | readonly (string | UntrustedText)[];
26
+
27
+ /** This function gives a text with its source, for an untrusted field of an output. */
28
+ export function untrustedText(text: string, source?: UntrustedSource): UntrustedText {
29
+ return source === undefined ? { text } : { text, source };
30
+ }
31
+
32
+ /** The names of the output fields that hold untrusted text (`metadata.untrusted`). Empty: none. */
33
+ export function untrustedFieldsOf(procedure: AnyProcedure): readonly string[] {
34
+ const fields = (procedure.metadata as { untrusted?: unknown } | undefined)?.untrusted;
35
+ return Array.isArray(fields) ? fields.filter((field): field is string => typeof field === "string") : [];
36
+ }
37
+
38
+ /** The texts of the value of an untrusted field, each with its source. Another value gives none. */
39
+ export function untrustedTextsOf(value: unknown): UntrustedText[] {
40
+ const one = (item: unknown): UntrustedText | undefined => {
41
+ if (typeof item === "string") return { text: item };
42
+ if (item !== null && typeof item === "object" && typeof (item as { text?: unknown }).text === "string") {
43
+ const { text, source } = item as { text: string; source?: unknown };
44
+ return source !== null && typeof source === "object" ? { text, source: source as UntrustedSource } : { text };
45
+ }
46
+ return undefined;
47
+ };
48
+ const items = Array.isArray(value) ? value : [value];
49
+ return items.map(one).filter((item): item is UntrustedText => item !== undefined);
50
+ }
@@ -15,6 +15,9 @@
15
15
  * no allowed origin is refused (an image tag or a form sends no `Origin` for a GET).
16
16
  * - HTTP only: a procedure call by GET or HEAD is refused unless `allowGet` is true, and a
17
17
  * request body must be JSON.
18
+ * - A request with ambient credentials (the cookies that a browser sends by itself) is trusted
19
+ * only from a named origin. `"*"` does not open the origin check for it (CSRF). A list or a
20
+ * function of origins still does.
18
21
  */
19
22
 
20
23
  import type { IncomingHttpHeaders } from "node:http";
@@ -74,8 +77,9 @@ export function hostNameOf(header: string | undefined): string {
74
77
  return (header.split(":")[0] ?? "").toLowerCase();
75
78
  }
76
79
 
77
- function originAllowedBy(origin: string, allowed: BrowserGuardOptions["allowedOrigins"]): boolean {
78
- if (allowed === "*") return true;
80
+ function originAllowedBy(origin: string, allowed: BrowserGuardOptions["allowedOrigins"], ambient: boolean): boolean {
81
+ // "*" names no origin: ambient credentials are not trusted from it
82
+ if (allowed === "*") return !ambient;
79
83
  if (typeof allowed === "function") return allowed(origin);
80
84
  return (allowed ?? []).includes(origin);
81
85
  }
@@ -102,14 +106,52 @@ function hasBody(headers: IncomingHttpHeaders): boolean {
102
106
  return length > 0 || headers["transfer-encoding"] !== undefined;
103
107
  }
104
108
 
109
+ /**
110
+ * True when a request carries ambient credentials: a `Cookie` header, which a browser sends by
111
+ * itself, also for a request that another site starts.
112
+ */
113
+ export function hasAmbientCredentials(headers: IncomingHttpHeaders): boolean {
114
+ const cookie = headers.cookie;
115
+ return typeof cookie === "string" && cookie.trim() !== "";
116
+ }
117
+
118
+ /**
119
+ * True when a face trusts a request only from a named origin. This is so when the server reads
120
+ * cookies for each request (`"always"`), or when the request carries a cookie that an auth path can
121
+ * read. `cookiesNamed` is true for a face that trusts no cookie from
122
+ * an origin that is not named, with or without an auth path.
123
+ *
124
+ * @internal
125
+ */
126
+ export function needsNamedOrigin(mode: "always" | "cookies" | undefined, headers: IncomingHttpHeaders, cookiesNamed: boolean): boolean {
127
+ return mode === "always" || (hasAmbientCredentials(headers) && (cookiesNamed || mode !== undefined));
128
+ }
129
+
130
+ /**
131
+ * This function refuses a face whose server reads cookies for each request (`principalOf`) while
132
+ * its browser checks permit each origin (`"*"`). Each site could then send a request with the cookies
133
+ * of the user (CSRF).
134
+ *
135
+ * @internal
136
+ */
137
+ export function assertNamedOrigins(face: string, mode: "always" | "cookies" | undefined, allowedOrigins: BrowserGuardOptions["allowedOrigins"], originOption: string): void {
138
+ if (mode !== "always" || allowedOrigins !== "*") return;
139
+ throw new Error(
140
+ `${face}: the request hook principalOf reads the cookies of each request, and the origin "*" lets each site send them (CSRF). Give ${originOption} or allowedOrigins a list or a function of the origins that can call.`
141
+ );
142
+ }
143
+
105
144
  /**
106
145
  * The checks of the host, the origin and the fetch site, for HTTP and WebSocket requests.
107
- * `http` adds the checks of the method and the body.
146
+ * `http` adds the checks of the method and the body. `ambient` is true when the server can trust
147
+ * ambient credentials (cookies) for the request. Then `"*"` does not permit another origin. Only a
148
+ * named origin (a list or a function) does.
108
149
  */
109
150
  export function checkBrowserRequest(
110
151
  req: GuardedRequest,
111
152
  options: BrowserGuardOptions = {},
112
- http = false
153
+ http = false,
154
+ ambient = false
113
155
  ): GuardResult {
114
156
  const { headers } = req;
115
157
 
@@ -123,12 +165,12 @@ export function checkBrowserRequest(
123
165
 
124
166
  const origin = typeof headers.origin === "string" ? headers.origin : undefined;
125
167
  if (origin !== undefined) {
126
- if (!sameOrigin(origin, headers.host) && !originAllowedBy(origin, options.allowedOrigins)) {
168
+ if (!sameOrigin(origin, headers.host) && !originAllowedBy(origin, options.allowedOrigins, ambient)) {
127
169
  return { ok: false, status: 403, code: "FORBIDDEN_ORIGIN", message: `Forbidden: the origin "${origin}" is not allowed` };
128
170
  }
129
171
  } else {
130
172
  const site = headers["sec-fetch-site"];
131
- if ((site === "cross-site" || site === "same-site") && options.allowedOrigins !== "*") {
173
+ if ((site === "cross-site" || site === "same-site") && (options.allowedOrigins !== "*" || ambient)) {
132
174
  return { ok: false, status: 403, code: "FORBIDDEN_ORIGIN", message: "Forbidden: a request from another site" };
133
175
  }
134
176
  }
@@ -12,6 +12,8 @@
12
12
  * procedures (a host for an outside caller sets it).
13
13
  * - `timeout`: the time limit of each call from a caller (not of its nested calls).
14
14
  * - `token`, `browser`: the checks of the network transports (`client-server` applies them).
15
+ * - `principalOf`: the request hook. It reads the credentials of a network request (a cookie, a
16
+ * header, the query) and gives a bearer token, a principal and a narrowing.
15
17
  * - `capability`, `readOnly`, `clearanceOrder`, `resources`, `admitItem`: the capability of each
16
18
  * call and what the host gives for it (CAPABILITIES-2026-10).
17
19
  * - `events`: the remote events of the host (`documentation/EVENTS.md`). The server pins
@@ -21,7 +23,7 @@
21
23
  import type { Capability, ClearanceOrder, ProcedurePath, ResourcesOf } from "../procedures/types.js";
22
24
  import type { AdmitItem, Interceptor, Invocation } from "../procedures/invoke.js";
23
25
  import { narrowCapability } from "../procedures/capability.js";
24
- import type { ServerRequest } from "./types.js";
26
+ import type { PrincipalOf, ServerRequest } from "./types.js";
25
27
  import { timeoutInterceptor } from "../procedures/interceptors.js";
26
28
  import type { BrowserGuardOptions } from "./browser-guard.js";
27
29
  import type { ProcedureRegistry } from "../procedures/registry.js";
@@ -73,6 +75,11 @@ export interface HostPolicyOptions {
73
75
  interceptors?: readonly Interceptor[] | undefined;
74
76
  /** The token that each network request must send (HTTP and WebSocket transports). */
75
77
  token?: string | undefined;
78
+ /**
79
+ * The request hook: it reads the credentials of each network request (a cookie, a header, the
80
+ * query) and gives a bearer token, a principal and a narrowing (`ProcedureServerOptions`).
81
+ */
82
+ principalOf?: PrincipalOf | undefined;
76
83
  /** The browser checks of the network transports (`allowedOrigins`, `allowedHosts`, `allowGet`). */
77
84
  browser?: BrowserGuardOptions | undefined;
78
85
  /**
@@ -103,6 +110,8 @@ export interface HostPolicy {
103
110
  readonly strictCalls: boolean;
104
111
  readonly interceptors: readonly Interceptor[];
105
112
  readonly token: string | undefined;
113
+ /** The request hook of the network requests, or undefined for none. */
114
+ readonly principalOf?: PrincipalOf | undefined;
106
115
  readonly browser: BrowserGuardOptions;
107
116
  /** The capability of a call from a caller, or undefined for no limits. */
108
117
  readonly capabilityOf: ((request?: ServerRequest<unknown>) => Capability | undefined) | undefined;
@@ -151,6 +160,7 @@ export function hostPolicy(options: HostPolicyOptions = {}): HostPolicy {
151
160
  strictCalls: options.strictCalls ?? false,
152
161
  interceptors,
153
162
  token: options.token,
163
+ ...(options.principalOf ? { principalOf: options.principalOf } : {}),
154
164
  browser: options.browser ?? {},
155
165
  capabilityOf: capabilityOfOptions(options),
156
166
  clearanceOrder: options.clearanceOrder,
@@ -169,6 +179,8 @@ export function procedureServerOptions(policy: HostPolicy, registry?: ProcedureR
169
179
  strictCalls: policy.strictCalls,
170
180
  // The token of the policy: ProcedureServer examines it for each network request (R18)
171
181
  ...(policy.token !== undefined ? { token: policy.token } : {}),
182
+ // The request hook of the policy: the credentials of a cookie, a header or the query (CLI-2a)
183
+ ...(policy.principalOf ? { principalOf: policy.principalOf } : {}),
172
184
  ...(policy.interceptors.length > 0 ? { interceptors: policy.interceptors } : {}),
173
185
  ...(policy.capabilityOf ? { capability: policy.capabilityOf } : {}),
174
186
  ...(policy.clearanceOrder ? { clearanceOrder: policy.clearanceOrder } : {}),
@@ -48,6 +48,9 @@ export {
48
48
  type RequestHandler,
49
49
  type Credentials,
50
50
  type Principal,
51
+ type PrincipalOf,
52
+ type RequestIdentity,
53
+ type AmbientCredentials,
51
54
  type ServerHandler,
52
55
  type ServerMiddleware,
53
56
  type ServerContext,
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { Server, type ServerOptions } from "./server.js";
9
- import type { Method, Principal, ServerHandler, ServerRequest, ServerResponse } from "./types.js";
9
+ import type { AmbientCredentials, Method, Principal, PrincipalOf, RequestIdentity, ServerHandler, ServerRequest, ServerResponse } from "./types.js";
10
10
  import { methodToPath, pathToMethod } from "./method.js";
11
11
  import type {
12
12
  AnyProcedure,
@@ -19,7 +19,7 @@ import { ProcedureRegistry, PROCEDURE_REGISTRY } from "../procedures/registry.js
19
19
  import { createCollectionProcedures } from "../procedures/collection/procedures.js";
20
20
  import { InvocationError, invokeProcedure, type AdmitItem, type Interceptor, type Invocation } from "../procedures/invoke.js";
21
21
  import type { Capability, ClearanceOrder, ResourcesOf } from "../procedures/types.js";
22
- import { TIMEOUT_METADATA_KEY, effectsOf, narrowCapability } from "../procedures/capability.js";
22
+ import { TIMEOUT_METADATA_KEY, effectsOf, joinNarrowing, narrowCapability } from "../procedures/capability.js";
23
23
  import { IdempotencyCache, idempotencyKeyOf, isRepeatable, type IdempotencyOptions } from "../procedures/idempotency.js";
24
24
  import { optionalMs, type Duration } from "../duration.js";
25
25
  import type { CollectionStorage } from "../procedures/collection/storage.js";
@@ -28,6 +28,7 @@ import { realClock, type Clock } from "../clock.js";
28
28
  import { errorCodeOf } from "../protocol.js";
29
29
  import { EventPublisher, type EventPublisherOptions } from "../events/publisher.js";
30
30
  import { getGlobalEventBus } from "../events/bus.js";
31
+ import type { CollectionProceduresOptions } from "../procedures/collection/procedures.js";
31
32
 
32
33
  // =============================================================================
33
34
  // Types
@@ -139,6 +140,24 @@ export interface ProcedureServerOptions extends ServerOptions {
139
140
  */
140
141
  authenticate?: ((request: ServerRequest<unknown>) => Principal | undefined | Promise<Principal | undefined>) | undefined;
141
142
 
143
+ /**
144
+ * The request hook of the server: it reads the credentials of each network request (a cookie, a
145
+ * header, the query) and gives a bearer token, a principal and a narrowing. It runs before the
146
+ * token check: its bearer token replaces the bearer token of the credentials. Its principal
147
+ * replaces the principal of the token check. `authenticate` runs after it and can read
148
+ * `request.principal`. Undefined or an error refuses the request with `UNAUTHORIZED`. A request
149
+ * without credentials (from this process) does not go through the hook.
150
+ */
151
+ principalOf?: PrincipalOf | undefined;
152
+
153
+ /**
154
+ * How the server can authenticate a request with ambient credentials (cookies), for the CSRF
155
+ * checks of the network faces. The default is `"always"` with `principalOf`, `"cookies"` with
156
+ * `authenticate`, and none without them. `false`: the server reads no cookies, for example an
157
+ * `authenticate` function that reads only the bearer token.
158
+ */
159
+ ambientCredentials?: AmbientCredentials | false | undefined;
160
+
142
161
  /**
143
162
  * The remote events of the server (`documentation/EVENTS.md`): a bus and its topic rule, or an
144
163
  * `EventPublisher`. The server pins the procedure `events.subscribe`, outside its registry, so a
@@ -326,13 +345,19 @@ export class ProcedureServer extends Server implements RepositoryProvider {
326
345
  private draining = false;
327
346
  /** The calls in flight, a stream until its end */
328
347
  private inFlight = 0;
329
- /** The token of the server, and its authenticate function (R18) */
348
+ /** The token of the server, its request hook and its authenticate function (R18) */
330
349
  private readonly token: string | undefined;
350
+ private readonly principalOf: PrincipalOf | undefined;
331
351
  private readonly authenticate: ProcedureServerOptions["authenticate"];
332
352
  /** The functions that `stop()` waits on: each one runs when no call is in flight */
333
353
  private idleWaiters: Array<() => void> = [];
334
354
  /** The publisher of the remote events, from the option `events`, or undefined. */
335
355
  readonly publisher: EventPublisher | undefined;
356
+ /**
357
+ * How the server can authenticate a request with ambient credentials (cookies). The network faces
358
+ * read it: they trust such a request only from a named origin (CSRF).
359
+ */
360
+ readonly ambientCredentials: AmbientCredentials | undefined;
336
361
 
337
362
  constructor(options: ProcedureServerOptions = {}) {
338
363
  super(options);
@@ -350,7 +375,10 @@ export class ProcedureServer extends Server implements RepositoryProvider {
350
375
  };
351
376
  this.idempotency = options.idempotency === false ? undefined : new IdempotencyCache(options.idempotency ?? {});
352
377
  this.token = options.token;
378
+ this.principalOf = options.principalOf;
353
379
  this.authenticate = options.authenticate;
380
+ const ambient = options.ambientCredentials ?? (this.principalOf ? "always" : this.authenticate ? "cookies" : undefined);
381
+ this.ambientCredentials = ambient === false ? undefined : ambient;
354
382
  // The remote events: the server pins events.subscribe, as it pins host.health (EVENTS.md)
355
383
  this.publisher = options.events instanceof EventPublisher ? options.events : options.events ? new EventPublisher(options.events) : undefined;
356
384
  if (this.publisher) this.registerProcedure(this.publisher.procedure);
@@ -642,9 +670,11 @@ export class ProcedureServer extends Server implements RepositoryProvider {
642
670
  }
643
671
 
644
672
  /**
645
- * The token check and the principal of a request (roadmap R18). A network request (with
646
- * credentials) must present the token. The authenticate function gives the principal. The
647
- * function gives an UNAUTHORIZED response, or undefined when the request passes.
673
+ * The token check and the principal of a request (roadmap R18). The request hook reads the
674
+ * credentials of a network request first: a bearer token, a principal and a narrowing. A
675
+ * network request (with credentials) must present the token. The authenticate function gives
676
+ * the principal. The function gives an UNAUTHORIZED response, or undefined when the request
677
+ * passes.
648
678
  */
649
679
  private async admit(request: ServerRequest<unknown>): Promise<ServerResponse<unknown> | undefined> {
650
680
  const unauthorized = (message: string): ServerResponse<unknown> => ({
@@ -652,10 +682,26 @@ export class ProcedureServer extends Server implements RepositoryProvider {
652
682
  status: { type: "error", code: "UNAUTHORIZED", message, retryable: false },
653
683
  metadata: {},
654
684
  });
655
- if (this.token !== undefined && request.credentials !== undefined) {
656
- if (!tokenMatches(this.token, request.credentials.bearer)) return unauthorized("Unauthorized: this server requires its token");
685
+ const credentials = request.credentials;
686
+ let identity: RequestIdentity | undefined;
687
+ if (this.principalOf && credentials !== undefined) {
688
+ try {
689
+ identity = await this.principalOf(credentials, request);
690
+ } catch {
691
+ identity = undefined;
692
+ }
693
+ if (!identity) return unauthorized("Unauthorized: the server did not accept the credentials");
694
+ }
695
+ if (this.token !== undefined && credentials !== undefined) {
696
+ const bearer = identity?.bearer ?? credentials.bearer;
697
+ if (!tokenMatches(this.token, bearer)) return unauthorized("Unauthorized: this server requires its token");
657
698
  request.principal = { id: "token", via: "token" };
658
699
  }
700
+ if (identity?.principal) request.principal = identity.principal;
701
+ if (identity?.narrowing) {
702
+ const order = this.capabilityOptions.clearanceOrder;
703
+ request.narrowing = request.narrowing ? joinNarrowing(request.narrowing, identity.narrowing, order) : identity.narrowing;
704
+ }
659
705
  if (this.authenticate) {
660
706
  let principal: Principal | undefined;
661
707
  try {
@@ -734,6 +780,17 @@ export class ProcedureServer extends Server implements RepositoryProvider {
734
780
  return { ...response, id: request.id };
735
781
  }
736
782
 
783
+ /**
784
+ * The capability of a request. It comes from the server, never from the request. The remaining
785
+ * time of the caller and the narrowing of the request can only make it narrower
786
+ * (CAPABILITIES-2026-10, decision 9). Two different clearances without an order throw.
787
+ */
788
+ private capabilityOf(request: ServerRequest<unknown>): Capability | undefined {
789
+ const { capability, clearanceOrder } = this.capabilityOptions;
790
+ const base = withRequestTimeout(typeof capability === "function" ? capability(request) : capability, request, clearanceOrder);
791
+ return request.narrowing ? narrowCapability(base, request.narrowing, clearanceOrder) : base;
792
+ }
793
+
737
794
  /** Run a procedure for a request with a signal. */
738
795
  private async run(procedure: AnyProcedure, request: ServerRequest<unknown>, signal: AbortSignal | undefined): Promise<ServerResponse<unknown>> {
739
796
  // The call uses the storages that it takes until its end: the end of a stream, for a stream
@@ -742,21 +799,31 @@ export class ProcedureServer extends Server implements RepositoryProvider {
742
799
  try {
743
800
  // One invocation path for every host (ARCHITECTURE-PROPOSALS P1): input validation, the
744
801
  // context, the expose rule for nested calls (BUGS-2026-07 H18) and output validation
745
- const { capability, clearanceOrder, resources, admitItem } = this.capabilityOptions;
802
+ const { clearanceOrder, resources, admitItem } = this.capabilityOptions;
803
+ let capability: Capability | undefined;
804
+ try {
805
+ capability = this.capabilityOf(request);
806
+ } catch (error) {
807
+ const message = `The server cannot narrow the capability of the request: ${error instanceof Error ? error.message : String(error)}`;
808
+ return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }));
809
+ }
746
810
  const output = await invokeProcedure(procedure, request.payload, {
747
811
  registry: this.procedureRegistry,
748
812
  metadata: request.metadata,
749
813
  signal,
814
+ // What the face of the call offers: it asks the person, and it shows the progress (CLI-12)
815
+ ...(request.elicit ? { elicit: request.elicit } : {}),
816
+ ...(request.progress ? { progress: request.progress } : {}),
750
817
  repository,
751
818
  expose: this.expose,
752
819
  strictCalls: this.strictCalls,
753
820
  interceptors: this.interceptors,
754
- // The capability comes from the server, never from the request. The remaining time of
755
- // the caller can only make the deadline earlier (CAPABILITIES-2026-10, decision 9).
756
- capability: withRequestTimeout(typeof capability === "function" ? capability(request) : capability, request, clearanceOrder),
821
+ capability,
757
822
  clearanceOrder,
758
823
  resources,
759
824
  admitItem,
825
+ // The principal of the authentication: ctx.principal, and the nested calls (R18)
826
+ ...(request.principal !== undefined ? { principal: request.principal } : {}),
760
827
  // The bus of the remote events is the bus of the procedures (EVENTS.md)
761
828
  ...(this.publisher ? { bus: this.publisher.bus } : {}),
762
829
  });
@@ -784,11 +851,12 @@ export class ProcedureServer extends Server implements RepositoryProvider {
784
851
  * Creates standard CRUD procedures using the configured storage.
785
852
  *
786
853
  * @param collectionName - Collection name
854
+ * @param options - The query policy of `find`. `$regex` is off unless the policy permits it.
787
855
  */
788
- registerCollectionProcedures(collectionName: string): void {
856
+ registerCollectionProcedures(collectionName: string, options?: CollectionProceduresOptions): void {
789
857
  // Uses a top-level import; require() is not available in this ESM package and
790
858
  // previously threw "Cannot find module" on every call. See BUGS-2026-07 H13.
791
- const procedures = createCollectionProcedures(collectionName);
859
+ const procedures = createCollectionProcedures(collectionName, options);
792
860
 
793
861
  // Register with the procedure registry first
794
862
  this.procedureRegistry.registerAll(procedures);
@@ -1,19 +1,41 @@
1
1
  /**
2
2
  * The credentials of a network request (roadmap R18). A server transport reads them into
3
- * `ServerRequest.credentials`, and leaves the secret headers out of the metadata, so a handler
4
- * and a nested call do not see them. `ProcedureServer` examines them.
3
+ * `ServerRequest.credentials`. It leaves the secret headers out of the metadata, so a handler and a
4
+ * nested call do not see them. `ProcedureServer` examines them.
5
+ *
6
+ * This module imports no Node code: a server in a browser or a worker can use it too.
5
7
  */
6
8
 
7
9
  import type { Credentials } from "./types.js";
8
10
 
9
11
  /** The headers that carry a secret. They do not go into the metadata of a request. */
10
- const SECRET_HEADERS = new Set(["authorization", "proxy-authorization", "cookie", "x-api-key"]);
12
+ const SECRET_HEADERS: ReadonlySet<string> = new Set(["authorization", "proxy-authorization", "cookie", "x-api-key"]);
11
13
 
12
- /** The headers without the secret ones, and without the headers that are not text. */
13
- export function withoutSecretHeaders(headers: Record<string, unknown>): Record<string, string> {
14
+ /**
15
+ * True when a header carries a secret: a header of the default list (`Authorization`,
16
+ * `Proxy-Authorization`, `Cookie`, `X-Api-Key`), or a header of `more`. The names are lower case.
17
+ */
18
+ export function isSecretHeader(name: string, more?: ReadonlySet<string>): boolean {
19
+ const key = name.toLowerCase();
20
+ return SECRET_HEADERS.has(key) || (more?.has(key) ?? false);
21
+ }
22
+
23
+ /** This function gives a set of lower-case header names. */
24
+ export function headerNames(names: Iterable<string> | undefined): ReadonlySet<string> {
25
+ const set = new Set<string>();
26
+ for (const name of names ?? []) set.add(name.toLowerCase());
27
+ return set;
28
+ }
29
+
30
+ /**
31
+ * The headers without the secret ones, and without the headers that are not text. `secret` names
32
+ * more headers that carry a secret, for example the token header of an application.
33
+ */
34
+ export function withoutSecretHeaders(headers: Record<string, unknown>, secret?: Iterable<string>): Record<string, string> {
35
+ const more = secret === undefined ? undefined : headerNames(secret);
14
36
  const out: Record<string, string> = {};
15
37
  for (const [key, value] of Object.entries(headers)) {
16
- if (!SECRET_HEADERS.has(key.toLowerCase()) && typeof value === "string") out[key] = value;
38
+ if (!isSecretHeader(key, more) && typeof value === "string") out[key] = value;
17
39
  }
18
40
  return out;
19
41
  }
@@ -33,3 +55,103 @@ export function credentialsOf(request: { headers: Record<string, unknown>; url?:
33
55
  const query = new URL(request.url ?? "/", "http://localhost").searchParams.get("token");
34
56
  return query === null ? {} : { bearer: query };
35
57
  }
58
+
59
+ /** The text of a header value: a list of values becomes one text, with ", " between the values. */
60
+ function headerText(value: unknown): string | undefined {
61
+ if (typeof value === "string") return value;
62
+ if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string").join(", ");
63
+ return undefined;
64
+ }
65
+
66
+ /** This function decodes a cookie value. A value that is not valid URI encoding stays as it is. */
67
+ function decodeCookieValue(value: string): string {
68
+ const unquoted = value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
69
+ try {
70
+ return decodeURIComponent(unquoted);
71
+ } catch {
72
+ return unquoted;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The cookies of a `Cookie` header, by name. The function decodes each URI-encoded value when it
78
+ * can. When two cookies have the same name, the first one stays: a browser sends the
79
+ * cookie of the longest path first. A part without `=` is not a cookie.
80
+ */
81
+ export function cookiesOf(header: unknown): Record<string, string> {
82
+ const text = Array.isArray(header) ? header.filter((item): item is string => typeof item === "string").join("; ") : header;
83
+ if (typeof text !== "string" || text === "") return {};
84
+ const cookies = new Map<string, string>();
85
+ for (const part of text.split(";")) {
86
+ const eq = part.indexOf("=");
87
+ if (eq <= 0) continue;
88
+ const name = part.slice(0, eq).trim();
89
+ if (name === "" || cookies.has(name)) continue;
90
+ cookies.set(name, decodeCookieValue(part.slice(eq + 1).trim()));
91
+ }
92
+ // fromEntries defines own properties: a cookie named "__proto__" changes no prototype
93
+ return Object.fromEntries(cookies);
94
+ }
95
+
96
+ /**
97
+ * The parts of a request URL: its path and its query. The function does not resolve the URL
98
+ * against a host, so a path that starts with "//" stays a path.
99
+ */
100
+ export function urlParts(url: string | undefined): { path: string; query: URLSearchParams } {
101
+ let raw = url ?? "/";
102
+ // The absolute form of a request line (a request through a proxy): the path and the query only
103
+ if (!raw.startsWith("/") && /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
104
+ try {
105
+ const absolute = new URL(raw);
106
+ raw = `${absolute.pathname}${absolute.search}`;
107
+ } catch {
108
+ // Not a URL: the text stays a path
109
+ }
110
+ }
111
+ const mark = raw.indexOf("?");
112
+ return mark === -1 ? { path: raw, query: new URLSearchParams() } : { path: raw.slice(0, mark), query: new URLSearchParams(raw.slice(mark + 1)) };
113
+ }
114
+
115
+ /** The first value of each query parameter, by name. */
116
+ export function queryRecord(query: URLSearchParams): Record<string, string> {
117
+ const values = new Map<string, string>();
118
+ for (const [key, value] of query) if (!values.has(key)) values.set(key, value);
119
+ return Object.fromEntries(values);
120
+ }
121
+
122
+ /** The options of `requestCredentials`. */
123
+ export interface RequestCredentialsOptions {
124
+ /**
125
+ * The query parameter whose value is the bearer token of a request without an
126
+ * `Authorization: Bearer` header. A browser WebSocket cannot set headers, so the WebSocket
127
+ * server reads `token`. `false` reads no query parameter.
128
+ * @default false
129
+ */
130
+ readonly tokenQuery?: string | false | undefined;
131
+ }
132
+
133
+ /**
134
+ * The credentials of a network request (roadmap R18): the bearer token, the cookies, each header
135
+ * (the secret ones too) and the first value of each query parameter. The request hook of a server
136
+ * (`principalOf`) reads them. They are not in the metadata, so a handler does not see them.
137
+ */
138
+ export function requestCredentials(
139
+ request: { headers: Record<string, unknown>; url?: string | undefined },
140
+ options: RequestCredentialsOptions = {}
141
+ ): Credentials {
142
+ const headers = new Map<string, string>();
143
+ for (const [key, value] of Object.entries(request.headers)) {
144
+ const text = headerText(value);
145
+ if (text !== undefined) headers.set(key.toLowerCase(), text);
146
+ }
147
+ const { query } = urlParts(request.url);
148
+ const fromHeader = bearerOf(headers.get("authorization"));
149
+ const fromQuery = options.tokenQuery ? query.get(options.tokenQuery) ?? undefined : undefined;
150
+ const bearer = fromHeader ?? fromQuery;
151
+ return {
152
+ ...(bearer !== undefined ? { bearer } : {}),
153
+ cookies: cookiesOf(headers.get("cookie")),
154
+ headers: Object.fromEntries(headers),
155
+ query: queryRecord(query),
156
+ };
157
+ }