@brightchain/brightchain-lib 0.29.25 → 0.29.27
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/package.json +10 -4
- package/src/index.d.ts +1 -1
- package/src/index.d.ts.map +1 -1
- package/src/index.js +2 -1
- package/src/index.js.map +1 -1
- package/src/lib/enumeration-translations/index.d.ts +2 -0
- package/src/lib/enumeration-translations/index.d.ts.map +1 -1
- package/src/lib/enumeration-translations/index.js +1 -0
- package/src/lib/enumeration-translations/index.js.map +1 -1
- package/src/lib/enumeration-translations/memberStatusType.d.ts +5 -0
- package/src/lib/enumeration-translations/memberStatusType.d.ts.map +1 -0
- package/src/lib/enumeration-translations/memberStatusType.js +58 -0
- package/src/lib/enumeration-translations/memberStatusType.js.map +1 -0
- package/src/lib/enumerations/brightChainStrings.d.ts +52 -0
- package/src/lib/enumerations/brightChainStrings.d.ts.map +1 -1
- package/src/lib/enumerations/brightChainStrings.js +54 -0
- package/src/lib/enumerations/brightChainStrings.js.map +1 -1
- package/src/lib/enumerations/brightchainFeatures.d.ts +1 -0
- package/src/lib/enumerations/brightchainFeatures.d.ts.map +1 -1
- package/src/lib/enumerations/brightchainFeatures.js +1 -0
- package/src/lib/enumerations/brightchainFeatures.js.map +1 -1
- package/src/lib/enumerations/communication.d.ts +7 -1
- package/src/lib/enumerations/communication.d.ts.map +1 -1
- package/src/lib/enumerations/communication.js +6 -0
- package/src/lib/enumerations/communication.js.map +1 -1
- package/src/lib/enumerations/messaging/emailErrorType.d.ts +15 -2
- package/src/lib/enumerations/messaging/emailErrorType.d.ts.map +1 -1
- package/src/lib/enumerations/messaging/emailErrorType.js +17 -1
- package/src/lib/enumerations/messaging/emailErrorType.js.map +1 -1
- package/src/lib/enumerations/messaging/messageEncryptionScheme.d.ts +4 -2
- package/src/lib/enumerations/messaging/messageEncryptionScheme.d.ts.map +1 -1
- package/src/lib/enumerations/messaging/messageEncryptionScheme.js +3 -1
- package/src/lib/enumerations/messaging/messageEncryptionScheme.js.map +1 -1
- package/src/lib/errors/encryptionErrors.d.ts +71 -0
- package/src/lib/errors/encryptionErrors.d.ts.map +1 -0
- package/src/lib/errors/encryptionErrors.js +112 -0
- package/src/lib/errors/encryptionErrors.js.map +1 -0
- package/src/lib/errors/index.d.ts +10 -0
- package/src/lib/errors/index.d.ts.map +1 -1
- package/src/lib/errors/index.js +13 -0
- package/src/lib/errors/index.js.map +1 -1
- package/src/lib/i18n/i18n-setup.d.ts +2 -2
- package/src/lib/i18n/i18n-setup.d.ts.map +1 -1
- package/src/lib/i18n/strings/englishUK.d.ts +2 -2
- package/src/lib/i18n/strings/englishUK.d.ts.map +1 -1
- package/src/lib/i18n/strings/englishUK.js.map +1 -1
- package/src/lib/i18n/strings/englishUs.d.ts +2 -2
- package/src/lib/i18n/strings/englishUs.d.ts.map +1 -1
- package/src/lib/i18n/strings/englishUs.js +55 -1
- package/src/lib/i18n/strings/englishUs.js.map +1 -1
- package/src/lib/i18n/strings/french.d.ts +2 -2
- package/src/lib/i18n/strings/french.d.ts.map +1 -1
- package/src/lib/i18n/strings/french.js +68 -13
- package/src/lib/i18n/strings/french.js.map +1 -1
- package/src/lib/i18n/strings/german.d.ts +2 -2
- package/src/lib/i18n/strings/german.d.ts.map +1 -1
- package/src/lib/i18n/strings/german.js +67 -12
- package/src/lib/i18n/strings/german.js.map +1 -1
- package/src/lib/i18n/strings/japanese.d.ts +2 -2
- package/src/lib/i18n/strings/japanese.d.ts.map +1 -1
- package/src/lib/i18n/strings/japanese.js +67 -12
- package/src/lib/i18n/strings/japanese.js.map +1 -1
- package/src/lib/i18n/strings/mandarin.d.ts +2 -2
- package/src/lib/i18n/strings/mandarin.d.ts.map +1 -1
- package/src/lib/i18n/strings/mandarin.js +67 -12
- package/src/lib/i18n/strings/mandarin.js.map +1 -1
- package/src/lib/i18n/strings/spanish.d.ts +2 -2
- package/src/lib/i18n/strings/spanish.d.ts.map +1 -1
- package/src/lib/i18n/strings/spanish.js +67 -12
- package/src/lib/i18n/strings/spanish.js.map +1 -1
- package/src/lib/i18n/strings/ukrainian.d.ts +2 -2
- package/src/lib/i18n/strings/ukrainian.d.ts.map +1 -1
- package/src/lib/i18n/strings/ukrainian.js +67 -12
- package/src/lib/i18n/strings/ukrainian.js.map +1 -1
- package/src/lib/index.d.ts +31 -0
- package/src/lib/index.d.ts.map +1 -1
- package/src/lib/index.js +29 -1
- package/src/lib/index.js.map +1 -1
- package/src/lib/interfaces/auth/writeProof.d.ts +2 -0
- package/src/lib/interfaces/auth/writeProof.d.ts.map +1 -1
- package/src/lib/interfaces/auth/writeProofUtils.d.ts +1 -1
- package/src/lib/interfaces/auth/writeProofUtils.d.ts.map +1 -1
- package/src/lib/interfaces/auth/writeProofUtils.js +2 -2
- package/src/lib/interfaces/auth/writeProofUtils.js.map +1 -1
- package/src/lib/interfaces/availability/gossipService.d.ts +99 -1
- package/src/lib/interfaces/availability/gossipService.d.ts.map +1 -1
- package/src/lib/interfaces/availability/gossipService.js +4 -0
- package/src/lib/interfaces/availability/gossipService.js.map +1 -1
- package/src/lib/interfaces/communication/blockContentStore.d.ts +57 -0
- package/src/lib/interfaces/communication/blockContentStore.d.ts.map +1 -0
- package/src/lib/interfaces/communication/blockContentStore.js +21 -0
- package/src/lib/interfaces/communication/blockContentStore.js.map +1 -0
- package/src/lib/interfaces/communication/chatStorageProvider.d.ts +77 -0
- package/src/lib/interfaces/communication/chatStorageProvider.d.ts.map +1 -0
- package/src/lib/interfaces/communication/chatStorageProvider.js +25 -0
- package/src/lib/interfaces/communication/chatStorageProvider.js.map +1 -0
- package/src/lib/interfaces/communication/index.d.ts +4 -0
- package/src/lib/interfaces/communication/index.d.ts.map +1 -0
- package/src/lib/interfaces/communication/index.js +3 -0
- package/src/lib/interfaces/communication/index.js.map +1 -0
- package/src/lib/interfaces/communication/server.d.ts +57 -0
- package/src/lib/interfaces/communication/server.d.ts.map +1 -0
- package/src/lib/interfaces/communication/server.js +14 -0
- package/src/lib/interfaces/communication/server.js.map +1 -0
- package/src/lib/interfaces/communication.d.ts +60 -5
- package/src/lib/interfaces/communication.d.ts.map +1 -1
- package/src/lib/interfaces/communication.js +8 -0
- package/src/lib/interfaces/communication.js.map +1 -1
- package/src/lib/interfaces/communicationEvents.d.ts +59 -1
- package/src/lib/interfaces/communicationEvents.d.ts.map +1 -1
- package/src/lib/interfaces/events/communicationEventEmitter.d.ts +9 -0
- package/src/lib/interfaces/events/communicationEventEmitter.d.ts.map +1 -1
- package/src/lib/interfaces/events/communicationEventEmitter.js +9 -0
- package/src/lib/interfaces/events/communicationEventEmitter.js.map +1 -1
- package/src/lib/interfaces/index.d.ts +4 -1
- package/src/lib/interfaces/index.d.ts.map +1 -1
- package/src/lib/interfaces/index.js +3 -0
- package/src/lib/interfaces/index.js.map +1 -1
- package/src/lib/interfaces/messaging/gpgKey.d.ts +93 -0
- package/src/lib/interfaces/messaging/gpgKey.d.ts.map +1 -0
- package/src/lib/interfaces/messaging/gpgKey.js +12 -0
- package/src/lib/interfaces/messaging/gpgKey.js.map +1 -0
- package/src/lib/interfaces/messaging/index.d.ts +4 -0
- package/src/lib/interfaces/messaging/index.d.ts.map +1 -1
- package/src/lib/interfaces/messaging/index.js +4 -0
- package/src/lib/interfaces/messaging/index.js.map +1 -1
- package/src/lib/interfaces/messaging/keyStore.d.ts +100 -0
- package/src/lib/interfaces/messaging/keyStore.d.ts.map +1 -0
- package/src/lib/interfaces/messaging/keyStore.js +13 -0
- package/src/lib/interfaces/messaging/keyStore.js.map +1 -0
- package/src/lib/interfaces/messaging/recipientKeyResolver.d.ts +92 -0
- package/src/lib/interfaces/messaging/recipientKeyResolver.d.ts.map +1 -0
- package/src/lib/interfaces/messaging/recipientKeyResolver.js +13 -0
- package/src/lib/interfaces/messaging/recipientKeyResolver.js.map +1 -0
- package/src/lib/interfaces/messaging/smimeCertificate.d.ts +99 -0
- package/src/lib/interfaces/messaging/smimeCertificate.d.ts.map +1 -0
- package/src/lib/interfaces/messaging/smimeCertificate.js +12 -0
- package/src/lib/interfaces/messaging/smimeCertificate.js.map +1 -0
- package/src/lib/interfaces/responses/adminDashboardResponse.d.ts +8 -0
- package/src/lib/interfaces/responses/adminDashboardResponse.d.ts.map +1 -1
- package/src/lib/interfaces/responses/communicationResponses.d.ts +25 -0
- package/src/lib/interfaces/responses/communicationResponses.d.ts.map +1 -1
- package/src/lib/services/blockService.d.ts +11 -3
- package/src/lib/services/blockService.d.ts.map +1 -1
- package/src/lib/services/blockService.js +22 -2
- package/src/lib/services/blockService.js.map +1 -1
- package/src/lib/services/communication/__tests__/mockChatStorageProvider.d.ts +108 -0
- package/src/lib/services/communication/__tests__/mockChatStorageProvider.d.ts.map +1 -0
- package/src/lib/services/communication/__tests__/mockChatStorageProvider.js +284 -0
- package/src/lib/services/communication/__tests__/mockChatStorageProvider.js.map +1 -0
- package/src/lib/services/communication/attachmentUtils.d.ts +20 -0
- package/src/lib/services/communication/attachmentUtils.d.ts.map +1 -0
- package/src/lib/services/communication/attachmentUtils.js +43 -0
- package/src/lib/services/communication/attachmentUtils.js.map +1 -0
- package/src/lib/services/communication/channelService.d.ts +127 -12
- package/src/lib/services/communication/channelService.d.ts.map +1 -1
- package/src/lib/services/communication/channelService.js +486 -30
- package/src/lib/services/communication/channelService.js.map +1 -1
- package/src/lib/services/communication/conversationService.d.ts +91 -4
- package/src/lib/services/communication/conversationService.d.ts.map +1 -1
- package/src/lib/services/communication/conversationService.js +237 -5
- package/src/lib/services/communication/conversationService.js.map +1 -1
- package/src/lib/services/communication/eciesKeyEncryptionHandler.d.ts +36 -0
- package/src/lib/services/communication/eciesKeyEncryptionHandler.d.ts.map +1 -0
- package/src/lib/services/communication/eciesKeyEncryptionHandler.js +30 -0
- package/src/lib/services/communication/eciesKeyEncryptionHandler.js.map +1 -0
- package/src/lib/services/communication/groupService.d.ts +99 -21
- package/src/lib/services/communication/groupService.d.ts.map +1 -1
- package/src/lib/services/communication/groupService.js +359 -39
- package/src/lib/services/communication/groupService.js.map +1 -1
- package/src/lib/services/communication/index.d.ts +6 -1
- package/src/lib/services/communication/index.d.ts.map +1 -1
- package/src/lib/services/communication/index.js +20 -1
- package/src/lib/services/communication/index.js.map +1 -1
- package/src/lib/services/communication/keyEpochManager.d.ts +41 -0
- package/src/lib/services/communication/keyEpochManager.d.ts.map +1 -0
- package/src/lib/services/communication/keyEpochManager.js +59 -0
- package/src/lib/services/communication/keyEpochManager.js.map +1 -0
- package/src/lib/services/communication/rehydrationHelpers.d.ts +32 -0
- package/src/lib/services/communication/rehydrationHelpers.d.ts.map +1 -0
- package/src/lib/services/communication/rehydrationHelpers.js +58 -0
- package/src/lib/services/communication/rehydrationHelpers.js.map +1 -0
- package/src/lib/services/communication/serverService.d.ts +193 -0
- package/src/lib/services/communication/serverService.d.ts.map +1 -0
- package/src/lib/services/communication/serverService.js +497 -0
- package/src/lib/services/communication/serverService.js.map +1 -0
- package/src/lib/services/memberStore.d.ts +10 -0
- package/src/lib/services/memberStore.d.ts.map +1 -1
- package/src/lib/services/memberStore.js +52 -20
- package/src/lib/services/memberStore.js.map +1 -1
- package/src/lib/services/messaging/emailEncryptionService.d.ts +162 -0
- package/src/lib/services/messaging/emailEncryptionService.d.ts.map +1 -1
- package/src/lib/services/messaging/emailEncryptionService.js +293 -0
- package/src/lib/services/messaging/emailEncryptionService.js.map +1 -1
- package/src/lib/services/messaging/emailMessageService.d.ts +64 -0
- package/src/lib/services/messaging/emailMessageService.d.ts.map +1 -1
- package/src/lib/services/messaging/emailMessageService.js +142 -13
- package/src/lib/services/messaging/emailMessageService.js.map +1 -1
- package/src/lib/services/messaging/gpgKeyManager.d.ts +130 -0
- package/src/lib/services/messaging/gpgKeyManager.d.ts.map +1 -0
- package/src/lib/services/messaging/gpgKeyManager.js +381 -0
- package/src/lib/services/messaging/gpgKeyManager.js.map +1 -0
- package/src/lib/services/messaging/index.d.ts +3 -0
- package/src/lib/services/messaging/index.d.ts.map +1 -1
- package/src/lib/services/messaging/index.js +3 -0
- package/src/lib/services/messaging/index.js.map +1 -1
- package/src/lib/services/messaging/recipientKeyResolver.d.ts +47 -0
- package/src/lib/services/messaging/recipientKeyResolver.d.ts.map +1 -0
- package/src/lib/services/messaging/recipientKeyResolver.js +132 -0
- package/src/lib/services/messaging/recipientKeyResolver.js.map +1 -0
- package/src/lib/services/messaging/smimeCertificateManager.d.ts +207 -0
- package/src/lib/services/messaging/smimeCertificateManager.d.ts.map +1 -0
- package/src/lib/services/messaging/smimeCertificateManager.js +696 -0
- package/src/lib/services/messaging/smimeCertificateManager.js.map +1 -0
|
@@ -6,7 +6,17 @@
|
|
|
6
6
|
* Supports channel creation with visibility modes, join/leave, messaging,
|
|
7
7
|
* search, invite token generation/redemption, mute/kick operations.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Uses epoch-aware key management via IKeyEpochState for forward secrecy.
|
|
10
|
+
* Each key rotation creates a new epoch. Messages record which epoch they
|
|
11
|
+
* were encrypted under. On member removal, all epoch keys are re-wrapped
|
|
12
|
+
* for remaining members only.
|
|
13
|
+
*
|
|
14
|
+
* When an IChatStorageProvider is injected, channels, channel messages, and
|
|
15
|
+
* invite tokens are also persisted to the provider's collections (write-through).
|
|
16
|
+
* Sync helper methods continue to read from the in-memory Maps so their
|
|
17
|
+
* signatures remain unchanged.
|
|
18
|
+
*
|
|
19
|
+
* Requirements: 10.3, 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 9.1
|
|
10
20
|
*/
|
|
11
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
22
|
exports.ChannelService = exports.InviteTokenNotFoundError = exports.InviteTokenExpiredError = exports.ChannelJoinDeniedError = exports.ChannelNameConflictError = exports.MemberAlreadyInChannelError = exports.ChannelReactionNotFoundError = exports.NotMessageAuthorError = exports.ChannelMemberMutedError = exports.ChannelPermissionError = exports.ChannelMessageNotFoundError = exports.NotChannelMemberError = exports.ChannelNotFoundError = void 0;
|
|
@@ -14,9 +24,12 @@ exports.extractChannelKeyFromDefault = extractChannelKeyFromDefault;
|
|
|
14
24
|
const uuid_1 = require("uuid");
|
|
15
25
|
const platformCrypto_1 = require("../../crypto/platformCrypto");
|
|
16
26
|
const communication_1 = require("../../enumerations/communication");
|
|
27
|
+
const attachmentUtils_1 = require("./attachmentUtils");
|
|
17
28
|
const events_1 = require("../../interfaces/events");
|
|
18
29
|
const pagination_1 = require("../../utils/pagination");
|
|
30
|
+
const encryptionErrors_1 = require("../../errors/encryptionErrors");
|
|
19
31
|
const messageOperationsService_1 = require("./messageOperationsService");
|
|
32
|
+
const rehydrationHelpers_1 = require("./rehydrationHelpers");
|
|
20
33
|
// ─── Error classes ──────────────────────────────────────────────────────────
|
|
21
34
|
class ChannelNotFoundError extends Error {
|
|
22
35
|
constructor(channelId) {
|
|
@@ -134,24 +147,109 @@ class ChannelService {
|
|
|
134
147
|
channels = new Map();
|
|
135
148
|
/** channelId → messages (ordered by createdAt ascending) */
|
|
136
149
|
messages = new Map();
|
|
137
|
-
/** channelId → raw
|
|
138
|
-
|
|
150
|
+
/** channelId → epoch-aware key state (raw keys + wrapped keys per epoch) */
|
|
151
|
+
keyEpochStates = new Map();
|
|
139
152
|
/** token string → IInviteToken */
|
|
140
153
|
inviteTokens = new Map();
|
|
141
154
|
/** channel name (lowercase) → channelId for uniqueness */
|
|
142
155
|
nameIndex = new Map();
|
|
156
|
+
/** Whether init() has already been called (idempotency guard). */
|
|
157
|
+
initialized = false;
|
|
143
158
|
permissionService;
|
|
144
159
|
encryptKey;
|
|
145
160
|
messageOps;
|
|
146
161
|
eventEmitter;
|
|
147
162
|
randomBytesProvider;
|
|
148
|
-
|
|
163
|
+
/** Optional persistent collection for channels (write-through). */
|
|
164
|
+
channelCollection;
|
|
165
|
+
/** Optional persistent collection for channel messages (write-through). */
|
|
166
|
+
channelMessageCollection;
|
|
167
|
+
/** Optional persistent collection for invite tokens (write-through). */
|
|
168
|
+
inviteTokenCollection;
|
|
169
|
+
/** Optional block content store for storing message content as blocks. */
|
|
170
|
+
blockContentStore;
|
|
171
|
+
constructor(permissionService, encryptKey = defaultKeyEncryption, messageOps, eventEmitter, randomBytesProvider, storageProvider, blockContentStore) {
|
|
149
172
|
this.permissionService = permissionService;
|
|
150
173
|
this.encryptKey = encryptKey;
|
|
151
174
|
this.messageOps =
|
|
152
175
|
messageOps ?? new messageOperationsService_1.MessageOperationsService(permissionService);
|
|
153
176
|
this.eventEmitter = eventEmitter ?? new events_1.NullEventEmitter();
|
|
154
177
|
this.randomBytesProvider = randomBytesProvider ?? platformCrypto_1.getRandomBytes;
|
|
178
|
+
this.blockContentStore = blockContentStore;
|
|
179
|
+
if (storageProvider) {
|
|
180
|
+
this.channelCollection = storageProvider.channels;
|
|
181
|
+
this.channelMessageCollection = storageProvider.channelMessages;
|
|
182
|
+
this.inviteTokenCollection = storageProvider.inviteTokens;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ─── Rehydration ──────────────────────────────────────────────────────
|
|
186
|
+
/**
|
|
187
|
+
* Load persisted channels, channel messages, and invite tokens back into
|
|
188
|
+
* in-memory Maps, rebuilding derived indexes, key epoch states, and
|
|
189
|
+
* permission registrations.
|
|
190
|
+
*
|
|
191
|
+
* Idempotent: only the first call performs rehydration. No-op when no
|
|
192
|
+
* storage provider was supplied at construction time.
|
|
193
|
+
*
|
|
194
|
+
* Requirements: 1.3, 1.5, 1.6, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 8.1, 8.2, 8.3, 9.1, 9.3, 9.4
|
|
195
|
+
*/
|
|
196
|
+
async init() {
|
|
197
|
+
if (this.initialized)
|
|
198
|
+
return;
|
|
199
|
+
if (!this.channelCollection)
|
|
200
|
+
return;
|
|
201
|
+
this.initialized = true;
|
|
202
|
+
// ── Load channels ─────────────────────────────────────────────────
|
|
203
|
+
let loadedChannels;
|
|
204
|
+
try {
|
|
205
|
+
loadedChannels = await this.channelCollection.findMany();
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
console.error('[ChannelService] Failed to load from channels collection:', error);
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
for (const channel of loadedChannels) {
|
|
212
|
+
this.channels.set(channel.id, channel);
|
|
213
|
+
// Rebuild name index
|
|
214
|
+
this.nameIndex.set(channel.name.toLowerCase(), channel.id);
|
|
215
|
+
// Reconstruct key epoch state
|
|
216
|
+
const epochState = (0, rehydrationHelpers_1.reconstructKeyEpochState)(channel.encryptedSharedKey);
|
|
217
|
+
if (epochState) {
|
|
218
|
+
this.keyEpochStates.set(channel.id, epochState);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
console.warn(`[ChannelService] Skipping key epoch reconstruction for channel ${channel.id}: malformed encryptedSharedKey`);
|
|
222
|
+
}
|
|
223
|
+
// Register permissions for each member
|
|
224
|
+
for (const member of channel.members) {
|
|
225
|
+
this.permissionService.assignRole(member.memberId, channel.id, member.role);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// ── Load channel messages ───────────────────────────────────────────
|
|
229
|
+
let loadedMessages;
|
|
230
|
+
try {
|
|
231
|
+
loadedMessages = await this.channelMessageCollection.findMany();
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
console.error('[ChannelService] Failed to load from channelMessages collection:', error);
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
const grouped = (0, rehydrationHelpers_1.groupAndSortMessages)(loadedMessages);
|
|
238
|
+
for (const [contextId, msgs] of grouped) {
|
|
239
|
+
this.messages.set(contextId, msgs);
|
|
240
|
+
}
|
|
241
|
+
// ── Load invite tokens ──────────────────────────────────────────────
|
|
242
|
+
let loadedTokens;
|
|
243
|
+
try {
|
|
244
|
+
loadedTokens = await this.inviteTokenCollection.findMany();
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
console.error('[ChannelService] Failed to load from inviteTokens collection:', error);
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
for (const token of loadedTokens) {
|
|
251
|
+
this.inviteTokens.set(token.token, token);
|
|
252
|
+
}
|
|
155
253
|
}
|
|
156
254
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
157
255
|
assertChannelExists(channelId) {
|
|
@@ -179,17 +277,102 @@ class ChannelService {
|
|
|
179
277
|
generateSymmetricKey() {
|
|
180
278
|
return this.randomBytesProvider(32);
|
|
181
279
|
}
|
|
182
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Encrypt a symmetric key for multiple members, returning a Map<memberId, wrappedKey>.
|
|
282
|
+
* Wraps key wrapping failures as KeyUnwrapError with context information.
|
|
283
|
+
*
|
|
284
|
+
* Requirements: 12.3
|
|
285
|
+
*/
|
|
286
|
+
encryptKeyForMembers(memberIds, symmetricKey, contextId, epoch) {
|
|
183
287
|
const encrypted = new Map();
|
|
184
288
|
for (const id of memberIds) {
|
|
185
|
-
|
|
289
|
+
try {
|
|
290
|
+
encrypted.set(id, this.encryptKey(id, symmetricKey));
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (contextId !== undefined && epoch !== undefined) {
|
|
294
|
+
throw new encryptionErrors_1.KeyUnwrapError(contextId, id, epoch);
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
186
298
|
}
|
|
187
299
|
return encrypted;
|
|
188
300
|
}
|
|
189
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Create initial epoch state (epoch 0) for a new channel.
|
|
303
|
+
* Uses KeyEpochManager pattern: creates epoch 0 with wrapped keys for all members.
|
|
304
|
+
*
|
|
305
|
+
* Requirements: 1.3, 2.1
|
|
306
|
+
*/
|
|
307
|
+
createInitialEpochState(symmetricKey, memberIds) {
|
|
308
|
+
const epochKeys = new Map();
|
|
309
|
+
epochKeys.set(0, symmetricKey);
|
|
310
|
+
const wrappedKeys = this.encryptKeyForMembers(memberIds, symmetricKey);
|
|
311
|
+
const encryptedEpochKeys = new Map();
|
|
312
|
+
encryptedEpochKeys.set(0, wrappedKeys);
|
|
313
|
+
return { currentEpoch: 0, epochKeys, encryptedEpochKeys };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Add a member to an existing epoch state: wrap ALL epoch keys for the new member.
|
|
317
|
+
* This gives the new member access to full message history.
|
|
318
|
+
*
|
|
319
|
+
* Requirements: 2.2, 6.1, 6.3
|
|
320
|
+
*/
|
|
321
|
+
addMemberToEpochState(state, newMemberId) {
|
|
322
|
+
for (const [epoch, rawKey] of state.epochKeys) {
|
|
323
|
+
const epochMap = state.encryptedEpochKeys.get(epoch) ?? new Map();
|
|
324
|
+
epochMap.set(newMemberId, this.encryptKey(newMemberId, rawKey));
|
|
325
|
+
state.encryptedEpochKeys.set(epoch, epochMap);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Rotate key: increment epoch, add new key, delete removed member from ALL epochs,
|
|
330
|
+
* re-wrap ALL epoch keys for remaining members.
|
|
331
|
+
*
|
|
332
|
+
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6
|
|
333
|
+
*/
|
|
334
|
+
rotateEpochState(state, newKey, remainingMemberIds, removedMemberId) {
|
|
335
|
+
const newEpoch = state.currentEpoch + 1;
|
|
336
|
+
// Add new epoch key
|
|
337
|
+
state.epochKeys.set(newEpoch, newKey);
|
|
338
|
+
// Delete removed member from ALL epochs
|
|
339
|
+
for (const [, memberMap] of state.encryptedEpochKeys) {
|
|
340
|
+
memberMap.delete(removedMemberId);
|
|
341
|
+
}
|
|
342
|
+
// Re-wrap ALL epoch keys for remaining members
|
|
343
|
+
for (const [epoch, rawKey] of state.epochKeys) {
|
|
344
|
+
state.encryptedEpochKeys.set(epoch, this.encryptKeyForMembers(remainingMemberIds, rawKey));
|
|
345
|
+
}
|
|
346
|
+
return { ...state, currentEpoch: newEpoch };
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Rotate the channel's symmetric key using epoch-aware key management.
|
|
350
|
+
* Generates a new key, increments epoch, re-wraps all epoch keys for remaining members,
|
|
351
|
+
* and removes the departed member from all epochs.
|
|
352
|
+
*
|
|
353
|
+
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5
|
|
354
|
+
*/
|
|
355
|
+
rotateKey(channel, removedMemberId) {
|
|
190
356
|
const newKey = this.generateSymmetricKey();
|
|
191
|
-
this.
|
|
192
|
-
|
|
357
|
+
const state = this.keyEpochStates.get(channel.id);
|
|
358
|
+
if (state) {
|
|
359
|
+
const remainingMemberIds = channel.members.map((m) => m.memberId);
|
|
360
|
+
const newState = this.rotateEpochState(state, newKey, remainingMemberIds, removedMemberId);
|
|
361
|
+
this.keyEpochStates.set(channel.id, newState);
|
|
362
|
+
channel.encryptedSharedKey = newState.encryptedEpochKeys;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Assert that a key epoch exists in the epoch state for a given context.
|
|
367
|
+
* Throws KeyEpochNotFoundError if the epoch is not found.
|
|
368
|
+
*
|
|
369
|
+
* Requirements: 12.3
|
|
370
|
+
*/
|
|
371
|
+
assertEpochExists(contextId, keyEpoch) {
|
|
372
|
+
const state = this.keyEpochStates.get(contextId);
|
|
373
|
+
if (!state || !state.epochKeys.has(keyEpoch)) {
|
|
374
|
+
throw new encryptionErrors_1.KeyEpochNotFoundError(contextId, keyEpoch);
|
|
375
|
+
}
|
|
193
376
|
}
|
|
194
377
|
normalizeChannelName(name) {
|
|
195
378
|
return name.toLowerCase().replace(/\s+/g, '-');
|
|
@@ -199,8 +382,9 @@ class ChannelService {
|
|
|
199
382
|
* Create a new channel.
|
|
200
383
|
* Generates a shared symmetric key and encrypts it for the creator.
|
|
201
384
|
* The creator is assigned the OWNER role.
|
|
385
|
+
* Uses KeyEpochManager pattern to create initial epoch 0.
|
|
202
386
|
*
|
|
203
|
-
*
|
|
387
|
+
* Requirements: 10.3, 1.3, 2.1, 9.1
|
|
204
388
|
*/
|
|
205
389
|
async createChannel(name, creatorId, visibility, topic = '') {
|
|
206
390
|
const normalized = this.normalizeChannelName(name);
|
|
@@ -217,7 +401,8 @@ class ChannelService {
|
|
|
217
401
|
joinedAt: now,
|
|
218
402
|
},
|
|
219
403
|
];
|
|
220
|
-
|
|
404
|
+
// Create initial epoch state (epoch 0) with wrapped key for creator
|
|
405
|
+
const epochState = this.createInitialEpochState(symmetricKey, [creatorId]);
|
|
221
406
|
const channel = {
|
|
222
407
|
id: channelId,
|
|
223
408
|
name: normalized,
|
|
@@ -225,7 +410,7 @@ class ChannelService {
|
|
|
225
410
|
creatorId,
|
|
226
411
|
visibility,
|
|
227
412
|
members,
|
|
228
|
-
encryptedSharedKey,
|
|
413
|
+
encryptedSharedKey: epochState.encryptedEpochKeys,
|
|
229
414
|
createdAt: now,
|
|
230
415
|
lastMessageAt: now,
|
|
231
416
|
pinnedMessageIds: [],
|
|
@@ -233,9 +418,13 @@ class ChannelService {
|
|
|
233
418
|
};
|
|
234
419
|
this.channels.set(channelId, channel);
|
|
235
420
|
this.messages.set(channelId, []);
|
|
236
|
-
this.
|
|
421
|
+
this.keyEpochStates.set(channelId, epochState);
|
|
237
422
|
this.nameIndex.set(normalized, channelId);
|
|
238
423
|
this.permissionService.assignRole(creatorId, channelId, communication_1.DefaultRole.OWNER);
|
|
424
|
+
// Persist to storage provider if available
|
|
425
|
+
if (this.channelCollection) {
|
|
426
|
+
await this.channelCollection.create(channel);
|
|
427
|
+
}
|
|
239
428
|
return channel;
|
|
240
429
|
}
|
|
241
430
|
/**
|
|
@@ -292,6 +481,10 @@ class ChannelService {
|
|
|
292
481
|
channel.historyVisibleToNewMembers = updates.historyVisibleToNewMembers;
|
|
293
482
|
}
|
|
294
483
|
this.eventEmitter.emitChannelUpdated(channelId, channelId, requesterId);
|
|
484
|
+
// Persist channel update to storage provider if available
|
|
485
|
+
if (this.channelCollection) {
|
|
486
|
+
await this.channelCollection.update(channelId, channel);
|
|
487
|
+
}
|
|
295
488
|
return channel;
|
|
296
489
|
}
|
|
297
490
|
/**
|
|
@@ -304,13 +497,18 @@ class ChannelService {
|
|
|
304
497
|
this.nameIndex.delete(channel.name);
|
|
305
498
|
this.channels.delete(channelId);
|
|
306
499
|
this.messages.delete(channelId);
|
|
307
|
-
this.
|
|
500
|
+
this.keyEpochStates.delete(channelId);
|
|
501
|
+
// Persist deletion to storage provider if available
|
|
502
|
+
if (this.channelCollection) {
|
|
503
|
+
await this.channelCollection.delete(channelId);
|
|
504
|
+
}
|
|
308
505
|
}
|
|
309
506
|
// ─── Join / Leave ─────────────────────────────────────────────────────
|
|
310
507
|
/**
|
|
311
508
|
* Join a public channel. Rejects invite-only channels.
|
|
509
|
+
* Wraps ALL epoch keys for the new member so they can read history.
|
|
312
510
|
*
|
|
313
|
-
*
|
|
511
|
+
* Requirements: 10.3, 2.2
|
|
314
512
|
*/
|
|
315
513
|
async joinChannel(channelId, memberId) {
|
|
316
514
|
const channel = this.assertChannelExists(channelId);
|
|
@@ -326,59 +524,184 @@ class ChannelService {
|
|
|
326
524
|
role: communication_1.DefaultRole.MEMBER,
|
|
327
525
|
joinedAt: now,
|
|
328
526
|
});
|
|
329
|
-
|
|
330
|
-
|
|
527
|
+
// Add member to epoch state: wrap ALL epoch keys for the new member
|
|
528
|
+
const state = this.keyEpochStates.get(channelId);
|
|
529
|
+
if (state) {
|
|
530
|
+
this.addMemberToEpochState(state, memberId);
|
|
531
|
+
channel.encryptedSharedKey = state.encryptedEpochKeys;
|
|
532
|
+
}
|
|
533
|
+
this.permissionService.assignRole(memberId, channelId, communication_1.DefaultRole.MEMBER);
|
|
534
|
+
// Persist channel update to storage provider if available
|
|
535
|
+
if (this.channelCollection) {
|
|
536
|
+
await this.channelCollection.update(channelId, channel);
|
|
537
|
+
}
|
|
538
|
+
this.eventEmitter.emitMemberJoined('channel', channelId, memberId);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Remove a member from a channel (server-level operation).
|
|
542
|
+
* Bypasses permission checks — used by ServerService when a member is removed
|
|
543
|
+
* from a server so that key rotation is performed for each affected channel.
|
|
544
|
+
* Silently skips if the member is not in the channel.
|
|
545
|
+
*
|
|
546
|
+
* Requirements: 7.1, 7.2, 7.3
|
|
547
|
+
*/
|
|
548
|
+
async removeMemberFromChannel(channelId, memberId) {
|
|
549
|
+
const channel = this.assertChannelExists(channelId);
|
|
550
|
+
// Skip if not a member
|
|
551
|
+
if (!channel.members.some((m) => m.memberId === memberId)) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
channel.members = channel.members.filter((m) => m.memberId !== memberId);
|
|
555
|
+
if (channel.members.length > 0) {
|
|
556
|
+
this.rotateKey(channel, memberId);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
// No members left — clean up epoch state
|
|
560
|
+
const state = this.keyEpochStates.get(channelId);
|
|
561
|
+
if (state) {
|
|
562
|
+
for (const [, epochMap] of state.encryptedEpochKeys) {
|
|
563
|
+
epochMap.delete(memberId);
|
|
564
|
+
}
|
|
565
|
+
channel.encryptedSharedKey = state.encryptedEpochKeys;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Persist channel update to storage provider if available
|
|
569
|
+
if (this.channelCollection) {
|
|
570
|
+
await this.channelCollection.update(channelId, channel);
|
|
571
|
+
}
|
|
572
|
+
this.eventEmitter.emitMemberLeft('channel', channelId, memberId);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Add a member to a channel (server-level operation).
|
|
576
|
+
* Bypasses visibility checks — used by ServerService when a member joins a server
|
|
577
|
+
* so that all epoch keys are wrapped for the new member across all server channels.
|
|
578
|
+
* Silently skips if the member is already in the channel.
|
|
579
|
+
*
|
|
580
|
+
* Requirements: 6.1, 6.2, 6.3
|
|
581
|
+
*/
|
|
582
|
+
async addMemberToChannel(channelId, memberId) {
|
|
583
|
+
const channel = this.assertChannelExists(channelId);
|
|
584
|
+
// Skip if already a member
|
|
585
|
+
if (channel.members.some((m) => m.memberId === memberId)) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const now = new Date();
|
|
589
|
+
channel.members.push({
|
|
590
|
+
memberId,
|
|
591
|
+
role: communication_1.DefaultRole.MEMBER,
|
|
592
|
+
joinedAt: now,
|
|
593
|
+
});
|
|
594
|
+
// Add member to epoch state: wrap ALL epoch keys for the new member
|
|
595
|
+
const state = this.keyEpochStates.get(channelId);
|
|
596
|
+
if (state) {
|
|
597
|
+
this.addMemberToEpochState(state, memberId);
|
|
598
|
+
channel.encryptedSharedKey = state.encryptedEpochKeys;
|
|
599
|
+
}
|
|
331
600
|
this.permissionService.assignRole(memberId, channelId, communication_1.DefaultRole.MEMBER);
|
|
601
|
+
// Persist channel update to storage provider if available
|
|
602
|
+
if (this.channelCollection) {
|
|
603
|
+
await this.channelCollection.update(channelId, channel);
|
|
604
|
+
}
|
|
332
605
|
this.eventEmitter.emitMemberJoined('channel', channelId, memberId);
|
|
333
606
|
}
|
|
334
607
|
/**
|
|
335
608
|
* Leave a channel voluntarily. Rotates the shared key.
|
|
609
|
+
*
|
|
610
|
+
* Requirements: 3.5
|
|
336
611
|
*/
|
|
337
612
|
async leaveChannel(channelId, memberId) {
|
|
338
613
|
const channel = this.assertChannelExists(channelId);
|
|
339
614
|
this.assertIsMember(channel, memberId);
|
|
340
615
|
channel.members = channel.members.filter((m) => m.memberId !== memberId);
|
|
341
|
-
channel.encryptedSharedKey.delete(memberId);
|
|
342
616
|
if (channel.members.length > 0) {
|
|
343
|
-
this.rotateKey(channel);
|
|
617
|
+
this.rotateKey(channel, memberId);
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
// No members left — clean up epoch state
|
|
621
|
+
const state = this.keyEpochStates.get(channelId);
|
|
622
|
+
if (state) {
|
|
623
|
+
for (const [, epochMap] of state.encryptedEpochKeys) {
|
|
624
|
+
epochMap.delete(memberId);
|
|
625
|
+
}
|
|
626
|
+
channel.encryptedSharedKey = state.encryptedEpochKeys;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// Persist channel update to storage provider if available
|
|
630
|
+
if (this.channelCollection) {
|
|
631
|
+
await this.channelCollection.update(channelId, channel);
|
|
344
632
|
}
|
|
345
633
|
this.eventEmitter.emitMemberLeft('channel', channelId, memberId);
|
|
346
634
|
}
|
|
347
635
|
// ─── Messaging ────────────────────────────────────────────────────────
|
|
348
636
|
/**
|
|
349
637
|
* Send a message to a channel.
|
|
350
|
-
*
|
|
638
|
+
* Encrypts content with the current epoch's CEK and records the keyEpoch.
|
|
639
|
+
* Optionally accepts attachments which are validated against platform limits.
|
|
640
|
+
*
|
|
641
|
+
* Requirements: 10.3, 1.1, 1.4, 9.1, 11.1, 11.2, 11.4, 11.5
|
|
351
642
|
*/
|
|
352
|
-
async sendMessage(channelId, senderId, content) {
|
|
643
|
+
async sendMessage(channelId, senderId, content, attachments) {
|
|
353
644
|
const channel = this.assertChannelExists(channelId);
|
|
354
645
|
this.assertIsMember(channel, senderId);
|
|
355
646
|
this.assertPermission(senderId, channelId, communication_1.Permission.SEND_MESSAGES);
|
|
356
647
|
this.assertNotMuted(senderId, channelId);
|
|
648
|
+
// Validate and prepare attachment metadata before creating the message
|
|
649
|
+
const attachmentMetadata = attachments?.length
|
|
650
|
+
? (0, attachmentUtils_1.validateAndPrepareAttachments)(attachments)
|
|
651
|
+
: [];
|
|
652
|
+
const state = this.keyEpochStates.get(channelId);
|
|
653
|
+
const currentEpoch = state?.currentEpoch ?? 0;
|
|
654
|
+
// Store content via block content store if available; otherwise use raw content
|
|
655
|
+
let messageContent = content;
|
|
656
|
+
if (this.blockContentStore) {
|
|
657
|
+
const memberIds = channel.members.map((m) => m.memberId);
|
|
658
|
+
const { blockReference } = await this.blockContentStore.storeContent(content, senderId, memberIds);
|
|
659
|
+
messageContent = blockReference;
|
|
660
|
+
}
|
|
357
661
|
const now = new Date();
|
|
358
662
|
const message = {
|
|
359
663
|
id: (0, uuid_1.v4)(),
|
|
360
664
|
contextType: 'channel',
|
|
361
665
|
contextId: channelId,
|
|
362
666
|
senderId,
|
|
363
|
-
encryptedContent:
|
|
667
|
+
encryptedContent: messageContent,
|
|
364
668
|
createdAt: now,
|
|
365
669
|
editHistory: [],
|
|
366
670
|
deleted: false,
|
|
367
671
|
pinned: false,
|
|
368
672
|
reactions: [],
|
|
673
|
+
keyEpoch: currentEpoch,
|
|
674
|
+
attachments: attachmentMetadata,
|
|
369
675
|
};
|
|
370
676
|
this.messages.get(channelId).push(message);
|
|
371
677
|
channel.lastMessageAt = now;
|
|
678
|
+
// Persist to storage provider if available
|
|
679
|
+
if (this.channelMessageCollection) {
|
|
680
|
+
await this.channelMessageCollection.create(message);
|
|
681
|
+
}
|
|
682
|
+
if (this.channelCollection) {
|
|
683
|
+
await this.channelCollection.update(channelId, channel);
|
|
684
|
+
}
|
|
372
685
|
this.eventEmitter.emitMessageSent('channel', channelId, message.id, senderId);
|
|
373
686
|
return message;
|
|
374
687
|
}
|
|
375
688
|
/**
|
|
376
689
|
* Get messages in a channel with cursor-based pagination.
|
|
690
|
+
* Messages are returned with their keyEpoch so clients can decrypt
|
|
691
|
+
* using the appropriate epoch's CEK.
|
|
692
|
+
* Validates that each message's keyEpoch exists in the epoch state;
|
|
693
|
+
* throws KeyEpochNotFoundError if a message references a non-existent epoch.
|
|
694
|
+
*
|
|
695
|
+
* Requirements: 1.2, 12.3
|
|
377
696
|
*/
|
|
378
697
|
async getMessages(channelId, memberId, cursor, limit = 50) {
|
|
379
698
|
const channel = this.assertChannelExists(channelId);
|
|
380
699
|
this.assertIsMember(channel, memberId);
|
|
381
700
|
const msgs = this.messages.get(channelId) ?? [];
|
|
701
|
+
// Validate that each message's keyEpoch exists in the epoch state
|
|
702
|
+
for (const msg of msgs) {
|
|
703
|
+
this.assertEpochExists(channelId, msg.keyEpoch);
|
|
704
|
+
}
|
|
382
705
|
return (0, pagination_1.paginateItems)(msgs, cursor, limit);
|
|
383
706
|
}
|
|
384
707
|
/**
|
|
@@ -389,6 +712,23 @@ class ChannelService {
|
|
|
389
712
|
this.assertIsMember(channel, memberId);
|
|
390
713
|
const msgs = this.messages.get(channelId) ?? [];
|
|
391
714
|
const lowerQuery = query.toLowerCase();
|
|
715
|
+
if (this.blockContentStore) {
|
|
716
|
+
// Retrieve content from block store for each non-deleted message before searching
|
|
717
|
+
const matching = [];
|
|
718
|
+
for (const m of msgs) {
|
|
719
|
+
if (m.deleted)
|
|
720
|
+
continue;
|
|
721
|
+
const contentBytes = await this.blockContentStore.retrieveContent(m.encryptedContent);
|
|
722
|
+
if (contentBytes) {
|
|
723
|
+
const contentText = new TextDecoder().decode(contentBytes);
|
|
724
|
+
if (contentText.toLowerCase().includes(lowerQuery)) {
|
|
725
|
+
matching.push(m);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return (0, pagination_1.paginateItems)(matching, cursor, limit);
|
|
730
|
+
}
|
|
731
|
+
// Fall back to current direct string search when block store is absent
|
|
392
732
|
const matching = msgs.filter((m) => !m.deleted &&
|
|
393
733
|
typeof m.encryptedContent === 'string' &&
|
|
394
734
|
m.encryptedContent.toLowerCase().includes(lowerQuery));
|
|
@@ -416,12 +756,17 @@ class ChannelService {
|
|
|
416
756
|
currentUses: 0,
|
|
417
757
|
};
|
|
418
758
|
this.inviteTokens.set(token.token, token);
|
|
759
|
+
// Persist invite token to storage provider if available
|
|
760
|
+
if (this.inviteTokenCollection) {
|
|
761
|
+
await this.inviteTokenCollection.create(token);
|
|
762
|
+
}
|
|
419
763
|
return token;
|
|
420
764
|
}
|
|
421
765
|
/**
|
|
422
766
|
* Redeem an invite token to join a channel.
|
|
767
|
+
* Wraps ALL epoch keys for the joining member.
|
|
423
768
|
*
|
|
424
|
-
*
|
|
769
|
+
* Requirements: 10.3, 2.2
|
|
425
770
|
*/
|
|
426
771
|
async redeemInvite(token, memberId) {
|
|
427
772
|
const invite = this.inviteTokens.get(token);
|
|
@@ -444,10 +789,21 @@ class ChannelService {
|
|
|
444
789
|
role: communication_1.DefaultRole.MEMBER,
|
|
445
790
|
joinedAt: now,
|
|
446
791
|
});
|
|
447
|
-
|
|
448
|
-
|
|
792
|
+
// Add member to epoch state: wrap ALL epoch keys for the new member
|
|
793
|
+
const state = this.keyEpochStates.get(channel.id);
|
|
794
|
+
if (state) {
|
|
795
|
+
this.addMemberToEpochState(state, memberId);
|
|
796
|
+
channel.encryptedSharedKey = state.encryptedEpochKeys;
|
|
797
|
+
}
|
|
449
798
|
this.permissionService.assignRole(memberId, channel.id, communication_1.DefaultRole.MEMBER);
|
|
450
799
|
invite.currentUses++;
|
|
800
|
+
// Persist changes to storage provider if available
|
|
801
|
+
if (this.channelCollection) {
|
|
802
|
+
await this.channelCollection.update(channel.id, channel);
|
|
803
|
+
}
|
|
804
|
+
if (this.inviteTokenCollection) {
|
|
805
|
+
await this.inviteTokenCollection.update(invite.token, invite);
|
|
806
|
+
}
|
|
451
807
|
this.eventEmitter.emitMemberJoined('channel', channel.id, memberId);
|
|
452
808
|
}
|
|
453
809
|
// ─── Moderation ───────────────────────────────────────────────────────
|
|
@@ -467,13 +823,17 @@ class ChannelService {
|
|
|
467
823
|
if (member) {
|
|
468
824
|
member.mutedUntil = new Date(Date.now() + durationMs);
|
|
469
825
|
}
|
|
826
|
+
// Persist channel update to storage provider if available
|
|
827
|
+
if (this.channelCollection) {
|
|
828
|
+
await this.channelCollection.update(channelId, channel);
|
|
829
|
+
}
|
|
470
830
|
this.eventEmitter.emitMemberMuted('channel', channelId, targetId, requesterId, durationMs);
|
|
471
831
|
}
|
|
472
832
|
/**
|
|
473
|
-
* Kick a member from a channel. Rotates encryption keys.
|
|
833
|
+
* Kick a member from a channel. Rotates encryption keys using epoch-aware rotation.
|
|
474
834
|
* Requires KICK_MEMBERS permission.
|
|
475
835
|
*
|
|
476
|
-
*
|
|
836
|
+
* Requirements: 10.3, 3.1, 3.2, 3.3, 3.4
|
|
477
837
|
*/
|
|
478
838
|
async kickMember(channelId, requesterId, targetId) {
|
|
479
839
|
const channel = this.assertChannelExists(channelId);
|
|
@@ -481,9 +841,22 @@ class ChannelService {
|
|
|
481
841
|
this.assertIsMember(channel, targetId);
|
|
482
842
|
this.assertPermission(requesterId, channelId, communication_1.Permission.KICK_MEMBERS);
|
|
483
843
|
channel.members = channel.members.filter((m) => m.memberId !== targetId);
|
|
484
|
-
channel.encryptedSharedKey.delete(targetId);
|
|
485
844
|
if (channel.members.length > 0) {
|
|
486
|
-
this.rotateKey(channel);
|
|
845
|
+
this.rotateKey(channel, targetId);
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
// No members left — clean up epoch state
|
|
849
|
+
const state = this.keyEpochStates.get(channelId);
|
|
850
|
+
if (state) {
|
|
851
|
+
for (const [, epochMap] of state.encryptedEpochKeys) {
|
|
852
|
+
epochMap.delete(targetId);
|
|
853
|
+
}
|
|
854
|
+
channel.encryptedSharedKey = state.encryptedEpochKeys;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Persist channel update to storage provider if available
|
|
858
|
+
if (this.channelCollection) {
|
|
859
|
+
await this.channelCollection.update(channelId, channel);
|
|
487
860
|
}
|
|
488
861
|
this.eventEmitter.emitMemberKicked('channel', channelId, targetId, requesterId);
|
|
489
862
|
}
|
|
@@ -496,7 +869,36 @@ class ChannelService {
|
|
|
496
869
|
const channel = this.assertChannelExists(channelId);
|
|
497
870
|
this.assertIsMember(channel, memberId);
|
|
498
871
|
const msgs = this.messages.get(channelId) ?? [];
|
|
872
|
+
if (this.blockContentStore) {
|
|
873
|
+
// Find the message manually to handle block storage
|
|
874
|
+
const message = msgs.find((m) => m.id === messageId);
|
|
875
|
+
if (!message)
|
|
876
|
+
throw new ChannelMessageNotFoundError(messageId);
|
|
877
|
+
if (message.senderId !== memberId)
|
|
878
|
+
throw new NotMessageAuthorError();
|
|
879
|
+
// Store new content as a new block
|
|
880
|
+
const memberIds = channel.members.map((m) => m.memberId);
|
|
881
|
+
const { blockReference } = await this.blockContentStore.storeContent(newContent, memberId, memberIds);
|
|
882
|
+
// Push old block reference to edit history
|
|
883
|
+
message.editHistory.push({
|
|
884
|
+
content: message.encryptedContent,
|
|
885
|
+
editedAt: new Date(),
|
|
886
|
+
});
|
|
887
|
+
// Update encryptedContent to new block reference
|
|
888
|
+
message.encryptedContent = blockReference;
|
|
889
|
+
message.editedAt = new Date();
|
|
890
|
+
// Persist edited message to storage provider if available
|
|
891
|
+
if (this.channelMessageCollection) {
|
|
892
|
+
await this.channelMessageCollection.update(messageId, message);
|
|
893
|
+
}
|
|
894
|
+
this.eventEmitter.emitMessageEdited('channel', channelId, messageId, memberId);
|
|
895
|
+
return message;
|
|
896
|
+
}
|
|
499
897
|
const edited = this.messageOps.editMessage(msgs, messageId, memberId, newContent, (id) => new ChannelMessageNotFoundError(id), () => new NotMessageAuthorError());
|
|
898
|
+
// Persist edited message to storage provider if available
|
|
899
|
+
if (this.channelMessageCollection) {
|
|
900
|
+
await this.channelMessageCollection.update(messageId, edited);
|
|
901
|
+
}
|
|
500
902
|
this.eventEmitter.emitMessageEdited('channel', channelId, messageId, memberId);
|
|
501
903
|
return edited;
|
|
502
904
|
}
|
|
@@ -509,6 +911,13 @@ class ChannelService {
|
|
|
509
911
|
this.assertIsMember(channel, memberId);
|
|
510
912
|
const msgs = this.messages.get(channelId) ?? [];
|
|
511
913
|
this.messageOps.deleteMessage(msgs, channelId, messageId, memberId, (id) => new ChannelMessageNotFoundError(id), (p) => new ChannelPermissionError(p));
|
|
914
|
+
// Persist deleted message to storage provider if available
|
|
915
|
+
if (this.channelMessageCollection) {
|
|
916
|
+
const deletedMsg = msgs.find((m) => m.id === messageId);
|
|
917
|
+
if (deletedMsg) {
|
|
918
|
+
await this.channelMessageCollection.update(messageId, deletedMsg);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
512
921
|
this.eventEmitter.emitMessageDeleted('channel', channelId, messageId, memberId);
|
|
513
922
|
}
|
|
514
923
|
/**
|
|
@@ -520,6 +929,16 @@ class ChannelService {
|
|
|
520
929
|
this.assertIsMember(channel, memberId);
|
|
521
930
|
const msgs = this.messages.get(channelId) ?? [];
|
|
522
931
|
this.messageOps.pinMessage(msgs, channelId, messageId, memberId, channel, (id) => new ChannelMessageNotFoundError(id), (p) => new ChannelPermissionError(p));
|
|
932
|
+
// Persist pin changes to storage provider if available
|
|
933
|
+
if (this.channelMessageCollection) {
|
|
934
|
+
const pinnedMsg = msgs.find((m) => m.id === messageId);
|
|
935
|
+
if (pinnedMsg) {
|
|
936
|
+
await this.channelMessageCollection.update(messageId, pinnedMsg);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (this.channelCollection) {
|
|
940
|
+
await this.channelCollection.update(channelId, channel);
|
|
941
|
+
}
|
|
523
942
|
this.eventEmitter.emitMessagePinEvent(communication_1.CommunicationEventType.MESSAGE_PINNED, 'channel', channelId, messageId, memberId);
|
|
524
943
|
}
|
|
525
944
|
/**
|
|
@@ -531,6 +950,16 @@ class ChannelService {
|
|
|
531
950
|
this.assertIsMember(channel, memberId);
|
|
532
951
|
const msgs = this.messages.get(channelId) ?? [];
|
|
533
952
|
this.messageOps.unpinMessage(msgs, channelId, messageId, memberId, channel, (id) => new ChannelMessageNotFoundError(id), (p) => new ChannelPermissionError(p));
|
|
953
|
+
// Persist unpin changes to storage provider if available
|
|
954
|
+
if (this.channelMessageCollection) {
|
|
955
|
+
const unpinnedMsg = msgs.find((m) => m.id === messageId);
|
|
956
|
+
if (unpinnedMsg) {
|
|
957
|
+
await this.channelMessageCollection.update(messageId, unpinnedMsg);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (this.channelCollection) {
|
|
961
|
+
await this.channelCollection.update(channelId, channel);
|
|
962
|
+
}
|
|
534
963
|
this.eventEmitter.emitMessagePinEvent(communication_1.CommunicationEventType.MESSAGE_UNPINNED, 'channel', channelId, messageId, memberId);
|
|
535
964
|
}
|
|
536
965
|
/**
|
|
@@ -542,6 +971,13 @@ class ChannelService {
|
|
|
542
971
|
this.assertIsMember(channel, memberId);
|
|
543
972
|
const msgs = this.messages.get(channelId) ?? [];
|
|
544
973
|
const reactionId = this.messageOps.addReaction(msgs, messageId, memberId, emoji, (id) => new ChannelMessageNotFoundError(id));
|
|
974
|
+
// Persist reaction change to storage provider if available
|
|
975
|
+
if (this.channelMessageCollection) {
|
|
976
|
+
const msg = msgs.find((m) => m.id === messageId);
|
|
977
|
+
if (msg) {
|
|
978
|
+
await this.channelMessageCollection.update(messageId, msg);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
545
981
|
this.eventEmitter.emitReactionEvent(communication_1.CommunicationEventType.REACTION_ADDED, 'channel', channelId, messageId, memberId, emoji, reactionId);
|
|
546
982
|
return reactionId;
|
|
547
983
|
}
|
|
@@ -554,14 +990,34 @@ class ChannelService {
|
|
|
554
990
|
this.assertIsMember(channel, memberId);
|
|
555
991
|
const msgs = this.messages.get(channelId) ?? [];
|
|
556
992
|
this.messageOps.removeReaction(msgs, messageId, reactionId, (id) => new ChannelMessageNotFoundError(id), (id) => new ChannelReactionNotFoundError(id));
|
|
993
|
+
// Persist reaction removal to storage provider if available
|
|
994
|
+
if (this.channelMessageCollection) {
|
|
995
|
+
const msg = msgs.find((m) => m.id === messageId);
|
|
996
|
+
if (msg) {
|
|
997
|
+
await this.channelMessageCollection.update(messageId, msg);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
557
1000
|
this.eventEmitter.emitReactionEvent(communication_1.CommunicationEventType.REACTION_REMOVED, 'channel', channelId, messageId, memberId, '', reactionId);
|
|
558
1001
|
}
|
|
559
1002
|
// ─── Accessors (for testing / internal use) ───────────────────────────
|
|
560
1003
|
getChannelById(channelId) {
|
|
561
1004
|
return this.channels.get(channelId);
|
|
562
1005
|
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Get the current epoch's raw symmetric key for a channel.
|
|
1008
|
+
* Returns the key for the latest epoch (backward-compatible accessor).
|
|
1009
|
+
*/
|
|
563
1010
|
getSymmetricKey(channelId) {
|
|
564
|
-
|
|
1011
|
+
const state = this.keyEpochStates.get(channelId);
|
|
1012
|
+
if (!state)
|
|
1013
|
+
return undefined;
|
|
1014
|
+
return state.epochKeys.get(state.currentEpoch);
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Get the full epoch state for a channel (testing / internal use).
|
|
1018
|
+
*/
|
|
1019
|
+
getKeyEpochState(channelId) {
|
|
1020
|
+
return this.keyEpochStates.get(channelId);
|
|
565
1021
|
}
|
|
566
1022
|
getAllMessages(channelId) {
|
|
567
1023
|
return this.messages.get(channelId) ?? [];
|