@imenam/mcp-messenger 1.0.4 → 1.0.6

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.
@@ -15,6 +15,15 @@ export interface Agent {
15
15
  /** false pour un agent créé implicitement parce qu'un message lui a été adressé. */
16
16
  registered: boolean;
17
17
  }
18
+ /**
19
+ * Entrée d'annuaire : la fiche de l'agent, plus le moment où sa boîte a vécu pour la
20
+ * dernière fois. C'est ce moment qui classe l'annuaire, pas l'identifiant : une adresse
21
+ * opaque ne dit rien à qui lit la console.
22
+ */
23
+ export interface AgentDirectoryEntry extends Agent {
24
+ /** Plus récent de la dernière connexion et de la dernière livraison ; null si aucune. */
25
+ last_activity_at: string | null;
26
+ }
18
27
  export interface SendMessageInput {
19
28
  from: string;
20
29
  to: string[];
@@ -24,10 +33,39 @@ export interface SendMessageInput {
24
33
  priority?: Priority;
25
34
  /** Id d'un message existant : la réponse hérite de son fil de discussion. */
26
35
  reply_to?: string | null;
36
+ /** Nom lisible donné au fil. Sur une réponse, renomme le fil existant. */
37
+ thread_title?: string | null;
38
+ thread_description?: string | null;
39
+ }
40
+ /**
41
+ * Intitulé d'un fil de discussion.
42
+ *
43
+ * Le titre est unique et partagé : tous les participants parlent du même sujet, ils
44
+ * doivent donc lire le même nom. Le dernier qui renomme a le dernier mot ; `named_by`
45
+ * garde la trace de qui c'était.
46
+ */
47
+ export interface ThreadInfo {
48
+ thread_id: string;
49
+ title: string | null;
50
+ description: string | null;
51
+ /** Agent ayant posé le nom courant, ou null tant que le fil n'a pas été nommé. */
52
+ named_by: string | null;
53
+ created_at: string;
54
+ updated_at: string | null;
55
+ }
56
+ export interface ThreadSummary extends ThreadInfo {
57
+ /** Sujet du message d'ouverture : intitulé de repli tant que le fil n'est pas nommé. */
58
+ subject: string;
59
+ messages: number;
60
+ unread: number;
61
+ participants: string[];
62
+ last_message_at: string;
27
63
  }
28
64
  export interface SendMessageResult {
29
65
  message_id: string;
30
66
  thread_id: string;
67
+ /** Titre du fil après envoi, null s'il n'a jamais été nommé. */
68
+ thread_title: string | null;
31
69
  created_at: string;
32
70
  delivered_to: string[];
33
71
  /** Destinataires jamais enregistrés sur ce serveur — le message les attend malgré tout. */
@@ -36,6 +74,8 @@ export interface SendMessageResult {
36
74
  export interface MessageSummary {
37
75
  message_id: string;
38
76
  thread_id: string;
77
+ /** Titre du fil, null tant que personne ne l'a nommé — le `subject` fait alors office. */
78
+ thread_title: string | null;
39
79
  from: string;
40
80
  to: string[];
41
81
  cc: string[];
@@ -58,6 +98,14 @@ export interface FolderInfo {
58
98
  total: number;
59
99
  unread: number;
60
100
  }
101
+ export interface ListThreadsOptions {
102
+ /** Ne garder que les fils ayant au moins un message non lu. */
103
+ unread_only?: boolean;
104
+ /** Texte libre confronté au titre, à la description et au sujet d'ouverture. */
105
+ search?: string;
106
+ limit?: number;
107
+ offset?: number;
108
+ }
61
109
  export interface ListMessagesOptions {
62
110
  folder?: string;
63
111
  unread_only?: boolean;
package/dist/src/index.js CHANGED
@@ -43,7 +43,7 @@ async function main() {
43
43
  "Ce jeton doit être identique aux trois endroits suivants :",
44
44
  " 1. le serveur : MESSENGER_TOKEN",
45
45
  " 2. chaque agent : MESSENGER_TOKEN dans son .mcp.json",
46
- " 3. la console web : http://127.0.0.1:3600/ui/?token=<jeton>",
46
+ " 3. la console web : http://127.0.0.1:3600/ui/ (à saisir dans le formulaire)",
47
47
  "",
48
48
  ].join("\n"));
49
49
  return;
@@ -25,21 +25,37 @@ export const TOOLS = [
25
25
  },
26
26
  },
27
27
  {
28
- name: "set_mailbox_label",
29
- description: "Give this mailbox a human-readable label and description, so the user can recognise it in the directory and in the web console. Addresses are opaque: a label is what makes a mailbox findable later.",
28
+ name: "set_label",
29
+ description: "Give a plain-language name to this mailbox, or to a thread. Ids are opaque: a name is what makes a mailbox or a conversation recognisable later, both for the other agents and for the user reading the web console. Without thread_id it names this mailbox ('Développement des tests unitaires du parser'); with thread_id it names that thread ('Le test échoue sur un bug applicatif'). A thread has a single shared name: renaming it changes it for every participant, and the last caller wins.",
30
30
  inputSchema: {
31
31
  type: "object",
32
32
  properties: {
33
- label: { type: "string", description: "Short name, e.g. 'Refactor du parser'." },
34
- description: { type: "string", description: "What this session is working on." },
33
+ label: { type: "string", description: "Short name, one line. Say what the work is about, not who does it." },
34
+ description: { type: "string", description: "Optional longer explanation of the mailbox or the thread." },
35
+ thread_id: {
36
+ type: "string",
37
+ description: "Thread to name. Omit to name this mailbox instead. You must be a participant of the thread.",
38
+ },
39
+ },
40
+ additionalProperties: false,
41
+ },
42
+ },
43
+ {
44
+ name: "list_threads",
45
+ description: "List the conversations visible from this mailbox, most recently active first, with their name, participants and unread count. This is the readable map of the ongoing work: prefer it over list_messages when you want to know what is being discussed rather than what arrived last. A thread with no name falls back to the subject of its opening message — name it with set_label.",
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ unread_only: { type: "boolean", description: "Keep only threads with unread messages." },
50
+ search: { type: "string", description: "Free text matched against thread name, description and subject." },
51
+ limit: { type: "number", description: "1-100, defaults to 25." },
35
52
  },
36
- required: ["label"],
37
53
  additionalProperties: false,
38
54
  },
39
55
  },
40
56
  {
41
57
  name: "list_agents",
42
- description: "List the agents known to the mailbox server, with their description and last activity. Use it to find the address of the agent you want to write to.",
58
+ description: "List the agents known to the mailbox server, most recently active first, with their name and description. Use it to find the address of the agent you want to write to.",
43
59
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
44
60
  },
45
61
  {
@@ -59,6 +75,11 @@ export const TOOLS = [
59
75
  body: { type: "string", description: "Message body, plain text or markdown." },
60
76
  priority: { type: "string", enum: [...PRIORITIES], description: "Defaults to normal." },
61
77
  reply_to: { type: "string", description: "Id of the message being answered; the reply joins its thread." },
78
+ thread_title: {
79
+ type: "string",
80
+ description: "Plain-language name of the conversation, e.g. 'Résolution de l'échec du test de parsing'. Name the thread when you open it — you already know what it is about. On a reply it renames the thread for everyone, which is what you want when the discussion has drifted.",
81
+ },
82
+ thread_description: { type: "string", description: "Optional longer description of the thread." },
62
83
  },
63
84
  required: ["to", "subject", "body"],
64
85
  additionalProperties: false,
@@ -195,6 +216,8 @@ const schemas = {
195
216
  body: z.string().min(1),
196
217
  priority: z.enum(PRIORITIES).optional(),
197
218
  reply_to: z.string().optional(),
219
+ thread_title: z.string().trim().min(1).max(200).optional(),
220
+ thread_description: z.string().trim().min(1).max(2000).optional(),
198
221
  }),
199
222
  list_messages: z.object({
200
223
  folder: z.string().optional(),
@@ -215,7 +238,20 @@ const schemas = {
215
238
  move_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), folder: z.string().min(1) }),
216
239
  delete_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), permanent: z.boolean().optional() }),
217
240
  attach_to_mailbox: z.object({ mailbox_id: z.string().trim().min(1).optional() }),
218
- set_mailbox_label: z.object({ label: z.string().trim().min(1).max(200), description: z.string().max(2000).optional() }),
241
+ set_label: z
242
+ .object({
243
+ label: z.string().trim().min(1).max(200).optional(),
244
+ description: z.string().trim().min(1).max(2000).optional(),
245
+ thread_id: z.string().trim().min(1).optional(),
246
+ })
247
+ .refine((v) => v.label !== undefined || v.description !== undefined, {
248
+ message: "provide `label` and/or `description`",
249
+ }),
250
+ list_threads: z.object({
251
+ unread_only: z.boolean().optional(),
252
+ search: z.string().trim().min(1).optional(),
253
+ limit: z.number().int().optional(),
254
+ }),
219
255
  create_folder: z.object({ name: z.string().min(1) }),
220
256
  delete_folder: z.object({ name: z.string().min(1) }),
221
257
  wait_for_message: z.object({ timeout_seconds: z.number().int().optional(), folder: z.string().optional() }),
@@ -233,9 +269,13 @@ export async function callTool(client, name, rawArgs) {
233
269
  const stats = await client.stats();
234
270
  const health = await client.health().catch(() => null);
235
271
  const folders = stats.folders.map((f) => ` ${f.name.padEnd(12)} ${f.total} message(s), ${f.unread} unread`);
272
+ const self = await client.getAgent(client.agentId).catch(() => null);
236
273
  const lines = [
237
274
  `Mailbox address: ${client.agentId}`,
238
275
  " ↳ give this address to another agent so it can write to you.",
276
+ self?.display_name
277
+ ? `Mailbox name: ${self.display_name}`
278
+ : "Mailbox name: none — call set_label so the user can tell this session apart from the others.",
239
279
  ];
240
280
  if (client.isAttachedElsewhere) {
241
281
  lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
@@ -273,10 +313,36 @@ export async function callTool(client, name, rawArgs) {
273
313
  .filter(Boolean)
274
314
  .join("\n");
275
315
  }
276
- case "set_mailbox_label": {
277
- const input = schemas.set_mailbox_label.parse(args);
278
- const agent = await client.updateProfile({ display_name: input.label, description: input.description ?? null });
279
- return `Mailbox ${agent.id} labelled "${agent.display_name}".`;
316
+ // `set_mailbox_label` n'est plus publié, mais reste accepté : une session en cours
317
+ // peut l'avoir appris avant la mise à jour du serveur.
318
+ case "set_mailbox_label":
319
+ case "set_label": {
320
+ const input = schemas.set_label.parse(args);
321
+ if (input.thread_id) {
322
+ const thread = await client.setThreadLabel(input.thread_id, {
323
+ title: input.label ?? null,
324
+ description: input.description ?? null,
325
+ });
326
+ return [
327
+ `Thread ${thread.thread_id} is now named "${thread.title}".`,
328
+ thread.description ? `Description: ${thread.description}` : null,
329
+ "Every participant of the thread sees this name.",
330
+ ]
331
+ .filter(Boolean)
332
+ .join("\n");
333
+ }
334
+ const agent = await client.updateProfile({
335
+ display_name: input.label ?? null,
336
+ description: input.description ?? null,
337
+ });
338
+ return `Mailbox ${agent.id} named "${agent.display_name}".`;
339
+ }
340
+ case "list_threads": {
341
+ const input = schemas.list_threads.parse(args);
342
+ const threads = await client.listThreads(input);
343
+ if (threads.length === 0)
344
+ return "No thread in this mailbox matching these filters.";
345
+ return [`${threads.length} thread(s):`, "", ...threads.map(formatThread)].join("\n");
280
346
  }
281
347
  case "list_agents": {
282
348
  const agents = await client.listAgents();
@@ -288,7 +354,8 @@ export async function callTool(client, name, rawArgs) {
288
354
  .filter(Boolean)
289
355
  .join(", ");
290
356
  const title = agent.display_name ? `${agent.id} — ${agent.display_name}` : agent.id;
291
- const seen = agent.last_seen_at ? `last seen ${agent.last_seen_at}` : "never seen";
357
+ // L'annuaire est classé par cette date : l'afficher rend l'ordre lisible.
358
+ const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
292
359
  return [`• ${title}${flags ? ` (${flags})` : ""}`, ` ${agent.description ?? "no description"}`, ` ${seen}`].join("\n");
293
360
  })
294
361
  .join("\n");
@@ -302,12 +369,17 @@ export async function callTool(client, name, rawArgs) {
302
369
  body: input.body,
303
370
  priority: input.priority,
304
371
  reply_to: input.reply_to ?? null,
372
+ thread_title: input.thread_title ?? null,
373
+ thread_description: input.thread_description ?? null,
305
374
  });
306
375
  const lines = [
307
376
  `Message sent: ${result.message_id}`,
308
- `Thread: ${result.thread_id}`,
377
+ `Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
309
378
  `Delivered to: ${result.delivered_to.join(", ")}`,
310
379
  ];
380
+ if (!result.thread_title && !input.reply_to) {
381
+ lines.push("This thread has no name yet — set_label with its thread_id makes it readable for everyone.");
382
+ }
311
383
  if (result.unknown_recipients.length > 0) {
312
384
  lines.push(`Warning — never connected to this server, the message will wait in their mailbox: ${result.unknown_recipients.join(", ")}`);
313
385
  }
@@ -328,8 +400,14 @@ export async function callTool(client, name, rawArgs) {
328
400
  }
329
401
  case "get_thread": {
330
402
  const input = schemas.get_thread.parse(args);
331
- const messages = await client.getThread(input.thread_id);
332
- return [`Thread ${input.thread_id} — ${messages.length} message(s):`, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
403
+ const { thread, messages } = await client.getThread(input.thread_id);
404
+ const header = [
405
+ `Thread ${formatThreadRef(thread?.title ?? null, input.thread_id)} — ${messages.length} message(s):`,
406
+ thread?.description ? `Description: ${thread.description}` : null,
407
+ ]
408
+ .filter(Boolean)
409
+ .join("\n");
410
+ return [header, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
333
411
  }
334
412
  case "mark_messages": {
335
413
  const input = schemas.mark_messages.parse(args);
@@ -424,6 +502,22 @@ function asArray(value) {
424
502
  return [];
425
503
  return Array.isArray(value) ? value : [value];
426
504
  }
505
+ /** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
506
+ function formatThreadRef(title, threadId) {
507
+ return title ? `${title} (${threadId})` : threadId;
508
+ }
509
+ function formatThread(thread) {
510
+ // Sans nom, le sujet d'ouverture reste le seul repère : mieux vaut l'afficher que l'id nu.
511
+ const name = thread.title ?? `${thread.subject} (unnamed)`;
512
+ return [
513
+ `${thread.unread > 0 ? "•" : " "} ${name}`,
514
+ thread.description ? ` ${thread.description}` : null,
515
+ ` ${thread.messages} message(s), ${thread.unread} unread · with ${thread.participants.join(", ")}`,
516
+ ` last activity ${thread.last_message_at} · thread ${thread.thread_id}`,
517
+ ]
518
+ .filter(Boolean)
519
+ .join("\n");
520
+ }
427
521
  function formatSummary(message) {
428
522
  const flags = [message.read ? " " : "•", message.starred ? "★" : " "].join("");
429
523
  const priority = message.priority === "normal" ? "" : ` [${message.priority}]`;
@@ -431,7 +525,7 @@ function formatSummary(message) {
431
525
  `${flags} ${message.subject}${priority}`,
432
526
  ` from ${message.from} to ${message.to.join(", ") || "—"}${message.cc.length ? ` (cc ${message.cc.join(", ")})` : ""} · ${message.created_at}`,
433
527
  ` ${message.preview}`,
434
- ` id ${message.message_id} · thread ${message.thread_id}`,
528
+ ` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
435
529
  ].join("\n");
436
530
  }
437
531
  function formatMessage(message) {
@@ -443,7 +537,7 @@ function formatMessage(message) {
443
537
  `Date: ${message.created_at}`,
444
538
  `Priority: ${message.priority}`,
445
539
  `Folder: ${message.folder}${message.read ? "" : " (unread)"}${message.starred ? " ★" : ""}`,
446
- `Id: ${message.message_id} · Thread: ${message.thread_id}${message.in_reply_to ? ` · reply to ${message.in_reply_to}` : ""}`,
540
+ `Id: ${message.message_id} · Thread: ${formatThreadRef(message.thread_title, message.thread_id)}${message.in_reply_to ? ` · reply to ${message.in_reply_to}` : ""}`,
447
541
  ]
448
542
  .filter(Boolean)
449
543
  .join("\n");
@@ -1,5 +1,4 @@
1
1
  import express from "express";
2
- import fs from "node:fs";
3
2
  import helmet from "helmet";
4
3
  import path from "node:path";
5
4
  import { fileURLToPath } from "node:url";
@@ -9,6 +8,7 @@ import { z } from "zod";
9
8
  import { MessengerError, normalizeAgentId } from "../core/store.js";
10
9
  import { PRIORITIES } from "../core/types.js";
11
10
  import { WatcherRegistry } from "./watchers.js";
11
+ import { CONSOLE_COOKIE, ConsoleSessions, consoleSessionId } from "./console-session.js";
12
12
  import { APP_VERSION } from "../version.js";
13
13
  import { logger } from "../logger.js";
14
14
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
@@ -27,6 +27,22 @@ const sendSchema = z.object({
27
27
  body: z.string().min(1).max(200_000),
28
28
  priority: z.enum(PRIORITIES).optional(),
29
29
  reply_to: z.string().trim().min(1).nullish(),
30
+ thread_title: z.string().trim().min(1).max(200).nullish(),
31
+ thread_description: z.string().trim().min(1).max(2000).nullish(),
32
+ });
33
+ const threadLabelSchema = z
34
+ .object({
35
+ title: z.string().trim().min(1).max(200).nullish(),
36
+ description: z.string().trim().min(1).max(2000).nullish(),
37
+ })
38
+ .refine((v) => v.title != null || v.description != null, {
39
+ message: "provide `title` and/or `description`",
40
+ });
41
+ const listThreadsQuerySchema = z.object({
42
+ unread_only: z.coerce.boolean().optional(),
43
+ search: z.string().trim().min(1).optional(),
44
+ limit: z.coerce.number().int().min(1).max(100).optional(),
45
+ offset: z.coerce.number().int().min(0).optional(),
30
46
  });
31
47
  const listQuerySchema = z.object({
32
48
  folder: z.string().trim().min(1).optional(),
@@ -56,6 +72,7 @@ const waitQuerySchema = z.object({
56
72
  // attente longue, mais seul le premier prouve que l'agent est joignable en continu.
57
73
  watcher: z.enum(["true", "1"]).optional(),
58
74
  });
75
+ const loginSchema = z.object({ token: z.string().min(1).max(1024) });
59
76
  const adminListSchema = z.object({
60
77
  search: z.string().trim().min(1).optional(),
61
78
  agent: z.string().trim().min(1).optional(),
@@ -90,8 +107,65 @@ export function createApp({ store, config }) {
90
107
  standardHeaders: "draft-8",
91
108
  legacyHeaders: false,
92
109
  }));
93
- // Porte d'entrée unique : aucune requête, quelle qu'elle soit, n'est servie sans
94
- // jeton valide. Les contrôles par route (identité de l'agent, console) viennent ensuite.
110
+ // --- Console web ----------------------------------------------------------
111
+ // La page et ses fichiers ne contiennent aucun secret : ils sont servis à tous. Ce sont
112
+ // les données (`/admin`) qui sont protégées, par une session ouverte contre le jeton
113
+ // admin. Voir `console-session.ts` pour le raisonnement.
114
+ const sessions = new ConsoleSessions();
115
+ const cookieOptions = (req) => ({
116
+ httpOnly: true,
117
+ sameSite: "strict",
118
+ // `req.secure` n'est vrai derrière un proxy TLS que si `trust proxy` est configuré.
119
+ secure: req.secure,
120
+ path: "/",
121
+ });
122
+ // Limite propre au formulaire : le jeton fait 256 bits, mais autant fermer la porte.
123
+ const loginLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 10, standardHeaders: "draft-8", legacyHeaders: false });
124
+ app.post("/ui/login", loginLimiter, (req, res) => {
125
+ const input = loginSchema.parse(req.body ?? {});
126
+ if (!safeEqual(input.token, config.adminToken)) {
127
+ res.status(401).json({ error: "invalid admin token" });
128
+ return;
129
+ }
130
+ res.cookie(CONSOLE_COOKIE, sessions.create(), { ...cookieOptions(req), maxAge: sessions.ttlMs });
131
+ res.status(204).end();
132
+ });
133
+ app.post("/ui/logout", (req, res) => {
134
+ sessions.revoke(consoleSessionId(req));
135
+ res.clearCookie(CONSOLE_COOKIE, cookieOptions(req));
136
+ res.status(204).end();
137
+ });
138
+ // `maxAge: 0` + ETag : la console reflète immédiatement une mise à jour du paquet,
139
+ // sans laisser un navigateur servir l'ancienne version depuis son cache.
140
+ app.use("/ui", express.static(UI_DIR, { index: "index.html", maxAge: 0, etag: true }));
141
+ app.get("/", (_req, res) => {
142
+ res.redirect("/ui/");
143
+ });
144
+ // --- Supervision (lecture seule) -----------------------------------------
145
+ // Montée avant la porte d'entrée : son contrôle est au moins aussi strict (session de
146
+ // console ou jeton admin), et la session par cookie n'est reconnue nulle part ailleurs.
147
+ const admin = express.Router();
148
+ admin.use(authenticateAdmin(config, sessions));
149
+ admin.get("/overview", (_req, res) => {
150
+ res.json(store.overview());
151
+ });
152
+ admin.get("/messages", (req, res) => {
153
+ const query = adminListSchema.parse(req.query);
154
+ res.json({ messages: store.listAllMessages(query) });
155
+ });
156
+ admin.get("/messages/:id", (req, res) => {
157
+ res.json(store.getMessageAdmin(String(req.params.id)));
158
+ });
159
+ admin.get("/threads/:id", (req, res) => {
160
+ const threadId = String(req.params.id);
161
+ const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
162
+ if (messages.length === 0)
163
+ throw new MessengerError(`thread not found: ${threadId}`, 404);
164
+ res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
165
+ });
166
+ app.use("/admin", admin);
167
+ // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
168
+ // contrôle par route (identité de l'agent) vient ensuite.
95
169
  app.use(requireAnyToken(config));
96
170
  // --- État du service ------------------------------------------------------
97
171
  app.get("/health", (_req, res) => {
@@ -154,6 +228,8 @@ export function createApp({ store, config }) {
154
228
  body: input.body,
155
229
  priority: input.priority,
156
230
  reply_to: input.reply_to ?? null,
231
+ thread_title: input.thread_title ?? null,
232
+ thread_description: input.thread_description ?? null,
157
233
  });
158
234
  logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to.join(", ")}`);
159
235
  res.status(201).json(result);
@@ -179,8 +255,18 @@ export function createApp({ store, config }) {
179
255
  const markRead = req.query.mark_read !== "false";
180
256
  res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
181
257
  });
258
+ api.get("/threads", (req, res) => {
259
+ const query = listThreadsQuerySchema.parse(req.query);
260
+ res.json({ threads: store.listThreads(req.agentId, query) });
261
+ });
182
262
  api.get("/threads/:id", (req, res) => {
183
- res.json({ thread_id: req.params.id, messages: store.getThread(req.agentId, String(req.params.id)) });
263
+ const threadId = String(req.params.id);
264
+ const messages = store.getThread(req.agentId, threadId);
265
+ res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages });
266
+ });
267
+ api.patch("/threads/:id", (req, res) => {
268
+ const input = threadLabelSchema.parse(req.body ?? {});
269
+ res.json({ thread: store.setThreadLabel(req.agentId, String(req.params.id), input) });
184
270
  });
185
271
  api.get("/folders", (req, res) => {
186
272
  res.json({ folders: store.listFolders(req.agentId) });
@@ -232,52 +318,6 @@ export function createApp({ store, config }) {
232
318
  req.once("close", onClose);
233
319
  });
234
320
  app.use("/api", api);
235
- // --- Supervision (lecture seule) -----------------------------------------
236
- const admin = express.Router();
237
- admin.use(authenticateAdmin(config));
238
- admin.get("/overview", (_req, res) => {
239
- res.json(store.overview());
240
- });
241
- admin.get("/messages", (req, res) => {
242
- const query = adminListSchema.parse(req.query);
243
- res.json({ messages: store.listAllMessages(query) });
244
- });
245
- admin.get("/messages/:id", (req, res) => {
246
- res.json(store.getMessageAdmin(String(req.params.id)));
247
- });
248
- admin.get("/threads/:id", (req, res) => {
249
- const messages = store.listAllMessages({ thread_id: String(req.params.id), limit: 100 });
250
- if (messages.length === 0)
251
- throw new MessengerError(`thread not found: ${req.params.id}`, 404);
252
- res.json({ thread_id: req.params.id, messages: messages.slice().reverse() });
253
- });
254
- app.use("/admin", admin);
255
- // --- Interface web --------------------------------------------------------
256
- // Un navigateur ne peut pas poser d'en-tête sur une navigation : la console s'ouvre
257
- // donc avec `?token=`. La page est servie avec ce jeton propagé sur ses ressources,
258
- // sans quoi le CSS et le script seraient refusés par la porte d'entrée.
259
- app.get(["/ui", "/ui/", "/ui/index.html"], (req, res, next) => {
260
- const token = presentedToken(req);
261
- if (!token) {
262
- next();
263
- return;
264
- }
265
- fs.readFile(path.join(UI_DIR, "index.html"), "utf-8", (error, html) => {
266
- if (error) {
267
- next(error);
268
- return;
269
- }
270
- const suffix = `?token=${encodeURIComponent(token)}`;
271
- res.type("html").send(html.replaceAll("./styles.css", `./styles.css${suffix}`).replaceAll("./app.js", `./app.js${suffix}`));
272
- });
273
- });
274
- // `maxAge: 0` + ETag : la console reflète immédiatement une mise à jour du paquet,
275
- // sans laisser un navigateur servir l'ancienne version depuis son cache.
276
- app.use("/ui", express.static(UI_DIR, { index: false, maxAge: 0, etag: true }));
277
- app.get("/", (req, res) => {
278
- const token = presentedToken(req);
279
- res.redirect(token ? `/ui/?token=${encodeURIComponent(token)}` : "/ui/");
280
- });
281
321
  // --- Erreurs --------------------------------------------------------------
282
322
  app.use((_req, res) => {
283
323
  res.status(404).json({ error: "not found" });
@@ -350,8 +390,13 @@ function authenticateAgent(config) {
350
390
  next();
351
391
  };
352
392
  }
353
- function authenticateAdmin(config) {
393
+ /** Session de console (navigateur) ou jeton admin (script, `curl`). */
394
+ function authenticateAdmin(config, sessions) {
354
395
  return (req, res, next) => {
396
+ if (sessions.has(consoleSessionId(req))) {
397
+ next();
398
+ return;
399
+ }
355
400
  const presented = presentedToken(req);
356
401
  if (!presented || !safeEqual(presented, config.adminToken)) {
357
402
  res.status(401).json({ error: "invalid or missing admin token" });
@@ -361,19 +406,15 @@ function authenticateAdmin(config) {
361
406
  };
362
407
  }
363
408
  /**
364
- * Jeton présenté par l'appelant, dans l'ordre de préférence : en-tête `Authorization`,
365
- * en-tête `X-Messenger-Token`, puis paramètre `?token=` — ce dernier existe pour la
366
- * navigation navigateur vers la console, qui ne peut pas poser d'en-tête.
409
+ * Jeton présenté par l'appelant : en-tête `Authorization`, sinon `X-Messenger-Token`.
410
+ * Jamais l'URL : un jeton en query string finit dans les journaux d'accès du proxy.
367
411
  */
368
412
  function presentedToken(req) {
369
413
  const header = req.header("authorization");
370
414
  if (header?.toLowerCase().startsWith("bearer "))
371
415
  return header.slice(7).trim() || null;
372
416
  const alt = req.header("x-messenger-token");
373
- if (alt?.trim())
374
- return alt.trim();
375
- const query = req.query.token;
376
- return typeof query === "string" && query.trim() ? query.trim() : null;
417
+ return alt?.trim() ? alt.trim() : null;
377
418
  }
378
419
  /** Comparaison à temps constant : évite de laisser fuiter le jeton octet par octet. */
379
420
  function safeEqual(a, b) {
@@ -0,0 +1,30 @@
1
+ import type { Request } from "express";
2
+ /**
3
+ * Sessions de la console web.
4
+ *
5
+ * Le navigateur ne peut pas poser d'en-tête sur une navigation, et faire transiter le
6
+ * jeton dans l'URL le dépose dans l'historique et les journaux d'accès du proxy. La
7
+ * console s'ouvre donc sans jeton, le demande une fois dans un formulaire, et reçoit en
8
+ * retour un identifiant de session opaque dans un cookie `HttpOnly`.
9
+ *
10
+ * Le cookie ne porte jamais le jeton lui-même : sa fuite ne donne qu'une session, à durée
11
+ * limitée, que le serveur oublie au redémarrage. Il n'est honoré que sur `/admin` et
12
+ * `/ui` : l'API des agents reste sur le Bearer, un site tiers ne peut donc pas faire
13
+ * agir un agent à travers le navigateur de l'administrateur.
14
+ */
15
+ export declare const CONSOLE_COOKIE = "messenger_console";
16
+ export declare class ConsoleSessions {
17
+ readonly ttlMs: number;
18
+ private readonly now;
19
+ private readonly sessions;
20
+ constructor(ttlMs?: number, now?: () => number);
21
+ /** Ouvre une session et rend son identifiant, seul contenu du cookie. */
22
+ create(): string;
23
+ /** Vrai si l'identifiant désigne une session encore valide. */
24
+ has(id: string | null): boolean;
25
+ revoke(id: string | null): void;
26
+ get size(): number;
27
+ private prune;
28
+ }
29
+ /** Identifiant de session porté par la requête, s'il y en a un. */
30
+ export declare function consoleSessionId(req: Request): string | null;
@@ -0,0 +1,85 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ /**
3
+ * Sessions de la console web.
4
+ *
5
+ * Le navigateur ne peut pas poser d'en-tête sur une navigation, et faire transiter le
6
+ * jeton dans l'URL le dépose dans l'historique et les journaux d'accès du proxy. La
7
+ * console s'ouvre donc sans jeton, le demande une fois dans un formulaire, et reçoit en
8
+ * retour un identifiant de session opaque dans un cookie `HttpOnly`.
9
+ *
10
+ * Le cookie ne porte jamais le jeton lui-même : sa fuite ne donne qu'une session, à durée
11
+ * limitée, que le serveur oublie au redémarrage. Il n'est honoré que sur `/admin` et
12
+ * `/ui` : l'API des agents reste sur le Bearer, un site tiers ne peut donc pas faire
13
+ * agir un agent à travers le navigateur de l'administrateur.
14
+ */
15
+ export const CONSOLE_COOKIE = "messenger_console";
16
+ /** Durée de vie d'une session : assez pour une journée de supervision, pas plus. */
17
+ const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
18
+ export class ConsoleSessions {
19
+ ttlMs;
20
+ now;
21
+ sessions = new Map();
22
+ constructor(ttlMs = SESSION_TTL_MS, now = Date.now) {
23
+ this.ttlMs = ttlMs;
24
+ this.now = now;
25
+ }
26
+ /** Ouvre une session et rend son identifiant, seul contenu du cookie. */
27
+ create() {
28
+ this.prune();
29
+ const id = randomBytes(32).toString("base64url");
30
+ this.sessions.set(id, { expiresAt: this.now() + this.ttlMs });
31
+ return id;
32
+ }
33
+ /** Vrai si l'identifiant désigne une session encore valide. */
34
+ has(id) {
35
+ if (!id)
36
+ return false;
37
+ // Parcours à temps constant : la Map compare ses clés en temps variable, ce qui
38
+ // laisserait deviner un identifiant octet par octet.
39
+ let match = null;
40
+ const presented = Buffer.from(id);
41
+ for (const [key, session] of this.sessions) {
42
+ const candidate = Buffer.from(key);
43
+ if (candidate.length === presented.length && timingSafeEqual(candidate, presented))
44
+ match = session;
45
+ }
46
+ if (!match)
47
+ return false;
48
+ if (match.expiresAt <= this.now()) {
49
+ this.sessions.delete(id);
50
+ return false;
51
+ }
52
+ return true;
53
+ }
54
+ revoke(id) {
55
+ if (id)
56
+ this.sessions.delete(id);
57
+ }
58
+ get size() {
59
+ this.prune();
60
+ return this.sessions.size;
61
+ }
62
+ prune() {
63
+ const now = this.now();
64
+ for (const [id, session] of this.sessions) {
65
+ if (session.expiresAt <= now)
66
+ this.sessions.delete(id);
67
+ }
68
+ }
69
+ }
70
+ /** Identifiant de session porté par la requête, s'il y en a un. */
71
+ export function consoleSessionId(req) {
72
+ const header = req.headers.cookie;
73
+ if (!header)
74
+ return null;
75
+ for (const part of header.split(";")) {
76
+ const separator = part.indexOf("=");
77
+ if (separator < 0)
78
+ continue;
79
+ if (part.slice(0, separator).trim() !== CONSOLE_COOKIE)
80
+ continue;
81
+ const value = part.slice(separator + 1).trim();
82
+ return value ? decodeURIComponent(value) : null;
83
+ }
84
+ return null;
85
+ }