u-foo 1.0.6 → 1.2.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 (149) hide show
  1. package/README.md +247 -23
  2. package/SKILLS/ufoo/SKILL.md +17 -2
  3. package/SKILLS/uinit/SKILL.md +8 -3
  4. package/bin/ucode-core.js +15 -0
  5. package/bin/ucode.js +125 -0
  6. package/bin/ufoo-assistant-agent.js +5 -0
  7. package/bin/ufoo-engine.js +25 -0
  8. package/bin/ufoo.js +4 -0
  9. package/modules/AGENTS.template.md +14 -4
  10. package/modules/bus/README.md +8 -5
  11. package/modules/bus/SKILLS/ubus/SKILL.md +5 -4
  12. package/modules/context/SKILLS/uctx/SKILL.md +3 -1
  13. package/modules/online/SKILLS/ufoo-online/SKILL.md +144 -0
  14. package/package.json +12 -3
  15. package/scripts/import-pi-mono.js +124 -0
  16. package/scripts/postinstall.js +20 -49
  17. package/scripts/sync-claude-skills.sh +21 -0
  18. package/src/agent/cliRunner.js +524 -31
  19. package/src/agent/internalRunner.js +76 -9
  20. package/src/agent/launcher.js +97 -45
  21. package/src/agent/normalizeOutput.js +1 -1
  22. package/src/agent/notifier.js +144 -4
  23. package/src/agent/ptyRunner.js +480 -10
  24. package/src/agent/ptyWrapper.js +28 -3
  25. package/src/agent/readyDetector.js +16 -0
  26. package/src/agent/ucode.js +443 -0
  27. package/src/agent/ucodeBootstrap.js +113 -0
  28. package/src/agent/ucodeBuild.js +67 -0
  29. package/src/agent/ucodeDoctor.js +184 -0
  30. package/src/agent/ucodeRuntimeConfig.js +129 -0
  31. package/src/agent/ufooAgent.js +168 -28
  32. package/src/assistant/agent.js +260 -0
  33. package/src/assistant/bridge.js +172 -0
  34. package/src/assistant/engine.js +252 -0
  35. package/src/assistant/stdio.js +58 -0
  36. package/src/assistant/ufooEngineCli.js +306 -0
  37. package/src/bus/activate.js +27 -11
  38. package/src/bus/daemon.js +133 -5
  39. package/src/bus/index.js +137 -80
  40. package/src/bus/inject.js +47 -17
  41. package/src/bus/message.js +145 -17
  42. package/src/bus/nickname.js +3 -1
  43. package/src/bus/queue.js +6 -1
  44. package/src/bus/store.js +189 -0
  45. package/src/bus/subscriber.js +20 -4
  46. package/src/bus/utils.js +9 -3
  47. package/src/chat/agentBar.js +117 -0
  48. package/src/chat/agentDirectory.js +88 -0
  49. package/src/chat/agentSockets.js +225 -0
  50. package/src/chat/agentViewController.js +298 -0
  51. package/src/chat/chatLogController.js +115 -0
  52. package/src/chat/commandExecutor.js +700 -0
  53. package/src/chat/commands.js +132 -0
  54. package/src/chat/completionController.js +414 -0
  55. package/src/chat/cronScheduler.js +160 -0
  56. package/src/chat/daemonConnection.js +166 -0
  57. package/src/chat/daemonCoordinator.js +64 -0
  58. package/src/chat/daemonMessageRouter.js +257 -0
  59. package/src/chat/daemonReconnect.js +41 -0
  60. package/src/chat/daemonTransport.js +36 -0
  61. package/src/chat/daemonTransportDefaults.js +10 -0
  62. package/src/chat/dashboardKeyController.js +480 -0
  63. package/src/chat/dashboardView.js +157 -0
  64. package/src/chat/index.js +938 -2910
  65. package/src/chat/inputHistoryController.js +105 -0
  66. package/src/chat/inputListenerController.js +304 -0
  67. package/src/chat/inputMath.js +104 -0
  68. package/src/chat/inputSubmitHandler.js +171 -0
  69. package/src/chat/layout.js +165 -0
  70. package/src/chat/pasteController.js +81 -0
  71. package/src/chat/rawKeyMap.js +42 -0
  72. package/src/chat/settingsController.js +133 -0
  73. package/src/chat/statusLineController.js +177 -0
  74. package/src/chat/streamTracker.js +138 -0
  75. package/src/chat/text.js +70 -0
  76. package/src/chat/transport.js +61 -0
  77. package/src/cli/busCoreCommands.js +59 -0
  78. package/src/cli/ctxCoreCommands.js +199 -0
  79. package/src/cli/onlineCoreCommands.js +379 -0
  80. package/src/cli.js +741 -238
  81. package/src/code/README.md +29 -0
  82. package/src/code/UCODE_PROMPT.md +32 -0
  83. package/src/code/agent.js +1651 -0
  84. package/src/code/cli.js +158 -0
  85. package/src/code/config +0 -0
  86. package/src/code/dispatch.js +42 -0
  87. package/src/code/index.js +70 -0
  88. package/src/code/nativeRunner.js +1213 -0
  89. package/src/code/runtime.js +154 -0
  90. package/src/code/sessionStore.js +162 -0
  91. package/src/code/taskDecomposer.js +269 -0
  92. package/src/code/tools/bash.js +53 -0
  93. package/src/code/tools/common.js +42 -0
  94. package/src/code/tools/edit.js +70 -0
  95. package/src/code/tools/read.js +44 -0
  96. package/src/code/tools/write.js +35 -0
  97. package/src/code/tui.js +1587 -0
  98. package/src/config.js +50 -2
  99. package/src/context/decisions.js +12 -2
  100. package/src/context/index.js +18 -1
  101. package/src/context/sync.js +127 -0
  102. package/src/daemon/agentProcessManager.js +74 -0
  103. package/src/daemon/cronOps.js +241 -0
  104. package/src/daemon/index.js +662 -489
  105. package/src/daemon/ipcServer.js +99 -0
  106. package/src/daemon/ops.js +417 -179
  107. package/src/daemon/promptLoop.js +319 -0
  108. package/src/daemon/promptRequest.js +101 -0
  109. package/src/daemon/providerSessions.js +32 -17
  110. package/src/daemon/reporting.js +90 -0
  111. package/src/daemon/run.js +2 -5
  112. package/src/daemon/status.js +24 -1
  113. package/src/init/index.js +68 -14
  114. package/src/online/bridge.js +663 -0
  115. package/src/online/client.js +245 -0
  116. package/src/online/runner.js +253 -0
  117. package/src/online/server.js +992 -0
  118. package/src/online/tokens.js +103 -0
  119. package/src/report/store.js +331 -0
  120. package/src/shared/eventContract.js +35 -0
  121. package/src/shared/ptySocketContract.js +21 -0
  122. package/src/status/index.js +50 -17
  123. package/src/terminal/adapterContract.js +87 -0
  124. package/src/terminal/adapterRouter.js +84 -0
  125. package/src/terminal/adapters/externalAdapter.js +14 -0
  126. package/src/terminal/adapters/internalAdapter.js +13 -0
  127. package/src/terminal/adapters/internalPtyAdapter.js +42 -0
  128. package/src/terminal/adapters/internalQueueAdapter.js +37 -0
  129. package/src/terminal/adapters/terminalAdapter.js +31 -0
  130. package/src/terminal/adapters/tmuxAdapter.js +30 -0
  131. package/src/ufoo/agentsStore.js +69 -3
  132. package/src/utils/banner.js +5 -2
  133. package/scripts/.archived/bash-to-js-migration/README.md +0 -46
  134. package/scripts/.archived/bash-to-js-migration/banner.sh +0 -89
  135. package/scripts/.archived/bash-to-js-migration/bus-alert.sh +0 -6
  136. package/scripts/.archived/bash-to-js-migration/bus-autotrigger.sh +0 -6
  137. package/scripts/.archived/bash-to-js-migration/bus-daemon.sh +0 -231
  138. package/scripts/.archived/bash-to-js-migration/bus-inject.sh +0 -176
  139. package/scripts/.archived/bash-to-js-migration/bus-listen.sh +0 -6
  140. package/scripts/.archived/bash-to-js-migration/bus.sh +0 -986
  141. package/scripts/.archived/bash-to-js-migration/context-decisions.sh +0 -167
  142. package/scripts/.archived/bash-to-js-migration/context-doctor.sh +0 -72
  143. package/scripts/.archived/bash-to-js-migration/context-lint.sh +0 -110
  144. package/scripts/.archived/bash-to-js-migration/doctor.sh +0 -22
  145. package/scripts/.archived/bash-to-js-migration/init.sh +0 -247
  146. package/scripts/.archived/bash-to-js-migration/skills.sh +0 -113
  147. package/scripts/.archived/bash-to-js-migration/status.sh +0 -125
  148. package/scripts/banner.sh +0 -2
  149. package/src/bus/API_DESIGN.md +0 -204
@@ -0,0 +1,992 @@
1
+ const http = require("http");
2
+ const https = require("https");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const crypto = require("crypto");
6
+ const EventEmitter = require("events");
7
+ const WebSocket = require("ws");
8
+
9
+ /**
10
+ * ufoo-online (Phase 1)
11
+ *
12
+ * Minimal WebSocket relay implementing hello/auth + join/leave + event routing.
13
+ * Intended WebSocket path: /ufoo/online (see docs/ufoo-online/PROTOCOL.md)
14
+ */
15
+ class OnlineServer extends EventEmitter {
16
+ constructor(options = {}) {
17
+ super();
18
+ this.port = options.port ?? 8787;
19
+ this.host = options.host ?? "127.0.0.1";
20
+ this.server = null;
21
+ this.wsServer = null;
22
+
23
+ this.clientsById = new Map();
24
+ this.clientsByNickname = new Map();
25
+ this.channels = new Map();
26
+ this.channelNames = new Map();
27
+
28
+ this.nicknameScope = options.nicknameScope || "global"; // global | world
29
+
30
+ this.allowedTokens = this.loadTokens(options);
31
+
32
+ // Step 1: --insecure guard
33
+ this.insecure = !!options.insecure;
34
+ if (this.allowedTokens === null && !this.insecure) {
35
+ throw new Error(
36
+ "No tokens configured. Use --token-file to provide tokens, or --insecure to allow any token (dev only)."
37
+ );
38
+ }
39
+ this.allowAnyToken = this.allowedTokens === null && this.insecure;
40
+
41
+ this.version = options.version || "0.1.0";
42
+ this.idleTimeoutMs = options.idleTimeoutMs ?? 30000;
43
+ this.sweepIntervalMs = options.sweepIntervalMs ?? 10000;
44
+ this.sweepTimer = null;
45
+
46
+ this.rooms = new Map();
47
+ this.roomPasswords = new Map();
48
+
49
+ // Step 2 + 3: Payload limits
50
+ this.maxHttpBodyBytes = options.maxHttpBodyBytes ?? 65536; // 64 KB
51
+ this.maxWsPayloadBytes = options.maxWsPayloadBytes ?? 1048576; // 1 MB
52
+
53
+ // Step 5: Rate limiting config
54
+ this.rateLimitWindow = options.rateLimitWindow ?? 10000; // 10s
55
+ this.rateLimitMax = options.rateLimitMax ?? 60;
56
+
57
+ // Security: connection limits
58
+ this.maxConnections = options.maxConnections ?? 1024;
59
+ this.maxConnectionsPerIp = options.maxConnectionsPerIp ?? 64;
60
+ this.connectionsByIp = new Map();
61
+
62
+ // Security: room/channel caps
63
+ this.maxRooms = options.maxRooms ?? 10000;
64
+ this.maxChannels = options.maxChannels ?? 10000;
65
+
66
+ // Security: input limits
67
+ this.maxIdLength = options.maxIdLength ?? 128;
68
+
69
+ // Security: room password brute-force protection
70
+ this.maxRoomAuthFailures = options.maxRoomAuthFailures ?? 5;
71
+ this.roomAuthLockoutMs = options.roomAuthLockoutMs ?? 60000;
72
+ this.roomAuthFailures = new Map(); // clientKey -> { count, lockedUntil }
73
+
74
+ // Security: pre-auth connection deadline (shorter than idle timeout)
75
+ this.authDeadlineMs = options.authDeadlineMs ?? 10000;
76
+
77
+ // Step 7: TLS support
78
+ this.tlsCert = options.tlsCert || null;
79
+ this.tlsKey = options.tlsKey || null;
80
+ }
81
+
82
+ loadTokens(options) {
83
+ if (options.tokens) {
84
+ return new Set(Array.isArray(options.tokens) ? options.tokens : Object.keys(options.tokens));
85
+ }
86
+
87
+ if (options.tokenFile) {
88
+ const filePath = path.resolve(options.tokenFile);
89
+ const raw = fs.readFileSync(filePath, "utf8");
90
+ const parsed = JSON.parse(raw);
91
+ if (Array.isArray(parsed)) return new Set(parsed);
92
+ if (Array.isArray(parsed.tokens)) return new Set(parsed.tokens);
93
+ if (parsed.tokens && typeof parsed.tokens === "object") return new Set(Object.keys(parsed.tokens));
94
+ if (parsed.agents && typeof parsed.agents === "object") {
95
+ return new Set(
96
+ Object.values(parsed.agents)
97
+ .map((entry) => entry && (entry.token_hash || entry.token))
98
+ .filter(Boolean)
99
+ );
100
+ }
101
+ if (typeof parsed === "object") return new Set(Object.keys(parsed));
102
+ return new Set();
103
+ }
104
+
105
+ return null; // allow any token if none configured
106
+ }
107
+
108
+ start() {
109
+ if (this.server) return Promise.resolve();
110
+
111
+ // Security: warn when binding non-localhost without TLS
112
+ const isLocal = ["127.0.0.1", "localhost", "::1"].includes(this.host);
113
+ if (!isLocal && !this.tlsCert) {
114
+ const msg = `[SECURITY WARNING] Server binding to ${this.host} without TLS. Tokens will be sent in plaintext. Use --tls-cert/--tls-key for production.`;
115
+ process.stderr.write(msg + "\n");
116
+ this.emit("warning", msg);
117
+ }
118
+
119
+ const requestHandler = (req, res) => {
120
+ if (!req.url) {
121
+ res.writeHead(404);
122
+ res.end();
123
+ return;
124
+ }
125
+
126
+ if (req.url.startsWith("/ufoo/online/rooms")) {
127
+ // Step 4: HTTP auth
128
+ if (!this.authenticateHttp(req, res)) return;
129
+ this.handleRoomsRequest(req, res);
130
+ return;
131
+ }
132
+
133
+ if (req.url.startsWith("/ufoo/online/channels")) {
134
+ // Step 4: HTTP auth
135
+ if (!this.authenticateHttp(req, res)) return;
136
+ this.handleChannelsRequest(req, res);
137
+ return;
138
+ }
139
+
140
+ res.writeHead(200, { "Content-Type": "text/plain" });
141
+ res.end("ufoo-online: running\n");
142
+ };
143
+
144
+ // Step 7: TLS support
145
+ if (this.tlsCert && this.tlsKey) {
146
+ this.server = https.createServer(
147
+ {
148
+ cert: fs.readFileSync(this.tlsCert),
149
+ key: fs.readFileSync(this.tlsKey),
150
+ },
151
+ requestHandler
152
+ );
153
+ } else {
154
+ this.server = http.createServer(requestHandler);
155
+ }
156
+
157
+ // Step 2: WS maxPayload
158
+ this.wsServer = new WebSocket.Server({ noServer: true, maxPayload: this.maxWsPayloadBytes });
159
+ this.wsServer.on("connection", (ws) => this.handleConnection(ws));
160
+
161
+ this.server.on("upgrade", (req, socket, head) => {
162
+ if (!req.url || !req.url.startsWith("/ufoo/online")) {
163
+ socket.destroy();
164
+ return;
165
+ }
166
+
167
+ // Security: enforce connection limits before upgrade
168
+ const totalConnections = this.wsServer ? this.wsServer.clients.size : 0;
169
+ if (totalConnections >= this.maxConnections) {
170
+ socket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
171
+ socket.destroy();
172
+ return;
173
+ }
174
+
175
+ const ip = req.socket.remoteAddress || "unknown";
176
+ const ipCount = this.connectionsByIp.get(ip) || 0;
177
+ if (ipCount >= this.maxConnectionsPerIp) {
178
+ socket.write("HTTP/1.1 429 Too Many Requests\r\n\r\n");
179
+ socket.destroy();
180
+ return;
181
+ }
182
+
183
+ this.wsServer.handleUpgrade(req, socket, head, (ws) => {
184
+ ws._remoteIp = ip;
185
+ this.connectionsByIp.set(ip, ipCount + 1);
186
+ this.wsServer.emit("connection", ws, req);
187
+ });
188
+ });
189
+
190
+ return new Promise((resolve) => {
191
+ this.server.listen(this.port, this.host, () => {
192
+ const address = this.server.address();
193
+ const actualPort = address && typeof address === "object" ? address.port : this.port;
194
+ this.port = actualPort;
195
+ this.emit("listening", { host: this.host, port: this.port });
196
+ this.startIdleSweep();
197
+ resolve();
198
+ });
199
+ });
200
+ }
201
+
202
+ stop() {
203
+ const server = this.server;
204
+ const wsServer = this.wsServer;
205
+ this.server = null;
206
+ this.wsServer = null;
207
+
208
+ this.stopIdleSweep();
209
+
210
+ if (wsServer) {
211
+ wsServer.clients.forEach((client) => client.terminate());
212
+ wsServer.close();
213
+ }
214
+
215
+ if (!server) return Promise.resolve();
216
+
217
+ return new Promise((resolve) => {
218
+ server.close(() => resolve());
219
+ });
220
+ }
221
+
222
+ // Step 4: HTTP bearer token authentication
223
+ authenticateHttp(req, res) {
224
+ if (this.allowAnyToken) return true;
225
+
226
+ const auth = req.headers.authorization || "";
227
+ const match = auth.match(/^Bearer\s+(.+)$/i);
228
+ if (!match) {
229
+ this.sendJson(res, 401, { ok: false, error: "Unauthorized" });
230
+ return false;
231
+ }
232
+ const token = match[1];
233
+ if (!this.allowedTokens || !this.allowedTokens.has(token)) {
234
+ this.sendJson(res, 401, { ok: false, error: "Unauthorized" });
235
+ return false;
236
+ }
237
+ return true;
238
+ }
239
+
240
+ // Step 3: readBody with size limit
241
+ readBody(req) {
242
+ const limit = this.maxHttpBodyBytes;
243
+ return new Promise((resolve, reject) => {
244
+ let body = "";
245
+ let bytes = 0;
246
+ req.on("data", (chunk) => {
247
+ bytes += chunk.length;
248
+ if (bytes > limit) {
249
+ req.destroy();
250
+ reject(new Error("Payload too large"));
251
+ return;
252
+ }
253
+ body += chunk.toString();
254
+ });
255
+ req.on("end", () => resolve(body));
256
+ req.on("error", (err) => reject(err));
257
+ });
258
+ }
259
+
260
+ sendJson(res, statusCode, payload) {
261
+ res.writeHead(statusCode, { "Content-Type": "application/json" });
262
+ res.end(JSON.stringify(payload));
263
+ }
264
+
265
+ // Step 6: scrypt password hashing (replaces SHA256)
266
+ hashPassword(password) {
267
+ const salt = crypto.randomBytes(16).toString("hex");
268
+ const derived = crypto.scryptSync(String(password || ""), salt, 32);
269
+ return `${salt}:${derived.toString("hex")}`;
270
+ }
271
+
272
+ verifyPassword(password, stored) {
273
+ if (!stored || !stored.includes(":")) return false;
274
+ const [salt, hash] = stored.split(":");
275
+ if (!salt || !hash) return false;
276
+ const derived = crypto.scryptSync(String(password || ""), salt, 32);
277
+ const expected = Buffer.from(hash, "hex");
278
+ if (derived.length !== expected.length) return false;
279
+ return crypto.timingSafeEqual(derived, expected);
280
+ }
281
+
282
+ listRooms() {
283
+ return Array.from(this.rooms.entries()).map(([roomId, room]) => ({
284
+ room_id: roomId,
285
+ name: room.name || "",
286
+ type: room.type,
287
+ members: room.members.size,
288
+ created_at: room.created_at,
289
+ }));
290
+ }
291
+
292
+ listChannels() {
293
+ return Array.from(this.channels.entries()).map(([channelId, channel]) => ({
294
+ channel_id: channelId,
295
+ name: channel.name || "",
296
+ type: channel.type || "public",
297
+ members: channel.members.size,
298
+ created_at: channel.created_at,
299
+ }));
300
+ }
301
+
302
+ handleRoomsRequest(req, res) {
303
+ if (req.method === "GET") {
304
+ this.sendJson(res, 200, { ok: true, rooms: this.listRooms() });
305
+ return;
306
+ }
307
+
308
+ if (req.method === "POST") {
309
+ this.readBody(req)
310
+ .then((body) => {
311
+ let payload = null;
312
+ try {
313
+ payload = JSON.parse(body || "{}");
314
+ } catch {
315
+ payload = null;
316
+ }
317
+ if (!payload || !payload.type) {
318
+ this.sendJson(res, 400, { ok: false, error: "Missing type" });
319
+ return;
320
+ }
321
+ const name = String(payload.name || "").trim();
322
+ const type = String(payload.type).trim();
323
+ if (!["public", "private"].includes(type)) {
324
+ this.sendJson(res, 400, { ok: false, error: "Invalid room type" });
325
+ return;
326
+ }
327
+ if (name) {
328
+ const nameErr = this.validateIdentifier(name, "name");
329
+ if (nameErr) { this.sendJson(res, 400, { ok: false, error: nameErr }); return; }
330
+ }
331
+ if (this.rooms.size >= this.maxRooms) {
332
+ this.sendJson(res, 429, { ok: false, error: "Room limit reached" });
333
+ return;
334
+ }
335
+ let roomId = "";
336
+ let attempts = 0;
337
+ do {
338
+ roomId = `room_${crypto.randomInt(1000000).toString().padStart(6, "0")}`;
339
+ if (++attempts > 100) {
340
+ this.sendJson(res, 503, { ok: false, error: "Unable to generate room ID" });
341
+ return;
342
+ }
343
+ } while (this.rooms.has(roomId));
344
+ if (type === "private") {
345
+ const password = String(payload.password || "");
346
+ if (!password) {
347
+ this.sendJson(res, 400, { ok: false, error: "Private room requires password" });
348
+ return;
349
+ }
350
+ this.roomPasswords.set(roomId, this.hashPassword(password));
351
+ }
352
+ this.rooms.set(roomId, {
353
+ name,
354
+ type,
355
+ members: new Set(),
356
+ created_at: new Date().toISOString(),
357
+ });
358
+ this.sendJson(res, 200, { ok: true, room: { room_id: roomId, name, type } });
359
+ })
360
+ .catch(() => {
361
+ // Step 3: 413 on payload too large
362
+ this.sendJson(res, 413, { ok: false, error: "Payload too large" });
363
+ });
364
+ return;
365
+ }
366
+
367
+ this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
368
+ }
369
+
370
+ handleChannelsRequest(req, res) {
371
+ if (req.method === "GET") {
372
+ this.sendJson(res, 200, { ok: true, channels: this.listChannels() });
373
+ return;
374
+ }
375
+
376
+ if (req.method === "POST") {
377
+ this.readBody(req)
378
+ .then((body) => {
379
+ let payload = null;
380
+ try {
381
+ payload = JSON.parse(body || "{}");
382
+ } catch {
383
+ payload = null;
384
+ }
385
+ if (!payload || !payload.name) {
386
+ this.sendJson(res, 400, { ok: false, error: "Missing name" });
387
+ return;
388
+ }
389
+ const name = String(payload.name || "").trim();
390
+ const type = String(payload.type || "public").trim();
391
+ if (!name) {
392
+ this.sendJson(res, 400, { ok: false, error: "Invalid channel name" });
393
+ return;
394
+ }
395
+ const chNameErr = this.validateIdentifier(name, "name");
396
+ if (chNameErr) { this.sendJson(res, 400, { ok: false, error: chNameErr }); return; }
397
+ if (!["world", "public"].includes(type)) {
398
+ this.sendJson(res, 400, { ok: false, error: "Invalid channel type" });
399
+ return;
400
+ }
401
+ if (this.channelNames.has(name)) {
402
+ this.sendJson(res, 409, { ok: false, error: "Channel name already exists" });
403
+ return;
404
+ }
405
+ if (this.channels.size >= this.maxChannels) {
406
+ this.sendJson(res, 429, { ok: false, error: "Channel limit reached" });
407
+ return;
408
+ }
409
+ let channelId = "";
410
+ let chAttempts = 0;
411
+ do {
412
+ channelId = `channel_${crypto.randomInt(1000000).toString().padStart(6, "0")}`;
413
+ if (++chAttempts > 100) {
414
+ this.sendJson(res, 503, { ok: false, error: "Unable to generate channel ID" });
415
+ return;
416
+ }
417
+ } while (this.channels.has(channelId));
418
+ this.channels.set(channelId, {
419
+ name,
420
+ type,
421
+ members: new Set(),
422
+ created_at: new Date().toISOString(),
423
+ });
424
+ this.channelNames.set(name, channelId);
425
+ this.sendJson(res, 200, { ok: true, channel: { channel_id: channelId, name, type } });
426
+ })
427
+ .catch(() => {
428
+ // Step 3: 413 on payload too large
429
+ this.sendJson(res, 413, { ok: false, error: "Payload too large" });
430
+ });
431
+ return;
432
+ }
433
+
434
+ this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
435
+ }
436
+
437
+ startIdleSweep() {
438
+ if (this.sweepTimer || this.idleTimeoutMs <= 0) return;
439
+ this.sweepTimer = setInterval(() => {
440
+ const now = Date.now();
441
+ if (!this.wsServer) return;
442
+ this.wsServer.clients.forEach((ws) => {
443
+ const client = ws._ufooClient;
444
+ if (!client) return;
445
+ // Security: disconnect pre-auth connections faster than idle timeout
446
+ if (!client.authed && now - client.connectedAt >= this.authDeadlineMs) {
447
+ this.sendError(ws, "Auth deadline exceeded", true, "AUTH_DEADLINE");
448
+ return;
449
+ }
450
+ if (now - client.lastSeen >= this.idleTimeoutMs) {
451
+ this.sendError(ws, "Disconnected due to inactivity", true, "IDLE_TIMEOUT");
452
+ }
453
+ });
454
+
455
+ // Security: prune expired roomAuthFailures entries
456
+ for (const [key, info] of this.roomAuthFailures) {
457
+ if (info.lockedUntil > 0 && info.lockedUntil <= now) {
458
+ this.roomAuthFailures.delete(key);
459
+ }
460
+ }
461
+ }, this.sweepIntervalMs);
462
+ if (this.sweepTimer.unref) this.sweepTimer.unref();
463
+ }
464
+
465
+ stopIdleSweep() {
466
+ if (this.sweepTimer) {
467
+ clearInterval(this.sweepTimer);
468
+ this.sweepTimer = null;
469
+ }
470
+ }
471
+
472
+ handleConnection(ws) {
473
+ const client = {
474
+ ws,
475
+ authed: false,
476
+ subscriberId: null,
477
+ nickname: null,
478
+ channels: new Set(),
479
+ helloReceived: false,
480
+ connectedAt: Date.now(),
481
+ lastSeen: Date.now(),
482
+ // Step 5: Rate limiting state
483
+ messageCount: 0,
484
+ rateLimitWindowStart: Date.now(),
485
+ };
486
+
487
+ ws._ufooClient = client;
488
+
489
+ ws.on("message", (data) => {
490
+ client.lastSeen = Date.now();
491
+ this.handleMessage(client, data);
492
+ });
493
+
494
+ ws.on("close", () => {
495
+ this.cleanupClient(client);
496
+ });
497
+ }
498
+
499
+ send(ws, payload) {
500
+ if (ws.readyState === WebSocket.OPEN) {
501
+ ws.send(JSON.stringify(payload));
502
+ }
503
+ }
504
+
505
+ sendError(ws, error, close = false, code = null) {
506
+ if (ws.readyState !== WebSocket.OPEN) {
507
+ if (close) ws.close();
508
+ return;
509
+ }
510
+ const payload = code ? { type: "error", code, error } : { type: "error", error };
511
+ if (close) {
512
+ ws.send(JSON.stringify(payload), () => {
513
+ ws.close();
514
+ });
515
+ return;
516
+ }
517
+ this.send(ws, payload);
518
+ }
519
+
520
+ requireAuth(client) {
521
+ if (!client.authed) {
522
+ this.sendError(client.ws, "Unauthorized", false, "UNAUTHORIZED");
523
+ return false;
524
+ }
525
+ return true;
526
+ }
527
+
528
+ // Step 5: Rate limiting check
529
+ checkRateLimit(client) {
530
+ const now = Date.now();
531
+ if (now - client.rateLimitWindowStart >= this.rateLimitWindow) {
532
+ // Reset window
533
+ client.messageCount = 0;
534
+ client.rateLimitWindowStart = now;
535
+ }
536
+ client.messageCount++;
537
+ if (client.messageCount > this.rateLimitMax) {
538
+ this.sendError(client.ws, "Rate limit exceeded", true, "RATE_LIMITED");
539
+ return false;
540
+ }
541
+ return true;
542
+ }
543
+
544
+ handleMessage(client, data) {
545
+ // Step 5: Rate limit check at entry
546
+ if (!this.checkRateLimit(client)) return;
547
+
548
+ let message = null;
549
+ try {
550
+ message = JSON.parse(data.toString());
551
+ } catch {
552
+ this.sendError(client.ws, "Invalid JSON");
553
+ return;
554
+ }
555
+
556
+ if (!message || typeof message.type !== "string") {
557
+ this.sendError(client.ws, "Invalid message", false, "INVALID_MESSAGE");
558
+ return;
559
+ }
560
+
561
+ switch (message.type) {
562
+ case "hello":
563
+ this.handleHello(client, message);
564
+ return;
565
+ case "auth":
566
+ this.handleAuth(client, message);
567
+ return;
568
+ case "join":
569
+ this.handleJoin(client, message);
570
+ return;
571
+ case "leave":
572
+ this.handleLeave(client, message);
573
+ return;
574
+ case "ping":
575
+ this.send(client.ws, { type: "pong" });
576
+ return;
577
+ case "pong":
578
+ return;
579
+ case "event":
580
+ this.handleEvent(client, message);
581
+ return;
582
+ default:
583
+ this.sendError(client.ws, "Unknown message type", false, "UNKNOWN_TYPE");
584
+ }
585
+ }
586
+
587
+ validateIdentifier(value, label) {
588
+ if (typeof value !== "string" || !value) return `Missing ${label}`;
589
+ if (value.length > this.maxIdLength) return `${label} too long (max ${this.maxIdLength})`;
590
+ // eslint-disable-next-line no-control-regex
591
+ if (/[\x00-\x1f\x7f]/.test(value)) return `${label} contains invalid characters`;
592
+ return null;
593
+ }
594
+
595
+ handleHello(client, message) {
596
+ if (client.helloReceived) {
597
+ this.sendError(client.ws, "Hello already received", false, "HELLO_DUPLICATE");
598
+ return;
599
+ }
600
+
601
+ const info = message.client || {};
602
+ const subscriberId = info.subscriber_id;
603
+ const nickname = info.nickname;
604
+ const world = info.world || "default";
605
+
606
+ if (!subscriberId || !nickname) {
607
+ this.sendError(client.ws, "Missing subscriber_id or nickname", false, "HELLO_INVALID");
608
+ return;
609
+ }
610
+
611
+ // Security: sanitize subscriber_id and nickname
612
+ const idErr = this.validateIdentifier(subscriberId, "subscriber_id");
613
+ if (idErr) { this.sendError(client.ws, idErr, true, "HELLO_INVALID"); return; }
614
+ const nickErr = this.validateIdentifier(nickname, "nickname");
615
+ if (nickErr) { this.sendError(client.ws, nickErr, true, "HELLO_INVALID"); return; }
616
+
617
+ client.helloReceived = true;
618
+ // Security: store pending identity — do NOT register in global maps until auth succeeds
619
+ client.pendingSubscriberId = subscriberId;
620
+ client.pendingNickname = nickname;
621
+ client.pendingWorld = world;
622
+ client.rooms = new Set();
623
+
624
+ this.send(client.ws, {
625
+ type: "hello_ack",
626
+ ok: true,
627
+ server: {
628
+ version: this.version,
629
+ time: new Date().toISOString(),
630
+ },
631
+ });
632
+
633
+ this.send(client.ws, {
634
+ type: "auth_required",
635
+ methods: ["token"],
636
+ });
637
+ }
638
+
639
+ isNicknameTaken(nickname, world) {
640
+ if (this.nicknameScope === "global") {
641
+ return this.clientsByNickname.has(nickname);
642
+ }
643
+ for (const client of this.clientsByNickname.values()) {
644
+ if (client.nickname === nickname && client.world === world) return true;
645
+ }
646
+ return false;
647
+ }
648
+
649
+ handleAuth(client, message) {
650
+ if (!client.helloReceived) {
651
+ this.sendError(client.ws, "Hello required", false, "HELLO_REQUIRED");
652
+ return;
653
+ }
654
+
655
+ if (client.authed) {
656
+ this.sendError(client.ws, "Already authenticated", false, "AUTH_DUPLICATE");
657
+ return;
658
+ }
659
+
660
+ if (message.method !== "token") {
661
+ this.sendError(client.ws, "Unsupported auth method", false, "AUTH_METHOD_UNSUPPORTED");
662
+ return;
663
+ }
664
+
665
+ if (!message.token && !message.token_hash) {
666
+ this.sendError(client.ws, "Missing token", false, "AUTH_TOKEN_MISSING");
667
+ return;
668
+ }
669
+
670
+ const tokenToCheck = message.token_hash || message.token;
671
+ if (!this.allowAnyToken && !this.allowedTokens.has(tokenToCheck)) {
672
+ this.sendError(client.ws, "Invalid token", true, "AUTH_TOKEN_INVALID");
673
+ return;
674
+ }
675
+
676
+ // Security: register identity AFTER auth succeeds (prevents nickname squatting)
677
+ const subscriberId = client.pendingSubscriberId;
678
+ const nickname = client.pendingNickname;
679
+ const world = client.pendingWorld;
680
+
681
+ if (this.clientsById.has(subscriberId)) {
682
+ this.sendError(client.ws, "Subscriber already connected", true, "SUBSCRIBER_EXISTS");
683
+ return;
684
+ }
685
+
686
+ if (this.isNicknameTaken(nickname, world)) {
687
+ this.sendError(client.ws, "Nickname already exists", true, "NICKNAME_TAKEN");
688
+ return;
689
+ }
690
+
691
+ client.subscriberId = subscriberId;
692
+ client.nickname = nickname;
693
+ client.world = world;
694
+ this.clientsById.set(subscriberId, client);
695
+ this.clientsByNickname.set(nickname, client);
696
+
697
+ client.authed = true;
698
+ this.send(client.ws, { type: "auth_ok", ok: true });
699
+ }
700
+
701
+ resolveChannel(channelRef) {
702
+ if (!channelRef) return null;
703
+ const direct = this.channels.get(channelRef);
704
+ if (direct) {
705
+ return { channelId: channelRef, channel: direct };
706
+ }
707
+ const mappedId = this.channelNames.get(channelRef);
708
+ if (!mappedId) return null;
709
+ const mapped = this.channels.get(mappedId);
710
+ if (!mapped) return null;
711
+ return { channelId: mappedId, channel: mapped };
712
+ }
713
+
714
+ getOrCreateJoinChannel(channelRef) {
715
+ const existing = this.resolveChannel(channelRef);
716
+ if (existing) return existing;
717
+
718
+ const channelErr = this.validateIdentifier(channelRef, "channel");
719
+ if (channelErr) {
720
+ return { error: channelErr, code: "CHANNEL_INVALID" };
721
+ }
722
+ if (this.channels.size >= this.maxChannels) {
723
+ return { error: "Channel limit reached", code: "CHANNEL_LIMIT" };
724
+ }
725
+
726
+ const channel = {
727
+ name: channelRef,
728
+ type: "public",
729
+ members: new Set(),
730
+ created_at: new Date().toISOString(),
731
+ };
732
+ this.channels.set(channelRef, channel);
733
+ this.channelNames.set(channelRef, channelRef);
734
+ return { channelId: channelRef, channel };
735
+ }
736
+
737
+ handleJoin(client, message) {
738
+ if (!this.requireAuth(client)) return;
739
+ const channel = message.channel;
740
+ const room = message.room;
741
+
742
+ if (room) {
743
+ this.handleRoomJoin(client, message);
744
+ return;
745
+ }
746
+
747
+ if (!channel) {
748
+ this.sendError(client.ws, "Missing channel", false, "CHANNEL_MISSING");
749
+ return;
750
+ }
751
+
752
+ const resolved = this.getOrCreateJoinChannel(channel);
753
+ if (!resolved || resolved.error) {
754
+ this.sendError(
755
+ client.ws,
756
+ resolved?.error || "Channel not found",
757
+ false,
758
+ resolved?.code || "CHANNEL_NOT_FOUND",
759
+ );
760
+ return;
761
+ }
762
+ const channelId = resolved.channelId;
763
+ const channelInfo = resolved.channel;
764
+
765
+ channelInfo.members.add(client);
766
+ client.channels.add(channelId);
767
+ this.send(client.ws, { type: "join_ack", ok: true, channel: channelId });
768
+ }
769
+
770
+ handleLeave(client, message) {
771
+ if (!this.requireAuth(client)) return;
772
+ const channel = message.channel;
773
+ const room = message.room;
774
+
775
+ if (room) {
776
+ this.handleRoomLeave(client, message);
777
+ return;
778
+ }
779
+
780
+ if (!channel) {
781
+ this.sendError(client.ws, "Missing channel", false, "CHANNEL_MISSING");
782
+ return;
783
+ }
784
+
785
+ const resolved = this.resolveChannel(channel);
786
+ const channelId = resolved?.channelId || channel;
787
+ const channelInfo = resolved?.channel || null;
788
+ if (channelInfo) {
789
+ channelInfo.members.delete(client);
790
+ }
791
+ client.channels.delete(channelId);
792
+ this.send(client.ws, { type: "leave_ack", ok: true, channel: channelId });
793
+ }
794
+
795
+ handleEvent(client, message) {
796
+ if (!this.requireAuth(client)) return;
797
+ if (!client.subscriberId) {
798
+ this.sendError(client.ws, "Unknown subscriber", false, "SUBSCRIBER_UNKNOWN");
799
+ return;
800
+ }
801
+
802
+ if (!message.payload || typeof message.payload.kind !== "string") {
803
+ this.sendError(client.ws, "Missing payload.kind", false, "EVENT_INVALID");
804
+ return;
805
+ }
806
+
807
+ if (message.from && message.from !== client.subscriberId) {
808
+ this.sendError(client.ws, "Invalid sender", false, "EVENT_SENDER_INVALID");
809
+ return;
810
+ }
811
+
812
+ // Security: whitelist forwarded fields instead of spreading entire message
813
+ const payload = {
814
+ type: message.type,
815
+ from: client.subscriberId,
816
+ ts: message.ts || new Date().toISOString(),
817
+ payload: message.payload,
818
+ };
819
+ if (message.to) payload.to = message.to;
820
+ if (message.id) payload.id = message.id;
821
+ if (message.channel) payload.channel = message.channel;
822
+ if (message.room) payload.room = message.room;
823
+
824
+ const kind = payload.payload.kind;
825
+
826
+ // Resolve allowed kinds based on routing target
827
+ const resolveAllowed = () => {
828
+ if (payload.room) {
829
+ const room = this.rooms.get(payload.room);
830
+ if (room && room.type === "private") return new Set(["message", "decisions.sync", "bus.sync", "wake"]);
831
+ return new Set(["message", "wake"]);
832
+ }
833
+ if (payload.channel) return new Set(["message"]);
834
+ return new Set();
835
+ };
836
+
837
+ const allowed = resolveAllowed();
838
+ if (!allowed.has(kind)) {
839
+ this.sendError(client.ws, "Event kind not allowed for this target", false, "EVENT_KIND_FORBIDDEN");
840
+ return;
841
+ }
842
+
843
+ if (payload.room) {
844
+ if (!client.rooms.has(payload.room)) {
845
+ this.sendError(client.ws, "Join room first", false, "NOT_IN_ROOM");
846
+ return;
847
+ }
848
+ const room = this.rooms.get(payload.room);
849
+ if (!room) {
850
+ this.sendError(client.ws, "Room not found", false, "ROOM_NOT_FOUND");
851
+ return;
852
+ }
853
+ room.members.forEach((member) => {
854
+ if (member !== client) {
855
+ this.send(member.ws, payload);
856
+ if (payload.payload && payload.payload.kind === "wake") {
857
+ this.send(member.ws, { type: "wake", from: client.subscriberId });
858
+ }
859
+ }
860
+ });
861
+ return;
862
+ }
863
+
864
+ if (payload.channel) {
865
+ const resolved = this.resolveChannel(payload.channel);
866
+ if (!resolved) {
867
+ this.sendError(client.ws, "Channel not found", false, "CHANNEL_NOT_FOUND");
868
+ return;
869
+ }
870
+ const channelId = resolved.channelId;
871
+ const channel = resolved.channel;
872
+
873
+ if (!client.channels.has(channelId)) {
874
+ this.sendError(client.ws, "Join channel first", false, "NOT_IN_CHANNEL");
875
+ return;
876
+ }
877
+ const members = channel ? channel.members : null;
878
+ if (!members || members.size === 0) return;
879
+ payload.channel = channelId;
880
+ members.forEach((member) => {
881
+ if (member !== client) {
882
+ this.send(member.ws, payload);
883
+ if (payload.payload && payload.payload.kind === "wake") {
884
+ this.send(member.ws, { type: "wake", from: client.subscriberId });
885
+ }
886
+ }
887
+ });
888
+ return;
889
+ }
890
+
891
+ this.sendError(client.ws, "Missing routing target", false, "ROUTE_MISSING");
892
+ }
893
+
894
+ handleRoomJoin(client, message) {
895
+ const roomId = String(message.room || "").trim();
896
+ if (!roomId) {
897
+ this.sendError(client.ws, "Missing room", false, "ROOM_MISSING");
898
+ return;
899
+ }
900
+ const room = this.rooms.get(roomId);
901
+ if (!room) {
902
+ this.sendError(client.ws, "Room not found", false, "ROOM_NOT_FOUND");
903
+ return;
904
+ }
905
+ if (room.type === "private") {
906
+ // Security: brute-force protection
907
+ const clientKey = client.subscriberId || (client.ws._remoteIp || "unknown");
908
+ const failInfo = this.roomAuthFailures.get(clientKey);
909
+ if (failInfo && failInfo.lockedUntil > Date.now()) {
910
+ this.sendError(client.ws, "Too many failed attempts, try again later", false, "ROOM_AUTH_LOCKED");
911
+ return;
912
+ }
913
+
914
+ const password = String(message.password || "");
915
+ const stored = this.roomPasswords.get(roomId);
916
+ // Step 6: scrypt verification
917
+ if (!stored || !this.verifyPassword(password, stored)) {
918
+ const info = failInfo || { count: 0, lockedUntil: 0 };
919
+ info.count++;
920
+ if (info.count >= this.maxRoomAuthFailures) {
921
+ info.lockedUntil = Date.now() + this.roomAuthLockoutMs;
922
+ info.count = 0;
923
+ }
924
+ this.roomAuthFailures.set(clientKey, info);
925
+ this.sendError(client.ws, "Invalid room password", false, "ROOM_PASSWORD_INVALID");
926
+ return;
927
+ }
928
+ // Reset on success
929
+ this.roomAuthFailures.delete(clientKey);
930
+ }
931
+
932
+ if (client.rooms.size >= 1 && !client.rooms.has(roomId)) {
933
+ this.sendError(client.ws, "Already in another room", false, "ROOM_ALREADY_JOINED");
934
+ return;
935
+ }
936
+
937
+ room.members.add(client);
938
+ client.rooms.add(roomId);
939
+ this.send(client.ws, { type: "join_ack", ok: true, room: roomId });
940
+ }
941
+
942
+ handleRoomLeave(client, message) {
943
+ const roomId = String(message.room || "").trim();
944
+ if (!roomId) {
945
+ this.sendError(client.ws, "Missing room", false, "ROOM_MISSING");
946
+ return;
947
+ }
948
+ const room = this.rooms.get(roomId);
949
+ if (room) {
950
+ room.members.delete(client);
951
+ }
952
+ client.rooms.delete(roomId);
953
+ this.send(client.ws, { type: "leave_ack", ok: true, room: roomId });
954
+ }
955
+
956
+ cleanupClient(client) {
957
+ if (client.subscriberId) {
958
+ this.clientsById.delete(client.subscriberId);
959
+ }
960
+ if (client.nickname) {
961
+ this.clientsByNickname.delete(client.nickname);
962
+ }
963
+
964
+ client.channels.forEach((channel) => {
965
+ const channelInfo = this.channels.get(channel);
966
+ if (channelInfo) {
967
+ channelInfo.members.delete(client);
968
+ }
969
+ });
970
+ client.channels.clear();
971
+
972
+ if (client.rooms) {
973
+ client.rooms.forEach((roomId) => {
974
+ const room = this.rooms.get(roomId);
975
+ if (room) {
976
+ room.members.delete(client);
977
+ }
978
+ });
979
+ client.rooms.clear();
980
+ }
981
+
982
+ // Security: decrement per-IP connection count
983
+ const ip = client.ws._remoteIp;
984
+ if (ip && this.connectionsByIp.has(ip)) {
985
+ const count = this.connectionsByIp.get(ip) - 1;
986
+ if (count <= 0) this.connectionsByIp.delete(ip);
987
+ else this.connectionsByIp.set(ip, count);
988
+ }
989
+ }
990
+ }
991
+
992
+ module.exports = OnlineServer;