@mercury-fw/channel-http 0.4.0 → 0.6.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 +38 -0
- package/README.md +10 -2
- package/dist/http-provider.d.ts +10 -1
- package/dist/http-server.d.ts +25 -4
- package/dist/index.d.ts +4 -2
- package/http-provider.ts +9 -1
- package/http-server.ts +96 -14
- package/index.ts +11 -3
- package/openapi.yaml +110 -4
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# @mercury-fw/channel-http
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a7f2f13: A user directory decides who people are and what they may make Mercury do.
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- `mercury.act-as-self` (every plugin) or `mercury.act-as-self.<plugin>` (one) lets a person make Mercury act with its own identity.
|
|
12
|
+
- A plugin acting as Mercury, such as atlassian-admin, is offered only to those people, and on the terminal.
|
|
13
|
+
- A plugin acting as the person also gives them each tool a second time, as `<tool>AsMercury`.
|
|
14
|
+
- Every call made as Mercury for someone is logged with who asked.
|
|
15
|
+
- `access.plugins` restricts a plugin to some roles. Prompts, skills and the manifest follow what each person is offered.
|
|
16
|
+
- New package `@mercury-fw/directory-static`: people, their identities on each channel and their roles, listed in `DIRECTORY_STATIC_PEOPLE`.
|
|
17
|
+
- The channel contract is at version 5:
|
|
18
|
+
- `ChannelRuntimeContext.admit` says whether the core talks to a caller;
|
|
19
|
+
- the reads are per person and async, the manifest included;
|
|
20
|
+
- `DirectoryPlugin` is the directory contract;
|
|
21
|
+
- `Principal` no longer carries `roles`.
|
|
22
|
+
- `channel-http` answers `403` to a caller the directory doesn't know and `503` when it can't be reached, on every route.
|
|
23
|
+
- `auth-static` refuses `roles` in `AUTH_STATIC_TOKENS`: roles come from the directory.
|
|
24
|
+
|
|
25
|
+
## 0.5.0
|
|
26
|
+
|
|
27
|
+
### Minor Changes
|
|
28
|
+
|
|
29
|
+
- 0739f77: A plugin acts as the person Mercury is talking to, who logs in to the service through Mercury.
|
|
30
|
+
|
|
31
|
+
- Plugin contract version 4: a plugin declares `actsAs: "person" | "mercury"`, and one acting as the person contributes a `login`; its tools get the turn's `person` and `requireLogin` in their context.
|
|
32
|
+
- A person is offered only the plugins acting as the person: their prompt fragments, skills and tools. A plugin acting as Mercury, or declaring nothing, is offered on the terminal only until people can be allowed to make Mercury act as itself; the startup log names it.
|
|
33
|
+
- `createCliTool` adds `--user` with the person's id to every command, staged confirmations included, and when the CLI exits with code 3 (not logged in) returns their login instead. `createCliPersonLogin` builds a plugin's `login` on a CLI's two-step remote login. A failed `runCli` carries `exitCode`.
|
|
34
|
+
- Channel contract version 4: channels get `logins` (`accept` a callback URL, `complete` a login) and `detectLoginRequired`.
|
|
35
|
+
- The HTTP channel, with `HTTP_SURFACE_PUBLIC_URL` set, streams a `login` event with the link to open and takes the person back at `GET /login/callback`, public and protected by the single-use state.
|
|
36
|
+
- `mercury.cliCredentials` can declare `userSetup`, run by `mfw credentials setup <plugin> --user-app`: the app people log in through, set up without logging anyone in.
|
|
37
|
+
- plugin-jira and plugin-bitbucket act as the person, on CLIs 2.3.0: set up their people's app with `--user-app` and register `<HTTP_SURFACE_PUBLIC_URL>/login/callback` on it. Jira's skill now says `currentUser()` is the person.
|
|
38
|
+
- plugin-atlassian-admin acts as Mercury, on CLI 0.2.0, whose `init` asks for the key without echoing it: until permissions exist it's available on the terminal only.
|
|
39
|
+
- A third-party plugin needs `apiVersion: 4` to load, and `actsAs: "person"` with a `login` to be offered to people.
|
|
40
|
+
|
|
3
41
|
## 0.4.0
|
|
4
42
|
|
|
5
43
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -20,12 +20,13 @@ It needs an auth provider next to it: [`@mercury-fw/auth-oidc`](../../auth/auth-
|
|
|
20
20
|
|---|---|
|
|
21
21
|
| `HTTP_SURFACE_PORT` | The port (default `4100`). |
|
|
22
22
|
| `HTTP_SURFACE_CORS_ORIGIN` | The origin a browser UI calls from (default `*`). |
|
|
23
|
+
| `HTTP_SURFACE_PUBLIC_URL` | The address people's browsers reach the surface at (e.g. `https://mercury.example.com`). With it, people log in here to the services plugins act on as them (see [`GET /login/callback`](#get-logincallback)); without it, nobody can. |
|
|
23
24
|
|
|
24
25
|
## API
|
|
25
26
|
|
|
26
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>`.
|
|
27
28
|
|
|
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
|
+
**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.
|
|
29
30
|
|
|
30
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.
|
|
31
32
|
|
|
@@ -60,6 +61,7 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
|
|
|
60
61
|
| `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
|
|
61
62
|
| `text` | `{ chunk }` | an answer-text delta |
|
|
62
63
|
| `pending` | `{ command, token }` | a confirm-required action was staged; send `token` back to confirm it — as a later `/turn` `text`, or via `POST /confirm` |
|
|
64
|
+
| `login` | `{ service, url }` | the caller isn't logged in to `service` yet, which a plugin acts on as them: show them `url` to open (the model never gets it) |
|
|
63
65
|
| `final` | `{ text }` | the complete answer (also emitted for a token confirmation, with no model turn) |
|
|
64
66
|
| `error` | `{ message }` | the turn failed mid-stream |
|
|
65
67
|
|
|
@@ -70,13 +72,19 @@ curl -N -X POST http://localhost:4100/turn \
|
|
|
70
72
|
-d '{"text":"Quante issue nel progetto KAN?","conversationId":"c1"}'
|
|
71
73
|
```
|
|
72
74
|
|
|
75
|
+
## `GET /login/callback`
|
|
76
|
+
|
|
77
|
+
Where a person comes back after logging in to a service a plugin acts on as them (Jira, Bitbucket): the `login` event's `url` sends them to the service, and the service sends their browser here with `code` and `state`. Mounted only with `HTTP_SURFACE_PUBLIC_URL`, at `<HTTP_SURFACE_PUBLIC_URL>/login/callback`, which is the URL to register as a callback on the service's app (the Atlassian 3LO app, the Bitbucket consumer people log in through). No bearer token: the browser has none here, and the `state` Mercury issued for that person, single-use and valid for ten minutes, is what ties the request to their login. Answers with a plain-text page saying how it went.
|
|
78
|
+
|
|
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
|
+
|
|
73
81
|
## `POST /confirm`
|
|
74
82
|
|
|
75
83
|
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.
|
|
76
84
|
|
|
77
85
|
## Read-only introspection
|
|
78
86
|
|
|
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
|
|
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. `/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.
|
|
80
88
|
|
|
81
89
|
| endpoint | `data` on success |
|
|
82
90
|
|---|---|
|
package/dist/http-provider.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
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 ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
13
|
+
import { type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
14
|
import type { Authenticate, Provider, ChannelHostReads } from "@mercury-fw/channel-types";
|
|
15
15
|
export type HttpProviderDeps = {
|
|
16
16
|
port: number;
|
|
@@ -21,8 +21,17 @@ 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;
|
|
28
|
+
/** People's logins, when the surface has a public URL to be sent back to:
|
|
29
|
+
* the callback is offered (`accept`) once the server listens. */
|
|
30
|
+
logins?: {
|
|
31
|
+
callbackUrl: string;
|
|
32
|
+
accept: (callbackUrl: string) => void;
|
|
33
|
+
complete: CompleteLoginFn;
|
|
34
|
+
};
|
|
26
35
|
};
|
|
27
36
|
export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
|
|
28
37
|
stop(): Promise<void>;
|
package/dist/http-server.d.ts
CHANGED
|
@@ -12,22 +12,28 @@
|
|
|
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
|
-
*
|
|
15
|
+
* `/login/callback` is where a person's browser comes back after logging in to
|
|
16
|
+
* a service a plugin acts on as them; the single-use state is its credential.
|
|
17
|
+
*
|
|
18
|
+
* Every route but the OpenAPI document, the login callback and the CORS
|
|
19
|
+
* preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
|
|
17
20
|
* before anything runs, and answers 401 when nobody is. A conversation belongs
|
|
18
21
|
* to whoever opened it: the session key is `<principal.id>:<conversationId>`.
|
|
19
22
|
* Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
|
|
20
23
|
* package never imports the app.
|
|
21
24
|
*/
|
|
22
|
-
import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type Principal } from "@mercury-fw/channel-types";
|
|
25
|
+
import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type LoginOutcome, type Principal, type Admission } from "@mercury-fw/channel-types";
|
|
23
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`). */
|
|
24
27
|
export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
|
|
25
28
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
26
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>;
|
|
27
32
|
export type TurnRequestDeps = {
|
|
28
33
|
handleTurn: HandleTurn;
|
|
29
34
|
/** Who is calling, injected from the app's auth provider; `null` = refused. */
|
|
30
35
|
authenticate: Authenticate;
|
|
36
|
+
admit?: AdmitFn;
|
|
31
37
|
/** Bare-token interception before the model, injected by the core. */
|
|
32
38
|
confirm: ConfirmFn;
|
|
33
39
|
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
@@ -56,6 +62,7 @@ export declare function openApiResponse(corsOrigin?: string): Response;
|
|
|
56
62
|
export declare function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response>;
|
|
57
63
|
export type ConfirmRequestDeps = {
|
|
58
64
|
authenticate: Authenticate;
|
|
65
|
+
admit?: AdmitFn;
|
|
59
66
|
resolveConfirmation: ResolveConfirmationFn;
|
|
60
67
|
corsOrigin?: string;
|
|
61
68
|
};
|
|
@@ -72,6 +79,18 @@ export type ConfirmRequestDeps = {
|
|
|
72
79
|
* even token-shaped). Never touches the model.
|
|
73
80
|
*/
|
|
74
81
|
export declare function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response>;
|
|
82
|
+
/** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
|
|
83
|
+
export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
|
|
84
|
+
/**
|
|
85
|
+
* `GET /login/callback`: where the provider sends a person back after they log
|
|
86
|
+
* in, with `code` and `state` (or `error`, when they refused). Public: the
|
|
87
|
+
* person's browser carries no token here, and the single-use `state` the core
|
|
88
|
+
* issued for them is what ties the request to their login. Answers with a
|
|
89
|
+
* plain page telling them how it went.
|
|
90
|
+
*/
|
|
91
|
+
export declare function handleLoginCallback(req: Request, deps: {
|
|
92
|
+
complete: CompleteLoginFn;
|
|
93
|
+
}): Promise<Response>;
|
|
75
94
|
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
|
|
76
95
|
type ReadRoute = {
|
|
77
96
|
GET: (req: Request) => Response | Promise<Response>;
|
|
@@ -88,12 +107,14 @@ type ReadRoute = {
|
|
|
88
107
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
89
108
|
* same for a 400.
|
|
90
109
|
*/
|
|
91
|
-
export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string): Record<string, ReadRoute>;
|
|
110
|
+
export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string, admit?: AdmitFn): Record<string, ReadRoute>;
|
|
92
111
|
export type HttpServerDeps = TurnRequestDeps & {
|
|
93
112
|
port: number;
|
|
94
113
|
reads?: ChannelHostReads;
|
|
95
114
|
/** The `/confirm` endpoint's structured resolver, injected by the core. */
|
|
96
115
|
resolveConfirmation: ResolveConfirmationFn;
|
|
116
|
+
/** Finishes people's logins; `/login/callback` is mounted only with it. */
|
|
117
|
+
completeLogin?: CompleteLoginFn;
|
|
97
118
|
};
|
|
98
119
|
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
99
120
|
* when `reads` is supplied. */
|
package/dist/index.d.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* is no runtime inert condition (unlike Google Chat's subscription): if it's
|
|
7
7
|
* declared, it starts.
|
|
8
8
|
*
|
|
9
|
-
* `build` reads its port
|
|
9
|
+
* `build` reads its port, CORS origin and public URL from `env` and takes the confirm
|
|
10
10
|
* capabilities, the in-process reads and the app's auth provider from the
|
|
11
11
|
* injected context (dependency inversion). Those are optional on the contract
|
|
12
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
|
+
* fail-soft, and without an auth provider the surface never opens. With a
|
|
14
|
+
* public URL (`HTTP_SURFACE_PUBLIC_URL`) it also takes people's logins: it
|
|
15
|
+
* mounts `<url>/login/callback` and, once it's listening, offers it to the core.
|
|
14
16
|
*/
|
|
15
17
|
import type { ChannelPlugin } from "@mercury-fw/channel-types";
|
|
16
18
|
export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
package/http-provider.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
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 ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
13
|
+
import { startHttpServer, type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
14
|
import type { Authenticate, Provider, HandleTurn, ChannelHostReads } from "@mercury-fw/channel-types";
|
|
15
15
|
|
|
16
16
|
export type HttpProviderDeps = {
|
|
@@ -22,8 +22,13 @@ 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;
|
|
29
|
+
/** People's logins, when the surface has a public URL to be sent back to:
|
|
30
|
+
* the callback is offered (`accept`) once the server listens. */
|
|
31
|
+
logins?: { callbackUrl: string; accept: (callbackUrl: string) => void; complete: CompleteLoginFn };
|
|
27
32
|
};
|
|
28
33
|
|
|
29
34
|
export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
|
|
@@ -37,8 +42,11 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
|
|
|
37
42
|
resolveConfirmation: deps.resolveConfirmation,
|
|
38
43
|
reads: deps.reads,
|
|
39
44
|
authenticate: deps.authenticate,
|
|
45
|
+
...(deps.admit === undefined ? {} : { admit: deps.admit }),
|
|
40
46
|
corsOrigin: deps.corsOrigin,
|
|
47
|
+
completeLogin: deps.logins?.complete,
|
|
41
48
|
});
|
|
49
|
+
deps.logins?.accept(deps.logins.callbackUrl);
|
|
42
50
|
},
|
|
43
51
|
async notify(): Promise<{ sessionKey: string }> {
|
|
44
52
|
// No proactive push channel over HTTP (request/response only).
|
package/http-server.ts
CHANGED
|
@@ -12,14 +12,18 @@
|
|
|
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
|
-
*
|
|
15
|
+
* `/login/callback` is where a person's browser comes back after logging in to
|
|
16
|
+
* a service a plugin acts on as them; the single-use state is its credential.
|
|
17
|
+
*
|
|
18
|
+
* Every route but the OpenAPI document, the login callback and the CORS
|
|
19
|
+
* preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
|
|
17
20
|
* before anything runs, and answers 401 when nobody is. A conversation belongs
|
|
18
21
|
* to whoever opened it: the session key is `<principal.id>:<conversationId>`.
|
|
19
22
|
* Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
|
|
20
23
|
* package never imports the app.
|
|
21
24
|
*/
|
|
22
25
|
import {
|
|
26
|
+
detectLoginRequired,
|
|
23
27
|
detectPendingConfirmation,
|
|
24
28
|
PENDING_CONFIRMATION_NOTE,
|
|
25
29
|
type HandleTurn,
|
|
@@ -27,7 +31,9 @@ import {
|
|
|
27
31
|
type Authenticate,
|
|
28
32
|
type ChannelHostReads,
|
|
29
33
|
type ConfirmOutcome,
|
|
34
|
+
type LoginOutcome,
|
|
30
35
|
type Principal,
|
|
36
|
+
type Admission,
|
|
31
37
|
} from "@mercury-fw/channel-types";
|
|
32
38
|
|
|
33
39
|
/**
|
|
@@ -48,10 +54,14 @@ export type ConfirmFn = (token: string, sessionKey: string, principal: Principal
|
|
|
48
54
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
49
55
|
export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
|
|
50
56
|
|
|
57
|
+
/** Whether the core talks to an authenticated caller at all, injected by the core (`ctx.admit`); everyone when absent. */
|
|
58
|
+
export type AdmitFn = (principal: Principal) => Promise<Admission>;
|
|
59
|
+
|
|
51
60
|
export type TurnRequestDeps = {
|
|
52
61
|
handleTurn: HandleTurn;
|
|
53
62
|
/** Who is calling, injected from the app's auth provider; `null` = refused. */
|
|
54
63
|
authenticate: Authenticate;
|
|
64
|
+
admit?: AdmitFn;
|
|
55
65
|
/** Bare-token interception before the model, injected by the core. */
|
|
56
66
|
confirm: ConfirmFn;
|
|
57
67
|
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
@@ -90,6 +100,22 @@ function unauthorized(origin: string): Response {
|
|
|
90
100
|
);
|
|
91
101
|
}
|
|
92
102
|
|
|
103
|
+
/** 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. */
|
|
104
|
+
function notAdmitted(admission: Exclude<Admission, { ok: true }>, origin: string): Response {
|
|
105
|
+
return Response.json(
|
|
106
|
+
{ ok: false, error: admission.message, reason: admission.reason },
|
|
107
|
+
{ status: admission.reason === "unknown" ? 403 : 503, headers: corsHeaders(origin) },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The caller of `req`, authenticated and admitted, or the response refusing them. */
|
|
112
|
+
async function caller(req: Request, deps: { authenticate: Authenticate; admit?: AdmitFn }, origin: string): Promise<Principal | Response> {
|
|
113
|
+
const principal = await deps.authenticate(req);
|
|
114
|
+
if (principal === null) return unauthorized(origin);
|
|
115
|
+
const admission = deps.admit ? await deps.admit(principal) : { ok: true as const };
|
|
116
|
+
return admission.ok ? principal : notAdmitted(admission, origin);
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
/** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
|
|
94
120
|
function preflight(origin: string): Response {
|
|
95
121
|
return new Response(null, { status: 204, headers: corsHeaders(origin) });
|
|
@@ -122,8 +148,8 @@ export function openApiResponse(corsOrigin = "*"): Response {
|
|
|
122
148
|
export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response> {
|
|
123
149
|
const origin = deps.corsOrigin ?? "*";
|
|
124
150
|
const cors = corsHeaders(origin);
|
|
125
|
-
const principal = await
|
|
126
|
-
if (principal
|
|
151
|
+
const principal = await caller(req, deps, origin);
|
|
152
|
+
if (principal instanceof Response) return principal;
|
|
127
153
|
let body: { text?: unknown; conversationId?: unknown };
|
|
128
154
|
try {
|
|
129
155
|
body = (await req.json()) as typeof body;
|
|
@@ -188,6 +214,8 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
188
214
|
onStep: (step) => {
|
|
189
215
|
const pending = detectPendingConfirmation(step);
|
|
190
216
|
if (pending) send("pending", { command: pending.summary, token: pending.token });
|
|
217
|
+
const login = detectLoginRequired(step);
|
|
218
|
+
if (login) send("login", { service: login.service, url: login.url });
|
|
191
219
|
},
|
|
192
220
|
onUsage: () => {},
|
|
193
221
|
finalize: async (finalText) => send("final", { text: finalText }),
|
|
@@ -229,6 +257,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
229
257
|
|
|
230
258
|
export type ConfirmRequestDeps = {
|
|
231
259
|
authenticate: Authenticate;
|
|
260
|
+
admit?: AdmitFn;
|
|
232
261
|
resolveConfirmation: ResolveConfirmationFn;
|
|
233
262
|
corsOrigin?: string;
|
|
234
263
|
};
|
|
@@ -248,8 +277,8 @@ export type ConfirmRequestDeps = {
|
|
|
248
277
|
export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response> {
|
|
249
278
|
const origin = deps.corsOrigin ?? "*";
|
|
250
279
|
const cors = corsHeaders(origin);
|
|
251
|
-
const principal = await
|
|
252
|
-
if (principal
|
|
280
|
+
const principal = await caller(req, deps, origin);
|
|
281
|
+
if (principal instanceof Response) return principal;
|
|
253
282
|
let body: { token?: unknown; conversationId?: unknown };
|
|
254
283
|
try {
|
|
255
284
|
body = (await req.json()) as typeof body;
|
|
@@ -276,6 +305,46 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
|
|
|
276
305
|
}
|
|
277
306
|
}
|
|
278
307
|
|
|
308
|
+
/** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
|
|
309
|
+
export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
|
|
310
|
+
|
|
311
|
+
/** A plain-text page for the person's browser, at the end of their login. */
|
|
312
|
+
function loginPage(text: string, status: number): Response {
|
|
313
|
+
// The URL carried the code and the state: neither kept nor passed on.
|
|
314
|
+
return new Response(text, {
|
|
315
|
+
status,
|
|
316
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" },
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* `GET /login/callback`: where the provider sends a person back after they log
|
|
322
|
+
* in, with `code` and `state` (or `error`, when they refused). Public: the
|
|
323
|
+
* person's browser carries no token here, and the single-use `state` the core
|
|
324
|
+
* issued for them is what ties the request to their login. Answers with a
|
|
325
|
+
* plain page telling them how it went.
|
|
326
|
+
*/
|
|
327
|
+
export async function handleLoginCallback(req: Request, deps: { complete: CompleteLoginFn }): Promise<Response> {
|
|
328
|
+
const params = new URL(req.url).searchParams;
|
|
329
|
+
const error = params.get("error");
|
|
330
|
+
if (error !== null) {
|
|
331
|
+
const reason = error.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64);
|
|
332
|
+
return loginPage(`The login didn't happen (${reason}). Ask Mercury again for a new link.`, 400);
|
|
333
|
+
}
|
|
334
|
+
const code = params.get("code");
|
|
335
|
+
const state = params.get("state");
|
|
336
|
+
if (!code || !state) return loginPage("The login link came back without a code or a state. Ask Mercury again for a new link.", 400);
|
|
337
|
+
const outcome = await deps.complete(state, code);
|
|
338
|
+
return outcome.ok
|
|
339
|
+
? loginPage(`You're logged in to ${outcome.service}. Go back to the conversation and ask again.`, 200)
|
|
340
|
+
: loginPage(outcome.error, 400);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The login callback route, mounted when the server can complete logins. */
|
|
344
|
+
function loginRoutes(complete: CompleteLoginFn): Record<string, { GET: (req: Request) => Promise<Response> }> {
|
|
345
|
+
return { "/login/callback": { GET: (req) => handleLoginCallback(req, { complete }) } };
|
|
346
|
+
}
|
|
347
|
+
|
|
279
348
|
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
|
|
280
349
|
type ReadRoute = {
|
|
281
350
|
GET: (req: Request) => Response | Promise<Response>;
|
|
@@ -293,7 +362,12 @@ type ReadRoute = {
|
|
|
293
362
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
294
363
|
* same for a 400.
|
|
295
364
|
*/
|
|
296
|
-
export function readRoutes(
|
|
365
|
+
export function readRoutes(
|
|
366
|
+
reads: ChannelHostReads,
|
|
367
|
+
authenticate: Authenticate,
|
|
368
|
+
corsOrigin = "*",
|
|
369
|
+
admit?: AdmitFn,
|
|
370
|
+
): Record<string, ReadRoute> {
|
|
297
371
|
const cors = corsHeaders(corsOrigin);
|
|
298
372
|
const json = (payload: object, status = 200): Response =>
|
|
299
373
|
Response.json(payload, { status, headers: cors });
|
|
@@ -302,14 +376,14 @@ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate,
|
|
|
302
376
|
const options = () => preflight(corsOrigin);
|
|
303
377
|
const route = (GET: (req: Request, principal: Principal) => Response | Promise<Response>): ReadRoute => ({
|
|
304
378
|
GET: async (req) => {
|
|
305
|
-
const principal = await authenticate(
|
|
306
|
-
return principal
|
|
379
|
+
const principal = await caller(req, { authenticate, ...(admit === undefined ? {} : { admit }) }, corsOrigin);
|
|
380
|
+
return principal instanceof Response ? principal : GET(req, principal);
|
|
307
381
|
},
|
|
308
382
|
OPTIONS: options,
|
|
309
383
|
});
|
|
310
384
|
return {
|
|
311
|
-
"/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
|
|
312
|
-
"/confirmations": route((_req, principal) => json({ ok: true, pending: reads.pendingConfirmations(principal) })),
|
|
385
|
+
"/manifest": route(async (_req, principal) => json({ ok: true, manifest: await reads.manifest(principal) })),
|
|
386
|
+
"/confirmations": route(async (_req, principal) => json({ ok: true, pending: await reads.pendingConfirmations(principal) })),
|
|
313
387
|
"/conversation": route(async (req, principal) => {
|
|
314
388
|
const url = new URL(req.url);
|
|
315
389
|
const id = url.searchParams.get("id");
|
|
@@ -332,7 +406,7 @@ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate,
|
|
|
332
406
|
.map((c) => ({ conversationId: c.sessionKey.slice(prefix.length), lastTimestamp: c.lastTimestamp, preview: c.preview })),
|
|
333
407
|
});
|
|
334
408
|
}),
|
|
335
|
-
"/tool-log": route((_req, principal) => json({ ok: true, entries: reads.toolLog(principal) })),
|
|
409
|
+
"/tool-log": route(async (_req, principal) => json({ ok: true, entries: await reads.toolLog(principal) })),
|
|
336
410
|
"/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
|
|
337
411
|
"/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
|
|
338
412
|
"/wiki/read": route(async (req, principal) => {
|
|
@@ -363,6 +437,8 @@ export type HttpServerDeps = TurnRequestDeps & {
|
|
|
363
437
|
reads?: ChannelHostReads;
|
|
364
438
|
/** The `/confirm` endpoint's structured resolver, injected by the core. */
|
|
365
439
|
resolveConfirmation: ResolveConfirmationFn;
|
|
440
|
+
/** Finishes people's logins; `/login/callback` is mounted only with it. */
|
|
441
|
+
completeLogin?: CompleteLoginFn;
|
|
366
442
|
};
|
|
367
443
|
|
|
368
444
|
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
@@ -384,11 +460,17 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
|
|
|
384
460
|
"/turn": { POST: (req) => handleTurnRequest(req, deps), OPTIONS: () => preflight(origin) },
|
|
385
461
|
"/confirm": {
|
|
386
462
|
POST: (req) =>
|
|
387
|
-
handleConfirmRequest(req, {
|
|
463
|
+
handleConfirmRequest(req, {
|
|
464
|
+
authenticate: deps.authenticate,
|
|
465
|
+
...(deps.admit === undefined ? {} : { admit: deps.admit }),
|
|
466
|
+
resolveConfirmation: deps.resolveConfirmation,
|
|
467
|
+
corsOrigin: origin,
|
|
468
|
+
}),
|
|
388
469
|
OPTIONS: () => preflight(origin),
|
|
389
470
|
},
|
|
390
471
|
"/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
|
|
391
|
-
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin) : {}),
|
|
472
|
+
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
|
|
473
|
+
...(deps.completeLogin ? loginRoutes(deps.completeLogin) : {}),
|
|
392
474
|
},
|
|
393
475
|
error: (err) =>
|
|
394
476
|
Response.json(
|
package/index.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* is no runtime inert condition (unlike Google Chat's subscription): if it's
|
|
7
7
|
* declared, it starts.
|
|
8
8
|
*
|
|
9
|
-
* `build` reads its port
|
|
9
|
+
* `build` reads its port, CORS origin and public URL from `env` and takes the confirm
|
|
10
10
|
* capabilities, the in-process reads and the app's auth provider from the
|
|
11
11
|
* injected context (dependency inversion). Those are optional on the contract
|
|
12
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
|
+
* fail-soft, and without an auth provider the surface never opens. With a
|
|
14
|
+
* public URL (`HTTP_SURFACE_PUBLIC_URL`) it also takes people's logins: it
|
|
15
|
+
* mounts `<url>/login/callback` and, once it's listening, offers it to the core.
|
|
14
16
|
*/
|
|
15
17
|
import type { ChannelPlugin } from "@mercury-fw/channel-types";
|
|
16
18
|
import { createHttpProvider } from "./http-provider.ts";
|
|
@@ -20,7 +22,7 @@ export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
|
|
20
22
|
export const httpChannel: ChannelPlugin = {
|
|
21
23
|
// The contract this channel is written for, as a literal: importing
|
|
22
24
|
// CHANNEL_API_VERSION would report whichever contract is installed.
|
|
23
|
-
apiVersion:
|
|
25
|
+
apiVersion: 5,
|
|
24
26
|
name: "http",
|
|
25
27
|
build: (ctx) => {
|
|
26
28
|
if (!ctx.resolveConfirmation || !ctx.reads) {
|
|
@@ -29,6 +31,10 @@ export const httpChannel: ChannelPlugin = {
|
|
|
29
31
|
if (!ctx.authenticate) {
|
|
30
32
|
throw new Error("http channel requires an auth provider (auth in mercury.config.ts)");
|
|
31
33
|
}
|
|
34
|
+
// The provider sends a person back here after they log in, so it needs
|
|
35
|
+
// the address their browser reaches the surface at.
|
|
36
|
+
const publicUrl = ctx.env.HTTP_SURFACE_PUBLIC_URL?.trim().replace(/\/+$/, "");
|
|
37
|
+
const logins = publicUrl ? ctx.logins : undefined;
|
|
32
38
|
return createHttpProvider({
|
|
33
39
|
port: Number(ctx.env.HTTP_SURFACE_PORT ?? "4100"),
|
|
34
40
|
corsOrigin: ctx.env.HTTP_SURFACE_CORS_ORIGIN ?? "*",
|
|
@@ -36,6 +42,8 @@ export const httpChannel: ChannelPlugin = {
|
|
|
36
42
|
resolveConfirmation: ctx.resolveConfirmation,
|
|
37
43
|
reads: ctx.reads,
|
|
38
44
|
authenticate: ctx.authenticate,
|
|
45
|
+
admit: ctx.admit,
|
|
46
|
+
...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
|
|
39
47
|
});
|
|
40
48
|
},
|
|
41
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,12 +88,18 @@ 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`:
|
|
88
98
|
`reasoning`/`reasoning_end`, `text` (incremental deltas), `tool`/
|
|
89
99
|
`tool_finish` (status of a tool call), `pending` (a staged
|
|
90
|
-
confirm-required action, with its command and token), `
|
|
100
|
+
confirm-required action, with its command and token), `login` (a
|
|
101
|
+
service the caller has to log in to first, with the link to open),
|
|
102
|
+
`final` (the
|
|
91
103
|
authoritative complete answer), and `error`.
|
|
92
104
|
content:
|
|
93
105
|
text/event-stream:
|
|
@@ -115,6 +127,10 @@ paths:
|
|
|
115
127
|
responses:
|
|
116
128
|
"401":
|
|
117
129
|
$ref: "#/components/responses/Unauthorized"
|
|
130
|
+
"403":
|
|
131
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
132
|
+
"503":
|
|
133
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
118
134
|
"200":
|
|
119
135
|
description: >
|
|
120
136
|
`resolved: true` (with the reply `text`) when the token was a pending
|
|
@@ -156,6 +172,10 @@ paths:
|
|
|
156
172
|
responses:
|
|
157
173
|
"401":
|
|
158
174
|
$ref: "#/components/responses/Unauthorized"
|
|
175
|
+
"403":
|
|
176
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
177
|
+
"503":
|
|
178
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
159
179
|
"200":
|
|
160
180
|
description: The conversation's messages in chronological order; none when it isn't one of the caller's.
|
|
161
181
|
content:
|
|
@@ -181,6 +201,10 @@ paths:
|
|
|
181
201
|
responses:
|
|
182
202
|
"401":
|
|
183
203
|
$ref: "#/components/responses/Unauthorized"
|
|
204
|
+
"403":
|
|
205
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
206
|
+
"503":
|
|
207
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
184
208
|
"200":
|
|
185
209
|
description: The conversation list.
|
|
186
210
|
content:
|
|
@@ -193,6 +217,10 @@ paths:
|
|
|
193
217
|
responses:
|
|
194
218
|
"401":
|
|
195
219
|
$ref: "#/components/responses/Unauthorized"
|
|
220
|
+
"403":
|
|
221
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
222
|
+
"503":
|
|
223
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
196
224
|
"200":
|
|
197
225
|
description: Health snapshot.
|
|
198
226
|
content:
|
|
@@ -201,10 +229,14 @@ paths:
|
|
|
201
229
|
type: object
|
|
202
230
|
/manifest:
|
|
203
231
|
get:
|
|
204
|
-
summary: The
|
|
232
|
+
summary: The plugins, tools and skills the caller is offered
|
|
205
233
|
responses:
|
|
206
234
|
"401":
|
|
207
235
|
$ref: "#/components/responses/Unauthorized"
|
|
236
|
+
"403":
|
|
237
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
238
|
+
"503":
|
|
239
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
208
240
|
"200":
|
|
209
241
|
description: Plugin manifest.
|
|
210
242
|
content:
|
|
@@ -217,6 +249,10 @@ paths:
|
|
|
217
249
|
responses:
|
|
218
250
|
"401":
|
|
219
251
|
$ref: "#/components/responses/Unauthorized"
|
|
252
|
+
"403":
|
|
253
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
254
|
+
"503":
|
|
255
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
220
256
|
"200":
|
|
221
257
|
description: Pending confirmations.
|
|
222
258
|
content:
|
|
@@ -229,6 +265,10 @@ paths:
|
|
|
229
265
|
responses:
|
|
230
266
|
"401":
|
|
231
267
|
$ref: "#/components/responses/Unauthorized"
|
|
268
|
+
"403":
|
|
269
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
270
|
+
"503":
|
|
271
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
232
272
|
"200":
|
|
233
273
|
description: Tool log.
|
|
234
274
|
content:
|
|
@@ -241,6 +281,10 @@ paths:
|
|
|
241
281
|
responses:
|
|
242
282
|
"401":
|
|
243
283
|
$ref: "#/components/responses/Unauthorized"
|
|
284
|
+
"403":
|
|
285
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
286
|
+
"503":
|
|
287
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
244
288
|
"200":
|
|
245
289
|
description: File list.
|
|
246
290
|
content:
|
|
@@ -260,6 +304,10 @@ paths:
|
|
|
260
304
|
responses:
|
|
261
305
|
"401":
|
|
262
306
|
$ref: "#/components/responses/Unauthorized"
|
|
307
|
+
"403":
|
|
308
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
309
|
+
"503":
|
|
310
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
263
311
|
"200":
|
|
264
312
|
description: File content.
|
|
265
313
|
content:
|
|
@@ -291,6 +339,10 @@ paths:
|
|
|
291
339
|
responses:
|
|
292
340
|
"401":
|
|
293
341
|
$ref: "#/components/responses/Unauthorized"
|
|
342
|
+
"403":
|
|
343
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
344
|
+
"503":
|
|
345
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
294
346
|
"200":
|
|
295
347
|
description: Matches.
|
|
296
348
|
content:
|
|
@@ -327,6 +379,10 @@ paths:
|
|
|
327
379
|
responses:
|
|
328
380
|
"401":
|
|
329
381
|
$ref: "#/components/responses/Unauthorized"
|
|
382
|
+
"403":
|
|
383
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
384
|
+
"503":
|
|
385
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
330
386
|
"200":
|
|
331
387
|
description: A page of points.
|
|
332
388
|
content:
|
|
@@ -339,6 +395,30 @@ paths:
|
|
|
339
395
|
application/json:
|
|
340
396
|
schema:
|
|
341
397
|
$ref: "#/components/schemas/Error"
|
|
398
|
+
/login/callback:
|
|
399
|
+
get:
|
|
400
|
+
summary: Where a person comes back after logging in to a service
|
|
401
|
+
description: >
|
|
402
|
+
Mounted when `HTTP_SURFACE_PUBLIC_URL` is set. The service sends the
|
|
403
|
+
person's browser here with `code` and `state` (or `error`); the
|
|
404
|
+
single-use state is the credential, so no bearer token is needed.
|
|
405
|
+
Answers with a plain-text page.
|
|
406
|
+
security: []
|
|
407
|
+
parameters:
|
|
408
|
+
- { name: code, in: query, schema: { type: string } }
|
|
409
|
+
- { name: state, in: query, schema: { type: string } }
|
|
410
|
+
- { name: error, in: query, schema: { type: string } }
|
|
411
|
+
responses:
|
|
412
|
+
"200":
|
|
413
|
+
description: Logged in.
|
|
414
|
+
content:
|
|
415
|
+
text/plain:
|
|
416
|
+
schema: { type: string }
|
|
417
|
+
"400":
|
|
418
|
+
description: Refused, expired, already used, or missing code/state.
|
|
419
|
+
content:
|
|
420
|
+
text/plain:
|
|
421
|
+
schema: { type: string }
|
|
342
422
|
/openapi.yaml:
|
|
343
423
|
get:
|
|
344
424
|
summary: This OpenAPI document
|
|
@@ -370,6 +450,26 @@ components:
|
|
|
370
450
|
application/json:
|
|
371
451
|
schema:
|
|
372
452
|
$ref: "#/components/schemas/Error"
|
|
453
|
+
NotAdmitted:
|
|
454
|
+
description: >
|
|
455
|
+
An authenticated caller the core won't talk to: `403` when the app's
|
|
456
|
+
directory doesn't know them, `503` when it can't be reached. Nothing
|
|
457
|
+
behind the route runs.
|
|
458
|
+
content:
|
|
459
|
+
application/json:
|
|
460
|
+
schema:
|
|
461
|
+
type: object
|
|
462
|
+
required: [ok, error, reason]
|
|
463
|
+
properties:
|
|
464
|
+
ok:
|
|
465
|
+
type: boolean
|
|
466
|
+
enum: [false]
|
|
467
|
+
error:
|
|
468
|
+
type: string
|
|
469
|
+
description: What to tell the caller.
|
|
470
|
+
reason:
|
|
471
|
+
type: string
|
|
472
|
+
enum: [unknown, unavailable]
|
|
373
473
|
schemas:
|
|
374
474
|
TurnRequest:
|
|
375
475
|
type: object
|
|
@@ -416,6 +516,12 @@ components:
|
|
|
416
516
|
token:
|
|
417
517
|
type: string
|
|
418
518
|
description: On `pending` — the one-time confirmation token.
|
|
519
|
+
service:
|
|
520
|
+
type: string
|
|
521
|
+
description: On `login` — the service the caller has to log in to.
|
|
522
|
+
url:
|
|
523
|
+
type: string
|
|
524
|
+
description: On `login` — the link the caller opens to log in; it comes back to `/login/callback`.
|
|
419
525
|
text:
|
|
420
526
|
type: string
|
|
421
527
|
description: On `final` — the authoritative complete answer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/channel-http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.42.0",
|
|
25
|
+
"@mercury-fw/plugin-types": "0.42.0",
|
|
26
26
|
"@mercury-fw/typescript-config": "*",
|
|
27
27
|
"@types/bun": "^1.4.2",
|
|
28
28
|
"typescript": "^6.0.3"
|