@gjermundgaraba/effect-actions 0.18.2 → 0.19.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 (144) hide show
  1. package/README.md +20 -47
  2. package/dist/Action.d.mts +96 -47
  3. package/dist/Action.mjs +11 -14
  4. package/dist/ActionCli.d.mts +81 -42
  5. package/dist/ActionCli.mjs +52 -33
  6. package/dist/ActionHttp.d.mts +84 -43
  7. package/dist/ActionHttp.mjs +2 -2
  8. package/dist/ActionMcp.d.mts +54 -27
  9. package/dist/ActionMcp.mjs +5 -5
  10. package/dist/ActionRpc.d.mts +48 -35
  11. package/dist/ActionRpc.mjs +1 -1
  12. package/dist/ActionToolkit.d.mts +32 -13
  13. package/dist/ActionToolkit.mjs +10 -7
  14. package/dist/Authentication.d.mts +92 -40
  15. package/dist/Authentication.mjs +37 -35
  16. package/dist/Testing.d.mts +22 -24
  17. package/dist/authentication/provider.d.mts +1 -4
  18. package/dist/authentication/provider.mjs +1 -1
  19. package/dist/authentication/refusal.mjs +2 -1
  20. package/dist/cli/commands.d.mts +34 -11
  21. package/dist/cli/commands.mjs +47 -22
  22. package/dist/contract/caller.mjs +1 -1
  23. package/dist/contract/declared.mjs +27 -14
  24. package/dist/contract/errors.d.mts +42 -17
  25. package/dist/contract/errors.mjs +42 -17
  26. package/dist/contract/hints.mjs +3 -2
  27. package/dist/contract/implementation.d.mts +5 -8
  28. package/dist/contract/implementation.mjs +29 -17
  29. package/dist/contract/media.d.mts +6 -2
  30. package/dist/contract/media.mjs +6 -2
  31. package/dist/contract/names.mjs +14 -12
  32. package/dist/contract/status.mjs +3 -10
  33. package/dist/http/client.d.mts +15 -4
  34. package/dist/http/client.mjs +1 -1
  35. package/dist/mcp/client.d.mts +21 -8
  36. package/dist/mcp/client.mjs +6 -8
  37. package/docs/Action-failures.md +6 -15
  38. package/docs/Action.md +159 -191
  39. package/docs/ActionCli-failures.md +12 -92
  40. package/docs/ActionCli.md +102 -156
  41. package/docs/ActionHttp-failures.md +14 -76
  42. package/docs/ActionHttp.md +150 -176
  43. package/docs/ActionMcp-failures.md +2 -22
  44. package/docs/ActionMcp.md +79 -170
  45. package/docs/ActionRpc-failures.md +3 -12
  46. package/docs/ActionRpc.md +183 -120
  47. package/docs/ActionToolkit-failures.md +3 -10
  48. package/docs/ActionToolkit.md +112 -109
  49. package/docs/Authentication-advanced.md +167 -108
  50. package/docs/Authentication-failures.md +11 -91
  51. package/docs/Authentication.md +114 -135
  52. package/docs/README.md +45 -30
  53. package/docs/Testing-failures.md +2 -6
  54. package/docs/Testing.md +126 -159
  55. package/docs/setup-failures.md +8 -32
  56. package/docs/setup.md +42 -37
  57. package/examples/README.md +6 -10
  58. package/examples/assistant/README.md +20 -38
  59. package/examples/assistant/src/agent.ts +2 -3
  60. package/examples/assistant/src/cli.ts +4 -9
  61. package/examples/assistant/src/commands.ts +6 -4
  62. package/examples/assistant/src/identity/authorize.ts +3 -3
  63. package/examples/assistant/src/tasks/Tasks.ts +1 -2
  64. package/examples/assistant/src/tasks/handlers.ts +3 -3
  65. package/examples/assistant/src/toolkit.ts +6 -8
  66. package/examples/assistant/test/commands.test.ts +16 -11
  67. package/examples/assistant/test/tasks/tasks.test.ts +0 -2
  68. package/examples/assistant/test/toolkit.test.ts +0 -1
  69. package/examples/kitchen-sink/README.md +30 -52
  70. package/examples/kitchen-sink/src/Actions.ts +2 -3
  71. package/examples/kitchen-sink/src/Api.ts +3 -4
  72. package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
  73. package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
  74. package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
  75. package/examples/kitchen-sink/src/agent.ts +1 -1
  76. package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
  77. package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
  78. package/examples/kitchen-sink/src/attachments/download.ts +5 -8
  79. package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
  80. package/examples/kitchen-sink/src/browser.ts +3 -4
  81. package/examples/kitchen-sink/src/cli.ts +6 -7
  82. package/examples/kitchen-sink/src/config.ts +2 -0
  83. package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
  84. package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
  85. package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
  86. package/examples/kitchen-sink/src/identity/quota.ts +1 -1
  87. package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
  88. package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
  89. package/examples/kitchen-sink/src/issues/guide.ts +2 -3
  90. package/examples/kitchen-sink/src/ops.ts +3 -6
  91. package/examples/kitchen-sink/src/request-policy.ts +3 -3
  92. package/examples/kitchen-sink/src/routes.ts +4 -6
  93. package/examples/kitchen-sink/src/seed.ts +0 -1
  94. package/examples/kitchen-sink/src/server.ts +11 -12
  95. package/examples/kitchen-sink/src/stdio.ts +3 -2
  96. package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
  97. package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
  98. package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
  99. package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
  100. package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
  101. package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
  102. package/examples/kitchen-sink/test/request-policy.test.ts +1 -1
  103. package/examples/kitchen-sink/test/routes.test.ts +3 -3
  104. package/examples/live-rpc/README.md +8 -49
  105. package/examples/live-rpc/src/browser.ts +8 -41
  106. package/examples/live-rpc/src/chat/Chat.ts +9 -3
  107. package/examples/live-rpc/src/chat/room.ts +1 -1
  108. package/examples/live-rpc/src/client.ts +2 -3
  109. package/examples/live-rpc/src/page.ts +34 -0
  110. package/examples/live-rpc/src/routes.ts +6 -7
  111. package/examples/live-rpc/src/server.ts +6 -4
  112. package/examples/live-rpc/test/browser.test.ts +6 -5
  113. package/examples/live-rpc/test/routes.test.ts +16 -15
  114. package/examples/live-rpc/test/server.test.ts +1 -1
  115. package/examples/mcp-tools/README.md +22 -45
  116. package/examples/mcp-tools/src/printers/Printers.ts +3 -7
  117. package/examples/mcp-tools/src/routes.ts +0 -7
  118. package/examples/mcp-tools/src/server.ts +1 -1
  119. package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
  120. package/examples/mcp-tools/test/routes.test.ts +2 -3
  121. package/examples/notes-service/README.md +9 -43
  122. package/examples/notes-service/src/cli.ts +0 -3
  123. package/examples/notes-service/src/commands.ts +7 -17
  124. package/examples/notes-service/src/identity/authorize.ts +2 -5
  125. package/examples/notes-service/src/identity/verifier.ts +3 -4
  126. package/examples/notes-service/src/notes/Notes.ts +2 -6
  127. package/examples/notes-service/src/notes/store.ts +5 -1
  128. package/examples/notes-service/src/routes.ts +6 -8
  129. package/examples/notes-service/src/server.ts +4 -3
  130. package/examples/notes-service/src/stdio.ts +4 -3
  131. package/examples/notes-service/test/commands.test.ts +1 -1
  132. package/examples/notes-service/test/notes/as.ts +6 -0
  133. package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
  134. package/examples/notes-service/test/notes/in-process.test.ts +50 -0
  135. package/examples/notes-service/test/notes/notes.test.ts +3 -42
  136. package/examples/notes-service/test/routes.test.ts +2 -2
  137. package/examples/quickstart/README.md +1 -1
  138. package/examples/quickstart/src/client.ts +1 -0
  139. package/examples/quickstart/src/server.ts +2 -4
  140. package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
  141. package/package.json +1 -1
  142. package/docs/Action-in-process.md +0 -93
  143. package/docs/CONTEXT.md +0 -46
  144. package/docs/guarantees.md +0 -44
@@ -25,9 +25,8 @@ export const protocol = (url: string) =>
25
25
  Layer.provide([RpcSerialization.layerJson, Socket.layerWebSocketConstructorGlobal]),
26
26
  );
27
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.
28
+ // `open("wss://tracker.example.com/rpc")`: one run on a socket of its own, closed when it ends.
29
+ // A page that stays connected builds the protocol's Layer once, such as in a `ManagedRuntime`,
30
+ // and runs its programs in it.
32
31
  export const open = (url: string) =>
33
32
  Effect.runPromise(board.pipe(Effect.scoped, Effect.provide(protocol(url))));
@@ -15,8 +15,10 @@ const tracker = ActionCli.remote(Http, {
15
15
  // Each command's options, keyed by its action's name and typed by that action.
16
16
  commands: {
17
17
  // `tracker list-issues -n 5 -s open`: short flags, and text instead of JSON (`--json` gives
18
- // the JSON back).
18
+ // the JSON back). Its help says so: the action's description, which HTTP and MCP keep, says
19
+ // what the JSON holds.
19
20
  listIssues: {
21
+ description: "List your workspace's issues, newest first, one line each: id, status, title.",
20
22
  aliases: { limit: "n", status: "s" },
21
23
  render: ({ issues }) =>
22
24
  issues.map(({ id, status, title }) => `${id} ${status} ${title}`).join("\n"),
@@ -42,10 +44,9 @@ const tracker = ActionCli.remote(Http, {
42
44
  `${attachment.name}: ${image.mimeType}, ${image.data.byteLength} bytes`,
43
45
  },
44
46
  },
45
- // The connection, read from `Config` when a command runs, so `--help` needs none of it. It is
46
- // these commands' client, so it reaches their requests and no others. The token and the
47
- // workspace are optional: `tracker get-status` is public and needs neither, and any other
48
- // command without them gets the server's 401.
47
+ // The connection. The token and the workspace are optional: `tracker get-status` is public
48
+ // and needs neither, and any other command without a token gets the server's 401, and without
49
+ // a workspace its 403.
49
50
  client: Effect.gen(function* () {
50
51
  const baseUrl = yield* Config.String("TRACKER_URL").pipe(
51
52
  Config.withDefault("http://127.0.0.1:3000"),
@@ -74,8 +75,6 @@ const tracker = ActionCli.remote(Http, {
74
75
  }),
75
76
  });
76
77
 
77
- // A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
78
- // HTTP sends, such as `{"_tag":"IssueNotFound","id":"ACME-9"}`, and the command exits 1.
79
78
  Command.run(tracker, { version: "1.0.0" }).pipe(
80
79
  Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
81
80
  ActionCli.logToStderr,
@@ -14,5 +14,7 @@ export const publicUrl = Config.String("PUBLIC_URL").pipe(
14
14
  */
15
15
  export const allowedOrigins = Config.all([
16
16
  publicUrl,
17
+ // DEMO ONLY: the default is the MCP Inspector's local origin. A page of any origin listed
18
+ // passes the request policy and CORS, so a deployment names its own client's.
17
19
  Config.String("MCP_ORIGIN").pipe(Config.withDefault("http://localhost:6274")),
18
20
  ]).pipe(Config.map(([url, mcp]) => [new URL(url).origin, mcp]));
@@ -22,8 +22,8 @@ export class CurrentMember extends Context.Service<CurrentMember, Member>()(
22
22
  "tracker/CurrentMember",
23
23
  ) {}
24
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.
25
+ // What a verifier may fail with besides a refusal: the issuer it checks tokens with is down.
26
+ // It is not the caller's fault, so it is no 401: signing in again cannot help.
27
27
  export class IssuerUnavailable extends Schema.TaggedError<IssuerUnavailable>()(
28
28
  "IssuerUnavailable",
29
29
  { retryAfterSeconds: Schema.Finite },
@@ -31,21 +31,21 @@ export class IssuerUnavailable extends Schema.TaggedError<IssuerUnavailable>()(
31
31
  ) {}
32
32
 
33
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).
34
+ // the default scheme.
35
35
  export const Login = Authentication.make("tracker.Login", CurrentMember, {
36
36
  error: IssuerUnavailable,
37
37
  });
38
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
39
+ // How the web app proves the same identity: the session cookie its sign-in page set. It is
40
+ // named by what accepts it: `Rpc`, the web app's socket, and the route its pages load an
42
41
  // attachment's image from.
43
42
  export const Session = Authentication.make("tracker.Session", CurrentMember, {
44
43
  security: HttpApiSecurity.apiKey({ in: "cookie", key: "tracker_session" }),
45
44
  });
46
45
 
47
46
  // 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.
47
+ // middleware sends it on any action's route, before its input is decoded. Only HTTP and RPC
48
+ // have that middleware.
49
49
  export class QuotaExceeded extends Schema.TaggedError<QuotaExceeded>()(
50
50
  "QuotaExceeded",
51
51
  { retryAfterSeconds: Schema.Finite },
@@ -26,10 +26,8 @@ const inService = Effect.gen(function* () {
26
26
  });
27
27
  });
28
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.
29
+ // The OAuth protected resource whose discovery the provider publishes, so an MCP client that
30
+ // gets a 401 finds where to sign in, asking for the read scope first.
33
31
  const protectedResource = Effect.map(
34
32
  publicUrl,
35
33
  (url) =>
@@ -74,7 +72,7 @@ export const authenticateBearer = Authentication.layer(
74
72
 
75
73
  // `Session`'s provider: the same identity from another credential. Its verifier receives what
76
74
  // 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.
75
+ // `Tenant`.
78
76
  export const authenticateSession = Authentication.layer(
79
77
  Session,
80
78
  Effect.gen(function* () {
@@ -3,9 +3,10 @@ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
3
  import { CurrentMember, scopes } from "./Identity.js";
4
4
 
5
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.
6
+ // write scope. The implementations of issues and attachments state it, so every surface serving
7
+ // them runs it before the handler: HTTP, MCP, RPC, stdio, the assistant's tools and a test alike.
8
+ // For a bearer token, naming the missing scope turns the 403 into the challenge an OAuth client
9
+ // re-authorizes on.
9
10
  export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
10
11
  const scope = action.readOnly ? scopes.read : scopes.write;
11
12
  const member = yield* CurrentMember;
@@ -43,7 +43,7 @@ const spend = Effect.gen(function* () {
43
43
  "RateLimitExceeded",
44
44
  ({ retryAfter }) =>
45
45
  Effect.fail(new QuotaExceeded({ retryAfterSeconds: Duration.toSeconds(retryAfter) })),
46
- // Any other reason is the limiter's store failing: a defect, an empty 500.
46
+ // Any other reason is the limiter's store failing: a defect, never the caller's.
47
47
  Effect.die,
48
48
  ),
49
49
  );
@@ -32,13 +32,13 @@ export class RateLimited extends Schema.TaggedError<RateLimited>()(
32
32
  ) {}
33
33
 
34
34
  // A shared error list: what any write to an existing issue may fail with. Each such action
35
- // spreads this one array into its `error`, here and in attachments/Attachments.ts, so an error
35
+ // names this one array as its `error`, here and in attachments/Attachments.ts, so an error
36
36
  // added to the list is declared by all of them.
37
37
  export const IssueWriteErrors = [IssueNotFound, RateLimited] as const;
38
38
 
39
39
  // An option bundle. What the two actions changing an issue share is one constant object spread
40
40
  // into `Action.make`, typed as if written inline. `readOnly` and `description` stay on each
41
- // action: authorization and an MCP client's hints read them.
41
+ // action.
42
42
  const change = {
43
43
  caller: CurrentMember,
44
44
  error: IssueWriteErrors,
@@ -3,8 +3,8 @@ import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
3
3
  import { issues } from "./handlers.js";
4
4
  import { CloseIssue, CommentOnIssue, GetIssue, ListIssues } from "./Issues.js";
5
5
 
6
- // The actions a model may call, listed here, where they are served: an action added to the
7
- // contracts later is no tool until it is listed. Filing issues is left to people.
6
+ // The actions a model may call, listed here, where they are served. Filing issues is left to
7
+ // people.
8
8
  const tools = [ListIssues, GetIssue, CommentOnIssue, CloseIssue] as const;
9
9
 
10
10
  // Some of the issue actions as a model's tools: Effect's native `Toolkit`, in process, with no
@@ -19,5 +19,5 @@ export const { toolkit, layer } = ActionToolkit.make(issues, {
19
19
  });
20
20
 
21
21
  // One turn of the assistant. The caller provides the model, and the member it acts for around
22
- // the whole turn: approval, `authorize` and the handlers all read that one caller.
22
+ // the whole turn: `authorize` and the handlers read that one caller.
23
23
  export const triage = (prompt: string) => LanguageModel.generateText({ prompt, toolkit });
@@ -15,9 +15,8 @@ const guide = `# Triage
15
15
  const tools = Action.byName(Issues);
16
16
 
17
17
  // Effect's own MCP resources and prompts, served beside the actions' tools as an endpoint's
18
- // `features`. They are native, not actions: no `authorize` runs for them, and they run with
19
- // the services they were built with, never a request's, so they cannot read the caller.
20
- // Content that depends on the caller is an action.
18
+ // `features`: native, not actions. This guide is the same for every caller, so it reads no
19
+ // caller, which `features` never receive.
21
20
  export const features = Layer.mergeAll(
22
21
  McpServer.resource({
23
22
  uri: "tracker://guides/triage",
@@ -9,10 +9,8 @@ import { WorkspaceRegistry } from "./workspaces/registry.js";
9
9
 
10
10
  // Entrypoint: `node --import tsx examples/kitchen-sink/src/ops.ts list-workspaces`.
11
11
  // The operators' console: the admin actions run in this process, on the host's own services,
12
- // with no server and no key. A local command is no bypass: the implementation's `authorize`
13
- // runs here as on the admin port, and admits the operator this host supplies.
12
+ // with no server and no key.
14
13
 
15
- // Only a host can name the trusted operator: no verifier's return type includes it.
16
14
  const operator: TrustedOperator = { id: "console", trusted: true };
17
15
 
18
16
  const ops = ActionCli.make(admin, {
@@ -28,9 +26,8 @@ const ops = ActionCli.make(admin, {
28
26
  resumeWorkspace: { positional: ["workspace"] },
29
27
  },
30
28
  }).pipe(
31
- // Services go on the command: built when an action runs, never for `--help` or a mistyped
32
- // flag. Only what these actions are built from. DEMO ONLY: memory, so this console sees its
33
- // own process, not the server's data. A real one provides the database Layers the server does.
29
+ // Only what these actions are built from. DEMO ONLY: memory, so this console sees its own
30
+ // process, not the server's data. A real one provides the database Layers the server does.
34
31
  Command.provide(Layer.merge(WorkspaceRegistry.layerMemory, Grants.layerDemo)),
35
32
  Command.provideSync(Operator, operator),
36
33
  );
@@ -5,12 +5,12 @@ import { allowedOrigins, publicUrl } from "./config.js";
5
5
  const loopback = ["localhost", "127.0.0.1"];
6
6
 
7
7
  // Which Host and Origin a request may carry: not the library's concern, but every request of
8
- // both ports passes it first. The web app signs in with a cookie, which a browser attaches to
8
+ // both ports passes it first. A Host that is neither loopback nor the public URL's is refused,
9
+ // against DNS rebinding. The web app signs in with a cookie, which a browser attaches to
9
10
  // any page's request, so a foreign Origin must be refused before authentication reads it.
10
11
  // That is why this is global router middleware (`global: true`): it runs before routing, so
11
12
  // before every route's authentication. As a layer's own `middleware` it would run inside the
12
- // authentication, too late. Whether it also runs before the discovery the Bearer provider
13
- // publishes, global middleware too, is decided where it is provided: last, in routes.ts.
13
+ // authentication, too late.
14
14
  export const requestPolicy = HttpRouter.middleware(
15
15
  Effect.gen(function* () {
16
16
  const hosts = [...loopback, new URL(yield* publicUrl).hostname];
@@ -32,15 +32,14 @@ const implementations = [status, identity, issues, attachments, assistant];
32
32
 
33
33
  // JSON routes at `/api/v1/<action>`, in two layers of one binding, each naming its `actions`.
34
34
  // The quota requires the caller, and a public route has none, so `getStatus` is served by a
35
- // layer without it. The middleware runs inside authentication and before the input is decoded.
35
+ // layer without it.
36
36
  const api = Layer.mergeAll(
37
37
  ActionHttp.layer(Http, implementations, { actions: [GetStatus] }),
38
38
  ActionHttp.layer(Http, implementations, { actions: MemberActions, middleware: [HttpQuota] }),
39
39
  );
40
40
 
41
- // The same actions as MCP tools at `/mcp`: a mixed endpoint. Listing the tools and calling
42
- // `getStatus` need no token; any other request answers the 401 an MCP client signs in on, the
43
- // one opening a session included, since a session may call any tool.
41
+ // The same actions as MCP tools at `/mcp`: a mixed endpoint, serving the public `getStatus`
42
+ // beside the members' tools.
44
43
  const mcp = Layer.unwrap(
45
44
  Effect.map(allowedOrigins, (origins) =>
46
45
  ActionMcp.layerHttp(implementations, {
@@ -85,8 +84,7 @@ const health = HttpRouter.add("GET", "/healthz", HttpServerResponse.text("ok"));
85
84
  // before them. Two things follow from that order.
86
85
  //
87
86
  // - `resolveTenant`, in a `Layer.provide` of its own after the authentication, gives the
88
- // `Tenant` the Bearer verifier reads per request. In one array with `authenticateBearer` it
89
- // would leave `Tenant` owed: an array's members provide to the routes, not to one another.
87
+ // `Tenant` the Bearer verifier reads per request; tenant.ts says why not in one array.
90
88
  // - Global middleware runs in the order it registers, and it registers when its layer is
91
89
  // built. The request policy, provided last, is built first, so it runs before everything:
92
90
  // before CORS, and before the OAuth discovery the Bearer provider registers when it is
@@ -46,7 +46,6 @@ const seed = Effect.gen(function* () {
46
46
 
47
47
  yield* tracker.commentOnIssue({ id: issue.id, text: "Reproduced on 2.4.1." });
48
48
 
49
- // No argument: `{}` is a valid input here, so `limit` takes its default and `status` none.
50
49
  const { issues } = yield* tracker.listIssues();
51
50
 
52
51
  yield* Console.log(`Filed ${issue.id} with attachment ${attachment.id}. Issues:`, issues);
@@ -7,7 +7,7 @@ import { memory } from "./memory.js";
7
7
  import { adminRoutes, builders, publicRoutes, rpc } from "./routes.js";
8
8
 
9
9
  // Entrypoint: `node --import tsx examples/kitchen-sink/src/server.ts`.
10
- // The composition root of the server: two listeners over one set of builders and services.
10
+ // The entrypoint of the server: two listeners over one set of builders and services.
11
11
 
12
12
  // The web app's rpcs over a WebSocket at `/rpc`: Node's HTTP server answers the upgrade. The
13
13
  // protocol is this entrypoint's choice, as the ports are. `rpc` leaves it open, so a test
@@ -37,21 +37,20 @@ const server = Layer.unwrap(
37
37
  });
38
38
 
39
39
  return Layer.mergeAll(
40
- // Two audiences, two listeners. Each `HttpRouter.serve` builds its routes in a layer
41
- // graph of its own, with a router of its own: the public port can only ever reach the
42
- // members' routes, and nothing built inside one is shared with the other. The socket
43
- // is merged into the public port's graph, so it is a route of that router: the request
44
- // policy, global to the router, refuses a foreign Origin's upgrade as it does a request,
45
- // and the rpcs spend the quota the routes spend, built once.
40
+ // Two audiences, two listeners, each with a router of its own: the public port can only
41
+ // ever reach the members' routes. The socket is merged into the public port's routes, so
42
+ // it is a route of that router: the request policy, global to the router, refuses a
43
+ // foreign Origin's upgrade as it does a request, and the rpcs spend the quota the routes
44
+ // spend, built once.
46
45
  HttpRouter.serve(Layer.merge(publicRoutes, socket)).pipe(Layer.provide(listen(publicPort))),
47
46
  HttpRouter.serve(adminRoutes).pipe(Layer.provide(listen(adminPort))),
48
47
  ).pipe(
49
- // Request bodies have no size limit unless the host sets one. This covers every HTTP
50
- // route of both ports, the MCP endpoint included.
48
+ // Request bodies have no size limit unless the host sets one. This covers every route
49
+ // that reads its body whole, on both ports, the MCP endpoint included.
51
50
  Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, limit)),
52
- // So what both ports share is provided outside both, once: the builders, then the
53
- // services the builders, the verifiers and the quota are built from. A workspace an
54
- // operator suspends on one port is thereby refused on the other.
51
+ // What both ports share is provided outside both: the builders, as routes.ts says, then
52
+ // the services the verifiers and the quota are built from, so a workspace an operator
53
+ // suspends on one port is refused on the other.
55
54
  Layer.provide(builders),
56
55
  Layer.provide(memory),
57
56
  );
@@ -15,7 +15,8 @@ import { WorkspaceRegistry } from "./workspaces/registry.js";
15
15
 
16
16
  // Entrypoint: an MCP server a local agent starts as a subprocess, such as
17
17
  // `claude mcp add tracker -- "$PWD/node_modules/.bin/tsx" "$PWD/examples/kitchen-sink/src/stdio.ts"`
18
- // from the repository root: mcp-tools' README says why the paths are full.
18
+ // from the repository root. The host starts the process in a directory it chooses, so `tsx`
19
+ // and the file are named by full path.
19
20
  // It lists the implementations it serves itself, and imports nothing of the HTTP router. They
20
21
  // are those of the `/mcp` endpoint, with its tools and features and the same `authorize`.
21
22
  // stdio is a local surface: it takes no `authentication`, since no token arrives.
@@ -29,7 +30,7 @@ ActionMcp.runStdio([status, identity, issues, attachments], {
29
30
  // started it, from its own login or configuration.
30
31
  Effect.provideService(CurrentMember, members.ada),
31
32
  Effect.provide([NodeStdio.layer, stores, limiter, WorkspaceRegistry.layerMemory]),
32
- // stdout carries the protocol alone, so logs and failures go to stderr. Applied last.
33
+ // stdout carries the protocol alone, so logs and failures go to stderr.
33
34
  ActionCli.logToStderr,
34
35
  NodeRuntime.runMain,
35
36
  );
@@ -9,7 +9,9 @@ import { HttpRouter, HttpServerRequest } from "effect/http";
9
9
  export class Tenant extends Context.Service<Tenant, Option.Option<string>>()("tracker/Tenant") {}
10
10
 
11
11
  // Native router middleware. It provides `Tenant` to whatever it is provided to, per request:
12
- // to the verifier too, when it is provided after the authentication (see routes.ts).
12
+ // to the verifier too, when it is provided after the authentication, in a `Layer.provide` of
13
+ // its own: in one array with the authentication it would leave `Tenant` owed, since an array's
14
+ // layers provide to the routes, not to one another.
13
15
  export const resolveTenant = HttpRouter.middleware<{ provides: Tenant }>()((route) =>
14
16
  Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) =>
15
17
  Effect.provideService(route, Tenant, Option.fromNullishOr(request.headers["x-workspace"])),
@@ -28,7 +28,6 @@ describe("the operators", () => {
28
28
 
29
29
  expect((yield* support.listWorkspaces()).workspaces).toHaveLength(2);
30
30
 
31
- // A key cannot step up, so the refusal names no scope: a plain 403.
32
31
  const refused = yield* Effect.flip(support.suspendWorkspace({ workspace: "acme" }));
33
32
 
34
33
  expect(refused).toBeInstanceOf(Action.Forbidden);
@@ -311,7 +311,7 @@ describe("the assistant, behind a route", () => {
311
311
  ),
312
312
  );
313
313
 
314
- it.effect("runs what its member approves in each turn of the demo model", () =>
314
+ it.effect("runs what its member approves in a later turn of the demo model", () =>
315
315
  Effect.gen(function* () {
316
316
  const ada = yield* asAda;
317
317
 
@@ -39,7 +39,7 @@ const attached = Effect.gen(function* () {
39
39
  });
40
40
 
41
41
  describe("attachments, over HTTP and MCP", () => {
42
- it.effect("sends an image as JSON over HTTP, and as an image block from the tool", () =>
42
+ it.effect("decodes an image over HTTP, and reads it as an image block from the tool", () =>
43
43
  Effect.gen(function* () {
44
44
  const attachment = yield* attached;
45
45
  const http = yield* ActionHttp.client(Http, as("ada", "acme"));
@@ -128,7 +128,6 @@ describe("attachments, over HTTP and MCP", () => {
128
128
 
129
129
  expect(mine.status).toBe(200);
130
130
  expect(Buffer.from(yield* mine.arrayBuffer).equals(screenshot)).toBe(true);
131
- // The route states its own caching; everything else authentication covers is `no-store`.
132
131
  expect(mine.headers["cache-control"]).toBe("private, max-age=31536000, immutable");
133
132
  // An uploaded image opened by its URL is a document on this origin: it gets no scripts.
134
133
  expect([
@@ -68,7 +68,6 @@ describe("a bearer token", () => {
68
68
 
69
69
  const refused = yield* Effect.flip(reader.createIssue({ title: "Crash", body: "" }));
70
70
 
71
- // `toEqual` compares an error's own fields, here `scopes`; never its `message`.
72
71
  expect(refused).toEqual(new Action.Forbidden({ scopes: ["issues:write"] }));
73
72
  expect(refused.message).toBe("Requires issues:write.");
74
73
 
@@ -47,7 +47,6 @@ describe("issues, in process", () => {
47
47
  );
48
48
  yield* client.closeIssue({ id: "ACME-2" }).pipe(as("ada"));
49
49
 
50
- // No argument is the input `{}`: `status` is absent and `limit` its default.
51
50
  const all = yield* client.listIssues().pipe(as("ada"));
52
51
  const closed = yield* client.listIssues({ status: "closed", limit: 20 }).pipe(as("ada"));
53
52
  const newest = yield* client.listIssues({ limit: 1 }).pipe(as("ada"));
@@ -69,7 +68,6 @@ describe("issues, in process", () => {
69
68
 
70
69
  const refused = yield* Effect.flip(create.pipe(as("linus")));
71
70
 
72
- // `toEqual` compares an error's own fields, here `scopes`; never its `message`.
73
71
  expect(refused).toEqual(new Action.Forbidden({ scopes: ["issues:write"] }));
74
72
  expect(refused.message).toBe("Requires issues:write.");
75
73
  }).pipe(Effect.provide(services)),
@@ -103,7 +103,7 @@ describe("the request policy", () => {
103
103
  expect(preflight.headers.get("access-control-allow-headers")).toContain("Mcp-Session-Id");
104
104
  });
105
105
 
106
- it("covers the admin port too, whatever key a request carries", async () => {
106
+ it("covers the admin port too, a valid key included", async () => {
107
107
  const key = { "x-operator-key": "ops-key" };
108
108
  const here = await adminWeb.handler(post("http://localhost/admin/listWorkspaces", key));
109
109
 
@@ -10,9 +10,9 @@ import { AdminHttp, Http } from "../src/Api.js";
10
10
  import { memory } from "../src/memory.js";
11
11
  import { adminRoutes, builders, publicRoutes } from "../src/routes.js";
12
12
 
13
- // The host's surfaces, as routes.ts builds them, answering in memory: no port is opened. Two
14
- // ports are two `Testing.layer`s, each a layer graph of its own, as two servers are. What they
15
- // share is provided above both, as server.ts does: the builders, then the services.
13
+ // The host's surfaces, as routes.ts builds them, answering in memory: no port is opened. Each
14
+ // port is a `Testing.layer` of its own, and what they share is provided above both, as
15
+ // server.ts does.
16
16
  const shared = builders.pipe(Layer.provideMerge(memory));
17
17
 
18
18
  const onPublicPort = Effect.provide(Testing.layer(publicRoutes));
@@ -2,15 +2,9 @@
2
2
 
3
3
  A chat served as Effect RPC over a WebSocket: one connection per page, many calls on it.
4
4
  Members post, read and delete messages in the rooms they belong to; anyone may list the rooms.
5
- Each message on the socket carries its own token and names its room, so a socket has no
6
- identity of its own. A message naming no room is about the default one, `lobby`.
7
-
8
- Start here if a client keeps a connection open and calls often, or if the application already
9
- speaks Effect RPC. For a JSON API or MCP tools, start from [notes-service](../notes-service/).
5
+ Each message on the socket carries its own token and names its room.
10
6
  To serve these same actions as JSON too, see
11
- [ActionRpc.md](../../docs/ActionRpc.md#serve-the-rpcs): kitchen-sink's `src/routes.ts` and
12
- `src/server.ts` merge both.
13
- Actions are unary, so nothing streams: the page reads again, passing the last id it holds.
7
+ [ActionRpc.md](../../docs/ActionRpc.md#serve-the-rpcs).
14
8
 
15
9
  ## Run it
16
10
 
@@ -24,26 +18,9 @@ node --import tsx examples/live-rpc/src/client.ts # CHAT_URL to change, ws:/
24
18
  vp test examples/live-rpc
25
19
  ```
26
20
 
27
- `client.ts` runs the browser's client from Node: two members on one socket, then each failure
28
- a page handles. `browser.ts` is imported by a page, not run: `openChat` for a page that is an
29
- Effect program, `connect(url)` for one that calls with promises. The tokens are fixed names (`ada`,
30
- `grace`) and the store is memory: replace both before deploying anything.
31
-
32
- ## When you copy this
33
-
34
- Four choices here show a feature. Decide each for your application before keeping it:
35
-
36
- - **`listRooms` is public** to show a public rpc beside protected ones. It tells a signed-out
37
- caller every room's name and how many messages it holds; a room appears once someone posts.
38
- - **A message naming no room is about `lobby`**, to show a default in request context. Yours may
39
- refuse instead.
40
- - **The room is a header, not an input**, because `authorize` sees the action and not its input:
41
- the membership rule reads the room from request context. With the room in the input, the
42
- check belongs in the store, never in a helper each handler must remember to call: the store's
43
- method takes the member and refuses, as `MessageStore.remove` in `src/chat/store.ts` takes the
44
- author and refuses with `NotAuthor`.
45
- - **`browser.ts` takes a message already gone as removed**, `MessageNotFound` included, since
46
- that is what the page asked for. Yours may tell the user it was not there.
21
+ `client.ts` runs the browser's client from Node: two members on one socket, then each declared
22
+ error and refusal a page handles. `browser.ts` is imported by a page, not run: `openChat` for a
23
+ page that is an Effect program; `page.ts` wraps it as `connect(url)` for one that calls with promises.
47
24
 
48
25
  ## Layout
49
26
 
@@ -62,7 +39,8 @@ src/
62
39
  ├── Rpc.ts the RPC binding of every action
63
40
  ├── routes.ts the rpcs, authenticated and with their middleware, not yet on a protocol
64
41
  ├── server.ts entrypoint: the rpcs over a WebSocket at /rpc, its messages bounded
65
- ├── browser.ts client module: the connection and the chat, as Effects and as promises
42
+ ├── browser.ts client module: the connection and the chat, as one Effect program
43
+ ├── page.ts the same chat as promises, for a page that is not one
66
44
  └── client.ts entrypoint: that client, run from Node
67
45
  test/
68
46
  ├── chat/ in process, with Action.client
@@ -71,24 +49,6 @@ test/
71
49
  └── server.test.ts the socket itself: a listening port, connect(url), the bound on a message
72
50
  ```
73
51
 
74
- - **The PascalCase files are what a page imports.** `Chat.ts`, `Identity.ts` and `Rpc.ts` import
75
- no server code. `browser.ts` imports them, the library's `ActionRpc`, and `effect`,
76
- `effect/rpc` and `effect/socket`, nothing else, so it bundles for a browser.
77
- - **A request-time service is never a startup service.** `CurrentMember` comes from
78
- authentication and `CurrentRoom` from middleware, both per message. Neither has a Layer, and
79
- no entrypoint provides one: a value provided at startup would serve every caller as one
80
- member in one room.
81
- - **The entrypoint chooses the protocol.** `routes.ts` exports the rpcs without one. `server.ts`
82
- gives them a WebSocket, its only route. `browser.ts` leaves the WebSocket constructor to its
83
- host: a page provides the browser's, `client.ts` provides Node's.
84
- - **The socket belongs to the Layer providing it.** `connection(url)` opens it when it is built
85
- and closes it when it is released: provide it once around the page's program, or keep it in
86
- a `ManagedRuntime`, as `connect(url)` does. The scope `openChat` needs holds only the client.
87
- - **One test file opens a port.** `Testing.layer` answers HTTP requests and cannot upgrade a
88
- WebSocket, so `routes.test.ts` and `browser.test.ts` give the same rpcs Effect's HTTP protocol,
89
- in memory. `server.test.ts` covers what that leaves out: it serves them over a WebSocket on a
90
- port the system picks, calls with `connect(url)`, and sends a message over the server's bound.
91
-
92
52
  ## Where to look
93
53
 
94
54
  | To see | Read |
@@ -96,13 +56,12 @@ test/
96
56
  | An RPC binding naming its authentication descriptor | `src/Rpc.ts` |
97
57
  | The rpcs on Effect's `RpcServer`, before a protocol is chosen | `src/routes.ts` |
98
58
  | Serving them over a WebSocket, speaking JSON | `src/server.ts` |
99
- | The same rpcs over Effect's HTTP protocol, for a test | `test/routes.test.ts` |
100
59
  | A verifier that runs for every message, not once per connection | `src/identity/verifier.ts` |
101
60
  | Native `RpcMiddleware` providing a per-request service, with a default | `src/chat/room.ts` |
102
61
  | Handlers reading that service, and the rule that reads it | `src/chat/handlers.ts`, `src/chat/authorize.ts` |
103
62
  | A public rpc beside protected ones | `src/chat/Chat.ts` |
104
63
  | A socket client sending its token and room on each message | `src/browser.ts` |
105
- | Who owns the socket, and how a promise page keeps it open | `src/browser.ts` |
64
+ | Who owns the socket, and how a promise page keeps it open | `src/browser.ts`, `src/page.ts` |
106
65
  | A declared error and the built-in refusals handled by tag | `src/browser.ts` |
107
66
  | Running that client from Node, two callers on one socket | `src/client.ts` |
108
67
  | Testing handlers and authorization with no transport | `test/chat/chat.test.ts` |