@antzsoft/chat-core 1.1.0 → 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/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
- encryptionMode: config.encryptionMode ?? "none",
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
- client.interceptors.request.use((req) => {
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();
@@ -535,10 +667,10 @@ var conversationsApi = {
535
667
  async delete(conversationId) {
536
668
  await getApiClient().delete(`/conversations/${conversationId}`);
537
669
  },
538
- async addParticipants(conversationId, userIds) {
670
+ async addParticipants(conversationId, userIds, role) {
539
671
  const { data } = await getApiClient().post(
540
672
  `/conversations/${conversationId}/participants`,
541
- { userIds }
673
+ { userIds, ...role && { role } }
542
674
  );
543
675
  return normalizeConversation(data);
544
676
  },
@@ -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", () => setStatus("disconnected"));
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
- _socket.on("read_receipt", (event) => {
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
- useChatStore2.getState().setLastRead(event.conversationId, event.messageId, event.readAt);
1148
+ const e = event;
1149
+ useChatStore2.getState().setLastRead(e.conversationId, e.messageId, e.readAt);
819
1150
  });
820
1151
  });
821
- _socket.on("user_online", (event) => {
1152
+ secureOn(_socket, "user_online", (event) => {
822
1153
  Promise.resolve().then(() => (init_chat_store(), chat_store_exports)).then(({ useChatStore: useChatStore2 }) => {
823
- const store = useChatStore2.getState();
824
- store.setUserOnline(event.userId);
1154
+ useChatStore2.getState().setUserOnline(event.userId);
825
1155
  });
826
1156
  });
827
- _socket.on("user_offline", (event) => {
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(event.userId);
831
- if (event.lastSeenAt) store.setLastSeen(event.userId, event.lastSeenAt);
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.emit(event, payload, (response) => {
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.emit(event, payload);
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.emit("get_online_users", { userIds }, (response) => {
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,