@gjermundgaraba/effect-actions 0.18.2 → 0.20.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 +20 -47
- package/dist/Action.d.mts +96 -49
- package/dist/Action.mjs +12 -16
- package/dist/ActionCli.d.mts +82 -44
- package/dist/ActionCli.mjs +54 -35
- package/dist/ActionHttp.d.mts +84 -45
- package/dist/ActionHttp.mjs +3 -4
- package/dist/ActionMcp.d.mts +54 -29
- package/dist/ActionMcp.mjs +33 -15
- package/dist/ActionRpc.d.mts +48 -37
- package/dist/ActionRpc.mjs +3 -4
- package/dist/ActionToolkit.d.mts +32 -15
- package/dist/ActionToolkit.mjs +11 -9
- package/dist/Authentication.d.mts +92 -42
- package/dist/Authentication.mjs +38 -37
- package/dist/Testing.d.mts +23 -27
- package/dist/Testing.mjs +1 -2
- package/dist/authentication/provider.d.mts +1 -6
- package/dist/authentication/provider.mjs +2 -3
- package/dist/authentication/refusal.mjs +3 -3
- package/dist/cli/commands.d.mts +34 -13
- package/dist/cli/commands.mjs +48 -24
- package/dist/contract/ast.mjs +1 -2
- package/dist/contract/call.d.mts +1 -3
- package/dist/contract/call.mjs +1 -2
- package/dist/contract/caller.d.mts +1 -3
- package/dist/contract/caller.mjs +2 -3
- package/dist/contract/declared.d.mts +0 -2
- package/dist/contract/declared.mjs +28 -16
- package/dist/contract/errors.d.mts +42 -19
- package/dist/contract/errors.mjs +43 -19
- package/dist/contract/hints.mjs +4 -4
- package/dist/contract/implementation.d.mts +7 -14
- package/dist/contract/implementation.mjs +31 -19
- package/dist/contract/media.d.mts +7 -5
- package/dist/contract/media.mjs +7 -4
- package/dist/contract/names.mjs +15 -14
- package/dist/contract/status.mjs +4 -12
- package/dist/http/client.d.mts +15 -6
- package/dist/http/client.mjs +2 -3
- package/dist/http/schema-errors.mjs +1 -2
- package/dist/mcp/client.d.mts +21 -10
- package/dist/mcp/client.mjs +8 -11
- package/dist/mcp/protocol.mjs +1 -2
- package/dist/stdio/console.mjs +1 -2
- package/dist/testing/memory.d.mts +1 -3
- package/dist/testing/memory.mjs +1 -2
- package/docs/Action-failures.md +6 -15
- package/docs/Action.md +159 -191
- package/docs/ActionCli-failures.md +12 -92
- package/docs/ActionCli.md +102 -156
- package/docs/ActionHttp-failures.md +14 -76
- package/docs/ActionHttp.md +150 -176
- package/docs/ActionMcp-failures.md +2 -22
- package/docs/ActionMcp.md +79 -170
- package/docs/ActionRpc-failures.md +3 -12
- package/docs/ActionRpc.md +183 -120
- package/docs/ActionToolkit-failures.md +3 -10
- package/docs/ActionToolkit.md +112 -109
- package/docs/Authentication-advanced.md +171 -108
- package/docs/Authentication-failures.md +11 -91
- package/docs/Authentication.md +114 -135
- package/docs/README.md +45 -30
- package/docs/Testing-failures.md +2 -6
- package/docs/Testing.md +126 -159
- package/docs/setup-failures.md +8 -32
- package/docs/setup.md +42 -38
- package/examples/README.md +6 -10
- package/examples/assistant/README.md +20 -38
- package/examples/assistant/src/agent.ts +2 -3
- package/examples/assistant/src/cli.ts +4 -9
- package/examples/assistant/src/commands.ts +6 -4
- package/examples/assistant/src/identity/authorize.ts +3 -3
- package/examples/assistant/src/tasks/Tasks.ts +1 -2
- package/examples/assistant/src/tasks/handlers.ts +3 -3
- package/examples/assistant/src/toolkit.ts +6 -8
- package/examples/assistant/test/commands.test.ts +16 -11
- package/examples/assistant/test/tasks/tasks.test.ts +0 -2
- package/examples/assistant/test/toolkit.test.ts +0 -1
- package/examples/kitchen-sink/README.md +30 -52
- package/examples/kitchen-sink/src/Actions.ts +2 -3
- package/examples/kitchen-sink/src/Api.ts +3 -4
- package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
- package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
- package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
- package/examples/kitchen-sink/src/agent.ts +1 -1
- package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
- package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
- package/examples/kitchen-sink/src/attachments/download.ts +5 -8
- package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
- package/examples/kitchen-sink/src/browser.ts +3 -4
- package/examples/kitchen-sink/src/cli.ts +6 -7
- package/examples/kitchen-sink/src/config.ts +2 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
- package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
- package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
- package/examples/kitchen-sink/src/identity/quota.ts +1 -1
- package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
- package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
- package/examples/kitchen-sink/src/issues/guide.ts +2 -3
- package/examples/kitchen-sink/src/ops.ts +3 -6
- package/examples/kitchen-sink/src/request-policy.ts +8 -4
- package/examples/kitchen-sink/src/routes.ts +4 -6
- package/examples/kitchen-sink/src/seed.ts +0 -1
- package/examples/kitchen-sink/src/server.ts +11 -12
- package/examples/kitchen-sink/src/stdio.ts +3 -2
- package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
- package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
- package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
- package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
- package/examples/kitchen-sink/test/request-policy.test.ts +39 -3
- package/examples/kitchen-sink/test/routes.test.ts +3 -3
- package/examples/live-rpc/README.md +8 -49
- package/examples/live-rpc/src/browser.ts +8 -41
- package/examples/live-rpc/src/chat/Chat.ts +9 -3
- package/examples/live-rpc/src/chat/room.ts +1 -1
- package/examples/live-rpc/src/client.ts +2 -3
- package/examples/live-rpc/src/page.ts +34 -0
- package/examples/live-rpc/src/routes.ts +6 -7
- package/examples/live-rpc/src/server.ts +6 -4
- package/examples/live-rpc/test/browser.test.ts +6 -5
- package/examples/live-rpc/test/routes.test.ts +16 -15
- package/examples/live-rpc/test/server.test.ts +1 -1
- package/examples/mcp-tools/README.md +22 -45
- package/examples/mcp-tools/src/printers/Printers.ts +3 -7
- package/examples/mcp-tools/src/routes.ts +0 -7
- package/examples/mcp-tools/src/server.ts +1 -1
- package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
- package/examples/mcp-tools/test/routes.test.ts +2 -3
- package/examples/notes-service/README.md +9 -43
- package/examples/notes-service/src/cli.ts +0 -3
- package/examples/notes-service/src/commands.ts +7 -17
- package/examples/notes-service/src/identity/authorize.ts +2 -5
- package/examples/notes-service/src/identity/verifier.ts +3 -4
- package/examples/notes-service/src/notes/Notes.ts +2 -6
- package/examples/notes-service/src/notes/store.ts +5 -1
- package/examples/notes-service/src/routes.ts +6 -8
- package/examples/notes-service/src/server.ts +4 -3
- package/examples/notes-service/src/stdio.ts +4 -3
- package/examples/notes-service/test/commands.test.ts +1 -1
- package/examples/notes-service/test/notes/as.ts +6 -0
- package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
- package/examples/notes-service/test/notes/in-process.test.ts +50 -0
- package/examples/notes-service/test/notes/notes.test.ts +3 -42
- package/examples/notes-service/test/routes.test.ts +2 -2
- package/examples/quickstart/README.md +1 -1
- package/examples/quickstart/src/client.ts +1 -0
- package/examples/quickstart/src/server.ts +2 -4
- package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
- package/package.json +12 -16
- package/docs/Action-in-process.md +0 -93
- package/docs/CONTEXT.md +0 -46
- package/docs/guarantees.md +0 -44
package/docs/ActionToolkit.md
CHANGED
|
@@ -4,139 +4,142 @@ Implementations as Effect's native AI `Toolkit`: one tool per action, run in the
|
|
|
4
4
|
calls the model, with no server. Reach for it when a `LanguageModel` or a `Chat` in your program
|
|
5
5
|
calls your actions. Nothing authenticates: the host names the caller. Code of your own calls
|
|
6
6
|
the same implementations typed, with `Action.client`
|
|
7
|
-
([Action
|
|
7
|
+
([Action.md](Action.md#call-actions-in-process)).
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
When something fails, search [ActionToolkit-failures.md](ActionToolkit-failures.md). Reading order:
|
|
11
|
-
[README.md](README.md).
|
|
12
|
-
|
|
13
|
-
## API
|
|
14
|
-
|
|
15
|
-
Import `@gjermundgaraba/effect-actions/ActionToolkit`.
|
|
16
|
-
|
|
17
|
-
| API | Purpose |
|
|
18
|
-
| --------------------------------- | ------------------------------------------------------------------------------ |
|
|
19
|
-
| `make(implementations, options?)` | Project an implementation or a list; returns `{ toolkit, layer }`. |
|
|
20
|
-
| `toolkit` | A native `Toolkit`, one tool per action, named after it. |
|
|
21
|
-
| `layer` | The tools' handlers. Requires the startup services their builders read. |
|
|
22
|
-
| `openApprovals(history)` | The approval requests a `Chat`'s history still holds open, each with its call. |
|
|
23
|
-
|
|
24
|
-
| Option | Meaning |
|
|
25
|
-
| --------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
26
|
-
| `actions` | The actions that are tools: `[GetUser, RenameUser]`. Defaults to every action of the implementations. |
|
|
27
|
-
| `needsApproval` | `(call, context) =>` a boolean, or an `Effect` of one: the calls a model must have approved. Defaults to none. |
|
|
28
|
-
|
|
29
|
-
Exported types: `Tools`, what `make` returns; `Options`, what it takes; `ToolCall<A>`, the call `needsApproval` receives, for a rule written apart from `make`; `Approval`, what `openApprovals` lists.
|
|
9
|
+
A message the library throws says its fix; for a compiler diagnostic or Effect's own error, search [ActionToolkit-failures.md](ActionToolkit-failures.md). For one symbol, `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`: each declaration's JSDoc carries its rules and gotchas, and this page only what an example cannot show.
|
|
30
10
|
|
|
31
11
|
## Give a model tools
|
|
32
12
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
13
|
+
`ActionToolkit.make` projects implementations into `{ toolkit, layer }`. Pass `toolkit` to
|
|
14
|
+
`LanguageModel.generateText` or a `Chat`, and provide `layer` once, around the program:
|
|
15
|
+
|
|
16
|
+
```ts example=assistant/src/toolkit.ts
|
|
17
|
+
import { Effect, Option } from "effect";
|
|
18
|
+
import { Tool, Toolkit } from "effect/ai";
|
|
19
|
+
import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
|
|
20
|
+
import { identity } from "./identity/handlers.js";
|
|
21
|
+
import { CurrentCaller, type Member } from "./identity/Identity.js";
|
|
22
|
+
import { tasks } from "./tasks/handlers.js";
|
|
23
|
+
|
|
24
|
+
// `actions.layer` holds the handlers, and requires the startup services their builders read;
|
|
25
|
+
// neither it nor `actions.toolkit` holds a caller.
|
|
26
|
+
const actions = ActionToolkit.make([tasks, identity], {
|
|
27
|
+
// One rule over every call a model makes, asked before the call runs. A read runs at once. A
|
|
28
|
+
// write waits for a person to approve it, except a task the caller adds to their own list:
|
|
29
|
+
// `call.name` narrows `call.input` to that action's. Without a caller, it asks.
|
|
30
|
+
needsApproval: (call) =>
|
|
31
|
+
!call.action.readOnly &&
|
|
32
|
+
Effect.map(
|
|
33
|
+
Effect.serviceOption(CurrentCaller),
|
|
34
|
+
Option.match({
|
|
35
|
+
onNone: () => true,
|
|
36
|
+
onSome: (caller) => call.name !== "addTask" || call.input.assignee !== caller.id,
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// A title for this assistant's approval display. The model still calls the tool `addTask`.
|
|
42
|
+
// The later definition wins; annotating the generated tool keeps its handler and approval rule.
|
|
43
|
+
export const toolkit = Toolkit.merge(
|
|
44
|
+
actions.toolkit,
|
|
45
|
+
Toolkit.make(actions.toolkit.tools.addTask.annotate(Tool.Title, "Add a task")),
|
|
46
|
+
);
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
const turn = chat.generateText({ prompt, toolkit }).pipe(actingFor(member));
|
|
48
|
+
export const layer = actions.layer;
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
50
|
+
// One toolkit serves every member, so who a turn runs as is provided around that turn. This is
|
|
51
|
+
// how this example names a model's caller, and it takes a `Member`. The tools themselves accept
|
|
52
|
+
// any `CurrentCaller`, so keeping the operator away from a model is this function's doing, and
|
|
53
|
+
// that of every host using it.
|
|
54
|
+
export const actingFor = (member: Member) => Effect.provideService(CurrentCaller, member);
|
|
49
55
|
```
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- **
|
|
57
|
+
[assistant/src/agent.ts](../examples/assistant/src/agent.ts) runs the turns and provides `layer`
|
|
58
|
+
with its startup services, with a scripted model in
|
|
59
|
+
[assistant/src/scripted.ts](../examples/assistant/src/scripted.ts). What a call returns, a
|
|
60
|
+
refusal and arguments that do not decode included, is on `ActionToolkit.make`; the options'
|
|
61
|
+
rules are on `ActionToolkit.Options`.
|
|
62
|
+
|
|
63
|
+
- **Let the model read its tools' results** by asking again:
|
|
64
|
+
[kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts).
|
|
65
|
+
- Where a model must not get every action, list the tools
|
|
66
|
+
([Action.md](Action.md#define-a-contract)):
|
|
67
|
+
[kitchen-sink/src/issues/assistant.ts](../examples/kitchen-sink/src/issues/assistant.ts).
|
|
68
|
+
- A turn's tool calls, the approved calls of an answering turn included, run concurrently unless
|
|
69
|
+
`generateText` is given a `concurrency`:
|
|
70
|
+
[assistant/src/tasks/store.ts](../examples/assistant/src/tasks/store.ts) locks each change.
|
|
71
|
+
- **A model loop behind a route.** Yield `toolkit` and the model in the implementation's
|
|
72
|
+
builder, and provide `layer` to the route's layer,
|
|
73
|
+
`ActionHttp.layer(Http, ask).pipe(Layer.provide(layer))`, so the tools share the routes' build
|
|
74
|
+
([Action.md](Action.md#share-one-build-across-servers)):
|
|
75
|
+
[kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts),
|
|
76
|
+
keeping each caller's `Chat` in
|
|
77
|
+
[conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts), with a scripted
|
|
78
|
+
model in [demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts) that the
|
|
79
|
+
entrypoint provides through [kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts),
|
|
80
|
+
tested over HTTP in
|
|
81
|
+
[kitchen-sink/test/assistant/assistant.test.ts](../examples/kitchen-sink/test/assistant/assistant.test.ts).
|
|
82
|
+
Name the files as it does, the route's contract `Assistant.ts` and the loop `handlers.ts`,
|
|
83
|
+
from the first one written.
|
|
84
|
+
- **The real model comes later?** Provide a scripted one from the entrypoint until then, as the
|
|
85
|
+
kitchen-sink does. Reading the model with `Effect.serviceOption` hides the requirement: the
|
|
86
|
+
server builds without one and every request fails.
|
|
58
87
|
|
|
59
88
|
### Customize the native tools
|
|
60
89
|
|
|
61
|
-
Give a generated tool a title
|
|
62
|
-
`effect/ai
|
|
63
|
-
title when printing approval requests
|
|
90
|
+
Give a generated tool a title, or other annotations, with native `Tool` and `Toolkit` from
|
|
91
|
+
`effect/ai`, as `toolkit` above does; [agent.ts](../examples/assistant/src/agent.ts) reads the
|
|
92
|
+
title when printing approval requests.
|
|
64
93
|
|
|
65
|
-
```ts
|
|
66
|
-
const actions = ActionToolkit.make([tasks, identity]);
|
|
67
|
-
const toolkit = Toolkit.merge(
|
|
68
|
-
actions.toolkit,
|
|
69
|
-
Toolkit.make(actions.toolkit.tools.addTask.annotate(Tool.Title, "Add a task")),
|
|
70
|
-
);
|
|
71
|
-
const layer = actions.layer;
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
- `Toolkit.merge` keeps the later definition on a name collision. Put the customized one last;
|
|
75
|
-
keep names distinct when adding unrelated tools.
|
|
76
|
-
- `annotate` and `annotateMerge` return a new definition preserving the generated tool's handler
|
|
77
|
-
identity and approval rule. Keep the original `layer`. Recreating it with `Tool.make` under
|
|
78
|
-
the same name does not preserve that binding.
|
|
79
94
|
- To add other native tools, use `Toolkit.merge(toolkit, others)` and provide both handler layers
|
|
80
95
|
with `Layer.mergeAll(layer, othersHandlers)`.
|
|
81
96
|
|
|
82
97
|
### Call a tool by hand
|
|
83
98
|
|
|
84
|
-
`yield* toolkit` gives `tools.handle(name, arguments)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
when `handle` runs, so provide the caller around the whole
|
|
88
|
-
`handle(...).pipe(Effect.flatMap(Stream.runCollect))`, not around the stream alone. `handle`
|
|
89
|
-
never asks for approval.
|
|
99
|
+
`yield* toolkit` gives `tools.handle(name, arguments)`:
|
|
100
|
+
[assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts). Its rules, where
|
|
101
|
+
to provide the caller among them, are on `ActionToolkit.Tools`.
|
|
90
102
|
|
|
91
103
|
## Name the caller
|
|
92
104
|
|
|
93
105
|
Provide the identity around each turn, or each call made by hand, as `actingFor` does in
|
|
94
|
-
[assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
- 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.
|
|
98
|
-
- 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)).
|
|
106
|
+
[assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts). Why never to `layer`, and that
|
|
107
|
+
a protected tool owes it whatever its handler reads, are on `Action.Options.caller`
|
|
108
|
+
([Action.md](Action.md#say-who-may-call-an-action)).
|
|
99
109
|
|
|
100
110
|
## Ask for approval
|
|
101
111
|
|
|
102
|
-
|
|
103
|
-
write: `needsApproval: (call) => !call.action.readOnly`
|
|
112
|
+
To ask before every write: `needsApproval: (call) => !call.action.readOnly`
|
|
104
113
|
([kitchen-sink/src/issues/assistant.ts](../examples/kitchen-sink/src/issues/assistant.ts)). A
|
|
105
|
-
rule reading the call's input and its caller
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
- `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.
|
|
109
|
-
- The rule's `Effect` requires nothing: read the caller with `Effect.serviceOption`, and decide what the absence of one means.
|
|
110
|
-
- 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)`.
|
|
111
|
-
- Approval is not authorization, which stays `authorize`. Only a model's turn asks for it, and `ActionMcp` has no such option.
|
|
114
|
+
rule reading the call's input and its caller is `toolkit` above; its rules are on
|
|
115
|
+
`ActionToolkit.Options`.
|
|
112
116
|
|
|
113
117
|
A call that needs approval does not run: the turn ends with a `tool-approval-request` part in
|
|
114
|
-
`response.content` in place of its result. Start the next turn with the person's answers
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
- **
|
|
139
|
-
-
|
|
140
|
-
-
|
|
141
|
-
|
|
142
|
-
- **Only the person asked answers.** A `tool-approval-response` whose `approvalId` matches a request in the history without a result is acted on, as the turn's caller, an answered one included: take an `approvalId` only from that caller, among those `openApprovals` lists, and keep the `Chat` where its owner alone reaches it.
|
|
118
|
+
`response.content` in place of its result. Start the next turn with the person's answers, in a
|
|
119
|
+
`Chat`, as the caller who was asked:
|
|
120
|
+
[assistant/src/agent.ts](../examples/assistant/src/agent.ts) shows both and says why, tested in
|
|
121
|
+
[assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts).
|
|
122
|
+
|
|
123
|
+
- **Each request is answered on its own**: an approved call runs, a denied one does not, and one
|
|
124
|
+
left unanswered stays open for a later turn; to require every answer at once, check it in the
|
|
125
|
+
host. List the open ones with `ActionToolkit.openApprovals(yield* Ref.get(chat.history))`.
|
|
126
|
+
The kitchen-sink refuses an `approvalId` answered twice
|
|
127
|
+
([Assistant.ts](../examples/kitchen-sink/src/assistant/Assistant.ts)), gives each call its own
|
|
128
|
+
id ([demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts)) and lets only its
|
|
129
|
+
owner reach a conversation
|
|
130
|
+
([conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)).
|
|
131
|
+
- A denied call's result, `{ type: "execution-denied", reason }`, has `reason` `undefined` unless
|
|
132
|
+
the answer gives one, so it is not JSON: a route answering it as a `Schema.Unknown` success
|
|
133
|
+
answers an empty 500.
|
|
134
|
+
- **If a turn fails or is interrupted once an approved call has started**, the call may have
|
|
135
|
+
run: end the conversation and say so in the error, as
|
|
136
|
+
[kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)
|
|
137
|
+
and [handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts) do. What a failed
|
|
138
|
+
`streamText` leaves is on `ActionToolkit.openApprovals`.
|
|
139
|
+
- **Show the person what is asked**: [assistant/src/agent.ts](../examples/assistant/src/agent.ts)
|
|
140
|
+
joins each request to its call by `toolCallId`; how to read the arguments is on
|
|
141
|
+
`ActionToolkit.Approval`.
|
|
142
|
+
- **If repeating a write would create an unwanted additional effect, retries need
|
|
143
|
+
application-owned deduplication.** Keep the same operation key across attempts, as in
|
|
144
|
+
[notes-service's creation example](../examples/notes-service/README.md#retry-a-creation).
|
|
145
|
+
Approval history alone does not make the mutation and its result record atomic.
|
|
@@ -3,137 +3,200 @@
|
|
|
3
3
|
A verifier reading the request, an API key or a cookie, a route of your own, a policy before
|
|
4
4
|
authentication, and a refusal outside the router.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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`. |
|
|
6
|
+
A message the library throws says its fix; for a compiler diagnostic or Effect's own error,
|
|
7
|
+
search [Authentication-failures.md](Authentication-failures.md). For one symbol,
|
|
8
|
+
`grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`: each declaration's JSDoc
|
|
9
|
+
carries its rules and gotchas, and this page only what an example cannot show.
|
|
27
10
|
|
|
28
11
|
## Read request context in a verifier
|
|
29
12
|
|
|
30
13
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
14
|
+
request addresses, which native router middleware provides:
|
|
15
|
+
|
|
16
|
+
```ts example=kitchen-sink/src/workspaces/tenant.ts
|
|
17
|
+
import { Context, Effect, Option } from "effect";
|
|
18
|
+
import { HttpRouter, HttpServerRequest } from "effect/http";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The workspace a request addresses, from its `X-Workspace` header. It is request context, not
|
|
22
|
+
* an identity: the verifier reads it to decide which of an account's memberships the caller
|
|
23
|
+
* acts in. Handlers never read it; they read the workspace of the authenticated member.
|
|
24
|
+
*/
|
|
25
|
+
export class Tenant extends Context.Service<Tenant, Option.Option<string>>()("tracker/Tenant") {}
|
|
26
|
+
|
|
27
|
+
// Native router middleware. It provides `Tenant` to whatever it is provided to, per request:
|
|
28
|
+
// to the verifier too, when it is provided after the authentication, in a `Layer.provide` of
|
|
29
|
+
// its own: in one array with the authentication it would leave `Tenant` owed, since an array's
|
|
30
|
+
// layers provide to the routes, not to one another.
|
|
31
|
+
export const resolveTenant = HttpRouter.middleware<{ provides: Tenant }>()((route) =>
|
|
32
|
+
Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) =>
|
|
33
|
+
Effect.provideService(route, Tenant, Option.fromNullishOr(request.headers["x-workspace"])),
|
|
34
|
+
),
|
|
35
|
+
);
|
|
37
36
|
```
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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)).
|
|
38
|
+
The verifier reading it is `authenticateBearer` in
|
|
39
|
+
[kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts),
|
|
40
|
+
and `publicRoutes` in [kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts)
|
|
41
|
+
provides the middleware.
|
|
45
42
|
|
|
46
43
|
## Use a scheme other than Bearer
|
|
47
44
|
|
|
48
|
-
Name the scheme in `
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
and
|
|
45
|
+
Name the scheme in `Authentication.Options.security`, whose JSDoc says what each scheme gives
|
|
46
|
+
up. See `Session` in
|
|
47
|
+
[kitchen-sink/src/identity/Identity.ts](../examples/kitchen-sink/src/identity/Identity.ts) and
|
|
48
|
+
`authenticateSession` in
|
|
49
|
+
[kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts)
|
|
50
|
+
for a session cookie beside Bearer, and
|
|
51
|
+
[kitchen-sink/src/admin/Admin.ts](../examples/kitchen-sink/src/admin/Admin.ts) and
|
|
52
|
+
[kitchen-sink/src/admin/authenticate.ts](../examples/kitchen-sink/src/admin/authenticate.ts)
|
|
54
53
|
for an API key in a header.
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
55
|
+
For a cookie covering rpcs over a WebSocket, see
|
|
56
|
+
[ActionRpc.md](ActionRpc.md#send-request-context-with-a-call). A scheme the deployment decides,
|
|
57
|
+
such as a cookie name a framework prefixes over HTTPS, is still one declaration, as
|
|
58
|
+
`Authentication.layer` says.
|
|
70
59
|
|
|
71
60
|
## Protect a route of your own
|
|
72
61
|
|
|
73
|
-
A
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
62
|
+
A route of the host's own, such as a download or a WebSocket upgrade, takes
|
|
63
|
+
`Authentication.protect(descriptor)`, whose rules, authorization and caching included, are on
|
|
64
|
+
its JSDoc:
|
|
65
|
+
|
|
66
|
+
```ts example=kitchen-sink/src/attachments/download.ts
|
|
67
|
+
import { Effect, Layer } from "effect";
|
|
68
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
69
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
70
|
+
import { authorize } from "../identity/authorize.js";
|
|
71
|
+
import { CurrentMember, Session } from "../identity/Identity.js";
|
|
72
|
+
import { GetAttachment } from "./Attachments.js";
|
|
73
|
+
import { AttachmentStore } from "./store.js";
|
|
74
|
+
|
|
75
|
+
// A route of the host's own: `GET /attachments/<id>` answers the image's bytes, for an `<img>`
|
|
76
|
+
// of the web app, which no action can (an action answers JSON), behind
|
|
77
|
+
// `Authentication.protect(Session)`: the page's cookie, which the web app's rpcs use. A token
|
|
78
|
+
// holder has no use for it: `getAttachment` gives an API client the same image.
|
|
79
|
+
export const download = Layer.unwrap(
|
|
80
|
+
Effect.map(AttachmentStore, (store) =>
|
|
81
|
+
HttpRouter.add(
|
|
82
|
+
"GET",
|
|
83
|
+
"/attachments/:id",
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
// The rule of the action it mirrors.
|
|
86
|
+
yield* authorize(GetAttachment);
|
|
87
|
+
|
|
88
|
+
const member = yield* CurrentMember;
|
|
89
|
+
const { id = "" } = yield* HttpRouter.params;
|
|
90
|
+
const { image } = yield* store.get(member.workspace, id);
|
|
91
|
+
|
|
92
|
+
// An attachment never changes, but it is one workspace's: `private`.
|
|
93
|
+
return HttpServerResponse.uint8Array(image.data, {
|
|
94
|
+
contentType: image.mimeType,
|
|
95
|
+
headers: {
|
|
96
|
+
"cache-control": "private, max-age=31536000, immutable",
|
|
97
|
+
// What a member uploaded is served from the application's own origin, and an image
|
|
98
|
+
// can be a document: an SVG opened by its URL, not in an `<img>`, runs its scripts
|
|
99
|
+
// as the member reading it. `sandbox` gives it no origin and no scripts, and
|
|
100
|
+
// `nosniff` keeps the browser to the type stated.
|
|
101
|
+
"content-security-policy": "sandbox",
|
|
102
|
+
"x-content-type-options": "nosniff",
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}).pipe(
|
|
106
|
+
Effect.catchTag("AttachmentNotFound", () =>
|
|
107
|
+
Effect.succeed(HttpServerResponse.empty({ status: 404 })),
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
).pipe(Layer.provide(Authentication.protect(Session).layer)),
|
|
111
|
+
),
|
|
79
112
|
);
|
|
80
113
|
```
|
|
81
114
|
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
115
|
+
[kitchen-sink/test/attachments/attachments.test.ts](../examples/kitchen-sink/test/attachments/attachments.test.ts)
|
|
116
|
+
asserts what it answers.
|
|
92
117
|
|
|
93
118
|
## Run a request policy before authentication
|
|
94
119
|
|
|
95
|
-
|
|
96
|
-
allowlist,
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
For a check that must refuse a request before its credential is read, such as a `Host` or
|
|
121
|
+
`Origin` allowlist, make global router middleware, `HttpRouter.middleware(check, { global: true })`,
|
|
122
|
+
and provide it in the last `Layer.provide`:
|
|
123
|
+
|
|
124
|
+
```ts example=kitchen-sink/src/request-policy.ts
|
|
125
|
+
import { Effect, Layer, Option } from "effect";
|
|
126
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
127
|
+
import { allowedOrigins, publicUrl } from "./config.js";
|
|
128
|
+
|
|
129
|
+
const loopback = ["localhost", "127.0.0.1"];
|
|
130
|
+
|
|
131
|
+
// Which Host and Origin a request may carry: not the library's concern, but every request of
|
|
132
|
+
// both ports passes it first. A Host that is neither loopback nor the public URL's is refused,
|
|
133
|
+
// against DNS rebinding. The web app signs in with a cookie, which a browser attaches to
|
|
134
|
+
// any page's request, so a foreign Origin must be refused before authentication reads it.
|
|
135
|
+
// That is why this is global router middleware (`global: true`): it runs before routing, so
|
|
136
|
+
// before every route's authentication. As a layer's own `middleware` it would run inside the
|
|
137
|
+
// authentication, too late.
|
|
138
|
+
export const requestPolicy = HttpRouter.middleware(
|
|
139
|
+
Effect.gen(function* () {
|
|
140
|
+
const hosts = [...loopback, new URL(yield* publicUrl).hostname];
|
|
141
|
+
const origins = yield* allowedOrigins;
|
|
142
|
+
|
|
143
|
+
return (httpEffect) =>
|
|
144
|
+
Effect.gen(function* () {
|
|
145
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
146
|
+
const url = HttpServerRequest.toURL(request.modify({ url: request.originalUrl }));
|
|
147
|
+
|
|
148
|
+
if (Option.isNone(url) || !hosts.includes(url.value.hostname)) {
|
|
149
|
+
return HttpServerResponse.text("Host not allowed", { status: 403 });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const origin = request.headers["origin"];
|
|
153
|
+
|
|
154
|
+
// The request's own origin is that of a page served from this port directly. Behind a
|
|
155
|
+
// proxy ending TLS it is an `http:` one from Effect 4.0.6, whose `originalUrl` takes the
|
|
156
|
+
// scheme of the connection, whatever `X-Forwarded-Proto` says, so the public URL's
|
|
157
|
+
// origin is among `origins`.
|
|
158
|
+
if (origin !== undefined && origin !== url.value.origin && !origins.includes(origin)) {
|
|
159
|
+
return HttpServerResponse.text("Origin not allowed", { status: 403 });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return yield* httpEffect;
|
|
163
|
+
});
|
|
164
|
+
}),
|
|
165
|
+
{ global: true },
|
|
166
|
+
);
|
|
99
167
|
|
|
100
|
-
|
|
101
|
-
|
|
168
|
+
// CORS for the one cross-origin caller, a browser MCP client. `HttpRouter.cors` is global to
|
|
169
|
+
// the router too: it answers the preflight before any route's authentication, and adds its
|
|
170
|
+
// headers to refusals as well. An MCP endpoint's `allowedOrigins` is no CORS: it only refuses
|
|
171
|
+
// origins. The headers are those an MCP client sends and must be able to read. A client of an
|
|
172
|
+
// MCP revision with sessions sends its session's id on each request, reads it from the answer
|
|
173
|
+
// that opened the session, and ends the session with a `DELETE`.
|
|
174
|
+
export const cors = Layer.unwrap(
|
|
175
|
+
Effect.map(allowedOrigins, (origins) =>
|
|
176
|
+
HttpRouter.cors({
|
|
177
|
+
allowedOrigins: origins,
|
|
178
|
+
allowedMethods: ["POST", "DELETE"],
|
|
179
|
+
allowedHeaders: [
|
|
180
|
+
"Content-Type",
|
|
181
|
+
"Authorization",
|
|
182
|
+
"X-Workspace",
|
|
183
|
+
"MCP-Protocol-Version",
|
|
184
|
+
"MCP-Method",
|
|
185
|
+
"MCP-Name",
|
|
186
|
+
"Mcp-Session-Id",
|
|
187
|
+
],
|
|
188
|
+
exposedHeaders: ["WWW-Authenticate", "MCP-Protocol-Version", "Mcp-Session-Id"],
|
|
189
|
+
}),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
102
192
|
```
|
|
103
193
|
|
|
104
|
-
See [kitchen-sink/src/
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
194
|
+
See `publicRoutes` in [kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts);
|
|
195
|
+
[kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts)
|
|
196
|
+
asserts it.
|
|
116
197
|
|
|
117
198
|
## Refuse outside the router
|
|
118
199
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
200
|
+
For a caller the router never routes, such as a Node `upgrade` handler admitting a socket, read
|
|
201
|
+
the token with `Authentication.bearerTokenOf` and answer a refusal with
|
|
202
|
+
`Authentication.refusalResponse`, whose JSDoc holds the rest.
|