@imenam/mcp-messenger 1.0.32 → 1.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +12 -22
- package/README.md +825 -772
- package/dist/src/cli/reset-password.d.ts +12 -0
- package/dist/src/cli/reset-password.js +29 -0
- package/dist/src/cli/setup-mcp.d.ts +5 -1
- package/dist/src/cli/setup-mcp.js +15 -37
- package/dist/src/cli/watch-handoff.d.ts +3 -3
- package/dist/src/cli/watch-handoff.js +2 -2
- package/dist/src/cli/watch.d.ts +1 -1
- package/dist/src/cli/watch.js +3 -3
- package/dist/src/client/messenger-client.d.ts +3 -3
- package/dist/src/client/messenger-client.js +5 -6
- package/dist/src/config.d.ts +13 -8
- package/dist/src/config.js +32 -43
- package/dist/src/core/access-store.d.ts +124 -0
- package/dist/src/core/access-store.js +307 -0
- package/dist/src/core/credentials.d.ts +28 -0
- package/dist/src/core/credentials.js +75 -0
- package/dist/src/core/errors.d.ts +5 -0
- package/dist/src/core/errors.js +9 -0
- package/dist/src/core/store.d.ts +5 -5
- package/dist/src/core/store.js +8 -9
- package/dist/src/index.js +32 -17
- package/dist/src/mcp/start.js +26 -26
- package/dist/src/mcp/tools.js +2 -2
- package/dist/src/server/app.d.ts +10 -1
- package/dist/src/server/app.js +247 -69
- package/dist/src/server/console-session.d.ts +31 -21
- package/dist/src/server/console-session.js +46 -63
- package/dist/src/server/start.d.ts +3 -0
- package/dist/src/server/start.js +10 -13
- package/dist/src/server/ui/app.js +1380 -38
- package/dist/src/server/ui/index.html +192 -9
- package/dist/src/server/ui/styles.css +1144 -0
- package/package.json +2 -3
|
@@ -0,0 +1,307 @@
|
|
|
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.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
|
+
created_at TEXT NOT NULL,
|
|
45
|
+
created_by TEXT,
|
|
46
|
+
expires_at TEXT,
|
|
47
|
+
lifetime_days INTEGER,
|
|
48
|
+
revoked_at TEXT,
|
|
49
|
+
last_used_at TEXT,
|
|
50
|
+
last_used_ip TEXT
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS mailbox_keys (
|
|
54
|
+
mailbox TEXT PRIMARY KEY,
|
|
55
|
+
api_key_id TEXT NOT NULL,
|
|
56
|
+
opened_at TEXT NOT NULL
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_mailbox_keys_key ON mailbox_keys (api_key_id);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_console_sessions_user ON console_sessions (user_id);
|
|
61
|
+
`);
|
|
62
|
+
}
|
|
63
|
+
iso(offsetMs = 0) {
|
|
64
|
+
return new Date(this.now() + offsetMs).toISOString();
|
|
65
|
+
}
|
|
66
|
+
// --- Comptes --------------------------------------------------------------
|
|
67
|
+
hasUsers() {
|
|
68
|
+
return Boolean(this.db.prepare("SELECT 1 FROM users LIMIT 1").get());
|
|
69
|
+
}
|
|
70
|
+
createUser(username, passwordHash) {
|
|
71
|
+
const id = newId("usr");
|
|
72
|
+
try {
|
|
73
|
+
this.db
|
|
74
|
+
.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)")
|
|
75
|
+
.run(id, username, passwordHash, this.iso());
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (isUniqueViolation(error))
|
|
79
|
+
throw new MessengerError(`username already taken: ${username}`, 409);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return this.getUser(id);
|
|
83
|
+
}
|
|
84
|
+
getUser(id) {
|
|
85
|
+
return (this.db.prepare("SELECT id, username, created_at, updated_at FROM users WHERE id = ?").get(id) ?? null);
|
|
86
|
+
}
|
|
87
|
+
findUserByName(username) {
|
|
88
|
+
return this.db.prepare("SELECT * FROM users WHERE username = ?").get(username) ?? null;
|
|
89
|
+
}
|
|
90
|
+
listUsers() {
|
|
91
|
+
return this.db.prepare("SELECT id, username, created_at, updated_at FROM users ORDER BY created_at").all();
|
|
92
|
+
}
|
|
93
|
+
getPasswordHash(id) {
|
|
94
|
+
const row = this.db.prepare("SELECT password_hash FROM users WHERE id = ?").get(id);
|
|
95
|
+
return row?.password_hash ?? null;
|
|
96
|
+
}
|
|
97
|
+
renameUser(id, username) {
|
|
98
|
+
try {
|
|
99
|
+
const result = this.db
|
|
100
|
+
.prepare("UPDATE users SET username = ?, updated_at = ? WHERE id = ?")
|
|
101
|
+
.run(username, this.iso(), id);
|
|
102
|
+
if (result.changes === 0)
|
|
103
|
+
throw new MessengerError(`user not found: ${id}`, 404);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (isUniqueViolation(error))
|
|
107
|
+
throw new MessengerError(`username already taken: ${username}`, 409);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
return this.getUser(id);
|
|
111
|
+
}
|
|
112
|
+
setPassword(id, passwordHash) {
|
|
113
|
+
const result = this.db
|
|
114
|
+
.prepare("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?")
|
|
115
|
+
.run(passwordHash, this.iso(), id);
|
|
116
|
+
if (result.changes === 0)
|
|
117
|
+
throw new MessengerError(`user not found: ${id}`, 404);
|
|
118
|
+
}
|
|
119
|
+
// --- Sessions de la console -----------------------------------------------
|
|
120
|
+
createSession(input) {
|
|
121
|
+
this.pruneSessions();
|
|
122
|
+
const id = newId("ses");
|
|
123
|
+
const now = this.iso();
|
|
124
|
+
this.db
|
|
125
|
+
.prepare(`INSERT INTO console_sessions (id, secret_hash, user_id, created_at, last_seen_at, expires_at, persistent, user_agent, ip)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
127
|
+
.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);
|
|
128
|
+
return this.getSession(id);
|
|
129
|
+
}
|
|
130
|
+
/** Session encore valide désignée par le contenu du cookie, avec son compte. */
|
|
131
|
+
findSession(secret, seen = { ip: null }) {
|
|
132
|
+
if (!secret)
|
|
133
|
+
return null;
|
|
134
|
+
const row = this.db
|
|
135
|
+
.prepare("SELECT * FROM console_sessions WHERE secret_hash = ? AND expires_at > ?")
|
|
136
|
+
.get(digestSecret(secret), this.iso());
|
|
137
|
+
if (!row)
|
|
138
|
+
return null;
|
|
139
|
+
const user = this.getUser(row.user_id);
|
|
140
|
+
if (!user)
|
|
141
|
+
return null;
|
|
142
|
+
if (this.now() - Date.parse(row.last_seen_at) > TOUCH_INTERVAL_MS) {
|
|
143
|
+
const now = this.iso();
|
|
144
|
+
this.db
|
|
145
|
+
.prepare("UPDATE console_sessions SET last_seen_at = ?, ip = COALESCE(?, ip) WHERE id = ?")
|
|
146
|
+
.run(now, seen.ip, row.id);
|
|
147
|
+
row.last_seen_at = now;
|
|
148
|
+
if (seen.ip)
|
|
149
|
+
row.ip = seen.ip;
|
|
150
|
+
}
|
|
151
|
+
return { session: toSession(row), user };
|
|
152
|
+
}
|
|
153
|
+
getSession(id) {
|
|
154
|
+
const row = this.db.prepare("SELECT * FROM console_sessions WHERE id = ?").get(id);
|
|
155
|
+
return row ? toSession(row) : null;
|
|
156
|
+
}
|
|
157
|
+
listSessions(userId) {
|
|
158
|
+
this.pruneSessions();
|
|
159
|
+
return this.db
|
|
160
|
+
.prepare("SELECT * FROM console_sessions WHERE user_id = ? ORDER BY last_seen_at DESC")
|
|
161
|
+
.all(userId).map(toSession);
|
|
162
|
+
}
|
|
163
|
+
revokeSession(userId, id) {
|
|
164
|
+
const result = this.db.prepare("DELETE FROM console_sessions WHERE id = ? AND user_id = ?").run(id, userId);
|
|
165
|
+
if (result.changes === 0)
|
|
166
|
+
throw new MessengerError(`session not found: ${id}`, 404);
|
|
167
|
+
}
|
|
168
|
+
revokeSessionBySecret(secret) {
|
|
169
|
+
if (secret)
|
|
170
|
+
this.db.prepare("DELETE FROM console_sessions WHERE secret_hash = ?").run(digestSecret(secret));
|
|
171
|
+
}
|
|
172
|
+
/** Ferme les sessions du compte sauf `keepId` (null : toutes). Rend le nombre fermé. */
|
|
173
|
+
revokeOtherSessions(userId, keepId) {
|
|
174
|
+
return this.db
|
|
175
|
+
.prepare("DELETE FROM console_sessions WHERE user_id = ? AND id != ?")
|
|
176
|
+
.run(userId, keepId ?? "").changes;
|
|
177
|
+
}
|
|
178
|
+
pruneSessions() {
|
|
179
|
+
this.db.prepare("DELETE FROM console_sessions WHERE expires_at <= ?").run(this.iso());
|
|
180
|
+
}
|
|
181
|
+
// --- Clés API -------------------------------------------------------------
|
|
182
|
+
createApiKey(input) {
|
|
183
|
+
const id = newId("key");
|
|
184
|
+
const visible = input.secret.startsWith(API_KEY_PREFIX) ? API_KEY_PREFIX.length : 0;
|
|
185
|
+
this.db
|
|
186
|
+
.prepare(`INSERT INTO api_keys (id, name, secret_hash, prefix, suffix, agent_name, created_at, created_by, expires_at, lifetime_days)
|
|
187
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
188
|
+
.run(id, input.name, digestSecret(input.secret), input.secret.slice(0, visible + 4), input.secret.slice(-4), input.agentName, this.iso(), input.createdBy, input.lifetimeDays === null ? null : this.iso(input.lifetimeDays * DAY_MS), input.lifetimeDays);
|
|
189
|
+
return this.getApiKey(id);
|
|
190
|
+
}
|
|
191
|
+
listApiKeys() {
|
|
192
|
+
return this.db.prepare(`SELECT ${KEY_COLUMNS} FROM api_keys k ORDER BY k.created_at DESC`).all().map((row) => this.toApiKey(row));
|
|
193
|
+
}
|
|
194
|
+
getApiKey(id) {
|
|
195
|
+
const row = this.db.prepare(`SELECT ${KEY_COLUMNS} FROM api_keys k WHERE k.id = ?`).get(id);
|
|
196
|
+
return row ? this.toApiKey(row) : null;
|
|
197
|
+
}
|
|
198
|
+
requireKey(id) {
|
|
199
|
+
const key = this.getApiKey(id);
|
|
200
|
+
if (!key)
|
|
201
|
+
throw new MessengerError(`API key not found: ${id}`, 404);
|
|
202
|
+
return key;
|
|
203
|
+
}
|
|
204
|
+
renameApiKey(id, name) {
|
|
205
|
+
this.requireKey(id);
|
|
206
|
+
this.db.prepare("UPDATE api_keys SET name = ? WHERE id = ?").run(name, id);
|
|
207
|
+
return this.getApiKey(id);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Repousse l'expiration de `days` jours, comptés depuis l'échéance encore à venir ou,
|
|
211
|
+
* pour une clé déjà expirée, depuis maintenant. `null` retire toute échéance.
|
|
212
|
+
*/
|
|
213
|
+
extendApiKey(id, days) {
|
|
214
|
+
const key = this.requireKey(id);
|
|
215
|
+
if (key.status === "revoked")
|
|
216
|
+
throw new MessengerError("a revoked key cannot be extended", 409);
|
|
217
|
+
const base = key.expires_at ? Math.max(this.now(), Date.parse(key.expires_at)) : this.now();
|
|
218
|
+
const expiresAt = days === null ? null : new Date(base + days * DAY_MS).toISOString();
|
|
219
|
+
this.db.prepare("UPDATE api_keys SET expires_at = ? WHERE id = ?").run(expiresAt, id);
|
|
220
|
+
return this.getApiKey(id);
|
|
221
|
+
}
|
|
222
|
+
revokeApiKey(id) {
|
|
223
|
+
const key = this.requireKey(id);
|
|
224
|
+
if (key.status === "revoked")
|
|
225
|
+
throw new MessengerError("this key is already revoked", 409);
|
|
226
|
+
this.db.prepare("UPDATE api_keys SET revoked_at = ? WHERE id = ?").run(this.iso(), id);
|
|
227
|
+
return this.getApiKey(id);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Remplace une clé : la nouvelle reprend le nom, l'agent et la durée de validité de
|
|
231
|
+
* l'ancienne ; l'ancienne reste acceptée `graceMs` encore, au plus jusqu'à son échéance.
|
|
232
|
+
*/
|
|
233
|
+
rotateApiKey(id, secret, createdBy, graceMs) {
|
|
234
|
+
const old = this.requireKey(id);
|
|
235
|
+
if (old.status !== "active")
|
|
236
|
+
throw new MessengerError(`only an active key can be replaced (this one is ${old.status})`, 409);
|
|
237
|
+
return this.db.transaction(() => {
|
|
238
|
+
const key = this.createApiKey({
|
|
239
|
+
name: old.name,
|
|
240
|
+
agentName: old.agent_name,
|
|
241
|
+
lifetimeDays: old.lifetime_days,
|
|
242
|
+
createdBy,
|
|
243
|
+
secret,
|
|
244
|
+
});
|
|
245
|
+
const grace = this.iso(graceMs);
|
|
246
|
+
const expiresAt = old.expires_at && old.expires_at < grace ? old.expires_at : grace;
|
|
247
|
+
this.db.prepare("UPDATE api_keys SET expires_at = ? WHERE id = ?").run(expiresAt, id);
|
|
248
|
+
return { key, replaced: this.getApiKey(id) };
|
|
249
|
+
})();
|
|
250
|
+
}
|
|
251
|
+
/** Clé active désignée par le secret présenté, ou null. Note son dernier usage. */
|
|
252
|
+
authenticateApiKey(secret, ip) {
|
|
253
|
+
if (!secret)
|
|
254
|
+
return null;
|
|
255
|
+
const row = this.db
|
|
256
|
+
.prepare(`SELECT ${KEY_COLUMNS} FROM api_keys k WHERE k.secret_hash = ?`)
|
|
257
|
+
.get(digestSecret(secret));
|
|
258
|
+
if (!row)
|
|
259
|
+
return null;
|
|
260
|
+
const key = this.toApiKey(row);
|
|
261
|
+
if (key.status !== "active")
|
|
262
|
+
return null;
|
|
263
|
+
if (!key.last_used_at || this.now() - Date.parse(key.last_used_at) > TOUCH_INTERVAL_MS || key.last_used_ip !== ip) {
|
|
264
|
+
const now = this.iso();
|
|
265
|
+
this.db.prepare("UPDATE api_keys SET last_used_at = ?, last_used_ip = ? WHERE id = ?").run(now, ip, key.id);
|
|
266
|
+
key.last_used_at = now;
|
|
267
|
+
key.last_used_ip = ip;
|
|
268
|
+
}
|
|
269
|
+
return key;
|
|
270
|
+
}
|
|
271
|
+
/** Retient la clé qui a ouvert ou repris une boîte : la dernière fait foi. */
|
|
272
|
+
recordMailboxKey(mailbox, apiKeyId) {
|
|
273
|
+
this.db
|
|
274
|
+
.prepare(`INSERT INTO mailbox_keys (mailbox, api_key_id, opened_at) VALUES (?, ?, ?)
|
|
275
|
+
ON CONFLICT (mailbox) DO UPDATE SET api_key_id = excluded.api_key_id, opened_at = excluded.opened_at
|
|
276
|
+
WHERE mailbox_keys.api_key_id != excluded.api_key_id`)
|
|
277
|
+
.run(mailbox, apiKeyId, this.iso());
|
|
278
|
+
}
|
|
279
|
+
mailboxesForKey(id) {
|
|
280
|
+
this.requireKey(id);
|
|
281
|
+
const rows = this.db
|
|
282
|
+
.prepare(`SELECT a.id, a.agent_name, a.display_name,
|
|
283
|
+
MAX(COALESCE(a.last_seen_at, ''),
|
|
284
|
+
COALESCE((SELECT MAX(d.created_at) FROM deliveries d WHERE d.agent_id = a.id), '')) AS last_activity_at
|
|
285
|
+
FROM mailbox_keys mk
|
|
286
|
+
JOIN agents a ON a.id = mk.mailbox
|
|
287
|
+
WHERE mk.api_key_id = ?
|
|
288
|
+
ORDER BY last_activity_at DESC, a.id`)
|
|
289
|
+
.all(id);
|
|
290
|
+
return rows.map((row) => ({ ...row, last_activity_at: row.last_activity_at || null }));
|
|
291
|
+
}
|
|
292
|
+
toApiKey(row) {
|
|
293
|
+
const status = row.revoked_at
|
|
294
|
+
? "revoked"
|
|
295
|
+
: row.expires_at && Date.parse(row.expires_at) <= this.now()
|
|
296
|
+
? "expired"
|
|
297
|
+
: "active";
|
|
298
|
+
return { ...row, status };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function toSession(row) {
|
|
302
|
+
const { secret_hash: _secret, ...rest } = row;
|
|
303
|
+
return { ...rest, persistent: Boolean(row.persistent) };
|
|
304
|
+
}
|
|
305
|
+
function isUniqueViolation(error) {
|
|
306
|
+
return typeof error === "object" && error !== null && error.code === "SQLITE_CONSTRAINT_UNIQUE";
|
|
307
|
+
}
|
|
@@ -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,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
|
+
}
|
package/dist/src/core/store.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/src/core/store.js
CHANGED
|
@@ -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
|
-
|
|
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 --
|
|
17
|
-
|
|
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
|
|
22
|
-
mailbox unless MESSENGER_AGENT_ID pins a fixed
|
|
23
|
-
|
|
24
|
-
and MESSENGER_DB_PATH
|
|
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
|
|
37
|
-
//
|
|
38
|
-
if (args.includes("--
|
|
39
|
-
const {
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
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({
|
|
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`.
|
package/dist/src/mcp/start.js
CHANGED
|
@@ -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) {
|
|
@@ -201,7 +201,7 @@ export async function runMcpMode() {
|
|
|
201
201
|
// Tant que ce battement continue, la boîte de cette session n'est reprise par personne.
|
|
202
202
|
// `unref` : c'est une trace, pas une raison de garder le processus en vie.
|
|
203
203
|
setInterval(heartbeat, HEARTBEAT_MS).unref();
|
|
204
|
-
// Le relais vers le veilleur porte
|
|
204
|
+
// Le relais vers le veilleur porte la clé API de la session : il ne survit pas à la
|
|
205
205
|
// session. Un arrêt brutal le laisse en place, la purge par ancienneté le rattrape.
|
|
206
206
|
// L'entrée du registre suit le même sort, à ceci près qu'elle est close et non effacée :
|
|
207
207
|
// c'est elle qui rendra sa boîte à la session suivante.
|