@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.
- package/README.md +103 -43
- package/dist/Action.d.mts +31 -118
- package/dist/Action.mjs +15 -53
- package/dist/ActionCli.d.mts +60 -28
- package/dist/ActionCli.mjs +54 -48
- package/dist/ActionHttp.d.mts +18 -67
- package/dist/ActionHttp.mjs +20 -70
- package/dist/ActionMcp.d.mts +25 -31
- package/dist/ActionMcp.mjs +140 -74
- package/dist/ActionRpc.d.mts +147 -0
- package/dist/ActionRpc.mjs +113 -0
- package/dist/ActionToolkit.d.mts +5 -8
- package/dist/ActionToolkit.mjs +3 -3
- package/dist/Authentication.d.mts +15 -15
- package/dist/Authentication.mjs +42 -96
- package/dist/Testing.d.mts +32 -25
- package/dist/Testing.mjs +45 -30
- package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
- package/dist/authentication/provider.mjs +12 -0
- package/dist/authentication/refusal.mjs +36 -0
- package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
- package/dist/cli/commands.mjs +302 -0
- package/dist/{internal → contract}/call.d.mts +2 -2
- package/dist/contract/call.mjs +10 -0
- package/dist/{internal → contract}/errors.d.mts +13 -10
- package/dist/{internal → contract}/errors.mjs +9 -14
- package/dist/{internal → contract}/implementation.d.mts +37 -21
- package/dist/{internal → contract}/implementation.mjs +18 -49
- package/dist/contract/media.d.mts +13 -0
- package/dist/contract/media.mjs +18 -0
- package/dist/contract/rules.d.mts +25 -0
- package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
- package/dist/{internal → http}/client.d.mts +7 -9
- package/dist/http/client.mjs +17 -0
- package/dist/http/declared.mjs +20 -0
- package/dist/http/schema-errors.mjs +9 -0
- package/dist/mcp/protocol.d.mts +17 -0
- package/dist/mcp/protocol.mjs +149 -0
- package/dist/stdio/console.mjs +64 -0
- package/dist/{internal → testing}/memory.d.mts +1 -1
- package/dist/{internal → testing}/memory.mjs +5 -17
- package/dist/toolkit/tools.mjs +29 -0
- package/docs/Action-failures.md +47 -0
- package/docs/Action-in-process.md +93 -0
- package/docs/Action.md +206 -424
- package/docs/ActionCli-failures.md +102 -0
- package/docs/ActionCli.md +206 -292
- package/docs/ActionHttp-failures.md +83 -0
- package/docs/ActionHttp.md +202 -281
- package/docs/ActionMcp-failures.md +31 -0
- package/docs/ActionMcp.md +229 -193
- package/docs/ActionRpc-failures.md +21 -0
- package/docs/ActionRpc.md +178 -0
- package/docs/ActionToolkit-failures.md +15 -0
- package/docs/ActionToolkit.md +100 -117
- package/docs/Authentication-advanced.md +139 -0
- package/docs/Authentication-failures.md +99 -0
- package/docs/Authentication.md +142 -392
- package/docs/CONTEXT.md +39 -23
- package/docs/README.md +71 -35
- package/docs/Testing-failures.md +20 -0
- package/docs/Testing.md +186 -151
- package/docs/guarantees.md +35 -95
- package/docs/setup-failures.md +34 -0
- package/docs/setup.md +88 -87
- package/examples/README.md +68 -0
- package/examples/assistant/README.md +92 -0
- package/examples/assistant/src/agent.ts +92 -0
- package/examples/assistant/src/cli.ts +34 -0
- package/examples/assistant/src/commands.ts +25 -0
- package/examples/assistant/src/identity/Identity.ts +33 -0
- package/examples/assistant/src/identity/authorize.ts +24 -0
- package/examples/assistant/src/identity/handlers.ts +10 -0
- package/examples/assistant/src/identity/members.ts +9 -0
- package/examples/assistant/src/identity/permissions.ts +23 -0
- package/examples/assistant/src/scripted.ts +23 -0
- package/examples/assistant/src/tasks/Tasks.ts +63 -0
- package/examples/assistant/src/tasks/handlers.ts +25 -0
- package/examples/assistant/src/tasks/store.ts +96 -0
- package/examples/assistant/src/toolkit.ts +30 -0
- package/examples/assistant/test/commands.test.ts +57 -0
- package/examples/assistant/test/identity/identity.test.ts +43 -0
- package/examples/assistant/test/tasks/store.test.ts +48 -0
- package/examples/assistant/test/tasks/tasks.test.ts +75 -0
- package/examples/assistant/test/toolkit.test.ts +121 -0
- package/examples/kitchen-sink/README.md +317 -0
- package/examples/kitchen-sink/src/Actions.ts +19 -0
- package/examples/kitchen-sink/src/Api.ts +24 -0
- package/examples/kitchen-sink/src/Rpc.ts +12 -0
- package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
- package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
- package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
- package/examples/kitchen-sink/src/admin/grants.ts +22 -0
- package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
- package/examples/kitchen-sink/src/admin/keys.ts +8 -0
- package/examples/kitchen-sink/src/agent.ts +47 -0
- package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
- package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
- package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
- package/examples/kitchen-sink/src/assistant/handlers.ts +146 -0
- package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
- package/examples/kitchen-sink/src/attachments/download.ts +49 -0
- package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
- package/examples/kitchen-sink/src/attachments/store.ts +58 -0
- package/examples/kitchen-sink/src/browser.ts +33 -0
- package/examples/kitchen-sink/src/cli.ts +85 -0
- package/examples/kitchen-sink/src/config.ts +18 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
- package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
- package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
- package/examples/kitchen-sink/src/identity/directory.ts +52 -0
- package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
- package/examples/kitchen-sink/src/identity/members.ts +13 -0
- package/examples/kitchen-sink/src/identity/quota.ts +62 -0
- package/examples/kitchen-sink/src/issues/Issues.ts +109 -0
- package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
- package/examples/kitchen-sink/src/issues/guide.ts +37 -0
- package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
- package/examples/kitchen-sink/src/issues/store.ts +96 -0
- package/examples/kitchen-sink/src/issues/write-limit.ts +41 -0
- package/examples/kitchen-sink/src/memory.ts +30 -0
- package/examples/kitchen-sink/src/ops.ts +42 -0
- package/examples/kitchen-sink/src/request-policy.ts +63 -0
- package/examples/kitchen-sink/src/routes.ts +120 -0
- package/examples/kitchen-sink/src/sdk.ts +46 -0
- package/examples/kitchen-sink/src/seed.ts +65 -0
- package/examples/kitchen-sink/src/server.ts +62 -0
- package/examples/kitchen-sink/src/stdio.ts +34 -0
- package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
- package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
- package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
- package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
- package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
- package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
- package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
- package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
- package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
- package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
- package/examples/kitchen-sink/test/routes.test.ts +123 -0
- package/examples/kitchen-sink/test/support/scripted.ts +40 -0
- package/examples/live-rpc/README.md +107 -0
- package/examples/live-rpc/src/Rpc.ts +8 -0
- package/examples/live-rpc/src/browser.ts +98 -0
- package/examples/live-rpc/src/chat/Chat.ts +75 -0
- package/examples/live-rpc/src/chat/authorize.ts +16 -0
- package/examples/live-rpc/src/chat/handlers.ts +50 -0
- package/examples/live-rpc/src/chat/room.ts +23 -0
- package/examples/live-rpc/src/chat/store.ts +54 -0
- package/examples/live-rpc/src/client.ts +37 -0
- package/examples/live-rpc/src/identity/Identity.ts +19 -0
- package/examples/live-rpc/src/identity/members.ts +8 -0
- package/examples/live-rpc/src/identity/verifier.ts +23 -0
- package/examples/live-rpc/src/routes.ts +22 -0
- package/examples/live-rpc/src/server.ts +43 -0
- package/examples/live-rpc/test/browser.test.ts +105 -0
- package/examples/live-rpc/test/chat/chat.test.ts +69 -0
- package/examples/live-rpc/test/routes.test.ts +99 -0
- package/examples/live-rpc/test/server.test.ts +75 -0
- package/examples/mcp-tools/README.md +136 -0
- package/examples/mcp-tools/src/instructions.ts +12 -0
- package/examples/mcp-tools/src/printers/Printers.ts +119 -0
- package/examples/mcp-tools/src/printers/fake.ts +115 -0
- package/examples/mcp-tools/src/printers/farm.ts +136 -0
- package/examples/mcp-tools/src/printers/handlers.ts +29 -0
- package/examples/mcp-tools/src/routes.ts +52 -0
- package/examples/mcp-tools/src/server.ts +30 -0
- package/examples/mcp-tools/src/stdio.ts +26 -0
- package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
- package/examples/mcp-tools/test/routes.test.ts +202 -0
- package/examples/notes-service/README.md +108 -0
- package/examples/notes-service/src/Api.ts +13 -0
- package/examples/notes-service/src/browser.ts +41 -0
- package/examples/notes-service/src/cli.ts +22 -0
- package/examples/notes-service/src/commands.ts +89 -0
- package/examples/notes-service/src/identity/Identity.ts +31 -0
- package/examples/notes-service/src/identity/authorize.ts +18 -0
- package/examples/notes-service/src/identity/handlers.ts +10 -0
- package/examples/notes-service/src/identity/users.ts +9 -0
- package/examples/notes-service/src/identity/verifier.ts +36 -0
- package/examples/notes-service/src/notes/Notes.ts +91 -0
- package/examples/notes-service/src/notes/handlers.ts +33 -0
- package/examples/notes-service/src/notes/store.ts +73 -0
- package/examples/notes-service/src/routes.ts +45 -0
- package/examples/notes-service/src/server.ts +26 -0
- package/examples/notes-service/src/status/Status.ts +10 -0
- package/examples/notes-service/src/status/handlers.ts +14 -0
- package/examples/notes-service/src/stdio.ts +25 -0
- package/examples/notes-service/test/commands.test.ts +134 -0
- package/examples/notes-service/test/notes/notes.test.ts +103 -0
- package/examples/notes-service/test/routes.test.ts +84 -0
- package/examples/quickstart/README.md +31 -0
- package/examples/quickstart/src/Api.ts +5 -0
- package/examples/quickstart/src/client.ts +13 -0
- package/examples/quickstart/src/greeting/Greeting.ts +12 -0
- package/examples/quickstart/src/greeting/handlers.ts +6 -0
- package/examples/quickstart/src/routes.ts +11 -0
- package/examples/quickstart/src/server.ts +16 -0
- package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
- package/package.json +32 -14
- package/dist/internal/actions.d.mts +0 -9
- package/dist/internal/authentication.mjs +0 -11
- package/dist/internal/call.mjs +0 -12
- package/dist/internal/cli.mjs +0 -328
- package/dist/internal/client.mjs +0 -25
- package/dist/internal/console.mjs +0 -77
- package/dist/internal/declared.d.mts +0 -13
- package/dist/internal/declared.mjs +0 -34
- package/dist/internal/mcp.d.mts +0 -11
- package/dist/internal/mcp.mjs +0 -52
- package/dist/internal/refusal.mjs +0 -67
- package/dist/internal/schema-errors.mjs +0 -20
- 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)).
|
package/docs/ActionToolkit.md
CHANGED
|
@@ -1,133 +1,116 @@
|
|
|
1
1
|
# ActionToolkit
|
|
2
2
|
|
|
3
|
-
Implementations as Effect's native AI `Toolkit
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
15
|
-
| `layer` | The
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
`
|
|
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.
|