@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.
@@ -0,0 +1,328 @@
1
+ import { API_KEY_PREFIX, digestSecret } from "./credentials.js";
2
+ import { newId } from "./ids.js";
3
+ import { MessengerError } from "./errors.js";
4
+ /** Une date de dernier usage n'est réécrite qu'au-delà de ce délai : pas une écriture par requête. */
5
+ const TOUCH_INTERVAL_MS = 60_000;
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ const KEY_COLUMNS = `k.id, k.name, k.prefix, k.suffix, k.agent_name, k.agent_description, k.created_at, k.created_by, k.expires_at,
8
+ k.lifetime_days, k.revoked_at, k.last_used_at, k.last_used_ip,
9
+ (SELECT COUNT(*) FROM mailbox_keys mk JOIN agents a ON a.id = mk.mailbox WHERE mk.api_key_id = k.id) AS mailboxes`;
10
+ export class AccessStore {
11
+ db;
12
+ now;
13
+ constructor(db, now = () => Date.now()) {
14
+ this.db = db;
15
+ this.now = now;
16
+ this.db.exec(`
17
+ CREATE TABLE IF NOT EXISTS users (
18
+ id TEXT PRIMARY KEY,
19
+ username TEXT NOT NULL UNIQUE COLLATE NOCASE,
20
+ password_hash TEXT NOT NULL,
21
+ created_at TEXT NOT NULL,
22
+ updated_at TEXT
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS console_sessions (
26
+ id TEXT PRIMARY KEY,
27
+ secret_hash TEXT NOT NULL UNIQUE,
28
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
29
+ created_at TEXT NOT NULL,
30
+ last_seen_at TEXT NOT NULL,
31
+ expires_at TEXT NOT NULL,
32
+ persistent INTEGER NOT NULL DEFAULT 0,
33
+ user_agent TEXT,
34
+ ip TEXT
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS api_keys (
38
+ id TEXT PRIMARY KEY,
39
+ name TEXT NOT NULL,
40
+ secret_hash TEXT NOT NULL UNIQUE,
41
+ prefix TEXT NOT NULL,
42
+ suffix TEXT NOT NULL,
43
+ agent_name TEXT,
44
+ agent_description TEXT,
45
+ created_at TEXT NOT NULL,
46
+ created_by TEXT,
47
+ expires_at TEXT,
48
+ lifetime_days INTEGER,
49
+ revoked_at TEXT,
50
+ last_used_at TEXT,
51
+ last_used_ip TEXT
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS mailbox_keys (
55
+ mailbox TEXT PRIMARY KEY,
56
+ api_key_id TEXT NOT NULL,
57
+ opened_at TEXT NOT NULL
58
+ );
59
+
60
+ CREATE INDEX IF NOT EXISTS idx_mailbox_keys_key ON mailbox_keys (api_key_id);
61
+ CREATE INDEX IF NOT EXISTS idx_console_sessions_user ON console_sessions (user_id);
62
+ `);
63
+ }
64
+ iso(offsetMs = 0) {
65
+ return new Date(this.now() + offsetMs).toISOString();
66
+ }
67
+ // --- Comptes --------------------------------------------------------------
68
+ hasUsers() {
69
+ return Boolean(this.db.prepare("SELECT 1 FROM users LIMIT 1").get());
70
+ }
71
+ createUser(username, passwordHash) {
72
+ const id = newId("usr");
73
+ try {
74
+ this.db
75
+ .prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)")
76
+ .run(id, username, passwordHash, this.iso());
77
+ }
78
+ catch (error) {
79
+ if (isUniqueViolation(error))
80
+ throw new MessengerError(`username already taken: ${username}`, 409);
81
+ throw error;
82
+ }
83
+ return this.getUser(id);
84
+ }
85
+ getUser(id) {
86
+ return (this.db.prepare("SELECT id, username, created_at, updated_at FROM users WHERE id = ?").get(id) ?? null);
87
+ }
88
+ findUserByName(username) {
89
+ return this.db.prepare("SELECT * FROM users WHERE username = ?").get(username) ?? null;
90
+ }
91
+ listUsers() {
92
+ return this.db.prepare("SELECT id, username, created_at, updated_at FROM users ORDER BY created_at").all();
93
+ }
94
+ getPasswordHash(id) {
95
+ const row = this.db.prepare("SELECT password_hash FROM users WHERE id = ?").get(id);
96
+ return row?.password_hash ?? null;
97
+ }
98
+ renameUser(id, username) {
99
+ try {
100
+ const result = this.db
101
+ .prepare("UPDATE users SET username = ?, updated_at = ? WHERE id = ?")
102
+ .run(username, this.iso(), id);
103
+ if (result.changes === 0)
104
+ throw new MessengerError(`user not found: ${id}`, 404);
105
+ }
106
+ catch (error) {
107
+ if (isUniqueViolation(error))
108
+ throw new MessengerError(`username already taken: ${username}`, 409);
109
+ throw error;
110
+ }
111
+ return this.getUser(id);
112
+ }
113
+ setPassword(id, passwordHash) {
114
+ const result = this.db
115
+ .prepare("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?")
116
+ .run(passwordHash, this.iso(), id);
117
+ if (result.changes === 0)
118
+ throw new MessengerError(`user not found: ${id}`, 404);
119
+ }
120
+ // --- Sessions de la console -----------------------------------------------
121
+ createSession(input) {
122
+ this.pruneSessions();
123
+ const id = newId("ses");
124
+ const now = this.iso();
125
+ this.db
126
+ .prepare(`INSERT INTO console_sessions (id, secret_hash, user_id, created_at, last_seen_at, expires_at, persistent, user_agent, ip)
127
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
128
+ .run(id, digestSecret(input.secret), input.userId, now, now, this.iso(input.ttlMs), input.persistent ? 1 : 0, input.userAgent?.slice(0, 300) ?? null, input.ip);
129
+ return this.getSession(id);
130
+ }
131
+ /** Session encore valide désignée par le contenu du cookie, avec son compte. */
132
+ findSession(secret, seen = { ip: null }) {
133
+ if (!secret)
134
+ return null;
135
+ const row = this.db
136
+ .prepare("SELECT * FROM console_sessions WHERE secret_hash = ? AND expires_at > ?")
137
+ .get(digestSecret(secret), this.iso());
138
+ if (!row)
139
+ return null;
140
+ const user = this.getUser(row.user_id);
141
+ if (!user)
142
+ return null;
143
+ if (this.now() - Date.parse(row.last_seen_at) > TOUCH_INTERVAL_MS) {
144
+ const now = this.iso();
145
+ this.db
146
+ .prepare("UPDATE console_sessions SET last_seen_at = ?, ip = COALESCE(?, ip) WHERE id = ?")
147
+ .run(now, seen.ip, row.id);
148
+ row.last_seen_at = now;
149
+ if (seen.ip)
150
+ row.ip = seen.ip;
151
+ }
152
+ return { session: toSession(row), user };
153
+ }
154
+ getSession(id) {
155
+ const row = this.db.prepare("SELECT * FROM console_sessions WHERE id = ?").get(id);
156
+ return row ? toSession(row) : null;
157
+ }
158
+ listSessions(userId) {
159
+ this.pruneSessions();
160
+ return this.db
161
+ .prepare("SELECT * FROM console_sessions WHERE user_id = ? ORDER BY last_seen_at DESC")
162
+ .all(userId).map(toSession);
163
+ }
164
+ revokeSession(userId, id) {
165
+ const result = this.db.prepare("DELETE FROM console_sessions WHERE id = ? AND user_id = ?").run(id, userId);
166
+ if (result.changes === 0)
167
+ throw new MessengerError(`session not found: ${id}`, 404);
168
+ }
169
+ revokeSessionBySecret(secret) {
170
+ if (secret)
171
+ this.db.prepare("DELETE FROM console_sessions WHERE secret_hash = ?").run(digestSecret(secret));
172
+ }
173
+ /** Ferme les sessions du compte sauf `keepId` (null : toutes). Rend le nombre fermé. */
174
+ revokeOtherSessions(userId, keepId) {
175
+ return this.db
176
+ .prepare("DELETE FROM console_sessions WHERE user_id = ? AND id != ?")
177
+ .run(userId, keepId ?? "").changes;
178
+ }
179
+ pruneSessions() {
180
+ this.db.prepare("DELETE FROM console_sessions WHERE expires_at <= ?").run(this.iso());
181
+ }
182
+ // --- Clés API -------------------------------------------------------------
183
+ createApiKey(input) {
184
+ const id = newId("key");
185
+ const visible = input.secret.startsWith(API_KEY_PREFIX) ? API_KEY_PREFIX.length : 0;
186
+ this.db
187
+ .prepare(`INSERT INTO api_keys (id, name, secret_hash, prefix, suffix, agent_name, agent_description, created_at, created_by, expires_at, lifetime_days)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
189
+ .run(id, input.name, digestSecret(input.secret), input.secret.slice(0, visible + 4), input.secret.slice(-4), input.agentName, input.agentDescription ?? null, this.iso(), input.createdBy, input.lifetimeDays === null ? null : this.iso(input.lifetimeDays * DAY_MS), input.lifetimeDays);
190
+ return this.getApiKey(id);
191
+ }
192
+ listApiKeys() {
193
+ return this.db.prepare(`SELECT ${KEY_COLUMNS} FROM api_keys k ORDER BY k.created_at DESC`).all().map((row) => this.toApiKey(row));
194
+ }
195
+ getApiKey(id) {
196
+ const row = this.db.prepare(`SELECT ${KEY_COLUMNS} FROM api_keys k WHERE k.id = ?`).get(id);
197
+ return row ? this.toApiKey(row) : null;
198
+ }
199
+ requireKey(id) {
200
+ const key = this.getApiKey(id);
201
+ if (!key)
202
+ throw new MessengerError(`API key not found: ${id}`, 404);
203
+ return key;
204
+ }
205
+ renameApiKey(id, name) {
206
+ this.requireKey(id);
207
+ this.db.prepare("UPDATE api_keys SET name = ? WHERE id = ?").run(name, id);
208
+ return this.getApiKey(id);
209
+ }
210
+ /**
211
+ * Change la description de l'agent portée par une clé liée à un agent, et la reporte sur
212
+ * les boîtes déjà ouvertes avec elle. null l'efface de la clé, pas des boîtes.
213
+ */
214
+ setApiKeyAgentDescription(id, description) {
215
+ const key = this.requireKey(id);
216
+ if (!key.agent_name) {
217
+ throw new MessengerError("only a key bound to an agent carries an agent description", 400);
218
+ }
219
+ this.db.transaction(() => {
220
+ this.db.prepare("UPDATE api_keys SET agent_description = ? WHERE id = ?").run(description, id);
221
+ if (description !== null) {
222
+ this.db
223
+ .prepare("UPDATE agents SET agent_description = ? WHERE id IN (SELECT mailbox FROM mailbox_keys WHERE api_key_id = ?)")
224
+ .run(description, id);
225
+ }
226
+ })();
227
+ return this.getApiKey(id);
228
+ }
229
+ /**
230
+ * Repousse l'expiration de `days` jours, comptés depuis l'échéance encore à venir ou,
231
+ * pour une clé déjà expirée, depuis maintenant. `null` retire toute échéance.
232
+ */
233
+ extendApiKey(id, days) {
234
+ const key = this.requireKey(id);
235
+ if (key.status === "revoked")
236
+ throw new MessengerError("a revoked key cannot be extended", 409);
237
+ const base = key.expires_at ? Math.max(this.now(), Date.parse(key.expires_at)) : this.now();
238
+ const expiresAt = days === null ? null : new Date(base + days * DAY_MS).toISOString();
239
+ this.db.prepare("UPDATE api_keys SET expires_at = ? WHERE id = ?").run(expiresAt, id);
240
+ return this.getApiKey(id);
241
+ }
242
+ revokeApiKey(id) {
243
+ const key = this.requireKey(id);
244
+ if (key.status === "revoked")
245
+ throw new MessengerError("this key is already revoked", 409);
246
+ this.db.prepare("UPDATE api_keys SET revoked_at = ? WHERE id = ?").run(this.iso(), id);
247
+ return this.getApiKey(id);
248
+ }
249
+ /**
250
+ * Remplace une clé : la nouvelle reprend le nom, l'agent, sa description et la durée de validité de
251
+ * l'ancienne ; l'ancienne reste acceptée `graceMs` encore, au plus jusqu'à son échéance.
252
+ */
253
+ rotateApiKey(id, secret, createdBy, graceMs) {
254
+ const old = this.requireKey(id);
255
+ if (old.status !== "active")
256
+ throw new MessengerError(`only an active key can be replaced (this one is ${old.status})`, 409);
257
+ return this.db.transaction(() => {
258
+ const key = this.createApiKey({
259
+ name: old.name,
260
+ agentName: old.agent_name,
261
+ agentDescription: old.agent_description,
262
+ lifetimeDays: old.lifetime_days,
263
+ createdBy,
264
+ secret,
265
+ });
266
+ const grace = this.iso(graceMs);
267
+ const expiresAt = old.expires_at && old.expires_at < grace ? old.expires_at : grace;
268
+ this.db.prepare("UPDATE api_keys SET expires_at = ? WHERE id = ?").run(expiresAt, id);
269
+ return { key, replaced: this.getApiKey(id) };
270
+ })();
271
+ }
272
+ /** Clé active désignée par le secret présenté, ou null. Note son dernier usage. */
273
+ authenticateApiKey(secret, ip) {
274
+ if (!secret)
275
+ return null;
276
+ const row = this.db
277
+ .prepare(`SELECT ${KEY_COLUMNS} FROM api_keys k WHERE k.secret_hash = ?`)
278
+ .get(digestSecret(secret));
279
+ if (!row)
280
+ return null;
281
+ const key = this.toApiKey(row);
282
+ if (key.status !== "active")
283
+ return null;
284
+ if (!key.last_used_at || this.now() - Date.parse(key.last_used_at) > TOUCH_INTERVAL_MS || key.last_used_ip !== ip) {
285
+ const now = this.iso();
286
+ this.db.prepare("UPDATE api_keys SET last_used_at = ?, last_used_ip = ? WHERE id = ?").run(now, ip, key.id);
287
+ key.last_used_at = now;
288
+ key.last_used_ip = ip;
289
+ }
290
+ return key;
291
+ }
292
+ /** Retient la clé qui a ouvert ou repris une boîte : la dernière fait foi. */
293
+ recordMailboxKey(mailbox, apiKeyId) {
294
+ this.db
295
+ .prepare(`INSERT INTO mailbox_keys (mailbox, api_key_id, opened_at) VALUES (?, ?, ?)
296
+ ON CONFLICT (mailbox) DO UPDATE SET api_key_id = excluded.api_key_id, opened_at = excluded.opened_at
297
+ WHERE mailbox_keys.api_key_id != excluded.api_key_id`)
298
+ .run(mailbox, apiKeyId, this.iso());
299
+ }
300
+ mailboxesForKey(id) {
301
+ this.requireKey(id);
302
+ const rows = this.db
303
+ .prepare(`SELECT a.id, a.agent_name, a.display_name,
304
+ MAX(COALESCE(a.last_seen_at, ''),
305
+ COALESCE((SELECT MAX(d.created_at) FROM deliveries d WHERE d.agent_id = a.id), '')) AS last_activity_at
306
+ FROM mailbox_keys mk
307
+ JOIN agents a ON a.id = mk.mailbox
308
+ WHERE mk.api_key_id = ?
309
+ ORDER BY last_activity_at DESC, a.id`)
310
+ .all(id);
311
+ return rows.map((row) => ({ ...row, last_activity_at: row.last_activity_at || null }));
312
+ }
313
+ toApiKey(row) {
314
+ const status = row.revoked_at
315
+ ? "revoked"
316
+ : row.expires_at && Date.parse(row.expires_at) <= this.now()
317
+ ? "expired"
318
+ : "active";
319
+ return { ...row, status };
320
+ }
321
+ }
322
+ function toSession(row) {
323
+ const { secret_hash: _secret, ...rest } = row;
324
+ return { ...rest, persistent: Boolean(row.persistent) };
325
+ }
326
+ function isUniqueViolation(error) {
327
+ return typeof error === "object" && error !== null && error.code === "SQLITE_CONSTRAINT_UNIQUE";
328
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Secrets de l'authentification : mots de passe, clés API, sessions, code d'installation.
3
+ *
4
+ * Rien de ce qui est fabriqué ici n'est stocké en clair. Un mot de passe est haché par
5
+ * scrypt, lent et salé, parce qu'il est choisi par un humain et donc devinable. Une clé ou
6
+ * un identifiant de session sort de 256 bits d'aléa : une empreinte SHA-256 suffit à le
7
+ * retrouver sans le conserver, et se calcule à chaque requête sans coût.
8
+ */
9
+ /** Préfixe des clés API : il les distingue des adresses (`box_`) et des messages (`msg_`). */
10
+ export declare const API_KEY_PREFIX = "mk_";
11
+ /** Longueur minimale d'un mot de passe. */
12
+ export declare const PASSWORD_MIN_LENGTH = 12;
13
+ /** Empreinte d'un mot de passe : `scrypt$N$r$p$sel$hash`, sel et hash en base64url. */
14
+ export declare function hashPassword(password: string): Promise<string>;
15
+ /** Vrai si le mot de passe correspond à l'empreinte. Toute empreinte illisible est un refus. */
16
+ export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
17
+ /** Empreinte d'un secret à haute entropie (clé API, identifiant de session). */
18
+ export declare function digestSecret(secret: string): string;
19
+ /** Clé API neuve : le préfixe, puis 256 bits d'aléa. */
20
+ export declare function generateApiKey(): string;
21
+ /** Identifiant de session de la console, seul contenu de son cookie. */
22
+ export declare function generateSessionSecret(): string;
23
+ /** Code d'installation : trois groupes de quatre caractères, à recopier depuis le journal. */
24
+ export declare function generateSetupCode(): string;
25
+ /** Mot de passe temporaire donné par `--reset-password`. */
26
+ export declare function generatePassword(): string;
27
+ /** Comparaison à temps constant de deux chaînes. */
28
+ export declare function safeEqual(a: string, b: string): boolean;
@@ -0,0 +1,75 @@
1
+ import { createHash, randomBytes, randomInt, scrypt, timingSafeEqual } from "node:crypto";
2
+ /**
3
+ * Secrets de l'authentification : mots de passe, clés API, sessions, code d'installation.
4
+ *
5
+ * Rien de ce qui est fabriqué ici n'est stocké en clair. Un mot de passe est haché par
6
+ * scrypt, lent et salé, parce qu'il est choisi par un humain et donc devinable. Une clé ou
7
+ * un identifiant de session sort de 256 bits d'aléa : une empreinte SHA-256 suffit à le
8
+ * retrouver sans le conserver, et se calcule à chaque requête sans coût.
9
+ */
10
+ /** Préfixe des clés API : il les distingue des adresses (`box_`) et des messages (`msg_`). */
11
+ export const API_KEY_PREFIX = "mk_";
12
+ /** Longueur minimale d'un mot de passe. */
13
+ export const PASSWORD_MIN_LENGTH = 12;
14
+ const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
15
+ function scryptAsync(password, salt, keylen, options) {
16
+ return new Promise((resolve, reject) => {
17
+ scrypt(password, salt, keylen, options, (error, derived) => (error ? reject(error) : resolve(derived)));
18
+ });
19
+ }
20
+ /** Empreinte d'un mot de passe : `scrypt$N$r$p$sel$hash`, sel et hash en base64url. */
21
+ export async function hashPassword(password) {
22
+ const salt = randomBytes(16);
23
+ const derived = await scryptAsync(password, salt, SCRYPT.keylen, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p });
24
+ return ["scrypt", SCRYPT.N, SCRYPT.r, SCRYPT.p, salt.toString("base64url"), derived.toString("base64url")].join("$");
25
+ }
26
+ /** Vrai si le mot de passe correspond à l'empreinte. Toute empreinte illisible est un refus. */
27
+ export async function verifyPassword(password, stored) {
28
+ const [scheme, n, r, p, salt, hash] = stored.split("$");
29
+ if (scheme !== "scrypt" || !salt || !hash)
30
+ return false;
31
+ const expected = Buffer.from(hash, "base64url");
32
+ const derived = await scryptAsync(password, Buffer.from(salt, "base64url"), expected.length, {
33
+ N: Number(n),
34
+ r: Number(r),
35
+ p: Number(p),
36
+ });
37
+ return derived.length === expected.length && timingSafeEqual(derived, expected);
38
+ }
39
+ /** Empreinte d'un secret à haute entropie (clé API, identifiant de session). */
40
+ export function digestSecret(secret) {
41
+ return createHash("sha256").update(secret).digest("hex");
42
+ }
43
+ /** Clé API neuve : le préfixe, puis 256 bits d'aléa. */
44
+ export function generateApiKey() {
45
+ return `${API_KEY_PREFIX}${randomBytes(32).toString("base64url")}`;
46
+ }
47
+ /** Identifiant de session de la console, seul contenu de son cookie. */
48
+ export function generateSessionSecret() {
49
+ return randomBytes(32).toString("base64url");
50
+ }
51
+ /** Lettres et chiffres sans les caractères qui se confondent à la lecture (0/O, 1/I/L). */
52
+ const SETUP_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
53
+ /** Code d'installation : trois groupes de quatre caractères, à recopier depuis le journal. */
54
+ export function generateSetupCode() {
55
+ const groups = [];
56
+ for (let g = 0; g < 3; g += 1) {
57
+ let group = "";
58
+ for (let i = 0; i < 4; i += 1)
59
+ group += SETUP_ALPHABET[randomInt(SETUP_ALPHABET.length)];
60
+ groups.push(group);
61
+ }
62
+ return groups.join("-");
63
+ }
64
+ /** Mot de passe temporaire donné par `--reset-password`. */
65
+ export function generatePassword() {
66
+ return randomBytes(18).toString("base64url");
67
+ }
68
+ /** Comparaison à temps constant de deux chaînes. */
69
+ export function safeEqual(a, b) {
70
+ const bufA = Buffer.from(a);
71
+ const bufB = Buffer.from(b);
72
+ if (bufA.length !== bufB.length)
73
+ return false;
74
+ return timingSafeEqual(bufA, bufB);
75
+ }
@@ -0,0 +1,5 @@
1
+ /** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
2
+ export declare class MessengerError extends Error {
3
+ readonly status: number;
4
+ constructor(message: string, status?: number);
5
+ }
@@ -0,0 +1,9 @@
1
+ /** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
2
+ export class MessengerError extends Error {
3
+ status;
4
+ constructor(message, status = 400) {
5
+ super(message);
6
+ this.status = status;
7
+ this.name = "MessengerError";
8
+ }
9
+ }
@@ -1,10 +1,8 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import { AccessStore } from "./access-store.js";
3
+ import { MessengerError } from "./errors.js";
2
4
  import { type AdminThreadSummary, type Agent, type AgentDirectoryEntry, type AttachmentInfo, type DeliveryRole, type FolderInfo, type LinkedMailbox, type ListMessagesOptions, type ListThreadsOptions, type MailboxLink, type MailboxStats, type Message, type MessageSummary, type Priority, type SendMessageInput, type SendMessageResult, type ThreadInfo, type ThreadSummary } from "./types.js";
3
- /** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
4
- export declare class MessengerError extends Error {
5
- readonly status: number;
6
- constructor(message: string, status?: number);
7
- }
5
+ export { MessengerError };
8
6
  /** Contenu d'une pièce jointe, avec ses métadonnées. */
9
7
  export interface AttachmentContent extends AttachmentInfo {
10
8
  message_id: string;
@@ -42,6 +40,8 @@ export interface StoreEvents {
42
40
  */
43
41
  export declare class MessageStore extends EventEmitter<StoreEvents> {
44
42
  private readonly db;
43
+ /** Comptes, sessions de la console et clés API, dans la même base. */
44
+ readonly access: AccessStore;
45
45
  constructor(dbPath: string);
46
46
  private migrate;
47
47
  close(): void;
@@ -3,17 +3,11 @@ import { EventEmitter } from "node:events";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, MAX_ATTACHMENT_BYTES, formatBytes } from "./attachments.js";
6
+ import { AccessStore } from "./access-store.js";
7
+ import { MessengerError } from "./errors.js";
6
8
  import { newId } from "./ids.js";
7
9
  import { DELIVERY_ROLES, SYSTEM_FOLDERS, } from "./types.js";
8
- /** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
9
- export class MessengerError extends Error {
10
- status;
11
- constructor(message, status = 400) {
12
- super(message);
13
- this.status = status;
14
- this.name = "MessengerError";
15
- }
16
- }
10
+ export { MessengerError };
17
11
  const PREVIEW_LENGTH = 160;
18
12
  const MAX_LIMIT = 100;
19
13
  const FOLDER_NAME_RE = /^[a-z0-9][a-z0-9 _-]{0,39}$/;
@@ -57,6 +51,8 @@ const ADMIN_DELIVERY = `'to' AS role,
57
51
  */
58
52
  export class MessageStore extends EventEmitter {
59
53
  db;
54
+ /** Comptes, sessions de la console et clés API, dans la même base. */
55
+ access;
60
56
  constructor(dbPath) {
61
57
  super();
62
58
  if (dbPath !== ":memory:") {
@@ -67,6 +63,7 @@ export class MessageStore extends EventEmitter {
67
63
  if (dbPath !== ":memory:")
68
64
  this.db.pragma("journal_mode = WAL");
69
65
  this.migrate();
66
+ this.access = new AccessStore(this.db);
70
67
  }
71
68
  migrate() {
72
69
  this.db.exec(`
@@ -723,10 +720,12 @@ export class MessageStore extends EventEmitter {
723
720
  const dropFolders = this.db.prepare("DELETE FROM folders WHERE agent_id = ?");
724
721
  const dropAgent = this.db.prepare("DELETE FROM agents WHERE id = ?");
725
722
  const dropLinks = this.db.prepare("DELETE FROM links WHERE mailbox_a = ? OR mailbox_b = ?");
723
+ const dropKey = this.db.prepare("DELETE FROM mailbox_keys WHERE mailbox = ?");
726
724
  for (const id of ids) {
727
725
  deliveries += dropDeliveries.run(id).changes;
728
726
  dropFolders.run(id);
729
727
  dropLinks.run(id, id);
728
+ dropKey.run(id);
730
729
  mailboxes += dropAgent.run(id).changes;
731
730
  }
732
731
  this.dropOrphanMessages();
package/dist/src/index.js CHANGED
@@ -11,18 +11,28 @@ Usage:
11
11
  mcp-messenger --watch <mailbox>
12
12
  Print one line per incoming message, forever. Meant to be
13
13
  run by the agent's host as a background watcher, not by hand.
14
- mcp-messenger --claude-setup-mcp [--force]
14
+ mcp-messenger --claude-setup-mcp [--server-url <url>] [--api-key <key>] [--force]
15
15
  Write the messenger entry into ./.mcp.json.
16
- mcp-messenger --generate-token
17
- Print a fresh random token for MESSENGER_TOKEN.
16
+ mcp-messenger --reset-password
17
+ Give the web console account a new random password and
18
+ close all its sessions. Run it on the server machine.
18
19
  mcp-messenger --version Print the version.
19
20
  mcp-messenger --help Print this help.
20
21
 
21
- MCP mode requires MESSENGER_SERVER_URL and MESSENGER_TOKEN; each session gets its own
22
- mailbox unless MESSENGER_AGENT_ID pins a fixed address.
23
- Server mode requires MESSENGER_TOKEN (or MESSENGER_AGENT_TOKENS) and reads MESSENGER_PORT
24
- and MESSENGER_DB_PATH. It always listens on 127.0.0.1.
22
+ MCP mode requires MESSENGER_SERVER_URL and MESSENGER_API_KEY (an API key created in the
23
+ web console); each session gets its own mailbox unless MESSENGER_AGENT_ID pins a fixed
24
+ address.
25
+ Server mode reads MESSENGER_PORT and MESSENGER_DB_PATH and always listens on 127.0.0.1.
26
+ At the first start it prints a setup code: open /ui/ to create the console account.
25
27
  See .env.example for the full list.`;
28
+ /** Valeur qui suit une option (`--api-key mk_…`), ou null si l'option est absente ou vide. */
29
+ function optionValue(args, name) {
30
+ const index = args.indexOf(name);
31
+ if (index === -1)
32
+ return null;
33
+ const value = args[index + 1];
34
+ return value && !value.startsWith("--") ? value : null;
35
+ }
26
36
  async function main() {
27
37
  const args = process.argv.slice(2);
28
38
  if (args.includes("--help") || args.includes("-h")) {
@@ -33,17 +43,17 @@ async function main() {
33
43
  process.stdout.write(`${APP_VERSION}\n`);
34
44
  return;
35
45
  }
36
- // Le jeton part seul sur stdout : la commande reste utilisable dans un pipe
37
- // (`mcp-messenger --generate-token > token.txt`), les conseils vont sur stderr.
38
- if (args.includes("--generate-token")) {
39
- const { generateToken } = await import("./cli/generate-token.js");
40
- process.stdout.write(`${generateToken()}\n`);
46
+ // Le mot de passe part seul sur stdout : la commande reste utilisable dans un pipe, les
47
+ // explications vont sur stderr.
48
+ if (args.includes("--reset-password")) {
49
+ const { resetPassword } = await import("./cli/reset-password.js");
50
+ const result = await resetPassword();
51
+ process.stdout.write(`${result.password}\n`);
41
52
  process.stderr.write([
42
53
  "",
43
- "Ce jeton doit être identique aux trois endroits suivants :",
44
- " 1. le serveur : MESSENGER_TOKEN",
45
- " 2. chaque agent : MESSENGER_TOKEN dans son .mcp.json",
46
- " 3. la console web : http://127.0.0.1:3600/ui/ (à saisir dans le formulaire)",
54
+ `Nouveau mot de passe du compte « ${result.username} » ci-dessus.`,
55
+ `${result.closedSessions} session(s) de la console fermée(s).`,
56
+ "Connectez-vous avec lui, puis changez-le depuis Mon compte.",
47
57
  "",
48
58
  ].join("\n"));
49
59
  return;
@@ -52,7 +62,12 @@ async function main() {
52
62
  // l'utilisateur, elle n'ouvre ni base ni serveur.
53
63
  if (args.includes("--claude-setup-mcp")) {
54
64
  const { runSetupMcp } = await import("./cli/setup-mcp.js");
55
- process.exit(runSetupMcp({ cwd: process.cwd(), force: args.includes("--force") }));
65
+ process.exit(runSetupMcp({
66
+ cwd: process.cwd(),
67
+ force: args.includes("--force"),
68
+ serverUrl: optionValue(args, "--server-url"),
69
+ apiKey: optionValue(args, "--api-key"),
70
+ }));
56
71
  }
57
72
  // `--watch` prend l'adresse en argument positionnel : c'est la seule chose que l'agent
58
73
  // recopie, le reste (URL, jeton) vient du relais déposé par `enable_notifications`.