@mercury-fw/channel-http 0.1.1 → 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 +32 -0
- package/README.md +18 -9
- package/dist/http-provider.d.ts +3 -1
- package/dist/http-server.d.ts +28 -18
- package/dist/index.d.ts +5 -4
- package/http-provider.ts +4 -1
- package/http-server.ts +83 -32
- package/index.ts +12 -5
- package/openapi.yaml +74 -12
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
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
|
+
|
|
24
|
+
## 0.2.0
|
|
25
|
+
|
|
26
|
+
### Minor Changes
|
|
27
|
+
|
|
28
|
+
- 8bed46b: - A turn carries a `Principal` (the person behind it and the provider that vouched for them) instead of the opaque `userId` and `wikiUserId`; the core derives every per-person id from it.
|
|
29
|
+
- Episodic memory follows the principal, not the channel: a session is captured whenever a provider vouched for the person. Google Chat keeps the ids it had; the terminal and the HTTP channel (until it authenticates its callers) send a principal nobody vouched for, so they stay out of episodic memory as before.
|
|
30
|
+
- The HTTP channel accepts a `conversationId` on `/turn` and `/confirm` only made of letters, digits, `-` and `_`, up to 128 characters once trimmed, and answers `400` otherwise: a client-chosen id can no longer reach another user's wiki notes, forge log lines or break the turn. An existing HTTP conversation whose id has other characters can still be read with `/conversation`, but not continued. `/conversation` keeps opening any session key `/conversations` lists, whatever channel it came from.
|
|
31
|
+
- The model's wiki read tools refuse a per-user id that isn't one path segment, as writes already did.
|
|
32
|
+
- `CHANNEL_API_VERSION` is 2: a channel written for version 1 is refused at load.
|
|
33
|
+
- The first-party channels declare their channel api version as a literal, so an older channel can't claim a newer contract it doesn't implement.
|
|
34
|
+
|
|
3
35
|
## 0.1.1
|
|
4
36
|
|
|
5
37
|
### Patch 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,9 +44,9 @@ 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
|
|
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
|
-
**Responses**: `200 text/event-stream` (the events below); `400` if `text` is missing
|
|
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
|
|
|
43
51
|
Reasoning and answer text arrive as **incremental deltas** — never one finished block — so a UI renders them live; treat the `final` event as the authoritative text. **To cancel** a turn (e.g. a generation that's diverging), close the connection: Mercury aborts the in-flight generation.
|
|
44
52
|
|
|
@@ -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,20 +66,21 @@ 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
|
|
|
64
73
|
## `POST /confirm`
|
|
65
74
|
|
|
66
|
-
Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation, `{ ok: true, resolved: false }` otherwise. `400` if `token` or `conversationId` is missing. Never invokes the model.
|
|
75
|
+
Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation, `{ ok: true, resolved: false }` otherwise. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
|
|
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=<
|
|
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 |
|
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 } 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 `*`. */
|
|
@@ -33,31 +37,35 @@ export type TurnRequestDeps = {
|
|
|
33
37
|
};
|
|
34
38
|
/**
|
|
35
39
|
* Serves the OpenAPI document that describes this surface — the single source
|
|
36
|
-
* of truth in `
|
|
40
|
+
* of truth in this package's `openapi.yaml`, also rendered as the GitHub Pages
|
|
37
41
|
* API docs. Served raw as `text/yaml` (no YAML parser needed at runtime); most
|
|
38
42
|
* tooling (Redoc, Swagger UI, Postman) reads YAML directly.
|
|
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,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. `
|
|
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.
|
|
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,10 +7,11 @@
|
|
|
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
|
-
import {
|
|
15
|
+
import type { ChannelPlugin } from "@mercury-fw/channel-types";
|
|
15
16
|
export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
|
16
17
|
export declare const httpChannel: ChannelPlugin;
|
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,20 +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
|
-
*
|
|
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,
|
|
27
30
|
} from "@mercury-fw/channel-types";
|
|
28
31
|
|
|
32
|
+
/**
|
|
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.
|
|
37
|
+
*/
|
|
38
|
+
const CONVERSATION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
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
|
+
|
|
29
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`). */
|
|
30
46
|
export type ConfirmFn = (token: string, sessionKey: string, userId: string) => Promise<string | null>;
|
|
31
47
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
@@ -33,6 +49,8 @@ export type ResolveConfirmationFn = (token: string, sessionKey: string, userId:
|
|
|
33
49
|
|
|
34
50
|
export type TurnRequestDeps = {
|
|
35
51
|
handleTurn: HandleTurn;
|
|
52
|
+
/** Who is calling, injected from the app's auth provider; `null` = refused. */
|
|
53
|
+
authenticate: Authenticate;
|
|
36
54
|
/** Bare-token interception before the model, injected by the core. */
|
|
37
55
|
confirm: ConfirmFn;
|
|
38
56
|
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
@@ -50,17 +68,27 @@ const SSE_HEADERS = {
|
|
|
50
68
|
/**
|
|
51
69
|
* The CORS headers echoed on every response so a browser UI served from a
|
|
52
70
|
* different origin (the separate custom-UI project) can call this surface.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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`.
|
|
55
75
|
*/
|
|
56
76
|
function corsHeaders(origin: string): Record<string, string> {
|
|
57
77
|
return {
|
|
58
78
|
"access-control-allow-origin": origin,
|
|
59
79
|
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
60
|
-
"access-control-allow-headers": "content-type",
|
|
80
|
+
"access-control-allow-headers": "content-type, authorization",
|
|
61
81
|
};
|
|
62
82
|
}
|
|
63
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
|
+
|
|
64
92
|
/** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
|
|
65
93
|
function preflight(origin: string): Response {
|
|
66
94
|
return new Response(null, { status: 204, headers: corsHeaders(origin) });
|
|
@@ -68,7 +96,7 @@ function preflight(origin: string): Response {
|
|
|
68
96
|
|
|
69
97
|
/**
|
|
70
98
|
* Serves the OpenAPI document that describes this surface — the single source
|
|
71
|
-
* of truth in `
|
|
99
|
+
* of truth in this package's `openapi.yaml`, also rendered as the GitHub Pages
|
|
72
100
|
* API docs. Served raw as `text/yaml` (no YAML parser needed at runtime); most
|
|
73
101
|
* tooling (Redoc, Swagger UI, Postman) reads YAML directly.
|
|
74
102
|
*/
|
|
@@ -80,17 +108,21 @@ export function openApiResponse(corsOrigin = "*"): Response {
|
|
|
80
108
|
}
|
|
81
109
|
|
|
82
110
|
/**
|
|
83
|
-
* Runs one `POST /turn` request and returns an SSE stream Response. The
|
|
84
|
-
* `{ text, conversationId? }`;
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
|
87
117
|
* one-on-one (`multiUser: false`). No `conversationId` means a fresh ephemeral
|
|
88
|
-
* session. Never throws: a
|
|
89
|
-
* 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.
|
|
90
120
|
*/
|
|
91
121
|
export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response> {
|
|
92
122
|
const origin = deps.corsOrigin ?? "*";
|
|
93
123
|
const cors = corsHeaders(origin);
|
|
124
|
+
const principal = await deps.authenticate(req);
|
|
125
|
+
if (principal === null) return unauthorized(origin);
|
|
94
126
|
let body: { text?: unknown; conversationId?: unknown };
|
|
95
127
|
try {
|
|
96
128
|
body = (await req.json()) as typeof body;
|
|
@@ -101,10 +133,14 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
101
133
|
return Response.json({ ok: false, error: "missing text" }, { status: 400, headers: cors });
|
|
102
134
|
}
|
|
103
135
|
const text = body.text;
|
|
104
|
-
const
|
|
136
|
+
const conversationId =
|
|
105
137
|
typeof body.conversationId === "string" && body.conversationId.trim().length > 0
|
|
106
138
|
? body.conversationId.trim()
|
|
107
139
|
: (deps.newSessionKey ?? (() => crypto.randomUUID()))();
|
|
140
|
+
if (!CONVERSATION_ID.test(conversationId)) {
|
|
141
|
+
return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
|
|
142
|
+
}
|
|
143
|
+
const sessionKey = `${principal.id}:${conversationId}`;
|
|
108
144
|
|
|
109
145
|
// Aborted when the client disconnects (see the stream's `cancel` below) so
|
|
110
146
|
// the in-flight turn stops instead of running to completion — the "stop"
|
|
@@ -133,7 +169,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
133
169
|
|
|
134
170
|
// Same deterministic interception as every other channel — a
|
|
135
171
|
// previously-approved mutation must never depend on the model.
|
|
136
|
-
const confirmReply = await deps.confirm(text, sessionKey,
|
|
172
|
+
const confirmReply = await deps.confirm(text, sessionKey, principal.id);
|
|
137
173
|
if (confirmReply !== null) {
|
|
138
174
|
send("final", { text: confirmReply });
|
|
139
175
|
close();
|
|
@@ -141,7 +177,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
141
177
|
}
|
|
142
178
|
|
|
143
179
|
const sink: TurnSink = {
|
|
144
|
-
onToolStart: (label, detail, toolCallId) => send("tool", { label, detail, toolCallId }),
|
|
180
|
+
onToolStart: (label, detail, toolCallId, toolName) => send("tool", { label, detail, toolCallId, name: toolName }),
|
|
145
181
|
onToolFinish: (toolCallId, outcome) => send("tool_finish", { toolCallId, outcome }),
|
|
146
182
|
onTextChunk: (chunk) => {
|
|
147
183
|
if (chunk !== PENDING_CONFIRMATION_NOTE) send("text", { chunk });
|
|
@@ -164,8 +200,9 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
164
200
|
multiUser: false,
|
|
165
201
|
text,
|
|
166
202
|
sessionKey,
|
|
167
|
-
|
|
168
|
-
|
|
203
|
+
principal,
|
|
204
|
+
// The caller's id comes from the auth provider, unchecked here.
|
|
205
|
+
logPrefix: `[http:${forLog(sessionKey)}] `,
|
|
169
206
|
abortSignal: abort.signal,
|
|
170
207
|
},
|
|
171
208
|
sink,
|
|
@@ -190,6 +227,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
190
227
|
}
|
|
191
228
|
|
|
192
229
|
export type ConfirmRequestDeps = {
|
|
230
|
+
authenticate: Authenticate;
|
|
193
231
|
resolveConfirmation: ResolveConfirmationFn;
|
|
194
232
|
corsOrigin?: string;
|
|
195
233
|
};
|
|
@@ -197,16 +235,20 @@ export type ConfirmRequestDeps = {
|
|
|
197
235
|
/**
|
|
198
236
|
* `POST /confirm { token, conversationId }` — the explicit confirmation
|
|
199
237
|
* endpoint. A nicer contract for a UI than re-POSTing the bare token as `text`
|
|
200
|
-
* to `/turn`, but the exact same mechanism underneath: it
|
|
201
|
-
* through the injected `resolveConfirmation`,
|
|
202
|
-
*
|
|
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
|
|
203
242
|
* pending confirmation and its staged action was consumed and run; the `text`
|
|
204
243
|
* then reports whether that execution succeeded. `resolved: false` means the
|
|
205
244
|
* token was not a pending confirmation (unknown, expired, already used, or not
|
|
206
245
|
* even token-shaped). Never touches the model.
|
|
207
246
|
*/
|
|
208
247
|
export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response> {
|
|
209
|
-
const
|
|
248
|
+
const origin = deps.corsOrigin ?? "*";
|
|
249
|
+
const cors = corsHeaders(origin);
|
|
250
|
+
const principal = await deps.authenticate(req);
|
|
251
|
+
if (principal === null) return unauthorized(origin);
|
|
210
252
|
let body: { token?: unknown; conversationId?: unknown };
|
|
211
253
|
try {
|
|
212
254
|
body = (await req.json()) as typeof body;
|
|
@@ -214,11 +256,14 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
|
|
|
214
256
|
return Response.json({ ok: false, error: "body must be JSON" }, { status: 400, headers: cors });
|
|
215
257
|
}
|
|
216
258
|
const token = typeof body.token === "string" ? body.token.trim() : "";
|
|
217
|
-
const
|
|
218
|
-
if (token.length === 0 ||
|
|
259
|
+
const conversationId = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
|
|
260
|
+
if (token.length === 0 || conversationId.length === 0) {
|
|
219
261
|
return Response.json({ ok: false, error: "missing token or conversationId" }, { status: 400, headers: cors });
|
|
220
262
|
}
|
|
221
|
-
|
|
263
|
+
if (!CONVERSATION_ID.test(conversationId)) {
|
|
264
|
+
return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
|
|
265
|
+
}
|
|
266
|
+
const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal.id);
|
|
222
267
|
switch (outcome.status) {
|
|
223
268
|
case "not-a-token":
|
|
224
269
|
case "not-found":
|
|
@@ -239,17 +284,22 @@ type ReadRoute = {
|
|
|
239
284
|
/**
|
|
240
285
|
* Builds the read-only routes, each carrying CORS headers on its `GET` and a
|
|
241
286
|
* shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
|
|
242
|
-
* reach them. `
|
|
243
|
-
*
|
|
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.
|
|
244
291
|
*/
|
|
245
|
-
export function readRoutes(reads: ChannelHostReads, corsOrigin = "*"): Record<string, ReadRoute> {
|
|
292
|
+
export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin = "*"): Record<string, ReadRoute> {
|
|
246
293
|
const cors = corsHeaders(corsOrigin);
|
|
247
294
|
const json = (payload: object, status = 200): Response =>
|
|
248
295
|
Response.json(payload, { status, headers: cors });
|
|
249
296
|
const badRequest = (message: string): Response => json({ ok: false, error: message }, 400);
|
|
250
297
|
const requireParam = (req: Request, name: string): string | null => new URL(req.url).searchParams.get(name);
|
|
251
298
|
const options = () => preflight(corsOrigin);
|
|
252
|
-
const route = (GET: ReadRoute["GET"]): ReadRoute => ({
|
|
299
|
+
const route = (GET: ReadRoute["GET"]): ReadRoute => ({
|
|
300
|
+
GET: async (req) => ((await authenticate(req)) === null ? unauthorized(corsOrigin) : GET(req)),
|
|
301
|
+
OPTIONS: options,
|
|
302
|
+
});
|
|
253
303
|
return {
|
|
254
304
|
"/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
|
|
255
305
|
"/confirmations": route(() => json({ ok: true, pending: reads.pendingConfirmations() })),
|
|
@@ -314,11 +364,12 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
|
|
|
314
364
|
routes: {
|
|
315
365
|
"/turn": { POST: (req) => handleTurnRequest(req, deps), OPTIONS: () => preflight(origin) },
|
|
316
366
|
"/confirm": {
|
|
317
|
-
POST: (req) =>
|
|
367
|
+
POST: (req) =>
|
|
368
|
+
handleConfirmRequest(req, { authenticate: deps.authenticate, resolveConfirmation: deps.resolveConfirmation, corsOrigin: origin }),
|
|
318
369
|
OPTIONS: () => preflight(origin),
|
|
319
370
|
},
|
|
320
371
|
"/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
|
|
321
|
-
...(deps.reads ? readRoutes(deps.reads, origin) : {}),
|
|
372
|
+
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin) : {}),
|
|
322
373
|
},
|
|
323
374
|
error: (err) =>
|
|
324
375
|
Response.json(
|
package/index.ts
CHANGED
|
@@ -7,28 +7,35 @@
|
|
|
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
|
-
import {
|
|
15
|
+
import type { ChannelPlugin } from "@mercury-fw/channel-types";
|
|
15
16
|
import { createHttpProvider } from "./http-provider.ts";
|
|
16
17
|
|
|
17
18
|
export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
|
18
19
|
|
|
19
20
|
export const httpChannel: ChannelPlugin = {
|
|
20
|
-
|
|
21
|
+
// The contract this channel is written for, as a literal: importing
|
|
22
|
+
// CHANNEL_API_VERSION would report whichever contract is installed.
|
|
23
|
+
apiVersion: 2,
|
|
21
24
|
name: "http",
|
|
22
25
|
build: (ctx) => {
|
|
23
26
|
if (!ctx.resolveConfirmation || !ctx.reads) {
|
|
24
27
|
throw new Error("http channel requires the resolveConfirmation and reads capabilities on the runtime context");
|
|
25
28
|
}
|
|
29
|
+
if (!ctx.authenticate) {
|
|
30
|
+
throw new Error("http channel requires an auth provider (auth in mercury.config.ts)");
|
|
31
|
+
}
|
|
26
32
|
return createHttpProvider({
|
|
27
33
|
port: Number(ctx.env.HTTP_SURFACE_PORT ?? "4100"),
|
|
28
34
|
corsOrigin: ctx.env.HTTP_SURFACE_CORS_ORIGIN ?? "*",
|
|
29
35
|
confirm: ctx.confirm,
|
|
30
36
|
resolveConfirmation: ctx.resolveConfirmation,
|
|
31
37
|
reads: ctx.reads,
|
|
38
|
+
authenticate: ctx.authenticate,
|
|
32
39
|
});
|
|
33
40
|
},
|
|
34
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`
|
|
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
|
+
|
|
16
24
|
## Conversations
|
|
17
25
|
|
|
18
|
-
A conversation is identified by an opaque, client-owned `conversationId
|
|
19
|
-
|
|
20
|
-
|
|
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`:
|
|
@@ -69,7 +84,7 @@ paths:
|
|
|
69
84
|
schema:
|
|
70
85
|
$ref: "#/components/schemas/TurnEvent"
|
|
71
86
|
"400":
|
|
72
|
-
description: Missing/invalid body.
|
|
87
|
+
description: Missing/invalid body, or a conversationId with characters outside the allowed ones.
|
|
73
88
|
content:
|
|
74
89
|
application/json:
|
|
75
90
|
schema:
|
|
@@ -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
|
|
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
|
|
@@ -98,7 +115,7 @@ paths:
|
|
|
98
115
|
schema:
|
|
99
116
|
$ref: "#/components/schemas/ConfirmResult"
|
|
100
117
|
"400":
|
|
101
|
-
description: Missing token or conversationId.
|
|
118
|
+
description: Missing token or conversationId, or a conversationId with characters outside the allowed ones.
|
|
102
119
|
content:
|
|
103
120
|
application/json:
|
|
104
121
|
schema:
|
|
@@ -110,7 +127,7 @@ paths:
|
|
|
110
127
|
- name: id
|
|
111
128
|
in: query
|
|
112
129
|
required: true
|
|
113
|
-
description: The
|
|
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
|
|
@@ -304,7 +361,8 @@ components:
|
|
|
304
361
|
description: The user's message, or a bare confirmation token.
|
|
305
362
|
conversationId:
|
|
306
363
|
type: string
|
|
307
|
-
|
|
364
|
+
pattern: "^[A-Za-z0-9_-]{1,128}$"
|
|
365
|
+
description: Opaque, client-owned; letters, digits, `-` and `_` (a UUID fits). Omit to start a fresh ephemeral conversation.
|
|
308
366
|
TurnEvent:
|
|
309
367
|
type: object
|
|
310
368
|
description: The shape carried by each SSE `data:` line, per event type.
|
|
@@ -324,6 +382,9 @@ components:
|
|
|
324
382
|
detail:
|
|
325
383
|
type: string
|
|
326
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.
|
|
327
388
|
toolCallId:
|
|
328
389
|
type: string
|
|
329
390
|
description: On `tool`/`tool_finish`.
|
|
@@ -350,6 +411,7 @@ components:
|
|
|
350
411
|
type: string
|
|
351
412
|
conversationId:
|
|
352
413
|
type: string
|
|
414
|
+
pattern: "^[A-Za-z0-9_-]{1,128}$"
|
|
353
415
|
ConfirmResult:
|
|
354
416
|
type: object
|
|
355
417
|
required: [ok, resolved]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/channel-http",
|
|
3
|
-
"version": "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.
|
|
21
|
+
"@mercury-fw/channel-types": ">=0.35.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.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"
|