@gjermundgaraba/effect-actions 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/README.md +101 -40
  2. package/dist/Action.d.mts +29 -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 +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 +28 -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 +17 -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 +206 -426
  46. package/docs/ActionCli-failures.md +102 -0
  47. package/docs/ActionCli.md +206 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +202 -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 +178 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +100 -119
  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 -24
  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 -96
  64. package/docs/setup-failures.md +34 -0
  65. package/docs/setup.md +87 -91
  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 +23 -5
  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,49 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/http";
3
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
+ import { authorize } from "../identity/authorize.js";
5
+ import { CurrentMember, Session } from "../identity/Identity.js";
6
+ import { GetAttachment } from "./Attachments.js";
7
+ import { AttachmentStore } from "./store.js";
8
+
9
+ // A route of the host's own: `GET /attachments/<id>` answers the image's bytes, for an `<img>`
10
+ // of the web app, which no action can (an action answers JSON). A descriptor covers action
11
+ // routes alone, so this one takes `Authentication.protect(Session)`: router middleware that
12
+ // verifies the page's cookie with the provider the web app's rpcs use, provides
13
+ // `CurrentMember`, and answers a refusal the route fails with as an action's is answered. A
14
+ // token holder has no use for it: `getAttachment` gives an API client the same image.
15
+ export const download = Layer.unwrap(
16
+ Effect.map(AttachmentStore, (store) =>
17
+ HttpRouter.add(
18
+ "GET",
19
+ "/attachments/:id",
20
+ Effect.gen(function* () {
21
+ // The route states its own authorization. It reuses the rule of the action it mirrors.
22
+ yield* authorize(GetAttachment);
23
+
24
+ const member = yield* CurrentMember;
25
+ const { id = "" } = yield* HttpRouter.params;
26
+ const { image } = yield* store.get(member.workspace, id);
27
+
28
+ // Authentication marks what it covers `no-store` unless the route states its own
29
+ // caching. An attachment never changes, but it is one workspace's: `private`.
30
+ return HttpServerResponse.uint8Array(image.data, {
31
+ contentType: image.mimeType,
32
+ headers: {
33
+ "cache-control": "private, max-age=31536000, immutable",
34
+ // What a member uploaded is served from the application's own origin, and an image
35
+ // can be a document: an SVG opened by its URL, not in an `<img>`, runs its scripts
36
+ // as the member reading it. `sandbox` gives it no origin and no scripts, and
37
+ // `nosniff` keeps the browser to the type stated.
38
+ "content-security-policy": "sandbox",
39
+ "x-content-type-options": "nosniff",
40
+ },
41
+ });
42
+ }).pipe(
43
+ Effect.catchTag("AttachmentNotFound", () =>
44
+ Effect.succeed(HttpServerResponse.empty({ status: 404 })),
45
+ ),
46
+ ),
47
+ ).pipe(Layer.provide(Authentication.protect(Session).layer)),
48
+ ),
49
+ );
@@ -0,0 +1,38 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { authorize } from "../identity/authorize.js";
4
+ import { CurrentMember } from "../identity/Identity.js";
5
+ import { writeLimit } from "../issues/write-limit.js";
6
+ import { Attachments } from "./Attachments.js";
7
+ import { AttachmentStore } from "./store.js";
8
+
9
+ export const attachments = Action.implement(
10
+ Attachments,
11
+ Effect.gen(function* () {
12
+ const store = yield* AttachmentStore;
13
+ // The issues' write limit: an attachment counts against the same ten writes a minute.
14
+ const limited = yield* writeLimit;
15
+
16
+ return {
17
+ attachImage: ({ issue, name, image }) =>
18
+ Effect.gen(function* () {
19
+ // Input that decodes but cannot be served is the built-in `InvalidInput`, which
20
+ // every surface declares: no second error for bad input. Its `issues` name the
21
+ // field, as the library's own do for input that does not decode.
22
+ if (!image.mimeType.startsWith("image/")) {
23
+ const message = `Expected an image, got ${image.mimeType}.`;
24
+
25
+ return yield* new Action.InvalidInput({
26
+ message,
27
+ issues: [{ path: ["image", "mimeType"], message }],
28
+ });
29
+ }
30
+
31
+ return yield* limited((member) => store.add(member.workspace, issue, name, image));
32
+ }),
33
+ getAttachment: ({ id }) =>
34
+ Effect.flatMap(CurrentMember, (member) => store.get(member.workspace, id)),
35
+ };
36
+ }),
37
+ { authorize },
38
+ );
@@ -0,0 +1,58 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import type * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import type { IssueNotFound } from "../issues/Issues.js";
4
+ import { IssueStore } from "../issues/store.js";
5
+ import { type Attachment, AttachmentNotFound } from "./Attachments.js";
6
+
7
+ type Image = typeof Action.Image.Type;
8
+
9
+ interface Stored {
10
+ readonly attachment: Attachment;
11
+ readonly image: Image;
12
+ }
13
+
14
+ /** Images attached to issues, each in its issue's workspace, as `IssueStore` keeps issues. */
15
+ export class AttachmentStore extends Context.Service<
16
+ AttachmentStore,
17
+ {
18
+ readonly add: (
19
+ workspace: string,
20
+ issue: string,
21
+ name: string,
22
+ image: Image,
23
+ ) => Effect.Effect<Attachment, IssueNotFound>;
24
+ readonly get: (workspace: string, id: string) => Effect.Effect<Stored, AttachmentNotFound>;
25
+ }
26
+ >()("tracker/AttachmentStore") {
27
+ // DEMO ONLY: memory. A service captures what it depends on when it is built, here the issue
28
+ // store, so its methods require nothing.
29
+ static readonly layerMemory = Layer.effect(
30
+ AttachmentStore,
31
+ Effect.gen(function* () {
32
+ const issues = yield* IssueStore;
33
+ const stored = new Map<string, Stored>();
34
+
35
+ return AttachmentStore.of({
36
+ add: Effect.fn("AttachmentStore.add")(function* (workspace, issue, name, image) {
37
+ yield* issues.get(workspace, issue);
38
+
39
+ const attachment = {
40
+ id: crypto.randomUUID(),
41
+ issue,
42
+ name,
43
+ mimeType: image.mimeType,
44
+ size: image.data.byteLength,
45
+ };
46
+
47
+ stored.set(`${workspace}/${attachment.id}`, { attachment, image });
48
+
49
+ return attachment;
50
+ }),
51
+ get: (workspace, id) =>
52
+ Effect.fromNullishOr(stored.get(`${workspace}/${id}`)).pipe(
53
+ Effect.mapError(() => new AttachmentNotFound({ id })),
54
+ ),
55
+ });
56
+ }),
57
+ );
58
+ }
@@ -0,0 +1,33 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { RpcClient, RpcSerialization } from "effect/rpc";
3
+ import { Socket } from "effect/socket";
4
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
5
+ import { Rpc } from "./Rpc.js";
6
+
7
+ // Entrypoint of the web app, an Effect program in a page. It imports the RPC binding and
8
+ // nothing of the server. Its client has the methods of `ActionHttp.client`, over one socket.
9
+ export const board = Effect.gen(function* () {
10
+ const tracker = yield* ActionRpc.client(Rpc);
11
+
12
+ const me = yield* tracker.whoAmI();
13
+ const { issues } = yield* tracker.listIssues({ status: "open", limit: 50 });
14
+
15
+ return { me, issues };
16
+ });
17
+
18
+ // Effect's own client protocol: a WebSocket speaking the server's JSON. The page sends no
19
+ // credential itself: the browser attaches the session cookie to the upgrade, and the server
20
+ // authenticates every message with it. A client that cannot rely on a cookie, such as a script,
21
+ // sends a header on each message instead, with `RpcClient.withHeaders`.
22
+ export const protocol = (url: string) =>
23
+ RpcClient.layerProtocolSocket().pipe(
24
+ Layer.provide(Socket.layerWebSocket(url)),
25
+ Layer.provide([RpcSerialization.layerJson, Socket.layerWebSocketConstructorGlobal]),
26
+ );
27
+
28
+ // `open("wss://tracker.example.com/rpc")`: one run on a socket of its own. The socket belongs
29
+ // to the Layer providing the protocol: it opens when that Layer is built, and closes when this
30
+ // `Effect.provide` ends. `Effect.scoped` only holds the client. A page that stays connected
31
+ // builds the Layer once, such as in a `ManagedRuntime`, and runs its programs in it.
32
+ export const open = (url: string) =>
33
+ Effect.runPromise(board.pipe(Effect.scoped, Effect.provide(protocol(url))));
@@ -0,0 +1,85 @@
1
+ import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
2
+ import { Config, Effect, flow, Option } from "effect";
3
+ import { Command } from "effect/cli";
4
+ import { HttpClient, HttpClientRequest } from "effect/http";
5
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
6
+ import { Http } from "./Api.js";
7
+
8
+ // Entrypoint: `node --import tsx examples/kitchen-sink/src/cli.ts list-issues`.
9
+ // The members' CLI: a command per action of the binding, a flag per input field, each calling
10
+ // the server over HTTP. It imports the binding alone and runs nothing locally: the server
11
+ // authenticates and authorizes, as for any client.
12
+ const tracker = ActionCli.remote(Http, {
13
+ name: "tracker",
14
+ // Each command's options, keyed by its action's name and typed by that action.
15
+ commands: {
16
+ // `tracker list-issues -n 5 -s open`: short flags, and text instead of JSON (`--json` gives
17
+ // the JSON back).
18
+ listIssues: {
19
+ aliases: { limit: "n", status: "s" },
20
+ render: ({ issues }) =>
21
+ issues.map(({ id, status, title }) => `${id} ${status} ${title}`).join("\n"),
22
+ },
23
+ // `tracker get-issue ACME-1`: the id is an argument, not a flag.
24
+ getIssue: {
25
+ positional: ["id"],
26
+ render: (issue) =>
27
+ [
28
+ `# ${issue.id} ${issue.title} (${issue.status})`,
29
+ issue.body,
30
+ ...issue.comments.map(({ author, text }) => `- ${author}: ${text}`),
31
+ ].join("\n"),
32
+ },
33
+ // `tracker create-issue --title Crash < report.md`: the body is piped in.
34
+ createIssue: { stdin: "body" },
35
+ commentOnIssue: { name: "comment", positional: ["id"], stdin: "text" },
36
+ closeIssue: { name: "close", positional: ["id"] },
37
+ // `render` receives the decoded success, the image's bytes a `Uint8Array`.
38
+ getAttachment: {
39
+ positional: ["id"],
40
+ render: ({ attachment, image }) =>
41
+ `${attachment.name}: ${image.mimeType}, ${image.data.byteLength} bytes`,
42
+ },
43
+ },
44
+ }).pipe(
45
+ // The connection is read from `Config` when a command runs, so `--help` needs none of it.
46
+ // It is provided on this command, so it reaches these commands' requests and no others.
47
+ // The token and the workspace are optional: `tracker get-status` is public and needs neither,
48
+ // and any other command without them gets the server's 401.
49
+ Command.provideEffect(
50
+ HttpClient.HttpClient,
51
+ Effect.gen(function* () {
52
+ const url = yield* Config.String("TRACKER_URL").pipe(
53
+ Config.withDefault("http://127.0.0.1:3000"),
54
+ );
55
+
56
+ const token = yield* Config.option(Config.Redacted("TRACKER_TOKEN"));
57
+ const workspace = yield* Config.option(Config.String("TRACKER_WORKSPACE"));
58
+
59
+ const unchanged = (request: HttpClientRequest.HttpClientRequest) => request;
60
+
61
+ return HttpClient.mapRequest(
62
+ yield* HttpClient.HttpClient,
63
+ flow(
64
+ HttpClientRequest.prependUrl(url),
65
+ Option.match(token, {
66
+ onNone: () => unchanged,
67
+ onSome: (value) => HttpClientRequest.bearerToken(value),
68
+ }),
69
+ Option.match(workspace, {
70
+ onNone: () => unchanged,
71
+ onSome: (slug) => HttpClientRequest.setHeader("x-workspace", slug),
72
+ }),
73
+ ),
74
+ );
75
+ }),
76
+ ),
77
+ );
78
+
79
+ // A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
80
+ // HTTP sends, such as `{"_tag":"IssueNotFound","id":"ACME-9"}`, and the command exits 1.
81
+ Command.run(tracker, { version: "1.0.0" }).pipe(
82
+ Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
83
+ ActionCli.logToStderr,
84
+ NodeRuntime.runMain,
85
+ );
@@ -0,0 +1,18 @@
1
+ import { Config } from "effect";
2
+
3
+ // What a deployment sets. A `Config` is only a description: each is read where a layer is
4
+ // built, so importing this file reads nothing, and a test runs on the defaults.
5
+
6
+ /** Where callers reach the public port: the OAuth resource, and a Host the server answers to. */
7
+ export const publicUrl = Config.String("PUBLIC_URL").pipe(
8
+ Config.withDefault("http://localhost:3000"),
9
+ );
10
+
11
+ /**
12
+ * The origins whose pages may call the public port: the web app's, which is the server's own,
13
+ * and one other, a browser MCP client's.
14
+ */
15
+ export const allowedOrigins = Config.all([
16
+ publicUrl,
17
+ Config.String("MCP_ORIGIN").pipe(Config.withDefault("http://localhost:6274")),
18
+ ]).pipe(Config.map(([url, mcp]) => [new URL(url).origin, mcp]));
@@ -0,0 +1,61 @@
1
+ import { Context, Schema } from "effect";
2
+ import { HttpApiSecurity } from "effect/http-api";
3
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
5
+
6
+ export const scopes = { read: "issues:read", write: "issues:write" } as const;
7
+
8
+ export const Scope = Schema.Literals([scopes.read, scopes.write]);
9
+
10
+ /** A person acting in one workspace, with the scopes their credential holds there. */
11
+ export interface Member {
12
+ readonly id: string;
13
+ readonly workspace: string;
14
+ readonly scopes: ReadonlyArray<typeof Scope.Type>;
15
+ }
16
+
17
+ /**
18
+ * Who is calling. A protected action declares it, `caller: CurrentMember`; authentication
19
+ * provides it per request, and a local host (stdio, the assistant, a test) provides it itself.
20
+ */
21
+ export class CurrentMember extends Context.Service<CurrentMember, Member>()(
22
+ "tracker/CurrentMember",
23
+ ) {}
24
+
25
+ // What a verifier may fail with besides a refusal. It is not the caller's fault, so it is no
26
+ // 401: signing in again cannot help. Every protected route declares it, so clients decode it.
27
+ export class IssuerUnavailable extends Schema.TaggedError<IssuerUnavailable>()(
28
+ "IssuerUnavailable",
29
+ { retryAfterSeconds: Schema.Finite },
30
+ { httpApiStatus: 503 },
31
+ ) {}
32
+
33
+ // How an API client, the CLI and an MCP client prove they are CurrentMember: a bearer token,
34
+ // the default scheme, and the only one that speaks OAuth (discovery, challenges, step-up).
35
+ export const Login = Authentication.make("tracker.Login", CurrentMember, {
36
+ error: IssuerUnavailable,
37
+ });
38
+
39
+ // How the web app proves the same identity: the session cookie its sign-in page set. A
40
+ // descriptor names one scheme, so a host accepting two credentials has two descriptors, each
41
+ // named by what accepts it: here `Rpc`, the web app's socket, and the route its pages load an
42
+ // attachment's image from.
43
+ export const Session = Authentication.make("tracker.Session", CurrentMember, {
44
+ security: HttpApiSecurity.apiKey({ in: "cookie", key: "tracker_session" }),
45
+ });
46
+
47
+ // What the API's request quota answers with. It is the bindings' error, not an action's:
48
+ // middleware sends it before any action is chosen, and only HTTP and RPC have that middleware.
49
+ export class QuotaExceeded extends Schema.TaggedError<QuotaExceeded>()(
50
+ "QuotaExceeded",
51
+ { retryAfterSeconds: Schema.Finite },
52
+ { httpApiStatus: 429 },
53
+ ) {}
54
+
55
+ // An action without input: its client method takes no argument, its command no flag.
56
+ export const WhoAmI = Action.make("whoAmI", {
57
+ description: "Show who the credential identifies, in which workspace, with which scopes.",
58
+ success: { id: Schema.String, workspace: Schema.String, scopes: Schema.Array(Scope) },
59
+ readOnly: true,
60
+ caller: CurrentMember,
61
+ });
@@ -0,0 +1,87 @@
1
+ import { Array, Effect, Option, type Redacted } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
+ import { publicUrl } from "../config.js";
5
+ import { WorkspaceRegistry } from "../workspaces/registry.js";
6
+ import { Tenant } from "../workspaces/tenant.js";
7
+ import { Directory } from "./directory.js";
8
+ import { Login, type Member, scopes, Session } from "./Identity.js";
9
+
10
+ // What both verifiers check once a credential has named a member: an operator may have
11
+ // suspended the workspace. Checked here, it holds on every remote surface.
12
+ const inService = Effect.gen(function* () {
13
+ const registry = yield* WorkspaceRegistry;
14
+
15
+ return Effect.fn("inService")(function* (member: Member) {
16
+ const suspended = yield* registry.get(member.workspace).pipe(
17
+ Effect.map((workspace) => workspace.suspended),
18
+ Effect.orElseSucceed(() => true),
19
+ );
20
+
21
+ // No scopes named: signing in again grants nothing, so this is a plain 403, never a
22
+ // challenge sending an OAuth client back to its authorization server.
23
+ if (suspended) return yield* new Action.Forbidden({ message: "This workspace is suspended." });
24
+
25
+ return member;
26
+ });
27
+ });
28
+
29
+ // As an OAuth protected resource the provider publishes discovery (RFC 9728) and names it in
30
+ // every challenge, so an MCP client that gets a 401 finds where to sign in, asking for the read
31
+ // scope first. The resource is the public URL, known only to the deployment: an Effect, read
32
+ // when the provider is built.
33
+ const protectedResource = Effect.map(
34
+ publicUrl,
35
+ (url) =>
36
+ ({
37
+ resource: `${url}/mcp`,
38
+ // DEMO ONLY: no server is there. Yours is your authorization server's issuer.
39
+ authorizationServers: ["https://auth.example.com"],
40
+ scopesSupported: [scopes.read, scopes.write],
41
+ scopesRequired: [scopes.read],
42
+ resourceName: "Tracker",
43
+ }) satisfies Authentication.ProtectedResource,
44
+ );
45
+
46
+ // Server only: `Login`'s provider. The Effect is a builder, as `Action.implement` takes one:
47
+ // it yields the startup services once, and returns the verifier, which runs per request and
48
+ // may read request services, here the `Tenant` router middleware provides.
49
+ export const authenticateBearer = Authentication.layer(
50
+ Login,
51
+ Effect.gen(function* () {
52
+ const directory = yield* Directory;
53
+ const admit = yield* inService;
54
+
55
+ return (token: Redacted.Redacted<string>) =>
56
+ Effect.gen(function* () {
57
+ const memberships = yield* directory.ofToken(token);
58
+
59
+ const member = Option.flatMap(yield* Tenant, (slug) =>
60
+ Array.findFirst(memberships, ({ workspace }) => workspace === slug),
61
+ );
62
+
63
+ if (Option.isNone(member)) {
64
+ return yield* new Action.Forbidden({
65
+ message: "Name a workspace you belong to in the X-Workspace header.",
66
+ });
67
+ }
68
+
69
+ return yield* admit(member.value);
70
+ });
71
+ }),
72
+ { protectedResource },
73
+ );
74
+
75
+ // `Session`'s provider: the same identity from another credential. Its verifier receives what
76
+ // its scheme decodes, the cookie's value. A session belongs to one workspace, so it reads no
77
+ // `Tenant`, and under a scheme other than Bearer there is no discovery and no step-up.
78
+ export const authenticateSession = Authentication.layer(
79
+ Session,
80
+ Effect.gen(function* () {
81
+ const directory = yield* Directory;
82
+ const admit = yield* inService;
83
+
84
+ return (session: Redacted.Redacted<string>) =>
85
+ Effect.flatMap(directory.ofSession(session), admit);
86
+ }),
87
+ );
@@ -0,0 +1,16 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { CurrentMember, scopes } from "./Identity.js";
4
+
5
+ // The members' rule, read from each contract: a read needs the read scope, anything else the
6
+ // write scope. Every implementation of the members' actions states it, so every surface runs
7
+ // it before the handler: HTTP, MCP, RPC, stdio, the assistant's tools and a test alike.
8
+ // Naming the missing scope turns the 403 into the challenge an OAuth client re-authorizes on.
9
+ export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
10
+ const scope = action.readOnly ? scopes.read : scopes.write;
11
+ const member = yield* CurrentMember;
12
+
13
+ if (!member.scopes.includes(scope)) {
14
+ return yield* new Action.Forbidden({ message: `Requires ${scope}.`, scopes: [scope] });
15
+ }
16
+ });
@@ -0,0 +1,52 @@
1
+ import { Context, Effect, Layer, Redacted } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import type { IssuerUnavailable, Member } from "./Identity.js";
4
+ import { members } from "./members.js";
5
+
6
+ /**
7
+ * Who a credential names. It is a startup service: the verifiers yield it once, when their
8
+ * layer is built, and call it per request.
9
+ */
10
+ export class Directory extends Context.Service<
11
+ Directory,
12
+ {
13
+ /** Every membership of the account an access token was issued to. */
14
+ readonly ofToken: (
15
+ token: Redacted.Redacted<string>,
16
+ ) => Effect.Effect<ReadonlyArray<Member>, Action.Unauthenticated | IssuerUnavailable>;
17
+ /** The member a web session was opened for: a session belongs to one workspace. */
18
+ readonly ofSession: (
19
+ session: Redacted.Redacted<string>,
20
+ ) => Effect.Effect<Member, Action.Unauthenticated>;
21
+ }
22
+ >()("tracker/Directory") {
23
+ // DEMO ONLY: a token is an account's name (`ada`, `linus`, `kay`, `grace`) and a session a fixed
24
+ // string. A real directory checks a signed token with the authorization server's library,
25
+ // its audience included, failing with `IssuerUnavailable` when it cannot reach its keys, and
26
+ // reads sessions from their store.
27
+ static readonly layerDemo = Layer.sync(Directory, () => {
28
+ const accounts = new Map<string, ReadonlyArray<Member>>([
29
+ ["ada", [members.ada, members.adaAtGlobex]],
30
+ ["linus", [members.linus]],
31
+ ["kay", [members.kay]],
32
+ ["grace", [members.grace]],
33
+ ]);
34
+
35
+ const sessions = new Map<string, Member>([
36
+ ["ada-at-acme", members.ada],
37
+ ["kay-at-acme", members.kay],
38
+ ["grace-at-globex", members.grace],
39
+ ]);
40
+
41
+ return Directory.of({
42
+ ofToken: (token) =>
43
+ Effect.fromNullishOr(accounts.get(Redacted.value(token))).pipe(
44
+ Effect.mapError(() => new Action.Unauthenticated({ message: "Unknown demo token." })),
45
+ ),
46
+ ofSession: (session) =>
47
+ Effect.fromNullishOr(sessions.get(Redacted.value(session))).pipe(
48
+ Effect.mapError(() => new Action.Unauthenticated({ message: "Unknown demo session." })),
49
+ ),
50
+ });
51
+ });
52
+ }
@@ -0,0 +1,9 @@
1
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import { CurrentMember, WhoAmI } from "./Identity.js";
3
+
4
+ // `Action.allowAll`: verification is the whole policy here. Any authenticated member may ask
5
+ // who they are, whatever their scopes, so there is no rule to state. It must still be said:
6
+ // an implementation of a protected action without `authorize` does not compile.
7
+ export const identity = Action.implement(WhoAmI, () => CurrentMember, {
8
+ authorize: Action.allowAll,
9
+ });
@@ -0,0 +1,13 @@
1
+ import { type Member, scopes } from "./Identity.js";
2
+
3
+ // DEMO ONLY: the people of the demo, each a member of one workspace. A real application has no
4
+ // such table: its directory reads a member from a signed token or a stored session. A local
5
+ // host names its caller with one of these, and so does a test.
6
+ export const members = {
7
+ ada: { id: "ada", workspace: "acme", scopes: [scopes.read, scopes.write] },
8
+ adaAtGlobex: { id: "ada", workspace: "globex", scopes: [scopes.read] },
9
+ linus: { id: "linus", workspace: "acme", scopes: [scopes.read] },
10
+ // Invited to acme, granted nothing yet.
11
+ kay: { id: "kay", workspace: "acme", scopes: [] },
12
+ grace: { id: "grace", workspace: "globex", scopes: [scopes.read, scopes.write] },
13
+ } as const satisfies Readonly<Record<string, Member>>;
@@ -0,0 +1,62 @@
1
+ import { Duration, Effect, Layer } from "effect";
2
+ import { HttpApiMiddleware } from "effect/http-api";
3
+ import { RateLimiter } from "effect/persistence";
4
+ import { RpcMiddleware } from "effect/rpc";
5
+ import { CurrentMember, QuotaExceeded } from "./Identity.js";
6
+
7
+ // The API's request quota: 60 requests a minute per member. It is a surface's own middleware,
8
+ // not a handler's check, because it must count before the input is decoded: a request whose
9
+ // body does not decode counts too. It runs inside authentication, so it may require the
10
+ // caller. HTTP and RPC each have their native middleware; an MCP endpoint takes none.
11
+
12
+ /** Listed in `ActionHttp.layer`'s `middleware`. It fails only with an error the binding declares. */
13
+ export class HttpQuota extends HttpApiMiddleware.Service<HttpQuota, { requires: CurrentMember }>()(
14
+ "tracker/HttpQuota",
15
+ { error: QuotaExceeded },
16
+ ) {}
17
+
18
+ /** Listed in `ActionRpc.layer`'s `middleware`: the same quota, counted per message. */
19
+ export class RpcQuota extends RpcMiddleware.Service<RpcQuota, { requires: CurrentMember }>()(
20
+ "tracker/RpcQuota",
21
+ { error: QuotaExceeded },
22
+ ) {}
23
+
24
+ // What both middleware do before a call. Each middleware's layer builds this for itself, so it
25
+ // runs twice; the count is one all the same, because both yield the one `RateLimiter` the
26
+ // entrypoint provides and spend under the same key.
27
+ const spend = Effect.gen(function* () {
28
+ const limiter = yield* RateLimiter.RateLimiter;
29
+
30
+ return Effect.gen(function* () {
31
+ const member = yield* CurrentMember;
32
+
33
+ yield* limiter
34
+ .consume({
35
+ key: JSON.stringify(["requests", member.workspace, member.id]),
36
+ limit: 60,
37
+ window: "1 minute",
38
+ onExceeded: "fail",
39
+ })
40
+ .pipe(
41
+ Effect.catchReason(
42
+ "RateLimiterError",
43
+ "RateLimitExceeded",
44
+ ({ retryAfter }) =>
45
+ Effect.fail(new QuotaExceeded({ retryAfterSeconds: Duration.toSeconds(retryAfter) })),
46
+ // Any other reason is the limiter's store failing: a defect, an empty 500.
47
+ Effect.die,
48
+ ),
49
+ );
50
+ });
51
+ });
52
+
53
+ export const quota = Layer.merge(
54
+ Layer.effect(
55
+ HttpQuota,
56
+ Effect.map(spend, (spent) => (route) => Effect.andThen(spent, route)),
57
+ ),
58
+ Layer.effect(
59
+ RpcQuota,
60
+ Effect.map(spend, (spent) => (rpc) => Effect.andThen(spent, rpc)),
61
+ ),
62
+ );