@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 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; only `/manifest` and `/health` are the same for everyone.
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
  |---|---|
@@ -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>;
@@ -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 deps.authenticate(req);
131
- if (principal === null) return unauthorized(origin);
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 deps.authenticate(req);
259
- if (principal === null) return unauthorized(origin);
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(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin = "*"): Record<string, ReadRoute> {
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(req);
353
- return principal === null ? unauthorized(corsOrigin) : GET(req, 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, { authenticate: deps.authenticate, resolveConfirmation: deps.resolveConfirmation, corsOrigin: origin }),
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: 4,
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. Only
30
- `/manifest` and `/health` are the same for everyone.
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 loaded plugins, tools and skills
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.5.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.40.0",
25
- "@mercury-fw/plugin-types": "0.40.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"