@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
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,41 @@ const waitQuerySchema = z.object({
|
|
|
90
92
|
// attente longue, mais seul le premier prouve que l'agent est joignable en continu.
|
|
91
93
|
watcher: z.enum(["true", "1"]).optional(),
|
|
92
94
|
});
|
|
93
|
-
const
|
|
95
|
+
const usernameSchema = z
|
|
96
|
+
.string()
|
|
97
|
+
.trim()
|
|
98
|
+
.min(1)
|
|
99
|
+
.max(100)
|
|
100
|
+
.regex(/^\S+$/, "a username has no spaces");
|
|
101
|
+
const newPasswordSchema = z
|
|
102
|
+
.string()
|
|
103
|
+
.min(PASSWORD_MIN_LENGTH, `at least ${PASSWORD_MIN_LENGTH} characters`)
|
|
104
|
+
.max(1024);
|
|
105
|
+
const loginSchema = z.object({
|
|
106
|
+
username: z.string().trim().min(1).max(100),
|
|
107
|
+
password: z.string().min(1).max(1024),
|
|
108
|
+
remember: z.boolean().optional(),
|
|
109
|
+
});
|
|
110
|
+
const setupSchema = z.object({
|
|
111
|
+
code: z.string().trim().min(1).max(64),
|
|
112
|
+
username: usernameSchema,
|
|
113
|
+
password: newPasswordSchema,
|
|
114
|
+
});
|
|
115
|
+
const accountSchema = z.object({ username: usernameSchema });
|
|
116
|
+
const passwordChangeSchema = z.object({
|
|
117
|
+
current_password: z.string().min(1).max(1024),
|
|
118
|
+
new_password: newPasswordSchema,
|
|
119
|
+
});
|
|
120
|
+
/** Durée de validité d'une clé, en jours ; null = sans échéance. */
|
|
121
|
+
const lifetimeSchema = z.number().int().min(1).max(3650).nullable();
|
|
122
|
+
const keyCreateSchema = z.object({
|
|
123
|
+
name: z.string().trim().min(1).max(200),
|
|
124
|
+
/** Agent dont la clé ouvre les boîtes ; null = tous les agents. */
|
|
125
|
+
agent_name: z.string().trim().min(1).max(200).nullable(),
|
|
126
|
+
lifetime_days: lifetimeSchema,
|
|
127
|
+
});
|
|
128
|
+
const keyRenameSchema = z.object({ name: z.string().trim().min(1).max(200) });
|
|
129
|
+
const keyExtendSchema = z.object({ days: lifetimeSchema });
|
|
94
130
|
const adminThreadsSchema = z.object({
|
|
95
131
|
search: z.string().trim().min(1).optional(),
|
|
96
132
|
// Une ou plusieurs adresses, séparées par des virgules : un agent tient une boîte par
|
|
@@ -148,7 +184,8 @@ const adminListSchema = z.object({
|
|
|
148
184
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
149
185
|
offset: z.coerce.number().int().min(0).optional(),
|
|
150
186
|
});
|
|
151
|
-
export function createApp({ store, config }) {
|
|
187
|
+
export function createApp({ store, config, setup = new SetupGate(store.access) }) {
|
|
188
|
+
const access = store.access;
|
|
152
189
|
const app = express();
|
|
153
190
|
// Présence des veilleurs : état de connexion, donc en mémoire du processus.
|
|
154
191
|
const watchers = new WatcherRegistry();
|
|
@@ -183,9 +220,8 @@ export function createApp({ store, config }) {
|
|
|
183
220
|
}));
|
|
184
221
|
// --- Console web ----------------------------------------------------------
|
|
185
222
|
// 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();
|
|
223
|
+
// les données (`/admin`) qui sont protégées, par une session ouverte avec le compte de
|
|
224
|
+
// la console. Voir `console-session.ts` pour le raisonnement.
|
|
189
225
|
const cookieOptions = (req) => ({
|
|
190
226
|
httpOnly: true,
|
|
191
227
|
sameSite: "strict",
|
|
@@ -195,19 +231,62 @@ export function createApp({ store, config }) {
|
|
|
195
231
|
secure: req.secure,
|
|
196
232
|
path: "/",
|
|
197
233
|
});
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Ouvre une session et pose son cookie. Sans « rester connecté », le cookie meurt avec
|
|
236
|
+
* le navigateur et la session au bout de 12 h ; avec, les deux tiennent 30 jours.
|
|
237
|
+
*/
|
|
238
|
+
const openSession = (req, res, user, persistent) => {
|
|
239
|
+
const secret = generateSessionSecret();
|
|
240
|
+
const ttlMs = persistent ? PERSISTENT_SESSION_TTL_MS : SESSION_TTL_MS;
|
|
241
|
+
access.createSession({
|
|
242
|
+
userId: user.id,
|
|
243
|
+
secret,
|
|
244
|
+
ttlMs,
|
|
245
|
+
persistent,
|
|
246
|
+
userAgent: req.header("user-agent") ?? null,
|
|
247
|
+
ip: req.ip ?? null,
|
|
248
|
+
});
|
|
249
|
+
res.cookie(CONSOLE_COOKIE, secret, persistent ? { ...cookieOptions(req), maxAge: ttlMs } : cookieOptions(req));
|
|
250
|
+
};
|
|
251
|
+
// Limite propre aux formulaires : un mot de passe se devine, autant ralentir qui essaie.
|
|
252
|
+
const formLimiter = credentialsLimiter();
|
|
253
|
+
// Ce que la console doit afficher en arrivant : la création du compte, la connexion,
|
|
254
|
+
// ou directement ses données.
|
|
255
|
+
app.get("/ui/session", (req, res) => {
|
|
256
|
+
const found = access.findSession(consoleSessionId(req), { ip: req.ip ?? null });
|
|
257
|
+
res.json({
|
|
258
|
+
authenticated: Boolean(found),
|
|
259
|
+
setup_required: setup.required,
|
|
260
|
+
user: found ? found.user : null,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
app.post("/ui/setup", formLimiter, async (req, res) => {
|
|
264
|
+
const input = setupSchema.parse(req.body ?? {});
|
|
265
|
+
if (!setup.required)
|
|
266
|
+
throw new MessengerError("the console account already exists", 409);
|
|
267
|
+
if (!setup.accepts(input.code))
|
|
268
|
+
throw new MessengerError("wrong setup code", 403);
|
|
269
|
+
const user = access.createUser(input.username, await hashPassword(input.password));
|
|
270
|
+
setup.consume();
|
|
271
|
+
logger.info(`console account created: ${user.username}`);
|
|
272
|
+
openSession(req, res, user, false);
|
|
273
|
+
res.status(201).json({ user });
|
|
274
|
+
});
|
|
275
|
+
app.post("/ui/login", formLimiter, async (req, res) => {
|
|
201
276
|
const input = loginSchema.parse(req.body ?? {});
|
|
202
|
-
|
|
203
|
-
|
|
277
|
+
const found = access.findUserByName(input.username);
|
|
278
|
+
// Un nom inconnu coûte le même calcul qu'un mot de passe faux : la durée de la réponse
|
|
279
|
+
// ne dit pas si le compte existe.
|
|
280
|
+
const valid = await verifyPassword(input.password, found?.password_hash ?? (await decoyHash()));
|
|
281
|
+
if (!found || !valid) {
|
|
282
|
+
res.status(401).json({ error: "invalid username or password" });
|
|
204
283
|
return;
|
|
205
284
|
}
|
|
206
|
-
|
|
285
|
+
openSession(req, res, access.getUser(found.id), input.remember ?? false);
|
|
207
286
|
res.status(204).end();
|
|
208
287
|
});
|
|
209
288
|
app.post("/ui/logout", (req, res) => {
|
|
210
|
-
|
|
289
|
+
access.revokeSessionBySecret(consoleSessionId(req));
|
|
211
290
|
res.clearCookie(CONSOLE_COOKIE, cookieOptions(req));
|
|
212
291
|
res.status(204).end();
|
|
213
292
|
});
|
|
@@ -218,10 +297,10 @@ export function createApp({ store, config }) {
|
|
|
218
297
|
res.redirect("/ui/");
|
|
219
298
|
});
|
|
220
299
|
// --- Supervision (lecture, liaisons et nettoyages de boîtes) -------------
|
|
221
|
-
// Montée avant la porte d'entrée
|
|
222
|
-
// console
|
|
300
|
+
// Montée avant la porte d'entrée des agents : `/admin` ne s'ouvre qu'avec une session de
|
|
301
|
+
// la console, et la session par cookie n'est reconnue nulle part ailleurs.
|
|
223
302
|
const admin = express.Router();
|
|
224
|
-
admin.use(
|
|
303
|
+
admin.use(authenticateConsole(store));
|
|
225
304
|
admin.get("/overview", (_req, res) => {
|
|
226
305
|
res.json(store.overview());
|
|
227
306
|
});
|
|
@@ -362,10 +441,93 @@ export function createApp({ store, config }) {
|
|
|
362
441
|
logger.info(`admin unlink ${linkId}`);
|
|
363
442
|
res.json({ link_id: linkId });
|
|
364
443
|
});
|
|
444
|
+
// --- Compte de la console -------------------------------------------------
|
|
445
|
+
admin.get("/account", (req, res) => {
|
|
446
|
+
const user = req.consoleUser;
|
|
447
|
+
res.json({
|
|
448
|
+
user,
|
|
449
|
+
sessions: access.listSessions(user.id).map((session) => ({
|
|
450
|
+
...session,
|
|
451
|
+
current: session.id === req.consoleSession.id,
|
|
452
|
+
})),
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
admin.patch("/account", (req, res) => {
|
|
456
|
+
const input = accountSchema.parse(req.body ?? {});
|
|
457
|
+
const user = access.renameUser(req.consoleUser.id, input.username);
|
|
458
|
+
logger.info(`console account renamed: ${req.consoleUser.username} -> ${user.username}`);
|
|
459
|
+
res.json({ user });
|
|
460
|
+
});
|
|
461
|
+
/** Changer de mot de passe ferme toutes les autres sessions : c'est souvent la raison du changement. */
|
|
462
|
+
admin.post("/account/password", formLimiter, async (req, res) => {
|
|
463
|
+
const input = passwordChangeSchema.parse(req.body ?? {});
|
|
464
|
+
const user = req.consoleUser;
|
|
465
|
+
if (!(await verifyPassword(input.current_password, access.getPasswordHash(user.id) ?? ""))) {
|
|
466
|
+
throw new MessengerError("the current password is wrong", 403);
|
|
467
|
+
}
|
|
468
|
+
access.setPassword(user.id, await hashPassword(input.new_password));
|
|
469
|
+
const closed = access.revokeOtherSessions(user.id, req.consoleSession.id);
|
|
470
|
+
logger.info(`console password changed for ${user.username}, ${closed} other session(s) closed`);
|
|
471
|
+
res.json({ closed_sessions: closed });
|
|
472
|
+
});
|
|
473
|
+
admin.delete("/account/sessions/:id", (req, res) => {
|
|
474
|
+
const id = String(req.params.id);
|
|
475
|
+
access.revokeSession(req.consoleUser.id, id);
|
|
476
|
+
res.json({ session_id: id });
|
|
477
|
+
});
|
|
478
|
+
admin.post("/account/sessions/revoke-others", (req, res) => {
|
|
479
|
+
res.json({ closed_sessions: access.revokeOtherSessions(req.consoleUser.id, req.consoleSession.id) });
|
|
480
|
+
});
|
|
481
|
+
// --- Clés API -------------------------------------------------------------
|
|
482
|
+
admin.get("/keys", (_req, res) => {
|
|
483
|
+
res.json({ keys: access.listApiKeys() });
|
|
484
|
+
});
|
|
485
|
+
/** La clé en clair ne sort qu'ici, une fois : le serveur n'en garde que l'empreinte. */
|
|
486
|
+
admin.post("/keys", (req, res) => {
|
|
487
|
+
const input = keyCreateSchema.parse(req.body ?? {});
|
|
488
|
+
const secret = generateApiKey();
|
|
489
|
+
const key = access.createApiKey({
|
|
490
|
+
name: input.name,
|
|
491
|
+
agentName: input.agent_name,
|
|
492
|
+
lifetimeDays: input.lifetime_days,
|
|
493
|
+
createdBy: req.consoleUser.username,
|
|
494
|
+
secret,
|
|
495
|
+
});
|
|
496
|
+
logger.info(`API key ${key.id} created (${key.name}, ${key.agent_name ?? "all agents"})`);
|
|
497
|
+
res.status(201).json({ key, secret });
|
|
498
|
+
});
|
|
499
|
+
admin.get("/keys/:id", (req, res) => {
|
|
500
|
+
const id = String(req.params.id);
|
|
501
|
+
const key = access.getApiKey(id);
|
|
502
|
+
if (!key)
|
|
503
|
+
throw new MessengerError(`API key not found: ${id}`, 404);
|
|
504
|
+
res.json({ key, mailboxes: access.mailboxesForKey(id) });
|
|
505
|
+
});
|
|
506
|
+
admin.patch("/keys/:id", (req, res) => {
|
|
507
|
+
const input = keyRenameSchema.parse(req.body ?? {});
|
|
508
|
+
res.json({ key: access.renameApiKey(String(req.params.id), input.name) });
|
|
509
|
+
});
|
|
510
|
+
admin.post("/keys/:id/extend", (req, res) => {
|
|
511
|
+
const input = keyExtendSchema.parse(req.body ?? {});
|
|
512
|
+
const key = access.extendApiKey(String(req.params.id), input.days);
|
|
513
|
+
logger.info(`API key ${key.id} extended until ${key.expires_at ?? "no expiry"}`);
|
|
514
|
+
res.json({ key });
|
|
515
|
+
});
|
|
516
|
+
admin.post("/keys/:id/rotate", (req, res) => {
|
|
517
|
+
const secret = generateApiKey();
|
|
518
|
+
const { key, replaced } = access.rotateApiKey(String(req.params.id), secret, req.consoleUser.username, ROTATION_GRACE_MS);
|
|
519
|
+
logger.info(`API key ${replaced.id} replaced by ${key.id}, old one valid until ${replaced.expires_at}`);
|
|
520
|
+
res.status(201).json({ key, secret, replaced });
|
|
521
|
+
});
|
|
522
|
+
admin.post("/keys/:id/revoke", (req, res) => {
|
|
523
|
+
const key = access.revokeApiKey(String(req.params.id));
|
|
524
|
+
logger.warn(`API key ${key.id} revoked (${key.name})`);
|
|
525
|
+
res.json({ key });
|
|
526
|
+
});
|
|
365
527
|
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(
|
|
528
|
+
// Porte d'entrée de tout le reste : aucune requête n'est servie sans clé API valide. Le
|
|
529
|
+
// contrôle par route (boîtes que la clé ouvre) vient ensuite.
|
|
530
|
+
app.use(requireApiKey(store));
|
|
369
531
|
// --- État du service ------------------------------------------------------
|
|
370
532
|
app.get("/health", (_req, res) => {
|
|
371
533
|
const overview = store.overview();
|
|
@@ -375,27 +537,35 @@ export function createApp({ store, config }) {
|
|
|
375
537
|
version: APP_VERSION,
|
|
376
538
|
agents: overview.agents.length,
|
|
377
539
|
messages: overview.messages,
|
|
378
|
-
// Toujours vrai :
|
|
540
|
+
// Toujours vrai : cette route, comme toute l'API, exige une clé.
|
|
379
541
|
auth_required: true,
|
|
380
542
|
});
|
|
381
543
|
});
|
|
382
544
|
// --- API agents -----------------------------------------------------------
|
|
383
545
|
const api = express.Router();
|
|
384
|
-
api.use(authenticateAgent(
|
|
546
|
+
api.use(authenticateAgent(store));
|
|
385
547
|
api.post("/agents/register", (req, res) => {
|
|
386
548
|
const input = registerSchema.parse(req.body ?? {});
|
|
387
549
|
// Une session qui alloue sa propre boîte ne doit jamais tomber sur celle d'une autre.
|
|
388
550
|
if (input.fresh && store.getAgent(req.agentId)) {
|
|
389
551
|
throw new MessengerError(`mailbox already exists: ${req.agentId}`, 409);
|
|
390
552
|
}
|
|
553
|
+
// Une clé liée à un agent donne son nom à la boîte. Un autre nom venu de la
|
|
554
|
+
// configuration du client est une erreur de réglage, signalée plutôt que corrigée.
|
|
555
|
+
const key = req.apiKey;
|
|
556
|
+
if (key.agent_name && input.agent_name && input.agent_name !== key.agent_name) {
|
|
557
|
+
throw new MessengerError(`this API key is bound to agent "${key.agent_name}": remove MESSENGER_AGENT_NAME from the client ` +
|
|
558
|
+
`configuration, or set it to "${key.agent_name}"`, 403);
|
|
559
|
+
}
|
|
391
560
|
const agent = store.ensureAgent(req.agentId, {
|
|
392
561
|
registered: true,
|
|
393
|
-
agent_name: input.agent_name ?? null,
|
|
562
|
+
agent_name: key.agent_name ?? input.agent_name ?? null,
|
|
394
563
|
agent_description: input.agent_description ?? null,
|
|
395
564
|
// Un client antérieur à la séparation agent / boîte n'envoie que ces deux-là.
|
|
396
565
|
display_name: input.display_name ?? null,
|
|
397
566
|
description: input.description ?? null,
|
|
398
567
|
});
|
|
568
|
+
access.recordMailboxKey(agent.id, key.id);
|
|
399
569
|
res.json({ agent });
|
|
400
570
|
});
|
|
401
571
|
// L'agent ne se voit pas dans son propre annuaire : sa boîte est la seule interdite d'envoi.
|
|
@@ -581,25 +751,53 @@ function sendAttachment(res, attachment) {
|
|
|
581
751
|
res.end(attachment.data);
|
|
582
752
|
}
|
|
583
753
|
/**
|
|
584
|
-
*
|
|
754
|
+
* Limite des formulaires d'identifiants : dix essais par quart d'heure et par adresse. Le
|
|
755
|
+
* refus dit dans combien de secondes réessayer, pour que la console puisse l'afficher.
|
|
756
|
+
*/
|
|
757
|
+
function credentialsLimiter() {
|
|
758
|
+
return rateLimit({
|
|
759
|
+
windowMs: 15 * 60_000,
|
|
760
|
+
limit: 10,
|
|
761
|
+
standardHeaders: "draft-8",
|
|
762
|
+
legacyHeaders: false,
|
|
763
|
+
handler: (req, res) => {
|
|
764
|
+
const reset = req.rateLimit?.resetTime?.getTime();
|
|
765
|
+
const retryAfter = reset ? Math.max(1, Math.ceil((reset - Date.now()) / 1000)) : 15 * 60;
|
|
766
|
+
res.status(429).json({ error: "too many attempts", retry_after_seconds: retryAfter });
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
let decoy = null;
|
|
771
|
+
/** Empreinte jetable, vérifiée quand le compte demandé n'existe pas. */
|
|
772
|
+
function decoyHash() {
|
|
773
|
+
decoy ??= hashPassword(generateSessionSecret());
|
|
774
|
+
return decoy;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Exige une clé API active sur toute requête entrante.
|
|
585
778
|
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
* route par route.
|
|
779
|
+
* C'est le filtre grossier : il dit que l'appelant est un agent connu. Ce que la clé
|
|
780
|
+
* l'autorise à ouvrir est vérifié juste après, par `authenticateAgent`.
|
|
589
781
|
*/
|
|
590
|
-
function
|
|
591
|
-
const accepted = [config.token, config.adminToken, ...config.agentTokens.values()].filter((value) => Boolean(value));
|
|
782
|
+
function requireApiKey(store) {
|
|
592
783
|
return (req, res, next) => {
|
|
593
|
-
const
|
|
594
|
-
if (!
|
|
595
|
-
res.status(401).json({ error: "invalid or
|
|
784
|
+
const key = store.access.authenticateApiKey(presentedKey(req), req.ip ?? null);
|
|
785
|
+
if (!key) {
|
|
786
|
+
res.status(401).json({ error: "invalid, expired or revoked API key" });
|
|
596
787
|
return;
|
|
597
788
|
}
|
|
789
|
+
req.apiKey = key;
|
|
598
790
|
next();
|
|
599
791
|
};
|
|
600
792
|
}
|
|
601
|
-
/**
|
|
602
|
-
|
|
793
|
+
/**
|
|
794
|
+
* Boîte visée (en-tête `X-Agent-Id`), et droit de la clé sur elle.
|
|
795
|
+
*
|
|
796
|
+
* Une clé liée à un agent n'ouvre que les boîtes de cet agent, plus celles qui n'ont encore
|
|
797
|
+
* jamais été ouvertes. Une clé valable pour tous les agents les ouvre toutes, comme la
|
|
798
|
+
* console l'annonce en la créant.
|
|
799
|
+
*/
|
|
800
|
+
function authenticateAgent(store) {
|
|
603
801
|
return (req, res, next) => {
|
|
604
802
|
const agentId = normalizeAgentId(String(req.header("x-agent-id") ?? ""));
|
|
605
803
|
if (!agentId) {
|
|
@@ -610,59 +808,39 @@ function authenticateAgent(config) {
|
|
|
610
808
|
res.status(400).json({ error: "agent id too long (max 128 chars)" });
|
|
611
809
|
return;
|
|
612
810
|
}
|
|
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" });
|
|
811
|
+
const key = req.apiKey;
|
|
812
|
+
if (key.agent_name) {
|
|
813
|
+
const mailbox = store.getAgent(agentId);
|
|
814
|
+
if (mailbox?.registered && mailbox.agent_name !== key.agent_name) {
|
|
815
|
+
res.status(403).json({ error: `this API key only opens mailboxes of agent "${key.agent_name}"` });
|
|
624
816
|
return;
|
|
625
817
|
}
|
|
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
818
|
}
|
|
631
819
|
req.agentId = agentId;
|
|
632
820
|
next();
|
|
633
821
|
};
|
|
634
822
|
}
|
|
635
|
-
/** Session de console
|
|
636
|
-
function
|
|
823
|
+
/** Session de la console, ouverte avec le compte. Rien d'autre n'ouvre `/admin`. */
|
|
824
|
+
function authenticateConsole(store) {
|
|
637
825
|
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" });
|
|
826
|
+
const found = store.access.findSession(consoleSessionId(req), { ip: req.ip ?? null });
|
|
827
|
+
if (!found) {
|
|
828
|
+
res.status(401).json({ error: "console session required" });
|
|
645
829
|
return;
|
|
646
830
|
}
|
|
831
|
+
req.consoleUser = found.user;
|
|
832
|
+
req.consoleSession = found.session;
|
|
647
833
|
next();
|
|
648
834
|
};
|
|
649
835
|
}
|
|
650
836
|
/**
|
|
651
|
-
*
|
|
652
|
-
* Jamais l'URL :
|
|
837
|
+
* Clé présentée par l'appelant : en-tête `Authorization`, sinon `X-Messenger-Token`.
|
|
838
|
+
* Jamais l'URL : une clé en query string finit dans les journaux d'accès du proxy.
|
|
653
839
|
*/
|
|
654
|
-
function
|
|
840
|
+
function presentedKey(req) {
|
|
655
841
|
const header = req.header("authorization");
|
|
656
842
|
if (header?.toLowerCase().startsWith("bearer "))
|
|
657
843
|
return header.slice(7).trim() || null;
|
|
658
844
|
const alt = req.header("x-messenger-token");
|
|
659
845
|
return alt?.trim() ? alt.trim() : null;
|
|
660
846
|
}
|
|
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
|
-
}
|
|
@@ -1,30 +1,40 @@
|
|
|
1
1
|
import type { Request } from "express";
|
|
2
|
+
import type { AccessStore } from "../core/access-store.js";
|
|
2
3
|
/**
|
|
3
4
|
* Sessions de la console web.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* La console s'ouvre avec un nom d'utilisateur et un mot de passe, et reçoit en retour un
|
|
7
|
+
* identifiant de session opaque dans un cookie `HttpOnly`. Le serveur n'en garde que
|
|
8
|
+
* l'empreinte, en base : une session survit à un redémarrage, et se ferme depuis la page
|
|
9
|
+
* « Mon compte ».
|
|
9
10
|
*
|
|
10
|
-
* Le cookie
|
|
11
|
-
*
|
|
12
|
-
* `/ui` : l'API des agents reste sur le Bearer, un site tiers ne peut donc pas faire
|
|
13
|
-
* agir un agent à travers le navigateur de l'administrateur.
|
|
11
|
+
* Le cookie n'est honoré que sur `/admin` et `/ui` : l'API des agents reste sur la clé
|
|
12
|
+
* API, un site tiers ne peut donc pas faire agir un agent à travers le navigateur.
|
|
14
13
|
*/
|
|
15
14
|
export declare const CONSOLE_COOKIE = "messenger_console";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
constructor(ttlMs?: number, now?: () => number);
|
|
21
|
-
/** Ouvre une session et rend son identifiant, seul contenu du cookie. */
|
|
22
|
-
create(): string;
|
|
23
|
-
/** Vrai si l'identifiant désigne une session encore valide. */
|
|
24
|
-
has(id: string | null): boolean;
|
|
25
|
-
revoke(id: string | null): void;
|
|
26
|
-
get size(): number;
|
|
27
|
-
private prune;
|
|
28
|
-
}
|
|
15
|
+
/** Durée d'une session ordinaire : assez pour une journée de supervision, pas plus. */
|
|
16
|
+
export declare const SESSION_TTL_MS: number;
|
|
17
|
+
/** Durée d'une session ouverte avec « rester connecté ». */
|
|
18
|
+
export declare const PERSISTENT_SESSION_TTL_MS: number;
|
|
29
19
|
/** Identifiant de session porté par la requête, s'il y en a un. */
|
|
30
20
|
export declare function consoleSessionId(req: Request): string | null;
|
|
21
|
+
/**
|
|
22
|
+
* Code d'installation : il garde la création du premier compte.
|
|
23
|
+
*
|
|
24
|
+
* Tant qu'aucun compte n'existe, la console propose de le créer, et le premier visiteur
|
|
25
|
+
* venu le pourrait. Le code, écrit dans le journal du serveur au démarrage, prouve qu'on a
|
|
26
|
+
* accès à la machine. Il ne vit qu'en mémoire, change à chaque démarrage, et ne sert qu'une
|
|
27
|
+
* fois : dès qu'un compte existe, il n'y a plus rien à garder.
|
|
28
|
+
*/
|
|
29
|
+
export declare class SetupGate {
|
|
30
|
+
private readonly access;
|
|
31
|
+
private code;
|
|
32
|
+
constructor(access: AccessStore);
|
|
33
|
+
/** Le code à recopier, tant qu'aucun compte n'existe. */
|
|
34
|
+
get pendingCode(): string | null;
|
|
35
|
+
get required(): boolean;
|
|
36
|
+
/** Vrai si le code présenté est le bon. Les tirets et la casse ne comptent pas. */
|
|
37
|
+
accepts(presented: string): boolean;
|
|
38
|
+
/** Le code a servi : il ne vaut plus rien. */
|
|
39
|
+
consume(): void;
|
|
40
|
+
}
|