@gjermundgaraba/effect-actions 0.2.0 → 0.4.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.
@@ -8,24 +8,34 @@ of groups into a contract value shared by the server, every client, and the Open
8
8
  ```ts
9
9
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
10
10
 
11
- function make<const G extends ReadonlyArray<Actions>>(options: Options, ...groups: G): Http<G>;
11
+ function make<const G extends ReadonlyArray<Actions>, const E extends ReadonlyArray<Codec> = []>(
12
+ options: Options<E>,
13
+ ...groups: G
14
+ ): Http<G, E>;
12
15
 
13
- interface Options {
16
+ interface Options<Errors = []> {
14
17
  readonly apiPath: `/${string}`; // no default
18
+ readonly errors?: Errors; // failures the surface answers with, declared on every endpoint
15
19
  }
16
20
 
17
- interface Http<Groups> {
21
+ interface LayerOptions<Errors, RB> {
22
+ /** Runs before the payload is decoded, on every request this layer answers. */
23
+ readonly before?: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
24
+ }
25
+
26
+ interface Http<Groups, Errors = []> {
18
27
  readonly groups: Groups; // the exact contracts bound, in declaration order
19
28
  readonly api: HttpApi.HttpApi<"actions", ...>; // native HttpApi, one HttpApiGroup per group
20
29
  /** Serve implementations. Errors and requirements are unions over exactly these apps. */
21
- readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>>(
30
+ readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>, RB = never>(
31
+ options: LayerOptions<Errors, RB>,
22
32
  ...apps: Apps
23
33
  ) => Layer.Layer<
24
34
  never,
25
35
  BuildError<Apps[number]>,
26
36
  | BuildContext<Apps[number]>
27
37
  | HttpRouter.HttpRouter
28
- | HttpRouter.Request.From<"Requires", RequestContext<Apps[number]>>
38
+ | HttpRouter.Request.From<"Requires", RequestContext<Apps[number]> | RB>
29
39
  | Etag.Generator | FileSystem | HttpPlatform.HttpPlatform | Path
30
40
  >;
31
41
  }
@@ -42,15 +52,23 @@ import { HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"
42
52
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
43
53
  import { PublicActions, UserActions } from "./contracts.js";
44
54
  import { PublicApp, UserApp } from "./handlers.js";
45
- import { authentication } from "./auth.js";
46
-
47
- // Contract: shared by server and clients.
48
- export const Http = ActionHttp.make({ apiPath: "/api/actions" }, PublicActions, UserActions);
55
+ import { authentication, authorize, Forbidden, Unauthenticated } from "./auth.js";
56
+
57
+ // Contract: shared by server and clients. `errors` are the failures the surface
58
+ // itself renders — here the 401 from `authentication` and the 403 from the hook
59
+ // below — so a typed client decodes them instead of reporting a decode error on
60
+ // an unexpected status.
61
+ export const Http = ActionHttp.make(
62
+ { apiPath: "/api/actions", errors: [Unauthenticated, Forbidden] },
63
+ PublicActions,
64
+ UserActions,
65
+ );
49
66
 
50
- // One layer per middleware set. Middleware provided to a layer applies to that layer only.
67
+ // One layer per middleware set and per policy. Middleware provided to a layer
68
+ // applies to that layer only, and so does its `before` hook.
51
69
  const routes = Layer.mergeAll(
52
- Http.layer(PublicApp),
53
- Http.layer(UserApp).pipe(Layer.provide(authentication.layer)),
70
+ Http.layer({}, PublicApp),
71
+ Http.layer({ before: authorize }, UserApp).pipe(Layer.provide(authentication.layer)),
54
72
  );
55
73
 
56
74
  // Documents are Effect's own, reading the same contract.
@@ -85,15 +103,21 @@ export const greeting = Effect.gen(function* () {
85
103
  ## Rules
86
104
 
87
105
  - `apiPath` has no default. Group names must be unique within one `make`. Action names need only be unique within their group; equal action names in different groups do not collide.
88
- - `Http.layer(...apps)` mounts only the supplied implementations. A group that is never passed to `layer` has no routes. A group with no HTTP-enabled actions is not built at all.
89
- - Middleware is per layer call. Groups that need different middleware go in separate `Http.layer` calls, merged with `Layer.mergeAll`.
106
+ - `errors` declares the failures the surface around these endpoints answers with rather than a handler: authentication, authorization, rate limiting, upstream unavailability. They are added to every endpoint's error schemas, so `HttpApiClient`, `ActionCliClient` and the in-memory `Testing.httpClient` decode them as typed failures, and they appear in OpenAPI on every operation. A schema an action already declares is not repeated.
107
+ - Schemas reachable from one endpoint must have distinct `_tag`s: the client decodes a response by trying the schemas declared for its status, and two errors may share a status. Effect unions them per status.
108
+ - `errors` changes only what is declared. Nothing produces them: the middleware that renders those responses must encode a body that matches the schema, or the client sees a decode error again. Handlers cannot fail with them.
109
+ - `Http.layer(options, ...apps)` mounts only the supplied implementations. A group that is never passed to `layer` has no routes. A group with no HTTP-enabled actions is not built at all. The options object is required; pass `{}` for a surface with no hook.
110
+ - `before` runs once per request, with the selected action contract, **before the payload is decoded**, so an unauthorized caller learns nothing about the input schema and the handler never runs. It fails with the binding's own `errors`, and that failure is encoded exactly like a declared error, with the schema's `httpApiStatus`. Its services are request-time requirements, joined with the handlers'.
111
+ - Middleware and the hook are per layer call. Groups that need different middleware or a different policy go in separate `Http.layer` calls, merged with `Layer.mergeAll`.
112
+ - Every response with a status of 400 or more carries `cache-control: no-store`, including hook refusals, declared errors and defects. Successful responses are left alone; only the host knows whether they are public.
113
+ - A hook refusal and a handler error are plain declared errors: a JSON body and a status, no other headers. Challenge headers such as `WWW-Authenticate` belong to the admission middleware that runs before the router reaches these routes (see [Authentication.md](Authentication.md)), which sets them on its own response.
90
114
  - Request-time handler services are `HttpRouter.Request.From<"Requires", R>`. Supply them with router middleware (`Authentication.middleware`, `HttpRouter.middleware`), `HttpRouter.provideRequest`, or the request context. Build-time services are ordinary layer requirements.
91
115
  - `Http.api` is a plain `HttpApi`. Anything Effect can do with an `HttpApi` works: `OpenApi.fromApi`, `HttpApiSwagger.layer`, `HttpApiScalar.layer`, `HttpApi.addHttpApi` to combine with other APIs, `HttpApiClient.make`.
92
116
  - Client calls always take `{ payload }`. No-input actions take `{ payload: {} }`. Pass `null` or `undefined` only when the codec accepts it. There is no flat client.
93
- - Client effects fail with the declared errors, the group's policy errors, `SchemaError` for local codec failures, and native `HttpClientError`. MCP-only actions are absent from the client.
117
+ - Client effects fail with the declared errors, the group's policy errors, the binding's `errors`, `SchemaError` for local codec failures, and native `HttpClientError`. MCP-only actions are absent from the client.
94
118
  - Add authentication headers with the native `transformClient` option. Use `HttpApiClient.makeWith` for custom error or service channels. Native per-call response modes are available.
95
119
  - Wire format without a policy: input failure is an empty 400, success is the encoded body, a declared error is its JSON encoding with its `httpApiStatus`, an encoding failure is an empty 400, a defect is an empty 500. Full table in [guarantees.md](guarantees.md).
96
- - Each handler runs in a span named `<group>.<action>`, a child of the request span. Decoding and encoding are outside it.
120
+ - Each handler runs in a span named `<group>.<action>`, a child of the request span. The hook, decoding and encoding are outside it, in the request span.
97
121
 
98
122
  ## Failure modes
99
123
 
@@ -104,3 +128,7 @@ export const greeting = Effect.gen(function* () {
104
128
  - Client method missing for an action: the action is `http: false`.
105
129
  - Empty 400 on a valid-looking request: input did not decode. Set a `schemaError` policy on the group to get a typed body, and check `Content-Type: application/json`.
106
130
  - 415: wrong or missing content type.
131
+ - `HttpClientError: Decode error (401 POST ...)` from a typed client: the surface answered with a status no endpoint declares. Add that error schema to `ActionHttp.make`'s `errors`.
132
+ - A surface error decodes as the wrong type: two schemas that share a status also share a `_tag`. Give them distinct tags.
133
+ - The hook's services appear as unsatisfied `HttpRouter.Request.From<"Requires", ...>`: the hook yields an identity tag and this `Http.layer` call has no middleware providing it. Wrap that call with the middleware's `.layer`.
134
+ - A refusal returns 500 instead of its status: the hook failed with an error the binding does not declare. Add its schema to `ActionHttp.make`'s `errors`.
package/docs/ActionMcp.md CHANGED
@@ -9,27 +9,33 @@ JSON-RPC on standard I/O for a subprocess.
9
9
  ```ts
10
10
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
11
11
 
12
- function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>>(
13
- options: Options,
12
+ function layerHttp<const Apps extends ReadonlyArray<AnyImplementation>, Errors = [], RB = never>(
13
+ options: Options<Errors, RB>,
14
14
  ...apps: Apps
15
15
  ): Layer.Layer<
16
16
  never,
17
17
  BuildError<Apps[number]> | Cause.IllegalArgumentError,
18
18
  | BuildContext<Apps[number]>
19
19
  | HttpRouter.HttpRouter
20
- | HttpRouter.Request.From<"Requires", RequestContext<Apps[number]>>
20
+ | HttpRouter.Request.From<"Requires", RequestContext<Apps[number]> | RB>
21
21
  >;
22
22
 
23
- function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>>(
24
- options: StdioOptions,
23
+ function layerStdio<const Apps extends ReadonlyArray<AnyImplementation>, Errors = [], RB = never>(
24
+ options: StdioOptions<Errors, RB>,
25
25
  ...apps: Apps
26
26
  ): Layer.Layer<
27
27
  never,
28
28
  BuildError<Apps[number]> | Cause.IllegalArgumentError,
29
- BuildContext<Apps[number]> | Stdio | RequestContext<Apps[number]>
29
+ BuildContext<Apps[number]> | Stdio | RequestContext<Apps[number]> | RB
30
30
  >;
31
31
 
32
- interface Options {
32
+ /** Both transports share these. */
33
+ interface SurfaceOptions<Errors, RB> {
34
+ readonly errors?: Errors; // declared on every tool of this transport
35
+ readonly before?: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
36
+ }
37
+
38
+ interface Options<Errors = [], RB = never> extends SurfaceOptions<Errors, RB> {
33
39
  readonly name: string; // server info
34
40
  readonly version: string;
35
41
  readonly protocols: ReadonlyArray<McpProtocol>; // required, e.g. [McpProtocol.v2026_07_28]
@@ -38,7 +44,7 @@ interface Options {
38
44
  readonly instructions?: string;
39
45
  }
40
46
 
41
- interface StdioOptions {
47
+ interface StdioOptions<Errors = [], RB = never> extends SurfaceOptions<Errors, RB> {
42
48
  readonly name: string;
43
49
  readonly version: string;
44
50
  readonly protocols: ReadonlyArray<McpProtocol>;
@@ -53,7 +59,7 @@ import { Layer } from "effect";
53
59
  import { McpProtocol } from "effect/unstable/ai";
54
60
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
55
61
  import { AuditApp, PublicApp, UserApp } from "./handlers.js";
56
- import { authentication } from "./auth.js";
62
+ import { authentication, authorize, Forbidden } from "./auth.js";
57
63
 
58
64
  const allowedOrigins = ["http://localhost:3000"];
59
65
 
@@ -77,6 +83,10 @@ const mcp = ActionMcp.layerHttp(
77
83
  version: "1.0.0",
78
84
  path: "/mcp",
79
85
  allowedOrigins,
86
+ // The same rule the HTTP binding runs, declared here so a refusal is an
87
+ // ordinary tool failure rather than a transport error.
88
+ errors: [Forbidden],
89
+ before: authorize,
80
90
  },
81
91
  UserApp,
82
92
  AuditApp,
@@ -116,11 +126,13 @@ Layer.launch(layer).pipe(
116
126
  - Success is `structuredContent: { value: <encoded success> }`. A declared error is an `isError` result whose text content is the error's JSON encoding, the same bytes HTTP sends as the body, and no `structuredContent`.
117
127
  - Invalid arguments and unencodable results are answered by the native `McpServer`: an `isError` result with a message for the model. Group `schemaError` policies do not apply.
118
128
  - Defects and encoding failures produce the generic `isError` text `Tool execution failed due to an internal server error.`; the cause is logged, not sent.
119
- - Tool discovery is not filtered by actor. Every caller sees every tool of the endpoint. Authorization happens in the handler.
129
+ - Tool discovery is not filtered by actor. Every caller sees every tool of the endpoint. Authorization happens in `before`.
120
130
  - Cancellation is Effect's native RPC interruption.
121
131
  - Handlers may yield `McpSchema.McpRequestContext` for the client's declared information; the native server supplies it to every tool call, so it is never a router or host requirement.
122
132
  - stdio: the host supplies `Stdio` (`NodeStdio.layer`) and any request-time services explicitly, including the trusted principal. There is no authentication middleware. Tool arguments never establish identity. Keep stdout for protocol messages only and route logs to stderr.
123
133
  - Each endpoint or subprocess owns a fresh native tool registry. That isolates tool names, not application context.
134
+ - `errors` are the failures this transport answers with rather than a handler. They join every tool's declared failures, so a refusal is returned exactly like an action's own error and no caller sees a protocol-level error instead. A schema an action already declares is not repeated.
135
+ - `before` runs once per tool call, with the selected action contract, before the handler. The native server decodes the tool arguments first, so unlike HTTP the hook runs after input decoding; it still runs on every call and the handler never runs when it fails. Its services are request-time requirements, joined with the handlers'.
124
136
 
125
137
  ## Failure modes
126
138
 
@@ -130,3 +142,4 @@ Layer.launch(layer).pipe(
130
142
  - Public tool requires a token: it shares an endpoint with protected tools. Give it its own path.
131
143
  - Client reports a broken transport from a stdio subprocess: something printed to stdout. Set `Logger.LogToStderr` and remove `console.log`.
132
144
  - Older MCP client cannot connect: the client expects a revision not listed in `protocols`. Add the adapter for that revision.
145
+ - A refusal arrives as a generic internal-error result: the hook failed with an error this transport does not declare, which is a defect. Add its schema to `errors`.
@@ -8,9 +8,19 @@ with `LanguageModel` or by hand. No server, no MCP envelope.
8
8
  ```ts
9
9
  import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
10
10
 
11
- function make<const Apps extends ReadonlyArray<AnyImplementation>>(
11
+ function make<const Apps extends ReadonlyArray<AnyImplementation>, Errors = [], RB = never>(
12
+ options: Options<Errors, RB>,
12
13
  ...apps: Apps
13
- ): Binding<ToolkitTools<Apps>, BuildError<Apps[number]>, BuildContext<Apps[number]>>;
14
+ ): Binding<
15
+ ToolkitTools<Apps, Errors[number], RB>,
16
+ BuildError<Apps[number]>,
17
+ BuildContext<Apps[number]>
18
+ >;
19
+
20
+ interface Options<Errors = [], RB = never> {
21
+ readonly errors?: Errors; // declared on every tool of this binding
22
+ readonly before?: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
23
+ }
14
24
 
15
25
  interface Binding<Tools, E, R> {
16
26
  readonly toolkit: Toolkit.Toolkit<Tools>; // native; tool names, schemas and requirements stay typed
@@ -24,10 +34,10 @@ Tool.Tool<
24
34
  {
25
35
  parameters: A["input"];
26
36
  success: A["success"];
27
- failure: Schema.Union<A["errors"]>;
37
+ failure: Schema.Union<ReadonlyArray<A["errors"][number] | Errors[number]>>;
28
38
  failureMode: "return";
29
39
  },
30
- HandlerRequirements
40
+ HandlerRequirements | RB
31
41
  >;
32
42
  ```
33
43
 
@@ -36,9 +46,12 @@ Tool.Tool<
36
46
  ```ts
37
47
  import { Effect, Stream } from "effect";
38
48
  import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
49
+ import { authorize, Forbidden } from "./auth.js";
39
50
  import { UserApp } from "./handlers.js";
40
51
 
41
- const binding = ActionToolkit.make(UserApp);
52
+ // The in-process caller is not trusted more than a remote one: it binds the same
53
+ // rule, and a refusal is a declared tool failure.
54
+ const binding = ActionToolkit.make({ errors: [Forbidden], before: authorize }, UserApp);
42
55
 
43
56
  const program = Effect.gen(function* () {
44
57
  const tools = yield* binding.toolkit; // bound handlers
@@ -56,17 +69,20 @@ With a model: pass `binding.toolkit` as `toolkit` to `LanguageModel.generateText
56
69
 
57
70
  ## Rules
58
71
 
59
- - Selects MCP-enabled actions only, named by `mcp.name`, with the resolved hints. Groups with no selected tools are not built.
72
+ - Selects MCP-enabled actions only, named by `mcp.name`, with the resolved hints. Groups with no selected tools are not built. The options object is required; pass `{}` for a binding with no hook.
60
73
  - Successes are the action's native values. There is no `{ value }` wrapper. Declared failures are returned as native tool results (`failureMode: "return"`), not raised.
61
74
  - `tools.handle(name, encodedInput)` takes encoded arguments and returns an Effect producing a result stream. The handler starts while that stream is constructed, so request services must be provided around the entire `handle(...).pipe(Effect.flatMap(Stream.runCollect))`, not only around the stream.
62
75
  - `binding.layer` acquires each implementation once, in the layer's scope. Build-time requirements belong to the whole selected implementation.
63
- - Each tool carries only its own handler's request requirements, not those of sibling or disabled tools.
76
+ - Each tool carries only its own handler's request requirements, plus the hook's, not those of sibling or disabled tools.
64
77
  - Supply identity at invocation, never when building the layer. Native context capture is not a security boundary.
65
78
  - This is not an MCP server. Use `ActionMcp` to expose the same actions to external clients.
79
+ - `errors` are the failures the caller answers with rather than a handler. They join every tool's declared failures, so a refusal is an ordinary returned tool failure. A schema an action already declares is not repeated.
80
+ - `before` runs once per call, with the selected action contract, after the arguments are decoded and before the handler. Its services join each tool's requirements, and the caller supplies them as it does a handler's.
66
81
 
67
82
  ## Failure modes
68
83
 
69
84
  - Tool missing from the toolkit: the action has `mcp: false`. The Toolkit selects by MCP enablement.
85
+ - Type error on `before`'s error channel: it fails with an error this binding does not declare. Add its schema to `errors`.
70
86
  - `Duplicate MCP tool: <name>` thrown at `make`: two apps expose the same tool name. Rename with `mcp.name`.
71
87
  - `Service not found` for a request tag at call time: it was provided only to the stream, or only to the layer. Provide it around the whole call effect.
72
88
  - Type error on `binding.layer` requirements: a build-time service is missing. Provide its Layer before `binding.layer`.
@@ -2,7 +2,8 @@
2
2
 
3
3
  Two helpers for hosts that own identity: router middleware that provides a request-scoped
4
4
  identity service, and RFC 9728 protected-resource discovery with bearer challenges. Token
5
- verification, login, and consent stay in the application.
5
+ verification, login, and consent stay in the application. Authorization is a group's
6
+ pre-handler hook ([ActionGroup.md](ActionGroup.md)), not part of this module.
6
7
 
7
8
  ## API
8
9
 
@@ -103,7 +104,8 @@ export const routes = Layer.mergeAll(
103
104
  - `protectedResource` publishes what it is given. The deployment must ensure `resource` and `authorizationServers` are valid OAuth URLs (HTTPS, or loopback HTTP in development).
104
105
  - Discovery is served at `/.well-known/oauth-protected-resource` followed by the resource's path and query, for `GET` and `HEAD`, matching that literal path and query. Other requests fall through to the host router. Mount `discovery.layer` outside the authenticated layers. Caching policy is the host's.
105
106
  - `challenge()` quotes and escapes parameter values; it never rejects them. Name only the scopes needed for the request in `scope`; `scopesSupported` advertises the full set in metadata.
106
- - Tool discovery is never filtered by actor. Authorization is a handler concern (`yield* CurrentActor`, then check).
107
+ - Tool discovery is never filtered by actor. Authorization belongs in each surface's `before` hook, which runs with the action contract in hand; write the rule against `action.access` rather than repeating a check in each handler.
108
+ - A response this middleware renders is not part of any endpoint's contract. Declare its schema in `ActionHttp.make`'s `errors` so typed clients decode the 401 instead of reporting a decode error ([ActionHttp.md](ActionHttp.md)).
107
109
 
108
110
  ## Failure modes
109
111
 
@@ -112,3 +114,4 @@ export const routes = Layer.mergeAll(
112
114
  - Discovery returns 404: the request path does not match `resource`'s path and query exactly, or `discovery.layer` is not merged into the served layer.
113
115
  - Discovery requires a token: `discovery.layer` was placed under the authentication middleware. Mount it separately.
114
116
  - 401 response lacks `WWW-Authenticate`: the host's failure response did not set it. Use `discovery.challenge(...)` in its headers.
117
+ - A typed client reports `Decode error (401 ...)` instead of the failure schema: the response this middleware renders is not declared. Add it to `ActionHttp.make`'s `errors`.
package/docs/CONTEXT.md CHANGED
@@ -5,19 +5,23 @@ use these words with these meanings.
5
5
 
6
6
  ## Terms
7
7
 
8
- - **Action**: one contract. A name, a description, an input schema, a success schema, declared error schemas, and transport metadata (`http`, `mcp`). Holds no behavior. Made by `Action.make`.
8
+ - **Action**: one contract. A name, a description, an input schema, a success schema, declared error schemas, authorization metadata (`access`), and transport metadata (`http`, `mcp`). Holds no behavior. Made by `Action.make`.
9
+ - **Access**: `"read"` or `"write"`, required on every action and kept as a literal. States what the action does to its resource. Authorization metadata read by a pre-handler hook, independent of the MCP `readOnly` hint, which defaults from it.
9
10
  - **Group**: a named, ordered set of actions plus group-level errors and an optional schema-error policy. Adapters serve groups, clients are organized by group, and the group name is the OpenAPI tag. Made by `ActionGroup.make`.
10
11
  - **Contract**: an action or a group. Pure data; safe to import anywhere, including browsers.
11
12
  - **Handler**: a function from decoded input to an Effect of decoded success, failing only with the action's declared errors, possibly requiring services.
12
13
  - **Implementation**: a nominal binding of a complete handler record to a group, made by `group.implement`. Carries build-time error and requirement channels. Adapters accept implementations; `ActionCatalog` accepts contracts.
13
14
  - **Builder**: the record or Effect passed to `implement`. Runs once per adapter layer that serves the implementation.
15
+ - **Pre-handler hook**: the `before` function an adapter binds. Runs once per invocation of that surface, with the selected action contract, before its handler. Fails with the surface's own errors. Its services are request-time requirements.
14
16
  - **Build-time requirement**: a service yielded in the builder. Resolved when an adapter layer is built.
15
17
  - **Request-time requirement**: a service yielded inside a handler. Supplied per invocation: by router middleware for HTTP-hosted adapters, by the host for Toolkit, CLI, and stdio.
16
18
  - **Adapter** (also **projection**): a module that maps implementations or contracts onto one surface: `ActionHttp`, `ActionMcp`, `ActionToolkit`, `ActionCli`, `ActionCliClient`, `ActionCatalog`. Each adapter reads only what it serves and validates only its own namespace.
17
19
  - **Binding**: the value an adapter returns before anything runs. `ActionHttp.make` returns the HTTP binding (`groups`, `api`, `layer`); `ActionToolkit.make` returns the Toolkit binding (`toolkit`, `layer`).
20
+ - **Contract map**: the record `ActionGroup.contracts` returns, keyed `<group>.<action>`, with each action's exact type. The shared identity of an action across routes, tools and commands.
18
21
  - **Declared error**: an error schema listed on an action or inherited from its group. Handlers may fail with it. Encoded on every transport with its `httpApiStatus`.
19
22
  - **Schema-error policy**: a group option mapping Effect's `HttpApiSchemaError` (decode or encode failure) to a declared policy error with a status. HTTP only.
20
23
  - **Policy error**: an error a schema-error policy may answer with. Part of the transport contract, never returnable by a handler.
24
+ - **Surface error**: an error declared on an adapter binding rather than on an action. Produced by middleware around the surface or by its pre-handler hook, never by a handler; declared so typed callers decode it.
21
25
  - **Local-only action**: `http: false` and `mcp: false`. Reachable through `ActionCli` and listed in the catalog.
22
26
  - **Tool**: the MCP or Toolkit projection of an MCP-enabled action, named by `mcp.name` and carrying its hints.
23
27
  - **Endpoint**: one `ActionMcp.layerHttp` mount. One route, one middleware set, one tool registry.
@@ -31,6 +35,6 @@ use these words with these meanings.
31
35
  - `src/internal/actions.ts` and `src/internal/implementation.ts` hold the shared shapes adapters consume: the contract half of a group, the schema-error policy, and the nominal `Implementation` class. Everything under `src/internal` is not public API; the public modules re-export the types consumers need.
32
36
  - Each adapter owns its own mapping from contracts to its surface and never reaches into another adapter. HTTP does not know about tool names; MCP does not know about routes.
33
37
  - Adapters build on Effect's own servers and clients: `HttpApi` for HTTP, `McpServer` for MCP, `Toolkit` for the AI toolkit, `Command` for the CLI. The library adds no protocol runtime and defines no application error types.
34
- - Authentication is router middleware plus discovery metadata. Verification, login, and consent stay in the application.
38
+ - Authentication is router middleware plus discovery metadata. Verification, login, and consent stay in the application. Authorization is the application's pre-handler hook, bound to each surface, which the library runs but never writes.
35
39
  - `Testing` depends only on `effect`. `TestingClient` is the single module that loads the optional MCP client peer.
36
40
  - Docs: `README.md` is the pitch for humans. `docs/` is the reference for agents, one card per module. `skills/effect-actions` is a copy of `docs/`, with this vocabulary included. `CONTRIBUTING.md` and `AGENTS.md` are for maintainers.
package/docs/README.md CHANGED
@@ -17,9 +17,9 @@ Package facts that apply everywhere:
17
17
  | Read | When you need to |
18
18
  | ---------------------------------------- | -------------------------------------------------------------------------------------- |
19
19
  | [setup.md](setup.md) | install, pin versions, pick entry points, know what the package does not do |
20
- | [Action.md](Action.md) | define one contract: input, success, errors, HTTP and MCP flags, tool hints |
21
- | [ActionGroup.md](ActionGroup.md) | group contracts, declare shared errors, set a schema-error policy, bind handlers |
22
- | [ActionHttp.md](ActionHttp.md) | serve JSON POST routes, publish OpenAPI, call the API with `HttpApiClient` |
20
+ | [Action.md](Action.md) | define one contract: input, success, errors, read/write access, HTTP and MCP flags |
21
+ | [ActionGroup.md](ActionGroup.md) | group contracts, declare shared errors, bind handlers, map every contract by name |
22
+ | [ActionHttp.md](ActionHttp.md) | serve JSON POST routes, declare surface errors and one hook, publish OpenAPI, call it |
23
23
  | [ActionMcp.md](ActionMcp.md) | serve MCP tools over Streamable HTTP or stdio |
24
24
  | [ActionToolkit.md](ActionToolkit.md) | use actions as a native Effect AI `Toolkit` without a server |
25
25
  | [ActionCli.md](ActionCli.md) | run handlers in-process from a terminal command |
@@ -54,7 +54,7 @@ const Greet = Action.make("greet", {
54
54
  description: "Greet someone by name.",
55
55
  input: Schema.Struct({ name: Schema.String }),
56
56
  success: Schema.String,
57
- mcp: { readOnly: true },
57
+ access: "read",
58
58
  });
59
59
 
60
60
  export const Actions = ActionGroup.make({ name: "greetings" }, Greet);
@@ -66,7 +66,7 @@ const app = Actions.implement({
66
66
  });
67
67
 
68
68
  export const routes = Layer.mergeAll(
69
- Http.layer(app),
69
+ Http.layer({}, app),
70
70
  ActionMcp.layerHttp(
71
71
  { protocols: [McpProtocol.v2026_07_28], name: "greetings", version: "1.0.0", path: "/mcp" },
72
72
  app,
package/docs/Testing.md CHANGED
@@ -88,4 +88,4 @@ const result = await TestingClient.withMcpClient(
88
88
  - `@modelcontextprotocol/client` not found: only `TestingClient` needs it. Install the peer in devDependencies, or use `Testing.mcpRequest` instead.
89
89
  - Official client fails negotiation against a stateless endpoint: set `versionNegotiation: { mode: { pin: "2026-07-28" } }`.
90
90
  - Handler leaks between tests: `web.dispose()` was not called. Register it with the test runner's cleanup hook.
91
- - 404 from `httpClient`: `routes` did not include `Http.layer(app)`, or `HttpServer.layerServices` was not provided to `toWebHandler`.
91
+ - 404 from `httpClient`: `routes` did not include `Http.layer({}, app)`, or `HttpServer.layerServices` was not provided to `toWebHandler`.
@@ -5,10 +5,16 @@ Rules that hold across every adapter. Module pages link here rather than repeati
5
5
  ## Dependency lifetimes
6
6
 
7
7
  - Handlers receive decoded input, return decoded results, and may fail only with declared errors. Anything else is a defect.
8
+ - Each surface binds its own pre-handler hook: `Http.layer({ before }, ...)`, `ActionMcp.layerHttp`/`layerStdio`, `ActionToolkit.make`, `ActionCli.command`/`group`. A bound hook runs once per invocation, before the selected handler and outside its span, for every action that surface serves. There is no per-action opt-out and no group that escapes it; only calling `build` directly does, and that is not dispatch.
9
+ - The hook receives the selected action contract (`Action.Any`), so a policy reads `action.access`, `action.name` or `action.mcp` instead of a hand-maintained list. It is not authentication: establish identity in middleware, then let the hook authorize what that identity may do.
10
+ - It fails with the surface's own `errors`, the same list that declares what the surface answers instead of a handler, so a refusal is encoded exactly like a declared error: the schema's `httpApiStatus` on HTTP, an `isError` tool result on MCP, a returned failure on the Toolkit. `ActionCli` declares no errors because it encodes nothing; there a refusal is a typed failure of the command effect.
11
+ - Its services are request-time requirements, like a handler's, and they join the adapter's request context. A local caller (`ActionCli`, `ActionToolkit`, stdio) supplies them itself.
12
+ - Ordering differs by surface, because each hooks into its own transport: HTTP runs the hook **before** the request payload is decoded, so an unauthorized caller learns nothing about the input schema. MCP, the Toolkit and the CLI run it after the native transport has decoded the arguments, since decoding is the transport's own first step there.
8
13
  - Build-time services are those yielded in the `implement` builder Effect. They resolve once per adapter layer, in that layer's scope. An implementation served by two adapters is built twice. Share state through a Layer provided to the adapters, never through the builder.
9
14
  - Request-time services are those yielded inside a handler. For HTTP-hosted adapters (`ActionHttp`, `ActionMcp.layerHttp`) they appear as `HttpRouter.Request.From<"Requires", R>` and are supplied by router middleware, `HttpRouter.provideRequest`, or request context. For `ActionToolkit`, `ActionCli`, and `ActionMcp.layerStdio` the host supplies them at invocation.
10
15
  - Use distinct tags for build-time capabilities and request-scoped identity. Never provide an identity or tenant tag in a startup layer or root context. The adapters use native Effect context capture and merging: a startup value under a tag can shadow a request value or satisfy a missing one. Types verify presence, not provenance.
11
- - Authentication must establish identity on every protected request. Types cannot verify that a handler performed authorization.
16
+ - Authentication must establish identity on every protected request. Types cannot verify that a handler performed authorization; the `before` hook is the one place the library guarantees runs first, so put the check there rather than in each handler.
17
+ - `access` is contract metadata, required on every action and kept as a literal type. Nothing in the library reads it: adapters ignore it and the catalog omits it. It exists so a hook can switch on what an action does instead of on its name.
12
18
  - `ActionCli` acquires the implementation per invocation and releases it after the call. `ActionToolkit.layer` acquires it once for the layer's lifetime.
13
19
 
14
20
  ## Wire behavior
@@ -20,14 +26,17 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
20
26
  | Input | decoded with the schema's JSON codec; failure is an empty **400** | decoded with the schema's JSON codec; failure is `InvalidParams`, presented as an `isError` result |
21
27
  | Success | the success codec's encoding as the body: `{"id":"1","name":"Ada"}`, `42` | `structuredContent: { value: <encoded> }` |
22
28
  | Declared error | encoded by its schema with its `httpApiStatus` (unannotated: 500): `{"_tag":"UserNotFound","id":"x"}` | `isError: true`; text content is the same JSON; no `structuredContent` |
29
+ | Pre-handler hook failure | the same, using the hook's error schema, before the payload is decoded; the handler never runs | the same `isError` result, after arguments are decoded; the handler never runs |
23
30
  | Invalid output encoding | empty **400** (`HttpApiSchemaError`) | `isError: true`, text `Tool execution failed due to an internal server error.`; cause logged, not sent |
24
31
  | Defect | empty **500** | same generic `isError` result; cause logged, not sent |
25
32
  | Unknown path or method | 404 | n/a |
26
33
  | Invalid JSON / content type | 400 / 415 | n/a |
27
34
 
28
35
  - A group `schemaError` policy replaces the empty 400s on HTTP with a declared error and status. It never affects MCP. Details in [ActionGroup.md](ActionGroup.md).
36
+ - Every HTTP response with a status of 400 or more carries `cache-control: no-store`. Successful responses are left to the host. An error response has no other added headers: a hook refusal and a handler error are plain declared errors, and challenge headers such as `WWW-Authenticate` belong to the admission middleware that answers before the router reaches these routes.
29
37
  - Routes are `POST <apiPath>/<group>/<action>`. Operation IDs are `<group>.<action>`. Effect generates OpenAPI component names and references.
30
- - HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP cannot be strict natively: `HttpApi.ParseOptions` also governs error encoding, where `onExcessProperty: "error"` rejects an error's own `stack`.
38
+ - `ActionHttp.make`'s `errors` are declared on every endpoint so clients decode the surface's own failures (401, 403, 429, 503). They are produced by middleware or by the binding's hook, never by a handler. `ActionMcp` and `ActionToolkit` declare their own `errors` the same way, joined into every tool's failure schema. Two errors may share an HTTP status; their `_tag`s must differ, because the client decodes a status by trying the schemas declared for it.
39
+ - HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP is not strict, and cannot be made strict without a hack: Effect merges one `HttpApi.ParseOptions` per endpoint and uses it for payload decoding _and_ error encoding, so `onExcessProperty: "error"` also rejects a `TaggedError` instance's own `message` and `stack`, turning a declared 409 into an empty 500. Making the payload schema itself strict would require wrapping it in an open schema, which erases its OpenAPI shape. Declare the fields you accept and treat extra HTTP fields as ignored.
31
40
 
32
41
  ## Namespaces
33
42
 
@@ -37,7 +46,7 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
37
46
 
38
47
  ## Observability
39
48
 
40
- - Each handler runs in a span named `<group>.<action>`, a child of the transport's request span. Decoding and encoding happen outside the handler span.
49
+ - Each handler runs in a span named `<group>.<action>`, a child of the transport's request span. The pre-handler hook, decoding and encoding happen outside the handler span.
41
50
  - MCP defects and encoding failures are logged with their cause and answered generically.
42
51
 
43
52
  ## MCP transport
@@ -51,5 +60,5 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
51
60
 
52
61
  - HTTP is JSON `POST` on `HttpApi`, not Effect RPC. MCP is native `McpServer`, no SDK runtime dependency.
53
62
  - Actions are unary: no streaming, uploads, prompts, resources, retries, or code-execution sandbox.
54
- - Authentication and authorization are the application's. Tool discovery is not filtered by actor. Production needs real token validation, MCP authorization discovery, request limits, and error reporting; the package supplies shapes for the first two only.
63
+ - Authentication and authorization are the application's. The library supplies the seams: `Authentication.middleware` for identity, one `before` hook per surface for the authorization rule, `access` for what a rule reads, and each surface's `errors` for what a caller can decode. It defines no error types, no scopes and no verifier. Tool discovery is not filtered by actor. Production needs real token validation, MCP authorization discovery, request limits, and error reporting.
55
64
  - The catalog is descriptive. Presence in it grants nothing.
package/docs/setup.md CHANGED
@@ -54,7 +54,7 @@ What the package does, and nothing else:
54
54
  - HTTP means JSON `POST` endpoints built on Effect's `HttpApi`. It is not Effect RPC.
55
55
  - MCP means one native `McpServer` `Tool` per action. There is no MCP SDK runtime dependency.
56
56
  - Actions are unary: one decoded input, one decoded success or one declared error. No streaming, uploads, prompts, resources, retries, or code-execution sandbox.
57
- - Authentication and authorization are the application's. The library provides a middleware shape and discovery metadata, not a token verifier. Tool discovery is never filtered by actor.
57
+ - Authentication and authorization are the application's. The library provides the seams — `Authentication.middleware` for identity, one `before` hook per surface for the authorization rule, `access` for what that rule reads, each surface's `errors` for what a caller can decode — and no token verifier, error types or scopes of its own. Tool discovery is never filtered by actor.
58
58
 
59
59
  ## Failure modes
60
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/effect-actions",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Define Effect action contracts once and project them onto HTTP, MCP, native Toolkits, catalogs, and CLIs.",
5
5
  "homepage": "https://github.com/gjermundgaraba/effect-actions#readme",
6
6
  "bugs": {