@mercury-fw/channel-http 0.6.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,19 @@
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
+
3
17
  ## 0.6.0
4
18
 
5
19
  ### Minor Changes
package/README.md CHANGED
@@ -78,6 +78,10 @@ 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.
@@ -11,7 +11,7 @@
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
13
  import { type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
- import type { Authenticate, Provider, ChannelHostReads } from "@mercury-fw/channel-types";
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. */
@@ -32,6 +32,8 @@ export type HttpProviderDeps = {
32
32
  accept: (callbackUrl: string) => void;
33
33
  complete: CompleteLoginFn;
34
34
  };
35
+ /** Linking another account to the caller (`POST /link`). */
36
+ linking?: ChannelLinking;
35
37
  };
36
38
  export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
37
39
  stop(): Promise<void>;
@@ -22,7 +22,7 @@
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, type Admission } 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`). */
@@ -79,6 +79,20 @@ export type ConfirmRequestDeps = {
79
79
  * even token-shaped). Never touches the model.
80
80
  */
81
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>;
82
96
  /** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
83
97
  export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
84
98
  /**
@@ -115,6 +129,8 @@ export type HttpServerDeps = TurnRequestDeps & {
115
129
  resolveConfirmation: ResolveConfirmationFn;
116
130
  /** Finishes people's logins; `/login/callback` is mounted only with it. */
117
131
  completeLogin?: CompleteLoginFn;
132
+ /** Linking another account to the caller; `/link` is mounted only with it. */
133
+ linking?: ChannelLinking;
118
134
  };
119
135
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
120
136
  * when `reads` is supplied. */
package/http-provider.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
13
  import { startHttpServer, type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
- import type { Authenticate, Provider, HandleTurn, ChannelHostReads } from "@mercury-fw/channel-types";
14
+ import type { Authenticate, Provider, HandleTurn, ChannelHostReads, ChannelLinking } from "@mercury-fw/channel-types";
15
15
 
16
16
  export type HttpProviderDeps = {
17
17
  port: number;
@@ -29,6 +29,8 @@ export type HttpProviderDeps = {
29
29
  /** People's logins, when the surface has a public URL to be sent back to:
30
30
  * the callback is offered (`accept`) once the server listens. */
31
31
  logins?: { callbackUrl: string; accept: (callbackUrl: string) => void; complete: CompleteLoginFn };
32
+ /** Linking another account to the caller (`POST /link`). */
33
+ linking?: ChannelLinking;
32
34
  };
33
35
 
34
36
  export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
@@ -45,6 +47,7 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
45
47
  ...(deps.admit === undefined ? {} : { admit: deps.admit }),
46
48
  corsOrigin: deps.corsOrigin,
47
49
  completeLogin: deps.logins?.complete,
50
+ ...(deps.linking === undefined ? {} : { linking: deps.linking }),
48
51
  });
49
52
  deps.logins?.accept(deps.logins.callbackUrl);
50
53
  },
package/http-server.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  type LoginOutcome,
35
35
  type Principal,
36
36
  type Admission,
37
+ type ChannelLinking,
37
38
  } from "@mercury-fw/channel-types";
38
39
 
39
40
  /**
@@ -305,6 +306,33 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
305
306
  }
306
307
  }
307
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
+
308
336
  /** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
309
337
  export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
310
338
 
@@ -439,6 +467,8 @@ export type HttpServerDeps = TurnRequestDeps & {
439
467
  resolveConfirmation: ResolveConfirmationFn;
440
468
  /** Finishes people's logins; `/login/callback` is mounted only with it. */
441
469
  completeLogin?: CompleteLoginFn;
470
+ /** Linking another account to the caller; `/link` is mounted only with it. */
471
+ linking?: ChannelLinking;
442
472
  };
443
473
 
444
474
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
@@ -471,6 +501,7 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
471
501
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
472
502
  ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
473
503
  ...(deps.completeLogin ? loginRoutes(deps.completeLogin) : {}),
504
+ ...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
474
505
  },
475
506
  error: (err) =>
476
507
  Response.json(
package/index.ts CHANGED
@@ -43,6 +43,7 @@ export const httpChannel: ChannelPlugin = {
43
43
  reads: ctx.reads,
44
44
  authenticate: ctx.authenticate,
45
45
  admit: ctx.admit,
46
+ ...(ctx.linking === undefined ? {} : { linking: ctx.linking }),
46
47
  ...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
47
48
  });
48
49
  },
package/openapi.yaml CHANGED
@@ -146,6 +146,45 @@ paths:
146
146
  application/json:
147
147
  schema:
148
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
149
188
  /conversation:
150
189
  get:
151
190
  summary: A conversation's durable transcript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.6.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.42.0",
25
- "@mercury-fw/plugin-types": "0.42.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"