@naisys/hub 3.0.0-beta.10

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/bin/naisys-hub ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/naisysHub.js";
@@ -0,0 +1,28 @@
1
+ import { HubEvents, RotateAccessKeyRequestSchema } from "@naisys/hub-protocol";
2
+ import { rotateAccessKey } from "../services/accessKeyService.js";
3
+ /**
4
+ * Handles hub access key rotation requests from the supervisor.
5
+ * Rotates the key, updates auth, and disconnects all clients.
6
+ * The new key is returned only to the requesting supervisor via ack;
7
+ * all other clients must be manually given the new key.
8
+ */
9
+ export function createHubAccessKeyService(naisysServer, logService) {
10
+ naisysServer.registerEvent(HubEvents.ROTATE_ACCESS_KEY, (_hostId, _data, ack) => {
11
+ try {
12
+ const newAccessKey = rotateAccessKey();
13
+ // Update the hub's auth middleware to accept the new key
14
+ naisysServer.updateHubAccessKey(newAccessKey);
15
+ logService.log(`[Hub:AccessKey] Access key rotated successfully`);
16
+ // Respond to the requesting supervisor before disconnecting
17
+ ack({ success: true, newAccessKey });
18
+ // Disconnect all clients — they'll need the new key to reconnect
19
+ logService.log(`[Hub:AccessKey] Disconnecting all clients after key rotation`);
20
+ naisysServer.disconnectAllClients();
21
+ }
22
+ catch (error) {
23
+ logService.error(`[Hub:AccessKey] Failed to rotate access key: ${error}`);
24
+ ack({ success: false, error: String(error) });
25
+ }
26
+ }, RotateAccessKeyRequestSchema);
27
+ }
28
+ //# sourceMappingURL=hubAccessKeyService.js.map
@@ -0,0 +1,238 @@
1
+ import { AgentPeekRequestSchema, AgentStartRequestSchema, AgentStopRequestSchema, HubEvents, } from "@naisys/hub-protocol";
2
+ /** Handles agent_start requests by routing them to the least-loaded eligible host */
3
+ export function createHubAgentService(naisysServer, { hubDb }, logService, heartbeatService, sendMailService, hostRegistrar) {
4
+ /** Find the least-loaded eligible host for a given user */
5
+ async function findBestHost(startUserId) {
6
+ // Look up which hosts this user is assigned to
7
+ const userHosts = await hubDb.user_hosts.findMany({
8
+ where: { user_id: startUserId },
9
+ select: { host_id: true },
10
+ });
11
+ const assignedHostIds = userHosts.map((uh) => uh.host_id);
12
+ // Determine eligible hosts: assigned hosts, or all connected (non-restricted) if unassigned
13
+ let eligibleHostIds;
14
+ if (assignedHostIds.length > 0) {
15
+ eligibleHostIds = assignedHostIds;
16
+ }
17
+ else {
18
+ const restrictedHostIds = new Set(hostRegistrar
19
+ .getAllHosts()
20
+ .filter((h) => h.restricted)
21
+ .map((h) => h.hostId));
22
+ eligibleHostIds = naisysServer
23
+ .getConnectedClients()
24
+ .map((c) => c.getHostId())
25
+ .filter((hid) => !restrictedHostIds.has(hid));
26
+ }
27
+ // Filter to connected hosts that can run agents
28
+ const connectedEligible = eligibleHostIds.filter((hid) => {
29
+ const conn = naisysServer.getConnectionByHostId(hid);
30
+ return conn && conn.getHostType() === "naisys";
31
+ });
32
+ if (connectedEligible.length === 0) {
33
+ return null;
34
+ }
35
+ // Pick the host with the fewest active agents
36
+ let bestHostId = connectedEligible[0];
37
+ let bestCount = heartbeatService.getHostActiveAgentCount(bestHostId);
38
+ for (const hid of connectedEligible.slice(1)) {
39
+ const count = heartbeatService.getHostActiveAgentCount(hid);
40
+ if (count < bestCount) {
41
+ bestHostId = hid;
42
+ bestCount = count;
43
+ }
44
+ }
45
+ return bestHostId;
46
+ }
47
+ /** Check if a user is enabled (not disabled or archived) */
48
+ async function isAgentEnabled(userId) {
49
+ const user = await hubDb.users.findUnique({
50
+ where: { id: userId },
51
+ select: { enabled: true, archived: true },
52
+ });
53
+ return !!user?.enabled && !user?.archived;
54
+ }
55
+ /** Try to start an agent on the best available host (fire-and-forget) */
56
+ async function tryStartAgent(startUserId) {
57
+ try {
58
+ if (!(await isAgentEnabled(startUserId))) {
59
+ logService.log(`[Hub:Agents] Auto-start: agent ${startUserId} is disabled or archived`);
60
+ return false;
61
+ }
62
+ const bestHostId = await findBestHost(startUserId);
63
+ if (bestHostId === null) {
64
+ logService.log(`[Hub:Agents] Auto-start: no eligible host for user ${startUserId}`);
65
+ return false;
66
+ }
67
+ const sent = naisysServer.sendMessage(bestHostId, HubEvents.AGENT_START, {
68
+ startUserId,
69
+ }, (response) => {
70
+ if (response.success) {
71
+ heartbeatService.addStartedAgent(bestHostId, startUserId);
72
+ }
73
+ else {
74
+ logService.error(`[Hub:Agents] Auto-start failed for user ${startUserId}: ${response.error}`);
75
+ }
76
+ });
77
+ if (sent) {
78
+ logService.log(`[Hub:Agents] Auto-start: sent start for user ${startUserId} to host ${bestHostId}`);
79
+ }
80
+ return sent;
81
+ }
82
+ catch (error) {
83
+ logService.error(`[Hub:Agents] Auto-start error: ${error}`);
84
+ return false;
85
+ }
86
+ }
87
+ naisysServer.registerEvent(HubEvents.AGENT_START, async (hostId, data, ack) => {
88
+ try {
89
+ const parsed = AgentStartRequestSchema.parse(data);
90
+ const requesterUserId = parsed.requesterUserId;
91
+ if (!(await isAgentEnabled(parsed.startUserId))) {
92
+ ack({
93
+ success: false,
94
+ error: `Agent ${parsed.startUserId} is disabled`,
95
+ });
96
+ return;
97
+ }
98
+ const bestHostId = await findBestHost(parsed.startUserId);
99
+ if (bestHostId === null) {
100
+ ack({
101
+ success: false,
102
+ error: `No eligible hosts are online for user ${parsed.startUserId}`,
103
+ });
104
+ return;
105
+ }
106
+ if (!requesterUserId) {
107
+ ack({
108
+ success: false,
109
+ error: `Missing requesterUserId in agent_start request for user ${parsed.startUserId}`,
110
+ });
111
+ return;
112
+ }
113
+ // Forward the start request to the selected host
114
+ const sent = naisysServer.sendMessage(bestHostId, HubEvents.AGENT_START, {
115
+ startUserId: parsed.startUserId,
116
+ taskDescription: parsed.taskDescription,
117
+ sourceHostId: hostId,
118
+ }, (response) => {
119
+ if (response.success) {
120
+ heartbeatService.addStartedAgent(bestHostId, parsed.startUserId);
121
+ }
122
+ // Reverse-ack with the response from the host (including success status and any error message) back to the original requester
123
+ ack(response);
124
+ // Send task description mail after successful start to avoid
125
+ // orphaned mails from failed start attempts
126
+ if (response.success && parsed.taskDescription) {
127
+ void sendTaskMail(parsed.startUserId, requesterUserId, parsed.taskDescription);
128
+ }
129
+ });
130
+ if (!sent) {
131
+ ack({
132
+ success: false,
133
+ error: `Failed to send to host ${bestHostId}`,
134
+ });
135
+ }
136
+ }
137
+ catch (error) {
138
+ logService.error(`[Hub:Agents] agent_start error from host ${hostId}: ${error}`);
139
+ ack({ success: false, error: String(error) });
140
+ }
141
+ });
142
+ async function sendTaskMail(startUserId, requesterUserId, taskDescription) {
143
+ try {
144
+ await sendMailService.sendMail({
145
+ fromUserId: requesterUserId,
146
+ recipientUserIds: [startUserId],
147
+ subject: "Agent Start", // Agent will send a 'Session Completed' mail when session is completed
148
+ body: taskDescription,
149
+ kind: "mail",
150
+ });
151
+ }
152
+ catch (err) {
153
+ logService.error(`[Hub:Agents] Failed to send task mail: ${err}`);
154
+ }
155
+ }
156
+ naisysServer.registerEvent(HubEvents.AGENT_STOP, (hostId, data, ack) => {
157
+ try {
158
+ const parsed = AgentStopRequestSchema.parse(data);
159
+ // Find which hosts the agent is currently running on
160
+ const targetHostIds = heartbeatService.findHostsForAgent(parsed.userId);
161
+ if (targetHostIds.length === 0) {
162
+ ack({
163
+ success: false,
164
+ error: `Agent ${parsed.userId} is not running on any known host`,
165
+ });
166
+ return;
167
+ }
168
+ // Forward the stop request to all hosts running this agent
169
+ let acked = false;
170
+ let sendFailures = 0;
171
+ for (const targetHostId of targetHostIds) {
172
+ const sent = naisysServer.sendMessage(targetHostId, HubEvents.AGENT_STOP, {
173
+ userId: parsed.userId,
174
+ reason: parsed.reason,
175
+ sourceHostId: hostId,
176
+ }, (response) => {
177
+ if (response.success) {
178
+ heartbeatService.removeStoppedAgent(targetHostId, parsed.userId);
179
+ }
180
+ // Ack with the first response
181
+ if (!acked) {
182
+ acked = true;
183
+ ack(response);
184
+ }
185
+ });
186
+ if (!sent) {
187
+ sendFailures++;
188
+ }
189
+ }
190
+ if (sendFailures === targetHostIds.length && !acked) {
191
+ ack({
192
+ success: false,
193
+ error: `No target hosts are connected`,
194
+ });
195
+ }
196
+ }
197
+ catch (error) {
198
+ logService.error(`[Hub:Agents] agent_stop error from host ${hostId}: ${error}`);
199
+ ack({ success: false, error: String(error) });
200
+ }
201
+ });
202
+ naisysServer.registerEvent(HubEvents.AGENT_PEEK, (hostId, data, ack) => {
203
+ try {
204
+ const parsed = AgentPeekRequestSchema.parse(data);
205
+ // Find which host the agent is running on
206
+ const targetHostIds = heartbeatService.findHostsForAgent(parsed.userId);
207
+ if (targetHostIds.length === 0) {
208
+ ack({
209
+ success: false,
210
+ error: `Agent ${parsed.userId} is not running on any known host`,
211
+ });
212
+ return;
213
+ }
214
+ // Forward peek request to the first host (only need one response)
215
+ const targetHostId = targetHostIds[0];
216
+ const sent = naisysServer.sendMessage(targetHostId, HubEvents.AGENT_PEEK, {
217
+ userId: parsed.userId,
218
+ skip: parsed.skip,
219
+ take: parsed.take,
220
+ sourceHostId: hostId,
221
+ }, (response) => {
222
+ ack(response);
223
+ });
224
+ if (!sent) {
225
+ ack({
226
+ success: false,
227
+ error: `Failed to send to host ${targetHostId}`,
228
+ });
229
+ }
230
+ }
231
+ catch (error) {
232
+ logService.error(`[Hub:Agents] agent_peek error from host ${hostId}: ${error}`);
233
+ ack({ success: false, error: String(error) });
234
+ }
235
+ });
236
+ return { tryStartAgent };
237
+ }
238
+ //# sourceMappingURL=hubAgentService.js.map
@@ -0,0 +1,227 @@
1
+ import { mimeFromFilename } from "@naisys/common";
2
+ import { extractBearerToken } from "@naisys/common-node";
3
+ import { createHash, randomBytes } from "crypto";
4
+ import { createReadStream, createWriteStream, existsSync, mkdirSync, renameSync, statSync, unlinkSync, } from "fs";
5
+ import { join } from "path";
6
+ import { pipeline, Writable } from "stream";
7
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
8
+ /**
9
+ * HTTP attachment upload/download service.
10
+ * Registers a `request` handler on the raw HTTPS server for `/attachments` paths.
11
+ * Non-matching paths are ignored so Socket.IO still works.
12
+ */
13
+ export function createHubAttachmentService(httpServer, { hubDb }, logService) {
14
+ const naisysFolder = process.env.NAISYS_FOLDER || "";
15
+ httpServer.on("request", (req, res) => {
16
+ const url = new URL(req.url || "", `https://${req.headers.host || "localhost"}`);
17
+ const pathname = url.pathname;
18
+ if (pathname === "/hub/attachments" && req.method === "POST") {
19
+ handleUpload(url, req, res).catch((err) => {
20
+ logService.error(`[Hub:Attachment] Upload error: ${err}`);
21
+ if (!res.writableEnded) {
22
+ res.writeHead(500, { "Content-Type": "application/json" });
23
+ res.end(JSON.stringify({ error: "Internal server error" }));
24
+ }
25
+ });
26
+ }
27
+ else if (pathname.startsWith("/hub/attachments/") &&
28
+ pathname !== "/hub/attachments/" &&
29
+ req.method === "GET") {
30
+ handleDownload(pathname, req, res).catch((err) => {
31
+ logService.error(`[Hub:Attachment] Download error: ${err}`);
32
+ if (!res.writableEnded) {
33
+ res.writeHead(500, { "Content-Type": "application/json" });
34
+ res.end(JSON.stringify({ error: "Internal server error" }));
35
+ }
36
+ });
37
+ }
38
+ // Non-matching paths: do nothing — let Socket.IO handle them
39
+ });
40
+ async function resolveUserByApiKey(apiKey) {
41
+ const user = await hubDb.users.findUnique({ where: { api_key: apiKey } });
42
+ return user?.id ?? null;
43
+ }
44
+ async function handleUpload(url, req, res) {
45
+ const apiKey = extractBearerToken(req.headers.authorization);
46
+ const filename = url.searchParams.get("filename");
47
+ const fileSizeStr = url.searchParams.get("filesize");
48
+ const fileHash = url.searchParams.get("filehash");
49
+ const purpose = url.searchParams.get("purpose");
50
+ if (!apiKey) {
51
+ res.writeHead(401, { "Content-Type": "application/json" });
52
+ res.end(JSON.stringify({ error: "Missing Authorization header" }));
53
+ return;
54
+ }
55
+ if (!filename || !fileSizeStr || !fileHash || !purpose) {
56
+ res.writeHead(400, { "Content-Type": "application/json" });
57
+ res.end(JSON.stringify({
58
+ error: "Missing required query params: filename, filesize, filehash, purpose",
59
+ }));
60
+ return;
61
+ }
62
+ if (purpose !== "mail" && purpose !== "context") {
63
+ res.writeHead(400, { "Content-Type": "application/json" });
64
+ res.end(JSON.stringify({
65
+ error: 'Invalid purpose. Must be "mail" or "context"',
66
+ }));
67
+ return;
68
+ }
69
+ const fileSize = parseInt(fileSizeStr, 10);
70
+ if (isNaN(fileSize) || fileSize <= 0) {
71
+ res.writeHead(400, { "Content-Type": "application/json" });
72
+ res.end(JSON.stringify({ error: "Invalid filesize" }));
73
+ return;
74
+ }
75
+ if (fileSize > MAX_FILE_SIZE) {
76
+ res.writeHead(413, { "Content-Type": "application/json" });
77
+ res.end(JSON.stringify({
78
+ error: `File too large. Max size: ${MAX_FILE_SIZE} bytes`,
79
+ }));
80
+ return;
81
+ }
82
+ const userId = await resolveUserByApiKey(apiKey);
83
+ if (userId == null) {
84
+ res.writeHead(401, { "Content-Type": "application/json" });
85
+ res.end(JSON.stringify({ error: "Invalid API key" }));
86
+ return;
87
+ }
88
+ // Stream to temp file, then move to content-addressable path
89
+ const tmpDir = join(naisysFolder, "attachments", "tmp");
90
+ mkdirSync(tmpDir, { recursive: true });
91
+ const tmpPath = join(tmpDir, `${Date.now()}_${userId}_${Math.random().toString(36).slice(2)}`);
92
+ const hash = createHash("sha256");
93
+ let bytesWritten = 0;
94
+ const fileStream = createWriteStream(tmpPath);
95
+ const success = await new Promise((resolve) => {
96
+ const sizeChecker = new Writable({
97
+ write(chunk, _encoding, callback) {
98
+ bytesWritten += chunk.length;
99
+ if (bytesWritten > MAX_FILE_SIZE) {
100
+ callback(new Error("File exceeds size limit"));
101
+ return;
102
+ }
103
+ hash.update(chunk);
104
+ fileStream.write(chunk, callback);
105
+ },
106
+ final(callback) {
107
+ fileStream.end(callback);
108
+ },
109
+ });
110
+ pipeline(req, sizeChecker, (err) => {
111
+ if (err) {
112
+ fileStream.destroy();
113
+ try {
114
+ unlinkSync(tmpPath);
115
+ }
116
+ catch {
117
+ /* ignore */
118
+ }
119
+ resolve(false);
120
+ }
121
+ else {
122
+ resolve(true);
123
+ }
124
+ });
125
+ });
126
+ if (!success) {
127
+ res.writeHead(413, { "Content-Type": "application/json" });
128
+ res.end(JSON.stringify({ error: "File exceeds size limit during upload" }));
129
+ return;
130
+ }
131
+ // Verify hash
132
+ const computedHash = hash.digest("hex");
133
+ if (computedHash !== fileHash) {
134
+ try {
135
+ unlinkSync(tmpPath);
136
+ }
137
+ catch {
138
+ /* ignore */
139
+ }
140
+ res.writeHead(400, { "Content-Type": "application/json" });
141
+ res.end(JSON.stringify({
142
+ error: `Hash mismatch. Expected: ${fileHash}, got: ${computedHash}`,
143
+ }));
144
+ return;
145
+ }
146
+ // Move to content-addressable path: attachments/hub/<first2>/<next2>/<fullhash>
147
+ const storageDir = join(naisysFolder, "attachments", "hub", computedHash.slice(0, 2), computedHash.slice(2, 4));
148
+ mkdirSync(storageDir, { recursive: true });
149
+ const storagePath = join(storageDir, computedHash);
150
+ if (existsSync(storagePath)) {
151
+ // Dedup: identical file already on disk, discard temp
152
+ try {
153
+ unlinkSync(tmpPath);
154
+ }
155
+ catch {
156
+ /* ignore */
157
+ }
158
+ }
159
+ else {
160
+ renameSync(tmpPath, storagePath);
161
+ }
162
+ // Create DB record
163
+ const record = await hubDb.attachments.create({
164
+ data: {
165
+ public_id: randomBytes(8).toString("base64url").slice(0, 10),
166
+ filepath: storagePath,
167
+ filename,
168
+ file_size: bytesWritten,
169
+ file_hash: computedHash,
170
+ purpose: purpose,
171
+ uploaded_by: userId,
172
+ },
173
+ });
174
+ const attachmentId = record.id;
175
+ logService.log(`[Hub:Attachment] Uploaded attachment ${attachmentId}: ${filename} (${bytesWritten} bytes) by user ${userId}`);
176
+ res.writeHead(200, { "Content-Type": "application/json" });
177
+ res.end(JSON.stringify({ id: attachmentId }));
178
+ }
179
+ async function handleDownload(pathname, req, res) {
180
+ const apiKey = extractBearerToken(req.headers.authorization);
181
+ if (!apiKey) {
182
+ res.writeHead(401, { "Content-Type": "application/json" });
183
+ res.end(JSON.stringify({ error: "Missing Authorization header" }));
184
+ return;
185
+ }
186
+ const userId = await resolveUserByApiKey(apiKey);
187
+ if (userId == null) {
188
+ res.writeHead(401, { "Content-Type": "application/json" });
189
+ res.end(JSON.stringify({ error: "Invalid API key" }));
190
+ return;
191
+ }
192
+ // Parse public ID from /hub/attachments/<publicId> or /hub/attachments/<publicId>/<filename>
193
+ const segments = pathname.slice("/hub/attachments/".length).split("/");
194
+ const publicId = segments[0];
195
+ if (!publicId) {
196
+ res.writeHead(400, { "Content-Type": "application/json" });
197
+ res.end(JSON.stringify({ error: "Missing attachment ID" }));
198
+ return;
199
+ }
200
+ const attachment = await hubDb.attachments.findUnique({
201
+ where: { public_id: publicId },
202
+ });
203
+ if (!attachment) {
204
+ res.writeHead(404, { "Content-Type": "application/json" });
205
+ res.end(JSON.stringify({ error: "Attachment not found" }));
206
+ return;
207
+ }
208
+ if (!existsSync(attachment.filepath)) {
209
+ res.writeHead(404, { "Content-Type": "application/json" });
210
+ res.end(JSON.stringify({ error: "Attachment file missing from disk" }));
211
+ return;
212
+ }
213
+ const stat = statSync(attachment.filepath);
214
+ const contentType = mimeFromFilename(attachment.filename);
215
+ const disposition = contentType.startsWith("image/")
216
+ ? "inline"
217
+ : "attachment";
218
+ res.writeHead(200, {
219
+ "Content-Type": contentType,
220
+ "Content-Disposition": `${disposition}; filename="${attachment.filename.replace(/"/g, '\\"')}"`,
221
+ "Content-Length": stat.size,
222
+ });
223
+ const readStream = createReadStream(attachment.filepath);
224
+ readStream.pipe(res);
225
+ }
226
+ }
227
+ //# sourceMappingURL=hubAttachmentService.js.map
@@ -0,0 +1,85 @@
1
+ import { buildClientConfig } from "@naisys/common";
2
+ import { HubEvents } from "@naisys/hub-protocol";
3
+ import dotenv from "dotenv";
4
+ /** Pushes the global config to NAISYS instances when they connect or when variables change */
5
+ export async function createHubConfigService(naisysServer, { hubDb }, logService) {
6
+ let cachedConfig = {
7
+ success: false,
8
+ error: "Not yet loaded",
9
+ };
10
+ // Seed DB from .env on first run
11
+ const existing = await hubDb.variables.findMany();
12
+ if (existing.length > 0) {
13
+ logService.log("[Hub:Config] .env variables already seeded");
14
+ }
15
+ else {
16
+ // First run: seed from .env file only (not all of process.env)
17
+ const { parsed: dotenvVars } = dotenv.config({ quiet: true });
18
+ const fileConfig = buildClientConfig(dotenvVars ?? {});
19
+ const entries = Object.entries(fileConfig.variableMap);
20
+ if (entries.length > 0) {
21
+ await hubDb.variables.createMany({
22
+ data: entries.map(([key, value]) => ({
23
+ key,
24
+ value,
25
+ created_by: "hub",
26
+ updated_by: "hub",
27
+ })),
28
+ });
29
+ }
30
+ logService.log(`[Hub:Config] Seeded ${entries.length} variables from .env file into database`);
31
+ }
32
+ /** Read variables from DB and build a ConfigResponse */
33
+ async function buildConfigPayload() {
34
+ const rows = await hubDb.variables.findMany();
35
+ const variableMap = {};
36
+ const shellExportKeys = new Set();
37
+ for (const row of rows) {
38
+ variableMap[row.key] = row.value;
39
+ if (row.export_to_shell) {
40
+ shellExportKeys.add(row.key);
41
+ }
42
+ }
43
+ cachedConfig = {
44
+ success: true,
45
+ config: buildClientConfig(variableMap, shellExportKeys),
46
+ };
47
+ return cachedConfig;
48
+ }
49
+ /** Broadcast current config to all connected clients */
50
+ async function broadcastConfig() {
51
+ try {
52
+ const payload = await buildConfigPayload();
53
+ logService.log(`[Hub:Config] Broadcasting config to all clients`);
54
+ naisysServer.broadcastToAll(HubEvents.VARIABLES_UPDATED, payload);
55
+ }
56
+ catch (error) {
57
+ logService.error(`[Hub:Config] Error broadcasting config: ${error}`);
58
+ }
59
+ }
60
+ // Push config to newly connected clients
61
+ naisysServer.registerEvent(HubEvents.CLIENT_CONNECTED, async (hostId, connection) => {
62
+ try {
63
+ const payload = await buildConfigPayload();
64
+ logService.log(`[Hub:Config] Pushing config to instance ${hostId}`);
65
+ connection.sendMessage(HubEvents.VARIABLES_UPDATED, payload);
66
+ }
67
+ catch (error) {
68
+ logService.error(`[Hub:Config] Error sending config to instance ${hostId}: ${error}`);
69
+ connection.sendMessage(HubEvents.VARIABLES_UPDATED, {
70
+ success: false,
71
+ error: String(error),
72
+ });
73
+ }
74
+ });
75
+ // Broadcast config to all clients when variables change
76
+ naisysServer.registerEvent(HubEvents.VARIABLES_CHANGED, async () => {
77
+ await broadcastConfig();
78
+ });
79
+ // Build initial config so it's available immediately
80
+ await buildConfigPayload();
81
+ return {
82
+ getConfig: () => cachedConfig,
83
+ };
84
+ }
85
+ //# sourceMappingURL=hubConfigService.js.map