@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
@@ -0,0 +1,12 @@
1
+ export interface ResetPasswordResult {
2
+ username: string;
3
+ password: string;
4
+ closedSessions: number;
5
+ }
6
+ /**
7
+ * Donne un mot de passe neuf au compte de la console, et ferme toutes ses sessions.
8
+ *
9
+ * La commande lit la base du serveur directement : c'est l'accès à la machine qui
10
+ * l'autorise, comme le code d'installation à la création du compte.
11
+ */
12
+ export declare function resetPassword(dbPath?: string): Promise<ResetPasswordResult>;
@@ -0,0 +1,29 @@
1
+ import { ConfigError, resolveDbPath } from "../config.js";
2
+ import { generatePassword, hashPassword } from "../core/credentials.js";
3
+ import { MessageStore } from "../core/store.js";
4
+ /**
5
+ * Donne un mot de passe neuf au compte de la console, et ferme toutes ses sessions.
6
+ *
7
+ * La commande lit la base du serveur directement : c'est l'accès à la machine qui
8
+ * l'autorise, comme le code d'installation à la création du compte.
9
+ */
10
+ export async function resetPassword(dbPath = resolveDbPath()) {
11
+ const store = new MessageStore(dbPath);
12
+ try {
13
+ const users = store.access.listUsers();
14
+ if (users.length === 0) {
15
+ throw new ConfigError(`no console account in ${dbPath}: open the web console to create it.`);
16
+ }
17
+ if (users.length > 1) {
18
+ throw new ConfigError(`${users.length} console accounts in ${dbPath}: expected exactly one.`);
19
+ }
20
+ const user = users[0];
21
+ const password = generatePassword();
22
+ store.access.setPassword(user.id, await hashPassword(password));
23
+ const closedSessions = store.access.revokeOtherSessions(user.id, null);
24
+ return { username: user.username, password, closedSessions };
25
+ }
26
+ finally {
27
+ store.close();
28
+ }
29
+ }
@@ -1,6 +1,10 @@
1
1
  export interface SetupMcpOptions {
2
2
  cwd: string;
3
3
  force: boolean;
4
+ /** URL du serveur à écrire ; défaut : le serveur local. */
5
+ serverUrl?: string | null;
6
+ /** Clé API à écrire ; laissée vide sinon. */
7
+ apiKey?: string | null;
4
8
  }
5
9
  /** Écrit l'entrée `mcp-messenger` dans le `.mcp.json` du répertoire courant. */
6
- export declare function runSetupMcp({ cwd, force }: SetupMcpOptions): number;
10
+ export declare function runSetupMcp({ cwd, force, serverUrl, apiKey }: SetupMcpOptions): number;
@@ -3,36 +3,19 @@ import path from "node:path";
3
3
  const SERVER_NAME = "mcp-messenger";
4
4
  const PACKAGE_NAME = "@imenam/mcp-messenger";
5
5
  const DEFAULT_SERVER_URL = "http://127.0.0.1:3600";
6
- /** "Mon Projet_2" -> "mon-projet-2" : un identifiant d'agent lisible et sans espace. */
7
- function toAgentId(folder) {
8
- const slug = folder
9
- .toLowerCase()
10
- .replace(/[^a-z0-9]+/g, "-")
11
- .replace(/^-+|-+$/g, "");
12
- return slug || "agent";
13
- }
14
- /** "mon-projet" -> "Mon Projet" */
15
- function toDisplayName(folder) {
16
- return toAgentId(folder)
17
- .split("-")
18
- .filter(Boolean)
19
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
20
- .join(" ");
21
- }
22
- function buildServerEntry(cwd) {
23
- // Le dossier courant donne une adresse d'agent par défaut : deux projets différents
24
- // ne se retrouvent pas avec la même boîte aux lettres.
25
- const folder = path.basename(cwd) || "agent";
6
+ function buildServerEntry(options) {
26
7
  return {
27
8
  command: "npx",
28
9
  args: ["-y", PACKAGE_NAME],
29
10
  env: {
30
- MESSENGER_SERVER_URL: DEFAULT_SERVER_URL,
31
- MESSENGER_TOKEN: "",
11
+ MESSENGER_SERVER_URL: options.serverUrl ?? DEFAULT_SERVER_URL,
12
+ MESSENGER_API_KEY: options.apiKey ?? "",
32
13
  // Laissé vide : chaque session reçoit sa propre boîte. Le renseigner force au
33
14
  // contraire toutes les sessions de ce projet à partager une même adresse.
34
15
  MESSENGER_AGENT_ID: "",
35
- MESSENGER_AGENT_NAME: toDisplayName(folder),
16
+ // Laissé vide : une clé liée à un agent donne son nom. Seule une clé valable pour
17
+ // tous les agents en demande un ici.
18
+ MESSENGER_AGENT_NAME: "",
36
19
  MESSENGER_AGENT_DESCRIPTION: "",
37
20
  MESSENGER_TIMEOUT_MS: "15000",
38
21
  },
@@ -40,11 +23,11 @@ function buildServerEntry(cwd) {
40
23
  }
41
24
  const REQUIRED_VARS = [
42
25
  ["MESSENGER_SERVER_URL", `URL du serveur boîte aux lettres (défaut : ${DEFAULT_SERVER_URL})`],
43
- ["MESSENGER_TOKEN", "jeton du serveur — À REMPLIR, le MCP ne peut rien faire sans lui"],
26
+ ["MESSENGER_API_KEY", "clé API créée dans la console (onglet Clés API) — le MCP ne peut rien faire sans elle"],
44
27
  ];
45
28
  const OPTIONAL_VARS = [
46
29
  ["MESSENGER_AGENT_ID", "adresse fixe : à laisser vide pour une boîte par session"],
47
- ["MESSENGER_AGENT_NAME", "nom de l'agent (le projet, typiquement) ; distinct du nom de la boîte, que l'agent pose lui-même"],
30
+ ["MESSENGER_AGENT_NAME", "nom de l'agent, pour une clé valable pour tous les agents ; une clé liée à un agent donne le sien"],
48
31
  ["MESSENGER_AGENT_DESCRIPTION", "à quoi sert cet agent, pour ses correspondants"],
49
32
  ["MESSENGER_TIMEOUT_MS", "délai maximal d'un appel HTTP (défaut : 15000)"],
50
33
  ];
@@ -88,7 +71,7 @@ function updateClaudeMd(cwd) {
88
71
  return "added";
89
72
  }
90
73
  /** Écrit l'entrée `mcp-messenger` dans le `.mcp.json` du répertoire courant. */
91
- export function runSetupMcp({ cwd, force }) {
74
+ export function runSetupMcp({ cwd, force, serverUrl = null, apiKey = null }) {
92
75
  const target = path.join(cwd, ".mcp.json");
93
76
  let config = {};
94
77
  const exists = fs.existsSync(target);
@@ -119,7 +102,7 @@ export function runSetupMcp({ cwd, force }) {
119
102
  console.error("Relancez avec --force pour écraser cette entrée (les autres serveurs sont préservés).");
120
103
  return 1;
121
104
  }
122
- config.mcpServers[SERVER_NAME] = buildServerEntry(cwd);
105
+ config.mcpServers[SERVER_NAME] = buildServerEntry({ serverUrl, apiKey });
123
106
  fs.writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
124
107
  const action = alreadyPresent ? "remplacée" : exists ? "ajoutée" : "créée";
125
108
  console.log(`Configuration ${SERVER_NAME} ${action} dans ${target}`);
@@ -142,16 +125,11 @@ export function runSetupMcp({ cwd, force }) {
142
125
  console.log(` ${name.padEnd(28)} ${description}`);
143
126
  }
144
127
  console.log("");
145
- console.log("Le serveur boîte aux lettres doit tourner à part, avec le même jeton client");
146
- console.log("et un second jeton, réservé à la console — les deux doivent différer :");
147
- console.log(` npx -y ${PACKAGE_NAME} --generate-token (une fois par jeton)`);
148
- console.log(` MESSENGER_TOKEN=<jeton-client> MESSENGER_ADMIN_TOKEN=<jeton-console> \\`);
149
- console.log(` npx -y ${PACKAGE_NAME} --server`);
150
- console.log(` console web : ${DEFAULT_SERVER_URL}/ui/ (le jeton console y est demandé une fois)`);
151
- console.log("");
152
- console.log("Le jeton client va dans le .mcp.json ci-dessus ; le jeton console n'y va jamais.");
153
- console.log("Il ouvre toutes les boîtes de tous les projets et permet de les supprimer.");
154
- console.log("");
128
+ if (!apiKey) {
129
+ console.log("La clé API se crée dans la console web du serveur (onglet Clés API). La console");
130
+ console.log("donne aussi cette commande avec --server-url et --api-key déjà remplis.");
131
+ console.log("");
132
+ }
155
133
  console.log("Chaque session d'agent reçoit sa propre boîte : demandez son adresse à l'agent");
156
134
  console.log("(outil whoami) pour la transmettre à un autre agent, ou pour la reprendre plus tard.");
157
135
  console.log("");
@@ -3,17 +3,17 @@
3
3
  *
4
4
  * Le watcher est lancé par l'agent via l'outil `Monitor` de l'hôte : c'est un processus
5
5
  * du shell de la session, pas un enfant du MCP. Il n'hérite donc **pas** du bloc `env`
6
- * du `.mcp.json`, et n'a ni URL de serveur ni jeton. Les passer sur la ligne de commande
6
+ * du `.mcp.json`, et n'a ni URL de serveur ni clé API. Les passer sur la ligne de commande
7
7
  * les afficherait dans la conversation ; ils transitent donc par un fichier en 0600 que
8
8
  * seule l'adresse de boîte permet de retrouver.
9
9
  *
10
10
  * Le fichier est écrit par `enable_notifications`, jamais au démarrage : une session qui
11
- * n'active pas les notifications ne dépose aucun jeton sur le disque.
11
+ * n'active pas les notifications ne dépose aucune clé sur le disque.
12
12
  */
13
13
  export interface Handoff {
14
14
  mailbox: string;
15
15
  serverUrl: string;
16
- token: string | null;
16
+ apiKey: string;
17
17
  /** Horodatage d'écriture, utilisé pour purger les relais abandonnés. */
18
18
  written_at: string;
19
19
  }
@@ -50,8 +50,8 @@ export function readHandoff(mailbox) {
50
50
  try {
51
51
  const parsed = JSON.parse(raw);
52
52
  // Le hachage identifie le fichier, le contenu confirme : un relais qui ne parle pas
53
- // de cette boîte est ignoré plutôt qu'utilisé de travers.
54
- return parsed.mailbox === mailbox ? parsed : null;
53
+ // de cette boîte, ou sans clé, est ignoré plutôt qu'utilisé de travers.
54
+ return parsed.mailbox === mailbox && typeof parsed.apiKey === "string" ? parsed : null;
55
55
  }
56
56
  catch {
57
57
  return null;
@@ -3,7 +3,7 @@ import type { LinkedMailbox, Message } from "../core/types.js";
3
3
  export interface WatchConfig {
4
4
  config: AgentConfig;
5
5
  mailbox: string;
6
- /** D'où viennent l'URL et le jeton : utile au diagnostic quand rien n'arrive. */
6
+ /** D'où viennent l'URL et la clé : utile au diagnostic quand rien n'arrive. */
7
7
  source: "handoff" | "env";
8
8
  }
9
9
  /**
@@ -1,4 +1,4 @@
1
- import { ConfigError } from "../config.js";
1
+ import { ConfigError, requireApiKey } from "../config.js";
2
2
  import { MessengerClient } from "../client/messenger-client.js";
3
3
  import { logger } from "../logger.js";
4
4
  import { readHandoff } from "./watch-handoff.js";
@@ -35,7 +35,7 @@ export function resolveWatchConfig(mailbox, env = process.env) {
35
35
  const serverUrl = handoff?.serverUrl ?? env.MESSENGER_SERVER_URL?.trim();
36
36
  if (!serverUrl) {
37
37
  throw new ConfigError(`no connection details for mailbox \`${target}\`: the MCP session did not enable notifications ` +
38
- "(call the enable_notifications tool first), or set MESSENGER_SERVER_URL and MESSENGER_TOKEN.");
38
+ "(call the enable_notifications tool first), or set MESSENGER_SERVER_URL and MESSENGER_API_KEY.");
39
39
  }
40
40
  return {
41
41
  mailbox: target,
@@ -43,7 +43,7 @@ export function resolveWatchConfig(mailbox, env = process.env) {
43
43
  config: {
44
44
  agentId: target,
45
45
  serverUrl: serverUrl.replace(/\/+$/, ""),
46
- token: handoff?.token ?? env.MESSENGER_TOKEN?.trim() ?? null,
46
+ apiKey: handoff?.apiKey ?? requireApiKey(env),
47
47
  agentName: null,
48
48
  agentDescription: null,
49
49
  timeoutMs: Number(env.MESSENGER_TIMEOUT_MS) || 15_000,
@@ -26,7 +26,7 @@ export interface WaitResult {
26
26
  * Client HTTP du serveur boîte aux lettres.
27
27
  *
28
28
  * L'identité de l'agent voyage dans `X-Agent-Id` ; le serveur ne l'accepte que si le
29
- * jeton présenté correspond à sa configuration. C'est l'unique point de sortie réseau
29
+ * clé API présentée lui ouvre cette boîte. C'est l'unique point de sortie réseau
30
30
  * du mode MCP : les outils n'appellent jamais `fetch` directement.
31
31
  */
32
32
  export declare class MessengerClient {
@@ -49,12 +49,12 @@ export declare class MessengerClient {
49
49
  get isAttachedElsewhere(): boolean;
50
50
  get serverUrl(): string;
51
51
  /**
52
- * Jeton de cette session, pour le seul relais vers le veilleur (`--watch`).
52
+ * Clé API de cette session, pour le seul relais vers le veilleur (`--watch`).
53
53
  *
54
54
  * Il ne doit jamais finir dans une réponse d'outil : ce que lit l'agent est lu par
55
55
  * l'utilisateur, et repart dans l'historique de la conversation.
56
56
  */
57
- get tokenForHandoff(): string | null;
57
+ get apiKeyForHandoff(): string;
58
58
  health(): Promise<{
59
59
  status: string;
60
60
  version: string;
@@ -12,7 +12,7 @@ export class MessengerClientError extends Error {
12
12
  * Client HTTP du serveur boîte aux lettres.
13
13
  *
14
14
  * L'identité de l'agent voyage dans `X-Agent-Id` ; le serveur ne l'accepte que si le
15
- * jeton présenté correspond à sa configuration. C'est l'unique point de sortie réseau
15
+ * clé API présentée lui ouvre cette boîte. C'est l'unique point de sortie réseau
16
16
  * du mode MCP : les outils n'appellent jamais `fetch` directement.
17
17
  */
18
18
  export class MessengerClient {
@@ -54,13 +54,13 @@ export class MessengerClient {
54
54
  return this.config.serverUrl;
55
55
  }
56
56
  /**
57
- * Jeton de cette session, pour le seul relais vers le veilleur (`--watch`).
57
+ * Clé API de cette session, pour le seul relais vers le veilleur (`--watch`).
58
58
  *
59
59
  * Il ne doit jamais finir dans une réponse d'outil : ce que lit l'agent est lu par
60
60
  * l'utilisateur, et repart dans l'historique de la conversation.
61
61
  */
62
- get tokenForHandoff() {
63
- return this.config.token;
62
+ get apiKeyForHandoff() {
63
+ return this.config.apiKey;
64
64
  }
65
65
  async health() {
66
66
  return this.request("GET", "/health");
@@ -190,8 +190,7 @@ export class MessengerClient {
190
190
  url.searchParams.set(key, String(value));
191
191
  }
192
192
  const headers = { "x-agent-id": this.agentId, accept: "application/json" };
193
- if (this.config.token)
194
- headers.authorization = `Bearer ${this.config.token}`;
193
+ headers.authorization = `Bearer ${this.config.apiKey}`;
195
194
  if (options.body !== undefined)
196
195
  headers["content-type"] = "application/json";
197
196
  try {
@@ -8,12 +8,6 @@ export interface ServerConfig {
8
8
  /** Toujours `127.0.0.1` — non configurable. */
9
9
  host: string;
10
10
  dbPath: string;
11
- /** Jeton partagé accepté pour tous les agents (ignoré si `agentTokens` est renseigné). */
12
- token: string | null;
13
- /** Jeton dédié par agent : `agent_id -> token`. Prioritaire sur `token`. */
14
- agentTokens: Map<string, string>;
15
- /** Jeton de l'interface de supervision. Toujours distinct de `token`, et exigé. */
16
- adminToken: string;
17
11
  /**
18
12
  * Valeur passée à `trust proxy` d'Express. Le serveur n'écoute qu'en loopback et se
19
13
  * déploie donc derrière un reverse proxy : sans ce réglage, `req.ip` vaut `127.0.0.1`
@@ -35,8 +29,12 @@ export interface AgentConfig {
35
29
  /** null = une boîte propre à cette session est allouée au démarrage. */
36
30
  agentId: string | null;
37
31
  serverUrl: string;
38
- token: string | null;
39
- /** Identité de l'agent, stable : d'où il écrit (le projet). Distincte du nom de la boîte. */
32
+ /** Clé API créée dans la console (`MESSENGER_API_KEY`). */
33
+ apiKey: string;
34
+ /**
35
+ * Nom d'agent posé par la configuration. Inutile avec une clé liée à un agent, qui donne
36
+ * le sien ; seule une clé valable pour tous les agents en a besoin.
37
+ */
40
38
  agentName: string | null;
41
39
  agentDescription: string | null;
42
40
  timeoutMs: number;
@@ -63,4 +61,11 @@ export declare function readSessionKey(env?: NodeJS.ProcessEnv): {
63
61
  } | null;
64
62
  export declare function isLoopbackHost(host: string): boolean;
65
63
  export declare function loadServerConfig(env?: NodeJS.ProcessEnv): ServerConfig;
64
+ /** Fichier SQLite du serveur : `MESSENGER_DB_PATH`, sinon `<cwd>/data/messenger.db`. */
65
+ export declare function resolveDbPath(env?: NodeJS.ProcessEnv): string;
66
66
  export declare function loadAgentConfig(env?: NodeJS.ProcessEnv): AgentConfig;
67
+ /**
68
+ * Clé API du client. `MESSENGER_TOKEN` est refusé plutôt que repris : le serveur ne
69
+ * reconnaît aucun jeton, et le relire ne ferait que reporter l'échec.
70
+ */
71
+ export declare function requireApiKey(env?: NodeJS.ProcessEnv): string;
@@ -9,6 +9,11 @@ export class ConfigError extends Error {
9
9
  }
10
10
  /** Le serveur n'écoute jamais ailleurs : la boîte aux lettres reste locale à la machine. */
11
11
  export const SERVER_HOST = "127.0.0.1";
12
+ /**
13
+ * Variables refusées au démarrage : l'authentification passe par le compte de la console
14
+ * et les clés API, et les ignorer laisserait croire qu'elles protègent quelque chose.
15
+ */
16
+ const RETIRED_SERVER_VARS = ["MESSENGER_TOKEN", "MESSENGER_AGENT_TOKENS", "MESSENGER_ADMIN_TOKEN"];
12
17
  /**
13
18
  * Variables où chercher l'identifiant de la session hôte, dans l'ordre.
14
19
  *
@@ -36,6 +41,12 @@ export function isLoopbackHost(host) {
36
41
  return LOOPBACK_HOSTS.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ""));
37
42
  }
38
43
  export function loadServerConfig(env = process.env) {
44
+ const retired = RETIRED_SERVER_VARS.filter((name) => nonEmpty(env[name]));
45
+ if (retired.length > 0) {
46
+ throw new ConfigError(`${retired.join(", ")} ${retired.length > 1 ? "are" : "is"} no longer supported and must be removed. ` +
47
+ "The web console now opens with a username and password (created at the first visit of /ui/), " +
48
+ "and agents authenticate with API keys created in the console (MESSENGER_API_KEY on the client side).");
49
+ }
39
50
  const port = parseIntEnv(env.MESSENGER_PORT, 3600, "MESSENGER_PORT");
40
51
  // L'interface d'écoute n'est pas un réglage : la boîte aux lettres reste sur la machine.
41
52
  // Une valeur non-loopback est refusée plutôt qu'ignorée en silence.
@@ -44,37 +55,10 @@ export function loadServerConfig(env = process.env) {
44
55
  throw new ConfigError(`MESSENGER_HOST=${requestedHost} is refused: the mailbox always listens on ${SERVER_HOST}. ` +
45
56
  "To reach it from another machine, put a reverse proxy in front of it.");
46
57
  }
47
- const dbPath = env.MESSENGER_DB_PATH?.trim()
48
- ? path.resolve(env.MESSENGER_DB_PATH.trim())
49
- : path.resolve(process.cwd(), "data", "messenger.db");
50
- const token = nonEmpty(env.MESSENGER_TOKEN);
51
- const agentTokens = parseAgentTokens(env.MESSENGER_AGENT_TOKENS);
52
- // Aucun démarrage sans jeton : un serveur qui tourne est un serveur authentifié.
53
- if (!token && agentTokens.size === 0) {
54
- throw new ConfigError("a token is required: set MESSENGER_TOKEN (shared) or MESSENGER_AGENT_TOKENS (one token per agent).");
55
- }
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);
61
- if (!adminToken) {
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.");
70
- }
71
58
  return {
72
59
  port,
73
60
  host: SERVER_HOST,
74
- dbPath,
75
- token,
76
- agentTokens,
77
- adminToken,
61
+ dbPath: resolveDbPath(env),
78
62
  trustProxy: parseTrustProxy(env.MESSENGER_TRUST_PROXY),
79
63
  rateLimit: {
80
64
  max: parseIntEnv(env.MESSENGER_RATE_LIMIT_MAX, 600, "MESSENGER_RATE_LIMIT_MAX"),
@@ -84,6 +68,12 @@ export function loadServerConfig(env = process.env) {
84
68
  maxWaitSeconds: parseIntEnv(env.MESSENGER_MAX_WAIT_SECONDS, 300, "MESSENGER_MAX_WAIT_SECONDS"),
85
69
  };
86
70
  }
71
+ /** Fichier SQLite du serveur : `MESSENGER_DB_PATH`, sinon `<cwd>/data/messenger.db`. */
72
+ export function resolveDbPath(env = process.env) {
73
+ return env.MESSENGER_DB_PATH?.trim()
74
+ ? path.resolve(env.MESSENGER_DB_PATH.trim())
75
+ : path.resolve(process.cwd(), "data", "messenger.db");
76
+ }
87
77
  export function loadAgentConfig(env = process.env) {
88
78
  // Adresse fixe optionnelle. Sans elle, chaque session reçoit sa propre boîte : deux
89
79
  // sessions ouvertes sur le même projet ne partagent donc pas leur courrier.
@@ -109,7 +99,7 @@ export function loadAgentConfig(env = process.env) {
109
99
  return {
110
100
  agentId,
111
101
  serverUrl: rawUrl.replace(/\/+$/, ""),
112
- token: nonEmpty(env.MESSENGER_TOKEN),
102
+ apiKey: requireApiKey(env),
113
103
  agentName: nonEmpty(env.MESSENGER_AGENT_NAME),
114
104
  agentDescription: nonEmpty(env.MESSENGER_AGENT_DESCRIPTION),
115
105
  timeoutMs: parseIntEnv(env.MESSENGER_TIMEOUT_MS, 15_000, "MESSENGER_TIMEOUT_MS"),
@@ -117,21 +107,20 @@ export function loadAgentConfig(env = process.env) {
117
107
  sessionKeySource: session?.source ?? null,
118
108
  };
119
109
  }
120
- function parseAgentTokens(raw) {
121
- const map = new Map();
122
- if (!raw?.trim())
123
- return map;
124
- for (const pair of raw.split(",")) {
125
- const trimmed = pair.trim();
126
- if (!trimmed)
127
- continue;
128
- const separator = trimmed.indexOf(":");
129
- if (separator <= 0 || separator === trimmed.length - 1) {
130
- throw new ConfigError(`MESSENGER_AGENT_TOKENS entry must look like "agent_id:token" (got "${trimmed}").`);
131
- }
132
- map.set(trimmed.slice(0, separator).trim(), trimmed.slice(separator + 1).trim());
110
+ /**
111
+ * Clé API du client. `MESSENGER_TOKEN` est refusé plutôt que repris : le serveur ne
112
+ * reconnaît aucun jeton, et le relire ne ferait que reporter l'échec.
113
+ */
114
+ export function requireApiKey(env = process.env) {
115
+ if (nonEmpty(env.MESSENGER_TOKEN)) {
116
+ throw new ConfigError("MESSENGER_TOKEN is no longer supported: create an API key in the web console (Clés API tab) " +
117
+ "and set it as MESSENGER_API_KEY instead.");
118
+ }
119
+ const apiKey = nonEmpty(env.MESSENGER_API_KEY);
120
+ if (!apiKey) {
121
+ throw new ConfigError("MESSENGER_API_KEY is required: create an API key in the web console (Clés API tab).");
133
122
  }
134
- return map;
123
+ return apiKey;
135
124
  }
136
125
  /**
137
126
  * Nombre de proxys de confiance devant le serveur, ou une expression qu'Express comprend
@@ -0,0 +1,124 @@
1
+ import type Database from "better-sqlite3";
2
+ /**
3
+ * Comptes de la console, leurs sessions, et les clés API des agents.
4
+ *
5
+ * Les secrets n'y entrent jamais en clair : un mot de passe y est sous forme d'empreinte
6
+ * scrypt, une clé ou une session sous forme d'empreinte SHA-256. Une clé ne se relit donc
7
+ * pas ; seuls son début et sa fin sont gardés, pour la reconnaître dans une liste.
8
+ */
9
+ /** Une clé est refusée dès qu'elle est révoquée ou que sa date d'expiration est passée. */
10
+ export type ApiKeyStatus = "active" | "expired" | "revoked";
11
+ export interface ApiKey {
12
+ id: string;
13
+ name: string;
14
+ /** Début de la clé, préfixe compris : `mk_7f3a`. */
15
+ prefix: string;
16
+ /** Quatre derniers caractères. */
17
+ suffix: string;
18
+ /** Agent dont la clé ouvre les boîtes ; null = tous les agents. */
19
+ agent_name: string | null;
20
+ created_at: string;
21
+ created_by: string | null;
22
+ expires_at: string | null;
23
+ /** Durée de validité choisie à la création, reprise par le remplacement ; null = sans fin. */
24
+ lifetime_days: number | null;
25
+ revoked_at: string | null;
26
+ last_used_at: string | null;
27
+ last_used_ip: string | null;
28
+ status: ApiKeyStatus;
29
+ /** Boîtes ouvertes avec cette clé et toujours présentes. */
30
+ mailboxes: number;
31
+ }
32
+ export interface KeyMailbox {
33
+ id: string;
34
+ agent_name: string | null;
35
+ display_name: string | null;
36
+ last_activity_at: string | null;
37
+ }
38
+ export interface User {
39
+ id: string;
40
+ username: string;
41
+ created_at: string;
42
+ updated_at: string | null;
43
+ }
44
+ export interface UserWithPassword extends User {
45
+ password_hash: string;
46
+ }
47
+ export interface ConsoleSession {
48
+ id: string;
49
+ user_id: string;
50
+ created_at: string;
51
+ last_seen_at: string;
52
+ expires_at: string;
53
+ /** Vrai quand la session a été ouverte avec « rester connecté ». */
54
+ persistent: boolean;
55
+ user_agent: string | null;
56
+ ip: string | null;
57
+ }
58
+ export declare class AccessStore {
59
+ private readonly db;
60
+ private readonly now;
61
+ constructor(db: Database.Database, now?: () => number);
62
+ private iso;
63
+ hasUsers(): boolean;
64
+ createUser(username: string, passwordHash: string): User;
65
+ getUser(id: string): User | null;
66
+ findUserByName(username: string): UserWithPassword | null;
67
+ listUsers(): User[];
68
+ getPasswordHash(id: string): string | null;
69
+ renameUser(id: string, username: string): User;
70
+ setPassword(id: string, passwordHash: string): void;
71
+ createSession(input: {
72
+ userId: string;
73
+ secret: string;
74
+ ttlMs: number;
75
+ persistent: boolean;
76
+ userAgent: string | null;
77
+ ip: string | null;
78
+ }): ConsoleSession;
79
+ /** Session encore valide désignée par le contenu du cookie, avec son compte. */
80
+ findSession(secret: string | null, seen?: {
81
+ ip: string | null;
82
+ }): {
83
+ session: ConsoleSession;
84
+ user: User;
85
+ } | null;
86
+ getSession(id: string): ConsoleSession | null;
87
+ listSessions(userId: string): ConsoleSession[];
88
+ revokeSession(userId: string, id: string): void;
89
+ revokeSessionBySecret(secret: string | null): void;
90
+ /** Ferme les sessions du compte sauf `keepId` (null : toutes). Rend le nombre fermé. */
91
+ revokeOtherSessions(userId: string, keepId: string | null): number;
92
+ private pruneSessions;
93
+ createApiKey(input: {
94
+ name: string;
95
+ agentName: string | null;
96
+ lifetimeDays: number | null;
97
+ createdBy: string | null;
98
+ secret: string;
99
+ }): ApiKey;
100
+ listApiKeys(): ApiKey[];
101
+ getApiKey(id: string): ApiKey | null;
102
+ private requireKey;
103
+ renameApiKey(id: string, name: string): ApiKey;
104
+ /**
105
+ * Repousse l'expiration de `days` jours, comptés depuis l'échéance encore à venir ou,
106
+ * pour une clé déjà expirée, depuis maintenant. `null` retire toute échéance.
107
+ */
108
+ extendApiKey(id: string, days: number | null): ApiKey;
109
+ revokeApiKey(id: string): ApiKey;
110
+ /**
111
+ * Remplace une clé : la nouvelle reprend le nom, l'agent et la durée de validité de
112
+ * l'ancienne ; l'ancienne reste acceptée `graceMs` encore, au plus jusqu'à son échéance.
113
+ */
114
+ rotateApiKey(id: string, secret: string, createdBy: string | null, graceMs: number): {
115
+ key: ApiKey;
116
+ replaced: ApiKey;
117
+ };
118
+ /** Clé active désignée par le secret présenté, ou null. Note son dernier usage. */
119
+ authenticateApiKey(secret: string | null, ip: string | null): ApiKey | null;
120
+ /** Retient la clé qui a ouvert ou repris une boîte : la dernière fait foi. */
121
+ recordMailboxKey(mailbox: string, apiKeyId: string): void;
122
+ mailboxesForKey(id: string): KeyMailbox[];
123
+ private toApiKey;
124
+ }