@antzsoft/chat-core 1.1.1 → 1.1.2
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 +22 -14
- package/dist/index.cjs +379 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -29
- package/dist/index.d.ts +22 -29
- package/dist/index.js +378 -27
- package/dist/index.js.map +1 -1
- package/docs/integration-guide.html +168 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -320,10 +320,14 @@ interface AntzChatConfig {
|
|
|
320
320
|
tenantId?: string;
|
|
321
321
|
|
|
322
322
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
323
|
+
* Enable payload-level transit encryption for all HTTP and socket traffic.
|
|
324
|
+
* Uses ECDH key exchange (X25519/P-256) + AES-256-GCM to encrypt every
|
|
325
|
+
* request, response, and socket event on the wire — independent of TLS.
|
|
326
|
+
* Server must have TRANSIT_ENCRYPTION_ENABLED=true (default).
|
|
327
|
+
* Default: true. Set false only for local development or debugging.
|
|
328
|
+
* Safe to toggle anytime — no data migration needed (wire-only, never stored).
|
|
325
329
|
*/
|
|
326
|
-
|
|
330
|
+
transitEncryption?: boolean;
|
|
327
331
|
|
|
328
332
|
/**
|
|
329
333
|
* The user's ID in the external auth system.
|
|
@@ -747,7 +751,6 @@ interface SendData {
|
|
|
747
751
|
attachments?: SendMessageAttachment[];
|
|
748
752
|
replyTo?: string; // messageId of the message being replied to
|
|
749
753
|
tempId?: string; // Client-generated ID for optimistic UI
|
|
750
|
-
isEncrypted?: boolean;
|
|
751
754
|
}
|
|
752
755
|
|
|
753
756
|
interface SearchParams {
|
|
@@ -1108,6 +1111,8 @@ const presigned = await storageApi.requestPresignedUrl({
|
|
|
1108
1111
|
mimeType: 'application/pdf',
|
|
1109
1112
|
size: 512000,
|
|
1110
1113
|
conversationId: 'conv-abc',
|
|
1114
|
+
// optional — stored on the server file record
|
|
1115
|
+
metadata: { compressed: true, originalSize: 900000, compressionAlgorithm: 'gzip' },
|
|
1111
1116
|
});
|
|
1112
1117
|
await platformUploadFn(presigned, file, (pct) => console.log(`${pct * 100}%`));
|
|
1113
1118
|
const fileRecord = await storageApi.confirmUpload(presigned.fileId);
|
|
@@ -1136,9 +1141,9 @@ When `platformCompressFn` is provided and `compression.enabled` is `true` (the d
|
|
|
1136
1141
|
1. Determine strategy per file (`image` → WebP/JPEG resize+encode, `gzip` → text/doc compression, `skip` → no-op)
|
|
1137
1142
|
2. Run `platformCompressFn(file, compressionConfig)` — returns a `CompressedFile`
|
|
1138
1143
|
3. If compressed result is **larger** than the original, the original is used instead (automatic fallback)
|
|
1139
|
-
4. Request presigned URL with the compressed size and
|
|
1144
|
+
4. Request presigned URL with the compressed size, MIME type, and compression metadata (`compressed`, `originalSize`, `compressionAlgorithm`)
|
|
1140
1145
|
5. Upload the compressed bytes
|
|
1141
|
-
6.
|
|
1146
|
+
6. Server persists `metadata.compressed`, `metadata.originalSize`, `metadata.compressionAlgorithm` on the file record alongside system fields (`userId`, `tenantId`)
|
|
1142
1147
|
|
|
1143
1148
|
### Strategy by file type
|
|
1144
1149
|
|
|
@@ -2048,9 +2053,6 @@ interface Message {
|
|
|
2048
2053
|
sender?: User;
|
|
2049
2054
|
readBy?: Array<{ userId: string; readAt: string }>;
|
|
2050
2055
|
deliveredTo?: Array<{ userId: string; deliveredAt: string }>;
|
|
2051
|
-
isEncrypted?: boolean;
|
|
2052
|
-
encryptionMode?: 'none' | 'server' | 'e2ee';
|
|
2053
|
-
encryptedContent?: EncryptedContent;
|
|
2054
2056
|
}
|
|
2055
2057
|
```
|
|
2056
2058
|
|
|
@@ -2123,9 +2125,6 @@ interface Conversation {
|
|
|
2123
2125
|
isPinned?: boolean;
|
|
2124
2126
|
isMuted?: boolean;
|
|
2125
2127
|
mutedUntil?: string;
|
|
2126
|
-
encryptionMode?: 'none' | 'server' | 'e2ee';
|
|
2127
|
-
isEncryptionEnabled?: boolean;
|
|
2128
|
-
encryptionKey?: string;
|
|
2129
2128
|
}
|
|
2130
2129
|
|
|
2131
2130
|
interface ConversationSettings {
|
|
@@ -2192,8 +2191,6 @@ interface SendMessagePayload {
|
|
|
2192
2191
|
attachments?: SendMessageAttachment[];
|
|
2193
2192
|
replyTo?: string; // messageId
|
|
2194
2193
|
tempId: string; // client-generated; echoed back in message_ack
|
|
2195
|
-
encryptedContent?: EncryptedContent;
|
|
2196
|
-
isEncrypted?: boolean;
|
|
2197
2194
|
}
|
|
2198
2195
|
|
|
2199
2196
|
interface SendMessageAttachment {
|
|
@@ -2401,10 +2398,21 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
|
|
|
2401
2398
|
|
|
2402
2399
|
## Changelog
|
|
2403
2400
|
|
|
2401
|
+
### v1.1.2
|
|
2402
|
+
- **New: Participants stored in a dedicated collection** — Participants are no longer embedded inside conversation documents. Each participant is now its own document in a separate collection, removing the MongoDB 16 MB document size ceiling for large groups and eliminating write contention on high-membership conversations. No client API changes.
|
|
2403
|
+
- **New: Transit encryption** — All socket payloads are now encrypted end-to-end using a per-session symmetric key negotiated on connect. Transparent to callers — no SDK API changes required.
|
|
2404
|
+
- **Fix: Disbanded groups now visible in read-only mode for removed members** — Previously removed members' views of disbanded groups were inconsistent. Removed participants now always see the conversation in read-only mode until they explicitly call `leave()`.
|
|
2405
|
+
- **Fix: Removed participant access hardened** — Removed users can no longer view messages sent after their removal via the REST API. Message history is now capped at the exact moment of removal. Removed users were also incorrectly able to search messages, view starred messages, and fetch unread counts from conversations they were removed from — all three are now blocked.
|
|
2406
|
+
- **Fix: Unread badge drops to zero immediately on removal** — When an admin removes a user from a group, all unread messages in that conversation are automatically marked as read for the removed user at the moment of removal. Previously the badge stayed non-zero until the user manually opened the conversation.
|
|
2407
|
+
- **Fix: Compression metadata now stored on the server** — When a compressed image is uploaded, the file record now correctly saves `compressed`, `originalSize`, and `compressionAlgorithm`. Previously this metadata was silently dropped by DTO validation. No changes needed on the client — handled automatically by `uploadBatch`.
|
|
2408
|
+
- **Fix: Delete for me also removes the message from your starred list** — Calling `deleteForMe` on a starred message now removes the star. Previously the message remained in the starred list even after being hidden.
|
|
2409
|
+
|
|
2404
2410
|
### v1.1.1
|
|
2405
2411
|
- **New: `attachmentSnapshot` on reply messages** — `replyTo` now includes a snapshot of the first attachment from the quoted message (`type`, `filename`, `mimeType`, `size`, `duration`, `dimensions`, signed `url`). Stored at write time so reply bubbles render without fetching the original message.
|
|
2406
2412
|
- **Fix: `contentPreview` for multi-attachment replies** — Now reflects attachment count: `"photo.jpg +2 more"`, `"3 Photos"`, `"3 Attachments"` (mixed types). Previously always showed only the first filename.
|
|
2407
2413
|
- **New: exported `ReplyAttachmentSnapshot` type.**
|
|
2414
|
+
- **Fix: Pin, unpin, and unmute operations** — Server-side issues causing pin, unpin, and unmute to fail in certain states are resolved.
|
|
2415
|
+
- **Fix: Various backend fixes** — Stability and correctness improvements across multiple server-side paths.
|
|
2408
2416
|
|
|
2409
2417
|
### v1.1.0
|
|
2410
2418
|
- **Fix: 500 error on device token registration** — Registering a push token that was previously registered under a different user or device ID (e.g. after app reinstall, account switch, or UUID rotation) now succeeds. The stale token record is removed before the upsert, preventing a duplicate key violation on the global `token_unique` index.
|
package/dist/index.cjs
CHANGED
|
@@ -116,6 +116,7 @@ __export(src_exports, {
|
|
|
116
116
|
resetAuthStore: () => resetAuthStore,
|
|
117
117
|
resolveConfig: () => resolveConfig,
|
|
118
118
|
setApiClientInstance: () => setApiClientInstance,
|
|
119
|
+
setTransitSession: () => setTransitSession,
|
|
119
120
|
socketEmit: () => socketEmit,
|
|
120
121
|
storageApi: () => storageApi,
|
|
121
122
|
tryGetSocket: () => tryGetSocket,
|
|
@@ -161,7 +162,7 @@ function resolveConfig(config) {
|
|
|
161
162
|
userId: config.userId,
|
|
162
163
|
tenantId: config.tenantId,
|
|
163
164
|
avatar: config.avatar,
|
|
164
|
-
|
|
165
|
+
transitEncryption: config.transitEncryption ?? true,
|
|
165
166
|
upload: {
|
|
166
167
|
maxFileSizeMB: limits,
|
|
167
168
|
maxFilesPerMessage: config.upload?.maxFilesPerMessage ?? 10,
|
|
@@ -248,6 +249,100 @@ async function compressFile(file, platformCompressFn, config) {
|
|
|
248
249
|
|
|
249
250
|
// src/api/client.ts
|
|
250
251
|
var import_axios = __toESM(require("axios"), 1);
|
|
252
|
+
|
|
253
|
+
// src/crypto/transit.ts
|
|
254
|
+
async function encryptPayload(data, sessionKey) {
|
|
255
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
256
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
|
257
|
+
const encrypted = await globalThis.crypto.subtle.encrypt(
|
|
258
|
+
{ name: "AES-GCM", iv },
|
|
259
|
+
sessionKey,
|
|
260
|
+
plaintext
|
|
261
|
+
);
|
|
262
|
+
const ct = encrypted.slice(0, encrypted.byteLength - 16);
|
|
263
|
+
const tag = encrypted.slice(encrypted.byteLength - 16);
|
|
264
|
+
return {
|
|
265
|
+
v: 1,
|
|
266
|
+
iv: bufToB64(iv),
|
|
267
|
+
tag: bufToB64(tag),
|
|
268
|
+
ct: bufToB64(ct)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
async function decryptPayload(envelope, sessionKey) {
|
|
272
|
+
const iv = b64ToBuf(envelope.iv);
|
|
273
|
+
const tag = b64ToBuf(envelope.tag);
|
|
274
|
+
const ct = b64ToBuf(envelope.ct);
|
|
275
|
+
const combined = new Uint8Array(ct.byteLength + tag.byteLength);
|
|
276
|
+
combined.set(new Uint8Array(ct), 0);
|
|
277
|
+
combined.set(new Uint8Array(tag), ct.byteLength);
|
|
278
|
+
const decrypted = await globalThis.crypto.subtle.decrypt(
|
|
279
|
+
{ name: "AES-GCM", iv: new Uint8Array(iv) },
|
|
280
|
+
sessionKey,
|
|
281
|
+
combined
|
|
282
|
+
);
|
|
283
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
284
|
+
}
|
|
285
|
+
function isTransitEnvelope(v) {
|
|
286
|
+
return typeof v === "object" && v !== null && v.v === 1 && typeof v.iv === "string" && typeof v.tag === "string" && typeof v.ct === "string";
|
|
287
|
+
}
|
|
288
|
+
function bufToB64(buf) {
|
|
289
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
290
|
+
let str = "";
|
|
291
|
+
bytes.forEach((b) => {
|
|
292
|
+
str += String.fromCharCode(b);
|
|
293
|
+
});
|
|
294
|
+
return btoa(str);
|
|
295
|
+
}
|
|
296
|
+
function b64ToBuf(b64) {
|
|
297
|
+
const bin = atob(b64);
|
|
298
|
+
const buf = new Uint8Array(bin.length);
|
|
299
|
+
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
300
|
+
return buf.buffer;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/crypto/session.ts
|
|
304
|
+
var _session = null;
|
|
305
|
+
var _readyResolve = null;
|
|
306
|
+
var _readyPromise = null;
|
|
307
|
+
var _transitConfigured = null;
|
|
308
|
+
function configureTransit(enabled) {
|
|
309
|
+
_transitConfigured = enabled;
|
|
310
|
+
if (!enabled) {
|
|
311
|
+
_readyResolve?.();
|
|
312
|
+
_readyResolve = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function waitForTransitReady() {
|
|
316
|
+
if (!_transitConfigured) return Promise.resolve();
|
|
317
|
+
if (_session) return Promise.resolve();
|
|
318
|
+
if (!_readyPromise) {
|
|
319
|
+
_readyPromise = new Promise((resolve) => {
|
|
320
|
+
_readyResolve = resolve;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return _readyPromise;
|
|
324
|
+
}
|
|
325
|
+
function setTransitSession(session) {
|
|
326
|
+
_session = session;
|
|
327
|
+
_readyResolve?.();
|
|
328
|
+
_readyResolve = null;
|
|
329
|
+
}
|
|
330
|
+
function clearTransitSession() {
|
|
331
|
+
_session = null;
|
|
332
|
+
_readyPromise = null;
|
|
333
|
+
_readyResolve = null;
|
|
334
|
+
}
|
|
335
|
+
function isTransitEnabled() {
|
|
336
|
+
return _session?.enabled === true;
|
|
337
|
+
}
|
|
338
|
+
function getSessionKey() {
|
|
339
|
+
return _session?.sessionKey ?? null;
|
|
340
|
+
}
|
|
341
|
+
function getSessionId() {
|
|
342
|
+
return _session?.sessionId ?? null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/api/client.ts
|
|
251
346
|
var _tokenStore = null;
|
|
252
347
|
var _config = null;
|
|
253
348
|
var _avatarSent = false;
|
|
@@ -259,7 +354,8 @@ function initApiClient(config, tokenStore) {
|
|
|
259
354
|
baseURL: config.apiUrl,
|
|
260
355
|
headers: { "Content-Type": "application/json" }
|
|
261
356
|
});
|
|
262
|
-
|
|
357
|
+
configureTransit(config.transitEncryption);
|
|
358
|
+
client.interceptors.request.use(async (req) => {
|
|
263
359
|
const token = _tokenStore?.getAccessToken();
|
|
264
360
|
if (token) req.headers["Authorization"] = `Bearer ${token}`;
|
|
265
361
|
if (_config?.userId) req.headers["x-user-id"] = _config.userId;
|
|
@@ -269,18 +365,54 @@ function initApiClient(config, tokenStore) {
|
|
|
269
365
|
else if (_config.avatar.url) req.headers["x-avatar-url"] = _config.avatar.url;
|
|
270
366
|
_avatarSent = true;
|
|
271
367
|
}
|
|
368
|
+
await waitForTransitReady();
|
|
369
|
+
if (isTransitEnabled()) {
|
|
370
|
+
const sessionId = getSessionId();
|
|
371
|
+
const key = getSessionKey();
|
|
372
|
+
if (sessionId && key) {
|
|
373
|
+
req.headers["x-transit-session"] = sessionId;
|
|
374
|
+
if (req.data !== void 0 && req.data !== null) {
|
|
375
|
+
const envelope = await encryptPayload(req.data, key);
|
|
376
|
+
req.data = envelope;
|
|
377
|
+
req.headers["x-transit-encrypted"] = "1";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
272
381
|
return req;
|
|
273
382
|
});
|
|
274
383
|
let isRefreshing = false;
|
|
275
384
|
let refreshQueue = [];
|
|
276
385
|
client.interceptors.response.use(
|
|
277
|
-
(response) => {
|
|
386
|
+
async (response) => {
|
|
387
|
+
if (isTransitEnabled()) {
|
|
388
|
+
const key = getSessionKey();
|
|
389
|
+
if (key) {
|
|
390
|
+
if (isTransitEnvelope(response.data)) {
|
|
391
|
+
response.data = await decryptPayload(response.data, key);
|
|
392
|
+
} else if (response.data?.data && isTransitEnvelope(response.data.data)) {
|
|
393
|
+
response.data.data = await decryptPayload(response.data.data, key);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
278
397
|
if (response.data && typeof response.data === "object" && "success" in response.data && "data" in response.data) {
|
|
279
398
|
response.data = response.data.data;
|
|
280
399
|
}
|
|
281
400
|
return response;
|
|
282
401
|
},
|
|
283
402
|
async (error) => {
|
|
403
|
+
if (isTransitEnabled() && error.response?.data) {
|
|
404
|
+
const key = getSessionKey();
|
|
405
|
+
if (key) {
|
|
406
|
+
try {
|
|
407
|
+
if (isTransitEnvelope(error.response.data)) {
|
|
408
|
+
error.response.data = await decryptPayload(error.response.data, key);
|
|
409
|
+
} else if (error.response.data?.data && isTransitEnvelope(error.response.data.data)) {
|
|
410
|
+
error.response.data.data = await decryptPayload(error.response.data.data, key);
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
284
416
|
const original = error.config;
|
|
285
417
|
if (error.response?.status === 401 && !original._retry) {
|
|
286
418
|
const refreshToken = _tokenStore?.getRefreshToken();
|
|
@@ -658,7 +790,14 @@ async function uploadBatch(files, platformUploadFn, conversationId, onProgress,
|
|
|
658
790
|
filename: f.name,
|
|
659
791
|
mimeType: f.type,
|
|
660
792
|
size: f.size,
|
|
661
|
-
conversationId
|
|
793
|
+
conversationId,
|
|
794
|
+
...f.compressed && {
|
|
795
|
+
metadata: {
|
|
796
|
+
compressed: f.compressed,
|
|
797
|
+
originalSize: f.originalSize,
|
|
798
|
+
compressionAlgorithm: f.compressionAlgorithm
|
|
799
|
+
}
|
|
800
|
+
}
|
|
662
801
|
}));
|
|
663
802
|
const { urls, errors: requestErrors } = await storageApi.requestPresignedUrlBatch(requests);
|
|
664
803
|
const progressMap = {};
|
|
@@ -755,6 +894,95 @@ var usersApi = {
|
|
|
755
894
|
|
|
756
895
|
// src/socket/socket.ts
|
|
757
896
|
var import_socket = require("socket.io-client");
|
|
897
|
+
|
|
898
|
+
// src/crypto/detect.ts
|
|
899
|
+
var _cached = null;
|
|
900
|
+
async function detectTransitAlgo() {
|
|
901
|
+
if (_cached) return _cached;
|
|
902
|
+
try {
|
|
903
|
+
await globalThis.crypto.subtle.generateKey(
|
|
904
|
+
{ name: "X25519" },
|
|
905
|
+
false,
|
|
906
|
+
["deriveKey"]
|
|
907
|
+
);
|
|
908
|
+
_cached = "x25519";
|
|
909
|
+
} catch {
|
|
910
|
+
_cached = "p256";
|
|
911
|
+
}
|
|
912
|
+
return _cached;
|
|
913
|
+
}
|
|
914
|
+
function resetAlgoCache() {
|
|
915
|
+
_cached = null;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/crypto/handshake.ts
|
|
919
|
+
async function fetchServerKeys(apiUrl) {
|
|
920
|
+
const res = await fetch(`${apiUrl}/crypto/pubkey`);
|
|
921
|
+
if (!res.ok) throw new Error(`[AntzChat] Failed to fetch server public key: ${res.status}`);
|
|
922
|
+
const body = await res.json();
|
|
923
|
+
return body?.data ?? body;
|
|
924
|
+
}
|
|
925
|
+
async function performHandshake(algo, serverKeys, socketHandshakeAuth) {
|
|
926
|
+
const ephemeral = await globalThis.crypto.subtle.generateKey(
|
|
927
|
+
algo === "x25519" ? { name: "X25519" } : { name: "ECDH", namedCurve: "P-256" },
|
|
928
|
+
false,
|
|
929
|
+
["deriveBits"]
|
|
930
|
+
);
|
|
931
|
+
const pubRaw = await globalThis.crypto.subtle.exportKey("raw", ephemeral.publicKey);
|
|
932
|
+
socketHandshakeAuth["transitEphemeralPub"] = bufToB642(pubRaw);
|
|
933
|
+
socketHandshakeAuth["transitAlgo"] = algo;
|
|
934
|
+
const ephemeralPriv = ephemeral.privateKey;
|
|
935
|
+
return (sessionId) => deriveSessionKey(ephemeralPriv, algo, serverKeys, sessionId);
|
|
936
|
+
}
|
|
937
|
+
async function deriveSessionKey(ephemeralPriv, algo, serverKeys, sessionId) {
|
|
938
|
+
const serverPubB64 = algo === "x25519" ? serverKeys.x25519 : serverKeys.p256;
|
|
939
|
+
const serverPubRaw = b64ToBuf2(serverPubB64);
|
|
940
|
+
const keyAlgoParams = algo === "x25519" ? { name: "X25519" } : { name: "ECDH", namedCurve: "P-256" };
|
|
941
|
+
const serverPubKey = await globalThis.crypto.subtle.importKey(
|
|
942
|
+
"raw",
|
|
943
|
+
serverPubRaw,
|
|
944
|
+
keyAlgoParams,
|
|
945
|
+
false,
|
|
946
|
+
[]
|
|
947
|
+
);
|
|
948
|
+
const sharedBits = await globalThis.crypto.subtle.deriveBits(
|
|
949
|
+
{ name: algo === "x25519" ? "X25519" : "ECDH", public: serverPubKey },
|
|
950
|
+
ephemeralPriv,
|
|
951
|
+
256
|
|
952
|
+
);
|
|
953
|
+
const hkdfKey = await globalThis.crypto.subtle.importKey(
|
|
954
|
+
"raw",
|
|
955
|
+
sharedBits,
|
|
956
|
+
"HKDF",
|
|
957
|
+
false,
|
|
958
|
+
["deriveKey"]
|
|
959
|
+
);
|
|
960
|
+
const salt = new TextEncoder().encode(sessionId);
|
|
961
|
+
const info = new TextEncoder().encode("antz-transit-v1");
|
|
962
|
+
return globalThis.crypto.subtle.deriveKey(
|
|
963
|
+
{ name: "HKDF", hash: "SHA-256", salt, info },
|
|
964
|
+
hkdfKey,
|
|
965
|
+
{ name: "AES-GCM", length: 256 },
|
|
966
|
+
false,
|
|
967
|
+
["encrypt", "decrypt"]
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
function bufToB642(buf) {
|
|
971
|
+
const bytes = new Uint8Array(buf);
|
|
972
|
+
let str = "";
|
|
973
|
+
bytes.forEach((b) => {
|
|
974
|
+
str += String.fromCharCode(b);
|
|
975
|
+
});
|
|
976
|
+
return btoa(str);
|
|
977
|
+
}
|
|
978
|
+
function b64ToBuf2(b64) {
|
|
979
|
+
const bin = atob(b64);
|
|
980
|
+
const buf = new Uint8Array(bin.length);
|
|
981
|
+
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
982
|
+
return buf.buffer;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/socket/socket.ts
|
|
758
986
|
var _socket = null;
|
|
759
987
|
var _status = "disconnected";
|
|
760
988
|
var _statusListeners = /* @__PURE__ */ new Set();
|
|
@@ -779,23 +1007,96 @@ function onSocketStatus(listener) {
|
|
|
779
1007
|
_statusListeners.add(listener);
|
|
780
1008
|
return () => _statusListeners.delete(listener);
|
|
781
1009
|
}
|
|
1010
|
+
async function secureEmit(socket, event, payload, ack) {
|
|
1011
|
+
if (isTransitEnabled()) {
|
|
1012
|
+
const key = getSessionKey();
|
|
1013
|
+
if (key) {
|
|
1014
|
+
const envelope = await encryptPayload(payload, key);
|
|
1015
|
+
if (ack) {
|
|
1016
|
+
socket.emit(event, envelope, async (encryptedResponse) => {
|
|
1017
|
+
if (isTransitEnvelope(encryptedResponse)) {
|
|
1018
|
+
try {
|
|
1019
|
+
const decrypted = await decryptPayload(encryptedResponse, key);
|
|
1020
|
+
ack(decrypted);
|
|
1021
|
+
} catch {
|
|
1022
|
+
ack(encryptedResponse);
|
|
1023
|
+
}
|
|
1024
|
+
} else {
|
|
1025
|
+
ack(encryptedResponse);
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
} else {
|
|
1029
|
+
socket.emit(event, envelope);
|
|
1030
|
+
}
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (ack) {
|
|
1035
|
+
socket.emit(event, payload, ack);
|
|
1036
|
+
} else {
|
|
1037
|
+
socket.emit(event, payload);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function secureOn(socket, event, handler) {
|
|
1041
|
+
socket.on(event, async (raw) => {
|
|
1042
|
+
if (isTransitEnabled() && isTransitEnvelope(raw)) {
|
|
1043
|
+
const key = getSessionKey();
|
|
1044
|
+
if (key) {
|
|
1045
|
+
try {
|
|
1046
|
+
const data = await decryptPayload(raw, key);
|
|
1047
|
+
handler(data);
|
|
1048
|
+
return;
|
|
1049
|
+
} catch {
|
|
1050
|
+
console.error(`[AntzChat] Transit decryption failed for event: ${event}`);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
handler(raw);
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
782
1058
|
async function connectSocket(config, getToken) {
|
|
783
1059
|
if (_socket && !_socket.disconnected) return _socket;
|
|
784
1060
|
_getToken = getToken;
|
|
785
1061
|
_userId = config.userId;
|
|
786
1062
|
_tenantId = config.tenantId;
|
|
787
1063
|
const token = getToken();
|
|
1064
|
+
const socketHandshakeAuth = {
|
|
1065
|
+
token: token ? `Bearer ${token}` : "",
|
|
1066
|
+
...config.userId && { userId: config.userId },
|
|
1067
|
+
...config.tenantId && { tenantId: config.tenantId },
|
|
1068
|
+
...config.avatar?.url && { avatarUrl: config.avatar.url },
|
|
1069
|
+
...config.avatar?.base64 && { avatarBase64: config.avatar.base64 }
|
|
1070
|
+
};
|
|
1071
|
+
let boundDeriveSessionKey = null;
|
|
1072
|
+
if (config.transitEncryption) {
|
|
1073
|
+
try {
|
|
1074
|
+
const serverKeys = await fetchServerKeys(config.apiUrl);
|
|
1075
|
+
if (!serverKeys.enabled) {
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
"[AntzChat] Transit encryption mismatch: SDK has transitEncryption=true but server has TRANSIT_ENCRYPTION_ENABLED=false. Align the config on both sides."
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
const algo = await detectTransitAlgo();
|
|
1081
|
+
boundDeriveSessionKey = await performHandshake(algo, serverKeys, socketHandshakeAuth);
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
if (err.message?.startsWith("[AntzChat] Transit encryption mismatch")) throw err;
|
|
1084
|
+
console.warn("[AntzChat] Transit handshake setup failed, connecting without transit encryption:", err.message);
|
|
1085
|
+
}
|
|
1086
|
+
} else {
|
|
1087
|
+
try {
|
|
1088
|
+
const serverKeys = await fetchServerKeys(config.apiUrl);
|
|
1089
|
+
if (serverKeys.enabled) {
|
|
1090
|
+
throw new Error(
|
|
1091
|
+
"[AntzChat] Transit encryption mismatch: SDK has transitEncryption=false but server has TRANSIT_ENCRYPTION_ENABLED=true. Align the config on both sides."
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
if (err.message?.startsWith("[AntzChat] Transit encryption mismatch")) throw err;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
788
1098
|
_socket = (0, import_socket.io)(`${config.socketOrigin}/chat`, {
|
|
789
|
-
auth:
|
|
790
|
-
token: token ? `Bearer ${token}` : "",
|
|
791
|
-
...config.userId && { userId: config.userId },
|
|
792
|
-
...config.tenantId && { tenantId: config.tenantId },
|
|
793
|
-
...config.avatar?.url && { avatarUrl: config.avatar.url },
|
|
794
|
-
...config.avatar?.base64 && { avatarBase64: config.avatar.base64 }
|
|
795
|
-
},
|
|
796
|
-
// path must match SOCKET_IO_PATH on the server (default '/socket.io').
|
|
797
|
-
// Set socketPath in SDK config when the server is behind a reverse proxy
|
|
798
|
-
// that adds a path prefix (e.g. '/chat-api/socket.io' for UAT).
|
|
1099
|
+
auth: socketHandshakeAuth,
|
|
799
1100
|
path: config.socketPath,
|
|
800
1101
|
transports: ["websocket", "polling"],
|
|
801
1102
|
reconnection: true,
|
|
@@ -806,32 +1107,80 @@ async function connectSocket(config, getToken) {
|
|
|
806
1107
|
});
|
|
807
1108
|
setStatus("connecting");
|
|
808
1109
|
_socket.on("connect", () => setStatus("connected"));
|
|
809
|
-
_socket.on("disconnect", () =>
|
|
1110
|
+
_socket.on("disconnect", () => {
|
|
1111
|
+
setStatus("disconnected");
|
|
1112
|
+
clearTransitSession();
|
|
1113
|
+
});
|
|
810
1114
|
_socket.on("connect_error", (err) => {
|
|
811
1115
|
console.error("[AntzChat] Socket connect_error:", err?.message, err?.data);
|
|
812
1116
|
setStatus("error");
|
|
813
1117
|
});
|
|
814
1118
|
_socket.on("reconnecting", () => setStatus("reconnecting"));
|
|
815
1119
|
_socket.on("reconnect", () => setStatus("connected"));
|
|
816
|
-
|
|
1120
|
+
if (config.transitEncryption && boundDeriveSessionKey) {
|
|
1121
|
+
await new Promise((resolve) => {
|
|
1122
|
+
const done = () => {
|
|
1123
|
+
clearTimeout(timeout);
|
|
1124
|
+
resolve();
|
|
1125
|
+
};
|
|
1126
|
+
const timeout = setTimeout(() => {
|
|
1127
|
+
console.warn("[AntzChat] transit_session timeout \u2014 unblocking HTTP without transit encryption");
|
|
1128
|
+
configureTransit(false);
|
|
1129
|
+
done();
|
|
1130
|
+
}, 5e3);
|
|
1131
|
+
_socket.on("transit_session", async ({ sessionId }) => {
|
|
1132
|
+
try {
|
|
1133
|
+
if (boundDeriveSessionKey) {
|
|
1134
|
+
const sessionKey = await boundDeriveSessionKey(sessionId);
|
|
1135
|
+
const algo = socketHandshakeAuth["transitAlgo"] ?? "p256";
|
|
1136
|
+
setTransitSession({ sessionKey, algo, sessionId, enabled: true });
|
|
1137
|
+
}
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
console.error("[AntzChat] Failed to derive transit session key:", err.message);
|
|
1140
|
+
}
|
|
1141
|
+
done();
|
|
1142
|
+
});
|
|
1143
|
+
_socket.on("connect_error", () => done());
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
secureOn(_socket, "read_receipt", (event) => {
|
|
817
1147
|
Promise.resolve().then(() => (init_chat_store(), chat_store_exports)).then(({ useChatStore: useChatStore2 }) => {
|
|
818
|
-
|
|
1148
|
+
const e = event;
|
|
1149
|
+
useChatStore2.getState().setLastRead(e.conversationId, e.messageId, e.readAt);
|
|
819
1150
|
});
|
|
820
1151
|
});
|
|
821
|
-
_socket
|
|
1152
|
+
secureOn(_socket, "user_online", (event) => {
|
|
822
1153
|
Promise.resolve().then(() => (init_chat_store(), chat_store_exports)).then(({ useChatStore: useChatStore2 }) => {
|
|
823
|
-
|
|
824
|
-
store.setUserOnline(event.userId);
|
|
1154
|
+
useChatStore2.getState().setUserOnline(event.userId);
|
|
825
1155
|
});
|
|
826
1156
|
});
|
|
827
|
-
_socket
|
|
1157
|
+
secureOn(_socket, "user_offline", (event) => {
|
|
828
1158
|
Promise.resolve().then(() => (init_chat_store(), chat_store_exports)).then(({ useChatStore: useChatStore2 }) => {
|
|
1159
|
+
const e = event;
|
|
829
1160
|
const store = useChatStore2.getState();
|
|
830
|
-
store.setUserOffline(
|
|
831
|
-
if (
|
|
1161
|
+
store.setUserOffline(e.userId);
|
|
1162
|
+
if (e.lastSeenAt) store.setLastSeen(e.userId, e.lastSeenAt);
|
|
832
1163
|
});
|
|
833
1164
|
});
|
|
834
|
-
return _socket;
|
|
1165
|
+
return createSecureSocketProxy(_socket);
|
|
1166
|
+
}
|
|
1167
|
+
function createSecureSocketProxy(socket) {
|
|
1168
|
+
return new Proxy(socket, {
|
|
1169
|
+
get(target, prop) {
|
|
1170
|
+
if (prop === "on") {
|
|
1171
|
+
return (event, handler) => {
|
|
1172
|
+
const internal = ["connect", "disconnect", "connect_error", "reconnect", "reconnecting", "error"];
|
|
1173
|
+
if (internal.includes(event)) {
|
|
1174
|
+
return target.on(event, handler);
|
|
1175
|
+
}
|
|
1176
|
+
secureOn(target, event, handler);
|
|
1177
|
+
return socket;
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
const val = target[prop];
|
|
1181
|
+
return typeof val === "function" ? val.bind(target) : val;
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
835
1184
|
}
|
|
836
1185
|
function disconnectSocket() {
|
|
837
1186
|
if (_socket) {
|
|
@@ -839,6 +1188,8 @@ function disconnectSocket() {
|
|
|
839
1188
|
_socket = null;
|
|
840
1189
|
setStatus("disconnected");
|
|
841
1190
|
}
|
|
1191
|
+
clearTransitSession();
|
|
1192
|
+
resetAlgoCache();
|
|
842
1193
|
_getToken = null;
|
|
843
1194
|
_userId = void 0;
|
|
844
1195
|
_tenantId = void 0;
|
|
@@ -896,7 +1247,7 @@ async function withAck(event, payload) {
|
|
|
896
1247
|
if (!socket) return Promise.reject(new Error(`[AntzChat] Socket not connected (event: ${event})`));
|
|
897
1248
|
return new Promise((resolve, reject) => {
|
|
898
1249
|
const timer = setTimeout(() => reject(new Error(`Socket ack timeout: ${event}`)), ACK_TIMEOUT);
|
|
899
|
-
socket
|
|
1250
|
+
secureEmit(socket, event, payload, (response) => {
|
|
900
1251
|
clearTimeout(timer);
|
|
901
1252
|
resolve(response);
|
|
902
1253
|
});
|
|
@@ -905,7 +1256,7 @@ async function withAck(event, payload) {
|
|
|
905
1256
|
function fireAndForget(event, payload) {
|
|
906
1257
|
const socket = tryGetSocket();
|
|
907
1258
|
if (!socket) return;
|
|
908
|
-
socket
|
|
1259
|
+
secureEmit(socket, event, payload);
|
|
909
1260
|
}
|
|
910
1261
|
var socketEmit = {
|
|
911
1262
|
joinRoom(conversationId) {
|
|
@@ -950,7 +1301,7 @@ var socketEmit = {
|
|
|
950
1301
|
if (!socket) return Promise.resolve([]);
|
|
951
1302
|
return new Promise((resolve, reject) => {
|
|
952
1303
|
const timer = setTimeout(() => reject(new Error("get_online_users timeout")), ACK_TIMEOUT);
|
|
953
|
-
socket
|
|
1304
|
+
secureEmit(socket, "get_online_users", { userIds }, (response) => {
|
|
954
1305
|
clearTimeout(timer);
|
|
955
1306
|
if (response && typeof response === "object" && "onlineStatus" in response) {
|
|
956
1307
|
const status = response.onlineStatus;
|
|
@@ -1132,6 +1483,7 @@ var AntzChatClient = class {
|
|
|
1132
1483
|
resetAuthStore,
|
|
1133
1484
|
resolveConfig,
|
|
1134
1485
|
setApiClientInstance,
|
|
1486
|
+
setTransitSession,
|
|
1135
1487
|
socketEmit,
|
|
1136
1488
|
storageApi,
|
|
1137
1489
|
tryGetSocket,
|