@gjermundgaraba/effect-actions 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/README.md +102 -41
  2. package/dist/Action.d.mts +31 -117
  3. package/dist/Action.mjs +14 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +23 -29
  9. package/dist/ActionMcp.mjs +56 -114
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +28 -9
  13. package/dist/ActionToolkit.mjs +31 -4
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +30 -23
  17. package/dist/Testing.mjs +27 -44
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +18 -0
  38. package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +211 -426
  46. package/docs/ActionCli-failures.md +104 -0
  47. package/docs/ActionCli.md +208 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +205 -282
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +222 -271
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +179 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +103 -119
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +143 -392
  59. package/docs/CONTEXT.md +40 -24
  60. package/docs/README.md +75 -35
  61. package/docs/Testing-failures.md +21 -0
  62. package/docs/Testing.md +193 -151
  63. package/docs/guarantees.md +35 -96
  64. package/docs/setup-failures.md +37 -0
  65. package/docs/setup.md +90 -92
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +318 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +126 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +111 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +42 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +77 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +141 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +121 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +31 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +111 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +137 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +119 -0
  183. package/examples/notes-service/src/notes/handlers.ts +39 -0
  184. package/examples/notes-service/src/notes/store.ts +84 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +288 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +27 -9
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -14
  211. package/dist/internal/media.d.mts +0 -8
  212. package/dist/internal/media.mjs +0 -26
  213. package/dist/internal/refusal.mjs +0 -67
  214. package/dist/internal/schema-errors.mjs +0 -20
  215. package/dist/internal/tools.mjs +0 -44
@@ -0,0 +1,137 @@
1
+ import { Config, Console, Effect, FileSystem, flow } from "effect";
2
+ import { Argument, CliError, Command } from "effect/cli";
3
+ import { HttpClient, HttpClientRequest } from "effect/http";
4
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
5
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
+ import { Http } from "./Api.js";
7
+
8
+ // The file's format: each note a `# ` title line, a blank line and its body, the notes apart by
9
+ // a blank line. A body line that starts as a title would is written with a backslash before it,
10
+ // as Markdown escapes one, and `import` takes it off, so every note reads back as it was.
11
+ const escape = (body: string) => body.replaceAll(/^(?=\\*# )/gmu, "\\");
12
+
13
+ const unescape = (body: string) => body.replaceAll(/^\\(?=\\*# )/gmu, "");
14
+
15
+ // A command of your own, for what no single action does: this one calls two and writes a file.
16
+ // It is a native Effect CLI command, so its syntax and its output are what you write: `notes
17
+ // export notes.md` takes its argument from `Argument`, as a flag would come from `Flag`. It
18
+ // calls the server through the binding's typed client, on the `HttpClient` the derived commands
19
+ // use, and writes with the `FileSystem` every Effect CLI command has.
20
+ const exportNotes = Command.make(
21
+ "export",
22
+ { file: Argument.String("file").pipe(Argument.withDescription("The Markdown file to write.")) },
23
+ ({ file }) =>
24
+ Effect.gen(function* () {
25
+ const client = yield* ActionHttp.client(Http);
26
+ const files = yield* FileSystem.FileSystem;
27
+ const { notes } = yield* client.listNotes();
28
+ const whole = yield* Effect.forEach(notes, ({ id }) => client.getNote({ id }));
29
+
30
+ yield* files.writeFileString(
31
+ file,
32
+ whole.map((note) => `# ${note.title}\n\n${escape(note.body)}`).join("\n\n"),
33
+ );
34
+
35
+ yield* Console.log(`Wrote ${whole.length} notes to ${file}`);
36
+ }).pipe(
37
+ // Context of your own for a failure goes to stderr before it, on a line of its own. A
38
+ // `UserError` wrapped around the failure instead would put its JSON mid-sentence. The
39
+ // line prints for every failure of the command, a file that cannot be written included,
40
+ // so it says only what is true of them all.
41
+ Effect.tapError(() => Console.error(`The export to ${file} did not finish.`)),
42
+ // Once, around the whole command: it then fails as a derived command does, an action's
43
+ // error printed as its JSON on stderr and any other, a file that cannot be written for
44
+ // one, named. A failure left as it is would be reported as a crash, with an `ERROR (#n)`
45
+ // line and a stack trace.
46
+ ActionCli.toUserError(Http),
47
+ ),
48
+ ).pipe(Command.withDescription("Write every note to a file, as Markdown."));
49
+
50
+ // The other half of `export`: it reads such a file and creates its notes with one call of the
51
+ // bulk action, so the server checks every note before it writes any, and a file of more than
52
+ // the action's 100 is refused whole. The command reads the file and nothing more.
53
+ // `Argument.FileText` reads it, and a missing file is a usage error naming it, where
54
+ // `Argument.String` and `FileSystem` would print the platform's error. Text before the first
55
+ // title is refused, not dropped: the file is not one `export` wrote. The derived `import-notes`
56
+ // stays beside it, taking the notes as a JSON array in `--notes`.
57
+ const importNotes = Command.make(
58
+ "import",
59
+ {
60
+ text: Argument.FileText("file").pipe(
61
+ Argument.withDescription("A Markdown file as `notes export` writes it."),
62
+ ),
63
+ },
64
+ ({ text }) =>
65
+ Effect.gen(function* () {
66
+ const client = yield* ActionHttp.client(Http);
67
+
68
+ if (text === "") return yield* Console.log("Created 0 notes");
69
+
70
+ if (!text.startsWith("# ")) {
71
+ return yield* new CliError.UserError({
72
+ cause: text.split("\n", 1)[0],
73
+ userMessage: "The file does not start with a `# ` title, as `notes export` writes one.",
74
+ });
75
+ }
76
+
77
+ const notes = text.split(/\n\n(?=# )/u).map((note) => {
78
+ const [title = "", ...body] = note.slice(2).split("\n\n");
79
+
80
+ return { title, body: unescape(body.join("\n\n")) };
81
+ });
82
+
83
+ const { notes: created } = yield* client.importNotes({ notes });
84
+
85
+ yield* Console.log(`Created ${created.length} notes`);
86
+ }).pipe(ActionCli.toUserError(Http)),
87
+ ).pipe(
88
+ Command.withDescription("Create the notes of a file `export` wrote, all or none, at most 100."),
89
+ );
90
+
91
+ // A command per action of the binding, a flag per input field, each calling the server over
92
+ // HTTP. It imports the binding alone, never a handler. It is apart from cli.ts so a test runs
93
+ // it: test/commands.test.ts.
94
+ export const commands = ActionCli.remote(Http, {
95
+ name: "notes",
96
+ commands: {
97
+ // `notes create-note --title Draft < draft.md`: the body is piped in, not a flag.
98
+ createNote: { stdin: "body" },
99
+ getNote: { positional: ["id"], render: (note) => `# ${note.title}\n\n${note.body}` },
100
+ // `notes update-note <id> --revision 1 < draft.md`: an argument, a flag and stdin together.
101
+ updateNote: { positional: ["id"], stdin: "body" },
102
+ },
103
+ // `Command.withSubcommands` on the result would replace the derived commands, so your own
104
+ // go here.
105
+ subcommands: [exportNotes, importNotes],
106
+ });
107
+
108
+ // Where the server is and who is calling, read from config: the `HttpClient` every command
109
+ // above calls with. It is an Effect, not a value, so that cli.ts provides it on the commands
110
+ // and it runs when one of them does.
111
+ export const connection = Effect.gen(function* () {
112
+ const url = yield* Config.String("NOTES_URL").pipe(Config.withDefault("http://127.0.0.1:3000"));
113
+
114
+ // Required, since every command but `get-status` is refused without one. A failure of what a
115
+ // command is provided is no action's error: left as it is, a missing token is reported as a
116
+ // crash, the log line `ERROR (#n): ConfigError: SchemaError(Expected string at
117
+ // ["NOTES_TOKEN"])`. Mapped to a `UserError`, it is printed as any failure of a command is:
118
+ // the message under an `ERROR` heading on stderr, and exit 1. `userMessage` is all that is
119
+ // printed, never `cause`: what the user needs from the cause goes into the message. A CLI
120
+ // whose public commands must answer signed out reads the token with `Config.option` instead
121
+ // and adds the header when it is there.
122
+ const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
123
+ Effect.mapError(
124
+ (cause) =>
125
+ new CliError.UserError({
126
+ cause,
127
+ userMessage:
128
+ "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
129
+ }),
130
+ ),
131
+ );
132
+
133
+ return HttpClient.mapRequest(
134
+ yield* HttpClient.HttpClient,
135
+ flow(HttpClientRequest.prependUrl(url), HttpClientRequest.bearerToken(token)),
136
+ );
137
+ });
@@ -0,0 +1,31 @@
1
+ import { Context, Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
+
5
+ export const scopes = { read: "notes:read", write: "notes:write" } as const;
6
+
7
+ export const Scope = Schema.Literals([scopes.read, scopes.write]);
8
+
9
+ export interface User {
10
+ readonly id: string;
11
+ readonly scopes: ReadonlyArray<typeof Scope.Type>;
12
+ }
13
+
14
+ /**
15
+ * Who is calling. A protected action declares it, `caller: CurrentUser`; authentication
16
+ * provides it per request, and a local host, such as the stdio server, provides it itself.
17
+ */
18
+ export class CurrentUser extends Context.Service<CurrentUser, User>()(
19
+ "notes-service/CurrentUser",
20
+ ) {}
21
+
22
+ // How a remote caller proves it is CurrentUser: a bearer token, the default scheme. Clients
23
+ // import this descriptor with the binding; its verifier, in verifier.ts, stays on the server.
24
+ export const Login = Authentication.make("notes-service.Login", CurrentUser);
25
+
26
+ export const WhoAmI = Action.make("whoAmI", {
27
+ description: "Show who the credential identifies and which scopes it holds.",
28
+ success: { id: Schema.String, scopes: Schema.Array(Scope) },
29
+ readOnly: true,
30
+ caller: CurrentUser,
31
+ });
@@ -0,0 +1,18 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { CurrentUser, scopes } from "./Identity.js";
4
+
5
+ // The one rule, read from each contract: a read needs the read scope, anything else the write
6
+ // scope. Every implementation of protected actions states it, so HTTP, MCP and stdio all run it
7
+ // before the handler. Naming the missing scope lets an OAuth client ask for it and retry: it is
8
+ // answered a challenge, which only a server publishing discovery (verifier.ts) can follow up.
9
+ // Where tokens are fixed, such as API keys, name no scope: the refusal is then a plain one.
10
+ // So `scopes` below goes when `protectedResource` in verifier.ts does: copy both or neither.
11
+ export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
12
+ const scope = action.readOnly ? scopes.read : scopes.write;
13
+ const user = yield* CurrentUser;
14
+
15
+ if (!user.scopes.includes(scope)) {
16
+ return yield* new Action.Forbidden({ message: `Requires ${scope}.`, scopes: [scope] });
17
+ }
18
+ });
@@ -0,0 +1,10 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { authorize } from "./authorize.js";
4
+ import { CurrentUser, WhoAmI } from "./Identity.js";
5
+
6
+ export const identity = Action.implement(
7
+ WhoAmI,
8
+ () => Effect.map(CurrentUser, ({ id, scopes }) => ({ id, scopes })),
9
+ { authorize },
10
+ );
@@ -0,0 +1,9 @@
1
+ import { scopes, type User } from "./Identity.js";
2
+
3
+ // DEMO ONLY: the people who may sign in, each under a token that is their name. A real service
4
+ // has no such table: its verifier reads the user from a signed token.
5
+ export const users = {
6
+ ada: { id: "ada", scopes: [scopes.read, scopes.write] },
7
+ grace: { id: "grace", scopes: [scopes.read, scopes.write] },
8
+ reader: { id: "reader", scopes: [scopes.read] },
9
+ } as const satisfies Readonly<Record<string, User>>;
@@ -0,0 +1,36 @@
1
+ import { Config, Effect, Redacted } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
+ import { Login, scopes } from "./Identity.js";
5
+ import { users } from "./users.js";
6
+
7
+ const isToken = (token: string): token is keyof typeof users => Object.hasOwn(users, token);
8
+
9
+ // DEMO ONLY: a token is a user's name. A real verifier checks a signed token with your
10
+ // authorization server's library, its audience included. An unknown token is the built-in
11
+ // `Unauthenticated`: a 401 every client decodes.
12
+ const verify = (token: Redacted.Redacted<string>) => {
13
+ const name = Redacted.value(token);
14
+
15
+ return isToken(name)
16
+ ? Effect.succeed(users[name])
17
+ : Effect.fail(new Action.Unauthenticated({ message: "Unknown demo token." }));
18
+ };
19
+
20
+ // Server only: `Login`'s provider. Every layer serving a protected action requires it and
21
+ // authenticates before it decodes the input. As an OAuth protected resource it also publishes
22
+ // discovery, so an MCP client that gets a 401 finds where to sign in, asking for read first.
23
+ // The resource is the server's public URL, known only at startup, so it is read from config.
24
+ export const authenticate = Authentication.layer(Login, verify, {
25
+ protectedResource: Effect.map(
26
+ Config.String("PUBLIC_URL").pipe(Config.withDefault("http://localhost:3000")),
27
+ (url) =>
28
+ ({
29
+ resource: `${url}/mcp`,
30
+ // DEMO ONLY: no server is there. Yours is your authorization server's issuer.
31
+ authorizationServers: ["https://auth.example.com"],
32
+ scopesSupported: [scopes.read, scopes.write],
33
+ scopesRequired: [scopes.read],
34
+ }) satisfies Authentication.ProtectedResource,
35
+ ),
36
+ });
@@ -0,0 +1,119 @@
1
+ import { Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { CurrentUser } from "../identity/Identity.js";
4
+
5
+ export const Note = Schema.Struct({
6
+ id: Schema.String,
7
+ title: Schema.String,
8
+ body: Schema.String,
9
+ revision: Schema.Finite,
10
+ });
11
+
12
+ export type Note = typeof Note.Type;
13
+
14
+ // A declared error is part of the contract: every client decodes it as this class, and its
15
+ // status is what HTTP answers with.
16
+ export class NoteNotFound extends Schema.TaggedError<NoteNotFound>()(
17
+ "NoteNotFound",
18
+ { id: Schema.String },
19
+ { httpApiStatus: 404 },
20
+ ) {}
21
+
22
+ export class NoteChanged extends Schema.TaggedError<NoteChanged>()(
23
+ "NoteChanged",
24
+ { id: Schema.String, revision: Schema.Finite },
25
+ { httpApiStatus: 409 },
26
+ ) {}
27
+
28
+ // A description on an input field is that field's help on every surface: beside its flag or
29
+ // argument in a command's `--help`, and in the input schema a model reads from the MCP tool
30
+ // listing and the OpenAPI document. A field without one has a flag with no help.
31
+ const Id = Schema.String.annotate({
32
+ description: "The note's id, as the list of your notes shows it.",
33
+ });
34
+
35
+ // A check's `message` is what a caller reads when the value fails it, in place of the
36
+ // generic "Expected a value with a length of at least 1". A model and a CLI user read it too.
37
+ const Title = Schema.String.check(
38
+ Schema.isMinLength(1, { message: "A note needs a title." }),
39
+ Schema.isPattern(/^[^\r\n\u2028\u2029]*$/u, { message: "A title is one line." }),
40
+ ).annotate({
41
+ description: "A short name for the note: one line, at least one character.",
42
+ });
43
+
44
+ const Body = Schema.String.annotate({ description: "The note's whole text." });
45
+
46
+ export const ListNotes = Action.make("listNotes", {
47
+ description: "List your notes, without their bodies.",
48
+ success: { notes: Schema.Array(Schema.Struct({ id: Schema.String, title: Schema.String })) },
49
+ readOnly: true,
50
+ caller: CurrentUser,
51
+ });
52
+
53
+ export const GetNote = Action.make("getNote", {
54
+ description: "Read one of your notes.",
55
+ input: { id: Id },
56
+ success: Note,
57
+ error: NoteNotFound,
58
+ readOnly: true,
59
+ caller: CurrentUser,
60
+ });
61
+
62
+ export const CreateNote = Action.make("createNote", {
63
+ description: "Create a note.",
64
+ input: { title: Title, body: Body },
65
+ success: Note,
66
+ readOnly: false,
67
+ caller: CurrentUser,
68
+ mcp: { destructiveHint: false },
69
+ });
70
+
71
+ export const UpdateNote = Action.make("updateNote", {
72
+ description:
73
+ "Replace a note's body. Pass the revision you read: the update is refused if the note changed since.",
74
+ input: {
75
+ id: Id,
76
+ body: Body.annotate({ description: "The text that replaces the note's body." }),
77
+ revision: Schema.Finite.annotate({
78
+ description: "The revision you read: 1 for a new note, one more after each update.",
79
+ }),
80
+ },
81
+ success: Note,
82
+ error: [NoteNotFound, NoteChanged],
83
+ readOnly: false,
84
+ caller: CurrentUser,
85
+ // No `destructiveHint`: an update replaces the body, so it is destructive, MCP's default for
86
+ // a write. `createNote` and `importNotes`, which only add, say `false`.
87
+ });
88
+
89
+ export const DeleteNote = Action.make("deleteNote", {
90
+ description: "Delete a note for good.",
91
+ input: { id: Id },
92
+ error: NoteNotFound,
93
+ readOnly: false,
94
+ caller: CurrentUser,
95
+ });
96
+
97
+ // Work every surface should offer is an action, a bulk import included: the HTTP API, the MCP
98
+ // tools and the CLI then all have it, and the whole list is decoded, so checked, before the
99
+ // handler writes any of it. A command of your own only reads the file and calls this once
100
+ // (commands.ts). `importNotes` adds and removes nothing, so `destructiveHint` is `false`.
101
+ export const ImportNotes = Action.make("importNotes", {
102
+ description:
103
+ "Create several notes at once, such as those `notes export` wrote. Every note is checked " +
104
+ "before any is written: an import holding one that is not valid writes none.",
105
+ input: {
106
+ notes: Schema.Array(Schema.Struct({ title: Title, body: Body }))
107
+ .check(
108
+ Schema.isMinLength(1, { message: "An import holds at least one note." }),
109
+ Schema.isMaxLength(100, { message: "An import holds at most 100 notes." }),
110
+ )
111
+ .annotate({ description: "The notes to create, in order: from 1 to 100." }),
112
+ },
113
+ success: { notes: Schema.Array(Note) },
114
+ readOnly: false,
115
+ caller: CurrentUser,
116
+ mcp: { destructiveHint: false },
117
+ });
118
+
119
+ export const Notes = [ListNotes, GetNote, CreateNote, UpdateNote, DeleteNote, ImportNotes] as const;
@@ -0,0 +1,39 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { authorize } from "../identity/authorize.js";
4
+ import { CurrentUser } from "../identity/Identity.js";
5
+ import { Notes } from "./Notes.js";
6
+ import { NoteStore } from "./store.js";
7
+
8
+ // The builder runs once, at startup, however many surfaces serve these actions: it is where a
9
+ // handler gets the services that last the process, never the caller. `CurrentUser` is read per
10
+ // call, inside each handler. Each handler is a thin step from the contract to the store, and
11
+ // none checks a scope: `authorize` has run before it.
12
+ export const notes = Action.implement(
13
+ Notes,
14
+ Effect.gen(function* () {
15
+ const store = yield* NoteStore;
16
+
17
+ return {
18
+ listNotes: () =>
19
+ Effect.gen(function* () {
20
+ const user = yield* CurrentUser;
21
+ const owned = yield* store.list(user.id);
22
+
23
+ return { notes: owned.map(({ id, title }) => ({ id, title })) };
24
+ }),
25
+ getNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.get(user.id, id)),
26
+ createNote: ({ title, body }) =>
27
+ Effect.flatMap(CurrentUser, (user) => store.create(user.id, title, body)),
28
+ updateNote: ({ id, body, revision }) =>
29
+ Effect.flatMap(CurrentUser, (user) => store.update(user.id, id, body, revision)),
30
+ deleteNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.remove(user.id, id)),
31
+ importNotes: ({ notes }) =>
32
+ Effect.map(
33
+ Effect.flatMap(CurrentUser, (user) => store.createAll(user.id, notes)),
34
+ (created) => ({ notes: created }),
35
+ ),
36
+ };
37
+ }),
38
+ { authorize },
39
+ );
@@ -0,0 +1,84 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import { type Note, NoteChanged, NoteNotFound } from "./Notes.js";
3
+
4
+ /**
5
+ * Where notes live, each owned by one user. Ownership is checked here, in the data access, so no
6
+ * surface and no handler can skip it.
7
+ */
8
+ export class NoteStore extends Context.Service<
9
+ NoteStore,
10
+ {
11
+ readonly list: (owner: string) => Effect.Effect<ReadonlyArray<Note>>;
12
+ readonly get: (owner: string, id: string) => Effect.Effect<Note, NoteNotFound>;
13
+ readonly create: (owner: string, title: string, body: string) => Effect.Effect<Note>;
14
+ /** Every note or none: a database does this in one transaction. */
15
+ readonly createAll: (
16
+ owner: string,
17
+ notes: ReadonlyArray<{ readonly title: string; readonly body: string }>,
18
+ ) => Effect.Effect<ReadonlyArray<Note>>;
19
+ readonly update: (
20
+ owner: string,
21
+ id: string,
22
+ body: string,
23
+ revision: number,
24
+ ) => Effect.Effect<Note, NoteNotFound | NoteChanged>;
25
+ readonly remove: (owner: string, id: string) => Effect.Effect<void, NoteNotFound>;
26
+ readonly count: Effect.Effect<number>;
27
+ }
28
+ >()("notes-service/NoteStore") {
29
+ // DEMO ONLY: memory, lost when the process stops. A real store is another Layer of this
30
+ // service, chosen by the entrypoint.
31
+ static readonly layerMemory = Layer.sync(NoteStore, () => {
32
+ // One map per owner: a note is only ever looked up among its owner's, whatever id is asked.
33
+ const notes = new Map<string, Map<string, Note>>();
34
+
35
+ const ownedBy = (owner: string) => notes.get(owner) ?? new Map<string, Note>();
36
+
37
+ const save = (owner: string, note: Note) => {
38
+ notes.set(owner, ownedBy(owner).set(note.id, note));
39
+
40
+ return note;
41
+ };
42
+
43
+ const get = Effect.fn("NoteStore.get")(function* (owner: string, id: string) {
44
+ const note = ownedBy(owner).get(id);
45
+
46
+ if (note === undefined) return yield* new NoteNotFound({ id });
47
+
48
+ return note;
49
+ });
50
+
51
+ return NoteStore.of({
52
+ list: (owner) => Effect.sync(() => Array.from(ownedBy(owner).values())),
53
+ get,
54
+ create: (owner, title, body) =>
55
+ Effect.sync(() => save(owner, { id: crypto.randomUUID(), title, body, revision: 1 })),
56
+ createAll: (owner, all) =>
57
+ Effect.sync(() =>
58
+ all.map(({ title, body }) =>
59
+ save(owner, { id: crypto.randomUUID(), title, body, revision: 1 }),
60
+ ),
61
+ ),
62
+ // No `yield*` between the read and the write: a fiber may be set aside at one, and two
63
+ // updates that each read revision 1 before either wrote would both succeed. A database
64
+ // does this with a conditional update or a transaction.
65
+ update: Effect.fn("NoteStore.update")(function* (owner, id, body, revision) {
66
+ const current = ownedBy(owner).get(id);
67
+
68
+ if (current === undefined) return yield* new NoteNotFound({ id });
69
+
70
+ if (current.revision !== revision) {
71
+ return yield* new NoteChanged({ id, revision: current.revision });
72
+ }
73
+
74
+ return save(owner, { ...current, body, revision: revision + 1 });
75
+ }),
76
+ remove: Effect.fn("NoteStore.remove")(function* (owner, id) {
77
+ if (!ownedBy(owner).delete(id)) return yield* new NoteNotFound({ id });
78
+ }),
79
+ count: Effect.sync(() =>
80
+ Array.from(notes.values()).reduce((total, owned) => total + owned.size, 0),
81
+ ),
82
+ });
83
+ });
84
+ }
@@ -0,0 +1,45 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/http";
3
+ import { OpenApi } from "effect/http-api";
4
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
5
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
6
+ import { Http } from "./Api.js";
7
+ import { identity } from "./identity/handlers.js";
8
+ import { Login } from "./identity/Identity.js";
9
+ import { authenticate } from "./identity/verifier.js";
10
+ import { notes } from "./notes/handlers.js";
11
+ import { status } from "./status/handlers.js";
12
+
13
+ // Every domain's implementation, once. Each surface below takes the same list.
14
+ const implementations = [status, notes, identity];
15
+
16
+ // JSON routes at `/api/<action>`, the binding's. A protected route authenticates before it
17
+ // reads its body; `getStatus` answers anyone.
18
+ const api = ActionHttp.layer(Http, implementations);
19
+
20
+ // The same actions as MCP tools at `/mcp`. Listing the tools and calling `getStatus` need no
21
+ // token, because `getStatus` is public: an endpoint of protected tools alone asks a token for
22
+ // the listing too. Any other call answers 401 with where to sign in, and so does a client of
23
+ // an MCP revision with sessions when it opens one, since a session may call any tool.
24
+ const mcp = ActionMcp.layerHttp(implementations, {
25
+ name: "notes-service",
26
+ version: "1.0.0",
27
+ instructions: "Notes belong to the signed-in user. Read a note before updating it.",
28
+ authentication: Login,
29
+ });
30
+
31
+ // The host's own routes sit beside the actions' on the same router.
32
+ const health = HttpRouter.add("GET", "/healthz", HttpServerResponse.text("ok"));
33
+
34
+ // Served to anyone, which is a choice: it tells a signed-out caller every action's name and
35
+ // shape. To keep them, leave the route out, or provide `Authentication.protect(Login).layer`
36
+ // to it.
37
+ const openApi = HttpRouter.add(
38
+ "GET",
39
+ "/api/openapi.json",
40
+ HttpServerResponse.jsonUnsafe(OpenApi.fromApi(Http.api)),
41
+ );
42
+
43
+ // What the host serves. It still requires `NoteStore`: the entrypoint chooses which one, and a
44
+ // test chooses its own.
45
+ export const routes = Layer.mergeAll(api, mcp, health, openApi).pipe(Layer.provide(authenticate));
@@ -0,0 +1,26 @@
1
+ import { createServer } from "node:http";
2
+ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
3
+ import { ByteSize, Config, Effect, Layer } from "effect";
4
+ import { HttpRouter, HttpServerRequest } from "effect/http";
5
+ import { NoteStore } from "./notes/store.js";
6
+ import { routes } from "./routes.js";
7
+
8
+ // Entrypoint: `node --import tsx examples/notes-service/src/server.ts`.
9
+ // This is the composition root of the server. It chooses the store, reads the configuration
10
+ // and starts Node's HTTP server; nothing below it knows which of those it got.
11
+ const server = Layer.unwrap(
12
+ Effect.gen(function* () {
13
+ // The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
14
+ const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
15
+ const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
16
+
17
+ return HttpRouter.serve(routes).pipe(
18
+ Layer.provide(NodeHttpServer.layer(createServer, { host, port })),
19
+ // Request bodies have no size limit unless the host sets one. This covers every route.
20
+ Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1))),
21
+ Layer.provide(NoteStore.layerMemory),
22
+ );
23
+ }),
24
+ );
25
+
26
+ Layer.launch(server).pipe(NodeRuntime.runMain);
@@ -0,0 +1,10 @@
1
+ import { Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+
4
+ // Public: `caller: Action.Anyone`. It answers before anyone has signed in, on every surface.
5
+ export const GetStatus = Action.make("getStatus", {
6
+ description: "Report whether the service is up and how many notes it holds.",
7
+ success: { service: Schema.String, notes: Schema.Finite },
8
+ readOnly: true,
9
+ caller: Action.Anyone,
10
+ });
@@ -0,0 +1,14 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { NoteStore } from "../notes/store.js";
4
+ import { GetStatus } from "./Status.js";
5
+
6
+ // A public action takes no `authorize`: nothing would run it.
7
+ export const status = Action.implement(
8
+ GetStatus,
9
+ Effect.gen(function* () {
10
+ const store = yield* NoteStore;
11
+
12
+ return () => Effect.map(store.count, (notes) => ({ service: "notes-service", notes }));
13
+ }),
14
+ );
@@ -0,0 +1,25 @@
1
+ import { NodeRuntime, NodeStdio } from "@effect/platform-node";
2
+ import { Effect } from "effect";
3
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
4
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
5
+ import { identity } from "./identity/handlers.js";
6
+ import { CurrentUser } from "./identity/Identity.js";
7
+ import { users } from "./identity/users.js";
8
+ import { notes } from "./notes/handlers.js";
9
+ import { NoteStore } from "./notes/store.js";
10
+ import { status } from "./status/handlers.js";
11
+
12
+ // Entrypoint: an MCP server a local agent starts as a subprocess, such as
13
+ // `claude mcp add notes -- node --import tsx examples/notes-service/src/stdio.ts`.
14
+ // The same implementations as the HTTP server, and the same `authorize`. No token arrives
15
+ // over stdio, so this host names the caller itself. It is a process of its own with services
16
+ // of its own: this in-memory store holds its notes, not the running server's. To share them,
17
+ // both provide a store they both reach, such as a database.
18
+ ActionMcp.runStdio([status, notes, identity], { name: "notes-service", version: "1.0.0" }).pipe(
19
+ // DEMO ONLY: a real host reads who started it, from its own login or configuration.
20
+ Effect.provideService(CurrentUser, users.ada),
21
+ Effect.provide([NodeStdio.layer, NoteStore.layerMemory]),
22
+ // stdout carries the protocol alone, so logs and failures go to stderr.
23
+ ActionCli.logToStderr,
24
+ NodeRuntime.runMain,
25
+ );