@imenam/mcp-messenger 1.0.32 → 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.
- package/.env.example +12 -22
- package/README.md +825 -772
- package/dist/src/cli/reset-password.d.ts +12 -0
- package/dist/src/cli/reset-password.js +29 -0
- package/dist/src/cli/setup-mcp.d.ts +5 -1
- package/dist/src/cli/setup-mcp.js +15 -37
- package/dist/src/cli/watch-handoff.d.ts +3 -3
- package/dist/src/cli/watch-handoff.js +2 -2
- package/dist/src/cli/watch.d.ts +1 -1
- package/dist/src/cli/watch.js +3 -3
- package/dist/src/client/messenger-client.d.ts +3 -3
- package/dist/src/client/messenger-client.js +5 -6
- package/dist/src/config.d.ts +13 -8
- package/dist/src/config.js +32 -43
- package/dist/src/core/access-store.d.ts +124 -0
- package/dist/src/core/access-store.js +307 -0
- package/dist/src/core/credentials.d.ts +28 -0
- package/dist/src/core/credentials.js +75 -0
- package/dist/src/core/errors.d.ts +5 -0
- package/dist/src/core/errors.js +9 -0
- package/dist/src/core/store.d.ts +5 -5
- package/dist/src/core/store.js +8 -9
- package/dist/src/index.js +32 -17
- package/dist/src/mcp/start.js +26 -26
- package/dist/src/mcp/tools.js +2 -2
- package/dist/src/server/app.d.ts +10 -1
- package/dist/src/server/app.js +247 -69
- package/dist/src/server/console-session.d.ts +31 -21
- package/dist/src/server/console-session.js +46 -63
- package/dist/src/server/start.d.ts +3 -0
- package/dist/src/server/start.js +10 -13
- package/dist/src/server/ui/app.js +1380 -38
- package/dist/src/server/ui/index.html +192 -9
- package/dist/src/server/ui/styles.css +1144 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
["
|
|
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
|
|
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(
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/src/cli/watch.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/src/cli/watch.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
63
|
-
return this.config.
|
|
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
|
-
|
|
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 {
|
package/dist/src/config.d.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
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;
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
+
}
|