mcp-coordinator 0.2.0 → 0.3.0

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.
Files changed (44) hide show
  1. package/README.md +846 -835
  2. package/dashboard/Dockerfile +19 -19
  3. package/dashboard/public/index.html +1178 -1178
  4. package/dist/cli/dashboard.js +9 -5
  5. package/dist/cli/server/start.js +24 -1
  6. package/dist/cli/server/status.js +16 -23
  7. package/dist/src/agent-activity.js +6 -6
  8. package/dist/src/agent-registry.js +6 -6
  9. package/dist/src/announce-workflow.d.ts +52 -0
  10. package/dist/src/announce-workflow.js +91 -0
  11. package/dist/src/consultation.d.ts +14 -0
  12. package/dist/src/consultation.js +110 -45
  13. package/dist/src/database.js +126 -126
  14. package/dist/src/dependency-map.js +3 -3
  15. package/dist/src/file-tracker.js +8 -8
  16. package/dist/src/http/handle-rest.d.ts +23 -0
  17. package/dist/src/http/handle-rest.js +374 -0
  18. package/dist/src/http/utils.d.ts +15 -0
  19. package/dist/src/http/utils.js +39 -0
  20. package/dist/src/introspection.js +1 -1
  21. package/dist/src/mqtt-bridge.d.ts +2 -0
  22. package/dist/src/mqtt-bridge.js +2 -0
  23. package/dist/src/mqtt-broker.d.ts +16 -0
  24. package/dist/src/mqtt-broker.js +16 -1
  25. package/dist/src/path-guard.d.ts +14 -0
  26. package/dist/src/path-guard.js +44 -0
  27. package/dist/src/reset-guard.d.ts +16 -0
  28. package/dist/src/reset-guard.js +24 -0
  29. package/dist/src/serve-http.d.ts +31 -1
  30. package/dist/src/serve-http.js +154 -445
  31. package/dist/src/server-setup.js +15 -364
  32. package/dist/src/tools/agents-tools.d.ts +8 -0
  33. package/dist/src/tools/agents-tools.js +46 -0
  34. package/dist/src/tools/consultation-tools.d.ts +21 -0
  35. package/dist/src/tools/consultation-tools.js +170 -0
  36. package/dist/src/tools/dependencies-tools.d.ts +8 -0
  37. package/dist/src/tools/dependencies-tools.js +27 -0
  38. package/dist/src/tools/files-tools.d.ts +8 -0
  39. package/dist/src/tools/files-tools.js +28 -0
  40. package/dist/src/tools/mqtt-tools.d.ts +9 -0
  41. package/dist/src/tools/mqtt-tools.js +33 -0
  42. package/dist/src/tools/status-tools.d.ts +8 -0
  43. package/dist/src/tools/status-tools.js +63 -0
  44. package/package.json +81 -80
@@ -0,0 +1,374 @@
1
+ import { getDb } from "../database.js";
2
+ import { runCommonAnnounceFlow } from "../announce-workflow.js";
3
+ import { canResetDb } from "../reset-guard.js";
4
+ import { parseBody, json } from "./utils.js";
5
+ export async function handleRest(req, res, ctx) {
6
+ const { services, httpLog, authEnabled, getRunConfig, setRunConfig } = ctx;
7
+ const url = req.url || "";
8
+ const body = await parseBody(req);
9
+ const agentId = body.agent_id;
10
+ // Dashboard/work-stealing polls these endpoints every few seconds — demote to debug
11
+ // to keep the info log focused on coordination events (announce, claim, resolve, etc).
12
+ const isPoll = url === "/api/hot-files" || url === "/api/threads-active" || url === "/api/status" || url === "/api/quota";
13
+ // Note: /api/quota/refresh is NOT in the poll list — it's a manual user
14
+ // action and deserves an info-level log for auditability.
15
+ if (isPoll) {
16
+ httpLog.debug({ method: req.method, url, agent_id: agentId }, "REST request");
17
+ }
18
+ else {
19
+ httpLog.info({ method: req.method, url, agent_id: agentId }, "REST request");
20
+ }
21
+ const { registry, activityTracker, consultation, fileTracker, introspection, sseEmitter, mqttBridge, quotaCache } = services;
22
+ if (url === "/api/register") {
23
+ const { agent_id, name, modules } = body;
24
+ const agent = registry.register(agent_id, name, modules || []);
25
+ sseEmitter.emit("agent_online", { agent_id, name, modules });
26
+ json(res, agent);
27
+ }
28
+ else if (url === "/api/session-start") {
29
+ const online = registry.listOnline();
30
+ const openThreads = consultation.listThreads({ status: "open" });
31
+ const hotFiles = fileTracker.getHotFiles(30);
32
+ const briefing = [
33
+ `Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
34
+ `Consultations ouvertes: ${openThreads.length}`,
35
+ `Hot files: ${hotFiles.map((f) => f.file_path).join(", ") || "aucun"}`,
36
+ ].join("\n");
37
+ json(res, { briefing, summary: { online: online.length, open_threads: openThreads.length, hot_files: hotFiles.length } });
38
+ }
39
+ else if (url === "/api/session-stop") {
40
+ const { agent_id } = body;
41
+ registry.setOffline(agent_id);
42
+ activityTracker.reportOffline(agent_id);
43
+ consultation.handleAgentDeparture(agent_id);
44
+ sseEmitter.emit("agent_offline", { agent_id });
45
+ json(res, { ok: true });
46
+ }
47
+ else if (url === "/api/check-conflict") {
48
+ const { file, agent_id } = body;
49
+ const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
50
+ const warnings = [];
51
+ if (conflict.conflict) {
52
+ warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
53
+ }
54
+ json(res, { conflict: conflict.conflict, warnings });
55
+ }
56
+ else if (url === "/api/log-file") {
57
+ const { session_id, agent_id, agent_name, tool_name, file } = body;
58
+ fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
59
+ activityTracker.reportFileActivity(agent_id, file);
60
+ sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name });
61
+ json(res, { ok: true });
62
+ }
63
+ else if (url === "/api/announce") {
64
+ const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to } = body;
65
+ const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
66
+ const agentInfo = registry.get(agent_id);
67
+ // S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
68
+ // impact_scored + introspection SSE, plan-quality downgrade event). Same
69
+ // function used by the MCP announce_work tool path.
70
+ const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
71
+ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
72
+ });
73
+ // REST-specific thread_opened SSE shape (different field set than MCP — kept
74
+ // divergent because consumers may depend on this exact contract).
75
+ sseEmitter.emit("thread_opened", {
76
+ thread_id: thread.id, subject, agent_id, agent_name: agentInfo?.name || agent_id,
77
+ target_modules, target_files, expected_respondents: respondents,
78
+ conflicts: updated.conflicts ? JSON.parse(updated.conflicts) : [],
79
+ created_at: updated.created_at,
80
+ mode: planQuality.mode,
81
+ plan: plan || null,
82
+ plan_quality: planQuality,
83
+ });
84
+ json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
85
+ }
86
+ else if (url === "/api/post-to-thread") {
87
+ const { thread_id, agent_id, agent_name, type, content } = body;
88
+ // Pre-check the thread so we can return actionable status codes instead
89
+ // of always-500 on any error. The client uses the status to decide
90
+ // whether to warn (unexpected) or silently skip (normal race).
91
+ const targetThread = consultation.getThread(thread_id);
92
+ if (!targetThread) {
93
+ json(res, { error: "thread_not_found", thread_id }, 404);
94
+ return;
95
+ }
96
+ if (targetThread.status === "cancelled") {
97
+ json(res, { error: "thread_cancelled", thread_id }, 410);
98
+ return;
99
+ }
100
+ const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
101
+ const thread = consultation.getThread(thread_id);
102
+ sseEmitter.emit("message_posted", {
103
+ thread_id, agent_id, agent_name: agent_name || agent_id,
104
+ type, content, round: thread?.round || 1,
105
+ token_estimate: msg.token_estimate || 0,
106
+ });
107
+ json(res, msg);
108
+ }
109
+ else if (url === "/api/token-usage") {
110
+ // Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
111
+ // and reports can pinpoint where tokens are being burned.
112
+ const payload = body;
113
+ sseEmitter.emit("token_usage", payload);
114
+ json(res, { ok: true });
115
+ }
116
+ else if (url === "/api/unclaim-task") {
117
+ const { thread_id, agent_id } = body;
118
+ if (!thread_id || !agent_id) {
119
+ json(res, { success: false, error: "thread_id and agent_id required" }, 400);
120
+ return;
121
+ }
122
+ const db = getDb();
123
+ // F4: increment unclaim counter. After POISON_THRESHOLD aborts, flip status
124
+ // to "poisoned" so no agent claims it again — prevents the tight
125
+ // claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
126
+ // Only the claiming agent can unclaim to prevent cross-agent interference.
127
+ const POISON_THRESHOLD = 2;
128
+ const result = db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL, unclaim_count = COALESCE(unclaim_count, 0) + 1 WHERE id = ? AND claimed_by = ? AND status = 'open'").run(thread_id, agent_id);
129
+ let poisoned = false;
130
+ if (result.changes === 1) {
131
+ const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ?").get(thread_id);
132
+ if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
133
+ db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND status = 'open'").run(thread_id);
134
+ poisoned = true;
135
+ httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
136
+ }
137
+ }
138
+ json(res, { success: result.changes === 1, poisoned });
139
+ }
140
+ else if (url === "/api/claim-task") {
141
+ const { thread_id, agent_id } = body;
142
+ if (!thread_id || !agent_id) {
143
+ json(res, { success: false, error: "thread_id and agent_id required" }, 400);
144
+ return;
145
+ }
146
+ const db = getDb();
147
+ // Only claim threads with status='open' — poisoned threads are filtered out
148
+ // automatically because the status filter excludes them.
149
+ // Directed-dispatch constraint: if assigned_to is set, only that specific
150
+ // agent can claim; NULL keeps the original open-pool semantics.
151
+ const result = db.prepare("UPDATE threads SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL AND status = 'open' AND (assigned_to IS NULL OR assigned_to = ?)").run(agent_id, new Date().toISOString(), thread_id, agent_id);
152
+ if (result.changes === 1) {
153
+ mqttBridge.publishTaskClaimed(thread_id, agent_id);
154
+ sseEmitter.emit("task_claimed", { thread_id, agent_id });
155
+ json(res, { success: true });
156
+ }
157
+ else {
158
+ const thread = consultation.getThread(thread_id);
159
+ // Surface the assigned_to in the 'why not' response so clients can
160
+ // distinguish "already claimed by X" from "reserved for Y".
161
+ json(res, {
162
+ success: false,
163
+ claimed_by: thread?.claimed_by || null,
164
+ assigned_to: thread?.assigned_to || null,
165
+ status: thread?.status,
166
+ });
167
+ }
168
+ }
169
+ else if (url === "/api/propose-resolution") {
170
+ const { thread_id, agent_id, summary } = body;
171
+ const agentInfo = registry.get(agent_id);
172
+ consultation.proposeResolution(thread_id, agent_id, summary);
173
+ sseEmitter.emit("resolution_proposed", {
174
+ thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
175
+ });
176
+ json(res, consultation.getThread(thread_id));
177
+ mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
178
+ }
179
+ else if (url === "/api/approve-resolution") {
180
+ const { thread_id, agent_id } = body;
181
+ const agentInfo = registry.get(agent_id);
182
+ consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
183
+ const t = consultation.getThread(thread_id);
184
+ json(res, t);
185
+ }
186
+ else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
187
+ const threadId = url.split("/")[3];
188
+ const thread = consultation.getThreadWithMessages(threadId);
189
+ if (!thread) {
190
+ json(res, { error: "not found" }, 404);
191
+ }
192
+ else {
193
+ json(res, {
194
+ status: thread.thread.status,
195
+ messages: thread.messages,
196
+ resolution_summary: thread.thread.resolution_summary,
197
+ expected_respondents: JSON.parse(thread.thread.expected_respondents || "[]"),
198
+ });
199
+ }
200
+ }
201
+ else if (url === "/api/threads-active") {
202
+ const open = consultation.listThreads({ status: "open" });
203
+ const resolving = consultation.listThreads({ status: "resolving" });
204
+ json(res, [...open, ...resolving]);
205
+ }
206
+ else if (url === "/api/hot-files") {
207
+ const { since_minutes } = body;
208
+ json(res, fileTracker.getHotFiles(since_minutes || 30));
209
+ }
210
+ else if (url === "/api/quota") {
211
+ // Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
212
+ // Keychain + Anthropic API are reachable, 503 otherwise. Consumers treat
213
+ // 503 as "quota unknown = proceed" (fail-open) per the project decision.
214
+ const info = await quotaCache.get();
215
+ if (!info) {
216
+ const status = quotaCache.snapshot();
217
+ json(res, {
218
+ error: "quota unavailable",
219
+ reason: status.lastError,
220
+ cooldown_until: status.cooldownUntil,
221
+ }, 503);
222
+ }
223
+ else {
224
+ json(res, {
225
+ five_hour: info.fiveHour,
226
+ seven_day: info.sevenDay,
227
+ seven_day_sonnet: info.sevenDaySonnet,
228
+ fetched_at: info.fetchedAt,
229
+ });
230
+ }
231
+ }
232
+ else if (url === "/api/quota/refresh") {
233
+ // Force-refresh the cache, bypassing the TTL. Used by the dashboard's
234
+ // manual refresh button. The underlying quotaCache.refresh() is single-
235
+ // flight-deduped, so mashing the button doesn't stack parallel fetches.
236
+ // The onRefresh callback on the cache broadcasts via SSE + MQTT, so the
237
+ // dashboard receives the update through the normal channel too — this
238
+ // endpoint only exists for "give me the answer now" semantics.
239
+ const info = await quotaCache.refresh();
240
+ if (!info) {
241
+ const status = quotaCache.snapshot();
242
+ json(res, {
243
+ error: "quota unavailable",
244
+ reason: status.lastError,
245
+ cooldown_until: status.cooldownUntil,
246
+ }, 503);
247
+ }
248
+ else {
249
+ json(res, {
250
+ five_hour: info.fiveHour,
251
+ seven_day: info.sevenDay,
252
+ seven_day_sonnet: info.sevenDaySonnet,
253
+ fetched_at: info.fetchedAt,
254
+ });
255
+ }
256
+ }
257
+ else if (url === "/api/introspection-response") {
258
+ const { introspection_id, concerned, reason } = body;
259
+ const intro = introspection.respond(introspection_id, concerned, reason);
260
+ // If concerned, add to thread's expected_respondents
261
+ if (concerned && intro) {
262
+ const db = getDb();
263
+ const thread = consultation.getThread(intro.thread_id);
264
+ if (thread && (thread.status === "open" || thread.status === "resolving")) {
265
+ const respondents = JSON.parse(thread.expected_respondents || "[]");
266
+ if (!respondents.includes(intro.agent_id)) {
267
+ respondents.push(intro.agent_id);
268
+ db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
269
+ .run(JSON.stringify(respondents), thread.id);
270
+ }
271
+ }
272
+ }
273
+ const agentInfo = registry.get(intro?.agent_id || "");
274
+ sseEmitter.emit("introspection_completed", {
275
+ introspection_id, thread_id: intro?.thread_id,
276
+ agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
277
+ concerned, reason,
278
+ });
279
+ json(res, intro);
280
+ }
281
+ else if (url?.startsWith("/api/pending-introspections")) {
282
+ const urlObj = new URL(url, "http://localhost");
283
+ const agent_id = urlObj.searchParams.get("agent_id") || "";
284
+ const pending = introspection.getPending(agent_id);
285
+ json(res, pending);
286
+ }
287
+ else if (url === "/api/run-config") {
288
+ if (req.method === "POST") {
289
+ setRunConfig(body);
290
+ sseEmitter.emit("run_config", getRunConfig());
291
+ json(res, { ok: true });
292
+ }
293
+ else {
294
+ json(res, getRunConfig() || { active: false });
295
+ }
296
+ }
297
+ else if (url === "/api/reset") {
298
+ // B4 fix: gate destructive reset when AUTH is disabled.
299
+ // When AUTH_ENABLED=true, ADMIN_ONLY_ROUTES already enforced upstream
300
+ // by authenticateRequest (see auth.ts). This guard covers the AUTH off case.
301
+ if (!canResetDb(process.env, authEnabled)) {
302
+ json(res, {
303
+ error: "Forbidden: /api/reset requires NODE_ENV=test, COORDINATOR_ALLOW_RESET=true, or COORDINATOR_AUTH_ENABLED with admin token",
304
+ }, 403);
305
+ return;
306
+ }
307
+ // Reset all tables for clean test run (disable FK checks to avoid ordering issues)
308
+ const db = getDb();
309
+ db.exec("PRAGMA foreign_keys = OFF");
310
+ db.exec("DELETE FROM introspections");
311
+ db.exec("DELETE FROM events");
312
+ db.exec("DELETE FROM thread_messages");
313
+ db.exec("DELETE FROM threads");
314
+ db.exec("DELETE FROM action_summaries");
315
+ db.exec("DELETE FROM file_activity");
316
+ db.exec("DELETE FROM agent_activity_status");
317
+ db.exec("DELETE FROM dependency_map");
318
+ db.exec("DELETE FROM agents");
319
+ db.exec("DELETE FROM revoked_agents");
320
+ db.exec("PRAGMA foreign_keys = ON");
321
+ setRunConfig(null);
322
+ json(res, { ok: true });
323
+ }
324
+ else if (url === "/api/check-interrupt") {
325
+ const { agent_id } = body;
326
+ // Check for threads where this agent is an expected respondent and hasn't posted yet.
327
+ // Covers both open threads (waiting for initial response) and resolving threads
328
+ // (waiting for approval/contest of a proposed resolution).
329
+ const pendingThreads = [
330
+ ...consultation.listThreads({ status: "open" }),
331
+ ...consultation.listThreads({ status: "resolving" }),
332
+ ].filter((t) => {
333
+ const respondents = JSON.parse(t.expected_respondents || "[]");
334
+ return respondents.includes(agent_id);
335
+ });
336
+ if (pendingThreads.length > 0) {
337
+ const details = pendingThreads.map((t) => ({
338
+ thread_id: t.id,
339
+ subject: t.subject,
340
+ initiator_id: t.initiator_id,
341
+ status: t.status,
342
+ target_files: JSON.parse(t.target_files || "[]"),
343
+ }));
344
+ json(res, { interrupt: true, threads: details });
345
+ }
346
+ else {
347
+ json(res, { interrupt: false });
348
+ }
349
+ }
350
+ else if (url?.startsWith("/api/agent-status/")) {
351
+ const aid = url.split("/")[3];
352
+ const agent = registry.get(aid);
353
+ if (!agent) {
354
+ json(res, { registered: false, status: "unknown" });
355
+ }
356
+ else {
357
+ const activity = activityTracker.getActivity(aid, { idleAfterMinutes: 5 });
358
+ json(res, { registered: true, status: agent.status, activity: activity.activity_status });
359
+ }
360
+ }
361
+ else if (url === "/api/status") {
362
+ const online = registry.listOnline();
363
+ const openThreads = consultation.listThreads({ status: "open" });
364
+ json(res, {
365
+ online: online.length,
366
+ open_threads: openThreads.length,
367
+ hot_files: fileTracker.getHotFiles(30).length,
368
+ mqtt: services.mqttBridge.isConnected(),
369
+ });
370
+ }
371
+ else {
372
+ json(res, { error: "not found" }, 404);
373
+ }
374
+ }
@@ -0,0 +1,15 @@
1
+ import type { IncomingMessage, ServerResponse } from "http";
2
+ /**
3
+ * S1: shared HTTP helpers extracted from serve-http.ts.
4
+ * parseBody, json, decodeJwtPayload, safeEqual.
5
+ */
6
+ export declare function parseBody(req: IncomingMessage): Promise<Record<string, unknown>>;
7
+ export declare function json(res: ServerResponse, data: unknown, status?: number): void;
8
+ /**
9
+ * Decode a JWT payload WITHOUT verifying. Used only on tokens we just minted
10
+ * ourselves (to read the `exp` claim before returning it to the client). Real
11
+ * verification of inbound tokens happens in `authenticateRequest` via
12
+ * jose.jwtVerify().
13
+ */
14
+ export declare function decodeJwtPayload(token: string): Record<string, unknown>;
15
+ export declare function safeEqual(a: string, b: string): boolean;
@@ -0,0 +1,39 @@
1
+ import { timingSafeEqual } from "crypto";
2
+ /**
3
+ * S1: shared HTTP helpers extracted from serve-http.ts.
4
+ * parseBody, json, decodeJwtPayload, safeEqual.
5
+ */
6
+ export function parseBody(req) {
7
+ return new Promise((resolve, reject) => {
8
+ let body = "";
9
+ req.on("data", (chunk) => (body += chunk.toString()));
10
+ req.on("end", () => {
11
+ try {
12
+ resolve(body ? JSON.parse(body) : {});
13
+ }
14
+ catch {
15
+ reject(new Error("Invalid JSON"));
16
+ }
17
+ });
18
+ req.on("error", reject);
19
+ });
20
+ }
21
+ export function json(res, data, status = 200) {
22
+ res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
23
+ res.end(JSON.stringify(data));
24
+ }
25
+ /**
26
+ * Decode a JWT payload WITHOUT verifying. Used only on tokens we just minted
27
+ * ourselves (to read the `exp` claim before returning it to the client). Real
28
+ * verification of inbound tokens happens in `authenticateRequest` via
29
+ * jose.jwtVerify().
30
+ */
31
+ export function decodeJwtPayload(token) {
32
+ const base64url = token.split(".")[1];
33
+ return JSON.parse(Buffer.from(base64url, "base64url").toString("utf-8"));
34
+ }
35
+ export function safeEqual(a, b) {
36
+ if (a.length !== b.length)
37
+ return false;
38
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
39
+ }
@@ -4,7 +4,7 @@ export class IntrospectionManager {
4
4
  create(params) {
5
5
  const db = getDb();
6
6
  const id = randomUUID();
7
- db.prepare(`INSERT INTO introspections (id, thread_id, agent_id, score, reasons)
7
+ db.prepare(`INSERT INTO introspections (id, thread_id, agent_id, score, reasons)
8
8
  VALUES (?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.score, JSON.stringify(params.reasons));
9
9
  return this.get(id);
10
10
  }
@@ -13,6 +13,8 @@ export declare class MqttBridge {
13
13
  constructor(logger?: Logger);
14
14
  connect(config: {
15
15
  url: string;
16
+ username?: string;
17
+ password?: string;
16
18
  }): Promise<void>;
17
19
  isConnected(): boolean;
18
20
  onOffline(handler: (agentId: string) => void): void;
@@ -17,6 +17,8 @@ export class MqttBridge {
17
17
  this.client = mqtt.connect(config.url, {
18
18
  clientId: `coordinator-${Date.now()}`,
19
19
  clean: true,
20
+ username: config.username,
21
+ password: config.password,
20
22
  });
21
23
  this.client.on("connect", () => {
22
24
  clearTimeout(timeout);
@@ -5,11 +5,27 @@ export interface EmbeddedMqttBroker {
5
5
  wsPath: string | null;
6
6
  close: () => Promise<void>;
7
7
  }
8
+ /**
9
+ * B3 fix: opt-in MQTT authentication. When provided, every CONNECT packet's
10
+ * password field is passed to authenticate(). Returning false rejects the
11
+ * client. When omitted (default), the broker accepts anonymous connections —
12
+ * preserving the existing behavior so essaim and other clients without auth
13
+ * keep working unchanged.
14
+ *
15
+ * The internal coordinator client (MqttBridge) bypasses this by passing an
16
+ * internal admin token when AUTH_ENABLED is true.
17
+ */
18
+ export type MqttAuthVerifier = (username: string | undefined, password: Buffer | undefined) => Promise<boolean>;
8
19
  export interface EmbeddedMqttOptions {
9
20
  tcpPort?: number;
10
21
  httpServer?: HttpServer;
11
22
  wsPath?: string;
12
23
  logger: Logger;
24
+ /**
25
+ * Per-CONNECT auth verifier. Omit to allow anonymous (default — backwards
26
+ * compatible with essaim and any client not using auth).
27
+ */
28
+ authenticate?: MqttAuthVerifier;
13
29
  }
14
30
  /**
15
31
  * Start an embedded MQTT broker (aedes) exposed via TCP, WebSocket, or both.
@@ -38,8 +38,23 @@ function wsToDuplex(ws) {
38
38
  * fully ready, which causes client connect timeouts in compiled binaries.
39
39
  */
40
40
  export async function startEmbeddedMqttBroker(opts) {
41
- const { tcpPort, httpServer, wsPath = "/mqtt", logger } = opts;
41
+ const { tcpPort, httpServer, wsPath = "/mqtt", logger, authenticate } = opts;
42
42
  const broker = await Aedes.createBroker();
43
+ if (authenticate) {
44
+ // B3 fix: when AUTH_ENABLED, every CONNECT must present a valid token.
45
+ broker.authenticate =
46
+ (client, username, password, cb) => {
47
+ Promise.resolve(authenticate(username, password)).then((ok) => {
48
+ if (!ok)
49
+ logger.warn({ client_id: client?.id, username }, "MQTT auth rejected");
50
+ cb(null, ok);
51
+ }, (err) => {
52
+ logger.warn({ client_id: client?.id, err: err.message }, "MQTT auth error");
53
+ cb(null, false);
54
+ });
55
+ };
56
+ logger.info("MQTT auth enabled (token in CONNECT password)");
57
+ }
43
58
  broker.on("client", (client) => {
44
59
  logger.debug({ client_id: client?.id }, "MQTT client connected");
45
60
  });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolve a request URL into a safe filesystem path within a known root.
3
+ *
4
+ * Defends against path traversal: a request like `/dashboard/../../etc/passwd`
5
+ * would otherwise escape the dashboard directory because `path.join` does not
6
+ * validate that the result stays under the root.
7
+ *
8
+ * Returns the resolved absolute path on success, or `null` if the path would
9
+ * escape the root, contains a null byte, or is otherwise invalid.
10
+ *
11
+ * `urlPath` should already have the route prefix stripped (e.g. for
12
+ * `/dashboard/app.js` pass `"app.js"`).
13
+ */
14
+ export declare function safeJoinUnderRoot(root: string, urlPath: string): string | null;
@@ -0,0 +1,44 @@
1
+ import path from "path";
2
+ /**
3
+ * Resolve a request URL into a safe filesystem path within a known root.
4
+ *
5
+ * Defends against path traversal: a request like `/dashboard/../../etc/passwd`
6
+ * would otherwise escape the dashboard directory because `path.join` does not
7
+ * validate that the result stays under the root.
8
+ *
9
+ * Returns the resolved absolute path on success, or `null` if the path would
10
+ * escape the root, contains a null byte, or is otherwise invalid.
11
+ *
12
+ * `urlPath` should already have the route prefix stripped (e.g. for
13
+ * `/dashboard/app.js` pass `"app.js"`).
14
+ */
15
+ export function safeJoinUnderRoot(root, urlPath) {
16
+ // Reject null bytes (NUL injection that some libs/OS handle inconsistently).
17
+ if (urlPath.includes("\0"))
18
+ return null;
19
+ // Decode percent-encoding so "%2e%2e/foo" cannot bypass the literal ".." check.
20
+ // decodeURIComponent throws on malformed sequences — treat as invalid.
21
+ let decoded;
22
+ try {
23
+ decoded = decodeURIComponent(urlPath);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ if (decoded.includes("\0"))
29
+ return null;
30
+ // Strip leading slashes so absolute-path injection ("//etc/passwd") becomes relative.
31
+ const trimmed = decoded.replace(/^[/\\]+/, "");
32
+ // Empty after strip → caller decides what default file to serve.
33
+ if (trimmed === "")
34
+ return null;
35
+ const rootResolved = path.resolve(root);
36
+ const candidate = path.resolve(rootResolved, trimmed);
37
+ // The resolved candidate MUST be either equal to the root or live below it.
38
+ // Append separator before comparison to avoid the "/var/data" vs "/var/data-evil" trap.
39
+ const rootWithSep = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep;
40
+ if (candidate !== rootResolved && !candidate.startsWith(rootWithSep)) {
41
+ return null;
42
+ }
43
+ return candidate;
44
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Guard for the destructive `/api/reset` endpoint.
3
+ *
4
+ * `/api/reset` wipes every coordination table. When `COORDINATOR_AUTH_ENABLED`
5
+ * is on, the upstream auth middleware enforces admin-role tokens for this route
6
+ * (see `auth.ts` `ADMIN_ONLY_ROUTES`). When auth is OFF (the default), we still
7
+ * need to prevent accidental data loss in production.
8
+ *
9
+ * Allowed when ANY of:
10
+ * - NODE_ENV === "test" (vitest sets this automatically)
11
+ * - COORDINATOR_ALLOW_RESET=true (explicit opt-in for dev/CI scripts)
12
+ * - authEnabled === true (handled by the auth middleware upstream)
13
+ *
14
+ * Otherwise rejected.
15
+ */
16
+ export declare function canResetDb(env: NodeJS.ProcessEnv, authEnabled: boolean): boolean;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Guard for the destructive `/api/reset` endpoint.
3
+ *
4
+ * `/api/reset` wipes every coordination table. When `COORDINATOR_AUTH_ENABLED`
5
+ * is on, the upstream auth middleware enforces admin-role tokens for this route
6
+ * (see `auth.ts` `ADMIN_ONLY_ROUTES`). When auth is OFF (the default), we still
7
+ * need to prevent accidental data loss in production.
8
+ *
9
+ * Allowed when ANY of:
10
+ * - NODE_ENV === "test" (vitest sets this automatically)
11
+ * - COORDINATOR_ALLOW_RESET=true (explicit opt-in for dev/CI scripts)
12
+ * - authEnabled === true (handled by the auth middleware upstream)
13
+ *
14
+ * Otherwise rejected.
15
+ */
16
+ export function canResetDb(env, authEnabled) {
17
+ if (authEnabled)
18
+ return true;
19
+ if (env.NODE_ENV === "test")
20
+ return true;
21
+ if (env.COORDINATOR_ALLOW_RESET === "true")
22
+ return true;
23
+ return false;
24
+ }
@@ -1,5 +1,35 @@
1
1
  export interface ServerOptions {
2
2
  port?: number;
3
3
  dataDir?: string;
4
+ /**
5
+ * MQTT TCP listener port. Defaults to COORDINATOR_MQTT_TCP_PORT env or 1883.
6
+ * Pass an OS-ephemeral free port (see net.createServer().listen(0)) to run
7
+ * multiple coordinators in the same process without collision.
8
+ */
9
+ mqttTcpPort?: number;
10
+ /**
11
+ * MQTT WebSocket path on the HTTP server. Defaults to COORDINATOR_MQTT_WS_PATH or "/mqtt".
12
+ */
13
+ mqttWsPath?: string;
14
+ /**
15
+ * If false, do NOT register process-level SIGTERM/SIGINT handlers. Default
16
+ * true. Embedders that manage their own signals (essaim's orchestrator runs
17
+ * many in-process coordinators per session) should pass false and call
18
+ * `handle.stop()` from their own teardown.
19
+ */
20
+ registerSignalHandlers?: boolean;
4
21
  }
5
- export declare function startServer(opts?: ServerOptions): Promise<void>;
22
+ /**
23
+ * Returned by startServer(). Lets callers shut down all owned resources
24
+ * (HTTP server, MQTT broker + bridge, SSE listeners, DB, quota timer) without
25
+ * waiting for process exit. Safe to call multiple times.
26
+ *
27
+ * Backward-compatible: previous callers used `await startServer({...})` and
28
+ * ignored the resolved value. They continue to work; the new return value is
29
+ * additive.
30
+ */
31
+ export interface ServerHandle {
32
+ port: number;
33
+ stop: () => Promise<void>;
34
+ }
35
+ export declare function startServer(opts?: ServerOptions): Promise<ServerHandle>;