@mercury-fw/channel-http 0.2.0 → 0.3.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,26 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8d3e5af: Authentication for the HTTP surface.
8
+
9
+ - 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.
10
+ - New contract in `@mercury-fw/channel-types`: `AuthPlugin`, `AUTH_API_VERSION` and `Authenticate`. `PrincipalProvider` gains `oidc` and `static`.
11
+ - 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`.
12
+ - New package `@mercury-fw/auth-static` maps fixed test tokens from `AUTH_STATIC_TOKENS` to users, for test apps and e2e tests.
13
+ - Breaking, `@mercury-fw/channel-http`: the channel doesn't start without an auth provider.
14
+ - `@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.
15
+ - 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.
16
+ - `@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.
17
+ - `@mercury-fw/channel-http`: authenticated callers get their own episodic memory and wiki area.
18
+ - `@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.
19
+ - The HTTP `tool` event carries the name of the tool the model called. `TurnSink.onToolStart` gets it as an optional fourth argument.
20
+ - `mfw create` pairs the HTTP channel with an auth provider: `--auth oidc|static`, which the wizard asks for once HTTP is chosen.
21
+ - `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.
22
+ - `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.
23
+
3
24
  ## 0.2.0
4
25
 
5
26
  ### 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,11 +76,11 @@ 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, but each one still shows every person's data: scoping them per person is the next step.
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 |
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 |
75
84
  | `GET /conversations?limit=<n>` | `{ conversations: [{ sessionKey, lastTimestamp, preview }] }` — known conversations, most-recently-active first |
76
85
  | `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
77
86
  | `GET /confirmations` | `{ pending: [{ sessionKey, binary, args, expiresAt }] }` — tokens are deliberately never included |
@@ -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 } 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
24
  export type ConfirmFn = (token: string, sessionKey: string, userId: string) => Promise<string | null>;
23
25
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
24
26
  export type ResolveConfirmationFn = (token: string, sessionKey: string, userId: string) => 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,12 @@ 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.
85
+ * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
86
+ * same for a 400.
77
87
  */
78
- export declare function readRoutes(reads: ChannelHostReads, corsOrigin?: string): Record<string, ReadRoute>;
88
+ export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string): Record<string, ReadRoute>;
79
89
  export type HttpServerDeps = TurnRequestDeps & {
80
90
  port: number;
81
91
  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,27 +12,36 @@
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,
27
30
  } from "@mercury-fw/channel-types";
28
31
 
29
32
  /**
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.
33
+ * A client's conversation id becomes part of the session key and the log
34
+ * prefix, so only letters, digits, `-` and `_` get in: no path segments, no
35
+ * line breaks, nothing to encode, and no `:`, which separates it from the
36
+ * caller's id in the session key.
33
37
  */
34
38
  const CONVERSATION_ID = /^[A-Za-z0-9_-]{1,128}$/;
35
39
 
40
+ /** `text` with every control character (line breaks included) as `?`, for a log line nobody can forge. */
41
+ function forLog(text: string): string {
42
+ return text.replace(/[\u0000-\u001f\u007f]/g, "?");
43
+ }
44
+
36
45
  /** 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
46
  export type ConfirmFn = (token: string, sessionKey: string, userId: string) => Promise<string | null>;
38
47
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
@@ -40,6 +49,8 @@ export type ResolveConfirmationFn = (token: string, sessionKey: string, userId:
40
49
 
41
50
  export type TurnRequestDeps = {
42
51
  handleTurn: HandleTurn;
52
+ /** Who is calling, injected from the app's auth provider; `null` = refused. */
53
+ authenticate: Authenticate;
43
54
  /** Bare-token interception before the model, injected by the core. */
44
55
  confirm: ConfirmFn;
45
56
  /** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
@@ -57,17 +68,27 @@ const SSE_HEADERS = {
57
68
  /**
58
69
  * The CORS headers echoed on every response so a browser UI served from a
59
70
  * 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`.
71
+ * The caller's token travels in an explicit `Authorization` header, never in a
72
+ * cookie the browser would attach on its own, so a wildcard origin is safe: a
73
+ * page on another origin can't borrow anyone's credentials. A specific origin
74
+ * can still be pinned via `HTTP_SURFACE_CORS_ORIGIN`.
62
75
  */
63
76
  function corsHeaders(origin: string): Record<string, string> {
64
77
  return {
65
78
  "access-control-allow-origin": origin,
66
79
  "access-control-allow-methods": "GET, POST, OPTIONS",
67
- "access-control-allow-headers": "content-type",
80
+ "access-control-allow-headers": "content-type, authorization",
68
81
  };
69
82
  }
70
83
 
84
+ /** 401 for a caller the auth provider refused, with the Bearer challenge and CORS headers (so a browser UI can read it). */
85
+ function unauthorized(origin: string): Response {
86
+ return Response.json(
87
+ { ok: false, error: "unauthorized" },
88
+ { status: 401, headers: { ...corsHeaders(origin), "www-authenticate": "Bearer" } },
89
+ );
90
+ }
91
+
71
92
  /** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
72
93
  function preflight(origin: string): Response {
73
94
  return new Response(null, { status: 204, headers: corsHeaders(origin) });
@@ -87,17 +108,21 @@ export function openApiResponse(corsOrigin = "*"): Response {
87
108
  }
88
109
 
89
110
  /**
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
111
+ * Runs one `POST /turn` request and returns an SSE stream Response. The caller
112
+ * is authenticated first. The body is `{ text, conversationId? }`;
113
+ * `conversationId` (opaque, client-owned) continues one of the caller's
114
+ * conversations: the session key is `<principal.id>:<conversationId>`, so the
115
+ * same id from someone else is a session of their own. Mercury keys its
116
+ * histories by session, so many conversations run concurrently, each
94
117
  * 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.
118
+ * session. Never throws: a refused caller is a 401, a bad body a 400, a
119
+ * mid-turn failure an `error` event on the stream.
97
120
  */
98
121
  export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response> {
99
122
  const origin = deps.corsOrigin ?? "*";
100
123
  const cors = corsHeaders(origin);
124
+ const principal = await deps.authenticate(req);
125
+ if (principal === null) return unauthorized(origin);
101
126
  let body: { text?: unknown; conversationId?: unknown };
102
127
  try {
103
128
  body = (await req.json()) as typeof body;
@@ -108,13 +133,14 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
108
133
  return Response.json({ ok: false, error: "missing text" }, { status: 400, headers: cors });
109
134
  }
110
135
  const text = body.text;
111
- const sessionKey =
136
+ const conversationId =
112
137
  typeof body.conversationId === "string" && body.conversationId.trim().length > 0
113
138
  ? body.conversationId.trim()
114
139
  : (deps.newSessionKey ?? (() => crypto.randomUUID()))();
115
- if (!CONVERSATION_ID.test(sessionKey)) {
140
+ if (!CONVERSATION_ID.test(conversationId)) {
116
141
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
117
142
  }
143
+ const sessionKey = `${principal.id}:${conversationId}`;
118
144
 
119
145
  // Aborted when the client disconnects (see the stream's `cancel` below) so
120
146
  // the in-flight turn stops instead of running to completion — the "stop"
@@ -143,7 +169,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
143
169
 
144
170
  // Same deterministic interception as every other channel — a
145
171
  // previously-approved mutation must never depend on the model.
146
- const confirmReply = await deps.confirm(text, sessionKey, sessionKey);
172
+ const confirmReply = await deps.confirm(text, sessionKey, principal.id);
147
173
  if (confirmReply !== null) {
148
174
  send("final", { text: confirmReply });
149
175
  close();
@@ -151,7 +177,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
151
177
  }
152
178
 
153
179
  const sink: TurnSink = {
154
- onToolStart: (label, detail, toolCallId) => send("tool", { label, detail, toolCallId }),
180
+ onToolStart: (label, detail, toolCallId, toolName) => send("tool", { label, detail, toolCallId, name: toolName }),
155
181
  onToolFinish: (toolCallId, outcome) => send("tool_finish", { toolCallId, outcome }),
156
182
  onTextChunk: (chunk) => {
157
183
  if (chunk !== PENDING_CONFIRMATION_NOTE) send("text", { chunk });
@@ -174,10 +200,9 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
174
200
  multiUser: false,
175
201
  text,
176
202
  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}] `,
203
+ principal,
204
+ // The caller's id comes from the auth provider, unchecked here.
205
+ logPrefix: `[http:${forLog(sessionKey)}] `,
181
206
  abortSignal: abort.signal,
182
207
  },
183
208
  sink,
@@ -202,6 +227,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
202
227
  }
203
228
 
204
229
  export type ConfirmRequestDeps = {
230
+ authenticate: Authenticate;
205
231
  resolveConfirmation: ResolveConfirmationFn;
206
232
  corsOrigin?: string;
207
233
  };
@@ -209,16 +235,20 @@ export type ConfirmRequestDeps = {
209
235
  /**
210
236
  * `POST /confirm { token, conversationId }` — the explicit confirmation
211
237
  * 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
238
+ * to `/turn`, but the exact same mechanism underneath: it authenticates the
239
+ * caller and resolves the token through the injected `resolveConfirmation`,
240
+ * keyed on the caller's own session (`<principal.id>:<conversationId>`), so a
241
+ * token staged in someone else's conversation is never found. `resolved: true` means the token matched a
215
242
  * pending confirmation and its staged action was consumed and run; the `text`
216
243
  * then reports whether that execution succeeded. `resolved: false` means the
217
244
  * token was not a pending confirmation (unknown, expired, already used, or not
218
245
  * even token-shaped). Never touches the model.
219
246
  */
220
247
  export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response> {
221
- const cors = corsHeaders(deps.corsOrigin ?? "*");
248
+ const origin = deps.corsOrigin ?? "*";
249
+ const cors = corsHeaders(origin);
250
+ const principal = await deps.authenticate(req);
251
+ if (principal === null) return unauthorized(origin);
222
252
  let body: { token?: unknown; conversationId?: unknown };
223
253
  try {
224
254
  body = (await req.json()) as typeof body;
@@ -226,14 +256,14 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
226
256
  return Response.json({ ok: false, error: "body must be JSON" }, { status: 400, headers: cors });
227
257
  }
228
258
  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) {
259
+ const conversationId = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
260
+ if (token.length === 0 || conversationId.length === 0) {
231
261
  return Response.json({ ok: false, error: "missing token or conversationId" }, { status: 400, headers: cors });
232
262
  }
233
- if (!CONVERSATION_ID.test(sessionKey)) {
263
+ if (!CONVERSATION_ID.test(conversationId)) {
234
264
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
235
265
  }
236
- const outcome = await deps.resolveConfirmation(token, sessionKey, sessionKey);
266
+ const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal.id);
237
267
  switch (outcome.status) {
238
268
  case "not-a-token":
239
269
  case "not-found":
@@ -254,17 +284,22 @@ type ReadRoute = {
254
284
  /**
255
285
  * Builds the read-only routes, each carrying CORS headers on its `GET` and a
256
286
  * 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.
287
+ * reach them. Every `GET` asks `authenticate` first and answers 401 without
288
+ * calling its getter when nobody is calling; the preflight doesn't ask.
289
+ * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
290
+ * same for a 400.
259
291
  */
260
- export function readRoutes(reads: ChannelHostReads, corsOrigin = "*"): Record<string, ReadRoute> {
292
+ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin = "*"): Record<string, ReadRoute> {
261
293
  const cors = corsHeaders(corsOrigin);
262
294
  const json = (payload: object, status = 200): Response =>
263
295
  Response.json(payload, { status, headers: cors });
264
296
  const badRequest = (message: string): Response => json({ ok: false, error: message }, 400);
265
297
  const requireParam = (req: Request, name: string): string | null => new URL(req.url).searchParams.get(name);
266
298
  const options = () => preflight(corsOrigin);
267
- const route = (GET: ReadRoute["GET"]): ReadRoute => ({ GET, OPTIONS: options });
299
+ const route = (GET: ReadRoute["GET"]): ReadRoute => ({
300
+ GET: async (req) => ((await authenticate(req)) === null ? unauthorized(corsOrigin) : GET(req)),
301
+ OPTIONS: options,
302
+ });
268
303
  return {
269
304
  "/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
270
305
  "/confirmations": route(() => json({ ok: true, pending: reads.pendingConfirmations() })),
@@ -329,11 +364,12 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
329
364
  routes: {
330
365
  "/turn": { POST: (req) => handleTurnRequest(req, deps), OPTIONS: () => preflight(origin) },
331
366
  "/confirm": {
332
- POST: (req) => handleConfirmRequest(req, { resolveConfirmation: deps.resolveConfirmation, corsOrigin: origin }),
367
+ POST: (req) =>
368
+ handleConfirmRequest(req, { authenticate: deps.authenticate, resolveConfirmation: deps.resolveConfirmation, corsOrigin: origin }),
333
369
  OPTIONS: () => preflight(origin),
334
370
  },
335
371
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
336
- ...(deps.reads ? readRoutes(deps.reads, origin) : {}),
372
+ ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin) : {}),
337
373
  },
338
374
  error: (err) =>
339
375
  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";
@@ -25,12 +26,16 @@ export const httpChannel: ChannelPlugin = {
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,19 +5,30 @@ 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
+
16
24
  ## Conversations
17
25
 
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
26
+ A conversation is identified by an opaque, client-owned `conversationId`
27
+ and belongs to whoever opened it: Mercury's session key is
28
+ `<caller id>:<conversationId>`, so the same id sent by someone else is a
29
+ conversation of their own, and a confirmation token staged in one person's
30
+ 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
21
32
  readable via `GET /conversation`, and the list of known conversations via
22
33
  `GET /conversations` (both backed by the verbatim archive; they degrade to
23
34
  empty when the vector store is unreachable).
@@ -38,6 +49,8 @@ info:
38
49
  emits a `pending` event carrying a one-time `token`. The client confirms by
39
50
  sending that token back — either as the `text` of a follow-up `POST /turn`,
40
51
  or via `POST /confirm`.
52
+ security:
53
+ - bearerAuth: []
41
54
  servers:
42
55
  - url: http://localhost:4100
43
56
  description: Local/dev surface (HTTP_SURFACE_PORT, default 4100)
@@ -57,6 +70,8 @@ paths:
57
70
  schema:
58
71
  $ref: "#/components/schemas/TurnRequest"
59
72
  responses:
73
+ "401":
74
+ $ref: "#/components/responses/Unauthorized"
60
75
  "200":
61
76
  description: >
62
77
  An SSE stream. Each event is one of the types in `TurnEvent`:
@@ -79,8 +94,8 @@ paths:
79
94
  summary: Confirm a staged irreversible action
80
95
  description: >
81
96
  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.
97
+ bare-token-in-`text` path uses, keyed on the caller's own conversation
98
+ (`<caller id>:<conversationId>`). Never touches the model.
84
99
  requestBody:
85
100
  required: true
86
101
  content:
@@ -88,6 +103,8 @@ paths:
88
103
  schema:
89
104
  $ref: "#/components/schemas/ConfirmRequest"
90
105
  responses:
106
+ "401":
107
+ $ref: "#/components/responses/Unauthorized"
91
108
  "200":
92
109
  description: >
93
110
  `resolved: true` (with the reply `text`) when the token was a pending
@@ -110,7 +127,7 @@ paths:
110
127
  - name: id
111
128
  in: query
112
129
  required: true
113
- description: The session key whose messages to return, as `/conversations` lists it (any channel's).
130
+ description: The session key whose messages to return, as `/conversations` lists it (any channel's; an HTTP one is `<caller id>:<conversationId>`).
114
131
  schema:
115
132
  type: string
116
133
  - name: limit
@@ -126,6 +143,8 @@ paths:
126
143
  schema:
127
144
  type: string
128
145
  responses:
146
+ "401":
147
+ $ref: "#/components/responses/Unauthorized"
129
148
  "200":
130
149
  description: The conversation's messages in chronological order.
131
150
  content:
@@ -149,6 +168,8 @@ paths:
149
168
  type: integer
150
169
  default: 50
151
170
  responses:
171
+ "401":
172
+ $ref: "#/components/responses/Unauthorized"
152
173
  "200":
153
174
  description: The conversation list.
154
175
  content:
@@ -159,6 +180,8 @@ paths:
159
180
  get:
160
181
  summary: Liveness/health of Mercury and its dependencies
161
182
  responses:
183
+ "401":
184
+ $ref: "#/components/responses/Unauthorized"
162
185
  "200":
163
186
  description: Health snapshot.
164
187
  content:
@@ -169,6 +192,8 @@ paths:
169
192
  get:
170
193
  summary: The loaded plugins, tools and skills
171
194
  responses:
195
+ "401":
196
+ $ref: "#/components/responses/Unauthorized"
172
197
  "200":
173
198
  description: Plugin manifest.
174
199
  content:
@@ -179,6 +204,8 @@ paths:
179
204
  get:
180
205
  summary: Currently pending confirmations (tokens redacted)
181
206
  responses:
207
+ "401":
208
+ $ref: "#/components/responses/Unauthorized"
182
209
  "200":
183
210
  description: Pending confirmations.
184
211
  content:
@@ -189,6 +216,8 @@ paths:
189
216
  get:
190
217
  summary: Recent tool-call log entries
191
218
  responses:
219
+ "401":
220
+ $ref: "#/components/responses/Unauthorized"
192
221
  "200":
193
222
  description: Tool log.
194
223
  content:
@@ -199,6 +228,8 @@ paths:
199
228
  get:
200
229
  summary: List the wiki vault's files
201
230
  responses:
231
+ "401":
232
+ $ref: "#/components/responses/Unauthorized"
202
233
  "200":
203
234
  description: File list.
204
235
  content:
@@ -215,6 +246,8 @@ paths:
215
246
  schema:
216
247
  type: string
217
248
  responses:
249
+ "401":
250
+ $ref: "#/components/responses/Unauthorized"
218
251
  "200":
219
252
  description: File content.
220
253
  content:
@@ -238,6 +271,8 @@ paths:
238
271
  schema:
239
272
  type: string
240
273
  responses:
274
+ "401":
275
+ $ref: "#/components/responses/Unauthorized"
241
276
  "200":
242
277
  description: Matches.
243
278
  content:
@@ -271,6 +306,8 @@ paths:
271
306
  schema:
272
307
  type: string
273
308
  responses:
309
+ "401":
310
+ $ref: "#/components/responses/Unauthorized"
274
311
  "200":
275
312
  description: A page of points.
276
313
  content:
@@ -286,6 +323,7 @@ paths:
286
323
  /openapi.yaml:
287
324
  get:
288
325
  summary: This OpenAPI document
326
+ security: []
289
327
  responses:
290
328
  "200":
291
329
  description: The raw OpenAPI YAML.
@@ -294,6 +332,25 @@ paths:
294
332
  schema:
295
333
  type: string
296
334
  components:
335
+ securitySchemes:
336
+ bearerAuth:
337
+ type: http
338
+ scheme: bearer
339
+ description: A token the app's auth provider accepts.
340
+ responses:
341
+ Unauthorized:
342
+ description: >
343
+ No `Authorization` header, or a token the auth provider refused.
344
+ Carries `WWW-Authenticate: Bearer`.
345
+ headers:
346
+ WWW-Authenticate:
347
+ schema:
348
+ type: string
349
+ enum: [Bearer]
350
+ content:
351
+ application/json:
352
+ schema:
353
+ $ref: "#/components/schemas/Error"
297
354
  schemas:
298
355
  TurnRequest:
299
356
  type: object
@@ -325,6 +382,9 @@ components:
325
382
  detail:
326
383
  type: string
327
384
  description: On `tool` — optional detail.
385
+ name:
386
+ type: string
387
+ description: On `tool` — the tool the model called (e.g. `jiraCommand`); absent for Mercury's own memory notes.
328
388
  toolCallId:
329
389
  type: string
330
390
  description: On `tool`/`tool_finish`.
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.3.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.35.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.35.0",
25
+ "@mercury-fw/plugin-types": "0.35.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"