@gjermundgaraba/effect-actions 0.11.0 → 0.13.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 +102 -41
- package/dist/Action.d.mts +31 -117
- package/dist/Action.mjs +14 -53
- package/dist/ActionCli.d.mts +60 -28
- package/dist/ActionCli.mjs +54 -48
- package/dist/ActionHttp.d.mts +18 -67
- package/dist/ActionHttp.mjs +20 -70
- package/dist/ActionMcp.d.mts +23 -29
- package/dist/ActionMcp.mjs +56 -114
- package/dist/ActionRpc.d.mts +147 -0
- package/dist/ActionRpc.mjs +113 -0
- package/dist/ActionToolkit.d.mts +28 -9
- package/dist/ActionToolkit.mjs +31 -4
- package/dist/Authentication.d.mts +15 -15
- package/dist/Authentication.mjs +42 -96
- package/dist/Testing.d.mts +30 -23
- package/dist/Testing.mjs +27 -44
- package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
- package/dist/authentication/provider.mjs +12 -0
- package/dist/authentication/refusal.mjs +36 -0
- package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
- package/dist/cli/commands.mjs +302 -0
- package/dist/{internal → contract}/call.d.mts +2 -2
- package/dist/contract/call.mjs +10 -0
- package/dist/{internal → contract}/errors.d.mts +13 -10
- package/dist/{internal → contract}/errors.mjs +9 -14
- package/dist/{internal → contract}/implementation.d.mts +37 -21
- package/dist/{internal → contract}/implementation.mjs +18 -49
- package/dist/contract/media.d.mts +13 -0
- package/dist/contract/media.mjs +18 -0
- package/dist/contract/rules.d.mts +25 -0
- package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
- package/dist/{internal → http}/client.d.mts +7 -9
- package/dist/http/client.mjs +17 -0
- package/dist/http/declared.mjs +20 -0
- package/dist/http/schema-errors.mjs +9 -0
- package/dist/mcp/protocol.d.mts +18 -0
- package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
- package/dist/stdio/console.mjs +64 -0
- package/dist/{internal → testing}/memory.d.mts +1 -1
- package/dist/{internal → testing}/memory.mjs +5 -17
- package/dist/toolkit/tools.mjs +29 -0
- package/docs/Action-failures.md +47 -0
- package/docs/Action-in-process.md +93 -0
- package/docs/Action.md +211 -426
- package/docs/ActionCli-failures.md +104 -0
- package/docs/ActionCli.md +208 -293
- package/docs/ActionHttp-failures.md +83 -0
- package/docs/ActionHttp.md +205 -282
- package/docs/ActionMcp-failures.md +31 -0
- package/docs/ActionMcp.md +222 -271
- package/docs/ActionRpc-failures.md +21 -0
- package/docs/ActionRpc.md +179 -0
- package/docs/ActionToolkit-failures.md +15 -0
- package/docs/ActionToolkit.md +103 -119
- package/docs/Authentication-advanced.md +139 -0
- package/docs/Authentication-failures.md +99 -0
- package/docs/Authentication.md +143 -392
- package/docs/CONTEXT.md +40 -24
- package/docs/README.md +75 -35
- package/docs/Testing-failures.md +21 -0
- package/docs/Testing.md +193 -151
- package/docs/guarantees.md +35 -96
- package/docs/setup-failures.md +37 -0
- package/docs/setup.md +90 -92
- package/examples/README.md +68 -0
- package/examples/assistant/README.md +92 -0
- package/examples/assistant/src/agent.ts +92 -0
- package/examples/assistant/src/cli.ts +34 -0
- package/examples/assistant/src/commands.ts +25 -0
- package/examples/assistant/src/identity/Identity.ts +33 -0
- package/examples/assistant/src/identity/authorize.ts +24 -0
- package/examples/assistant/src/identity/handlers.ts +10 -0
- package/examples/assistant/src/identity/members.ts +9 -0
- package/examples/assistant/src/identity/permissions.ts +23 -0
- package/examples/assistant/src/scripted.ts +23 -0
- package/examples/assistant/src/tasks/Tasks.ts +63 -0
- package/examples/assistant/src/tasks/handlers.ts +25 -0
- package/examples/assistant/src/tasks/store.ts +96 -0
- package/examples/assistant/src/toolkit.ts +30 -0
- package/examples/assistant/test/commands.test.ts +57 -0
- package/examples/assistant/test/identity/identity.test.ts +43 -0
- package/examples/assistant/test/tasks/store.test.ts +48 -0
- package/examples/assistant/test/tasks/tasks.test.ts +75 -0
- package/examples/assistant/test/toolkit.test.ts +121 -0
- package/examples/kitchen-sink/README.md +318 -0
- package/examples/kitchen-sink/src/Actions.ts +19 -0
- package/examples/kitchen-sink/src/Api.ts +24 -0
- package/examples/kitchen-sink/src/Rpc.ts +12 -0
- package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
- package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
- package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
- package/examples/kitchen-sink/src/admin/grants.ts +22 -0
- package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
- package/examples/kitchen-sink/src/admin/keys.ts +8 -0
- package/examples/kitchen-sink/src/agent.ts +47 -0
- package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
- package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
- package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
- package/examples/kitchen-sink/src/assistant/handlers.ts +126 -0
- package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
- package/examples/kitchen-sink/src/attachments/download.ts +49 -0
- package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
- package/examples/kitchen-sink/src/attachments/store.ts +58 -0
- package/examples/kitchen-sink/src/browser.ts +33 -0
- package/examples/kitchen-sink/src/cli.ts +85 -0
- package/examples/kitchen-sink/src/config.ts +18 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
- package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
- package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
- package/examples/kitchen-sink/src/identity/directory.ts +52 -0
- package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
- package/examples/kitchen-sink/src/identity/members.ts +13 -0
- package/examples/kitchen-sink/src/identity/quota.ts +62 -0
- package/examples/kitchen-sink/src/issues/Issues.ts +111 -0
- package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
- package/examples/kitchen-sink/src/issues/guide.ts +37 -0
- package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
- package/examples/kitchen-sink/src/issues/store.ts +96 -0
- package/examples/kitchen-sink/src/issues/write-limit.ts +42 -0
- package/examples/kitchen-sink/src/memory.ts +30 -0
- package/examples/kitchen-sink/src/ops.ts +42 -0
- package/examples/kitchen-sink/src/request-policy.ts +63 -0
- package/examples/kitchen-sink/src/routes.ts +120 -0
- package/examples/kitchen-sink/src/sdk.ts +46 -0
- package/examples/kitchen-sink/src/seed.ts +65 -0
- package/examples/kitchen-sink/src/server.ts +62 -0
- package/examples/kitchen-sink/src/stdio.ts +34 -0
- package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
- package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
- package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
- package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
- package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
- package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
- package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
- package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
- package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
- package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
- package/examples/kitchen-sink/test/routes.test.ts +123 -0
- package/examples/kitchen-sink/test/support/scripted.ts +40 -0
- package/examples/live-rpc/README.md +107 -0
- package/examples/live-rpc/src/Rpc.ts +8 -0
- package/examples/live-rpc/src/browser.ts +98 -0
- package/examples/live-rpc/src/chat/Chat.ts +77 -0
- package/examples/live-rpc/src/chat/authorize.ts +16 -0
- package/examples/live-rpc/src/chat/handlers.ts +50 -0
- package/examples/live-rpc/src/chat/room.ts +23 -0
- package/examples/live-rpc/src/chat/store.ts +54 -0
- package/examples/live-rpc/src/client.ts +37 -0
- package/examples/live-rpc/src/identity/Identity.ts +19 -0
- package/examples/live-rpc/src/identity/members.ts +8 -0
- package/examples/live-rpc/src/identity/verifier.ts +23 -0
- package/examples/live-rpc/src/routes.ts +22 -0
- package/examples/live-rpc/src/server.ts +43 -0
- package/examples/live-rpc/test/browser.test.ts +105 -0
- package/examples/live-rpc/test/chat/chat.test.ts +69 -0
- package/examples/live-rpc/test/routes.test.ts +99 -0
- package/examples/live-rpc/test/server.test.ts +75 -0
- package/examples/mcp-tools/README.md +141 -0
- package/examples/mcp-tools/src/instructions.ts +12 -0
- package/examples/mcp-tools/src/printers/Printers.ts +121 -0
- package/examples/mcp-tools/src/printers/fake.ts +115 -0
- package/examples/mcp-tools/src/printers/farm.ts +136 -0
- package/examples/mcp-tools/src/printers/handlers.ts +29 -0
- package/examples/mcp-tools/src/routes.ts +52 -0
- package/examples/mcp-tools/src/server.ts +31 -0
- package/examples/mcp-tools/src/stdio.ts +26 -0
- package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
- package/examples/mcp-tools/test/routes.test.ts +202 -0
- package/examples/notes-service/README.md +111 -0
- package/examples/notes-service/src/Api.ts +13 -0
- package/examples/notes-service/src/browser.ts +41 -0
- package/examples/notes-service/src/cli.ts +22 -0
- package/examples/notes-service/src/commands.ts +137 -0
- package/examples/notes-service/src/identity/Identity.ts +31 -0
- package/examples/notes-service/src/identity/authorize.ts +18 -0
- package/examples/notes-service/src/identity/handlers.ts +10 -0
- package/examples/notes-service/src/identity/users.ts +9 -0
- package/examples/notes-service/src/identity/verifier.ts +36 -0
- package/examples/notes-service/src/notes/Notes.ts +119 -0
- package/examples/notes-service/src/notes/handlers.ts +39 -0
- package/examples/notes-service/src/notes/store.ts +84 -0
- package/examples/notes-service/src/routes.ts +45 -0
- package/examples/notes-service/src/server.ts +26 -0
- package/examples/notes-service/src/status/Status.ts +10 -0
- package/examples/notes-service/src/status/handlers.ts +14 -0
- package/examples/notes-service/src/stdio.ts +25 -0
- package/examples/notes-service/test/commands.test.ts +288 -0
- package/examples/notes-service/test/notes/notes.test.ts +103 -0
- package/examples/notes-service/test/routes.test.ts +84 -0
- package/examples/quickstart/README.md +31 -0
- package/examples/quickstart/src/Api.ts +5 -0
- package/examples/quickstart/src/client.ts +13 -0
- package/examples/quickstart/src/greeting/Greeting.ts +12 -0
- package/examples/quickstart/src/greeting/handlers.ts +6 -0
- package/examples/quickstart/src/routes.ts +11 -0
- package/examples/quickstart/src/server.ts +16 -0
- package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
- package/package.json +27 -9
- package/dist/internal/actions.d.mts +0 -9
- package/dist/internal/authentication.mjs +0 -11
- package/dist/internal/call.mjs +0 -12
- package/dist/internal/cli.mjs +0 -328
- package/dist/internal/client.mjs +0 -25
- package/dist/internal/console.mjs +0 -77
- package/dist/internal/declared.d.mts +0 -13
- package/dist/internal/declared.mjs +0 -34
- package/dist/internal/mcp.d.mts +0 -14
- package/dist/internal/media.d.mts +0 -8
- package/dist/internal/media.mjs +0 -26
- package/dist/internal/refusal.mjs +0 -67
- package/dist/internal/schema-errors.mjs +0 -20
- package/dist/internal/tools.mjs +0 -44
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { InvalidInput } from "./contract/errors.mjs";
|
|
2
|
+
import { Anyone, assertDistinct, assertOnce, assertOwnTags, errorList, projectedErrors } from "./contract/rules.mjs";
|
|
3
|
+
import { acquire, provideHandlers, servedBy, toList } from "./contract/implementation.mjs";
|
|
4
|
+
import { checked } from "./contract/call.mjs";
|
|
5
|
+
import { assertAuthentication, providerOf, withVerifierErrors } from "./authentication/provider.mjs";
|
|
6
|
+
import { Cause, Effect, ErrorReporter, Layer, Predicate, Result, Schema, SchemaTransformation } from "effect";
|
|
7
|
+
import { HttpClientRequest, HttpServerRequest } from "effect/http";
|
|
8
|
+
import { HttpApiBuilder } from "effect/http-api";
|
|
9
|
+
import { Rpc, RpcClient, RpcGroup, RpcSerialization, RpcServer } from "effect/rpc";
|
|
10
|
+
//#region src/rpc/ActionRpc.ts
|
|
11
|
+
const declaredErrorsOf = (action, errors, authentication) => projectedErrors(action, withVerifierErrors(action, errors, authentication?.error));
|
|
12
|
+
function make(actions, options = {}) {
|
|
13
|
+
assertOnce("action", actions);
|
|
14
|
+
const errors = errorList(options.error);
|
|
15
|
+
assertOwnTags("ActionRpc binding", errors);
|
|
16
|
+
assertAuthentication(actions, options.authentication);
|
|
17
|
+
return {
|
|
18
|
+
actions,
|
|
19
|
+
error: errors,
|
|
20
|
+
group: RpcGroup.make(...actions.map((action) => Rpc.make(action.name, {
|
|
21
|
+
payload: action.input,
|
|
22
|
+
success: action.success,
|
|
23
|
+
error: Schema.Union(declaredErrorsOf(action, errors, options.authentication))
|
|
24
|
+
}))),
|
|
25
|
+
authentication: options.authentication
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const credentialFromRpcHeaders = (security, headers) => HttpApiBuilder.securityDecode(security).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(HttpClientRequest.get("/", { headers }))), Effect.provideService(HttpServerRequest.ParsedSearchParams, {}));
|
|
29
|
+
const authenticationStepOf = (authentication) => Effect.map(providerOf(authentication), (provider) => (effect, { headers }) => credentialFromRpcHeaders(authentication.security, headers).pipe(Effect.flatMap(provider.authenticate), Effect.flatMap((actor) => Effect.provideService(effect, authentication.identity, actor))));
|
|
30
|
+
var InternalError = class extends Error {
|
|
31
|
+
[ErrorReporter.ignore] = true;
|
|
32
|
+
constructor() {
|
|
33
|
+
super("Internal server error");
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var DeclaredFailure = class {
|
|
37
|
+
json;
|
|
38
|
+
[ErrorReporter.ignore] = true;
|
|
39
|
+
constructor(json) {
|
|
40
|
+
this.json = json;
|
|
41
|
+
}
|
|
42
|
+
toString() {
|
|
43
|
+
return Predicate.isString(this.json) ? this.json : JSON.stringify(this.json);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const DeclaredFromJson = Schema.Json.pipe(Schema.decodeTo(Schema.instanceOf(DeclaredFailure), SchemaTransformation.transform({
|
|
47
|
+
decode: (json) => new DeclaredFailure(json),
|
|
48
|
+
encode: (failure) => failure.json
|
|
49
|
+
})));
|
|
50
|
+
const callBoundaryOf = (error) => {
|
|
51
|
+
const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(error));
|
|
52
|
+
const internal = (cause) => Effect.logError(cause).pipe(Effect.andThen(ErrorReporter.report(cause)), Effect.andThen(Effect.die(new InternalError())));
|
|
53
|
+
return (effect) => Effect.catchCause(effect, (cause) => {
|
|
54
|
+
if (Cause.hasDies(cause)) return internal(cause);
|
|
55
|
+
const failure = Cause.findFail(cause);
|
|
56
|
+
return Result.isSuccess(failure) ? encode(failure.success.error).pipe(Effect.matchCauseEffect({
|
|
57
|
+
onFailure: (unencoded) => internal(Cause.combine(cause, unencoded)),
|
|
58
|
+
onSuccess: (encoded) => Effect.fail(new DeclaredFailure(encoded))
|
|
59
|
+
})) : Effect.failCause(cause);
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
const handlerOf = (action, run, steps, error) => {
|
|
63
|
+
const decodeStrictly = Schema.decodeUnknownEffect(Schema.toCodecJson(action.input), {
|
|
64
|
+
errors: "all",
|
|
65
|
+
onExcessProperty: "error"
|
|
66
|
+
});
|
|
67
|
+
const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(action.success));
|
|
68
|
+
const boundary = callBoundaryOf(error);
|
|
69
|
+
return (payload, options) => {
|
|
70
|
+
const call = Effect.suspend(() => decodeStrictly(payload)).pipe(Effect.mapError(InvalidInput.fromSchemaError), Effect.flatMap(run), Effect.flatMap(encode));
|
|
71
|
+
const wrapped = steps.reduce((inner, step) => Effect.suspend(() => step(inner, {
|
|
72
|
+
...options,
|
|
73
|
+
payload
|
|
74
|
+
})), call);
|
|
75
|
+
return boundary(wrapped);
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
function layer(binding, served, options = {}) {
|
|
79
|
+
const apps = servedBy("RPC binding", binding.actions, toList(served), options.actions);
|
|
80
|
+
const actions = apps.flatMap((app) => app.actions);
|
|
81
|
+
assertAuthentication(actions, binding.authentication);
|
|
82
|
+
const middleware = options.middleware ?? [];
|
|
83
|
+
assertDistinct("middleware", middleware, (key) => key.key);
|
|
84
|
+
const auth = actions.some((action) => action.caller !== Anyone) ? binding.authentication : void 0;
|
|
85
|
+
const jsonGroup = RpcGroup.make(...actions.map((action) => Rpc.make(action.name, {
|
|
86
|
+
payload: Schema.Json,
|
|
87
|
+
success: Schema.Json,
|
|
88
|
+
error: DeclaredFromJson
|
|
89
|
+
})));
|
|
90
|
+
const handlers = Layer.unwrap(Effect.gen(function* () {
|
|
91
|
+
const { codecFor } = yield* RpcServer.Protocol;
|
|
92
|
+
if (codecFor !== RpcSerialization.json.codecFor) return yield* Effect.die(/* @__PURE__ */ new Error("ActionRpc serves a protocol speaking JSON: RpcSerialization.layerJson, layerNdjson, layerJsonRpc or layerNdJsonRpc, not another codec, such as schema-binary's"));
|
|
93
|
+
const listedMiddleware = yield* Effect.forEach(middleware, (key) => Effect.service(key));
|
|
94
|
+
const authenticate = auth === void 0 ? void 0 : yield* authenticationStepOf(auth);
|
|
95
|
+
const bound = yield* acquire(apps);
|
|
96
|
+
const byName = Object.fromEntries(bound.map(([action, run]) => [action.name, handlerOf(action, run, action.caller === Anyone || authenticate === void 0 ? listedMiddleware : [...listedMiddleware, authenticate], Schema.Union(declaredErrorsOf(action, binding.error, binding.authentication)))]));
|
|
97
|
+
return jsonGroup.toLayer(byName);
|
|
98
|
+
})).pipe(provideHandlers(apps));
|
|
99
|
+
return RpcServer.layer(jsonGroup, { disableFatalDefects: true }).pipe(Layer.provide(handlers));
|
|
100
|
+
}
|
|
101
|
+
function client(binding, options) {
|
|
102
|
+
const group = binding.group;
|
|
103
|
+
return Effect.map(RpcClient.make(group, options), (client) => {
|
|
104
|
+
const native = client;
|
|
105
|
+
return Object.fromEntries(binding.actions.map((action) => {
|
|
106
|
+
const method = native[action.name];
|
|
107
|
+
if (method === void 0) throw new Error(`No client method for ${action.name}`);
|
|
108
|
+
return [action.name, checked(action, (payload) => method(payload))];
|
|
109
|
+
}));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
export { client, layer, make };
|
package/dist/ActionToolkit.d.mts
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
|
-
import { BuiltIns } from "./
|
|
2
|
-
import { ActionOf, BuildError, BuildServices, Holding, Known, Member, Offered, OptionalUnless, RequestOf, SelectedOf, Served, Serving } from "./
|
|
1
|
+
import { BuiltIns } from "./contract/errors.mjs";
|
|
2
|
+
import { ActionOf, BuildError, BuildServices, Holding, Known, Member, Offered, OptionalUnless, RequestOf, SelectedOf, Served, Serving } from "./contract/implementation.mjs";
|
|
3
3
|
import { Action_d_exports, Any } from "./Action.mjs";
|
|
4
4
|
import { Effect, Layer, Schema } from "effect";
|
|
5
|
-
import { Tool, Toolkit } from "effect/ai";
|
|
6
|
-
//#region src/ActionToolkit.d.ts
|
|
7
|
-
/** A native tool named after its action, taking and giving JSON. */
|
|
5
|
+
import { Prompt, Tool, Toolkit } from "effect/ai";
|
|
6
|
+
//#region src/toolkit/ActionToolkit.d.ts
|
|
8
7
|
type NativeTool<A extends Any, R> = Tool.Tool<A["name"], {
|
|
9
8
|
readonly parameters: Schema.toCodecJson<A["input"]>;
|
|
10
9
|
readonly success: Schema.toCodecJson<A["success"]>;
|
|
11
10
|
readonly failure: Schema.toCodecJson<Schema.Union<ReadonlyArray<A["error"][number] | BuiltIns>>>;
|
|
12
11
|
readonly failureMode: "return";
|
|
13
12
|
}, R>;
|
|
14
|
-
/**
|
|
15
|
-
* The tool of each action of `App` among `Listed`. A tool needs what its handler and its
|
|
16
|
-
* implementation's authorization need.
|
|
17
|
-
*/
|
|
18
13
|
type ToolFor<App, Listed extends Any> = App extends unknown ? Serving<App, Listed> extends (infer A extends Any) ? A extends Any ? NativeTool<A, RequestOf<App, A>> : never : never : never;
|
|
19
14
|
/**
|
|
20
15
|
* Native tools bound to their action implementations: a native `Toolkit` and the layer of
|
|
@@ -64,6 +59,30 @@ export interface Options<A extends Any> {
|
|
|
64
59
|
* when the resulting toolkit handles a call, the identity an implementation's `authorize`
|
|
65
60
|
* reads included: the caller provides it. `needsApproval` marks the calls a model must have
|
|
66
61
|
* approved; it authorizes nothing, which stays `authorize`'s.
|
|
62
|
+
* Guide: `docs/ActionToolkit.md` in this package; a loop served behind a route, with approval:
|
|
63
|
+
* `examples/kitchen-sink/src/assistant/`.
|
|
67
64
|
*/
|
|
68
65
|
export declare function make<const Apps extends Served, const O extends Options<ActionOf<Member<Apps>>> = {}>(implementations: Apps, ...options: OptionalUnless<O, Options<ActionOf<Member<Apps>>> & O & NoInfer<Known<O, Options<Any>>>>): Tools<{ readonly [T in ToolFor<Member<Apps>, Offered<O, ActionOf<Member<Apps>>>> as T["name"]]: T; }, BuildError<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>, BuildServices<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>>;
|
|
66
|
+
/** A tool call a model asked to have approved, which no answer has resolved yet. */
|
|
67
|
+
export interface Approval {
|
|
68
|
+
/** What a `tool-approval-response` names to answer it. */
|
|
69
|
+
readonly approvalId: string;
|
|
70
|
+
readonly toolCallId: string;
|
|
71
|
+
/** The tool's name, the action's. */
|
|
72
|
+
readonly name: string;
|
|
73
|
+
/** The call's arguments as the model sent them: show them as JSON. */
|
|
74
|
+
readonly params: unknown;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The approval requests still open in a conversation's history, such as a `Chat`'s, in the
|
|
78
|
+
* order they were asked: those with neither an answer nor a tool result. An answer recorded
|
|
79
|
+
* without a result, as a `streamText` turn that failed while its call ran leaves it, is not
|
|
80
|
+
* open: the next turn acts on it unasked, and another answer would run the call twice. A turn's
|
|
81
|
+
* response lists only the requests that turn made, and the answering turn's none, so read every
|
|
82
|
+
* open one here. Each names the call an answer would run, found as Effect finds it: the last
|
|
83
|
+
* request with its `approvalId`, listed where it was asked, and the last call with its id.
|
|
84
|
+
* Guide: `docs/ActionToolkit.md` in this package; in an application:
|
|
85
|
+
* `examples/kitchen-sink/src/assistant/handlers.ts`.
|
|
86
|
+
*/
|
|
87
|
+
export declare const openApprovals: ({ content: messages }: Prompt.Prompt) => ReadonlyArray<Approval>;
|
|
69
88
|
//#endregion
|
package/dist/ActionToolkit.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Implementation, provideHandlers, select, toList } from "./
|
|
2
|
-
import { bindTools } from "./
|
|
3
|
-
//#region src/ActionToolkit.ts
|
|
1
|
+
import { Implementation, provideHandlers, select, toList } from "./contract/implementation.mjs";
|
|
2
|
+
import { bindTools } from "./toolkit/tools.mjs";
|
|
3
|
+
//#region src/toolkit/ActionToolkit.ts
|
|
4
4
|
function make(apps, options) {
|
|
5
5
|
const served = select(toList(apps), options?.actions);
|
|
6
6
|
const needsApproval = options?.needsApproval;
|
|
@@ -17,5 +17,32 @@ function make(apps, options) {
|
|
|
17
17
|
layer: layer.pipe(provideHandlers(served))
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The approval requests still open in a conversation's history, such as a `Chat`'s, in the
|
|
22
|
+
* order they were asked: those with neither an answer nor a tool result. An answer recorded
|
|
23
|
+
* without a result, as a `streamText` turn that failed while its call ran leaves it, is not
|
|
24
|
+
* open: the next turn acts on it unasked, and another answer would run the call twice. A turn's
|
|
25
|
+
* response lists only the requests that turn made, and the answering turn's none, so read every
|
|
26
|
+
* open one here. Each names the call an answer would run, found as Effect finds it: the last
|
|
27
|
+
* request with its `approvalId`, listed where it was asked, and the last call with its id.
|
|
28
|
+
* Guide: `docs/ActionToolkit.md` in this package; in an application:
|
|
29
|
+
* `examples/kitchen-sink/src/assistant/handlers.ts`.
|
|
30
|
+
*/
|
|
31
|
+
const openApprovals = ({ content: messages }) => {
|
|
32
|
+
const said = messages.flatMap((message) => message.role === "assistant" ? message.content : []);
|
|
33
|
+
const told = messages.flatMap((message) => message.role === "tool" ? message.content : []);
|
|
34
|
+
const answered = new Set(told.flatMap((part) => part.type === "tool-approval-response" ? [part.approvalId] : []));
|
|
35
|
+
const ran = new Set(told.flatMap((part) => part.type === "tool-result" ? [part.id] : []));
|
|
36
|
+
const calls = new Map(said.flatMap((part) => part.type === "tool-call" ? [[part.id, part]] : []));
|
|
37
|
+
return said.filter((part) => part.type === "tool-approval-request").filter(({ approvalId }, place, all) => all.findLastIndex((later) => later.approvalId === approvalId) === place).flatMap(({ approvalId, toolCallId }) => {
|
|
38
|
+
const call = calls.get(toolCallId);
|
|
39
|
+
return call === void 0 || answered.has(approvalId) || ran.has(toolCallId) ? [] : [{
|
|
40
|
+
approvalId,
|
|
41
|
+
toolCallId,
|
|
42
|
+
name: call.name,
|
|
43
|
+
params: call.params
|
|
44
|
+
}];
|
|
45
|
+
});
|
|
46
|
+
};
|
|
20
47
|
//#endregion
|
|
21
|
-
export { make };
|
|
48
|
+
export { make, openApprovals };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { ErrorsOf } from "./contract/rules.mjs";
|
|
2
|
+
import { Refusal } from "./contract/errors.mjs";
|
|
3
|
+
import { Known, OptionalUnless } from "./contract/implementation.mjs";
|
|
3
4
|
import { Errors } from "./Action.mjs";
|
|
4
|
-
import { Any, Descriptor, Provider, Security } from "./
|
|
5
|
-
import { ErrorsOf } from "./internal/declared.mjs";
|
|
5
|
+
import { Any, Descriptor, Provider, Security } from "./authentication/provider.mjs";
|
|
6
6
|
import { Array, Context, Effect, Layer, Option, Redacted, Scope } from "effect";
|
|
7
7
|
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
8
8
|
import { HttpApiSecurity } from "effect/http-api";
|
|
9
|
-
//#region src/Authentication.d.ts
|
|
9
|
+
//#region src/authentication/Authentication.d.ts
|
|
10
10
|
/**
|
|
11
11
|
* The bearer token of an `Authorization` header, `authorization`, or none, read as Effect's
|
|
12
12
|
* `HttpApiSecurity.bearer` reads it for routes and MCP, which a test holds equal. It is for a
|
|
@@ -17,7 +17,10 @@ import { HttpApiSecurity } from "effect/http-api";
|
|
|
17
17
|
export declare const bearerTokenOf: (authorization: string | null | undefined) => Option.Option<Redacted.Redacted<string>>;
|
|
18
18
|
/** An OAuth protected resource (RFC 9728), as `layer` publishes it. */
|
|
19
19
|
export interface ProtectedResource {
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Exact OAuth resource identifier; its path and query select the discovery path. It has no
|
|
22
|
+
* fragment, which no request URL carries, so its discovery would never answer (RFC 9728).
|
|
23
|
+
*/
|
|
21
24
|
readonly resource: string;
|
|
22
25
|
/** Where clients get tokens: nonempty. */
|
|
23
26
|
readonly authorizationServers: Array.NonEmptyReadonlyArray<string>;
|
|
@@ -50,7 +53,6 @@ export interface RefusalResponseOptions<D extends Any = Any> {
|
|
|
50
53
|
*/
|
|
51
54
|
readonly authorization?: string | null | undefined;
|
|
52
55
|
}
|
|
53
|
-
/** What the descriptor `D` declares its verifier fails with besides a refusal. */
|
|
54
56
|
type DeclaredOf<D> = D extends {
|
|
55
57
|
readonly error: infer E extends Errors;
|
|
56
58
|
} ? E[number]["Type"] : never;
|
|
@@ -76,7 +78,6 @@ export interface Options {
|
|
|
76
78
|
*/
|
|
77
79
|
readonly error?: Errors | Errors[number];
|
|
78
80
|
}
|
|
79
|
-
/** The scheme options `O` give: Bearer too wherever they may leave it out, as at run time. */
|
|
80
81
|
type SecurityOf<O> = O extends {
|
|
81
82
|
readonly security: infer S extends Security;
|
|
82
83
|
} ? S : "security" extends keyof O ? NonNullable<O["security" & keyof O]> | HttpApiSecurity.Http : HttpApiSecurity.Http;
|
|
@@ -86,6 +87,7 @@ type SecurityOf<O> = O extends {
|
|
|
86
87
|
* as `HttpApiSecurity.apiKey({ in: "cookie", key: "session" })`. The literal name
|
|
87
88
|
* identifies the provider, like a native Context.Key name, and the scheme's OpenAPI key, so it
|
|
88
89
|
* holds only letters, digits, `_`, `.` and `-`; reuse one name only for one declaration.
|
|
90
|
+
* Guide: `docs/Authentication.md` in this package; in an application: `examples/notes-service/src/identity/`.
|
|
89
91
|
*/
|
|
90
92
|
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
93
|
/**
|
|
@@ -97,21 +99,19 @@ export type Verify<A, S extends Security, R, E = never> = (credential: HttpApiSe
|
|
|
97
99
|
export interface LayerOptions<EP = never, RP = never> {
|
|
98
100
|
readonly protectedResource?: ProtectedResource | Effect.Effect<ProtectedResource | undefined, EP, RP>;
|
|
99
101
|
}
|
|
100
|
-
/**
|
|
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.
|
|
103
|
-
*/
|
|
104
102
|
type ResourceOf<S> = [S] extends [HttpApiSecurity.Http] ? unknown : {
|
|
105
103
|
readonly protectedResource?: undefined;
|
|
106
104
|
};
|
|
105
|
+
type Resource = ProtectedResource | Effect.Effect<ProtectedResource | undefined, unknown, unknown> | undefined;
|
|
107
106
|
/**
|
|
108
107
|
* The provider of `authentication`: `verify`, or an Effect building it once per layer graph, run on
|
|
109
108
|
* each remote request a protected action receives, with the credential the descriptor's
|
|
110
109
|
* scheme decodes. A Bearer scheme answers each 401 with its challenge, and may publish the
|
|
111
|
-
* OAuth protected resource `protectedResource
|
|
110
|
+
* OAuth protected resource `protectedResource`, on the `HttpRouter` it then requires.
|
|
112
111
|
*/
|
|
113
|
-
export declare function layer<const Name extends string, I, A, const S extends Security, E extends Errors, R, EX = never, RX = never,
|
|
114
|
-
|
|
112
|
+
export declare function layer<const Name extends string, I, A, const S extends Security, E extends Errors, R, EX = never, RX = never, P extends Resource = undefined>(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?: {
|
|
113
|
+
readonly protectedResource?: P;
|
|
114
|
+
} & ResourceOf<S>): Layer.Layer<Provider<I, Name>, EX | Effect.Error<P>, ([P] extends [undefined] ? never : HttpRouter.HttpRouter) | Exclude<RX | Effect.Services<P>, Scope.Scope> | HttpRouter.Request.From<"Requires", Exclude<R, HttpRouter.Provided>>>;
|
|
115
115
|
type Provides<I> = unknown extends I ? never : I;
|
|
116
116
|
/**
|
|
117
117
|
* What `protect` returns for a descriptor named `Name` authenticating the identity `I`: router
|
package/dist/Authentication.mjs
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import { Unauthenticated, scopeToken } from "./
|
|
2
|
-
import { assertOwnTags, errorList } from "./
|
|
3
|
-
import {
|
|
4
|
-
import { answer, answerStepUp, bearer, challenge, isRefusal, isStepUp, plain } from "./
|
|
1
|
+
import { Unauthenticated, scopeToken } from "./contract/errors.mjs";
|
|
2
|
+
import { assertOwnTags, errorList } from "./contract/rules.mjs";
|
|
3
|
+
import { providerOf } from "./authentication/provider.mjs";
|
|
4
|
+
import { answer, answerStepUp, bearer, challenge, isRefusal, isStepUp, plain } from "./authentication/refusal.mjs";
|
|
5
|
+
import { declaredResponse } from "./http/declared.mjs";
|
|
5
6
|
import { Context, Effect, Layer, Match, Option, Predicate, Redacted } from "effect";
|
|
6
7
|
import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
7
8
|
import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/http-api";
|
|
8
|
-
//#region src/Authentication.ts
|
|
9
|
-
|
|
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.
|
|
14
|
-
*/
|
|
15
|
-
const bearerScheme = /^[ \t]*Bearer +(.*[^ \t])[ \t]*$/i;
|
|
9
|
+
//#region src/authentication/Authentication.ts
|
|
10
|
+
const bearerAuthorizationHeader = /^[ \t]*Bearer +(.*[^ \t])[ \t]*$/i;
|
|
16
11
|
/**
|
|
17
12
|
* The bearer token of an `Authorization` header, `authorization`, or none, read as Effect's
|
|
18
13
|
* `HttpApiSecurity.bearer` reads it for routes and MCP, which a test holds equal. It is for a
|
|
@@ -20,44 +15,24 @@ const bearerScheme = /^[ \t]*Bearer +(.*[^ \t])[ \t]*$/i;
|
|
|
20
15
|
* own is authenticated by `protect`. The scheme is matched case-insensitively, as RFC 9110
|
|
21
16
|
* requires, and the token is `Redacted`, as Effect's own decoder gives it.
|
|
22
17
|
*/
|
|
23
|
-
const bearerTokenOf = (authorization) => Option.map(Option.fromNullishOr(
|
|
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) => {
|
|
18
|
+
const bearerTokenOf = (authorization) => Option.map(Option.fromNullishOr(bearerAuthorizationHeader.exec(authorization ?? "")?.[1]), (token) => Redacted.make(token));
|
|
19
|
+
const metadataUrlOf = (options) => {
|
|
29
20
|
const resource = new URL(options.resource);
|
|
30
21
|
const url = new URL(resource);
|
|
31
22
|
url.pathname = `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`;
|
|
32
23
|
return url;
|
|
33
24
|
};
|
|
34
|
-
/** What challenges about `options` name. */
|
|
35
25
|
const namedOf = (options) => ({
|
|
36
26
|
scope: options?.scopesRequired?.join(" "),
|
|
37
|
-
metadataUrl: options === void 0 ? void 0 :
|
|
27
|
+
metadataUrl: options === void 0 ? void 0 : metadataUrlOf(options).href
|
|
38
28
|
});
|
|
39
|
-
|
|
40
|
-
const
|
|
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([
|
|
29
|
+
const presentsBearerToken = (authorization) => Option.isSome(bearerTokenOf(authorization));
|
|
30
|
+
const bearerChallengeOf = (named, presented) => bearer([
|
|
48
31
|
["error", presented ? "invalid_token" : void 0],
|
|
49
32
|
["scope", named.scope],
|
|
50
33
|
["resource_metadata", named.metadataUrl]
|
|
51
34
|
]);
|
|
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) => {
|
|
35
|
+
const assertPublishableResource = (oauth, options) => {
|
|
61
36
|
if (options === void 0) return;
|
|
62
37
|
if (!oauth) throw new Error("A protected resource is published only for a Bearer scheme");
|
|
63
38
|
if (Effect.isEffect(options)) return;
|
|
@@ -67,11 +42,7 @@ const assertResource = (oauth, options) => {
|
|
|
67
42
|
}
|
|
68
43
|
if (new URL(options.resource).href.includes("#")) throw new Error(`A protected resource has no fragment: ${options.resource}`);
|
|
69
44
|
};
|
|
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, {
|
|
45
|
+
const withNoStoreAndChallenge = (response, challenge) => HttpServerResponse.setHeaders(response, {
|
|
75
46
|
...response.headers["cache-control"] === void 0 ? { "cache-control": "no-store" } : {},
|
|
76
47
|
...challenge !== void 0 && response.status === 401 && response.headers["www-authenticate"] === void 0 ? { "www-authenticate": challenge } : {}
|
|
77
48
|
});
|
|
@@ -89,32 +60,19 @@ const settle = (response, challenge) => HttpServerResponse.setHeaders(response,
|
|
|
89
60
|
const refusalResponse = (error, options) => {
|
|
90
61
|
const authentication = options?.authentication;
|
|
91
62
|
const oauth = authentication === void 0 || isBearer(authentication.security);
|
|
92
|
-
|
|
63
|
+
assertPublishableResource(oauth, options?.protectedResource);
|
|
93
64
|
const named = namedOf(options?.protectedResource);
|
|
94
|
-
const challenge = authentication !== void 0 && !oauth ?
|
|
65
|
+
const challenge = authentication !== void 0 && !oauth ? nonBearerChallengeOf(authentication.security, authentication.name) : bearerChallengeOf(named, presentsBearerToken(options?.authorization));
|
|
95
66
|
if (!isRefusal(error)) {
|
|
96
67
|
const response = declaredResponse(authentication?.error ?? [], error);
|
|
97
68
|
if (response === void 0) throw new Error("Not a refusal, nor an error the authentication declares");
|
|
98
|
-
return
|
|
69
|
+
return withNoStoreAndChallenge(response, challenge);
|
|
99
70
|
}
|
|
100
|
-
return
|
|
71
|
+
return withNoStoreAndChallenge(oauth ? answer(error, named.metadataUrl) : plain(error), challenge);
|
|
101
72
|
};
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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);
|
|
73
|
+
const mayResolveToDiscoveryPath = /\.well-known|[\t\n\r]/;
|
|
74
|
+
const discoveryMiddleware = (options) => {
|
|
75
|
+
const discoveryUrl = metadataUrlOf(options);
|
|
118
76
|
const target = discoveryUrl.href.slice(discoveryUrl.origin.length);
|
|
119
77
|
const metadata = HttpServerResponse.jsonUnsafe({
|
|
120
78
|
resource: options.resource,
|
|
@@ -123,8 +81,7 @@ const discovery = (options) => {
|
|
|
123
81
|
scopes_supported: options.scopesSupported,
|
|
124
82
|
resource_name: options.resourceName
|
|
125
83
|
}, { headers: { "access-control-allow-origin": "*" } });
|
|
126
|
-
|
|
127
|
-
const preflight = (request) => {
|
|
84
|
+
const corsPreflight = (request) => {
|
|
128
85
|
const requested = request.headers["access-control-request-headers"];
|
|
129
86
|
return HttpServerResponse.empty({
|
|
130
87
|
status: 204,
|
|
@@ -139,46 +96,36 @@ const discovery = (options) => {
|
|
|
139
96
|
});
|
|
140
97
|
};
|
|
141
98
|
return (next) => Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => {
|
|
142
|
-
const targeted =
|
|
99
|
+
const targeted = mayResolveToDiscoveryPath.test(request.url) && URL.canParse(request.url, discoveryUrl.origin) && new URL(request.url, discoveryUrl.origin).href.slice(discoveryUrl.origin.length) === target;
|
|
143
100
|
if (targeted && (request.method === "GET" || request.method === "HEAD")) return Effect.succeed(metadata);
|
|
144
|
-
if (targeted && request.method === "OPTIONS") return Effect.succeed(
|
|
101
|
+
if (targeted && request.method === "OPTIONS") return Effect.succeed(corsPreflight(request));
|
|
145
102
|
return next;
|
|
146
103
|
});
|
|
147
104
|
};
|
|
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) => {
|
|
105
|
+
const resourceAnswersOf = (options) => {
|
|
154
106
|
const named = namedOf(options);
|
|
155
107
|
return {
|
|
156
108
|
metadataUrl: named.metadataUrl,
|
|
157
|
-
invalid:
|
|
158
|
-
anonymous:
|
|
159
|
-
published: options === void 0 ? void 0 :
|
|
109
|
+
invalid: bearerChallengeOf(named, true),
|
|
110
|
+
anonymous: bearerChallengeOf(named, false),
|
|
111
|
+
published: options === void 0 ? void 0 : discoveryMiddleware(options)
|
|
160
112
|
};
|
|
161
113
|
};
|
|
162
|
-
/** Whether `security` is the Bearer scheme, the one OAuth challenges and discovery name. */
|
|
163
114
|
const isBearer = (security) => Predicate.isTagged(security, "Http") && security.scheme.toLowerCase() === "bearer";
|
|
164
|
-
|
|
165
|
-
const
|
|
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);
|
|
115
|
+
const openApiComponentKey = /^[\w.-]+$/;
|
|
116
|
+
const nonBearerChallengeOf = (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
117
|
/**
|
|
172
118
|
* Browser-safe declaration shared by clients and remote surfaces: the `identity` it
|
|
173
119
|
* authenticates, by the one native scheme `security`, Bearer unless it names another, such
|
|
174
120
|
* as `HttpApiSecurity.apiKey({ in: "cookie", key: "session" })`. The literal name
|
|
175
121
|
* identifies the provider, like a native Context.Key name, and the scheme's OpenAPI key, so it
|
|
176
122
|
* holds only letters, digits, `_`, `.` and `-`; reuse one name only for one declaration.
|
|
123
|
+
* Guide: `docs/Authentication.md` in this package; in an application: `examples/notes-service/src/identity/`.
|
|
177
124
|
*/
|
|
178
125
|
const make = (name, identity, ...options) => {
|
|
179
126
|
const security = options[0]?.security ?? HttpApiSecurity.bearer;
|
|
180
127
|
const error = errorList(options[0]?.error);
|
|
181
|
-
if (!
|
|
128
|
+
if (!openApiComponentKey.test(name)) throw new Error(`Invalid authentication name: ${JSON.stringify(name)}, not an OpenAPI key`);
|
|
182
129
|
assertOwnTags(`Authentication "${name}"`, error);
|
|
183
130
|
if (!Predicate.isTagged(security, "Http") && !Predicate.isTagged(security, "ApiKey") && !Predicate.isTagged(security, "Basic")) throw new Error("Authentication takes one native HttpApiSecurity scheme");
|
|
184
131
|
return {
|
|
@@ -191,27 +138,25 @@ const make = (name, identity, ...options) => {
|
|
|
191
138
|
"~verified": Context.Service(`effect-actions/Authentication/Verified/${name}`)
|
|
192
139
|
};
|
|
193
140
|
};
|
|
194
|
-
/** Whether `credential`, as a native scheme decodes it, holds anything: an absent one is empty. */
|
|
195
141
|
const presented = (credential) => Redacted.isRedacted(credential) ? Redacted.value(credential) !== "" : credential.username !== "" || Redacted.value(credential.password) !== "";
|
|
196
142
|
function layer(authentication, verify, options = {}) {
|
|
197
143
|
const { security } = authentication;
|
|
198
144
|
const oauth = isBearer(security);
|
|
199
145
|
const protectedResource = options.protectedResource;
|
|
200
|
-
|
|
146
|
+
assertPublishableResource(oauth, protectedResource);
|
|
201
147
|
const resource = Effect.isEffect(protectedResource) ? Effect.map(protectedResource, (built) => {
|
|
202
|
-
|
|
203
|
-
return
|
|
204
|
-
}) : Effect.succeed(
|
|
148
|
+
assertPublishableResource(oauth, built);
|
|
149
|
+
return resourceAnswersOf(built);
|
|
150
|
+
}) : Effect.succeed(resourceAnswersOf(protectedResource));
|
|
205
151
|
const missing = new Unauthenticated({ message: oauth ? "A bearer token is required." : "A credential is required." });
|
|
206
152
|
return Layer.effect(authentication["~provider"], Effect.gen(function* () {
|
|
207
|
-
const router = yield* HttpRouter.HttpRouter;
|
|
208
153
|
const { metadataUrl, invalid, anonymous, published } = yield* resource;
|
|
209
|
-
if (published !== void 0) yield*
|
|
154
|
+
if (published !== void 0) yield* (yield* HttpRouter.HttpRouter).addGlobalMiddleware(published);
|
|
210
155
|
const verifier = Effect.isEffect(verify) ? yield* verify : verify;
|
|
211
156
|
if (!Predicate.isFunction(verifier)) return yield* Effect.die(/* @__PURE__ */ new Error("Missing verify: pass a verify function, or an Effect building one"));
|
|
212
157
|
const authenticate = (credential) => presented(credential) ? verifier(credential) : Effect.fail(missing);
|
|
213
|
-
const
|
|
214
|
-
const challengeOf = (credential) => !oauth ?
|
|
158
|
+
const noStoreOnFailure = HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")));
|
|
159
|
+
const challengeOf = (credential) => !oauth ? nonBearerChallengeOf(security, authentication.name) : presented(credential) ? invalid : anonymous;
|
|
215
160
|
const refuse = (error) => isRefusal(error) ? Effect.succeed(oauth ? answer(error, metadataUrl) : plain(error)) : Effect.suspend(() => {
|
|
216
161
|
const response = declaredResponse(authentication.error, error);
|
|
217
162
|
return response === void 0 ? Effect.die(error) : Effect.succeed(response);
|
|
@@ -221,10 +166,11 @@ function layer(authentication, verify, options = {}) {
|
|
|
221
166
|
return Effect.matchEffect(authenticate(credential), {
|
|
222
167
|
onFailure: refuse,
|
|
223
168
|
onSuccess: (actor) => Effect.provideService(route, slot, actor)
|
|
224
|
-
}).pipe(Effect.onError(() =>
|
|
169
|
+
}).pipe(Effect.onError(() => noStoreOnFailure), HttpEffect.withPreResponseHandler((_request, response) => Effect.succeed(withNoStoreAndChallenge(response, challenge))));
|
|
225
170
|
};
|
|
226
171
|
return {
|
|
227
172
|
descriptor: authentication,
|
|
173
|
+
authenticate,
|
|
228
174
|
middleware: { [authentication.name]: (route, { credential }) => verified(credential, route, authentication.identity) },
|
|
229
175
|
stepUp: (route) => oauth ? Effect.catchIf(route, isStepUp, (error) => Effect.succeed(answer(error, metadataUrl))) : route,
|
|
230
176
|
http: ((route, optional) => Effect.flatMap(HttpApiBuilder.securityDecode(security), (credential) => optional && !presented(credential) ? route : verified(credential, oauth ? answerStepUp(route, metadataUrl) : route, authentication["~verified"]))),
|
|
@@ -243,6 +189,6 @@ function layer(authentication, verify, options = {}) {
|
|
|
243
189
|
* `layer(authentication, ...)`. An erased descriptor, `Any`, provides no service the types can
|
|
244
190
|
* name: its identity is `unknown`, which would discharge every request service the route owes.
|
|
245
191
|
*/
|
|
246
|
-
const protect = (authentication) => HttpRouter.middleware()(Effect.map(
|
|
192
|
+
const protect = (authentication) => HttpRouter.middleware()(Effect.map(providerOf(authentication), (runtime) => (route) => runtime.route(route)));
|
|
247
193
|
//#endregion
|
|
248
194
|
export { bearerTokenOf, layer, make, protect, refusalResponse };
|