@gjermundgaraba/effect-actions 0.10.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.
Files changed (214) hide show
  1. package/README.md +103 -43
  2. package/dist/Action.d.mts +31 -118
  3. package/dist/Action.mjs +15 -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 +25 -31
  9. package/dist/ActionMcp.mjs +140 -74
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +5 -8
  13. package/dist/ActionToolkit.mjs +3 -3
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +32 -25
  17. package/dist/Testing.mjs +45 -30
  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 +17 -0
  38. package/dist/mcp/protocol.mjs +149 -0
  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 +206 -424
  46. package/docs/ActionCli-failures.md +102 -0
  47. package/docs/ActionCli.md +206 -292
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +202 -281
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +229 -193
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +178 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +100 -117
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +142 -392
  59. package/docs/CONTEXT.md +39 -23
  60. package/docs/README.md +71 -35
  61. package/docs/Testing-failures.md +20 -0
  62. package/docs/Testing.md +186 -151
  63. package/docs/guarantees.md +35 -95
  64. package/docs/setup-failures.md +34 -0
  65. package/docs/setup.md +88 -87
  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 +317 -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 +146 -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 +109 -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 +41 -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 +75 -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 +136 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +119 -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 +30 -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 +108 -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 +89 -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 +91 -0
  183. package/examples/notes-service/src/notes/handlers.ts +33 -0
  184. package/examples/notes-service/src/notes/store.ts +73 -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 +134 -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 +32 -14
  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 -11
  211. package/dist/internal/mcp.mjs +0 -52
  212. package/dist/internal/refusal.mjs +0 -67
  213. package/dist/internal/schema-errors.mjs +0 -20
  214. package/dist/internal/tools.mjs +0 -44
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Array, Effect, flow, 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 { Http } from "../../src/Api.js";
8
+ import { Directory } from "../../src/identity/directory.js";
9
+ import { IssuerUnavailable, QuotaExceeded, WhoAmI } from "../../src/identity/Identity.js";
10
+ import { memory } from "../../src/memory.js";
11
+ import { publicRoutes } from "../../src/routes.js";
12
+
13
+ // What authentication adds on the wire: the public port behind its real providers, answering
14
+ // in memory. A caller is the credential its client sends.
15
+ const served = Testing.layer(publicRoutes).pipe(Layer.provide(memory));
16
+
17
+ // A test replaces the one service it is about. Provided before `memory`, this layer is the
18
+ // `Directory` the routes are built with; `memory` gives them the rest.
19
+ const servedWith = (directory: Layer.Layer<Directory>) =>
20
+ Testing.layer(publicRoutes).pipe(Layer.provide(directory), Layer.provide(memory));
21
+
22
+ // A directory that cannot reach its issuer.
23
+ const issuerDown = Layer.succeed(
24
+ Directory,
25
+ Directory.of({
26
+ ofToken: () => Effect.fail(new IssuerUnavailable({ retryAfterSeconds: 30 })),
27
+ ofSession: () => Effect.fail(new Action.Unauthenticated()),
28
+ }),
29
+ );
30
+
31
+ const as = (token: string, workspace: string) => ({
32
+ transformClient: HttpClient.mapRequest(
33
+ flow(
34
+ HttpClientRequest.bearerToken(token),
35
+ HttpClientRequest.setHeader("x-workspace", workspace),
36
+ ),
37
+ ),
38
+ });
39
+
40
+ describe("a bearer token", () => {
41
+ it.effect("makes the caller the member it is in the workspace the request names", () =>
42
+ Effect.gen(function* () {
43
+ const atAcme = yield* ActionHttp.client(Http, as("ada", "acme"));
44
+ const atGlobex = yield* ActionHttp.client(Http, as("ada", "globex"));
45
+ const elsewhere = yield* ActionHttp.client(Http, as("linus", "globex"));
46
+
47
+ expect(yield* atAcme.whoAmI()).toMatchObject({ workspace: "acme" });
48
+ expect(yield* atGlobex.whoAmI()).toEqual({
49
+ id: "ada",
50
+ workspace: "globex",
51
+ scopes: ["issues:read"],
52
+ });
53
+
54
+ // A member of acme alone, asking for globex: refused, with no scope to step up to.
55
+ const refused = yield* Effect.flip(elsewhere.whoAmI());
56
+
57
+ expect(refused).toBeInstanceOf(Action.Forbidden);
58
+ expect(refused.message).toBe("Name a workspace you belong to in the X-Workspace header.");
59
+ }).pipe(Effect.provide(served)),
60
+ );
61
+
62
+ it.effect(
63
+ "is refused a write it lacks the scope for, by an error and a challenge naming it",
64
+ () =>
65
+ Effect.gen(function* () {
66
+ const reader = yield* ActionHttp.client(Http, as("linus", "acme"));
67
+
68
+ const refused = yield* Effect.flip(reader.createIssue({ title: "Crash", body: "" }));
69
+
70
+ // `toEqual` compares an error's own fields, here `scopes`; never its `message`.
71
+ expect(refused).toEqual(new Action.Forbidden({ scopes: ["issues:write"] }));
72
+ expect(refused.message).toBe("Requires issues:write.");
73
+
74
+ // The same refusal as sent: the 403 an OAuth client re-authorizes on.
75
+ const response = yield* HttpClientRequest.post("/api/v1/closeIssue").pipe(
76
+ HttpClientRequest.bearerToken("linus"),
77
+ HttpClientRequest.setHeader("x-workspace", "acme"),
78
+ HttpClientRequest.bodyJsonUnsafe({ id: "ACME-1" }),
79
+ HttpClient.execute,
80
+ );
81
+
82
+ expect(response.status).toBe(403);
83
+ expect(response.headers["www-authenticate"]).toContain(
84
+ 'error="insufficient_scope", scope="issues:write"',
85
+ );
86
+ }).pipe(Effect.provide(served)),
87
+ );
88
+
89
+ it.effect("finds where to sign in: published discovery, named in the challenge of a 401", () =>
90
+ Effect.gen(function* () {
91
+ const discovery = yield* HttpClient.get("/.well-known/oauth-protected-resource/mcp");
92
+
93
+ expect(yield* discovery.json).toMatchObject({
94
+ resource: "http://localhost:3000/mcp",
95
+ scopes_supported: ["issues:read", "issues:write"],
96
+ });
97
+
98
+ // A protected tool's call without a token: the endpoint's own 401, before any tool runs.
99
+ const refused = yield* HttpClient.execute(
100
+ Testing.mcpRequest("tools/call", { name: "whoAmI", arguments: {} }),
101
+ );
102
+
103
+ expect(refused.status).toBe(401);
104
+ expect(refused.headers["www-authenticate"]).toBe(
105
+ 'Bearer scope="issues:read", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp"',
106
+ );
107
+ }).pipe(Effect.provide(served)),
108
+ );
109
+
110
+ it.effect("fails with the verifier's declared error, a typed 503, when the issuer is down", () =>
111
+ Effect.gen(function* () {
112
+ const http = yield* ActionHttp.client(Http, as("ada", "acme"));
113
+ const mcp = yield* Testing.mcpClient([WhoAmI], as("ada", "acme"));
114
+
115
+ expect(yield* Effect.flip(http.whoAmI())).toEqual(
116
+ new IssuerUnavailable({ retryAfterSeconds: 30 }),
117
+ );
118
+ // Over MCP it is the endpoint's HTTP answer, before any tool runs, not a tool's result:
119
+ // the client fails with the error that holds such an answer.
120
+ expect(yield* Effect.flip(mcp.whoAmI())).toBeInstanceOf(Testing.McpCallError);
121
+ }).pipe(Effect.provide(servedWith(issuerDown))),
122
+ );
123
+ });
124
+
125
+ describe("the request quota", () => {
126
+ it.effect("is spent per member, and answered as the binding's typed error", () =>
127
+ Effect.gen(function* () {
128
+ const ada = yield* ActionHttp.client(Http, as("ada", "acme"));
129
+ const grace = yield* ActionHttp.client(Http, as("grace", "globex"));
130
+
131
+ yield* Effect.forEach(Array.range(1, 60), () => ada.whoAmI());
132
+
133
+ expect(yield* Effect.flip(ada.whoAmI())).toBeInstanceOf(QuotaExceeded);
134
+ expect(yield* grace.whoAmI()).toMatchObject({ id: "grace" });
135
+ }).pipe(Effect.provide(served)),
136
+ );
137
+ });
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Array, Effect, flow, Layer } from "effect";
3
+ import { HttpClient, HttpClientRequest } from "effect/http";
4
+ import { RpcClient, RpcSerialization, RpcServer } from "effect/rpc";
5
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
6
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
7
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
8
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
9
+ import { Http } from "../../src/Api.js";
10
+ import { QuotaExceeded } from "../../src/identity/Identity.js";
11
+ import { memory } from "../../src/memory.js";
12
+ import { publicRoutes, rpc } from "../../src/routes.js";
13
+ import { Rpc } from "../../src/Rpc.js";
14
+ import { WorkspaceRegistry } from "../../src/workspaces/registry.js";
15
+
16
+ // The web app's rpcs in memory. The server serves them over a WebSocket; here the same layer
17
+ // is given Effect's HTTP protocol, which a client reaches through `Testing.layer`.
18
+ const server = rpc.pipe(
19
+ Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
20
+ Layer.provide(RpcSerialization.layerJson),
21
+ );
22
+
23
+ const protocol = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
24
+ Layer.provide(RpcSerialization.layerJson),
25
+ );
26
+
27
+ const socket = protocol.pipe(Layer.provide(Testing.layer(server)));
28
+
29
+ // `provideMerge`: the test reads the registry the rpcs' verifier reads.
30
+ const served = socket.pipe(Layer.provideMerge(memory));
31
+
32
+ // The rpcs beside the public port's routes under one `Testing.layer`, as server.ts serves them
33
+ // under one router, there over a WebSocket. `provideMerge`: the test calls the JSON routes
34
+ // with the `HttpClient` the rpc client's protocol is given.
35
+ const servedWithHttp = protocol.pipe(
36
+ Layer.provideMerge(Testing.layer(Layer.merge(server, publicRoutes))),
37
+ Layer.provide(memory),
38
+ );
39
+
40
+ // Each call carries its caller's session cookie, as a browser attaches it, so one client
41
+ // serves several callers. Authentication is per message.
42
+ const as = (session: string) => RpcClient.withHeaders({ cookie: `tracker_session=${session}` });
43
+
44
+ describe("a session cookie, over RPC", () => {
45
+ it.effect("authenticates each message", () =>
46
+ Effect.gen(function* () {
47
+ const client = yield* ActionRpc.client(Rpc);
48
+ const issue = yield* client.createIssue({ title: "Crash", body: "" }).pipe(as("ada-at-acme"));
49
+
50
+ expect(issue.id).toBe("ACME-1");
51
+ expect(yield* client.whoAmI().pipe(as("grace-at-globex"))).toMatchObject({
52
+ workspace: "globex",
53
+ });
54
+ expect(yield* client.listIssues().pipe(as("grace-at-globex"))).toEqual({ issues: [] });
55
+ }).pipe(Effect.scoped, Effect.provide(served)),
56
+ );
57
+
58
+ it.effect("fails a call without a session, or with an unknown one, as a typed refusal", () =>
59
+ Effect.gen(function* () {
60
+ const client = yield* ActionRpc.client(Rpc);
61
+
62
+ expect(yield* Effect.flip(client.whoAmI())).toBeInstanceOf(Action.Unauthenticated);
63
+
64
+ const unknown = yield* Effect.flip(client.whoAmI().pipe(as("stolen")));
65
+
66
+ expect(unknown).toBeInstanceOf(Action.Unauthenticated);
67
+ expect(unknown.message).toBe("Unknown demo session.");
68
+ }).pipe(Effect.scoped, Effect.provide(served)),
69
+ );
70
+
71
+ it.effect("is refused once its workspace is suspended, as a token is", () =>
72
+ Effect.gen(function* () {
73
+ const client = yield* ActionRpc.client(Rpc);
74
+ const registry = yield* WorkspaceRegistry;
75
+
76
+ yield* registry.setSuspended("acme", true);
77
+
78
+ const refused = yield* Effect.flip(client.whoAmI().pipe(as("ada-at-acme")));
79
+
80
+ expect(refused).toBeInstanceOf(Action.Forbidden);
81
+ expect(refused.message).toBe("This workspace is suspended.");
82
+ expect(yield* client.whoAmI().pipe(as("grace-at-globex"))).toMatchObject({ id: "grace" });
83
+ }).pipe(Effect.scoped, Effect.provide(served)),
84
+ );
85
+
86
+ it.effect("spends the one request quota its member also spends over HTTP", () =>
87
+ Effect.gen(function* () {
88
+ const rpcs = yield* ActionRpc.client(Rpc);
89
+
90
+ const http = yield* ActionHttp.client(Http, {
91
+ transformClient: HttpClient.mapRequest(
92
+ flow(
93
+ HttpClientRequest.bearerToken("ada"),
94
+ HttpClientRequest.setHeader("x-workspace", "acme"),
95
+ ),
96
+ ),
97
+ });
98
+
99
+ // Sixty requests as ada in acme, half on each surface: each middleware counts, and the
100
+ // next request is refused on either.
101
+ yield* Effect.forEach(Array.range(1, 30), () => http.whoAmI());
102
+ yield* Effect.forEach(Array.range(1, 30), () => rpcs.whoAmI().pipe(as("ada-at-acme")));
103
+
104
+ expect(yield* Effect.flip(rpcs.whoAmI().pipe(as("ada-at-acme")))).toBeInstanceOf(
105
+ QuotaExceeded,
106
+ );
107
+ expect(yield* Effect.flip(http.whoAmI())).toBeInstanceOf(QuotaExceeded);
108
+ }).pipe(Effect.scoped, Effect.provide(servedWithHttp)),
109
+ );
110
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Effect, Layer, Stream } from "effect";
3
+ import { LanguageModel } from "effect/ai";
4
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
5
+ import { CurrentMember } from "../../src/identity/Identity.js";
6
+ import { members } from "../../src/identity/members.js";
7
+ import { layer, toolkit, triage } from "../../src/issues/assistant.js";
8
+ import { issues } from "../../src/issues/handlers.js";
9
+ import { memory } from "../../src/memory.js";
10
+
11
+ // A model that asks for the same two tools in every turn stands in for a provider's.
12
+ const model = LanguageModel.make({
13
+ generateText: () =>
14
+ Effect.succeed([
15
+ { type: "tool-call", id: "1", name: "getIssue", params: { id: "ACME-1" } },
16
+ { type: "tool-call", id: "2", name: "closeIssue", params: { id: "ACME-1" } },
17
+ ] as const),
18
+ streamText: () => Stream.empty,
19
+ });
20
+
21
+ const asAda = Effect.provideService(CurrentMember, members.ada);
22
+
23
+ // The toolkit's handlers and a client of the same implementation, over one store.
24
+ const services = layer.pipe(Layer.provideMerge(memory));
25
+
26
+ describe("the assistant's tools", () => {
27
+ it.effect("runs a model's read and holds its write for approval", () =>
28
+ Effect.gen(function* () {
29
+ const tracker = yield* Action.client(issues);
30
+
31
+ yield* tracker.createIssue({ title: "Crash", body: "" }).pipe(asAda);
32
+
33
+ const turn = yield* triage("Close what is done.").pipe(
34
+ asAda,
35
+ Effect.provideServiceEffect(LanguageModel.LanguageModel, model),
36
+ );
37
+
38
+ expect(turn.toolResults.map(({ name }) => name)).toEqual(["getIssue"]);
39
+ expect(
40
+ turn.content.flatMap((part) =>
41
+ part.type === "tool-approval-request" ? [part.toolCallId] : [],
42
+ ),
43
+ ).toEqual(["2"]);
44
+ expect(yield* tracker.getIssue({ id: "ACME-1" }).pipe(asAda)).toMatchObject({
45
+ status: "open",
46
+ });
47
+ }).pipe(Effect.provide(services)),
48
+ );
49
+
50
+ it.effect("offers the listed actions alone, and runs `authorize` for a call made by hand", () =>
51
+ Effect.gen(function* () {
52
+ const tools = yield* toolkit;
53
+
54
+ expect(Object.keys(tools.tools)).toEqual([
55
+ "listIssues",
56
+ "getIssue",
57
+ "commentOnIssue",
58
+ "closeIssue",
59
+ ]);
60
+
61
+ // `tools.handle` is a caller that is not a model's turn: it asks no approval, and takes
62
+ // the arguments as JSON. The implementation's `authorize` runs all the same.
63
+ const refused = yield* tools
64
+ .handle("closeIssue", { id: "ACME-1" })
65
+ .pipe(
66
+ Effect.flatMap(Stream.runCollect),
67
+ Effect.provideService(CurrentMember, members.linus),
68
+ );
69
+
70
+ expect(refused.map(({ isFailure, result }) => [isFailure, result])).toEqual([
71
+ [true, new Action.Forbidden({ scopes: ["issues:write"] })],
72
+ ]);
73
+ }).pipe(Effect.provide(services)),
74
+ );
75
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Array, Effect, Layer, References } from "effect";
3
+ import { RateLimiter } from "effect/persistence";
4
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
5
+ import { CurrentMember } from "../../src/identity/Identity.js";
6
+ import { members } from "../../src/identity/members.js";
7
+ import { issues } from "../../src/issues/handlers.js";
8
+ import { IssueNotFound, Issues, RateLimited } from "../../src/issues/Issues.js";
9
+ import { IssueStore } from "../../src/issues/store.js";
10
+ import { status } from "../../src/workspaces/handlers.js";
11
+
12
+ // What the implementation does, with no transport: `Action.client` runs the same decoding,
13
+ // `authorize` and handler a server runs. Each call names its caller, as a request would.
14
+ const as = (member: keyof typeof members) => Effect.provideService(CurrentMember, members[member]);
15
+
16
+ // The two startup services the issues' builder yields, and nothing else of the application.
17
+ const services = Layer.mergeAll(
18
+ IssueStore.layerMemory,
19
+ RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory)),
20
+ );
21
+
22
+ describe("issues, in process", () => {
23
+ it.effect("keeps each workspace's issues its own", () =>
24
+ Effect.gen(function* () {
25
+ const client = yield* Action.client(issues);
26
+ const issue = yield* client.createIssue({ title: "Crash", body: "On save." }).pipe(as("ada"));
27
+
28
+ expect(issue).toMatchObject({ id: "ACME-1", status: "open" });
29
+ expect(yield* client.listIssues().pipe(as("linus"))).toEqual({
30
+ issues: [{ id: "ACME-1", title: "Crash", status: "open" }],
31
+ });
32
+ expect(yield* client.listIssues().pipe(as("grace"))).toEqual({ issues: [] });
33
+ expect(yield* Effect.flip(client.getIssue({ id: issue.id }).pipe(as("grace")))).toEqual(
34
+ new IssueNotFound({ id: "ACME-1" }),
35
+ );
36
+ }).pipe(Effect.provide(services)),
37
+ );
38
+
39
+ it.effect("lists twenty by default, newest first, or the status and limit asked for", () =>
40
+ Effect.gen(function* () {
41
+ const client = yield* Action.client(issues);
42
+ const store = yield* IssueStore;
43
+
44
+ // Written to the store, not through `createIssue`: twenty-one are over its write limit.
45
+ yield* Effect.forEach(Array.range(1, 21), (n) =>
46
+ store.create(members.ada.workspace, { title: `Issue ${n}`, body: "" }),
47
+ );
48
+ yield* client.closeIssue({ id: "ACME-2" }).pipe(as("ada"));
49
+
50
+ // No argument is the input `{}`: `status` is absent and `limit` its default.
51
+ const all = yield* client.listIssues().pipe(as("ada"));
52
+ const closed = yield* client.listIssues({ status: "closed", limit: 20 }).pipe(as("ada"));
53
+ const newest = yield* client.listIssues({ limit: 1 }).pipe(as("ada"));
54
+
55
+ expect(all.issues.map(({ id }) => id)).toEqual(
56
+ Array.range(2, 21)
57
+ .map((n) => `ACME-${n}`)
58
+ .toReversed(),
59
+ );
60
+ expect(closed.issues.map(({ id }) => id)).toEqual(["ACME-2"]);
61
+ expect(newest.issues.map(({ id }) => id)).toEqual(["ACME-21"]);
62
+ }).pipe(Effect.provide(services)),
63
+ );
64
+
65
+ it.effect("refuses a write from a member holding the read scope alone", () =>
66
+ Effect.gen(function* () {
67
+ const client = yield* Action.client(issues);
68
+ const create = client.createIssue({ title: "Crash", body: "" });
69
+
70
+ const refused = yield* Effect.flip(create.pipe(as("linus")));
71
+
72
+ // `toEqual` compares an error's own fields, here `scopes`; never its `message`.
73
+ expect(refused).toEqual(new Action.Forbidden({ scopes: ["issues:write"] }));
74
+ expect(refused.message).toBe("Requires issues:write.");
75
+ }).pipe(Effect.provide(services)),
76
+ );
77
+
78
+ it.effect("keeps both of two comments made at once", () =>
79
+ Effect.gen(function* () {
80
+ const store = yield* IssueStore;
81
+ const { workspace } = members.ada;
82
+
83
+ // Asked of the store, not through `commentOnIssue`, whose write limit this would spend.
84
+ // A fiber is set aside after every `steps` steps, and two that run at once then take
85
+ // turns. Each count sets the first comment aside at another point of its work; under
86
+ // three, the two do not finish.
87
+ const kept = yield* Effect.forEach(Array.range(3, 60), (steps) =>
88
+ Effect.gen(function* () {
89
+ const { id } = yield* store.create(workspace, { title: "Crash", body: "" });
90
+
91
+ yield* Effect.all(
92
+ ["One", "Two"].map((text) => store.comment(workspace, id, { author: "ada", text })),
93
+ { concurrency: "unbounded" },
94
+ ).pipe(Effect.provideService(References.MaxOpsBeforeYield, steps));
95
+
96
+ return (yield* store.get(workspace, id)).comments.length;
97
+ }),
98
+ );
99
+
100
+ expect(kept).toEqual(Array.range(3, 60).map(() => 2));
101
+ }).pipe(Effect.provide(services)),
102
+ );
103
+
104
+ it.effect("counts every write of a member against one limit, and no one else's", () =>
105
+ Effect.gen(function* () {
106
+ const client = yield* Action.client(issues);
107
+
108
+ yield* client.createIssue({ title: "Crash", body: "" }).pipe(as("ada"));
109
+ yield* Effect.forEach(Array.range(1, 9), (n) =>
110
+ client.commentOnIssue({ id: "ACME-1", text: `Comment ${n}` }).pipe(as("ada")),
111
+ );
112
+
113
+ // Ten writes are spent, by two actions: the eleventh, of a third, is refused.
114
+ expect(
115
+ yield* Effect.flip(client.closeIssue({ id: "ACME-1" }).pipe(as("ada"))),
116
+ ).toBeInstanceOf(RateLimited);
117
+ expect(yield* client.getIssue({ id: "ACME-1" }).pipe(as("ada"))).toMatchObject({
118
+ status: "open",
119
+ });
120
+ expect(
121
+ yield* client.createIssue({ title: "Mine", body: "" }).pipe(as("grace")),
122
+ ).toMatchObject({ id: "GLOBEX-1" });
123
+ }).pipe(Effect.provide(services)),
124
+ );
125
+
126
+ it.effect("builds only the implementations whose actions a client lists", () =>
127
+ Effect.gen(function* () {
128
+ // Two implementations are passed, and `actions` selects the issues': `status` is left
129
+ // out, unbuilt, so `services` need not hold the registry its builder yields.
130
+ const client = yield* Action.client([status, issues], { actions: Issues });
131
+
132
+ expect(yield* client.listIssues().pipe(as("ada"))).toEqual({ issues: [] });
133
+ }).pipe(Effect.provide(services)),
134
+ );
135
+ });
@@ -0,0 +1,125 @@
1
+ import { afterAll, describe, expect, it } from "@effect/vitest";
2
+ import { Effect, Layer } from "effect";
3
+ import { HttpRouter, HttpServer } from "effect/http";
4
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
5
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
6
+ import { Http } from "../src/Api.js";
7
+ import { memory } from "../src/memory.js";
8
+ import { adminRoutes, publicRoutes } from "../src/routes.js";
9
+
10
+ // Each port as a web handler, `Request` in and `Response` out: what a platform without
11
+ // Effect's server runs, and what a test sends raw requests to. A handler builds its routes
12
+ // once; the test owns it, and disposes it when done.
13
+ const web = HttpRouter.toWebHandler(
14
+ publicRoutes.pipe(Layer.provide(HttpServer.layerServices), Layer.provide(memory)),
15
+ );
16
+
17
+ const adminWeb = HttpRouter.toWebHandler(
18
+ adminRoutes.pipe(Layer.provide(HttpServer.layerServices), Layer.provide(memory)),
19
+ );
20
+
21
+ afterAll(() => Promise.all([web.dispose(), adminWeb.dispose()]));
22
+
23
+ const post = (url: string, headers: Record<string, string> = {}) =>
24
+ new Request(url, {
25
+ method: "POST",
26
+ headers: { "content-type": "application/json", ...headers },
27
+ body: "{}",
28
+ });
29
+
30
+ const discovery = "/.well-known/oauth-protected-resource/mcp";
31
+
32
+ describe("the request policy", () => {
33
+ it("refuses a foreign Host or Origin", async () => {
34
+ const foreignHost = await web.handler(post("http://tracker.evil.example/api/v1/getStatus"));
35
+
36
+ const foreignOrigin = await web.handler(
37
+ post("http://localhost/api/v1/getStatus", { origin: "http://evil.example" }),
38
+ );
39
+
40
+ expect([foreignHost.status, await foreignHost.text()]).toEqual([403, "Host not allowed"]);
41
+ expect([foreignOrigin.status, await foreignOrigin.text()]).toEqual([403, "Origin not allowed"]);
42
+ });
43
+
44
+ it("runs before authentication: a foreign Host is refused, not asked to sign in", async () => {
45
+ const signedOut = await web.handler(post("http://localhost/api/v1/whoAmI"));
46
+ const foreignHost = await web.handler(post("http://tracker.evil.example/api/v1/whoAmI"));
47
+
48
+ expect([signedOut.status, foreignHost.status]).toEqual([401, 403]);
49
+ expect(foreignHost.headers.get("www-authenticate")).toBeNull();
50
+ });
51
+
52
+ it("runs before the discovery the Bearer provider publishes", async () => {
53
+ const read = (url: string, headers: Record<string, string> = {}) =>
54
+ web.handler(new Request(url, { headers }));
55
+
56
+ const here = await read(`http://localhost${discovery}`);
57
+ const foreignHost = await read(`http://tracker.evil.example${discovery}`);
58
+
59
+ const foreignOrigin = await read(`http://localhost${discovery}`, {
60
+ origin: "http://evil.test",
61
+ });
62
+
63
+ expect([here.status, foreignHost.status, foreignOrigin.status]).toEqual([200, 403, 403]);
64
+ });
65
+
66
+ it("answers the browser MCP client's origin, and its CORS preflight", async () => {
67
+ const allowed = await web.handler(
68
+ post("http://localhost/api/v1/getStatus", { origin: "http://localhost:6274" }),
69
+ );
70
+
71
+ const preflight = await web.handler(
72
+ new Request("http://localhost/mcp", {
73
+ method: "OPTIONS",
74
+ headers: {
75
+ origin: "http://localhost:6274",
76
+ "access-control-request-method": "POST",
77
+ "access-control-request-headers": "authorization, mcp-protocol-version",
78
+ },
79
+ }),
80
+ );
81
+
82
+ expect(allowed.status).toBe(200);
83
+ expect(allowed.headers.get("access-control-allow-origin")).toBe("http://localhost:6274");
84
+ expect(preflight.status).toBe(204);
85
+ expect(preflight.headers.get("access-control-allow-headers")).toContain("MCP-Protocol-Version");
86
+ expect(allowed.headers.get("access-control-expose-headers")).toContain("Mcp-Session-Id");
87
+ });
88
+
89
+ it("lets a browser client of an MCP revision with sessions end its session", async () => {
90
+ const preflight = await web.handler(
91
+ new Request("http://localhost/mcp", {
92
+ method: "OPTIONS",
93
+ headers: {
94
+ origin: "http://localhost:6274",
95
+ "access-control-request-method": "DELETE",
96
+ "access-control-request-headers": "authorization, mcp-session-id",
97
+ },
98
+ }),
99
+ );
100
+
101
+ expect(preflight.status).toBe(204);
102
+ expect(preflight.headers.get("access-control-allow-methods")).toContain("DELETE");
103
+ expect(preflight.headers.get("access-control-allow-headers")).toContain("Mcp-Session-Id");
104
+ });
105
+
106
+ it("covers the admin port too, whatever key a request carries", async () => {
107
+ const key = { "x-operator-key": "ops-key" };
108
+ const here = await adminWeb.handler(post("http://localhost/admin/listWorkspaces", key));
109
+
110
+ const foreignHost = await adminWeb.handler(
111
+ post("http://tracker.evil.example/admin/listWorkspaces", key),
112
+ );
113
+
114
+ expect([here.status, foreignHost.status]).toEqual([200, 403]);
115
+ });
116
+
117
+ // `Testing.layer(handler)` gives the typed clients the same handler the raw requests hit.
118
+ it.effect("leaves the typed client, served from the same web handler, alone", () =>
119
+ Effect.gen(function* () {
120
+ const client = yield* ActionHttp.client(Http);
121
+
122
+ expect(yield* client.getStatus()).toMatchObject({ service: "tracker", workspaces: 2 });
123
+ }).pipe(Effect.provide(Testing.layer(web.handler))),
124
+ );
125
+ });