@thezelijah/majik-message 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # Majik Message
2
2
 
3
+ [![Developed by Zelijah](https://img.shields.io/badge/Developed%20by-Zelijah-red?logo=github&logoColor=white)](https://thezelijah.world)
4
+
3
5
  **Majik Message** is a browser extension for **encrypting and decrypting text directly in your browser**. It is **not a chat platform** — it does not host conversations or store messages on a server. Instead, it allows you to securely encrypt text, share it with contacts, and decrypt it on any webpage, giving you full control over your data.
4
6
 
7
+ ![npm](https://img.shields.io/npm/v/@thezelijah/majik-message) ![npm downloads](https://img.shields.io/npm/dm/@thezelijah/majik-message) ![npm bundle size](https://img.shields.io/bundlephobia/min/%40thezelijah%2Fmajik-message) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue)
8
+
5
9
 
6
10
 
7
11
  ---
@@ -0,0 +1,17 @@
1
+ type SupportedInput = string | object | ArrayBuffer | Uint8Array;
2
+ export type MajikCompressorType = "str" | "json" | "blob";
3
+ export declare class MajikCompressor {
4
+ private static PREFIX;
5
+ private static initialized;
6
+ private static ensureInit;
7
+ private static encodeInput;
8
+ private static decodeOutput;
9
+ static compress(input: SupportedInput, level?: number): Promise<string>;
10
+ static decompress(compressedStr: string): Promise<string | Record<string, any> | Uint8Array>;
11
+ static decompressJSON(compressedStr: string): Promise<Record<string, any>>;
12
+ static decompressString(compressedStr: string): Promise<string>;
13
+ static decompressBlob(compressedStr: string): Promise<Uint8Array>;
14
+ private static uint8ArrayToBase64;
15
+ private static base64ToUint8Array;
16
+ }
17
+ export {};
@@ -0,0 +1,86 @@
1
+ import { init, compress as zstdCompress, decompress as zstdDecompress, } from "@bokuweb/zstd-wasm";
2
+ export class MajikCompressor {
3
+ static PREFIX = "mjkcmp";
4
+ static initialized = false;
5
+ static async ensureInit() {
6
+ if (!this.initialized) {
7
+ await init();
8
+ this.initialized = true;
9
+ }
10
+ }
11
+ static encodeInput(input) {
12
+ if (typeof input === "string")
13
+ return { type: "str", data: new TextEncoder().encode(input) };
14
+ if (input instanceof Uint8Array)
15
+ return { type: "blob", data: input };
16
+ if (input instanceof ArrayBuffer)
17
+ return { type: "blob", data: new Uint8Array(input) };
18
+ if (typeof input === "object")
19
+ return {
20
+ type: "json",
21
+ data: new TextEncoder().encode(JSON.stringify(input)),
22
+ };
23
+ throw new Error("Unsupported input type for MajikCompressor");
24
+ }
25
+ static decodeOutput(type, data) {
26
+ if (type === "str")
27
+ return new TextDecoder().decode(data);
28
+ if (type === "json")
29
+ return JSON.parse(new TextDecoder().decode(data));
30
+ if (type === "blob")
31
+ return data;
32
+ throw new Error(`Unsupported type for decoding: ${type}`);
33
+ }
34
+ // --- Compress input and return string ---
35
+ static async compress(input, level = 9) {
36
+ await this.ensureInit();
37
+ const { type, data } = this.encodeInput(input);
38
+ const compressed = zstdCompress(data, level); // synchronous
39
+ const b64 = this.uint8ArrayToBase64(compressed);
40
+ return `${this.PREFIX}:${type}:${b64}`;
41
+ }
42
+ // --- Decompress string with prefix ---
43
+ static async decompress(compressedStr) {
44
+ await this.ensureInit();
45
+ if (!compressedStr.startsWith(`${this.PREFIX}:`))
46
+ throw new Error("Invalid MajikCompressor string format");
47
+ const [, type, b64] = compressedStr.split(":", 3);
48
+ const compressedData = this.base64ToUint8Array(b64);
49
+ const decompressed = zstdDecompress(compressedData); // synchronous
50
+ return this.decodeOutput(type, decompressed);
51
+ }
52
+ static async decompressJSON(compressedStr) {
53
+ const result = await this.decompress(compressedStr);
54
+ if (typeof result === "object" && !(result instanceof Uint8Array))
55
+ return result;
56
+ throw new Error("Decompressed data is not JSON");
57
+ }
58
+ static async decompressString(compressedStr) {
59
+ const result = await this.decompress(compressedStr);
60
+ if (typeof result === "string")
61
+ return result;
62
+ throw new Error("Decompressed data is not a string");
63
+ }
64
+ static async decompressBlob(compressedStr) {
65
+ const result = await this.decompress(compressedStr);
66
+ if (result instanceof Uint8Array)
67
+ return result;
68
+ throw new Error("Decompressed data is not a blob");
69
+ }
70
+ static uint8ArrayToBase64(u8) {
71
+ let binary = "";
72
+ const chunkSize = 0x8000;
73
+ for (let i = 0; i < u8.length; i += chunkSize) {
74
+ const chunk = u8.subarray(i, i + chunkSize);
75
+ binary += String.fromCharCode(...chunk);
76
+ }
77
+ return btoa(binary);
78
+ }
79
+ static base64ToUint8Array(b64) {
80
+ const binary = atob(b64);
81
+ const u8 = new Uint8Array(binary.length);
82
+ for (let i = 0; i < binary.length; i++)
83
+ u8[i] = binary.charCodeAt(i);
84
+ return u8;
85
+ }
86
+ }
@@ -216,6 +216,9 @@ export class KeyStore {
216
216
  if (!mnemonic || typeof mnemonic !== "string") {
217
217
  throw new KeyStoreError("Mnemonic must be a non-empty string");
218
218
  }
219
+ if (!passphrase?.trim()) {
220
+ throw new Error("Passphrase cannot be empty or undefined");
221
+ }
219
222
  try {
220
223
  const identity = await EncryptionEngine.deriveIdentityFromMnemonic(mnemonic);
221
224
  const id = identity.fingerprint; // stable id
@@ -344,6 +347,9 @@ export class KeyStore {
344
347
  * Private Helpers
345
348
  * ================================ */
346
349
  static async encryptPrivateKey(buffer, passphrase, salt) {
350
+ if (!passphrase?.trim()) {
351
+ throw new Error("Passphrase cannot be empty or undefined");
352
+ }
347
353
  const keyBytes = providerDeriveKeyFromPassphrase(passphrase, salt);
348
354
  const iv = generateRandomBytes(IV_LENGTH);
349
355
  const ciphertext = aesGcmEncrypt(keyBytes, iv, new Uint8Array(buffer));
@@ -509,6 +515,12 @@ export class KeyStore {
509
515
  */
510
516
  static async importIdentityFromMnemonicBackup(backupBase64, mnemonic, passphrase) {
511
517
  try {
518
+ if (!passphrase?.trim()) {
519
+ throw new Error("Passphrase cannot be empty or undefined");
520
+ }
521
+ if (!mnemonic?.trim()) {
522
+ throw new Error("Seed phrase cannot be empty or undefined");
523
+ }
512
524
  const jsonStr = base64ToUtf8(backupBase64);
513
525
  const obj = JSON.parse(jsonStr);
514
526
  if (!obj.iv || !obj.ciphertext || !obj.publicKey || !obj.fingerprint) {
@@ -0,0 +1,94 @@
1
+ import { MajikMessageAccountID, MajikMessageChatID, MajikMessagePublicKey } from "../../types";
2
+ import { MajikMessageIdentity } from "../system/identity";
3
+ import { MajikMessageChatJSON } from "./types";
4
+ /**
5
+ * Represents a temporary, compressed message with automatic expiration.
6
+ * Messages are automatically compressed on creation and stored in Redis by default.
7
+ * Optionally can be persisted to Supabase for long-term storage.
8
+ */
9
+ export declare class MajikMessageChat {
10
+ private id;
11
+ private _account;
12
+ private message;
13
+ private sender;
14
+ private recipients;
15
+ private timestamp;
16
+ private expires_at;
17
+ private read_by;
18
+ private static readonly MAX_MESSAGE_LENGTH;
19
+ constructor(id: MajikMessageChatID, account: MajikMessageAccountID, message: string, sender: MajikMessagePublicKey, recipients: MajikMessagePublicKey[], timestamp: string, expires_at: string, read_by?: string[]);
20
+ getID(): string;
21
+ get account(): MajikMessageAccountID;
22
+ set account(account: MajikMessageIdentity);
23
+ /**
24
+ * Gets the decompressed message content.
25
+ * @returns Promise resolving to the original uncompressed message string
26
+ * @throws Error if message is expired or decompression fails
27
+ */
28
+ getMessage(): Promise<string>;
29
+ /**
30
+ * Gets the raw compressed message without decompression.
31
+ * Use this for encryption, storage operations, or when decompression is not needed.
32
+ * @returns The compressed message string
33
+ */
34
+ getCompressedMessage(): string;
35
+ /**
36
+ * Set the message content (already compressed).
37
+ * Used after encryption to update the message with encrypted payload.
38
+ */
39
+ setMessage(compressedMessage: string): void;
40
+ getSender(): string;
41
+ getRecipients(): string[];
42
+ getTimestamp(): string;
43
+ getExpiresAt(): string;
44
+ getReadBy(): string[];
45
+ /**
46
+ * Creates a new message instance with automatic compression.
47
+ * @param account - Majik Message identity account of the message sender
48
+ * @param message - Plain text message that will be automatically compressed
49
+ * @param recipients - Array of recipient user IDs
50
+ * @param expiresInMs - Time until expiration in milliseconds (default: 24 hours)
51
+ * @returns Promise resolving to new MajikMessageChat instance with compressed message
52
+ * @throws Error if validation or compression fails
53
+ */
54
+ static create(account: MajikMessageIdentity, message: string, recipients: string[], expiresInMs?: number): Promise<MajikMessageChat>;
55
+ isExpired(): boolean;
56
+ canBeDeleted(): boolean;
57
+ getReadPercentage(): number;
58
+ getTimeUntilExpiration(): number;
59
+ canUserAccess(userId: string): boolean;
60
+ isSender(userId: string): boolean;
61
+ addRecipient(recipientId: string): void;
62
+ removeRecipient(recipientId: string): void;
63
+ hasRecipient(recipientId: string): boolean;
64
+ markAsRead(userId: string): boolean;
65
+ static markMultipleAsRead(messages: MajikMessageChat[], userId: string): {
66
+ updated: MajikMessageChat[];
67
+ unchanged: MajikMessageChat[];
68
+ };
69
+ hasUserRead(userId: string): boolean;
70
+ isReadByAll(): boolean;
71
+ getUnreadRecipients(): string[];
72
+ toJSON(): MajikMessageChatJSON;
73
+ static fromJSON(json: string | MajikMessageChatJSON): MajikMessageChat;
74
+ getRedisKey(): string;
75
+ getTTLSeconds(): number;
76
+ clone(): MajikMessageChat;
77
+ /**
78
+ * Validates raw message length before compression.
79
+ * Can be used without creating an instance.
80
+ * @param message - Raw user input
81
+ * @param bypassLengthCheck - Skip check if true (default: false)
82
+ * @throws Error if message length exceeds MAX_MESSAGE_LENGTH
83
+ */
84
+ static validateRawMessageLength(message: string, bypassLengthCheck?: boolean): void;
85
+ private validateID;
86
+ private validateAccount;
87
+ private validateMessage;
88
+ private validateSender;
89
+ private validateRecipients;
90
+ private validateTimestamp;
91
+ private validateExpiresAt;
92
+ private validateReadBy;
93
+ static isValidJSON(json: any): json is MajikMessageChatJSON;
94
+ }
@@ -0,0 +1,432 @@
1
+ import { MajikCompressor } from "../../compressor/majik-compressor";
2
+ import { autogenerateID } from "../../utils/utilities";
3
+ /**
4
+ * Represents a temporary, compressed message with automatic expiration.
5
+ * Messages are automatically compressed on creation and stored in Redis by default.
6
+ * Optionally can be persisted to Supabase for long-term storage.
7
+ */
8
+ export class MajikMessageChat {
9
+ id;
10
+ _account;
11
+ message;
12
+ sender;
13
+ recipients;
14
+ timestamp;
15
+ expires_at;
16
+ read_by;
17
+ // Maximum allowed length for the compressed message string
18
+ static MAX_MESSAGE_LENGTH = 10000;
19
+ constructor(id, account, message, sender, recipients, timestamp, expires_at, read_by = []) {
20
+ this.validateID(id);
21
+ this.validateAccount(account);
22
+ this.validateMessage(message);
23
+ this.validateSender(sender);
24
+ this.validateRecipients(recipients);
25
+ this.validateTimestamp(timestamp);
26
+ this.validateExpiresAt(expires_at, timestamp);
27
+ this.validateReadBy(read_by, recipients);
28
+ this.id = id;
29
+ this._account = account;
30
+ this.message = message;
31
+ this.sender = sender;
32
+ this.recipients = [...recipients]; // Clone to prevent external mutation
33
+ this.timestamp = timestamp;
34
+ this.expires_at = expires_at;
35
+ this.read_by = [...read_by]; // Clone to prevent external mutation
36
+ }
37
+ // ============= GETTERS =============
38
+ getID() {
39
+ return this.id;
40
+ }
41
+ get account() {
42
+ return this._account;
43
+ }
44
+ set account(account) {
45
+ if (!account) {
46
+ throw new Error("Invalid sender account: must be provided");
47
+ }
48
+ if (!account.validateIntegrity()) {
49
+ throw new Error("Invalid sender account: integrity check failed");
50
+ }
51
+ if (account.isRestricted()) {
52
+ throw new Error("This account is restricted and cannot send messages");
53
+ }
54
+ const accountID = account.id;
55
+ this._account = accountID;
56
+ }
57
+ /**
58
+ * Gets the decompressed message content.
59
+ * @returns Promise resolving to the original uncompressed message string
60
+ * @throws Error if message is expired or decompression fails
61
+ */
62
+ async getMessage() {
63
+ if (this.isExpired()) {
64
+ throw new Error("Cannot access message: message has expired");
65
+ }
66
+ try {
67
+ return await MajikCompressor.decompressString(this.message);
68
+ }
69
+ catch (error) {
70
+ throw new Error(`Failed to decompress message: ${error instanceof Error ? error.message : "Unknown error"}`);
71
+ }
72
+ }
73
+ /**
74
+ * Gets the raw compressed message without decompression.
75
+ * Use this for encryption, storage operations, or when decompression is not needed.
76
+ * @returns The compressed message string
77
+ */
78
+ getCompressedMessage() {
79
+ return this.message;
80
+ }
81
+ /**
82
+ * Set the message content (already compressed).
83
+ * Used after encryption to update the message with encrypted payload.
84
+ */
85
+ setMessage(compressedMessage) {
86
+ this.validateMessage(compressedMessage);
87
+ this.message = compressedMessage;
88
+ }
89
+ getSender() {
90
+ return this.sender;
91
+ }
92
+ getRecipients() {
93
+ return [...this.recipients]; // Return a copy to prevent external mutation
94
+ }
95
+ getTimestamp() {
96
+ return this.timestamp;
97
+ }
98
+ getExpiresAt() {
99
+ return this.expires_at;
100
+ }
101
+ getReadBy() {
102
+ return [...this.read_by]; // Return a copy to prevent external mutation
103
+ }
104
+ // ============= STATIC FACTORY METHOD =============
105
+ /**
106
+ * Creates a new message instance with automatic compression.
107
+ * @param account - Majik Message identity account of the message sender
108
+ * @param message - Plain text message that will be automatically compressed
109
+ * @param recipients - Array of recipient user IDs
110
+ * @param expiresInMs - Time until expiration in milliseconds (default: 24 hours)
111
+ * @returns Promise resolving to new MajikMessageChat instance with compressed message
112
+ * @throws Error if validation or compression fails
113
+ */
114
+ static async create(account, message, recipients, expiresInMs = 24 * 60 * 60 * 1000) {
115
+ if (!account) {
116
+ throw new Error("Invalid sender account: must be provided");
117
+ }
118
+ if (!account.validateIntegrity()) {
119
+ throw new Error("Invalid sender account: integrity check failed");
120
+ }
121
+ if (account.isRestricted()) {
122
+ throw new Error("This account is restricted and cannot send messages");
123
+ }
124
+ const accountID = account.id;
125
+ const senderID = account.publicKey;
126
+ if (!senderID || typeof senderID !== "string" || senderID.trim() === "") {
127
+ throw new Error("Invalid sender ID: must be a non-empty string");
128
+ }
129
+ if (!message || typeof message !== "string" || message.trim() === "") {
130
+ throw new Error("Invalid message: must be a non-empty string");
131
+ }
132
+ // Validate **raw** message length before compression
133
+ this.validateRawMessageLength(message);
134
+ if (!Array.isArray(recipients) || recipients.length === 0) {
135
+ throw new Error("Invalid recipients: must be a non-empty array");
136
+ }
137
+ if (typeof expiresInMs !== "number" || expiresInMs <= 0) {
138
+ throw new Error("Invalid expiresInMs: must be a positive number");
139
+ }
140
+ const now = new Date();
141
+ const expiresAt = new Date(now.getTime() + expiresInMs);
142
+ // Compress the message before storing
143
+ let compressedMessage;
144
+ try {
145
+ compressedMessage = await MajikCompressor.compress(message.trim());
146
+ }
147
+ catch (error) {
148
+ throw new Error(`Failed to compress message: ${error instanceof Error ? error.message : "Unknown error"}`);
149
+ }
150
+ return new MajikMessageChat(autogenerateID(), accountID, compressedMessage, senderID.trim(), recipients.map((r) => r.trim()).filter((r) => r !== ""), now.toISOString(), expiresAt.toISOString(), []);
151
+ }
152
+ // ============= EXPIRATION METHODS =============
153
+ isExpired() {
154
+ const now = new Date();
155
+ const expiresAt = new Date(this.expires_at);
156
+ return now >= expiresAt;
157
+ }
158
+ // ============= METADATA and AUDIT METHODS =============
159
+ // Check if message can be deleted (all read OR expired)
160
+ canBeDeleted() {
161
+ return this.isExpired() || this.isReadByAll();
162
+ }
163
+ // Get read percentage
164
+ getReadPercentage() {
165
+ return (this.read_by.length / this.recipients.length) * 100;
166
+ }
167
+ // Get time until expiration
168
+ getTimeUntilExpiration() {
169
+ const now = new Date();
170
+ const expiresAt = new Date(this.expires_at);
171
+ return Math.max(0, expiresAt.getTime() - now.getTime());
172
+ }
173
+ // ============= ACCESS CONTROL =============
174
+ // Check if user can access this message
175
+ canUserAccess(userId) {
176
+ if (!userId || typeof userId !== "string") {
177
+ return false;
178
+ }
179
+ const trimmedId = userId.trim();
180
+ return trimmedId === this.sender || this.recipients.includes(trimmedId);
181
+ }
182
+ // Check if user is sender
183
+ isSender(userId) {
184
+ if (!userId || typeof userId !== "string") {
185
+ return false;
186
+ }
187
+ return userId.trim() === this.sender;
188
+ }
189
+ // ============= RECIPIENT MANAGEMENT =============
190
+ addRecipient(recipientId) {
191
+ if (!recipientId ||
192
+ typeof recipientId !== "string" ||
193
+ recipientId.trim() === "") {
194
+ throw new Error("Invalid recipientId: must be a non-empty string");
195
+ }
196
+ const trimmedId = recipientId.trim();
197
+ if (this.recipients.includes(trimmedId)) {
198
+ throw new Error(`Recipient ${trimmedId} already exists`);
199
+ }
200
+ if (trimmedId === this.sender) {
201
+ throw new Error("Cannot add sender as a recipient");
202
+ }
203
+ this.recipients.push(trimmedId);
204
+ }
205
+ removeRecipient(recipientId) {
206
+ if (!recipientId ||
207
+ typeof recipientId !== "string" ||
208
+ recipientId.trim() === "") {
209
+ throw new Error("Invalid recipientId: must be a non-empty string");
210
+ }
211
+ const trimmedId = recipientId.trim();
212
+ const index = this.recipients.indexOf(trimmedId);
213
+ if (index === -1) {
214
+ throw new Error(`Recipient ${trimmedId} not found`);
215
+ }
216
+ this.recipients.splice(index, 1);
217
+ // Also remove from read_by if they were there
218
+ const readByIndex = this.read_by.indexOf(trimmedId);
219
+ if (readByIndex !== -1) {
220
+ this.read_by.splice(readByIndex, 1);
221
+ }
222
+ if (this.recipients.length === 0) {
223
+ throw new Error("Cannot remove last recipient: message must have at least one recipient");
224
+ }
225
+ }
226
+ hasRecipient(recipientId) {
227
+ if (!recipientId || typeof recipientId !== "string") {
228
+ return false;
229
+ }
230
+ return this.recipients.includes(recipientId.trim());
231
+ }
232
+ // ============= READER MANAGEMENT =============
233
+ markAsRead(userId) {
234
+ if (!userId || typeof userId !== "string" || userId.trim() === "") {
235
+ throw new Error("Invalid userId: must be a non-empty string");
236
+ }
237
+ const trimmedId = userId.trim();
238
+ if (!this.recipients.includes(trimmedId)) {
239
+ throw new Error(`User ${trimmedId} is not a recipient of this message`);
240
+ }
241
+ if (this.isExpired()) {
242
+ throw new Error("Cannot mark expired message as read");
243
+ }
244
+ // Make idempotent - return false if already read
245
+ if (this.read_by.includes(trimmedId)) {
246
+ return false; // Already read, no change
247
+ }
248
+ this.read_by.push(trimmedId);
249
+ return true; // Successfully marked as read
250
+ }
251
+ // Helper for batch marking as read
252
+ static markMultipleAsRead(messages, userId) {
253
+ const updated = [];
254
+ const unchanged = [];
255
+ for (const message of messages) {
256
+ try {
257
+ const wasUpdated = message.markAsRead(userId);
258
+ if (wasUpdated) {
259
+ updated.push(message);
260
+ }
261
+ else {
262
+ unchanged.push(message);
263
+ }
264
+ }
265
+ catch (error) {
266
+ // Skip messages where user isn't a recipient
267
+ unchanged.push(message);
268
+ }
269
+ }
270
+ return { updated, unchanged };
271
+ }
272
+ hasUserRead(userId) {
273
+ if (!userId || typeof userId !== "string") {
274
+ return false;
275
+ }
276
+ return this.read_by.includes(userId.trim());
277
+ }
278
+ isReadByAll() {
279
+ return (this.read_by.length === this.recipients.length &&
280
+ this.recipients.every((recipient) => this.read_by.includes(recipient)));
281
+ }
282
+ getUnreadRecipients() {
283
+ return this.recipients.filter((recipient) => !this.read_by.includes(recipient));
284
+ }
285
+ // ============= SERIALIZATION =============
286
+ toJSON() {
287
+ return {
288
+ id: this.id,
289
+ account: this.account,
290
+ message: this.message,
291
+ sender: this.sender,
292
+ recipients: [...this.recipients],
293
+ timestamp: this.timestamp,
294
+ expires_at: this.expires_at,
295
+ read_by: [...this.read_by],
296
+ };
297
+ }
298
+ static fromJSON(json) {
299
+ const rawParse = typeof json === "string" ? JSON.parse(json) : json;
300
+ if (!this.isValidJSON(rawParse)) {
301
+ throw new Error("Invalid JSON: missing required fields or invalid types");
302
+ }
303
+ return new MajikMessageChat(rawParse.id, rawParse.account, rawParse.message, rawParse.sender, rawParse.recipients || [], rawParse.timestamp, rawParse.expires_at, rawParse.read_by || []);
304
+ }
305
+ // ============= REDIS METHODS =============
306
+ // Generate Redis key
307
+ getRedisKey() {
308
+ return `majik_message:${this.id}`;
309
+ }
310
+ // Get TTL in seconds for Redis EXPIREAT
311
+ getTTLSeconds() {
312
+ const expiresAt = new Date(this.expires_at);
313
+ return Math.floor(expiresAt.getTime() / 1000);
314
+ }
315
+ // Clone method for updates
316
+ clone() {
317
+ return MajikMessageChat.fromJSON(this.toJSON());
318
+ }
319
+ // ============= PRIVATE VALIDATION METHODS =============
320
+ /**
321
+ * Validates raw message length before compression.
322
+ * Can be used without creating an instance.
323
+ * @param message - Raw user input
324
+ * @param bypassLengthCheck - Skip check if true (default: false)
325
+ * @throws Error if message length exceeds MAX_MESSAGE_LENGTH
326
+ */
327
+ static validateRawMessageLength(message, bypassLengthCheck = false) {
328
+ if (!message || typeof message !== "string" || message.trim() === "") {
329
+ throw new Error("Invalid message: must be a non-empty string");
330
+ }
331
+ if (!bypassLengthCheck && message.length > this.MAX_MESSAGE_LENGTH) {
332
+ throw new Error(`Raw message exceeds maximum allowed length of ${this.MAX_MESSAGE_LENGTH} characters. ` +
333
+ `Current length: ${message.length}`);
334
+ }
335
+ }
336
+ validateID(id) {
337
+ if (!id || typeof id !== "string" || id.trim() === "") {
338
+ throw new Error("Invalid id: must be a non-empty string");
339
+ }
340
+ }
341
+ validateAccount(id) {
342
+ if (!id || typeof id !== "string" || id.trim() === "") {
343
+ throw new Error("Invalid account ID: must be a non-empty string");
344
+ }
345
+ }
346
+ validateMessage(message) {
347
+ if (!message || typeof message !== "string" || message.trim() === "") {
348
+ throw new Error("Invalid message: must be a non-empty string");
349
+ }
350
+ // Validate it's a compressed MajikCompressor format
351
+ if (!message.startsWith("mjkcmp:")) {
352
+ throw new Error("Invalid message: must be a compressed MajikCompressor string");
353
+ }
354
+ }
355
+ validateSender(sender) {
356
+ if (!sender || typeof sender !== "string" || sender.trim() === "") {
357
+ throw new Error("Invalid sender: must be a non-empty string");
358
+ }
359
+ }
360
+ validateRecipients(recipients) {
361
+ if (!Array.isArray(recipients)) {
362
+ throw new Error("Invalid recipients: must be an array");
363
+ }
364
+ if (recipients.length === 0) {
365
+ throw new Error("Invalid recipients: must have at least one recipient");
366
+ }
367
+ for (const recipient of recipients) {
368
+ if (!recipient ||
369
+ typeof recipient !== "string" ||
370
+ recipient.trim() === "") {
371
+ throw new Error("Invalid recipient: all recipients must be non-empty strings");
372
+ }
373
+ }
374
+ // Check for duplicates
375
+ const uniqueRecipients = new Set(recipients);
376
+ if (uniqueRecipients.size !== recipients.length) {
377
+ throw new Error("Invalid recipients: duplicate recipients found");
378
+ }
379
+ }
380
+ validateTimestamp(timestamp) {
381
+ if (!timestamp || typeof timestamp !== "string") {
382
+ throw new Error("Invalid timestamp: must be a string");
383
+ }
384
+ const date = new Date(timestamp);
385
+ if (isNaN(date.getTime())) {
386
+ throw new Error("Invalid timestamp: must be a valid ISO string");
387
+ }
388
+ }
389
+ validateExpiresAt(expires_at, timestamp) {
390
+ if (!expires_at || typeof expires_at !== "string") {
391
+ throw new Error("Invalid expires_at: must be a string");
392
+ }
393
+ const expiresDate = new Date(expires_at);
394
+ if (isNaN(expiresDate.getTime())) {
395
+ throw new Error("Invalid expires_at: must be a valid ISO string");
396
+ }
397
+ const timestampDate = new Date(timestamp);
398
+ if (expiresDate <= timestampDate) {
399
+ throw new Error("Invalid expires_at: must be after timestamp");
400
+ }
401
+ }
402
+ validateReadBy(read_by, recipients) {
403
+ if (!Array.isArray(read_by)) {
404
+ throw new Error("Invalid read_by: must be an array");
405
+ }
406
+ for (const reader of read_by) {
407
+ if (!reader || typeof reader !== "string" || reader.trim() === "") {
408
+ throw new Error("Invalid read_by: all readers must be non-empty strings");
409
+ }
410
+ if (!recipients.includes(reader)) {
411
+ throw new Error(`Invalid read_by: reader ${reader} is not in recipients list`);
412
+ }
413
+ }
414
+ // Check for duplicates
415
+ const uniqueReaders = new Set(read_by);
416
+ if (uniqueReaders.size !== read_by.length) {
417
+ throw new Error("Invalid read_by: duplicate readers found");
418
+ }
419
+ }
420
+ // Add this static method
421
+ static isValidJSON(json) {
422
+ return (json &&
423
+ typeof json === "object" &&
424
+ typeof json.id === "string" &&
425
+ typeof json.message === "string" &&
426
+ typeof json.sender === "string" &&
427
+ Array.isArray(json.recipients) &&
428
+ typeof json.timestamp === "string" &&
429
+ typeof json.expires_at === "string" &&
430
+ Array.isArray(json.read_by));
431
+ }
432
+ }
@@ -0,0 +1,11 @@
1
+ import { MajikMessageAccountID, MajikMessageChatID, MajikMessagePublicKey } from "../../types";
2
+ export interface MajikMessageChatJSON {
3
+ id: MajikMessageChatID;
4
+ account: MajikMessageAccountID;
5
+ message: string;
6
+ sender: MajikMessagePublicKey;
7
+ recipients: MajikMessagePublicKey[];
8
+ timestamp: string;
9
+ expires_at: string;
10
+ read_by: string[];
11
+ }