@gjermundgaraba/effect-actions 0.9.0 → 0.11.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.
Files changed (70) hide show
  1. package/README.md +56 -50
  2. package/dist/Action.d.mts +286 -79
  3. package/dist/Action.mjs +125 -23
  4. package/dist/ActionCli.d.mts +102 -31
  5. package/dist/ActionCli.mjs +114 -16
  6. package/dist/ActionHttp.d.mts +170 -51
  7. package/dist/ActionHttp.mjs +130 -79
  8. package/dist/ActionMcp.d.mts +71 -21
  9. package/dist/ActionMcp.mjs +231 -80
  10. package/dist/ActionToolkit.d.mts +56 -27
  11. package/dist/ActionToolkit.mjs +16 -5
  12. package/dist/Authentication.d.mts +129 -33
  13. package/dist/Authentication.mjs +231 -38
  14. package/dist/Testing.d.mts +84 -57
  15. package/dist/Testing.mjs +121 -79
  16. package/dist/internal/actions.d.mts +5 -25
  17. package/dist/internal/actions.mjs +75 -27
  18. package/dist/internal/authentication.d.mts +102 -0
  19. package/dist/internal/authentication.mjs +11 -0
  20. package/dist/internal/call.d.mts +12 -0
  21. package/dist/internal/call.mjs +12 -0
  22. package/dist/internal/cli.d.mts +36 -22
  23. package/dist/internal/cli.mjs +311 -33
  24. package/dist/internal/client.d.mts +43 -0
  25. package/dist/internal/client.mjs +25 -0
  26. package/dist/internal/console.mjs +77 -0
  27. package/dist/internal/declared.d.mts +13 -0
  28. package/dist/internal/declared.mjs +34 -0
  29. package/dist/internal/errors.d.mts +77 -0
  30. package/dist/internal/errors.mjs +79 -0
  31. package/dist/internal/implementation.d.mts +171 -41
  32. package/dist/internal/implementation.mjs +147 -29
  33. package/dist/internal/mcp.d.mts +14 -0
  34. package/dist/internal/mcp.mjs +182 -0
  35. package/dist/internal/media.d.mts +8 -0
  36. package/dist/internal/media.mjs +26 -0
  37. package/dist/internal/memory.d.mts +6 -0
  38. package/dist/internal/memory.mjs +38 -0
  39. package/dist/internal/refusal.mjs +67 -0
  40. package/dist/internal/schema-errors.mjs +20 -0
  41. package/dist/internal/tools.mjs +27 -57
  42. package/docs/Action.md +403 -73
  43. package/docs/ActionCli.md +281 -51
  44. package/docs/ActionHttp.md +267 -82
  45. package/docs/ActionMcp.md +232 -102
  46. package/docs/ActionToolkit.md +99 -35
  47. package/docs/Authentication.md +369 -62
  48. package/docs/CONTEXT.md +24 -36
  49. package/docs/README.md +29 -73
  50. package/docs/Testing.md +154 -77
  51. package/docs/guarantees.md +82 -43
  52. package/docs/setup.md +95 -27
  53. package/package.json +12 -20
  54. package/dist/ActionCatalog.d.mts +0 -30
  55. package/dist/ActionCatalog.mjs +0 -39
  56. package/dist/ActionCliClient.d.mts +0 -48
  57. package/dist/ActionCliClient.mjs +0 -43
  58. package/dist/ActionGroup.d.mts +0 -44
  59. package/dist/ActionGroup.mjs +0 -35
  60. package/dist/ActionHttpClient.d.mts +0 -33
  61. package/dist/ActionHttpClient.mjs +0 -16
  62. package/dist/TestingClient.d.mts +0 -17
  63. package/dist/TestingClient.mjs +0 -22
  64. package/dist/internal/fetchClient.d.mts +0 -6
  65. package/dist/internal/fetchClient.mjs +0 -8
  66. package/dist/internal/tools.d.mts +0 -22
  67. package/docs/ActionCatalog.md +0 -54
  68. package/docs/ActionCliClient.md +0 -63
  69. package/docs/ActionGroup.md +0 -128
  70. package/docs/ActionHttpClient.md +0 -80
@@ -1,46 +1,142 @@
1
- import { Context, Effect } from "effect";
1
+ import { Refusal } from "./internal/errors.mjs";
2
+ import { Known, OptionalUnless } from "./internal/implementation.mjs";
3
+ import { Errors } from "./Action.mjs";
4
+ import { Any, Descriptor, Provider, Security } from "./internal/authentication.mjs";
5
+ import { ErrorsOf } from "./internal/declared.mjs";
6
+ import { Array, Context, Effect, Layer, Option, Redacted, Scope } from "effect";
2
7
  import { HttpRouter, HttpServerResponse } from "effect/http";
3
- import { NonEmptyReadonlyArray } from "effect/Array";
8
+ import { HttpApiSecurity } from "effect/http-api";
4
9
  //#region src/Authentication.d.ts
5
10
  /**
6
- * Authenticate each request and provide its identity to the downstream handler.
7
- * `authenticate` fails with the response to send instead, so the host owns its
8
- * status, body and challenge headers. Dependencies remain native router request
9
- * requirements. Acquired resources live until the request scope closes, including
10
- * while the handler is running. Every response is marked `Cache-Control: no-store`,
11
- * including private failures serialized by enclosing middleware.
11
+ * The bearer token of an `Authorization` header, `authorization`, or none, read as Effect's
12
+ * `HttpApiSecurity.bearer` reads it for routes and MCP, which a test holds equal. It is for a
13
+ * caller the router never routes, which holds the header and no request; a route of the host's
14
+ * own is authenticated by `protect`. The scheme is matched case-insensitively, as RFC 9110
15
+ * requires, and the token is `Redacted`, as Effect's own decoder gives it.
12
16
  */
13
- export declare const middleware: <I, A, R>(service: Context.Key<I, A>, authenticate: Effect.Effect<NoInfer<A>, HttpServerResponse.HttpServerResponse, R>) => HttpRouter.Middleware<{
14
- provides: I;
15
- handles: never;
16
- error: never;
17
- requires: Exclude<R, HttpRouter.Provided> | Exclude<Exclude<import("effect/Types").NoInfer<I>, I>, HttpRouter.Provided>;
18
- layerError: never;
19
- layerRequires: never;
20
- }>;
21
- /** RFC 9728 metadata to publish; the host is responsible for these being valid OAuth URLs. */
22
- export interface ProtectedResourceOptions {
17
+ export declare const bearerTokenOf: (authorization: string | null | undefined) => Option.Option<Redacted.Redacted<string>>;
18
+ /** An OAuth protected resource (RFC 9728), as `layer` publishes it. */
19
+ export interface ProtectedResource {
23
20
  /** Exact OAuth resource identifier; its path and query select the discovery path. */
24
21
  readonly resource: string;
25
- readonly authorizationServers: NonEmptyReadonlyArray<string>;
22
+ /** Where clients get tokens: nonempty. */
23
+ readonly authorizationServers: Array.NonEmptyReadonlyArray<string>;
24
+ /**
25
+ * Every scope the resource accepts, each an OAuth scope token, which a client requests when a
26
+ * 401 names none.
27
+ */
26
28
  readonly scopesSupported?: ReadonlyArray<string>;
29
+ /**
30
+ * The scopes every 401 names, each an OAuth scope token: what a client requests when it
31
+ * authenticates, rather than every scope supported. A `Forbidden` naming scopes asks for
32
+ * more when a call needs them.
33
+ */
34
+ readonly scopesRequired?: Array.NonEmptyReadonlyArray<string>;
27
35
  readonly resourceName?: string;
28
36
  }
29
- /** Parameters of one `WWW-Authenticate: Bearer` challenge, RFC 6750 §3. */
30
- export interface BearerChallengeOptions {
31
- readonly error?: "invalid_token" | "insufficient_scope";
32
- readonly errorDescription?: string;
33
- /** Space-separated scopes needed for this request, not all supported scopes. */
34
- readonly scope?: string;
37
+ /** What `refusalResponse` answers about. */
38
+ export interface RefusalResponseOptions<D extends Any = Any> {
39
+ /**
40
+ * The descriptor refusing, whose scheme decides the challenge: Bearer's, below, when left
41
+ * out; another scheme's 401 names that scheme, and nothing steps up under it. An error it
42
+ * declares is sent as its endpoints declare it.
43
+ */
44
+ readonly authentication?: D | undefined;
45
+ /** The OAuth protected resource refusing: the one given to `layer`. */
46
+ readonly protectedResource?: ProtectedResource | undefined;
47
+ /**
48
+ * The request's `Authorization` header, as the web `Headers.get` gives it, which decides whether a
49
+ * 401 names `invalid_token`.
50
+ */
51
+ readonly authorization?: string | null | undefined;
52
+ }
53
+ /** What the descriptor `D` declares its verifier fails with besides a refusal. */
54
+ type DeclaredOf<D> = D extends {
55
+ readonly error: infer E extends Errors;
56
+ } ? E[number]["Type"] : never;
57
+ /**
58
+ * The response `layer` answers a refusal with, for a caller the router never routes, such as
59
+ * a Node `upgrade` handler admitting a socket: its status, its JSON, `Cache-Control: no-store`
60
+ * and its challenge, an `Unauthenticated`'s `Bearer`, naming `invalid_token` when
61
+ * `authorization` presented a bearer token, or the `insufficient_scope` of a `Forbidden`
62
+ * naming scopes. Given the protected resource `layer` was, every challenge names its metadata
63
+ * URL and a 401's its `scopesRequired`. Under a descriptor of another scheme, it is the JSON
64
+ * and status alone, a 401 naming that scheme. An error `authentication` declares is sent as
65
+ * its endpoints declare it, its JSON with its status. `HttpServerResponse.toWeb` gives it as a
66
+ * web `Response`.
67
+ */
68
+ export declare const refusalResponse: <D extends Any = never>(error: Refusal | DeclaredOf<D>, options?: RefusalResponseOptions<D>) => HttpServerResponse.HttpServerResponse;
69
+ /** What `make` takes besides the name and the identity. */
70
+ export interface Options {
71
+ /** The one native scheme a caller proves the identity by: Bearer when left out. */
72
+ readonly security?: Security;
73
+ /**
74
+ * What the verifier may fail with besides a refusal, such as its provider being unreachable:
75
+ * one schema, or a list. Every protected endpoint declares it, so clients decode it.
76
+ */
77
+ readonly error?: Errors | Errors[number];
78
+ }
79
+ /** The scheme options `O` give: Bearer too wherever they may leave it out, as at run time. */
80
+ type SecurityOf<O> = O extends {
81
+ readonly security: infer S extends Security;
82
+ } ? S : "security" extends keyof O ? NonNullable<O["security" & keyof O]> | HttpApiSecurity.Http : HttpApiSecurity.Http;
83
+ /**
84
+ * Browser-safe declaration shared by clients and remote surfaces: the `identity` it
85
+ * authenticates, by the one native scheme `security`, Bearer unless it names another, such
86
+ * as `HttpApiSecurity.apiKey({ in: "cookie", key: "session" })`. The literal name
87
+ * identifies the provider, like a native Context.Key name, and the scheme's OpenAPI key, so it
88
+ * holds only letters, digits, `_`, `.` and `-`; reuse one name only for one declaration.
89
+ */
90
+ export declare const make: <const Name extends string, I, A, const O extends Options = {}>(name: Name, identity: Context.Key<I, A>, ...options: OptionalUnless<O, O & NoInfer<Known<O, Options>>>) => Descriptor<I, A, SecurityOf<O>, Name, ErrorsOf<O>>;
91
+ /**
92
+ * A descriptor's verifier: the credential its scheme decodes, as Effect's own decoder gives
93
+ * it, to the identity, or a refusal, or an error the descriptor declares, `E`. Its services
94
+ * belong to the request.
95
+ */
96
+ export type Verify<A, S extends Security, R, E = never> = (credential: HttpApiSecurity.HttpApiSecurity.Type<S>) => Effect.Effect<A, Refusal | E, R>;
97
+ export interface LayerOptions<EP = never, RP = never> {
98
+ readonly protectedResource?: ProtectedResource | Effect.Effect<ProtectedResource | undefined, EP, RP>;
35
99
  }
36
100
  /**
37
- * Publish RFC 9728 discovery independently from the authenticated routes.
38
- * This supplies metadata and challenges; the host still verifies access tokens.
101
+ * Only a Bearer scheme publishes an OAuth protected resource. The types tell only an `Http`
102
+ * scheme from the others, as its scheme is a `string`: `layer` refuses another `Http` one.
39
103
  */
40
- export declare const protectedResource: (options: ProtectedResourceOptions) => {
41
- layer: import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter>;
42
- metadataUrl: string;
43
- /** The `WWW-Authenticate` value; parameter values are quoted and escaped. */
44
- challenge: (challenge?: BearerChallengeOptions) => string;
104
+ type ResourceOf<S> = [S] extends [HttpApiSecurity.Http] ? unknown : {
105
+ readonly protectedResource?: undefined;
45
106
  };
46
- //#endregion
107
+ /**
108
+ * The provider of `authentication`: `verify`, or an Effect building it once per layer graph, run on
109
+ * each remote request a protected action receives, with the credential the descriptor's
110
+ * scheme decodes. A Bearer scheme answers each 401 with its challenge, and may publish the
111
+ * OAuth protected resource `protectedResource`.
112
+ */
113
+ export declare function layer<const Name extends string, I, A, const S extends Security, E extends Errors, R, EX = never, RX = never, EP = never, RP = never>(authentication: Descriptor<I, A, S, Name, E>, verify: Verify<NoInfer<A>, NoInfer<S>, R, NoInfer<E[number]["Type"]>> | Effect.Effect<Verify<NoInfer<A>, NoInfer<S>, R, NoInfer<E[number]["Type"]>>, EX, RX>, options?: LayerOptions<EP, RP> & ResourceOf<S>): Layer.Layer<Provider<I, Name>, EX | EP, HttpRouter.HttpRouter | Exclude<RX | RP, Scope.Scope> | HttpRouter.Request.From<"Requires", Exclude<R, HttpRouter.Provided>>>;
114
+ /** The identity `I`, or none where it is erased to `unknown`. */
115
+ type Provides<I> = unknown extends I ? never : I;
116
+ /**
117
+ * What `protect` returns for a descriptor named `Name` authenticating the identity `I`: router
118
+ * middleware providing it, answering a refusal its routes fail with, and requiring the
119
+ * descriptor's provider. Named so a package emitting declarations can export one.
120
+ */
121
+ export type Protection<I, Name extends string> = HttpRouter.Middleware<{
122
+ provides: Provides<I>;
123
+ handles: Refusal;
124
+ error: never;
125
+ requires: never;
126
+ layerError: never;
127
+ layerRequires: Provider<I, Name>;
128
+ }>;
129
+ /**
130
+ * Native router middleware authenticating a route of the host's own, such as an export, a
131
+ * page frame or a WebSocket upgrade, with `authentication`'s provider, as its actions' routes
132
+ * are: the credential its scheme decodes, verified by the same verifier, and the identity
133
+ * provided to the route. It answers as an action route: a missing or invalid credential with
134
+ * the refusal, every response to the request it authenticates `no-store` unless it states its
135
+ * own caching, and a refusal the route fails with, such as a scope check's `Forbidden`, as its
136
+ * status, JSON and challenge, stepping up under Bearer. The layer requires the provider,
137
+ * `layer(authentication, ...)`. An erased descriptor, `Any`, provides no service the types can
138
+ * name: its identity is `unknown`, which would discharge every request service the route owes.
139
+ */
140
+ export declare const protect: <I, A, S extends Security, Name extends string, E extends Errors>(authentication: Descriptor<I, A, S, Name, E>) => Protection<I, Name>;
141
+ //#endregion
142
+ export type { Any, Descriptor, Provider };
@@ -1,55 +1,248 @@
1
- import { Effect } from "effect";
1
+ import { Unauthenticated, scopeToken } from "./internal/errors.mjs";
2
+ import { assertOwnTags, errorList } from "./internal/actions.mjs";
3
+ import { declaredResponse } from "./internal/declared.mjs";
4
+ import { answer, answerStepUp, bearer, challenge, isRefusal, isStepUp, plain } from "./internal/refusal.mjs";
5
+ import { Context, Effect, Layer, Match, Option, Predicate, Redacted } from "effect";
2
6
  import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
7
+ import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/http-api";
3
8
  //#region src/Authentication.ts
4
9
  /**
5
- * Authenticate each request and provide its identity to the downstream handler.
6
- * `authenticate` fails with the response to send instead, so the host owns its
7
- * status, body and challenge headers. Dependencies remain native router request
8
- * requirements. Acquired resources live until the request scope closes, including
9
- * while the handler is running. Every response is marked `Cache-Control: no-store`,
10
- * including private failures serialized by enclosing middleware.
10
+ * An `Authorization` header of the `Bearer` scheme, and its token, as Effect's own
11
+ * `HttpApiSecurity.bearer` decodes a request's: the scheme, matched case-insensitively, one or
12
+ * more spaces, then the rest of the header, whatever it holds, a tab included. The whitespace
13
+ * around a header value is no part of it, and an HTTP parser strips it before any route reads it.
11
14
  */
12
- const middleware = (service, authenticate) => HttpRouter.middleware()((httpEffect) => authenticate.pipe(Effect.matchEffect({
13
- onFailure: Effect.succeed,
14
- onSuccess: (identity) => Effect.provideService(httpEffect, service, identity)
15
- }), HttpEffect.withPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")))));
16
- const quoted = (value) => `"${value.replace(/["\\]/g, "\\$&")}"`;
15
+ const bearerScheme = /^[ \t]*Bearer +(.*[^ \t])[ \t]*$/i;
17
16
  /**
18
- * Publish RFC 9728 discovery independently from the authenticated routes.
19
- * This supplies metadata and challenges; the host still verifies access tokens.
17
+ * The bearer token of an `Authorization` header, `authorization`, or none, read as Effect's
18
+ * `HttpApiSecurity.bearer` reads it for routes and MCP, which a test holds equal. It is for a
19
+ * caller the router never routes, which holds the header and no request; a route of the host's
20
+ * own is authenticated by `protect`. The scheme is matched case-insensitively, as RFC 9110
21
+ * requires, and the token is `Redacted`, as Effect's own decoder gives it.
20
22
  */
21
- const protectedResource = (options) => {
23
+ const bearerTokenOf = (authorization) => Option.map(Option.fromNullishOr(bearerScheme.exec(authorization ?? "")?.[1]), (token) => Redacted.make(token));
24
+ /**
25
+ * The RFC 9728 metadata URL of `options`: `/.well-known/oauth-protected-resource` followed
26
+ * by the resource's path, where MCP clients look when a 401 names no metadata URL.
27
+ */
28
+ const metadataUrl = (options) => {
22
29
  const resource = new URL(options.resource);
23
- const path = `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`;
24
- const discoveryUrl = new URL(resource);
25
- discoveryUrl.pathname = path;
26
- const metadataUrl = discoveryUrl.href;
27
- const target = metadataUrl.slice(discoveryUrl.origin.length);
28
- const response = HttpServerResponse.jsonUnsafe({
30
+ const url = new URL(resource);
31
+ url.pathname = `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`;
32
+ return url;
33
+ };
34
+ /** What challenges about `options` name. */
35
+ const namedOf = (options) => ({
36
+ scope: options?.scopesRequired?.join(" "),
37
+ metadataUrl: options === void 0 ? void 0 : metadataUrl(options).href
38
+ });
39
+ /** Whether a request whose `Authorization` header is `authorization` presented a bearer token. */
40
+ const tokenPresented = (authorization) => Option.isSome(bearerTokenOf(authorization));
41
+ /**
42
+ * The challenge of a 401: `Bearer`, naming the scopes a client requests and the metadata URL
43
+ * where it finds its authorization server. A request that presented a bearer token is told
44
+ * it is invalid, RFC 6750's `invalid_token`, on which a client may refresh its token before
45
+ * it signs in again; one that presented none names no error code.
46
+ */
47
+ const challengeOf = (named, presented) => bearer([
48
+ ["error", presented ? "invalid_token" : void 0],
49
+ ["scope", named.scope],
50
+ ["resource_metadata", named.metadataUrl]
51
+ ]);
52
+ /**
53
+ * Refuse a protected resource that cannot be published, as `layer` and `refusalResponse` both
54
+ * do: one under a scheme other than Bearer (`oauth` false), a `scopesSupported` or
55
+ * `scopesRequired` that is no list of OAuth scope tokens, as `Forbidden` refuses, and a
56
+ * resource with a fragment, which no request URL carries, so its discovery would never answer
57
+ * (RFC 9728). A resource an Effect builds is checked for its scheme alone, and for the rest
58
+ * once built.
59
+ */
60
+ const assertResource = (oauth, options) => {
61
+ if (options === void 0) return;
62
+ if (!oauth) throw new Error("A protected resource is published only for a Bearer scheme");
63
+ if (Effect.isEffect(options)) return;
64
+ for (const key of ["scopesSupported", "scopesRequired"]) {
65
+ const invalid = options[key]?.find((scope) => !scopeToken.test(scope));
66
+ if (invalid !== void 0) throw new Error(`Invalid scope in ${key}: "${invalid}"`);
67
+ }
68
+ if (new URL(options.resource).href.includes("#")) throw new Error(`A protected resource has no fragment: ${options.resource}`);
69
+ };
70
+ /**
71
+ * `response` as the routes of the authentication answer: `no-store` unless it states its
72
+ * own caching, and a 401 challenged with `challenge` unless it names its own.
73
+ */
74
+ const settle = (response, challenge) => HttpServerResponse.setHeaders(response, {
75
+ ...response.headers["cache-control"] === void 0 ? { "cache-control": "no-store" } : {},
76
+ ...challenge !== void 0 && response.status === 401 && response.headers["www-authenticate"] === void 0 ? { "www-authenticate": challenge } : {}
77
+ });
78
+ /**
79
+ * The response `layer` answers a refusal with, for a caller the router never routes, such as
80
+ * a Node `upgrade` handler admitting a socket: its status, its JSON, `Cache-Control: no-store`
81
+ * and its challenge, an `Unauthenticated`'s `Bearer`, naming `invalid_token` when
82
+ * `authorization` presented a bearer token, or the `insufficient_scope` of a `Forbidden`
83
+ * naming scopes. Given the protected resource `layer` was, every challenge names its metadata
84
+ * URL and a 401's its `scopesRequired`. Under a descriptor of another scheme, it is the JSON
85
+ * and status alone, a 401 naming that scheme. An error `authentication` declares is sent as
86
+ * its endpoints declare it, its JSON with its status. `HttpServerResponse.toWeb` gives it as a
87
+ * web `Response`.
88
+ */
89
+ const refusalResponse = (error, options) => {
90
+ const authentication = options?.authentication;
91
+ const oauth = authentication === void 0 || isBearer(authentication.security);
92
+ assertResource(oauth, options?.protectedResource);
93
+ const named = namedOf(options?.protectedResource);
94
+ const challenge = authentication !== void 0 && !oauth ? schemeChallenge(authentication.security, authentication.name) : challengeOf(named, tokenPresented(options?.authorization));
95
+ if (!isRefusal(error)) {
96
+ const response = declaredResponse(authentication?.error ?? [], error);
97
+ if (response === void 0) throw new Error("Not a refusal, nor an error the authentication declares");
98
+ return settle(response, challenge);
99
+ }
100
+ return settle(oauth ? answer(error, named.metadataUrl) : plain(error), challenge);
101
+ };
102
+ /**
103
+ * Whether a request URL may resolve to a discovery path, which every other request skips
104
+ * parsing: the URL parser copies `.well-known` as it is, removing only a tab or a newline. Node
105
+ * refuses a target holding those before routing; a platform passing one through still parses.
106
+ */
107
+ const mayDiscover = /\.well-known|[\t\n\r]/;
108
+ /**
109
+ * RFC 9728 discovery of `options` at its metadata URL, as global router middleware: it
110
+ * answers before routing, so no route middleware, authentication included, ever covers it.
111
+ * The metadata is public to every origin, as a browser client needs it after a 401: it
112
+ * carries `Access-Control-Allow-Origin: *`, and discovery answers its own CORS preflight.
113
+ * Where the host's CORS middleware runs before it, that policy answers the preflight and
114
+ * adds its headers to reads, which keep the `*` where it sets no origin.
115
+ */
116
+ const discovery = (options) => {
117
+ const discoveryUrl = metadataUrl(options);
118
+ const target = discoveryUrl.href.slice(discoveryUrl.origin.length);
119
+ const metadata = HttpServerResponse.jsonUnsafe({
29
120
  resource: options.resource,
30
121
  authorization_servers: options.authorizationServers,
31
122
  bearer_methods_supported: ["header"],
32
123
  scopes_supported: options.scopesSupported,
33
124
  resource_name: options.resourceName
125
+ }, { headers: { "access-control-allow-origin": "*" } });
126
+ /** The preflight of a cross-origin read, allowing the headers it asks for. */
127
+ const preflight = (request) => {
128
+ const requested = request.headers["access-control-request-headers"];
129
+ return HttpServerResponse.empty({
130
+ status: 204,
131
+ headers: {
132
+ "access-control-allow-origin": "*",
133
+ "access-control-allow-methods": "GET, HEAD, OPTIONS",
134
+ ...requested === void 0 ? {} : {
135
+ "access-control-allow-headers": requested,
136
+ vary: "Access-Control-Request-Headers"
137
+ }
138
+ }
139
+ });
140
+ };
141
+ return (next) => Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => {
142
+ const targeted = mayDiscover.test(request.url) && URL.canParse(request.url, discoveryUrl.origin) && new URL(request.url, discoveryUrl.origin).href.slice(discoveryUrl.origin.length) === target;
143
+ if (targeted && (request.method === "GET" || request.method === "HEAD")) return Effect.succeed(metadata);
144
+ if (targeted && request.method === "OPTIONS") return Effect.succeed(preflight(request));
145
+ return next;
34
146
  });
147
+ };
148
+ /**
149
+ * What `layer` answers about the protected resource `options`, written once: its metadata
150
+ * URL, each 401's challenge, to a request that presented a bearer token and to one that did
151
+ * not, and its discovery, none without a resource.
152
+ */
153
+ const answersOf = (options) => {
154
+ const named = namedOf(options);
35
155
  return {
36
- layer: HttpRouter.middleware((next) => Effect.gen(function* () {
37
- const request = yield* HttpServerRequest.HttpServerRequest;
38
- const url = new URL(request.url, resource.origin);
39
- if ((request.method === "GET" || request.method === "HEAD") && url.href.slice(url.origin.length) === target) return response;
40
- return yield* next;
41
- }), { global: true }),
42
- metadataUrl,
43
- /** The `WWW-Authenticate` value; parameter values are quoted and escaped. */
44
- challenge: (challenge = {}) => {
45
- return `Bearer ${[
46
- ["resource_metadata", metadataUrl],
47
- ["error", challenge.error],
48
- ["error_description", challenge.errorDescription],
49
- ["scope", challenge.scope]
50
- ].flatMap(([name, value]) => value === void 0 ? [] : [`${name}=${quoted(value)}`]).join(", ")}`;
51
- }
156
+ metadataUrl: named.metadataUrl,
157
+ invalid: challengeOf(named, true),
158
+ anonymous: challengeOf(named, false),
159
+ published: options === void 0 ? void 0 : discovery(options)
52
160
  };
53
161
  };
162
+ /** Whether `security` is the Bearer scheme, the one OAuth challenges and discovery name. */
163
+ const isBearer = (security) => Predicate.isTagged(security, "Http") && security.scheme.toLowerCase() === "bearer";
164
+ /** What an OpenAPI component key may hold, and so a descriptor's name, which is its key. */
165
+ const componentKey = /^[\w.-]+$/;
166
+ /**
167
+ * The challenge of a 401 under a scheme other than Bearer: the scheme an `Http` one names,
168
+ * Basic with its realm, the descriptor's name; an API key has no scheme to name.
169
+ */
170
+ const schemeChallenge = (security, name) => Match.value(security).pipe(Match.tag("Http", ({ scheme }) => challenge(scheme, [])), Match.tag("Basic", () => challenge("Basic", [["realm", name]])), Match.tag("ApiKey", () => void 0), Match.exhaustive);
171
+ /**
172
+ * Browser-safe declaration shared by clients and remote surfaces: the `identity` it
173
+ * authenticates, by the one native scheme `security`, Bearer unless it names another, such
174
+ * as `HttpApiSecurity.apiKey({ in: "cookie", key: "session" })`. The literal name
175
+ * identifies the provider, like a native Context.Key name, and the scheme's OpenAPI key, so it
176
+ * holds only letters, digits, `_`, `.` and `-`; reuse one name only for one declaration.
177
+ */
178
+ const make = (name, identity, ...options) => {
179
+ const security = options[0]?.security ?? HttpApiSecurity.bearer;
180
+ const error = errorList(options[0]?.error);
181
+ if (!componentKey.test(name)) throw new Error(`Invalid authentication name: ${JSON.stringify(name)}, not an OpenAPI key`);
182
+ assertOwnTags(`Authentication "${name}"`, error);
183
+ if (!Predicate.isTagged(security, "Http") && !Predicate.isTagged(security, "ApiKey") && !Predicate.isTagged(security, "Basic")) throw new Error("Authentication takes one native HttpApiSecurity scheme");
184
+ return {
185
+ name,
186
+ identity,
187
+ security,
188
+ error,
189
+ "~provider": Context.Service(`effect-actions/Authentication/Provider/${name}`),
190
+ "~middleware": HttpApiMiddleware.Service()(`effect-actions/Authentication/Security/${name}`, { security: { [name]: security } }),
191
+ "~verified": Context.Service(`effect-actions/Authentication/Verified/${name}`)
192
+ };
193
+ };
194
+ /** Whether `credential`, as a native scheme decodes it, holds anything: an absent one is empty. */
195
+ const presented = (credential) => Redacted.isRedacted(credential) ? Redacted.value(credential) !== "" : credential.username !== "" || Redacted.value(credential.password) !== "";
196
+ function layer(authentication, verify, options = {}) {
197
+ const { security } = authentication;
198
+ const oauth = isBearer(security);
199
+ const protectedResource = options.protectedResource;
200
+ assertResource(oauth, protectedResource);
201
+ const resource = Effect.isEffect(protectedResource) ? Effect.map(protectedResource, (built) => {
202
+ assertResource(oauth, built);
203
+ return answersOf(built);
204
+ }) : Effect.succeed(answersOf(protectedResource));
205
+ const missing = new Unauthenticated({ message: oauth ? "A bearer token is required." : "A credential is required." });
206
+ return Layer.effect(authentication["~provider"], Effect.gen(function* () {
207
+ const router = yield* HttpRouter.HttpRouter;
208
+ const { metadataUrl, invalid, anonymous, published } = yield* resource;
209
+ if (published !== void 0) yield* router.addGlobalMiddleware(published);
210
+ const verifier = Effect.isEffect(verify) ? yield* verify : verify;
211
+ if (!Predicate.isFunction(verifier)) return yield* Effect.die(/* @__PURE__ */ new Error("Missing verify: pass a verify function, or an Effect building one"));
212
+ const authenticate = (credential) => presented(credential) ? verifier(credential) : Effect.fail(missing);
213
+ const failed = HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")));
214
+ const challengeOf = (credential) => !oauth ? schemeChallenge(security, authentication.name) : presented(credential) ? invalid : anonymous;
215
+ const refuse = (error) => isRefusal(error) ? Effect.succeed(oauth ? answer(error, metadataUrl) : plain(error)) : Effect.suspend(() => {
216
+ const response = declaredResponse(authentication.error, error);
217
+ return response === void 0 ? Effect.die(error) : Effect.succeed(response);
218
+ });
219
+ const verified = (credential, route, slot) => {
220
+ const challenge = challengeOf(credential);
221
+ return Effect.matchEffect(authenticate(credential), {
222
+ onFailure: refuse,
223
+ onSuccess: (actor) => Effect.provideService(route, slot, actor)
224
+ }).pipe(Effect.onError(() => failed), HttpEffect.withPreResponseHandler((_request, response) => Effect.succeed(settle(response, challenge))));
225
+ };
226
+ return {
227
+ descriptor: authentication,
228
+ middleware: { [authentication.name]: (route, { credential }) => verified(credential, route, authentication.identity) },
229
+ stepUp: (route) => oauth ? Effect.catchIf(route, isStepUp, (error) => Effect.succeed(answer(error, metadataUrl))) : route,
230
+ http: ((route, optional) => Effect.flatMap(HttpApiBuilder.securityDecode(security), (credential) => optional && !presented(credential) ? route : verified(credential, oauth ? answerStepUp(route, metadataUrl) : route, authentication["~verified"]))),
231
+ route: ((route) => Effect.flatMap(HttpApiBuilder.securityDecode(security), (credential) => verified(credential, Effect.catchIf(route, isRefusal, refuse), authentication.identity)))
232
+ };
233
+ }));
234
+ }
235
+ /**
236
+ * Native router middleware authenticating a route of the host's own, such as an export, a
237
+ * page frame or a WebSocket upgrade, with `authentication`'s provider, as its actions' routes
238
+ * are: the credential its scheme decodes, verified by the same verifier, and the identity
239
+ * provided to the route. It answers as an action route: a missing or invalid credential with
240
+ * the refusal, every response to the request it authenticates `no-store` unless it states its
241
+ * own caching, and a refusal the route fails with, such as a scope check's `Forbidden`, as its
242
+ * status, JSON and challenge, stepping up under Bearer. The layer requires the provider,
243
+ * `layer(authentication, ...)`. An erased descriptor, `Any`, provides no service the types can
244
+ * name: its identity is `unknown`, which would discharge every request service the route owes.
245
+ */
246
+ const protect = (authentication) => HttpRouter.middleware()(Effect.map(Effect.service(authentication["~provider"]), (runtime) => (route) => runtime.route(route)));
54
247
  //#endregion
55
- export { middleware, protectedResource };
248
+ export { bearerTokenOf, layer, make, protect, refusalResponse };
@@ -1,68 +1,95 @@
1
- import { ClientOptions } from "./internal/fetchClient.mjs";
2
- import { Schema } from "effect";
3
- import { HttpApi, HttpApiGroup } from "effect/http-api";
1
+ import { BuiltIn } from "./internal/errors.mjs";
2
+ import { Call } from "./internal/call.mjs";
3
+ import { Action_d_exports, Any } from "./Action.mjs";
4
+ import { Params } from "./internal/mcp.mjs";
5
+ import { Served } from "./internal/memory.mjs";
6
+ import { Effect, Layer, Schema } from "effect";
7
+ import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpRouter } from "effect/http";
4
8
  //#region src/Testing.d.ts
5
- /** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
6
- export type Handler = (request: Request) => Promise<Response>;
7
9
  /**
8
- * The native grouped HTTP client, calling `handler` in memory instead of the network.
9
- * `baseUrl` defaults to `http://localhost`.
10
+ * The native `HttpClient`, answered by `handler` instead of the network: a web handler the
11
+ * test serves, which other tests may share, such as
12
+ * `HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))).handler`,
13
+ * the routes' platform services provided, as `layer(routes)` provides them itself. It is the
14
+ * client `layer(routes)` gives, without building anything: the test owns the handler, and
15
+ * disposes of it.
10
16
  */
11
- export declare const httpClient: <Id extends string, Groups extends HttpApiGroup.Constraint>(api: HttpApi.HttpApi<Id, Groups>, handler: Handler, options?: ClientOptions) => import("effect/Effect").Effect<import("effect/Types").Simplify<{ readonly [Group in Extract<Groups, {
12
- readonly topLevel: false;
13
- }> as HttpApiGroup.Identifier<Group>]: import("effect/http-api/HttpApiClient").Client.GroupByEndpoint<Group, never, never>; } & import("effect/http-api/HttpApiClient").Client.TopLevelMethods<Groups, never, never>>, never, Exclude<Exclude<import("effect/http-api/HttpApiMiddleware").MiddlewareClient<import("effect/http-api/HttpApiEndpoint").Middleware<HttpApiGroup.Endpoints<Groups>>>, import("effect/http/HttpClient").HttpClient>, never>>;
14
- /** A stateless 2026-07-28 request. */
15
- export interface McpRequestOptions {
16
- readonly url: string | URL;
17
- readonly method: string;
18
- readonly params?: McpRequestParams;
19
- readonly headers?: ConstructorParameters<typeof Headers>[0];
20
- }
17
+ export declare function layer(handler: (request: Request) => Promise<Response>): Layer.Layer<HttpClient.HttpClient>;
21
18
  /**
22
- * What `JSON.stringify` accepts, not only valid JSON: `undefined` fields are
23
- * dropped, and tests send malformed arguments on purpose.
19
+ * The native `HttpClient`, answered in memory by `routes` instead of the network: provide
20
+ * it to `ActionHttp.client` and to `mcpClient`. The routes are built with this layer and
21
+ * released with its scope, without request logs. What they still require is this layer's, as
22
+ * under `HttpRouter.serve`: a builder's services, and a per-request service no middleware of
23
+ * theirs provides, including one a global middleware reads. The routes keep their real
24
+ * authentication: a client sends its caller's credential. Provided around it, the test
25
+ * program shares them. It never requires
26
+ * the platform services, `FileSystem`, `Path`, `HttpPlatform` and `Etag.Generator`: one
27
+ * provided around it is the routes' too, and `HttpServer.layerServices`' defaults stand in for
28
+ * the rest, whose `FileSystem` is a no-op. A relative URL resolves against `http://localhost`.
29
+ * The client is the layer's own: the program's other HTTP clients get none of its requests,
30
+ * and it none of theirs.
24
31
  */
25
- export type McpRequestValue = string | number | boolean | null | undefined | ReadonlyArray<McpRequestValue> | {
26
- readonly [key: string]: McpRequestValue;
27
- };
28
- /** JSON-RPC `params`; `_meta` is merged shallowly over the defaults `mcpRequest` supplies. */
29
- export interface McpRequestParams {
30
- readonly _meta?: {
31
- readonly [key: string]: McpRequestValue;
32
- };
33
- readonly [key: string]: McpRequestValue;
32
+ export declare function layer<A, E, R>(routes: Layer.Layer<A, E, R>): Layer.Layer<HttpClient.HttpClient, E, Exclude<HttpRouter.Request.Without<R> | HttpRouter.Request.Only<"Requires", R> | HttpRouter.Request.Only<"GlobalRequires", R>, Served>>;
33
+ /** Where `mcpClient` sends, and through what client. */
34
+ export interface McpClientOptions {
35
+ /**
36
+ * The endpoint, resolved by the `HttpClient`: relative under `layer`. Defaults to `/mcp`,
37
+ * the default `ActionMcp.layerHttp` path.
38
+ */
39
+ readonly url?: string;
40
+ /** Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token, say. */
41
+ readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient;
34
42
  }
35
- /** Build one stateless 2026-07-28 JSON-RPC request, with client metadata defaulted. */
36
- export declare const mcpRequest: ({ url, method, params, headers: init }: McpRequestOptions) => Request;
37
- /** One `tools/call` for `mcpCall`: the endpoint, the tool and its arguments. */
38
- export interface McpCallOptions {
39
- readonly url: string | URL;
40
- /** The tool name, `mcp.name` of its action. */
41
- readonly name: string;
42
- /** Defaults to `{}`. Like `params`, it may be malformed on purpose. */
43
- readonly arguments?: {
44
- readonly [key: string]: McpRequestValue;
45
- };
46
- readonly headers?: ConstructorParameters<typeof Headers>[0];
43
+ /** Where an `mcpRequest` goes, and with what headers. */
44
+ export interface McpRequestOptions {
45
+ /**
46
+ * The endpoint, resolved by the `HttpClient` that sends the request: relative under `layer`.
47
+ * Defaults to `/mcp`, as `mcpClient`'s does; converting to a web `Request` needs an absolute
48
+ * one.
49
+ */
50
+ readonly url?: string;
51
+ readonly headers?: Headers.Input;
47
52
  }
53
+ declare const McpCallError_base: Schema.Class<McpCallError, Schema.TaggedStruct<"McpCallError", {
54
+ readonly message: Schema.String;
55
+ }>, import("effect/Cause").YieldableError>;
56
+ /**
57
+ * An answer a client method cannot decode as the action's success or a declared error,
58
+ * such as the native server's message for invalid arguments or an unknown tool. Its message
59
+ * holds the answer.
60
+ */
61
+ export declare class McpCallError extends McpCallError_base {}
62
+ /**
63
+ * What one call of `A` fails with: a declared error value (the action's own or a built-in one
64
+ * from the tool, or a refusal from the endpoint's authentication), a `SchemaError` when the
65
+ * input does not encode or the success does not decode, an `HttpClientError` when the
66
+ * endpoint could not be reached, or an `McpCallError` for any other answer.
67
+ */
68
+ type CallError<A extends Any> = A["error"][number]["Type"] | BuiltIn | Schema.SchemaError | HttpClientError.HttpClientError | McpCallError;
69
+ /** Every action of `Actions` as `client.<action>(input)`, calling its tool. */
70
+ export type McpClient<Actions extends ReadonlyArray<Any>> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A>>>; };
48
71
  /**
49
- * A tool call's outcome: the success from `structuredContent`, with the text an action's
50
- * `mcp.text` field sent before it; or the error text of an `isError` result, parsed as
51
- * JSON when it is JSON (a declared error, whatever its shape) and kept as the text
52
- * otherwise (the native server's own message, such as for invalid arguments).
72
+ * A client of an MCP endpoint served by `ActionMcp.layerHttp`, one method per action calling
73
+ * its tool with one stateless request, as `ActionHttp.client` calls routes: the input is
74
+ * encoded with the action's schema, and the success decoded from its structured content, each
75
+ * media field from the blocks whose `_meta` names it, and a success that is media, or whose
76
+ * every field is, from its blocks alone. A declared error the tool returns, the action's own
77
+ * or a refusal, is its decoded value, and so is a refusal the endpoint's authentication
78
+ * answers with. The argument may be omitted when `{}` is a valid input. Requires the native
79
+ * `HttpClient`, such as the one `layer` provides.
53
80
  */
54
- export type McpCallResult = {
55
- readonly isError: false;
56
- readonly value: Schema.Json;
57
- readonly text?: string;
58
- } | {
59
- readonly isError: true;
60
- readonly error: Schema.Json;
61
- };
81
+ export declare function mcpClient<const Actions extends ReadonlyArray<Any>>(actions: Actions, options?: McpClientOptions): Effect.Effect<McpClient<Actions>, never, HttpClient.HttpClient>;
62
82
  /**
63
- * Call one tool through `handler` with a stateless 2026-07-28 request and return its
64
- * outcome. A response that is not an HTTP 200 carrying a tool result, such as an
65
- * authentication refusal or a JSON-RPC error, throws; use `mcpRequest` to inspect one.
83
+ * One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it,
84
+ * as the native request value: the JSON-RPC envelope, the 2026-07-28 headers, `mcp-name` from
85
+ * `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
86
+ * `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
87
+ * version is always the request's own. The test sends it: `HttpClient.execute` answers the
88
+ * response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
89
+ * gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
90
+ * asserting on what `mcpClient` decodes away, such as `tools/list`, a refusal's challenge, or
91
+ * a call its types would not send.
66
92
  */
67
- export declare const mcpCall: (handler: Handler, { url, name, headers, arguments: args }: McpCallOptions) => Promise<McpCallResult>;
68
- //#endregion
93
+ export declare const mcpRequest: (method: string, params?: Params, { headers, url }?: McpRequestOptions) => HttpClientRequest.HttpClientRequest;
94
+ //#endregion
95
+ export type { Params as McpParams };