@oxyhq/services 5.16.25 → 5.16.26

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.
@@ -59,9 +59,12 @@ export interface OxyContextState {
59
59
  isIdentitySynced: () => Promise<boolean>;
60
60
  syncIdentity: () => Promise<User>;
61
61
  deleteIdentityAndClearAccount: (skipBackup?: boolean, force?: boolean, userConfirmed?: boolean) => Promise<void>;
62
- storeTransferCode: (transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => void;
63
- getTransferCode: (transferId: string) => { code: string; sourceDeviceId: string | null; publicKey: string; timestamp: number } | null;
64
- clearTransferCode: (transferId: string) => void;
62
+ storeTransferCode: (transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => Promise<void>;
63
+ getTransferCode: (transferId: string) => { code: string; sourceDeviceId: string | null; publicKey: string; timestamp: number; state: 'pending' | 'completed' | 'failed' } | null;
64
+ clearTransferCode: (transferId: string) => Promise<void>;
65
+ getAllPendingTransfers: () => Array<{ transferId: string; data: { code: string; sourceDeviceId: string | null; publicKey: string; timestamp: number; state: 'pending' | 'completed' | 'failed' } }>;
66
+ getActiveTransferId: () => string | null;
67
+ updateTransferState: (transferId: string, state: 'pending' | 'completed' | 'failed') => Promise<void>;
65
68
 
66
69
  // Identity sync state (reactive, from Zustand store)
67
70
  identitySyncState: {
@@ -384,6 +387,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
384
387
  oxyServices.clearCache();
385
388
  }, [queryClient, storage, clearSessionState, logger, oxyServices]);
386
389
 
390
+ // Transfer code management functions (must be defined before deleteIdentityAndClearAccount)
391
+ const getAllPendingTransfers = useCallback(() => {
392
+ const pending: Array<{ transferId: string; data: TransferCodeData }> = [];
393
+ transferCodesRef.current.forEach((data, transferId) => {
394
+ if (data.state === 'pending') {
395
+ pending.push({ transferId, data });
396
+ }
397
+ });
398
+ return pending;
399
+ }, []);
400
+
401
+ const getActiveTransferId = useCallback(() => {
402
+ return activeTransferIdRef.current;
403
+ }, []);
404
+
387
405
  // Delete identity and clear all account data
388
406
  // In accounts app, deleting identity means losing the account completely
389
407
  const deleteIdentityAndClearAccount = useCallback(async (
@@ -391,12 +409,30 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
391
409
  force: boolean = false,
392
410
  userConfirmed: boolean = false
393
411
  ): Promise<void> => {
412
+ // CRITICAL: Check for active transfers before deletion (unless force is true)
413
+ // This prevents accidental identity loss during transfer
414
+ if (!force) {
415
+ const pendingTransfers = getAllPendingTransfers();
416
+ if (pendingTransfers.length > 0) {
417
+ const activeTransferId = getActiveTransferId();
418
+ const hasActiveTransfer = activeTransferId && pendingTransfers.some(t => t.transferId === activeTransferId);
419
+
420
+ if (hasActiveTransfer) {
421
+ throw new Error(
422
+ 'Cannot delete identity: An active identity transfer is in progress. ' +
423
+ 'Please wait for the transfer to complete or cancel it first. ' +
424
+ 'If you proceed, you may lose access to your identity permanently.'
425
+ );
426
+ }
427
+ }
428
+ }
429
+
394
430
  // First, clear all account data
395
431
  await clearAllAccountData();
396
432
 
397
433
  // Then delete the identity keys
398
434
  await KeyManager.deleteIdentity(skipBackup, force, userConfirmed);
399
- }, [clearAllAccountData]);
435
+ }, [clearAllAccountData, getAllPendingTransfers, getActiveTransferId]);
400
436
 
401
437
  // Network reconnect sync - TanStack Query automatically retries mutations on reconnect
402
438
  // We only need to sync identity if it's not synced
@@ -634,51 +670,207 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
634
670
  // The JWT token's userId field contains the MongoDB ObjectId
635
671
  const userId = oxyServices.getCurrentUserId() || user?.id;
636
672
 
637
- // Store transfer codes in memory for verification
638
- // Map: transferId -> { code, sourceDeviceId, publicKey, timestamp }
639
- const transferCodesRef = useRef<Map<string, { code: string; sourceDeviceId: string | null; publicKey: string; timestamp: number }>>(new Map());
673
+ // Transfer code storage interface
674
+ interface TransferCodeData {
675
+ code: string;
676
+ sourceDeviceId: string | null;
677
+ publicKey: string;
678
+ timestamp: number;
679
+ state: 'pending' | 'completed' | 'failed';
680
+ }
681
+
682
+ // Store transfer codes in memory for verification (also persisted to storage)
683
+ // Map: transferId -> TransferCodeData
684
+ const transferCodesRef = useRef<Map<string, TransferCodeData>>(new Map());
685
+ const activeTransferIdRef = useRef<string | null>(null);
686
+ const TRANSFER_CODES_STORAGE_KEY = `${storageKeyPrefix}_transfer_codes`;
687
+ const ACTIVE_TRANSFER_STORAGE_KEY = `${storageKeyPrefix}_active_transfer_id`;
688
+
689
+ // Load transfer codes from storage on startup
690
+ useEffect(() => {
691
+ if (!storage || !isStorageReady) return;
692
+
693
+ const loadTransferCodes = async () => {
694
+ try {
695
+ // Load transfer codes
696
+ const storedCodes = await storage.getItem(TRANSFER_CODES_STORAGE_KEY);
697
+ if (storedCodes) {
698
+ const parsed = JSON.parse(storedCodes);
699
+ const now = Date.now();
700
+ const fifteenMinutes = 15 * 60 * 1000;
701
+
702
+ // Only restore non-expired pending transfers
703
+ Object.entries(parsed).forEach(([transferId, data]: [string, any]) => {
704
+ if (data.state === 'pending' && (now - data.timestamp) < fifteenMinutes) {
705
+ transferCodesRef.current.set(transferId, data);
706
+ if (__DEV__) {
707
+ logger('Restored pending transfer from storage', { transferId });
708
+ }
709
+ }
710
+ });
711
+ }
712
+
713
+ // Load active transfer ID
714
+ const activeTransferId = await storage.getItem(ACTIVE_TRANSFER_STORAGE_KEY);
715
+ if (activeTransferId) {
716
+ // Verify it's still valid
717
+ const transferData = transferCodesRef.current.get(activeTransferId);
718
+ if (transferData && transferData.state === 'pending') {
719
+ activeTransferIdRef.current = activeTransferId;
720
+ if (__DEV__) {
721
+ logger('Restored active transfer ID from storage', { transferId: activeTransferId });
722
+ }
723
+ } else {
724
+ // Clear invalid active transfer
725
+ await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
726
+ }
727
+ }
728
+ } catch (error) {
729
+ if (__DEV__) {
730
+ logger('Failed to load transfer codes from storage', error);
731
+ }
732
+ }
733
+ };
734
+
735
+ loadTransferCodes();
736
+ }, [storage, isStorageReady, logger, storageKeyPrefix]);
737
+
738
+ // Persist transfer codes to storage whenever they change
739
+ const persistTransferCodes = useCallback(async () => {
740
+ if (!storage) return;
741
+
742
+ try {
743
+ const codesToStore: Record<string, TransferCodeData> = {};
744
+ transferCodesRef.current.forEach((value, key) => {
745
+ codesToStore[key] = value;
746
+ });
747
+ await storage.setItem(TRANSFER_CODES_STORAGE_KEY, JSON.stringify(codesToStore));
748
+ } catch (error) {
749
+ if (__DEV__) {
750
+ logger('Failed to persist transfer codes', error);
751
+ }
752
+ }
753
+ }, [storage, logger]);
640
754
 
641
755
  // Cleanup old transfer codes (older than 15 minutes)
642
756
  useEffect(() => {
643
- const cleanup = setInterval(() => {
757
+ const cleanup = setInterval(async () => {
644
758
  const now = Date.now();
645
759
  const fifteenMinutes = 15 * 60 * 1000;
760
+ let needsPersist = false;
761
+
646
762
  transferCodesRef.current.forEach((value, key) => {
647
763
  if (now - value.timestamp > fifteenMinutes) {
648
764
  transferCodesRef.current.delete(key);
765
+ needsPersist = true;
649
766
  if (__DEV__) {
650
767
  logger('Cleaned up expired transfer code', { transferId: key });
651
768
  }
652
769
  }
653
770
  });
771
+
772
+ // Clear active transfer if it was deleted
773
+ if (activeTransferIdRef.current && !transferCodesRef.current.has(activeTransferIdRef.current)) {
774
+ activeTransferIdRef.current = null;
775
+ if (storage) {
776
+ try {
777
+ await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
778
+ } catch (error) {
779
+ // Ignore storage errors
780
+ }
781
+ }
782
+ }
783
+
784
+ if (needsPersist) {
785
+ await persistTransferCodes();
786
+ }
654
787
  }, 60000); // Check every minute
655
788
 
656
789
  return () => clearInterval(cleanup);
657
- }, [logger]);
790
+ }, [logger, persistTransferCodes, storage]);
658
791
 
659
792
  // Transfer code management functions
660
- const storeTransferCode = useCallback((transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => {
661
- transferCodesRef.current.set(transferId, {
793
+ const storeTransferCode = useCallback(async (transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => {
794
+ const transferData: TransferCodeData = {
662
795
  code,
663
796
  sourceDeviceId,
664
797
  publicKey,
665
798
  timestamp: Date.now(),
666
- });
799
+ state: 'pending',
800
+ };
801
+
802
+ transferCodesRef.current.set(transferId, transferData);
803
+ activeTransferIdRef.current = transferId;
804
+
805
+ // Persist to storage
806
+ await persistTransferCodes();
807
+ if (storage) {
808
+ try {
809
+ await storage.setItem(ACTIVE_TRANSFER_STORAGE_KEY, transferId);
810
+ } catch (error) {
811
+ if (__DEV__) {
812
+ logger('Failed to persist active transfer ID', error);
813
+ }
814
+ }
815
+ }
816
+
667
817
  if (__DEV__) {
668
818
  logger('Stored transfer code', { transferId, sourceDeviceId, publicKey: publicKey.substring(0, 16) + '...' });
669
819
  }
670
- }, [logger]);
820
+ }, [logger, persistTransferCodes, storage]);
671
821
 
672
822
  const getTransferCode = useCallback((transferId: string) => {
673
823
  return transferCodesRef.current.get(transferId) || null;
674
824
  }, []);
675
825
 
676
- const clearTransferCode = useCallback((transferId: string) => {
826
+ const updateTransferState = useCallback(async (transferId: string, state: 'pending' | 'completed' | 'failed') => {
827
+ const transferData = transferCodesRef.current.get(transferId);
828
+ if (transferData) {
829
+ transferData.state = state;
830
+ transferCodesRef.current.set(transferId, transferData);
831
+
832
+ // Clear active transfer if completed or failed
833
+ if (state === 'completed' || state === 'failed') {
834
+ if (activeTransferIdRef.current === transferId) {
835
+ activeTransferIdRef.current = null;
836
+ if (storage) {
837
+ try {
838
+ await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
839
+ } catch (error) {
840
+ // Ignore storage errors
841
+ }
842
+ }
843
+ }
844
+ }
845
+
846
+ await persistTransferCodes();
847
+
848
+ if (__DEV__) {
849
+ logger('Updated transfer state', { transferId, state });
850
+ }
851
+ }
852
+ }, [logger, persistTransferCodes, storage]);
853
+
854
+ const clearTransferCode = useCallback(async (transferId: string) => {
677
855
  transferCodesRef.current.delete(transferId);
856
+
857
+ if (activeTransferIdRef.current === transferId) {
858
+ activeTransferIdRef.current = null;
859
+ if (storage) {
860
+ try {
861
+ await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
862
+ } catch (error) {
863
+ // Ignore storage errors
864
+ }
865
+ }
866
+ }
867
+
868
+ await persistTransferCodes();
869
+
678
870
  if (__DEV__) {
679
871
  logger('Cleared transfer code', { transferId });
680
872
  }
681
- }, [logger]);
873
+ }, [logger, persistTransferCodes, storage]);
682
874
 
683
875
  const refreshSessionsWithUser = useCallback(
684
876
  () => refreshSessions(userId),
@@ -789,6 +981,44 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
789
981
  return;
790
982
  }
791
983
 
984
+ // CRITICAL: Verify target device has identity before deleting on source device
985
+ // This ensures we never lose the identity
986
+ try {
987
+ logger('Verifying target device has identity before deletion', {
988
+ transferId: data.transferId,
989
+ publicKey: data.publicKey.substring(0, 16) + '...',
990
+ });
991
+
992
+ // Verify target device has active session with matching public key
993
+ const verifyResponse = await oxyServices.makeRequest<{ verified: boolean; hasActiveSession: boolean }>(
994
+ 'GET',
995
+ `/api/identity/verify-transfer?publicKey=${encodeURIComponent(data.publicKey)}`,
996
+ undefined,
997
+ { cache: false }
998
+ );
999
+
1000
+ if (!verifyResponse.verified || !verifyResponse.hasActiveSession) {
1001
+ logger('Target device verification failed - identity will not be deleted', {
1002
+ transferId: data.transferId,
1003
+ verified: verifyResponse.verified,
1004
+ hasActiveSession: verifyResponse.hasActiveSession,
1005
+ });
1006
+ await updateTransferState(data.transferId, 'failed');
1007
+ toast.error('Transfer verification failed: Target device does not have active session. Identity will not be deleted.');
1008
+ return;
1009
+ }
1010
+
1011
+ logger('Target device verification passed', {
1012
+ transferId: data.transferId,
1013
+ });
1014
+ } catch (verifyError: any) {
1015
+ // If verification fails, don't delete identity - it's safer to keep it
1016
+ logger('Failed to verify target device - identity will not be deleted', verifyError);
1017
+ await updateTransferState(data.transferId, 'failed');
1018
+ toast.error('Transfer verification failed: Could not verify target device. Identity will not be deleted.');
1019
+ return;
1020
+ }
1021
+
792
1022
  logger('All transfer verifications passed, deleting identity from source device', {
793
1023
  transferId: data.transferId,
794
1024
  sourceDeviceId: data.sourceDeviceId,
@@ -796,8 +1026,31 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
796
1026
  });
797
1027
 
798
1028
  try {
1029
+ // Verify identity still exists before deletion (safety check)
1030
+ const identityStillExists = await KeyManager.hasIdentity();
1031
+ if (!identityStillExists) {
1032
+ logger('Identity already deleted - skipping deletion', {
1033
+ transferId: data.transferId,
1034
+ });
1035
+ await updateTransferState(data.transferId, 'completed');
1036
+ await clearTransferCode(data.transferId);
1037
+ return;
1038
+ }
1039
+
799
1040
  await deleteIdentityAndClearAccount(false, false, true);
800
- clearTransferCode(data.transferId);
1041
+
1042
+ // Verify identity was actually deleted
1043
+ const identityDeleted = !(await KeyManager.hasIdentity());
1044
+ if (!identityDeleted) {
1045
+ logger('Identity deletion failed - identity still exists', {
1046
+ transferId: data.transferId,
1047
+ });
1048
+ await updateTransferState(data.transferId, 'failed');
1049
+ throw new Error('Identity deletion failed - identity still exists');
1050
+ }
1051
+
1052
+ await updateTransferState(data.transferId, 'completed');
1053
+ await clearTransferCode(data.transferId);
801
1054
 
802
1055
  logger('Identity successfully deleted and transfer code cleared', {
803
1056
  transferId: data.transferId,
@@ -806,6 +1059,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
806
1059
  toast.success('Identity successfully transferred and removed from this device');
807
1060
  } catch (deleteError: any) {
808
1061
  logger('Error during identity deletion', deleteError);
1062
+ await updateTransferState(data.transferId, 'failed');
809
1063
  throw deleteError;
810
1064
  }
811
1065
  } catch (error: any) {
@@ -813,7 +1067,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
813
1067
  toast.error(error?.message || 'Failed to remove identity from this device. Please try again manually from Security Settings.');
814
1068
  }
815
1069
  },
816
- [deleteIdentityAndClearAccount, logger, getTransferCode, clearTransferCode, currentDeviceId, activeSessionId],
1070
+ [deleteIdentityAndClearAccount, logger, getTransferCode, clearTransferCode, updateTransferState, currentDeviceId, activeSessionId, oxyServices],
817
1071
  );
818
1072
 
819
1073
  useSessionSocket({
@@ -907,6 +1161,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
907
1161
  storeTransferCode,
908
1162
  getTransferCode,
909
1163
  clearTransferCode,
1164
+ getAllPendingTransfers,
1165
+ getActiveTransferId,
1166
+ updateTransferState,
910
1167
  identitySyncState: {
911
1168
  isSynced: isIdentitySyncedStore ?? true,
912
1169
  isSyncing: isSyncing ?? false,
@@ -940,6 +1197,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
940
1197
  storeTransferCode,
941
1198
  getTransferCode,
942
1199
  clearTransferCode,
1200
+ getAllPendingTransfers,
1201
+ getActiveTransferId,
1202
+ updateTransferState,
943
1203
  isIdentitySyncedStore,
944
1204
  isSyncing,
945
1205
  currentLanguage,