@gjermundgaraba/effect-actions 0.20.0 → 0.21.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 (321) hide show
  1. package/README.md +19 -41
  2. package/dist/Action.d.mts +140 -46
  3. package/dist/Action.mjs +87 -42
  4. package/dist/ActionCli.d.mts +69 -33
  5. package/dist/ActionCli.mjs +51 -32
  6. package/dist/ActionHttp.d.mts +69 -35
  7. package/dist/ActionHttp.mjs +75 -23
  8. package/dist/ActionMcp.d.mts +56 -22
  9. package/dist/ActionMcp.mjs +94 -69
  10. package/dist/ActionRpc.d.mts +222 -55
  11. package/dist/ActionRpc.mjs +299 -46
  12. package/dist/ActionToolkit.d.mts +22 -5
  13. package/dist/ActionToolkit.mjs +36 -12
  14. package/dist/Authentication.d.mts +42 -10
  15. package/dist/Authentication.mjs +37 -30
  16. package/dist/Event.d.mts +370 -0
  17. package/dist/Event.mjs +103 -0
  18. package/dist/Testing.mjs +1 -1
  19. package/dist/Webhook.d.mts +146 -0
  20. package/dist/Webhook.mjs +72 -0
  21. package/dist/authentication/provider.d.mts +35 -10
  22. package/dist/authentication/provider.mjs +1 -1
  23. package/dist/authentication/refusal.mjs +2 -2
  24. package/dist/cli/commands.d.mts +9 -5
  25. package/dist/cli/commands.mjs +84 -33
  26. package/dist/contract/ast.mjs +4 -1
  27. package/dist/contract/authorizer.d.mts +8 -0
  28. package/dist/contract/authorizer.mjs +20 -0
  29. package/dist/contract/call.d.mts +9 -6
  30. package/dist/contract/call.mjs +48 -6
  31. package/dist/contract/callback.d.mts +7 -0
  32. package/dist/contract/callback.mjs +27 -0
  33. package/dist/contract/caller.d.mts +13 -2
  34. package/dist/contract/caller.mjs +22 -3
  35. package/dist/contract/canonical.mjs +10 -0
  36. package/dist/contract/codec.d.mts +9 -0
  37. package/dist/contract/codec.mjs +9 -0
  38. package/dist/contract/codes.mjs +81 -0
  39. package/dist/contract/declared.mjs +25 -9
  40. package/dist/contract/digest.mjs +6 -0
  41. package/dist/contract/errors.d.mts +9 -3
  42. package/dist/contract/errors.mjs +1 -1
  43. package/dist/contract/feed.d.mts +127 -0
  44. package/dist/contract/feed.mjs +249 -0
  45. package/dist/contract/hub.d.mts +94 -0
  46. package/dist/contract/hub.mjs +133 -0
  47. package/dist/contract/idempotency.d.mts +47 -0
  48. package/dist/contract/idempotency.mjs +99 -0
  49. package/dist/contract/implementation.d.mts +73 -47
  50. package/dist/contract/implementation.mjs +92 -58
  51. package/dist/contract/input-check.d.mts +15 -0
  52. package/dist/contract/input-check.mjs +16 -0
  53. package/dist/contract/lease.mjs +28 -0
  54. package/dist/contract/listed.d.mts +4 -0
  55. package/dist/contract/listed.mjs +22 -0
  56. package/dist/contract/log.d.mts +41 -0
  57. package/dist/contract/log.mjs +144 -0
  58. package/dist/contract/names.mjs +14 -4
  59. package/dist/contract/occurrence.d.mts +29 -0
  60. package/dist/contract/occurrence.mjs +45 -0
  61. package/dist/contract/pages.d.mts +13 -0
  62. package/dist/contract/pages.mjs +39 -0
  63. package/dist/contract/progress.d.mts +26 -0
  64. package/dist/contract/progress.mjs +72 -0
  65. package/dist/contract/push.d.mts +20 -0
  66. package/dist/contract/push.mjs +96 -0
  67. package/dist/contract/receiving.d.mts +29 -0
  68. package/dist/contract/receiving.mjs +42 -0
  69. package/dist/contract/recent.mjs +12 -0
  70. package/dist/contract/reported.mjs +16 -0
  71. package/dist/contract/serving.mjs +63 -0
  72. package/dist/contract/status.mjs +14 -5
  73. package/dist/contract/stream.d.mts +34 -0
  74. package/dist/contract/stream.mjs +31 -0
  75. package/dist/contract/subscribe.d.mts +45 -0
  76. package/dist/contract/subscribe.mjs +50 -0
  77. package/dist/contract/transient.mjs +28 -0
  78. package/dist/contract/watch.d.mts +18 -0
  79. package/dist/contract/watch.mjs +105 -0
  80. package/dist/contract/webhooks.d.mts +42 -0
  81. package/dist/contract/webhooks.mjs +53 -0
  82. package/dist/http/client.d.mts +37 -12
  83. package/dist/http/client.mjs +51 -12
  84. package/dist/http/events.d.mts +56 -0
  85. package/dist/http/events.mjs +102 -0
  86. package/dist/http/placed.d.mts +36 -0
  87. package/dist/http/placed.mjs +71 -0
  88. package/dist/http/schema-errors.mjs +1 -1
  89. package/dist/http/streamed.d.mts +21 -0
  90. package/dist/http/streamed.mjs +94 -0
  91. package/dist/http/watch.mjs +50 -0
  92. package/dist/mcp/bridge.mjs +283 -0
  93. package/dist/mcp/client.d.mts +30 -97
  94. package/dist/mcp/client.mjs +59 -132
  95. package/dist/mcp/events.mjs +142 -0
  96. package/dist/mcp/progress.mjs +34 -0
  97. package/dist/mcp/protocol.mjs +39 -13
  98. package/dist/mcp/reply.d.mts +79 -0
  99. package/dist/mcp/reply.mjs +99 -0
  100. package/dist/mcp/request.d.mts +40 -0
  101. package/dist/mcp/request.mjs +51 -0
  102. package/dist/mcp/watch.mjs +115 -0
  103. package/dist/rpc/watch.mjs +39 -0
  104. package/dist/stdio/console.mjs +13 -5
  105. package/dist/testing/memory.d.mts +1 -1
  106. package/dist/testing/memory.mjs +3 -3
  107. package/dist/webhook/address.mjs +47 -0
  108. package/dist/webhook/attempt.mjs +65 -0
  109. package/dist/webhook/receiver.mjs +67 -0
  110. package/dist/webhook/signing.mjs +28 -0
  111. package/dist/webhook/stored.mjs +57 -0
  112. package/dist/webhook/subscriptions.mjs +342 -0
  113. package/docs/Action-failures.md +2 -2
  114. package/docs/Action-streams.md +115 -0
  115. package/docs/Action.md +58 -124
  116. package/docs/ActionCli-failures.md +1 -2
  117. package/docs/ActionCli.md +83 -88
  118. package/docs/ActionHttp-failures.md +3 -3
  119. package/docs/ActionHttp.md +90 -70
  120. package/docs/ActionMcp-failures.md +2 -1
  121. package/docs/ActionMcp.md +120 -92
  122. package/docs/ActionRpc-events.md +126 -0
  123. package/docs/ActionRpc-failures.md +1 -2
  124. package/docs/ActionRpc.md +108 -79
  125. package/docs/ActionToolkit-failures.md +1 -2
  126. package/docs/ActionToolkit.md +33 -90
  127. package/docs/Authentication-advanced.md +52 -60
  128. package/docs/Authentication-failures.md +5 -4
  129. package/docs/Authentication.md +23 -58
  130. package/docs/Event-failures.md +15 -0
  131. package/docs/Event.md +281 -0
  132. package/docs/README.md +51 -82
  133. package/docs/Testing-failures.md +1 -5
  134. package/docs/Testing.md +126 -86
  135. package/docs/Webhook-failures.md +8 -0
  136. package/docs/Webhook.md +319 -0
  137. package/docs/setup-failures.md +2 -3
  138. package/docs/setup.md +32 -52
  139. package/examples/README.md +12 -37
  140. package/examples/assistant/README.md +21 -26
  141. package/examples/assistant/src/agent.ts +66 -24
  142. package/examples/assistant/src/cli.ts +7 -4
  143. package/examples/assistant/src/commands.ts +9 -4
  144. package/examples/assistant/src/identity/Identity.ts +5 -4
  145. package/examples/assistant/src/identity/authorize.ts +13 -5
  146. package/examples/assistant/src/identity/handlers.ts +3 -2
  147. package/examples/assistant/src/identity/permissions.ts +9 -3
  148. package/examples/assistant/src/platform/tasks-file.ts +15 -11
  149. package/examples/assistant/src/scripted.ts +7 -4
  150. package/examples/assistant/src/tasks/Tasks.ts +20 -6
  151. package/examples/assistant/src/tasks/handlers.ts +9 -5
  152. package/examples/assistant/src/tasks/store.ts +38 -11
  153. package/examples/assistant/src/toolkit.ts +11 -7
  154. package/examples/assistant/test/commands.test.ts +53 -27
  155. package/examples/assistant/test/identity/identity.test.ts +27 -11
  156. package/examples/assistant/test/platform/tasks-file.test.ts +39 -23
  157. package/examples/assistant/test/tasks/tasks.test.ts +62 -34
  158. package/examples/assistant/test/toolkit.test.ts +104 -75
  159. package/examples/build-events/README.md +83 -0
  160. package/examples/build-events/src/Api.ts +27 -0
  161. package/examples/build-events/src/Rpc.ts +26 -0
  162. package/examples/build-events/src/builds/Builds.ts +108 -0
  163. package/examples/build-events/src/builds/events.ts +54 -0
  164. package/examples/build-events/src/builds/handlers.ts +58 -0
  165. package/examples/build-events/src/builds/log.ts +30 -0
  166. package/examples/build-events/src/builds/status.ts +28 -0
  167. package/examples/build-events/src/builds/store.ts +130 -0
  168. package/examples/build-events/src/cli.ts +18 -0
  169. package/examples/build-events/src/commands.ts +32 -0
  170. package/examples/build-events/src/deploys/Deploys.ts +44 -0
  171. package/examples/build-events/src/deploys/approvers.ts +55 -0
  172. package/examples/build-events/src/deploys/events.ts +32 -0
  173. package/examples/build-events/src/deploys/handlers.ts +38 -0
  174. package/examples/build-events/src/identity/Identity.ts +29 -0
  175. package/examples/build-events/src/identity/members.ts +7 -0
  176. package/examples/build-events/src/identity/verifier.ts +22 -0
  177. package/examples/build-events/src/notifier/Notices.ts +26 -0
  178. package/examples/build-events/src/notifier/routes.ts +71 -0
  179. package/examples/build-events/src/notifier.ts +48 -0
  180. package/examples/build-events/src/routes.ts +71 -0
  181. package/examples/build-events/src/server.ts +62 -0
  182. package/examples/build-events/test/builds/events.test.ts +68 -0
  183. package/examples/build-events/test/builds/log.test.ts +71 -0
  184. package/examples/build-events/test/builds/progress.test.ts +57 -0
  185. package/examples/build-events/test/builds/status.test.ts +42 -0
  186. package/examples/build-events/test/commands.test.ts +103 -0
  187. package/examples/build-events/test/deploys/events.test.ts +52 -0
  188. package/examples/build-events/test/http.test.ts +95 -0
  189. package/examples/build-events/test/notifier.test.ts +80 -0
  190. package/examples/build-events/test/routes.test.ts +96 -0
  191. package/examples/build-events/test/rpc.test.ts +84 -0
  192. package/examples/build-events/test/support/as.ts +8 -0
  193. package/examples/build-events/test/support/memory.ts +43 -0
  194. package/examples/build-events/test/webhooks.test.ts +143 -0
  195. package/examples/kitchen-sink/README.md +178 -166
  196. package/examples/kitchen-sink/src/Actions.ts +11 -3
  197. package/examples/kitchen-sink/src/Api.ts +12 -6
  198. package/examples/kitchen-sink/src/Rpc.ts +7 -5
  199. package/examples/kitchen-sink/src/admin/Admin.ts +21 -10
  200. package/examples/kitchen-sink/src/admin/authenticate.ts +8 -4
  201. package/examples/kitchen-sink/src/admin/authorize.ts +13 -5
  202. package/examples/kitchen-sink/src/admin/grants.ts +5 -3
  203. package/examples/kitchen-sink/src/admin/handlers.ts +9 -5
  204. package/examples/kitchen-sink/src/agent.ts +14 -6
  205. package/examples/kitchen-sink/src/assistant/Assistant.ts +21 -15
  206. package/examples/kitchen-sink/src/assistant/conversations.ts +14 -9
  207. package/examples/kitchen-sink/src/assistant/demo-model.ts +2 -2
  208. package/examples/kitchen-sink/src/assistant/handlers.ts +42 -22
  209. package/examples/kitchen-sink/src/attachments/Attachments.ts +6 -6
  210. package/examples/kitchen-sink/src/attachments/download.ts +7 -6
  211. package/examples/kitchen-sink/src/attachments/handlers.ts +9 -4
  212. package/examples/kitchen-sink/src/attachments/store.ts +25 -18
  213. package/examples/kitchen-sink/src/browser.ts +10 -4
  214. package/examples/kitchen-sink/src/cli.ts +28 -12
  215. package/examples/kitchen-sink/src/config.ts +1 -1
  216. package/examples/kitchen-sink/src/identity/Identity.ts +24 -10
  217. package/examples/kitchen-sink/src/identity/authenticate.ts +19 -11
  218. package/examples/kitchen-sink/src/identity/authorize.ts +14 -4
  219. package/examples/kitchen-sink/src/identity/directory.ts +16 -7
  220. package/examples/kitchen-sink/src/identity/handlers.ts +1 -0
  221. package/examples/kitchen-sink/src/identity/members.ts +7 -2
  222. package/examples/kitchen-sink/src/identity/quota.ts +23 -20
  223. package/examples/kitchen-sink/src/issues/Issues.ts +91 -15
  224. package/examples/kitchen-sink/src/issues/assistant.ts +4 -2
  225. package/examples/kitchen-sink/src/issues/events.ts +46 -0
  226. package/examples/kitchen-sink/src/issues/export.ts +35 -0
  227. package/examples/kitchen-sink/src/issues/guide.ts +4 -3
  228. package/examples/kitchen-sink/src/issues/handlers.ts +20 -7
  229. package/examples/kitchen-sink/src/issues/store.ts +156 -67
  230. package/examples/kitchen-sink/src/issues/write-limit.ts +44 -18
  231. package/examples/kitchen-sink/src/memory.ts +11 -2
  232. package/examples/kitchen-sink/src/ops.ts +10 -5
  233. package/examples/kitchen-sink/src/request-policy.ts +21 -9
  234. package/examples/kitchen-sink/src/routes.ts +67 -27
  235. package/examples/kitchen-sink/src/sdk.ts +22 -12
  236. package/examples/kitchen-sink/src/seed.ts +15 -9
  237. package/examples/kitchen-sink/src/server.ts +24 -10
  238. package/examples/kitchen-sink/src/stdio.ts +9 -3
  239. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +10 -7
  240. package/examples/kitchen-sink/src/workspaces/handlers.ts +3 -2
  241. package/examples/kitchen-sink/src/workspaces/registry.ts +16 -10
  242. package/examples/kitchen-sink/src/workspaces/tenant.ts +12 -5
  243. package/examples/kitchen-sink/test/admin/admin.test.ts +38 -23
  244. package/examples/kitchen-sink/test/assistant/assistant.test.ts +471 -299
  245. package/examples/kitchen-sink/test/attachments/attachments.test.ts +135 -94
  246. package/examples/kitchen-sink/test/identity/identity.test.ts +110 -82
  247. package/examples/kitchen-sink/test/identity/session.test.ts +65 -43
  248. package/examples/kitchen-sink/test/issues/assistant.test.ts +50 -31
  249. package/examples/kitchen-sink/test/issues/events.test.ts +273 -0
  250. package/examples/kitchen-sink/test/issues/export.test.ts +120 -0
  251. package/examples/kitchen-sink/test/issues/issues.test.ts +105 -75
  252. package/examples/kitchen-sink/test/request-policy.test.ts +108 -47
  253. package/examples/kitchen-sink/test/routes.test.ts +102 -57
  254. package/examples/kitchen-sink/test/support/as.ts +9 -0
  255. package/examples/kitchen-sink/test/support/scripted.ts +6 -5
  256. package/examples/kitchen-sink/test/support/services.ts +17 -0
  257. package/examples/live-rpc/README.md +33 -35
  258. package/examples/live-rpc/src/Rpc.ts +9 -5
  259. package/examples/live-rpc/src/browser.ts +39 -11
  260. package/examples/live-rpc/src/chat/Chat.ts +30 -16
  261. package/examples/live-rpc/src/chat/authorize.ts +2 -1
  262. package/examples/live-rpc/src/chat/events.ts +74 -0
  263. package/examples/live-rpc/src/chat/handlers.ts +13 -5
  264. package/examples/live-rpc/src/chat/room.ts +9 -5
  265. package/examples/live-rpc/src/chat/store.ts +51 -14
  266. package/examples/live-rpc/src/client.ts +22 -5
  267. package/examples/live-rpc/src/identity/Identity.ts +3 -3
  268. package/examples/live-rpc/src/identity/verifier.ts +8 -4
  269. package/examples/live-rpc/src/page.ts +58 -12
  270. package/examples/live-rpc/src/routes.ts +14 -10
  271. package/examples/live-rpc/src/server.ts +15 -5
  272. package/examples/live-rpc/test/browser.test.ts +127 -50
  273. package/examples/live-rpc/test/chat/chat.test.ts +215 -23
  274. package/examples/live-rpc/test/routes.test.ts +130 -46
  275. package/examples/live-rpc/test/server.test.ts +94 -33
  276. package/examples/mcp-tools/README.md +24 -38
  277. package/examples/mcp-tools/src/platform/fake-farm.ts +63 -24
  278. package/examples/mcp-tools/src/platform/print-farm.ts +59 -29
  279. package/examples/mcp-tools/src/printers/Printers.ts +18 -7
  280. package/examples/mcp-tools/src/printers/farm.ts +15 -5
  281. package/examples/mcp-tools/src/printers/handlers.ts +12 -6
  282. package/examples/mcp-tools/src/routes.ts +8 -6
  283. package/examples/mcp-tools/src/server.ts +12 -5
  284. package/examples/mcp-tools/src/stdio.ts +3 -2
  285. package/examples/mcp-tools/test/printers/printers.test.ts +132 -89
  286. package/examples/mcp-tools/test/routes.test.ts +140 -81
  287. package/examples/notes-service/README.md +30 -45
  288. package/examples/notes-service/src/Api.ts +1 -0
  289. package/examples/notes-service/src/browser.ts +15 -14
  290. package/examples/notes-service/src/cli.ts +8 -3
  291. package/examples/notes-service/src/commands.ts +46 -20
  292. package/examples/notes-service/src/identity/Identity.ts +2 -2
  293. package/examples/notes-service/src/identity/authorize.ts +6 -2
  294. package/examples/notes-service/src/identity/handlers.ts +3 -2
  295. package/examples/notes-service/src/identity/users.ts +2 -1
  296. package/examples/notes-service/src/identity/verifier.ts +12 -6
  297. package/examples/notes-service/src/notes/Notes.ts +46 -22
  298. package/examples/notes-service/src/notes/handlers.ts +18 -9
  299. package/examples/notes-service/src/notes/store.ts +70 -43
  300. package/examples/notes-service/src/routes.ts +7 -5
  301. package/examples/notes-service/src/server.ts +12 -5
  302. package/examples/notes-service/src/status/Status.ts +1 -1
  303. package/examples/notes-service/src/status/handlers.ts +5 -3
  304. package/examples/notes-service/src/stdio.ts +7 -3
  305. package/examples/notes-service/src/worker.ts +6 -3
  306. package/examples/notes-service/test/commands.test.ts +164 -91
  307. package/examples/notes-service/test/notes/as.ts +3 -1
  308. package/examples/notes-service/test/notes/idempotency.test.ts +97 -67
  309. package/examples/notes-service/test/notes/in-process.test.ts +26 -15
  310. package/examples/notes-service/test/notes/notes.test.ts +49 -31
  311. package/examples/notes-service/test/routes.test.ts +106 -71
  312. package/examples/notes-service/test/worker.test.ts +21 -13
  313. package/examples/quickstart/README.md +2 -6
  314. package/examples/quickstart/src/Api.ts +1 -0
  315. package/examples/quickstart/src/client.ts +6 -3
  316. package/examples/quickstart/src/greeting/Greeting.ts +1 -1
  317. package/examples/quickstart/src/greeting/handlers.ts +5 -2
  318. package/examples/quickstart/src/routes.ts +3 -2
  319. package/examples/quickstart/src/server.ts +6 -2
  320. package/examples/quickstart/test/routes.test.ts +3 -2
  321. package/package.json +10 -4
@@ -1,21 +1,26 @@
1
1
  import { Buffer } from "node:buffer";
2
+
2
3
  import { describe, expect, it } from "@effect/vitest";
3
- import { Array, Effect, flow, Layer } from "effect";
4
- import { HttpClient, HttpClientRequest } from "effect/http";
5
4
  import * as Action from "@gjermundgaraba/effect-actions/Action";
6
5
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
7
6
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
8
7
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
8
+ import { Array, Effect, flow, Layer } from "effect";
9
+ import { HttpClient, HttpClientRequest } from "effect/http";
10
+
9
11
  import { Http } from "../../src/Api.js";
10
12
  import { GetAttachment } from "../../src/attachments/Attachments.js";
11
13
  import { RateLimited } from "../../src/issues/Issues.js";
12
- import { memory } from "../../src/memory.js";
13
14
  import { publicRoutes } from "../../src/routes.js";
15
+ import { services } from "../support/services.js";
14
16
 
15
- const served = Testing.layer(publicRoutes).pipe(Layer.provide(memory));
17
+ const served = Testing.layer(publicRoutes).pipe(Layer.provide(services()));
16
18
 
17
19
  const signedIn = (token: string, workspace: string) =>
18
- flow(HttpClientRequest.bearerToken(token), HttpClientRequest.setHeader("x-workspace", workspace));
20
+ flow(
21
+ HttpClientRequest.bearerToken(token),
22
+ HttpClientRequest.setHeader("x-workspace", workspace)
23
+ );
19
24
 
20
25
  const as = (token: string, workspace: string) => ({
21
26
  transformClient: HttpClient.mapRequest(signedIn(token, workspace)),
@@ -24,7 +29,7 @@ const as = (token: string, workspace: string) => ({
24
29
  // A one-pixel PNG stands in for a screenshot.
25
30
  const screenshot = Buffer.from(
26
31
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
27
- "base64",
32
+ "base64"
28
33
  );
29
34
 
30
35
  const attached = Effect.gen(function* () {
@@ -39,55 +44,67 @@ const attached = Effect.gen(function* () {
39
44
  });
40
45
 
41
46
  describe("attachments, over HTTP and MCP", () => {
42
- it.effect("decodes an image over HTTP, and reads it as an image block from the tool", () =>
43
- Effect.gen(function* () {
44
- const attachment = yield* attached;
45
- const http = yield* ActionHttp.client(Http, as("ada", "acme"));
46
- const mcp = yield* ActionMcp.client([GetAttachment], as("ada", "acme"));
47
-
48
- expect(attachment).toMatchObject({ name: "crash.png", mimeType: "image/png", size: 68 });
49
-
50
- // Both clients decode the same success, the bytes a `Uint8Array` again.
51
- const overHttp = yield* http.getAttachment({ id: attachment.id });
52
- const overMcp = yield* mcp.getAttachment({ id: attachment.id });
53
-
54
- expect(Buffer.from(overHttp.image.data).equals(screenshot)).toBe(true);
55
- expect(overMcp).toEqual(overHttp);
56
-
57
- // As sent: the tool's result holds the rest of the success as JSON, and the image as a
58
- // content block of its own, which a model receives as an image.
59
- const call = yield* HttpClient.execute(
60
- ActionMcp.request(
61
- "tools/call",
62
- { name: "getAttachment", arguments: { id: attachment.id } },
63
- { headers: { authorization: "Bearer ada", "x-workspace": "acme" } },
64
- ),
65
- );
47
+ it.effect(
48
+ "decodes an image over HTTP, and reads it as an image block from the tool",
49
+ () =>
50
+ Effect.gen(function* () {
51
+ const attachment = yield* attached;
52
+ const http = yield* ActionHttp.client(Http, as("ada", "acme"));
53
+ const mcp = yield* ActionMcp.client([GetAttachment], as("ada", "acme"));
54
+
55
+ expect(attachment).toMatchObject({
56
+ name: "crash.png",
57
+ mimeType: "image/png",
58
+ size: 68,
59
+ });
60
+
61
+ // Both clients decode the same success, the bytes a `Uint8Array` again.
62
+ const overHttp = yield* http.getAttachment({ id: attachment.id });
63
+ const overMcp = yield* mcp.getAttachment({ id: attachment.id });
64
+
65
+ expect(Buffer.from(overHttp.image.data).equals(screenshot)).toBe(true);
66
+ expect(overMcp).toStrictEqual(overHttp);
67
+
68
+ // As sent: the tool's result holds the rest of the success as JSON, and the image as a
69
+ // content block of its own, which a model receives as an image.
70
+ const call = yield* HttpClient.execute(
71
+ ActionMcp.request(
72
+ "tools/call",
73
+ { name: "getAttachment", arguments: { id: attachment.id } },
74
+ { headers: { authorization: "Bearer ada", "x-workspace": "acme" } }
75
+ )
76
+ );
66
77
 
67
- const text = yield* call.text;
78
+ const text = yield* call.text;
68
79
 
69
- expect(text).toContain('"type":"image"');
70
- expect(text).toContain('"mimeType":"image/png"');
71
- expect(text).toContain(`"structuredContent":{"attachment":{"id":"${attachment.id}"`);
72
- }).pipe(Effect.provide(served)),
80
+ expect(text).toContain('"type":"image"');
81
+ expect(text).toContain('"mimeType":"image/png"');
82
+ expect(text).toContain(
83
+ `"structuredContent":{"attachment":{"id":"${attachment.id}"`
84
+ );
85
+ }).pipe(Effect.provide(served))
73
86
  );
74
87
 
75
- it.effect("refuses a file that is no image as invalid input, naming the field", () =>
76
- Effect.gen(function* () {
77
- const ada = yield* ActionHttp.client(Http, as("ada", "acme"));
78
- const issue = yield* ada.createIssue({ title: "Crash", body: "" });
79
-
80
- const refused = yield* Effect.flip(
81
- ada.attachImage({
82
- issue: issue.id,
83
- name: "notes.txt",
84
- image: { data: screenshot, mimeType: "text/plain" },
85
- }),
86
- );
88
+ it.effect(
89
+ "refuses a file that is no image as invalid input, naming the field",
90
+ () =>
91
+ Effect.gen(function* () {
92
+ const ada = yield* ActionHttp.client(Http, as("ada", "acme"));
93
+ const issue = yield* ada.createIssue({ title: "Crash", body: "" });
94
+
95
+ const refused = yield* Effect.flip(
96
+ ada.attachImage({
97
+ issue: issue.id,
98
+ name: "notes.txt",
99
+ image: { data: screenshot, mimeType: "text/plain" },
100
+ })
101
+ );
87
102
 
88
- expect(refused).toBeInstanceOf(Action.InvalidInput);
89
- expect(refused).toMatchObject({ issues: [{ path: ["image", "mimeType"] }] });
90
- }).pipe(Effect.provide(served)),
103
+ expect(refused).toBeInstanceOf(Action.InvalidInput);
104
+ expect(refused).toMatchObject({
105
+ issues: [{ path: ["image", "mimeType"] }],
106
+ });
107
+ }).pipe(Effect.provide(served))
91
108
  );
92
109
 
93
110
  it.effect("counts an attachment against the issues' write limit", () =>
@@ -97,7 +114,7 @@ describe("attachments, over HTTP and MCP", () => {
97
114
 
98
115
  // `attached` spent two writes, an issue and its image. Eight comments spend the rest.
99
116
  yield* Effect.forEach(Array.range(1, 8), (n) =>
100
- ada.commentOnIssue({ id: attachment.issue, text: `Comment ${n}` }),
117
+ ada.commentOnIssue({ id: attachment.issue, text: `Comment ${n}` })
101
118
  );
102
119
 
103
120
  const refused = yield* Effect.flip(
@@ -105,57 +122,81 @@ describe("attachments, over HTTP and MCP", () => {
105
122
  issue: attachment.issue,
106
123
  name: "again.png",
107
124
  image: { data: screenshot, mimeType: "image/png" },
108
- }),
125
+ })
109
126
  );
110
127
 
111
128
  expect(refused).toBeInstanceOf(RateLimited);
112
- }).pipe(Effect.provide(served)),
129
+ }).pipe(Effect.provide(served))
113
130
  );
114
131
 
115
- it.effect("serves the bytes to the web app's cookie, from the host's own route", () =>
116
- Effect.gen(function* () {
117
- const attachment = yield* attached;
132
+ it.effect(
133
+ "serves the bytes to the web app's cookie, from the host's own route",
134
+ () =>
135
+ Effect.gen(function* () {
136
+ const attachment = yield* attached;
137
+
138
+ const download = (session: string) =>
139
+ HttpClient.execute(
140
+ HttpClientRequest.get(`/attachments/${attachment.id}`).pipe(
141
+ HttpClientRequest.setHeader(
142
+ "cookie",
143
+ `tracker_session=${session}`
144
+ )
145
+ )
146
+ );
147
+
148
+ const mine = yield* download("ada-at-acme");
149
+ const anotherWorkspace = yield* download("grace-at-globex");
150
+
151
+ expect(mine.status).toBe(200);
152
+ expect(Buffer.from(yield* mine.arrayBuffer).equals(screenshot)).toBe(
153
+ true
154
+ );
155
+ expect(mine.headers["cache-control"]).toBe(
156
+ "private, max-age=31536000, immutable"
157
+ );
158
+ // An uploaded image opened by its URL is a document on this origin: it gets no scripts.
159
+ expect([
160
+ mine.headers["content-security-policy"],
161
+ mine.headers["x-content-type-options"],
162
+ ]).toStrictEqual(["sandbox", "nosniff"]);
163
+ expect(anotherWorkspace.status).toBe(404);
164
+ }).pipe(Effect.provide(served))
165
+ );
166
+
167
+ it.effect(
168
+ "refuses, on that route, whom authentication and the route's rule refuse",
169
+ () =>
170
+ Effect.gen(function* () {
171
+ const attachment = yield* attached;
172
+ const download = HttpClientRequest.get(`/attachments/${attachment.id}`);
118
173
 
119
- const download = (session: string) =>
120
- HttpClient.execute(
121
- HttpClientRequest.get(`/attachments/${attachment.id}`).pipe(
122
- HttpClientRequest.setHeader("cookie", `tracker_session=${session}`),
123
- ),
174
+ const withCookie = HttpClientRequest.setHeader(
175
+ "cookie",
176
+ "tracker_session=kay-at-acme"
124
177
  );
125
178
 
126
- const mine = yield* download("ada-at-acme");
127
- const anotherWorkspace = yield* download("grace-at-globex");
128
-
129
- expect(mine.status).toBe(200);
130
- expect(Buffer.from(yield* mine.arrayBuffer).equals(screenshot)).toBe(true);
131
- expect(mine.headers["cache-control"]).toBe("private, max-age=31536000, immutable");
132
- // An uploaded image opened by its URL is a document on this origin: it gets no scripts.
133
- expect([
134
- mine.headers["content-security-policy"],
135
- mine.headers["x-content-type-options"],
136
- ]).toEqual(["sandbox", "nosniff"]);
137
- expect(anotherWorkspace.status).toBe(404);
138
- }).pipe(Effect.provide(served)),
139
- );
179
+ const signedOut = yield* HttpClient.execute(download);
140
180
 
141
- it.effect("refuses, on that route, whom authentication and the route's rule refuse", () =>
142
- Effect.gen(function* () {
143
- const attachment = yield* attached;
144
- const download = HttpClientRequest.get(`/attachments/${attachment.id}`);
145
- const withCookie = HttpClientRequest.setHeader("cookie", "tracker_session=kay-at-acme");
146
-
147
- const signedOut = yield* HttpClient.execute(download);
148
- const withToken = yield* HttpClient.execute(signedIn("ada", "acme")(download));
149
- const withoutScope = yield* HttpClient.execute(withCookie(download));
150
-
151
- // A cookie scheme has no challenge. A token is no credential on this route.
152
- expect([signedOut.status, signedOut.headers["cache-control"]]).toEqual([401, "no-store"]);
153
- expect(signedOut.headers["www-authenticate"]).toBeUndefined();
154
- expect(withToken.status).toBe(401);
155
- // kay is signed in, in the right workspace, without the read scope: the route's own
156
- // `authorize` refuses.
157
- expect(withoutScope.status).toBe(403);
158
- expect(yield* withoutScope.json).toMatchObject({ message: "Requires issues:read." });
159
- }).pipe(Effect.provide(served)),
181
+ const withToken = yield* HttpClient.execute(
182
+ signedIn("ada", "acme")(download)
183
+ );
184
+
185
+ const withoutScope = yield* HttpClient.execute(withCookie(download));
186
+
187
+ // A cookie scheme has no challenge. A token is no credential on this route.
188
+ expect([
189
+ signedOut.status,
190
+ signedOut.headers["cache-control"],
191
+ ]).toStrictEqual([401, "no-store"]);
192
+ expect(signedOut.headers["www-authenticate"]).toBeUndefined();
193
+ expect(withToken.status).toBe(401);
194
+ // kay is signed in, in the right workspace, without the read scope: the route's own
195
+ // `authorize` refuses.
196
+ expect(withoutScope.status).toBe(403);
197
+ expect(yield* withoutScope.json).toMatchObject({
198
+ message: "Requires issues:read.",
199
+ });
200
+ }).pipe(Effect.provide(served))
160
201
  );
161
202
  });
@@ -1,63 +1,77 @@
1
1
  import { describe, expect, it } from "@effect/vitest";
2
- import { Array, Effect, flow, Layer } from "effect";
3
- import { HttpClient, HttpClientRequest } from "effect/http";
4
2
  import * as Action from "@gjermundgaraba/effect-actions/Action";
5
3
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
4
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
7
5
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
6
+ import { Array, Effect, flow, Layer } from "effect";
7
+ import { HttpClient, HttpClientRequest } from "effect/http";
8
+
8
9
  import { Http } from "../../src/Api.js";
9
10
  import { Directory } from "../../src/identity/directory.js";
10
- import { IssuerUnavailable, Login, QuotaExceeded, WhoAmI } from "../../src/identity/Identity.js";
11
- import { memory } from "../../src/memory.js";
11
+ import {
12
+ IssuerUnavailable,
13
+ Login,
14
+ QuotaExceeded,
15
+ WhoAmI,
16
+ } from "../../src/identity/Identity.js";
12
17
  import { publicRoutes } from "../../src/routes.js";
18
+ import { services } from "../support/services.js";
13
19
 
14
20
  // What authentication adds on the wire: the public port behind its real providers, answering
15
21
  // in memory. A caller is the credential its client sends.
16
- const served = Testing.layer(publicRoutes).pipe(Layer.provide(memory));
22
+ const served = Testing.layer(publicRoutes).pipe(Layer.provide(services()));
17
23
 
18
- // A test replaces the one service it is about. Provided before `memory`, this layer is the
19
- // `Directory` the routes are built with; `memory` gives them the rest.
24
+ // A test replaces the one service it is about. Provided before `services`, this layer is the
25
+ // `Directory` the routes are built with; `services` gives them the rest.
20
26
  const servedWith = (directory: Layer.Layer<Directory>) =>
21
- Testing.layer(publicRoutes).pipe(Layer.provide(directory), Layer.provide(memory));
27
+ Testing.layer(publicRoutes).pipe(
28
+ Layer.provide(directory),
29
+ Layer.provide(services())
30
+ );
22
31
 
23
32
  // A directory that cannot reach its issuer.
24
33
  const issuerDown = Layer.succeed(
25
34
  Directory,
26
35
  Directory.of({
27
- ofToken: () => Effect.fail(new IssuerUnavailable({ retryAfterSeconds: 30 })),
36
+ ofToken: () =>
37
+ Effect.fail(new IssuerUnavailable({ retryAfterSeconds: 30 })),
28
38
  ofSession: () => Effect.fail(new Action.Unauthenticated()),
29
- }),
39
+ })
30
40
  );
31
41
 
32
42
  const as = (token: string, workspace: string) => ({
33
43
  transformClient: HttpClient.mapRequest(
34
44
  flow(
35
45
  HttpClientRequest.bearerToken(token),
36
- HttpClientRequest.setHeader("x-workspace", workspace),
37
- ),
46
+ HttpClientRequest.setHeader("x-workspace", workspace)
47
+ )
38
48
  ),
39
49
  });
40
50
 
41
51
  describe("a bearer token", () => {
42
- it.effect("makes the caller the member it is in the workspace the request names", () =>
43
- Effect.gen(function* () {
44
- const atAcme = yield* ActionHttp.client(Http, as("ada", "acme"));
45
- const atGlobex = yield* ActionHttp.client(Http, as("ada", "globex"));
46
- const elsewhere = yield* ActionHttp.client(Http, as("linus", "globex"));
47
-
48
- expect(yield* atAcme.whoAmI()).toMatchObject({ workspace: "acme" });
49
- expect(yield* atGlobex.whoAmI()).toEqual({
50
- id: "ada",
51
- workspace: "globex",
52
- scopes: ["issues:read"],
53
- });
54
-
55
- // A member of acme alone, asking for globex: refused, with no scope to step up to.
56
- const refused = yield* Effect.flip(elsewhere.whoAmI());
57
-
58
- expect(refused).toBeInstanceOf(Action.Forbidden);
59
- expect(refused.message).toBe("Name a workspace you belong to in the X-Workspace header.");
60
- }).pipe(Effect.provide(served)),
52
+ it.effect(
53
+ "makes the caller the member it is in the workspace the request names",
54
+ () =>
55
+ Effect.gen(function* () {
56
+ const atAcme = yield* ActionHttp.client(Http, as("ada", "acme"));
57
+ const atGlobex = yield* ActionHttp.client(Http, as("ada", "globex"));
58
+ const elsewhere = yield* ActionHttp.client(Http, as("linus", "globex"));
59
+
60
+ expect(yield* atAcme.whoAmI()).toMatchObject({ workspace: "acme" });
61
+ expect(yield* atGlobex.whoAmI()).toStrictEqual({
62
+ id: "ada",
63
+ workspace: "globex",
64
+ scopes: ["issues:read"],
65
+ });
66
+
67
+ // A member of acme alone, asking for globex: refused, with no scope to step up to.
68
+ const refused = yield* Effect.flip(elsewhere.whoAmI());
69
+
70
+ expect(refused).toBeInstanceOf(Action.Forbidden);
71
+ expect(refused.message).toBe(
72
+ "Name a workspace you belong to in the X-Workspace header."
73
+ );
74
+ }).pipe(Effect.provide(served))
61
75
  );
62
76
 
63
77
  it.effect(
@@ -66,78 +80,92 @@ describe("a bearer token", () => {
66
80
  Effect.gen(function* () {
67
81
  const reader = yield* ActionHttp.client(Http, as("linus", "acme"));
68
82
 
69
- const refused = yield* Effect.flip(reader.createIssue({ title: "Crash", body: "" }));
83
+ const refused = yield* Effect.flip(
84
+ reader.createIssue({ title: "Crash", body: "" })
85
+ );
70
86
 
71
- expect(refused).toEqual(new Action.Forbidden({ scopes: ["issues:write"] }));
87
+ expect(refused).toStrictEqual(
88
+ new Action.Forbidden({ scopes: ["issues:write"] })
89
+ );
72
90
  expect(refused.message).toBe("Requires issues:write.");
73
91
 
74
92
  // The same refusal as sent: the 403 an OAuth client re-authorizes on.
75
- const response = yield* HttpClientRequest.post("/api/v1/closeIssue").pipe(
93
+ const response = yield* HttpClientRequest.post(
94
+ "/api/v1/closeIssue"
95
+ ).pipe(
76
96
  HttpClientRequest.bearerToken("linus"),
77
97
  HttpClientRequest.setHeader("x-workspace", "acme"),
78
98
  HttpClientRequest.bodyJsonUnsafe({ id: "ACME-1" }),
79
- HttpClient.execute,
99
+ HttpClient.execute
80
100
  );
81
101
 
82
102
  expect(response.status).toBe(403);
83
103
  expect(response.headers["www-authenticate"]).toContain(
84
- 'error="insufficient_scope", scope="issues:write"',
104
+ 'error="insufficient_scope", scope="issues:write"'
85
105
  );
86
- }).pipe(Effect.provide(served)),
106
+ }).pipe(Effect.provide(served))
87
107
  );
88
108
 
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
- ActionMcp.request("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)),
109
+ it.effect(
110
+ "finds where to sign in: published discovery, named in the challenge of a 401",
111
+ () =>
112
+ Effect.gen(function* () {
113
+ const discovery = yield* HttpClient.get(
114
+ "/.well-known/oauth-protected-resource/mcp"
115
+ );
116
+
117
+ expect(yield* discovery.json).toMatchObject({
118
+ resource: "http://localhost:3000/mcp",
119
+ scopes_supported: ["issues:read", "issues:write"],
120
+ });
121
+
122
+ // A protected tool's call without a token: the endpoint's own 401, before any tool runs.
123
+ const refused = yield* HttpClient.execute(
124
+ ActionMcp.request("tools/call", { name: "whoAmI", arguments: {} })
125
+ );
126
+
127
+ expect(refused.status).toBe(401);
128
+ expect(refused.headers["www-authenticate"]).toBe(
129
+ 'Bearer scope="issues:read", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp"'
130
+ );
131
+ }).pipe(Effect.provide(served))
108
132
  );
109
133
 
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
-
114
- // Over MCP it is the endpoint's HTTP answer, before any tool runs, not a tool's result: the
115
- // client decodes it given the descriptor the endpoint serves with.
116
- const mcp = yield* ActionMcp.client([WhoAmI], {
117
- ...as("ada", "acme"),
118
- authentication: Login,
119
- });
120
-
121
- expect(yield* Effect.flip(http.whoAmI())).toEqual(
122
- new IssuerUnavailable({ retryAfterSeconds: 30 }),
123
- );
124
- expect(yield* Effect.flip(mcp.whoAmI())).toEqual(
125
- new IssuerUnavailable({ retryAfterSeconds: 30 }),
126
- );
127
- }).pipe(Effect.provide(servedWith(issuerDown))),
134
+ it.effect(
135
+ "fails with the verifier's declared error, a typed 503, when the issuer is down",
136
+ () =>
137
+ Effect.gen(function* () {
138
+ const http = yield* ActionHttp.client(Http, as("ada", "acme"));
139
+
140
+ // Over MCP it is the endpoint's HTTP answer, before any tool runs, not a tool's result: the
141
+ // client decodes it given the descriptor the endpoint serves with.
142
+ const mcp = yield* ActionMcp.client([WhoAmI], {
143
+ ...as("ada", "acme"),
144
+ authentication: Login,
145
+ });
146
+
147
+ expect(yield* Effect.flip(http.whoAmI())).toStrictEqual(
148
+ new IssuerUnavailable({ retryAfterSeconds: 30 })
149
+ );
150
+ expect(yield* Effect.flip(mcp.whoAmI())).toStrictEqual(
151
+ new IssuerUnavailable({ retryAfterSeconds: 30 })
152
+ );
153
+ }).pipe(Effect.provide(servedWith(issuerDown)))
128
154
  );
129
155
  });
130
156
 
131
157
  describe("the request quota", () => {
132
- it.effect("is spent per member, and answered as the binding's typed error", () =>
133
- Effect.gen(function* () {
134
- const ada = yield* ActionHttp.client(Http, as("ada", "acme"));
135
- const grace = yield* ActionHttp.client(Http, as("grace", "globex"));
158
+ it.effect(
159
+ "is spent per member, and answered as the binding's typed error",
160
+ () =>
161
+ Effect.gen(function* () {
162
+ const ada = yield* ActionHttp.client(Http, as("ada", "acme"));
163
+ const grace = yield* ActionHttp.client(Http, as("grace", "globex"));
136
164
 
137
- yield* Effect.forEach(Array.range(1, 60), () => ada.whoAmI());
165
+ yield* Effect.forEach(Array.range(1, 60), () => ada.whoAmI());
138
166
 
139
- expect(yield* Effect.flip(ada.whoAmI())).toBeInstanceOf(QuotaExceeded);
140
- expect(yield* grace.whoAmI()).toMatchObject({ id: "grace" });
141
- }).pipe(Effect.provide(served)),
167
+ expect(yield* Effect.flip(ada.whoAmI())).toBeInstanceOf(QuotaExceeded);
168
+ expect(yield* grace.whoAmI()).toMatchObject({ id: "grace" });
169
+ }).pipe(Effect.provide(served))
142
170
  );
143
171
  });