chatly-sdk 1.0.0 → 2.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.
- package/CONTRIBUTING.md +658 -0
- package/IMPROVEMENTS.md +402 -0
- package/LICENSE +21 -0
- package/README.md +1576 -162
- package/dist/index.d.ts +502 -11
- package/dist/index.js +1619 -66
- package/examples/01-basic-chat/README.md +61 -0
- package/examples/01-basic-chat/index.js +58 -0
- package/examples/01-basic-chat/package.json +13 -0
- package/examples/02-group-chat/README.md +78 -0
- package/examples/02-group-chat/index.js +76 -0
- package/examples/02-group-chat/package.json +13 -0
- package/examples/03-offline-messaging/README.md +73 -0
- package/examples/03-offline-messaging/index.js +80 -0
- package/examples/03-offline-messaging/package.json +13 -0
- package/examples/04-live-chat/README.md +80 -0
- package/examples/04-live-chat/index.js +114 -0
- package/examples/04-live-chat/package.json +13 -0
- package/examples/05-hybrid-messaging/README.md +71 -0
- package/examples/05-hybrid-messaging/index.js +106 -0
- package/examples/05-hybrid-messaging/package.json +13 -0
- package/examples/06-postgresql-integration/README.md +101 -0
- package/examples/06-postgresql-integration/adapters/groupStore.js +73 -0
- package/examples/06-postgresql-integration/adapters/messageStore.js +47 -0
- package/examples/06-postgresql-integration/adapters/userStore.js +40 -0
- package/examples/06-postgresql-integration/index.js +92 -0
- package/examples/06-postgresql-integration/package.json +14 -0
- package/examples/06-postgresql-integration/schema.sql +58 -0
- package/examples/08-customer-support/README.md +70 -0
- package/examples/08-customer-support/index.js +104 -0
- package/examples/08-customer-support/package.json +13 -0
- package/examples/README.md +105 -0
- package/jest.config.cjs +28 -0
- package/package.json +15 -6
- package/src/chat/ChatSession.ts +160 -3
- package/src/chat/GroupSession.ts +108 -1
- package/src/constants.ts +61 -0
- package/src/crypto/e2e.ts +9 -20
- package/src/crypto/utils.ts +3 -1
- package/src/index.ts +530 -63
- package/src/models/mediaTypes.ts +62 -0
- package/src/models/message.ts +4 -1
- package/src/storage/adapters.ts +36 -0
- package/src/storage/localStorage.ts +49 -0
- package/src/storage/s3Storage.ts +84 -0
- package/src/stores/adapters.ts +2 -0
- package/src/stores/memory/messageStore.ts +8 -0
- package/src/transport/adapters.ts +51 -1
- package/src/transport/memoryTransport.ts +75 -13
- package/src/transport/websocketClient.ts +269 -21
- package/src/transport/websocketServer.ts +26 -26
- package/src/utils/errors.ts +97 -0
- package/src/utils/logger.ts +96 -0
- package/src/utils/mediaUtils.ts +235 -0
- package/src/utils/messageQueue.ts +162 -0
- package/src/utils/validation.ts +99 -0
- package/test/crypto.test.ts +122 -35
- package/test/sdk.test.ts +276 -0
- package/test/validation.test.ts +64 -0
- package/tsconfig.json +11 -10
- package/tsconfig.test.json +11 -0
- package/src/ChatManager.ts +0 -103
- package/src/crypto/keyManager.ts +0 -28
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { MediaType, MediaMetadata, MediaAttachment, SUPPORTED_MIME_TYPES, FILE_SIZE_LIMITS } from '../models/mediaTypes.js';
|
|
2
|
+
import { ValidationError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert File or Blob to base64 string
|
|
6
|
+
*/
|
|
7
|
+
export async function encodeFileToBase64(file: File | Blob): Promise<string> {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const reader = new FileReader();
|
|
10
|
+
reader.onload = () => {
|
|
11
|
+
const result = reader.result as string;
|
|
12
|
+
if (!result) {
|
|
13
|
+
reject(new Error('Failed to read file: result is null'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const base64 = result.split(',')[1];
|
|
17
|
+
if (!base64) {
|
|
18
|
+
reject(new Error('Failed to extract base64 data'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
resolve(base64);
|
|
22
|
+
};
|
|
23
|
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
24
|
+
reader.readAsDataURL(file);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert base64 string to Blob
|
|
30
|
+
*/
|
|
31
|
+
export function decodeBase64ToBlob(base64: string, mimeType: string): Blob {
|
|
32
|
+
const byteCharacters = atob(base64);
|
|
33
|
+
const byteNumbers = new Array(byteCharacters.length);
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < byteCharacters.length; i++) {
|
|
36
|
+
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const byteArray = new Uint8Array(byteNumbers);
|
|
40
|
+
return new Blob([byteArray], { type: mimeType });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detect media type from MIME type
|
|
45
|
+
*/
|
|
46
|
+
export function getMediaType(mimeType: string): MediaType {
|
|
47
|
+
if (SUPPORTED_MIME_TYPES.image.includes(mimeType)) {
|
|
48
|
+
return MediaType.IMAGE;
|
|
49
|
+
}
|
|
50
|
+
if (SUPPORTED_MIME_TYPES.audio.includes(mimeType)) {
|
|
51
|
+
return MediaType.AUDIO;
|
|
52
|
+
}
|
|
53
|
+
if (SUPPORTED_MIME_TYPES.video.includes(mimeType)) {
|
|
54
|
+
return MediaType.VIDEO;
|
|
55
|
+
}
|
|
56
|
+
if (SUPPORTED_MIME_TYPES.document.includes(mimeType)) {
|
|
57
|
+
return MediaType.DOCUMENT;
|
|
58
|
+
}
|
|
59
|
+
throw new ValidationError(`Unsupported MIME type: ${mimeType}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate media file
|
|
64
|
+
*/
|
|
65
|
+
export function validateMediaFile(file: File | Blob, filename?: string): void {
|
|
66
|
+
const mimeType = file.type;
|
|
67
|
+
|
|
68
|
+
// Check if MIME type is supported
|
|
69
|
+
const mediaType = getMediaType(mimeType);
|
|
70
|
+
|
|
71
|
+
// Check file size
|
|
72
|
+
const maxSize = FILE_SIZE_LIMITS[mediaType];
|
|
73
|
+
if (file.size > maxSize) {
|
|
74
|
+
throw new ValidationError(
|
|
75
|
+
`File size exceeds limit. Max size for ${mediaType}: ${maxSize / 1024 / 1024}MB`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate filename if provided
|
|
80
|
+
if (filename && filename.length > 255) {
|
|
81
|
+
throw new ValidationError('Filename too long (max 255 characters)');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create media metadata from file
|
|
87
|
+
*/
|
|
88
|
+
export async function createMediaMetadata(
|
|
89
|
+
file: File | Blob,
|
|
90
|
+
filename?: string
|
|
91
|
+
): Promise<MediaMetadata> {
|
|
92
|
+
const actualFilename = filename || (file instanceof File ? file.name : 'file');
|
|
93
|
+
|
|
94
|
+
const metadata: MediaMetadata = {
|
|
95
|
+
filename: actualFilename,
|
|
96
|
+
mimeType: file.type,
|
|
97
|
+
size: file.size,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// For images, try to get dimensions
|
|
101
|
+
if (file.type.startsWith('image/')) {
|
|
102
|
+
try {
|
|
103
|
+
const dimensions = await getImageDimensions(file);
|
|
104
|
+
metadata.width = dimensions.width;
|
|
105
|
+
metadata.height = dimensions.height;
|
|
106
|
+
|
|
107
|
+
// Generate thumbnail for images
|
|
108
|
+
metadata.thumbnail = await generateThumbnail(file);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Dimensions optional, continue without them
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// For audio/video, duration would need to be provided by the caller
|
|
115
|
+
// as we can't reliably get it in Node.js without external libraries
|
|
116
|
+
|
|
117
|
+
return metadata;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get image dimensions
|
|
122
|
+
*/
|
|
123
|
+
function getImageDimensions(file: Blob): Promise<{ width: number; height: number }> {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const img = new Image();
|
|
126
|
+
const url = URL.createObjectURL(file);
|
|
127
|
+
|
|
128
|
+
img.onload = () => {
|
|
129
|
+
URL.revokeObjectURL(url);
|
|
130
|
+
resolve({ width: img.width, height: img.height });
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
img.onerror = () => {
|
|
134
|
+
URL.revokeObjectURL(url);
|
|
135
|
+
reject(new Error('Failed to load image'));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
img.src = url;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generate thumbnail for image (max 200x200)
|
|
144
|
+
*/
|
|
145
|
+
async function generateThumbnail(file: Blob): Promise<string> {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const img = new Image();
|
|
148
|
+
const url = URL.createObjectURL(file);
|
|
149
|
+
|
|
150
|
+
img.onload = () => {
|
|
151
|
+
URL.revokeObjectURL(url);
|
|
152
|
+
|
|
153
|
+
// Calculate thumbnail size (max 200x200, maintain aspect ratio)
|
|
154
|
+
const maxSize = 200;
|
|
155
|
+
let width = img.width;
|
|
156
|
+
let height = img.height;
|
|
157
|
+
|
|
158
|
+
if (width > height) {
|
|
159
|
+
if (width > maxSize) {
|
|
160
|
+
height = (height * maxSize) / width;
|
|
161
|
+
width = maxSize;
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
if (height > maxSize) {
|
|
165
|
+
width = (width * maxSize) / height;
|
|
166
|
+
height = maxSize;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Create canvas and draw thumbnail
|
|
171
|
+
const canvas = document.createElement('canvas');
|
|
172
|
+
canvas.width = width;
|
|
173
|
+
canvas.height = height;
|
|
174
|
+
const ctx = canvas.getContext('2d');
|
|
175
|
+
|
|
176
|
+
if (!ctx) {
|
|
177
|
+
reject(new Error('Failed to get canvas context'));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
182
|
+
|
|
183
|
+
// Convert to base64
|
|
184
|
+
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
|
185
|
+
const thumbnail = dataUrl.split(',')[1];
|
|
186
|
+
if (!thumbnail) {
|
|
187
|
+
reject(new Error('Failed to generate thumbnail'));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
resolve(thumbnail);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
img.onerror = () => {
|
|
194
|
+
URL.revokeObjectURL(url);
|
|
195
|
+
reject(new Error('Failed to load image for thumbnail'));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
img.src = url;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Create media attachment from file
|
|
204
|
+
*/
|
|
205
|
+
export async function createMediaAttachment(
|
|
206
|
+
file: File | Blob,
|
|
207
|
+
filename?: string
|
|
208
|
+
): Promise<MediaAttachment> {
|
|
209
|
+
// Validate file
|
|
210
|
+
validateMediaFile(file, filename);
|
|
211
|
+
|
|
212
|
+
// Get media type
|
|
213
|
+
const mediaType = getMediaType(file.type);
|
|
214
|
+
|
|
215
|
+
// Encode file to base64
|
|
216
|
+
const data = await encodeFileToBase64(file);
|
|
217
|
+
|
|
218
|
+
// Create metadata
|
|
219
|
+
const metadata = await createMediaMetadata(file, filename);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
type: mediaType,
|
|
223
|
+
data,
|
|
224
|
+
metadata,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Format file size for display
|
|
230
|
+
*/
|
|
231
|
+
export function formatFileSize(bytes: number): string {
|
|
232
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
233
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
234
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
235
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Message } from '../models/message.js';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
import { MAX_QUEUE_SIZE, MESSAGE_RETRY_ATTEMPTS, MESSAGE_RETRY_DELAY, MessageStatus } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Queued message with retry metadata
|
|
7
|
+
*/
|
|
8
|
+
export interface QueuedMessage {
|
|
9
|
+
message: Message;
|
|
10
|
+
status: MessageStatus;
|
|
11
|
+
attempts: number;
|
|
12
|
+
lastAttempt?: number;
|
|
13
|
+
error?: Error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Message queue for offline support and retry logic
|
|
18
|
+
*/
|
|
19
|
+
export class MessageQueue {
|
|
20
|
+
private queue: Map<string, QueuedMessage> = new Map();
|
|
21
|
+
private maxSize: number;
|
|
22
|
+
private maxRetries: number;
|
|
23
|
+
private retryDelay: number;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
maxSize: number = MAX_QUEUE_SIZE,
|
|
27
|
+
maxRetries: number = MESSAGE_RETRY_ATTEMPTS,
|
|
28
|
+
retryDelay: number = MESSAGE_RETRY_DELAY
|
|
29
|
+
) {
|
|
30
|
+
this.maxSize = maxSize;
|
|
31
|
+
this.maxRetries = maxRetries;
|
|
32
|
+
this.retryDelay = retryDelay;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add a message to the queue
|
|
37
|
+
*/
|
|
38
|
+
enqueue(message: Message): void {
|
|
39
|
+
if (this.queue.size >= this.maxSize) {
|
|
40
|
+
logger.warn('Message queue is full, removing oldest message');
|
|
41
|
+
const firstKey = this.queue.keys().next().value;
|
|
42
|
+
if (firstKey) {
|
|
43
|
+
this.queue.delete(firstKey);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.queue.set(message.id, {
|
|
48
|
+
message,
|
|
49
|
+
status: MessageStatus.PENDING,
|
|
50
|
+
attempts: 0,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
logger.debug('Message enqueued', { messageId: message.id });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mark a message as sent
|
|
58
|
+
*/
|
|
59
|
+
markSent(messageId: string): void {
|
|
60
|
+
const queued = this.queue.get(messageId);
|
|
61
|
+
if (queued) {
|
|
62
|
+
queued.status = MessageStatus.SENT;
|
|
63
|
+
logger.debug('Message marked as sent', { messageId });
|
|
64
|
+
// Remove from queue after successful send
|
|
65
|
+
this.queue.delete(messageId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mark a message as failed
|
|
71
|
+
*/
|
|
72
|
+
markFailed(messageId: string, error: Error): void {
|
|
73
|
+
const queued = this.queue.get(messageId);
|
|
74
|
+
if (queued) {
|
|
75
|
+
queued.status = MessageStatus.FAILED;
|
|
76
|
+
queued.error = error;
|
|
77
|
+
queued.attempts++;
|
|
78
|
+
queued.lastAttempt = Date.now();
|
|
79
|
+
|
|
80
|
+
logger.warn('Message failed', {
|
|
81
|
+
messageId,
|
|
82
|
+
attempts: queued.attempts,
|
|
83
|
+
error: error.message,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Remove if max retries exceeded
|
|
87
|
+
if (queued.attempts >= this.maxRetries) {
|
|
88
|
+
logger.error('Message exceeded max retries, removing from queue', {
|
|
89
|
+
messageId,
|
|
90
|
+
attempts: queued.attempts,
|
|
91
|
+
});
|
|
92
|
+
this.queue.delete(messageId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get messages that need to be retried
|
|
99
|
+
*/
|
|
100
|
+
getRetryableMessages(): QueuedMessage[] {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const retryable: QueuedMessage[] = [];
|
|
103
|
+
|
|
104
|
+
for (const queued of this.queue.values()) {
|
|
105
|
+
if (
|
|
106
|
+
queued.status === MessageStatus.FAILED &&
|
|
107
|
+
queued.attempts < this.maxRetries &&
|
|
108
|
+
(!queued.lastAttempt || now - queued.lastAttempt >= this.retryDelay)
|
|
109
|
+
) {
|
|
110
|
+
retryable.push(queued);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return retryable;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get all pending messages
|
|
119
|
+
*/
|
|
120
|
+
getPendingMessages(): QueuedMessage[] {
|
|
121
|
+
return Array.from(this.queue.values()).filter(
|
|
122
|
+
(q) => q.status === MessageStatus.PENDING
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get queue size
|
|
128
|
+
*/
|
|
129
|
+
size(): number {
|
|
130
|
+
return this.queue.size;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Clear the queue
|
|
135
|
+
*/
|
|
136
|
+
clear(): void {
|
|
137
|
+
this.queue.clear();
|
|
138
|
+
logger.debug('Message queue cleared');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get message by ID
|
|
143
|
+
*/
|
|
144
|
+
get(messageId: string): QueuedMessage | undefined {
|
|
145
|
+
return this.queue.get(messageId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Remove message from queue
|
|
150
|
+
*/
|
|
151
|
+
remove(messageId: string): void {
|
|
152
|
+
this.queue.delete(messageId);
|
|
153
|
+
logger.debug('Message removed from queue', { messageId });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get all messages in queue
|
|
158
|
+
*/
|
|
159
|
+
getAll(): QueuedMessage[] {
|
|
160
|
+
return Array.from(this.queue.values());
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ValidationError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validation utilities
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const USERNAME_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
|
|
8
|
+
const MAX_MESSAGE_LENGTH = 10000;
|
|
9
|
+
const MIN_GROUP_MEMBERS = 2;
|
|
10
|
+
const MAX_GROUP_MEMBERS = 256;
|
|
11
|
+
const MAX_GROUP_NAME_LENGTH = 100;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validate username format
|
|
15
|
+
*/
|
|
16
|
+
export function validateUsername(username: string): void {
|
|
17
|
+
if (!username || typeof username !== 'string') {
|
|
18
|
+
throw new ValidationError('Username is required', { username });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!USERNAME_REGEX.test(username)) {
|
|
22
|
+
throw new ValidationError(
|
|
23
|
+
'Username must be 3-20 characters and contain only letters, numbers, underscores, and hyphens',
|
|
24
|
+
{ username }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validate message content
|
|
31
|
+
*/
|
|
32
|
+
export function validateMessage(message: string): void {
|
|
33
|
+
if (!message || typeof message !== 'string') {
|
|
34
|
+
throw new ValidationError('Message content is required');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (message.length === 0) {
|
|
38
|
+
throw new ValidationError('Message cannot be empty');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (message.length > MAX_MESSAGE_LENGTH) {
|
|
42
|
+
throw new ValidationError(
|
|
43
|
+
`Message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`,
|
|
44
|
+
{ length: message.length, max: MAX_MESSAGE_LENGTH }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate group name
|
|
51
|
+
*/
|
|
52
|
+
export function validateGroupName(name: string): void {
|
|
53
|
+
if (!name || typeof name !== 'string') {
|
|
54
|
+
throw new ValidationError('Group name is required');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (name.trim().length === 0) {
|
|
58
|
+
throw new ValidationError('Group name cannot be empty');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (name.length > MAX_GROUP_NAME_LENGTH) {
|
|
62
|
+
throw new ValidationError(
|
|
63
|
+
`Group name exceeds maximum length of ${MAX_GROUP_NAME_LENGTH} characters`,
|
|
64
|
+
{ length: name.length, max: MAX_GROUP_NAME_LENGTH }
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate group members count
|
|
71
|
+
*/
|
|
72
|
+
export function validateGroupMembers(memberCount: number): void {
|
|
73
|
+
if (memberCount < MIN_GROUP_MEMBERS) {
|
|
74
|
+
throw new ValidationError(
|
|
75
|
+
`Group must have at least ${MIN_GROUP_MEMBERS} members`,
|
|
76
|
+
{ count: memberCount, min: MIN_GROUP_MEMBERS }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (memberCount > MAX_GROUP_MEMBERS) {
|
|
81
|
+
throw new ValidationError(
|
|
82
|
+
`Group cannot have more than ${MAX_GROUP_MEMBERS} members`,
|
|
83
|
+
{ count: memberCount, max: MAX_GROUP_MEMBERS }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate user ID format
|
|
90
|
+
*/
|
|
91
|
+
export function validateUserId(userId: string): void {
|
|
92
|
+
if (!userId || typeof userId !== 'string') {
|
|
93
|
+
throw new ValidationError('User ID is required');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (userId.trim().length === 0) {
|
|
97
|
+
throw new ValidationError('User ID cannot be empty');
|
|
98
|
+
}
|
|
99
|
+
}
|
package/test/crypto.test.ts
CHANGED
|
@@ -1,45 +1,132 @@
|
|
|
1
|
+
import { encryptMessage, decryptMessage, deriveSharedSecret } from '../src/crypto/e2e.js';
|
|
2
|
+
import { generateIdentityKeyPair } from '../src/crypto/keys.js';
|
|
1
3
|
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
describe('End-to-End Encryption', () => {
|
|
5
|
+
describe('Key Generation', () => {
|
|
6
|
+
it('should generate a valid key pair', () => {
|
|
7
|
+
const keyPair = generateIdentityKeyPair();
|
|
8
|
+
|
|
9
|
+
expect(keyPair).toHaveProperty('publicKey');
|
|
10
|
+
expect(keyPair).toHaveProperty('privateKey');
|
|
11
|
+
expect(typeof keyPair.publicKey).toBe('string');
|
|
12
|
+
expect(typeof keyPair.privateKey).toBe('string');
|
|
13
|
+
expect(keyPair.publicKey.length).toBeGreaterThan(0);
|
|
14
|
+
expect(keyPair.privateKey.length).toBeGreaterThan(0);
|
|
15
|
+
});
|
|
6
16
|
|
|
7
|
-
|
|
8
|
-
|
|
17
|
+
it('should generate unique key pairs', () => {
|
|
18
|
+
const keyPair1 = generateIdentityKeyPair();
|
|
19
|
+
const keyPair2 = generateIdentityKeyPair();
|
|
20
|
+
|
|
21
|
+
expect(keyPair1.publicKey).not.toBe(keyPair2.publicKey);
|
|
22
|
+
expect(keyPair1.privateKey).not.toBe(keyPair2.privateKey);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
9
25
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
26
|
+
describe('Shared Secret Derivation', () => {
|
|
27
|
+
it('should derive the same shared secret for both parties', async () => {
|
|
28
|
+
const aliceKeys = generateIdentityKeyPair();
|
|
29
|
+
const bobKeys = generateIdentityKeyPair();
|
|
30
|
+
|
|
31
|
+
const aliceShared = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
32
|
+
const bobShared = await deriveSharedSecret(bobKeys, aliceKeys.publicKey);
|
|
33
|
+
|
|
34
|
+
expect(aliceShared).toEqual(bobShared);
|
|
35
|
+
});
|
|
13
36
|
|
|
14
|
-
|
|
15
|
-
|
|
37
|
+
it('should derive different secrets for different key pairs', async () => {
|
|
38
|
+
const aliceKeys = generateIdentityKeyPair();
|
|
39
|
+
const bobKeys = generateIdentityKeyPair();
|
|
40
|
+
const charlieKeys = generateIdentityKeyPair();
|
|
41
|
+
|
|
42
|
+
const aliceBobSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
43
|
+
const aliceCharlieSecret = await deriveSharedSecret(aliceKeys, charlieKeys.publicKey);
|
|
44
|
+
|
|
45
|
+
expect(aliceBobSecret).not.toEqual(aliceCharlieSecret);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
16
48
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
49
|
+
describe('Message Encryption and Decryption', () => {
|
|
50
|
+
it('should encrypt and decrypt a message correctly', async () => {
|
|
51
|
+
const aliceKeys = generateIdentityKeyPair();
|
|
52
|
+
const bobKeys = generateIdentityKeyPair();
|
|
53
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
54
|
+
|
|
55
|
+
const plaintext = 'Hello, Bob!';
|
|
56
|
+
const encrypted = await encryptMessage(plaintext, sharedSecret);
|
|
57
|
+
|
|
58
|
+
expect(encrypted).toHaveProperty('ciphertext');
|
|
59
|
+
expect(encrypted).toHaveProperty('iv');
|
|
60
|
+
expect(typeof encrypted.ciphertext).toBe('string');
|
|
61
|
+
expect(typeof encrypted.iv).toBe('string');
|
|
62
|
+
|
|
63
|
+
const decrypted = await decryptMessage(encrypted.ciphertext, encrypted.iv, sharedSecret);
|
|
64
|
+
expect(decrypted).toBe(plaintext);
|
|
65
|
+
});
|
|
21
66
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
67
|
+
it('should produce different ciphertexts for the same plaintext', async () => {
|
|
68
|
+
const aliceKeys = generateIdentityKeyPair();
|
|
69
|
+
const bobKeys = generateIdentityKeyPair();
|
|
70
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
71
|
+
|
|
72
|
+
const plaintext = 'Hello, Bob!';
|
|
73
|
+
const encrypted1 = await encryptMessage(plaintext, sharedSecret);
|
|
74
|
+
const encrypted2 = await encryptMessage(plaintext, sharedSecret);
|
|
75
|
+
|
|
76
|
+
expect(encrypted1.ciphertext).not.toBe(encrypted2.ciphertext);
|
|
77
|
+
expect(encrypted1.iv).not.toBe(encrypted2.iv);
|
|
78
|
+
});
|
|
26
79
|
|
|
27
|
-
|
|
80
|
+
it('should fail to decrypt with wrong shared secret', async () => {
|
|
81
|
+
const aliceKeys = generateIdentityKeyPair();
|
|
82
|
+
const bobKeys = generateIdentityKeyPair();
|
|
83
|
+
const charlieKeys = generateIdentityKeyPair();
|
|
84
|
+
|
|
85
|
+
const aliceBobSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
86
|
+
const aliceCharlieSecret = await deriveSharedSecret(aliceKeys, charlieKeys.publicKey);
|
|
87
|
+
|
|
88
|
+
const plaintext = 'Secret message';
|
|
89
|
+
const encrypted = await encryptMessage(plaintext, aliceBobSecret);
|
|
90
|
+
|
|
91
|
+
expect(() =>
|
|
92
|
+
decryptMessage(encrypted.ciphertext, encrypted.iv, aliceCharlieSecret)
|
|
93
|
+
).toThrow(/Unsupported state or unable to authenticate data/);
|
|
94
|
+
});
|
|
28
95
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
96
|
+
it('should handle empty messages', async () => {
|
|
97
|
+
const aliceKeys = generateIdentityKeyPair();
|
|
98
|
+
const bobKeys = generateIdentityKeyPair();
|
|
99
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
100
|
+
|
|
101
|
+
const plaintext = '';
|
|
102
|
+
const encrypted = await encryptMessage(plaintext, sharedSecret);
|
|
103
|
+
const decrypted = await decryptMessage(encrypted.ciphertext, encrypted.iv, sharedSecret);
|
|
104
|
+
|
|
105
|
+
expect(decrypted).toBe(plaintext);
|
|
106
|
+
});
|
|
34
107
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
108
|
+
it('should handle long messages', async () => {
|
|
109
|
+
const aliceKeys = generateIdentityKeyPair();
|
|
110
|
+
const bobKeys = generateIdentityKeyPair();
|
|
111
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
112
|
+
|
|
113
|
+
const plaintext = 'A'.repeat(10000);
|
|
114
|
+
const encrypted = await encryptMessage(plaintext, sharedSecret);
|
|
115
|
+
const decrypted = await decryptMessage(encrypted.ciphertext, encrypted.iv, sharedSecret);
|
|
116
|
+
|
|
117
|
+
expect(decrypted).toBe(plaintext);
|
|
118
|
+
});
|
|
39
119
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
120
|
+
it('should handle special characters and emojis', async () => {
|
|
121
|
+
const aliceKeys = generateIdentityKeyPair();
|
|
122
|
+
const bobKeys = generateIdentityKeyPair();
|
|
123
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
124
|
+
|
|
125
|
+
const plaintext = 'Hello 👋 World! 🌍 Special chars: @#$%^&*()';
|
|
126
|
+
const encrypted = await encryptMessage(plaintext, sharedSecret);
|
|
127
|
+
const decrypted = await decryptMessage(encrypted.ciphertext, encrypted.iv, sharedSecret);
|
|
128
|
+
|
|
129
|
+
expect(decrypted).toBe(plaintext);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|