@mercury-fw/channel-http 0.2.0 → 0.4.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,44 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 521db42: Isolation between the people an agent talks to.
8
+
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - 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>`.
13
+ - 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.
14
+ - `@mercury-fw/confirm-engine`: the store's `stage`, `take` and `pending` take the owner's key.
15
+ - 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.
16
+ - `@mercury-fw/channel-google-chat`: on channel contract 3.
17
+ - 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.
18
+ - `mfw vault read` refuses a path outside the vault.
19
+ - 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.
20
+
21
+ ## 0.3.0
22
+
23
+ ### Minor Changes
24
+
25
+ - 8d3e5af: Authentication for the HTTP surface.
26
+
27
+ - An app declares an auth provider as `auth` in `mercury.config.ts`. The core builds it and hands channels `ctx.authenticate`. A provider that fails to load leaves the channel without it, so the surface stays closed.
28
+ - New contract in `@mercury-fw/channel-types`: `AuthPlugin`, `AUTH_API_VERSION` and `Authenticate`. `PrincipalProvider` gains `oidc` and `static`.
29
+ - New package `@mercury-fw/auth-oidc` verifies an OpenID Connect bearer token against the issuer's keys and checks `iss`, `aud` and expiry. It reads `OIDC_ISSUER` and `OIDC_AUDIENCE`.
30
+ - New package `@mercury-fw/auth-static` maps fixed test tokens from `AUTH_STATIC_TOKENS` to users, for test apps and e2e tests.
31
+ - Breaking, `@mercury-fw/channel-http`: the channel doesn't start without an auth provider.
32
+ - `@mercury-fw/channel-http`: every route except `GET /openapi.yaml` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and a missing or refused token gets `401` before anything runs.
33
+ - Breaking, `@mercury-fw/channel-http`: a conversation belongs to whoever opened it. The session key is `<caller id>:<conversationId>`, so the same id from someone else is a conversation of their own. Existing HTTP conversations stay readable but can't be continued.
34
+ - `@mercury-fw/channel-http`: a confirmation token is checked against the caller's own session, so it can't be confirmed from someone else's.
35
+ - `@mercury-fw/channel-http`: authenticated callers get their own episodic memory and wiki area.
36
+ - `@mercury-fw/channel-http`: the read routes (`/conversations`, `/conversation`, `/wiki/*`, `/memory/scroll`, `/tool-log`, `/confirmations`) need an authenticated caller, but still show every person's data. Scoping them per person comes next, so don't give the surface to people who shouldn't see each other's conversations yet. Confirmation tokens are never listed.
37
+ - The HTTP `tool` event carries the name of the tool the model called. `TurnSink.onToolStart` gets it as an optional fourth argument.
38
+ - `mfw create` pairs the HTTP channel with an auth provider: `--auth oidc|static`, which the wizard asks for once HTTP is chosen.
39
+ - `mfw e2e` runs cases on the HTTP surface (`channel: "http"`) as one of the test's `users`, each a token the app accepts. A turn can name its own user, and it reports the response's status.
40
+ - `mfw create --local-packages <folder>` makes an app on packed tarballs, as `mfw local-packages` does on an existing one. Versions come from the tarballs, so a package that isn't published yet works too.
41
+
3
42
  ## 0.2.0
4
43
 
5
44
  ### Minor Changes
package/README.md CHANGED
@@ -8,10 +8,14 @@ bun add @mercury-fw/channel-http
8
8
 
9
9
  ```ts
10
10
  import { httpChannel } from "@mercury-fw/channel-http";
11
+ import { oidcAuth } from "@mercury-fw/auth-oidc";
11
12
 
12
13
  channels: [httpChannel],
14
+ auth: oidcAuth,
13
15
  ```
14
16
 
17
+ It needs an auth provider next to it: [`@mercury-fw/auth-oidc`](../../auth/auth-oidc) for real users, [`@mercury-fw/auth-static`](../../auth/auth-static) for the test bed and e2e tests. Without one, or with one that fails to load, the channel doesn't start and the log says why.
18
+
15
19
  | Variable | |
16
20
  |---|---|
17
21
  | `HTTP_SURFACE_PORT` | The port (default `4100`). |
@@ -19,13 +23,17 @@ channels: [httpChannel],
19
23
 
20
24
  ## API
21
25
 
22
- It's active whenever it's declared in `mercury.config.ts`'s `channels`; remove it there to turn the surface off. It listens on `HTTP_SURFACE_PORT` (default `4100`). **No authentication** — do not publish the port outside the container network (same posture as the admin panel). Base URL `http://<host>:<port>`.
26
+ 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
+ **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
+
30
+ **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.
23
31
 
24
- **CORS** is enabled on every response and every route answers an `OPTIONS` preflight, so a browser UI on another origin can call it. The allowed origin is `HTTP_SURFACE_CORS_ORIGIN` (default `*`; no credentials are used).
32
+ **CORS** is enabled on every response and every route answers an `OPTIONS` preflight, so a browser UI on another origin can call it. The allowed origin is `HTTP_SURFACE_CORS_ORIGIN` (default `*`: the token travels in a header, never in a cookie the browser would send on its own).
25
33
 
26
34
  The full contract is described by an **OpenAPI document**, served raw at `GET /openapi.yaml` and published as a rendered docs page on GitHub Pages (see `packages/channels/channel-http/openapi.yaml`, the single source of truth).
27
35
 
28
- All responses are JSON except `POST /turn`, which streams `text/event-stream`. Every JSON response is either `{ "ok": true, ... }` or, on error, `{ "ok": false, "error": "<message>" }` with HTTP `400`/`500`.
36
+ All responses are JSON except `POST /turn`, which streams `text/event-stream`. Every JSON response is either `{ "ok": true, ... }` or, on error, `{ "ok": false, "error": "<message>" }` with HTTP `400`/`401`/`500`.
29
37
 
30
38
  ## `POST /turn`
31
39
 
@@ -36,7 +44,7 @@ Runs one conversational turn; the reply streams back as Server-Sent Events.
36
44
  | field | type | required | description |
37
45
  |---|---|---|---|
38
46
  | `text` | string | yes | The user message. A bare confirmation token here confirms a staged action (see the `pending` event) without invoking the model. |
39
- | `conversationId` | string | no | Opaque, client-owned id that continues a conversation: letters, digits, `-` and `_`, up to 128 characters (a UUID fits) once surrounding whitespace is trimmed, `400` otherwise. Omitted ⇒ a fresh one-off session. |
47
+ | `conversationId` | string | no | Opaque, client-owned id that continues one of your conversations: letters, digits, `-` and `_`, up to 128 characters (a UUID fits) once surrounding whitespace is trimmed, `400` otherwise. Omitted ⇒ a fresh one-off session. |
40
48
 
41
49
  **Responses**: `200 text/event-stream` (the events below); `400` if `text` is missing, the body isn't JSON, or `conversationId` has other characters than the ones above.
42
50
 
@@ -48,7 +56,7 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
48
56
  |---|---|---|
49
57
  | `reasoning` | `{ chunk, id }` | a model reasoning delta |
50
58
  | `reasoning_end` | `{ id, failed }` | a reasoning block ends |
51
- | `tool` | `{ label, detail, toolCallId }` | a tool call starts |
59
+ | `tool` | `{ label, detail, toolCallId, name }` | a tool call starts; `name` is the tool the model called, absent when Mercury is saving something to memory |
52
60
  | `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
53
61
  | `text` | `{ chunk }` | an answer-text delta |
54
62
  | `pending` | `{ command, token }` | a confirm-required action was staged; send `token` back to confirm it — as a later `/turn` `text`, or via `POST /confirm` |
@@ -58,6 +66,7 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
58
66
  ```bash
59
67
  curl -N -X POST http://localhost:4100/turn \
60
68
  -H 'content-type: application/json' \
69
+ -H "authorization: Bearer $TOKEN" \
61
70
  -d '{"text":"Quante issue nel progetto KAN?","conversationId":"c1"}'
62
71
  ```
63
72
 
@@ -67,20 +76,20 @@ Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, con
67
76
 
68
77
  ## Read-only introspection
69
78
 
70
- All `GET`, all JSON, all reporting state already held in-process.
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.
71
80
 
72
81
  | endpoint | `data` on success |
73
82
  |---|---|
74
- | `GET /conversation?id=<conversationId>&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 |
75
- | `GET /conversations?limit=<n>` | `{ conversations: [{ sessionKey, lastTimestamp, preview }] }` — known conversations, most-recently-active first |
83
+ | `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 |
84
+ | `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
76
85
  | `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
77
- | `GET /confirmations` | `{ pending: [{ sessionKey, binary, args, expiresAt }] }` — tokens are deliberately never included |
78
- | `GET /tool-log` | `{ entries: [...] }` |
86
+ | `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
87
+ | `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
79
88
  | `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
80
- | `GET /wiki/list` | `{ files: [...] }` |
81
- | `GET /wiki/read?path=<vault-path>` | `{ content }` — `400` if `path` is missing |
89
+ | `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
90
+ | `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 |
82
91
  | `GET /wiki/grep?pattern=<regex>` | `{ matches: [...] }` — `400` if `pattern` is missing |
83
- | `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of the named episodic/semantic collection |
92
+ | `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 |
84
93
  | `GET /openapi.yaml` | the OpenAPI document for this surface (`text/yaml`) |
85
94
 
86
95
  Conversation history and the conversation list are backed by the durable verbatim archive; they degrade to empty when the vector store is unreachable.
@@ -11,7 +11,7 @@
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
13
  import { type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
- import type { Provider, ChannelHostReads } from "@mercury-fw/channel-types";
14
+ import type { Authenticate, Provider, ChannelHostReads } from "@mercury-fw/channel-types";
15
15
  export type HttpProviderDeps = {
16
16
  port: number;
17
17
  /** Bare-token interception before the model. */
@@ -19,6 +19,8 @@ export type HttpProviderDeps = {
19
19
  /** Structured resolver for the `/confirm` endpoint's `resolved` flag. */
20
20
  resolveConfirmation: ResolveConfirmationFn;
21
21
  reads?: ChannelHostReads;
22
+ /** Who is calling, from the app's auth provider. */
23
+ authenticate: Authenticate;
22
24
  /** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
23
25
  corsOrigin?: string;
24
26
  };
@@ -12,18 +12,22 @@
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
- * No auth by design — this surface is opt-in (enabled by listing this channel in
16
- * `mercury.config.ts`) and must not be reachable from outside the container
17
- * network, the same posture as the admin panel. Confirm resolution is injected
18
- * (`confirm`/`resolveConfirmation`), so this package never imports the app.
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
17
+ * before anything runs, and answers 401 when nobody is. A conversation belongs
18
+ * to whoever opened it: the session key is `<principal.id>:<conversationId>`.
19
+ * Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
20
+ * package never imports the app.
19
21
  */
20
- import { type HandleTurn, type ChannelHostReads, type ConfirmOutcome } from "@mercury-fw/channel-types";
22
+ import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type Principal } from "@mercury-fw/channel-types";
21
23
  /** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
22
- export type ConfirmFn = (token: string, sessionKey: string, userId: string) => Promise<string | null>;
24
+ export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
23
25
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
24
- export type ResolveConfirmationFn = (token: string, sessionKey: string, userId: string) => Promise<ConfirmOutcome>;
26
+ export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
25
27
  export type TurnRequestDeps = {
26
28
  handleTurn: HandleTurn;
29
+ /** Who is calling, injected from the app's auth provider; `null` = refused. */
30
+ authenticate: Authenticate;
27
31
  /** Bare-token interception before the model, injected by the core. */
28
32
  confirm: ConfirmFn;
29
33
  /** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
@@ -39,25 +43,29 @@ export type TurnRequestDeps = {
39
43
  */
40
44
  export declare function openApiResponse(corsOrigin?: string): Response;
41
45
  /**
42
- * Runs one `POST /turn` request and returns an SSE stream Response. The body is
43
- * `{ text, conversationId? }`; `conversationId` (opaque, client-owned) becomes
44
- * the session key so a client can continue a conversation — Mercury already
45
- * keys its histories by session, so many conversations run concurrently, each
46
+ * Runs one `POST /turn` request and returns an SSE stream Response. The caller
47
+ * is authenticated first. The body is `{ text, conversationId? }`;
48
+ * `conversationId` (opaque, client-owned) continues one of the caller's
49
+ * conversations: the session key is `<principal.id>:<conversationId>`, so the
50
+ * same id from someone else is a session of their own. Mercury keys its
51
+ * histories by session, so many conversations run concurrently, each
46
52
  * one-on-one (`multiUser: false`). No `conversationId` means a fresh ephemeral
47
- * session. Never throws: a bad body is a 400, a mid-turn failure is an `error`
48
- * event on the stream.
53
+ * session. Never throws: a refused caller is a 401, a bad body a 400, a
54
+ * mid-turn failure an `error` event on the stream.
49
55
  */
50
56
  export declare function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response>;
51
57
  export type ConfirmRequestDeps = {
58
+ authenticate: Authenticate;
52
59
  resolveConfirmation: ResolveConfirmationFn;
53
60
  corsOrigin?: string;
54
61
  };
55
62
  /**
56
63
  * `POST /confirm { token, conversationId }` — the explicit confirmation
57
64
  * endpoint. A nicer contract for a UI than re-POSTing the bare token as `text`
58
- * to `/turn`, but the exact same mechanism underneath: it resolves the token
59
- * through the injected `resolveConfirmation`, keyed on
60
- * `conversationId` as the session. `resolved: true` means the token matched a
65
+ * to `/turn`, but the exact same mechanism underneath: it authenticates the
66
+ * caller and resolves the token through the injected `resolveConfirmation`,
67
+ * keyed on the caller's own session (`<principal.id>:<conversationId>`), so a
68
+ * token staged in someone else's conversation is never found. `resolved: true` means the token matched a
61
69
  * pending confirmation and its staged action was consumed and run; the `text`
62
70
  * then reports whether that execution succeeded. `resolved: false` means the
63
71
  * token was not a pending confirmation (unknown, expired, already used, or not
@@ -72,10 +80,15 @@ type ReadRoute = {
72
80
  /**
73
81
  * Builds the read-only routes, each carrying CORS headers on its `GET` and a
74
82
  * shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
75
- * reach them. `jsonRoute` wraps a getter into a `GET` that always JSON-encodes
76
- * with the CORS headers merged in; `badRequest` does the same for a 400.
83
+ * reach them. Every `GET` asks `authenticate` first and answers 401 without
84
+ * calling its getter when nobody is calling; the preflight doesn't ask. The
85
+ * caller's principal goes to every per-person getter, so the core answers with
86
+ * their data only; a conversation is named by its conversationId, as on
87
+ * `/turn`, and its session key is built from the caller.
88
+ * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
89
+ * same for a 400.
77
90
  */
78
- export declare function readRoutes(reads: ChannelHostReads, corsOrigin?: string): Record<string, ReadRoute>;
91
+ export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string): Record<string, ReadRoute>;
79
92
  export type HttpServerDeps = TurnRequestDeps & {
80
93
  port: number;
81
94
  reads?: ChannelHostReads;
package/dist/index.d.ts CHANGED
@@ -7,9 +7,10 @@
7
7
  * declared, it starts.
8
8
  *
9
9
  * `build` reads its port and CORS origin from `env` and takes the confirm
10
- * capabilities and in-process reads from the injected context (dependency
11
- * inversion). Those two are optional on the contract but required here, so a
12
- * context missing them throws — the loader isolates it fail-soft.
10
+ * capabilities, the in-process reads and the app's auth provider from the
11
+ * injected context (dependency inversion). Those are optional on the contract
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
14
  */
14
15
  import type { ChannelPlugin } from "@mercury-fw/channel-types";
15
16
  export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
package/http-provider.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
13
  import { startHttpServer, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
- import type { Provider, HandleTurn, ChannelHostReads } from "@mercury-fw/channel-types";
14
+ import type { Authenticate, Provider, HandleTurn, ChannelHostReads } from "@mercury-fw/channel-types";
15
15
 
16
16
  export type HttpProviderDeps = {
17
17
  port: number;
@@ -20,6 +20,8 @@ export type HttpProviderDeps = {
20
20
  /** Structured resolver for the `/confirm` endpoint's `resolved` flag. */
21
21
  resolveConfirmation: ResolveConfirmationFn;
22
22
  reads?: ChannelHostReads;
23
+ /** Who is calling, from the app's auth provider. */
24
+ authenticate: Authenticate;
23
25
  /** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
24
26
  corsOrigin?: string;
25
27
  };
@@ -34,6 +36,7 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
34
36
  confirm: deps.confirm,
35
37
  resolveConfirmation: deps.resolveConfirmation,
36
38
  reads: deps.reads,
39
+ authenticate: deps.authenticate,
37
40
  corsOrigin: deps.corsOrigin,
38
41
  });
39
42
  },
package/http-server.ts CHANGED
@@ -12,34 +12,46 @@
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
- * No auth by design — this surface is opt-in (enabled by listing this channel in
16
- * `mercury.config.ts`) and must not be reachable from outside the container
17
- * network, the same posture as the admin panel. Confirm resolution is injected
18
- * (`confirm`/`resolveConfirmation`), so this package never imports the app.
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
17
+ * before anything runs, and answers 401 when nobody is. A conversation belongs
18
+ * to whoever opened it: the session key is `<principal.id>:<conversationId>`.
19
+ * Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
20
+ * package never imports the app.
19
21
  */
20
22
  import {
21
23
  detectPendingConfirmation,
22
24
  PENDING_CONFIRMATION_NOTE,
23
25
  type HandleTurn,
24
26
  type TurnSink,
27
+ type Authenticate,
25
28
  type ChannelHostReads,
26
29
  type ConfirmOutcome,
30
+ type Principal,
27
31
  } from "@mercury-fw/channel-types";
28
32
 
29
33
  /**
30
- * A client's conversation id becomes the session key, a log prefix and (until
31
- * the channel authenticates) the per-person wiki id, so only letters, digits,
32
- * `-` and `_` get in: no path segments, no line breaks, nothing to encode.
34
+ * A client's conversation id becomes part of the session key and the log
35
+ * prefix, so only letters, digits, `-` and `_` get in: no path segments, no
36
+ * line breaks, nothing to encode, and no `:`, which separates it from the
37
+ * caller's id in the session key.
33
38
  */
34
39
  const CONVERSATION_ID = /^[A-Za-z0-9_-]{1,128}$/;
35
40
 
41
+ /** `text` with every control character (line breaks included) as `?`, for a log line nobody can forge. */
42
+ function forLog(text: string): string {
43
+ return text.replace(/[\u0000-\u001f\u007f]/g, "?");
44
+ }
45
+
36
46
  /** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
37
- export type ConfirmFn = (token: string, sessionKey: string, userId: string) => Promise<string | null>;
47
+ export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
38
48
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
39
- export type ResolveConfirmationFn = (token: string, sessionKey: string, userId: string) => Promise<ConfirmOutcome>;
49
+ export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
40
50
 
41
51
  export type TurnRequestDeps = {
42
52
  handleTurn: HandleTurn;
53
+ /** Who is calling, injected from the app's auth provider; `null` = refused. */
54
+ authenticate: Authenticate;
43
55
  /** Bare-token interception before the model, injected by the core. */
44
56
  confirm: ConfirmFn;
45
57
  /** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
@@ -57,17 +69,27 @@ const SSE_HEADERS = {
57
69
  /**
58
70
  * The CORS headers echoed on every response so a browser UI served from a
59
71
  * different origin (the separate custom-UI project) can call this surface.
60
- * No credentials are ever used here, so a wildcard origin is safe; a specific
61
- * origin can still be pinned via `HTTP_SURFACE_CORS_ORIGIN`.
72
+ * The caller's token travels in an explicit `Authorization` header, never in a
73
+ * cookie the browser would attach on its own, so a wildcard origin is safe: a
74
+ * page on another origin can't borrow anyone's credentials. A specific origin
75
+ * can still be pinned via `HTTP_SURFACE_CORS_ORIGIN`.
62
76
  */
63
77
  function corsHeaders(origin: string): Record<string, string> {
64
78
  return {
65
79
  "access-control-allow-origin": origin,
66
80
  "access-control-allow-methods": "GET, POST, OPTIONS",
67
- "access-control-allow-headers": "content-type",
81
+ "access-control-allow-headers": "content-type, authorization",
68
82
  };
69
83
  }
70
84
 
85
+ /** 401 for a caller the auth provider refused, with the Bearer challenge and CORS headers (so a browser UI can read it). */
86
+ function unauthorized(origin: string): Response {
87
+ return Response.json(
88
+ { ok: false, error: "unauthorized" },
89
+ { status: 401, headers: { ...corsHeaders(origin), "www-authenticate": "Bearer" } },
90
+ );
91
+ }
92
+
71
93
  /** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
72
94
  function preflight(origin: string): Response {
73
95
  return new Response(null, { status: 204, headers: corsHeaders(origin) });
@@ -87,17 +109,21 @@ export function openApiResponse(corsOrigin = "*"): Response {
87
109
  }
88
110
 
89
111
  /**
90
- * Runs one `POST /turn` request and returns an SSE stream Response. The body is
91
- * `{ text, conversationId? }`; `conversationId` (opaque, client-owned) becomes
92
- * the session key so a client can continue a conversation — Mercury already
93
- * keys its histories by session, so many conversations run concurrently, each
112
+ * Runs one `POST /turn` request and returns an SSE stream Response. The caller
113
+ * is authenticated first. The body is `{ text, conversationId? }`;
114
+ * `conversationId` (opaque, client-owned) continues one of the caller's
115
+ * conversations: the session key is `<principal.id>:<conversationId>`, so the
116
+ * same id from someone else is a session of their own. Mercury keys its
117
+ * histories by session, so many conversations run concurrently, each
94
118
  * one-on-one (`multiUser: false`). No `conversationId` means a fresh ephemeral
95
- * session. Never throws: a bad body is a 400, a mid-turn failure is an `error`
96
- * event on the stream.
119
+ * session. Never throws: a refused caller is a 401, a bad body a 400, a
120
+ * mid-turn failure an `error` event on the stream.
97
121
  */
98
122
  export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response> {
99
123
  const origin = deps.corsOrigin ?? "*";
100
124
  const cors = corsHeaders(origin);
125
+ const principal = await deps.authenticate(req);
126
+ if (principal === null) return unauthorized(origin);
101
127
  let body: { text?: unknown; conversationId?: unknown };
102
128
  try {
103
129
  body = (await req.json()) as typeof body;
@@ -108,13 +134,14 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
108
134
  return Response.json({ ok: false, error: "missing text" }, { status: 400, headers: cors });
109
135
  }
110
136
  const text = body.text;
111
- const sessionKey =
137
+ const conversationId =
112
138
  typeof body.conversationId === "string" && body.conversationId.trim().length > 0
113
139
  ? body.conversationId.trim()
114
140
  : (deps.newSessionKey ?? (() => crypto.randomUUID()))();
115
- if (!CONVERSATION_ID.test(sessionKey)) {
141
+ if (!CONVERSATION_ID.test(conversationId)) {
116
142
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
117
143
  }
144
+ const sessionKey = `${principal.id}:${conversationId}`;
118
145
 
119
146
  // Aborted when the client disconnects (see the stream's `cancel` below) so
120
147
  // the in-flight turn stops instead of running to completion — the "stop"
@@ -143,7 +170,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
143
170
 
144
171
  // Same deterministic interception as every other channel — a
145
172
  // previously-approved mutation must never depend on the model.
146
- const confirmReply = await deps.confirm(text, sessionKey, sessionKey);
173
+ const confirmReply = await deps.confirm(text, sessionKey, principal);
147
174
  if (confirmReply !== null) {
148
175
  send("final", { text: confirmReply });
149
176
  close();
@@ -151,7 +178,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
151
178
  }
152
179
 
153
180
  const sink: TurnSink = {
154
- onToolStart: (label, detail, toolCallId) => send("tool", { label, detail, toolCallId }),
181
+ onToolStart: (label, detail, toolCallId, toolName) => send("tool", { label, detail, toolCallId, name: toolName }),
155
182
  onToolFinish: (toolCallId, outcome) => send("tool_finish", { toolCallId, outcome }),
156
183
  onTextChunk: (chunk) => {
157
184
  if (chunk !== PENDING_CONFIRMATION_NOTE) send("text", { chunk });
@@ -174,10 +201,9 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
174
201
  multiUser: false,
175
202
  text,
176
203
  sessionKey,
177
- // Unauthenticated: the conversation id stands in for the caller,
178
- // and nobody vouches for it.
179
- principal: { id: sessionKey, provider: "none" },
180
- logPrefix: `[http:${sessionKey}] `,
204
+ principal,
205
+ // The caller's id comes from the auth provider, unchecked here.
206
+ logPrefix: `[http:${forLog(sessionKey)}] `,
181
207
  abortSignal: abort.signal,
182
208
  },
183
209
  sink,
@@ -202,6 +228,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
202
228
  }
203
229
 
204
230
  export type ConfirmRequestDeps = {
231
+ authenticate: Authenticate;
205
232
  resolveConfirmation: ResolveConfirmationFn;
206
233
  corsOrigin?: string;
207
234
  };
@@ -209,16 +236,20 @@ export type ConfirmRequestDeps = {
209
236
  /**
210
237
  * `POST /confirm { token, conversationId }` — the explicit confirmation
211
238
  * endpoint. A nicer contract for a UI than re-POSTing the bare token as `text`
212
- * to `/turn`, but the exact same mechanism underneath: it resolves the token
213
- * through the injected `resolveConfirmation`, keyed on
214
- * `conversationId` as the session. `resolved: true` means the token matched a
239
+ * to `/turn`, but the exact same mechanism underneath: it authenticates the
240
+ * caller and resolves the token through the injected `resolveConfirmation`,
241
+ * keyed on the caller's own session (`<principal.id>:<conversationId>`), so a
242
+ * token staged in someone else's conversation is never found. `resolved: true` means the token matched a
215
243
  * pending confirmation and its staged action was consumed and run; the `text`
216
244
  * then reports whether that execution succeeded. `resolved: false` means the
217
245
  * token was not a pending confirmation (unknown, expired, already used, or not
218
246
  * even token-shaped). Never touches the model.
219
247
  */
220
248
  export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response> {
221
- const cors = corsHeaders(deps.corsOrigin ?? "*");
249
+ const origin = deps.corsOrigin ?? "*";
250
+ const cors = corsHeaders(origin);
251
+ const principal = await deps.authenticate(req);
252
+ if (principal === null) return unauthorized(origin);
222
253
  let body: { token?: unknown; conversationId?: unknown };
223
254
  try {
224
255
  body = (await req.json()) as typeof body;
@@ -226,14 +257,14 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
226
257
  return Response.json({ ok: false, error: "body must be JSON" }, { status: 400, headers: cors });
227
258
  }
228
259
  const token = typeof body.token === "string" ? body.token.trim() : "";
229
- const sessionKey = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
230
- if (token.length === 0 || sessionKey.length === 0) {
260
+ const conversationId = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
261
+ if (token.length === 0 || conversationId.length === 0) {
231
262
  return Response.json({ ok: false, error: "missing token or conversationId" }, { status: 400, headers: cors });
232
263
  }
233
- if (!CONVERSATION_ID.test(sessionKey)) {
264
+ if (!CONVERSATION_ID.test(conversationId)) {
234
265
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
235
266
  }
236
- const outcome = await deps.resolveConfirmation(token, sessionKey, sessionKey);
267
+ const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal);
237
268
  switch (outcome.status) {
238
269
  case "not-a-token":
239
270
  case "not-found":
@@ -254,52 +285,75 @@ type ReadRoute = {
254
285
  /**
255
286
  * Builds the read-only routes, each carrying CORS headers on its `GET` and a
256
287
  * shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
257
- * reach them. `jsonRoute` wraps a getter into a `GET` that always JSON-encodes
258
- * with the CORS headers merged in; `badRequest` does the same for a 400.
288
+ * reach them. Every `GET` asks `authenticate` first and answers 401 without
289
+ * calling its getter when nobody is calling; the preflight doesn't ask. The
290
+ * caller's principal goes to every per-person getter, so the core answers with
291
+ * their data only; a conversation is named by its conversationId, as on
292
+ * `/turn`, and its session key is built from the caller.
293
+ * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
294
+ * same for a 400.
259
295
  */
260
- export function readRoutes(reads: ChannelHostReads, corsOrigin = "*"): Record<string, ReadRoute> {
296
+ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin = "*"): Record<string, ReadRoute> {
261
297
  const cors = corsHeaders(corsOrigin);
262
298
  const json = (payload: object, status = 200): Response =>
263
299
  Response.json(payload, { status, headers: cors });
264
300
  const badRequest = (message: string): Response => json({ ok: false, error: message }, 400);
265
301
  const requireParam = (req: Request, name: string): string | null => new URL(req.url).searchParams.get(name);
266
302
  const options = () => preflight(corsOrigin);
267
- const route = (GET: ReadRoute["GET"]): ReadRoute => ({ GET, OPTIONS: options });
303
+ const route = (GET: (req: Request, principal: Principal) => Response | Promise<Response>): ReadRoute => ({
304
+ GET: async (req) => {
305
+ const principal = await authenticate(req);
306
+ return principal === null ? unauthorized(corsOrigin) : GET(req, principal);
307
+ },
308
+ OPTIONS: options,
309
+ });
268
310
  return {
269
311
  "/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
270
- "/confirmations": route(() => json({ ok: true, pending: reads.pendingConfirmations() })),
271
- "/conversation": route(async (req) => {
312
+ "/confirmations": route((_req, principal) => json({ ok: true, pending: reads.pendingConfirmations(principal) })),
313
+ "/conversation": route(async (req, principal) => {
272
314
  const url = new URL(req.url);
273
315
  const id = url.searchParams.get("id");
274
316
  if (!id) return badRequest("missing ?id");
317
+ if (!CONVERSATION_ID.test(id)) return badRequest("invalid conversationId");
275
318
  const limit = Number(url.searchParams.get("limit") ?? "200");
276
319
  const offset = url.searchParams.get("offset") ?? undefined;
277
- return json({ ok: true, ...(await reads.conversation(id, limit, offset) as object) });
320
+ return json({ ok: true, ...(await reads.conversation(principal, `${principal.id}:${id}`, limit, offset) as object) });
278
321
  }),
279
- "/conversations": route(async (req) => {
322
+ "/conversations": route(async (req, principal) => {
280
323
  const limit = Number(new URL(req.url).searchParams.get("limit") ?? "50");
281
- return json({ ok: true, ...(await reads.conversations(limit) as object) });
324
+ const { conversations } = (await reads.conversations(principal, limit)) as {
325
+ conversations: Array<{ sessionKey: string; lastTimestamp: string; preview: string }>;
326
+ };
327
+ const prefix = `${principal.id}:`;
328
+ return json({
329
+ ok: true,
330
+ conversations: conversations
331
+ .filter((c) => c.sessionKey.startsWith(prefix))
332
+ .map((c) => ({ conversationId: c.sessionKey.slice(prefix.length), lastTimestamp: c.lastTimestamp, preview: c.preview })),
333
+ });
282
334
  }),
283
- "/tool-log": route(() => json({ ok: true, entries: reads.toolLog() })),
335
+ "/tool-log": route((_req, principal) => json({ ok: true, entries: reads.toolLog(principal) })),
284
336
  "/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
285
- "/wiki/list": route(async () => json({ ok: true, files: await reads.wikiList() })),
286
- "/wiki/read": route(async (req) => {
337
+ "/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
338
+ "/wiki/read": route(async (req, principal) => {
287
339
  const path = requireParam(req, "path");
288
340
  if (!path) return badRequest("missing ?path");
289
- return json({ ok: true, content: await reads.wikiRead(path) });
341
+ const content = await reads.wikiRead(principal, path);
342
+ return content === null ? json({ ok: false, error: "not found" }, 404) : json({ ok: true, content });
290
343
  }),
291
- "/wiki/grep": route(async (req) => {
344
+ "/wiki/grep": route(async (req, principal) => {
292
345
  const pattern = requireParam(req, "pattern");
293
346
  if (!pattern) return badRequest("missing ?pattern");
294
- return json({ ok: true, matches: await reads.wikiGrep(pattern) });
347
+ return json({ ok: true, matches: await reads.wikiGrep(principal, pattern) });
295
348
  }),
296
- "/memory/scroll": route(async (req) => {
349
+ "/memory/scroll": route(async (req, principal) => {
297
350
  const url = new URL(req.url);
298
351
  const collection = url.searchParams.get("collection");
299
352
  if (!collection) return badRequest("missing ?collection");
300
353
  const limit = Number(url.searchParams.get("limit") ?? "50");
301
354
  const offset = url.searchParams.get("offset") ?? undefined;
302
- return json({ ok: true, ...(await reads.memoryScroll(collection, limit, offset) as object) });
355
+ const page = await reads.memoryScroll(principal, collection, limit, offset);
356
+ return page === null ? badRequest(`unknown collection: ${collection}`) : json({ ok: true, ...(page as object) });
303
357
  }),
304
358
  };
305
359
  }
@@ -329,11 +383,12 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
329
383
  routes: {
330
384
  "/turn": { POST: (req) => handleTurnRequest(req, deps), OPTIONS: () => preflight(origin) },
331
385
  "/confirm": {
332
- POST: (req) => handleConfirmRequest(req, { resolveConfirmation: deps.resolveConfirmation, corsOrigin: origin }),
386
+ POST: (req) =>
387
+ handleConfirmRequest(req, { authenticate: deps.authenticate, resolveConfirmation: deps.resolveConfirmation, corsOrigin: origin }),
333
388
  OPTIONS: () => preflight(origin),
334
389
  },
335
390
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
336
- ...(deps.reads ? readRoutes(deps.reads, origin) : {}),
391
+ ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin) : {}),
337
392
  },
338
393
  error: (err) =>
339
394
  Response.json(
package/index.ts CHANGED
@@ -7,9 +7,10 @@
7
7
  * declared, it starts.
8
8
  *
9
9
  * `build` reads its port and CORS origin from `env` and takes the confirm
10
- * capabilities and in-process reads from the injected context (dependency
11
- * inversion). Those two are optional on the contract but required here, so a
12
- * context missing them throws — the loader isolates it fail-soft.
10
+ * capabilities, the in-process reads and the app's auth provider from the
11
+ * injected context (dependency inversion). Those are optional on the contract
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
14
  */
14
15
  import type { ChannelPlugin } from "@mercury-fw/channel-types";
15
16
  import { createHttpProvider } from "./http-provider.ts";
@@ -19,18 +20,22 @@ export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
19
20
  export const httpChannel: ChannelPlugin = {
20
21
  // The contract this channel is written for, as a literal: importing
21
22
  // CHANNEL_API_VERSION would report whichever contract is installed.
22
- apiVersion: 2,
23
+ apiVersion: 3,
23
24
  name: "http",
24
25
  build: (ctx) => {
25
26
  if (!ctx.resolveConfirmation || !ctx.reads) {
26
27
  throw new Error("http channel requires the resolveConfirmation and reads capabilities on the runtime context");
27
28
  }
29
+ if (!ctx.authenticate) {
30
+ throw new Error("http channel requires an auth provider (auth in mercury.config.ts)");
31
+ }
28
32
  return createHttpProvider({
29
33
  port: Number(ctx.env.HTTP_SURFACE_PORT ?? "4100"),
30
34
  corsOrigin: ctx.env.HTTP_SURFACE_CORS_ORIGIN ?? "*",
31
35
  confirm: ctx.confirm,
32
36
  resolveConfirmation: ctx.resolveConfirmation,
33
37
  reads: ctx.reads,
38
+ authenticate: ctx.authenticate,
34
39
  });
35
40
  },
36
41
  };
package/openapi.yaml CHANGED
@@ -5,22 +5,43 @@ info:
5
5
  description: >
6
6
  The HTTP surface of Mercury — the intended primary channel for a
7
7
  custom web UI. It is enabled by declaring the `http` channel in
8
- `mercury.config.ts` and, by design,
9
- carries **no authentication**: it must not be published outside the trusted
10
- container network (production simply does not expose the port). CORS is
8
+ `mercury.config.ts`, together with an auth provider (`auth`): without one
9
+ the surface doesn't start. CORS is
11
10
  enabled on every response (`Access-Control-Allow-Origin`, configurable via
12
11
  `HTTP_SURFACE_CORS_ORIGIN`, default `*`) and every route answers an `OPTIONS`
13
12
  preflight, so a browser UI on another origin can call it.
14
13
 
15
14
 
15
+ ## Authentication
16
+
17
+ Every route except this document and the `OPTIONS` preflights needs
18
+ `Authorization: Bearer <token>`. The app's auth provider decides what a
19
+ valid token is (an OpenID Connect access token, a static test token); a
20
+ missing or refused one gets `401` with `WWW-Authenticate: Bearer`, before
21
+ anything runs.
22
+
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
+
16
33
  ## Conversations
17
34
 
18
- A conversation is identified by an opaque, client-owned `conversationId`,
19
- which becomes Mercury's session key. Omitting it on `POST /turn` starts a
20
- fresh ephemeral conversation. The durable transcript of a conversation is
21
- readable via `GET /conversation`, and the list of known conversations via
22
- `GET /conversations` (both backed by the verbatim archive; they degrade to
23
- empty when the vector store is unreachable).
35
+ A conversation is identified by an opaque, client-owned `conversationId`
36
+ and belongs to whoever opened it: Mercury's session key is
37
+ `<caller id>:<conversationId>`, so the same id sent by someone else is a
38
+ conversation of their own, and a confirmation token staged in one person's
39
+ conversation is never found from another's. Omitting `conversationId` on
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).
24
45
 
25
46
 
26
47
  ## Streaming and cancellation
@@ -38,6 +59,8 @@ info:
38
59
  emits a `pending` event carrying a one-time `token`. The client confirms by
39
60
  sending that token back — either as the `text` of a follow-up `POST /turn`,
40
61
  or via `POST /confirm`.
62
+ security:
63
+ - bearerAuth: []
41
64
  servers:
42
65
  - url: http://localhost:4100
43
66
  description: Local/dev surface (HTTP_SURFACE_PORT, default 4100)
@@ -57,6 +80,8 @@ paths:
57
80
  schema:
58
81
  $ref: "#/components/schemas/TurnRequest"
59
82
  responses:
83
+ "401":
84
+ $ref: "#/components/responses/Unauthorized"
60
85
  "200":
61
86
  description: >
62
87
  An SSE stream. Each event is one of the types in `TurnEvent`:
@@ -79,8 +104,8 @@ paths:
79
104
  summary: Confirm a staged irreversible action
80
105
  description: >
81
106
  Runs a one-time confirmation token through the same mechanism the
82
- bare-token-in-`text` path uses, keyed on `conversationId`. Never touches
83
- the model.
107
+ bare-token-in-`text` path uses, keyed on the caller's own conversation
108
+ (`<caller id>:<conversationId>`). Never touches the model.
84
109
  requestBody:
85
110
  required: true
86
111
  content:
@@ -88,6 +113,8 @@ paths:
88
113
  schema:
89
114
  $ref: "#/components/schemas/ConfirmRequest"
90
115
  responses:
116
+ "401":
117
+ $ref: "#/components/responses/Unauthorized"
91
118
  "200":
92
119
  description: >
93
120
  `resolved: true` (with the reply `text`) when the token was a pending
@@ -110,9 +137,10 @@ paths:
110
137
  - name: id
111
138
  in: query
112
139
  required: true
113
- description: The session key whose messages to return, as `/conversations` lists it (any channel's).
140
+ description: The caller's `conversationId`, as `/conversations` lists it.
114
141
  schema:
115
142
  type: string
143
+ pattern: "^[A-Za-z0-9_-]{1,128}$"
116
144
  - name: limit
117
145
  in: query
118
146
  required: false
@@ -126,21 +154,23 @@ paths:
126
154
  schema:
127
155
  type: string
128
156
  responses:
157
+ "401":
158
+ $ref: "#/components/responses/Unauthorized"
129
159
  "200":
130
- description: The conversation's messages in chronological order.
160
+ description: The conversation's messages in chronological order; none when it isn't one of the caller's.
131
161
  content:
132
162
  application/json:
133
163
  schema:
134
164
  $ref: "#/components/schemas/ConversationPage"
135
165
  "400":
136
- description: Missing `?id`.
166
+ description: Missing or malformed `?id`.
137
167
  content:
138
168
  application/json:
139
169
  schema:
140
170
  $ref: "#/components/schemas/Error"
141
171
  /conversations:
142
172
  get:
143
- summary: List known conversations (most-recently-active first)
173
+ summary: List the caller's conversations (most-recently-active first)
144
174
  parameters:
145
175
  - name: limit
146
176
  in: query
@@ -149,6 +179,8 @@ paths:
149
179
  type: integer
150
180
  default: 50
151
181
  responses:
182
+ "401":
183
+ $ref: "#/components/responses/Unauthorized"
152
184
  "200":
153
185
  description: The conversation list.
154
186
  content:
@@ -159,6 +191,8 @@ paths:
159
191
  get:
160
192
  summary: Liveness/health of Mercury and its dependencies
161
193
  responses:
194
+ "401":
195
+ $ref: "#/components/responses/Unauthorized"
162
196
  "200":
163
197
  description: Health snapshot.
164
198
  content:
@@ -169,6 +203,8 @@ paths:
169
203
  get:
170
204
  summary: The loaded plugins, tools and skills
171
205
  responses:
206
+ "401":
207
+ $ref: "#/components/responses/Unauthorized"
172
208
  "200":
173
209
  description: Plugin manifest.
174
210
  content:
@@ -177,8 +213,10 @@ paths:
177
213
  type: object
178
214
  /confirmations:
179
215
  get:
180
- summary: Currently pending confirmations (tokens redacted)
216
+ summary: The caller's pending confirmations (tokens redacted)
181
217
  responses:
218
+ "401":
219
+ $ref: "#/components/responses/Unauthorized"
182
220
  "200":
183
221
  description: Pending confirmations.
184
222
  content:
@@ -187,8 +225,10 @@ paths:
187
225
  type: object
188
226
  /tool-log:
189
227
  get:
190
- summary: Recent tool-call log entries
228
+ summary: The tool calls made in the caller's recent turns
191
229
  responses:
230
+ "401":
231
+ $ref: "#/components/responses/Unauthorized"
192
232
  "200":
193
233
  description: Tool log.
194
234
  content:
@@ -197,8 +237,10 @@ paths:
197
237
  type: object
198
238
  /wiki/list:
199
239
  get:
200
- summary: List the wiki vault's files
240
+ summary: List the wiki files the caller can see (`curated/` and `personal/`)
201
241
  responses:
242
+ "401":
243
+ $ref: "#/components/responses/Unauthorized"
202
244
  "200":
203
245
  description: File list.
204
246
  content:
@@ -207,14 +249,17 @@ paths:
207
249
  type: object
208
250
  /wiki/read:
209
251
  get:
210
- summary: Read one wiki vault file
252
+ summary: Read one wiki file the caller can see
211
253
  parameters:
212
254
  - name: path
213
255
  in: query
214
256
  required: true
257
+ description: A path as `/wiki/list` gives it, e.g. `curated/team.md` or `personal/notes/plan.md`.
215
258
  schema:
216
259
  type: string
217
260
  responses:
261
+ "401":
262
+ $ref: "#/components/responses/Unauthorized"
218
263
  "200":
219
264
  description: File content.
220
265
  content:
@@ -227,9 +272,15 @@ paths:
227
272
  application/json:
228
273
  schema:
229
274
  $ref: "#/components/schemas/Error"
275
+ "404":
276
+ description: No such file among those the caller can see.
277
+ content:
278
+ application/json:
279
+ schema:
280
+ $ref: "#/components/schemas/Error"
230
281
  /wiki/grep:
231
282
  get:
232
- summary: Grep the wiki vault
283
+ summary: Grep the wiki files the caller can see
233
284
  parameters:
234
285
  - name: pattern
235
286
  in: query
@@ -238,6 +289,8 @@ paths:
238
289
  schema:
239
290
  type: string
240
291
  responses:
292
+ "401":
293
+ $ref: "#/components/responses/Unauthorized"
241
294
  "200":
242
295
  description: Matches.
243
296
  content:
@@ -252,11 +305,12 @@ paths:
252
305
  $ref: "#/components/schemas/Error"
253
306
  /memory/scroll:
254
307
  get:
255
- summary: Scroll a Qdrant memory collection
308
+ summary: Scroll the caller's points in a memory collection
256
309
  parameters:
257
310
  - name: collection
258
311
  in: query
259
312
  required: true
313
+ description: One of the collections kept per person (the verbatim archive, episodic memory, semantic facts), by its configured name.
260
314
  schema:
261
315
  type: string
262
316
  - name: limit
@@ -271,6 +325,8 @@ paths:
271
325
  schema:
272
326
  type: string
273
327
  responses:
328
+ "401":
329
+ $ref: "#/components/responses/Unauthorized"
274
330
  "200":
275
331
  description: A page of points.
276
332
  content:
@@ -278,7 +334,7 @@ paths:
278
334
  schema:
279
335
  type: object
280
336
  "400":
281
- description: Missing `?collection`.
337
+ description: Missing `?collection`, or one that isn't kept per person.
282
338
  content:
283
339
  application/json:
284
340
  schema:
@@ -286,6 +342,7 @@ paths:
286
342
  /openapi.yaml:
287
343
  get:
288
344
  summary: This OpenAPI document
345
+ security: []
289
346
  responses:
290
347
  "200":
291
348
  description: The raw OpenAPI YAML.
@@ -294,6 +351,25 @@ paths:
294
351
  schema:
295
352
  type: string
296
353
  components:
354
+ securitySchemes:
355
+ bearerAuth:
356
+ type: http
357
+ scheme: bearer
358
+ description: A token the app's auth provider accepts.
359
+ responses:
360
+ Unauthorized:
361
+ description: >
362
+ No `Authorization` header, or a token the auth provider refused.
363
+ Carries `WWW-Authenticate: Bearer`.
364
+ headers:
365
+ WWW-Authenticate:
366
+ schema:
367
+ type: string
368
+ enum: [Bearer]
369
+ content:
370
+ application/json:
371
+ schema:
372
+ $ref: "#/components/schemas/Error"
297
373
  schemas:
298
374
  TurnRequest:
299
375
  type: object
@@ -325,6 +401,9 @@ components:
325
401
  detail:
326
402
  type: string
327
403
  description: On `tool` — optional detail.
404
+ name:
405
+ type: string
406
+ description: On `tool` — the tool the model called (e.g. `jiraCommand`); absent for Mercury's own memory notes.
328
407
  toolCallId:
329
408
  type: string
330
409
  description: On `tool`/`tool_finish`.
@@ -392,9 +471,9 @@ components:
392
471
  - type: "null"
393
472
  ConversationSummary:
394
473
  type: object
395
- required: [sessionKey, lastTimestamp, preview]
474
+ required: [conversationId, lastTimestamp, preview]
396
475
  properties:
397
- sessionKey:
476
+ conversationId:
398
477
  type: string
399
478
  lastTimestamp:
400
479
  type: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.2.0",
3
+ "version": "0.4.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.34.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.34.0",
25
- "@mercury-fw/plugin-types": "0.34.0",
24
+ "@mercury-fw/channel-types": "0.36.0",
25
+ "@mercury-fw/plugin-types": "0.36.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"