@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 +43 -12
- package/dist/src/cli/setup-mcp.js +9 -3
- package/dist/src/config.d.ts +10 -1
- package/dist/src/config.js +33 -2
- package/dist/src/core/store.d.ts +11 -0
- package/dist/src/core/store.js +22 -0
- package/dist/src/server/app.js +46 -10
- package/dist/src/server/start.js +7 -0
- package/dist/src/server/ui/app.js +32 -7
- package/dist/src/server/ui/index.html +11 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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
|
-
#
|
|
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=<
|
|
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
|
-
|
|
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. **
|
|
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
|
|
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`
|
|
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
|
-
|
|
649
|
-
désignée à la main, les vides, celles sans activité depuis une date
|
|
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(
|
|
143
|
-
console.log(`
|
|
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.");
|
package/dist/src/config.d.ts
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/dist/src/core/store.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/src/core/store.js
CHANGED
|
@@ -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
|
*
|
package/dist/src/server/app.js
CHANGED
|
@@ -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 ; `
|
|
108
|
-
*
|
|
109
|
-
* doit donc savoir lequel
|
|
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) =>
|
|
119
|
-
message: "provide
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
258
|
-
|
|
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({
|
|
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();
|
package/dist/src/server/start.js
CHANGED
|
@@ -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 =
|
|
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
|
|
1377
|
-
// remplissent la même liste, et elles ne portent pas sur les mêmes boîtes.
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
-
:
|
|
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"
|
|
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
|
-
|
|
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">
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.28";
|
package/dist/src/version.js
CHANGED
|
@@ -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.
|
|
2
|
+
export const APP_VERSION = "1.0.28";
|