@gjermundgaraba/effect-actions 0.8.0 → 0.10.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 +57 -50
- package/dist/Action.d.mts +285 -79
- package/dist/Action.mjs +124 -23
- package/dist/ActionCli.d.mts +102 -31
- package/dist/ActionCli.mjs +114 -16
- package/dist/ActionHttp.d.mts +170 -51
- package/dist/ActionHttp.mjs +130 -79
- package/dist/ActionMcp.d.mts +71 -21
- package/dist/ActionMcp.mjs +108 -81
- package/dist/ActionToolkit.d.mts +56 -27
- package/dist/ActionToolkit.mjs +16 -5
- package/dist/Authentication.d.mts +129 -33
- package/dist/Authentication.mjs +231 -38
- package/dist/Testing.d.mts +82 -57
- package/dist/Testing.mjs +89 -79
- package/dist/internal/actions.d.mts +5 -25
- package/dist/internal/actions.mjs +75 -27
- package/dist/internal/authentication.d.mts +102 -0
- package/dist/internal/authentication.mjs +11 -0
- package/dist/internal/call.d.mts +12 -0
- package/dist/internal/call.mjs +12 -0
- package/dist/internal/cli.d.mts +36 -22
- package/dist/internal/cli.mjs +311 -33
- package/dist/internal/client.d.mts +43 -0
- package/dist/internal/client.mjs +25 -0
- package/dist/internal/console.mjs +77 -0
- package/dist/internal/declared.d.mts +13 -0
- package/dist/internal/declared.mjs +34 -0
- package/dist/internal/errors.d.mts +77 -0
- package/dist/internal/errors.mjs +79 -0
- package/dist/internal/implementation.d.mts +171 -41
- package/dist/internal/implementation.mjs +147 -29
- package/dist/internal/mcp.d.mts +11 -0
- package/dist/internal/mcp.mjs +52 -0
- package/dist/internal/memory.d.mts +6 -0
- package/dist/internal/memory.mjs +38 -0
- package/dist/internal/refusal.mjs +67 -0
- package/dist/internal/schema-errors.mjs +20 -0
- package/dist/internal/tools.mjs +27 -57
- package/docs/Action.md +401 -73
- package/docs/ActionCli.md +280 -51
- package/docs/ActionHttp.md +266 -82
- package/docs/ActionMcp.md +149 -104
- package/docs/ActionToolkit.md +97 -35
- package/docs/Authentication.md +369 -62
- package/docs/CONTEXT.md +23 -36
- package/docs/README.md +29 -73
- package/docs/Testing.md +154 -77
- package/docs/guarantees.md +81 -43
- package/docs/setup.md +90 -27
- package/package.json +9 -17
- package/dist/ActionCatalog.d.mts +0 -30
- package/dist/ActionCatalog.mjs +0 -39
- package/dist/ActionCliClient.d.mts +0 -48
- package/dist/ActionCliClient.mjs +0 -43
- package/dist/ActionGroup.d.mts +0 -44
- package/dist/ActionGroup.mjs +0 -35
- package/dist/ActionHttpClient.d.mts +0 -33
- package/dist/ActionHttpClient.mjs +0 -16
- package/dist/TestingClient.d.mts +0 -17
- package/dist/TestingClient.mjs +0 -22
- package/dist/internal/fetchClient.d.mts +0 -6
- package/dist/internal/fetchClient.mjs +0 -8
- package/dist/internal/tools.d.mts +0 -22
- package/docs/ActionCatalog.md +0 -54
- package/docs/ActionCliClient.md +0 -63
- package/docs/ActionGroup.md +0 -128
- package/docs/ActionHttpClient.md +0 -80
package/docs/Authentication.md
CHANGED
|
@@ -1,98 +1,405 @@
|
|
|
1
1
|
# Authentication
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
How a remote caller proves the identity a protected contract declares, and its verification on
|
|
4
|
+
the server. A contract states its identity, `caller: CurrentActor` ([Action.md](Action.md#rules)).
|
|
5
|
+
`make` declares how a caller proves it: a named, browser-safe descriptor of one native scheme,
|
|
6
|
+
Bearer unless it names another, which a binding and an MCP endpoint serving protected actions
|
|
7
|
+
name. `layer` is that
|
|
8
|
+
descriptor's provider: the server-only verifier, built once per layer graph, which every HTTP or
|
|
9
|
+
MCP layer serving protected actions requires, and which authenticates their requests before
|
|
10
|
+
decoding them. The surfaces enforce it with Effect's native endpoint security, which also
|
|
11
|
+
documents it in OpenAPI. Given an OAuth protected resource, a Bearer provider publishes the resource's
|
|
12
|
+
RFC 9728 discovery and names it in every challenge, so an MCP client that is refused finds its
|
|
13
|
+
authorization server and steps up to a scope it lacks. Token verification, login, and consent
|
|
14
|
+
stay in the application. Action-level authorization belongs in the implementation's
|
|
15
|
+
`authorize`; record-level checks belong in the handler's data access
|
|
16
|
+
([guarantees.md](guarantees.md#authorization)).
|
|
7
17
|
|
|
8
18
|
## API
|
|
9
19
|
|
|
10
20
|
Import `@gjermundgaraba/effect-actions/Authentication`.
|
|
11
21
|
|
|
12
|
-
| API
|
|
13
|
-
|
|
|
14
|
-
| `
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
22
|
+
| API | Purpose |
|
|
23
|
+
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `make(name, identity, { security, error }?)` | A browser-safe authentication descriptor: how a caller proves `identity`, one native scheme. |
|
|
25
|
+
| `layer(descriptor, verify, options?)` | Its provider: the server-only verifier, or an Effect building it once per layer graph, and discovery. |
|
|
26
|
+
| `protect(descriptor)` | Router middleware authenticating a route of the host's own as the descriptor's actions are. |
|
|
27
|
+
| `bearerTokenOf(authorization)` | The bearer token of an `Authorization` header value or `null`, an `Option`, for an unrouted caller. |
|
|
28
|
+
| `refusalResponse(error, options?)` | The response authentication answers a refusal with, for a caller the router never routes. |
|
|
29
|
+
| `Any` | Any descriptor, erased: what a binding's and an MCP endpoint's `authentication` take. |
|
|
30
|
+
| `Descriptor`, `Provider`, `Protection` | What `make`, `layer` and `protect` return, to name in a package that emits declarations. |
|
|
31
|
+
| `Verify`, `Options`, `LayerOptions` | The verifier's type, of the credential its descriptor's scheme decodes, and `make`'s and `layer`'s options. |
|
|
32
|
+
| `ProtectedResource`, `RefusalResponseOptions` | An OAuth protected resource, and what `refusalResponse` takes. |
|
|
18
33
|
|
|
19
|
-
`
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
`name` is a literal, unique per declaration: it names the one provider that satisfies the
|
|
35
|
+
descriptor, so a provider of another descriptor of the same identity does not. `security` is one
|
|
36
|
+
native `HttpApiSecurity` scheme, `HttpApiSecurity.bearer` when omitted: another is
|
|
37
|
+
`HttpApiSecurity.apiKey({ in: "cookie", key: "session" })`, an API key in a header or the query,
|
|
38
|
+
or `HttpApiSecurity.basic`. Exported type: `Options`. Options whose `security` may be left
|
|
39
|
+
out, an optional property or a union with options lacking it, type the verifier's credential as
|
|
40
|
+
that scheme's or Bearer's, as at run time; an explicit type argument naming a scheme requires
|
|
41
|
+
the options argument. `error` is what the verifier may fail with besides a refusal, such as
|
|
42
|
+
its issuer being unreachable: one schema, or a list, held as a list, refused where it encodes with
|
|
43
|
+
a built-in error's `_tag`. A misspelled option is a type error. A descriptor's public fields are
|
|
44
|
+
`name`, `identity`, `security` and `error`; its `~` keys are the library's own wiring, which no
|
|
45
|
+
host composes.
|
|
22
46
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
47
|
+
`verify` is the verifier itself, or a builder of it, like the one `Action.implement` takes
|
|
48
|
+
([Action.md](Action.md#implementations)): an Effect that yields startup services, such as a token
|
|
49
|
+
verifier, and returns the verifier, run once per layer graph.
|
|
50
|
+
|
|
51
|
+
A verifier receives the credential the descriptor's native scheme decodes, a `Redacted<string>`
|
|
52
|
+
token or key, or Basic's `{ username, password }`, and succeeds with the identity, or fails with
|
|
53
|
+
`Action.Unauthenticated`, `Action.Forbidden`, or an error its descriptor declares in `error`. A
|
|
54
|
+
missing or empty credential is refused before it runs. It reads the request the router provides;
|
|
55
|
+
another service it reads per request comes from router middleware provided after the
|
|
56
|
+
authentication.
|
|
57
|
+
|
|
58
|
+
`options.protectedResource`, for a Bearer descriptor only, is an OAuth protected resource, which
|
|
59
|
+
the provider publishes and names in every challenge, or an Effect that builds one, or none, where
|
|
60
|
+
it is known only at startup: it runs with the verifier's build, once per layer graph, and the
|
|
61
|
+
provider's layer requires what it yields. Exported type: `ProtectedResource`.
|
|
62
|
+
|
|
63
|
+
`refusalResponse` takes `authentication`, the descriptor refusing, `protectedResource`, the one
|
|
64
|
+
given to `layer`, and `authorization`, the request's `Authorization` header. Under a descriptor of
|
|
65
|
+
another scheme it answers as that descriptor's routes do: the JSON and status, `no-store`, a 401
|
|
66
|
+
naming that scheme, `Basic realm="<name>"` or the `Http` scheme, none for an API key, and no step-up
|
|
67
|
+
challenge. Left out, or a Bearer descriptor, it answers as Bearer's. It also takes an error
|
|
68
|
+
`authentication` declares, answered as the routes answer it. Exported type:
|
|
69
|
+
`RefusalResponseOptions`.
|
|
70
|
+
|
|
71
|
+
| Protected resource option | Meaning |
|
|
72
|
+
| ------------------------- | --------------------------------------------------------------------------------------------- |
|
|
73
|
+
| `resource` | Required exact OAuth resource identifier; its path selects the discovery path. |
|
|
74
|
+
| `authorizationServers` | Required, nonempty: where clients get tokens. |
|
|
75
|
+
| `scopesSupported` | Optional OAuth scope tokens: every scope the resource accepts, published in discovery. |
|
|
76
|
+
| `scopesRequired` | Optional, nonempty OAuth scope tokens: every 401 names them, and a first login requests them. |
|
|
77
|
+
| `resourceName` | Optional human-readable name. |
|
|
27
78
|
|
|
28
79
|
## Canonical
|
|
29
80
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
81
|
+
The example app's authentication, in two modules. The binding, which servers and clients import,
|
|
82
|
+
names the descriptor, `Login`, beside its actions ([ActionHttp.md](ActionHttp.md#canonical)):
|
|
83
|
+
|
|
84
|
+
```ts example=binding.ts
|
|
85
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
33
86
|
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
87
|
+
import { CurrentActor } from "./authorization.js";
|
|
88
|
+
import { Double, GetUser, RenameUser, Status, WhoAmI } from "./contracts.js";
|
|
34
89
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
90
|
+
// Browser-safe: how a remote caller proves it is CurrentActor, a bearer token unless
|
|
91
|
+
// `security` names another native scheme. The verifier lives in authentication.ts. The literal
|
|
92
|
+
// name identifies the verifier that may provide it.
|
|
93
|
+
export const Login = Authentication.make("example.Login", CurrentActor);
|
|
39
94
|
|
|
40
|
-
//
|
|
41
|
-
|
|
95
|
+
// Protected contracts get native bearer security (enforced and documented); `status`,
|
|
96
|
+
// declared `caller: Action.Anyone`, gets none.
|
|
97
|
+
export const Http = ActionHttp.make([Status, GetUser, RenameUser, Double, WhoAmI], {
|
|
98
|
+
authentication: Login,
|
|
99
|
+
});
|
|
100
|
+
```
|
|
42
101
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
102
|
+
The server provides `Login`'s verifier, `authenticate`, to every layer serving protected
|
|
103
|
+
actions ([ActionHttp.md](ActionHttp.md#serving), [ActionMcp.md](ActionMcp.md#canonical)). It
|
|
104
|
+
answers a missing or unknown token with the built-in 401 and publishes the resource's discovery.
|
|
105
|
+
|
|
106
|
+
```ts example=authentication.ts
|
|
107
|
+
import { Effect, Redacted } from "effect";
|
|
108
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
109
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
110
|
+
import { type Actor, actors } from "./authorization.js";
|
|
111
|
+
import { Login } from "./binding.js";
|
|
112
|
+
|
|
113
|
+
// DEMO ONLY: a token is an actor's name. Verify real tokens with your authorization
|
|
114
|
+
// server's library instead, their audience included: issued for this resource.
|
|
115
|
+
const isActorToken = (token: string): token is keyof typeof actors => Object.hasOwn(actors, token);
|
|
116
|
+
|
|
117
|
+
// Each request's token, as the native `HttpApiSecurity.bearer` decodes it, to its actor. A
|
|
118
|
+
// missing or empty token never reaches it; an unknown one is the built-in `Unauthenticated`,
|
|
119
|
+
// a 401 every client decodes, as is a missing one.
|
|
120
|
+
export const verify = (
|
|
121
|
+
token: Redacted.Redacted<string>,
|
|
122
|
+
): Effect.Effect<Actor, Action.Unauthenticated> => {
|
|
123
|
+
const name = Redacted.value(token);
|
|
48
124
|
|
|
49
|
-
|
|
50
|
-
|
|
125
|
+
return isActorToken(name)
|
|
126
|
+
? Effect.succeed(actors[name])
|
|
127
|
+
: Effect.fail(new Action.Unauthenticated({ message: "Unknown demo token." }));
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// As an OAuth protected resource, it publishes RFC 9728 discovery, public, and every challenge
|
|
131
|
+
// names it, so an MCP client that was refused finds the server issuing its tokens. A first
|
|
132
|
+
// login requests read only; a write refused for its scope steps up.
|
|
133
|
+
export const protectedResource = {
|
|
134
|
+
resource: "http://localhost:3000/mcp",
|
|
51
135
|
authorizationServers: ["https://auth.example.com"],
|
|
52
136
|
scopesSupported: ["users:read", "users:write"],
|
|
137
|
+
scopesRequired: ["users:read"],
|
|
138
|
+
} satisfies Authentication.ProtectedResource;
|
|
139
|
+
|
|
140
|
+
// Server-only: `Login`'s verifier, provided to every layer serving protected actions. An
|
|
141
|
+
// Effect building it instead yields startup services, such as a token verifier, as a handler
|
|
142
|
+
// builder does; this demo needs none.
|
|
143
|
+
export const authenticate = Authentication.layer(Login, verify, {
|
|
144
|
+
protectedResource,
|
|
53
145
|
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Outer and inner middleware
|
|
149
|
+
|
|
150
|
+
A host serving tenants on their own subdomains: a verifier built once at startup; each request's
|
|
151
|
+
tenant from native router middleware, provided after the authentication, which the verifier
|
|
152
|
+
reads per request; and endpoint middleware reading the identity, the HTTP layer's `middleware`,
|
|
153
|
+
which runs inside the authentication.
|
|
154
|
+
|
|
155
|
+
```ts example=authentication-tenant.ts
|
|
156
|
+
import { Context, Effect, Layer, Redacted } from "effect";
|
|
157
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
158
|
+
import { HttpApiMiddleware } from "effect/http-api";
|
|
159
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
160
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
161
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
162
|
+
import { type Actor, actors, CurrentActor } from "./authorization.js";
|
|
163
|
+
import { Http, Login } from "./binding.js";
|
|
164
|
+
import { userActions } from "./handlers.js";
|
|
165
|
+
|
|
166
|
+
/** Each request's tenant: the first label of its host, `acme` for acme.example.com. */
|
|
167
|
+
export class Tenant extends Context.Service<Tenant, string>()("example/Tenant") {}
|
|
168
|
+
|
|
169
|
+
// Outer: native router middleware, providing what the verifier reads per request.
|
|
170
|
+
const resolveTenant = HttpRouter.middleware<{ provides: Tenant }>()((route) =>
|
|
171
|
+
Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) =>
|
|
172
|
+
Effect.provideService(route, Tenant, request.headers.host?.split(".")[0] ?? ""),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Inner: reads the identity, so it is native endpoint middleware, which the layer runs inside
|
|
177
|
+
// the authentication of every route it serves, all of them protected.
|
|
178
|
+
export class LogCaller extends HttpApiMiddleware.Service<LogCaller, { requires: CurrentActor }>()(
|
|
179
|
+
"example/LogCaller",
|
|
180
|
+
) {}
|
|
181
|
+
|
|
182
|
+
const LogCallerLive = Layer.succeed(LogCaller, (route) =>
|
|
183
|
+
Effect.flatMap(CurrentActor, ({ id }) =>
|
|
184
|
+
Effect.map(route, HttpServerResponse.setHeader("x-actor", id)),
|
|
185
|
+
),
|
|
186
|
+
);
|
|
54
187
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{
|
|
59
|
-
|
|
188
|
+
/** DEMO ONLY: a startup service verifying tokens, as your authorization server's library would. */
|
|
189
|
+
export class Verifier extends Context.Service<
|
|
190
|
+
Verifier,
|
|
191
|
+
{
|
|
192
|
+
readonly verify: (
|
|
193
|
+
token: Redacted.Redacted<string>,
|
|
194
|
+
) => Effect.Effect<Actor, Action.Unauthenticated>;
|
|
195
|
+
}
|
|
196
|
+
>()("example/Verifier") {
|
|
197
|
+
static readonly layer = Layer.sync(Verifier, () => {
|
|
198
|
+
const known = new Map<string, Actor>(Object.entries(actors));
|
|
60
199
|
|
|
61
|
-
|
|
62
|
-
|
|
200
|
+
return Verifier.of({
|
|
201
|
+
verify: (token) =>
|
|
202
|
+
Effect.fromNullishOr(known.get(Redacted.value(token))).pipe(
|
|
203
|
+
Effect.mapError(() => new Action.Unauthenticated({ message: "Unknown demo token." })),
|
|
204
|
+
),
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Like a handler builder: the verifier once, at startup; the token and the tenant per
|
|
210
|
+
// request. An actor of another tenant is refused.
|
|
211
|
+
const authenticate = Authentication.layer(
|
|
212
|
+
Login,
|
|
63
213
|
Effect.gen(function* () {
|
|
64
|
-
const
|
|
65
|
-
|
|
214
|
+
const verifier = yield* Verifier;
|
|
215
|
+
|
|
216
|
+
return (token: Redacted.Redacted<string>) =>
|
|
217
|
+
Effect.gen(function* () {
|
|
218
|
+
const actor = yield* verifier.verify(token);
|
|
219
|
+
|
|
220
|
+
if (actor.tenantId !== (yield* Tenant)) {
|
|
221
|
+
return yield* new Action.Forbidden({ message: "Not a member of this tenant." });
|
|
222
|
+
}
|
|
66
223
|
|
|
67
|
-
|
|
224
|
+
return actor;
|
|
225
|
+
});
|
|
68
226
|
}),
|
|
69
227
|
);
|
|
70
228
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
229
|
+
// Provided in order: each `Layer.provide` gives what the layers before it still owe, so
|
|
230
|
+
// `resolveTenant`, last, gives the Tenant the verifier reads per request. One array,
|
|
231
|
+
// `Layer.provide([authenticate, resolveTenant.layer])`, would leave it owed: an array's
|
|
232
|
+
// members provide to the routes, not to one another.
|
|
233
|
+
export const routes = ActionHttp.layer(Http, userActions, { middleware: [LogCaller] }).pipe(
|
|
234
|
+
Layer.provide([authenticate, LogCallerLive]),
|
|
235
|
+
Layer.provide(Verifier.layer),
|
|
236
|
+
Layer.provide(resolveTenant.layer),
|
|
237
|
+
);
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### A scheme other than Bearer
|
|
241
|
+
|
|
242
|
+
A descriptor naming another native scheme, here a session cookie, gives its verifier the
|
|
243
|
+
credential that scheme decodes. Nothing steps up under it, and it publishes no protected
|
|
244
|
+
resource.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
export const SessionLogin = Authentication.make("example.SessionLogin", CurrentActor, {
|
|
248
|
+
security: HttpApiSecurity.apiKey({ in: "cookie", key: "session" }),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
export const authenticateSession = Authentication.layer(
|
|
252
|
+
SessionLogin,
|
|
253
|
+
Effect.map(
|
|
254
|
+
Sessions,
|
|
255
|
+
(sessions) => (session: Redacted.Redacted<string>) => sessions.actorOf(Redacted.value(session)),
|
|
256
|
+
),
|
|
75
257
|
);
|
|
76
258
|
```
|
|
77
259
|
|
|
260
|
+
### A route of your own
|
|
261
|
+
|
|
262
|
+
A descriptor covers action endpoints alone. A route of the host's own beside them, such as an
|
|
263
|
+
export, a page frame or a WebSocket upgrade, takes `protect(descriptor)`, native router
|
|
264
|
+
middleware: the descriptor's provider verifies the request and gives the route the identity,
|
|
265
|
+
and the route is answered as an action's, its refusals, challenges and caching included. Its
|
|
266
|
+
own rule, such as a scope, fails with a refusal.
|
|
267
|
+
|
|
268
|
+
```ts example=authentication-route.ts
|
|
269
|
+
import { Effect, Layer } from "effect";
|
|
270
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
271
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
272
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
273
|
+
import { CurrentActor } from "./authorization.js";
|
|
274
|
+
import { Login } from "./binding.js";
|
|
275
|
+
|
|
276
|
+
// A route of the host's own beside the actions, authenticated as they are: the binding's
|
|
277
|
+
// descriptor's provider verifies the request and gives the route the actor. A refusal it
|
|
278
|
+
// fails with is answered as an action's, and a scope a caller lacks steps up under Bearer.
|
|
279
|
+
export const exportRoute = HttpRouter.add(
|
|
280
|
+
"GET",
|
|
281
|
+
"/export",
|
|
282
|
+
Effect.gen(function* () {
|
|
283
|
+
const actor = yield* CurrentActor;
|
|
284
|
+
|
|
285
|
+
if (!actor.permissions.includes("users:read")) {
|
|
286
|
+
return yield* new Action.Forbidden({
|
|
287
|
+
message: "Requires users:read.",
|
|
288
|
+
scopes: ["users:read"],
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return HttpServerResponse.text(`Users of ${actor.tenantId}.`);
|
|
293
|
+
}),
|
|
294
|
+
).pipe(Layer.provide(Authentication.protect(Login).layer));
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Outside the router
|
|
298
|
+
|
|
299
|
+
A caller the router never routes, such as a Node `upgrade` handler admitting a socket,
|
|
300
|
+
authenticates its own header and refuses with `refusalResponse`: the response authentication
|
|
301
|
+
answers the same refusal with on a route.
|
|
302
|
+
|
|
303
|
+
```ts example=authentication-upgrade.ts
|
|
304
|
+
import { Effect } from "effect";
|
|
305
|
+
import { HttpServerResponse } from "effect/http";
|
|
306
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
307
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
308
|
+
import { protectedResource, verify } from "./authentication.js";
|
|
309
|
+
import type { Actor } from "./authorization.js";
|
|
310
|
+
|
|
311
|
+
// A caller the router never routes, such as a Node `upgrade` handler admitting a socket,
|
|
312
|
+
// which authenticates the `Authorization` header itself: read as a route's is, with
|
|
313
|
+
// `bearerTokenOf`, and verified by the routes' own `verify`. A socket writes, so it needs the
|
|
314
|
+
// write scope. It gives the actor, or the response to refuse with, the one authentication
|
|
315
|
+
// answers the same refusal with on a route.
|
|
316
|
+
export const admit = (authorization: string | undefined): Effect.Effect<Actor, Response> =>
|
|
317
|
+
Effect.fromOption(
|
|
318
|
+
Authentication.bearerTokenOf(authorization),
|
|
319
|
+
() => new Action.Unauthenticated(),
|
|
320
|
+
).pipe(
|
|
321
|
+
Effect.flatMap(verify),
|
|
322
|
+
Effect.filterOrFail(
|
|
323
|
+
(actor) => actor.permissions.includes("users:write"),
|
|
324
|
+
() => new Action.Forbidden({ message: "Requires users:write.", scopes: ["users:write"] }),
|
|
325
|
+
),
|
|
326
|
+
Effect.mapError((error) =>
|
|
327
|
+
HttpServerResponse.toWeb(
|
|
328
|
+
Authentication.refusalResponse(error, { protectedResource, authorization }),
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
```
|
|
333
|
+
|
|
78
334
|
## Rules
|
|
79
335
|
|
|
80
|
-
- `
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
-
-
|
|
336
|
+
- `make` is browser-safe and declares no verifier: the binding imports it, and so does every client of the binding. Keep `layer`, and what its verifier imports, in server-only modules.
|
|
337
|
+
- One descriptor per identity and resource. Its name literal is its type identity: a layer serving protected actions requires `Provider<CurrentActor, "example.Login">`, which only `layer(Login, ...)` provides. A provider of another descriptor, even of the same identity, does not satisfy it. Reuse one name only for one declaration. A scheme the deployment decides, such as a cookie name a framework prefixes over HTTPS, is still one declaration: the server builds its descriptor and binding at startup under the same name and identity, and a browser's binding keeps a static one, since a client never reads the scheme. A server builds its binding and its provider from one descriptor value: `ActionHttp.layer` refuses a binding whose descriptor is not the one its provider was built from.
|
|
338
|
+
- The name is also its scheme's OpenAPI component key, as it is, so it holds only letters, digits, `_`, `.` and `-`: `example.Login`, not `example/Login`. `make` throws for any other.
|
|
339
|
+
- A descriptor names one native `HttpApiSecurity` scheme, decoded as Effect decodes it: `HttpApiSecurity.bearer` by default, for a credential sent as `Authorization: Bearer`, such as an OAuth access token; or another, such as `HttpApiSecurity.apiKey({ in: "cookie", key: "session" })` for a browser session, an API key in a header or the query, or `HttpApiSecurity.basic`. A record of schemes, or anything that is no native scheme, is refused by the types, and throws `Authentication takes one native HttpApiSecurity scheme`. There is no fallback between schemes: a host accepting two credentials serves them on two bindings or endpoints, each naming its own descriptor.
|
|
340
|
+
- OAuth is Bearer's: only a Bearer descriptor's provider takes a `protectedResource`, which the types refuse for another scheme and `layer` throws for, and only under a Bearer descriptor does a protected action's refusal step up ([guarantees.md](guarantees.md#authorization)); a public route's or a public tool's never does, signed in or not. Under another scheme, a refusal over HTTP is its JSON and status, with no `insufficient_scope` challenge, and over MCP a tool's refusal, from the authorizer or a handler, is an `isError` tool result, as on an endpoint without authentication; the authentication's own refusal is still its 401 or 403. Its 401 carries the challenge its scheme has: an `Http` scheme names itself, such as `Token`; Basic sends `Basic realm="<descriptor name>"`, quoted and escaped; an API key sends none. Responses keep `Cache-Control: no-store`.
|
|
341
|
+
- Basic's credential counts as presented when its user-id or its password is non-empty, so a token sent as the password, `Basic base64(":token")`, reaches the verifier. A cookie-authenticated browser client sends its cookie itself; whether a cross-origin page may is the host's CORS policy ([ActionHttp.md](ActionHttp.md#rules)).
|
|
342
|
+
- A binding holding a protected action names its descriptor, `ActionHttp.make(actions, { authentication: Login })`, and so does an MCP endpoint serving one, `ActionMcp.layerHttp(apps, { authentication: Login })`: required, and a descriptor of another identity is refused, in the types and when called. One identity per binding or endpoint: every protected action it holds declares the descriptor's.
|
|
343
|
+
- The descriptor both enforces and documents. Each protected endpoint of a binding carries Effect's native security middleware for it, which `OpenApi.fromApi`, Swagger and Scalar show as the operation's `security`, `[{ "example.Login": [] }]`, and in `components.securitySchemes`, keyed by the descriptor's name: distinct descriptors have distinct names, so their schemes never share a key in one combined document; a public endpoint states `security: []`. Clients are unchanged: they send whatever credential `transformClient` adds.
|
|
344
|
+
- Every HTTP or MCP layer serving a protected action requires the provider: `Layer.provide(authenticate)`. Give each the same `layer` value: it is built once per layer graph, however many layers it is provided to, and publishes discovery once. A layer serving only public actions requires none.
|
|
345
|
+
- Authentication runs before decoding: a protected request without a credential that verifies is refused before its body is read ([guarantees.md](guarantees.md#authorization)).
|
|
346
|
+
- The verifier succeeds with the identity value, fails with a refusal, or fails with an error its descriptor declares. `Unauthenticated` is sent as its JSON with **401**; `Forbidden` as its JSON with **403**. Both are the bodies every endpoint declares, so typed clients decode them. A declared error is sent as its JSON with its `httpApiStatus`, or 422 without one, as every protected endpoint of a binding naming the descriptor declares it, so `ActionHttp.client` and a remote CLI command decode it as a typed failure; a public endpoint, which nothing verifies, does not declare it. A route, an MCP endpoint and `protect` send it alike, `no-store`, and challenge it only as a 401. It can fail with nothing else: any other error is a type error, and in plain JavaScript a defect, an empty 500.
|
|
347
|
+
- A failure that is not the caller's, such as an issuer the verifier cannot reach, is a declared error, `error: ProviderUnavailable` with `httpApiStatus: 503`, not a refusal: an `Unauthenticated` would have the caller sign in again, which cannot help. Over MCP it is the endpoint's HTTP answer, before any tool runs, not a tool result: `Testing.mcpClient` fails with an `McpCallError` naming its status and body.
|
|
348
|
+
- It provides the identity to protected routes alone. A public route never gets it, beside protected ones in one layer too, and over HTTP ignores any credential the request carries. A public tool's call on an MCP endpoint naming a descriptor is verified when it presents a credential, refused with 401 when that does not verify, as MCP authorization requires of an invalid or expired token, and passes without one, or with one its scheme decodes as empty, such as `Authorization: Bearer` alone; the tool still gets no identity.
|
|
349
|
+
- Nothing else provides a protected action's identity remotely: neither a value provided at startup, around the server or the program, nor `HttpRouter.provideRequest`, nor other router middleware satisfies a layer serving it, which requires the provider ([guarantees.md](guarantees.md#authorization)).
|
|
350
|
+
- Every response to a request it authenticates gets `Cache-Control: no-store` unless its route states its own caching, and every 401 among them its scheme's challenge: [guarantees.md](guarantees.md#wire-behavior).
|
|
351
|
+
- A Bearer descriptor's 401 challenge is `Bearer`, or as a protected resource `Bearer scope="<scopesRequired>", resource_metadata="<metadata URL>"`, `scope` only when `scopesRequired` is given.
|
|
352
|
+
- A 401 to a request that presented a bearer token also names `error="invalid_token"` first (RFC 6750), whoever refused it: the token did not authenticate it, and a client may refresh it before it signs in again. A malformed token, such as `Bearer a b`, is a presented one (RFC 6750 §3.1). A request without one, or with another scheme such as `Basic`, gets no error code.
|
|
353
|
+
- An MCP client requests the scopes a 401 names, or every one of `scopesSupported` when it names none. Give `scopesRequired` whenever some scopes are needed only by some actions, so a first login asks for the least, and a `Forbidden` naming scopes asks for more when a call needs them.
|
|
354
|
+
- Step-up refusals follow [guarantees.md](guarantees.md#authorization), including the MCP exception when a notification has already started the **200** stream: a later refusal is an `isError` tool result without an HTTP challenge.
|
|
355
|
+
- When a `Forbidden` naming `scopes` is sent as an HTTP refusal under a Bearer descriptor, its **403** carries `WWW-Authenticate: Bearer error="insufficient_scope", scope="<scopes>"`. As a protected resource it adds `resource_metadata`, and `error_description` when the message is a valid one (printable ASCII without `"` or `\`).
|
|
356
|
+
- An OAuth client re-authorizes on that challenge with those scopes added, as MCP authorization requires; the official MCP client does. A `Forbidden` naming no scope has no challenge ([guarantees.md](guarantees.md#authorization)).
|
|
357
|
+
- Name scopes only when re-authorizing can grant them. A caller whose credential cannot step up, such as an API key, gets a `Forbidden` naming none: a plain 403, and a tool result over MCP, rather than a login prompt that cannot help.
|
|
358
|
+
- The descriptor covers actions alone. A route of the host's own, such as a WebSocket upgrade route, `RpcServer.layerProtocolWebsocket`'s, takes `protect(descriptor)`, native router middleware provided to it, `route.pipe(Layer.provide(Authentication.protect(Login).layer))`, which requires the descriptor's provider ([A route of your own](#a-route-of-your-own)). The credential the descriptor's scheme decodes is verified by the provider's verifier, and the route is given the identity. It is answered as an action's route: without a credential that verifies, with the refusal and challenge, or the error the verifier declares; its responses are marked and challenged as an action route's ([guarantees.md](guarantees.md#wire-behavior)). A refusal the route fails with is answered as an action's: `Unauthenticated` as 401, and `Forbidden` as 403, naming its `scopes` in an `insufficient_scope` challenge under Bearer, so an OAuth client steps up. `.combine` puts middleware reading the identity inside it.
|
|
359
|
+
- A caller the router never routes, such as a Node `upgrade` handler, verifies its own credential and answers a refusal with `refusalResponse(error, { protectedResource, authorization })`: the status, JSON, `Cache-Control: no-store` and challenge authentication answers that refusal with, by the rules above, `invalid_token` when `authorization` presented a bearer token. An error the descriptor declares takes the descriptor too, `refusalResponse(error, { authentication: Login, protectedResource, authorization })`, and is answered as its routes answer it. Give it the `protectedResource` given to `layer`, or its challenges name no metadata URL and no `scopesRequired`. It is an `HttpServerResponse`; `HttpServerResponse.toWeb` gives a web `Response`. It refuses, by throwing, the resource `layer` refuses: one under a descriptor of another scheme, an invalid `scopesSupported` or `scopesRequired`, or a `resource` with a fragment.
|
|
360
|
+
- Such a caller reads its header with `bearerTokenOf(authorization)`, an `Option` of the `Redacted` token, given the header as the web `Headers.get` returns it, `null` when absent: the reading every challenge is decided by and a Bearer descriptor's verifier receives, as Effect's `HttpApiSecurity.bearer` decodes a request's header. Whitespace around the header value is no part of it, as an HTTP parser strips it before a route reads it; then `Bearer`, matched case-insensitively, one or more spaces, and the rest of the header is the token, a malformed one such as `a b` too. No scheme, another scheme, or nothing after the scheme is none.
|
|
361
|
+
- Verify that a token was issued for this resource, its audience `resource`, as MCP authorization requires (RFC 8707): one issued for another resource fails with `Unauthenticated`, as any token that does not verify. `layer` publishes `resource` in its discovery, but reads no token.
|
|
362
|
+
- An Effect building the verifier runs when the provider's layer is built, once per layer graph however many layers it is provided to, as a handler builder does ([guarantees.md](guarantees.md#dependency-lifetimes)). The services it yields are startup requirements of that layer: `Authentication.layer(Login, build).pipe(Layer.provide(Verifier.layer))`, or provided after it, `routes.pipe(Layer.provide(authenticate), Layer.provide(Verifier.layer))`. A resource it acquires lives as long as the layer; its failure fails the layer, so the server does not start. A verifier needing no startup service is passed as it is, `Authentication.layer(Login, verify)`.
|
|
363
|
+
- The services a verifier yields per request, beyond what the router provides, such as the request and its scope, are request requirements, as a handler's are. Native router middleware provides them, provided after the authentication, each in a `Layer.provide` of its own: `routes.pipe(Layer.provide(authenticate), Layer.provide(resolveTenant.layer))`. One array, `Layer.provide([authenticate, resolveTenant.layer])`, gives both to the routes and neither to the other, so the verifier's request requirement stays owed ([Failure modes](#failure-modes)). A startup service, such as a verifier, is yielded by the Effect building the verifier instead, never per request.
|
|
364
|
+
- Middleware reading the identity is the HTTP layer's own: `ActionHttp.layer(Http, apps, { middleware: [LogCaller] })`, native `HttpApiMiddleware` that runs inside the authentication of a protected route and before decoding ([ActionHttp.md](ActionHttp.md#rules)). One requiring the identity takes a layer whose `actions` lists only protected ones. An MCP endpoint has none.
|
|
365
|
+
- Router middleware provided around the routes runs before their authentication. Middleware that must also cover discovery and unrouted requests, such as a Host or Origin check, or one setting security headers on every answer, is global middleware, `HttpRouter.middleware(check, { global: true })`, merged beside the routes: it runs before routing. Merged before the routes, it also runs before the discovery `layer` publishes.
|
|
366
|
+
- A local surface has no remote caller: the host provides the identity service itself, as `Effect.provideService(CurrentActor, actor)`, or on a CLI command, `Command.provideSync(CurrentActor, actor)`. `authorize` still runs. An identity only a host supplies, such as an admin CLI's operator, is typed apart from what a verifier returns, so no token names it ([ActionCli.md](ActionCli.md#trusted-callers)).
|
|
367
|
+
- Resources a verifier acquires in the request scope live until that scope closes, including while the handler runs.
|
|
84
368
|
- Downstream action errors are handled by their transport. They are never serialized as authentication failures.
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
- `
|
|
89
|
-
-
|
|
90
|
-
- A
|
|
369
|
+
- Never provide `CurrentActor` or any identity or tenant tag in a startup layer or root context: [guarantees.md](guarantees.md#dependency-lifetimes).
|
|
370
|
+
- A resource read from configuration or a service is given as an Effect: `Authentication.layer(Login, verify, { protectedResource: Effect.map(Settings, ({ resource }) => resource) })`. Succeeding with `undefined` publishes nothing and challenges with a bare `Bearer`.
|
|
371
|
+
- A protected resource's discovery is published when the provider's layer is built, once per layer graph, however many layers it is provided to, and needs no route of its own. It publishes what it is given: the deployment must ensure `resource` and `authorizationServers` are valid OAuth URLs (HTTPS, or loopback HTTP in development).
|
|
372
|
+
- Discovery is served at `/.well-known/oauth-protected-resource` followed by the resource's path (`/.well-known/oauth-protected-resource/mcp` for `https://host/mcp`), for `GET` and `HEAD`, matching that literal path and query, and answers the CORS preflight there. It answers before routing, so no route middleware, authentication included, covers it. Other requests fall through to the host router. Caching policy is the host's.
|
|
373
|
+
- Any origin may read discovery, as a browser MCP client must after a 401: it carries `Access-Control-Allow-Origin: *`, and answers an `OPTIONS` preflight at its URL with **204**, allowing `GET`, `HEAD` and `OPTIONS` and the headers the preflight asks for. Where the host's CORS middleware runs before it, that policy answers discovery's preflight and adds its headers to discovery's reads, so an origin it allows may read discovery, and discovery keeps its `*` where the policy sets no origin. Global middleware runs in the order it registers while layers build, which is the order it is merged in unless something built before it is asynchronous; either way, an origin the host's policy allows may read discovery.
|
|
374
|
+
- A host with two resources makes one descriptor and one `layer` per resource; a cookie-authenticated dashboard beside them takes a descriptor of its own, on a binding of its own.
|
|
375
|
+
- Tool discovery is never filtered by actor. Action-level authorization belongs in the implementation's `authorize`, which runs with the action contract in hand; write the rule against `action.readOnly` rather than repeating a check in each handler. Record-level checks follow [guarantees.md](guarantees.md#authorization).
|
|
91
376
|
|
|
92
377
|
## Failure modes
|
|
93
378
|
|
|
94
|
-
-
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
- A
|
|
379
|
+
- `Provider<CurrentActor, "example.Login">` among a layer's requirements, `Type 'Provider<...>' is not assignable to type 'never'` where the server is launched, at `Layer.launch` or `NodeRuntime.runMain`, or `Expected 2 arguments, but got 1` at a web handler's `handler(request)`: a layer serves protected actions, and no provider of the binding's or endpoint's descriptor is provided to it. Provide it, `Layer.provide(authenticate)`. A value of the identity provided at startup, around `HttpRouter.serve` or the program, or per request with `HttpRouter.provideRequest`, does not satisfy it, by design. Where the routes require no provider, `Expected 2 arguments` at `handler(request)` means their platform services are not provided instead ([Testing.md](Testing.md#failure-modes)).
|
|
380
|
+
- `Provider<CurrentActor, "example.Login">` still owed although a `layer` is provided: it is a provider of another descriptor, of the same identity or not. Provide the provider of the descriptor the binding or endpoint names.
|
|
381
|
+
- `No overload matches this call` at `ActionHttp.make`, its last overload naming `"Protected actions take options naming their authentication"`, or a type error naming `authentication` there or at `ActionMcp.layerHttp`: the binding or endpoint serves a protected action and names no descriptor. Give `{ authentication: Login }`. `"Authentication descriptor does not cover every protected action"` instead: the descriptor's identity is not the one the protected actions declare.
|
|
382
|
+
- `Protected action '<name>' requires its matching authentication descriptor` thrown by `ActionHttp.make`, `ActionHttp.layer` or `ActionMcp.layerHttp`: plain JavaScript gave no descriptor, or one of another identity. Give the descriptor of the identity the action declares.
|
|
383
|
+
- A type error on `security` at `make`, or `Authentication takes one native HttpApiSecurity scheme` thrown by `make`: `security` is a record of schemes, or no native scheme at all. Omit it for Bearer, or give one scheme, `HttpApiSecurity.apiKey({ in: "cookie", key: "session" })`.
|
|
384
|
+
- A type error on `protectedResource` at `layer`, or `A protected resource is published only for a Bearer scheme` thrown by `layer` or `refusalResponse`: the descriptor names another scheme, which OAuth discovery and challenges do not speak. Drop the resource, or authenticate with a Bearer descriptor.
|
|
385
|
+
- `Invalid authentication name: "<name>", not an OpenAPI key` thrown by `make`: the name holds a character an OpenAPI component key cannot, such as `/` or `:`. Use letters, digits, `_`, `.` and `-`: `example.Login`.
|
|
386
|
+
- `Missing verify: pass a verify function, or an Effect building one`, a defect when the provider's layer builds: `layer` was given neither a function nor an Effect building one, as plain JavaScript may pass. Pass `verify` itself.
|
|
387
|
+
- `Request<"Requires", Tenant>` still owed although the tenant middleware's layer is provided: it was provided in one array with the authentication, `Layer.provide([authenticate, resolveTenant.layer])`, whose members provide to the routes and not to one another. Provide it in a later `Layer.provide` of its own ([Rules](#rules)).
|
|
388
|
+
- An action that must be authenticated answers without credentials: its contract states `caller: Action.Anyone`. Make it protected; the surfaces then authenticate it, whatever its handler reads.
|
|
389
|
+
- A public tool's call answers 401 on an MCP endpoint serving protected tools too: it presented a credential that does not verify, such as an expired token, and the endpoint verifies a presented credential. Send a valid one, or none.
|
|
390
|
+
- An MCP host asks for sign-in when it connects: every tool of the endpoint is protected, so every request authenticates. One serving public tools too lists its tools and runs the public ones without signing in, and a protected tool's call answers the 401 the host signs in on; whether a host signs in then, rather than reporting the error, is the host's.
|
|
391
|
+
- Type error at `layer` on `verify`: a verifier fails with an error that is neither a refusal nor one its descriptor declares, or succeeds with a value that is not the identity. Declare the error in the descriptor's `error`, or map it to a refusal where it is the caller's, and return the identity's type.
|
|
392
|
+
- `Authentication "<name>": error _tag "<tag>" is built in, and declared on every surface` thrown by `make`: an `error` entry encodes with the `_tag` of `InvalidInput`, `Unauthenticated` or `Forbidden`. Fail with the built-in refusal itself, which needs no declaring, or give the error a `_tag` of its own.
|
|
393
|
+
- A verifier's declared error arrives as 422, which a client reads as its request being wrong: the error states no `httpApiStatus`, and a declared error without one is sent as 422, as an action's is. Give it the status it means, such as `{ httpApiStatus: 503 }` for an issuer the verifier cannot reach.
|
|
394
|
+
- `Not a refusal, nor an error the authentication declares` thrown by `refusalResponse`: the error is neither, or the descriptor declaring it was not given. Pass `authentication`.
|
|
395
|
+
- `Verifier` unsatisfied at startup: the Effect building the verifier yields it. Provide it to the provider's layer, `Authentication.layer(Login, build).pipe(Layer.provide(Verifier.layer))`, or above it.
|
|
396
|
+
- A request the Host or Origin check should refuse gets a 401 and a challenge: the check is the HTTP layer's `middleware`, which runs inside the authentication. Make it router middleware around the routes, or global middleware, merged beside the routes, to cover discovery too ([Rules](#rules)).
|
|
397
|
+
- Discovery returns 404: the request path does not match `resource`'s path and query exactly, or the provider is provided to no served layer.
|
|
398
|
+
- An MCP client does not find the authorization server: discovery is not at the well-known URL for the endpoint's path. Set `resource` to the endpoint's exact URL.
|
|
399
|
+
- An MCP client reports `InsufficientScopeError` instead of re-authorizing: it has no OAuth provider configured, so it cannot step up. Configure one, or grant the scope up front.
|
|
400
|
+
- `new Action.Forbidden({ scopes })` throws a schema validation error: a scope is not an OAuth scope token (it is empty, or contains a space, `"` or `\`). Give each scope as its own element.
|
|
401
|
+
- An MCP client asks a user who only reads to consent to writes on first login: the 401 names no scope, so it requests every one of `scopesSupported`. Give `scopesRequired`.
|
|
402
|
+
- `Authentication "<name>": the binding's descriptor is not its provider's`, a defect when `ActionHttp.layer` builds: the binding names a descriptor built apart from the one `layer(...)` was given, such as a browser's static cookie descriptor served with a provider built at startup from the deployment's, or the same options passed to `make` twice. Give the server's binding the descriptor its provider was built from.
|
|
403
|
+
- `Invalid scope in scopesRequired: "<scope>"`, or `in scopesSupported`, thrown by `layer` or `refusalResponse`, or for a built resource a defect when its layer builds: a scope is empty or contains a space, `"` or `\`. Give each scope as its own element.
|
|
404
|
+
- `A protected resource has no fragment: <resource>` thrown by `layer` or `refusalResponse`, or for a built resource a defect when its layer builds: `resource` has a `#fragment`, which no request URL carries, so its discovery would never answer (RFC 9728). Drop the fragment.
|
|
405
|
+
- A route's `Cache-Control` is replaced by `no-store`: an enclosing middleware serialized the response of a failure. Only a route's own answer keeps its caching.
|