ciphermesh 2.0.0 → 2.1.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 +33 -6
- package/README.pt-BR.md +33 -6
- package/bin/ciphermesh.js +5 -0
- package/docs/ARCHITECTURE.md +41 -1
- package/docs/PLUGINS.md +69 -0
- package/examples/plugins/poll.js +28 -0
- package/examples/plugins/roll.js +24 -0
- package/package.json +2 -1
- package/src/client/ChatController.js +385 -9
- package/src/client/UI.js +128 -4
- 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 +31 -4
- package/src/protocol/validators.js +22 -1
- package/src/server/SessionManager.js +26 -2
- package/src/server/WebSocketServer.js +138 -1
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createServer as createHttpsServer } from 'node:https';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { WebSocketServer as WSServer } from 'ws';
|
|
3
4
|
import { createLogger } from '../shared/logger.js';
|
|
4
5
|
import {
|
|
@@ -8,6 +9,10 @@ import {
|
|
|
8
9
|
MAX_CONNECTIONS_PER_IP,
|
|
9
10
|
JOIN_TIMEOUT_MS,
|
|
10
11
|
MESSAGE_RATE_LIMIT_PER_SECOND,
|
|
12
|
+
ROOM_CHALLENGE_NONCE_SIZE,
|
|
13
|
+
ROOM_CHALLENGE_TTL_MS,
|
|
14
|
+
ROOM_AUTH_MAX_FAILS,
|
|
15
|
+
ROOM_AUTH_FAIL_WINDOW_MS,
|
|
11
16
|
} from '../shared/constants.js';
|
|
12
17
|
import {
|
|
13
18
|
MSG,
|
|
@@ -16,6 +21,7 @@ import {
|
|
|
16
21
|
createPeerLeft,
|
|
17
22
|
createPeerKeyUpdated,
|
|
18
23
|
createRoomChanged,
|
|
24
|
+
createRoomChallenge,
|
|
19
25
|
createRoomList,
|
|
20
26
|
createPeerKicked,
|
|
21
27
|
createPeerMuted,
|
|
@@ -28,10 +34,12 @@ import {
|
|
|
28
34
|
validateEncryptedMessage,
|
|
29
35
|
validateKeyUpdate,
|
|
30
36
|
validateChangeRoom,
|
|
37
|
+
validateRoomAuth,
|
|
31
38
|
validateKickPeer,
|
|
32
39
|
validateMutePeer,
|
|
33
40
|
validateBanPeer,
|
|
34
41
|
} from '../protocol/validators.js';
|
|
42
|
+
import { verifyRoomChallenge } from '../crypto/RoomKey.js';
|
|
35
43
|
|
|
36
44
|
const log = createLogger('ws-server');
|
|
37
45
|
|
|
@@ -188,6 +196,10 @@ export class SecureWSServer {
|
|
|
188
196
|
this.#handleChangeRoom(ws, msg);
|
|
189
197
|
break;
|
|
190
198
|
|
|
199
|
+
case MSG.ROOM_AUTH:
|
|
200
|
+
this.#handleRoomAuth(ws, msg);
|
|
201
|
+
break;
|
|
202
|
+
|
|
191
203
|
case MSG.LIST_ROOMS:
|
|
192
204
|
this.#handleListRooms(ws);
|
|
193
205
|
break;
|
|
@@ -353,6 +365,41 @@ export class SecureWSServer {
|
|
|
353
365
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
|
|
354
366
|
return;
|
|
355
367
|
}
|
|
368
|
+
|
|
369
|
+
// Creating a private room: register the password verifier, but only for
|
|
370
|
+
// a room that doesn't exist yet (rooms die when the last member leaves).
|
|
371
|
+
if (validation.roomAuthPk) {
|
|
372
|
+
if (validation.room === 'general' || this.#sessionManager.roomHasMembers(validation.room)) {
|
|
373
|
+
ws.send(
|
|
374
|
+
JSON.stringify(
|
|
375
|
+
createError(ERR.ROOM_EXISTS, 'Room already exists — join it with /join instead'),
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
|
|
381
|
+
if (!result) {
|
|
382
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.#sessionManager.setRoomPrivate(validation.room, validation.roomAuthPk);
|
|
386
|
+
this.#finishRoomSwitch(ws, session, result);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Joining a private room: don't switch yet — issue a challenge the client
|
|
391
|
+
// must sign with the password-derived key (see #handleRoomAuth).
|
|
392
|
+
if (this.#sessionManager.isRoomPrivate(validation.room)) {
|
|
393
|
+
const nonce = randomBytes(ROOM_CHALLENGE_NONCE_SIZE).toString('base64');
|
|
394
|
+
ws.roomChallenge = {
|
|
395
|
+
room: validation.room,
|
|
396
|
+
nonce,
|
|
397
|
+
expiresAt: Date.now() + ROOM_CHALLENGE_TTL_MS,
|
|
398
|
+
};
|
|
399
|
+
ws.send(JSON.stringify(createRoomChallenge(validation.room, nonce)));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
356
403
|
const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
|
|
357
404
|
if (!result) {
|
|
358
405
|
// Already in this room
|
|
@@ -360,6 +407,92 @@ export class SecureWSServer {
|
|
|
360
407
|
return;
|
|
361
408
|
}
|
|
362
409
|
|
|
410
|
+
this.#finishRoomSwitch(ws, session, result);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#handleRoomAuth(ws, msg) {
|
|
414
|
+
if (!ws.hasJoined || !ws.sessionId) {
|
|
415
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const validation = validateRoomAuth(msg);
|
|
420
|
+
if (!validation.valid) {
|
|
421
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Throttle wrong-password attempts per connection.
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
if (
|
|
428
|
+
!ws.roomAuthFailWindowStart ||
|
|
429
|
+
now - ws.roomAuthFailWindowStart >= ROOM_AUTH_FAIL_WINDOW_MS
|
|
430
|
+
) {
|
|
431
|
+
ws.roomAuthFailWindowStart = now;
|
|
432
|
+
ws.roomAuthFails = 0;
|
|
433
|
+
}
|
|
434
|
+
if (ws.roomAuthFails >= ROOM_AUTH_MAX_FAILS) {
|
|
435
|
+
ws.send(
|
|
436
|
+
JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many failed attempts — wait a minute')),
|
|
437
|
+
);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const challenge = ws.roomChallenge;
|
|
442
|
+
if (
|
|
443
|
+
!challenge ||
|
|
444
|
+
challenge.room !== validation.room ||
|
|
445
|
+
challenge.nonce !== validation.nonce ||
|
|
446
|
+
now > challenge.expiresAt
|
|
447
|
+
) {
|
|
448
|
+
ws.send(JSON.stringify(createError(ERR.ROOM_AUTH_FAILED, 'Challenge expired — /join again')));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
ws.roomChallenge = null;
|
|
452
|
+
|
|
453
|
+
const authPkB64 = this.#sessionManager.getRoomAuthPk(validation.room);
|
|
454
|
+
if (!authPkB64) {
|
|
455
|
+
// Room emptied (and died) between challenge and answer.
|
|
456
|
+
ws.send(
|
|
457
|
+
JSON.stringify(
|
|
458
|
+
createError(ERR.ROOM_AUTH_FAILED, 'Room no longer exists — /join again to create it'),
|
|
459
|
+
),
|
|
460
|
+
);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const ok = verifyRoomChallenge(
|
|
465
|
+
Buffer.from(authPkB64, 'base64'),
|
|
466
|
+
Buffer.from(validation.signature, 'base64'),
|
|
467
|
+
validation.room,
|
|
468
|
+
validation.nonce,
|
|
469
|
+
ws.sessionId,
|
|
470
|
+
);
|
|
471
|
+
if (!ok) {
|
|
472
|
+
ws.roomAuthFails++;
|
|
473
|
+
ws.send(JSON.stringify(createError(ERR.ROOM_AUTH_FAILED, 'Wrong room password')));
|
|
474
|
+
log.warn(`Failed room auth for ${validation.room} (${ws.sessionId.slice(0, 8)})`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const session = this.#sessionManager.getSession(ws.sessionId);
|
|
479
|
+
if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
|
|
480
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
|
|
485
|
+
if (!result) {
|
|
486
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
this.#finishRoomSwitch(ws, session, result);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Shared tail of a successful room switch: notify both rooms and send the
|
|
494
|
+
// ROOM_CHANGED (with the private flag) to the mover.
|
|
495
|
+
#finishRoomSwitch(ws, session, result) {
|
|
363
496
|
// Notify old room that peer left
|
|
364
497
|
this.#sessionManager.broadcastToRoom(
|
|
365
498
|
result.oldRoom,
|
|
@@ -380,7 +513,11 @@ export class SecureWSServer {
|
|
|
380
513
|
|
|
381
514
|
// Send new room info to the client
|
|
382
515
|
const newPeers = this.#sessionManager.getRoomPeers(result.newRoom, ws.sessionId);
|
|
383
|
-
const roomChanged = createRoomChanged(
|
|
516
|
+
const roomChanged = createRoomChanged(
|
|
517
|
+
result.newRoom,
|
|
518
|
+
newPeers,
|
|
519
|
+
this.#sessionManager.isRoomPrivate(result.newRoom),
|
|
520
|
+
);
|
|
384
521
|
const newOwnerSid = this.#sessionManager.getRoomOwner(result.newRoom);
|
|
385
522
|
if (newOwnerSid) {
|
|
386
523
|
const ownerSess = this.#sessionManager.getSession(newOwnerSid);
|
package/src/shared/AuditLog.js
CHANGED
|
@@ -26,6 +26,9 @@ export const AuditEvent = {
|
|
|
26
26
|
ADMIN_KICK: 'ADMIN_KICK',
|
|
27
27
|
ADMIN_MUTE: 'ADMIN_MUTE',
|
|
28
28
|
ADMIN_BAN: 'ADMIN_BAN',
|
|
29
|
+
SCREEN_LOCKED: 'SCREEN_LOCKED',
|
|
30
|
+
SCREEN_UNLOCKED: 'SCREEN_UNLOCKED',
|
|
31
|
+
SCREEN_UNLOCK_FAILED: 'SCREEN_UNLOCK_FAILED',
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
export class AuditLog {
|
|
@@ -4,7 +4,10 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Resolved lazily so tests (and future flags) can point HOME elsewhere.
|
|
8
|
+
export function pluginDir() {
|
|
9
|
+
return join(homedir(), '.ciphermesh', 'plugins');
|
|
10
|
+
}
|
|
8
11
|
|
|
9
12
|
export class PluginManager {
|
|
10
13
|
#plugins; // Map<name, module>
|
|
@@ -15,15 +18,15 @@ export class PluginManager {
|
|
|
15
18
|
this.#commands = new Map();
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
async loadAll() {
|
|
19
|
-
if (!existsSync(
|
|
20
|
-
mkdirSync(
|
|
21
|
+
async loadAll(dir = pluginDir()) {
|
|
22
|
+
if (!existsSync(dir)) {
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
let files;
|
|
25
28
|
try {
|
|
26
|
-
files = await readdir(
|
|
29
|
+
files = await readdir(dir);
|
|
27
30
|
} catch {
|
|
28
31
|
return;
|
|
29
32
|
}
|
|
@@ -32,7 +35,7 @@ export class PluginManager {
|
|
|
32
35
|
|
|
33
36
|
for (const file of jsFiles) {
|
|
34
37
|
try {
|
|
35
|
-
const filePath = join(
|
|
38
|
+
const filePath = join(dir, file);
|
|
36
39
|
const fileUrl = pathToFileURL(filePath).href;
|
|
37
40
|
const mod = await import(fileUrl);
|
|
38
41
|
const plugin = mod.default || mod;
|
package/src/shared/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
|
|
5
5
|
// Optional user config at ~/.ciphermesh/config.json. Everything is a default the
|
|
6
6
|
// user can still override at the prompt or with a slash-command. Unknown keys
|
|
@@ -15,6 +15,7 @@ const ALLOWED = [
|
|
|
15
15
|
'deniable',
|
|
16
16
|
'theme',
|
|
17
17
|
'autoAway',
|
|
18
|
+
'autoLock',
|
|
18
19
|
'dnd',
|
|
19
20
|
];
|
|
20
21
|
|
|
@@ -51,6 +52,27 @@ export function loadConfig(path = configPath()) {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/** True if a config file already exists (used to detect the first run). */
|
|
56
|
+
export function hasConfigFile(path = configPath()) {
|
|
57
|
+
return existsSync(path);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Persist config (whitelisted keys only, merged over what's on disk so a
|
|
62
|
+
* partial save never wipes hand-edited settings). Returns what was written.
|
|
63
|
+
*/
|
|
64
|
+
export function saveConfig(cfg, path = configPath()) {
|
|
65
|
+
const merged = { ...loadConfig(path) };
|
|
66
|
+
for (const k of ALLOWED) {
|
|
67
|
+
if (cfg[k] !== undefined) {
|
|
68
|
+
merged[k] = cfg[k];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
72
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
73
|
+
return merged;
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
/**
|
|
55
77
|
* Translate config toggles into the slash-commands that apply them, so startup
|
|
56
78
|
* reuses the exact command handlers (no duplicated logic). Pure — testable.
|
|
@@ -81,6 +103,9 @@ export function startupCommands(config) {
|
|
|
81
103
|
if (Number.isInteger(config.autoAway) && config.autoAway > 0) {
|
|
82
104
|
cmds.push(`/autoaway ${config.autoAway}`);
|
|
83
105
|
}
|
|
106
|
+
if (Number.isInteger(config.autoLock) && config.autoLock > 0) {
|
|
107
|
+
cmds.push(`/autolock ${config.autoLock}`);
|
|
108
|
+
}
|
|
84
109
|
if (config.dnd === 'on' || config.dnd === 'mentions') {
|
|
85
110
|
cmds.push(`/dnd ${config.dnd}`);
|
|
86
111
|
} else if (typeof config.dnd === 'string' && /^\d/.test(config.dnd)) {
|
package/src/shared/constants.js
CHANGED
|
@@ -41,6 +41,14 @@ export const OFFLINE_QUEUE_MAX_PER_PEER = 100;
|
|
|
41
41
|
export const OFFLINE_QUEUE_MAX_AGE_MS = 3_600_000; // 1h
|
|
42
42
|
export const OFFLINE_QUEUE_MAX_TOTAL = 1000;
|
|
43
43
|
|
|
44
|
+
// Private rooms (password-protected, zero-knowledge)
|
|
45
|
+
export const ROOM_AUTH_PK_SIZE = 32; // Ed25519 verifier public key
|
|
46
|
+
export const ROOM_AUTH_SIG_SIZE = 64; // Ed25519 detached signature
|
|
47
|
+
export const ROOM_CHALLENGE_NONCE_SIZE = 24;
|
|
48
|
+
export const ROOM_CHALLENGE_TTL_MS = 60_000; // challenge must be answered within this
|
|
49
|
+
export const ROOM_AUTH_MAX_FAILS = 5; // wrong-password attempts per connection…
|
|
50
|
+
export const ROOM_AUTH_FAIL_WINDOW_MS = 60_000; // …within this window
|
|
51
|
+
|
|
44
52
|
// Message padding (anti-metadata): every ciphertext is padded up to one of
|
|
45
53
|
// these bucket sizes so the relay can't read the true plaintext length.
|
|
46
54
|
export const MESSAGE_PAD_BUCKETS = [128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Remembers where the user was (server + room) so the next launch can offer to
|
|
2
|
+
// reconnect. Deliberately tiny and best-effort: losing this file only costs a
|
|
3
|
+
// couple of keystrokes. Privacy: callers must NOT pass the room of a private
|
|
4
|
+
// room — its name never touches disk (see ChatController#saveLastSession).
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function lastSessionPath() {
|
|
10
|
+
return join(homedir(), '.ciphermesh', 'last-session.json');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Load { server, room?, at } or null when missing/corrupt. */
|
|
14
|
+
export function loadLastSession(path = lastSessionPath()) {
|
|
15
|
+
try {
|
|
16
|
+
const obj = JSON.parse(readFileSync(path, 'utf-8'));
|
|
17
|
+
if (obj === null || typeof obj !== 'object' || typeof obj.server !== 'string') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const out = { server: obj.server, at: Number(obj.at) || 0 };
|
|
21
|
+
if (typeof obj.room === 'string' && /^[a-zA-Z0-9_-]{1,30}$/.test(obj.room)) {
|
|
22
|
+
out.room = obj.room;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Persist { server, room? }. Room is optional on purpose (private rooms). */
|
|
31
|
+
export function saveLastSession({ server, room }, path = lastSessionPath()) {
|
|
32
|
+
if (typeof server !== 'string' || !server) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const data = { server, at: Date.now() };
|
|
36
|
+
if (typeof room === 'string' && room) {
|
|
37
|
+
data.room = room;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
writeFileSync(path, `${JSON.stringify(data)}\n`, { mode: 0o600 });
|
|
42
|
+
} catch {
|
|
43
|
+
// Best effort — never break the chat over this.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearLastSession(path = lastSessionPath()) {
|
|
48
|
+
try {
|
|
49
|
+
if (existsSync(path)) {
|
|
50
|
+
unlinkSync(path);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Best effort.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// First-run setup wizard: ~30 seconds from `npx ciphermesh` to chatting, with
|
|
2
|
+
// just enough context to use the security features. Runs when no config file
|
|
3
|
+
// exists yet (or on demand via --setup) and persists the answers so it never
|
|
4
|
+
// asks twice.
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { SERVER_PORT } from './constants.js';
|
|
7
|
+
import { promptLabel, promptDim, promptError } from './banner.js';
|
|
8
|
+
import { themeNames, setTheme, getThemeName, THEMES } from './themes.js';
|
|
9
|
+
import { parseInvite } from './invite.js';
|
|
10
|
+
import { configPath, saveConfig } from './config.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a theme answer: a number from the printed list ("2"), a name
|
|
14
|
+
* ("matrix"), or empty → fallback. Pure — exported for testing.
|
|
15
|
+
*/
|
|
16
|
+
export function parseThemeChoice(input, names, fallback) {
|
|
17
|
+
const clean = (input || '').trim().toLowerCase();
|
|
18
|
+
if (!clean) {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
if (/^\d+$/.test(clean)) {
|
|
22
|
+
const idx = Number(clean) - 1;
|
|
23
|
+
return names[idx] || fallback;
|
|
24
|
+
}
|
|
25
|
+
return names.includes(clean) ? clean : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the server answer into what to use this session and what to save
|
|
30
|
+
* as the default. Invites are used as-is for the session but saved as their
|
|
31
|
+
* host:port (a room invite is one-shot, the host is worth keeping).
|
|
32
|
+
* Pure — exported for testing.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveServerAnswer(input, fallback = `localhost:${SERVER_PORT}`) {
|
|
35
|
+
const clean = (input || '').trim();
|
|
36
|
+
if (!clean) {
|
|
37
|
+
return { session: fallback, save: fallback };
|
|
38
|
+
}
|
|
39
|
+
const invite = parseInvite(clean);
|
|
40
|
+
if (invite) {
|
|
41
|
+
return { session: clean, save: invite.wsUrl.replace(/^wss?:\/\//, '') };
|
|
42
|
+
}
|
|
43
|
+
return { session: clean, save: clean };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Interactive first-run wizard. Uses the same readline interface as the rest
|
|
48
|
+
* of startup. Returns `{ nickname, server, theme }` — the caller uses them
|
|
49
|
+
* directly for this session (no duplicate prompts) — after persisting them
|
|
50
|
+
* to the config file.
|
|
51
|
+
*/
|
|
52
|
+
export async function runOnboarding(rl, { savePath = configPath() } = {}) {
|
|
53
|
+
const dim = (t) => console.log(promptDim(` ${t}`));
|
|
54
|
+
|
|
55
|
+
console.log();
|
|
56
|
+
console.log(chalk.bold.white(' First time here? Quick setup — 30 seconds.'));
|
|
57
|
+
dim(`Everything is saved to ${savePath} (re-run anytime with: ciphermesh --setup)`);
|
|
58
|
+
console.log();
|
|
59
|
+
console.log(chalk.white(' How CipherMesh works, in three lines:'));
|
|
60
|
+
dim('• Everything is end-to-end encrypted — the relay only ever sees ciphertext.');
|
|
61
|
+
dim('• Your identity is a keypair; its short fingerprint is shown when you connect.');
|
|
62
|
+
dim('• Verify friends out-of-band with /verify — a green ✓ appears next to their name.');
|
|
63
|
+
console.log();
|
|
64
|
+
|
|
65
|
+
// 1. Nickname
|
|
66
|
+
let nickname = '';
|
|
67
|
+
while (!nickname) {
|
|
68
|
+
const raw = await rl.question(promptLabel(`Nickname ${promptDim('(a-z, 0-9, _, -)')}: `));
|
|
69
|
+
const clean = raw.trim().replace(/[^a-zA-Z0-9_-]/g, '');
|
|
70
|
+
if (clean.length >= 1 && clean.length <= 20) {
|
|
71
|
+
nickname = clean;
|
|
72
|
+
} else {
|
|
73
|
+
console.log(promptError('Invalid nickname. Use 1-20 alphanumeric characters.'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Theme
|
|
78
|
+
const names = themeNames();
|
|
79
|
+
console.log();
|
|
80
|
+
console.log(chalk.white(' Colour theme for nicknames:'));
|
|
81
|
+
names.forEach((name, i) => {
|
|
82
|
+
const swatch = THEMES[name]
|
|
83
|
+
.slice(0, 5)
|
|
84
|
+
.map((c) => (c.startsWith('#') ? chalk.hex(c)('█') : chalk[c]?.('█') || '█'))
|
|
85
|
+
.join('');
|
|
86
|
+
console.log(promptDim(` ${i + 1}. ${name.padEnd(8)} ${swatch}`));
|
|
87
|
+
});
|
|
88
|
+
const themeRaw = await rl.question(
|
|
89
|
+
promptLabel(`Theme ${promptDim(`(1-${names.length} or name, Enter = ${getThemeName()})`)}: `),
|
|
90
|
+
);
|
|
91
|
+
const theme = parseThemeChoice(themeRaw, names, getThemeName());
|
|
92
|
+
setTheme(theme);
|
|
93
|
+
|
|
94
|
+
// 3. Default server
|
|
95
|
+
console.log();
|
|
96
|
+
console.log(chalk.white(' Which server should be your default?'));
|
|
97
|
+
dim(`• Same machine as the relay → localhost:${SERVER_PORT}`);
|
|
98
|
+
dim(`• Someone else hosts it (LAN/Tailscale) → their IP, e.g. 100.64.0.9:${SERVER_PORT}`);
|
|
99
|
+
dim('• Got a ciphermesh:// invite? Paste it here.');
|
|
100
|
+
const serverRaw = await rl.question(
|
|
101
|
+
promptLabel(`Server ${promptDim(`(Enter = localhost:${SERVER_PORT})`)}: `),
|
|
102
|
+
);
|
|
103
|
+
const { session: server, save: serverToSave } = resolveServerAnswer(serverRaw);
|
|
104
|
+
|
|
105
|
+
saveConfig({ nickname, theme, server: serverToSave }, savePath);
|
|
106
|
+
console.log();
|
|
107
|
+
console.log(promptLabel('Setup saved — next time you go straight to the chat.'));
|
|
108
|
+
console.log();
|
|
109
|
+
|
|
110
|
+
return { nickname, server, theme };
|
|
111
|
+
}
|