@imenam/mcp-messenger 1.0.29 → 1.0.31

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.
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { timingSafeEqual } from "node:crypto";
6
6
  import { rateLimit } from "express-rate-limit";
7
7
  import { z } from "zod";
8
+ import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, contentDisposition } from "../core/attachments.js";
8
9
  import { MessengerError, normalizeAgentId } from "../core/store.js";
9
10
  import { PRIORITIES } from "../core/types.js";
10
11
  import { WatcherRegistry } from "./watchers.js";
@@ -12,6 +13,9 @@ import { CONSOLE_COOKIE, ConsoleSessions, consoleSessionId } from "./console-ses
12
13
  import { APP_VERSION } from "../version.js";
13
14
  import { logger } from "../logger.js";
14
15
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
16
+ const JSON_TYPES = ["application/json", "application/*+json"];
17
+ /** Corps d'un envoi : le texte, plus les pièces jointes encodées en base64 (4 octets pour 3). */
18
+ const SEND_BODY_LIMIT = Math.ceil((MAX_ATTACHMENTS_TOTAL_BYTES * 4) / 3) + 1024 * 1024;
15
19
  const agentIdSchema = z.string().trim().min(1).max(128);
16
20
  const idListSchema = z.array(z.string().trim().min(1)).min(1).max(200);
17
21
  /** Nom et description de la boîte : ce que l'agent pose lui-même sur sa session. */
@@ -34,6 +38,14 @@ const sendSchema = z.object({
34
38
  reply_to: z.string().trim().min(1).nullish(),
35
39
  thread_title: z.string().trim().min(1).max(200).nullish(),
36
40
  thread_description: z.string().trim().min(1).max(2000).nullish(),
41
+ attachments: z
42
+ .array(z.object({
43
+ filename: z.string().trim().min(1).max(255),
44
+ mime_type: z.string().trim().max(255).nullish(),
45
+ content_base64: z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/, "expected base64 content"),
46
+ }))
47
+ .max(MAX_ATTACHMENTS)
48
+ .optional(),
37
49
  });
38
50
  const threadLabelSchema = z
39
51
  .object({
@@ -121,6 +133,10 @@ const purgeSchema = z
121
133
  .refine((input) => [input.before, input.created_before, input.empty].filter((value) => value !== undefined).length === 1, {
122
134
  message: "provide exactly one of `before` (inactive since that date), `created_before` (created before that date) or `empty: true`",
123
135
  });
136
+ const linkSchema = z.object({
137
+ mailboxes: z.tuple([agentIdSchema, agentIdSchema]),
138
+ note: z.string().trim().max(500).nullish(),
139
+ });
124
140
  const adminListSchema = z.object({
125
141
  search: z.string().trim().min(1).optional(),
126
142
  agent: z.string().trim().min(1).optional(),
@@ -151,7 +167,10 @@ export function createApp({ store, config }) {
151
167
  },
152
168
  crossOriginEmbedderPolicy: false,
153
169
  }));
154
- app.use(express.json({ type: ["application/json", "application/*+json"], limit: "2mb" }));
170
+ // L'envoi d'un message a sa propre limite, plus large pour ses pièces jointes, et ne
171
+ // s'applique qu'une fois l'agent authentifié.
172
+ const jsonBody = express.json({ type: JSON_TYPES, limit: "2mb" });
173
+ app.use((req, res, next) => (isSendRequest(req) ? next() : jsonBody(req, res, next)));
155
174
  app.use(rateLimit({
156
175
  windowMs: config.rateLimit.windowMs,
157
176
  limit: config.rateLimit.max,
@@ -194,7 +213,7 @@ export function createApp({ store, config }) {
194
213
  app.get("/", (_req, res) => {
195
214
  res.redirect("/ui/");
196
215
  });
197
- // --- Supervision (lecture, sauf les nettoyages de boîtes) ----------------
216
+ // --- Supervision (lecture, liaisons et nettoyages de boîtes) -------------
198
217
  // Montée avant la porte d'entrée : son contrôle est au moins aussi strict (session de
199
218
  // console ou jeton admin), et la session par cookie n'est reconnue nulle part ailleurs.
200
219
  const admin = express.Router();
@@ -209,6 +228,9 @@ export function createApp({ store, config }) {
209
228
  admin.get("/messages/:id", (req, res) => {
210
229
  res.json(store.getMessageAdmin(String(req.params.id)));
211
230
  });
231
+ admin.get("/attachments/:id", (req, res) => {
232
+ sendAttachment(res, store.getAttachmentAdmin(String(req.params.id)));
233
+ });
212
234
  // Déclarée avant `/threads/:id` : la console lit d'abord les fils, le détail ensuite.
213
235
  admin.get("/threads", (req, res) => {
214
236
  const query = adminThreadsSchema.parse(req.query);
@@ -263,7 +285,7 @@ export function createApp({ store, config }) {
263
285
  res.json({ mailboxes: store.emptyMailboxes() });
264
286
  });
265
287
  /**
266
- * La seule route de la console qui écrit, et elle détruit.
288
+ * Suppression en lot : elle détruit.
267
289
  *
268
290
  * Deux garde-fous, parce qu'un nettoyage ne se rattrape pas : le critère est redemandé
269
291
  * avec les adresses, et le serveur refuse toute adresse qui n'y répond plus au moment
@@ -313,6 +335,21 @@ export function createApp({ store, config }) {
313
335
  logger.warn(`admin delete: mailbox ${id} deleted (${removed.messages} message(s) erased)`);
314
336
  res.json({ mailbox: id, ...removed });
315
337
  });
338
+ admin.get("/links", (_req, res) => {
339
+ res.json({ links: store.listLinks() });
340
+ });
341
+ admin.post("/links", (req, res) => {
342
+ const input = linkSchema.parse(req.body ?? {});
343
+ const link = store.createLink(input.mailboxes[0], input.mailboxes[1], input.note ?? null);
344
+ logger.info(`admin link ${link.link_id}: ${link.mailboxes[0].mailbox} <-> ${link.mailboxes[1].mailbox}`);
345
+ res.status(201).json({ link });
346
+ });
347
+ admin.delete("/links/:id", (req, res) => {
348
+ const linkId = String(req.params.id);
349
+ store.deleteLink(linkId);
350
+ logger.info(`admin unlink ${linkId}`);
351
+ res.json({ link_id: linkId });
352
+ });
316
353
  app.use("/admin", admin);
317
354
  // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
318
355
  // contrôle par route (identité de l'agent) vient ensuite.
@@ -370,10 +407,14 @@ export function createApp({ store, config }) {
370
407
  throw new MessengerError(`mailbox not found: ${req.params.id}`, 404);
371
408
  res.json({ agent });
372
409
  });
410
+ // Lire ses liaisons, c'est en prendre connaissance : la console l'affiche comme reçue.
411
+ api.get("/links", (req, res) => {
412
+ res.json({ links: store.linksFor(req.agentId, { markSeen: true }) });
413
+ });
373
414
  api.get("/mailbox", (req, res) => {
374
415
  res.json({ ...store.stats(req.agentId), watcher: watchers.status(req.agentId) });
375
416
  });
376
- api.post("/messages", (req, res) => {
417
+ api.post("/messages", express.json({ type: JSON_TYPES, limit: SEND_BODY_LIMIT }), (req, res) => {
377
418
  const input = sendSchema.parse(req.body ?? {});
378
419
  const result = store.sendMessage({
379
420
  from: req.agentId,
@@ -384,6 +425,11 @@ export function createApp({ store, config }) {
384
425
  reply_to: input.reply_to ?? null,
385
426
  thread_title: input.thread_title ?? null,
386
427
  thread_description: input.thread_description ?? null,
428
+ attachments: (input.attachments ?? []).map((a) => ({
429
+ filename: a.filename,
430
+ mime_type: a.mime_type ?? null,
431
+ data: Buffer.from(a.content_base64, "base64"),
432
+ })),
387
433
  });
388
434
  logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to}`);
389
435
  res.status(201).json(result);
@@ -409,6 +455,9 @@ export function createApp({ store, config }) {
409
455
  const markRead = req.query.mark_read !== "false";
410
456
  res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
411
457
  });
458
+ api.get("/attachments/:id", (req, res) => {
459
+ sendAttachment(res, store.getAttachment(req.agentId, String(req.params.id)));
460
+ });
412
461
  api.get("/threads", (req, res) => {
413
462
  const query = listThreadsQuerySchema.parse(req.query);
414
463
  res.json({ threads: store.listThreads(req.agentId, query) });
@@ -434,7 +483,8 @@ export function createApp({ store, config }) {
434
483
  });
435
484
  // Attente longue : seuls les messages arrivés APRÈS le début de l'attente réveillent
436
485
  // l'appelant ; les non-lus déjà présents sont ignorés (sinon l'outil rendrait la main
437
- // immédiatement tant que la boîte n'est pas vidée).
486
+ // immédiatement tant que la boîte n'est pas vidée). Un veilleur est aussi réveillé par
487
+ // une liaison posée sur sa boîte.
438
488
  api.get("/wait", (req, res) => {
439
489
  const { timeout_seconds, folder, watcher } = waitQuerySchema.parse(req.query);
440
490
  const agentId = req.agentId;
@@ -445,11 +495,15 @@ export function createApp({ store, config }) {
445
495
  // Le relâchement doit couvrir les trois sorties possibles : message, expiration,
446
496
  // client parti. Un compteur qui ne redescend pas ferait croire à un veilleur éternel.
447
497
  const releaseWatcher = watcher ? watchers.begin(agentId) : () => { };
448
- const done = (payload) => {
498
+ const stop = () => {
449
499
  clearTimeout(timer);
450
500
  store.off("message", onMessage);
451
- req.off("close", onClose);
501
+ store.off("link", onLink);
502
+ req.off("close", stop);
452
503
  releaseWatcher();
504
+ };
505
+ const done = (payload) => {
506
+ stop();
453
507
  if (!res.headersSent)
454
508
  res.json(payload);
455
509
  };
@@ -462,14 +516,17 @@ export function createApp({ store, config }) {
462
516
  return;
463
517
  done({ timed_out: false, message: store.getMessage(agentId, event.message_id, false) });
464
518
  };
465
- const onClose = () => {
466
- clearTimeout(timer);
467
- store.off("message", onMessage);
468
- releaseWatcher();
519
+ const onLink = (event) => {
520
+ if (event.agent_id !== agentId)
521
+ return;
522
+ const [link] = store.linksFor(agentId, { linkId: event.link_id });
523
+ done({ timed_out: false, message: null, link });
469
524
  };
470
525
  const timer = setTimeout(() => done({ timed_out: true, message: null }), timeoutSeconds * 1000);
471
526
  store.on("message", onMessage);
472
- req.once("close", onClose);
527
+ if (watcher)
528
+ store.on("link", onLink);
529
+ req.once("close", stop);
473
530
  });
474
531
  app.use("/api", api);
475
532
  // --- Erreurs --------------------------------------------------------------
@@ -485,12 +542,32 @@ export function createApp({ store, config }) {
485
542
  res.status(400).json({ error: "invalid request", details: z.treeifyError(err) });
486
543
  return;
487
544
  }
545
+ // Erreurs du lecteur de corps (trop gros, JSON illisible) : leur message est fait pour être lu.
546
+ if (isExposedHttpError(err)) {
547
+ res.status(err.status).json({ error: err.message });
548
+ return;
549
+ }
488
550
  logger.error("unhandled error", err);
489
551
  // Le détail reste dans les logs : la réponse ne doit rien révéler de l'hôte.
490
552
  res.status(500).json({ error: "internal server error" });
491
553
  });
492
554
  return app;
493
555
  }
556
+ function isExposedHttpError(err) {
557
+ if (typeof err !== "object" || err === null)
558
+ return false;
559
+ const { expose, status } = err;
560
+ return expose === true && typeof status === "number" && status >= 400 && status < 500;
561
+ }
562
+ function isSendRequest(req) {
563
+ return req.method === "POST" && req.path === "/api/messages";
564
+ }
565
+ function sendAttachment(res, attachment) {
566
+ res.setHeader("content-type", attachment.mime_type);
567
+ res.setHeader("content-length", String(attachment.data.length));
568
+ res.setHeader("content-disposition", contentDisposition(attachment.filename));
569
+ res.end(attachment.data);
570
+ }
494
571
  /**
495
572
  * Exige un jeton connu du serveur sur toute requête entrante.
496
573
  *