@mercury-fw/channel-http 0.5.0 → 0.7.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 +36 -0
- package/README.md +6 -2
- package/dist/http-provider.d.ts +6 -2
- package/dist/http-server.d.ts +22 -2
- package/http-provider.ts +8 -2
- package/http-server.ts +75 -12
- package/index.ts +3 -1
- package/openapi.yaml +116 -3
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# @mercury-fw/channel-http
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2c63bbf: One person across accounts: linking them by a code.
|
|
8
|
+
|
|
9
|
+
- `POST /link` on the HTTP surface hands the caller a one-time code (`xxxx-xxxx-xxxx`, ten minutes, single use). They send it from the other account, on any channel, twice. The first time Mercury says whom the account would be linked to, the second time it links it.
|
|
10
|
+
- From then on the account is the person it's linked to: one private area, one memory, the same roles. Its earlier area stays where it was, and undoing the link gives it back.
|
|
11
|
+
- A link never touches the terminal, never chains, and is refused for an account the directory already knows as someone else.
|
|
12
|
+
- The links live on the vault's volume, out of the vault's git and out of reach of the model and the HTTP reads.
|
|
13
|
+
- `mfw identity links | link | unlink` lets the operator manage them, for instances where nobody can be shown a code.
|
|
14
|
+
- Channels get the code through the confirmation hook they already call (`ctx.confirm`), and the HTTP surface gets `ctx.linking`.
|
|
15
|
+
- Fixed: `ctx.confirm` refused someone the core doesn't admit before checking that their text was a token at all. On Google Chat, every message from an unknown sender came back as the refusal.
|
|
16
|
+
|
|
17
|
+
## 0.6.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- a7f2f13: A user directory decides who people are and what they may make Mercury do.
|
|
22
|
+
|
|
23
|
+
- An app can declare a `directory` in `mercury.config.ts`. Given whoever a channel says is talking, it returns the person with their roles. The person's key is `<directory name>:<id>`, so one person reached from several channels keeps one private area, one memory and one set of confirmations.
|
|
24
|
+
- With a directory the instance is closed. Someone it doesn't know gets a refusal and nothing runs, unless `access.unknown` is `"allow"`; `access.unknownMessage` sets what they're told. A directory that can't be reached, or fails to load, refuses everyone but the terminal. Answers are cached for five minutes, so a revoked role stops counting within that time.
|
|
25
|
+
- `mercury.act-as-self` (every plugin) or `mercury.act-as-self.<plugin>` (one) lets a person make Mercury act with its own identity.
|
|
26
|
+
- A plugin acting as Mercury, such as atlassian-admin, is offered only to those people, and on the terminal.
|
|
27
|
+
- A plugin acting as the person also gives them each tool a second time, as `<tool>AsMercury`.
|
|
28
|
+
- Every call made as Mercury for someone is logged with who asked.
|
|
29
|
+
- `access.plugins` restricts a plugin to some roles. Prompts, skills and the manifest follow what each person is offered.
|
|
30
|
+
- New package `@mercury-fw/directory-static`: people, their identities on each channel and their roles, listed in `DIRECTORY_STATIC_PEOPLE`.
|
|
31
|
+
- The channel contract is at version 5:
|
|
32
|
+
- `ChannelRuntimeContext.admit` says whether the core talks to a caller;
|
|
33
|
+
- the reads are per person and async, the manifest included;
|
|
34
|
+
- `DirectoryPlugin` is the directory contract;
|
|
35
|
+
- `Principal` no longer carries `roles`.
|
|
36
|
+
- `channel-http` answers `403` to a caller the directory doesn't know and `503` when it can't be reached, on every route.
|
|
37
|
+
- `auth-static` refuses `roles` in `AUTH_STATIC_TOKENS`: roles come from the directory.
|
|
38
|
+
|
|
3
39
|
## 0.5.0
|
|
4
40
|
|
|
5
41
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ It needs an auth provider next to it: [`@mercury-fw/auth-oidc`](../../auth/auth-
|
|
|
26
26
|
|
|
27
27
|
It's active whenever it's declared in `mercury.config.ts`'s `channels` with an auth provider; remove it there to turn the surface off. It listens on `HTTP_SURFACE_PORT` (default `4100`). Base URL `http://<host>:<port>`.
|
|
28
28
|
|
|
29
|
-
**Authentication.** Every route except `GET /openapi.yaml`, `GET /login/callback` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth provider decides who the token belongs to. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs.
|
|
29
|
+
**Authentication.** Every route except `GET /openapi.yaml`, `GET /login/callback` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth provider decides who the token belongs to. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs. Then the core decides whether it talks to that person at all: someone the app's directory doesn't know gets `403` (unless the app lets unknown people in), and `503` when the directory can't be reached, both with `{ ok: false, error, reason }`, where `error` is what to tell them.
|
|
30
30
|
|
|
31
31
|
**Conversations belong to whoever opened them.** The session key is `<caller id>:<conversationId>`, so the same `conversationId` sent by someone else is a conversation of their own, and a confirmation token staged in your conversation can't be confirmed from theirs. Each authenticated caller also gets their own episodic memory and wiki area, as on Google Chat.
|
|
32
32
|
|
|
@@ -78,13 +78,17 @@ Where a person comes back after logging in to a service a plugin acts on as them
|
|
|
78
78
|
|
|
79
79
|
Whoever opens the link and accepts decides which account gets linked: a link belongs to the person it was shown to, and someone else accepting it would link their own account to that person's Mercury identity. A UI should say so next to the link. Checking the linked account against the person is [#179](https://github.com/lucabro81/mercury-fw/issues/179).
|
|
80
80
|
|
|
81
|
+
## `POST /link`
|
|
82
|
+
|
|
83
|
+
A code for linking another of the caller's accounts to them, for the UI to show: `{ ok: true, code, expiresAt }`, or `400` with the reason when there's none. The person sends the code from the other account, on any channel, twice: the first time Mercury says whom the account would be linked to, the second time it links it, and from then on both accounts are one person (one private area, the same roles). The code lasts ten minutes, works once, and only the caller sees it; the model never does. It belongs to the first account that sends it, so it should be sent in a one-to-one conversation, never in a shared space where someone else could send it first. Over HTTP only an account the instance already admits can send it (anyone else is refused before the turn), so an account a closed instance doesn't know links from a chat.
|
|
84
|
+
|
|
81
85
|
## `POST /confirm`
|
|
82
86
|
|
|
83
87
|
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.
|
|
84
88
|
|
|
85
89
|
## Read-only introspection
|
|
86
90
|
|
|
87
|
-
All `GET`, all JSON, all reporting state already held in-process. They need an authenticated caller and show only what belongs to them: their conversations, confirmations, tool calls and memory, and the wiki as they see it (the team's `curated/` plus their own notes as `personal/`). Someone else's data doesn't exist as far as a caller can tell
|
|
91
|
+
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. `/manifest` lists what the caller is offered (a plugin acting as Mercury only for someone allowed to use it), and only `/health` is the same for everyone.
|
|
88
92
|
|
|
89
93
|
| endpoint | `data` on success |
|
|
90
94
|
|---|---|
|
package/dist/http-provider.d.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* (that stays with Google Chat). `stop` closes the socket on shutdown. Confirm is
|
|
11
11
|
* injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
12
12
|
*/
|
|
13
|
-
import { type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
|
-
import type { Authenticate, Provider, ChannelHostReads } from "@mercury-fw/channel-types";
|
|
13
|
+
import { type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
|
+
import type { Authenticate, Provider, ChannelHostReads, ChannelLinking } from "@mercury-fw/channel-types";
|
|
15
15
|
export type HttpProviderDeps = {
|
|
16
16
|
port: number;
|
|
17
17
|
/** Bare-token interception before the model. */
|
|
@@ -21,6 +21,8 @@ export type HttpProviderDeps = {
|
|
|
21
21
|
reads?: ChannelHostReads;
|
|
22
22
|
/** Who is calling, from the app's auth provider. */
|
|
23
23
|
authenticate: Authenticate;
|
|
24
|
+
/** Whether the core talks to them, from the core. */
|
|
25
|
+
admit?: AdmitFn;
|
|
24
26
|
/** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
|
|
25
27
|
corsOrigin?: string;
|
|
26
28
|
/** People's logins, when the surface has a public URL to be sent back to:
|
|
@@ -30,6 +32,8 @@ export type HttpProviderDeps = {
|
|
|
30
32
|
accept: (callbackUrl: string) => void;
|
|
31
33
|
complete: CompleteLoginFn;
|
|
32
34
|
};
|
|
35
|
+
/** Linking another account to the caller (`POST /link`). */
|
|
36
|
+
linking?: ChannelLinking;
|
|
33
37
|
};
|
|
34
38
|
export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
|
|
35
39
|
stop(): Promise<void>;
|
package/dist/http-server.d.ts
CHANGED
|
@@ -22,15 +22,18 @@
|
|
|
22
22
|
* Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
|
|
23
23
|
* package never imports the app.
|
|
24
24
|
*/
|
|
25
|
-
import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type LoginOutcome, type Principal } from "@mercury-fw/channel-types";
|
|
25
|
+
import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type LoginOutcome, type Principal, type Admission, type ChannelLinking } from "@mercury-fw/channel-types";
|
|
26
26
|
/** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
|
|
27
27
|
export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
|
|
28
28
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
29
29
|
export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
|
|
30
|
+
/** Whether the core talks to an authenticated caller at all, injected by the core (`ctx.admit`); everyone when absent. */
|
|
31
|
+
export type AdmitFn = (principal: Principal) => Promise<Admission>;
|
|
30
32
|
export type TurnRequestDeps = {
|
|
31
33
|
handleTurn: HandleTurn;
|
|
32
34
|
/** Who is calling, injected from the app's auth provider; `null` = refused. */
|
|
33
35
|
authenticate: Authenticate;
|
|
36
|
+
admit?: AdmitFn;
|
|
34
37
|
/** Bare-token interception before the model, injected by the core. */
|
|
35
38
|
confirm: ConfirmFn;
|
|
36
39
|
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
@@ -59,6 +62,7 @@ export declare function openApiResponse(corsOrigin?: string): Response;
|
|
|
59
62
|
export declare function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response>;
|
|
60
63
|
export type ConfirmRequestDeps = {
|
|
61
64
|
authenticate: Authenticate;
|
|
65
|
+
admit?: AdmitFn;
|
|
62
66
|
resolveConfirmation: ResolveConfirmationFn;
|
|
63
67
|
corsOrigin?: string;
|
|
64
68
|
};
|
|
@@ -75,6 +79,20 @@ export type ConfirmRequestDeps = {
|
|
|
75
79
|
* even token-shaped). Never touches the model.
|
|
76
80
|
*/
|
|
77
81
|
export declare function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response>;
|
|
82
|
+
export type LinkRequestDeps = {
|
|
83
|
+
authenticate: Authenticate;
|
|
84
|
+
admit?: AdmitFn;
|
|
85
|
+
/** Hands the caller a link code, injected by the core (`ctx.linking.start`). */
|
|
86
|
+
start: ChannelLinking["start"];
|
|
87
|
+
corsOrigin?: string;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* `POST /link` — a one-time code for linking another account to the caller
|
|
91
|
+
* (the custom UI shows it). The person then sends it from the other account,
|
|
92
|
+
* on any channel. Nobody but the caller ever sees it, the model included, and
|
|
93
|
+
* it isn't cached. `400` with the core's reason when there's no code for them.
|
|
94
|
+
*/
|
|
95
|
+
export declare function handleLinkRequest(req: Request, deps: LinkRequestDeps): Promise<Response>;
|
|
78
96
|
/** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
|
|
79
97
|
export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
|
|
80
98
|
/**
|
|
@@ -103,7 +121,7 @@ type ReadRoute = {
|
|
|
103
121
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
104
122
|
* same for a 400.
|
|
105
123
|
*/
|
|
106
|
-
export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string): Record<string, ReadRoute>;
|
|
124
|
+
export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string, admit?: AdmitFn): Record<string, ReadRoute>;
|
|
107
125
|
export type HttpServerDeps = TurnRequestDeps & {
|
|
108
126
|
port: number;
|
|
109
127
|
reads?: ChannelHostReads;
|
|
@@ -111,6 +129,8 @@ export type HttpServerDeps = TurnRequestDeps & {
|
|
|
111
129
|
resolveConfirmation: ResolveConfirmationFn;
|
|
112
130
|
/** Finishes people's logins; `/login/callback` is mounted only with it. */
|
|
113
131
|
completeLogin?: CompleteLoginFn;
|
|
132
|
+
/** Linking another account to the caller; `/link` is mounted only with it. */
|
|
133
|
+
linking?: ChannelLinking;
|
|
114
134
|
};
|
|
115
135
|
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
116
136
|
* when `reads` is supplied. */
|
package/http-provider.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* (that stays with Google Chat). `stop` closes the socket on shutdown. Confirm is
|
|
11
11
|
* injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
12
12
|
*/
|
|
13
|
-
import { startHttpServer, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
|
-
import type { Authenticate, Provider, HandleTurn, ChannelHostReads } from "@mercury-fw/channel-types";
|
|
13
|
+
import { startHttpServer, type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
|
+
import type { Authenticate, Provider, HandleTurn, ChannelHostReads, ChannelLinking } from "@mercury-fw/channel-types";
|
|
15
15
|
|
|
16
16
|
export type HttpProviderDeps = {
|
|
17
17
|
port: number;
|
|
@@ -22,11 +22,15 @@ export type HttpProviderDeps = {
|
|
|
22
22
|
reads?: ChannelHostReads;
|
|
23
23
|
/** Who is calling, from the app's auth provider. */
|
|
24
24
|
authenticate: Authenticate;
|
|
25
|
+
/** Whether the core talks to them, from the core. */
|
|
26
|
+
admit?: AdmitFn;
|
|
25
27
|
/** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
|
|
26
28
|
corsOrigin?: string;
|
|
27
29
|
/** People's logins, when the surface has a public URL to be sent back to:
|
|
28
30
|
* the callback is offered (`accept`) once the server listens. */
|
|
29
31
|
logins?: { callbackUrl: string; accept: (callbackUrl: string) => void; complete: CompleteLoginFn };
|
|
32
|
+
/** Linking another account to the caller (`POST /link`). */
|
|
33
|
+
linking?: ChannelLinking;
|
|
30
34
|
};
|
|
31
35
|
|
|
32
36
|
export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
|
|
@@ -40,8 +44,10 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
|
|
|
40
44
|
resolveConfirmation: deps.resolveConfirmation,
|
|
41
45
|
reads: deps.reads,
|
|
42
46
|
authenticate: deps.authenticate,
|
|
47
|
+
...(deps.admit === undefined ? {} : { admit: deps.admit }),
|
|
43
48
|
corsOrigin: deps.corsOrigin,
|
|
44
49
|
completeLogin: deps.logins?.complete,
|
|
50
|
+
...(deps.linking === undefined ? {} : { linking: deps.linking }),
|
|
45
51
|
});
|
|
46
52
|
deps.logins?.accept(deps.logins.callbackUrl);
|
|
47
53
|
},
|
package/http-server.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
type ConfirmOutcome,
|
|
34
34
|
type LoginOutcome,
|
|
35
35
|
type Principal,
|
|
36
|
+
type Admission,
|
|
37
|
+
type ChannelLinking,
|
|
36
38
|
} from "@mercury-fw/channel-types";
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -53,10 +55,14 @@ export type ConfirmFn = (token: string, sessionKey: string, principal: Principal
|
|
|
53
55
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
54
56
|
export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
|
|
55
57
|
|
|
58
|
+
/** Whether the core talks to an authenticated caller at all, injected by the core (`ctx.admit`); everyone when absent. */
|
|
59
|
+
export type AdmitFn = (principal: Principal) => Promise<Admission>;
|
|
60
|
+
|
|
56
61
|
export type TurnRequestDeps = {
|
|
57
62
|
handleTurn: HandleTurn;
|
|
58
63
|
/** Who is calling, injected from the app's auth provider; `null` = refused. */
|
|
59
64
|
authenticate: Authenticate;
|
|
65
|
+
admit?: AdmitFn;
|
|
60
66
|
/** Bare-token interception before the model, injected by the core. */
|
|
61
67
|
confirm: ConfirmFn;
|
|
62
68
|
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
@@ -95,6 +101,22 @@ function unauthorized(origin: string): Response {
|
|
|
95
101
|
);
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
/** The core's refusal of an authenticated caller: 403 for someone it doesn't know, 503 when it couldn't tell, with what to tell them. */
|
|
105
|
+
function notAdmitted(admission: Exclude<Admission, { ok: true }>, origin: string): Response {
|
|
106
|
+
return Response.json(
|
|
107
|
+
{ ok: false, error: admission.message, reason: admission.reason },
|
|
108
|
+
{ status: admission.reason === "unknown" ? 403 : 503, headers: corsHeaders(origin) },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The caller of `req`, authenticated and admitted, or the response refusing them. */
|
|
113
|
+
async function caller(req: Request, deps: { authenticate: Authenticate; admit?: AdmitFn }, origin: string): Promise<Principal | Response> {
|
|
114
|
+
const principal = await deps.authenticate(req);
|
|
115
|
+
if (principal === null) return unauthorized(origin);
|
|
116
|
+
const admission = deps.admit ? await deps.admit(principal) : { ok: true as const };
|
|
117
|
+
return admission.ok ? principal : notAdmitted(admission, origin);
|
|
118
|
+
}
|
|
119
|
+
|
|
98
120
|
/** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
|
|
99
121
|
function preflight(origin: string): Response {
|
|
100
122
|
return new Response(null, { status: 204, headers: corsHeaders(origin) });
|
|
@@ -127,8 +149,8 @@ export function openApiResponse(corsOrigin = "*"): Response {
|
|
|
127
149
|
export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response> {
|
|
128
150
|
const origin = deps.corsOrigin ?? "*";
|
|
129
151
|
const cors = corsHeaders(origin);
|
|
130
|
-
const principal = await
|
|
131
|
-
if (principal
|
|
152
|
+
const principal = await caller(req, deps, origin);
|
|
153
|
+
if (principal instanceof Response) return principal;
|
|
132
154
|
let body: { text?: unknown; conversationId?: unknown };
|
|
133
155
|
try {
|
|
134
156
|
body = (await req.json()) as typeof body;
|
|
@@ -236,6 +258,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
236
258
|
|
|
237
259
|
export type ConfirmRequestDeps = {
|
|
238
260
|
authenticate: Authenticate;
|
|
261
|
+
admit?: AdmitFn;
|
|
239
262
|
resolveConfirmation: ResolveConfirmationFn;
|
|
240
263
|
corsOrigin?: string;
|
|
241
264
|
};
|
|
@@ -255,8 +278,8 @@ export type ConfirmRequestDeps = {
|
|
|
255
278
|
export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response> {
|
|
256
279
|
const origin = deps.corsOrigin ?? "*";
|
|
257
280
|
const cors = corsHeaders(origin);
|
|
258
|
-
const principal = await
|
|
259
|
-
if (principal
|
|
281
|
+
const principal = await caller(req, deps, origin);
|
|
282
|
+
if (principal instanceof Response) return principal;
|
|
260
283
|
let body: { token?: unknown; conversationId?: unknown };
|
|
261
284
|
try {
|
|
262
285
|
body = (await req.json()) as typeof body;
|
|
@@ -283,6 +306,33 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
|
|
|
283
306
|
}
|
|
284
307
|
}
|
|
285
308
|
|
|
309
|
+
export type LinkRequestDeps = {
|
|
310
|
+
authenticate: Authenticate;
|
|
311
|
+
admit?: AdmitFn;
|
|
312
|
+
/** Hands the caller a link code, injected by the core (`ctx.linking.start`). */
|
|
313
|
+
start: ChannelLinking["start"];
|
|
314
|
+
corsOrigin?: string;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* `POST /link` — a one-time code for linking another account to the caller
|
|
319
|
+
* (the custom UI shows it). The person then sends it from the other account,
|
|
320
|
+
* on any channel. Nobody but the caller ever sees it, the model included, and
|
|
321
|
+
* it isn't cached. `400` with the core's reason when there's no code for them.
|
|
322
|
+
*/
|
|
323
|
+
export async function handleLinkRequest(req: Request, deps: LinkRequestDeps): Promise<Response> {
|
|
324
|
+
const origin = deps.corsOrigin ?? "*";
|
|
325
|
+
const principal = await caller(req, deps, origin);
|
|
326
|
+
if (principal instanceof Response) return principal;
|
|
327
|
+
const started = await deps.start(principal);
|
|
328
|
+
return Response.json(started, { status: started.ok ? 200 : 400, headers: { ...corsHeaders(origin), "cache-control": "no-store" } });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** `POST /link` and its preflight, mounted when the core offers linking. */
|
|
332
|
+
function linkRoutes(deps: LinkRequestDeps): Record<string, { POST: (req: Request) => Promise<Response>; OPTIONS: () => Response }> {
|
|
333
|
+
return { "/link": { POST: (req) => handleLinkRequest(req, deps), OPTIONS: () => preflight(deps.corsOrigin ?? "*") } };
|
|
334
|
+
}
|
|
335
|
+
|
|
286
336
|
/** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
|
|
287
337
|
export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
|
|
288
338
|
|
|
@@ -340,7 +390,12 @@ type ReadRoute = {
|
|
|
340
390
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
341
391
|
* same for a 400.
|
|
342
392
|
*/
|
|
343
|
-
export function readRoutes(
|
|
393
|
+
export function readRoutes(
|
|
394
|
+
reads: ChannelHostReads,
|
|
395
|
+
authenticate: Authenticate,
|
|
396
|
+
corsOrigin = "*",
|
|
397
|
+
admit?: AdmitFn,
|
|
398
|
+
): Record<string, ReadRoute> {
|
|
344
399
|
const cors = corsHeaders(corsOrigin);
|
|
345
400
|
const json = (payload: object, status = 200): Response =>
|
|
346
401
|
Response.json(payload, { status, headers: cors });
|
|
@@ -349,14 +404,14 @@ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate,
|
|
|
349
404
|
const options = () => preflight(corsOrigin);
|
|
350
405
|
const route = (GET: (req: Request, principal: Principal) => Response | Promise<Response>): ReadRoute => ({
|
|
351
406
|
GET: async (req) => {
|
|
352
|
-
const principal = await authenticate(
|
|
353
|
-
return principal
|
|
407
|
+
const principal = await caller(req, { authenticate, ...(admit === undefined ? {} : { admit }) }, corsOrigin);
|
|
408
|
+
return principal instanceof Response ? principal : GET(req, principal);
|
|
354
409
|
},
|
|
355
410
|
OPTIONS: options,
|
|
356
411
|
});
|
|
357
412
|
return {
|
|
358
|
-
"/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
|
|
359
|
-
"/confirmations": route((_req, principal) => json({ ok: true, pending: reads.pendingConfirmations(principal) })),
|
|
413
|
+
"/manifest": route(async (_req, principal) => json({ ok: true, manifest: await reads.manifest(principal) })),
|
|
414
|
+
"/confirmations": route(async (_req, principal) => json({ ok: true, pending: await reads.pendingConfirmations(principal) })),
|
|
360
415
|
"/conversation": route(async (req, principal) => {
|
|
361
416
|
const url = new URL(req.url);
|
|
362
417
|
const id = url.searchParams.get("id");
|
|
@@ -379,7 +434,7 @@ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate,
|
|
|
379
434
|
.map((c) => ({ conversationId: c.sessionKey.slice(prefix.length), lastTimestamp: c.lastTimestamp, preview: c.preview })),
|
|
380
435
|
});
|
|
381
436
|
}),
|
|
382
|
-
"/tool-log": route((_req, principal) => json({ ok: true, entries: reads.toolLog(principal) })),
|
|
437
|
+
"/tool-log": route(async (_req, principal) => json({ ok: true, entries: await reads.toolLog(principal) })),
|
|
383
438
|
"/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
|
|
384
439
|
"/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
|
|
385
440
|
"/wiki/read": route(async (req, principal) => {
|
|
@@ -412,6 +467,8 @@ export type HttpServerDeps = TurnRequestDeps & {
|
|
|
412
467
|
resolveConfirmation: ResolveConfirmationFn;
|
|
413
468
|
/** Finishes people's logins; `/login/callback` is mounted only with it. */
|
|
414
469
|
completeLogin?: CompleteLoginFn;
|
|
470
|
+
/** Linking another account to the caller; `/link` is mounted only with it. */
|
|
471
|
+
linking?: ChannelLinking;
|
|
415
472
|
};
|
|
416
473
|
|
|
417
474
|
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
@@ -433,12 +490,18 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
|
|
|
433
490
|
"/turn": { POST: (req) => handleTurnRequest(req, deps), OPTIONS: () => preflight(origin) },
|
|
434
491
|
"/confirm": {
|
|
435
492
|
POST: (req) =>
|
|
436
|
-
handleConfirmRequest(req, {
|
|
493
|
+
handleConfirmRequest(req, {
|
|
494
|
+
authenticate: deps.authenticate,
|
|
495
|
+
...(deps.admit === undefined ? {} : { admit: deps.admit }),
|
|
496
|
+
resolveConfirmation: deps.resolveConfirmation,
|
|
497
|
+
corsOrigin: origin,
|
|
498
|
+
}),
|
|
437
499
|
OPTIONS: () => preflight(origin),
|
|
438
500
|
},
|
|
439
501
|
"/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
|
|
440
|
-
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin) : {}),
|
|
502
|
+
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
|
|
441
503
|
...(deps.completeLogin ? loginRoutes(deps.completeLogin) : {}),
|
|
504
|
+
...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
|
|
442
505
|
},
|
|
443
506
|
error: (err) =>
|
|
444
507
|
Response.json(
|
package/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
|
|
22
22
|
export const httpChannel: ChannelPlugin = {
|
|
23
23
|
// The contract this channel is written for, as a literal: importing
|
|
24
24
|
// CHANNEL_API_VERSION would report whichever contract is installed.
|
|
25
|
-
apiVersion:
|
|
25
|
+
apiVersion: 5,
|
|
26
26
|
name: "http",
|
|
27
27
|
build: (ctx) => {
|
|
28
28
|
if (!ctx.resolveConfirmation || !ctx.reads) {
|
|
@@ -42,6 +42,8 @@ export const httpChannel: ChannelPlugin = {
|
|
|
42
42
|
resolveConfirmation: ctx.resolveConfirmation,
|
|
43
43
|
reads: ctx.reads,
|
|
44
44
|
authenticate: ctx.authenticate,
|
|
45
|
+
admit: ctx.admit,
|
|
46
|
+
...(ctx.linking === undefined ? {} : { linking: ctx.linking }),
|
|
45
47
|
...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
|
|
46
48
|
});
|
|
47
49
|
},
|
package/openapi.yaml
CHANGED
|
@@ -20,14 +20,20 @@ info:
|
|
|
20
20
|
missing or refused one gets `401` with `WWW-Authenticate: Bearer`, before
|
|
21
21
|
anything runs.
|
|
22
22
|
|
|
23
|
+
Then the core decides whether it talks to that person at all: someone the
|
|
24
|
+
app's directory doesn't know gets `403` (unless the app lets unknown people
|
|
25
|
+
in), and `503` when the directory can't be reached. Both carry
|
|
26
|
+
`{ ok: false, error, reason }`, `error` being what to tell them.
|
|
27
|
+
|
|
23
28
|
|
|
24
29
|
## What a caller sees
|
|
25
30
|
|
|
26
31
|
Every read is scoped to the caller: their own conversations, pending
|
|
27
32
|
confirmations, tool calls and memory, and the wiki as they see it, the
|
|
28
33
|
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.
|
|
30
|
-
`/manifest` and `/health`
|
|
34
|
+
Someone else's data doesn't exist as far as a caller can tell.
|
|
35
|
+
`/manifest` lists what the caller is offered, and only `/health` is the
|
|
36
|
+
same for everyone.
|
|
31
37
|
|
|
32
38
|
|
|
33
39
|
## Conversations
|
|
@@ -82,6 +88,10 @@ paths:
|
|
|
82
88
|
responses:
|
|
83
89
|
"401":
|
|
84
90
|
$ref: "#/components/responses/Unauthorized"
|
|
91
|
+
"403":
|
|
92
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
93
|
+
"503":
|
|
94
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
85
95
|
"200":
|
|
86
96
|
description: >
|
|
87
97
|
An SSE stream. Each event is one of the types in `TurnEvent`:
|
|
@@ -117,6 +127,10 @@ paths:
|
|
|
117
127
|
responses:
|
|
118
128
|
"401":
|
|
119
129
|
$ref: "#/components/responses/Unauthorized"
|
|
130
|
+
"403":
|
|
131
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
132
|
+
"503":
|
|
133
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
120
134
|
"200":
|
|
121
135
|
description: >
|
|
122
136
|
`resolved: true` (with the reply `text`) when the token was a pending
|
|
@@ -132,6 +146,45 @@ paths:
|
|
|
132
146
|
application/json:
|
|
133
147
|
schema:
|
|
134
148
|
$ref: "#/components/schemas/Error"
|
|
149
|
+
/link:
|
|
150
|
+
post:
|
|
151
|
+
summary: A code for linking another account to the caller
|
|
152
|
+
description: >
|
|
153
|
+
A one-time code (`xxxx-xxxx-xxxx`, valid for ten minutes) the person
|
|
154
|
+
sends from another account, on any channel, twice: the first time
|
|
155
|
+
Mercury says whom the account would be linked to, the second time it
|
|
156
|
+
links it. From then on both accounts are one person. Only the caller
|
|
157
|
+
sees the code; the model never does.
|
|
158
|
+
responses:
|
|
159
|
+
"401":
|
|
160
|
+
$ref: "#/components/responses/Unauthorized"
|
|
161
|
+
"403":
|
|
162
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
163
|
+
"503":
|
|
164
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
165
|
+
"400":
|
|
166
|
+
description: No code for this caller, with the reason.
|
|
167
|
+
content:
|
|
168
|
+
application/json:
|
|
169
|
+
schema:
|
|
170
|
+
$ref: "#/components/schemas/Error"
|
|
171
|
+
"200":
|
|
172
|
+
description: The code and when it expires.
|
|
173
|
+
content:
|
|
174
|
+
application/json:
|
|
175
|
+
schema:
|
|
176
|
+
type: object
|
|
177
|
+
required: [ok, code, expiresAt]
|
|
178
|
+
properties:
|
|
179
|
+
ok:
|
|
180
|
+
type: boolean
|
|
181
|
+
enum: [true]
|
|
182
|
+
code:
|
|
183
|
+
type: string
|
|
184
|
+
pattern: "^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$"
|
|
185
|
+
expiresAt:
|
|
186
|
+
type: string
|
|
187
|
+
format: date-time
|
|
135
188
|
/conversation:
|
|
136
189
|
get:
|
|
137
190
|
summary: A conversation's durable transcript
|
|
@@ -158,6 +211,10 @@ paths:
|
|
|
158
211
|
responses:
|
|
159
212
|
"401":
|
|
160
213
|
$ref: "#/components/responses/Unauthorized"
|
|
214
|
+
"403":
|
|
215
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
216
|
+
"503":
|
|
217
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
161
218
|
"200":
|
|
162
219
|
description: The conversation's messages in chronological order; none when it isn't one of the caller's.
|
|
163
220
|
content:
|
|
@@ -183,6 +240,10 @@ paths:
|
|
|
183
240
|
responses:
|
|
184
241
|
"401":
|
|
185
242
|
$ref: "#/components/responses/Unauthorized"
|
|
243
|
+
"403":
|
|
244
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
245
|
+
"503":
|
|
246
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
186
247
|
"200":
|
|
187
248
|
description: The conversation list.
|
|
188
249
|
content:
|
|
@@ -195,6 +256,10 @@ paths:
|
|
|
195
256
|
responses:
|
|
196
257
|
"401":
|
|
197
258
|
$ref: "#/components/responses/Unauthorized"
|
|
259
|
+
"403":
|
|
260
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
261
|
+
"503":
|
|
262
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
198
263
|
"200":
|
|
199
264
|
description: Health snapshot.
|
|
200
265
|
content:
|
|
@@ -203,10 +268,14 @@ paths:
|
|
|
203
268
|
type: object
|
|
204
269
|
/manifest:
|
|
205
270
|
get:
|
|
206
|
-
summary: The
|
|
271
|
+
summary: The plugins, tools and skills the caller is offered
|
|
207
272
|
responses:
|
|
208
273
|
"401":
|
|
209
274
|
$ref: "#/components/responses/Unauthorized"
|
|
275
|
+
"403":
|
|
276
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
277
|
+
"503":
|
|
278
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
210
279
|
"200":
|
|
211
280
|
description: Plugin manifest.
|
|
212
281
|
content:
|
|
@@ -219,6 +288,10 @@ paths:
|
|
|
219
288
|
responses:
|
|
220
289
|
"401":
|
|
221
290
|
$ref: "#/components/responses/Unauthorized"
|
|
291
|
+
"403":
|
|
292
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
293
|
+
"503":
|
|
294
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
222
295
|
"200":
|
|
223
296
|
description: Pending confirmations.
|
|
224
297
|
content:
|
|
@@ -231,6 +304,10 @@ paths:
|
|
|
231
304
|
responses:
|
|
232
305
|
"401":
|
|
233
306
|
$ref: "#/components/responses/Unauthorized"
|
|
307
|
+
"403":
|
|
308
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
309
|
+
"503":
|
|
310
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
234
311
|
"200":
|
|
235
312
|
description: Tool log.
|
|
236
313
|
content:
|
|
@@ -243,6 +320,10 @@ paths:
|
|
|
243
320
|
responses:
|
|
244
321
|
"401":
|
|
245
322
|
$ref: "#/components/responses/Unauthorized"
|
|
323
|
+
"403":
|
|
324
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
325
|
+
"503":
|
|
326
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
246
327
|
"200":
|
|
247
328
|
description: File list.
|
|
248
329
|
content:
|
|
@@ -262,6 +343,10 @@ paths:
|
|
|
262
343
|
responses:
|
|
263
344
|
"401":
|
|
264
345
|
$ref: "#/components/responses/Unauthorized"
|
|
346
|
+
"403":
|
|
347
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
348
|
+
"503":
|
|
349
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
265
350
|
"200":
|
|
266
351
|
description: File content.
|
|
267
352
|
content:
|
|
@@ -293,6 +378,10 @@ paths:
|
|
|
293
378
|
responses:
|
|
294
379
|
"401":
|
|
295
380
|
$ref: "#/components/responses/Unauthorized"
|
|
381
|
+
"403":
|
|
382
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
383
|
+
"503":
|
|
384
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
296
385
|
"200":
|
|
297
386
|
description: Matches.
|
|
298
387
|
content:
|
|
@@ -329,6 +418,10 @@ paths:
|
|
|
329
418
|
responses:
|
|
330
419
|
"401":
|
|
331
420
|
$ref: "#/components/responses/Unauthorized"
|
|
421
|
+
"403":
|
|
422
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
423
|
+
"503":
|
|
424
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
332
425
|
"200":
|
|
333
426
|
description: A page of points.
|
|
334
427
|
content:
|
|
@@ -396,6 +489,26 @@ components:
|
|
|
396
489
|
application/json:
|
|
397
490
|
schema:
|
|
398
491
|
$ref: "#/components/schemas/Error"
|
|
492
|
+
NotAdmitted:
|
|
493
|
+
description: >
|
|
494
|
+
An authenticated caller the core won't talk to: `403` when the app's
|
|
495
|
+
directory doesn't know them, `503` when it can't be reached. Nothing
|
|
496
|
+
behind the route runs.
|
|
497
|
+
content:
|
|
498
|
+
application/json:
|
|
499
|
+
schema:
|
|
500
|
+
type: object
|
|
501
|
+
required: [ok, error, reason]
|
|
502
|
+
properties:
|
|
503
|
+
ok:
|
|
504
|
+
type: boolean
|
|
505
|
+
enum: [false]
|
|
506
|
+
error:
|
|
507
|
+
type: string
|
|
508
|
+
description: What to tell the caller.
|
|
509
|
+
reason:
|
|
510
|
+
type: string
|
|
511
|
+
enum: [unknown, unavailable]
|
|
399
512
|
schemas:
|
|
400
513
|
TurnRequest:
|
|
401
514
|
type: object
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/channel-http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
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.44.0",
|
|
25
|
+
"@mercury-fw/plugin-types": "0.44.0",
|
|
26
26
|
"@mercury-fw/typescript-config": "*",
|
|
27
27
|
"@types/bun": "^1.4.2",
|
|
28
28
|
"typescript": "^6.0.3"
|