@mercury-fw/channel-http 0.3.0 → 0.5.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,39 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0739f77: A plugin acts as the person Mercury is talking to, who logs in to the service through Mercury.
8
+
9
+ - 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.
10
+ - 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.
11
+ - `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`.
12
+ - Channel contract version 4: channels get `logins` (`accept` a callback URL, `complete` a login) and `detectLoginRequired`.
13
+ - 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.
14
+ - `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.
15
+ - 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.
16
+ - 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.
17
+ - A third-party plugin needs `apiVersion: 4` to load, and `actsAs: "person"` with a `login` to be offered to people.
18
+
19
+ ## 0.4.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 521db42: Isolation between the people an agent talks to.
24
+
25
+ - The core keeps one identity per person, `<provider>:<id>`, so two providers issuing the same id are two people. Memory, the conversation archive, the tool log and pending confirmations are keyed on it.
26
+ - The wiki has a common area (`curated/`) everyone reads and an area per person (`users/<key>/`), which the model sees as `personal/`. Listing, reading and grepping reach the common area and the caller's own area, nothing else.
27
+ - Breaking: the model's `write_file` writes only under `personal/notes/`. The new `promote_note` tool copies one of the person's notes into `curated/`, and only once they confirm it with the token.
28
+ - A confirmation can only be confirmed by whoever staged it, in the same session. Its note is written once, in the person's area: a staged note no longer stays "pending" after it's resolved, for ids such as Google Chat's `users/<n>`.
29
+ - Breaking, `@mercury-fw/channel-types`: channel contract 3. `confirm` and `resolveConfirmation` take the caller's `Principal`, and so does every read getter except `manifest` and `health`. Channels written for contract 2 are refused.
30
+ - `@mercury-fw/confirm-engine`: the store's `stage`, `take` and `pending` take the owner's key.
31
+ - Breaking, `@mercury-fw/channel-http`: every read route returns only the caller's data. `/conversation?id=` takes one of the caller's `conversationId`s and `/conversations` lists them by `conversationId`. `/wiki/read` answers `404` outside what the caller can see, and `/memory/scroll` answers `400` for a collection that isn't kept per person.
32
+ - `@mercury-fw/channel-google-chat`: on channel contract 3.
33
+ - At startup, an instance's existing data moves to the new layout. Google Chat and terminal notes go into their areas, and Qdrant's ids go onto the new keys. Ids that don't say which provider they came from are left in place and logged.
34
+ - `mfw vault read` refuses a path outside the vault.
35
+ - The admin panel's wiki box edits the common area with the operator's tools (list, read, grep, `write_curated`), the same the nightly review uses.
36
+
3
37
  ## 0.3.0
4
38
 
5
39
  ### 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.
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,26 +72,32 @@ 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, but each one still shows every person's data: scoping them per person is the next step.
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; only `/manifest` and `/health` are the same for everyone.
80
88
 
81
89
  | endpoint | `data` on success |
82
90
  |---|---|
83
- | `GET /conversation?id=<sessionKey>&limit=<n>&offset=<cursor>` | `{ messages: [{ role, content, timestamp }], nextOffset }` — a conversation's durable transcript in order; `400` if `id` is missing. Any session key `/conversations` lists opens here, whatever channel it came from |
84
- | `GET /conversations?limit=<n>` | `{ conversations: [{ sessionKey, lastTimestamp, preview }] }` — known conversations, most-recently-active first |
91
+ | `GET /conversation?id=<conversationId>&limit=<n>&offset=<cursor>` | `{ messages: [{ role, content, timestamp }], nextOffset }` — one of your conversations, its durable transcript in order (empty for an id that isn't yours); `400` if `id` is missing or has other characters than `/turn` accepts |
92
+ | `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
85
93
  | `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
86
- | `GET /confirmations` | `{ pending: [{ sessionKey, binary, args, expiresAt }] }` — tokens are deliberately never included |
87
- | `GET /tool-log` | `{ entries: [...] }` |
94
+ | `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
95
+ | `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
88
96
  | `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
89
- | `GET /wiki/list` | `{ files: [...] }` |
90
- | `GET /wiki/read?path=<vault-path>` | `{ content }` — `400` if `path` is missing |
97
+ | `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
98
+ | `GET /wiki/read?path=<path>` | `{ content }` — a path as `/wiki/list` gives it; `400` if `path` is missing, `404` if it isn't among yours |
91
99
  | `GET /wiki/grep?pattern=<regex>` | `{ matches: [...] }` — `400` if `pattern` is missing |
92
- | `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of the named episodic/semantic collection |
100
+ | `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of your points in a collection kept per person (verbatim archive, episodic memory, semantic facts); `400` for any other collection |
93
101
  | `GET /openapi.yaml` | the OpenAPI document for this surface (`text/yaml`) |
94
102
 
95
103
  Conversation history and the conversation list are backed by the durable verbatim archive; they degrade to empty when the vector store is unreachable.
@@ -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 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;
@@ -23,6 +23,13 @@ export type HttpProviderDeps = {
23
23
  authenticate: Authenticate;
24
24
  /** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
25
25
  corsOrigin?: string;
26
+ /** People's logins, when the surface has a public URL to be sent back to:
27
+ * the callback is offered (`accept`) once the server listens. */
28
+ logins?: {
29
+ callbackUrl: string;
30
+ accept: (callbackUrl: string) => void;
31
+ complete: CompleteLoginFn;
32
+ };
26
33
  };
27
34
  export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
28
35
  stop(): Promise<void>;
@@ -12,18 +12,21 @@
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 } from "@mercury-fw/channel-types";
25
+ import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type LoginOutcome, type Principal } 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
- export type ConfirmFn = (token: string, sessionKey: string, userId: string) => Promise<string | null>;
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
- export type ResolveConfirmationFn = (token: string, sessionKey: string, userId: string) => Promise<ConfirmOutcome>;
29
+ export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
27
30
  export type TurnRequestDeps = {
28
31
  handleTurn: HandleTurn;
29
32
  /** Who is calling, injected from the app's auth provider; `null` = refused. */
@@ -72,6 +75,18 @@ export type ConfirmRequestDeps = {
72
75
  * even token-shaped). Never touches the model.
73
76
  */
74
77
  export declare function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response>;
78
+ /** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
79
+ export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
80
+ /**
81
+ * `GET /login/callback`: where the provider sends a person back after they log
82
+ * in, with `code` and `state` (or `error`, when they refused). Public: the
83
+ * person's browser carries no token here, and the single-use `state` the core
84
+ * issued for them is what ties the request to their login. Answers with a
85
+ * plain page telling them how it went.
86
+ */
87
+ export declare function handleLoginCallback(req: Request, deps: {
88
+ complete: CompleteLoginFn;
89
+ }): Promise<Response>;
75
90
  /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
76
91
  type ReadRoute = {
77
92
  GET: (req: Request) => Response | Promise<Response>;
@@ -81,7 +96,10 @@ type ReadRoute = {
81
96
  * Builds the read-only routes, each carrying CORS headers on its `GET` and a
82
97
  * shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
83
98
  * reach them. Every `GET` asks `authenticate` first and answers 401 without
84
- * calling its getter when nobody is calling; the preflight doesn't ask.
99
+ * calling its getter when nobody is calling; the preflight doesn't ask. The
100
+ * caller's principal goes to every per-person getter, so the core answers with
101
+ * their data only; a conversation is named by its conversationId, as on
102
+ * `/turn`, and its session key is built from the caller.
85
103
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
86
104
  * same for a 400.
87
105
  */
@@ -91,6 +109,8 @@ export type HttpServerDeps = TurnRequestDeps & {
91
109
  reads?: ChannelHostReads;
92
110
  /** The `/confirm` endpoint's structured resolver, injected by the core. */
93
111
  resolveConfirmation: ResolveConfirmationFn;
112
+ /** Finishes people's logins; `/login/callback` is mounted only with it. */
113
+ completeLogin?: CompleteLoginFn;
94
114
  };
95
115
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
96
116
  * 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 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 = {
@@ -24,6 +24,9 @@ export type HttpProviderDeps = {
24
24
  authenticate: Authenticate;
25
25
  /** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
26
26
  corsOrigin?: string;
27
+ /** People's logins, when the surface has a public URL to be sent back to:
28
+ * the callback is offered (`accept`) once the server listens. */
29
+ logins?: { callbackUrl: string; accept: (callbackUrl: string) => void; complete: CompleteLoginFn };
27
30
  };
28
31
 
29
32
  export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
@@ -38,7 +41,9 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
38
41
  reads: deps.reads,
39
42
  authenticate: deps.authenticate,
40
43
  corsOrigin: deps.corsOrigin,
44
+ completeLogin: deps.logins?.complete,
41
45
  });
46
+ deps.logins?.accept(deps.logins.callbackUrl);
42
47
  },
43
48
  async notify(): Promise<{ sessionKey: string }> {
44
49
  // 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,6 +31,8 @@ import {
27
31
  type Authenticate,
28
32
  type ChannelHostReads,
29
33
  type ConfirmOutcome,
34
+ type LoginOutcome,
35
+ type Principal,
30
36
  } from "@mercury-fw/channel-types";
31
37
 
32
38
  /**
@@ -43,9 +49,9 @@ function forLog(text: string): string {
43
49
  }
44
50
 
45
51
  /** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
46
- export type ConfirmFn = (token: string, sessionKey: string, userId: string) => Promise<string | null>;
52
+ export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
47
53
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
48
- export type ResolveConfirmationFn = (token: string, sessionKey: string, userId: string) => Promise<ConfirmOutcome>;
54
+ export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
49
55
 
50
56
  export type TurnRequestDeps = {
51
57
  handleTurn: HandleTurn;
@@ -169,7 +175,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
169
175
 
170
176
  // Same deterministic interception as every other channel — a
171
177
  // previously-approved mutation must never depend on the model.
172
- const confirmReply = await deps.confirm(text, sessionKey, principal.id);
178
+ const confirmReply = await deps.confirm(text, sessionKey, principal);
173
179
  if (confirmReply !== null) {
174
180
  send("final", { text: confirmReply });
175
181
  close();
@@ -187,6 +193,8 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
187
193
  onStep: (step) => {
188
194
  const pending = detectPendingConfirmation(step);
189
195
  if (pending) send("pending", { command: pending.summary, token: pending.token });
196
+ const login = detectLoginRequired(step);
197
+ if (login) send("login", { service: login.service, url: login.url });
190
198
  },
191
199
  onUsage: () => {},
192
200
  finalize: async (finalText) => send("final", { text: finalText }),
@@ -263,7 +271,7 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
263
271
  if (!CONVERSATION_ID.test(conversationId)) {
264
272
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
265
273
  }
266
- const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal.id);
274
+ const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal);
267
275
  switch (outcome.status) {
268
276
  case "not-a-token":
269
277
  case "not-found":
@@ -275,6 +283,46 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
275
283
  }
276
284
  }
277
285
 
286
+ /** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
287
+ export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
288
+
289
+ /** A plain-text page for the person's browser, at the end of their login. */
290
+ function loginPage(text: string, status: number): Response {
291
+ // The URL carried the code and the state: neither kept nor passed on.
292
+ return new Response(text, {
293
+ status,
294
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" },
295
+ });
296
+ }
297
+
298
+ /**
299
+ * `GET /login/callback`: where the provider sends a person back after they log
300
+ * in, with `code` and `state` (or `error`, when they refused). Public: the
301
+ * person's browser carries no token here, and the single-use `state` the core
302
+ * issued for them is what ties the request to their login. Answers with a
303
+ * plain page telling them how it went.
304
+ */
305
+ export async function handleLoginCallback(req: Request, deps: { complete: CompleteLoginFn }): Promise<Response> {
306
+ const params = new URL(req.url).searchParams;
307
+ const error = params.get("error");
308
+ if (error !== null) {
309
+ const reason = error.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64);
310
+ return loginPage(`The login didn't happen (${reason}). Ask Mercury again for a new link.`, 400);
311
+ }
312
+ const code = params.get("code");
313
+ const state = params.get("state");
314
+ if (!code || !state) return loginPage("The login link came back without a code or a state. Ask Mercury again for a new link.", 400);
315
+ const outcome = await deps.complete(state, code);
316
+ return outcome.ok
317
+ ? loginPage(`You're logged in to ${outcome.service}. Go back to the conversation and ask again.`, 200)
318
+ : loginPage(outcome.error, 400);
319
+ }
320
+
321
+ /** The login callback route, mounted when the server can complete logins. */
322
+ function loginRoutes(complete: CompleteLoginFn): Record<string, { GET: (req: Request) => Promise<Response> }> {
323
+ return { "/login/callback": { GET: (req) => handleLoginCallback(req, { complete }) } };
324
+ }
325
+
278
326
  /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
279
327
  type ReadRoute = {
280
328
  GET: (req: Request) => Response | Promise<Response>;
@@ -285,7 +333,10 @@ type ReadRoute = {
285
333
  * Builds the read-only routes, each carrying CORS headers on its `GET` and a
286
334
  * shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
287
335
  * reach them. Every `GET` asks `authenticate` first and answers 401 without
288
- * calling its getter when nobody is calling; the preflight doesn't ask.
336
+ * calling its getter when nobody is calling; the preflight doesn't ask. The
337
+ * caller's principal goes to every per-person getter, so the core answers with
338
+ * their data only; a conversation is named by its conversationId, as on
339
+ * `/turn`, and its session key is built from the caller.
289
340
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
290
341
  * same for a 400.
291
342
  */
@@ -296,45 +347,60 @@ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate,
296
347
  const badRequest = (message: string): Response => json({ ok: false, error: message }, 400);
297
348
  const requireParam = (req: Request, name: string): string | null => new URL(req.url).searchParams.get(name);
298
349
  const options = () => preflight(corsOrigin);
299
- const route = (GET: ReadRoute["GET"]): ReadRoute => ({
300
- GET: async (req) => ((await authenticate(req)) === null ? unauthorized(corsOrigin) : GET(req)),
350
+ const route = (GET: (req: Request, principal: Principal) => Response | Promise<Response>): ReadRoute => ({
351
+ GET: async (req) => {
352
+ const principal = await authenticate(req);
353
+ return principal === null ? unauthorized(corsOrigin) : GET(req, principal);
354
+ },
301
355
  OPTIONS: options,
302
356
  });
303
357
  return {
304
358
  "/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
305
- "/confirmations": route(() => json({ ok: true, pending: reads.pendingConfirmations() })),
306
- "/conversation": route(async (req) => {
359
+ "/confirmations": route((_req, principal) => json({ ok: true, pending: reads.pendingConfirmations(principal) })),
360
+ "/conversation": route(async (req, principal) => {
307
361
  const url = new URL(req.url);
308
362
  const id = url.searchParams.get("id");
309
363
  if (!id) return badRequest("missing ?id");
364
+ if (!CONVERSATION_ID.test(id)) return badRequest("invalid conversationId");
310
365
  const limit = Number(url.searchParams.get("limit") ?? "200");
311
366
  const offset = url.searchParams.get("offset") ?? undefined;
312
- return json({ ok: true, ...(await reads.conversation(id, limit, offset) as object) });
367
+ return json({ ok: true, ...(await reads.conversation(principal, `${principal.id}:${id}`, limit, offset) as object) });
313
368
  }),
314
- "/conversations": route(async (req) => {
369
+ "/conversations": route(async (req, principal) => {
315
370
  const limit = Number(new URL(req.url).searchParams.get("limit") ?? "50");
316
- return json({ ok: true, ...(await reads.conversations(limit) as object) });
371
+ const { conversations } = (await reads.conversations(principal, limit)) as {
372
+ conversations: Array<{ sessionKey: string; lastTimestamp: string; preview: string }>;
373
+ };
374
+ const prefix = `${principal.id}:`;
375
+ return json({
376
+ ok: true,
377
+ conversations: conversations
378
+ .filter((c) => c.sessionKey.startsWith(prefix))
379
+ .map((c) => ({ conversationId: c.sessionKey.slice(prefix.length), lastTimestamp: c.lastTimestamp, preview: c.preview })),
380
+ });
317
381
  }),
318
- "/tool-log": route(() => json({ ok: true, entries: reads.toolLog() })),
382
+ "/tool-log": route((_req, principal) => json({ ok: true, entries: reads.toolLog(principal) })),
319
383
  "/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
320
- "/wiki/list": route(async () => json({ ok: true, files: await reads.wikiList() })),
321
- "/wiki/read": route(async (req) => {
384
+ "/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
385
+ "/wiki/read": route(async (req, principal) => {
322
386
  const path = requireParam(req, "path");
323
387
  if (!path) return badRequest("missing ?path");
324
- return json({ ok: true, content: await reads.wikiRead(path) });
388
+ const content = await reads.wikiRead(principal, path);
389
+ return content === null ? json({ ok: false, error: "not found" }, 404) : json({ ok: true, content });
325
390
  }),
326
- "/wiki/grep": route(async (req) => {
391
+ "/wiki/grep": route(async (req, principal) => {
327
392
  const pattern = requireParam(req, "pattern");
328
393
  if (!pattern) return badRequest("missing ?pattern");
329
- return json({ ok: true, matches: await reads.wikiGrep(pattern) });
394
+ return json({ ok: true, matches: await reads.wikiGrep(principal, pattern) });
330
395
  }),
331
- "/memory/scroll": route(async (req) => {
396
+ "/memory/scroll": route(async (req, principal) => {
332
397
  const url = new URL(req.url);
333
398
  const collection = url.searchParams.get("collection");
334
399
  if (!collection) return badRequest("missing ?collection");
335
400
  const limit = Number(url.searchParams.get("limit") ?? "50");
336
401
  const offset = url.searchParams.get("offset") ?? undefined;
337
- return json({ ok: true, ...(await reads.memoryScroll(collection, limit, offset) as object) });
402
+ const page = await reads.memoryScroll(principal, collection, limit, offset);
403
+ return page === null ? badRequest(`unknown collection: ${collection}`) : json({ ok: true, ...(page as object) });
338
404
  }),
339
405
  };
340
406
  }
@@ -344,6 +410,8 @@ export type HttpServerDeps = TurnRequestDeps & {
344
410
  reads?: ChannelHostReads;
345
411
  /** The `/confirm` endpoint's structured resolver, injected by the core. */
346
412
  resolveConfirmation: ResolveConfirmationFn;
413
+ /** Finishes people's logins; `/login/callback` is mounted only with it. */
414
+ completeLogin?: CompleteLoginFn;
347
415
  };
348
416
 
349
417
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
@@ -370,6 +438,7 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
370
438
  },
371
439
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
372
440
  ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin) : {}),
441
+ ...(deps.completeLogin ? loginRoutes(deps.completeLogin) : {}),
373
442
  },
374
443
  error: (err) =>
375
444
  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: 2,
25
+ apiVersion: 4,
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,7 @@ export const httpChannel: ChannelPlugin = {
36
42
  resolveConfirmation: ctx.resolveConfirmation,
37
43
  reads: ctx.reads,
38
44
  authenticate: ctx.authenticate,
45
+ ...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
39
46
  });
40
47
  },
41
48
  };
package/openapi.yaml CHANGED
@@ -21,6 +21,15 @@ info:
21
21
  anything runs.
22
22
 
23
23
 
24
+ ## What a caller sees
25
+
26
+ Every read is scoped to the caller: their own conversations, pending
27
+ confirmations, tool calls and memory, and the wiki as they see it, the
28
+ 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.
31
+
32
+
24
33
  ## Conversations
25
34
 
26
35
  A conversation is identified by an opaque, client-owned `conversationId`
@@ -28,10 +37,11 @@ info:
28
37
  `<caller id>:<conversationId>`, so the same id sent by someone else is a
29
38
  conversation of their own, and a confirmation token staged in one person's
30
39
  conversation is never found from another's. Omitting `conversationId` on
31
- `POST /turn` starts a fresh ephemeral conversation. The durable transcript of a conversation is
32
- readable via `GET /conversation`, and the list of known conversations via
33
- `GET /conversations` (both backed by the verbatim archive; they degrade to
34
- empty when the vector store is unreachable).
40
+ `POST /turn` starts a fresh ephemeral conversation. The durable transcript
41
+ of one of the caller's conversations is readable via `GET /conversation`,
42
+ and the list of their conversations via `GET /conversations` (both backed
43
+ by the verbatim archive; they degrade to empty when the vector store is
44
+ unreachable).
35
45
 
36
46
 
37
47
  ## Streaming and cancellation
@@ -77,7 +87,9 @@ paths:
77
87
  An SSE stream. Each event is one of the types in `TurnEvent`:
78
88
  `reasoning`/`reasoning_end`, `text` (incremental deltas), `tool`/
79
89
  `tool_finish` (status of a tool call), `pending` (a staged
80
- confirm-required action, with its command and token), `final` (the
90
+ confirm-required action, with its command and token), `login` (a
91
+ service the caller has to log in to first, with the link to open),
92
+ `final` (the
81
93
  authoritative complete answer), and `error`.
82
94
  content:
83
95
  text/event-stream:
@@ -127,9 +139,10 @@ paths:
127
139
  - name: id
128
140
  in: query
129
141
  required: true
130
- description: The session key whose messages to return, as `/conversations` lists it (any channel's; an HTTP one is `<caller id>:<conversationId>`).
142
+ description: The caller's `conversationId`, as `/conversations` lists it.
131
143
  schema:
132
144
  type: string
145
+ pattern: "^[A-Za-z0-9_-]{1,128}$"
133
146
  - name: limit
134
147
  in: query
135
148
  required: false
@@ -146,20 +159,20 @@ paths:
146
159
  "401":
147
160
  $ref: "#/components/responses/Unauthorized"
148
161
  "200":
149
- description: The conversation's messages in chronological order.
162
+ description: The conversation's messages in chronological order; none when it isn't one of the caller's.
150
163
  content:
151
164
  application/json:
152
165
  schema:
153
166
  $ref: "#/components/schemas/ConversationPage"
154
167
  "400":
155
- description: Missing `?id`.
168
+ description: Missing or malformed `?id`.
156
169
  content:
157
170
  application/json:
158
171
  schema:
159
172
  $ref: "#/components/schemas/Error"
160
173
  /conversations:
161
174
  get:
162
- summary: List known conversations (most-recently-active first)
175
+ summary: List the caller's conversations (most-recently-active first)
163
176
  parameters:
164
177
  - name: limit
165
178
  in: query
@@ -202,7 +215,7 @@ paths:
202
215
  type: object
203
216
  /confirmations:
204
217
  get:
205
- summary: Currently pending confirmations (tokens redacted)
218
+ summary: The caller's pending confirmations (tokens redacted)
206
219
  responses:
207
220
  "401":
208
221
  $ref: "#/components/responses/Unauthorized"
@@ -214,7 +227,7 @@ paths:
214
227
  type: object
215
228
  /tool-log:
216
229
  get:
217
- summary: Recent tool-call log entries
230
+ summary: The tool calls made in the caller's recent turns
218
231
  responses:
219
232
  "401":
220
233
  $ref: "#/components/responses/Unauthorized"
@@ -226,7 +239,7 @@ paths:
226
239
  type: object
227
240
  /wiki/list:
228
241
  get:
229
- summary: List the wiki vault's files
242
+ summary: List the wiki files the caller can see (`curated/` and `personal/`)
230
243
  responses:
231
244
  "401":
232
245
  $ref: "#/components/responses/Unauthorized"
@@ -238,11 +251,12 @@ paths:
238
251
  type: object
239
252
  /wiki/read:
240
253
  get:
241
- summary: Read one wiki vault file
254
+ summary: Read one wiki file the caller can see
242
255
  parameters:
243
256
  - name: path
244
257
  in: query
245
258
  required: true
259
+ description: A path as `/wiki/list` gives it, e.g. `curated/team.md` or `personal/notes/plan.md`.
246
260
  schema:
247
261
  type: string
248
262
  responses:
@@ -260,9 +274,15 @@ paths:
260
274
  application/json:
261
275
  schema:
262
276
  $ref: "#/components/schemas/Error"
277
+ "404":
278
+ description: No such file among those the caller can see.
279
+ content:
280
+ application/json:
281
+ schema:
282
+ $ref: "#/components/schemas/Error"
263
283
  /wiki/grep:
264
284
  get:
265
- summary: Grep the wiki vault
285
+ summary: Grep the wiki files the caller can see
266
286
  parameters:
267
287
  - name: pattern
268
288
  in: query
@@ -287,11 +307,12 @@ paths:
287
307
  $ref: "#/components/schemas/Error"
288
308
  /memory/scroll:
289
309
  get:
290
- summary: Scroll a Qdrant memory collection
310
+ summary: Scroll the caller's points in a memory collection
291
311
  parameters:
292
312
  - name: collection
293
313
  in: query
294
314
  required: true
315
+ description: One of the collections kept per person (the verbatim archive, episodic memory, semantic facts), by its configured name.
295
316
  schema:
296
317
  type: string
297
318
  - name: limit
@@ -315,11 +336,35 @@ paths:
315
336
  schema:
316
337
  type: object
317
338
  "400":
318
- description: Missing `?collection`.
339
+ description: Missing `?collection`, or one that isn't kept per person.
319
340
  content:
320
341
  application/json:
321
342
  schema:
322
343
  $ref: "#/components/schemas/Error"
344
+ /login/callback:
345
+ get:
346
+ summary: Where a person comes back after logging in to a service
347
+ description: >
348
+ Mounted when `HTTP_SURFACE_PUBLIC_URL` is set. The service sends the
349
+ person's browser here with `code` and `state` (or `error`); the
350
+ single-use state is the credential, so no bearer token is needed.
351
+ Answers with a plain-text page.
352
+ security: []
353
+ parameters:
354
+ - { name: code, in: query, schema: { type: string } }
355
+ - { name: state, in: query, schema: { type: string } }
356
+ - { name: error, in: query, schema: { type: string } }
357
+ responses:
358
+ "200":
359
+ description: Logged in.
360
+ content:
361
+ text/plain:
362
+ schema: { type: string }
363
+ "400":
364
+ description: Refused, expired, already used, or missing code/state.
365
+ content:
366
+ text/plain:
367
+ schema: { type: string }
323
368
  /openapi.yaml:
324
369
  get:
325
370
  summary: This OpenAPI document
@@ -397,6 +442,12 @@ components:
397
442
  token:
398
443
  type: string
399
444
  description: On `pending` — the one-time confirmation token.
445
+ service:
446
+ type: string
447
+ description: On `login` — the service the caller has to log in to.
448
+ url:
449
+ type: string
450
+ description: On `login` — the link the caller opens to log in; it comes back to `/login/callback`.
400
451
  text:
401
452
  type: string
402
453
  description: On `final` — the authoritative complete answer.
@@ -452,9 +503,9 @@ components:
452
503
  - type: "null"
453
504
  ConversationSummary:
454
505
  type: object
455
- required: [sessionKey, lastTimestamp, preview]
506
+ required: [conversationId, lastTimestamp, preview]
456
507
  properties:
457
- sessionKey:
508
+ conversationId:
458
509
  type: string
459
510
  lastTimestamp:
460
511
  type: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,11 +18,11 @@
18
18
  "access": "public"
19
19
  },
20
20
  "peerDependencies": {
21
- "@mercury-fw/channel-types": ">=0.35.0 <1.0.0"
21
+ "@mercury-fw/channel-types": ">=0.36.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.35.0",
25
- "@mercury-fw/plugin-types": "0.35.0",
24
+ "@mercury-fw/channel-types": "0.40.0",
25
+ "@mercury-fw/plugin-types": "0.40.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"