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.
- package/README.md +247 -23
- package/SKILLS/ufoo/SKILL.md +17 -2
- package/SKILLS/uinit/SKILL.md +8 -3
- package/bin/ucode-core.js +15 -0
- package/bin/ucode.js +125 -0
- package/bin/ufoo-assistant-agent.js +5 -0
- package/bin/ufoo-engine.js +25 -0
- package/bin/ufoo.js +4 -0
- package/modules/AGENTS.template.md +14 -4
- package/modules/bus/README.md +8 -5
- package/modules/bus/SKILLS/ubus/SKILL.md +5 -4
- package/modules/context/SKILLS/uctx/SKILL.md +3 -1
- package/modules/online/SKILLS/ufoo-online/SKILL.md +144 -0
- package/package.json +12 -3
- package/scripts/import-pi-mono.js +124 -0
- package/scripts/postinstall.js +20 -49
- package/scripts/sync-claude-skills.sh +21 -0
- package/src/agent/cliRunner.js +524 -31
- package/src/agent/internalRunner.js +76 -9
- package/src/agent/launcher.js +97 -45
- package/src/agent/normalizeOutput.js +1 -1
- package/src/agent/notifier.js +144 -4
- package/src/agent/ptyRunner.js +480 -10
- package/src/agent/ptyWrapper.js +28 -3
- package/src/agent/readyDetector.js +16 -0
- package/src/agent/ucode.js +443 -0
- package/src/agent/ucodeBootstrap.js +113 -0
- package/src/agent/ucodeBuild.js +67 -0
- package/src/agent/ucodeDoctor.js +184 -0
- package/src/agent/ucodeRuntimeConfig.js +129 -0
- package/src/agent/ufooAgent.js +168 -28
- package/src/assistant/agent.js +260 -0
- package/src/assistant/bridge.js +172 -0
- package/src/assistant/engine.js +252 -0
- package/src/assistant/stdio.js +58 -0
- package/src/assistant/ufooEngineCli.js +306 -0
- package/src/bus/activate.js +27 -11
- package/src/bus/daemon.js +133 -5
- package/src/bus/index.js +137 -80
- package/src/bus/inject.js +47 -17
- package/src/bus/message.js +145 -17
- package/src/bus/nickname.js +3 -1
- package/src/bus/queue.js +6 -1
- package/src/bus/store.js +189 -0
- package/src/bus/subscriber.js +20 -4
- package/src/bus/utils.js +9 -3
- package/src/chat/agentBar.js +117 -0
- package/src/chat/agentDirectory.js +88 -0
- package/src/chat/agentSockets.js +225 -0
- package/src/chat/agentViewController.js +298 -0
- package/src/chat/chatLogController.js +115 -0
- package/src/chat/commandExecutor.js +700 -0
- package/src/chat/commands.js +132 -0
- package/src/chat/completionController.js +414 -0
- package/src/chat/cronScheduler.js +160 -0
- package/src/chat/daemonConnection.js +166 -0
- package/src/chat/daemonCoordinator.js +64 -0
- package/src/chat/daemonMessageRouter.js +257 -0
- package/src/chat/daemonReconnect.js +41 -0
- package/src/chat/daemonTransport.js +36 -0
- package/src/chat/daemonTransportDefaults.js +10 -0
- package/src/chat/dashboardKeyController.js +480 -0
- package/src/chat/dashboardView.js +157 -0
- package/src/chat/index.js +938 -2910
- package/src/chat/inputHistoryController.js +105 -0
- package/src/chat/inputListenerController.js +304 -0
- package/src/chat/inputMath.js +104 -0
- package/src/chat/inputSubmitHandler.js +171 -0
- package/src/chat/layout.js +165 -0
- package/src/chat/pasteController.js +81 -0
- package/src/chat/rawKeyMap.js +42 -0
- package/src/chat/settingsController.js +133 -0
- package/src/chat/statusLineController.js +177 -0
- package/src/chat/streamTracker.js +138 -0
- package/src/chat/text.js +70 -0
- package/src/chat/transport.js +61 -0
- package/src/cli/busCoreCommands.js +59 -0
- package/src/cli/ctxCoreCommands.js +199 -0
- package/src/cli/onlineCoreCommands.js +379 -0
- package/src/cli.js +741 -238
- package/src/code/README.md +29 -0
- package/src/code/UCODE_PROMPT.md +32 -0
- package/src/code/agent.js +1651 -0
- package/src/code/cli.js +158 -0
- package/src/code/config +0 -0
- package/src/code/dispatch.js +42 -0
- package/src/code/index.js +70 -0
- package/src/code/nativeRunner.js +1213 -0
- package/src/code/runtime.js +154 -0
- package/src/code/sessionStore.js +162 -0
- package/src/code/taskDecomposer.js +269 -0
- package/src/code/tools/bash.js +53 -0
- package/src/code/tools/common.js +42 -0
- package/src/code/tools/edit.js +70 -0
- package/src/code/tools/read.js +44 -0
- package/src/code/tools/write.js +35 -0
- package/src/code/tui.js +1587 -0
- package/src/config.js +50 -2
- package/src/context/decisions.js +12 -2
- package/src/context/index.js +18 -1
- package/src/context/sync.js +127 -0
- package/src/daemon/agentProcessManager.js +74 -0
- package/src/daemon/cronOps.js +241 -0
- package/src/daemon/index.js +662 -489
- package/src/daemon/ipcServer.js +99 -0
- package/src/daemon/ops.js +417 -179
- package/src/daemon/promptLoop.js +319 -0
- package/src/daemon/promptRequest.js +101 -0
- package/src/daemon/providerSessions.js +32 -17
- package/src/daemon/reporting.js +90 -0
- package/src/daemon/run.js +2 -5
- package/src/daemon/status.js +24 -1
- package/src/init/index.js +68 -14
- package/src/online/bridge.js +663 -0
- package/src/online/client.js +245 -0
- package/src/online/runner.js +253 -0
- package/src/online/server.js +992 -0
- package/src/online/tokens.js +103 -0
- package/src/report/store.js +331 -0
- package/src/shared/eventContract.js +35 -0
- package/src/shared/ptySocketContract.js +21 -0
- package/src/status/index.js +50 -17
- package/src/terminal/adapterContract.js +87 -0
- package/src/terminal/adapterRouter.js +84 -0
- package/src/terminal/adapters/externalAdapter.js +14 -0
- package/src/terminal/adapters/internalAdapter.js +13 -0
- package/src/terminal/adapters/internalPtyAdapter.js +42 -0
- package/src/terminal/adapters/internalQueueAdapter.js +37 -0
- package/src/terminal/adapters/terminalAdapter.js +31 -0
- package/src/terminal/adapters/tmuxAdapter.js +30 -0
- package/src/ufoo/agentsStore.js +69 -3
- package/src/utils/banner.js +5 -2
- package/scripts/.archived/bash-to-js-migration/README.md +0 -46
- package/scripts/.archived/bash-to-js-migration/banner.sh +0 -89
- package/scripts/.archived/bash-to-js-migration/bus-alert.sh +0 -6
- package/scripts/.archived/bash-to-js-migration/bus-autotrigger.sh +0 -6
- package/scripts/.archived/bash-to-js-migration/bus-daemon.sh +0 -231
- package/scripts/.archived/bash-to-js-migration/bus-inject.sh +0 -176
- package/scripts/.archived/bash-to-js-migration/bus-listen.sh +0 -6
- package/scripts/.archived/bash-to-js-migration/bus.sh +0 -986
- package/scripts/.archived/bash-to-js-migration/context-decisions.sh +0 -167
- package/scripts/.archived/bash-to-js-migration/context-doctor.sh +0 -72
- package/scripts/.archived/bash-to-js-migration/context-lint.sh +0 -110
- package/scripts/.archived/bash-to-js-migration/doctor.sh +0 -22
- package/scripts/.archived/bash-to-js-migration/init.sh +0 -247
- package/scripts/.archived/bash-to-js-migration/skills.sh +0 -113
- package/scripts/.archived/bash-to-js-migration/status.sh +0 -125
- package/scripts/banner.sh +0 -2
- 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;
|