@imenam/mcp-messenger 1.0.5 → 1.0.7

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.
@@ -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
+ }
@@ -43,8 +43,7 @@ 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 n'est pas écrit dans les logs : seule sa provenance est rappelée.
47
- logger.info(`web console: ${running.url}/ui/?token=<MESSENGER_ADMIN_TOKEN>`);
46
+ logger.info(`web console: ${running.url}/ui/ (asks for MESSENGER_ADMIN_TOKEN once)`);
48
47
  let shuttingDown = false;
49
48
  const shutdown = (signal) => {
50
49
  if (shuttingDown)
@@ -1,15 +1,12 @@
1
1
  // Console de supervision : lecture seule sur les routes /admin du serveur.
2
2
  // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement.
3
3
 
4
- const TOKEN_KEY = "messenger.admin.token";
5
4
  const REFRESH_MS = 5000;
6
5
 
7
- // Le serveur exige un jeton sur toute requête, y compris celle qui a servi cette page :
8
- // il est propagé sur l'URL de ce script. On le reprend tel quel comme jeton de session.
9
- const TOKEN_FROM_URL = new URL(import.meta.url).searchParams.get("token");
6
+ // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
7
+ // ce script ne voit jamais le jeton, et ne stocke rien dans le navigateur.
10
8
 
11
9
  const state = {
12
- token: TOKEN_FROM_URL || localStorage.getItem(TOKEN_KEY) || "",
13
10
  agent: null,
14
11
  search: "",
15
12
  selectedMessageId: null,
@@ -28,10 +25,10 @@ const el = {
28
25
  clearFilters: document.getElementById("clear-filters"),
29
26
  refresh: document.getElementById("refresh"),
30
27
  autoRefresh: document.getElementById("auto-refresh"),
31
- tokenBar: document.getElementById("token-bar"),
28
+ logout: document.getElementById("logout"),
29
+ loginForm: document.getElementById("login-form"),
32
30
  tokenInput: document.getElementById("token"),
33
31
  tokenError: document.getElementById("token-error"),
34
- saveToken: document.getElementById("save-token"),
35
32
  };
36
33
 
37
34
  async function api(path, params) {
@@ -39,12 +36,10 @@ async function api(path, params) {
39
36
  for (const [key, value] of Object.entries(params || {})) {
40
37
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
41
38
  }
42
- const headers = { accept: "application/json" };
43
- if (state.token) headers.authorization = "Bearer " + state.token;
44
39
 
45
- const response = await fetch(url, { headers });
40
+ const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
46
41
  if (response.status === 401) {
47
- el.tokenBar.hidden = false;
42
+ setLoggedIn(false);
48
43
  throw new Error("unauthorized");
49
44
  }
50
45
  if (!response.ok) {
@@ -54,6 +49,12 @@ async function api(path, params) {
54
49
  return response.json();
55
50
  }
56
51
 
52
+ function setLoggedIn(loggedIn) {
53
+ el.loginForm.hidden = loggedIn;
54
+ el.logout.hidden = !loggedIn;
55
+ if (!loggedIn) el.tokenInput.focus();
56
+ }
57
+
57
58
  /** Remplace le contenu du panneau de détail (et retire l'état « vide » hérité du HTML). */
58
59
  function setDetail(child, empty) {
59
60
  el.detail.className = empty ? "empty" : "detail-content";
@@ -86,8 +87,11 @@ function renderAgents(overview) {
86
87
 
87
88
  for (const agent of overview.agents) {
88
89
  const item = node("li", state.agent === agent.id ? "selected" : "");
89
- // Le nom d'abord : l'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
90
- item.appendChild(node("div", "name", agent.display_name || "sans nom"));
90
+ // Deux noms, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte de quoi
91
+ // cette session s'occupe. L'un sans l'autre ne suffit pas à s'y retrouver.
92
+ // L'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
93
+ item.appendChild(node("div", "name", agent.agent_name || "agent sans nom"));
94
+ item.appendChild(node("div", "mailbox-name", agent.display_name || "boîte sans nom"));
91
95
  item.appendChild(node("div", "id", agent.id));
92
96
  item.appendChild(
93
97
  node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
@@ -131,7 +135,7 @@ function renderMessages(messages) {
131
135
  // Le nom du fil dit de quelle conversation relève le message ; sans nom, rien à afficher.
132
136
  if (message.thread_title) item.appendChild(node("div", "thread-name", message.thread_title));
133
137
  item.appendChild(
134
- node("div", "route", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
138
+ node("div", "route", message.from + " → " + (message.to || "—") + " · " + formatDate(message.created_at))
135
139
  );
136
140
  item.appendChild(node("div", "preview", message.preview));
137
141
  item.addEventListener("click", () => openMessage(message.message_id));
@@ -160,8 +164,7 @@ function renderDetail(message) {
160
164
  "De " +
161
165
  message.from +
162
166
  " · À " +
163
- (message.to.join(", ") || "—") +
164
- (message.cc.length ? " · Cc " + message.cc.join(", ") : "") +
167
+ (message.to || "—") +
165
168
  "\n" +
166
169
  formatDate(message.created_at) +
167
170
  " · priorité " +
@@ -225,7 +228,7 @@ async function showThread(threadId) {
225
228
  const block = node("div", "thread-message");
226
229
  block.appendChild(node("div", "subject", message.subject));
227
230
  block.appendChild(
228
- node("div", "meta", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
231
+ node("div", "meta", message.from + " → " + (message.to || "—") + " · " + formatDate(message.created_at))
229
232
  );
230
233
  block.appendChild(node("div", "preview", message.preview));
231
234
  const openButton = node("button", "link", "ouvrir");
@@ -245,7 +248,7 @@ async function refresh(options) {
245
248
  api("/admin/overview"),
246
249
  api("/admin/messages", { agent: state.agent, search: state.search, thread_id: state.threadId, limit: 100 }),
247
250
  ]);
248
- el.tokenBar.hidden = true;
251
+ setLoggedIn(true);
249
252
  renderAgents(overview);
250
253
  renderMessages(list.messages);
251
254
  if (!options || !options.keepDetail) {
@@ -279,17 +282,39 @@ el.search.addEventListener("input", (event) => {
279
282
  searchTimer = setTimeout(() => refresh({ keepDetail: true }), 250);
280
283
  });
281
284
 
282
- el.saveToken.addEventListener("click", () => {
283
- state.token = el.tokenInput.value.trim();
284
- localStorage.setItem(TOKEN_KEY, state.token);
285
+ el.loginForm.addEventListener("submit", async (event) => {
286
+ event.preventDefault();
285
287
  el.tokenError.textContent = "";
286
- refresh({ keepDetail: true }).then(() => {
287
- if (!el.tokenBar.hidden) el.tokenError.textContent = "jeton refusé";
288
- });
288
+ const token = el.tokenInput.value.trim();
289
+ if (!token) return;
290
+ const response = await fetch("/ui/login", {
291
+ method: "POST",
292
+ headers: { "content-type": "application/json" },
293
+ credentials: "same-origin",
294
+ body: JSON.stringify({ token }),
295
+ }).catch(() => null);
296
+ // Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
297
+ el.tokenInput.value = "";
298
+ if (!response || !response.ok) {
299
+ el.tokenError.textContent = response && response.status === 429 ? "trop de tentatives, réessayez plus tard" : "jeton refusé";
300
+ return;
301
+ }
302
+ refresh();
303
+ });
304
+
305
+ el.logout.addEventListener("click", async () => {
306
+ await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
307
+ setLoggedIn(false);
308
+ el.statusDot.className = "dot";
309
+ el.serverStats.textContent = "déconnecté";
310
+ el.agents.replaceChildren();
311
+ el.messages.replaceChildren();
312
+ state.selectedMessageId = null;
313
+ setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
289
314
  });
290
315
 
291
316
  setInterval(() => {
292
- if (el.autoRefresh.checked) refresh({ keepDetail: true });
317
+ if (el.autoRefresh.checked && el.loginForm.hidden) refresh({ keepDetail: true });
293
318
  }, REFRESH_MS);
294
319
 
295
320
  refresh();
@@ -19,15 +19,16 @@
19
19
  rafraîchissement auto
20
20
  </label>
21
21
  <button id="refresh">Rafraîchir</button>
22
+ <button id="logout" hidden>Se déconnecter</button>
22
23
  </div>
23
24
  </header>
24
25
 
25
- <div id="token-bar" class="token-bar" hidden>
26
+ <form id="login-form" class="token-bar" hidden>
26
27
  <label for="token">Jeton d'accès requis</label>
27
- <input type="password" id="token" placeholder="MESSENGER_ADMIN_TOKEN ou MESSENGER_TOKEN" />
28
- <button id="save-token">Se connecter</button>
28
+ <input type="password" id="token" autocomplete="current-password" placeholder="MESSENGER_ADMIN_TOKEN" />
29
+ <button type="submit">Se connecter</button>
29
30
  <span class="error" id="token-error"></span>
30
- </div>
31
+ </form>
31
32
 
32
33
  <main>
33
34
  <aside class="panel agents">
@@ -121,6 +121,7 @@ input[type="password"] {
121
121
  }
122
122
 
123
123
  .token-bar {
124
+ margin: 0;
124
125
  display: flex;
125
126
  align-items: center;
126
127
  gap: 10px;
@@ -184,6 +185,11 @@ ul {
184
185
  font-weight: 600;
185
186
  }
186
187
 
188
+ /* Le nom de la boîte, sous celui de l'agent : le sujet de la session, sans lui voler la vedette. */
189
+ .agents .mailbox-name {
190
+ font-size: 12px;
191
+ }
192
+
187
193
  /* L'adresse reste lisible pour être recopiée, mais ne prend plus le pas sur le nom. */
188
194
  .agents .id {
189
195
  color: var(--muted);
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.5";
1
+ export declare const APP_VERSION = "1.0.7";
@@ -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.5";
2
+ export const APP_VERSION = "1.0.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",