@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
package/docs/Action.md
CHANGED
|
@@ -1,449 +1,231 @@
|
|
|
1
1
|
# Action
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
takes an implementation or a list of them, and so does `client`, which calls them in process
|
|
9
|
-
with the methods `ActionHttp.client` has. The module also exports the built-in errors every
|
|
10
|
-
surface declares and any handler may fail with.
|
|
3
|
+
`Action.make` defines an action, and `Action.implement` binds handlers and an authorizer to
|
|
4
|
+
contracts: every surface takes the implementation it returns, or a list of them.
|
|
5
|
+
|
|
6
|
+
Read this page alone, in one tool call: pages read together are cut. When something fails,
|
|
7
|
+
search [Action-failures.md](Action-failures.md). Reading order: [README.md](README.md).
|
|
11
8
|
|
|
12
9
|
## API
|
|
13
10
|
|
|
14
11
|
Import `@gjermundgaraba/effect-actions/Action`.
|
|
15
12
|
|
|
16
|
-
| Export
|
|
17
|
-
|
|
|
18
|
-
| `make(name, options)`
|
|
19
|
-
| `byName(actions)`
|
|
20
|
-
| `implement(
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
| `
|
|
41
|
-
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
builder builds handlers. Authentication is the surface's: a remote one verifies the identity a
|
|
57
|
-
protected contract declares through the descriptor its binding or endpoint names
|
|
58
|
-
([Authentication.md](Authentication.md)); on a local one the host provides it.
|
|
59
|
-
|
|
60
|
-
`input` and `success` take a schema or plain fields: `{ id: Schema.String }` is
|
|
61
|
-
`Schema.Struct({ id: Schema.String })`. A tool is read-only exactly when its action is
|
|
62
|
-
(`readOnlyHint`). `mcp` takes MCP's own hints, `destructiveHint`, `idempotentHint` and
|
|
63
|
-
`openWorldHint`, each MCP's default when left out, but `destructiveHint`, which is `false` for a
|
|
64
|
-
read-only action. The tool is named after the action. Handlers receive decoded input and return
|
|
65
|
-
decoded success, failing only with declared errors and the built-in ones.
|
|
66
|
-
|
|
67
|
-
## Canonical
|
|
68
|
-
|
|
69
|
-
Contracts are pure values, safe to import in a browser. A protected contract names its identity,
|
|
70
|
-
a `Context.Service` declared without a verifier.
|
|
71
|
-
|
|
72
|
-
```ts example=contracts.ts
|
|
73
|
-
import { Schema } from "effect";
|
|
74
|
-
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
75
|
-
import { CurrentActor } from "./authorization.js";
|
|
76
|
-
|
|
77
|
-
export const User = Schema.Struct({
|
|
78
|
-
id: Schema.String,
|
|
79
|
-
name: Schema.String,
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
|
|
83
|
-
"UserNotFound",
|
|
84
|
-
{ id: Schema.String },
|
|
85
|
-
{ httpApiStatus: 404 },
|
|
86
|
-
) {}
|
|
87
|
-
|
|
88
|
-
// Reachable without credentials: it must work before anyone has signed in.
|
|
89
|
-
export const Status = Action.make("status", {
|
|
90
|
-
description: "Report whether the service is up.",
|
|
91
|
-
success: { service: Schema.String, users: Schema.Finite },
|
|
92
|
-
readOnly: true,
|
|
93
|
-
caller: Action.Anyone,
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
export const GetUser = Action.make("getUser", {
|
|
97
|
-
description: "Look up a user in your tenant.",
|
|
13
|
+
| Export | Purpose |
|
|
14
|
+
| ---------------------------------------------- | ------------------------------------------------------------ |
|
|
15
|
+
| `make(name, options)` | Define a contract. |
|
|
16
|
+
| `byName(actions)` | A list of actions keyed by name, each its exact contract. |
|
|
17
|
+
| `implement(actions, handlers, { authorize })` | Bind a handler, a record of them, or a builder of either. |
|
|
18
|
+
| `allowAll` | The authorizer without a rule: any authenticated caller. |
|
|
19
|
+
| `Anyone` | The `caller` of a public action: anyone, signed in or not. |
|
|
20
|
+
| `client`, `layer`, `Client`, `ClientOptions` | In [Action-in-process.md](Action-in-process.md#api). |
|
|
21
|
+
| `Image` | A media field's schema: `data` bytes and a `mimeType`. |
|
|
22
|
+
| `InvalidInput`, `Unauthenticated`, `Forbidden` | Built-in errors: 400 with `issues`, 401, 403 with `scopes`. |
|
|
23
|
+
| `InvalidInput.fromSchemaError(error)` | What every surface answers a `Schema.SchemaError` with. |
|
|
24
|
+
| `Issue` | One of `InvalidInput`'s `issues`: a `path` and a `message`. |
|
|
25
|
+
| `Refusal` | `Unauthenticated \| Forbidden`: an authorizer's failures. |
|
|
26
|
+
| `BuiltIn` | `InvalidInput \| Refusal`: what any handler may fail with. |
|
|
27
|
+
| `Errors` | A list of error schemas, as `error` holds it. |
|
|
28
|
+
| `Action`, `Any`, `Implementation` | Concrete and erased contracts, and bound implementations. |
|
|
29
|
+
| `AnyImplementation`, `AnyImplementation<A>` | Any implementation, or any of actions `A`, erased. |
|
|
30
|
+
| `BuildServices`, `BuildError` | What builders read and fail with at startup. |
|
|
31
|
+
| `implementation.actions` | Its exact contracts, as `Testing.mcpClient` takes them. |
|
|
32
|
+
| `Handler`, `Handlers`, `Authorize` | A typed handler, a list's record of them, and an authorizer. |
|
|
33
|
+
| `Options`, `Mcp` | What `make` takes, and its `mcp` options. |
|
|
34
|
+
|
|
35
|
+
| Option | Meaning |
|
|
36
|
+
| ------------- | -------------------------------------------------------------- |
|
|
37
|
+
| `description` | Required. A model reads it beside the tool; a CLI prints it. |
|
|
38
|
+
| `readOnly` | Required: whether the action leaves its resource unchanged. |
|
|
39
|
+
| `caller` | Required: `Action.Anyone`, or the caller's identity service. |
|
|
40
|
+
| `input` | A schema or struct fields; omitted, no arguments. |
|
|
41
|
+
| `success` | A schema or struct fields; omitted, nothing is returned. |
|
|
42
|
+
| `error` | The declared errors: one schema or a list; defaults to none. |
|
|
43
|
+
| `mcp` | The tool's MCP hints, in MCP's names, its `title` and `_meta`. |
|
|
44
|
+
|
|
45
|
+
## Define a contract
|
|
46
|
+
|
|
47
|
+
Write one `Action.make` per action in the domain's contract file, and export the domain's
|
|
48
|
+
actions as one `as const` list: `implement` and every surface take lists.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
export const GetNote = Action.make("getNote", {
|
|
52
|
+
description: "Read one of your notes.",
|
|
98
53
|
input: { id: Schema.String },
|
|
99
|
-
success:
|
|
100
|
-
error:
|
|
101
|
-
readOnly: true,
|
|
102
|
-
caller: CurrentActor,
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
export const RenameUser = Action.make("renameUser", {
|
|
106
|
-
description: "Rename a user in your tenant.",
|
|
107
|
-
input: {
|
|
108
|
-
id: Schema.String,
|
|
109
|
-
name: Schema.String.check(Schema.isMinLength(1)),
|
|
110
|
-
},
|
|
111
|
-
success: User,
|
|
112
|
-
error: UserNotFound,
|
|
113
|
-
readOnly: false,
|
|
114
|
-
caller: CurrentActor,
|
|
115
|
-
mcp: { destructiveHint: false },
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// On either transport, input is { value: "21" }. The handler receives numeric 21.
|
|
119
|
-
export const Double = Action.make("double", {
|
|
120
|
-
description: "Double a finite number supplied as a string.",
|
|
121
|
-
input: { value: Schema.FiniteFromString },
|
|
122
|
-
success: Schema.Finite,
|
|
123
|
-
readOnly: true,
|
|
124
|
-
caller: CurrentActor,
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
// Identity comes from the host's authenticated request context, not action input.
|
|
128
|
-
export const WhoAmI = Action.make("whoAmI", {
|
|
129
|
-
description: "Inspect the authenticated actor.",
|
|
130
|
-
success: { id: Schema.String, tenantId: Schema.String },
|
|
131
|
-
readOnly: true,
|
|
132
|
-
caller: CurrentActor,
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
export const Change = Schema.Struct({
|
|
136
|
-
actorId: Schema.String,
|
|
137
|
-
userId: Schema.String,
|
|
138
|
-
name: Schema.String,
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
export const ListChanges = Action.make("listChanges", {
|
|
142
|
-
description: "List the renames made in your tenant, oldest first.",
|
|
143
|
-
success: { changes: Schema.Array(Change) },
|
|
54
|
+
success: Note,
|
|
55
|
+
error: NoteNotFound,
|
|
144
56
|
readOnly: true,
|
|
145
|
-
caller:
|
|
146
|
-
});
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
### Identity and authorization
|
|
150
|
-
|
|
151
|
-
The module the contracts, the implementations, the authentication and every local caller import:
|
|
152
|
-
the identity, `CurrentActor`, which the protected contracts declare and authentication provides
|
|
153
|
-
per request, and `authorize`, which reads each action's `readOnly`. Its demo actors stand in for
|
|
154
|
-
the identities a token verifier returns.
|
|
155
|
-
|
|
156
|
-
```ts example=authorization.ts
|
|
157
|
-
import { Context, Effect } from "effect";
|
|
158
|
-
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
159
|
-
|
|
160
|
-
export type Permission = "users:read" | "users:write";
|
|
161
|
-
|
|
162
|
-
export interface Actor {
|
|
163
|
-
readonly id: string;
|
|
164
|
-
readonly tenantId: string;
|
|
165
|
-
readonly permissions: ReadonlyArray<Permission>;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** DEMO ONLY: fixed credentials, not OAuth or a production token verifier. */
|
|
169
|
-
export const actors = {
|
|
170
|
-
alice: { id: "alice", tenantId: "acme", permissions: ["users:read", "users:write"] },
|
|
171
|
-
reader: { id: "reader", tenantId: "acme", permissions: ["users:read"] },
|
|
172
|
-
bob: { id: "bob", tenantId: "other", permissions: ["users:read", "users:write"] },
|
|
173
|
-
} as const satisfies Readonly<Record<string, Actor>>;
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* The identity a protected contract declares, `caller: CurrentActor`: provided per request by
|
|
177
|
-
* authentication, and by the host on a local surface.
|
|
178
|
-
*/
|
|
179
|
-
export class CurrentActor extends Context.Service<CurrentActor, Actor>()("example/CurrentActor") {}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* One authorization rule for every surface, derived from each contract's own `readOnly`. An
|
|
183
|
-
* implementation of protected actions states it, `{ authorize }`, so every surface serving
|
|
184
|
-
* them runs it before each handler, once the caller is authenticated, and no handler contains
|
|
185
|
-
* authorization code. Its `Forbidden` is built in: every endpoint and tool declares it, and
|
|
186
|
-
* every client decodes it. Naming the missing scope makes it the `insufficient_scope`
|
|
187
|
-
* challenge an OAuth client steps up on.
|
|
188
|
-
*/
|
|
189
|
-
export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
|
|
190
|
-
const permission: Permission = action.readOnly ? "users:read" : "users:write";
|
|
191
|
-
const actor = yield* CurrentActor;
|
|
192
|
-
|
|
193
|
-
if (!actor.permissions.includes(permission)) {
|
|
194
|
-
return yield* new Action.Forbidden({
|
|
195
|
-
message: `Requires ${permission}.`,
|
|
196
|
-
scopes: [permission],
|
|
197
|
-
});
|
|
198
|
-
}
|
|
57
|
+
caller: CurrentUser,
|
|
199
58
|
});
|
|
200
59
|
```
|
|
201
60
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
61
|
+
Start from [quickstart/src/greeting/Greeting.ts](../examples/quickstart/src/greeting/Greeting.ts).
|
|
62
|
+
[notes-service/src/notes/Notes.ts](../examples/notes-service/src/notes/Notes.ts) adds declared
|
|
63
|
+
errors and the list, and [kitchen-sink/src/issues/Issues.ts](../examples/kitchen-sink/src/issues/Issues.ts)
|
|
64
|
+
uses every option, sharing some by spreading a constant (`IssueWriteErrors`, `change`).
|
|
65
|
+
|
|
66
|
+
- **The contract file imports no server code and no transport**, so a browser or a CLI can
|
|
67
|
+
import it ([setup.md](setup.md#bundle-for-a-browser)).
|
|
68
|
+
- **The name** matches `[A-Za-z0-9_-]{1,128}`, is not `then`, and is unique within each binding,
|
|
69
|
+
MCP endpoint, Toolkit, CLI and client holding it.
|
|
70
|
+
- **For no arguments, omit `input`.** A `Schema.Struct({})` accepts any value but `null`, and MCP
|
|
71
|
+
refuses it as a tool's input ([ActionMcp.md](ActionMcp.md#describe-tools-to-a-model)).
|
|
72
|
+
- **A field callers may omit is `Schema.optional`.** On the wire it also takes `null`, read as
|
|
73
|
+
left out, as a tool's schema lists; a typed client refuses `null`. `Schema.optionalKey` takes
|
|
74
|
+
omission alone, and `Schema.NullOr` keeps `null` as a value. One with a decoding default (`limit` in
|
|
75
|
+
`Issues.ts`) is still stated by a typed client given an argument
|
|
76
|
+
([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)).
|
|
77
|
+
- **An action's `name`, `input`, `success`, `error` and `readOnly` are public**: reuse them, as
|
|
78
|
+
in `Schema.Array(SaveLink.input)`.
|
|
79
|
+
- **Annotate each declared error with `httpApiStatus`**, or HTTP answers it as a 422.
|
|
80
|
+
- **Give each declared error its own `_tag`**: a client tells errors sharing one, or having
|
|
81
|
+
none, apart by their other fields alone, the first that decodes winning.
|
|
82
|
+
- **A contract does not say where it is served**: each surface takes a list of actions. Keep
|
|
83
|
+
those lists in one file beside the contracts
|
|
84
|
+
([kitchen-sink/src/Actions.ts](../examples/kitchen-sink/src/Actions.ts)): an action added
|
|
85
|
+
later is no tool until listed there.
|
|
86
|
+
- **List an action as a tool only where a model may hold what it takes and returns.** A tool
|
|
87
|
+
call passes both through the model's context and records its arguments on the current span,
|
|
88
|
+
a `Schema.Redacted` value included.
|
|
89
|
+
- **Surfaces match an implementation to a contract by object identity, never by name**:
|
|
90
|
+
implement, bind and list the one exported value. A list narrowed at run time, by a filter or a
|
|
91
|
+
conditional element, is still typed as holding every member.
|
|
92
|
+
|
|
93
|
+
## Say who may call an action
|
|
94
|
+
|
|
95
|
+
`caller: Action.Anyone` makes a public action. An identity service, `caller: CurrentUser`, makes
|
|
96
|
+
a protected one: every surface requires that identity, whether or not the handler reads it. A
|
|
97
|
+
remote surface gets it by authenticating the request
|
|
98
|
+
([Authentication.md](Authentication.md#declare-how-callers-sign-in)); on a local surface the
|
|
99
|
+
host provides it ([ActionToolkit.md](ActionToolkit.md#name-the-caller),
|
|
100
|
+
[ActionCli.md](ActionCli.md#name-the-caller), [ActionMcp.md](ActionMcp.md#serve-tools-over-stdio)).
|
|
101
|
+
|
|
102
|
+
`CurrentUser` is in [notes-service/src/identity/Identity.ts](../examples/notes-service/src/identity/Identity.ts),
|
|
103
|
+
and a public action in [notes-service/src/status/Status.ts](../examples/notes-service/src/status/Status.ts).
|
|
104
|
+
|
|
105
|
+
- **Declare the identity as a `Context.Service`**, in a contract file, apart from its verifier.
|
|
106
|
+
- **Never provide an identity, or a tenant, at startup**: not to a server's layer or root
|
|
107
|
+
context, not to `Action.layer`, not around the acquisition of a client. A public handler or a
|
|
108
|
+
route of your own reading the tag would serve every caller as that identity, and the types do
|
|
109
|
+
not stop it. A test sends each caller's credential instead
|
|
110
|
+
([Testing.md](Testing.md#test-as-a-signed-in-caller)).
|
|
111
|
+
- **One identity per action**, and every protected action one remote surface serves declares
|
|
112
|
+
the same one. A tenant is a request-time requirement of the handler, not a second identity.
|
|
113
|
+
A second audience takes its own identity, descriptor and binding (`Operator` in
|
|
114
|
+
[kitchen-sink/src/admin/Admin.ts](../examples/kitchen-sink/src/admin/Admin.ts)).
|
|
115
|
+
- **No surface provides a public action an identity.** An action whose answer depends on the
|
|
116
|
+
caller is protected.
|
|
117
|
+
- **One protected contract serves callers with a token over HTTP and a local host without
|
|
118
|
+
one**, never a public copy of it: the host provides the caller around `runStdio`
|
|
119
|
+
([notes-service/src/stdio.ts](../examples/notes-service/src/stdio.ts)).
|
|
120
|
+
|
|
121
|
+
## Implement actions
|
|
122
|
+
|
|
123
|
+
`Action.implement` binds a handler to one action, or a record of handlers keyed by name to a
|
|
124
|
+
list. Either may instead be a builder: an Effect that returns the handler or the record, which
|
|
125
|
+
is how a handler gets a service.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
export const notes = Action.implement(
|
|
129
|
+
Notes,
|
|
236
130
|
Effect.gen(function* () {
|
|
237
|
-
const
|
|
131
|
+
const store = yield* NoteStore; // a startup service: yielded once
|
|
238
132
|
|
|
239
133
|
return {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
|
|
244
|
-
listChanges: () =>
|
|
245
|
-
Effect.gen(function* () {
|
|
246
|
-
const actor = yield* CurrentActor;
|
|
247
|
-
|
|
248
|
-
return { changes: yield* users.changes(actor.tenantId) };
|
|
249
|
-
}),
|
|
134
|
+
// CurrentUser is a request-time requirement: yielded per call
|
|
135
|
+
getNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.get(user.id, id)),
|
|
136
|
+
// ...one handler per action of the list
|
|
250
137
|
};
|
|
251
138
|
}),
|
|
252
139
|
{ authorize },
|
|
253
140
|
);
|
|
254
|
-
|
|
255
|
-
// Pure: no builder and no services, only the authorization.
|
|
256
|
-
export const double = Action.implement(Double, ({ value }) => Effect.succeed(value * 2), {
|
|
257
|
-
authorize,
|
|
258
|
-
});
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
### Built authorization
|
|
262
|
-
|
|
263
|
-
An authorizer that reads a startup service, such as a permission store, is built as handlers
|
|
264
|
-
are: an Effect yields the store once and returns the authorizer, which yields the caller on every
|
|
265
|
-
call. The host provides `Permissions.layerMemory` at startup, as it provides `Users`;
|
|
266
|
-
authentication still provides `CurrentActor` per request. Defined apart from `implement`, its
|
|
267
|
-
`action` is annotated; written in `implement`'s options, it would be inferred.
|
|
268
|
-
|
|
269
|
-
```ts example=authorization-built.ts
|
|
270
|
-
import { Context, Effect, Layer } from "effect";
|
|
271
|
-
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
272
|
-
import { actors, CurrentActor, type Permission } from "./authorization.js";
|
|
273
|
-
import { WhoAmI } from "./contracts.js";
|
|
274
|
-
|
|
275
|
-
/** DEMO ONLY: each actor's permissions, kept in a store rather than in the identity. */
|
|
276
|
-
export class Permissions extends Context.Service<
|
|
277
|
-
Permissions,
|
|
278
|
-
{ readonly of: (actorId: string) => Effect.Effect<ReadonlyArray<Permission>> }
|
|
279
|
-
>()("example/Permissions") {
|
|
280
|
-
static readonly layerMemory = Layer.succeed(Permissions, {
|
|
281
|
-
of: (actorId) =>
|
|
282
|
-
Effect.succeed(Object.values(actors).find(({ id }) => id === actorId)?.permissions ?? []),
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// The `authorize` rule, built as handlers are: the store is yielded once, a startup service
|
|
287
|
-
// provided next to `Users`, and the function it returns yields the actor on every call.
|
|
288
|
-
export const authorizeStored = Effect.gen(function* () {
|
|
289
|
-
const permissions = yield* Permissions;
|
|
290
|
-
|
|
291
|
-
return (action: Action.Any) =>
|
|
292
|
-
Effect.gen(function* () {
|
|
293
|
-
const permission: Permission = action.readOnly ? "users:read" : "users:write";
|
|
294
|
-
const actor = yield* CurrentActor;
|
|
295
|
-
|
|
296
|
-
if (!(yield* permissions.of(actor.id)).includes(permission)) {
|
|
297
|
-
return yield* new Action.Forbidden({
|
|
298
|
-
message: `Requires ${permission}.`,
|
|
299
|
-
scopes: [permission],
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
// Built once per layer graph for this implementation, and per invocation of a local command.
|
|
306
|
-
export const whoAmI = Action.implement(
|
|
307
|
-
WhoAmI,
|
|
308
|
-
() => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
|
|
309
|
-
{ authorize: authorizeStored },
|
|
310
|
-
);
|
|
311
141
|
```
|
|
312
142
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
143
|
+
A plain handler is [quickstart/src/greeting/handlers.ts](../examples/quickstart/src/greeting/handlers.ts),
|
|
144
|
+
and the whole builder above [notes-service/src/notes/handlers.ts](../examples/notes-service/src/notes/handlers.ts).
|
|
145
|
+
|
|
146
|
+
- **One implementation may hold public and protected actions**, and actions only some
|
|
147
|
+
surfaces serve: group by domain.
|
|
148
|
+
- **What the builder yields is shared by every call; what a handler yields is resolved per
|
|
149
|
+
call**, as the caller must be.
|
|
150
|
+
- **A builder runs once per layer graph**, however many surfaces serve its implementation. Two
|
|
151
|
+
servers are two graphs
|
|
152
|
+
([Share one build across servers](Action-in-process.md#share-one-build-across-servers)), and
|
|
153
|
+
two `implement` calls are two builders, even of one builder Effect.
|
|
154
|
+
- **Each call has a scope of its own**, so a resource that must outlive a call is the
|
|
155
|
+
builder's.
|
|
156
|
+
- **Pass the builder to `implement` itself.** Through `.pipe(...)` or a wrapper such as
|
|
157
|
+
`Effect.withSpan(builder, name)`, an `Effect.fn` handler it returns takes an `any` input. A
|
|
158
|
+
builder written apart returns its record `satisfies Action.Handlers<typeof actions>`.
|
|
159
|
+
- **Leave a handler's `Effect.fn` unnamed**: each handler already runs in a span named after its
|
|
160
|
+
action, and its log lines carry `action.name` and `action.read_only`.
|
|
161
|
+
- **Calling the function you passed to `implement` skips authorization.** Call the
|
|
162
|
+
implementation through [`Action.client`](Action-in-process.md#call-actions-in-process).
|
|
163
|
+
|
|
164
|
+
## Authorize calls
|
|
165
|
+
|
|
166
|
+
An implementation holding a protected action states `{ authorize }`: a function of the called
|
|
167
|
+
action that succeeds, or fails with a refusal. Every surface runs it before the handler, and
|
|
168
|
+
none has a policy of its own. An implementation of public actions alone takes none
|
|
169
|
+
([notes-service/src/status/handlers.ts](../examples/notes-service/src/status/handlers.ts)), and
|
|
170
|
+
the authorizer never runs for a public action.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
|
|
174
|
+
const scope = action.readOnly ? scopes.read : scopes.write;
|
|
175
|
+
const user = yield* CurrentUser;
|
|
341
176
|
|
|
342
|
-
|
|
177
|
+
if (!user.scopes.includes(scope)) {
|
|
178
|
+
return yield* new Action.Forbidden({ message: `Requires ${scope}.` });
|
|
179
|
+
}
|
|
343
180
|
});
|
|
344
|
-
|
|
345
|
-
// The builders are released with the program's scope, before the services they captured.
|
|
346
|
-
console.log(
|
|
347
|
-
await Effect.runPromise(program.pipe(Effect.scoped, Effect.provide(Users.layerMemory))),
|
|
348
|
-
);
|
|
349
181
|
```
|
|
350
182
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
-
|
|
361
|
-
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
-
|
|
379
|
-
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
-
|
|
389
|
-
|
|
390
|
-
-
|
|
391
|
-
-
|
|
392
|
-
|
|
393
|
-
-
|
|
394
|
-
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
### Clients
|
|
402
|
-
|
|
403
|
-
- `client(implementations)` takes one implementation or a list, as a surface does, and gives one method per action, named after it and taking its input directly: the methods `ActionHttp.client` gives, so moving between an in-process and a remote caller changes the line acquiring it. The argument may be left out when `{}` is a valid encoded input, such as an input whose every field has a decoding default, and is then what `{}` decodes to; an input whose encoded form requires a field its decoded one lacks takes the argument. Every implemented action has a method, whether or not a surface serves it. An action name twice among the implementations throws `Duplicate action: <name>` where `client` is called.
|
|
404
|
-
- A call runs as a remote one does, through the dispatch every surface shares. Its input passes through its JSON codec, encoded to JSON text then decoded, so the handler receives what a remote handler decodes: an undeclared field dropped, as a client's encoding drops it, a class instance built anew, a decoding transformation such as a trim applied, `0` for `-0`. Authorization runs, then the handler, in its action's span, each call in a scope of its own ([guarantees.md](guarantees.md#dependency-lifetimes)). The success passes through its codec the same way, and a failure through the codec of every error the action declares, the built-in ones included, so the caller gets what a remote caller decodes: no undeclared field, a decoding transformation applied, `undefined` for a `Schema.Void` success. A failure keeps its trace: where the authorizer or the handler failed.
|
|
405
|
-
- A call fails with the action's declared errors and the built-in ones, as `ActionHttp.client` decodes them, and with nothing of a transport. Input that does not pass through its codec is `InvalidInput`, with the schema's message and `issues`, and authorization and the handler never run; a class input is passed as an instance, as over HTTP. A protected action's call without its identity fails with `Unauthenticated` before the authorizer runs. The authorizer's refusal is the call's failure. A success or a failure that does not pass, an error the action does not declare included, is a defect, as it is an empty 500 over HTTP: the handler broke its contract. The defect is its `SchemaError`, and for a failure, the failure itself after it.
|
|
406
|
-
- Each method owes, per call, what its handler and its implementation's authorizer read, and a protected action's identity whether or not they read it, and the caller provides them around the call, or around a program making several: `users.renameUser(input).pipe(Effect.provideService(CurrentActor, actor))`. A call reads its caller's context, never the one the client was acquired in: provided around the acquisition, a service reaches no call, so neither does a startup identity, and one client serves every caller.
|
|
407
|
-
- `actions` lists the actions it calls among the implementations', as a surface's `actions` does: an implementation holding none of them is left out, unbuilt, and a listed action none of them holds throws `Listed in actions, but no implementation holds it: <names>`. Where `actions` may be absent, an optional property or a union with options lacking it, the client offers methods only for the actions it may list, which are present either way, while it owes and builds what every action needs ([guarantees.md](guarantees.md#names)). An explicit type argument listing actions requires the options argument.
|
|
408
|
-
- Acquiring a client builds its implementations' builders and every authorizer an Effect builds, as a layer does: the acquisition owes their startup services and a `Scope`, and fails as they fail; no call does. Where it builds, and what it shares: [dependency lifetimes](guarantees.md#dependency-lifetimes). Acquire it where builders live, once: in a builder, in a layer providing it as a service of type `Action.Client<typeof users>`, or in a scoped program, such as a test, a job or a command. Never per request, in a handler.
|
|
409
|
-
|
|
410
|
-
## Failure modes
|
|
411
|
-
|
|
412
|
-
- `Invalid action name: <name>` thrown by `make`: the name is empty, longer than 128 characters, `then`, or has another character.
|
|
413
|
-
- `Invalid readOnly: <value>` thrown by `make`: `readOnly` is not a boolean, which only a caller the compiler never sees can pass.
|
|
414
|
-
- `Property 'caller' is missing` at `make`, or `Missing caller: declare Action.Anyone or an identity service key` thrown by `make`: the contract does not state who may call it. Give `caller: Action.Anyone`, or the identity service a caller must have. There is no default.
|
|
415
|
-
- `Type ... is not assignable to type '"An identity is a Context.Service, not a Context.Reference"'` at `make`, or `Invalid caller: an identity is a Context.Service, not a Context.Reference` thrown by `make`: `caller` is a `Context.Reference`, whose default would authenticate every caller. Declare the identity with `Context.Service`.
|
|
416
|
-
- `... is not assignable to parameter of type 'ProtectedActionsTakeAuthorize'` at `implement`, ending `Type 'typeof CurrentActor' is not assignable to type 'unique symbol'`: the target holds a protected action, and the implementation states no `authorize`. Pass `{ authorize }`, or `{ authorize: Action.allowAll }` where every authenticated caller may call.
|
|
417
|
-
- `No overload matches this call` at `implement`, ending `... is not assignable to type '"A public-only target takes no authorize"'`: an implementation of public actions alone states `authorize`, which nothing would run. Drop it; a public action is never authorized. If the action needs a caller, make its contract protected. Plain JavaScript passing one throws `A public-only target takes no authorize` at `implement`.
|
|
418
|
-
- `Protected actions require authorize, or Action.allowAll` thrown at `implement`: plain JavaScript left `authorize` out for a target holding a protected action. `Missing authorize: pass an authorization function, or Action.allowAll` thrown at `implement`, or the layer build dies with it: `authorize` is neither a function nor an Effect building one, or that Effect built something else.
|
|
419
|
-
- `Argument of type '... | undefined' is not assignable` at `implement`'s `authorize`: an authorizer that may be `undefined`, such as `enabled ? authorize : undefined`. Write `enabled ? authorize : Action.allowAll`.
|
|
420
|
-
- `Parameter 'action' implicitly has an 'any' type`, or an `Effect.fn` authorizer whose `action` is `any`, where it is defined apart from `implement`: nothing types it there. Annotate it, `(action: Action.Any)`.
|
|
421
|
-
- Type error `Effect<..., X, ...> is not assignable` at `implement`: the handler fails with an undeclared error `X`. Add it to the action's `error` or handle it.
|
|
422
|
-
- `Type 'X' is not assignable to type 'Refusal'` at `implement`, ending in a `_tag` mismatch with `"Unauthenticated"`: the authorizer fails with `X`, which is not a refusal. A limit or another failure callers should see is the handler's: declare `X` in the action's `error` and fail with it there.
|
|
423
|
-
- `Type 'CurrentActor' is not assignable to type 'never'`, or `Request<"Requires", CurrentActor>` still owed, for a layer serving only public actions: a public action's handler reads the identity, and no surface provides an identity to a public action. Make the action protected.
|
|
424
|
-
- `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
|
|
425
|
-
- `Missing handlers: <names>` at `implement`, or when a builder's layer builds: the record has no own-property function for those actions. Add them to the record itself, not to a prototype.
|
|
426
|
-
- `Unknown handlers: <keys>` at `implement` or when a builder's layer builds, or a type error names a key: the record has a handler for an action not in this `implement` call. Remove it, or add its action to the list.
|
|
427
|
-
- `Listed in actions, but no implementation holds it: <names>` thrown by `client`: a listed action is none of the implementations'. Pass the implementation holding it, or check the contract values are the ones it implemented.
|
|
428
|
-
- `Duplicate action: <name>` at `implement`: the list names one action twice. Thrown by `client`: two of its implementations implement an action of that name. Separate contracts sharing a name are told apart by `actions`, listing one; two implementations of the same contract are not, since listing it keeps both: pass one implementation.
|
|
429
|
-
- Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
|
|
430
|
-
- `Property 'readOnly' is missing` at `make`: every action declares whether it is read-only. There is no default.
|
|
431
|
-
- A service is resolved once and shared across requests when it should be per request: it was yielded in the builder, or in the Effect building the authorizer. Move the `yield*` into the handler or the authorizer. An identity yielded there is a startup requirement of the layer; never provide one at startup ([guarantees.md](guarantees.md#dependency-lifetimes)).
|
|
432
|
-
- `HttpRouter.Request<"Requires", X>` still owed, or `Type 'X' is not assignable to type 'never'` at `runMain`, although `X`, a store the authorizer reads, is provided at startup: the authorizer yields it on every call. Yield it in an Effect that builds the authorizer ([Built authorization](#built-authorization)).
|
|
433
|
-
- `unknown` among a surface's requirements, and a type error where it is served or run, such as `Argument of type 'Layer<never, unknown, unknown>' is not assignable` at `HttpRouter.toWebHandler` or `Type 'unknown' is not assignable to type 'never'` where it is launched or run: the implementations are typed `Action.AnyImplementation`, as a helper's parameter or a list's annotation. Make the helper generic over them, or drop the annotation.
|
|
434
|
-
- `Request<"Requires", unknown>` in a type error where an implementation is served, or `unknown` among the services a call or a run still requires: TypeScript inferred no requirements for a handler of a record. A data-first call whose type takes a parameter from no argument does it, `Effect.catchTag(effect, tag, f)` for one, where `Effect.catch`, `Effect.map` and `Effect.orDie` do not. Write that handler's Effect `effect.pipe(Effect.catchTag(tag, f))`, or as a generator.
|
|
435
|
-
- `'readOnlyHint' does not exist in type 'Mcp'` at `make`, or `Type 'true' is not assignable to type 'never'` on `readOnlyHint` beside another hint: a tool's read-only hint is its action's `readOnly`. Set `readOnly` instead.
|
|
436
|
-
- `Type '...' is not assignable to type 'never'` on a key at `make`, in `mcp` too: an option or hint it does not take, or a misspelled one, such as `destructive` for `destructiveHint`.
|
|
437
|
-
- `Type 'H' is not assignable to type 'H & ...'` at `make`, where `H` is a helper's type parameter, as in `<const H extends Action.Mcp>(mcp: H)`: `make` cannot read options a type parameter stands for, so it refuses them. An action's type carries no `mcp` types, so type the parameter `Action.Mcp`; the helper loses nothing. A constant object spread into the options is no type parameter: `make` reads it as written.
|
|
438
|
-
- Type error on `error` at `make`, `... is not assignable to type 'readonly never[]'` or naming the remaining errors: it lists a built-in error. Drop it; every surface declares it, and a handler may fail with it anyway.
|
|
439
|
-
- `make` throws `Action "<name>": error _tag "Forbidden" is built in, and declared on every surface`: an error in the action's `error`, or a member of a union there, encodes with the `_tag` of a built-in error, `InvalidInput`, `Unauthenticated` or `Forbidden`. Drop a built-in error from `error`, since a handler may fail with it anyway; rename an error of your own.
|
|
440
|
-
- `ReferenceError` thrown by `make`: an entry of `error` is a `Schema.suspend` whose thunk reads a `const` declared further down. `make` reads every error's `_tag` when it is called; declare the error first.
|
|
441
|
-
- `Type 'CurrentActor' is not assignable to type 'never'` where a program calling a client runs: a call of a protected action owes the caller, and nothing provides one around it. Provide it around the call, or around the calls it makes; provided around the acquisition, it reaches no call. Never provide it to a layer or a builder acquiring the client.
|
|
442
|
-
- A type error that a program still requires `Scope`: acquiring a client builds, as a layer does. Acquire it in a builder or a layer, or run the program in `Effect.scoped`.
|
|
443
|
-
- A builder runs on every request, or a route owes `HttpRouter.Request<"Requires", X>` for `X`, a service its builder yields: a handler acquires a client per request. Acquire it in the builder, and call it in the handler.
|
|
444
|
-
- A builder runs twice, or a job, an agent or a second server reads other state than the routes, such as an empty in-memory store: it is merged beside `HttpRouter.serve`, whose layer graph built the builder first. Put it inside the layer the server serves, or provide `Action.layer` and the services they share above both ([dependency lifetimes](guarantees.md#dependency-lifetimes)).
|
|
445
|
-
- Surfaces given startup services of their own, provided around each or to an `Action.layer` of each, all answer with one surface's: a builder runs once per layer graph, with the services of its first build. Wrap each surface in `Layer.fresh`, or give each an implementation of its own ([dependency lifetimes](guarantees.md#dependency-lifetimes)).
|
|
446
|
-
- `'<name>' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer` (TS7022) at `implement`, or, with the types bypassed, an acquisition that never completes: a builder acquires a client of its own implementation, directly or through another builder. Call the services both use instead.
|
|
447
|
-
- A call dies with a `SchemaError`: the handler's success, or what the handler fails with, does not pass through its codec, which an HTTP caller sees as an empty 500. A failure follows the `SchemaError` in the cause, as `Cause.pretty` shows. Return what the success schema accepts, and fail with an error the action declares, as its schema accepts it.
|
|
448
|
-
- `InvalidInput` with `Expected <Class>` from a call given a plain object: the input is a class, which encodes only its instances. Pass `new Class({ ... })`, or leave out an argument whose fields are all optional.
|
|
449
|
-
- `Not an implementation made by this Action.implement: is effect-actions installed twice?` thrown by a surface, `client` or `layer`, or by a local CLI command when it runs: another installed copy of the package made the implementation, and only that copy serves and calls it. Install one copy of the package; contracts and bindings still cross copies.
|
|
183
|
+
[notes-service/src/identity/authorize.ts](../examples/notes-service/src/identity/authorize.ts)
|
|
184
|
+
is that rule with `scopes: [scope]` added, the OAuth variant: it publishes discovery.
|
|
185
|
+
Derive the rule from `action.readOnly`, never from an `mcp` hint: changing an action's
|
|
186
|
+
`readOnly` then changes who may call it.
|
|
187
|
+
|
|
188
|
+
- **A call runs authentication, then over HTTP and RPC the layer's `middleware`, input
|
|
189
|
+
decoding, the authorizer, then the handler**, each refusing before the next. So a caller the
|
|
190
|
+
authorizer would refuse can still receive an `InvalidInput` describing the input's schema. To
|
|
191
|
+
refuse or limit before decoding, use that `middleware`
|
|
192
|
+
([ActionHttp.md](ActionHttp.md#serve-the-routes), [ActionRpc.md](ActionRpc.md#serve-the-rpcs)).
|
|
193
|
+
- **Name `scopes` in a `Forbidden` only where re-authorizing can grant them.** An OAuth
|
|
194
|
+
challenge answers them, which a client follows only where the server publishes discovery
|
|
195
|
+
([Authentication.md](Authentication.md#publish-oauth-discovery)); with fixed tokens, such as
|
|
196
|
+
API keys, leave `scopes` out (it holds at least one: `scopes: []` is a type error). Without scopes it is a 403 over HTTP and, over MCP, an `isError` tool
|
|
197
|
+
result in a 200 ([guarantees.md](guarantees.md#wire-behavior)).
|
|
198
|
+
- **What a plain authorizer yields is a request-time requirement of every surface.** To read a
|
|
199
|
+
store, make `authorize` an Effect that yields the store once and returns the function, as a
|
|
200
|
+
builder does
|
|
201
|
+
([assistant/src/identity/authorize.ts](../examples/assistant/src/identity/authorize.ts)). It
|
|
202
|
+
is built once for each implementation stating it: keep state they share in a service it
|
|
203
|
+
yields.
|
|
204
|
+
- **A limit, such as a rate limit, is the handler's, not the authorizer's.** It fails with an
|
|
205
|
+
error the action declares, and its limiter is a service the builder yields
|
|
206
|
+
([kitchen-sink/src/issues/write-limit.ts](../examples/kitchen-sink/src/issues/write-limit.ts)):
|
|
207
|
+
a service is shared wherever it is provided
|
|
208
|
+
([kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts)), state kept in a
|
|
209
|
+
builder only within one layer graph.
|
|
210
|
+
- **A check on one record belongs in the handler's data access**
|
|
211
|
+
([notes-service/src/notes/store.ts](../examples/notes-service/src/notes/store.ts)). So does a
|
|
212
|
+
rule across callers, such as uniqueness.
|
|
213
|
+
|
|
214
|
+
## Fail with declared and built-in errors
|
|
215
|
+
|
|
216
|
+
A handler fails with the errors its action lists in `error`, and with the three built-in
|
|
217
|
+
errors, which every surface declares and every client decodes: never list those. No other
|
|
218
|
+
failure compiles. What each surface answers is in [guarantees.md](guarantees.md#wire-behavior).
|
|
219
|
+
|
|
220
|
+
- **Fail with `Action.InvalidInput` for input that decodes but cannot be served**, naming
|
|
221
|
+
`issues` of your own, instead of declaring a second error for bad input
|
|
222
|
+
([kitchen-sink/src/attachments/handlers.ts](../examples/kitchen-sink/src/attachments/handlers.ts)).
|
|
223
|
+
- **For input you decode yourself**, such as a header or a route parameter, answer as the
|
|
224
|
+
surfaces do: `Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(Action.InvalidInput.fromSchemaError))`.
|
|
225
|
+
- **Map another service's refusals before failing with them.** A client that decodes the
|
|
226
|
+
built-in errors, such as `ActionHttp.client`, hands a handler that service's `Unauthenticated`
|
|
227
|
+
or `Forbidden`. Passed on, it is this server's own refusal: a caller whose token is valid is
|
|
228
|
+
told to sign in again. Map it to an error the action declares, or to a defect.
|
|
229
|
+
- **Map that client's `HttpClientError` and `SchemaError` to one declared error a caller can
|
|
230
|
+
act on** ([mcp-tools/src/printers/farm.ts](../examples/mcp-tools/src/printers/farm.ts)). As
|
|
231
|
+
defects, a model reads only a generic text.
|