@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,65 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { NodeRuntime } from "@effect/platform-node";
|
|
3
|
+
import { Config, Console, Effect, flow } from "effect";
|
|
4
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
5
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
|
+
import { Http } from "./Api.js";
|
|
7
|
+
|
|
8
|
+
// Entrypoint: `node --import tsx examples/kitchen-sink/src/seed.ts`, with the server running.
|
|
9
|
+
// An Effect program calling the API with the binding's typed client: one method per action,
|
|
10
|
+
// taking its input and answering its decoded success. DEMO ONLY: it files a first issue in
|
|
11
|
+
// `acme` as `ada`, since the server's stores start empty.
|
|
12
|
+
|
|
13
|
+
// A one-pixel PNG stands in for a screenshot.
|
|
14
|
+
const screenshot = Buffer.from(
|
|
15
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
|
16
|
+
"base64",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const seed = Effect.gen(function* () {
|
|
20
|
+
const baseUrl = yield* Config.String("TRACKER_URL").pipe(
|
|
21
|
+
Config.withDefault("http://127.0.0.1:3000"),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const tracker = yield* ActionHttp.client(Http, {
|
|
25
|
+
baseUrl,
|
|
26
|
+
// Every request of this client: the token, and the header naming the workspace.
|
|
27
|
+
transformClient: HttpClient.mapRequest(
|
|
28
|
+
flow(
|
|
29
|
+
HttpClientRequest.bearerToken("ada"),
|
|
30
|
+
HttpClientRequest.setHeader("x-workspace", "acme"),
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const issue = yield* tracker.createIssue({
|
|
36
|
+
title: "Crash on save",
|
|
37
|
+
body: "Saving an empty draft crashes the editor.",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Over HTTP the image is JSON, its bytes in base64: the client encodes the `Uint8Array`.
|
|
41
|
+
const attachment = yield* tracker.attachImage({
|
|
42
|
+
issue: issue.id,
|
|
43
|
+
name: "crash.png",
|
|
44
|
+
image: { data: screenshot, mimeType: "image/png" },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
yield* tracker.commentOnIssue({ id: issue.id, text: "Reproduced on 2.4.1." });
|
|
48
|
+
|
|
49
|
+
// No argument: `{}` is a valid input here, so `limit` takes its default and `status` none.
|
|
50
|
+
const { issues } = yield* tracker.listIssues();
|
|
51
|
+
|
|
52
|
+
yield* Console.log(`Filed ${issue.id} with attachment ${attachment.id}. Issues:`, issues);
|
|
53
|
+
}).pipe(
|
|
54
|
+
// A declared error is a typed failure, the binding's and the verifier's included: each is
|
|
55
|
+
// handled by its tag. Anything else, such as an unreachable server, stays Effect's own.
|
|
56
|
+
Effect.catchTags({
|
|
57
|
+
RateLimited: ({ retryAfterSeconds }) =>
|
|
58
|
+
Console.error(`Write limit reached: retry in ${retryAfterSeconds}s.`),
|
|
59
|
+
QuotaExceeded: ({ retryAfterSeconds }) =>
|
|
60
|
+
Console.error(`Request quota spent: retry in ${retryAfterSeconds}s.`),
|
|
61
|
+
IssuerUnavailable: () => Console.error("The sign-in service is down: try again later."),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
seed.pipe(Effect.provide(FetchHttpClient.layer), NodeRuntime.runMain);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
|
|
3
|
+
import { ByteSize, Config, Effect, Layer } from "effect";
|
|
4
|
+
import { HttpRouter, HttpServerRequest } from "effect/http";
|
|
5
|
+
import { RpcSerialization, RpcServer } from "effect/rpc";
|
|
6
|
+
import { memory } from "./memory.js";
|
|
7
|
+
import { adminRoutes, builders, publicRoutes, rpc } from "./routes.js";
|
|
8
|
+
|
|
9
|
+
// Entrypoint: `node --import tsx examples/kitchen-sink/src/server.ts`.
|
|
10
|
+
// The composition root of the server: two listeners over one set of builders and services.
|
|
11
|
+
|
|
12
|
+
// The web app's rpcs over a WebSocket at `/rpc`: Node's HTTP server answers the upgrade. The
|
|
13
|
+
// protocol is this entrypoint's choice, as the ports are. `rpc` leaves it open, so a test
|
|
14
|
+
// serves the same layer over Effect's HTTP protocol.
|
|
15
|
+
const socket = rpc.pipe(
|
|
16
|
+
Layer.provide(RpcServer.layerProtocolWebsocket({ path: "/rpc" })),
|
|
17
|
+
Layer.provide(RpcSerialization.layerJson),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const server = Layer.unwrap(
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const publicPort = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
|
|
23
|
+
const adminPort = yield* Config.Port("ADMIN_PORT").pipe(Config.withDefault(3001));
|
|
24
|
+
|
|
25
|
+
const limit = ByteSize.mebibytes(1);
|
|
26
|
+
|
|
27
|
+
const listen = (port: number) =>
|
|
28
|
+
NodeHttpServer.layer(createServer, {
|
|
29
|
+
// This machine alone. A server others reach reads its host from config:
|
|
30
|
+
// `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
|
|
31
|
+
host: "127.0.0.1",
|
|
32
|
+
port,
|
|
33
|
+
// A message on the WebSocket of `/rpc` is no request body, so the limit below does not
|
|
34
|
+
// bound it: Node's WebSocket server reads one of up to 100 MiB whole unless told
|
|
35
|
+
// otherwise. A larger one closes the socket (1009).
|
|
36
|
+
websocket: { maxPayload: Number(limit) },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return Layer.mergeAll(
|
|
40
|
+
// Two audiences, two listeners. Each `HttpRouter.serve` builds its routes in a layer
|
|
41
|
+
// graph of its own, with a router of its own: the public port can only ever reach the
|
|
42
|
+
// members' routes, and nothing built inside one is shared with the other. The socket
|
|
43
|
+
// is merged into the public port's graph, so it is a route of that router: the request
|
|
44
|
+
// policy, global to the router, refuses a foreign Origin's upgrade as it does a request,
|
|
45
|
+
// and the rpcs spend the quota the routes spend, built once.
|
|
46
|
+
HttpRouter.serve(Layer.merge(publicRoutes, socket)).pipe(Layer.provide(listen(publicPort))),
|
|
47
|
+
HttpRouter.serve(adminRoutes).pipe(Layer.provide(listen(adminPort))),
|
|
48
|
+
).pipe(
|
|
49
|
+
// Request bodies have no size limit unless the host sets one. This covers every HTTP
|
|
50
|
+
// route of both ports, the MCP endpoint included.
|
|
51
|
+
Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, limit)),
|
|
52
|
+
// So what both ports share is provided outside both, once: the builders, then the
|
|
53
|
+
// services the builders, the verifiers and the quota are built from. A workspace an
|
|
54
|
+
// operator suspends on one port is thereby refused on the other.
|
|
55
|
+
Layer.provide(builders),
|
|
56
|
+
Layer.provide(memory),
|
|
57
|
+
);
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// NodeRuntime handles signals: the scoped layers stop both listeners and release the services.
|
|
62
|
+
Layer.launch(server).pipe(NodeRuntime.runMain);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NodeRuntime, NodeStdio } from "@effect/platform-node";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
4
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
5
|
+
import { Tools } from "./Actions.js";
|
|
6
|
+
import { attachments } from "./attachments/handlers.js";
|
|
7
|
+
import { identity } from "./identity/handlers.js";
|
|
8
|
+
import { CurrentMember } from "./identity/Identity.js";
|
|
9
|
+
import { members } from "./identity/members.js";
|
|
10
|
+
import { features } from "./issues/guide.js";
|
|
11
|
+
import { issues } from "./issues/handlers.js";
|
|
12
|
+
import { limiter, stores } from "./memory.js";
|
|
13
|
+
import { status } from "./workspaces/handlers.js";
|
|
14
|
+
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
|
15
|
+
|
|
16
|
+
// Entrypoint: an MCP server a local agent starts as a subprocess, such as
|
|
17
|
+
// `claude mcp add tracker -- node --import tsx examples/kitchen-sink/src/stdio.ts`.
|
|
18
|
+
// It lists the implementations it serves itself, and imports nothing of the HTTP router. They
|
|
19
|
+
// are those of the `/mcp` endpoint, with its tools and features and the same `authorize`.
|
|
20
|
+
// stdio is a local surface: it takes no `authentication`, since no token arrives.
|
|
21
|
+
ActionMcp.runStdio([status, identity, issues, attachments], {
|
|
22
|
+
name: "tracker",
|
|
23
|
+
version: "1.0.0",
|
|
24
|
+
actions: Tools,
|
|
25
|
+
features,
|
|
26
|
+
}).pipe(
|
|
27
|
+
// The host names the caller itself, around every call. DEMO ONLY: a real host reads who
|
|
28
|
+
// started it, from its own login or configuration.
|
|
29
|
+
Effect.provideService(CurrentMember, members.ada),
|
|
30
|
+
Effect.provide([NodeStdio.layer, stores, limiter, WorkspaceRegistry.layerMemory]),
|
|
31
|
+
// stdout carries the protocol alone, so logs and failures go to stderr. Applied last.
|
|
32
|
+
ActionCli.logToStderr,
|
|
33
|
+
NodeRuntime.runMain,
|
|
34
|
+
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
|
|
4
|
+
export const Workspace = Schema.Struct({
|
|
5
|
+
slug: Schema.String,
|
|
6
|
+
name: Schema.String,
|
|
7
|
+
suspended: Schema.Boolean,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type Workspace = typeof Workspace.Type;
|
|
11
|
+
|
|
12
|
+
export class WorkspaceNotFound extends Schema.TaggedError<WorkspaceNotFound>()(
|
|
13
|
+
"WorkspaceNotFound",
|
|
14
|
+
{ slug: Schema.String },
|
|
15
|
+
{ httpApiStatus: 404 },
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
// Public: `caller: Action.Anyone`. It answers before anyone has signed in, on every surface,
|
|
19
|
+
// so it tells nothing that depends on the caller.
|
|
20
|
+
export const GetStatus = Action.make("getStatus", {
|
|
21
|
+
description:
|
|
22
|
+
"Report whether the tracker is up, since which start, and how many workspaces it serves.",
|
|
23
|
+
// `instance` names one start of the process: it changes on a restart, and with it the demo's
|
|
24
|
+
// memory stores are empty again.
|
|
25
|
+
success: { service: Schema.String, instance: Schema.String, workspaces: Schema.Finite },
|
|
26
|
+
readOnly: true,
|
|
27
|
+
caller: Action.Anyone,
|
|
28
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { WorkspaceRegistry } from "./registry.js";
|
|
4
|
+
import { GetStatus } from "./Workspaces.js";
|
|
5
|
+
|
|
6
|
+
// One action, one handler, built once: the builder yields the registry at startup, and names
|
|
7
|
+
// this start of the process. Both ports serve this implementation, so what the builder makes
|
|
8
|
+
// tells whether they share one run of it (see `builders` in routes.ts). A public action takes
|
|
9
|
+
// no `authorize`: nothing would run it.
|
|
10
|
+
export const status = Action.implement(
|
|
11
|
+
GetStatus,
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const registry = yield* WorkspaceRegistry;
|
|
14
|
+
const instance = crypto.randomUUID();
|
|
15
|
+
|
|
16
|
+
return () =>
|
|
17
|
+
Effect.map(registry.list, (workspaces) => ({
|
|
18
|
+
service: "tracker",
|
|
19
|
+
instance,
|
|
20
|
+
workspaces: workspaces.length,
|
|
21
|
+
}));
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { type Workspace, WorkspaceNotFound } from "./Workspaces.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The tenants. Operators list and suspend them on the admin port; authentication reads the
|
|
6
|
+
* suspension on the public one, so both servers must share this one service.
|
|
7
|
+
*/
|
|
8
|
+
export class WorkspaceRegistry extends Context.Service<
|
|
9
|
+
WorkspaceRegistry,
|
|
10
|
+
{
|
|
11
|
+
readonly list: Effect.Effect<ReadonlyArray<Workspace>>;
|
|
12
|
+
readonly get: (slug: string) => Effect.Effect<Workspace, WorkspaceNotFound>;
|
|
13
|
+
readonly setSuspended: (
|
|
14
|
+
slug: string,
|
|
15
|
+
suspended: boolean,
|
|
16
|
+
) => Effect.Effect<Workspace, WorkspaceNotFound>;
|
|
17
|
+
}
|
|
18
|
+
>()("tracker/WorkspaceRegistry") {
|
|
19
|
+
// DEMO ONLY: two workspaces in memory. A real registry is another Layer of this service.
|
|
20
|
+
static readonly layerMemory = Layer.sync(WorkspaceRegistry, () => {
|
|
21
|
+
const workspaces = new Map<string, Workspace>([
|
|
22
|
+
["acme", { slug: "acme", name: "Acme", suspended: false }],
|
|
23
|
+
["globex", { slug: "globex", name: "Globex", suspended: false }],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const get = Effect.fn("WorkspaceRegistry.get")(function* (slug: string) {
|
|
27
|
+
const workspace = workspaces.get(slug);
|
|
28
|
+
|
|
29
|
+
if (workspace === undefined) return yield* new WorkspaceNotFound({ slug });
|
|
30
|
+
|
|
31
|
+
return workspace;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return WorkspaceRegistry.of({
|
|
35
|
+
list: Effect.sync(() => Array.from(workspaces.values())),
|
|
36
|
+
get,
|
|
37
|
+
setSuspended: Effect.fn("WorkspaceRegistry.setSuspended")(function* (slug, suspended) {
|
|
38
|
+
const workspace = { ...(yield* get(slug)), suspended };
|
|
39
|
+
|
|
40
|
+
workspaces.set(slug, workspace);
|
|
41
|
+
|
|
42
|
+
return workspace;
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Context, Effect, Option } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerRequest } from "effect/http";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The workspace a request addresses, from its `X-Workspace` header. It is request context, not
|
|
6
|
+
* an identity: the verifier reads it to decide which of an account's memberships the caller
|
|
7
|
+
* acts in. Handlers never read it; they read the workspace of the authenticated member.
|
|
8
|
+
*/
|
|
9
|
+
export class Tenant extends Context.Service<Tenant, Option.Option<string>>()("tracker/Tenant") {}
|
|
10
|
+
|
|
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 (see routes.ts).
|
|
13
|
+
export const resolveTenant = HttpRouter.middleware<{ provides: Tenant }>()((route) =>
|
|
14
|
+
Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) =>
|
|
15
|
+
Effect.provideService(route, Tenant, Option.fromNullishOr(request.headers["x-workspace"])),
|
|
16
|
+
),
|
|
17
|
+
);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
4
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
5
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
7
|
+
import { Operator } from "../../src/admin/Admin.js";
|
|
8
|
+
import { Grants } from "../../src/admin/grants.js";
|
|
9
|
+
import { admin } from "../../src/admin/handlers.js";
|
|
10
|
+
import { AdminHttp } from "../../src/Api.js";
|
|
11
|
+
import { memory } from "../../src/memory.js";
|
|
12
|
+
import { adminRoutes } from "../../src/routes.js";
|
|
13
|
+
import { WorkspaceRegistry } from "../../src/workspaces/registry.js";
|
|
14
|
+
|
|
15
|
+
const served = Testing.layer(adminRoutes).pipe(Layer.provide(memory));
|
|
16
|
+
|
|
17
|
+
const key = (operatorKey: string) => ({
|
|
18
|
+
transformClient: HttpClient.mapRequest(
|
|
19
|
+
HttpClientRequest.setHeader("x-operator-key", operatorKey),
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("the operators", () => {
|
|
24
|
+
it.effect("are authorized by the grants stored for their key", () =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const support = yield* ActionHttp.client(AdminHttp, key("support-key"));
|
|
27
|
+
const ops = yield* ActionHttp.client(AdminHttp, key("ops-key"));
|
|
28
|
+
|
|
29
|
+
expect((yield* support.listWorkspaces()).workspaces).toHaveLength(2);
|
|
30
|
+
|
|
31
|
+
// A key cannot step up, so the refusal names no scope: a plain 403.
|
|
32
|
+
const refused = yield* Effect.flip(support.suspendWorkspace({ workspace: "acme" }));
|
|
33
|
+
|
|
34
|
+
expect(refused).toBeInstanceOf(Action.Forbidden);
|
|
35
|
+
expect(refused).not.toHaveProperty("scopes");
|
|
36
|
+
expect(refused.message).toBe("Requires the workspaces:suspend grant.");
|
|
37
|
+
expect(yield* ops.suspendWorkspace({ workspace: "acme" })).toMatchObject({ suspended: true });
|
|
38
|
+
}).pipe(Effect.provide(served)),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
it.effect("are not the members: a member's token is no credential here", () =>
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
const member = yield* ActionHttp.client(AdminHttp, {
|
|
44
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken("ada")),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(yield* Effect.flip(member.listWorkspaces())).toBeInstanceOf(Action.Unauthenticated);
|
|
48
|
+
}).pipe(Effect.provide(served)),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
it.effect("include the console's trusted operator, admitted by the same rule in process", () =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const client = yield* Action.client(admin);
|
|
54
|
+
const suspend = client.suspendWorkspace({ workspace: "globex" });
|
|
55
|
+
|
|
56
|
+
// No grants are stored for either: only the operator a host supplies is trusted.
|
|
57
|
+
expect(
|
|
58
|
+
yield* suspend.pipe(Effect.provideService(Operator, { id: "console", trusted: true })),
|
|
59
|
+
).toMatchObject({ suspended: true });
|
|
60
|
+
expect(
|
|
61
|
+
yield* Effect.flip(
|
|
62
|
+
suspend.pipe(Effect.provideService(Operator, { id: "intern", trusted: false })),
|
|
63
|
+
),
|
|
64
|
+
).toBeInstanceOf(Action.Forbidden);
|
|
65
|
+
}).pipe(Effect.provide([WorkspaceRegistry.layerMemory, Grants.layerDemo])),
|
|
66
|
+
);
|
|
67
|
+
});
|