@mercury-fw/channel-http 0.4.0 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a7f2f13: A user directory decides who people are and what they may make Mercury do.
8
+
9
+ - An app can declare a `directory` in `mercury.config.ts`. Given whoever a channel says is talking, it returns the person with their roles. The person's key is `<directory name>:<id>`, so one person reached from several channels keeps one private area, one memory and one set of confirmations.
10
+ - With a directory the instance is closed. Someone it doesn't know gets a refusal and nothing runs, unless `access.unknown` is `"allow"`; `access.unknownMessage` sets what they're told. A directory that can't be reached, or fails to load, refuses everyone but the terminal. Answers are cached for five minutes, so a revoked role stops counting within that time.
11
+ - `mercury.act-as-self` (every plugin) or `mercury.act-as-self.<plugin>` (one) lets a person make Mercury act with its own identity.
12
+ - A plugin acting as Mercury, such as atlassian-admin, is offered only to those people, and on the terminal.
13
+ - A plugin acting as the person also gives them each tool a second time, as `<tool>AsMercury`.
14
+ - Every call made as Mercury for someone is logged with who asked.
15
+ - `access.plugins` restricts a plugin to some roles. Prompts, skills and the manifest follow what each person is offered.
16
+ - New package `@mercury-fw/directory-static`: people, their identities on each channel and their roles, listed in `DIRECTORY_STATIC_PEOPLE`.
17
+ - The channel contract is at version 5:
18
+ - `ChannelRuntimeContext.admit` says whether the core talks to a caller;
19
+ - the reads are per person and async, the manifest included;
20
+ - `DirectoryPlugin` is the directory contract;
21
+ - `Principal` no longer carries `roles`.
22
+ - `channel-http` answers `403` to a caller the directory doesn't know and `503` when it can't be reached, on every route.
23
+ - `auth-static` refuses `roles` in `AUTH_STATIC_TOKENS`: roles come from the directory.
24
+
25
+ ## 0.5.0
26
+
27
+ ### Minor Changes
28
+
29
+ - 0739f77: A plugin acts as the person Mercury is talking to, who logs in to the service through Mercury.
30
+
31
+ - Plugin contract version 4: a plugin declares `actsAs: "person" | "mercury"`, and one acting as the person contributes a `login`; its tools get the turn's `person` and `requireLogin` in their context.
32
+ - A person is offered only the plugins acting as the person: their prompt fragments, skills and tools. A plugin acting as Mercury, or declaring nothing, is offered on the terminal only until people can be allowed to make Mercury act as itself; the startup log names it.
33
+ - `createCliTool` adds `--user` with the person's id to every command, staged confirmations included, and when the CLI exits with code 3 (not logged in) returns their login instead. `createCliPersonLogin` builds a plugin's `login` on a CLI's two-step remote login. A failed `runCli` carries `exitCode`.
34
+ - Channel contract version 4: channels get `logins` (`accept` a callback URL, `complete` a login) and `detectLoginRequired`.
35
+ - The HTTP channel, with `HTTP_SURFACE_PUBLIC_URL` set, streams a `login` event with the link to open and takes the person back at `GET /login/callback`, public and protected by the single-use state.
36
+ - `mercury.cliCredentials` can declare `userSetup`, run by `mfw credentials setup <plugin> --user-app`: the app people log in through, set up without logging anyone in.
37
+ - plugin-jira and plugin-bitbucket act as the person, on CLIs 2.3.0: set up their people's app with `--user-app` and register `<HTTP_SURFACE_PUBLIC_URL>/login/callback` on it. Jira's skill now says `currentUser()` is the person.
38
+ - plugin-atlassian-admin acts as Mercury, on CLI 0.2.0, whose `init` asks for the key without echoing it: until permissions exist it's available on the terminal only.
39
+ - A third-party plugin needs `apiVersion: 4` to load, and `actsAs: "person"` with a `login` to be offered to people.
40
+
3
41
  ## 0.4.0
4
42
 
5
43
  ### Minor Changes
package/README.md CHANGED
@@ -20,12 +20,13 @@ It needs an auth provider next to it: [`@mercury-fw/auth-oidc`](../../auth/auth-
20
20
  |---|---|
21
21
  | `HTTP_SURFACE_PORT` | The port (default `4100`). |
22
22
  | `HTTP_SURFACE_CORS_ORIGIN` | The origin a browser UI calls from (default `*`). |
23
+ | `HTTP_SURFACE_PUBLIC_URL` | The address people's browsers reach the surface at (e.g. `https://mercury.example.com`). With it, people log in here to the services plugins act on as them (see [`GET /login/callback`](#get-logincallback)); without it, nobody can. |
23
24
 
24
25
  ## API
25
26
 
26
27
  It's active whenever it's declared in `mercury.config.ts`'s `channels` with an auth provider; remove it there to turn the surface off. It listens on `HTTP_SURFACE_PORT` (default `4100`). Base URL `http://<host>:<port>`.
27
28
 
28
- **Authentication.** Every route except `GET /openapi.yaml` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth provider decides who the token belongs to. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs.
29
+ **Authentication.** Every route except `GET /openapi.yaml`, `GET /login/callback` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth provider decides who the token belongs to. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs. Then the core decides whether it talks to that person at all: someone the app's directory doesn't know gets `403` (unless the app lets unknown people in), and `503` when the directory can't be reached, both with `{ ok: false, error, reason }`, where `error` is what to tell them.
29
30
 
30
31
  **Conversations belong to whoever opened them.** The session key is `<caller id>:<conversationId>`, so the same `conversationId` sent by someone else is a conversation of their own, and a confirmation token staged in your conversation can't be confirmed from theirs. Each authenticated caller also gets their own episodic memory and wiki area, as on Google Chat.
31
32
 
@@ -60,6 +61,7 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
60
61
  | `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
61
62
  | `text` | `{ chunk }` | an answer-text delta |
62
63
  | `pending` | `{ command, token }` | a confirm-required action was staged; send `token` back to confirm it — as a later `/turn` `text`, or via `POST /confirm` |
64
+ | `login` | `{ service, url }` | the caller isn't logged in to `service` yet, which a plugin acts on as them: show them `url` to open (the model never gets it) |
63
65
  | `final` | `{ text }` | the complete answer (also emitted for a token confirmation, with no model turn) |
64
66
  | `error` | `{ message }` | the turn failed mid-stream |
65
67
 
@@ -70,13 +72,19 @@ curl -N -X POST http://localhost:4100/turn \
70
72
  -d '{"text":"Quante issue nel progetto KAN?","conversationId":"c1"}'
71
73
  ```
72
74
 
75
+ ## `GET /login/callback`
76
+
77
+ Where a person comes back after logging in to a service a plugin acts on as them (Jira, Bitbucket): the `login` event's `url` sends them to the service, and the service sends their browser here with `code` and `state`. Mounted only with `HTTP_SURFACE_PUBLIC_URL`, at `<HTTP_SURFACE_PUBLIC_URL>/login/callback`, which is the URL to register as a callback on the service's app (the Atlassian 3LO app, the Bitbucket consumer people log in through). No bearer token: the browser has none here, and the `state` Mercury issued for that person, single-use and valid for ten minutes, is what ties the request to their login. Answers with a plain-text page saying how it went.
78
+
79
+ Whoever opens the link and accepts decides which account gets linked: a link belongs to the person it was shown to, and someone else accepting it would link their own account to that person's Mercury identity. A UI should say so next to the link. Checking the linked account against the person is [#179](https://github.com/lucabro81/mercury-fw/issues/179).
80
+
73
81
  ## `POST /confirm`
74
82
 
75
83
  Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation, `{ ok: true, resolved: false }` otherwise. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
76
84
 
77
85
  ## Read-only introspection
78
86
 
79
- All `GET`, all JSON, all reporting state already held in-process. They need an authenticated caller and show only what belongs to them: their conversations, confirmations, tool calls and memory, and the wiki as they see it (the team's `curated/` plus their own notes as `personal/`). Someone else's data doesn't exist as far as a caller can tell; only `/manifest` and `/health` are the same for everyone.
87
+ All `GET`, all JSON, all reporting state already held in-process. They need an authenticated caller and show only what belongs to them: their conversations, confirmations, tool calls and memory, and the wiki as they see it (the team's `curated/` plus their own notes as `personal/`). Someone else's data doesn't exist as far as a caller can tell. `/manifest` lists what the caller is offered (a plugin acting as Mercury only for someone allowed to use it), and only `/health` is the same for everyone.
80
88
 
81
89
  | endpoint | `data` on success |
82
90
  |---|---|
@@ -10,7 +10,7 @@
10
10
  * (that stays with Google Chat). `stop` closes the socket on shutdown. Confirm is
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
- import { type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
13
+ import { type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
14
  import type { Authenticate, Provider, ChannelHostReads } from "@mercury-fw/channel-types";
15
15
  export type HttpProviderDeps = {
16
16
  port: number;
@@ -21,8 +21,17 @@ export type HttpProviderDeps = {
21
21
  reads?: ChannelHostReads;
22
22
  /** Who is calling, from the app's auth provider. */
23
23
  authenticate: Authenticate;
24
+ /** Whether the core talks to them, from the core. */
25
+ admit?: AdmitFn;
24
26
  /** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
25
27
  corsOrigin?: string;
28
+ /** People's logins, when the surface has a public URL to be sent back to:
29
+ * the callback is offered (`accept`) once the server listens. */
30
+ logins?: {
31
+ callbackUrl: string;
32
+ accept: (callbackUrl: string) => void;
33
+ complete: CompleteLoginFn;
34
+ };
26
35
  };
27
36
  export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
28
37
  stop(): Promise<void>;
@@ -12,22 +12,28 @@
12
12
  * is unit-tested without standing up a socket. The read-only routes (4b) mount
13
13
  * alongside `/turn` here, driven by the injected `reads` getters.
14
14
  *
15
- * Every route but the OpenAPI document and the CORS preflights asks the
16
- * injected `authenticate` (the auth provider the app declares) who is calling
15
+ * `/login/callback` is where a person's browser comes back after logging in to
16
+ * a service a plugin acts on as them; the single-use state is its credential.
17
+ *
18
+ * Every route but the OpenAPI document, the login callback and the CORS
19
+ * preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
17
20
  * before anything runs, and answers 401 when nobody is. A conversation belongs
18
21
  * to whoever opened it: the session key is `<principal.id>:<conversationId>`.
19
22
  * Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
20
23
  * package never imports the app.
21
24
  */
22
- import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type Principal } from "@mercury-fw/channel-types";
25
+ import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type LoginOutcome, type Principal, type Admission } from "@mercury-fw/channel-types";
23
26
  /** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
24
27
  export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
25
28
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
26
29
  export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
30
+ /** Whether the core talks to an authenticated caller at all, injected by the core (`ctx.admit`); everyone when absent. */
31
+ export type AdmitFn = (principal: Principal) => Promise<Admission>;
27
32
  export type TurnRequestDeps = {
28
33
  handleTurn: HandleTurn;
29
34
  /** Who is calling, injected from the app's auth provider; `null` = refused. */
30
35
  authenticate: Authenticate;
36
+ admit?: AdmitFn;
31
37
  /** Bare-token interception before the model, injected by the core. */
32
38
  confirm: ConfirmFn;
33
39
  /** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
@@ -56,6 +62,7 @@ export declare function openApiResponse(corsOrigin?: string): Response;
56
62
  export declare function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response>;
57
63
  export type ConfirmRequestDeps = {
58
64
  authenticate: Authenticate;
65
+ admit?: AdmitFn;
59
66
  resolveConfirmation: ResolveConfirmationFn;
60
67
  corsOrigin?: string;
61
68
  };
@@ -72,6 +79,18 @@ export type ConfirmRequestDeps = {
72
79
  * even token-shaped). Never touches the model.
73
80
  */
74
81
  export declare function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response>;
82
+ /** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
83
+ export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
84
+ /**
85
+ * `GET /login/callback`: where the provider sends a person back after they log
86
+ * in, with `code` and `state` (or `error`, when they refused). Public: the
87
+ * person's browser carries no token here, and the single-use `state` the core
88
+ * issued for them is what ties the request to their login. Answers with a
89
+ * plain page telling them how it went.
90
+ */
91
+ export declare function handleLoginCallback(req: Request, deps: {
92
+ complete: CompleteLoginFn;
93
+ }): Promise<Response>;
75
94
  /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
76
95
  type ReadRoute = {
77
96
  GET: (req: Request) => Response | Promise<Response>;
@@ -88,12 +107,14 @@ type ReadRoute = {
88
107
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
89
108
  * same for a 400.
90
109
  */
91
- export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string): Record<string, ReadRoute>;
110
+ export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string, admit?: AdmitFn): Record<string, ReadRoute>;
92
111
  export type HttpServerDeps = TurnRequestDeps & {
93
112
  port: number;
94
113
  reads?: ChannelHostReads;
95
114
  /** The `/confirm` endpoint's structured resolver, injected by the core. */
96
115
  resolveConfirmation: ResolveConfirmationFn;
116
+ /** Finishes people's logins; `/login/callback` is mounted only with it. */
117
+ completeLogin?: CompleteLoginFn;
97
118
  };
98
119
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
99
120
  * when `reads` is supplied. */
package/dist/index.d.ts CHANGED
@@ -6,11 +6,13 @@
6
6
  * is no runtime inert condition (unlike Google Chat's subscription): if it's
7
7
  * declared, it starts.
8
8
  *
9
- * `build` reads its port and CORS origin from `env` and takes the confirm
9
+ * `build` reads its port, CORS origin and public URL from `env` and takes the confirm
10
10
  * capabilities, the in-process reads and the app's auth provider from the
11
11
  * injected context (dependency inversion). Those are optional on the contract
12
12
  * but required here, so a context missing them throws — the loader isolates it
13
- * fail-soft, and without an auth provider the surface never opens.
13
+ * fail-soft, and without an auth provider the surface never opens. With a
14
+ * public URL (`HTTP_SURFACE_PUBLIC_URL`) it also takes people's logins: it
15
+ * mounts `<url>/login/callback` and, once it's listening, offers it to the core.
14
16
  */
15
17
  import type { ChannelPlugin } from "@mercury-fw/channel-types";
16
18
  export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
package/http-provider.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * (that stays with Google Chat). `stop` closes the socket on shutdown. Confirm is
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
- import { startHttpServer, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
13
+ import { startHttpServer, type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
14
  import type { Authenticate, Provider, HandleTurn, ChannelHostReads } from "@mercury-fw/channel-types";
15
15
 
16
16
  export type HttpProviderDeps = {
@@ -22,8 +22,13 @@ export type HttpProviderDeps = {
22
22
  reads?: ChannelHostReads;
23
23
  /** Who is calling, from the app's auth provider. */
24
24
  authenticate: Authenticate;
25
+ /** Whether the core talks to them, from the core. */
26
+ admit?: AdmitFn;
25
27
  /** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
26
28
  corsOrigin?: string;
29
+ /** People's logins, when the surface has a public URL to be sent back to:
30
+ * the callback is offered (`accept`) once the server listens. */
31
+ logins?: { callbackUrl: string; accept: (callbackUrl: string) => void; complete: CompleteLoginFn };
27
32
  };
28
33
 
29
34
  export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
@@ -37,8 +42,11 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
37
42
  resolveConfirmation: deps.resolveConfirmation,
38
43
  reads: deps.reads,
39
44
  authenticate: deps.authenticate,
45
+ ...(deps.admit === undefined ? {} : { admit: deps.admit }),
40
46
  corsOrigin: deps.corsOrigin,
47
+ completeLogin: deps.logins?.complete,
41
48
  });
49
+ deps.logins?.accept(deps.logins.callbackUrl);
42
50
  },
43
51
  async notify(): Promise<{ sessionKey: string }> {
44
52
  // No proactive push channel over HTTP (request/response only).
package/http-server.ts CHANGED
@@ -12,14 +12,18 @@
12
12
  * is unit-tested without standing up a socket. The read-only routes (4b) mount
13
13
  * alongside `/turn` here, driven by the injected `reads` getters.
14
14
  *
15
- * Every route but the OpenAPI document and the CORS preflights asks the
16
- * injected `authenticate` (the auth provider the app declares) who is calling
15
+ * `/login/callback` is where a person's browser comes back after logging in to
16
+ * a service a plugin acts on as them; the single-use state is its credential.
17
+ *
18
+ * Every route but the OpenAPI document, the login callback and the CORS
19
+ * preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
17
20
  * before anything runs, and answers 401 when nobody is. A conversation belongs
18
21
  * to whoever opened it: the session key is `<principal.id>:<conversationId>`.
19
22
  * Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
20
23
  * package never imports the app.
21
24
  */
22
25
  import {
26
+ detectLoginRequired,
23
27
  detectPendingConfirmation,
24
28
  PENDING_CONFIRMATION_NOTE,
25
29
  type HandleTurn,
@@ -27,7 +31,9 @@ import {
27
31
  type Authenticate,
28
32
  type ChannelHostReads,
29
33
  type ConfirmOutcome,
34
+ type LoginOutcome,
30
35
  type Principal,
36
+ type Admission,
31
37
  } from "@mercury-fw/channel-types";
32
38
 
33
39
  /**
@@ -48,10 +54,14 @@ export type ConfirmFn = (token: string, sessionKey: string, principal: Principal
48
54
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
49
55
  export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
50
56
 
57
+ /** Whether the core talks to an authenticated caller at all, injected by the core (`ctx.admit`); everyone when absent. */
58
+ export type AdmitFn = (principal: Principal) => Promise<Admission>;
59
+
51
60
  export type TurnRequestDeps = {
52
61
  handleTurn: HandleTurn;
53
62
  /** Who is calling, injected from the app's auth provider; `null` = refused. */
54
63
  authenticate: Authenticate;
64
+ admit?: AdmitFn;
55
65
  /** Bare-token interception before the model, injected by the core. */
56
66
  confirm: ConfirmFn;
57
67
  /** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
@@ -90,6 +100,22 @@ function unauthorized(origin: string): Response {
90
100
  );
91
101
  }
92
102
 
103
+ /** The core's refusal of an authenticated caller: 403 for someone it doesn't know, 503 when it couldn't tell, with what to tell them. */
104
+ function notAdmitted(admission: Exclude<Admission, { ok: true }>, origin: string): Response {
105
+ return Response.json(
106
+ { ok: false, error: admission.message, reason: admission.reason },
107
+ { status: admission.reason === "unknown" ? 403 : 503, headers: corsHeaders(origin) },
108
+ );
109
+ }
110
+
111
+ /** The caller of `req`, authenticated and admitted, or the response refusing them. */
112
+ async function caller(req: Request, deps: { authenticate: Authenticate; admit?: AdmitFn }, origin: string): Promise<Principal | Response> {
113
+ const principal = await deps.authenticate(req);
114
+ if (principal === null) return unauthorized(origin);
115
+ const admission = deps.admit ? await deps.admit(principal) : { ok: true as const };
116
+ return admission.ok ? principal : notAdmitted(admission, origin);
117
+ }
118
+
93
119
  /** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
94
120
  function preflight(origin: string): Response {
95
121
  return new Response(null, { status: 204, headers: corsHeaders(origin) });
@@ -122,8 +148,8 @@ export function openApiResponse(corsOrigin = "*"): Response {
122
148
  export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response> {
123
149
  const origin = deps.corsOrigin ?? "*";
124
150
  const cors = corsHeaders(origin);
125
- const principal = await deps.authenticate(req);
126
- if (principal === null) return unauthorized(origin);
151
+ const principal = await caller(req, deps, origin);
152
+ if (principal instanceof Response) return principal;
127
153
  let body: { text?: unknown; conversationId?: unknown };
128
154
  try {
129
155
  body = (await req.json()) as typeof body;
@@ -188,6 +214,8 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
188
214
  onStep: (step) => {
189
215
  const pending = detectPendingConfirmation(step);
190
216
  if (pending) send("pending", { command: pending.summary, token: pending.token });
217
+ const login = detectLoginRequired(step);
218
+ if (login) send("login", { service: login.service, url: login.url });
191
219
  },
192
220
  onUsage: () => {},
193
221
  finalize: async (finalText) => send("final", { text: finalText }),
@@ -229,6 +257,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
229
257
 
230
258
  export type ConfirmRequestDeps = {
231
259
  authenticate: Authenticate;
260
+ admit?: AdmitFn;
232
261
  resolveConfirmation: ResolveConfirmationFn;
233
262
  corsOrigin?: string;
234
263
  };
@@ -248,8 +277,8 @@ export type ConfirmRequestDeps = {
248
277
  export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response> {
249
278
  const origin = deps.corsOrigin ?? "*";
250
279
  const cors = corsHeaders(origin);
251
- const principal = await deps.authenticate(req);
252
- if (principal === null) return unauthorized(origin);
280
+ const principal = await caller(req, deps, origin);
281
+ if (principal instanceof Response) return principal;
253
282
  let body: { token?: unknown; conversationId?: unknown };
254
283
  try {
255
284
  body = (await req.json()) as typeof body;
@@ -276,6 +305,46 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
276
305
  }
277
306
  }
278
307
 
308
+ /** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
309
+ export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
310
+
311
+ /** A plain-text page for the person's browser, at the end of their login. */
312
+ function loginPage(text: string, status: number): Response {
313
+ // The URL carried the code and the state: neither kept nor passed on.
314
+ return new Response(text, {
315
+ status,
316
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" },
317
+ });
318
+ }
319
+
320
+ /**
321
+ * `GET /login/callback`: where the provider sends a person back after they log
322
+ * in, with `code` and `state` (or `error`, when they refused). Public: the
323
+ * person's browser carries no token here, and the single-use `state` the core
324
+ * issued for them is what ties the request to their login. Answers with a
325
+ * plain page telling them how it went.
326
+ */
327
+ export async function handleLoginCallback(req: Request, deps: { complete: CompleteLoginFn }): Promise<Response> {
328
+ const params = new URL(req.url).searchParams;
329
+ const error = params.get("error");
330
+ if (error !== null) {
331
+ const reason = error.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64);
332
+ return loginPage(`The login didn't happen (${reason}). Ask Mercury again for a new link.`, 400);
333
+ }
334
+ const code = params.get("code");
335
+ const state = params.get("state");
336
+ if (!code || !state) return loginPage("The login link came back without a code or a state. Ask Mercury again for a new link.", 400);
337
+ const outcome = await deps.complete(state, code);
338
+ return outcome.ok
339
+ ? loginPage(`You're logged in to ${outcome.service}. Go back to the conversation and ask again.`, 200)
340
+ : loginPage(outcome.error, 400);
341
+ }
342
+
343
+ /** The login callback route, mounted when the server can complete logins. */
344
+ function loginRoutes(complete: CompleteLoginFn): Record<string, { GET: (req: Request) => Promise<Response> }> {
345
+ return { "/login/callback": { GET: (req) => handleLoginCallback(req, { complete }) } };
346
+ }
347
+
279
348
  /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
280
349
  type ReadRoute = {
281
350
  GET: (req: Request) => Response | Promise<Response>;
@@ -293,7 +362,12 @@ type ReadRoute = {
293
362
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
294
363
  * same for a 400.
295
364
  */
296
- export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin = "*"): Record<string, ReadRoute> {
365
+ export function readRoutes(
366
+ reads: ChannelHostReads,
367
+ authenticate: Authenticate,
368
+ corsOrigin = "*",
369
+ admit?: AdmitFn,
370
+ ): Record<string, ReadRoute> {
297
371
  const cors = corsHeaders(corsOrigin);
298
372
  const json = (payload: object, status = 200): Response =>
299
373
  Response.json(payload, { status, headers: cors });
@@ -302,14 +376,14 @@ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate,
302
376
  const options = () => preflight(corsOrigin);
303
377
  const route = (GET: (req: Request, principal: Principal) => Response | Promise<Response>): ReadRoute => ({
304
378
  GET: async (req) => {
305
- const principal = await authenticate(req);
306
- return principal === null ? unauthorized(corsOrigin) : GET(req, principal);
379
+ const principal = await caller(req, { authenticate, ...(admit === undefined ? {} : { admit }) }, corsOrigin);
380
+ return principal instanceof Response ? principal : GET(req, principal);
307
381
  },
308
382
  OPTIONS: options,
309
383
  });
310
384
  return {
311
- "/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
312
- "/confirmations": route((_req, principal) => json({ ok: true, pending: reads.pendingConfirmations(principal) })),
385
+ "/manifest": route(async (_req, principal) => json({ ok: true, manifest: await reads.manifest(principal) })),
386
+ "/confirmations": route(async (_req, principal) => json({ ok: true, pending: await reads.pendingConfirmations(principal) })),
313
387
  "/conversation": route(async (req, principal) => {
314
388
  const url = new URL(req.url);
315
389
  const id = url.searchParams.get("id");
@@ -332,7 +406,7 @@ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate,
332
406
  .map((c) => ({ conversationId: c.sessionKey.slice(prefix.length), lastTimestamp: c.lastTimestamp, preview: c.preview })),
333
407
  });
334
408
  }),
335
- "/tool-log": route((_req, principal) => json({ ok: true, entries: reads.toolLog(principal) })),
409
+ "/tool-log": route(async (_req, principal) => json({ ok: true, entries: await reads.toolLog(principal) })),
336
410
  "/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
337
411
  "/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
338
412
  "/wiki/read": route(async (req, principal) => {
@@ -363,6 +437,8 @@ export type HttpServerDeps = TurnRequestDeps & {
363
437
  reads?: ChannelHostReads;
364
438
  /** The `/confirm` endpoint's structured resolver, injected by the core. */
365
439
  resolveConfirmation: ResolveConfirmationFn;
440
+ /** Finishes people's logins; `/login/callback` is mounted only with it. */
441
+ completeLogin?: CompleteLoginFn;
366
442
  };
367
443
 
368
444
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
@@ -384,11 +460,17 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
384
460
  "/turn": { POST: (req) => handleTurnRequest(req, deps), OPTIONS: () => preflight(origin) },
385
461
  "/confirm": {
386
462
  POST: (req) =>
387
- handleConfirmRequest(req, { authenticate: deps.authenticate, resolveConfirmation: deps.resolveConfirmation, corsOrigin: origin }),
463
+ handleConfirmRequest(req, {
464
+ authenticate: deps.authenticate,
465
+ ...(deps.admit === undefined ? {} : { admit: deps.admit }),
466
+ resolveConfirmation: deps.resolveConfirmation,
467
+ corsOrigin: origin,
468
+ }),
388
469
  OPTIONS: () => preflight(origin),
389
470
  },
390
471
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
391
- ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin) : {}),
472
+ ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
473
+ ...(deps.completeLogin ? loginRoutes(deps.completeLogin) : {}),
392
474
  },
393
475
  error: (err) =>
394
476
  Response.json(
package/index.ts CHANGED
@@ -6,11 +6,13 @@
6
6
  * is no runtime inert condition (unlike Google Chat's subscription): if it's
7
7
  * declared, it starts.
8
8
  *
9
- * `build` reads its port and CORS origin from `env` and takes the confirm
9
+ * `build` reads its port, CORS origin and public URL from `env` and takes the confirm
10
10
  * capabilities, the in-process reads and the app's auth provider from the
11
11
  * injected context (dependency inversion). Those are optional on the contract
12
12
  * but required here, so a context missing them throws — the loader isolates it
13
- * fail-soft, and without an auth provider the surface never opens.
13
+ * fail-soft, and without an auth provider the surface never opens. With a
14
+ * public URL (`HTTP_SURFACE_PUBLIC_URL`) it also takes people's logins: it
15
+ * mounts `<url>/login/callback` and, once it's listening, offers it to the core.
14
16
  */
15
17
  import type { ChannelPlugin } from "@mercury-fw/channel-types";
16
18
  import { createHttpProvider } from "./http-provider.ts";
@@ -20,7 +22,7 @@ export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
20
22
  export const httpChannel: ChannelPlugin = {
21
23
  // The contract this channel is written for, as a literal: importing
22
24
  // CHANNEL_API_VERSION would report whichever contract is installed.
23
- apiVersion: 3,
25
+ apiVersion: 5,
24
26
  name: "http",
25
27
  build: (ctx) => {
26
28
  if (!ctx.resolveConfirmation || !ctx.reads) {
@@ -29,6 +31,10 @@ export const httpChannel: ChannelPlugin = {
29
31
  if (!ctx.authenticate) {
30
32
  throw new Error("http channel requires an auth provider (auth in mercury.config.ts)");
31
33
  }
34
+ // The provider sends a person back here after they log in, so it needs
35
+ // the address their browser reaches the surface at.
36
+ const publicUrl = ctx.env.HTTP_SURFACE_PUBLIC_URL?.trim().replace(/\/+$/, "");
37
+ const logins = publicUrl ? ctx.logins : undefined;
32
38
  return createHttpProvider({
33
39
  port: Number(ctx.env.HTTP_SURFACE_PORT ?? "4100"),
34
40
  corsOrigin: ctx.env.HTTP_SURFACE_CORS_ORIGIN ?? "*",
@@ -36,6 +42,8 @@ export const httpChannel: ChannelPlugin = {
36
42
  resolveConfirmation: ctx.resolveConfirmation,
37
43
  reads: ctx.reads,
38
44
  authenticate: ctx.authenticate,
45
+ admit: ctx.admit,
46
+ ...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
39
47
  });
40
48
  },
41
49
  };
package/openapi.yaml CHANGED
@@ -20,14 +20,20 @@ info:
20
20
  missing or refused one gets `401` with `WWW-Authenticate: Bearer`, before
21
21
  anything runs.
22
22
 
23
+ Then the core decides whether it talks to that person at all: someone the
24
+ app's directory doesn't know gets `403` (unless the app lets unknown people
25
+ in), and `503` when the directory can't be reached. Both carry
26
+ `{ ok: false, error, reason }`, `error` being what to tell them.
27
+
23
28
 
24
29
  ## What a caller sees
25
30
 
26
31
  Every read is scoped to the caller: their own conversations, pending
27
32
  confirmations, tool calls and memory, and the wiki as they see it, the
28
33
  team's common area (`curated/`) plus their own notes (`personal/`).
29
- Someone else's data doesn't exist as far as a caller can tell. Only
30
- `/manifest` and `/health` are the same for everyone.
34
+ Someone else's data doesn't exist as far as a caller can tell.
35
+ `/manifest` lists what the caller is offered, and only `/health` is the
36
+ same for everyone.
31
37
 
32
38
 
33
39
  ## Conversations
@@ -82,12 +88,18 @@ paths:
82
88
  responses:
83
89
  "401":
84
90
  $ref: "#/components/responses/Unauthorized"
91
+ "403":
92
+ $ref: "#/components/responses/NotAdmitted"
93
+ "503":
94
+ $ref: "#/components/responses/NotAdmitted"
85
95
  "200":
86
96
  description: >
87
97
  An SSE stream. Each event is one of the types in `TurnEvent`:
88
98
  `reasoning`/`reasoning_end`, `text` (incremental deltas), `tool`/
89
99
  `tool_finish` (status of a tool call), `pending` (a staged
90
- confirm-required action, with its command and token), `final` (the
100
+ confirm-required action, with its command and token), `login` (a
101
+ service the caller has to log in to first, with the link to open),
102
+ `final` (the
91
103
  authoritative complete answer), and `error`.
92
104
  content:
93
105
  text/event-stream:
@@ -115,6 +127,10 @@ paths:
115
127
  responses:
116
128
  "401":
117
129
  $ref: "#/components/responses/Unauthorized"
130
+ "403":
131
+ $ref: "#/components/responses/NotAdmitted"
132
+ "503":
133
+ $ref: "#/components/responses/NotAdmitted"
118
134
  "200":
119
135
  description: >
120
136
  `resolved: true` (with the reply `text`) when the token was a pending
@@ -156,6 +172,10 @@ paths:
156
172
  responses:
157
173
  "401":
158
174
  $ref: "#/components/responses/Unauthorized"
175
+ "403":
176
+ $ref: "#/components/responses/NotAdmitted"
177
+ "503":
178
+ $ref: "#/components/responses/NotAdmitted"
159
179
  "200":
160
180
  description: The conversation's messages in chronological order; none when it isn't one of the caller's.
161
181
  content:
@@ -181,6 +201,10 @@ paths:
181
201
  responses:
182
202
  "401":
183
203
  $ref: "#/components/responses/Unauthorized"
204
+ "403":
205
+ $ref: "#/components/responses/NotAdmitted"
206
+ "503":
207
+ $ref: "#/components/responses/NotAdmitted"
184
208
  "200":
185
209
  description: The conversation list.
186
210
  content:
@@ -193,6 +217,10 @@ paths:
193
217
  responses:
194
218
  "401":
195
219
  $ref: "#/components/responses/Unauthorized"
220
+ "403":
221
+ $ref: "#/components/responses/NotAdmitted"
222
+ "503":
223
+ $ref: "#/components/responses/NotAdmitted"
196
224
  "200":
197
225
  description: Health snapshot.
198
226
  content:
@@ -201,10 +229,14 @@ paths:
201
229
  type: object
202
230
  /manifest:
203
231
  get:
204
- summary: The loaded plugins, tools and skills
232
+ summary: The plugins, tools and skills the caller is offered
205
233
  responses:
206
234
  "401":
207
235
  $ref: "#/components/responses/Unauthorized"
236
+ "403":
237
+ $ref: "#/components/responses/NotAdmitted"
238
+ "503":
239
+ $ref: "#/components/responses/NotAdmitted"
208
240
  "200":
209
241
  description: Plugin manifest.
210
242
  content:
@@ -217,6 +249,10 @@ paths:
217
249
  responses:
218
250
  "401":
219
251
  $ref: "#/components/responses/Unauthorized"
252
+ "403":
253
+ $ref: "#/components/responses/NotAdmitted"
254
+ "503":
255
+ $ref: "#/components/responses/NotAdmitted"
220
256
  "200":
221
257
  description: Pending confirmations.
222
258
  content:
@@ -229,6 +265,10 @@ paths:
229
265
  responses:
230
266
  "401":
231
267
  $ref: "#/components/responses/Unauthorized"
268
+ "403":
269
+ $ref: "#/components/responses/NotAdmitted"
270
+ "503":
271
+ $ref: "#/components/responses/NotAdmitted"
232
272
  "200":
233
273
  description: Tool log.
234
274
  content:
@@ -241,6 +281,10 @@ paths:
241
281
  responses:
242
282
  "401":
243
283
  $ref: "#/components/responses/Unauthorized"
284
+ "403":
285
+ $ref: "#/components/responses/NotAdmitted"
286
+ "503":
287
+ $ref: "#/components/responses/NotAdmitted"
244
288
  "200":
245
289
  description: File list.
246
290
  content:
@@ -260,6 +304,10 @@ paths:
260
304
  responses:
261
305
  "401":
262
306
  $ref: "#/components/responses/Unauthorized"
307
+ "403":
308
+ $ref: "#/components/responses/NotAdmitted"
309
+ "503":
310
+ $ref: "#/components/responses/NotAdmitted"
263
311
  "200":
264
312
  description: File content.
265
313
  content:
@@ -291,6 +339,10 @@ paths:
291
339
  responses:
292
340
  "401":
293
341
  $ref: "#/components/responses/Unauthorized"
342
+ "403":
343
+ $ref: "#/components/responses/NotAdmitted"
344
+ "503":
345
+ $ref: "#/components/responses/NotAdmitted"
294
346
  "200":
295
347
  description: Matches.
296
348
  content:
@@ -327,6 +379,10 @@ paths:
327
379
  responses:
328
380
  "401":
329
381
  $ref: "#/components/responses/Unauthorized"
382
+ "403":
383
+ $ref: "#/components/responses/NotAdmitted"
384
+ "503":
385
+ $ref: "#/components/responses/NotAdmitted"
330
386
  "200":
331
387
  description: A page of points.
332
388
  content:
@@ -339,6 +395,30 @@ paths:
339
395
  application/json:
340
396
  schema:
341
397
  $ref: "#/components/schemas/Error"
398
+ /login/callback:
399
+ get:
400
+ summary: Where a person comes back after logging in to a service
401
+ description: >
402
+ Mounted when `HTTP_SURFACE_PUBLIC_URL` is set. The service sends the
403
+ person's browser here with `code` and `state` (or `error`); the
404
+ single-use state is the credential, so no bearer token is needed.
405
+ Answers with a plain-text page.
406
+ security: []
407
+ parameters:
408
+ - { name: code, in: query, schema: { type: string } }
409
+ - { name: state, in: query, schema: { type: string } }
410
+ - { name: error, in: query, schema: { type: string } }
411
+ responses:
412
+ "200":
413
+ description: Logged in.
414
+ content:
415
+ text/plain:
416
+ schema: { type: string }
417
+ "400":
418
+ description: Refused, expired, already used, or missing code/state.
419
+ content:
420
+ text/plain:
421
+ schema: { type: string }
342
422
  /openapi.yaml:
343
423
  get:
344
424
  summary: This OpenAPI document
@@ -370,6 +450,26 @@ components:
370
450
  application/json:
371
451
  schema:
372
452
  $ref: "#/components/schemas/Error"
453
+ NotAdmitted:
454
+ description: >
455
+ An authenticated caller the core won't talk to: `403` when the app's
456
+ directory doesn't know them, `503` when it can't be reached. Nothing
457
+ behind the route runs.
458
+ content:
459
+ application/json:
460
+ schema:
461
+ type: object
462
+ required: [ok, error, reason]
463
+ properties:
464
+ ok:
465
+ type: boolean
466
+ enum: [false]
467
+ error:
468
+ type: string
469
+ description: What to tell the caller.
470
+ reason:
471
+ type: string
472
+ enum: [unknown, unavailable]
373
473
  schemas:
374
474
  TurnRequest:
375
475
  type: object
@@ -416,6 +516,12 @@ components:
416
516
  token:
417
517
  type: string
418
518
  description: On `pending` — the one-time confirmation token.
519
+ service:
520
+ type: string
521
+ description: On `login` — the service the caller has to log in to.
522
+ url:
523
+ type: string
524
+ description: On `login` — the link the caller opens to log in; it comes back to `/login/callback`.
419
525
  text:
420
526
  type: string
421
527
  description: On `final` — the authoritative complete answer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,8 +21,8 @@
21
21
  "@mercury-fw/channel-types": ">=0.36.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.36.0",
25
- "@mercury-fw/plugin-types": "0.36.0",
24
+ "@mercury-fw/channel-types": "0.42.0",
25
+ "@mercury-fw/plugin-types": "0.42.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"