@gjermundgaraba/effect-actions 0.18.2 → 0.19.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 +20 -47
- package/dist/Action.d.mts +96 -47
- package/dist/Action.mjs +11 -14
- package/dist/ActionCli.d.mts +81 -42
- package/dist/ActionCli.mjs +52 -33
- package/dist/ActionHttp.d.mts +84 -43
- package/dist/ActionHttp.mjs +2 -2
- package/dist/ActionMcp.d.mts +54 -27
- package/dist/ActionMcp.mjs +5 -5
- package/dist/ActionRpc.d.mts +48 -35
- package/dist/ActionRpc.mjs +1 -1
- package/dist/ActionToolkit.d.mts +32 -13
- package/dist/ActionToolkit.mjs +10 -7
- package/dist/Authentication.d.mts +92 -40
- package/dist/Authentication.mjs +37 -35
- package/dist/Testing.d.mts +22 -24
- package/dist/authentication/provider.d.mts +1 -4
- package/dist/authentication/provider.mjs +1 -1
- package/dist/authentication/refusal.mjs +2 -1
- package/dist/cli/commands.d.mts +34 -11
- package/dist/cli/commands.mjs +47 -22
- package/dist/contract/caller.mjs +1 -1
- package/dist/contract/declared.mjs +27 -14
- package/dist/contract/errors.d.mts +42 -17
- package/dist/contract/errors.mjs +42 -17
- package/dist/contract/hints.mjs +3 -2
- package/dist/contract/implementation.d.mts +5 -8
- package/dist/contract/implementation.mjs +29 -17
- package/dist/contract/media.d.mts +6 -2
- package/dist/contract/media.mjs +6 -2
- package/dist/contract/names.mjs +14 -12
- package/dist/contract/status.mjs +3 -10
- package/dist/http/client.d.mts +15 -4
- package/dist/http/client.mjs +1 -1
- package/dist/mcp/client.d.mts +21 -8
- package/dist/mcp/client.mjs +6 -8
- package/docs/Action-failures.md +6 -15
- package/docs/Action.md +159 -191
- package/docs/ActionCli-failures.md +12 -92
- package/docs/ActionCli.md +102 -156
- package/docs/ActionHttp-failures.md +14 -76
- package/docs/ActionHttp.md +150 -176
- package/docs/ActionMcp-failures.md +2 -22
- package/docs/ActionMcp.md +79 -170
- package/docs/ActionRpc-failures.md +3 -12
- package/docs/ActionRpc.md +183 -120
- package/docs/ActionToolkit-failures.md +3 -10
- package/docs/ActionToolkit.md +112 -109
- package/docs/Authentication-advanced.md +167 -108
- package/docs/Authentication-failures.md +11 -91
- package/docs/Authentication.md +114 -135
- package/docs/README.md +45 -30
- package/docs/Testing-failures.md +2 -6
- package/docs/Testing.md +126 -159
- package/docs/setup-failures.md +8 -32
- package/docs/setup.md +42 -37
- package/examples/README.md +6 -10
- package/examples/assistant/README.md +20 -38
- package/examples/assistant/src/agent.ts +2 -3
- package/examples/assistant/src/cli.ts +4 -9
- package/examples/assistant/src/commands.ts +6 -4
- package/examples/assistant/src/identity/authorize.ts +3 -3
- package/examples/assistant/src/tasks/Tasks.ts +1 -2
- package/examples/assistant/src/tasks/handlers.ts +3 -3
- package/examples/assistant/src/toolkit.ts +6 -8
- package/examples/assistant/test/commands.test.ts +16 -11
- package/examples/assistant/test/tasks/tasks.test.ts +0 -2
- package/examples/assistant/test/toolkit.test.ts +0 -1
- package/examples/kitchen-sink/README.md +30 -52
- package/examples/kitchen-sink/src/Actions.ts +2 -3
- package/examples/kitchen-sink/src/Api.ts +3 -4
- package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
- package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
- package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
- package/examples/kitchen-sink/src/agent.ts +1 -1
- package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
- package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
- package/examples/kitchen-sink/src/attachments/download.ts +5 -8
- package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
- package/examples/kitchen-sink/src/browser.ts +3 -4
- package/examples/kitchen-sink/src/cli.ts +6 -7
- package/examples/kitchen-sink/src/config.ts +2 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
- package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
- package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
- package/examples/kitchen-sink/src/identity/quota.ts +1 -1
- package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
- package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
- package/examples/kitchen-sink/src/issues/guide.ts +2 -3
- package/examples/kitchen-sink/src/ops.ts +3 -6
- package/examples/kitchen-sink/src/request-policy.ts +3 -3
- package/examples/kitchen-sink/src/routes.ts +4 -6
- package/examples/kitchen-sink/src/seed.ts +0 -1
- package/examples/kitchen-sink/src/server.ts +11 -12
- package/examples/kitchen-sink/src/stdio.ts +3 -2
- package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
- package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
- package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
- package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
- package/examples/kitchen-sink/test/request-policy.test.ts +1 -1
- package/examples/kitchen-sink/test/routes.test.ts +3 -3
- package/examples/live-rpc/README.md +8 -49
- package/examples/live-rpc/src/browser.ts +8 -41
- package/examples/live-rpc/src/chat/Chat.ts +9 -3
- package/examples/live-rpc/src/chat/room.ts +1 -1
- package/examples/live-rpc/src/client.ts +2 -3
- package/examples/live-rpc/src/page.ts +34 -0
- package/examples/live-rpc/src/routes.ts +6 -7
- package/examples/live-rpc/src/server.ts +6 -4
- package/examples/live-rpc/test/browser.test.ts +6 -5
- package/examples/live-rpc/test/routes.test.ts +16 -15
- package/examples/live-rpc/test/server.test.ts +1 -1
- package/examples/mcp-tools/README.md +22 -45
- package/examples/mcp-tools/src/printers/Printers.ts +3 -7
- package/examples/mcp-tools/src/routes.ts +0 -7
- package/examples/mcp-tools/src/server.ts +1 -1
- package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
- package/examples/mcp-tools/test/routes.test.ts +2 -3
- package/examples/notes-service/README.md +9 -43
- package/examples/notes-service/src/cli.ts +0 -3
- package/examples/notes-service/src/commands.ts +7 -17
- package/examples/notes-service/src/identity/authorize.ts +2 -5
- package/examples/notes-service/src/identity/verifier.ts +3 -4
- package/examples/notes-service/src/notes/Notes.ts +2 -6
- package/examples/notes-service/src/notes/store.ts +5 -1
- package/examples/notes-service/src/routes.ts +6 -8
- package/examples/notes-service/src/server.ts +4 -3
- package/examples/notes-service/src/stdio.ts +4 -3
- package/examples/notes-service/test/commands.test.ts +1 -1
- package/examples/notes-service/test/notes/as.ts +6 -0
- package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
- package/examples/notes-service/test/notes/in-process.test.ts +50 -0
- package/examples/notes-service/test/notes/notes.test.ts +3 -42
- package/examples/notes-service/test/routes.test.ts +2 -2
- package/examples/quickstart/README.md +1 -1
- package/examples/quickstart/src/client.ts +1 -0
- package/examples/quickstart/src/server.ts +2 -4
- package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
- package/package.json +1 -1
- package/docs/Action-in-process.md +0 -93
- package/docs/CONTEXT.md +0 -46
- package/docs/guarantees.md +0 -44
|
@@ -25,9 +25,8 @@ export const protocol = (url: string) =>
|
|
|
25
25
|
Layer.provide([RpcSerialization.layerJson, Socket.layerWebSocketConstructorGlobal]),
|
|
26
26
|
);
|
|
27
27
|
|
|
28
|
-
// `open("wss://tracker.example.com/rpc")`: one run on a socket of its own
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// builds the Layer once, such as in a `ManagedRuntime`, and runs its programs in it.
|
|
28
|
+
// `open("wss://tracker.example.com/rpc")`: one run on a socket of its own, closed when it ends.
|
|
29
|
+
// A page that stays connected builds the protocol's Layer once, such as in a `ManagedRuntime`,
|
|
30
|
+
// and runs its programs in it.
|
|
32
31
|
export const open = (url: string) =>
|
|
33
32
|
Effect.runPromise(board.pipe(Effect.scoped, Effect.provide(protocol(url))));
|
|
@@ -15,8 +15,10 @@ const tracker = ActionCli.remote(Http, {
|
|
|
15
15
|
// Each command's options, keyed by its action's name and typed by that action.
|
|
16
16
|
commands: {
|
|
17
17
|
// `tracker list-issues -n 5 -s open`: short flags, and text instead of JSON (`--json` gives
|
|
18
|
-
// the JSON back).
|
|
18
|
+
// the JSON back). Its help says so: the action's description, which HTTP and MCP keep, says
|
|
19
|
+
// what the JSON holds.
|
|
19
20
|
listIssues: {
|
|
21
|
+
description: "List your workspace's issues, newest first, one line each: id, status, title.",
|
|
20
22
|
aliases: { limit: "n", status: "s" },
|
|
21
23
|
render: ({ issues }) =>
|
|
22
24
|
issues.map(({ id, status, title }) => `${id} ${status} ${title}`).join("\n"),
|
|
@@ -42,10 +44,9 @@ const tracker = ActionCli.remote(Http, {
|
|
|
42
44
|
`${attachment.name}: ${image.mimeType}, ${image.data.byteLength} bytes`,
|
|
43
45
|
},
|
|
44
46
|
},
|
|
45
|
-
// The connection
|
|
46
|
-
//
|
|
47
|
-
// workspace
|
|
48
|
-
// command without them gets the server's 401.
|
|
47
|
+
// The connection. The token and the workspace are optional: `tracker get-status` is public
|
|
48
|
+
// and needs neither, and any other command without a token gets the server's 401, and without
|
|
49
|
+
// a workspace its 403.
|
|
49
50
|
client: Effect.gen(function* () {
|
|
50
51
|
const baseUrl = yield* Config.String("TRACKER_URL").pipe(
|
|
51
52
|
Config.withDefault("http://127.0.0.1:3000"),
|
|
@@ -74,8 +75,6 @@ const tracker = ActionCli.remote(Http, {
|
|
|
74
75
|
}),
|
|
75
76
|
});
|
|
76
77
|
|
|
77
|
-
// A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
|
|
78
|
-
// HTTP sends, such as `{"_tag":"IssueNotFound","id":"ACME-9"}`, and the command exits 1.
|
|
79
78
|
Command.run(tracker, { version: "1.0.0" }).pipe(
|
|
80
79
|
Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
|
|
81
80
|
ActionCli.logToStderr,
|
|
@@ -14,5 +14,7 @@ export const publicUrl = Config.String("PUBLIC_URL").pipe(
|
|
|
14
14
|
*/
|
|
15
15
|
export const allowedOrigins = Config.all([
|
|
16
16
|
publicUrl,
|
|
17
|
+
// DEMO ONLY: the default is the MCP Inspector's local origin. A page of any origin listed
|
|
18
|
+
// passes the request policy and CORS, so a deployment names its own client's.
|
|
17
19
|
Config.String("MCP_ORIGIN").pipe(Config.withDefault("http://localhost:6274")),
|
|
18
20
|
]).pipe(Config.map(([url, mcp]) => [new URL(url).origin, mcp]));
|
|
@@ -22,8 +22,8 @@ export class CurrentMember extends Context.Service<CurrentMember, Member>()(
|
|
|
22
22
|
"tracker/CurrentMember",
|
|
23
23
|
) {}
|
|
24
24
|
|
|
25
|
-
// What a verifier may fail with besides a refusal
|
|
26
|
-
//
|
|
25
|
+
// What a verifier may fail with besides a refusal: the issuer it checks tokens with is down.
|
|
26
|
+
// It is not the caller's fault, so it is no 401: signing in again cannot help.
|
|
27
27
|
export class IssuerUnavailable extends Schema.TaggedError<IssuerUnavailable>()(
|
|
28
28
|
"IssuerUnavailable",
|
|
29
29
|
{ retryAfterSeconds: Schema.Finite },
|
|
@@ -31,21 +31,21 @@ export class IssuerUnavailable extends Schema.TaggedError<IssuerUnavailable>()(
|
|
|
31
31
|
) {}
|
|
32
32
|
|
|
33
33
|
// How an API client, the CLI and an MCP client prove they are CurrentMember: a bearer token,
|
|
34
|
-
// the default scheme
|
|
34
|
+
// the default scheme.
|
|
35
35
|
export const Login = Authentication.make("tracker.Login", CurrentMember, {
|
|
36
36
|
error: IssuerUnavailable,
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
// How the web app proves the same identity: the session cookie its sign-in page set.
|
|
40
|
-
//
|
|
41
|
-
// named by what accepts it: here `Rpc`, the web app's socket, and the route its pages load an
|
|
39
|
+
// How the web app proves the same identity: the session cookie its sign-in page set. It is
|
|
40
|
+
// named by what accepts it: `Rpc`, the web app's socket, and the route its pages load an
|
|
42
41
|
// attachment's image from.
|
|
43
42
|
export const Session = Authentication.make("tracker.Session", CurrentMember, {
|
|
44
43
|
security: HttpApiSecurity.apiKey({ in: "cookie", key: "tracker_session" }),
|
|
45
44
|
});
|
|
46
45
|
|
|
47
46
|
// What the API's request quota answers with. It is the bindings' error, not an action's:
|
|
48
|
-
// middleware sends it
|
|
47
|
+
// middleware sends it on any action's route, before its input is decoded. Only HTTP and RPC
|
|
48
|
+
// have that middleware.
|
|
49
49
|
export class QuotaExceeded extends Schema.TaggedError<QuotaExceeded>()(
|
|
50
50
|
"QuotaExceeded",
|
|
51
51
|
{ retryAfterSeconds: Schema.Finite },
|
|
@@ -26,10 +26,8 @@ const inService = Effect.gen(function* () {
|
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// scope first. The resource is the public URL, known only to the deployment: an Effect, read
|
|
32
|
-
// when the provider is built.
|
|
29
|
+
// The OAuth protected resource whose discovery the provider publishes, so an MCP client that
|
|
30
|
+
// gets a 401 finds where to sign in, asking for the read scope first.
|
|
33
31
|
const protectedResource = Effect.map(
|
|
34
32
|
publicUrl,
|
|
35
33
|
(url) =>
|
|
@@ -74,7 +72,7 @@ export const authenticateBearer = Authentication.layer(
|
|
|
74
72
|
|
|
75
73
|
// `Session`'s provider: the same identity from another credential. Its verifier receives what
|
|
76
74
|
// its scheme decodes, the cookie's value. A session belongs to one workspace, so it reads no
|
|
77
|
-
// `Tenant
|
|
75
|
+
// `Tenant`.
|
|
78
76
|
export const authenticateSession = Authentication.layer(
|
|
79
77
|
Session,
|
|
80
78
|
Effect.gen(function* () {
|
|
@@ -3,9 +3,10 @@ import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
|
3
3
|
import { CurrentMember, scopes } from "./Identity.js";
|
|
4
4
|
|
|
5
5
|
// The members' rule, read from each contract: a read needs the read scope, anything else the
|
|
6
|
-
// write scope.
|
|
7
|
-
// it before the handler: HTTP, MCP, RPC, stdio, the assistant's tools and a test alike.
|
|
8
|
-
//
|
|
6
|
+
// write scope. The implementations of issues and attachments state it, so every surface serving
|
|
7
|
+
// them runs it before the handler: HTTP, MCP, RPC, stdio, the assistant's tools and a test alike.
|
|
8
|
+
// For a bearer token, naming the missing scope turns the 403 into the challenge an OAuth client
|
|
9
|
+
// re-authorizes on.
|
|
9
10
|
export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
|
|
10
11
|
const scope = action.readOnly ? scopes.read : scopes.write;
|
|
11
12
|
const member = yield* CurrentMember;
|
|
@@ -43,7 +43,7 @@ const spend = Effect.gen(function* () {
|
|
|
43
43
|
"RateLimitExceeded",
|
|
44
44
|
({ retryAfter }) =>
|
|
45
45
|
Effect.fail(new QuotaExceeded({ retryAfterSeconds: Duration.toSeconds(retryAfter) })),
|
|
46
|
-
// Any other reason is the limiter's store failing: a defect,
|
|
46
|
+
// Any other reason is the limiter's store failing: a defect, never the caller's.
|
|
47
47
|
Effect.die,
|
|
48
48
|
),
|
|
49
49
|
);
|
|
@@ -32,13 +32,13 @@ export class RateLimited extends Schema.TaggedError<RateLimited>()(
|
|
|
32
32
|
) {}
|
|
33
33
|
|
|
34
34
|
// A shared error list: what any write to an existing issue may fail with. Each such action
|
|
35
|
-
//
|
|
35
|
+
// names this one array as its `error`, here and in attachments/Attachments.ts, so an error
|
|
36
36
|
// added to the list is declared by all of them.
|
|
37
37
|
export const IssueWriteErrors = [IssueNotFound, RateLimited] as const;
|
|
38
38
|
|
|
39
39
|
// An option bundle. What the two actions changing an issue share is one constant object spread
|
|
40
40
|
// into `Action.make`, typed as if written inline. `readOnly` and `description` stay on each
|
|
41
|
-
// action
|
|
41
|
+
// action.
|
|
42
42
|
const change = {
|
|
43
43
|
caller: CurrentMember,
|
|
44
44
|
error: IssueWriteErrors,
|
|
@@ -3,8 +3,8 @@ import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
|
|
|
3
3
|
import { issues } from "./handlers.js";
|
|
4
4
|
import { CloseIssue, CommentOnIssue, GetIssue, ListIssues } from "./Issues.js";
|
|
5
5
|
|
|
6
|
-
// The actions a model may call, listed here, where they are served
|
|
7
|
-
//
|
|
6
|
+
// The actions a model may call, listed here, where they are served. Filing issues is left to
|
|
7
|
+
// people.
|
|
8
8
|
const tools = [ListIssues, GetIssue, CommentOnIssue, CloseIssue] as const;
|
|
9
9
|
|
|
10
10
|
// Some of the issue actions as a model's tools: Effect's native `Toolkit`, in process, with no
|
|
@@ -19,5 +19,5 @@ export const { toolkit, layer } = ActionToolkit.make(issues, {
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
// One turn of the assistant. The caller provides the model, and the member it acts for around
|
|
22
|
-
// the whole turn:
|
|
22
|
+
// the whole turn: `authorize` and the handlers read that one caller.
|
|
23
23
|
export const triage = (prompt: string) => LanguageModel.generateText({ prompt, toolkit });
|
|
@@ -15,9 +15,8 @@ const guide = `# Triage
|
|
|
15
15
|
const tools = Action.byName(Issues);
|
|
16
16
|
|
|
17
17
|
// Effect's own MCP resources and prompts, served beside the actions' tools as an endpoint's
|
|
18
|
-
// `features
|
|
19
|
-
//
|
|
20
|
-
// Content that depends on the caller is an action.
|
|
18
|
+
// `features`: native, not actions. This guide is the same for every caller, so it reads no
|
|
19
|
+
// caller, which `features` never receive.
|
|
21
20
|
export const features = Layer.mergeAll(
|
|
22
21
|
McpServer.resource({
|
|
23
22
|
uri: "tracker://guides/triage",
|
|
@@ -9,10 +9,8 @@ import { WorkspaceRegistry } from "./workspaces/registry.js";
|
|
|
9
9
|
|
|
10
10
|
// Entrypoint: `node --import tsx examples/kitchen-sink/src/ops.ts list-workspaces`.
|
|
11
11
|
// The operators' console: the admin actions run in this process, on the host's own services,
|
|
12
|
-
// with no server and no key.
|
|
13
|
-
// runs here as on the admin port, and admits the operator this host supplies.
|
|
12
|
+
// with no server and no key.
|
|
14
13
|
|
|
15
|
-
// Only a host can name the trusted operator: no verifier's return type includes it.
|
|
16
14
|
const operator: TrustedOperator = { id: "console", trusted: true };
|
|
17
15
|
|
|
18
16
|
const ops = ActionCli.make(admin, {
|
|
@@ -28,9 +26,8 @@ const ops = ActionCli.make(admin, {
|
|
|
28
26
|
resumeWorkspace: { positional: ["workspace"] },
|
|
29
27
|
},
|
|
30
28
|
}).pipe(
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// own process, not the server's data. A real one provides the database Layers the server does.
|
|
29
|
+
// Only what these actions are built from. DEMO ONLY: memory, so this console sees its own
|
|
30
|
+
// process, not the server's data. A real one provides the database Layers the server does.
|
|
34
31
|
Command.provide(Layer.merge(WorkspaceRegistry.layerMemory, Grants.layerDemo)),
|
|
35
32
|
Command.provideSync(Operator, operator),
|
|
36
33
|
);
|
|
@@ -5,12 +5,12 @@ import { allowedOrigins, publicUrl } from "./config.js";
|
|
|
5
5
|
const loopback = ["localhost", "127.0.0.1"];
|
|
6
6
|
|
|
7
7
|
// Which Host and Origin a request may carry: not the library's concern, but every request of
|
|
8
|
-
// both ports passes it first.
|
|
8
|
+
// both ports passes it first. A Host that is neither loopback nor the public URL's is refused,
|
|
9
|
+
// against DNS rebinding. The web app signs in with a cookie, which a browser attaches to
|
|
9
10
|
// any page's request, so a foreign Origin must be refused before authentication reads it.
|
|
10
11
|
// That is why this is global router middleware (`global: true`): it runs before routing, so
|
|
11
12
|
// before every route's authentication. As a layer's own `middleware` it would run inside the
|
|
12
|
-
// authentication, too late.
|
|
13
|
-
// publishes, global middleware too, is decided where it is provided: last, in routes.ts.
|
|
13
|
+
// authentication, too late.
|
|
14
14
|
export const requestPolicy = HttpRouter.middleware(
|
|
15
15
|
Effect.gen(function* () {
|
|
16
16
|
const hosts = [...loopback, new URL(yield* publicUrl).hostname];
|
|
@@ -32,15 +32,14 @@ const implementations = [status, identity, issues, attachments, assistant];
|
|
|
32
32
|
|
|
33
33
|
// JSON routes at `/api/v1/<action>`, in two layers of one binding, each naming its `actions`.
|
|
34
34
|
// The quota requires the caller, and a public route has none, so `getStatus` is served by a
|
|
35
|
-
// layer without it.
|
|
35
|
+
// layer without it.
|
|
36
36
|
const api = Layer.mergeAll(
|
|
37
37
|
ActionHttp.layer(Http, implementations, { actions: [GetStatus] }),
|
|
38
38
|
ActionHttp.layer(Http, implementations, { actions: MemberActions, middleware: [HttpQuota] }),
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
-
// The same actions as MCP tools at `/mcp`: a mixed endpoint
|
|
42
|
-
//
|
|
43
|
-
// one opening a session included, since a session may call any tool.
|
|
41
|
+
// The same actions as MCP tools at `/mcp`: a mixed endpoint, serving the public `getStatus`
|
|
42
|
+
// beside the members' tools.
|
|
44
43
|
const mcp = Layer.unwrap(
|
|
45
44
|
Effect.map(allowedOrigins, (origins) =>
|
|
46
45
|
ActionMcp.layerHttp(implementations, {
|
|
@@ -85,8 +84,7 @@ const health = HttpRouter.add("GET", "/healthz", HttpServerResponse.text("ok"));
|
|
|
85
84
|
// before them. Two things follow from that order.
|
|
86
85
|
//
|
|
87
86
|
// - `resolveTenant`, in a `Layer.provide` of its own after the authentication, gives the
|
|
88
|
-
// `Tenant` the Bearer verifier reads per request.
|
|
89
|
-
// would leave `Tenant` owed: an array's members provide to the routes, not to one another.
|
|
87
|
+
// `Tenant` the Bearer verifier reads per request; tenant.ts says why not in one array.
|
|
90
88
|
// - Global middleware runs in the order it registers, and it registers when its layer is
|
|
91
89
|
// built. The request policy, provided last, is built first, so it runs before everything:
|
|
92
90
|
// before CORS, and before the OAuth discovery the Bearer provider registers when it is
|
|
@@ -46,7 +46,6 @@ const seed = Effect.gen(function* () {
|
|
|
46
46
|
|
|
47
47
|
yield* tracker.commentOnIssue({ id: issue.id, text: "Reproduced on 2.4.1." });
|
|
48
48
|
|
|
49
|
-
// No argument: `{}` is a valid input here, so `limit` takes its default and `status` none.
|
|
50
49
|
const { issues } = yield* tracker.listIssues();
|
|
51
50
|
|
|
52
51
|
yield* Console.log(`Filed ${issue.id} with attachment ${attachment.id}. Issues:`, issues);
|
|
@@ -7,7 +7,7 @@ import { memory } from "./memory.js";
|
|
|
7
7
|
import { adminRoutes, builders, publicRoutes, rpc } from "./routes.js";
|
|
8
8
|
|
|
9
9
|
// Entrypoint: `node --import tsx examples/kitchen-sink/src/server.ts`.
|
|
10
|
-
// The
|
|
10
|
+
// The entrypoint of the server: two listeners over one set of builders and services.
|
|
11
11
|
|
|
12
12
|
// The web app's rpcs over a WebSocket at `/rpc`: Node's HTTP server answers the upgrade. The
|
|
13
13
|
// protocol is this entrypoint's choice, as the ports are. `rpc` leaves it open, so a test
|
|
@@ -37,21 +37,20 @@ const server = Layer.unwrap(
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
return Layer.mergeAll(
|
|
40
|
-
// Two audiences, two listeners
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// and the rpcs spend the quota the routes spend, built once.
|
|
40
|
+
// Two audiences, two listeners, each with a router of its own: the public port can only
|
|
41
|
+
// ever reach the members' routes. The socket is merged into the public port's routes, so
|
|
42
|
+
// it is a route of that router: the request policy, global to the router, refuses a
|
|
43
|
+
// foreign Origin's upgrade as it does a request, and the rpcs spend the quota the routes
|
|
44
|
+
// spend, built once.
|
|
46
45
|
HttpRouter.serve(Layer.merge(publicRoutes, socket)).pipe(Layer.provide(listen(publicPort))),
|
|
47
46
|
HttpRouter.serve(adminRoutes).pipe(Layer.provide(listen(adminPort))),
|
|
48
47
|
).pipe(
|
|
49
|
-
// Request bodies have no size limit unless the host sets one. This covers every
|
|
50
|
-
//
|
|
48
|
+
// Request bodies have no size limit unless the host sets one. This covers every route
|
|
49
|
+
// that reads its body whole, on both ports, the MCP endpoint included.
|
|
51
50
|
Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, limit)),
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
51
|
+
// What both ports share is provided outside both: the builders, as routes.ts says, then
|
|
52
|
+
// the services the verifiers and the quota are built from, so a workspace an operator
|
|
53
|
+
// suspends on one port is refused on the other.
|
|
55
54
|
Layer.provide(builders),
|
|
56
55
|
Layer.provide(memory),
|
|
57
56
|
);
|
|
@@ -15,7 +15,8 @@ import { WorkspaceRegistry } from "./workspaces/registry.js";
|
|
|
15
15
|
|
|
16
16
|
// Entrypoint: an MCP server a local agent starts as a subprocess, such as
|
|
17
17
|
// `claude mcp add tracker -- "$PWD/node_modules/.bin/tsx" "$PWD/examples/kitchen-sink/src/stdio.ts"`
|
|
18
|
-
// from the repository root
|
|
18
|
+
// from the repository root. The host starts the process in a directory it chooses, so `tsx`
|
|
19
|
+
// and the file are named by full path.
|
|
19
20
|
// It lists the implementations it serves itself, and imports nothing of the HTTP router. They
|
|
20
21
|
// are those of the `/mcp` endpoint, with its tools and features and the same `authorize`.
|
|
21
22
|
// stdio is a local surface: it takes no `authentication`, since no token arrives.
|
|
@@ -29,7 +30,7 @@ ActionMcp.runStdio([status, identity, issues, attachments], {
|
|
|
29
30
|
// started it, from its own login or configuration.
|
|
30
31
|
Effect.provideService(CurrentMember, members.ada),
|
|
31
32
|
Effect.provide([NodeStdio.layer, stores, limiter, WorkspaceRegistry.layerMemory]),
|
|
32
|
-
// stdout carries the protocol alone, so logs and failures go to stderr.
|
|
33
|
+
// stdout carries the protocol alone, so logs and failures go to stderr.
|
|
33
34
|
ActionCli.logToStderr,
|
|
34
35
|
NodeRuntime.runMain,
|
|
35
36
|
);
|
|
@@ -9,7 +9,9 @@ import { HttpRouter, HttpServerRequest } from "effect/http";
|
|
|
9
9
|
export class Tenant extends Context.Service<Tenant, Option.Option<string>>()("tracker/Tenant") {}
|
|
10
10
|
|
|
11
11
|
// Native router middleware. It provides `Tenant` to whatever it is provided to, per request:
|
|
12
|
-
// to the verifier too, when it is provided after the authentication
|
|
12
|
+
// to the verifier too, when it is provided after the authentication, in a `Layer.provide` of
|
|
13
|
+
// its own: in one array with the authentication it would leave `Tenant` owed, since an array's
|
|
14
|
+
// layers provide to the routes, not to one another.
|
|
13
15
|
export const resolveTenant = HttpRouter.middleware<{ provides: Tenant }>()((route) =>
|
|
14
16
|
Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) =>
|
|
15
17
|
Effect.provideService(route, Tenant, Option.fromNullishOr(request.headers["x-workspace"])),
|
|
@@ -28,7 +28,6 @@ describe("the operators", () => {
|
|
|
28
28
|
|
|
29
29
|
expect((yield* support.listWorkspaces()).workspaces).toHaveLength(2);
|
|
30
30
|
|
|
31
|
-
// A key cannot step up, so the refusal names no scope: a plain 403.
|
|
32
31
|
const refused = yield* Effect.flip(support.suspendWorkspace({ workspace: "acme" }));
|
|
33
32
|
|
|
34
33
|
expect(refused).toBeInstanceOf(Action.Forbidden);
|
|
@@ -311,7 +311,7 @@ describe("the assistant, behind a route", () => {
|
|
|
311
311
|
),
|
|
312
312
|
);
|
|
313
313
|
|
|
314
|
-
it.effect("runs what its member approves in
|
|
314
|
+
it.effect("runs what its member approves in a later turn of the demo model", () =>
|
|
315
315
|
Effect.gen(function* () {
|
|
316
316
|
const ada = yield* asAda;
|
|
317
317
|
|
|
@@ -39,7 +39,7 @@ const attached = Effect.gen(function* () {
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
describe("attachments, over HTTP and MCP", () => {
|
|
42
|
-
it.effect("
|
|
42
|
+
it.effect("decodes an image over HTTP, and reads it as an image block from the tool", () =>
|
|
43
43
|
Effect.gen(function* () {
|
|
44
44
|
const attachment = yield* attached;
|
|
45
45
|
const http = yield* ActionHttp.client(Http, as("ada", "acme"));
|
|
@@ -128,7 +128,6 @@ describe("attachments, over HTTP and MCP", () => {
|
|
|
128
128
|
|
|
129
129
|
expect(mine.status).toBe(200);
|
|
130
130
|
expect(Buffer.from(yield* mine.arrayBuffer).equals(screenshot)).toBe(true);
|
|
131
|
-
// The route states its own caching; everything else authentication covers is `no-store`.
|
|
132
131
|
expect(mine.headers["cache-control"]).toBe("private, max-age=31536000, immutable");
|
|
133
132
|
// An uploaded image opened by its URL is a document on this origin: it gets no scripts.
|
|
134
133
|
expect([
|
|
@@ -68,7 +68,6 @@ describe("a bearer token", () => {
|
|
|
68
68
|
|
|
69
69
|
const refused = yield* Effect.flip(reader.createIssue({ title: "Crash", body: "" }));
|
|
70
70
|
|
|
71
|
-
// `toEqual` compares an error's own fields, here `scopes`; never its `message`.
|
|
72
71
|
expect(refused).toEqual(new Action.Forbidden({ scopes: ["issues:write"] }));
|
|
73
72
|
expect(refused.message).toBe("Requires issues:write.");
|
|
74
73
|
|
|
@@ -47,7 +47,6 @@ describe("issues, in process", () => {
|
|
|
47
47
|
);
|
|
48
48
|
yield* client.closeIssue({ id: "ACME-2" }).pipe(as("ada"));
|
|
49
49
|
|
|
50
|
-
// No argument is the input `{}`: `status` is absent and `limit` its default.
|
|
51
50
|
const all = yield* client.listIssues().pipe(as("ada"));
|
|
52
51
|
const closed = yield* client.listIssues({ status: "closed", limit: 20 }).pipe(as("ada"));
|
|
53
52
|
const newest = yield* client.listIssues({ limit: 1 }).pipe(as("ada"));
|
|
@@ -69,7 +68,6 @@ describe("issues, in process", () => {
|
|
|
69
68
|
|
|
70
69
|
const refused = yield* Effect.flip(create.pipe(as("linus")));
|
|
71
70
|
|
|
72
|
-
// `toEqual` compares an error's own fields, here `scopes`; never its `message`.
|
|
73
71
|
expect(refused).toEqual(new Action.Forbidden({ scopes: ["issues:write"] }));
|
|
74
72
|
expect(refused.message).toBe("Requires issues:write.");
|
|
75
73
|
}).pipe(Effect.provide(services)),
|
|
@@ -103,7 +103,7 @@ describe("the request policy", () => {
|
|
|
103
103
|
expect(preflight.headers.get("access-control-allow-headers")).toContain("Mcp-Session-Id");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it("covers the admin port too,
|
|
106
|
+
it("covers the admin port too, a valid key included", async () => {
|
|
107
107
|
const key = { "x-operator-key": "ops-key" };
|
|
108
108
|
const here = await adminWeb.handler(post("http://localhost/admin/listWorkspaces", key));
|
|
109
109
|
|
|
@@ -10,9 +10,9 @@ import { AdminHttp, Http } from "../src/Api.js";
|
|
|
10
10
|
import { memory } from "../src/memory.js";
|
|
11
11
|
import { adminRoutes, builders, publicRoutes } from "../src/routes.js";
|
|
12
12
|
|
|
13
|
-
// The host's surfaces, as routes.ts builds them, answering in memory: no port is opened.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// The host's surfaces, as routes.ts builds them, answering in memory: no port is opened. Each
|
|
14
|
+
// port is a `Testing.layer` of its own, and what they share is provided above both, as
|
|
15
|
+
// server.ts does.
|
|
16
16
|
const shared = builders.pipe(Layer.provideMerge(memory));
|
|
17
17
|
|
|
18
18
|
const onPublicPort = Effect.provide(Testing.layer(publicRoutes));
|
|
@@ -2,15 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A chat served as Effect RPC over a WebSocket: one connection per page, many calls on it.
|
|
4
4
|
Members post, read and delete messages in the rooms they belong to; anyone may list the rooms.
|
|
5
|
-
Each message on the socket carries its own token and names its room
|
|
6
|
-
identity of its own. A message naming no room is about the default one, `lobby`.
|
|
7
|
-
|
|
8
|
-
Start here if a client keeps a connection open and calls often, or if the application already
|
|
9
|
-
speaks Effect RPC. For a JSON API or MCP tools, start from [notes-service](../notes-service/).
|
|
5
|
+
Each message on the socket carries its own token and names its room.
|
|
10
6
|
To serve these same actions as JSON too, see
|
|
11
|
-
[ActionRpc.md](../../docs/ActionRpc.md#serve-the-rpcs)
|
|
12
|
-
`src/server.ts` merge both.
|
|
13
|
-
Actions are unary, so nothing streams: the page reads again, passing the last id it holds.
|
|
7
|
+
[ActionRpc.md](../../docs/ActionRpc.md#serve-the-rpcs).
|
|
14
8
|
|
|
15
9
|
## Run it
|
|
16
10
|
|
|
@@ -24,26 +18,9 @@ node --import tsx examples/live-rpc/src/client.ts # CHAT_URL to change, ws:/
|
|
|
24
18
|
vp test examples/live-rpc
|
|
25
19
|
```
|
|
26
20
|
|
|
27
|
-
`client.ts` runs the browser's client from Node: two members on one socket, then each
|
|
28
|
-
a page handles. `browser.ts` is imported by a page, not run: `openChat` for a
|
|
29
|
-
Effect program
|
|
30
|
-
`grace`) and the store is memory: replace both before deploying anything.
|
|
31
|
-
|
|
32
|
-
## When you copy this
|
|
33
|
-
|
|
34
|
-
Four choices here show a feature. Decide each for your application before keeping it:
|
|
35
|
-
|
|
36
|
-
- **`listRooms` is public** to show a public rpc beside protected ones. It tells a signed-out
|
|
37
|
-
caller every room's name and how many messages it holds; a room appears once someone posts.
|
|
38
|
-
- **A message naming no room is about `lobby`**, to show a default in request context. Yours may
|
|
39
|
-
refuse instead.
|
|
40
|
-
- **The room is a header, not an input**, because `authorize` sees the action and not its input:
|
|
41
|
-
the membership rule reads the room from request context. With the room in the input, the
|
|
42
|
-
check belongs in the store, never in a helper each handler must remember to call: the store's
|
|
43
|
-
method takes the member and refuses, as `MessageStore.remove` in `src/chat/store.ts` takes the
|
|
44
|
-
author and refuses with `NotAuthor`.
|
|
45
|
-
- **`browser.ts` takes a message already gone as removed**, `MessageNotFound` included, since
|
|
46
|
-
that is what the page asked for. Yours may tell the user it was not there.
|
|
21
|
+
`client.ts` runs the browser's client from Node: two members on one socket, then each declared
|
|
22
|
+
error and refusal a page handles. `browser.ts` is imported by a page, not run: `openChat` for a
|
|
23
|
+
page that is an Effect program; `page.ts` wraps it as `connect(url)` for one that calls with promises.
|
|
47
24
|
|
|
48
25
|
## Layout
|
|
49
26
|
|
|
@@ -62,7 +39,8 @@ src/
|
|
|
62
39
|
├── Rpc.ts the RPC binding of every action
|
|
63
40
|
├── routes.ts the rpcs, authenticated and with their middleware, not yet on a protocol
|
|
64
41
|
├── server.ts entrypoint: the rpcs over a WebSocket at /rpc, its messages bounded
|
|
65
|
-
├── browser.ts client module: the connection and the chat, as
|
|
42
|
+
├── browser.ts client module: the connection and the chat, as one Effect program
|
|
43
|
+
├── page.ts the same chat as promises, for a page that is not one
|
|
66
44
|
└── client.ts entrypoint: that client, run from Node
|
|
67
45
|
test/
|
|
68
46
|
├── chat/ in process, with Action.client
|
|
@@ -71,24 +49,6 @@ test/
|
|
|
71
49
|
└── server.test.ts the socket itself: a listening port, connect(url), the bound on a message
|
|
72
50
|
```
|
|
73
51
|
|
|
74
|
-
- **The PascalCase files are what a page imports.** `Chat.ts`, `Identity.ts` and `Rpc.ts` import
|
|
75
|
-
no server code. `browser.ts` imports them, the library's `ActionRpc`, and `effect`,
|
|
76
|
-
`effect/rpc` and `effect/socket`, nothing else, so it bundles for a browser.
|
|
77
|
-
- **A request-time service is never a startup service.** `CurrentMember` comes from
|
|
78
|
-
authentication and `CurrentRoom` from middleware, both per message. Neither has a Layer, and
|
|
79
|
-
no entrypoint provides one: a value provided at startup would serve every caller as one
|
|
80
|
-
member in one room.
|
|
81
|
-
- **The entrypoint chooses the protocol.** `routes.ts` exports the rpcs without one. `server.ts`
|
|
82
|
-
gives them a WebSocket, its only route. `browser.ts` leaves the WebSocket constructor to its
|
|
83
|
-
host: a page provides the browser's, `client.ts` provides Node's.
|
|
84
|
-
- **The socket belongs to the Layer providing it.** `connection(url)` opens it when it is built
|
|
85
|
-
and closes it when it is released: provide it once around the page's program, or keep it in
|
|
86
|
-
a `ManagedRuntime`, as `connect(url)` does. The scope `openChat` needs holds only the client.
|
|
87
|
-
- **One test file opens a port.** `Testing.layer` answers HTTP requests and cannot upgrade a
|
|
88
|
-
WebSocket, so `routes.test.ts` and `browser.test.ts` give the same rpcs Effect's HTTP protocol,
|
|
89
|
-
in memory. `server.test.ts` covers what that leaves out: it serves them over a WebSocket on a
|
|
90
|
-
port the system picks, calls with `connect(url)`, and sends a message over the server's bound.
|
|
91
|
-
|
|
92
52
|
## Where to look
|
|
93
53
|
|
|
94
54
|
| To see | Read |
|
|
@@ -96,13 +56,12 @@ test/
|
|
|
96
56
|
| An RPC binding naming its authentication descriptor | `src/Rpc.ts` |
|
|
97
57
|
| The rpcs on Effect's `RpcServer`, before a protocol is chosen | `src/routes.ts` |
|
|
98
58
|
| Serving them over a WebSocket, speaking JSON | `src/server.ts` |
|
|
99
|
-
| The same rpcs over Effect's HTTP protocol, for a test | `test/routes.test.ts` |
|
|
100
59
|
| A verifier that runs for every message, not once per connection | `src/identity/verifier.ts` |
|
|
101
60
|
| Native `RpcMiddleware` providing a per-request service, with a default | `src/chat/room.ts` |
|
|
102
61
|
| Handlers reading that service, and the rule that reads it | `src/chat/handlers.ts`, `src/chat/authorize.ts` |
|
|
103
62
|
| A public rpc beside protected ones | `src/chat/Chat.ts` |
|
|
104
63
|
| A socket client sending its token and room on each message | `src/browser.ts` |
|
|
105
|
-
| Who owns the socket, and how a promise page keeps it open | `src/browser.ts`
|
|
64
|
+
| Who owns the socket, and how a promise page keeps it open | `src/browser.ts`, `src/page.ts` |
|
|
106
65
|
| A declared error and the built-in refusals handled by tag | `src/browser.ts` |
|
|
107
66
|
| Running that client from Node, two callers on one socket | `src/client.ts` |
|
|
108
67
|
| Testing handlers and authorization with no transport | `test/chat/chat.test.ts` |
|