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.
Files changed (66) hide show
  1. package/README.md +24 -0
  2. package/dist/src/agent-activity.d.ts +13 -9
  3. package/dist/src/agent-activity.js +45 -24
  4. package/dist/src/agent-registry.d.ts +7 -7
  5. package/dist/src/agent-registry.js +19 -18
  6. package/dist/src/announce-workflow.d.ts +1 -0
  7. package/dist/src/announce-workflow.js +13 -12
  8. package/dist/src/auth/providers/registry.d.ts +4 -0
  9. package/dist/src/auth/providers/registry.js +7 -0
  10. package/dist/src/auth/providers/types.d.ts +11 -0
  11. package/dist/src/auth/providers/types.js +1 -0
  12. package/dist/src/auth.d.ts +24 -5
  13. package/dist/src/auth.js +172 -23
  14. package/dist/src/conflict-detector.d.ts +1 -0
  15. package/dist/src/conflict-detector.js +4 -4
  16. package/dist/src/consultation.d.ts +28 -14
  17. package/dist/src/consultation.js +101 -68
  18. package/dist/src/context-provider.d.ts +2 -2
  19. package/dist/src/context-provider.js +3 -4
  20. package/dist/src/database.js +203 -4
  21. package/dist/src/dependency-map.d.ts +25 -4
  22. package/dist/src/dependency-map.js +49 -11
  23. package/dist/src/file-tracker.d.ts +5 -4
  24. package/dist/src/file-tracker.js +16 -14
  25. package/dist/src/git-cochange-builder.d.ts +11 -2
  26. package/dist/src/git-cochange-builder.js +15 -7
  27. package/dist/src/http/handle-health.d.ts +9 -5
  28. package/dist/src/http/handle-health.js +22 -8
  29. package/dist/src/http/handle-rest.d.ts +3 -0
  30. package/dist/src/http/handle-rest.js +86 -57
  31. package/dist/src/http/utils.d.ts +4 -0
  32. package/dist/src/http/utils.js +7 -1
  33. package/dist/src/impact-scorer.d.ts +3 -0
  34. package/dist/src/impact-scorer.js +65 -51
  35. package/dist/src/introspection.d.ts +13 -7
  36. package/dist/src/introspection.js +34 -11
  37. package/dist/src/metrics.js +2 -1
  38. package/dist/src/mqtt-bridge.d.ts +3 -2
  39. package/dist/src/mqtt-bridge.js +33 -23
  40. package/dist/src/mqtt-broker.d.ts +16 -7
  41. package/dist/src/mqtt-broker.js +57 -15
  42. package/dist/src/security/audit.d.ts +11 -0
  43. package/dist/src/security/audit.js +7 -0
  44. package/dist/src/security/encryption.d.ts +17 -0
  45. package/dist/src/security/encryption.js +5 -0
  46. package/dist/src/serve-http.js +136 -57
  47. package/dist/src/server-setup.d.ts +12 -2
  48. package/dist/src/server-setup.js +33 -15
  49. package/dist/src/sse-emitter.d.ts +7 -4
  50. package/dist/src/sse-emitter.js +27 -21
  51. package/dist/src/tools/agents-tools.d.ts +2 -1
  52. package/dist/src/tools/agents-tools.js +36 -12
  53. package/dist/src/tools/consultation-tools.d.ts +2 -1
  54. package/dist/src/tools/consultation-tools.js +106 -40
  55. package/dist/src/tools/dependencies-tools.d.ts +2 -1
  56. package/dist/src/tools/dependencies-tools.js +25 -7
  57. package/dist/src/tools/files-tools.d.ts +2 -1
  58. package/dist/src/tools/files-tools.js +26 -8
  59. package/dist/src/tools/mqtt-tools.d.ts +7 -1
  60. package/dist/src/tools/mqtt-tools.js +27 -4
  61. package/dist/src/tools/status-tools.d.ts +7 -1
  62. package/dist/src/tools/status-tools.js +26 -9
  63. package/dist/src/types.d.ts +2 -0
  64. package/dist/src/working-files-tracker.d.ts +21 -11
  65. package/dist/src/working-files-tracker.js +32 -21
  66. 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
- db.prepare(`INSERT INTO introspections (id, thread_id, agent_id, score, reasons)
8
- VALUES (?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.score, JSON.stringify(params.reasons));
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(id, concerned, response) {
16
+ respond(orgId, id, response) {
12
17
  const db = getDb();
13
- db.prepare(`UPDATE introspections SET status = ?, concerned = ?, response = ?, responded_at = ? WHERE id = ?`).run(concerned ? "concerned" : "not_concerned", concerned ? 1 : 0, response, new Date().toISOString(), id);
14
- return this.get(id);
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.prepare("SELECT * FROM introspections WHERE id = ?").get(id) || null;
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.prepare("SELECT * FROM introspections WHERE agent_id = ? AND status = 'pending' ORDER BY created_at").all(agentId);
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
- getByThread(threadId) {
41
+ list(orgId, threadId) {
25
42
  const db = getDb();
26
- return db.prepare("SELECT * FROM introspections WHERE thread_id = ? ORDER BY created_at").all(threadId);
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
  }
@@ -145,7 +145,8 @@ export class Metrics {
145
145
  */
146
146
  gaugeSnapshot(services) {
147
147
  try {
148
- this.agentsOnline.set(services.registry.listOnline().length);
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/consultations/new`.
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;
@@ -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/consultations/new`.
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.log = logger || silentLogger;
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
- // Subscribe to agent status for LWT detection
49
- this.client.subscribe("coordinator/agents/+/status");
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
- if (parts[1] === "agents" && parts[3] === "status") {
58
- const agentId = parts[2];
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
- if (parts[1] === "consultations" || parts[1] === "broadcast") {
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("coordinator/consultations/new", JSON.stringify({ thread_id: threadId, agent_id: agentId, subject, target_modules: targetModules }), { qos: 1, retain: true });
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("coordinator/consultations/new", "", { qos: 1, retain: true });
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("coordinator/broadcast", JSON.stringify({ agent_id: agentId, message }));
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("coordinator/quota/update", JSON.stringify(info));
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
- this.client.publish(topic, payload);
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(). 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.
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 MqttAuthVerifier = (username: string | undefined, password: Buffer | undefined) => Promise<boolean>;
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. Omit to allow anonymous (defaultbackwards
26
- * compatible with essaim and any client not using auth).
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
  }
@@ -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
- const broker = await Aedes.createBroker();
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
- 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");
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
- if (tcpPort && tcpPort > 0) {
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
- logger.info({ port: tcpPort, transport: "tcp" }, "Embedded MQTT broker listening");
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: tcpPort }, "Embedded MQTT TCP server error");
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: tcpPort && tcpPort > 0 ? tcpPort : null,
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
+ }
@@ -0,0 +1,5 @@
1
+ export class PassthroughEncryption {
2
+ encrypt(p, _context) { return p; }
3
+ decrypt(c, _context) { return c; }
4
+ hmac(v, _context) { return v; }
5
+ }