ciphermesh 1.0.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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/README.pt-BR.md +253 -0
  4. package/bin/ciphermesh.js +34 -0
  5. package/docs/ARCHITECTURE.md +1188 -0
  6. package/docs/SETUP.md +305 -0
  7. package/docs/demo.svg +46 -0
  8. package/package.json +87 -0
  9. package/src/client/ChatController.js +2476 -0
  10. package/src/client/Connection.js +129 -0
  11. package/src/client/FileTransfer.js +488 -0
  12. package/src/client/ImagePreview.js +88 -0
  13. package/src/client/UI.js +1830 -0
  14. package/src/client/index.js +231 -0
  15. package/src/crypto/CertPinStore.js +79 -0
  16. package/src/crypto/DeniableEncrypt.js +53 -0
  17. package/src/crypto/DoubleRatchet.js +574 -0
  18. package/src/crypto/Handshake.js +219 -0
  19. package/src/crypto/HistoryStore.js +241 -0
  20. package/src/crypto/IdentityBackup.js +70 -0
  21. package/src/crypto/KeyManager.js +134 -0
  22. package/src/crypto/MessageCrypto.js +181 -0
  23. package/src/crypto/NonceManager.js +72 -0
  24. package/src/crypto/SealedSender.js +58 -0
  25. package/src/crypto/SenderKey.js +204 -0
  26. package/src/crypto/StateManager.js +138 -0
  27. package/src/crypto/TrustStore.js +216 -0
  28. package/src/p2p/Discovery.js +80 -0
  29. package/src/p2p/P2PChatController.js +1856 -0
  30. package/src/p2p/PeerConnectionManager.js +252 -0
  31. package/src/p2p/PeerServer.js +68 -0
  32. package/src/p2p/index.js +219 -0
  33. package/src/protocol/messages.js +138 -0
  34. package/src/protocol/validators.js +175 -0
  35. package/src/server/CertManager.js +173 -0
  36. package/src/server/MessageRouter.js +80 -0
  37. package/src/server/OfflineQueue.js +124 -0
  38. package/src/server/SessionManager.js +296 -0
  39. package/src/server/WebSocketServer.js +632 -0
  40. package/src/server/index.js +89 -0
  41. package/src/shared/AuditLog.js +91 -0
  42. package/src/shared/PluginManager.js +83 -0
  43. package/src/shared/banner.js +271 -0
  44. package/src/shared/commandSuggest.js +59 -0
  45. package/src/shared/config.js +90 -0
  46. package/src/shared/constants.js +126 -0
  47. package/src/shared/coverTraffic.js +34 -0
  48. package/src/shared/dnd.js +60 -0
  49. package/src/shared/emoji.js +17 -0
  50. package/src/shared/fuzzy.js +40 -0
  51. package/src/shared/invite.js +61 -0
  52. package/src/shared/keyArt.js +66 -0
  53. package/src/shared/logger.js +38 -0
  54. package/src/shared/panic.js +38 -0
  55. package/src/shared/prompt.js +31 -0
  56. package/src/shared/terminalGraphics.js +72 -0
  57. package/src/shared/themes.js +36 -0
  58. package/src/shared/voiceNote.js +128 -0
@@ -0,0 +1,175 @@
1
+ import {
2
+ PROTOCOL_VERSION,
3
+ MAX_NICKNAME_LENGTH,
4
+ MAX_PAYLOAD_SIZE,
5
+ PUBLIC_KEY_SIZE,
6
+ } from '../shared/constants.js';
7
+
8
+ // ── Helpers ────────────────────────────────────────────────────
9
+ function isString(v) {
10
+ return typeof v === 'string';
11
+ }
12
+
13
+ function isNumber(v) {
14
+ return typeof v === 'number' && Number.isFinite(v);
15
+ }
16
+
17
+ function isObject(v) {
18
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
19
+ }
20
+
21
+ function isValidBase64(str, expectedBytes) {
22
+ if (!isString(str)) {
23
+ return false;
24
+ }
25
+ try {
26
+ const buf = Buffer.from(str, 'base64');
27
+ return expectedBytes ? buf.length === expectedBytes : buf.length > 0;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function sanitizeNickname(nick) {
34
+ if (!isString(nick)) {
35
+ return null;
36
+ }
37
+ // eslint-disable-next-line no-control-regex
38
+ const clean = nick.replace(/[\x00-\x1f\x7f]/g, '').trim();
39
+ if (clean.length === 0 || clean.length > MAX_NICKNAME_LENGTH) {
40
+ return null;
41
+ }
42
+ if (!/^[a-zA-Z0-9_-]+$/.test(clean)) {
43
+ return null;
44
+ }
45
+ return clean;
46
+ }
47
+
48
+ // ── Parse + validate incoming JSON ─────────────────────────────
49
+ export function parseMessage(raw) {
50
+ if (typeof raw === 'string' && raw.length > MAX_PAYLOAD_SIZE) {
51
+ return { valid: false, error: 'Payload too large' };
52
+ }
53
+
54
+ let msg;
55
+ try {
56
+ msg = typeof raw === 'string' ? JSON.parse(raw) : raw;
57
+ } catch {
58
+ return { valid: false, error: 'Invalid JSON' };
59
+ }
60
+
61
+ if (!isObject(msg)) {
62
+ return { valid: false, error: 'Message must be an object' };
63
+ }
64
+ if (msg.version !== PROTOCOL_VERSION) {
65
+ return { valid: false, error: `Unsupported protocol version: ${msg.version}` };
66
+ }
67
+ if (!isString(msg.type)) {
68
+ return { valid: false, error: 'Missing message type' };
69
+ }
70
+ if (!isNumber(msg.timestamp)) {
71
+ return { valid: false, error: 'Missing timestamp' };
72
+ }
73
+
74
+ return { valid: true, msg };
75
+ }
76
+
77
+ // ── Type-specific validators ───────────────────────────────────
78
+ export function validateJoin(msg) {
79
+ const nick = sanitizeNickname(msg.nickname);
80
+ if (!nick) {
81
+ return { valid: false, error: 'Invalid nickname (1-20 chars, alphanumeric/underscore/dash)' };
82
+ }
83
+ if (!isValidBase64(msg.publicKey, PUBLIC_KEY_SIZE)) {
84
+ return { valid: false, error: 'Invalid public key' };
85
+ }
86
+ return { valid: true, nickname: nick };
87
+ }
88
+
89
+ export function validateEncryptedMessage(msg) {
90
+ if (!isString(msg.from) || !isString(msg.to)) {
91
+ return { valid: false, error: 'Missing from/to fields' };
92
+ }
93
+ if (!isObject(msg.payload)) {
94
+ return { valid: false, error: 'Missing payload' };
95
+ }
96
+ if (!isString(msg.payload.ciphertext) || !isString(msg.payload.nonce)) {
97
+ return { valid: false, error: 'Invalid payload structure' };
98
+ }
99
+ if (!isValidBase64(msg.payload.nonce, 24)) {
100
+ return { valid: false, error: 'Invalid nonce' };
101
+ }
102
+
103
+ // Ratcheted message: validate extra fields
104
+ if (msg.payload.ephemeralPublicKey !== undefined) {
105
+ if (!isValidBase64(msg.payload.ephemeralPublicKey, PUBLIC_KEY_SIZE)) {
106
+ return { valid: false, error: 'Invalid ephemeral public key' };
107
+ }
108
+ if (!Number.isInteger(msg.payload.counter) || msg.payload.counter < 0) {
109
+ return { valid: false, error: 'Invalid counter' };
110
+ }
111
+ if (!Number.isInteger(msg.payload.previousCounter) || msg.payload.previousCounter < 0) {
112
+ return { valid: false, error: 'Invalid previousCounter' };
113
+ }
114
+ }
115
+
116
+ return { valid: true };
117
+ }
118
+
119
+ export function validateKeyUpdate(msg) {
120
+ if (!isValidBase64(msg.publicKey, PUBLIC_KEY_SIZE)) {
121
+ return { valid: false, error: 'Invalid public key' };
122
+ }
123
+ return { valid: true };
124
+ }
125
+
126
+ export function validateChangeRoom(msg) {
127
+ if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
128
+ return { valid: false, error: 'Invalid room name (1-30 chars)' };
129
+ }
130
+ if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
131
+ return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
132
+ }
133
+ return { valid: true, room: msg.room.toLowerCase() };
134
+ }
135
+
136
+ export function validateListRooms() {
137
+ return { valid: true };
138
+ }
139
+
140
+ export function validateKickPeer(msg) {
141
+ const nick = sanitizeNickname(msg.targetNickname);
142
+ if (!nick) {
143
+ return { valid: false, error: 'Invalid target nickname' };
144
+ }
145
+ return {
146
+ valid: true,
147
+ targetNickname: nick,
148
+ reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
149
+ };
150
+ }
151
+
152
+ export function validateMutePeer(msg) {
153
+ const nick = sanitizeNickname(msg.targetNickname);
154
+ if (!nick) {
155
+ return { valid: false, error: 'Invalid target nickname' };
156
+ }
157
+ if (!isNumber(msg.durationMs) || msg.durationMs <= 0) {
158
+ return { valid: false, error: 'Invalid mute duration' };
159
+ }
160
+ return { valid: true, targetNickname: nick, durationMs: msg.durationMs };
161
+ }
162
+
163
+ export function validateBanPeer(msg) {
164
+ const nick = sanitizeNickname(msg.targetNickname);
165
+ if (!nick) {
166
+ return { valid: false, error: 'Invalid target nickname' };
167
+ }
168
+ return {
169
+ valid: true,
170
+ targetNickname: nick,
171
+ reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
172
+ };
173
+ }
174
+
175
+ export { sanitizeNickname };
@@ -0,0 +1,173 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { generateKeyPairSync, createSign, createHash } from 'node:crypto';
4
+ import { createLogger } from '../shared/logger.js';
5
+
6
+ const log = createLogger('cert');
7
+
8
+ const CERT_DIR = join(process.cwd(), 'certs');
9
+ const KEY_PATH = join(CERT_DIR, 'server.key');
10
+ const CERT_PATH = join(CERT_DIR, 'server.cert');
11
+
12
+ /**
13
+ * Load or auto-generate self-signed TLS certificates.
14
+ * @returns {{ key: Buffer, cert: Buffer }}
15
+ */
16
+ export function loadOrGenerateCerts() {
17
+ if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) {
18
+ log.info('TLS certificates loaded from certs/');
19
+ return {
20
+ key: readFileSync(KEY_PATH),
21
+ cert: readFileSync(CERT_PATH),
22
+ };
23
+ }
24
+
25
+ log.info('Generating self-signed TLS certificate...');
26
+
27
+ if (!existsSync(CERT_DIR)) {
28
+ mkdirSync(CERT_DIR, { recursive: true });
29
+ }
30
+
31
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', {
32
+ modulusLength: 2048,
33
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
34
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
35
+ });
36
+
37
+ const cert = createSelfSignedCert(publicKey, privateKey);
38
+
39
+ writeFileSync(KEY_PATH, privateKey);
40
+ writeFileSync(CERT_PATH, cert);
41
+
42
+ log.info('Self-signed TLS certificate generated in certs/');
43
+ return { key: Buffer.from(privateKey), cert: Buffer.from(cert) };
44
+ }
45
+
46
+ /**
47
+ * Create a minimal self-signed X.509 certificate in PEM format.
48
+ */
49
+ function createSelfSignedCert(publicKeyPem, privateKeyPem) {
50
+ // Build a basic X.509 v3 certificate using DER encoding
51
+ const now = new Date();
52
+ const notAfter = new Date(now);
53
+ notAfter.setFullYear(notAfter.getFullYear() + 1);
54
+
55
+ // Extract raw public key bytes from SPKI PEM
56
+ const spkiDer = pemToDer(publicKeyPem);
57
+
58
+ // Build TBS (To Be Signed) certificate
59
+ const serialNumber = createHash('sha256')
60
+ .update(Buffer.from(Date.now().toString()))
61
+ .digest()
62
+ .subarray(0, 8);
63
+
64
+ const issuer = derSequence([
65
+ derSet([
66
+ derSequence(
67
+ [
68
+ Buffer.from('550403', 'hex'), // OID: commonName
69
+ derUtf8('CipherMesh'),
70
+ ].map((b, i) => (i === 0 ? derOid(b) : b)),
71
+ ),
72
+ ]),
73
+ ]);
74
+
75
+ const validity = derSequence([derUtcTime(now), derUtcTime(notAfter)]);
76
+
77
+ const tbs = derSequence([
78
+ derExplicit(0, derInteger(2)), // version v3
79
+ derInteger(serialNumber),
80
+ derSequence([derOid(Buffer.from('2a864886f70d01010b', 'hex')), derNull()]), // sha256WithRSA
81
+ issuer,
82
+ validity,
83
+ issuer, // subject = issuer (self-signed)
84
+ Buffer.from(spkiDer), // subjectPublicKeyInfo
85
+ ]);
86
+
87
+ // Sign the TBS with SHA-256 + RSA
88
+ const signer = createSign('SHA256');
89
+ signer.update(tbs);
90
+ const signature = signer.sign(privateKeyPem);
91
+
92
+ // Build the full certificate
93
+ const cert = derSequence([
94
+ tbs,
95
+ derSequence([derOid(Buffer.from('2a864886f70d01010b', 'hex')), derNull()]),
96
+ derBitString(signature),
97
+ ]);
98
+
99
+ return derToPem(cert, 'CERTIFICATE');
100
+ }
101
+
102
+ // ── DER encoding helpers ─────────────────────────────────────
103
+
104
+ function derTag(tag, content) {
105
+ const len = derLength(content.length);
106
+ return Buffer.concat([Buffer.from([tag]), len, content]);
107
+ }
108
+
109
+ function derLength(length) {
110
+ if (length < 0x80) {
111
+ return Buffer.from([length]);
112
+ }
113
+ if (length < 0x100) {
114
+ return Buffer.from([0x81, length]);
115
+ }
116
+ return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff]);
117
+ }
118
+
119
+ function derSequence(items) {
120
+ return derTag(0x30, Buffer.concat(items));
121
+ }
122
+
123
+ function derSet(items) {
124
+ return derTag(0x31, Buffer.concat(items));
125
+ }
126
+
127
+ function derInteger(value) {
128
+ const buf = Buffer.isBuffer(value) ? value : Buffer.from([value]);
129
+ // Prepend 0x00 if high bit is set (positive integer)
130
+ const padded = buf[0] & 0x80 ? Buffer.concat([Buffer.from([0]), buf]) : buf;
131
+ return derTag(0x02, padded);
132
+ }
133
+
134
+ function derOid(buf) {
135
+ return derTag(0x06, buf);
136
+ }
137
+
138
+ function derNull() {
139
+ return Buffer.from([0x05, 0x00]);
140
+ }
141
+
142
+ function derUtf8(str) {
143
+ return derTag(0x0c, Buffer.from(str, 'utf-8'));
144
+ }
145
+
146
+ function derUtcTime(date) {
147
+ const y = String(date.getUTCFullYear()).slice(-2);
148
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
149
+ const d = String(date.getUTCDate()).padStart(2, '0');
150
+ const h = String(date.getUTCHours()).padStart(2, '0');
151
+ const min = String(date.getUTCMinutes()).padStart(2, '0');
152
+ const s = String(date.getUTCSeconds()).padStart(2, '0');
153
+ return derTag(0x17, Buffer.from(`${y}${m}${d}${h}${min}${s}Z`, 'ascii'));
154
+ }
155
+
156
+ function derBitString(content) {
157
+ return derTag(0x03, Buffer.concat([Buffer.from([0x00]), content]));
158
+ }
159
+
160
+ function derExplicit(tag, content) {
161
+ return derTag(0xa0 | tag, content);
162
+ }
163
+
164
+ function pemToDer(pem) {
165
+ const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, '');
166
+ return Buffer.from(b64, 'base64');
167
+ }
168
+
169
+ function derToPem(der, label) {
170
+ const b64 = der.toString('base64');
171
+ const lines = b64.match(/.{1,64}/g) || [];
172
+ return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
173
+ }
@@ -0,0 +1,80 @@
1
+ import { createLogger } from '../shared/logger.js';
2
+ import { RATE_LIMIT_PER_SECOND } from '../shared/constants.js';
3
+ import { createError, ERR } from '../protocol/messages.js';
4
+
5
+ const log = createLogger('router');
6
+
7
+ export class MessageRouter {
8
+ #sessionManager;
9
+ #offlineQueue;
10
+ #rateCounts; // Map<sessionId, { count, resetAt }>
11
+
12
+ constructor(sessionManager, offlineQueue) {
13
+ this.#sessionManager = sessionManager;
14
+ this.#offlineQueue = offlineQueue;
15
+ this.#rateCounts = new Map();
16
+ }
17
+
18
+ /**
19
+ * Route an encrypted_message from sender to recipient.
20
+ * The server NEVER inspects payload contents.
21
+ */
22
+ route(senderSessionId, msg) {
23
+ // Rate limit check
24
+ if (!this.#checkRateLimit(senderSessionId)) {
25
+ const senderSession = this.#sessionManager.getSession(senderSessionId);
26
+ if (senderSession?.ws.readyState === 1) {
27
+ senderSession.ws.send(
28
+ JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many messages per second')),
29
+ );
30
+ }
31
+ return;
32
+ }
33
+
34
+ const recipientSession = this.#sessionManager.getSession(msg.to);
35
+ if (!recipientSession) {
36
+ // Try to enqueue for offline delivery
37
+ const leftPeer = this.#sessionManager.getRecentlyLeft(msg.to);
38
+ if (leftPeer) {
39
+ this.#offlineQueue.enqueue(leftPeer.nickname, leftPeer.publicKey, msg);
40
+ log.debug(`Message queued for ${leftPeer.nickname} (offline)`);
41
+ return;
42
+ }
43
+
44
+ const senderSession = this.#sessionManager.getSession(senderSessionId);
45
+ if (senderSession?.ws.readyState === 1) {
46
+ senderSession.ws.send(
47
+ JSON.stringify(createError(ERR.PEER_NOT_FOUND, 'Recipient not found')),
48
+ );
49
+ }
50
+ log.warn(`Peer ${msg.to?.slice(0, 8)} not found`);
51
+ return;
52
+ }
53
+
54
+ if (recipientSession.ws.readyState === 1) {
55
+ recipientSession.ws.send(JSON.stringify(msg));
56
+ log.debug(`${senderSessionId.slice(0, 8)} -> ${msg.to.slice(0, 8)}`);
57
+ }
58
+ }
59
+
60
+ #checkRateLimit(sessionId) {
61
+ const now = Date.now();
62
+ const entry = this.#rateCounts.get(sessionId);
63
+
64
+ if (!entry || now >= entry.resetAt) {
65
+ this.#rateCounts.set(sessionId, { count: 1, resetAt: now + 1000 });
66
+ return true;
67
+ }
68
+
69
+ if (entry.count >= RATE_LIMIT_PER_SECOND) {
70
+ return false;
71
+ }
72
+
73
+ entry.count++;
74
+ return true;
75
+ }
76
+
77
+ cleanupSession(sessionId) {
78
+ this.#rateCounts.delete(sessionId);
79
+ }
80
+ }
@@ -0,0 +1,124 @@
1
+ import { createLogger } from '../shared/logger.js';
2
+ import {
3
+ OFFLINE_QUEUE_MAX_PER_PEER,
4
+ OFFLINE_QUEUE_MAX_AGE_MS,
5
+ OFFLINE_QUEUE_MAX_TOTAL,
6
+ } from '../shared/constants.js';
7
+
8
+ const log = createLogger('offline-queue');
9
+
10
+ export class OfflineQueue {
11
+ #queues; // Map<nickname_lower, { publicKey, messages[] }>
12
+ #totalCount;
13
+
14
+ constructor() {
15
+ this.#queues = new Map();
16
+ this.#totalCount = 0;
17
+ }
18
+
19
+ /**
20
+ * Enqueue a message for an offline peer.
21
+ * @param {string} nickname - Peer's nickname
22
+ * @param {string} publicKey - Peer's public key (base64)
23
+ * @param {object} msg - The raw encrypted_message to store
24
+ */
25
+ enqueue(nickname, publicKey, msg) {
26
+ if (this.#totalCount >= OFFLINE_QUEUE_MAX_TOTAL) {
27
+ log.warn('Global offline queue full, dropping message');
28
+ return false;
29
+ }
30
+
31
+ const key = nickname.toLowerCase();
32
+ let entry = this.#queues.get(key);
33
+
34
+ if (!entry) {
35
+ entry = { publicKey, messages: [] };
36
+ this.#queues.set(key, entry);
37
+ }
38
+
39
+ if (entry.publicKey !== publicKey) {
40
+ log.debug(`PublicKey changed for ${nickname}, dropping old queue`);
41
+ this.#totalCount -= entry.messages.length;
42
+ entry.publicKey = publicKey;
43
+ entry.messages = [];
44
+ }
45
+
46
+ if (entry.messages.length >= OFFLINE_QUEUE_MAX_PER_PEER) {
47
+ log.warn(`Queue full for ${nickname}, dropping oldest message`);
48
+ entry.messages.shift();
49
+ this.#totalCount--;
50
+ }
51
+
52
+ entry.messages.push({ msg, queuedAt: Date.now() });
53
+ this.#totalCount++;
54
+
55
+ log.debug(`Message queued for ${nickname} (${entry.messages.length} in queue)`);
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * Dequeue all messages for a peer if publicKey matches.
61
+ * @param {string} nickname - Peer's nickname
62
+ * @param {string} publicKey - Peer's current public key (base64)
63
+ * @returns {object[]} Array of stored messages (may be empty)
64
+ */
65
+ dequeue(nickname, publicKey) {
66
+ const key = nickname.toLowerCase();
67
+ const entry = this.#queues.get(key);
68
+
69
+ if (!entry) {
70
+ return [];
71
+ }
72
+
73
+ if (entry.publicKey !== publicKey) {
74
+ log.info(`${nickname} reconnected with a different key, dropping queue`);
75
+ this.#totalCount -= entry.messages.length;
76
+ this.#queues.delete(key);
77
+ return [];
78
+ }
79
+
80
+ const now = Date.now();
81
+ const valid = entry.messages.filter((item) => now - item.queuedAt < OFFLINE_QUEUE_MAX_AGE_MS);
82
+
83
+ const expired = entry.messages.length - valid.length;
84
+ if (expired > 0) {
85
+ log.debug(`${expired} expired messages dropped for ${nickname}`);
86
+ }
87
+
88
+ this.#totalCount -= entry.messages.length;
89
+ this.#queues.delete(key);
90
+
91
+ log.info(`${valid.length} messages delivered to ${nickname}`);
92
+ return valid.map((item) => item.msg);
93
+ }
94
+
95
+ /**
96
+ * Remove expired entries across all queues.
97
+ */
98
+ cleanup() {
99
+ const now = Date.now();
100
+ let removed = 0;
101
+
102
+ for (const [key, entry] of this.#queues) {
103
+ const before = entry.messages.length;
104
+ entry.messages = entry.messages.filter(
105
+ (item) => now - item.queuedAt < OFFLINE_QUEUE_MAX_AGE_MS,
106
+ );
107
+ const diff = before - entry.messages.length;
108
+ removed += diff;
109
+ this.#totalCount -= diff;
110
+
111
+ if (entry.messages.length === 0) {
112
+ this.#queues.delete(key);
113
+ }
114
+ }
115
+
116
+ if (removed > 0) {
117
+ log.info(`Cleanup: ${removed} expired messages removed`);
118
+ }
119
+ }
120
+
121
+ get size() {
122
+ return this.#totalCount;
123
+ }
124
+ }