ciphermesh 2.0.0 → 2.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 +40 -7
- package/README.pt-BR.md +40 -7
- package/bin/ciphermesh.js +5 -0
- package/docs/ARCHITECTURE.md +44 -1
- package/docs/PLUGINS.md +69 -0
- package/examples/plugins/poll.js +28 -0
- package/examples/plugins/roll.js +24 -0
- package/package.json +4 -3
- package/src/client/ChatController.js +814 -92
- package/src/client/UI.js +262 -6
- package/src/client/index.js +50 -7
- package/src/crypto/RoomKey.js +162 -0
- package/src/crypto/TrustStore.js +47 -0
- package/src/p2p/P2PChatController.js +9 -1
- package/src/protocol/messages.js +76 -8
- package/src/protocol/validators.js +61 -1
- package/src/server/SessionManager.js +124 -16
- package/src/server/WebSocketServer.js +339 -41
- package/src/shared/AuditLog.js +3 -0
- package/src/shared/PluginManager.js +9 -6
- package/src/shared/config.js +27 -2
- package/src/shared/constants.js +8 -0
- package/src/shared/lastSession.js +55 -0
- package/src/shared/onboarding.js +111 -0
package/src/protocol/messages.js
CHANGED
|
@@ -12,8 +12,14 @@ export const MSG = {
|
|
|
12
12
|
PEER_KEY_UPDATED: 'peer_key_updated',
|
|
13
13
|
CHANGE_ROOM: 'change_room',
|
|
14
14
|
ROOM_CHANGED: 'room_changed',
|
|
15
|
+
JOIN_ROOM: 'join_room',
|
|
16
|
+
ROOM_JOINED: 'room_joined',
|
|
17
|
+
LEAVE_ROOM: 'leave_room',
|
|
18
|
+
ROOM_LEFT: 'room_left',
|
|
15
19
|
LIST_ROOMS: 'list_rooms',
|
|
16
20
|
ROOM_LIST: 'room_list',
|
|
21
|
+
ROOM_CHALLENGE: 'room_challenge',
|
|
22
|
+
ROOM_AUTH: 'room_auth',
|
|
17
23
|
KICK_PEER: 'kick_peer',
|
|
18
24
|
MUTE_PEER: 'mute_peer',
|
|
19
25
|
BAN_PEER: 'ban_peer',
|
|
@@ -30,6 +36,8 @@ export const ERR = {
|
|
|
30
36
|
PEER_NOT_FOUND: 'PEER_NOT_FOUND',
|
|
31
37
|
RATE_LIMITED: 'RATE_LIMITED',
|
|
32
38
|
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
|
|
39
|
+
ROOM_AUTH_FAILED: 'ROOM_AUTH_FAILED',
|
|
40
|
+
ROOM_EXISTS: 'ROOM_EXISTS',
|
|
33
41
|
};
|
|
34
42
|
|
|
35
43
|
// ── Factory helpers ────────────────────────────────────────────
|
|
@@ -49,12 +57,22 @@ export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general
|
|
|
49
57
|
return ack;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
// `room` (multi-room, additive): which room this event refers to. A peer can
|
|
61
|
+
// leave room X while still sharing room Y with you. Old clients ignore it.
|
|
62
|
+
export function createPeerJoined(peer, room = null) {
|
|
63
|
+
const msg = { ...base(MSG.PEER_JOINED), peer };
|
|
64
|
+
if (room) {
|
|
65
|
+
msg.room = room;
|
|
66
|
+
}
|
|
67
|
+
return msg;
|
|
54
68
|
}
|
|
55
69
|
|
|
56
|
-
export function createPeerLeft(sessionId, nickname) {
|
|
57
|
-
|
|
70
|
+
export function createPeerLeft(sessionId, nickname, room = null) {
|
|
71
|
+
const msg = { ...base(MSG.PEER_LEFT), sessionId, nickname };
|
|
72
|
+
if (room) {
|
|
73
|
+
msg.room = room;
|
|
74
|
+
}
|
|
75
|
+
return msg;
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
|
|
@@ -108,12 +126,62 @@ export function createPong() {
|
|
|
108
126
|
return base(MSG.PONG);
|
|
109
127
|
}
|
|
110
128
|
|
|
111
|
-
|
|
112
|
-
|
|
129
|
+
// roomAuthPk (optional): Ed25519 verifier public key — present only when
|
|
130
|
+
// CREATING a private room; the server stores it in memory as the room's
|
|
131
|
+
// password verifier (see crypto/RoomKey.js).
|
|
132
|
+
export function createChangeRoom(room, roomAuthPkB64 = null) {
|
|
133
|
+
const msg = { ...base(MSG.CHANGE_ROOM), room };
|
|
134
|
+
if (roomAuthPkB64) {
|
|
135
|
+
msg.roomAuthPk = roomAuthPkB64;
|
|
136
|
+
}
|
|
137
|
+
return msg;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createRoomChanged(room, peers, isPrivate = false) {
|
|
141
|
+
const msg = { ...base(MSG.ROOM_CHANGED), room, peers };
|
|
142
|
+
if (isPrivate) {
|
|
143
|
+
msg.private = true;
|
|
144
|
+
}
|
|
145
|
+
return msg;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Multi-room (IRC-style buffers) ─────────────────────────────
|
|
149
|
+
// join_room ADDS a membership (unlike change_room, which replaces them all).
|
|
150
|
+
// Same optional roomAuthPk as change_room for creating a private room.
|
|
151
|
+
export function createJoinRoom(room, roomAuthPkB64 = null) {
|
|
152
|
+
const msg = { ...base(MSG.JOIN_ROOM), room };
|
|
153
|
+
if (roomAuthPkB64) {
|
|
154
|
+
msg.roomAuthPk = roomAuthPkB64;
|
|
155
|
+
}
|
|
156
|
+
return msg;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function createRoomJoined(room, peers, isPrivate = false) {
|
|
160
|
+
const msg = { ...base(MSG.ROOM_JOINED), room, peers };
|
|
161
|
+
if (isPrivate) {
|
|
162
|
+
msg.private = true;
|
|
163
|
+
}
|
|
164
|
+
return msg;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createLeaveRoom(room) {
|
|
168
|
+
return { ...base(MSG.LEAVE_ROOM), room };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createRoomLeft(room) {
|
|
172
|
+
return { ...base(MSG.ROOM_LEFT), room };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Server → client: the target room is private; prove password knowledge by
|
|
176
|
+
// signing this nonce. The password itself never travels.
|
|
177
|
+
export function createRoomChallenge(room, nonceB64) {
|
|
178
|
+
return { ...base(MSG.ROOM_CHALLENGE), room, nonce: nonceB64 };
|
|
113
179
|
}
|
|
114
180
|
|
|
115
|
-
|
|
116
|
-
|
|
181
|
+
// Client → server: Ed25519 signature over (room, nonce, sessionId) with the
|
|
182
|
+
// password-derived key (see crypto/RoomKey.js#signRoomChallenge).
|
|
183
|
+
export function createRoomAuth(room, nonceB64, signatureB64) {
|
|
184
|
+
return { ...base(MSG.ROOM_AUTH), room, nonce: nonceB64, signature: signatureB64 };
|
|
117
185
|
}
|
|
118
186
|
|
|
119
187
|
export function createListRooms() {
|
|
@@ -3,6 +3,9 @@ import {
|
|
|
3
3
|
MAX_NICKNAME_LENGTH,
|
|
4
4
|
MAX_PAYLOAD_SIZE,
|
|
5
5
|
PUBLIC_KEY_SIZE,
|
|
6
|
+
ROOM_AUTH_PK_SIZE,
|
|
7
|
+
ROOM_AUTH_SIG_SIZE,
|
|
8
|
+
ROOM_CHALLENGE_NONCE_SIZE,
|
|
6
9
|
} from '../shared/constants.js';
|
|
7
10
|
|
|
8
11
|
// ── Helpers ────────────────────────────────────────────────────
|
|
@@ -109,6 +112,26 @@ export function validateKeyUpdate(msg) {
|
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export function validateChangeRoom(msg) {
|
|
115
|
+
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
116
|
+
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
117
|
+
}
|
|
118
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
|
|
119
|
+
return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
|
|
120
|
+
}
|
|
121
|
+
// Optional: Ed25519 verifier public key, present only when creating a
|
|
122
|
+
// private room.
|
|
123
|
+
if (msg.roomAuthPk !== undefined && !isValidBase64(msg.roomAuthPk, ROOM_AUTH_PK_SIZE)) {
|
|
124
|
+
return { valid: false, error: 'Invalid room verifier key' };
|
|
125
|
+
}
|
|
126
|
+
return { valid: true, room: msg.room.toLowerCase(), roomAuthPk: msg.roomAuthPk || null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// join_room shares change_room's shape (room + optional verifier key).
|
|
130
|
+
export function validateJoinRoom(msg) {
|
|
131
|
+
return validateChangeRoom(msg);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function validateLeaveRoom(msg) {
|
|
112
135
|
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
113
136
|
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
114
137
|
}
|
|
@@ -118,19 +141,47 @@ export function validateChangeRoom(msg) {
|
|
|
118
141
|
return { valid: true, room: msg.room.toLowerCase() };
|
|
119
142
|
}
|
|
120
143
|
|
|
144
|
+
export function validateRoomAuth(msg) {
|
|
145
|
+
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
146
|
+
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
147
|
+
}
|
|
148
|
+
if (!isValidBase64(msg.nonce, ROOM_CHALLENGE_NONCE_SIZE)) {
|
|
149
|
+
return { valid: false, error: 'Invalid challenge nonce' };
|
|
150
|
+
}
|
|
151
|
+
if (!isValidBase64(msg.signature, ROOM_AUTH_SIG_SIZE)) {
|
|
152
|
+
return { valid: false, error: 'Invalid challenge signature' };
|
|
153
|
+
}
|
|
154
|
+
return { valid: true, room: msg.room.toLowerCase(), nonce: msg.nonce, signature: msg.signature };
|
|
155
|
+
}
|
|
156
|
+
|
|
121
157
|
export function validateListRooms() {
|
|
122
158
|
return { valid: true };
|
|
123
159
|
}
|
|
124
160
|
|
|
161
|
+
// Optional multi-room context on moderation commands: which room the owner is
|
|
162
|
+
// acting on. Absent → the server falls back to the session's only room.
|
|
163
|
+
function optionalRoom(msg) {
|
|
164
|
+
if (msg.room === undefined) {
|
|
165
|
+
return { ok: true, room: null };
|
|
166
|
+
}
|
|
167
|
+
const v = validateLeaveRoom({ room: msg.room });
|
|
168
|
+
return v.valid ? { ok: true, room: v.room } : { ok: false, error: v.error };
|
|
169
|
+
}
|
|
170
|
+
|
|
125
171
|
export function validateKickPeer(msg) {
|
|
126
172
|
const nick = sanitizeNickname(msg.targetNickname);
|
|
127
173
|
if (!nick) {
|
|
128
174
|
return { valid: false, error: 'Invalid target nickname' };
|
|
129
175
|
}
|
|
176
|
+
const roomCheck = optionalRoom(msg);
|
|
177
|
+
if (!roomCheck.ok) {
|
|
178
|
+
return { valid: false, error: roomCheck.error };
|
|
179
|
+
}
|
|
130
180
|
return {
|
|
131
181
|
valid: true,
|
|
132
182
|
targetNickname: nick,
|
|
133
183
|
reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
|
|
184
|
+
room: roomCheck.room,
|
|
134
185
|
};
|
|
135
186
|
}
|
|
136
187
|
|
|
@@ -142,7 +193,11 @@ export function validateMutePeer(msg) {
|
|
|
142
193
|
if (!isNumber(msg.durationMs) || msg.durationMs <= 0) {
|
|
143
194
|
return { valid: false, error: 'Invalid mute duration' };
|
|
144
195
|
}
|
|
145
|
-
|
|
196
|
+
const roomCheck = optionalRoom(msg);
|
|
197
|
+
if (!roomCheck.ok) {
|
|
198
|
+
return { valid: false, error: roomCheck.error };
|
|
199
|
+
}
|
|
200
|
+
return { valid: true, targetNickname: nick, durationMs: msg.durationMs, room: roomCheck.room };
|
|
146
201
|
}
|
|
147
202
|
|
|
148
203
|
export function validateBanPeer(msg) {
|
|
@@ -150,10 +205,15 @@ export function validateBanPeer(msg) {
|
|
|
150
205
|
if (!nick) {
|
|
151
206
|
return { valid: false, error: 'Invalid target nickname' };
|
|
152
207
|
}
|
|
208
|
+
const roomCheck = optionalRoom(msg);
|
|
209
|
+
if (!roomCheck.ok) {
|
|
210
|
+
return { valid: false, error: roomCheck.error };
|
|
211
|
+
}
|
|
153
212
|
return {
|
|
154
213
|
valid: true,
|
|
155
214
|
targetNickname: nick,
|
|
156
215
|
reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
|
|
216
|
+
room: roomCheck.room,
|
|
157
217
|
};
|
|
158
218
|
}
|
|
159
219
|
|
|
@@ -4,13 +4,14 @@ import { createLogger } from '../shared/logger.js';
|
|
|
4
4
|
const log = createLogger('session');
|
|
5
5
|
|
|
6
6
|
export class SessionManager {
|
|
7
|
-
#sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt,
|
|
7
|
+
#sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt, rooms: Set }>
|
|
8
8
|
#nicknames; // Set<nickname> for quick dupe check
|
|
9
9
|
#recentlyLeft; // Map<sessionId, { nickname, publicKey, leftAt }>
|
|
10
10
|
#rooms; // Map<roomName, Set<sessionId>>
|
|
11
11
|
#roomOwners; // Map<roomName, sessionId>
|
|
12
12
|
#muteState; // Map<sessionId, { until: timestamp }>
|
|
13
13
|
#banList; // Map<roomName, Set<nickname_lower>>
|
|
14
|
+
#roomMeta; // Map<roomName, { authPk: base64 }> — private-room verifiers (memory only)
|
|
14
15
|
|
|
15
16
|
constructor() {
|
|
16
17
|
this.#sessions = new Map();
|
|
@@ -20,6 +21,7 @@ export class SessionManager {
|
|
|
20
21
|
this.#roomOwners = new Map();
|
|
21
22
|
this.#muteState = new Map();
|
|
22
23
|
this.#banList = new Map();
|
|
24
|
+
this.#roomMeta = new Map();
|
|
23
25
|
// Ensure default room exists
|
|
24
26
|
this.#rooms.set('general', new Set());
|
|
25
27
|
}
|
|
@@ -35,7 +37,7 @@ export class SessionManager {
|
|
|
35
37
|
nickname,
|
|
36
38
|
publicKey,
|
|
37
39
|
connectedAt: Date.now(),
|
|
38
|
-
|
|
40
|
+
rooms: new Set(),
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
this.#sessions.set(sessionId, session);
|
|
@@ -52,7 +54,9 @@ export class SessionManager {
|
|
|
52
54
|
return null;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
for (const room of [...session.rooms]) {
|
|
58
|
+
this.#leaveRoom(sessionId, room);
|
|
59
|
+
}
|
|
56
60
|
this.#nicknames.delete(session.nickname.toLowerCase());
|
|
57
61
|
this.#sessions.delete(sessionId);
|
|
58
62
|
this.#muteState.delete(sessionId);
|
|
@@ -91,7 +95,7 @@ export class SessionManager {
|
|
|
91
95
|
const peers = [];
|
|
92
96
|
for (const [id, session] of this.#sessions) {
|
|
93
97
|
if (id !== excludeSessionId) {
|
|
94
|
-
if (room && session.room
|
|
98
|
+
if (room && !session.rooms.has(room)) {
|
|
95
99
|
continue;
|
|
96
100
|
}
|
|
97
101
|
peers.push({
|
|
@@ -104,6 +108,35 @@ export class SessionManager {
|
|
|
104
108
|
return peers;
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Send a message once to every session that shares at least one room with
|
|
113
|
+
* the given session (deduplicated — a peer in two shared rooms gets one).
|
|
114
|
+
*/
|
|
115
|
+
broadcastToPeersOf(sessionId, msg, excludeSessionId = sessionId) {
|
|
116
|
+
const session = this.#sessions.get(sessionId);
|
|
117
|
+
if (!session) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const data = JSON.stringify(msg);
|
|
121
|
+
const notified = new Set();
|
|
122
|
+
for (const room of session.rooms) {
|
|
123
|
+
const members = this.#rooms.get(room);
|
|
124
|
+
if (!members) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
for (const sid of members) {
|
|
128
|
+
if (sid === excludeSessionId || notified.has(sid)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
notified.add(sid);
|
|
132
|
+
const peer = this.#sessions.get(sid);
|
|
133
|
+
if (peer && peer.ws.readyState === 1) {
|
|
134
|
+
peer.ws.send(data);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
107
140
|
/**
|
|
108
141
|
* Send a JSON message to all sessions except one.
|
|
109
142
|
*/
|
|
@@ -145,6 +178,7 @@ export class SessionManager {
|
|
|
145
178
|
this.#rooms.set(room, new Set());
|
|
146
179
|
}
|
|
147
180
|
this.#rooms.get(room).add(sessionId);
|
|
181
|
+
this.#sessions.get(sessionId)?.rooms.add(room);
|
|
148
182
|
|
|
149
183
|
// First person to create/join an empty non-general room becomes owner
|
|
150
184
|
if (isNew && room !== 'general' && !this.#roomOwners.has(room)) {
|
|
@@ -153,13 +187,13 @@ export class SessionManager {
|
|
|
153
187
|
}
|
|
154
188
|
}
|
|
155
189
|
|
|
156
|
-
#leaveRoom(sessionId) {
|
|
190
|
+
#leaveRoom(sessionId, room) {
|
|
157
191
|
const session = this.#sessions.get(sessionId);
|
|
158
192
|
if (!session) {
|
|
159
193
|
return;
|
|
160
194
|
}
|
|
161
195
|
|
|
162
|
-
|
|
196
|
+
session.rooms.delete(room);
|
|
163
197
|
const members = this.#rooms.get(room);
|
|
164
198
|
if (members) {
|
|
165
199
|
members.delete(sessionId);
|
|
@@ -167,9 +201,12 @@ export class SessionManager {
|
|
|
167
201
|
if (members.size === 0 && room !== 'general') {
|
|
168
202
|
// Empty non-general room — drop it and all associated moderation state
|
|
169
203
|
// (otherwise a recreated room stays owner-less and unmoderatable).
|
|
204
|
+
// The private-room verifier dies here too: the room and its password
|
|
205
|
+
// exist only while someone is inside.
|
|
170
206
|
this.#rooms.delete(room);
|
|
171
207
|
this.#roomOwners.delete(room);
|
|
172
208
|
this.#banList.delete(room);
|
|
209
|
+
this.#roomMeta.delete(room);
|
|
173
210
|
} else if (this.#roomOwners.get(room) === sessionId) {
|
|
174
211
|
// Owner left but room still has members — transfer ownership so the
|
|
175
212
|
// room keeps a moderator instead of becoming owner-less.
|
|
@@ -180,23 +217,68 @@ export class SessionManager {
|
|
|
180
217
|
}
|
|
181
218
|
}
|
|
182
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Legacy single-room semantics (protocol `change_room`): leave every
|
|
222
|
+
* current room and end up only in `newRoom`.
|
|
223
|
+
*/
|
|
183
224
|
switchRoom(sessionId, newRoom) {
|
|
184
225
|
const session = this.#sessions.get(sessionId);
|
|
185
226
|
if (!session) {
|
|
186
227
|
return null;
|
|
187
228
|
}
|
|
188
229
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return null;
|
|
230
|
+
if (session.rooms.size === 1 && session.rooms.has(newRoom)) {
|
|
231
|
+
return null; // already exactly there
|
|
192
232
|
}
|
|
193
233
|
|
|
194
|
-
|
|
195
|
-
|
|
234
|
+
const oldRooms = [...session.rooms].filter((r) => r !== newRoom);
|
|
235
|
+
for (const room of oldRooms) {
|
|
236
|
+
this.#leaveRoom(sessionId, room);
|
|
237
|
+
}
|
|
196
238
|
this.#joinRoom(sessionId, newRoom);
|
|
197
239
|
|
|
198
|
-
log.info(`${session.nickname} switched room: ${
|
|
199
|
-
return { oldRoom, newRoom };
|
|
240
|
+
log.info(`${session.nickname} switched room: ${oldRooms.join(',') || '-'} → ${newRoom}`);
|
|
241
|
+
return { oldRoom: oldRooms[0] || null, oldRooms, newRoom };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Multi-room: join an ADDITIONAL room, keeping the current ones.
|
|
246
|
+
* Returns null when already a member.
|
|
247
|
+
*/
|
|
248
|
+
joinAdditional(sessionId, room) {
|
|
249
|
+
const session = this.#sessions.get(sessionId);
|
|
250
|
+
if (!session || session.rooms.has(room)) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
this.#joinRoom(sessionId, room);
|
|
254
|
+
log.info(`${session.nickname} joined room ${room} (now in ${session.rooms.size})`);
|
|
255
|
+
return { room };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Multi-room: leave one room. Refuses to leave the last one (a session is
|
|
260
|
+
* always somewhere — that keeps peer visibility and moderation coherent).
|
|
261
|
+
*/
|
|
262
|
+
leaveOneRoom(sessionId, room) {
|
|
263
|
+
const session = this.#sessions.get(sessionId);
|
|
264
|
+
if (!session || !session.rooms.has(room)) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (session.rooms.size === 1) {
|
|
268
|
+
return { lastRoom: true };
|
|
269
|
+
}
|
|
270
|
+
this.#leaveRoom(sessionId, room);
|
|
271
|
+
log.info(`${session.nickname} left room ${room} (now in ${session.rooms.size})`);
|
|
272
|
+
return { room };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
getSessionRooms(sessionId) {
|
|
276
|
+
const session = this.#sessions.get(sessionId);
|
|
277
|
+
return session ? [...session.rooms] : [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
isInRoom(sessionId, room) {
|
|
281
|
+
return this.#sessions.get(sessionId)?.rooms.has(room) === true;
|
|
200
282
|
}
|
|
201
283
|
|
|
202
284
|
getRoomPeers(room, excludeSessionId) {
|
|
@@ -207,19 +289,45 @@ export class SessionManager {
|
|
|
207
289
|
const rooms = [];
|
|
208
290
|
for (const [name, members] of this.#rooms) {
|
|
209
291
|
if (members.size > 0) {
|
|
210
|
-
rooms.push({ name, memberCount: members.size });
|
|
292
|
+
rooms.push({ name, memberCount: members.size, private: this.#roomMeta.has(name) });
|
|
211
293
|
}
|
|
212
294
|
}
|
|
213
295
|
// Always include 'general' even if empty
|
|
214
296
|
if (!rooms.some((r) => r.name === 'general')) {
|
|
215
|
-
rooms.unshift({ name: 'general', memberCount: 0 });
|
|
297
|
+
rooms.unshift({ name: 'general', memberCount: 0, private: false });
|
|
216
298
|
}
|
|
217
299
|
return rooms.sort((a, b) => a.name.localeCompare(b.name));
|
|
218
300
|
}
|
|
219
301
|
|
|
302
|
+
// ── Private rooms ────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
roomHasMembers(room) {
|
|
305
|
+
const members = this.#rooms.get(room);
|
|
306
|
+
return !!members && members.size > 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
setRoomPrivate(room, authPkB64) {
|
|
310
|
+
this.#roomMeta.set(room, { authPk: authPkB64 });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
isRoomPrivate(room) {
|
|
314
|
+
return this.#roomMeta.has(room);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
getRoomAuthPk(room) {
|
|
318
|
+
return this.#roomMeta.get(room)?.authPk || null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Legacy helper: a session's room when it has exactly one; null otherwise.
|
|
323
|
+
* Multi-room callers must pass the room explicitly instead.
|
|
324
|
+
*/
|
|
220
325
|
getSessionRoom(sessionId) {
|
|
221
326
|
const session = this.#sessions.get(sessionId);
|
|
222
|
-
|
|
327
|
+
if (!session) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
return session.rooms.size === 1 ? [...session.rooms][0] : null;
|
|
223
331
|
}
|
|
224
332
|
|
|
225
333
|
updatePublicKey(sessionId, newPublicKey) {
|