@gjermundgaraba/effect-actions 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/README.md +102 -41
  2. package/dist/Action.d.mts +31 -117
  3. package/dist/Action.mjs +14 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +23 -29
  9. package/dist/ActionMcp.mjs +56 -114
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +28 -9
  13. package/dist/ActionToolkit.mjs +31 -4
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +30 -23
  17. package/dist/Testing.mjs +27 -44
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +18 -0
  38. package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +211 -426
  46. package/docs/ActionCli-failures.md +104 -0
  47. package/docs/ActionCli.md +208 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +205 -282
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +222 -271
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +179 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +103 -119
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +143 -392
  59. package/docs/CONTEXT.md +40 -24
  60. package/docs/README.md +75 -35
  61. package/docs/Testing-failures.md +21 -0
  62. package/docs/Testing.md +193 -151
  63. package/docs/guarantees.md +35 -96
  64. package/docs/setup-failures.md +37 -0
  65. package/docs/setup.md +90 -92
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +318 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +126 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +111 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +42 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +77 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +141 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +121 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +31 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +111 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +137 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +119 -0
  183. package/examples/notes-service/src/notes/handlers.ts +39 -0
  184. package/examples/notes-service/src/notes/store.ts +84 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +288 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +27 -9
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -14
  211. package/dist/internal/media.d.mts +0 -8
  212. package/dist/internal/media.mjs +0 -26
  213. package/dist/internal/refusal.mjs +0 -67
  214. package/dist/internal/schema-errors.mjs +0 -20
  215. package/dist/internal/tools.mjs +0 -44
@@ -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
+ // The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
23
+ const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
24
+ const publicPort = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
25
+ const adminPort = yield* Config.Port("ADMIN_PORT").pipe(Config.withDefault(3001));
26
+
27
+ const limit = ByteSize.mebibytes(1);
28
+
29
+ const listen = (port: number) =>
30
+ NodeHttpServer.layer(createServer, {
31
+ host,
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
+ });