mcp-coordinator 0.6.0 → 0.7.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.
- package/README.md +24 -0
- package/dist/src/agent-activity.d.ts +13 -9
- package/dist/src/agent-activity.js +45 -24
- package/dist/src/agent-registry.d.ts +7 -7
- package/dist/src/agent-registry.js +19 -18
- package/dist/src/announce-workflow.d.ts +1 -0
- package/dist/src/announce-workflow.js +13 -12
- package/dist/src/auth/providers/registry.d.ts +4 -0
- package/dist/src/auth/providers/registry.js +7 -0
- package/dist/src/auth/providers/types.d.ts +11 -0
- package/dist/src/auth/providers/types.js +1 -0
- package/dist/src/auth.d.ts +24 -5
- package/dist/src/auth.js +172 -23
- package/dist/src/conflict-detector.d.ts +1 -0
- package/dist/src/conflict-detector.js +4 -4
- package/dist/src/consultation.d.ts +28 -14
- package/dist/src/consultation.js +101 -68
- package/dist/src/context-provider.d.ts +2 -2
- package/dist/src/context-provider.js +3 -4
- package/dist/src/database.js +203 -4
- package/dist/src/dependency-map.d.ts +25 -4
- package/dist/src/dependency-map.js +49 -11
- package/dist/src/file-tracker.d.ts +5 -4
- package/dist/src/file-tracker.js +16 -14
- package/dist/src/git-cochange-builder.d.ts +11 -2
- package/dist/src/git-cochange-builder.js +15 -7
- package/dist/src/http/handle-health.d.ts +9 -5
- package/dist/src/http/handle-health.js +22 -8
- package/dist/src/http/handle-rest.d.ts +3 -0
- package/dist/src/http/handle-rest.js +86 -57
- package/dist/src/http/utils.d.ts +4 -0
- package/dist/src/http/utils.js +7 -1
- package/dist/src/impact-scorer.d.ts +3 -0
- package/dist/src/impact-scorer.js +65 -51
- package/dist/src/introspection.d.ts +13 -7
- package/dist/src/introspection.js +34 -11
- package/dist/src/metrics.js +2 -1
- package/dist/src/mqtt-bridge.d.ts +3 -2
- package/dist/src/mqtt-bridge.js +33 -23
- package/dist/src/mqtt-broker.d.ts +16 -7
- package/dist/src/mqtt-broker.js +57 -15
- package/dist/src/security/audit.d.ts +11 -0
- package/dist/src/security/audit.js +7 -0
- package/dist/src/security/encryption.d.ts +17 -0
- package/dist/src/security/encryption.js +5 -0
- package/dist/src/serve-http.js +136 -57
- package/dist/src/server-setup.d.ts +12 -2
- package/dist/src/server-setup.js +33 -15
- package/dist/src/sse-emitter.d.ts +7 -4
- package/dist/src/sse-emitter.js +27 -21
- package/dist/src/tools/agents-tools.d.ts +2 -1
- package/dist/src/tools/agents-tools.js +36 -12
- package/dist/src/tools/consultation-tools.d.ts +2 -1
- package/dist/src/tools/consultation-tools.js +106 -40
- package/dist/src/tools/dependencies-tools.d.ts +2 -1
- package/dist/src/tools/dependencies-tools.js +25 -7
- package/dist/src/tools/files-tools.d.ts +2 -1
- package/dist/src/tools/files-tools.js +26 -8
- package/dist/src/tools/mqtt-tools.d.ts +7 -1
- package/dist/src/tools/mqtt-tools.js +27 -4
- package/dist/src/tools/status-tools.d.ts +7 -1
- package/dist/src/tools/status-tools.js +26 -9
- package/dist/src/types.d.ts +2 -0
- package/dist/src/working-files-tracker.d.ts +21 -11
- package/dist/src/working-files-tracker.js +32 -21
- package/package.json +4 -1
|
@@ -1,28 +1,51 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { getDb } from "./database.js";
|
|
3
3
|
export class IntrospectionManager {
|
|
4
|
-
create(params) {
|
|
4
|
+
create(orgId, params) {
|
|
5
5
|
const db = getDb();
|
|
6
6
|
const id = randomUUID();
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const reasons = params.reasons == null
|
|
8
|
+
? null
|
|
9
|
+
: Array.isArray(params.reasons)
|
|
10
|
+
? JSON.stringify(params.reasons)
|
|
11
|
+
: params.reasons;
|
|
12
|
+
db.prepare(`INSERT INTO introspections (id, org_id, thread_id, agent_id, score, reasons)
|
|
13
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(id, orgId, params.thread_id, params.agent_id, params.score, reasons);
|
|
9
14
|
return this.get(id);
|
|
10
15
|
}
|
|
11
|
-
respond(
|
|
16
|
+
respond(orgId, id, response) {
|
|
12
17
|
const db = getDb();
|
|
13
|
-
db.prepare(`UPDATE introspections SET
|
|
14
|
-
return this.
|
|
18
|
+
db.prepare(`UPDATE introspections SET response = ?, status = 'responded', responded_at = ? WHERE org_id = ? AND id = ?`).run(response, new Date().toISOString(), orgId, id);
|
|
19
|
+
return this.getScoped(orgId, id);
|
|
15
20
|
}
|
|
21
|
+
/** Retrieve a single record by id (unscoped — internal helper only). */
|
|
16
22
|
get(id) {
|
|
17
23
|
const db = getDb();
|
|
18
|
-
return db
|
|
24
|
+
return (db
|
|
25
|
+
.prepare("SELECT * FROM introspections WHERE id = ?")
|
|
26
|
+
.get(id) || null);
|
|
27
|
+
}
|
|
28
|
+
/** Retrieve a single record scoped to an org. */
|
|
29
|
+
getScoped(orgId, id) {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
return (db
|
|
32
|
+
.prepare("SELECT * FROM introspections WHERE org_id = ? AND id = ?")
|
|
33
|
+
.get(orgId, id) || null);
|
|
19
34
|
}
|
|
20
|
-
getPending(agentId) {
|
|
35
|
+
getPending(orgId, agentId) {
|
|
21
36
|
const db = getDb();
|
|
22
|
-
return db
|
|
37
|
+
return db
|
|
38
|
+
.prepare("SELECT * FROM introspections WHERE org_id = ? AND agent_id = ? AND status = 'pending' ORDER BY created_at")
|
|
39
|
+
.all(orgId, agentId);
|
|
23
40
|
}
|
|
24
|
-
|
|
41
|
+
list(orgId, threadId) {
|
|
25
42
|
const db = getDb();
|
|
26
|
-
return db
|
|
43
|
+
return db
|
|
44
|
+
.prepare("SELECT * FROM introspections WHERE org_id = ? AND thread_id = ? ORDER BY created_at")
|
|
45
|
+
.all(orgId, threadId);
|
|
46
|
+
}
|
|
47
|
+
/** @deprecated Use list(orgId, threadId) instead. Kept for backward compat. */
|
|
48
|
+
getByThread(orgId, threadId) {
|
|
49
|
+
return this.list(orgId, threadId);
|
|
27
50
|
}
|
|
28
51
|
}
|
package/dist/src/metrics.js
CHANGED
|
@@ -145,7 +145,8 @@ export class Metrics {
|
|
|
145
145
|
*/
|
|
146
146
|
gaugeSnapshot(services) {
|
|
147
147
|
try {
|
|
148
|
-
|
|
148
|
+
// TODO(Task 23.5): thread real org_id from MCP session claims; for now MCP uses 'default' (cross-org leak window — single-tenant only)
|
|
149
|
+
this.agentsOnline.set(services.registry.listOnline("default").length);
|
|
149
150
|
}
|
|
150
151
|
catch {
|
|
151
152
|
// Registry not initialised yet (test bootstrap race) — leave gauge at 0.
|
|
@@ -5,6 +5,7 @@ interface QueuedMessage {
|
|
|
5
5
|
timestamp: number;
|
|
6
6
|
}
|
|
7
7
|
export declare class MqttBridge {
|
|
8
|
+
private orgId;
|
|
8
9
|
private client;
|
|
9
10
|
private connected;
|
|
10
11
|
private onOfflineHandler;
|
|
@@ -12,13 +13,13 @@ export declare class MqttBridge {
|
|
|
12
13
|
private log;
|
|
13
14
|
private agentId;
|
|
14
15
|
/**
|
|
15
|
-
* P1: track the last threadId we retained on `coordinator
|
|
16
|
+
* P1: track the last threadId we retained on `coordinator/<orgId>/consultations/new`.
|
|
16
17
|
* The topic is fixed (not per-thread), so retain holds only the LAST event.
|
|
17
18
|
* `clearRetainedConsultation(threadId)` only clears when it matches, so a
|
|
18
19
|
* later consultation isn't accidentally wiped by a stale resolve callback.
|
|
19
20
|
*/
|
|
20
21
|
private lastRetainedConsultationThreadId;
|
|
21
|
-
constructor(logger?: Logger);
|
|
22
|
+
constructor(orgId: string, logger?: Logger);
|
|
22
23
|
connect(config: {
|
|
23
24
|
url: string;
|
|
24
25
|
username?: string;
|
package/dist/src/mqtt-bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import mqtt from "mqtt";
|
|
2
2
|
import { silentLogger } from "./logger.js";
|
|
3
3
|
export class MqttBridge {
|
|
4
|
+
orgId;
|
|
4
5
|
client = null;
|
|
5
6
|
connected = false;
|
|
6
7
|
onOfflineHandler = null;
|
|
@@ -8,14 +9,15 @@ export class MqttBridge {
|
|
|
8
9
|
log;
|
|
9
10
|
agentId = "coordinator-internal";
|
|
10
11
|
/**
|
|
11
|
-
* P1: track the last threadId we retained on `coordinator
|
|
12
|
+
* P1: track the last threadId we retained on `coordinator/<orgId>/consultations/new`.
|
|
12
13
|
* The topic is fixed (not per-thread), so retain holds only the LAST event.
|
|
13
14
|
* `clearRetainedConsultation(threadId)` only clears when it matches, so a
|
|
14
15
|
* later consultation isn't accidentally wiped by a stale resolve callback.
|
|
15
16
|
*/
|
|
16
17
|
lastRetainedConsultationThreadId = null;
|
|
17
|
-
constructor(logger) {
|
|
18
|
-
this.
|
|
18
|
+
constructor(orgId, logger) {
|
|
19
|
+
this.orgId = orgId;
|
|
20
|
+
this.log = logger ?? silentLogger;
|
|
19
21
|
}
|
|
20
22
|
async connect(config) {
|
|
21
23
|
return new Promise((resolve, reject) => {
|
|
@@ -35,7 +37,7 @@ export class MqttBridge {
|
|
|
35
37
|
// bridge automatically broadcasts offline status. Without this the
|
|
36
38
|
// agent appears online indefinitely after an unexpected disconnect.
|
|
37
39
|
will: {
|
|
38
|
-
topic: `coordinator/agents/${this.agentId}/status`,
|
|
40
|
+
topic: `coordinator/${this.orgId}/agents/${this.agentId}/status`,
|
|
39
41
|
payload: Buffer.from(JSON.stringify({ status: "offline", reason: "lwt_unexpected" })),
|
|
40
42
|
qos: 1,
|
|
41
43
|
retain: false,
|
|
@@ -45,24 +47,27 @@ export class MqttBridge {
|
|
|
45
47
|
clearTimeout(timeout);
|
|
46
48
|
this.connected = true;
|
|
47
49
|
this.log.info({ url: config.url }, "MQTT connected");
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
+
// All SUBSCRIBE packets must be sent AFTER CONNACK or the broker may
|
|
51
|
+
// silently drop them under clean:true sessions. Keep the three
|
|
52
|
+
// subscribes co-located inside this handler.
|
|
53
|
+
this.client.subscribe(`coordinator/${this.orgId}/agents/+/status`);
|
|
54
|
+
this.client.subscribe(`coordinator/${this.orgId}/consultations/#`);
|
|
55
|
+
this.client.subscribe(`coordinator/${this.orgId}/broadcast`);
|
|
50
56
|
resolve();
|
|
51
57
|
});
|
|
52
|
-
// Subscribe to consultation topics for agent listeners
|
|
53
|
-
this.client.subscribe("coordinator/consultations/#");
|
|
54
|
-
this.client.subscribe("coordinator/broadcast");
|
|
55
58
|
this.client.on("message", (topic, message) => {
|
|
56
59
|
const parts = topic.split("/");
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
// Topic: coordinator/<orgId>/agents/<agentId>/status → parts[2]="agents", parts[3]=agentId, parts[4]="status"
|
|
61
|
+
if (parts[2] === "agents" && parts[4] === "status") {
|
|
62
|
+
const agentId = parts[3];
|
|
59
63
|
const status = message.toString();
|
|
60
64
|
if (status === "offline" && this.onOfflineHandler) {
|
|
61
65
|
this.onOfflineHandler(agentId);
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
// Route consultation messages to agent listeners
|
|
65
|
-
|
|
69
|
+
// Topic: coordinator/<orgId>/consultations/... or coordinator/<orgId>/broadcast
|
|
70
|
+
if (parts[2] === "consultations" || parts[2] === "broadcast") {
|
|
66
71
|
try {
|
|
67
72
|
const payload = JSON.parse(message.toString());
|
|
68
73
|
const msg = { topic, payload, timestamp: Date.now() };
|
|
@@ -96,7 +101,7 @@ export class MqttBridge {
|
|
|
96
101
|
registerAgent(agentId, name) {
|
|
97
102
|
if (!this.client || !this.connected)
|
|
98
103
|
return;
|
|
99
|
-
this.client.publish(`coordinator/agents/${agentId}/status`, JSON.stringify({ status: "online", name }), { retain: true });
|
|
104
|
+
this.client.publish(`coordinator/${this.orgId}/agents/${agentId}/status`, JSON.stringify({ status: "online", name }), { retain: true });
|
|
100
105
|
}
|
|
101
106
|
publishConsultation(threadId, agentId, subject, targetModules) {
|
|
102
107
|
if (!this.client || !this.connected)
|
|
@@ -105,7 +110,7 @@ export class MqttBridge {
|
|
|
105
110
|
// disconnects. retain=true so a coordinator/subscriber restart can rebuild
|
|
106
111
|
// the active state without an event-history replay.
|
|
107
112
|
this.lastRetainedConsultationThreadId = threadId;
|
|
108
|
-
this.client.publish(
|
|
113
|
+
this.client.publish(`coordinator/${this.orgId}/consultations/new`, JSON.stringify({ thread_id: threadId, agent_id: agentId, subject, target_modules: targetModules }), { qos: 1, retain: true });
|
|
109
114
|
}
|
|
110
115
|
/**
|
|
111
116
|
* P1 fix: clear the retained `coordinator/consultations/new` event when the
|
|
@@ -121,43 +126,43 @@ export class MqttBridge {
|
|
|
121
126
|
return;
|
|
122
127
|
if (this.lastRetainedConsultationThreadId !== threadId)
|
|
123
128
|
return;
|
|
124
|
-
this.client.publish(
|
|
129
|
+
this.client.publish(`coordinator/${this.orgId}/consultations/new`, "", { qos: 1, retain: true });
|
|
125
130
|
this.lastRetainedConsultationThreadId = null;
|
|
126
131
|
}
|
|
127
132
|
publishMessage(threadId, agentId, type, content) {
|
|
128
133
|
if (!this.client || !this.connected)
|
|
129
134
|
return;
|
|
130
135
|
// QoS 0: high-frequency chat-style traffic, lossy-OK.
|
|
131
|
-
this.client.publish(`coordinator/consultations/${threadId}/messages`, JSON.stringify({ agent_id: agentId, type, content }));
|
|
136
|
+
this.client.publish(`coordinator/${this.orgId}/consultations/${threadId}/messages`, JSON.stringify({ agent_id: agentId, type, content }));
|
|
132
137
|
}
|
|
133
138
|
publishResolution(threadId, status, summary) {
|
|
134
139
|
if (!this.client || !this.connected)
|
|
135
140
|
return;
|
|
136
141
|
// P1 fix: QoS 1 (at-least-once) — resolution is a state-change event.
|
|
137
|
-
this.client.publish(`coordinator/consultations/${threadId}/status`, JSON.stringify({ status, summary }), { qos: 1, retain: true });
|
|
142
|
+
this.client.publish(`coordinator/${this.orgId}/consultations/${threadId}/status`, JSON.stringify({ status, summary }), { qos: 1, retain: true });
|
|
138
143
|
}
|
|
139
144
|
publishBroadcast(agentId, message) {
|
|
140
145
|
if (!this.client || !this.connected)
|
|
141
146
|
return;
|
|
142
|
-
this.client.publish(
|
|
147
|
+
this.client.publish(`coordinator/${this.orgId}/broadcast`, JSON.stringify({ agent_id: agentId, message }));
|
|
143
148
|
}
|
|
144
149
|
publishAgentOffline(agentId) {
|
|
145
150
|
if (!this.client || !this.connected)
|
|
146
151
|
return;
|
|
147
|
-
this.client.publish(`coordinator/agents/${agentId}/status`, JSON.stringify({ status: "offline" }), { retain: true });
|
|
152
|
+
this.client.publish(`coordinator/${this.orgId}/agents/${agentId}/status`, JSON.stringify({ status: "offline" }), { retain: true });
|
|
148
153
|
}
|
|
149
154
|
publishTaskClaimed(threadId, claimedBy) {
|
|
150
155
|
if (!this.client || !this.connected)
|
|
151
156
|
return;
|
|
152
157
|
// P1 fix: QoS 1 — claim is a coordination state-change. Loss would mean
|
|
153
158
|
// multiple agents think a task is unclaimed.
|
|
154
|
-
this.client.publish(`coordinator/consultations/${threadId}/claimed`, JSON.stringify({ agent_id: claimedBy, claimed_by: claimedBy, claimed_at: new Date().toISOString() }), { qos: 1 });
|
|
159
|
+
this.client.publish(`coordinator/${this.orgId}/consultations/${threadId}/claimed`, JSON.stringify({ agent_id: claimedBy, claimed_by: claimedBy, claimed_at: new Date().toISOString() }), { qos: 1 });
|
|
155
160
|
}
|
|
156
161
|
publishTaskCompleted(threadId, completedBy, summary) {
|
|
157
162
|
if (!this.client || !this.connected)
|
|
158
163
|
return;
|
|
159
164
|
// P1 fix: QoS 1 — completion is a coordination state-change.
|
|
160
|
-
this.client.publish(`coordinator/consultations/${threadId}/completed`, JSON.stringify({ agent_id: completedBy, completed_by: completedBy, summary }), { qos: 1 });
|
|
165
|
+
this.client.publish(`coordinator/${this.orgId}/consultations/${threadId}/completed`, JSON.stringify({ agent_id: completedBy, completed_by: completedBy, summary }), { qos: 1 });
|
|
161
166
|
}
|
|
162
167
|
/**
|
|
163
168
|
* Fanout a refreshed QuotaInfo to live subscribers (dashboard widget,
|
|
@@ -168,7 +173,7 @@ export class MqttBridge {
|
|
|
168
173
|
if (!this.client || !this.connected)
|
|
169
174
|
return;
|
|
170
175
|
// QoS 0: high-frequency telemetry, lossy-OK (the next refresh overwrites).
|
|
171
|
-
this.client.publish(
|
|
176
|
+
this.client.publish(`coordinator/${this.orgId}/quota/update`, JSON.stringify(info));
|
|
172
177
|
}
|
|
173
178
|
// ── Agent listener methods (for integrated MCP tools) ──
|
|
174
179
|
registerListener(agentId) {
|
|
@@ -210,7 +215,12 @@ export class MqttBridge {
|
|
|
210
215
|
}
|
|
211
216
|
mqttPublish(topic, payload) {
|
|
212
217
|
if (this.client && this.connected) {
|
|
213
|
-
|
|
218
|
+
// Ensure all outbound topics are org-scoped. If caller passes an unscoped
|
|
219
|
+
// topic, prepend the org prefix; if already prefixed, pass through.
|
|
220
|
+
const scopedTopic = topic.startsWith(`coordinator/${this.orgId}/`)
|
|
221
|
+
? topic
|
|
222
|
+
: `coordinator/${this.orgId}/${topic.replace(/^coordinator\//, "")}`;
|
|
223
|
+
this.client.publish(scopedTopic, payload);
|
|
214
224
|
}
|
|
215
225
|
}
|
|
216
226
|
async disconnect() {
|
|
@@ -7,23 +7,32 @@ export interface EmbeddedMqttBroker {
|
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
9
|
* B3 fix: opt-in MQTT authentication. When provided, every CONNECT packet's
|
|
10
|
-
* password field is passed to authenticate().
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* password field is passed to authenticate(). Returns `{ ok: true, org }` on
|
|
11
|
+
* success — the org is attached to the Aedes client and used by the ACL hooks.
|
|
12
|
+
* Returns `{ ok: false }` to reject the CONNECT.
|
|
13
|
+
* When omitted (default), the broker accepts anonymous connections — preserving
|
|
14
|
+
* the existing behavior so essaim and other clients without auth keep working.
|
|
14
15
|
*
|
|
15
16
|
* The internal coordinator client (MqttBridge) bypasses this by passing an
|
|
16
17
|
* internal admin token when AUTH_ENABLED is true.
|
|
17
18
|
*/
|
|
18
|
-
export type
|
|
19
|
+
export type MqttAuthResult = {
|
|
20
|
+
ok: false;
|
|
21
|
+
} | {
|
|
22
|
+
ok: true;
|
|
23
|
+
org: string;
|
|
24
|
+
};
|
|
25
|
+
export type MqttAuthVerifier = (username: string | undefined, password: Buffer | undefined) => Promise<MqttAuthResult>;
|
|
19
26
|
export interface EmbeddedMqttOptions {
|
|
20
27
|
tcpPort?: number;
|
|
21
28
|
httpServer?: HttpServer;
|
|
22
29
|
wsPath?: string;
|
|
23
30
|
logger: Logger;
|
|
24
31
|
/**
|
|
25
|
-
* Per-CONNECT auth verifier.
|
|
26
|
-
*
|
|
32
|
+
* Per-CONNECT auth verifier. Returns `{ ok: true, org }` on success — the org is
|
|
33
|
+
* attached to the Aedes client and used by authorizeSubscribe/authorizePublish.
|
|
34
|
+
* Returning `{ ok: false }` rejects the CONNECT.
|
|
35
|
+
* Omit to allow anonymous (default — Phase 1 backward compat).
|
|
27
36
|
*/
|
|
28
37
|
authenticate?: MqttAuthVerifier;
|
|
29
38
|
}
|
package/dist/src/mqtt-broker.js
CHANGED
|
@@ -39,22 +39,57 @@ function wsToDuplex(ws) {
|
|
|
39
39
|
*/
|
|
40
40
|
export async function startEmbeddedMqttBroker(opts) {
|
|
41
41
|
const { tcpPort, httpServer, wsPath = "/mqtt", logger, authenticate } = opts;
|
|
42
|
-
|
|
42
|
+
// Build Aedes options. Hooks are passed at construction time to guarantee
|
|
43
|
+
// they are set before the broker accepts any connections.
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
const aedesOpts = {};
|
|
43
46
|
if (authenticate) {
|
|
44
47
|
// B3 fix: when AUTH_ENABLED, every CONNECT must present a valid token.
|
|
45
|
-
|
|
46
|
-
(
|
|
47
|
-
|
|
48
|
-
|
|
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");
|
|
48
|
+
aedesOpts.authenticate = (client, username, password, cb) => {
|
|
49
|
+
Promise.resolve(authenticate(username, password)).then((result) => {
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
logger.warn({ client_id: client?.id, username }, "MQTT auth rejected");
|
|
53
52
|
cb(null, false);
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Attach org to the Aedes client object — survives the connection lifetime.
|
|
56
|
+
client.org = result.org;
|
|
57
|
+
cb(null, true);
|
|
58
|
+
}, (err) => {
|
|
59
|
+
logger.warn({ client_id: client?.id, err: err.message }, "MQTT auth error");
|
|
60
|
+
cb(null, false);
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
// ACL: subscriptions must match coordinator/<org>/...
|
|
64
|
+
// cb(null, null) → granted=128 (subscription failure per MQTT 3.1.1)
|
|
65
|
+
aedesOpts.authorizeSubscribe = (client, sub, cb) => {
|
|
66
|
+
const org = client.org;
|
|
67
|
+
if (!org)
|
|
68
|
+
return cb(new Error("MQTT client missing org"), null);
|
|
69
|
+
const prefix = `coordinator/${org}/`;
|
|
70
|
+
if (!sub.topic.startsWith(prefix)) {
|
|
71
|
+
logger.warn({ client_id: client?.id, org, topic: sub.topic }, "MQTT subscribe denied (cross-org)");
|
|
72
|
+
return cb(null, null);
|
|
73
|
+
}
|
|
74
|
+
cb(null, sub);
|
|
75
|
+
};
|
|
76
|
+
// ACL: publishes must match coordinator/<org>/...
|
|
77
|
+
// Passing an Error to cb causes Aedes to disconnect the client (intended:
|
|
78
|
+
// cross-org publish is treated as a protocol violation, not silently dropped).
|
|
79
|
+
aedesOpts.authorizePublish = (client, packet, cb) => {
|
|
80
|
+
const org = client.org;
|
|
81
|
+
if (!org)
|
|
82
|
+
return cb(new Error("MQTT client missing org"));
|
|
83
|
+
const prefix = `coordinator/${org}/`;
|
|
84
|
+
if (!packet.topic.startsWith(prefix)) {
|
|
85
|
+
logger.warn({ client_id: client?.id, org, topic: packet.topic }, "MQTT publish denied (cross-org) — client will be disconnected");
|
|
86
|
+
return cb(new Error("Cross-org publish denied"));
|
|
87
|
+
}
|
|
88
|
+
cb(null);
|
|
89
|
+
};
|
|
56
90
|
logger.info("MQTT auth enabled (token in CONNECT password)");
|
|
57
91
|
}
|
|
92
|
+
const broker = await Aedes.createBroker(aedesOpts);
|
|
58
93
|
broker.on("client", (client) => {
|
|
59
94
|
logger.debug({ client_id: client?.id }, "MQTT client connected");
|
|
60
95
|
});
|
|
@@ -66,7 +101,8 @@ export async function startEmbeddedMqttBroker(opts) {
|
|
|
66
101
|
});
|
|
67
102
|
let tcpServerClose = null;
|
|
68
103
|
let wsServerClose = null;
|
|
69
|
-
|
|
104
|
+
let resolvedTcpPort = null;
|
|
105
|
+
if (tcpPort !== undefined && tcpPort >= 0) {
|
|
70
106
|
const tcpServer = createTcpServer((socket) => {
|
|
71
107
|
broker.handle(socket);
|
|
72
108
|
});
|
|
@@ -76,12 +112,18 @@ export async function startEmbeddedMqttBroker(opts) {
|
|
|
76
112
|
tcpServer.once("error", reject);
|
|
77
113
|
tcpServer.listen(tcpPort, "127.0.0.1", () => {
|
|
78
114
|
tcpServer.off("error", reject);
|
|
79
|
-
|
|
115
|
+
const addr = tcpServer.address();
|
|
116
|
+
// TS narrowing doesn't flow into this callback. `tcpPort` is typed
|
|
117
|
+
// `number | undefined` here even though the outer guard rules out
|
|
118
|
+
// undefined. Use `?? null` to keep the assignment compatible with
|
|
119
|
+
// `number | null`.
|
|
120
|
+
resolvedTcpPort = typeof addr === "object" && addr ? addr.port : (tcpPort ?? null);
|
|
121
|
+
logger.info({ port: resolvedTcpPort, transport: "tcp" }, "Embedded MQTT broker listening");
|
|
80
122
|
resolve();
|
|
81
123
|
});
|
|
82
124
|
});
|
|
83
125
|
tcpServer.on("error", (err) => {
|
|
84
|
-
logger.error({ err, port:
|
|
126
|
+
logger.error({ err, port: resolvedTcpPort }, "Embedded MQTT TCP server error");
|
|
85
127
|
});
|
|
86
128
|
tcpServerClose = () => new Promise((resolve) => tcpServer.close(() => resolve()));
|
|
87
129
|
}
|
|
@@ -101,7 +143,7 @@ export async function startEmbeddedMqttBroker(opts) {
|
|
|
101
143
|
wsServerClose = () => new Promise((resolve) => wss.close(() => resolve()));
|
|
102
144
|
}
|
|
103
145
|
return {
|
|
104
|
-
tcpPort:
|
|
146
|
+
tcpPort: resolvedTcpPort,
|
|
105
147
|
wsPath: httpServer ? wsPath : null,
|
|
106
148
|
close: async () => {
|
|
107
149
|
if (tcpServerClose)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface AuditEvent {
|
|
2
|
+
user_id?: string | null;
|
|
3
|
+
/** Nullable: unauthenticated actions (e.g. failed login before identity established). */
|
|
4
|
+
org_id?: string | null;
|
|
5
|
+
action: string;
|
|
6
|
+
target?: string;
|
|
7
|
+
ip?: string;
|
|
8
|
+
user_agent?: string;
|
|
9
|
+
metadata?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export declare function auditLog(ev: AuditEvent): void;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getDb } from "../database.js";
|
|
2
|
+
export function auditLog(ev) {
|
|
3
|
+
getDb()
|
|
4
|
+
.prepare(`INSERT INTO audit_log (user_id, org_id, action, target, ip, user_agent, metadata)
|
|
5
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
6
|
+
.run(ev.user_id ?? null, ev.org_id ?? null, ev.action, ev.target ?? null, ev.ip ?? null, ev.user_agent ?? null, ev.metadata ? JSON.stringify(ev.metadata) : null);
|
|
7
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface EncryptionContext {
|
|
2
|
+
org_id: string;
|
|
3
|
+
column: string;
|
|
4
|
+
}
|
|
5
|
+
export interface EncryptionProvider {
|
|
6
|
+
/** Encrypt a value for storage. Returns base64 ciphertext in real impls. */
|
|
7
|
+
encrypt(plaintext: string, context: EncryptionContext): string;
|
|
8
|
+
/** Decrypt a base64 ciphertext. Throws on wrong key / corruption. */
|
|
9
|
+
decrypt(ciphertext: string, context: EncryptionContext): string;
|
|
10
|
+
/** Stable HMAC for indexing on encrypted columns without leaking plaintext. */
|
|
11
|
+
hmac(value: string, context: EncryptionContext): string;
|
|
12
|
+
}
|
|
13
|
+
export declare class PassthroughEncryption implements EncryptionProvider {
|
|
14
|
+
encrypt(p: string, _context: EncryptionContext): string;
|
|
15
|
+
decrypt(c: string, _context: EncryptionContext): string;
|
|
16
|
+
hmac(v: string, _context: EncryptionContext): string;
|
|
17
|
+
}
|