@gjermundgaraba/effect-actions 0.2.0 → 0.3.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 CHANGED
@@ -18,7 +18,7 @@ const Greet = Action.make("greet", {
18
18
  description: "Greet someone by name.",
19
19
  input: Schema.Struct({ name: Schema.String }),
20
20
  success: Schema.String,
21
- mcp: { readOnly: true },
21
+ access: "read",
22
22
  });
23
23
 
24
24
  export const Actions = ActionGroup.make({ name: "greetings" }, Greet);
@@ -61,8 +61,9 @@ const greeting = Effect.gen(function* () {
61
61
  ## Why
62
62
 
63
63
  - Input, success, and errors are declared once. Routes, tools, commands, clients, OpenAPI, and the catalog are derived from that declaration, so they cannot disagree.
64
- - A handler may fail only with the errors its action declares. Shared failures such as `Forbidden` are declared once on the group.
64
+ - A handler may fail only with the errors its action declares. Shared failures such as `Forbidden` are declared once on the group, and one `before` hook runs on every surface before every handler, so a policy such as scope enforcement is written once and cannot be forgotten.
65
65
  - The pieces are Effect's own. `Http.api` is a native `HttpApi`, so `OpenApi.fromApi`, Swagger, Scalar, and `HttpApiClient` work on it unchanged. MCP is Effect's native `McpServer`, one `Tool` per action, with no SDK runtime dependency.
66
+ - Every action states its `access` (`"read"` or `"write"`, defaulting to `"write"`), so authorization reads the contract instead of a hand-maintained list of mutation names.
66
67
  - Build-time services and per-request services are tracked separately in the types. Middleware is per group, so a public group and an authenticated group can share one mount path.
67
68
  - Tests run in memory. Call the routes through a web handler with the same typed client, or drive the official MCP client, without opening a port.
68
69
 
package/dist/Action.d.mts CHANGED
@@ -1,14 +1,19 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  declare namespace Action_d_exports {
3
- export { Action, Any, Codec, Handler, McpOptions, Options, make };
3
+ export { Access, Action, Any, Codec, Handler, McpOptions, Options, make };
4
4
  }
5
5
  /** Any service-free schema. Only handlers may require services. */
6
6
  export type Codec = Schema.Codec<unknown, unknown, never, never>;
7
+ /**
8
+ * What an action does to the resource it serves: `"read"` observes, `"write"`
9
+ * may change it. Authorization metadata, not an MCP hint.
10
+ */
11
+ export type Access = "read" | "write";
7
12
  /** MCP tool metadata; every field has a default derived from the action. */
8
13
  export interface McpOptions {
9
14
  /** Tool name; defaults to the action name. Must match `^[A-Za-z0-9_-]{1,128}$`. */
10
15
  readonly name?: string;
11
- /** `readOnlyHint`; defaults to `false`. */
16
+ /** `readOnlyHint`; defaults to `access === "read"`. */
12
17
  readonly readOnly?: boolean;
13
18
  /** `destructiveHint`; defaults to `!readOnly`, as the MCP spec only defines it for writes. */
14
19
  readonly destructive?: boolean;
@@ -32,6 +37,12 @@ export interface Options<Input extends Codec, Output extends Codec, Errors exten
32
37
  readonly success: Output;
33
38
  /** One schema per declared failure; each keeps its own HTTP status annotation. Defaults to none. */
34
39
  readonly errors?: Errors;
40
+ /**
41
+ * What the action does to its resource. Defaults to `"write"`, so an action
42
+ * nobody classified is treated as the more dangerous one. Read by a group's
43
+ * `before` hook; the library itself authorizes nothing.
44
+ */
45
+ readonly access?: Access;
35
46
  /** `false` hides the action from HTTP routes and clients. */
36
47
  readonly http?: Http;
37
48
  /** `false` hides the action from MCP; otherwise tool metadata. */
@@ -44,6 +55,7 @@ export interface Action<Name extends string, Input extends Codec, Output extends
44
55
  readonly input: Input;
45
56
  readonly success: Output;
46
57
  readonly errors: Errors;
58
+ readonly access: Access;
47
59
  readonly http: Http;
48
60
  readonly mcp: ResolvedMcp<Name, Mcp>;
49
61
  }
package/dist/Action.mjs CHANGED
@@ -5,7 +5,8 @@ import { Schema } from "effect";
5
5
  const NoInput = Schema.Record(Schema.String, Schema.Never);
6
6
  function make(name, options) {
7
7
  assertName("action name", name);
8
- const readOnly = options.mcp === false ? false : options.mcp?.readOnly ?? false;
8
+ const access = options.access ?? "write";
9
+ const readOnly = options.mcp === false ? false : options.mcp?.readOnly ?? access === "read";
9
10
  const mcp = options.mcp === false ? false : {
10
11
  name: options.mcp?.name ?? name,
11
12
  readOnly,
@@ -21,6 +22,7 @@ function make(name, options) {
21
22
  input: options.input ?? NoInput,
22
23
  success: options.success,
23
24
  errors: options.errors ?? [],
25
+ access,
24
26
  http: options.http !== false,
25
27
  mcp
26
28
  };
@@ -1,4 +1,4 @@
1
- import { Action_d_exports, Any } from "./Action.mjs";
1
+ import { Access, Action_d_exports, Any } from "./Action.mjs";
2
2
  import { ActionGroup_d_exports, Any as Any$1 } from "./ActionGroup.mjs";
3
3
  import { JsonSchema } from "effect";
4
4
  //#region src/ActionCatalog.d.ts
@@ -8,6 +8,8 @@ export interface Entry {
8
8
  readonly group: string;
9
9
  readonly name: string;
10
10
  readonly description: string;
11
+ /** Authorization metadata, resolved: `"write"` unless the contract says `"read"`. */
12
+ readonly access: Access;
11
13
  readonly http: boolean;
12
14
  readonly mcp: Any["mcp"];
13
15
  readonly input: JsonSchema.JsonSchema;
@@ -23,6 +23,7 @@ const make = (...groups) => {
23
23
  group: group.name,
24
24
  name: action.name,
25
25
  description: action.description,
26
+ access: action.access,
26
27
  http: action.http,
27
28
  mcp: action.mcp,
28
29
  input: jsonSchema(action.input),
@@ -18,10 +18,10 @@ type BoundHandler<Name extends string, A extends Any, R> = Readonly<Record<Name,
18
18
  * Project one explicitly selected local action into a native Effect CLI command.
19
19
  * The action name resolves against the bound group; no HTTP fallback exists.
20
20
  */
21
- export declare const command: <G extends Actions, Name extends G["actions"][number]["name"], H extends BoundHandler<Name, Selected<G, Name>, HandlerContext<H, Name>>, EX, RX, Parameters extends Command.Command.Config = never>(app: Implementation<G, H, EX, RX>, name: Name, options?: Options<Selected<G, Name>["success"]["Type"], Parameters>) => Command.Command<string, never, {}, EX | import("effect/Schema").SchemaError | Extract<G["actions"][number], {
21
+ export declare const command: <G extends Actions, Name extends G["actions"][number]["name"], H extends BoundHandler<Name, Selected<G, Name>, HandlerContext<H, Name>>, EX, RX, RB, Parameters extends Command.Command.Config = never>(app: Implementation<G, H, EX, RX, RB>, name: Name, options?: Options<Selected<G, Name>["success"]["Type"], Parameters>) => Command.Command<string, never, {}, EX | import("effect/Schema").SchemaError | Extract<G["actions"][number], {
22
22
  readonly name: Name;
23
- }>["errors"][number]["Type"], Exclude<RX, Scope.Scope> | Exclude<HandlerContext<H, Name>, Scope.Scope>>;
23
+ }>["errors"][number]["Type"], Exclude<RB, Scope.Scope> | Exclude<RX, Scope.Scope> | Exclude<HandlerContext<H, Name>, Scope.Scope>>;
24
24
  /** Project every local action below its group namespace with default CLI options. */
25
- export declare const group: <G extends Actions, H extends Handlers<HandlersContext<H>>, EX, RX>(app: Implementation<G, H, EX, RX>, options?: GroupOptions) => Command.Command<string, {}, {}, EX | import("effect/Schema").SchemaError | G["actions"][number]["errors"][number]["Type"], Exclude<Exclude<RX, Scope.Scope>, Command.CommandContext<string>> | Exclude<Exclude<HandlersContext<H>, Scope.Scope>, Command.CommandContext<string>>>;
25
+ export declare const group: <G extends Actions, H extends Handlers<HandlersContext<H>>, EX, RX, RB>(app: Implementation<G, H, EX, RX, RB>, options?: GroupOptions) => Command.Command<string, {}, {}, EX | import("effect/Schema").SchemaError | G["actions"][number]["errors"][number]["Type"], Exclude<Exclude<RB, Scope.Scope>, Command.CommandContext<string>> | Exclude<Exclude<RX, Scope.Scope>, Command.CommandContext<string>> | Exclude<Exclude<HandlersContext<H>, Scope.Scope>, Command.CommandContext<string>>>;
26
26
  //#endregion
27
27
  export type { Options };
@@ -8,7 +8,7 @@ const select = (group, name) => {
8
8
  if (action === void 0) throw new Error(`Unknown action "${group.name}.${name}"`);
9
9
  return action;
10
10
  };
11
- const local = (app, action, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers)(input)));
11
+ const local = (app, action, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, app.before)(input)));
12
12
  /**
13
13
  * Project one explicitly selected local action into a native Effect CLI command.
14
14
  * The action name resolves against the bound group; no HTTP fallback exists.
@@ -19,7 +19,7 @@ const command = (app, name, options) => {
19
19
  };
20
20
  /** Project every local action below its group namespace with default CLI options. */
21
21
  const group = (app, options) => {
22
- const commands = app.group.actions.map((action) => command$1(action, (input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers)(input)))));
22
+ const commands = app.group.actions.map((action) => command$1(action, (input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, app.before)(input)))));
23
23
  return Command.make(options?.name ?? app.group.name).pipe(Command.withSubcommands(commands));
24
24
  };
25
25
  //#endregion
@@ -1,3 +1,4 @@
1
+ import { Action_d_exports, Codec } from "./Action.mjs";
1
2
  import { Actions } from "./internal/actions.mjs";
2
3
  import { Options as Options$1 } from "./internal/cli.mjs";
3
4
  import { ActionHttp_d_exports, Api, Http } from "./ActionHttp.mjs";
@@ -27,19 +28,19 @@ type Selected<G extends Actions, Name extends G["actions"][number]["name"]> = Ex
27
28
  type HttpNames<G extends Actions> = Extract<G["actions"][number], {
28
29
  readonly http: true;
29
30
  }>["name"];
30
- type ApiGroups<Groups extends ReadonlyArray<Actions>> = Api<Groups[number]> extends HttpApi.HttpApi<"actions", infer ApiGroups> ? ApiGroups : never;
31
- type Endpoint<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Extract<HttpApiGroup.EndpointsWithIdentifier<ApiGroups<Groups>, Extract<GroupName, HttpApiGroup.Identifier<ApiGroups<Groups>>>>, {
31
+ type ApiGroups<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>> = Api<Groups[number], Errors[number]> extends HttpApi.HttpApi<"actions", infer ApiGroups> ? ApiGroups : never;
32
+ type Endpoint<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Extract<HttpApiGroup.EndpointsWithIdentifier<ApiGroups<Groups, Errors>, Extract<GroupName, HttpApiGroup.Identifier<ApiGroups<Groups, Errors>>>>, {
32
33
  readonly identifier: ActionName;
33
34
  }>;
34
- type NativeMethod<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = HttpApiClient.Client.Method<Extract<Endpoint<Groups, GroupName, ActionName>, HttpApiEndpoint.ConstraintRequest>, never, never>;
35
- type RemoteCommand<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Command.Command<string, never, {}, Effect.Error<ReturnType<NativeMethod<Groups, GroupName, ActionName>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, GroupName, ActionName>>>>;
36
- type RemoteGroupCommand<Groups extends ReadonlyArray<Actions>, GroupName extends Groups[number]["name"]> = Command.Command<string, {}, {}, Effect.Error<ReturnType<NativeMethod<Groups, GroupName, HttpNames<Group<Groups, GroupName>>>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, GroupName, HttpNames<Group<Groups, GroupName>>>>>>;
35
+ type NativeMethod<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = HttpApiClient.Client.Method<Extract<Endpoint<Groups, Errors, GroupName, ActionName>, HttpApiEndpoint.ConstraintRequest>, never, never>;
36
+ type RemoteCommand<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Command.Command<string, never, {}, Effect.Error<ReturnType<NativeMethod<Groups, Errors, GroupName, ActionName>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, Errors, GroupName, ActionName>>>>;
37
+ type RemoteGroupCommand<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>, GroupName extends Groups[number]["name"]> = Command.Command<string, {}, {}, Effect.Error<ReturnType<NativeMethod<Groups, Errors, GroupName, HttpNames<Group<Groups, GroupName>>>>>, HttpClient.HttpClient | Effect.Services<ReturnType<NativeMethod<Groups, Errors, GroupName, HttpNames<Group<Groups, GroupName>>>>>>;
37
38
  /**
38
39
  * Project one HTTP action retained by the binding into a native Effect CLI command.
39
40
  * Group and action selectors resolve exclusively from `http.groups`; runtime guards
40
41
  * keep dynamically supplied selector strings from reaching the native client.
41
42
  */
42
- export declare const command: <const Groups extends ReadonlyArray<Actions>, const GroupName extends Groups[number]["name"], const ActionName extends HttpNames<Group<Groups, GroupName>>, ParsedParameters extends Command.Command.Config = never>(http: Http<Groups>, groupName: GroupName, actionName: ActionName, options?: Options<Selected<Group<Groups, GroupName>, ActionName>["success"]["Type"], ParsedParameters>) => RemoteCommand<Groups, GroupName, ActionName>;
43
+ export declare const command: <const Groups extends ReadonlyArray<Actions>, const Errors extends ReadonlyArray<Codec>, const GroupName extends Groups[number]["name"], const ActionName extends HttpNames<Group<Groups, GroupName>>, ParsedParameters extends Command.Command.Config = never>(http: Http<Groups, Errors>, groupName: GroupName, actionName: ActionName, options?: Options<Selected<Group<Groups, GroupName>, ActionName>["success"]["Type"], ParsedParameters>) => RemoteCommand<Groups, Errors, GroupName, ActionName>;
43
44
  /** Project all HTTP-enabled actions retained by one group below its group namespace. */
44
- export declare const group: <const Groups extends ReadonlyArray<Actions>, const GroupName extends Groups[number]["name"]>(http: Http<Groups>, groupName: GroupName, options?: GroupOptions) => RemoteGroupCommand<Groups, GroupName>;
45
+ export declare const group: <const Groups extends ReadonlyArray<Actions>, const Errors extends ReadonlyArray<Codec>, const GroupName extends Groups[number]["name"]>(http: Http<Groups, Errors>, groupName: GroupName, options?: GroupOptions) => RemoteGroupCommand<Groups, Errors, GroupName>;
45
46
  //#endregion
@@ -3,7 +3,7 @@ import { Actions, SchemaErrorPolicy } from "./internal/actions.mjs";
3
3
  import { Implementation } from "./internal/implementation.mjs";
4
4
  import { Effect, Scope } from "effect";
5
5
  declare namespace ActionGroup_d_exports {
6
- export { Any, Group, Implementation, Options, SchemaErrorPolicy, make };
6
+ export { Any, Group, ImplementOptions, Implementation, Options, SchemaErrorPolicy, make };
7
7
  }
8
8
  type HandlersFrom<Actions extends ReadonlyArray<Any$1>> = { readonly [A in Actions[number] as A["name"]]: Handler<A, any>; };
9
9
  /** Group-level errors join each action's own, so a shared set is declared once. */
@@ -17,14 +17,24 @@ export interface Options<Name extends string, Errors extends ReadonlyArray<Codec
17
17
  /** How HTTP answers failed decoding or encoding of these actions; MCP keeps its native answers. */
18
18
  readonly schemaError?: SchemaErrorPolicy<PolicyErrors>;
19
19
  }
20
+ /** What `implement` accepts besides the handlers themselves. */
21
+ export interface ImplementOptions<Errors extends ReadonlyArray<Codec>, R> {
22
+ /**
23
+ * Runs once per invocation before the selected handler, on every surface, with
24
+ * the action contract it is about to run. It may fail only with the group's own
25
+ * `errors`, because those are the failures every action of the group declares.
26
+ * Its services are request-time requirements, like a handler's.
27
+ */
28
+ readonly before: (action: Any$1) => Effect.Effect<void, Errors[number]["Type"], R>;
29
+ }
20
30
  /** A named set of action contracts: what adapters serve and what `implement` binds. */
21
- export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>, PolicyErrors extends ReadonlyArray<Codec> = ReadonlyArray<Codec>> extends Actions<Name, Actions$1, PolicyErrors> {
31
+ export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>, PolicyErrors extends ReadonlyArray<Codec> = ReadonlyArray<Codec>, Errors extends ReadonlyArray<Codec> = ReadonlyArray<Codec>> extends Actions<Name, Actions$1, PolicyErrors> {
22
32
  /** Bind every handler at once, resolving build-time services once per adapter layer. */
23
- readonly implement: <H extends HandlersFrom<Actions$1>, EX = never, RX = never>(build: H | Effect.Effect<H, EX, RX>) => Implementation<Group<Name, Actions$1, PolicyErrors>, H, NoInfer<EX>, NoInfer<Exclude<RX, Scope.Scope>>>;
33
+ readonly implement: <H extends HandlersFrom<Actions$1>, EX = never, RX = never, RB = never>(build: H | Effect.Effect<H, EX, RX>, options?: ImplementOptions<Errors, RB>) => Implementation<Group<Name, Actions$1, PolicyErrors, Errors>, H, NoInfer<EX>, NoInfer<Exclude<RX, Scope.Scope>>, NoInfer<RB>>;
24
34
  }
25
- /** Any group, with its actions erased. */
26
- export type Any = Group<string, ReadonlyArray<Any$1>>;
35
+ /** Any group, with its actions and error schemas erased. */
36
+ export type Any = Group<string, ReadonlyArray<Any$1>, ReadonlyArray<Codec>, any>;
27
37
  /** Define a group. Invalid or duplicate action and MCP names fail here, at definition time. */
28
- export declare function make<const Name extends string, const Actions extends ReadonlyArray<Any$1>, const Errors extends ReadonlyArray<Codec> = [], const PolicyErrors extends ReadonlyArray<Codec> = []>(options: Options<Name, Errors, PolicyErrors>, ...actions: Actions): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
38
+ export declare function make<const Name extends string, const Actions extends ReadonlyArray<Any$1>, const Errors extends ReadonlyArray<Codec> = [], const PolicyErrors extends ReadonlyArray<Codec> = []>(options: Options<Name, Errors, PolicyErrors>, ...actions: Actions): Group<Name, WithErrors<Actions, Errors>, PolicyErrors, Errors>;
29
39
  //#endregion
30
40
  export { ActionGroup_d_exports, type Implementation, type SchemaErrorPolicy };
@@ -15,9 +15,10 @@ function make(options, ...declared) {
15
15
  name,
16
16
  actions,
17
17
  schemaError: options.schemaError,
18
- implement: (build) => {
18
+ implement: (build, options) => {
19
19
  const built = Effect.isEffect(build) ? build : Effect.succeed(build);
20
- return Implementation.make(group, built);
20
+ const before = options?.before;
21
+ return Implementation.make(group, built, before);
21
22
  }
22
23
  };
23
24
  return group;
@@ -10,20 +10,28 @@ declare namespace ActionHttp_d_exports {
10
10
  export { Api, Http, Options, make };
11
11
  }
12
12
  /** Contract-level configuration: servers and clients must agree on it. */
13
- export interface Options {
13
+ export interface Options<Errors extends ReadonlyArray<Codec> = []> {
14
14
  readonly apiPath: `/${string}`;
15
+ /**
16
+ * Failures the surface around these endpoints answers with instead of a
17
+ * handler: authentication, authorization, rate limits. Declared on every
18
+ * endpoint, so `HttpApiClient` and `ActionCliClient` decode them as typed
19
+ * failures rather than reporting a decode error. Each schema keeps its own
20
+ * `httpApiStatus`, and no two of them may share a status with an action error.
21
+ */
22
+ readonly errors?: Errors;
15
23
  }
16
24
  type Endpoint<A extends Any, E extends Codec> = A extends {
17
25
  readonly http: false;
18
26
  } ? never : A extends Any ? HttpApiEndpoint.HttpApiEndpoint<A["name"], "POST", `/${string}`, never, never, Schema.toCodecJson<A["input"]>, never, Schema.toCodecJson<A["success"]>, Schema.toCodecJson<A["errors"][number] | E>, never> : never;
19
- type ApiGroup<G extends Actions> = G extends Actions ? [Endpoint<G["actions"][number], never>] extends [never] ? never : HttpApiGroup.HttpApiGroup<G["name"], Endpoint<G["actions"][number], PolicyError<G>>> : never;
27
+ type ApiGroup<G extends Actions, E extends Codec> = G extends Actions ? [Endpoint<G["actions"][number], never>] extends [never] ? never : HttpApiGroup.HttpApiGroup<G["name"], Endpoint<G["actions"][number], PolicyError<G> | E>> : never;
20
28
  /** The native `HttpApi` of every HTTP-enabled action of `G`, one group per action group. */
21
- export type Api<G extends Actions> = HttpApi.HttpApi<"actions", ApiGroup<G>>;
29
+ export type Api<G extends Actions, E extends Codec = never> = HttpApi.HttpApi<"actions", ApiGroup<G, E>>;
22
30
  /** A namespaced HTTP binding. */
23
- export interface Http<Groups extends ReadonlyArray<Actions>> {
31
+ export interface Http<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec> = []> {
24
32
  /** The exact contracts bound to this HTTP adapter, in declaration order. */
25
33
  readonly groups: Groups;
26
- readonly api: Api<Groups[number]>;
34
+ readonly api: Api<Groups[number], Errors[number]>;
27
35
  /**
28
36
  * Serve any number of implementations in one layer. Build failures, build
29
37
  * services and request services are unions over precisely those apps.
@@ -34,6 +42,6 @@ export interface Http<Groups extends ReadonlyArray<Actions>> {
34
42
  * Bind the contract-level configuration once. Every route is `POST
35
43
  * <apiPath>/<group>/<action>`, so action names may repeat across groups.
36
44
  */
37
- export declare function make<const G extends ReadonlyArray<Actions>>(options: Options, ...groups: G): Http<G>;
45
+ export declare function make<const G extends ReadonlyArray<Actions>, const E extends ReadonlyArray<Codec> = []>(options: Options<E>, ...groups: G): Http<G, E>;
38
46
  //#endregion
39
47
  export { ActionHttp_d_exports };
@@ -4,10 +4,12 @@ import { Effect, Layer } from "effect";
4
4
  import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi";
5
5
  //#region src/ActionHttp.ts
6
6
  const httpActions = (group) => group.actions.filter((action) => action.http);
7
- const endpoint = (apiPath, group, action) => HttpApiEndpoint.post(action.name, `${apiPath}/${group.name}/${action.name}`, {
7
+ /** Surface errors join an action's own, without repeating a schema it already declares. */
8
+ const errorsOf = (options, action) => [...action.errors, ...(options.errors ?? []).filter((error) => !action.errors.includes(error))];
9
+ const endpoint = (options, group, action) => HttpApiEndpoint.post(action.name, `${options.apiPath}/${group.name}/${action.name}`, {
8
10
  payload: action.input,
9
11
  success: action.success,
10
- error: action.errors
12
+ error: errorsOf(options, action)
11
13
  }).annotate(OpenApi.Description, action.description);
12
14
  /** The native middleware declares the policy's errors on every endpoint it wraps. */
13
15
  const schemaErrorMiddleware = (name, policy) => {
@@ -18,7 +20,7 @@ const schemaErrorMiddleware = (name, policy) => {
18
20
  };
19
21
  };
20
22
  function bind(options, group) {
21
- const [first, ...rest] = httpActions(group).map((action) => endpoint(options.apiPath, group, action));
23
+ const [first, ...rest] = httpActions(group).map((action) => endpoint(options, group, action));
22
24
  if (first === void 0) return void 0;
23
25
  const native = HttpApiGroup.make(group.name).add(first, ...rest);
24
26
  if (group.schemaError === void 0) return {
@@ -48,7 +50,7 @@ const erasedHandlers = (handlers) => {
48
50
  };
49
51
  /** Register one group's handlers. Handler erasure is limited to native dynamic endpoint registration. */
50
52
  const groupHandlers = (api, app, policy) => {
51
- const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) => [action.name, (request) => dispatch(app.group, action, record)(request.payload)]));
53
+ const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) => [action.name, (request) => dispatch(app.group, action, record, app.before)(request.payload)]));
52
54
  return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(erasedHandlers(record)))))).pipe(Layer.provide(policy));
53
55
  };
54
56
  function make(options, ...groups) {
@@ -1,5 +1,5 @@
1
1
  import { Action_d_exports, Any } from "./Action.mjs";
2
- import { AnyImplementation, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
2
+ import { AnyImplementation, BeforeContext, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
3
3
  import { Layer, Schema } from "effect";
4
4
  import { Tool, Toolkit } from "effect/unstable/ai";
5
5
  //#region src/ActionToolkit.d.ts
@@ -14,10 +14,11 @@ type NativeTool<A extends Any, R> = A extends {
14
14
  readonly failure: Schema.Union<A["errors"]>;
15
15
  readonly failureMode: "return";
16
16
  }, R> : never;
17
- type HandlersOf<App extends AnyImplementation> = App extends Implementation<any, infer H, any, any> ? H : never;
17
+ type HandlersOf<App extends AnyImplementation> = App extends Implementation<any, infer H, any, any, any> ? H : never;
18
+ /** A tool needs what its handler needs, plus what the group's `before` hook needs. */
18
19
  type ToolForAction<App extends AnyImplementation, A extends Any> = A extends {
19
20
  readonly mcp: object;
20
- } ? NativeTool<A, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>>> : never;
21
+ } ? NativeTool<A, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>> | BeforeContext<App>> : never;
21
22
  type ToolsFor<App extends AnyImplementation> = App extends AnyImplementation ? ToolForAction<App, App["group"]["actions"][number]> : never;
22
23
  type ToolkitTools<Apps extends ReadonlyArray<AnyImplementation>> = { readonly [T in ToolsFor<Apps[number]> as T["name"]]: T; };
23
24
  /** Native tools and the layer that binds their action implementations. */
@@ -9,31 +9,40 @@ type ErasedHandler<R> = {
9
9
  }["handle"];
10
10
  /** An adapter's erased view of a record of handlers with request requirements `R`. */
11
11
  export type Handlers<R> = Readonly<Record<string, ErasedHandler<R>>>;
12
+ /**
13
+ * An adapter's erased view of the pre-handler hook. It sees the selected action
14
+ * contract, so a group-wide policy reads `access` rather than the action name.
15
+ */
16
+ export type Before<R> = (action: Any) => Effect.Effect<void, ErasedValue, R>;
12
17
  /** Per-request requirements of the handler selected by an action name. */
13
18
  export type HandlerContext<H, Name extends keyof H> = H[Name] extends ((input: never) => Effect.Effect<infer _A, infer _E, infer R>) ? R : never;
14
19
  /** The union of per-request requirements declared by a handler record. */
15
20
  export type HandlersContext<H> = { readonly [K in keyof H]: HandlerContext<H, K>; }[keyof H];
16
21
  /**
17
- * An acquired handler record for a group.
22
+ * An acquired handler record for a group, with the hook that runs before each of them.
18
23
  *
19
24
  * The private field makes this class nominal: a structurally similar object,
20
25
  * including one made by spreading an implementation, is not an implementation.
21
26
  */
22
- export declare class Implementation<G extends Actions, H, EX, RX> {
27
+ export declare class Implementation<G extends Actions, H, EX, RX, RB> {
23
28
  #private;
24
29
  /** The group whose exact handler record was built. */
25
30
  readonly group: G;
26
31
  private constructor();
27
32
  /** Acquire the exact handler record in the adapter layer's scope. */
28
33
  get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
29
- static make<G extends Actions, H, EX, RX>(group: G, build: Effect.Effect<H, EX, RX | Scope.Scope>): Implementation<G, H, EX, RX>;
34
+ /** Runs once per invocation, before the selected handler, on every surface. */
35
+ get before(): Before<RB> | undefined;
36
+ static make<G extends Actions, H, EX, RX, RB>(group: G, build: Effect.Effect<H, EX, RX | Scope.Scope>, before: Before<RB> | undefined): Implementation<G, H, EX, RX, RB>;
30
37
  }
31
38
  /** Any nominal implementation of `G`, with its record and channels erased. */
32
- export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any>;
39
+ export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any, any>;
40
+ /** Pre-handler requirements of one implementation, or of a union of them. */
41
+ export type BeforeContext<App> = App extends Implementation<any, any, any, any, infer RB> ? RB : never;
33
42
  /** Per-request requirements of one implementation, or of a union of them. */
34
- export type RequestContext<App> = App extends Implementation<any, infer H, any, any> ? HandlersContext<H> : never;
43
+ export type RequestContext<App> = App extends Implementation<any, infer H, any, any, infer RB> ? HandlersContext<H> | RB : never;
35
44
  /** Handler-acquisition failures of one implementation, or of a union of them. */
36
- export type BuildError<App> = App extends Implementation<any, any, infer EX, any> ? EX : never;
45
+ export type BuildError<App> = App extends Implementation<any, any, infer EX, any, any> ? EX : never;
37
46
  /** Handler-acquisition requirements of one implementation, or of a union of them. */
38
- export type BuildContext<App> = App extends Implementation<any, any, any, infer RX> ? RX : never;
47
+ export type BuildContext<App> = App extends Implementation<any, any, any, infer RX, any> ? RX : never;
39
48
  //#endregion
@@ -1,7 +1,7 @@
1
1
  import { Effect } from "effect";
2
2
  //#region src/internal/implementation.ts
3
3
  /**
4
- * An acquired handler record for a group.
4
+ * An acquired handler record for a group, with the hook that runs before each of them.
5
5
  *
6
6
  * The private field makes this class nominal: a structurally similar object,
7
7
  * including one made by spreading an implementation, is not an implementation.
@@ -9,28 +9,37 @@ import { Effect } from "effect";
9
9
  var Implementation = class Implementation {
10
10
  group;
11
11
  #build;
12
- constructor(group, build) {
12
+ #before;
13
+ constructor(group, build, before) {
13
14
  this.group = group;
14
15
  this.#build = build;
16
+ this.#before = before;
15
17
  }
16
18
  /** Acquire the exact handler record in the adapter layer's scope. */
17
19
  get build() {
18
20
  return this.#build;
19
21
  }
20
- static make(group, build) {
21
- return new Implementation(group, build);
22
+ /** Runs once per invocation, before the selected handler, on every surface. */
23
+ get before() {
24
+ return this.#before;
25
+ }
26
+ static make(group, build, before) {
27
+ return new Implementation(group, build, before);
22
28
  }
23
29
  };
24
30
  /**
25
31
  * Select and invoke a handler after an adapter has selected a concrete action.
32
+ * The pre-handler hook runs first, inside the action's span, so every surface
33
+ * applies it exactly once and its failures are declared errors of the action.
26
34
  * `R` remains in the returned effect so transport layers cannot erase required
27
35
  * per-request services while assembling routes.
28
36
  */
29
- const dispatch = (group, action, handlers) => {
37
+ const dispatch = (group, action, handlers, before) => {
30
38
  const handle = handlers[action.name];
31
39
  if (handle === void 0) throw new Error(`Missing handler: ${action.name}`);
32
40
  return (input) => {
33
- return Effect.withSpan(Effect.suspend(() => handle(input)), `${group.name}.${action.name}`, { captureStackTrace: false });
41
+ const run = () => before === void 0 ? handle(input) : Effect.flatMap(before(action), () => handle(input));
42
+ return Effect.withSpan(Effect.suspend(run), `${group.name}.${action.name}`, { captureStackTrace: false });
34
43
  };
35
44
  };
36
45
  //#endregion
@@ -60,7 +60,7 @@ const project = (projection, entry) => {
60
60
  return tool.annotate(Tool.Strict, true);
61
61
  };
62
62
  const handler = (projection, app, action, handlers) => {
63
- const run = (input) => dispatch(app.group, action, handlers)(input);
63
+ const run = (input) => dispatch(app.group, action, handlers, app.before)(input);
64
64
  return projection === "native" ? run : (input) => Effect.map(run(input), (value) => ({ value }));
65
65
  };
66
66
  /**
package/docs/Action.md CHANGED
@@ -16,18 +16,22 @@ function make<Name, Input = NoInput, Output, Errors = [], Http = true, Mcp = und
16
16
  options: Options<Input, Output, Errors, Http, Mcp>,
17
17
  ): Action<Name, Input, Output, Errors, Http, Mcp>;
18
18
 
19
+ /** What the action does to its resource. Authorization metadata, not an MCP hint. */
20
+ type Access = "read" | "write";
21
+
19
22
  interface Options<Input, Output, Errors, Http, Mcp> {
20
23
  readonly description: string;
21
24
  readonly input?: Input; // omit for an action without arguments
22
25
  readonly success: Output;
23
26
  readonly errors?: Errors; // ReadonlyArray<Codec>; defaults to []
27
+ readonly access?: Access; // defaults to "write"
24
28
  readonly http?: Http; // false hides the action from HTTP routes and clients
25
29
  readonly mcp?: Mcp; // false hides the action from MCP; otherwise McpOptions
26
30
  }
27
31
 
28
32
  interface McpOptions {
29
33
  readonly name?: string; // tool name; defaults to the action name; ^[A-Za-z0-9_-]{1,128}$
30
- readonly readOnly?: boolean; // readOnlyHint, default false
34
+ readonly readOnly?: boolean; // readOnlyHint, default access === "read"
31
35
  readonly destructive?: boolean; // destructiveHint, default !readOnly
32
36
  readonly idempotent?: boolean; // idempotentHint, default false
33
37
  readonly openWorld?: boolean; // openWorldHint, default true
@@ -39,6 +43,7 @@ interface Action<Name, Input, Output, Errors, Http, Mcp> {
39
43
  readonly input: Input;
40
44
  readonly success: Output;
41
45
  readonly errors: Errors;
46
+ readonly access: Access; // resolved
42
47
  readonly http: Http;
43
48
  readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld }; // resolved
44
49
  }
@@ -63,27 +68,42 @@ export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
63
68
  { httpApiStatus: 404 },
64
69
  ) {}
65
70
 
71
+ // `access: "read"` states the fact once: it is what the group's `before` hook
72
+ // authorizes on, and it is where `mcp.readOnly` comes from.
66
73
  export const GetUser = Action.make("getUser", {
67
74
  description: "Look up a user in your tenant.",
68
75
  input: Schema.Struct({ id: Schema.String }),
69
76
  success: Schema.Struct({ id: Schema.String, name: Schema.String }),
70
77
  errors: [UserNotFound],
71
- mcp: { name: "get_user", readOnly: true },
78
+ access: "read",
79
+ mcp: { name: "get_user" },
80
+ });
81
+
82
+ // A write. `access` defaults to "write", but say it: the default is a fallback,
83
+ // not a statement, and an unclassified action is the one a reviewer must check.
84
+ export const RenameUser = Action.make("renameUser", {
85
+ description: "Rename a user in your tenant.",
86
+ input: Schema.Struct({ id: Schema.String, name: Schema.String }),
87
+ success: Schema.Struct({ id: Schema.String, name: Schema.String }),
88
+ errors: [UserNotFound],
89
+ access: "write",
90
+ mcp: { name: "rename_user", destructive: false },
72
91
  });
73
92
 
74
93
  // No arguments: omit `input`. Identity comes from request context, never from input.
75
94
  export const WhoAmI = Action.make("whoAmI", {
76
95
  description: "Inspect the authenticated actor.",
77
96
  success: Schema.Struct({ id: Schema.String, tenantId: Schema.String }),
78
- mcp: { readOnly: true },
97
+ access: "read",
79
98
  });
80
99
 
81
100
  // MCP-only: no HTTP route, no client method.
82
101
  export const ListChanges = Action.make("listChanges", {
83
102
  description: "List renames in your tenant, oldest first.",
84
103
  success: Schema.Struct({ changes: Schema.Array(Schema.String) }),
104
+ access: "read",
85
105
  http: false,
86
- mcp: { name: "list_changes", readOnly: true },
106
+ mcp: { name: "list_changes" },
87
107
  });
88
108
 
89
109
  // Input decoding is the schema's: "21" on the wire, 21 in the handler.
@@ -91,7 +111,7 @@ export const Double = Action.make("double", {
91
111
  description: "Double a finite number supplied as a string.",
92
112
  input: Schema.Struct({ value: Schema.FiniteFromString }),
93
113
  success: Schema.Finite,
94
- mcp: { readOnly: true },
114
+ access: "read",
95
115
  });
96
116
  ```
97
117
 
@@ -101,13 +121,15 @@ export const Double = Action.make("double", {
101
121
  - Omit `input` for a no-argument action. The default is an empty object schema, which satisfies MCP's object-root requirement. Clients still pass `{ payload: {} }`.
102
122
  - `errors` is a list of schemas, default none. Each keeps its own `httpApiStatus` annotation; an unannotated error is served as HTTP 500. Group-level `errors` are appended to every action of the group (see [ActionGroup.md](ActionGroup.md)).
103
123
  - A handler may fail only with the declared errors. Anything else is a defect.
124
+ - `access` is `"read"` or `"write"` and defaults to `"write"`, so an action nobody classified is treated as the dangerous one. It is authorization metadata for a group's `before` hook (see [ActionGroup.md](ActionGroup.md)); the library itself authorizes nothing and no adapter reads it.
125
+ - `access` is independent of `mcp`. A local-only action (`mcp: false`) still has one, and an action may set `access: "write"` with `mcp: { readOnly: true }` if the tool hint should say something else. Derive authorization from `access`, never from a tool hint.
104
126
  - Both transports are on by default. `http: false` removes the route and the client method. `mcp: false` removes the tool. Both `false` makes a local-only action, reachable through `ActionCli` only.
105
127
  - MCP input must have an object-root JSON Schema. Scalar or array input is fine for HTTP, but `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, and `ActionToolkit.make` throw when called with such an action. Success and error schemas may be any shape.
106
128
  - `mcp.name` is the tool name, at most 128 characters, unique within the group and within any Toolkit or MCP projection that serves the group. Default is the action name.
107
- - Hint defaults: `readOnly: false`, `destructive: !readOnly`, `idempotent: false`, `openWorld: true`. Hints are metadata for the model. They do not enforce authorization, approval, or retries.
129
+ - Hint defaults: `readOnly: access === "read"`, `destructive: !readOnly`, `idempotent: false`, `openWorld: true`. Hints are metadata for the model. They do not enforce authorization, approval, or retries.
108
130
  - Schemas must be service-free. Put service access in the handler.
109
131
  - The group name, not the action, is the OpenAPI tag and operation-ID prefix: `<group>.<action>`.
110
- - 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`.
132
+ - 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.
111
133
 
112
134
  ## Failure modes
113
135
 
@@ -115,3 +137,5 @@ export const Double = Action.make("double", {
115
137
  - Type error `Effect<..., X, ...> is not assignable` in `implement`: the handler fails with an undeclared error `X`. Add it to `errors` or handle it.
116
138
  - `<action>: MCP input must have an object root` thrown by `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, or `ActionToolkit.make`: an MCP-enabled action has non-object input. Wrap it in `Schema.Struct` or set `mcp: false`.
117
139
  - Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
140
+ - A read action is refused by an authorization hook that reads `access`: `access` was omitted, so it defaulted to `"write"`. Declare it.
141
+ - A tool shows `readOnlyHint: false` for a read: the action sets `mcp: { readOnly: false }` explicitly, which wins over `access`.
@@ -20,6 +20,7 @@ interface Entry {
20
20
  readonly group: string;
21
21
  readonly name: string;
22
22
  readonly description: string;
23
+ readonly access: "read" | "write"; // resolved; "write" unless the contract says otherwise
23
24
  readonly http: boolean;
24
25
  readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld };
25
26
  readonly input: JsonSchema.JsonSchema;
@@ -47,6 +48,7 @@ process.stdout.write(JSON.stringify(catalog, null, 2));
47
48
  - Schemas describe encoded action values: the JSON on the wire, not MCP's `{ value }` envelope and not deployment URLs.
48
49
  - Each schema owns its `$defs`. Equal schema identifiers in different entries never replace each other. Recursive references stay local to their schema.
49
50
  - `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`.
51
+ - `access` is the action's resolved authorization metadata, not a permission. A reader may use it to tell reads from writes; it grants nothing and names no scope.
50
52
  - Local-only actions (`http: false`, `mcp: false`) are included. Presence in a catalog is descriptive. It is not authorization, tool publication, or proof that a route is mounted. The host decides what to publish.
51
53
  - The package adds no search, no catalog HTTP endpoint, and no TypeScript code generation.
52
54
 
package/docs/ActionCli.md CHANGED
@@ -9,15 +9,15 @@ nothing is fetched over HTTP.
9
9
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
10
10
 
11
11
  /** One explicitly selected action. */
12
- const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, Parameters = never>(
13
- app: Implementation<G, H, EX, RX>,
12
+ const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, RB, Parameters = never>(
13
+ app: Implementation<G, H, EX, RX, RB>,
14
14
  name: Name,
15
15
  options?: Options<SuccessType, Parameters>,
16
- ) => Command.Command<string, never, {}, EX | SchemaError | DeclaredErrors, Exclude<RX | HandlerContext, Scope>>;
16
+ ) => Command.Command<string, never, {}, EX | SchemaError | DeclaredErrors, Exclude<RX | RB | HandlerContext, Scope>>;
17
17
 
18
18
  /** Every action of the group, including local-only ones, under the group name. */
19
- const group: <G, H, EX, RX>(
20
- app: Implementation<G, H, EX, RX>,
19
+ const group: <G, H, EX, RX, RB>(
20
+ app: Implementation<G, H, EX, RX, RB>,
21
21
  options?: GroupOptions,
22
22
  ) => Command.Command<string, {}, {}, EX | SchemaError | DeclaredErrors, ...>;
23
23
 
@@ -49,6 +49,7 @@ import { Console, Effect, Logger } from "effect";
49
49
  import { Command, Flag } from "effect/unstable/cli";
50
50
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
51
51
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
52
+ import { actors, CurrentActor } from "./auth.js";
52
53
  import { UserApp } from "./handlers.js";
53
54
  import { Users } from "./users.js";
54
55
 
@@ -64,6 +65,9 @@ const all = ActionCli.group(UserApp);
64
65
  Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
65
66
  Effect.tapCause((cause) => Console.error(cause)),
66
67
  Effect.provideService(Logger.LogToStderr, true),
68
+ // The group's `before` hook runs here too, so a local caller supplies the
69
+ // identity it reads exactly as HTTP middleware does for a request.
70
+ Effect.provideService(CurrentActor, actors.alice),
67
71
  Effect.provide(Users.layerMemory),
68
72
  Effect.provide(NodeServices.layer),
69
73
  NodeRuntime.runMain({ disableErrorReporting: true }),
@@ -80,6 +84,7 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
80
84
  - Output is validated and encoded before printing. Default output is JSON. `render(decoded)` gives human output; `--json` selects JSON again. Rendering cannot bypass validation.
81
85
  - Explicit flag names must not collide with the renderer's `--json` flag. A payload field named `json` is ordinary data; only the flag name is reserved.
82
86
  - Each invocation acquires the implementation in a scope and releases it after the call. Domain services and authority come from the host's provided layers. There is no HTTP fallback.
87
+ - The group's pre-handler hook runs here too, before every command's handler. Its services are the caller's to provide, so a local CLI supplies the identity the hook reads exactly as HTTP middleware does. A CLI is not a trusted bypass.
83
88
  - Group commands use the default JSON syntax. For a custom tree, compose individual `command` results with native `Command` combinators.
84
89
 
85
90
  ## Failure modes
@@ -88,4 +93,5 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
88
93
  - Command rejects `--input`: the command was built with `parameters`. Use its flags.
89
94
  - Type error: `name` is not an action of the group. Check `group.actions` names, not MCP tool names.
90
95
  - Handler cannot find a service: provide its Layer to the runtime (`Effect.provide`) before `runMain`. The command does not supply services.
96
+ - A command requires a request-identity tag no handler yields: the group's `before` hook yields it. Provide it, or bind a separate implementation without the hook for local use.
91
97
  - `--json` flag conflict at definition: rename the native flag; the config property can keep its name.
@@ -1,7 +1,8 @@
1
1
  # ActionGroup
2
2
 
3
3
  A named set of actions. The group is what adapters serve, what clients are grouped by, and
4
- what `implement` binds handlers to. Shared errors and the HTTP schema-error policy live here.
4
+ what `implement` binds handlers to. Shared errors, the pre-handler hook, and the HTTP
5
+ schema-error policy live here.
5
6
 
6
7
  ## API
7
8
 
@@ -11,7 +12,7 @@ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
11
12
  function make<Name, Actions, Errors = [], PolicyErrors = []>(
12
13
  options: Options<Name, Errors, PolicyErrors>,
13
14
  ...actions: Actions
14
- ): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
15
+ ): Group<Name, WithErrors<Actions, Errors>, PolicyErrors, Errors>;
15
16
 
16
17
  interface Options<Name, Errors, PolicyErrors> {
17
18
  readonly name: Name; // HttpApiGroup identifier, OpenAPI tag, operation-ID prefix
@@ -24,20 +25,33 @@ interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
24
25
  readonly map: (failure: HttpApiError.HttpApiSchemaError) => Errors[number]["Type"];
25
26
  }
26
27
 
27
- interface Group<Name, Actions, PolicyErrors> {
28
+ interface ImplementOptions<Errors, RB> {
29
+ /** Runs before every handler, on every surface. May fail only with the group's `errors`. */
30
+ readonly before: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
31
+ }
32
+
33
+ interface Group<Name, Actions, PolicyErrors, Errors> {
28
34
  readonly name: Name;
29
35
  readonly actions: Actions;
30
36
  readonly schemaError: SchemaErrorPolicy<PolicyErrors> | undefined;
31
37
  /** Bind every handler at once; build-time services resolve once per adapter layer. */
32
- readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never>(
38
+ readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never, RB = never>(
33
39
  build: H | Effect.Effect<H, EX, RX>,
34
- ) => Implementation<Group<Name, Actions, PolicyErrors>, H, EX, Exclude<RX, Scope.Scope>>;
40
+ options?: ImplementOptions<Errors, RB>,
41
+ ) => Implementation<
42
+ Group<Name, Actions, PolicyErrors, Errors>,
43
+ H,
44
+ EX,
45
+ Exclude<RX, Scope.Scope>,
46
+ RB
47
+ >;
35
48
  }
36
49
 
37
50
  /** Nominal. `group` is the bound contract; `build` acquires the handler record in a scope. */
38
- class Implementation<G, H, EX, RX> {
51
+ class Implementation<G, H, EX, RX, RB> {
39
52
  readonly group: G;
40
53
  get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
54
+ get before(): ((action: Action.Any) => Effect.Effect<void, unknown, RB>) | undefined;
41
55
  }
42
56
  ```
43
57
 
@@ -50,7 +64,8 @@ all required.
50
64
  import { Effect, Schema } from "effect";
51
65
  import type { HttpApiError } from "effect/unstable/httpapi";
52
66
  import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
53
- import { Forbidden, authorize, CurrentActor } from "./auth.js";
67
+ import type * as Action from "@gjermundgaraba/effect-actions/Action";
68
+ import { Forbidden, CurrentActor } from "./auth.js";
54
69
  import { GetUser, RenameUser, WhoAmI } from "./contracts.js";
55
70
  import { Users } from "./users.js";
56
71
 
@@ -82,28 +97,29 @@ export const UserActions = ActionGroup.make(
82
97
  WhoAmI,
83
98
  );
84
99
 
100
+ // One rule for the whole group, read from each contract's own `access`. It runs
101
+ // on HTTP, MCP, Toolkit and CLI alike, so no handler contains authorization code.
102
+ const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
103
+ const permission = action.access === "read" ? "users:read" : "users:write";
104
+ const actor = yield* CurrentActor;
105
+
106
+ if (!actor.permissions.includes(permission)) return yield* new Forbidden({ permission });
107
+ });
108
+
85
109
  // Build-time services (Users) are yielded in the builder, once per adapter layer.
86
- // Request-time services (CurrentActor) are yielded inside handlers.
110
+ // Request-time services (CurrentActor) are yielded inside handlers and in `before`.
87
111
  export const UserApp = UserActions.implement(
88
112
  Effect.gen(function* () {
89
113
  const users = yield* Users;
90
114
 
91
115
  return {
92
- getUser: ({ id }) =>
93
- Effect.gen(function* () {
94
- const actor = yield* authorize("users:read");
95
-
96
- return yield* users.get(actor.tenantId, id);
97
- }),
116
+ getUser: ({ id }) => Effect.flatMap(CurrentActor, (actor) => users.get(actor.tenantId, id)),
98
117
  renameUser: ({ id, name }) =>
99
- Effect.gen(function* () {
100
- const actor = yield* authorize("users:write");
101
-
102
- return yield* users.rename(actor, id, name);
103
- }),
118
+ Effect.flatMap(CurrentActor, (actor) => users.rename(actor, id, name)),
104
119
  whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
105
120
  };
106
121
  }),
122
+ { before: authorize },
107
123
  );
108
124
 
109
125
  // No build-time services: pass the record directly.
@@ -120,6 +136,15 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
120
136
  - `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
121
137
  - `build` exists for direct handler tests under `Effect.scoped`. It bypasses transport validation and is not a substitute for adapter tests. There is no public handler service or Layer.
122
138
 
139
+ ### Pre-handler hook
140
+
141
+ - `implement(handlers, { before })` binds one hook to the whole implementation. It runs once per invocation, immediately before the selected handler, on **every** surface: `ActionHttp`, `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, `ActionToolkit`, and `ActionCli`. There is no surface that skips it and no per-action opt-out.
142
+ - It receives the selected action contract (`Action.Any`), so a policy reads `action.access`, `action.name` or `action.mcp` instead of a hand-maintained list. Write authorization against `access`.
143
+ - It may fail only with the group's own `errors`, because those are the failures every action of the group declares. A hook that fails with anything else is a compile error; a group with no `errors` has no failure a hook could use. Its failure is encoded exactly like a handler's: the error's `httpApiStatus` on HTTP, an `isError` tool result on MCP.
144
+ - Services it yields 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.
145
+ - It runs inside the `<group>.<action>` span, before the handler. When it fails, the handler never runs.
146
+ - It is not authentication. Establish identity in middleware, then let the hook authorize what that identity may do.
147
+
123
148
  ### Schema-error policy
124
149
 
125
150
  - Without a policy, HTTP answers decoding and encoding failures with Effect's native empty 400.
@@ -139,3 +164,6 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
139
164
  - Handler compiles but returns an error not in `errors`: type error on the handler's error channel. Declare it on the action or the group.
140
165
  - `map` is not typed from `errors` when declared as a standalone constant: annotate its parameter as `HttpApiError.HttpApiSchemaError` (type import from `effect/unstable/httpapi`).
141
166
  - A service is resolved once and shared across requests when it should be per request: it was yielded in the builder. Move the `yield*` into the handler.
167
+ - Type error on `before`'s error channel: the hook fails with an error the group does not declare. Add it to the group's `errors` so every action declares it.
168
+ - `Type 'X' is not assignable to type 'never'` on `before` in a group without `errors`: the group declares no shared failure. Declare one.
169
+ - The hook does not run for an action: it cannot happen through an adapter. A handler invoked directly through `build` is not dispatched and is not hooked; use an adapter, or `ActionCli`, in tests that must exercise the policy.
@@ -8,13 +8,17 @@ 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 Http<Groups, Errors = []> {
18
22
  readonly groups: Groups; // the exact contracts bound, in declaration order
19
23
  readonly api: HttpApi.HttpApi<"actions", ...>; // native HttpApi, one HttpApiGroup per group
20
24
  /** Serve implementations. Errors and requirements are unions over exactly these apps. */
@@ -42,10 +46,16 @@ import { HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"
42
46
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
43
47
  import { PublicActions, UserActions } from "./contracts.js";
44
48
  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);
49
+ import { authentication, Unauthenticated } from "./auth.js";
50
+
51
+ // Contract: shared by server and clients. `errors` are the failures the surface
52
+ // itself renders — here the 401 from `authentication` — so a typed client decodes
53
+ // them instead of reporting a decode error on an unexpected status.
54
+ export const Http = ActionHttp.make(
55
+ { apiPath: "/api/actions", errors: [Unauthenticated] },
56
+ PublicActions,
57
+ UserActions,
58
+ );
49
59
 
50
60
  // One layer per middleware set. Middleware provided to a layer applies to that layer only.
51
61
  const routes = Layer.mergeAll(
@@ -85,12 +95,15 @@ export const greeting = Effect.gen(function* () {
85
95
  ## Rules
86
96
 
87
97
  - `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.
98
+ - `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.
99
+ - No two schemas reachable from one endpoint may share an `httpApiStatus`: the client selects the decoder by status. Keep surface statuses (401, 403, 429, 503) apart from action statuses.
100
+ - `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.
88
101
  - `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
102
  - Middleware is per layer call. Groups that need different middleware go in separate `Http.layer` calls, merged with `Layer.mergeAll`.
90
103
  - 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
104
  - `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
105
  - 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.
106
+ - 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
107
  - 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
108
  - 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
109
  - Each handler runs in a span named `<group>.<action>`, a child of the request span. Decoding and encoding are outside it.
@@ -104,3 +117,5 @@ export const greeting = Effect.gen(function* () {
104
117
  - Client method missing for an action: the action is `http: false`.
105
118
  - 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
119
  - 415: wrong or missing content type.
120
+ - `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`.
121
+ - A surface error decodes as the wrong type: two schemas share an `httpApiStatus` on the same endpoint. Give them distinct statuses.
package/docs/ActionMcp.md CHANGED
@@ -122,6 +122,8 @@ Layer.launch(layer).pipe(
122
122
  - 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
123
  - Each endpoint or subprocess owns a fresh native tool registry. That isolates tool names, not application context.
124
124
 
125
+ - The group's pre-handler hook runs before every tool call. A refusal is the tool's declared failure: an `isError` result whose text is the error's JSON encoding, exactly as for a handler failure.
126
+
125
127
  ## Failure modes
126
128
 
127
129
  - `<action>: MCP input must have an object root; omit input for no arguments` thrown at the `layerHttp` or `layerStdio` call: the action has scalar or array input. Wrap it in a struct or set `mcp: false`.
@@ -64,6 +64,8 @@ With a model: pass `binding.toolkit` as `toolkit` to `LanguageModel.generateText
64
64
  - Supply identity at invocation, never when building the layer. Native context capture is not a security boundary.
65
65
  - This is not an MCP server. Use `ActionMcp` to expose the same actions to external clients.
66
66
 
67
+ - The group's pre-handler hook runs before every tool call, and its services join each tool's requirements. The caller supplies them, as it does a handler's.
68
+
67
69
  ## Failure modes
68
70
 
69
71
  - Tool missing from the toolkit: the action has `mcp: false`. The Toolkit selects by MCP enablement.
@@ -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 the group's `before` hook, which runs on every surface 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,12 +5,14 @@ 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"` on an action, defaulting to `"write"`. 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 passed to `implement`. Runs once per invocation, on every surface, with the selected action contract, before its handler. May fail only with the group'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.
@@ -18,6 +20,7 @@ use these words with these meanings.
18
20
  - **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
21
  - **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
22
  - **Policy error**: an error a schema-error policy may answer with. Part of the transport contract, never returnable by a handler.
23
+ - **Surface error**: an error declared on an `ActionHttp` binding rather than on an action. Produced by middleware around the endpoints, never by a handler; declared so typed clients decode it.
21
24
  - **Local-only action**: `http: false` and `mcp: false`. Reachable through `ActionCli` and listed in the catalog.
22
25
  - **Tool**: the MCP or Toolkit projection of an MCP-enabled action, named by `mcp.name` and carrying its hints.
23
26
  - **Endpoint**: one `ActionMcp.layerHttp` mount. One route, one middleware set, one tool registry.
@@ -31,6 +34,6 @@ use these words with these meanings.
31
34
  - `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
35
  - 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
36
  - 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.
37
+ - Authentication is router middleware plus discovery metadata. Verification, login, and consent stay in the application. Authorization is the application's pre-handler hook, which the library runs but never writes.
35
38
  - `Testing` depends only on `effect`. `TestingClient` is the single module that loads the optional MCP client peer.
36
39
  - 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, run one check before every handler, bind them |
22
+ | [ActionHttp.md](ActionHttp.md) | serve JSON POST routes, declare the surface's own errors, 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);
@@ -5,10 +5,12 @@ 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
+ - A group's pre-handler hook (`implement(handlers, { before })`) runs once per invocation on every surface, before the selected handler, inside that handler's span. It may fail only with the group's own errors, and its services are request-time requirements like a handler's. No adapter can skip it; only calling `build` directly does, and that is not dispatch.
8
9
  - 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
10
  - 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
11
  - 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.
12
+ - 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.
13
+ - `access` is contract metadata. Nothing in the library reads it: adapters ignore it and the catalog only reports it. An action defaults to `access: "write"` so a hook that fails closed does so without a list of exceptions.
12
14
  - `ActionCli` acquires the implementation per invocation and releases it after the call. `ActionToolkit.layer` acquires it once for the layer's lifetime.
13
15
 
14
16
  ## Wire behavior
@@ -20,6 +22,7 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
20
22
  | 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
23
  | Success | the success codec's encoding as the body: `{"id":"1","name":"Ada"}`, `42` | `structuredContent: { value: <encoded> }` |
22
24
  | 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` |
25
+ | Pre-handler hook failure | the same, using the hook's error schema; the handler never runs | the same `isError` result; the handler never runs |
23
26
  | Invalid output encoding | empty **400** (`HttpApiSchemaError`) | `isError: true`, text `Tool execution failed due to an internal server error.`; cause logged, not sent |
24
27
  | Defect | empty **500** | same generic `isError` result; cause logged, not sent |
25
28
  | Unknown path or method | 404 | n/a |
@@ -27,7 +30,8 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
27
30
 
28
31
  - 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).
29
32
  - 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`.
33
+ - `ActionHttp.make`'s `errors` are declared on every endpoint so clients decode the surface's own failures (401, 403, 429, 503). They are HTTP-only, produced by middleware rather than by the library, and never returnable by a handler. MCP is unaffected.
34
+ - 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
35
 
32
36
  ## Namespaces
33
37
 
@@ -51,5 +55,5 @@ Defaults without a schema-error policy. HTTP is a native `HttpApi`; MCP is a nat
51
55
 
52
56
  - HTTP is JSON `POST` on `HttpApi`, not Effect RPC. MCP is native `McpServer`, no SDK runtime dependency.
53
57
  - 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.
58
+ - Authentication and authorization are the application's. The library supplies the seams: `Authentication.middleware` for identity, `implement({ before })` for one authorization rule per group, `access` for what a rule reads, and `ActionHttp.make`'s `errors` for what a client 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
59
  - 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, `implement({ before })` for one authorization rule per group, `access` for what that rule reads, `ActionHttp.make`'s `errors` for what a client 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.3.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": {