@gjermundgaraba/effect-actions 0.11.0 → 0.12.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 +101 -40
- package/dist/Action.d.mts +29 -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 +5 -8
- package/dist/ActionToolkit.mjs +3 -3
- package/dist/Authentication.d.mts +15 -15
- package/dist/Authentication.mjs +42 -96
- package/dist/Testing.d.mts +28 -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 +17 -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 +206 -426
- package/docs/ActionCli-failures.md +102 -0
- package/docs/ActionCli.md +206 -293
- package/docs/ActionHttp-failures.md +83 -0
- package/docs/ActionHttp.md +202 -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 +178 -0
- package/docs/ActionToolkit-failures.md +15 -0
- package/docs/ActionToolkit.md +100 -119
- package/docs/Authentication-advanced.md +139 -0
- package/docs/Authentication-failures.md +99 -0
- package/docs/Authentication.md +142 -392
- package/docs/CONTEXT.md +39 -24
- package/docs/README.md +71 -35
- package/docs/Testing-failures.md +20 -0
- package/docs/Testing.md +186 -151
- package/docs/guarantees.md +35 -96
- package/docs/setup-failures.md +34 -0
- package/docs/setup.md +87 -91
- 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 +317 -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 +146 -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 +109 -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 +41 -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 +75 -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 +136 -0
- package/examples/mcp-tools/src/instructions.ts +12 -0
- package/examples/mcp-tools/src/printers/Printers.ts +119 -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 +30 -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 +108 -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 +89 -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 +91 -0
- package/examples/notes-service/src/notes/handlers.ts +33 -0
- package/examples/notes-service/src/notes/store.ts +73 -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 +134 -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 +23 -5
- 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,109 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
4
|
+
|
|
5
|
+
export const Status = Schema.Literals(["open", "closed"]);
|
|
6
|
+
|
|
7
|
+
export const Comment = Schema.Struct({ author: Schema.String, text: Schema.String });
|
|
8
|
+
|
|
9
|
+
export const Issue = Schema.Struct({
|
|
10
|
+
id: Schema.String,
|
|
11
|
+
title: Schema.String,
|
|
12
|
+
body: Schema.String,
|
|
13
|
+
status: Status,
|
|
14
|
+
assignee: Schema.optional(Schema.String),
|
|
15
|
+
comments: Schema.Array(Comment),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type Issue = typeof Issue.Type;
|
|
19
|
+
|
|
20
|
+
// A declared error is part of the contract: every client decodes it as this class, and its
|
|
21
|
+
// status is what HTTP answers with. Without `httpApiStatus` it would be a 422.
|
|
22
|
+
export class IssueNotFound extends Schema.TaggedError<IssueNotFound>()(
|
|
23
|
+
"IssueNotFound",
|
|
24
|
+
{ id: Schema.String },
|
|
25
|
+
{ httpApiStatus: 404 },
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
export class RateLimited extends Schema.TaggedError<RateLimited>()(
|
|
29
|
+
"RateLimited",
|
|
30
|
+
{ retryAfterSeconds: Schema.Finite },
|
|
31
|
+
{ httpApiStatus: 429 },
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
// A shared error list: what any write to an existing issue may fail with. Each such action
|
|
35
|
+
// spreads this one array into its `error`, here and in attachments/Attachments.ts, so an error
|
|
36
|
+
// added to the list is declared by all of them.
|
|
37
|
+
export const IssueWriteErrors = [IssueNotFound, RateLimited] as const;
|
|
38
|
+
|
|
39
|
+
// An option bundle. What the two actions changing an issue share is one constant object spread
|
|
40
|
+
// into `Action.make`, typed as if written inline. `readOnly` and `description` stay on each
|
|
41
|
+
// action: authorization and an MCP client's hints read them.
|
|
42
|
+
const change = {
|
|
43
|
+
caller: CurrentMember,
|
|
44
|
+
error: IssueWriteErrors,
|
|
45
|
+
// MCP hints, in MCP's names. They tell a model what a tool does and enforce nothing; the
|
|
46
|
+
// read-only hint is `readOnly` itself.
|
|
47
|
+
mcp: { destructiveHint: false, openWorldHint: false },
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
const Text = Schema.String.check(Schema.isMinLength(1));
|
|
51
|
+
|
|
52
|
+
export const ListIssues = Action.make("listIssues", {
|
|
53
|
+
description: "List your workspace's issues, newest first, without bodies or comments.",
|
|
54
|
+
input: {
|
|
55
|
+
// Optional: left out, issues of every status are listed.
|
|
56
|
+
status: Schema.optional(Status),
|
|
57
|
+
// Defaulted: left out, the handler receives 20. A field description is also the help text
|
|
58
|
+
// of its CLI flag and the description in the tool's schema.
|
|
59
|
+
limit: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 })).pipe(
|
|
60
|
+
Schema.annotate({ description: "How many issues to list, at most 100." }),
|
|
61
|
+
Schema.withDecodingDefaultKey(Effect.succeed(20)),
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
success: {
|
|
65
|
+
issues: Schema.Array(
|
|
66
|
+
Schema.Struct({ id: Schema.String, title: Schema.String, status: Status }),
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
readOnly: true,
|
|
70
|
+
caller: CurrentMember,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const GetIssue = Action.make("getIssue", {
|
|
74
|
+
description: "Read one issue with its comments.",
|
|
75
|
+
input: { id: Schema.String },
|
|
76
|
+
success: Issue,
|
|
77
|
+
error: IssueNotFound,
|
|
78
|
+
readOnly: true,
|
|
79
|
+
caller: CurrentMember,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const CreateIssue = Action.make("createIssue", {
|
|
83
|
+
description: "File an issue in your workspace.",
|
|
84
|
+
input: { title: Text, body: Schema.String, assignee: Schema.optional(Schema.String) },
|
|
85
|
+
success: Issue,
|
|
86
|
+
error: RateLimited,
|
|
87
|
+
readOnly: false,
|
|
88
|
+
caller: CurrentMember,
|
|
89
|
+
mcp: { title: "File an issue", destructiveHint: false, openWorldHint: false },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const CommentOnIssue = Action.make("commentOnIssue", {
|
|
93
|
+
...change,
|
|
94
|
+
description: "Add a comment to an issue.",
|
|
95
|
+
input: { id: Schema.String, text: Text },
|
|
96
|
+
success: Issue,
|
|
97
|
+
readOnly: false,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const CloseIssue = Action.make("closeIssue", {
|
|
101
|
+
...change,
|
|
102
|
+
description: "Close an issue. Closing a closed issue changes nothing.",
|
|
103
|
+
input: { id: Schema.String },
|
|
104
|
+
success: Issue,
|
|
105
|
+
readOnly: false,
|
|
106
|
+
mcp: { ...change.mcp, idempotentHint: true },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const Issues = [ListIssues, GetIssue, CreateIssue, CommentOnIssue, CloseIssue] as const;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { LanguageModel } from "effect/ai";
|
|
2
|
+
import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
|
|
3
|
+
import { issues } from "./handlers.js";
|
|
4
|
+
import { CloseIssue, CommentOnIssue, GetIssue, ListIssues } from "./Issues.js";
|
|
5
|
+
|
|
6
|
+
// The actions a model may call, listed here, where they are served: an action added to the
|
|
7
|
+
// contracts later is no tool until it is listed. Filing issues is left to people.
|
|
8
|
+
const tools = [ListIssues, GetIssue, CommentOnIssue, CloseIssue] as const;
|
|
9
|
+
|
|
10
|
+
// Some of the issue actions as a model's tools: Effect's native `Toolkit`, in process, with no
|
|
11
|
+
// server between. `toolkit` holds the tool definitions and `layer` their handlers, the same
|
|
12
|
+
// implementation every other surface serves, behind the same `authorize` and write limit.
|
|
13
|
+
export const { toolkit, layer } = ActionToolkit.make(issues, {
|
|
14
|
+
actions: tools,
|
|
15
|
+
// One rule over every call a model makes, before the call runs: a write ends the turn with
|
|
16
|
+
// an approval request instead of a result. Approval is not authorization: `authorize` still
|
|
17
|
+
// runs for an approved call, as for any other.
|
|
18
|
+
needsApproval: (call) => !call.action.readOnly,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// One turn of the assistant. The caller provides the model, and the member it acts for around
|
|
22
|
+
// the whole turn: approval, `authorize` and the handlers all read that one caller.
|
|
23
|
+
export const triage = (prompt: string) => LanguageModel.generateText({ prompt, toolkit });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect, Layer, Schema } from "effect";
|
|
2
|
+
import { McpServer } from "effect/ai";
|
|
3
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
+
import { Issues } from "./Issues.js";
|
|
5
|
+
|
|
6
|
+
const guide = `# Triage
|
|
7
|
+
|
|
8
|
+
- An issue is \`open\` or \`closed\`. Its id is the workspace's key and a number: \`ACME-12\`.
|
|
9
|
+
- Read an issue before commenting on it, and comment before closing it: say why.
|
|
10
|
+
- Writes are limited to ten a minute. When refused, wait as long as the error says.
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
// The contracts keyed by name, each its exact contract. The prompt below names its tools through
|
|
14
|
+
// them, so renaming an action is a compile error here, not a stale instruction to a model.
|
|
15
|
+
const tools = Action.byName(Issues);
|
|
16
|
+
|
|
17
|
+
// Effect's own MCP resources and prompts, served beside the actions' tools as an endpoint's
|
|
18
|
+
// `features`. They are native, not actions: no `authorize` runs for them, and they run with
|
|
19
|
+
// the services they were built with, never a request's, so they cannot read the caller.
|
|
20
|
+
// Content that depends on the caller is an action.
|
|
21
|
+
export const features = Layer.mergeAll(
|
|
22
|
+
McpServer.resource({
|
|
23
|
+
uri: "tracker://guides/triage",
|
|
24
|
+
name: "Triage guide",
|
|
25
|
+
mimeType: "text/markdown",
|
|
26
|
+
content: Effect.succeed(guide),
|
|
27
|
+
}),
|
|
28
|
+
McpServer.prompt({
|
|
29
|
+
name: "triage",
|
|
30
|
+
description: "Go through the open issues and propose what to do with each.",
|
|
31
|
+
parameters: { focus: Schema.String },
|
|
32
|
+
content: ({ focus }) =>
|
|
33
|
+
Effect.succeed(
|
|
34
|
+
`Call ${tools.listIssues.name} for the open issues and ${tools.getIssue.name} for those about ${focus}. For each, propose ${tools.commentOnIssue.name} or ${tools.closeIssue.name}. Follow tracker://guides/triage.`,
|
|
35
|
+
),
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { authorize } from "../identity/authorize.js";
|
|
4
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
5
|
+
import { Issues } from "./Issues.js";
|
|
6
|
+
import { IssueStore } from "./store.js";
|
|
7
|
+
import { writeLimit } from "./write-limit.js";
|
|
8
|
+
|
|
9
|
+
// Several actions sharing one builder. It runs once, at startup, however many surfaces serve
|
|
10
|
+
// these actions, and yields the startup services: the store, and the write limit built on the
|
|
11
|
+
// limiter. `CurrentMember` is a per-request service: each handler reads it per call, so one
|
|
12
|
+
// build serves every caller. No handler checks a scope: `authorize` has run before it.
|
|
13
|
+
export const issues = Action.implement(
|
|
14
|
+
Issues,
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const store = yield* IssueStore;
|
|
17
|
+
const limited = yield* writeLimit;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
listIssues: ({ status, limit }) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const member = yield* CurrentMember;
|
|
23
|
+
const listed = yield* store.list(member.workspace, status, limit);
|
|
24
|
+
|
|
25
|
+
return { issues: listed.map(({ id, title, status }) => ({ id, title, status })) };
|
|
26
|
+
}),
|
|
27
|
+
getIssue: ({ id }) =>
|
|
28
|
+
Effect.flatMap(CurrentMember, (member) => store.get(member.workspace, id)),
|
|
29
|
+
// One function wraps every limited handler, and gives it the member it counted.
|
|
30
|
+
createIssue: (draft) => limited((member) => store.create(member.workspace, draft)),
|
|
31
|
+
commentOnIssue: ({ id, text }) =>
|
|
32
|
+
limited((member) => store.comment(member.workspace, id, { author: member.id, text })),
|
|
33
|
+
closeIssue: ({ id }) => limited((member) => store.close(member.workspace, id)),
|
|
34
|
+
};
|
|
35
|
+
}),
|
|
36
|
+
{ authorize },
|
|
37
|
+
);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { type Comment, type Issue, IssueNotFound, type Status } from "./Issues.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where issues live, each in one workspace. Every method takes the workspace, so tenant
|
|
6
|
+
* isolation is the data access's: no surface and no handler can skip it, and an issue of
|
|
7
|
+
* another workspace is simply not found.
|
|
8
|
+
*/
|
|
9
|
+
export class IssueStore extends Context.Service<
|
|
10
|
+
IssueStore,
|
|
11
|
+
{
|
|
12
|
+
readonly list: (
|
|
13
|
+
workspace: string,
|
|
14
|
+
status: typeof Status.Type | undefined,
|
|
15
|
+
limit: number,
|
|
16
|
+
) => Effect.Effect<ReadonlyArray<Issue>>;
|
|
17
|
+
readonly get: (workspace: string, id: string) => Effect.Effect<Issue, IssueNotFound>;
|
|
18
|
+
readonly create: (
|
|
19
|
+
workspace: string,
|
|
20
|
+
draft: Pick<Issue, "title" | "body" | "assignee">,
|
|
21
|
+
) => Effect.Effect<Issue>;
|
|
22
|
+
readonly comment: (
|
|
23
|
+
workspace: string,
|
|
24
|
+
id: string,
|
|
25
|
+
comment: typeof Comment.Type,
|
|
26
|
+
) => Effect.Effect<Issue, IssueNotFound>;
|
|
27
|
+
readonly close: (workspace: string, id: string) => Effect.Effect<Issue, IssueNotFound>;
|
|
28
|
+
}
|
|
29
|
+
>()("tracker/IssueStore") {
|
|
30
|
+
// DEMO ONLY: memory, lost when the process stops. A real store is another Layer of this
|
|
31
|
+
// service, chosen by the entrypoint.
|
|
32
|
+
static readonly layerMemory = Layer.sync(IssueStore, () => {
|
|
33
|
+
const issues = new Map<string, Array<Issue>>();
|
|
34
|
+
|
|
35
|
+
const of = (workspace: string) => issues.get(workspace) ?? [];
|
|
36
|
+
|
|
37
|
+
const get = Effect.fn("IssueStore.get")(function* (workspace: string, id: string) {
|
|
38
|
+
const issue = of(workspace).find((candidate) => candidate.id === id);
|
|
39
|
+
|
|
40
|
+
if (issue === undefined) return yield* new IssueNotFound({ id });
|
|
41
|
+
|
|
42
|
+
return issue;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// No `yield*` between the read and the write: a fiber may be set aside at one, and two
|
|
46
|
+
// changes that each read the issue before either wrote would keep only the second. A
|
|
47
|
+
// database does this in one statement or a transaction.
|
|
48
|
+
const change = Effect.fn("IssueStore.change")(function* (
|
|
49
|
+
workspace: string,
|
|
50
|
+
id: string,
|
|
51
|
+
to: (issue: Issue) => Issue,
|
|
52
|
+
) {
|
|
53
|
+
const issue = of(workspace).find((candidate) => candidate.id === id);
|
|
54
|
+
|
|
55
|
+
if (issue === undefined) return yield* new IssueNotFound({ id });
|
|
56
|
+
|
|
57
|
+
const changed = to(issue);
|
|
58
|
+
|
|
59
|
+
issues.set(
|
|
60
|
+
workspace,
|
|
61
|
+
of(workspace).map((current) => (current === issue ? changed : current)),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return changed;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return IssueStore.of({
|
|
68
|
+
list: (workspace, status, limit) =>
|
|
69
|
+
Effect.sync(() =>
|
|
70
|
+
of(workspace)
|
|
71
|
+
.filter((issue) => status === undefined || issue.status === status)
|
|
72
|
+
.toReversed()
|
|
73
|
+
.slice(0, limit),
|
|
74
|
+
),
|
|
75
|
+
get,
|
|
76
|
+
create: (workspace, draft) =>
|
|
77
|
+
Effect.sync(() => {
|
|
78
|
+
const existing = of(workspace);
|
|
79
|
+
|
|
80
|
+
const issue: Issue = {
|
|
81
|
+
...draft,
|
|
82
|
+
id: `${workspace.toUpperCase()}-${existing.length + 1}`,
|
|
83
|
+
status: "open",
|
|
84
|
+
comments: [],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
issues.set(workspace, [...existing, issue]);
|
|
88
|
+
|
|
89
|
+
return issue;
|
|
90
|
+
}),
|
|
91
|
+
comment: (workspace, id, comment) =>
|
|
92
|
+
change(workspace, id, (issue) => ({ ...issue, comments: [...issue.comments, comment] })),
|
|
93
|
+
close: (workspace, id) => change(workspace, id, (issue) => ({ ...issue, status: "closed" })),
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Duration, Effect } from "effect";
|
|
2
|
+
import { RateLimiter } from "effect/persistence";
|
|
3
|
+
import { CurrentMember, type Member } from "../identity/Identity.js";
|
|
4
|
+
import { RateLimited } from "./Issues.js";
|
|
5
|
+
|
|
6
|
+
// The write limit: ten writes a minute per member. It is the handlers', not a surface's, so it
|
|
7
|
+
// holds wherever they run: HTTP, MCP, RPC, stdio and the assistant's tools all count against
|
|
8
|
+
// the one limiter. A builder yields this once and wraps each limited handler in the function
|
|
9
|
+
// it gets, which fails with an error those actions' contracts declare; every surface sends it
|
|
10
|
+
// as any declared error. The limiter is a service, so its counts are shared wherever it is
|
|
11
|
+
// provided: memory.ts provides Effect's own, `RateLimiter.layer` over a store. `consume` counts
|
|
12
|
+
// in a fixed window unless given `algorithm: "token-bucket"`: ten writes in each minute, so
|
|
13
|
+
// twenty may pass across the boundary between two.
|
|
14
|
+
export const writeLimit = Effect.gen(function* () {
|
|
15
|
+
const limiter = yield* RateLimiter.RateLimiter;
|
|
16
|
+
|
|
17
|
+
return <A, E, R>(write: (member: Member) => Effect.Effect<A, E, R>) =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const member = yield* CurrentMember;
|
|
20
|
+
|
|
21
|
+
yield* limiter
|
|
22
|
+
.consume({
|
|
23
|
+
key: JSON.stringify(["writes", member.workspace, member.id]),
|
|
24
|
+
limit: 10,
|
|
25
|
+
window: "1 minute",
|
|
26
|
+
onExceeded: "fail",
|
|
27
|
+
})
|
|
28
|
+
.pipe(
|
|
29
|
+
Effect.catchReason(
|
|
30
|
+
"RateLimiterError",
|
|
31
|
+
"RateLimitExceeded",
|
|
32
|
+
({ retryAfter }) =>
|
|
33
|
+
Effect.fail(new RateLimited({ retryAfterSeconds: Duration.toSeconds(retryAfter) })),
|
|
34
|
+
// Any other reason is the limiter's store failing: a defect, not the caller's.
|
|
35
|
+
Effect.die,
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return yield* write(member);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { RateLimiter } from "effect/persistence";
|
|
3
|
+
import { Grants } from "./admin/grants.js";
|
|
4
|
+
import { Conversations } from "./assistant/conversations.js";
|
|
5
|
+
import { demoModel } from "./assistant/demo-model.js";
|
|
6
|
+
import { AttachmentStore } from "./attachments/store.js";
|
|
7
|
+
import { Directory } from "./identity/directory.js";
|
|
8
|
+
import { IssueStore } from "./issues/store.js";
|
|
9
|
+
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
|
10
|
+
|
|
11
|
+
// DEMO ONLY: the application's services as their memory or fixed-data Layers, lost when the
|
|
12
|
+
// process stops. A real entrypoint provides a database-backed Layer per service instead, and a
|
|
13
|
+
// test replaces the one it is about.
|
|
14
|
+
|
|
15
|
+
/** Both stores. The attachment store is built on the issue store: `provideMerge` keeps it. */
|
|
16
|
+
export const stores = AttachmentStore.layerMemory.pipe(Layer.provideMerge(IssueStore.layerMemory));
|
|
17
|
+
|
|
18
|
+
/** Effect's own limiter, counting in memory. `RateLimiter.layerStoreRedis` counts across processes. */
|
|
19
|
+
export const limiter = RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory));
|
|
20
|
+
|
|
21
|
+
/** Everything the server needs, for both ports. A local entrypoint provides only what it serves. */
|
|
22
|
+
export const memory = Layer.mergeAll(
|
|
23
|
+
stores,
|
|
24
|
+
limiter,
|
|
25
|
+
WorkspaceRegistry.layerMemory,
|
|
26
|
+
Directory.layerDemo,
|
|
27
|
+
Grants.layerDemo,
|
|
28
|
+
Conversations.layerMemory,
|
|
29
|
+
demoModel,
|
|
30
|
+
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { Command } from "effect/cli";
|
|
4
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
5
|
+
import { Operator, type TrustedOperator } from "./admin/Admin.js";
|
|
6
|
+
import { Grants } from "./admin/grants.js";
|
|
7
|
+
import { admin } from "./admin/handlers.js";
|
|
8
|
+
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
|
9
|
+
|
|
10
|
+
// Entrypoint: `node --import tsx examples/kitchen-sink/src/ops.ts list-workspaces`.
|
|
11
|
+
// The operators' console: the admin actions run in this process, on the host's own services,
|
|
12
|
+
// with no server and no key. A local command is no bypass: the implementation's `authorize`
|
|
13
|
+
// runs here as on the admin port, and admits the operator this host supplies.
|
|
14
|
+
|
|
15
|
+
// Only a host can name the trusted operator: no verifier's return type includes it.
|
|
16
|
+
const operator: TrustedOperator = { id: "console", trusted: true };
|
|
17
|
+
|
|
18
|
+
const ops = ActionCli.make(admin, {
|
|
19
|
+
name: "tracker-ops",
|
|
20
|
+
commands: {
|
|
21
|
+
listWorkspaces: {
|
|
22
|
+
render: ({ workspaces }) =>
|
|
23
|
+
workspaces
|
|
24
|
+
.map(({ slug, name, suspended }) => `${slug} ${name}${suspended ? " (suspended)" : ""}`)
|
|
25
|
+
.join("\n"),
|
|
26
|
+
},
|
|
27
|
+
suspendWorkspace: { positional: ["workspace"] },
|
|
28
|
+
resumeWorkspace: { positional: ["workspace"] },
|
|
29
|
+
},
|
|
30
|
+
}).pipe(
|
|
31
|
+
// Services go on the command: built when an action runs, never for `--help` or a mistyped
|
|
32
|
+
// flag. Only what these actions are built from. DEMO ONLY: memory, so this console sees its
|
|
33
|
+
// own process, not the server's data. A real one provides the database Layers the server does.
|
|
34
|
+
Command.provide(Layer.merge(WorkspaceRegistry.layerMemory, Grants.layerDemo)),
|
|
35
|
+
Command.provideSync(Operator, operator),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
Command.run(ops, { version: "1.0.0" }).pipe(
|
|
39
|
+
Effect.provide(NodeServices.layer),
|
|
40
|
+
ActionCli.logToStderr,
|
|
41
|
+
NodeRuntime.runMain,
|
|
42
|
+
);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Effect, Layer, Option } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
3
|
+
import { allowedOrigins, publicUrl } from "./config.js";
|
|
4
|
+
|
|
5
|
+
const loopback = ["localhost", "127.0.0.1"];
|
|
6
|
+
|
|
7
|
+
// Which Host and Origin a request may carry: not the library's concern, but every request of
|
|
8
|
+
// both ports passes it first. The web app signs in with a cookie, which a browser attaches to
|
|
9
|
+
// any page's request, so a foreign Origin must be refused before authentication reads it.
|
|
10
|
+
// That is why this is global router middleware (`global: true`): it runs before routing, so
|
|
11
|
+
// before every route's authentication. As a layer's own `middleware` it would run inside the
|
|
12
|
+
// authentication, too late. Whether it also runs before the discovery the Bearer provider
|
|
13
|
+
// publishes, global middleware too, is decided where it is provided: last, in routes.ts.
|
|
14
|
+
export const requestPolicy = HttpRouter.middleware(
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const hosts = [...loopback, new URL(yield* publicUrl).hostname];
|
|
17
|
+
const origins = yield* allowedOrigins;
|
|
18
|
+
|
|
19
|
+
return (httpEffect) =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
22
|
+
const url = HttpServerRequest.toURL(request.modify({ url: request.originalUrl }));
|
|
23
|
+
|
|
24
|
+
if (Option.isNone(url) || !hosts.includes(url.value.hostname)) {
|
|
25
|
+
return HttpServerResponse.text("Host not allowed", { status: 403 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const origin = request.headers.origin;
|
|
29
|
+
|
|
30
|
+
if (origin !== undefined && origin !== url.value.origin && !origins.includes(origin)) {
|
|
31
|
+
return HttpServerResponse.text("Origin not allowed", { status: 403 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return yield* httpEffect;
|
|
35
|
+
});
|
|
36
|
+
}),
|
|
37
|
+
{ global: true },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// CORS for the one cross-origin caller, a browser MCP client. `HttpRouter.cors` is global to
|
|
41
|
+
// the router too: it answers the preflight before any route's authentication, and adds its
|
|
42
|
+
// headers to refusals as well. An MCP endpoint's `allowedOrigins` is no CORS: it only refuses
|
|
43
|
+
// origins. The headers are those an MCP client sends and must be able to read. A client of an
|
|
44
|
+
// MCP revision with sessions sends its session's id on each request, reads it from the answer
|
|
45
|
+
// that opened the session, and ends the session with a `DELETE`.
|
|
46
|
+
export const cors = Layer.unwrap(
|
|
47
|
+
Effect.map(allowedOrigins, (origins) =>
|
|
48
|
+
HttpRouter.cors({
|
|
49
|
+
allowedOrigins: origins,
|
|
50
|
+
allowedMethods: ["POST", "DELETE"],
|
|
51
|
+
allowedHeaders: [
|
|
52
|
+
"Content-Type",
|
|
53
|
+
"Authorization",
|
|
54
|
+
"X-Workspace",
|
|
55
|
+
"MCP-Protocol-Version",
|
|
56
|
+
"MCP-Method",
|
|
57
|
+
"MCP-Name",
|
|
58
|
+
"Mcp-Session-Id",
|
|
59
|
+
],
|
|
60
|
+
exposedHeaders: ["WWW-Authenticate", "MCP-Protocol-Version", "Mcp-Session-Id"],
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
3
|
+
import { HttpApiSwagger, OpenApi } from "effect/http-api";
|
|
4
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
5
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
7
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
8
|
+
import { MemberActions, Tools } from "./Actions.js";
|
|
9
|
+
import { authenticateOperator } from "./admin/authenticate.js";
|
|
10
|
+
import { assistant } from "./assistant/handlers.js";
|
|
11
|
+
import { admin } from "./admin/handlers.js";
|
|
12
|
+
import { AdminHttp, Http } from "./Api.js";
|
|
13
|
+
import { download } from "./attachments/download.js";
|
|
14
|
+
import { attachments } from "./attachments/handlers.js";
|
|
15
|
+
import { allowedOrigins } from "./config.js";
|
|
16
|
+
import { authenticateBearer, authenticateSession } from "./identity/authenticate.js";
|
|
17
|
+
import { identity } from "./identity/handlers.js";
|
|
18
|
+
import { Login } from "./identity/Identity.js";
|
|
19
|
+
import { HttpQuota, quota, RpcQuota } from "./identity/quota.js";
|
|
20
|
+
import { layer as assistantTools } from "./issues/assistant.js";
|
|
21
|
+
import { features } from "./issues/guide.js";
|
|
22
|
+
import { issues } from "./issues/handlers.js";
|
|
23
|
+
import { cors, requestPolicy } from "./request-policy.js";
|
|
24
|
+
import { Rpc } from "./Rpc.js";
|
|
25
|
+
import { status } from "./workspaces/handlers.js";
|
|
26
|
+
import { resolveTenant } from "./workspaces/tenant.js";
|
|
27
|
+
import { GetStatus } from "./workspaces/Workspaces.js";
|
|
28
|
+
|
|
29
|
+
// Every domain's implementation of the public port's actions, once. Each surface below takes
|
|
30
|
+
// this one list and serves the actions its binding or its `actions` names.
|
|
31
|
+
const implementations = [status, identity, issues, attachments, assistant];
|
|
32
|
+
|
|
33
|
+
// JSON routes at `/api/v1/<action>`, in two layers of one binding, each naming its `actions`.
|
|
34
|
+
// The quota requires the caller, and a public route has none, so `getStatus` is served by a
|
|
35
|
+
// layer without it. The middleware runs inside authentication and before the input is decoded.
|
|
36
|
+
const api = Layer.mergeAll(
|
|
37
|
+
ActionHttp.layer(Http, implementations, { actions: [GetStatus] }),
|
|
38
|
+
ActionHttp.layer(Http, implementations, { actions: MemberActions, middleware: [HttpQuota] }),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// The same actions as MCP tools at `/mcp`: a mixed endpoint. Listing the tools and calling
|
|
42
|
+
// `getStatus` need no token; any other request answers the 401 an MCP client signs in on, the
|
|
43
|
+
// one opening a session included, since a session may call any tool.
|
|
44
|
+
const mcp = Layer.unwrap(
|
|
45
|
+
Effect.map(allowedOrigins, (origins) =>
|
|
46
|
+
ActionMcp.layerHttp(implementations, {
|
|
47
|
+
name: "tracker",
|
|
48
|
+
version: "1.0.0",
|
|
49
|
+
instructions: "Read an issue before commenting on it or closing it.",
|
|
50
|
+
authentication: Login,
|
|
51
|
+
actions: Tools,
|
|
52
|
+
features,
|
|
53
|
+
// The endpoint's own Origin allowlist, narrower than the request policy, which has
|
|
54
|
+
// already refused a foreign Origin: that policy also lets through a page of any name
|
|
55
|
+
// this host answers to, such as http://127.0.0.1:3000. Not listed here, such a page gets
|
|
56
|
+
// a 403 from the endpoint.
|
|
57
|
+
allowedOrigins: origins,
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// The members' actions as Effect RPC, for the web app, behind its session cookie and the same
|
|
63
|
+
// quota as native RPC middleware. It still requires its protocol and serialization, chosen by
|
|
64
|
+
// whoever serves it: server.ts gives it a WebSocket, and a test Effect's HTTP protocol, which
|
|
65
|
+
// answers in memory.
|
|
66
|
+
export const rpc = ActionRpc.layer(Rpc, implementations, { middleware: [RpcQuota] }).pipe(
|
|
67
|
+
Layer.provide([authenticateSession, quota, assistantTools]),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Effect's own tools read the binding's native `HttpApi`: the OpenAPI document and a Swagger
|
|
71
|
+
// UI, each showing the bearer scheme on every route but `getStatus`.
|
|
72
|
+
const documentation = Layer.mergeAll(
|
|
73
|
+
HttpRouter.add(
|
|
74
|
+
"GET",
|
|
75
|
+
`${Http.prefix}/openapi.json`,
|
|
76
|
+
HttpServerResponse.jsonUnsafe(OpenApi.fromApi(Http.api)),
|
|
77
|
+
),
|
|
78
|
+
HttpApiSwagger.layer(Http.api, { path: "/docs" }),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const health = HttpRouter.add("GET", "/healthz", HttpServerResponse.text("ok"));
|
|
82
|
+
|
|
83
|
+
// The public port's HTTP routes: what members reach, but for the rpcs, which server.ts serves
|
|
84
|
+
// beside these. Each `Layer.provide` gives what the layers before it still owe, and is built
|
|
85
|
+
// before them. Two things follow from that order.
|
|
86
|
+
//
|
|
87
|
+
// - `resolveTenant`, in a `Layer.provide` of its own after the authentication, gives the
|
|
88
|
+
// `Tenant` the Bearer verifier reads per request. In one array with `authenticateBearer` it
|
|
89
|
+
// would leave `Tenant` owed: an array's members provide to the routes, not to one another.
|
|
90
|
+
// - Global middleware runs in the order it registers, and it registers when its layer is
|
|
91
|
+
// built. The request policy, provided last, is built first, so it runs before everything:
|
|
92
|
+
// before CORS, and before the OAuth discovery the Bearer provider registers when it is
|
|
93
|
+
// built. Merged beside the routes instead, it would register after that provider, and a
|
|
94
|
+
// foreign Host could read the discovery document.
|
|
95
|
+
//
|
|
96
|
+
// The assistant's builder yields its tools, so their handlers are provided to the layers
|
|
97
|
+
// serving it, here and on `rpc`: one Layer, built once in the layer graph serving both, where
|
|
98
|
+
// the tools run the issue implementation the routes serve, with no second build of it.
|
|
99
|
+
export const publicRoutes = Layer.mergeAll(api, mcp, download, documentation, health).pipe(
|
|
100
|
+
Layer.provide(assistantTools),
|
|
101
|
+
Layer.provide([authenticateBearer, authenticateSession, quota]),
|
|
102
|
+
Layer.provide(resolveTenant.layer),
|
|
103
|
+
Layer.provide(cors),
|
|
104
|
+
Layer.provide(requestPolicy),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// The admin port: what operators reach, and nothing else. It is a router of its own, with its
|
|
108
|
+
// own binding, descriptor and provider, so no member's token is even understood here. It
|
|
109
|
+
// serves one implementation the public port serves too, `status`.
|
|
110
|
+
export const adminRoutes = Layer.mergeAll(
|
|
111
|
+
ActionHttp.layer(AdminHttp, [status, admin]),
|
|
112
|
+
health,
|
|
113
|
+
).pipe(Layer.provide(authenticateOperator), Layer.provide(requestPolicy));
|
|
114
|
+
|
|
115
|
+
// Every builder as one layer. The two ports are two layer graphs, and each would run the
|
|
116
|
+
// builder of an implementation it serves: `status`, which both serve, twice, each port then
|
|
117
|
+
// reporting an `instance` of its own. Provided above both, with the services, each builder
|
|
118
|
+
// runs once for every surface of both ports. The assistant's is left out: the public port
|
|
119
|
+
// alone serves it, and builds it once for its routes and its rpcs.
|
|
120
|
+
export const builders = Action.layer([status, identity, issues, attachments, admin]);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Effect, flow } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
4
|
+
import { Http } from "./Api.js";
|
|
5
|
+
import type { Issue } from "./issues/Issues.js";
|
|
6
|
+
|
|
7
|
+
// Entrypoint for code written with promises: an integration, a bot, another product's page.
|
|
8
|
+
// It imports the binding and nothing of the server, so a bundler keeps the contracts and
|
|
9
|
+
// Effect's fetch client, no handler.
|
|
10
|
+
export const connect = (options: {
|
|
11
|
+
readonly url: string;
|
|
12
|
+
readonly token: string;
|
|
13
|
+
readonly workspace: string;
|
|
14
|
+
}) => {
|
|
15
|
+
// Built once, outside any Effect, over `fetch`: its methods require nothing, so each call
|
|
16
|
+
// runs with `Effect.runPromise`.
|
|
17
|
+
const api = ActionHttp.fetchClient(Http, {
|
|
18
|
+
baseUrl: options.url,
|
|
19
|
+
transformClient: HttpClient.mapRequest(
|
|
20
|
+
flow(
|
|
21
|
+
HttpClientRequest.bearerToken(options.token),
|
|
22
|
+
HttpClientRequest.setHeader("x-workspace", options.workspace),
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
// A method takes the action's decoded input, so a field with a decoding default is given
|
|
29
|
+
// here; only a call without any argument sends `{}` and gets the defaults.
|
|
30
|
+
openIssues: (limit = 20) =>
|
|
31
|
+
Effect.runPromise(
|
|
32
|
+
Effect.map(api.listIssues({ status: "open", limit }), ({ issues }) => issues),
|
|
33
|
+
),
|
|
34
|
+
|
|
35
|
+
// A declared error is a typed failure, handled by its tag: a missing issue is `undefined`.
|
|
36
|
+
issue: (id: string): Promise<Issue | undefined> =>
|
|
37
|
+
api.getIssue({ id }).pipe(
|
|
38
|
+
Effect.catchTag("IssueNotFound", () => Effect.succeed(undefined)),
|
|
39
|
+
Effect.runPromise,
|
|
40
|
+
),
|
|
41
|
+
|
|
42
|
+
// Left unhandled, a declared error rejects the promise as its decoded value, so a caller's
|
|
43
|
+
// `catch` may test `error instanceof RateLimited`.
|
|
44
|
+
file: (title: string, body: string) => Effect.runPromise(api.createIssue({ title, body })),
|
|
45
|
+
};
|
|
46
|
+
};
|