@gjermundgaraba/effect-actions 0.10.0 → 0.12.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.
Files changed (214) hide show
  1. package/README.md +103 -43
  2. package/dist/Action.d.mts +31 -118
  3. package/dist/Action.mjs +15 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +25 -31
  9. package/dist/ActionMcp.mjs +140 -74
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +5 -8
  13. package/dist/ActionToolkit.mjs +3 -3
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +32 -25
  17. package/dist/Testing.mjs +45 -30
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +17 -0
  38. package/dist/mcp/protocol.mjs +149 -0
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +206 -424
  46. package/docs/ActionCli-failures.md +102 -0
  47. package/docs/ActionCli.md +206 -292
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +202 -281
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +229 -193
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +178 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +100 -117
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +142 -392
  59. package/docs/CONTEXT.md +39 -23
  60. package/docs/README.md +71 -35
  61. package/docs/Testing-failures.md +20 -0
  62. package/docs/Testing.md +186 -151
  63. package/docs/guarantees.md +35 -95
  64. package/docs/setup-failures.md +34 -0
  65. package/docs/setup.md +88 -87
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +317 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +146 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +109 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +41 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +75 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +136 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +119 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +30 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +108 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +89 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +91 -0
  183. package/examples/notes-service/src/notes/handlers.ts +33 -0
  184. package/examples/notes-service/src/notes/store.ts +73 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +134 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +32 -14
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -11
  211. package/dist/internal/mcp.mjs +0 -52
  212. package/dist/internal/refusal.mjs +0 -67
  213. package/dist/internal/schema-errors.mjs +0 -20
  214. package/dist/internal/tools.mjs +0 -44
@@ -0,0 +1,178 @@
1
+ # ActionRpc
2
+
3
+ Actions as Effect RPC, on Effect's own `RpcServer` and `RpcClient`: one unary rpc per action.
4
+ Reach for it when a client keeps a connection open and calls often, such as a page on a
5
+ WebSocket, or when the application already speaks Effect RPC.
6
+
7
+ Read this page alone, in one tool call: pages read together are cut. When something fails,
8
+ search [ActionRpc-failures.md](ActionRpc-failures.md). Reading order: [README.md](README.md).
9
+
10
+ ## API
11
+
12
+ Import `@gjermundgaraba/effect-actions/ActionRpc`.
13
+
14
+ | API | Purpose |
15
+ | --------------------------------------- | -------------------------------------------------------------------------- |
16
+ | `make(actions, options?)` | Bind a list of actions; returns a `Binding`. |
17
+ | `Rpc.actions` | The bound actions. |
18
+ | `Rpc.error` | The errors every rpc declares besides its action's own. |
19
+ | `Rpc.authentication` | The authentication descriptor of its protected actions, or `undefined`. |
20
+ | `Rpc.group` | Native `RpcGroup`, one rpc per action, for `RpcClient.make`. |
21
+ | `layer(Rpc, implementations, options?)` | Serve the rpcs of the bound actions the implementations hold. |
22
+ | `client(Rpc, options?)` | An Effect of a typed client; requires the client `Protocol` and a `Scope`. |
23
+
24
+ Exported types: `Binding`; `Any`, any binding; `Client<typeof Rpc>`; `MethodError`, what one call fails with; `Options` of `make`, `LayerOptions` of `layer`, `ClientOptions` of `client`.
25
+
26
+ | Option | Meaning |
27
+ | ------------------------ | ------------------------------------------------------------------------------------------------------ |
28
+ | `make`: `error` | Errors the layer's `middleware` fails with: declared by every rpc, so clients decode them. |
29
+ | `make`: `authentication` | `Authentication.make(...)` of the protected actions' identity: required when the binding holds one. |
30
+ | `layer`: `actions` | The bound actions it serves: `[GetUser]`. Defaults to every one the implementations hold. |
31
+ | `layer`: `middleware` | Native `RpcMiddleware` around every rpc, inside authentication, outside decoding; the first innermost. |
32
+ | `client`: options | The native `RpcClient.make` options but `flatten`. |
33
+
34
+ ## Bind actions to rpcs
35
+
36
+ Call `ActionRpc.make` once, in a file that imports no server code
37
+ ([setup.md](setup.md#bundle-for-a-browser)), and export the binding: the server serves it and
38
+ every client calls it. A binding that holds a protected action names its authentication
39
+ descriptor ([Authentication.md](Authentication.md#declare-how-callers-sign-in)).
40
+
41
+ ```ts
42
+ export const Rpc = ActionRpc.make([ListRooms, ...Messages], { authentication: Login });
43
+ ```
44
+
45
+ [live-rpc/src/Rpc.ts](../examples/live-rpc/src/Rpc.ts) is that binding;
46
+ [kitchen-sink/src/Rpc.ts](../examples/kitchen-sink/src/Rpc.ts) adds `error`.
47
+
48
+ Nothing streams. A page that follows changes calls again on its open socket, as `readMessages`
49
+ does in [live-rpc/src/chat/Chat.ts](../examples/live-rpc/src/chat/Chat.ts).
50
+
51
+ ## Serve the rpcs
52
+
53
+ In the routes file, give `ActionRpc.layer` the binding and the implementations, and provide
54
+ what it requires except the protocol: the service of each listed middleware and, where it
55
+ serves a protected action, the authentication provider
56
+ ([Authentication.md](Authentication.md#verify-credentials)). The entrypoint chooses the
57
+ protocol and its serialization, then serves the result with `HttpRouter.serve`.
58
+
59
+ ```ts
60
+ // routes.ts
61
+ export const rpc = ActionRpc.layer(Rpc, [rooms, messages], { middleware: [ResolveRoom] }).pipe(
62
+ Layer.provide([resolveRoom, authenticate]),
63
+ );
64
+
65
+ // server.ts
66
+ const socket = rpc.pipe(
67
+ Layer.provide(RpcServer.layerProtocolWebsocket({ path: "/rpc" })),
68
+ Layer.provide(RpcSerialization.layerJson),
69
+ );
70
+ ```
71
+
72
+ [live-rpc/src/routes.ts](../examples/live-rpc/src/routes.ts) and
73
+ [live-rpc/src/server.ts](../examples/live-rpc/src/server.ts) are the two halves.
74
+
75
+ - Provide the protocol to the rpc layer, inside what `HttpRouter.serve` serves:
76
+ `RpcServer.layerProtocolWebsocket` and `layerProtocolHttp` add their route to that router.
77
+ - Export the rpc layer before it is given a protocol. `Testing.layer` cannot upgrade a
78
+ WebSocket, so a test gives that layer the HTTP protocol ([Testing.md](Testing.md#test-rpcs)).
79
+ - Provide a JSON serialization, the same one on the client: `RpcSerialization.layerJson`,
80
+ `layerNdjson`, `layerJsonRpc()` or `layerNdJsonRpc()`.
81
+ - Bound a WebSocket message on the server: `HttpServerRequest.MaxBodySize` does not, and Node's
82
+ reads up to 100 MiB whole. `server.ts` passes `websocket: { maxPayload }` to
83
+ `NodeHttpServer.layer`; a larger message closes the socket.
84
+ - Authentication runs for every message of a protected rpc, not once per connection.
85
+ - A limit in `middleware` may key on the caller and counts input that does not decode, as the
86
+ quota of [kitchen-sink/src/identity/quota.ts](../examples/kitchen-sink/src/identity/quota.ts)
87
+ does; a limit in a handler holds on every surface.
88
+ - A middleware may require the identity only where every action its layer serves is
89
+ protected, and one protocol takes one `ActionRpc.layer`: a second layer given the same
90
+ protocol is never reached. So bind the protected actions alone, as
91
+ [kitchen-sink/src/Rpc.ts](../examples/kitchen-sink/src/Rpc.ts) does, or have the middleware
92
+ read the identity with `Effect.serviceOption`, which a public rpc's call lacks.
93
+ - A protocol with no router, such as `RpcServer.layerProtocolSocketServer` or
94
+ `layerProtocolStdio`, serves protected actions too, but has no HTTP request, which limits its
95
+ provider and its verifier: [ActionRpc-failures.md](ActionRpc-failures.md).
96
+ - **The same actions as JSON too.** Merge `ActionHttp.layer(Http, implementations)` beside the
97
+ rpc layer, each given its descriptor's provider
98
+ ([kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts) defines both, and
99
+ [server.ts](../examples/kitchen-sink/src/server.ts) merges them): one provider given to both
100
+ is built once. The routes need what
101
+ the rpcs did not: an `httpApiStatus` on each declared error, which answers 422 without one
102
+ ([live-rpc/src/chat/Chat.ts](../examples/live-rpc/src/chat/Chat.ts)); a request body limit;
103
+ and router middleware for a service an `RpcMiddleware` provides
104
+ ([ActionHttp.md](ActionHttp.md#serve-the-routes)). Write the header's decoding once, in a
105
+ function both middleware call.
106
+
107
+ ## Call the rpcs
108
+
109
+ `ActionRpc.client(Rpc)` has the methods of `ActionHttp.client`
110
+ ([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)): `client.readMessages({ after })`.
111
+ Its connection is a Layer of Effect's own:
112
+
113
+ ```ts
114
+ export const connection = (url: string) =>
115
+ RpcClient.layerProtocolSocket().pipe(
116
+ Layer.provide(Socket.layerWebSocket(url)),
117
+ Layer.provide(RpcSerialization.layerJson),
118
+ );
119
+ ```
120
+
121
+ It leaves the WebSocket constructor to its host: a page provides
122
+ `Socket.layerWebSocketConstructorGlobal`, and Node `NodeSocket.layerWebSocketConstructor`.
123
+ [live-rpc/src/browser.ts](../examples/live-rpc/src/browser.ts) is a page's client module, and
124
+ [live-rpc/src/client.ts](../examples/live-rpc/src/client.ts) runs it from Node.
125
+
126
+ - The socket belongs to the Layer providing the protocol, not to the scope around the client:
127
+ provide the connection once, around the page's whole program, never around one call. A page
128
+ that is not one Effect program keeps it in a `ManagedRuntime` and calls with promises:
129
+ `connect(url)` in `browser.ts`, whose socket opens on the first call.
130
+ - A declared error or a refusal is its call's typed failure: only that call fails, and the
131
+ socket stays open. RPC answers no 401 or 403 challenge, and a token may expire under an open
132
+ socket, so the page handles `Unauthenticated` itself
133
+ ([guarantees.md](guarantees.md#wire-behavior)).
134
+
135
+ ## Send request context with a call
136
+
137
+ What travels beside a call's input, such as a token, a tenant or a room, goes in headers. On
138
+ the client, wrap the call in `RpcClient.withHeaders`:
139
+
140
+ ```ts
141
+ const here = RpcClient.withHeaders({ authorization: `Bearer ${token}`, [roomHeader]: name });
142
+
143
+ client.postMessage({ text }).pipe(here);
144
+ ```
145
+
146
+ On the server, authentication decodes the credential from those headers. Anything else is read
147
+ by native `RpcMiddleware` listed in `layer`'s `middleware`, which provides it as a service to
148
+ the handlers and to `authorize`:
149
+
150
+ ```ts
151
+ export class ResolveRoom extends RpcMiddleware.Service<ResolveRoom, { provides: CurrentRoom }>()(
152
+ "live-rpc/ResolveRoom",
153
+ ) {}
154
+
155
+ export const resolveRoom = Layer.succeed(ResolveRoom, (call, { headers }) =>
156
+ Effect.provideService(call, CurrentRoom, headers[roomHeader] ?? defaultRoom),
157
+ );
158
+ ```
159
+
160
+ [live-rpc/src/chat/room.ts](../examples/live-rpc/src/chat/room.ts) is that middleware, and
161
+ [live-rpc/src/browser.ts](../examples/live-rpc/src/browser.ts) the client sending the header.
162
+
163
+ - Over a WebSocket, a message's headers are the upgrade request's with its own merged over
164
+ them. A browser WebSocket sets no header on its upgrade, so a bearer token goes on each
165
+ message, and a refreshed token needs no new socket.
166
+ - A cookie the browser attaches to the upgrade covers every message
167
+ ([kitchen-sink/src/browser.ts](../examples/kitchen-sink/src/browser.ts)). A browser attaches
168
+ it from any page, so refuse a foreign `Origin` on the upgrade, with a request policy
169
+ ([Authentication-advanced.md](Authentication-advanced.md#run-a-request-policy-before-authentication)).
170
+ - An API key in the query string never reaches authentication.
171
+ - A request-time requirement is never a startup service: `Layer.succeed(CurrentRoom, "lobby")`
172
+ provided to the layer leaves `CurrentRoom` owed, in its type.
173
+ - Router middleware or `HttpRouter.provideRequest` around the layer provides one too, but over
174
+ a WebSocket it runs once, for the upgrade, so its value holds for the whole connection. What
175
+ differs per message needs a listed middleware.
176
+ - Authentication runs outside the listed middleware, so none of them provides what a verifier
177
+ reads
178
+ ([Authentication-advanced.md](Authentication-advanced.md#read-request-context-in-a-verifier)).
@@ -0,0 +1,15 @@
1
+ # ActionToolkit failure modes
2
+
3
+ What you see when something is wrong, and the fix. Search for a fragment of the message.
4
+ The tasks are in [ActionToolkit.md](ActionToolkit.md).
5
+
6
+ - An approved call never runs, and nothing is raised: a `tool-approval-response` finds its request by `approvalId` in the prompt history, and was sent without it. Use a `Chat`, or resend the assistant message holding the call and its request before the response.
7
+ - `Type 'X' is not assignable to type 'never'` where a turn or a call by hand runs, or `Service not found` in plain JavaScript: a request service is provided around the turn, or around the whole call effect, not to the stream alone, and a protected tool owes its identity whatever its handler reads. A startup service's Layer is provided to `layer`.
8
+ - A returned failure whose `result` is an `AiError` with reason `ToolParameterValidationError` (`failureOrigin: "parameters"`), not a declared error: the arguments did not decode. Neither `authorize` nor the handler ran.
9
+ - A turn fails with an `AiError`, `Failed to encode result for tool '<name>'`: the handler failed with an error its action does not declare, which a cast or plain JavaScript lets through. Declare it on the action.
10
+ - A model reads base64 text where an image was meant: a tool's success holds an `Action.Image`, which a Toolkit gives the model as JSON text. Leave the action out of `actions`, or serve it over MCP, which sends an image block ([ActionMcp.md](ActionMcp.md#return-images-and-other-media)).
11
+ - `Listed in actions, but no implementation holds it: <names>` thrown at `make`: those actions are not the implementations', by identity. `(another contract)` marks one whose name an implementation holds, as a second copy of the contracts module makes. Pass the implementation holding the action, or list the contract it implements.
12
+ - `Duplicate tool: <name>` thrown at `make`: two implementations serve actions of one name. Rename one action; of two implementations of one contract, pass one.
13
+ - A defect when a tool handles a call: the `layer` provided is another implementation's, with tools of the same names, which the types accept. Provide the `layer` that `make` returned with the `toolkit`.
14
+ - Two toolkits of one implementation answer with one set of startup services: their `layer`s, given different services, were provided together, and one serves both. Use separate implementations for separate services.
15
+ - A job or an agent beside `HttpRouter.serve` reads other state than the routes, or its builder runs twice: the toolkit's `layer` builds in another layer graph than the routes. Provide that `layer` inside the layer the server serves, or `Action.layer` and the services they share above both ([Action-in-process.md](Action-in-process.md#share-one-build-across-servers)).
@@ -1,133 +1,116 @@
1
1
  # ActionToolkit
2
2
 
3
- Implementations as Effect's native AI `Toolkit`, for programs that call tools in-process
4
- with `LanguageModel` or by hand. No server. A tool takes and gives JSON, as a model does; code
5
- of your own calls the implementations typed, with `Action.client` ([Action.md](Action.md#clients)).
3
+ Implementations as Effect's native AI `Toolkit`: one tool per action, run in the process that
4
+ calls the model, with no server. Reach for it when a `LanguageModel` or a `Chat` in your program
5
+ calls your actions. Nothing authenticates: the host names the caller. Code of your own calls
6
+ the same implementations typed, with `Action.client`
7
+ ([Action-in-process.md](Action-in-process.md#call-actions-in-process)).
8
+
9
+ Read this page alone, in one tool call: pages read together are cut. When something fails,
10
+ search [ActionToolkit-failures.md](ActionToolkit-failures.md). Reading order: [README.md](README.md).
6
11
 
7
12
  ## API
8
13
 
9
14
  Import `@gjermundgaraba/effect-actions/ActionToolkit`.
10
15
 
11
- | API | Purpose |
12
- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
13
- | `make(implementations, options?)` | Project an implementation or a list; returns `{ toolkit, layer }`. |
14
- | `toolkit` | A native `Toolkit`: `toolkit.tools` holds the tool definitions by name, with typed schemas, MCP hints and approval. |
15
- | `layer` | The handler layer: builds handlers and built authorizers in its scope; requires build-time services. |
16
-
17
- | Option | Meaning |
18
- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
19
- | `actions` | The actions that are tools, among the implementations': `[GetUser, RenameUser]`. Defaults to every action of them. |
20
- | `needsApproval` | `(call) =>` a boolean, or an `Effect` of one: the calls `LanguageModel` asks approval for instead of running them ([Approval](#approval)). |
21
-
22
- Exported types: `Tools`, what `make` returns; `Options`, what it takes; `ToolCall<A>`, the call `needsApproval` receives, `A` the union of its actions, for a rule written apart from `make`.
23
-
24
- A local surface: each implementation's `authorize` runs before the handlers of its protected
25
- actions, and the caller provides the identity.
26
-
27
- Each tool takes and gives the JSON encoding of the action's schemas, as a model speaks and
28
- as an MCP tool does, combines the action's errors with the built-in `InvalidInput`,
29
- `Unauthenticated` and `Forbidden` in a native union, and uses `failureMode: "return"`. Its request requirements are its handler's and its authorizer's, and a protected
30
- action's identity, whether or not anything reads it; build failures and services belong to the
31
- handler layer.
32
-
33
- ## Canonical
34
-
35
- ```ts example=toolkit-authorized.ts
36
- import { NodeRuntime } from "@effect/platform-node";
37
- import { Console, Effect, Layer, Stream } from "effect";
38
- import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
39
- import { actors, CurrentActor } from "./authorization.js";
40
- import { double, userActions } from "./handlers.js";
41
- import { Users } from "./users.js";
42
-
43
- // The implementations' `authorize` runs for the in-process caller as for the servers.
44
- const { toolkit, layer } = ActionToolkit.make([userActions, double]);
45
-
46
- const program = Effect.gen(function* () {
47
- const tools = yield* toolkit;
48
- const calls = yield* tools.handle("getUser", { id: "1" }); // encoded arguments
49
- const results = yield* Stream.runCollect(calls);
50
- yield* Console.log(results);
51
- }).pipe(
52
- Effect.provideService(CurrentActor, actors.alice), // identity around the whole invocation
53
- Effect.provide(layer.pipe(Layer.provide(Users.layerMemory))), // startup services only
54
- );
16
+ | API | Purpose |
17
+ | --------------------------------- | ----------------------------------------------------------------------- |
18
+ | `make(implementations, options?)` | Project an implementation or a list; returns `{ toolkit, layer }`. |
19
+ | `toolkit` | A native `Toolkit`, one tool per action, named after it. |
20
+ | `layer` | The tools' handlers. Requires the startup services their builders read. |
55
21
 
56
- program.pipe(NodeRuntime.runMain);
57
- ```
22
+ | Option | Meaning |
23
+ | --------------- | -------------------------------------------------------------------------------------------------------------- |
24
+ | `actions` | The actions that are tools: `[GetUser, RenameUser]`. Defaults to every action of the implementations. |
25
+ | `needsApproval` | `(call, context) =>` a boolean, or an `Effect` of one: the calls a model must have approved. Defaults to none. |
26
+
27
+ Exported types: `Tools`, what `make` returns; `Options`, what it takes; `ToolCall<A>`, the call `needsApproval` receives, for a rule written apart from `make`.
28
+
29
+ ## Give a model tools
58
30
 
59
- With a model: pass `toolkit` as `toolkit` to `LanguageModel.generateText` and provide `layer`.
60
- Beside tools of your own, or provider-defined ones such as a web search, merge the toolkits
61
- natively and provide every handler layer:
31
+ Pass `toolkit` to `LanguageModel.generateText` or a `Chat`, and provide `layer`:
32
+ [assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts) and
33
+ [assistant/src/agent.ts](../examples/assistant/src/agent.ts), with a scripted model in
34
+ [assistant/src/scripted.ts](../examples/assistant/src/scripted.ts).
62
35
 
63
36
  ```ts
64
- LanguageModel.generateText({ prompt, toolkit: Toolkit.merge(toolkit, WebKit) }).pipe(
65
- Effect.provide(Layer.mergeAll(layer, WebKitHandlers)),
37
+ export const { toolkit, layer } = ActionToolkit.make([tasks, identity]);
38
+
39
+ // One turn, as a member.
40
+ const turn = chat.generateText({ prompt, toolkit }).pipe(actingFor(member));
41
+
42
+ conversations.pipe(
43
+ Effect.provide(model),
44
+ // Once, around the program. Startup services only, never a caller.
45
+ Effect.provide(layer.pipe(Layer.provide([TaskStore.layerFile, Permissions.layerMemory]))),
66
46
  );
67
47
  ```
68
48
 
69
- ### Approval
70
-
71
- ```ts example=toolkit-approval.ts
72
- import { Effect, Option } from "effect";
73
- import { LanguageModel } from "effect/ai";
74
- import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
75
- import { type Actor, CurrentActor } from "./authorization.js";
76
- import { double, userActions } from "./handlers.js";
77
-
78
- // A model's writes wait for their caller's approval, except a rename of the caller itself:
79
- // `call.name` narrows `call.input` across both implementations. Without a caller, it asks.
80
- export const { toolkit, layer } = ActionToolkit.make([userActions, double], {
81
- needsApproval: (call) =>
82
- !call.action.readOnly &&
83
- Effect.map(
84
- Effect.serviceOption(CurrentActor),
85
- Option.match({
86
- onNone: () => true,
87
- onSome: ({ id }) => call.name !== "renameUser" || call.input.id !== id,
88
- }),
89
- ),
90
- });
91
-
92
- // One turn: the caller is provided around it, for the approval, `authorize` and the handlers.
93
- export const chat = (actor: Actor, prompt: string) =>
94
- LanguageModel.generateText({ prompt, toolkit }).pipe(Effect.provideService(CurrentActor, actor));
49
+ - **One `generateText` is one request to the model.** Its tool calls run, and their results come back in the response, unread by the model. For the model to read them, ask the `Chat` again with `prompt: []` while a response has `toolCalls` and no `tool-approval-request`.
50
+ - Without `actions`, every action of the implementations is a tool, one added later included. Where a model must not get them all, list the tools ([Action.md](Action.md#define-a-contract)): [kitchen-sink/src/issues/assistant.ts](../examples/kitchen-sink/src/issues/assistant.ts).
51
+ - A refusal or a declared error is not raised: it is a tool result with `isFailure: true`, which the model reads.
52
+ - A turn's tool calls, the approved calls of an answering turn included, run concurrently unless `generateText` is given a `concurrency`. A service they share must tolerate it: [assistant/src/tasks/store.ts](../examples/assistant/src/tasks/store.ts) locks each change.
53
+ - Arguments the input does not declare are dropped, where HTTP and a CLI refuse them ([guarantees.md](guarantees.md#wire-behavior)): a misspelled optional argument is ignored silently.
54
+ - Beside other tools, merge natively and provide every handler layer: `Toolkit.merge(toolkit, others)` with `Layer.mergeAll(layer, othersHandlers)`.
55
+ - **A model loop behind a route.** Yield `toolkit` and the model in the implementation's builder, `Action.implement(Ask, Effect.map(toolkit, (tools) => (input) => ...))`: what the handler itself yields is owed per request. Provide `layer` to the route's layer, `ActionHttp.layer(Http, ask).pipe(Layer.provide(layer))`, not in the handler, so the tools share the routes' build ([Action-in-process.md](Action-in-process.md#share-one-build-across-servers)). A tool call runs as the request's caller, unless the handler provides another around it. Keep each caller's `Chat` in a service between requests ([kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)), and answer an approval in a later one: [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts), with a scripted model in [demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts), provided in [kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts) and tested over HTTP in [kitchen-sink/test/assistant/assistant.test.ts](../examples/kitchen-sink/test/assistant/assistant.test.ts).
56
+
57
+ ### Call a tool by hand
58
+
59
+ `yield* toolkit` gives `tools.handle(name, arguments)`, which takes the JSON a model sends, an
60
+ ISO string for a `Schema.Date`, and returns an Effect of a stream of results:
61
+ [assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts). The handler starts
62
+ when `handle` runs, so provide the caller around the whole
63
+ `handle(...).pipe(Effect.flatMap(Stream.runCollect))`, not around the stream alone. `handle`
64
+ never asks for approval.
65
+
66
+ ## Name the caller
67
+
68
+ Provide the identity around each turn, or each call made by hand, as `actingFor` does in
69
+ [assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts): the approval rule, `authorize`
70
+ and the handlers of every call in the turn read it.
71
+
72
+ - Never provide an identity to `layer`, which is built once for every caller. It satisfies no protected tool's call, which still owes its own in its type, and it silently answers for any call that lacks one.
73
+ - A protected action's tool owes its identity whether or not its handler reads it ([Action.md](Action.md#say-who-may-call-an-action)).
74
+
75
+ ## Ask for approval
76
+
77
+ `needsApproval` is one rule over every call a model makes, asked before the call runs. Every
78
+ write: `needsApproval: (call) => !call.action.readOnly`
79
+ ([kitchen-sink/src/issues/assistant.ts](../examples/kitchen-sink/src/issues/assistant.ts)). A
80
+ rule reading the call's input and its caller:
81
+ [assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts).
82
+
83
+ - `call` is `{ name, action, input }`, the input decoded, over every action of the implementations, whatever `actions` lists. Check `call.name` to narrow the other two.
84
+ - The rule's `Effect` requires nothing: read the caller with `Effect.serviceOption`, and decide what the absence of one means.
85
+ - Its `Effect` cannot fail in its type, and one that fails anyway runs the call unapproved. To ask when a policy cannot be read, end it with `Effect.orElseSucceed(() => true)`.
86
+ - Approval is not authorization, which stays `authorize`. Only a model's turn asks for it, and `ActionMcp` has no such option.
87
+
88
+ A call that needs approval does not run: the turn ends with a `tool-approval-request` part in
89
+ `response.content` in place of its result. Start the next turn with the person's answers
90
+ ([assistant/src/agent.ts](../examples/assistant/src/agent.ts)):
91
+
92
+ ```ts
93
+ chat
94
+ .generateText({
95
+ toolkit,
96
+ prompt: [
97
+ {
98
+ role: "tool",
99
+ content: asked.content.flatMap((part) =>
100
+ part.type === "tool-approval-request"
101
+ ? [{ type: "tool-approval-response", approvalId: part.approvalId, approved: true }]
102
+ : [],
103
+ ),
104
+ },
105
+ ],
106
+ })
107
+ .pipe(actingFor(member));
95
108
  ```
96
109
 
97
- A call that needs approval ends the turn with a `tool-approval-request` part instead of a
98
- result; the host answers it with a native `tool-approval-response` prompt part in the next turn.
99
- Every write, whoever calls: `needsApproval: (call) => !call.action.readOnly`. A
100
- toolkit made for another agent or policy from the same implementations runs with this `layer`:
101
- provide it once. So does one of fewer tools, such as an agent's
102
- `ActionToolkit.make(userActions, { actions: [GetUser] }).toolkit`.
103
-
104
- ## Rules
105
-
106
- - Every action of the implementations passed becomes a tool, named after the action, with its `mcp` hints, unless `actions` lists the tools: then only those, each behind its implementation's authorizer and from its builder's one run, and an implementation holding none of them is not built. `make(implementations)` accepts one implementation or a list, such as `[userActions, double]`. The types take only actions of the implementations, and type `toolkit` and `layer`'s build services by the listed actions alone; `needsApproval`'s `call` is typed by every action of the implementations. Options whose `actions` may be absent, such as a value typed `ActionToolkit.Options<A>`, serve every action when it is absent and the listed ones when given, so their types owe what every action owes, and hold only the tools present either way ([guarantees.md](guarantees.md#names)).
107
- - A result's `result` is the action's decoded success or declared failure, and its `encodedResult` the JSON a model reads, such as `"42"` for a `BigInt`. Declared failures are returned as native tool results (`failureMode: "return"`), not raised.
108
- - `tools.handle(name, encodedInput)` takes JSON arguments, as a model sends them: an ISO string for a `Schema.Date`, `null` for an absent `Schema.optional` field. It returns an Effect producing a result stream. The handler starts while that stream is constructed, so request services must be provided around the entire `handle(...).pipe(Effect.flatMap(Stream.runCollect))`, not only around the stream.
109
- - Builders, calls and identity follow the [dependency lifetimes](guarantees.md#dependency-lifetimes), and authorization the [authorization rules](guarantees.md#authorization): `layer` builds, a call releases what it acquired when it ends, whether a model's turn or `tools.handle` makes it, and identity is supplied at invocation, never when building the layer. A protected tool's call owes its identity in its type; one call without it anyway, as from plain JavaScript, fails with `Unauthenticated` before `authorize` runs. The innermost caller provided wins: a call made under a narrower identity runs as that one.
110
- - A model loop in a route yields `toolkit` in its implementation's builder, `Action.implement(Chat, Effect.map(toolkit, (tools) => ({ prompt }) => ...), { authorize })`, and passes `tools` to `LanguageModel.generateText`; the route's layer takes the toolkit's `layer`, `ActionHttp.layer(ChatHttp, chat).pipe(Layer.provide(layer))`. That is one build, in the routes' layer graph, shared with every surface serving the same implementations. `Effect.provide(layer)` in the handler builds it for every call unless a surface of the graph serves the same implementations, and `HttpRouter.provideRequest(layer)` once, but in a layer graph of its own: a second build when another surface serves them and has not built them first.
111
- - Each tool carries only its own handler's request requirements, plus its authorizer's, not those of sibling actions.
112
- - `needsApproval` is one function over every call a model makes, before the call runs: `(call, context) =>`, where `call` is `{ name, action, input }`, the input decoded, and `context` is Effect's native approval context. `call` is typed by every action of the implementations, whatever `actions` lists, and checking `call.name` narrows `call.action` and `call.input` to that action's, across implementations too: `(call) => call.name === "erase" && call.input.id !== "draft"`. It gives a boolean, or an `Effect` of one that requires nothing: it runs in the caller's context, so it reads the caller with `Effect.serviceOption`. Default: no tool needs approval.
113
- - A `needsApproval` Effect that fails means no approval is needed, as `LanguageModel` decides natively. Its `Effect` cannot fail in its type: decide in `needsApproval` what a failure means, `Effect.orElseSucceed(() => true)` to ask when a policy cannot be read.
114
- - Tools belong to what runs them, their implementation's handlers behind its authorizer: the `layer` of a `make` call serves the tools it selected in any `toolkit` of the same implementations, a toolkit of fewer `actions` included, so toolkits made per agent or per approval policy run with one handler layer, built once. Two implementations never run each other's handlers, even with tools of one name, such as two implementations of one action behind different authorizers, whatever order their layers are provided in.
115
- - `toolkit` is a native `Toolkit`: `Toolkit.merge` combines it with other tools, and `yield* toolkit` gives the handled toolkit once the handler layers of all its tools are provided.
116
- - `LanguageModel` enforces approval; `tools.handle` ignores it, like any caller that is not a model's turn. It is not authorization, which stays the implementation's `authorize`. MCP has no such field, so `ActionMcp` takes no such option.
117
- - This is not an MCP server. Use `ActionMcp` to expose the same actions to external clients.
118
- - Every tool declares the built-in errors ([guarantees.md](guarantees.md#wire-behavior)), so an authorizer's refusal, or a handler's built-in failure, is an ordinary returned tool failure.
119
-
120
- ## Failure modes
121
-
122
- - Tool missing from the toolkit: its implementation was not passed to `make`, or `actions` leaves it out.
123
- - `Listed in actions, but no implementation holds it: <names>` thrown at `make`: those actions are not among the implementations' actions, by identity. `(another contract)` marks one whose name an implementation holds, as a second copy of the contracts module makes. Pass the implementation holding it, or list the contract it implements.
124
- - A defect when a tool handles a call: no `layer` of its implementation is provided, only one of another implementation with tools of the same names, such as another implementation of the same actions, which the types accept. Provide a `layer` made from the implementations the `toolkit` was made from.
125
- - A job or an agent beside `HttpRouter.serve`, or a model loop in a route given `layer` with `HttpRouter.provideRequest`, reads other state than the routes, or its builder runs twice: its `layer` builds in another layer graph than the routes. Put it inside the layer the server serves, a route's toolkit in its builder ([Rules](#rules)), or provide `Action.layer` and the services they share above both ([dependency lifetimes](guarantees.md#dependency-lifetimes)).
126
- - Every toolkit of an implementation answering with one set of startup services: two layers of it, built apart with different services, were provided together, and one of them serves its tools. Use separate implementations for separate services.
127
- - Type error on `needsApproval`'s `Effect`, which requires a service such as the caller: `needsApproval` requires nothing. Read the caller with `Effect.serviceOption(CurrentActor)`, which gives `Option.none()` without one.
128
- - `Property '<field>' does not exist` on `call.input`: `call` is a union over every action of the toolkit, and another action's input lacks the field. Check `call.name` first.
129
- - `Duplicate tool: <name>` thrown at `make`: two implementations serve actions of the same name. Rename one action. `actions` tells apart only separate contracts sharing a name, listing one; two implementations of the same contract stay both listed: pass one implementation.
130
- - `Service not found` for a request tag at call time: it was provided only to the stream. Provide it around the whole call effect. Providing a service to `layer` does not fail: that startup value fills in for every call that lacks its own ([guarantees.md](guarantees.md#dependency-lifetimes)), so never provide an identity there; a protected tool's call still owes it in its type.
131
- - `Type 'CurrentActor' is not assignable to type 'never'` where a program calling a tool runs: the tool's action is protected, and its call owes the caller, whatever the handler reads. Provide it around the call, or around the turn.
132
- - A returned failure whose `result` is an `AiError` with reason `ToolParameterValidationError` (`failureOrigin: "parameters"`), not a declared error: the arguments did not decode. Authorization and the handler did not run.
133
- - Type error on `layer` requirements: a build-time service is missing. Provide its Layer before `layer`.
110
+ - The answer needs the prompt history, where its request is found by `approvalId`. Sent alone through a bare `LanguageModel.generateText`, it runs nothing and raises nothing. Use a `Chat`, or send again the assistant message holding the call and its request, before the answer.
111
+ - An approved call runs in the answering turn, before the model is asked again, as whoever that turn runs as: provide the caller who was asked. `authorize` runs for it too ([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)).
112
+ - **Each request is answered on its own.** Of a turn's several requests, an approved call runs, a denied one (`approved: false`) does not and gives the model a failed result (`{ type: "execution-denied", reason }`, whose `reason` is `undefined` unless the answer gives one: not JSON, so never an action's success), and one left unanswered stays open for a later turn. To require every answer at once, check it in the host. Refuse an `approvalId` answered twice in one request: every answer is acted on, so its call would run twice. The answering turn's response does not repeat a request left open: read every open request from the `Chat`'s history, as [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts) does. Effect finds an approval's call by its tool-call id, which a provider makes unique: a model of your own that repeats one in a `Chat` runs another call than was shown, or none.
113
+ - **Continue on `toolCalls`, never on `toolResults`.** The answering turn's response holds the approved call's result, no tool call, and the model's reply to it: a loop that continues while a response has tool results asks the model again and loses that reply.
114
+ - **If a turn fails or is interrupted after an approved call**, the model failing or the client going away, the call has run, the `Chat` has recorded nothing of that turn, and the same answer sent again runs the call again. End the conversation where a turn does not succeed, or record an answer before its turn, and say in the error that an approved call may have run. Run one turn of a conversation at a time, and look it up again once the lock is held, so a turn that waited does not run on a conversation just ended: [kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts) does both.
115
+ - **Show the person what is asked.** A request names its call by `toolCallId`; that call, in `response.toolCalls`, holds the model's `params`, typed `unknown`. Show them as JSON, as [assistant/src/agent.ts](../examples/assistant/src/agent.ts) does, which joins the two; decode them with `Schema.toCodecJson(AddTask.input)`, the tool's parameters, only where you read a field.
116
+ - **Only the person asked answers.** A `tool-approval-response` whose `approvalId` matches an open request in the history is acted on, as the turn's caller: take an `approvalId` only from that caller, and keep the `Chat` where its owner alone reaches it.
@@ -0,0 +1,139 @@
1
+ # Authentication beyond a bearer token
2
+
3
+ A verifier reading the request, an API key or a cookie, a route of your own, a policy before
4
+ authentication, and a refusal outside the router.
5
+
6
+ Read [Authentication.md](Authentication.md) first, and this page alone, in one tool call. When
7
+ something fails, search [Authentication-failures.md](Authentication-failures.md). Reading order:
8
+ [README.md](README.md).
9
+
10
+ ## API
11
+
12
+ Import `@gjermundgaraba/effect-actions/Authentication`. `make` and `layer`:
13
+ [Authentication.md](Authentication.md#api).
14
+
15
+ | API | Purpose |
16
+ | -------------------------------------- | ----------------------------------------------------------------- |
17
+ | `protect(descriptor)` | Router middleware authenticating a route of the host's own. |
18
+ | `bearerTokenOf(authorization)` | The bearer token of an `Authorization` header value, an `Option`. |
19
+ | `refusalResponse(error, options?)` | A refusal's response, for a caller the router never routes. |
20
+ | `Protection`, `RefusalResponseOptions` | What `protect` returns, and what `refusalResponse` takes. |
21
+
22
+ | `refusalResponse` option | Meaning |
23
+ | ------------------------ | ---------------------------------------------------------------------------------------- |
24
+ | `authentication` | The descriptor refusing: needed for an error it declares, or a scheme other than Bearer. |
25
+ | `protectedResource` | The resource `layer` was given: challenges name its metadata URL and `scopesRequired`. |
26
+ | `authorization` | The request's `Authorization` header: a 401 to a presented token says `invalid_token`. |
27
+
28
+ ## Read request context in a verifier
29
+
30
+ A verifier may read per-request services: the request, or one of your own, such as the tenant a
31
+ request addresses, which native router middleware provides. Provide that middleware after the
32
+ provider, in a `Layer.provide` of its own; in one array, the members provide to the routes and
33
+ not to one another, and the tenant stays owed:
34
+
35
+ ```ts
36
+ routes.pipe(Layer.provide(authenticate), Layer.provide(resolveTenant.layer));
37
+ ```
38
+
39
+ See [kitchen-sink/src/workspaces/tenant.ts](../examples/kitchen-sink/src/workspaces/tenant.ts)
40
+ and [kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts).
41
+
42
+ - Router middleware provided around the routes runs before their authentication. Middleware
43
+ that reads the identity runs inside it: give it to the layer as its own `middleware`
44
+ ([ActionHttp.md](ActionHttp.md#serve-the-routes), [ActionRpc.md](ActionRpc.md#serve-the-rpcs)).
45
+
46
+ ## Use a scheme other than Bearer
47
+
48
+ Name the scheme in `make`'s `security`: an API key in a cookie, a header or the query, such as
49
+ `HttpApiSecurity.apiKey({ in: "cookie", key: "tracker_session" })`, or `HttpApiSecurity.basic`.
50
+ See [kitchen-sink/src/identity/Identity.ts](../examples/kitchen-sink/src/identity/Identity.ts)
51
+ and [kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts)
52
+ for a session cookie beside Bearer, and [kitchen-sink/src/admin/Admin.ts](../examples/kitchen-sink/src/admin/Admin.ts)
53
+ and [kitchen-sink/src/admin/authenticate.ts](../examples/kitchen-sink/src/admin/authenticate.ts)
54
+ for an API key in a header.
55
+
56
+ - OAuth is Bearer's. Another scheme takes no `protectedResource` and nothing steps up under it:
57
+ the scopes a `Forbidden` names there are sent in its JSON and challenge nothing.
58
+ - A browser attaches a cookie to any page's request, so refuse a foreign `Origin` before
59
+ authentication reads the cookie
60
+ ([Run a request policy before authentication](#run-a-request-policy-before-authentication)).
61
+ CORS guards the JSON routes alone ([ActionHttp.md](ActionHttp.md#call-the-api-from-a-browser));
62
+ a WebSocket upgrade is never preflighted, so for rpcs over a socket under a cookie that policy
63
+ is the only check ([ActionRpc.md](ActionRpc.md#send-request-context-with-a-call)).
64
+ - A scheme the deployment decides, such as a cookie name a framework prefixes over HTTPS, is
65
+ still one declaration: the server builds its descriptor at startup, under the same name and
66
+ identity, and from that one value its binding, its endpoint and its `protect`; a browser's
67
+ binding keeps a static one.
68
+ - Basic's credential reaches the verifier when its username or its password is non-empty, so a
69
+ verifier may receive an empty username.
70
+
71
+ ## Protect a route of your own
72
+
73
+ A descriptor covers action routes alone. A route of the host's own, such as a download or a
74
+ WebSocket upgrade, takes `Authentication.protect(descriptor)`:
75
+
76
+ ```ts
77
+ HttpRouter.add("GET", "/attachments/:id", handler).pipe(
78
+ Layer.provide(Authentication.protect(Session).layer),
79
+ );
80
+ ```
81
+
82
+ It answers a refusal the route fails with as an action's is answered. See
83
+ [kitchen-sink/src/attachments/download.ts](../examples/kitchen-sink/src/attachments/download.ts)
84
+ and [kitchen-sink/test/attachments/attachments.test.ts](../examples/kitchen-sink/test/attachments/attachments.test.ts).
85
+
86
+ - No authorizer runs for such a route: it states its own authorization.
87
+ - Every response to a request authentication covers is `Cache-Control: no-store` unless the
88
+ route states its own caching. A route that does says `private` unless its answer is the same
89
+ for every caller, or a cache in front of the server may serve it to anyone.
90
+ - The route of an RPC protocol needs no `protect`: `ActionRpc.layer` authenticates each
91
+ message itself.
92
+
93
+ ## Run a request policy before authentication
94
+
95
+ A check that must refuse a request before its credential is read, such as a `Host` or `Origin`
96
+ allowlist, is not the layer's own `middleware`, which runs inside authentication. Make it
97
+ global router middleware, `HttpRouter.middleware(check, { global: true })`, which covers every
98
+ request of the router, and provide it last:
99
+
100
+ ```ts
101
+ routes.pipe(Layer.provide(authenticate), Layer.provide(requestPolicy));
102
+ ```
103
+
104
+ See [kitchen-sink/src/request-policy.ts](../examples/kitchen-sink/src/request-policy.ts),
105
+ `publicRoutes` in [kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts) and
106
+ [kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts).
107
+
108
+ - **An MCP endpoint's `allowedOrigins` checks no `Host`.** It refuses only an unlisted `Origin`:
109
+ a request sending none passes, whatever its `Host`. The example's policy refuses a `Host` that
110
+ is neither loopback nor the public URL's, the check against DNS rebinding.
111
+ - **The order decides whether the policy covers discovery.** Global middleware runs in the
112
+ order it registers, a provider publishing a protected resource registers its discovery when
113
+ it is built, and the layer provided last is built first. A policy merged beside the routes,
114
+ with the provider provided around the merge, registers after the provider, and a foreign
115
+ `Host` reads the discovery document.
116
+
117
+ ## Refuse outside the router
118
+
119
+ A caller the router never routes, such as a Node `upgrade` handler admitting a socket, holds a
120
+ header and no request. Read the token with `bearerTokenOf`, verify it with the verifier the
121
+ routes use, and answer a refusal with `refusalResponse` ([options](#api)):
122
+
123
+ ```ts
124
+ const admit = (authorization: string | undefined): Effect.Effect<User, Response> =>
125
+ Effect.fromOption(
126
+ Authentication.bearerTokenOf(authorization),
127
+ () => new Action.Unauthenticated(),
128
+ ).pipe(
129
+ Effect.flatMap(verify),
130
+ Effect.mapError((error) =>
131
+ HttpServerResponse.toWeb(
132
+ Authentication.refusalResponse(error, { protectedResource, authorization }),
133
+ ),
134
+ ),
135
+ );
136
+ ```
137
+
138
+ - `protectedResource` is a value, not the Effect building it: where `layer` builds the resource
139
+ from configuration, build the handler's from the same configuration.