@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 +39 -0
- package/README.md +22 -13
- package/dist/http-provider.d.ts +3 -1
- package/dist/http-server.d.ts +32 -19
- package/dist/index.d.ts +4 -3
- package/http-provider.ts +4 -1
- package/http-server.ts +108 -53
- package/index.ts +9 -4
- package/openapi.yaml +103 -24
- package/package.json +4 -4
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
|
|
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
|
|
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
|
|
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 }` —
|
|
75
|
-
| `GET /conversations?limit=<n>` | `{ conversations: [{
|
|
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,
|
|
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=<
|
|
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
|
|
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.
|
package/dist/http-provider.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/http-server.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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,
|
|
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,
|
|
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
|
|
43
|
-
* `{ text, conversationId? }`;
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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
|
|
59
|
-
* through the injected `resolveConfirmation`,
|
|
60
|
-
*
|
|
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. `
|
|
76
|
-
*
|
|
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
|
|
11
|
-
* inversion). Those
|
|
12
|
-
* context missing them throws — the loader isolates it
|
|
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
|
-
*
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
|
91
|
-
* `{ text, conversationId? }`;
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
178
|
-
//
|
|
179
|
-
|
|
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
|
|
213
|
-
* through the injected `resolveConfirmation`,
|
|
214
|
-
*
|
|
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
|
|
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
|
|
230
|
-
if (token.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(
|
|
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,
|
|
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. `
|
|
258
|
-
*
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
11
|
-
* inversion). Those
|
|
12
|
-
* context missing them throws — the loader isolates it
|
|
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:
|
|
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`
|
|
9
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: [
|
|
474
|
+
required: [conversationId, lastTimestamp, preview]
|
|
396
475
|
properties:
|
|
397
|
-
|
|
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.
|
|
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.
|
|
21
|
+
"@mercury-fw/channel-types": ">=0.36.0 <1.0.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@mercury-fw/channel-types": "0.
|
|
25
|
-
"@mercury-fw/plugin-types": "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"
|