@imenam/mcp-messenger 1.0.31 → 1.0.33

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.
Files changed (36) hide show
  1. package/.env.example +12 -22
  2. package/README.md +825 -764
  3. package/dist/src/cli/reset-password.d.ts +12 -0
  4. package/dist/src/cli/reset-password.js +29 -0
  5. package/dist/src/cli/setup-mcp.d.ts +5 -1
  6. package/dist/src/cli/setup-mcp.js +15 -37
  7. package/dist/src/cli/watch-handoff.d.ts +3 -3
  8. package/dist/src/cli/watch-handoff.js +2 -2
  9. package/dist/src/cli/watch.d.ts +1 -1
  10. package/dist/src/cli/watch.js +3 -3
  11. package/dist/src/client/messenger-client.d.ts +3 -3
  12. package/dist/src/client/messenger-client.js +5 -6
  13. package/dist/src/config.d.ts +13 -8
  14. package/dist/src/config.js +32 -43
  15. package/dist/src/core/access-store.d.ts +124 -0
  16. package/dist/src/core/access-store.js +307 -0
  17. package/dist/src/core/credentials.d.ts +28 -0
  18. package/dist/src/core/credentials.js +75 -0
  19. package/dist/src/core/errors.d.ts +5 -0
  20. package/dist/src/core/errors.js +9 -0
  21. package/dist/src/core/store.d.ts +10 -5
  22. package/dist/src/core/store.js +18 -9
  23. package/dist/src/core/types.d.ts +1 -1
  24. package/dist/src/index.js +32 -17
  25. package/dist/src/mcp/start.js +26 -26
  26. package/dist/src/mcp/tools.js +2 -2
  27. package/dist/src/server/app.d.ts +10 -1
  28. package/dist/src/server/app.js +259 -69
  29. package/dist/src/server/console-session.d.ts +31 -21
  30. package/dist/src/server/console-session.js +46 -63
  31. package/dist/src/server/start.d.ts +3 -0
  32. package/dist/src/server/start.js +10 -13
  33. package/dist/src/server/ui/app.js +3997 -2464
  34. package/dist/src/server/ui/index.html +467 -284
  35. package/dist/src/server/ui/styles.css +1263 -18
  36. package/package.json +2 -3
@@ -18,31 +18,31 @@ import { TOOLS, callTool } from "./tools.js";
18
18
  * recopier une adresse dans le corps d'un message (le serveur la pose déjà, la copie ment
19
19
  * dès qu'elle vieillit). Les dire ici, c'est les dire avant l'erreur.
20
20
  */
21
- const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exchange messages with other agent sessions.
22
-
23
- Two words, two different things, used consistently by every tool here:
24
- - a **mailbox** is an address (\`box_…\`). It identifies exactly one session, and it is what you write to.
25
- - an **agent name** is the project a session works for. It is a label, never an address, and every session of that project carries it.
26
-
27
- Who you are, and who the others are — read this before writing to anyone:
28
- - A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
29
- - The agent name (e.g. "Ai Corrector Dev") comes from the configuration and says which project the agent works on. It is NOT unique: every session started from that configuration carries it.
30
- - \`list_agents\` groups mailboxes under their agent name: one group per project, one line per session. A group holding several lines is several live sessions of that project, each with its own address — pick one of those addresses, never the group name.
31
- - One of those groups may carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — those are other sessions of the same project, each a different agent with its own mailbox. Compare addresses, never names.
32
- - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
33
-
34
- Resuming earlier work:
35
- - A resumed session usually keeps the mailbox it had: the address, the threads and the unread messages come back on their own. \`whoami\` says so when it happens.
36
- - When it cannot be decided automatically, \`whoami\` lists the mailboxes left by a stopped session of this project. If you are continuing that work, call \`attach_to_mailbox\` with the right address.
37
- - If a \`box_…\` address appears earlier in your own context, from a previous part of this conversation, that is your mailbox from before: attach to it rather than writing from a fresh one, so your correspondents keep reaching you at the address they know.
38
-
39
- Linked mailboxes:
40
- - The user can link this mailbox to others from the web console: those are the correspondents you are meant to work with. \`whoami\` and \`list_links\` list them, and the notification watcher announces each new link as it arrives.
41
-
42
- Writing:
43
- - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
44
- - Never write a mailbox address in the body of a message. The server attaches the sender and the recipient to every message, and the reader sees them: an address copied by hand adds nothing and misleads as soon as it is stale or mistaken.
45
- - Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.
21
+ const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exchange messages with other agent sessions.
22
+
23
+ Two words, two different things, used consistently by every tool here:
24
+ - a **mailbox** is an address (\`box_…\`). It identifies exactly one session, and it is what you write to.
25
+ - an **agent name** is the project a session works for. It is a label, never an address, and every session of that project carries it.
26
+
27
+ Who you are, and who the others are — read this before writing to anyone:
28
+ - A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
29
+ - The agent name (e.g. "Ai Corrector Dev") comes from the configuration and says which project the agent works on. It is NOT unique: every session started from that configuration carries it.
30
+ - \`list_agents\` groups mailboxes under their agent name: one group per project, one line per session. A group holding several lines is several live sessions of that project, each with its own address — pick one of those addresses, never the group name.
31
+ - One of those groups may carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — those are other sessions of the same project, each a different agent with its own mailbox. Compare addresses, never names.
32
+ - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
33
+
34
+ Resuming earlier work:
35
+ - A resumed session usually keeps the mailbox it had: the address, the threads and the unread messages come back on their own. \`whoami\` says so when it happens.
36
+ - When it cannot be decided automatically, \`whoami\` lists the mailboxes left by a stopped session of this project. If you are continuing that work, call \`attach_to_mailbox\` with the right address.
37
+ - If a \`box_…\` address appears earlier in your own context, from a previous part of this conversation, that is your mailbox from before: attach to it rather than writing from a fresh one, so your correspondents keep reaching you at the address they know.
38
+
39
+ Linked mailboxes:
40
+ - The user can link this mailbox to others from the web console: those are the correspondents you are meant to work with. \`whoami\` and \`list_links\` list them, and the notification watcher announces each new link as it arrives.
41
+
42
+ Writing:
43
+ - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
44
+ - Never write a mailbox address in the body of a message. The server attaches the sender and the recipient to every message, and the reader sees them: an address copied by hand adds nothing and misleads as soon as it is stale or mistaken.
45
+ - Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.
46
46
  - The host stops that watcher after 30 minutes at most. Each time it tells you the watcher expired, re-arm it at once with the same Monitor call; otherwise notifications stop for good.`;
47
47
  /** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */
48
48
  export function createMcpServer(client) {
@@ -201,7 +201,7 @@ export async function runMcpMode() {
201
201
  // Tant que ce battement continue, la boîte de cette session n'est reprise par personne.
202
202
  // `unref` : c'est une trace, pas une raison de garder le processus en vie.
203
203
  setInterval(heartbeat, HEARTBEAT_MS).unref();
204
- // Le relais vers le veilleur porte le jeton de la session : il ne survit pas à la
204
+ // Le relais vers le veilleur porte la clé API de la session : il ne survit pas à la
205
205
  // session. Un arrêt brutal le laisse en place, la purge par ancienneté le rattrape.
206
206
  // L'entrée du registre suit le même sort, à ceci près qu'elle est close et non effacée :
207
207
  // c'est elle qui rendra sa boîte à la session suivante.
@@ -410,7 +410,7 @@ export async function callTool(client, name, rawArgs) {
410
410
  " messages you send them — tell it to the user if asked, never inside a message body.",
411
411
  self?.agent_name
412
412
  ? `Agent: ${self.agent_name}`
413
- : "Agent: unnamed (set MESSENGER_AGENT_NAME in the MCP configuration)",
413
+ : "Agent: unnamed (bind the API key to an agent in the web console, or set MESSENGER_AGENT_NAME)",
414
414
  // Le nom vient de la configuration : toutes les sessions du projet le portent. Le
415
415
  // dire ici évite qu'un agent prenne une session homonyme pour la sienne.
416
416
  " ↳ this name is shared with every session of the same project; only the address above identifies you.",
@@ -632,7 +632,7 @@ export async function callTool(client, name, rawArgs) {
632
632
  }
633
633
  case "enable_notifications": {
634
634
  const mailbox = client.agentId;
635
- writeHandoff({ mailbox, serverUrl: client.serverUrl, token: client.tokenForHandoff });
635
+ writeHandoff({ mailbox, serverUrl: client.serverUrl, apiKey: client.apiKeyForHandoff });
636
636
  const stats = await client.stats().catch(() => null);
637
637
  const pending = stats?.folders.find((f) => f.name === "inbox")?.unread ?? 0;
638
638
  // Un veilleur déjà en place suffit : en armer un second doublerait chaque
@@ -1,15 +1,24 @@
1
1
  import express from "express";
2
2
  import type { ServerConfig } from "../config.js";
3
+ import type { ApiKey, ConsoleSession, User } from "../core/access-store.js";
3
4
  import { MessageStore } from "../core/store.js";
5
+ import { SetupGate } from "./console-session.js";
4
6
  declare global {
5
7
  namespace Express {
6
8
  interface Request {
7
9
  agentId?: string;
10
+ /** Clé API présentée par un agent, une fois vérifiée. */
11
+ apiKey?: ApiKey;
12
+ /** Compte et session de la console, une fois la session vérifiée. */
13
+ consoleUser?: User;
14
+ consoleSession?: ConsoleSession;
8
15
  }
9
16
  }
10
17
  }
11
18
  export interface AppOptions {
12
19
  store: MessageStore;
13
20
  config: ServerConfig;
21
+ /** Garde de la création du premier compte ; créée ici quand l'appelant n'en fournit pas. */
22
+ setup?: SetupGate;
14
23
  }
15
- export declare function createApp({ store, config }: AppOptions): express.Express;
24
+ export declare function createApp({ store, config, setup }: AppOptions): express.Express;
@@ -2,17 +2,19 @@ import express from "express";
2
2
  import helmet from "helmet";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { timingSafeEqual } from "node:crypto";
6
5
  import { rateLimit } from "express-rate-limit";
7
6
  import { z } from "zod";
8
7
  import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, contentDisposition } from "../core/attachments.js";
8
+ import { PASSWORD_MIN_LENGTH, generateApiKey, generateSessionSecret, hashPassword, verifyPassword, } from "../core/credentials.js";
9
9
  import { MessengerError, normalizeAgentId } from "../core/store.js";
10
10
  import { PRIORITIES } from "../core/types.js";
11
11
  import { WatcherRegistry } from "./watchers.js";
12
- import { CONSOLE_COOKIE, ConsoleSessions, consoleSessionId } from "./console-session.js";
12
+ import { CONSOLE_COOKIE, PERSISTENT_SESSION_TTL_MS, SESSION_TTL_MS, SetupGate, consoleSessionId, } from "./console-session.js";
13
13
  import { APP_VERSION } from "../version.js";
14
14
  import { logger } from "../logger.js";
15
15
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
16
+ /** Délai pendant lequel une clé remplacée reste acceptée, le temps de mettre le client à jour. */
17
+ const ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
16
18
  const JSON_TYPES = ["application/json", "application/*+json"];
17
19
  /** Corps d'un envoi : le texte, plus les pièces jointes encodées en base64 (4 octets pour 3). */
18
20
  const SEND_BODY_LIMIT = Math.ceil((MAX_ATTACHMENTS_TOTAL_BYTES * 4) / 3) + 1024 * 1024;
@@ -90,7 +92,41 @@ const waitQuerySchema = z.object({
90
92
  // attente longue, mais seul le premier prouve que l'agent est joignable en continu.
91
93
  watcher: z.enum(["true", "1"]).optional(),
92
94
  });
93
- const loginSchema = z.object({ token: z.string().min(1).max(1024) });
95
+ const usernameSchema = z
96
+ .string()
97
+ .trim()
98
+ .min(1)
99
+ .max(100)
100
+ .regex(/^\S+$/, "a username has no spaces");
101
+ const newPasswordSchema = z
102
+ .string()
103
+ .min(PASSWORD_MIN_LENGTH, `at least ${PASSWORD_MIN_LENGTH} characters`)
104
+ .max(1024);
105
+ const loginSchema = z.object({
106
+ username: z.string().trim().min(1).max(100),
107
+ password: z.string().min(1).max(1024),
108
+ remember: z.boolean().optional(),
109
+ });
110
+ const setupSchema = z.object({
111
+ code: z.string().trim().min(1).max(64),
112
+ username: usernameSchema,
113
+ password: newPasswordSchema,
114
+ });
115
+ const accountSchema = z.object({ username: usernameSchema });
116
+ const passwordChangeSchema = z.object({
117
+ current_password: z.string().min(1).max(1024),
118
+ new_password: newPasswordSchema,
119
+ });
120
+ /** Durée de validité d'une clé, en jours ; null = sans échéance. */
121
+ const lifetimeSchema = z.number().int().min(1).max(3650).nullable();
122
+ const keyCreateSchema = z.object({
123
+ name: z.string().trim().min(1).max(200),
124
+ /** Agent dont la clé ouvre les boîtes ; null = tous les agents. */
125
+ agent_name: z.string().trim().min(1).max(200).nullable(),
126
+ lifetime_days: lifetimeSchema,
127
+ });
128
+ const keyRenameSchema = z.object({ name: z.string().trim().min(1).max(200) });
129
+ const keyExtendSchema = z.object({ days: lifetimeSchema });
94
130
  const adminThreadsSchema = z.object({
95
131
  search: z.string().trim().min(1).optional(),
96
132
  // Une ou plusieurs adresses, séparées par des virgules : un agent tient une boîte par
@@ -133,6 +169,10 @@ const purgeSchema = z
133
169
  .refine((input) => [input.before, input.created_before, input.empty].filter((value) => value !== undefined).length === 1, {
134
170
  message: "provide exactly one of `before` (inactive since that date), `created_before` (created before that date) or `empty: true`",
135
171
  });
172
+ /** Nom donné à une boîte depuis la console : une chaîne vide l'efface. */
173
+ const mailboxNameSchema = z.object({
174
+ display_name: z.string().trim().max(200).nullable(),
175
+ });
136
176
  const linkSchema = z.object({
137
177
  mailboxes: z.tuple([agentIdSchema, agentIdSchema]),
138
178
  note: z.string().trim().max(500).nullish(),
@@ -144,7 +184,8 @@ const adminListSchema = z.object({
144
184
  limit: z.coerce.number().int().min(1).max(100).optional(),
145
185
  offset: z.coerce.number().int().min(0).optional(),
146
186
  });
147
- export function createApp({ store, config }) {
187
+ export function createApp({ store, config, setup = new SetupGate(store.access) }) {
188
+ const access = store.access;
148
189
  const app = express();
149
190
  // Présence des veilleurs : état de connexion, donc en mémoire du processus.
150
191
  const watchers = new WatcherRegistry();
@@ -179,9 +220,8 @@ export function createApp({ store, config }) {
179
220
  }));
180
221
  // --- Console web ----------------------------------------------------------
181
222
  // La page et ses fichiers ne contiennent aucun secret : ils sont servis à tous. Ce sont
182
- // les données (`/admin`) qui sont protégées, par une session ouverte contre le jeton
183
- // admin. Voir `console-session.ts` pour le raisonnement.
184
- const sessions = new ConsoleSessions();
223
+ // les données (`/admin`) qui sont protégées, par une session ouverte avec le compte de
224
+ // la console. Voir `console-session.ts` pour le raisonnement.
185
225
  const cookieOptions = (req) => ({
186
226
  httpOnly: true,
187
227
  sameSite: "strict",
@@ -191,19 +231,62 @@ export function createApp({ store, config }) {
191
231
  secure: req.secure,
192
232
  path: "/",
193
233
  });
194
- // Limite propre au formulaire : le jeton fait 256 bits, mais autant fermer la porte.
195
- const loginLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 10, standardHeaders: "draft-8", legacyHeaders: false });
196
- app.post("/ui/login", loginLimiter, (req, res) => {
234
+ /**
235
+ * Ouvre une session et pose son cookie. Sans « rester connecté », le cookie meurt avec
236
+ * le navigateur et la session au bout de 12 h ; avec, les deux tiennent 30 jours.
237
+ */
238
+ const openSession = (req, res, user, persistent) => {
239
+ const secret = generateSessionSecret();
240
+ const ttlMs = persistent ? PERSISTENT_SESSION_TTL_MS : SESSION_TTL_MS;
241
+ access.createSession({
242
+ userId: user.id,
243
+ secret,
244
+ ttlMs,
245
+ persistent,
246
+ userAgent: req.header("user-agent") ?? null,
247
+ ip: req.ip ?? null,
248
+ });
249
+ res.cookie(CONSOLE_COOKIE, secret, persistent ? { ...cookieOptions(req), maxAge: ttlMs } : cookieOptions(req));
250
+ };
251
+ // Limite propre aux formulaires : un mot de passe se devine, autant ralentir qui essaie.
252
+ const formLimiter = credentialsLimiter();
253
+ // Ce que la console doit afficher en arrivant : la création du compte, la connexion,
254
+ // ou directement ses données.
255
+ app.get("/ui/session", (req, res) => {
256
+ const found = access.findSession(consoleSessionId(req), { ip: req.ip ?? null });
257
+ res.json({
258
+ authenticated: Boolean(found),
259
+ setup_required: setup.required,
260
+ user: found ? found.user : null,
261
+ });
262
+ });
263
+ app.post("/ui/setup", formLimiter, async (req, res) => {
264
+ const input = setupSchema.parse(req.body ?? {});
265
+ if (!setup.required)
266
+ throw new MessengerError("the console account already exists", 409);
267
+ if (!setup.accepts(input.code))
268
+ throw new MessengerError("wrong setup code", 403);
269
+ const user = access.createUser(input.username, await hashPassword(input.password));
270
+ setup.consume();
271
+ logger.info(`console account created: ${user.username}`);
272
+ openSession(req, res, user, false);
273
+ res.status(201).json({ user });
274
+ });
275
+ app.post("/ui/login", formLimiter, async (req, res) => {
197
276
  const input = loginSchema.parse(req.body ?? {});
198
- if (!safeEqual(input.token, config.adminToken)) {
199
- res.status(401).json({ error: "invalid admin token" });
277
+ const found = access.findUserByName(input.username);
278
+ // Un nom inconnu coûte le même calcul qu'un mot de passe faux : la durée de la réponse
279
+ // ne dit pas si le compte existe.
280
+ const valid = await verifyPassword(input.password, found?.password_hash ?? (await decoyHash()));
281
+ if (!found || !valid) {
282
+ res.status(401).json({ error: "invalid username or password" });
200
283
  return;
201
284
  }
202
- res.cookie(CONSOLE_COOKIE, sessions.create(), { ...cookieOptions(req), maxAge: sessions.ttlMs });
285
+ openSession(req, res, access.getUser(found.id), input.remember ?? false);
203
286
  res.status(204).end();
204
287
  });
205
288
  app.post("/ui/logout", (req, res) => {
206
- sessions.revoke(consoleSessionId(req));
289
+ access.revokeSessionBySecret(consoleSessionId(req));
207
290
  res.clearCookie(CONSOLE_COOKIE, cookieOptions(req));
208
291
  res.status(204).end();
209
292
  });
@@ -214,10 +297,10 @@ export function createApp({ store, config }) {
214
297
  res.redirect("/ui/");
215
298
  });
216
299
  // --- Supervision (lecture, liaisons et nettoyages de boîtes) -------------
217
- // Montée avant la porte d'entrée : son contrôle est au moins aussi strict (session de
218
- // console ou jeton admin), et la session par cookie n'est reconnue nulle part ailleurs.
300
+ // Montée avant la porte d'entrée des agents : `/admin` ne s'ouvre qu'avec une session de
301
+ // la console, et la session par cookie n'est reconnue nulle part ailleurs.
219
302
  const admin = express.Router();
220
- admin.use(authenticateAdmin(config, sessions));
303
+ admin.use(authenticateConsole(store));
221
304
  admin.get("/overview", (_req, res) => {
222
305
  res.json(store.overview());
223
306
  });
@@ -335,6 +418,14 @@ export function createApp({ store, config }) {
335
418
  logger.warn(`admin delete: mailbox ${id} deleted (${removed.messages} message(s) erased)`);
336
419
  res.json({ mailbox: id, ...removed });
337
420
  });
421
+ /** Renomme une boîte. Le nom remplace celui que l'agent a pu poser avec `rename_mailbox`. */
422
+ admin.patch("/mailboxes/:id", (req, res) => {
423
+ const id = normalizeAgentId(String(req.params.id));
424
+ const input = mailboxNameSchema.parse(req.body ?? {});
425
+ const agent = store.setMailboxName(id, input.display_name || null);
426
+ logger.info(`admin rename: mailbox ${id} -> ${agent.display_name ?? "(no name)"}`);
427
+ res.json({ agent });
428
+ });
338
429
  admin.get("/links", (_req, res) => {
339
430
  res.json({ links: store.listLinks() });
340
431
  });
@@ -350,10 +441,93 @@ export function createApp({ store, config }) {
350
441
  logger.info(`admin unlink ${linkId}`);
351
442
  res.json({ link_id: linkId });
352
443
  });
444
+ // --- Compte de la console -------------------------------------------------
445
+ admin.get("/account", (req, res) => {
446
+ const user = req.consoleUser;
447
+ res.json({
448
+ user,
449
+ sessions: access.listSessions(user.id).map((session) => ({
450
+ ...session,
451
+ current: session.id === req.consoleSession.id,
452
+ })),
453
+ });
454
+ });
455
+ admin.patch("/account", (req, res) => {
456
+ const input = accountSchema.parse(req.body ?? {});
457
+ const user = access.renameUser(req.consoleUser.id, input.username);
458
+ logger.info(`console account renamed: ${req.consoleUser.username} -> ${user.username}`);
459
+ res.json({ user });
460
+ });
461
+ /** Changer de mot de passe ferme toutes les autres sessions : c'est souvent la raison du changement. */
462
+ admin.post("/account/password", formLimiter, async (req, res) => {
463
+ const input = passwordChangeSchema.parse(req.body ?? {});
464
+ const user = req.consoleUser;
465
+ if (!(await verifyPassword(input.current_password, access.getPasswordHash(user.id) ?? ""))) {
466
+ throw new MessengerError("the current password is wrong", 403);
467
+ }
468
+ access.setPassword(user.id, await hashPassword(input.new_password));
469
+ const closed = access.revokeOtherSessions(user.id, req.consoleSession.id);
470
+ logger.info(`console password changed for ${user.username}, ${closed} other session(s) closed`);
471
+ res.json({ closed_sessions: closed });
472
+ });
473
+ admin.delete("/account/sessions/:id", (req, res) => {
474
+ const id = String(req.params.id);
475
+ access.revokeSession(req.consoleUser.id, id);
476
+ res.json({ session_id: id });
477
+ });
478
+ admin.post("/account/sessions/revoke-others", (req, res) => {
479
+ res.json({ closed_sessions: access.revokeOtherSessions(req.consoleUser.id, req.consoleSession.id) });
480
+ });
481
+ // --- Clés API -------------------------------------------------------------
482
+ admin.get("/keys", (_req, res) => {
483
+ res.json({ keys: access.listApiKeys() });
484
+ });
485
+ /** La clé en clair ne sort qu'ici, une fois : le serveur n'en garde que l'empreinte. */
486
+ admin.post("/keys", (req, res) => {
487
+ const input = keyCreateSchema.parse(req.body ?? {});
488
+ const secret = generateApiKey();
489
+ const key = access.createApiKey({
490
+ name: input.name,
491
+ agentName: input.agent_name,
492
+ lifetimeDays: input.lifetime_days,
493
+ createdBy: req.consoleUser.username,
494
+ secret,
495
+ });
496
+ logger.info(`API key ${key.id} created (${key.name}, ${key.agent_name ?? "all agents"})`);
497
+ res.status(201).json({ key, secret });
498
+ });
499
+ admin.get("/keys/:id", (req, res) => {
500
+ const id = String(req.params.id);
501
+ const key = access.getApiKey(id);
502
+ if (!key)
503
+ throw new MessengerError(`API key not found: ${id}`, 404);
504
+ res.json({ key, mailboxes: access.mailboxesForKey(id) });
505
+ });
506
+ admin.patch("/keys/:id", (req, res) => {
507
+ const input = keyRenameSchema.parse(req.body ?? {});
508
+ res.json({ key: access.renameApiKey(String(req.params.id), input.name) });
509
+ });
510
+ admin.post("/keys/:id/extend", (req, res) => {
511
+ const input = keyExtendSchema.parse(req.body ?? {});
512
+ const key = access.extendApiKey(String(req.params.id), input.days);
513
+ logger.info(`API key ${key.id} extended until ${key.expires_at ?? "no expiry"}`);
514
+ res.json({ key });
515
+ });
516
+ admin.post("/keys/:id/rotate", (req, res) => {
517
+ const secret = generateApiKey();
518
+ const { key, replaced } = access.rotateApiKey(String(req.params.id), secret, req.consoleUser.username, ROTATION_GRACE_MS);
519
+ logger.info(`API key ${replaced.id} replaced by ${key.id}, old one valid until ${replaced.expires_at}`);
520
+ res.status(201).json({ key, secret, replaced });
521
+ });
522
+ admin.post("/keys/:id/revoke", (req, res) => {
523
+ const key = access.revokeApiKey(String(req.params.id));
524
+ logger.warn(`API key ${key.id} revoked (${key.name})`);
525
+ res.json({ key });
526
+ });
353
527
  app.use("/admin", admin);
354
- // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
355
- // contrôle par route (identité de l'agent) vient ensuite.
356
- app.use(requireAnyToken(config));
528
+ // Porte d'entrée de tout le reste : aucune requête n'est servie sans clé API valide. Le
529
+ // contrôle par route (boîtes que la clé ouvre) vient ensuite.
530
+ app.use(requireApiKey(store));
357
531
  // --- État du service ------------------------------------------------------
358
532
  app.get("/health", (_req, res) => {
359
533
  const overview = store.overview();
@@ -363,27 +537,35 @@ export function createApp({ store, config }) {
363
537
  version: APP_VERSION,
364
538
  agents: overview.agents.length,
365
539
  messages: overview.messages,
366
- // Toujours vrai : le serveur ne démarre pas sans jeton, et cette route en exige un.
540
+ // Toujours vrai : cette route, comme toute l'API, exige une clé.
367
541
  auth_required: true,
368
542
  });
369
543
  });
370
544
  // --- API agents -----------------------------------------------------------
371
545
  const api = express.Router();
372
- api.use(authenticateAgent(config));
546
+ api.use(authenticateAgent(store));
373
547
  api.post("/agents/register", (req, res) => {
374
548
  const input = registerSchema.parse(req.body ?? {});
375
549
  // Une session qui alloue sa propre boîte ne doit jamais tomber sur celle d'une autre.
376
550
  if (input.fresh && store.getAgent(req.agentId)) {
377
551
  throw new MessengerError(`mailbox already exists: ${req.agentId}`, 409);
378
552
  }
553
+ // Une clé liée à un agent donne son nom à la boîte. Un autre nom venu de la
554
+ // configuration du client est une erreur de réglage, signalée plutôt que corrigée.
555
+ const key = req.apiKey;
556
+ if (key.agent_name && input.agent_name && input.agent_name !== key.agent_name) {
557
+ throw new MessengerError(`this API key is bound to agent "${key.agent_name}": remove MESSENGER_AGENT_NAME from the client ` +
558
+ `configuration, or set it to "${key.agent_name}"`, 403);
559
+ }
379
560
  const agent = store.ensureAgent(req.agentId, {
380
561
  registered: true,
381
- agent_name: input.agent_name ?? null,
562
+ agent_name: key.agent_name ?? input.agent_name ?? null,
382
563
  agent_description: input.agent_description ?? null,
383
564
  // Un client antérieur à la séparation agent / boîte n'envoie que ces deux-là.
384
565
  display_name: input.display_name ?? null,
385
566
  description: input.description ?? null,
386
567
  });
568
+ access.recordMailboxKey(agent.id, key.id);
387
569
  res.json({ agent });
388
570
  });
389
571
  // L'agent ne se voit pas dans son propre annuaire : sa boîte est la seule interdite d'envoi.
@@ -569,25 +751,53 @@ function sendAttachment(res, attachment) {
569
751
  res.end(attachment.data);
570
752
  }
571
753
  /**
572
- * Exige un jeton connu du serveur sur toute requête entrante.
754
+ * Limite des formulaires d'identifiants : dix essais par quart d'heure et par adresse. Le
755
+ * refus dit dans combien de secondes réessayer, pour que la console puisse l'afficher.
756
+ */
757
+ function credentialsLimiter() {
758
+ return rateLimit({
759
+ windowMs: 15 * 60_000,
760
+ limit: 10,
761
+ standardHeaders: "draft-8",
762
+ legacyHeaders: false,
763
+ handler: (req, res) => {
764
+ const reset = req.rateLimit?.resetTime?.getTime();
765
+ const retryAfter = reset ? Math.max(1, Math.ceil((reset - Date.now()) / 1000)) : 15 * 60;
766
+ res.status(429).json({ error: "too many attempts", retry_after_seconds: retryAfter });
767
+ },
768
+ });
769
+ }
770
+ let decoy = null;
771
+ /** Empreinte jetable, vérifiée quand le compte demandé n'existe pas. */
772
+ function decoyHash() {
773
+ decoy ??= hashPassword(generateSessionSecret());
774
+ return decoy;
775
+ }
776
+ /**
777
+ * Exige une clé API active sur toute requête entrante.
573
778
  *
574
- * Accepte le jeton partagé, le jeton de la console, ou celui de n'importe quel agent :
575
- * c'est le filtre grossier. La correspondance jeton ↔ agent est vérifiée juste après,
576
- * route par route.
779
+ * C'est le filtre grossier : il dit que l'appelant est un agent connu. Ce que la clé
780
+ * l'autorise à ouvrir est vérifié juste après, par `authenticateAgent`.
577
781
  */
578
- function requireAnyToken(config) {
579
- const accepted = [config.token, config.adminToken, ...config.agentTokens.values()].filter((value) => Boolean(value));
782
+ function requireApiKey(store) {
580
783
  return (req, res, next) => {
581
- const presented = presentedToken(req);
582
- if (!presented || !accepted.some((candidate) => safeEqual(presented, candidate))) {
583
- res.status(401).json({ error: "invalid or missing token" });
784
+ const key = store.access.authenticateApiKey(presentedKey(req), req.ip ?? null);
785
+ if (!key) {
786
+ res.status(401).json({ error: "invalid, expired or revoked API key" });
584
787
  return;
585
788
  }
789
+ req.apiKey = key;
586
790
  next();
587
791
  };
588
792
  }
589
- /** Identité de l'agent (en-tête `X-Agent-Id`) + jeton, selon la configuration du serveur. */
590
- function authenticateAgent(config) {
793
+ /**
794
+ * Boîte visée (en-tête `X-Agent-Id`), et droit de la clé sur elle.
795
+ *
796
+ * Une clé liée à un agent n'ouvre que les boîtes de cet agent, plus celles qui n'ont encore
797
+ * jamais été ouvertes. Une clé valable pour tous les agents les ouvre toutes, comme la
798
+ * console l'annonce en la créant.
799
+ */
800
+ function authenticateAgent(store) {
591
801
  return (req, res, next) => {
592
802
  const agentId = normalizeAgentId(String(req.header("x-agent-id") ?? ""));
593
803
  if (!agentId) {
@@ -598,59 +808,39 @@ function authenticateAgent(config) {
598
808
  res.status(400).json({ error: "agent id too long (max 128 chars)" });
599
809
  return;
600
810
  }
601
- const presented = presentedToken(req);
602
- if (config.agentTokens.size > 0) {
603
- const expected = config.agentTokens.get(agentId);
604
- if (!expected || !presented || !safeEqual(presented, expected)) {
605
- res.status(401).json({ error: "invalid credentials for this agent" });
811
+ const key = req.apiKey;
812
+ if (key.agent_name) {
813
+ const mailbox = store.getAgent(agentId);
814
+ if (mailbox?.registered && mailbox.agent_name !== key.agent_name) {
815
+ res.status(403).json({ error: `this API key only opens mailboxes of agent "${key.agent_name}"` });
606
816
  return;
607
817
  }
608
818
  }
609
- else if (config.token) {
610
- if (!presented || !safeEqual(presented, config.token)) {
611
- res.status(401).json({ error: "invalid or missing bearer token" });
612
- return;
613
- }
614
- // Le jeton partagé atteste l'appartenance à la flotte, pas une adresse : `X-Agent-Id`
615
- // n'est adossé à rien ici, et rien ne cloisonne les boîtes entre elles. C'est le mode
616
- // « cercle de confiance », annoncé comme tel au démarrage et dans le README ;
617
- // MESSENGER_AGENT_TOKENS est ce qui lie une adresse à un jeton.
618
- }
619
819
  req.agentId = agentId;
620
820
  next();
621
821
  };
622
822
  }
623
- /** Session de console (navigateur) ou jeton admin (script, `curl`). */
624
- function authenticateAdmin(config, sessions) {
823
+ /** Session de la console, ouverte avec le compte. Rien d'autre n'ouvre `/admin`. */
824
+ function authenticateConsole(store) {
625
825
  return (req, res, next) => {
626
- if (sessions.has(consoleSessionId(req))) {
627
- next();
628
- return;
629
- }
630
- const presented = presentedToken(req);
631
- if (!presented || !safeEqual(presented, config.adminToken)) {
632
- res.status(401).json({ error: "invalid or missing admin token" });
826
+ const found = store.access.findSession(consoleSessionId(req), { ip: req.ip ?? null });
827
+ if (!found) {
828
+ res.status(401).json({ error: "console session required" });
633
829
  return;
634
830
  }
831
+ req.consoleUser = found.user;
832
+ req.consoleSession = found.session;
635
833
  next();
636
834
  };
637
835
  }
638
836
  /**
639
- * Jeton présenté par l'appelant : en-tête `Authorization`, sinon `X-Messenger-Token`.
640
- * Jamais l'URL : un jeton en query string finit dans les journaux d'accès du proxy.
837
+ * Clé présentée par l'appelant : en-tête `Authorization`, sinon `X-Messenger-Token`.
838
+ * Jamais l'URL : une clé en query string finit dans les journaux d'accès du proxy.
641
839
  */
642
- function presentedToken(req) {
840
+ function presentedKey(req) {
643
841
  const header = req.header("authorization");
644
842
  if (header?.toLowerCase().startsWith("bearer "))
645
843
  return header.slice(7).trim() || null;
646
844
  const alt = req.header("x-messenger-token");
647
845
  return alt?.trim() ? alt.trim() : null;
648
846
  }
649
- /** Comparaison à temps constant : évite de laisser fuiter le jeton octet par octet. */
650
- function safeEqual(a, b) {
651
- const bufA = Buffer.from(a);
652
- const bufB = Buffer.from(b);
653
- if (bufA.length !== bufB.length)
654
- return false;
655
- return timingSafeEqual(bufA, bufB);
656
- }
@@ -1,30 +1,40 @@
1
1
  import type { Request } from "express";
2
+ import type { AccessStore } from "../core/access-store.js";
2
3
  /**
3
4
  * Sessions de la console web.
4
5
  *
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`.
6
+ * La console s'ouvre avec un nom d'utilisateur et un mot de passe, et reçoit en retour un
7
+ * identifiant de session opaque dans un cookie `HttpOnly`. Le serveur n'en garde que
8
+ * l'empreinte, en base : une session survit à un redémarrage, et se ferme depuis la page
9
+ * « Mon compte ».
9
10
  *
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.
11
+ * Le cookie n'est honoré que sur `/admin` et `/ui` : l'API des agents reste sur la clé
12
+ * API, un site tiers ne peut donc pas faire agir un agent à travers le navigateur.
14
13
  */
15
14
  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
- }
15
+ /** Durée d'une session ordinaire : assez pour une journée de supervision, pas plus. */
16
+ export declare const SESSION_TTL_MS: number;
17
+ /** Durée d'une session ouverte avec « rester connecté ». */
18
+ export declare const PERSISTENT_SESSION_TTL_MS: number;
29
19
  /** Identifiant de session porté par la requête, s'il y en a un. */
30
20
  export declare function consoleSessionId(req: Request): string | null;
21
+ /**
22
+ * Code d'installation : il garde la création du premier compte.
23
+ *
24
+ * Tant qu'aucun compte n'existe, la console propose de le créer, et le premier visiteur
25
+ * venu le pourrait. Le code, écrit dans le journal du serveur au démarrage, prouve qu'on a
26
+ * accès à la machine. Il ne vit qu'en mémoire, change à chaque démarrage, et ne sert qu'une
27
+ * fois : dès qu'un compte existe, il n'y a plus rien à garder.
28
+ */
29
+ export declare class SetupGate {
30
+ private readonly access;
31
+ private code;
32
+ constructor(access: AccessStore);
33
+ /** Le code à recopier, tant qu'aucun compte n'existe. */
34
+ get pendingCode(): string | null;
35
+ get required(): boolean;
36
+ /** Vrai si le code présenté est le bon. Les tirets et la casse ne comptent pas. */
37
+ accepts(presented: string): boolean;
38
+ /** Le code a servi : il ne vaut plus rien. */
39
+ consume(): void;
40
+ }