@imenam/mcp-messenger 1.0.32 → 1.0.34
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 +69 -78
- package/README.md +832 -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 +132 -0
- package/dist/src/core/access-store.js +328 -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 +39 -29
- package/dist/src/mcp/tools.js +2 -2
- package/dist/src/server/app.d.ts +10 -1
- package/dist/src/server/app.js +279 -70
- 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 +1453 -38
- package/dist/src/server/ui/index.html +192 -9
- package/dist/src/server/ui/styles.css +1181 -0
- package/package.json +61 -62
package/dist/src/mcp/start.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
-
import { loadAgentConfig } from "../config.js";
|
|
4
|
+
import { ConfigError, loadAgentConfig } from "../config.js";
|
|
5
5
|
import { MessengerClient, MessengerClientError } from "../client/messenger-client.js";
|
|
6
6
|
import { removeOwnHandoffs } from "../cli/watch-handoff.js";
|
|
7
7
|
import { newId } from "../core/ids.js";
|
|
@@ -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) {
|
|
@@ -190,18 +190,28 @@ export async function runMcpMode() {
|
|
|
190
190
|
const config = loadAgentConfig();
|
|
191
191
|
const client = new MessengerClient(config);
|
|
192
192
|
// Un serveur momentanément injoignable ne doit pas empêcher la session MCP de démarrer :
|
|
193
|
-
// l'agent verra l'erreur au premier outil appelé, et pourra réessayer.
|
|
193
|
+
// l'agent verra l'erreur au premier outil appelé, et pourra réessayer. Un refus du
|
|
194
|
+
// serveur, lui, ne passera pas en réessayant : la clé ou la configuration est à corriger,
|
|
195
|
+
// et le démarrage s'arrête en le disant.
|
|
194
196
|
try {
|
|
195
197
|
const identity = await resolveIdentity(client, config);
|
|
196
198
|
logger.info(`mailbox \`${identity.mailbox}\` ready on ${config.serverUrl}${describeIdentity(identity, config)}`);
|
|
197
199
|
}
|
|
198
200
|
catch (error) {
|
|
199
|
-
|
|
201
|
+
if (error instanceof MessengerClientError && (error.status === 401 || error.status === 403)) {
|
|
202
|
+
throw new ConfigError(`the messenger server at ${config.serverUrl} refused this configuration: ${error.message}`);
|
|
203
|
+
}
|
|
204
|
+
if (error instanceof MessengerClientError && error.status !== undefined) {
|
|
205
|
+
logger.warn(`mailbox registration failed on ${config.serverUrl} (HTTP ${error.status}): ${error.message}`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
logger.warn(`could not reach ${config.serverUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
209
|
+
}
|
|
200
210
|
}
|
|
201
211
|
// Tant que ce battement continue, la boîte de cette session n'est reprise par personne.
|
|
202
212
|
// `unref` : c'est une trace, pas une raison de garder le processus en vie.
|
|
203
213
|
setInterval(heartbeat, HEARTBEAT_MS).unref();
|
|
204
|
-
// Le relais vers le veilleur porte
|
|
214
|
+
// Le relais vers le veilleur porte la clé API de la session : il ne survit pas à la
|
|
205
215
|
// session. Un arrêt brutal le laisse en place, la purge par ancienneté le rattrape.
|
|
206
216
|
// L'entrée du registre suit le même sort, à ceci près qu'elle est close et non effacée :
|
|
207
217
|
// c'est elle qui rendra sa boîte à la session suivante.
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -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 (
|
|
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,
|
|
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
|
package/dist/src/server/app.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/server/app.js
CHANGED
|
@@ -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,
|
|
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,60 @@ 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
|
|
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
|
+
/** Description de l'agent portée par une clé : une chaîne vide l'efface. */
|
|
123
|
+
const agentDescriptionSchema = z
|
|
124
|
+
.string()
|
|
125
|
+
.trim()
|
|
126
|
+
.max(2000)
|
|
127
|
+
.nullable()
|
|
128
|
+
.transform((value) => value || null);
|
|
129
|
+
const keyCreateSchema = z
|
|
130
|
+
.object({
|
|
131
|
+
name: z.string().trim().min(1).max(200),
|
|
132
|
+
/** Agent dont la clé ouvre les boîtes ; null = tous les agents. */
|
|
133
|
+
agent_name: z.string().trim().min(1).max(200).nullable(),
|
|
134
|
+
agent_description: agentDescriptionSchema.optional(),
|
|
135
|
+
lifetime_days: lifetimeSchema,
|
|
136
|
+
})
|
|
137
|
+
.refine((input) => input.agent_name !== null || !input.agent_description, {
|
|
138
|
+
message: "only a key bound to an agent carries an agent description",
|
|
139
|
+
});
|
|
140
|
+
const keyPatchSchema = z
|
|
141
|
+
.object({
|
|
142
|
+
name: z.string().trim().min(1).max(200).optional(),
|
|
143
|
+
agent_description: agentDescriptionSchema.optional(),
|
|
144
|
+
})
|
|
145
|
+
.refine((input) => input.name !== undefined || input.agent_description !== undefined, {
|
|
146
|
+
message: "provide `name` and/or `agent_description`",
|
|
147
|
+
});
|
|
148
|
+
const keyExtendSchema = z.object({ days: lifetimeSchema });
|
|
94
149
|
const adminThreadsSchema = z.object({
|
|
95
150
|
search: z.string().trim().min(1).optional(),
|
|
96
151
|
// Une ou plusieurs adresses, séparées par des virgules : un agent tient une boîte par
|
|
@@ -148,7 +203,8 @@ const adminListSchema = z.object({
|
|
|
148
203
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
149
204
|
offset: z.coerce.number().int().min(0).optional(),
|
|
150
205
|
});
|
|
151
|
-
export function createApp({ store, config }) {
|
|
206
|
+
export function createApp({ store, config, setup = new SetupGate(store.access) }) {
|
|
207
|
+
const access = store.access;
|
|
152
208
|
const app = express();
|
|
153
209
|
// Présence des veilleurs : état de connexion, donc en mémoire du processus.
|
|
154
210
|
const watchers = new WatcherRegistry();
|
|
@@ -183,9 +239,8 @@ export function createApp({ store, config }) {
|
|
|
183
239
|
}));
|
|
184
240
|
// --- Console web ----------------------------------------------------------
|
|
185
241
|
// La page et ses fichiers ne contiennent aucun secret : ils sont servis à tous. Ce sont
|
|
186
|
-
// les données (`/admin`) qui sont protégées, par une session ouverte
|
|
187
|
-
//
|
|
188
|
-
const sessions = new ConsoleSessions();
|
|
242
|
+
// les données (`/admin`) qui sont protégées, par une session ouverte avec le compte de
|
|
243
|
+
// la console. Voir `console-session.ts` pour le raisonnement.
|
|
189
244
|
const cookieOptions = (req) => ({
|
|
190
245
|
httpOnly: true,
|
|
191
246
|
sameSite: "strict",
|
|
@@ -195,19 +250,62 @@ export function createApp({ store, config }) {
|
|
|
195
250
|
secure: req.secure,
|
|
196
251
|
path: "/",
|
|
197
252
|
});
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Ouvre une session et pose son cookie. Sans « rester connecté », le cookie meurt avec
|
|
255
|
+
* le navigateur et la session au bout de 12 h ; avec, les deux tiennent 30 jours.
|
|
256
|
+
*/
|
|
257
|
+
const openSession = (req, res, user, persistent) => {
|
|
258
|
+
const secret = generateSessionSecret();
|
|
259
|
+
const ttlMs = persistent ? PERSISTENT_SESSION_TTL_MS : SESSION_TTL_MS;
|
|
260
|
+
access.createSession({
|
|
261
|
+
userId: user.id,
|
|
262
|
+
secret,
|
|
263
|
+
ttlMs,
|
|
264
|
+
persistent,
|
|
265
|
+
userAgent: req.header("user-agent") ?? null,
|
|
266
|
+
ip: req.ip ?? null,
|
|
267
|
+
});
|
|
268
|
+
res.cookie(CONSOLE_COOKIE, secret, persistent ? { ...cookieOptions(req), maxAge: ttlMs } : cookieOptions(req));
|
|
269
|
+
};
|
|
270
|
+
// Limite propre aux formulaires : un mot de passe se devine, autant ralentir qui essaie.
|
|
271
|
+
const formLimiter = credentialsLimiter();
|
|
272
|
+
// Ce que la console doit afficher en arrivant : la création du compte, la connexion,
|
|
273
|
+
// ou directement ses données.
|
|
274
|
+
app.get("/ui/session", (req, res) => {
|
|
275
|
+
const found = access.findSession(consoleSessionId(req), { ip: req.ip ?? null });
|
|
276
|
+
res.json({
|
|
277
|
+
authenticated: Boolean(found),
|
|
278
|
+
setup_required: setup.required,
|
|
279
|
+
user: found ? found.user : null,
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
app.post("/ui/setup", formLimiter, async (req, res) => {
|
|
283
|
+
const input = setupSchema.parse(req.body ?? {});
|
|
284
|
+
if (!setup.required)
|
|
285
|
+
throw new MessengerError("the console account already exists", 409);
|
|
286
|
+
if (!setup.accepts(input.code))
|
|
287
|
+
throw new MessengerError("wrong setup code", 403);
|
|
288
|
+
const user = access.createUser(input.username, await hashPassword(input.password));
|
|
289
|
+
setup.consume();
|
|
290
|
+
logger.info(`console account created: ${user.username}`);
|
|
291
|
+
openSession(req, res, user, false);
|
|
292
|
+
res.status(201).json({ user });
|
|
293
|
+
});
|
|
294
|
+
app.post("/ui/login", formLimiter, async (req, res) => {
|
|
201
295
|
const input = loginSchema.parse(req.body ?? {});
|
|
202
|
-
|
|
203
|
-
|
|
296
|
+
const found = access.findUserByName(input.username);
|
|
297
|
+
// Un nom inconnu coûte le même calcul qu'un mot de passe faux : la durée de la réponse
|
|
298
|
+
// ne dit pas si le compte existe.
|
|
299
|
+
const valid = await verifyPassword(input.password, found?.password_hash ?? (await decoyHash()));
|
|
300
|
+
if (!found || !valid) {
|
|
301
|
+
res.status(401).json({ error: "invalid username or password" });
|
|
204
302
|
return;
|
|
205
303
|
}
|
|
206
|
-
|
|
304
|
+
openSession(req, res, access.getUser(found.id), input.remember ?? false);
|
|
207
305
|
res.status(204).end();
|
|
208
306
|
});
|
|
209
307
|
app.post("/ui/logout", (req, res) => {
|
|
210
|
-
|
|
308
|
+
access.revokeSessionBySecret(consoleSessionId(req));
|
|
211
309
|
res.clearCookie(CONSOLE_COOKIE, cookieOptions(req));
|
|
212
310
|
res.status(204).end();
|
|
213
311
|
});
|
|
@@ -218,10 +316,10 @@ export function createApp({ store, config }) {
|
|
|
218
316
|
res.redirect("/ui/");
|
|
219
317
|
});
|
|
220
318
|
// --- Supervision (lecture, liaisons et nettoyages de boîtes) -------------
|
|
221
|
-
// Montée avant la porte d'entrée
|
|
222
|
-
// console
|
|
319
|
+
// Montée avant la porte d'entrée des agents : `/admin` ne s'ouvre qu'avec une session de
|
|
320
|
+
// la console, et la session par cookie n'est reconnue nulle part ailleurs.
|
|
223
321
|
const admin = express.Router();
|
|
224
|
-
admin.use(
|
|
322
|
+
admin.use(authenticateConsole(store));
|
|
225
323
|
admin.get("/overview", (_req, res) => {
|
|
226
324
|
res.json(store.overview());
|
|
227
325
|
});
|
|
@@ -362,10 +460,98 @@ export function createApp({ store, config }) {
|
|
|
362
460
|
logger.info(`admin unlink ${linkId}`);
|
|
363
461
|
res.json({ link_id: linkId });
|
|
364
462
|
});
|
|
463
|
+
// --- Compte de la console -------------------------------------------------
|
|
464
|
+
admin.get("/account", (req, res) => {
|
|
465
|
+
const user = req.consoleUser;
|
|
466
|
+
res.json({
|
|
467
|
+
user,
|
|
468
|
+
sessions: access.listSessions(user.id).map((session) => ({
|
|
469
|
+
...session,
|
|
470
|
+
current: session.id === req.consoleSession.id,
|
|
471
|
+
})),
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
admin.patch("/account", (req, res) => {
|
|
475
|
+
const input = accountSchema.parse(req.body ?? {});
|
|
476
|
+
const user = access.renameUser(req.consoleUser.id, input.username);
|
|
477
|
+
logger.info(`console account renamed: ${req.consoleUser.username} -> ${user.username}`);
|
|
478
|
+
res.json({ user });
|
|
479
|
+
});
|
|
480
|
+
/** Changer de mot de passe ferme toutes les autres sessions : c'est souvent la raison du changement. */
|
|
481
|
+
admin.post("/account/password", formLimiter, async (req, res) => {
|
|
482
|
+
const input = passwordChangeSchema.parse(req.body ?? {});
|
|
483
|
+
const user = req.consoleUser;
|
|
484
|
+
if (!(await verifyPassword(input.current_password, access.getPasswordHash(user.id) ?? ""))) {
|
|
485
|
+
throw new MessengerError("the current password is wrong", 403);
|
|
486
|
+
}
|
|
487
|
+
access.setPassword(user.id, await hashPassword(input.new_password));
|
|
488
|
+
const closed = access.revokeOtherSessions(user.id, req.consoleSession.id);
|
|
489
|
+
logger.info(`console password changed for ${user.username}, ${closed} other session(s) closed`);
|
|
490
|
+
res.json({ closed_sessions: closed });
|
|
491
|
+
});
|
|
492
|
+
admin.delete("/account/sessions/:id", (req, res) => {
|
|
493
|
+
const id = String(req.params.id);
|
|
494
|
+
access.revokeSession(req.consoleUser.id, id);
|
|
495
|
+
res.json({ session_id: id });
|
|
496
|
+
});
|
|
497
|
+
admin.post("/account/sessions/revoke-others", (req, res) => {
|
|
498
|
+
res.json({ closed_sessions: access.revokeOtherSessions(req.consoleUser.id, req.consoleSession.id) });
|
|
499
|
+
});
|
|
500
|
+
// --- Clés API -------------------------------------------------------------
|
|
501
|
+
admin.get("/keys", (_req, res) => {
|
|
502
|
+
res.json({ keys: access.listApiKeys() });
|
|
503
|
+
});
|
|
504
|
+
/** La clé en clair ne sort qu'ici, une fois : le serveur n'en garde que l'empreinte. */
|
|
505
|
+
admin.post("/keys", (req, res) => {
|
|
506
|
+
const input = keyCreateSchema.parse(req.body ?? {});
|
|
507
|
+
const secret = generateApiKey();
|
|
508
|
+
const key = access.createApiKey({
|
|
509
|
+
name: input.name,
|
|
510
|
+
agentName: input.agent_name,
|
|
511
|
+
agentDescription: input.agent_description ?? null,
|
|
512
|
+
lifetimeDays: input.lifetime_days,
|
|
513
|
+
createdBy: req.consoleUser.username,
|
|
514
|
+
secret,
|
|
515
|
+
});
|
|
516
|
+
logger.info(`API key ${key.id} created (${key.name}, ${key.agent_name ?? "all agents"})`);
|
|
517
|
+
res.status(201).json({ key, secret });
|
|
518
|
+
});
|
|
519
|
+
admin.get("/keys/:id", (req, res) => {
|
|
520
|
+
const id = String(req.params.id);
|
|
521
|
+
const key = access.getApiKey(id);
|
|
522
|
+
if (!key)
|
|
523
|
+
throw new MessengerError(`API key not found: ${id}`, 404);
|
|
524
|
+
res.json({ key, mailboxes: access.mailboxesForKey(id) });
|
|
525
|
+
});
|
|
526
|
+
admin.patch("/keys/:id", (req, res) => {
|
|
527
|
+
const input = keyPatchSchema.parse(req.body ?? {});
|
|
528
|
+
const id = String(req.params.id);
|
|
529
|
+
let key = input.name !== undefined ? access.renameApiKey(id, input.name) : null;
|
|
530
|
+
if (input.agent_description !== undefined)
|
|
531
|
+
key = access.setApiKeyAgentDescription(id, input.agent_description);
|
|
532
|
+
res.json({ key });
|
|
533
|
+
});
|
|
534
|
+
admin.post("/keys/:id/extend", (req, res) => {
|
|
535
|
+
const input = keyExtendSchema.parse(req.body ?? {});
|
|
536
|
+
const key = access.extendApiKey(String(req.params.id), input.days);
|
|
537
|
+
logger.info(`API key ${key.id} extended until ${key.expires_at ?? "no expiry"}`);
|
|
538
|
+
res.json({ key });
|
|
539
|
+
});
|
|
540
|
+
admin.post("/keys/:id/rotate", (req, res) => {
|
|
541
|
+
const secret = generateApiKey();
|
|
542
|
+
const { key, replaced } = access.rotateApiKey(String(req.params.id), secret, req.consoleUser.username, ROTATION_GRACE_MS);
|
|
543
|
+
logger.info(`API key ${replaced.id} replaced by ${key.id}, old one valid until ${replaced.expires_at}`);
|
|
544
|
+
res.status(201).json({ key, secret, replaced });
|
|
545
|
+
});
|
|
546
|
+
admin.post("/keys/:id/revoke", (req, res) => {
|
|
547
|
+
const key = access.revokeApiKey(String(req.params.id));
|
|
548
|
+
logger.warn(`API key ${key.id} revoked (${key.name})`);
|
|
549
|
+
res.json({ key });
|
|
550
|
+
});
|
|
365
551
|
app.use("/admin", admin);
|
|
366
|
-
// Porte d'entrée de tout le reste : aucune requête n'est servie sans
|
|
367
|
-
// contrôle par route (
|
|
368
|
-
app.use(
|
|
552
|
+
// Porte d'entrée de tout le reste : aucune requête n'est servie sans clé API valide. Le
|
|
553
|
+
// contrôle par route (boîtes que la clé ouvre) vient ensuite.
|
|
554
|
+
app.use(requireApiKey(store));
|
|
369
555
|
// --- État du service ------------------------------------------------------
|
|
370
556
|
app.get("/health", (_req, res) => {
|
|
371
557
|
const overview = store.overview();
|
|
@@ -375,27 +561,42 @@ export function createApp({ store, config }) {
|
|
|
375
561
|
version: APP_VERSION,
|
|
376
562
|
agents: overview.agents.length,
|
|
377
563
|
messages: overview.messages,
|
|
378
|
-
// Toujours vrai :
|
|
564
|
+
// Toujours vrai : cette route, comme toute l'API, exige une clé.
|
|
379
565
|
auth_required: true,
|
|
380
566
|
});
|
|
381
567
|
});
|
|
382
568
|
// --- API agents -----------------------------------------------------------
|
|
383
569
|
const api = express.Router();
|
|
384
|
-
api.use(authenticateAgent(
|
|
570
|
+
api.use(authenticateAgent(store));
|
|
385
571
|
api.post("/agents/register", (req, res) => {
|
|
386
572
|
const input = registerSchema.parse(req.body ?? {});
|
|
387
573
|
// Une session qui alloue sa propre boîte ne doit jamais tomber sur celle d'une autre.
|
|
388
574
|
if (input.fresh && store.getAgent(req.agentId)) {
|
|
389
575
|
throw new MessengerError(`mailbox already exists: ${req.agentId}`, 409);
|
|
390
576
|
}
|
|
577
|
+
// Une clé liée à un agent donne son nom et sa description à la boîte. Une autre valeur
|
|
578
|
+
// venue de la configuration du client est une erreur de réglage, signalée plutôt que
|
|
579
|
+
// corrigée.
|
|
580
|
+
const key = req.apiKey;
|
|
581
|
+
if (key.agent_name) {
|
|
582
|
+
if (input.agent_name && input.agent_name !== key.agent_name) {
|
|
583
|
+
throw new MessengerError(`this API key is bound to agent "${key.agent_name}": remove MESSENGER_AGENT_NAME from the client ` +
|
|
584
|
+
`configuration, or set it to "${key.agent_name}"`, 403);
|
|
585
|
+
}
|
|
586
|
+
if (input.agent_description && input.agent_description !== key.agent_description) {
|
|
587
|
+
throw new MessengerError(`this API key carries the description of agent "${key.agent_name}": remove MESSENGER_AGENT_DESCRIPTION ` +
|
|
588
|
+
"from the client configuration, and edit the description in the web console", 403);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
391
591
|
const agent = store.ensureAgent(req.agentId, {
|
|
392
592
|
registered: true,
|
|
393
|
-
agent_name: input.agent_name ?? null,
|
|
394
|
-
agent_description: input.agent_description ?? null,
|
|
593
|
+
agent_name: key.agent_name ?? input.agent_name ?? null,
|
|
594
|
+
agent_description: key.agent_name ? key.agent_description : (input.agent_description ?? null),
|
|
395
595
|
// Un client antérieur à la séparation agent / boîte n'envoie que ces deux-là.
|
|
396
596
|
display_name: input.display_name ?? null,
|
|
397
597
|
description: input.description ?? null,
|
|
398
598
|
});
|
|
599
|
+
access.recordMailboxKey(agent.id, key.id);
|
|
399
600
|
res.json({ agent });
|
|
400
601
|
});
|
|
401
602
|
// L'agent ne se voit pas dans son propre annuaire : sa boîte est la seule interdite d'envoi.
|
|
@@ -581,25 +782,53 @@ function sendAttachment(res, attachment) {
|
|
|
581
782
|
res.end(attachment.data);
|
|
582
783
|
}
|
|
583
784
|
/**
|
|
584
|
-
*
|
|
785
|
+
* Limite des formulaires d'identifiants : dix essais par quart d'heure et par adresse. Le
|
|
786
|
+
* refus dit dans combien de secondes réessayer, pour que la console puisse l'afficher.
|
|
787
|
+
*/
|
|
788
|
+
function credentialsLimiter() {
|
|
789
|
+
return rateLimit({
|
|
790
|
+
windowMs: 15 * 60_000,
|
|
791
|
+
limit: 10,
|
|
792
|
+
standardHeaders: "draft-8",
|
|
793
|
+
legacyHeaders: false,
|
|
794
|
+
handler: (req, res) => {
|
|
795
|
+
const reset = req.rateLimit?.resetTime?.getTime();
|
|
796
|
+
const retryAfter = reset ? Math.max(1, Math.ceil((reset - Date.now()) / 1000)) : 15 * 60;
|
|
797
|
+
res.status(429).json({ error: "too many attempts", retry_after_seconds: retryAfter });
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
let decoy = null;
|
|
802
|
+
/** Empreinte jetable, vérifiée quand le compte demandé n'existe pas. */
|
|
803
|
+
function decoyHash() {
|
|
804
|
+
decoy ??= hashPassword(generateSessionSecret());
|
|
805
|
+
return decoy;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Exige une clé API active sur toute requête entrante.
|
|
585
809
|
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
* route par route.
|
|
810
|
+
* C'est le filtre grossier : il dit que l'appelant est un agent connu. Ce que la clé
|
|
811
|
+
* l'autorise à ouvrir est vérifié juste après, par `authenticateAgent`.
|
|
589
812
|
*/
|
|
590
|
-
function
|
|
591
|
-
const accepted = [config.token, config.adminToken, ...config.agentTokens.values()].filter((value) => Boolean(value));
|
|
813
|
+
function requireApiKey(store) {
|
|
592
814
|
return (req, res, next) => {
|
|
593
|
-
const
|
|
594
|
-
if (!
|
|
595
|
-
res.status(401).json({ error: "invalid or
|
|
815
|
+
const key = store.access.authenticateApiKey(presentedKey(req), req.ip ?? null);
|
|
816
|
+
if (!key) {
|
|
817
|
+
res.status(401).json({ error: "invalid, expired or revoked API key" });
|
|
596
818
|
return;
|
|
597
819
|
}
|
|
820
|
+
req.apiKey = key;
|
|
598
821
|
next();
|
|
599
822
|
};
|
|
600
823
|
}
|
|
601
|
-
/**
|
|
602
|
-
|
|
824
|
+
/**
|
|
825
|
+
* Boîte visée (en-tête `X-Agent-Id`), et droit de la clé sur elle.
|
|
826
|
+
*
|
|
827
|
+
* Une clé liée à un agent n'ouvre que les boîtes de cet agent, plus celles qui n'ont encore
|
|
828
|
+
* jamais été ouvertes. Une clé valable pour tous les agents les ouvre toutes, comme la
|
|
829
|
+
* console l'annonce en la créant.
|
|
830
|
+
*/
|
|
831
|
+
function authenticateAgent(store) {
|
|
603
832
|
return (req, res, next) => {
|
|
604
833
|
const agentId = normalizeAgentId(String(req.header("x-agent-id") ?? ""));
|
|
605
834
|
if (!agentId) {
|
|
@@ -610,59 +839,39 @@ function authenticateAgent(config) {
|
|
|
610
839
|
res.status(400).json({ error: "agent id too long (max 128 chars)" });
|
|
611
840
|
return;
|
|
612
841
|
}
|
|
613
|
-
const
|
|
614
|
-
if (
|
|
615
|
-
const
|
|
616
|
-
if (
|
|
617
|
-
res.status(
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
else if (config.token) {
|
|
622
|
-
if (!presented || !safeEqual(presented, config.token)) {
|
|
623
|
-
res.status(401).json({ error: "invalid or missing bearer token" });
|
|
842
|
+
const key = req.apiKey;
|
|
843
|
+
if (key.agent_name) {
|
|
844
|
+
const mailbox = store.getAgent(agentId);
|
|
845
|
+
if (mailbox?.registered && mailbox.agent_name !== key.agent_name) {
|
|
846
|
+
res.status(403).json({ error: `this API key only opens mailboxes of agent "${key.agent_name}"` });
|
|
624
847
|
return;
|
|
625
848
|
}
|
|
626
|
-
// Le jeton partagé atteste l'appartenance à la flotte, pas une adresse : `X-Agent-Id`
|
|
627
|
-
// n'est adossé à rien ici, et rien ne cloisonne les boîtes entre elles. C'est le mode
|
|
628
|
-
// « cercle de confiance », annoncé comme tel au démarrage et dans le README ;
|
|
629
|
-
// MESSENGER_AGENT_TOKENS est ce qui lie une adresse à un jeton.
|
|
630
849
|
}
|
|
631
850
|
req.agentId = agentId;
|
|
632
851
|
next();
|
|
633
852
|
};
|
|
634
853
|
}
|
|
635
|
-
/** Session de console
|
|
636
|
-
function
|
|
854
|
+
/** Session de la console, ouverte avec le compte. Rien d'autre n'ouvre `/admin`. */
|
|
855
|
+
function authenticateConsole(store) {
|
|
637
856
|
return (req, res, next) => {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
const presented = presentedToken(req);
|
|
643
|
-
if (!presented || !safeEqual(presented, config.adminToken)) {
|
|
644
|
-
res.status(401).json({ error: "invalid or missing admin token" });
|
|
857
|
+
const found = store.access.findSession(consoleSessionId(req), { ip: req.ip ?? null });
|
|
858
|
+
if (!found) {
|
|
859
|
+
res.status(401).json({ error: "console session required" });
|
|
645
860
|
return;
|
|
646
861
|
}
|
|
862
|
+
req.consoleUser = found.user;
|
|
863
|
+
req.consoleSession = found.session;
|
|
647
864
|
next();
|
|
648
865
|
};
|
|
649
866
|
}
|
|
650
867
|
/**
|
|
651
|
-
*
|
|
652
|
-
* Jamais l'URL :
|
|
868
|
+
* Clé présentée par l'appelant : en-tête `Authorization`, sinon `X-Messenger-Token`.
|
|
869
|
+
* Jamais l'URL : une clé en query string finit dans les journaux d'accès du proxy.
|
|
653
870
|
*/
|
|
654
|
-
function
|
|
871
|
+
function presentedKey(req) {
|
|
655
872
|
const header = req.header("authorization");
|
|
656
873
|
if (header?.toLowerCase().startsWith("bearer "))
|
|
657
874
|
return header.slice(7).trim() || null;
|
|
658
875
|
const alt = req.header("x-messenger-token");
|
|
659
876
|
return alt?.trim() ? alt.trim() : null;
|
|
660
877
|
}
|
|
661
|
-
/** Comparaison à temps constant : évite de laisser fuiter le jeton octet par octet. */
|
|
662
|
-
function safeEqual(a, b) {
|
|
663
|
-
const bufA = Buffer.from(a);
|
|
664
|
-
const bufB = Buffer.from(b);
|
|
665
|
-
if (bufA.length !== bufB.length)
|
|
666
|
-
return false;
|
|
667
|
-
return timingSafeEqual(bufA, bufB);
|
|
668
|
-
}
|