@imenam/mcp-messenger 1.0.27 → 1.0.28

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/README.md CHANGED
@@ -37,16 +37,20 @@ identifiée par une adresse opaque du type `box_9f2c4a01b3d7e5f8` — voir
37
37
  ### 1. Lancer la boîte aux lettres
38
38
 
39
39
  ```bash
40
- # Un jeton, à conserver : il sera demandé au serveur, aux agents et à la console.
41
- npx @imenam/mcp-messenger --generate-token
40
+ # Deux jetons distincts : celui des agents, et celui de la console.
41
+ npx @imenam/mcp-messenger --generate-token # → jeton client
42
+ npx @imenam/mcp-messenger --generate-token # → jeton console
42
43
 
43
- MESSENGER_TOKEN=<le-jeton> npx @imenam/mcp-messenger --server
44
+ MESSENGER_TOKEN=<jeton-client> MESSENGER_ADMIN_TOKEN=<jeton-console> \
45
+ npx @imenam/mcp-messenger --server
44
46
  # → listening on http://127.0.0.1:3600 (loopback only)
45
47
  # → auth: shared token — required on every request
48
+ # → WARN shared token: no isolation between mailboxes — any holder can act as any address
49
+ # → WARN set MESSENGER_AGENT_TOKENS (one token per agent) to compartmentalize
46
50
  # → web console: http://127.0.0.1:3600/ui/ (asks for MESSENGER_ADMIN_TOKEN once)
47
51
  ```
48
52
 
49
- Deux invariants, non négociables et vérifiés par les tests :
53
+ Trois invariants, non négociables et vérifiés par les tests :
50
54
 
51
55
  1. **Le serveur écoute toujours sur `127.0.0.1`.** `MESSENGER_HOST` n'est pas un réglage :
52
56
  toute autre valeur fait échouer le démarrage. Pour joindre la boîte depuis une autre
@@ -55,6 +59,20 @@ Deux invariants, non négociables et vérifiés par les tests :
55
59
  et données de la console (`/admin`) comprises. Seule la page de la console, qui ne
56
60
  contient aucun secret, est servie librement. Sans `MESSENGER_TOKEN` ni
57
61
  `MESSENGER_AGENT_TOKENS`, le serveur refuse de démarrer.
62
+ 3. **Le jeton de la console n'est jamais celui des agents.** `MESSENGER_ADMIN_TOKEN` est
63
+ exigé dans tous les cas, et le démarrage échoue s'il vaut `MESSENGER_TOKEN`. La console
64
+ voit toutes les boîtes de tous les projets et sait les supprimer, alors que le jeton
65
+ client est recopié dans le `.mcp.json` de chaque projet : les confondre donnerait ce
66
+ pouvoir à tout porteur de ce fichier.
67
+
68
+ > **Le mode jeton partagé ne cloisonne pas les boîtes.** `MESSENGER_TOKEN` atteste
69
+ > l'appartenance à la flotte, pas une adresse : l'en-tête `X-Agent-Id` qui accompagne chaque
70
+ > appel n'est alors adossé à rien, et tout porteur du jeton peut lire, envoyer et supprimer
71
+ > au nom de n'importe quelle boîte. C'est un mode « cercle de confiance », adapté aux
72
+ > sessions d'une même personne sur une même machine — le serveur le rappelle au démarrage.
73
+ > Dès que les agents ne se font pas mutuellement confiance, passer à
74
+ > [`MESSENGER_AGENT_TOKENS`](#serveur), qui lie chaque adresse à son propre
75
+ > jeton et rejette toute autre combinaison.
58
76
 
59
77
  ### 2. Déclarer le MCP côté agent
60
78
 
@@ -474,7 +492,7 @@ navigateur de l'administrateur. Les routes `/admin` acceptent toujours le jeton
474
492
  quart d'heure et par IP.
475
493
 
476
494
  La console n'envoie ni ne modifie aucun message. Les seules écritures qu'elle commande
477
- sont des suppressions, derrière l'icône de corbeille de la barre du haut. **Deux
495
+ sont des suppressions, derrière l'icône de corbeille de la barre du haut. **Trois
478
496
  nettoyages y cohabitent, avec chacun son bouton, parce qu'ils ne visent pas la même
479
497
  chose :**
480
498
 
@@ -485,12 +503,16 @@ chose :**
485
503
  boîte à regrouper ;
486
504
  - **les boîtes inactives depuis une date** : elles ont servi, mais plus rien ne les a
487
505
  touchées depuis. **Une boîte créée après cette date est épargnée, même vide** — une
488
- session qui vient de démarrer et n'a pas encore parlé n'est pas une boîte à l'abandon.
506
+ session qui vient de démarrer et n'a pas encore parlé n'est pas une boîte à l'abandon ;
507
+ - **les boîtes créées avant une date** : le critère est l'âge de la boîte, et lui seul.
508
+ **Une boîte ouverte avant la coupure en fait partie même si elle a parlé ce matin** —
509
+ c'est l'exact inverse du nettoyage précédent, et c'est le geste à passer pour remettre
510
+ un serveur à une date donnée plutôt que pour y faire le tri.
489
511
 
490
512
  S'y ajoute la suppression d'**une boîte en particulier** : chaque ligne de la colonne de
491
513
  gauche porte une corbeille, révélée au survol ou au clavier. Elle n'efface rien d'elle-même,
492
514
  elle ouvre la même fenêtre sur cette seule boîte, avec ce qu'elle contient sous les yeux.
493
- C'est la seule des trois qui ne demande aucun critère : l'adresse vient d'être montrée du
515
+ C'est la seule des quatre qui ne demande aucun critère : l'adresse vient d'être montrée du
494
516
  doigt, il n'y a rien à revérifier.
495
517
 
496
518
  Toutes se font en deux temps, parce qu'elles ne se rattrapent pas :
@@ -526,7 +548,8 @@ Toutes les variables sont documentées dans [`.env.example`](.env.example). L'es
526
548
  | `MESSENGER_DB_PATH` | `./data/messenger.db` | Fichier SQLite |
527
549
  | `MESSENGER_TOKEN` | — | **Obligatoire** (sauf si `MESSENGER_AGENT_TOKENS`) : jeton exigé sur toute requête. `--generate-token` en produit un |
528
550
  | `MESSENGER_AGENT_TOKENS` | — | Un jeton par agent : `planner:tok-a,coder:tok-b` |
529
- | `MESSENGER_ADMIN_TOKEN` | `MESSENGER_TOKEN` | Jeton de la console ; obligatoire si seul `MESSENGER_AGENT_TOKENS` est défini |
551
+ | `MESSENGER_ADMIN_TOKEN` | — | **Obligatoire**, et toujours différent de `MESSENGER_TOKEN` : jeton de la console, qui voit et supprime toutes les boîtes |
552
+ | `MESSENGER_TRUST_PROXY` | `1` | Proxys de confiance devant le serveur (nombre de sauts, `loopback`, ou `false`). Sans lui, toutes les requêtes venues du proxy se ressemblent : le quota devient un compteur unique et le cookie perd `Secure` |
530
553
  | `MESSENGER_RATE_LIMIT_MAX` | `600` | Requêtes par IP et par fenêtre |
531
554
  | `MESSENGER_RATE_LIMIT_WINDOW_MINUTES` | `15` | Durée de la fenêtre |
532
555
  | `MESSENGER_TRASH_RETENTION_DAYS` | `30` | Purge automatique de la corbeille (`0` = jamais) |
@@ -577,8 +600,9 @@ la console (cookie), qui elle n'ouvre rien d'autre.
577
600
  | `GET` | `/admin/threads` | Fils du serveur entier (`agent` — une ou plusieurs adresses séparées par des virgules —, `search`, `unread_only`, `priority`, `limit`, `offset`) |
578
601
  | `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
579
602
  | `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
603
+ | `GET` | `/admin/mailboxes/created-before` | Boîtes ouvertes avant `created_before`, quelle que soit leur activité |
580
604
  | `GET` | `/admin/mailboxes/empty` | Boîtes par lesquelles rien n'est jamais passé, sans critère de date |
581
- | `POST` | `/admin/mailboxes/purge` | Supprime les boîtes nommées (`mailboxes`, plus `before` **ou** `empty: true`) — refusé si l'une d'elles ne répond plus au critère |
605
+ | `POST` | `/admin/mailboxes/purge` | Supprime les boîtes nommées (`mailboxes`, plus exactement un critère : `before`, `created_before` **ou** `empty: true`) — refusé si l'une d'elles ne répond plus au critère |
582
606
  | `DELETE` | `/admin/mailboxes/:id` | Supprime une boîte désignée, quel que soit son état — 404 si l'adresse est inconnue |
583
607
  | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
584
608
 
@@ -606,7 +630,13 @@ la console (cookie), qui elle n'ouvre rien d'autre.
606
630
  suppriment des boîtes : réservées au jeton d'administration, confirmées deux fois, et
607
631
  revérifiées par le serveur quand elles portent sur un lot.
608
632
  - Derrière un reverse proxy TLS, exposer le serveur en `https://` et donner cette URL aux
609
- agents via `MESSENGER_SERVER_URL`.
633
+ agents via `MESSENGER_SERVER_URL`. Renseigner `MESSENGER_TRUST_PROXY` (défaut `1`) : c'est
634
+ lui qui rend au serveur l'adresse réelle de l'appelant, donc un quota par client plutôt
635
+ qu'un compteur unique, et le `Secure` du cookie de console.
636
+ - **Le jeton posé dans le bloc `env` d'un `.mcp.json` survit à ce fichier.** L'hôte enregistre
637
+ la configuration du serveur MCP dans ses transcriptions de session — pour Claude Code,
638
+ `~/.claude/projects/*.jsonl`, en clair. Le retirer du `.mcp.json` ne l'en efface pas :
639
+ après une fuite, il faut faire tourner le jeton, pas seulement corriger le fichier.
610
640
 
611
641
  ---
612
642
 
@@ -645,8 +675,9 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
645
675
 
646
676
  ### Tests
647
677
 
648
- 287 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
649
- désignée à la main, les vides, celles sans activité depuis une date — avec leur aperçu,
678
+ 309 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
679
+ désignée à la main, les vides, celles sans activité depuis une date, celles créées avant
680
+ une date — avec leur aperçu,
650
681
  leur double confirmation et le refus nominatif d'une boîte qui ne répond plus au critère, la reprise de la boîte d'une session interrompue (clé de session de
651
682
  l'hôte, boîte seulement proposée à défaut, boîte d'une session vivante jamais reprise), le rendu markdown des messages et l'aplatissement des aperçus (dont
652
683
  l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
@@ -138,9 +138,15 @@ export function runSetupMcp({ cwd, force }) {
138
138
  console.log(` ${name.padEnd(28)} ${description}`);
139
139
  }
140
140
  console.log("");
141
- console.log("Le serveur boîte aux lettres doit tourner à part, avec le même jeton :");
142
- console.log(` MESSENGER_TOKEN=<votre-jeton> npx -y ${PACKAGE_NAME} --server`);
143
- console.log(` console web : ${DEFAULT_SERVER_URL}/ui/ (le jeton y est demandé une fois)`);
141
+ console.log("Le serveur boîte aux lettres doit tourner à part, avec le même jeton client");
142
+ console.log("et un second jeton, réservé à la console — les deux doivent différer :");
143
+ console.log(` npx -y ${PACKAGE_NAME} --generate-token (une fois par jeton)`);
144
+ console.log(` MESSENGER_TOKEN=<jeton-client> MESSENGER_ADMIN_TOKEN=<jeton-console> \\`);
145
+ console.log(` npx -y ${PACKAGE_NAME} --server`);
146
+ console.log(` console web : ${DEFAULT_SERVER_URL}/ui/ (le jeton console y est demandé une fois)`);
147
+ console.log("");
148
+ console.log("Le jeton client va dans le .mcp.json ci-dessus ; le jeton console n'y va jamais.");
149
+ console.log("Il ouvre toutes les boîtes de tous les projets et permet de les supprimer.");
144
150
  console.log("");
145
151
  console.log("Chaque session d'agent reçoit sa propre boîte : demandez son adresse à l'agent");
146
152
  console.log("(outil whoami) pour la transmettre à un autre agent, ou pour la reprendre plus tard.");
@@ -12,8 +12,17 @@ export interface ServerConfig {
12
12
  token: string | null;
13
13
  /** Jeton dédié par agent : `agent_id -> token`. Prioritaire sur `token`. */
14
14
  agentTokens: Map<string, string>;
15
- /** Jeton de l'interface de supervision. À défaut, `token` fait office. Jamais nul. */
15
+ /** Jeton de l'interface de supervision. Toujours distinct de `token`, et exigé. */
16
16
  adminToken: string;
17
+ /**
18
+ * Valeur passée à `trust proxy` d'Express. Le serveur n'écoute qu'en loopback et se
19
+ * déploie donc derrière un reverse proxy : sans ce réglage, `req.ip` vaut `127.0.0.1`
20
+ * pour tout le monde — le quota devient un compteur unique partagé par tous les clients,
21
+ * et `req.secure` reste faux même en HTTPS.
22
+ *
23
+ * `1` par défaut : un seul proxy devant, le cas documenté. `0` désactive.
24
+ */
25
+ trustProxy: number | string | boolean;
17
26
  rateLimit: {
18
27
  max: number;
19
28
  windowMs: number;
@@ -53,9 +53,20 @@ export function loadServerConfig(env = process.env) {
53
53
  if (!token && agentTokens.size === 0) {
54
54
  throw new ConfigError("a token is required: set MESSENGER_TOKEN (shared) or MESSENGER_AGENT_TOKENS (one token per agent).");
55
55
  }
56
- const adminToken = nonEmpty(env.MESSENGER_ADMIN_TOKEN) ?? token;
56
+ // Jamais de repli sur `token` : la console d'administration voit toutes les boîtes de
57
+ // tous les projets et sait les supprimer, alors que le jeton partagé est distribué à
58
+ // chaque client MCP. Les faire coïncider donnerait ce pouvoir à tout porteur du jeton —
59
+ // en silence, et précisément dans la configuration la plus courante.
60
+ const adminToken = nonEmpty(env.MESSENGER_ADMIN_TOKEN);
57
61
  if (!adminToken) {
58
- throw new ConfigError("MESSENGER_ADMIN_TOKEN is required when only MESSENGER_AGENT_TOKENS is set: the web console needs its own token.");
62
+ throw new ConfigError("MESSENGER_ADMIN_TOKEN is required: the web console sees every mailbox and can delete them, " +
63
+ "so it never shares the token handed to MCP clients. " +
64
+ "Generate one with `npx @imenam/mcp-messenger --generate-token`.");
65
+ }
66
+ // Le poser à la même valeur à la main rouvrirait exactement la porte que l'on ferme.
67
+ if (token && adminToken === token) {
68
+ throw new ConfigError("MESSENGER_ADMIN_TOKEN must differ from MESSENGER_TOKEN: the shared token is distributed to " +
69
+ "every MCP client, and the admin token opens the whole console.");
59
70
  }
60
71
  return {
61
72
  port,
@@ -64,6 +75,7 @@ export function loadServerConfig(env = process.env) {
64
75
  token,
65
76
  agentTokens,
66
77
  adminToken,
78
+ trustProxy: parseTrustProxy(env.MESSENGER_TRUST_PROXY),
67
79
  rateLimit: {
68
80
  max: parseIntEnv(env.MESSENGER_RATE_LIMIT_MAX, 600, "MESSENGER_RATE_LIMIT_MAX"),
69
81
  windowMs: parseIntEnv(env.MESSENGER_RATE_LIMIT_WINDOW_MINUTES, 15, "MESSENGER_RATE_LIMIT_WINDOW_MINUTES") * 60_000,
@@ -121,6 +133,25 @@ function parseAgentTokens(raw) {
121
133
  }
122
134
  return map;
123
135
  }
136
+ /**
137
+ * Nombre de proxys de confiance devant le serveur, ou une expression qu'Express comprend
138
+ * (`loopback`, une liste d'adresses...). Défaut `1` : le déploiement documenté met un
139
+ * reverse proxy devant, et c'est lui qui pose `X-Forwarded-For` / `X-Forwarded-Proto`.
140
+ */
141
+ function parseTrustProxy(raw) {
142
+ const value = nonEmpty(raw);
143
+ if (value === null)
144
+ return 1;
145
+ if (value === "false")
146
+ return false;
147
+ if (value === "true")
148
+ return true;
149
+ // Un nombre est un nombre de sauts : Express ne retient alors que l'adresse posée par le
150
+ // proxy le plus proche, celle qu'un client ne peut pas forger.
151
+ if (/^\d+$/.test(value))
152
+ return Number(value);
153
+ return value;
154
+ }
124
155
  function nonEmpty(value) {
125
156
  const trimmed = value?.trim();
126
157
  return trimmed ? trimmed : null;
@@ -98,6 +98,17 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
98
98
  staleMailboxes(before: string): (AgentDirectoryEntry & {
99
99
  messages: number;
100
100
  })[];
101
+ /**
102
+ * Boîtes ouvertes avant `before`, quoi qu'elles aient fait depuis.
103
+ *
104
+ * L'âge se lit ici sur la seule date de création : une boîte née avant la coupure en
105
+ * fait partie même si elle a parlé ce matin. C'est l'inverse de `staleMailboxes`, qui
106
+ * épargne tout ce qui bouge encore — les deux critères se ressemblent, et c'est la
107
+ * raison pour laquelle ils sont deux.
108
+ */
109
+ mailboxesCreatedBefore(before: string): (AgentDirectoryEntry & {
110
+ messages: number;
111
+ })[];
101
112
  /**
102
113
  * Boîtes par lesquelles rien n'est jamais passé, ni envoi ni réception.
103
114
  *
@@ -603,6 +603,28 @@ export class MessageStore extends EventEmitter {
603
603
  last_activity_at: row.last_activity_at || null,
604
604
  }));
605
605
  }
606
+ /**
607
+ * Boîtes ouvertes avant `before`, quoi qu'elles aient fait depuis.
608
+ *
609
+ * L'âge se lit ici sur la seule date de création : une boîte née avant la coupure en
610
+ * fait partie même si elle a parlé ce matin. C'est l'inverse de `staleMailboxes`, qui
611
+ * épargne tout ce qui bouge encore — les deux critères se ressemblent, et c'est la
612
+ * raison pour laquelle ils sont deux.
613
+ */
614
+ mailboxesCreatedBefore(before) {
615
+ const rows = this.db
616
+ .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at,
617
+ (SELECT COUNT(*) FROM deliveries d WHERE d.agent_id = a.id) AS messages
618
+ FROM agents a
619
+ WHERE a.created_at < ?
620
+ ORDER BY a.created_at ASC`)
621
+ .all(before);
622
+ return rows.map((row) => ({
623
+ ...row,
624
+ registered: Boolean(row.registered),
625
+ last_activity_at: row.last_activity_at || null,
626
+ }));
627
+ }
606
628
  /**
607
629
  * Boîtes par lesquelles rien n'est jamais passé, ni envoi ni réception.
608
630
  *
@@ -102,21 +102,24 @@ const isoDate = z
102
102
  .regex(/^\d{4}-\d{2}-\d{2}(T[\d:.]+Z?)?$/, "expected a date such as 2026-08-01")
103
103
  .refine((value) => !Number.isNaN(Date.parse(value)), "not a real date");
104
104
  const staleSchema = z.object({ before: isoDate });
105
+ const createdBeforeSchema = z.object({ created_before: isoDate });
105
106
  /**
106
107
  * Une suppression désigne ses boîtes, et rappelle à quel titre elles sont supprimables :
107
- * `before`, elles n'ont plus d'activité depuis cette date ; `empty`, rien n'y est jamais
108
- * passé. Les deux critères s'excluent — le serveur revérifie celui qu'on lui donne, il
109
- * doit donc savoir lequel c'est.
108
+ * `before`, elles n'ont plus d'activité depuis cette date ; `created_before`, elles ont
109
+ * été ouvertes avant cette date ; `empty`, rien n'y est jamais passé. Les trois critères
110
+ * s'excluent — le serveur revérifie celui qu'on lui donne, il doit donc savoir lequel
111
+ * c'est, et les deux critères datés ne désignent pas les mêmes boîtes.
110
112
  */
111
113
  const purgeSchema = z
112
114
  .object({
113
115
  before: isoDate.optional(),
116
+ created_before: isoDate.optional(),
114
117
  empty: z.literal(true).optional(),
115
118
  /** Les adresses vues dans l'aperçu : on ne supprime jamais autre chose que ce qui a été montré. */
116
119
  mailboxes: z.array(z.string().trim().min(1)).min(1).max(500),
117
120
  })
118
- .refine((input) => (input.before === undefined) !== (input.empty === undefined), {
119
- message: "provide either `before` (inactive since that date) or `empty: true`, not both",
121
+ .refine((input) => [input.before, input.created_before, input.empty].filter((value) => value !== undefined).length === 1, {
122
+ message: "provide exactly one of `before` (inactive since that date), `created_before` (created before that date) or `empty: true`",
120
123
  });
121
124
  const adminListSchema = z.object({
122
125
  search: z.string().trim().min(1).optional(),
@@ -130,7 +133,10 @@ export function createApp({ store, config }) {
130
133
  // Présence des veilleurs : état de connexion, donc en mémoire du processus.
131
134
  const watchers = new WatcherRegistry();
132
135
  app.disable("x-powered-by");
133
- app.set("trust proxy", false);
136
+ // Le serveur n'écoute qu'en loopback : ce qui l'atteint de l'extérieur passe par un
137
+ // reverse proxy. Sans ce réglage, toutes les requêtes se présentent comme `127.0.0.1`
138
+ // et le quota par adresse n'en distingue aucune.
139
+ app.set("trust proxy", config.trustProxy);
134
140
  app.use(helmet({
135
141
  contentSecurityPolicy: {
136
142
  directives: {
@@ -160,7 +166,9 @@ export function createApp({ store, config }) {
160
166
  const cookieOptions = (req) => ({
161
167
  httpOnly: true,
162
168
  sameSite: "strict",
163
- // `req.secure` n'est vrai derrière un proxy TLS que si `trust proxy` est configuré.
169
+ // Vrai dès que le proxy annonce `X-Forwarded-Proto: https`, ce que `trust proxy` (posé
170
+ // plus haut depuis la configuration) autorise Express à lire. Reste faux en clair sur
171
+ // la machine, où le cookie doit justement circuler.
164
172
  secure: req.secure,
165
173
  path: "/",
166
174
  });
@@ -236,6 +244,16 @@ export function createApp({ store, config }) {
236
244
  const { before } = staleSchema.parse(req.query);
237
245
  res.json({ before, mailboxes: store.staleMailboxes(before) });
238
246
  });
247
+ /**
248
+ * Boîtes ouvertes avant une date : ce que la purge par ancienneté emporterait.
249
+ *
250
+ * Même aperçu obligatoire que les autres nettoyages, mais un critère différent — ici
251
+ * l'activité ne protège rien, seule compte la date d'ouverture de la boîte.
252
+ */
253
+ admin.get("/mailboxes/created-before", (req, res) => {
254
+ const { created_before: createdBefore } = createdBeforeSchema.parse(req.query);
255
+ res.json({ created_before: createdBefore, mailboxes: store.mailboxesCreatedBefore(createdBefore) });
256
+ });
239
257
  /**
240
258
  * Boîtes par lesquelles rien n'est jamais passé. Sans date : une boîte vide l'est
241
259
  * indépendamment de son âge, et un agent dont toutes les boîtes sont vides disparaît
@@ -254,15 +272,29 @@ export function createApp({ store, config }) {
254
272
  */
255
273
  admin.post("/mailboxes/purge", (req, res) => {
256
274
  const input = purgeSchema.parse(req.body ?? {});
257
- const reason = input.empty ? "empty" : `inactive since ${input.before}`;
258
- const eligible = new Set((input.empty ? store.emptyMailboxes() : store.staleMailboxes(input.before)).map((mailbox) => mailbox.id));
275
+ const reason = input.empty
276
+ ? "empty"
277
+ : input.created_before
278
+ ? `older than ${input.created_before}`
279
+ : `inactive since ${input.before}`;
280
+ const candidates = input.empty
281
+ ? store.emptyMailboxes()
282
+ : input.created_before
283
+ ? store.mailboxesCreatedBefore(input.created_before)
284
+ : store.staleMailboxes(input.before);
285
+ const eligible = new Set(candidates.map((mailbox) => mailbox.id));
259
286
  const changed = input.mailboxes.filter((id) => !eligible.has(id));
260
287
  if (changed.length > 0) {
261
288
  throw new MessengerError(`these mailboxes are no longer ${reason}, nothing was deleted: ${changed.join(", ")}`, 409);
262
289
  }
263
290
  const removed = store.deleteMailboxes(input.mailboxes);
264
291
  logger.warn(`admin purge: ${removed.mailboxes} mailbox(es) deleted, ${reason}`);
265
- res.json({ before: input.before ?? null, empty: input.empty ?? false, ...removed });
292
+ res.json({
293
+ before: input.before ?? null,
294
+ created_before: input.created_before ?? null,
295
+ empty: input.empty ?? false,
296
+ ...removed,
297
+ });
266
298
  });
267
299
  /**
268
300
  * Suppression d'une boîte désignée à la main, quel que soit son état.
@@ -502,6 +534,10 @@ function authenticateAgent(config) {
502
534
  res.status(401).json({ error: "invalid or missing bearer token" });
503
535
  return;
504
536
  }
537
+ // Le jeton partagé atteste l'appartenance à la flotte, pas une adresse : `X-Agent-Id`
538
+ // n'est adossé à rien ici, et rien ne cloisonne les boîtes entre elles. C'est le mode
539
+ // « cercle de confiance », annoncé comme tel au démarrage et dans le README ;
540
+ // MESSENGER_AGENT_TOKENS est ce qui lie une adresse à un jeton.
505
541
  }
506
542
  req.agentId = agentId;
507
543
  next();
@@ -43,6 +43,13 @@ export async function runServerMode() {
43
43
  logger.info(`mcp-messenger v${APP_VERSION} listening on ${running.url} (loopback only)`);
44
44
  logger.info(`database: ${config.dbPath}`);
45
45
  logger.info(`auth: ${authMode} — required on every request`);
46
+ // Le jeton partagé prouve qu'on fait partie de la flotte, pas qu'on est telle boîte :
47
+ // `X-Agent-Id` n'est alors adossé à rien. Le dire ici, sinon la ligne au-dessus se lit
48
+ // comme une garantie de cloisonnement qu'elle ne donne pas.
49
+ if (config.agentTokens.size === 0) {
50
+ logger.warn("shared token: no isolation between mailboxes — any holder can act as any address");
51
+ logger.warn("set MESSENGER_AGENT_TOKENS (one token per agent) to compartmentalize");
52
+ }
46
53
  logger.info(`web console: ${running.url}/ui/ (asks for MESSENGER_ADMIN_TOKEN once)`);
47
54
  let shuttingDown = false;
48
55
  const shutdown = (signal) => {
@@ -101,7 +101,9 @@ const el = {
101
101
  purgeClose: document.getElementById("purge-close"),
102
102
  purgeCancel: document.getElementById("purge-cancel"),
103
103
  purgeBefore: document.getElementById("purge-before"),
104
+ purgeCreatedBefore: document.getElementById("purge-created-before"),
104
105
  purgePreview: document.getElementById("purge-preview"),
106
+ purgePreviewCreated: document.getElementById("purge-preview-created"),
105
107
  purgePreviewEmpty: document.getElementById("purge-preview-empty"),
106
108
  purgeResult: document.getElementById("purge-result"),
107
109
  purgeConfirm: document.getElementById("purge-confirm"),
@@ -1303,8 +1305,11 @@ const purge = { mode: null, before: null, mailboxes: [], selected: new Set(), ar
1303
1305
 
1304
1306
  function openPurge() {
1305
1307
  const month = new Date(Date.now() - 30 * 86_400_000);
1308
+ const today = new Date().toISOString().slice(0, 10);
1306
1309
  el.purgeBefore.value = month.toISOString().slice(0, 10);
1307
- el.purgeBefore.max = new Date().toISOString().slice(0, 10);
1310
+ el.purgeBefore.max = today;
1311
+ el.purgeCreatedBefore.value = month.toISOString().slice(0, 10);
1312
+ el.purgeCreatedBefore.max = today;
1308
1313
  resetPurge();
1309
1314
  el.purge.showModal();
1310
1315
  }
@@ -1356,6 +1361,13 @@ async function previewStale() {
1356
1361
  await previewPurge("stale", () => api("/admin/mailboxes/stale", { before }), before);
1357
1362
  }
1358
1363
 
1364
+ /** Boîtes ouvertes avant la date choisie, qu'elles servent encore ou non. */
1365
+ async function previewCreated() {
1366
+ const before = el.purgeCreatedBefore.value;
1367
+ if (!before) return;
1368
+ await previewPurge("created", () => api("/admin/mailboxes/created-before", { created_before: before }), before);
1369
+ }
1370
+
1359
1371
  async function previewPurge(mode, load, before = null) {
1360
1372
  resetPurge();
1361
1373
  try {
@@ -1373,10 +1385,15 @@ async function previewPurge(mode, load, before = null) {
1373
1385
  function renderPurgePreview() {
1374
1386
  const container = document.createDocumentFragment();
1375
1387
  const count = purge.mailboxes.length;
1376
- // Le critère est rappelé sous les yeux de qui va confirmer : les trois suppressions
1377
- // remplissent la même liste, et elles ne portent pas sur les mêmes boîtes.
1378
- const since = ` sans activité depuis le ${formatDay(purge.before)}`;
1379
- const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : since;
1388
+ // Le critère est rappelé sous les yeux de qui va confirmer : les quatre suppressions
1389
+ // remplissent la même liste, et elles ne portent pas sur les mêmes boîtes. Les deux
1390
+ // critères datés se distinguent par leur seule formulation : « depuis » regarde
1391
+ // l'activité, « créée avant » regarde l'ouverture de la boîte.
1392
+ const dated =
1393
+ purge.mode === "created"
1394
+ ? ` créée avant le ${formatDay(purge.before)}`
1395
+ : ` sans activité depuis le ${formatDay(purge.before)}`;
1396
+ const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : dated;
1380
1397
 
1381
1398
  if (count === 0) {
1382
1399
  container.appendChild(node("p", "purge-empty", `Aucune boîte ${criterion.trim()}.`));
@@ -1392,7 +1409,9 @@ function renderPurgePreview() {
1392
1409
  ? "Cette boîte, et tout ce que personne d'autre ne pourrait plus lire :"
1393
1410
  : purge.mode === "empty"
1394
1411
  ? `${plural(count, "boîte vide", "boîtes vides")}.`
1395
- : `${plural(count, "boîte")}${since}.`
1412
+ : purge.mode === "created"
1413
+ ? `${plural(count, "boîte créée", "boîtes créées")} avant le ${formatDay(purge.before)}.`
1414
+ : `${plural(count, "boîte")}${dated}.`
1396
1415
  )
1397
1416
  );
1398
1417
 
@@ -1457,7 +1476,11 @@ async function confirmPurge() {
1457
1476
  ? await remove(`/admin/mailboxes/${encodeURIComponent(deleted[0])}`)
1458
1477
  : await post("/admin/mailboxes/purge", {
1459
1478
  // Le critère repart avec les adresses : c'est lui que le serveur revérifie.
1460
- ...(purge.mode === "empty" ? { empty: true } : { before: purge.before }),
1479
+ ...(purge.mode === "empty"
1480
+ ? { empty: true }
1481
+ : purge.mode === "created"
1482
+ ? { created_before: purge.before }
1483
+ : { before: purge.before }),
1461
1484
  mailboxes: deleted,
1462
1485
  });
1463
1486
  resetPurge();
@@ -1488,7 +1511,9 @@ el.purgeOpen.addEventListener("click", openPurge);
1488
1511
  el.purgeClose.addEventListener("click", () => el.purge.close());
1489
1512
  el.purgeCancel.addEventListener("click", () => el.purge.close());
1490
1513
  el.purgeBefore.addEventListener("change", resetPurge);
1514
+ el.purgeCreatedBefore.addEventListener("change", resetPurge);
1491
1515
  el.purgePreview.addEventListener("click", previewStale);
1516
+ el.purgePreviewCreated.addEventListener("click", previewCreated);
1492
1517
  el.purgePreviewEmpty.addEventListener("click", previewEmpty);
1493
1518
  el.purgeConfirm.addEventListener("click", confirmPurge);
1494
1519
 
@@ -70,7 +70,7 @@
70
70
  </button>
71
71
  </div>
72
72
  <p class="modal-sub">
73
- Deux nettoyages, deux critères. La suppression est définitive dans les deux cas, et emporte les messages que
73
+ Trois nettoyages, trois critères. La suppression est définitive dans tous les cas, et emporte les messages que
74
74
  plus personne ne pourrait lire ; ce que le correspondant a reçu de son côté reste intact. Un agent dont toutes
75
75
  les boîtes disparaissent quitte l'annuaire avec elles.
76
76
  </p>
@@ -94,6 +94,16 @@
94
94
  <button type="button" class="btn" id="purge-preview">Prévisualiser</button>
95
95
  </div>
96
96
 
97
+ <!-- Troisième critère : l'âge de la boîte, que son activité ne rachète pas. -->
98
+ <div class="purge-controls">
99
+ <label class="field">
100
+ <span class="field-label">Boîtes créées avant le</span>
101
+ <span class="field-input"><input type="date" id="purge-created-before" /></span>
102
+ <span class="purge-hint">Sur la seule date d'ouverture : une boîte encore active en fait partie.</span>
103
+ </label>
104
+ <button type="button" class="btn" id="purge-preview-created">Prévisualiser</button>
105
+ </div>
106
+
97
107
  <div class="purge-result" id="purge-result"></div>
98
108
 
99
109
  <div class="modal-foot">
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.27";
1
+ export declare const APP_VERSION = "1.0.28";
@@ -1,2 +1,2 @@
1
1
  // Généré par scripts/generate-version.js — ne pas modifier à la main.
2
- export const APP_VERSION = "1.0.27";
2
+ export const APP_VERSION = "1.0.28";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",