From a555467dd28b9571e79df8775fd84b1d1b52867a Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 8 Jul 2026 18:12:29 -0400 Subject: [PATCH 01/10] Implement UserProfileAccessController and integrate with OrbitDbService and UserProfileStore --- .../nest/storage/orbitDb/orbitDb.service.ts | 10 +- .../nest/storage/orbitDb/orbitdb.module.ts | 3 + .../UserProfileAccessController.ts | 257 ++++++++++++++++++ .../UserProfileAccessController.types.ts | 11 + .../UserProfileAccessController.unit.spec.ts | 240 ++++++++++++++++ .../userProfile/userProfile.store.spec.ts | 6 +- .../storage/userProfile/userProfile.store.ts | 29 +- .../storage/userProfile/userProfile.utils.ts | 20 +- 8 files changed, 556 insertions(+), 20 deletions(-) create mode 100644 packages/backend/src/nest/storage/userProfile/UserProfileAccessController.ts create mode 100644 packages/backend/src/nest/storage/userProfile/UserProfileAccessController.types.ts create mode 100644 packages/backend/src/nest/storage/userProfile/UserProfileAccessController.unit.spec.ts diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index 1a0edecad8..86a71ed14c 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -8,6 +8,7 @@ import { createLogger } from '../../common/logger' import { logEntryToLogUpdate, posixJoin } from './util' import { MessagesAccessController } from '../channels/messages/orbitdb/MessagesAccessController' import { ChannelMetadataAccessController } from '../channels/orbitdb/ChannelMetadataAccessController' +import { UserProfileAccessController } from '../userProfile/UserProfileAccessController' import { createOrbitDB, type OrbitDBType, @@ -171,7 +172,8 @@ export class OrbitDbService { private readonly sigChainService: SigChainService, private readonly lfaIdentities: LFAIdentities, private readonly messagesAccessController: MessagesAccessController, - private readonly channelMetadataAccessController: ChannelMetadataAccessController + private readonly channelMetadataAccessController: ChannelMetadataAccessController, + private readonly userProfileAccessController: UserProfileAccessController ) { this.attachOrbitDbUpdateListener() } @@ -220,6 +222,12 @@ export class OrbitDbService { sigchainService: this.sigChainService, }) as any ) + orbitDbUseAccessController( + this.userProfileAccessController.createAccessControllerFunc({ + write: ['*'], + sigchainService: this.sigChainService, + }) as any + ) /** * This overrides the built-in identity system to use our custom LFA-based identity service diff --git a/packages/backend/src/nest/storage/orbitDb/orbitdb.module.ts b/packages/backend/src/nest/storage/orbitDb/orbitdb.module.ts index 48f138a577..cdea2523c4 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitdb.module.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitdb.module.ts @@ -16,6 +16,7 @@ import { NotificationTokensStore } from '../notifications/notificationTokens.sto import { MessagesAccessController } from '../channels/messages/orbitdb/MessagesAccessController' import { PrivateMessagesAccessController } from '../channels/messages/orbitdb/PrivateMessagesAccessController' import { ChannelMetadataAccessController } from '../channels/orbitdb/ChannelMetadataAccessController' +import { UserProfileAccessController } from '../userProfile/UserProfileAccessController' @Module({ imports: [ @@ -38,6 +39,7 @@ import { ChannelMetadataAccessController } from '../channels/orbitdb/ChannelMeta MessagesAccessController, PrivateMessagesAccessController, ChannelMetadataAccessController, + UserProfileAccessController, ], exports: [ OrbitDbService, @@ -52,6 +54,7 @@ import { ChannelMetadataAccessController } from '../channels/orbitdb/ChannelMeta MessagesAccessController, PrivateMessagesAccessController, ChannelMetadataAccessController, + UserProfileAccessController, ], }) export class OrbitDbModule {} diff --git a/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.ts b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.ts new file mode 100644 index 0000000000..fc038175f8 --- /dev/null +++ b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.ts @@ -0,0 +1,257 @@ +/** + * OrbitDB access controller for user profiles + */ + +import { + AccessController, + type CanAppendFunc, + type IdentitiesType, + type LogEntry, + type OrbitDBType, +} from '@orbitdb/core' +import { Injectable } from '@nestjs/common' +import * as Block from 'multiformats/block' +import * as dagCbor from '@ipld/dag-cbor' +import { sha256 } from 'multiformats/hashes/sha2' +import { base58btc } from 'multiformats/bases/base58' +import { ComposedStorage, IPFSBlockStorage, LRUStorage, type Storage } from '@orbitdb/core' +import { UserProfile } from '@quiet/types' +import { QuietLogger } from '@quiet/logger' + +import { RoleName } from '../../auth/services/roles/roles' +import { SigChainService } from '../../auth/sigchain.service' +import { EncryptedAndSignedPayload } from '../../auth/services/crypto/types' +import { createLogger } from '../../common/logger' +import { posixJoin } from '../orbitDb/util' +import { validateUserProfile } from './userProfile.utils' +import type { SigChain } from '../../auth/sigchain' +import type { UserProfileAccessControllerConfig, UserProfileWriterIdentity } from './UserProfileAccessController.types' + +const TYPE = 'userprofileaccess' +const codec = dagCbor +const hasher = sha256 +const hashStringEncoding = base58btc + +const AccessControlList = async ({ storage, params }: { storage: Storage; params: Record }) => { + const manifest = { + type: TYPE, + ...params, + } + const { cid, bytes } = await Block.encode({ value: manifest, codec, hasher }) + const hash = cid.toString(hashStringEncoding) + await storage.put(hash, bytes) + return hash +} + +const getAccessControllerManifestHash = (address: string): string => { + const hash = address.split('/').filter(Boolean).pop() + if (hash == null) { + throw new Error(`Invalid access controller address: ${address}`) + } + return hash +} + +@Injectable() +export class UserProfileAccessController { + protected readonly logger: QuietLogger + + constructor(protected sigchainService: SigChainService) { + this.logger = createLogger(`storage:user-profile:orbitdb:access-control:${TYPE}`) + } + + public createAccessControllerFunc(config: UserProfileAccessControllerConfig): typeof AccessController { + const accessController = (options?: any) => { + if (options?.orbitdb != null) { + return this._createAccessControllerFuncImpl(config)(options) + } + return this._createAccessControllerFuncImpl({ ...config, ...options }) + } + ;(accessController as any).type = TYPE + return accessController as typeof AccessController + } + + private _createAccessControllerFuncImpl(config: UserProfileAccessControllerConfig): typeof AccessController { + return async ({ + orbitdb, + identities, + address, + }: { + orbitdb: OrbitDBType + identities: IdentitiesType + address?: string + name?: string + }) => { + const storage = await ComposedStorage( + await LRUStorage({ size: 1000 }), + await IPFSBlockStorage({ ipfs: orbitdb.ipfs, pin: true }) + ) + let write = config.write || [orbitdb.identity.id] + + if (address) { + const manifestBytes = await storage.get(getAccessControllerManifestHash(address)) + const { value } = await Block.decode({ bytes: manifestBytes, codec, hasher }) + // FIXME: Figure out typings + // @ts-ignore + write = value.write + } else { + address = await AccessControlList({ storage, params: { write } }) + address = posixJoin('/', TYPE, address) + } + + return { + type: TYPE, + address, + write, + canAppend: this.canAppend({ ...config, write }, identities) as any, + } + } + } + + protected canAppend(config: UserProfileAccessControllerConfig, identities: IdentitiesType): CanAppendFunc { + return async (entry: LogEntry): Promise => { + const writerIdentity = (await identities.getIdentity(entry.identity)) as UserProfileWriterIdentity + if (!writerIdentity) { + return false + } + + if (!config.write.includes(writerIdentity.id) && !config.write.includes('*')) { + return false + } + + if (!(await identities.verifyIdentity(writerIdentity as any))) { + return false + } + + const chain = config.sigchainService.getActiveChain(false) + if (chain == null) { + this.logger.warn(`Cannot verify user profile writer without an active chain`) + return false + } + + const activeTeamId = chain.team?.id + if (activeTeamId == null) { + this.logger.warn(`Cannot verify user profile writer without an active team`) + return false + } + + if (writerIdentity.teamId != null && writerIdentity.teamId !== activeTeamId) { + this.logger.warn(`User profile writer identity is from a different team`, { + entryTeamId: writerIdentity.teamId, + activeTeamId, + }) + return false + } + + if (entry.payload.op === 'PUT' && !(await this.canAppendPutEntry(entry, chain, writerIdentity.id))) { + return false + } + + if (chain.roles.memberIsAdmin(writerIdentity.id)) { + return true + } + + if (!chain.roles.memberHasRole(writerIdentity.id, RoleName.MEMBER)) { + this.logger.warn(`User profile writer is not a team member`, { + writerId: writerIdentity.id, + }) + return false + } + + return true + } + } + + private async canAppendPutEntry( + entry: LogEntry, + chain: SigChain, + writerId: string + ): Promise { + const key = entry.payload.key + const encPayload = entry.payload.value + if (key == null) { + this.logger.warn(`User profile PUT rejected because the entry key is missing`, { + entryHash: entry.hash, + }) + return false + } + + if (encPayload == null) { + this.logger.warn(`User profile PUT rejected because the payload value is missing`, { + key, + entryHash: entry.hash, + }) + return false + } + + const valueUserId = encPayload.userId + const sigAuthor = encPayload.signature?.author?.name + if (valueUserId == null || sigAuthor == null || key !== valueUserId || key !== sigAuthor) { + this.logger.warn(`User profile PUT rejected because entry ids do not match`, { + key, + valueUserId, + sigAuthor, + entryHash: entry.hash, + }) + return false + } + + if (writerId !== sigAuthor) { + this.logger.warn(`User profile PUT rejected because writer does not match encrypted signature author`, { + writerId, + sigAuthor, + entryHash: entry.hash, + }) + return false + } + + if (encPayload.teamId !== chain.team?.id) { + this.logger.warn(`User profile PUT rejected because payload team does not match active chain`, { + payloadTeamId: encPayload.teamId, + activeTeamId: chain.team?.id, + entryHash: entry.hash, + }) + return false + } + + let profile: UserProfile + try { + const decrypted = chain.crypto.decryptAndVerify(encPayload.encrypted, encPayload.signature) + if (!decrypted.isValid) { + this.logger.warn(`User profile PUT rejected because the encrypted payload signature is invalid`, { + key, + entryHash: entry.hash, + }) + return false + } + profile = decrypted.contents + } catch (err) { + this.logger.warn(`User profile PUT rejected because the payload could not be decrypted`, { + key, + entryHash: entry.hash, + error: err, + }) + return false + } + + if (profile?.userId == null || profile.userId !== key) { + this.logger.warn(`User profile PUT rejected because decrypted user id does not match the entry key`, { + key, + decryptedUserId: profile?.userId, + entryHash: entry.hash, + }) + return false + } + + const validation = await validateUserProfile(profile) + if (!validation.success) { + this.logger.warn(`User profile PUT rejected because the decrypted profile is invalid`, { + key, + entryHash: entry.hash, + error: validation.error, + }) + return false + } + + return true + } +} diff --git a/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.types.ts b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.types.ts new file mode 100644 index 0000000000..e1b89df524 --- /dev/null +++ b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.types.ts @@ -0,0 +1,11 @@ +import type { SigChainService } from '../../auth/sigchain.service' + +export interface UserProfileAccessControllerConfig { + write: string[] + sigchainService: SigChainService +} + +export interface UserProfileWriterIdentity { + id: string + teamId?: string +} diff --git a/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.unit.spec.ts b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.unit.spec.ts new file mode 100644 index 0000000000..b328ee2897 --- /dev/null +++ b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.unit.spec.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { type LogEntry } from '@orbitdb/core' +import { UserProfile } from '@quiet/types' +import { base58btc } from 'multiformats/bases/base58' + +import { UserProfileAccessController } from './UserProfileAccessController' +import { RoleName } from '../../auth/services/roles/roles' +import { EncryptedAndSignedPayload, EncryptionScopeType } from '../../auth/services/crypto/types' + +const emptyAsyncIterable = async function* () {} + +const createInMemoryIpfs = () => { + const blocks = new Map() + const cidKey = (cid: any): string => cid.toString(base58btc) + + return { + blockstore: { + put: async (cid: any, bytes: Uint8Array) => { + blocks.set(cidKey(cid), bytes) + }, + get: async (cid: any) => blocks.get(cidKey(cid)), + }, + pins: { + isPinned: async () => false, + add: () => emptyAsyncIterable(), + }, + } +} + +const createUserProfile = (userId = 'writer-id', overrides: Partial = {}): UserProfile => ({ + userId, + nickname: 'Writer', + ...overrides, +}) + +const createSigchainService = ({ + teamId = 'team-id', + member = true, + admin = false, + decryptedProfile = createUserProfile(), + signatureIsValid = true, + decryptThrows = false, +}: { + teamId?: string + member?: boolean + admin?: boolean + decryptedProfile?: UserProfile + signatureIsValid?: boolean + decryptThrows?: boolean +} = {}) => + ({ + getActiveChain: jest.fn().mockReturnValue({ + team: { id: teamId }, + roles: { + memberHasRole: jest.fn( + (memberId: string, roleName: string) => memberId === 'writer-id' && roleName === RoleName.MEMBER && member + ), + memberIsAdmin: jest.fn((memberId: string) => memberId === 'writer-id' && admin), + }, + crypto: { + decryptAndVerify: jest.fn(() => { + if (decryptThrows) { + throw new Error('decrypt failed') + } + return { + contents: decryptedProfile, + isValid: signatureIsValid, + } + }), + }, + }), + }) as any + +const createEncryptedPayload = ({ + userId = 'writer-id', + sigAuthor = userId, + teamId = 'team-id', +}: { + userId?: string + sigAuthor?: string + teamId?: string +} = {}): EncryptedAndSignedPayload => + ({ + encrypted: { + contents: new Uint8Array([1, 2, 3]), + scope: { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + generation: 0, + }, + }, + signature: { + signature: 'signature', + author: { name: sigAuthor }, + }, + ts: 1, + userId, + teamId, + }) as unknown as EncryptedAndSignedPayload + +const createEntry = ({ + key = 'writer-id', + hash = `put-${key}`, + value = createEncryptedPayload(), + includeValue = true, +}: { + key?: string + hash?: string + value?: EncryptedAndSignedPayload + includeValue?: boolean +} = {}): LogEntry => + ({ + hash, + identity: 'writer-identity-hash', + payload: { + op: 'PUT', + key, + value: includeValue ? value : undefined, + }, + }) as unknown as LogEntry + +const createAccess = async ( + sigchainService: any, + writerIdentity: { id: string; teamId?: string } | undefined = { id: 'writer-id', teamId: 'team-id' }, + verifyIdentity = true +) => { + const controller = new UserProfileAccessController(sigchainService) + const factory = controller.createAccessControllerFunc({ write: ['*'], sigchainService }) + return (factory as any)({ + orbitdb: { + identity: { id: 'local-orbitdb-identity' }, + ipfs: createInMemoryIpfs(), + }, + identities: { + getIdentity: jest.fn().mockResolvedValue(writerIdentity as never), + verifyIdentity: jest.fn().mockResolvedValue(verifyIdentity as never), + }, + }) +} + +describe('UserProfileAccessController', () => { + it('loads the ACL manifest from a persisted typed access-controller address', async () => { + const sigchainService = createSigchainService() + const controller = new UserProfileAccessController(sigchainService) + const factory = controller.createAccessControllerFunc({ write: ['writer-id'], sigchainService }) + const orbitdb = { + identity: { id: 'local-orbitdb-identity' }, + ipfs: createInMemoryIpfs(), + } + const identities = {} + + const created = await (factory as any)({ orbitdb, identities }) + expect(created.address).toMatch(/^\/userprofileaccess\/z/) + expect(created.write).toEqual(['writer-id']) + + await expect((factory as any)({ orbitdb, identities, address: created.address })).resolves.toMatchObject({ + address: created.address, + write: ['writer-id'], + }) + }) + + it('allows user profile PUT entries from team members when entry ids match', async () => { + const access = await createAccess(createSigchainService()) + + await expect(access.canAppend(createEntry())).resolves.toBe(true) + }) + + it('rejects user profile PUT entries from non-members', async () => { + const access = await createAccess(createSigchainService({ member: false })) + + await expect(access.canAppend(createEntry())).resolves.toBe(false) + }) + + it('rejects user profile PUT entries without an active chain', async () => { + const access = await createAccess({ + getActiveChain: jest.fn().mockReturnValue(undefined), + }) + + await expect(access.canAppend(createEntry())).resolves.toBe(false) + }) + + it('rejects user profile PUT entries from a different team identity', async () => { + const access = await createAccess(createSigchainService(), { id: 'writer-id', teamId: 'other-team-id' }) + + await expect(access.canAppend(createEntry())).resolves.toBe(false) + }) + + it('rejects user profile PUT entries when encrypted ids do not match the key', async () => { + const access = await createAccess(createSigchainService()) + const entry = createEntry({ + value: createEncryptedPayload({ + userId: 'writer-id', + sigAuthor: 'writer-id', + }), + key: 'other-user-id', + }) + + await expect(access.canAppend(entry)).resolves.toBe(false) + }) + + it('rejects user profile PUT entries when the writer does not match the encrypted signature author', async () => { + const access = await createAccess(createSigchainService(), { id: 'other-user-id', teamId: 'team-id' }) + + await expect(access.canAppend(createEntry())).resolves.toBe(false) + }) + + it('rejects user profile PUT entries when decrypted user id does not match the key', async () => { + const access = await createAccess( + createSigchainService({ + decryptedProfile: createUserProfile('other-user-id'), + }) + ) + + await expect(access.canAppend(createEntry())).resolves.toBe(false) + }) + + it('rejects user profile PUT entries when the encrypted payload signature is invalid', async () => { + const access = await createAccess(createSigchainService({ signatureIsValid: false })) + + await expect(access.canAppend(createEntry())).resolves.toBe(false) + }) + + it('rejects user profile PUT entries when the decrypted profile is invalid', async () => { + const access = await createAccess( + createSigchainService({ + decryptedProfile: createUserProfile('writer-id', { + photo: 'data:text/html,invalid', + }), + }) + ) + + await expect(access.canAppend(createEntry())).resolves.toBe(false) + }) + + it('rejects user profile PUT entries when the payload value is missing', async () => { + const access = await createAccess(createSigchainService()) + + await expect(access.canAppend(createEntry({ includeValue: false }))).resolves.toBe(false) + }) +}) diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts index 425e1737ba..af104887af 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts @@ -213,7 +213,11 @@ describe('UserProfileStore/validateEntry', () => { } const decEntry: any = { userId: aliceUserId } // Patch decryptEntry to return decEntry - const store = new UserProfileStore({} as any, { crypto: {}, user: { userId: aliceUserId }, on: jest.fn() } as any) + const store = new UserProfileStore( + {} as any, + { crypto: {}, user: { userId: aliceUserId }, on: jest.fn() } as any, + {} as any + ) jest.spyOn(store, 'decryptEntry').mockResolvedValue(decEntry) jest.spyOn(UserProfileStore, 'validateUserProfile').mockResolvedValue({ success: true }) const entry = { diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts index 44cbb7fdff..c2af52b42b 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts @@ -1,17 +1,18 @@ import { Injectable } from '@nestjs/common' -import { type LogEntry, type KeyValueType, IPFSAccessController } from '@orbitdb/core' +import { type LogEntry } from '@orbitdb/core' import { SetUserProfileResponse, UserProfile } from '@quiet/types' import { createLogger } from '../../common/logger' import { OrbitDbService } from '../orbitDb/orbitDb.service' import { StorageEvents } from '../storage.types' import { KeyValueIndexedValidated, type KeyValueIndexedValidatedType } from '../orbitDb/keyValueIndexedValidated' -import { validatePhoto } from './userProfile.utils' +import { validateUserProfile } from './userProfile.utils' import { EncryptedKeyValueIndexedValidatedStoreBase } from '../base.store' import { EncryptedAndSignedPayload, EncryptionScopeType } from '../../auth/services/crypto/types' import { SigChainService } from '../../auth/sigchain.service' import { RoleName } from '../../auth/services/roles/roles' import { SigchainEvents } from '../../auth/types' +import { UserProfileAccessController } from './UserProfileAccessController' const logger = createLogger('UserProfileStore') @@ -25,7 +26,8 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase constructor( private readonly orbitDbService: OrbitDbService, - private readonly auth: SigChainService + private readonly auth: SigChainService, + private readonly userProfileAccessController: UserProfileAccessController ) { super() this.auth.on(SigchainEvents.UPDATED, this.handleAuthUpdated) @@ -39,7 +41,10 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase type: 'KeyValueIndexedValidated', sync: false, Database: KeyValueIndexedValidated(this.validateEntry.bind(this)), - AccessController: IPFSAccessController({ write: ['*'] }), + AccessController: this.userProfileAccessController.createAccessControllerFunc({ + write: ['*'], + sigchainService: this.auth, + }), } ) @@ -175,7 +180,8 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase public async setEntry(key: string, userProfile: UserProfile): Promise { logger.info('Adding user profile') try { - if (!UserProfileStore.validateUserProfile(userProfile)) { + const validationResponse = await UserProfileStore.validateUserProfile(userProfile) + if (!validationResponse.success) { // TODO: Send validation errors to frontend or replicate // validation on frontend? logger.error('Failed to add user profile, profile is invalid', userProfile.userId) @@ -223,18 +229,7 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase * @returns True if valid, false otherwise. */ public static async validateUserProfile(userProfile: UserProfile): Promise { - try { - if (userProfile?.photo) { - const photoValidation = validatePhoto(userProfile.photo ?? '', userProfile.userId) - if (!photoValidation.success) { - return { success: false, error: photoValidation.error } - } - } - } catch (err) { - logger.error('Error validating user profile:', userProfile.userId, err) - return { success: false, error: 'Internal error: Failed to validate user profile' } - } - return { success: true } + return validateUserProfile(userProfile) } /** diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.utils.ts b/packages/backend/src/nest/storage/userProfile/userProfile.utils.ts index 1e51fdc6d1..ebc153c4a5 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.utils.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.utils.ts @@ -1,4 +1,4 @@ -import { SetUserProfileResponse } from '@quiet/types' +import { SetUserProfileResponse, UserProfile } from '@quiet/types' import { createLogger } from '../../common/logger' const logger = createLogger('UserProfileStoreUtils') @@ -118,6 +118,24 @@ export const validatePhoto = (photoString: string, pubKey: string): SetUserProfi return { success: true } } +/** + * Validates a user profile, including its photo if present. + */ +export const validateUserProfile = async (userProfile: UserProfile): Promise => { + try { + if (userProfile?.photo) { + const photoValidation = validatePhoto(userProfile.photo ?? '', userProfile.userId) + if (!photoValidation.success) { + return { success: false, error: photoValidation.error } + } + } + } catch (err) { + logger.error('Error validating user profile:', userProfile.userId, err) + return { success: false, error: 'Internal error: Failed to validate user profile' } + } + return { success: true } +} + /** * Takes a base64 data URI string that starts with 'data:*\/*;base64,' * as returned from From 9cc8b362596cd1a944691db0d86b494cb1964adf Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 8 Jul 2026 18:21:12 -0400 Subject: [PATCH 02/10] add migration for new user profile store --- .../updateUserProfiles.saga.test.ts | 109 ++++++++++++++++++ .../userProfile/updateUserProfiles.saga.ts | 40 ++++++- 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts index 75a09bffed..57ad12a45e 100644 --- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts @@ -11,6 +11,7 @@ import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' import { getBaseTypesFactory } from '../../../utils/tests/factories' import { combineReducers } from 'redux' import { userProfileSelectors } from './userProfile.selectors' +import { identitySelectors } from '../../identity/identity.selectors' describe('updateUserProfilesSaga', () => { let store: Store @@ -230,4 +231,112 @@ describe('updateUserProfilesSaga', () => { }) .run() }) + + it('should migrate cached current user profile to storage when it is missing from stored profiles', async () => { + socket.registerExpectedResponse(SocketActions.SET_USER_PROFILE, { success: true }) + + const cachedProfile: UserProfile = { + ...userProfile, + fileMetadata: { + name: 'profile-file', + ext: '.jpg', + path: '/tmp/profile-file.jpg', + cid: 'profile-file-cid', + message: { + id: 'profile-file-message-id', + channelId: 'profile-photo-channel', + }, + }, + profilePhoto: { + name: 'profile-photo', + ext: '.jpg', + path: '/tmp/profile-photo.jpg', + cid: 'profile-photo-cid', + message: { + id: 'profile-photo-message-id', + channelId: 'profile-photo-channel', + }, + }, + } + const existingProfiles = { + [userId]: cachedProfile, + } + const expectedMigratedProfile: UserProfile = { + ...cachedProfile, + fileMetadata: { + ...cachedProfile.fileMetadata!, + path: null, + }, + profilePhoto: { + ...cachedProfile.profilePhoto!, + path: null, + }, + } + + await expectSaga(updateUserProfilesSaga, socket as unknown as Socket, usersActions.updateUserProfiles([])) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .provide([ + { + select: ({ selector }: any, next: any) => { + if (selector === userProfileSelectors.userProfiles) { + return existingProfiles + } + if (selector === identitySelectors.currentIdentity) { + return { userId } + } + return next() + }, + }, + ]) + .call.like({ + context: socket, + fn: socket.emitWithAck, + args: [ + SocketActions.SET_USER_PROFILE, + { + profile: expectedMigratedProfile, + }, + ], + }) + .put.like({ + action: { + type: usersActions.setUserProfiles.type, + payload: [cachedProfile], + }, + }) + .run() + }) + + it('should not migrate cached current user profile when it is already stored', async () => { + const existingProfiles = { + [userId]: userProfile, + } + + await expectSaga( + updateUserProfilesSaga, + socket as unknown as Socket, + usersActions.updateUserProfiles([userProfile]) + ) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .provide([ + { + select: ({ selector }: any, next: any) => { + if (selector === userProfileSelectors.userProfiles) { + return existingProfiles + } + if (selector === identitySelectors.currentIdentity) { + return { userId } + } + return next() + }, + }, + ]) + .not.call.like({ + context: socket, + fn: socket.emitWithAck, + }) + .run() + }) }) diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts index a4881df090..26416de0c3 100644 --- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts @@ -1,21 +1,57 @@ import { PayloadAction } from '@reduxjs/toolkit' import { createLogger } from '../../../utils/logger' -import { apply, call, put, select } from 'typed-redux-saga' +import { apply, put, select } from 'typed-redux-saga' import { userProfileSelectors } from './userProfile.selectors' -import { SocketActions, SocketEvents, SocketEventsMap, UserProfile, UserProfilesUpdatedPayload } from '@quiet/types' +import { SocketActions, UserProfile, UserProfilesUpdatedPayload } from '@quiet/types' import { applyEmitParams, Socket } from '../../../types' import { usersActions } from '../users.slice' +import { identitySelectors } from '../../identity/identity.selectors' const logger = createLogger('updateUserProfilesSaga') export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction): Generator { logger.info(`Updating user profiles (profile count = ${action.payload.length})`) const existingProfiles = yield* select(userProfileSelectors.userProfiles) + const currentIdentity = yield* select(identitySelectors.currentIdentity) const output: UserProfilesUpdatedPayload = { new: [], updates: [], } const updates = { ...existingProfiles } + const cachedCurrentUserProfile = + currentIdentity?.userId != null ? (existingProfiles[currentIdentity.userId] as UserProfile | undefined) : undefined + const currentUserProfileIsStored = + cachedCurrentUserProfile != null && + action.payload.some(profile => profile.userId === cachedCurrentUserProfile.userId) + + if (cachedCurrentUserProfile != null && !currentUserProfileIsStored) { + logger.info('Migrating cached current user profile into storage', cachedCurrentUserProfile.userId) + const response = yield* apply( + socket, + socket.emitWithAck, + applyEmitParams(SocketActions.SET_USER_PROFILE, { + profile: { + ...cachedCurrentUserProfile, + fileMetadata: cachedCurrentUserProfile.fileMetadata + ? { + ...cachedCurrentUserProfile.fileMetadata, + path: null, + } + : undefined, + profilePhoto: cachedCurrentUserProfile.profilePhoto + ? { + ...cachedCurrentUserProfile.profilePhoto, + path: null, + } + : undefined, + }, + }) + ) + if (!response?.success) { + logger.warn('Failed to migrate cached current user profile into storage', response?.error) + } + } + for (const userProfile of action.payload) { if (existingProfiles[userProfile.userId]) { const existingProfile = existingProfiles[userProfile.userId] From 2c97a21c51344bc32cd983207dd6f5b9174bcb28 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 8 Jul 2026 20:16:54 -0400 Subject: [PATCH 03/10] Implement cached user profile request and response handling in socket and storage services; replace saga based migration with backend driven migration --- .../src/nest/storage/storage.module.ts | 11 ++- .../src/nest/storage/storage.service.spec.ts | 46 +++++++++- .../src/nest/storage/storage.service.ts | 66 +++++++++++++- .../startConnection/startConnection.saga.ts | 20 ++++ .../respondCachedUserProfile.saga.test.ts | 80 ++++++++++++++++ .../respondCachedUserProfile.saga.ts | 19 ++++ .../updateUserProfiles.saga.test.ts | 91 ++----------------- .../userProfile/updateUserProfiles.saga.ts | 35 ------- .../src/sagas/users/users.master.saga.ts | 2 + .../src/sagas/users/users.slice.ts | 7 ++ packages/types/src/socket.ts | 7 ++ packages/types/src/user.ts | 8 ++ 12 files changed, 272 insertions(+), 120 deletions(-) create mode 100644 packages/state-manager/src/sagas/users/userProfile/respondCachedUserProfile.saga.test.ts create mode 100644 packages/state-manager/src/sagas/users/userProfile/respondCachedUserProfile.saga.ts diff --git a/packages/backend/src/nest/storage/storage.module.ts b/packages/backend/src/nest/storage/storage.module.ts index 123582a081..e64ac9af67 100644 --- a/packages/backend/src/nest/storage/storage.module.ts +++ b/packages/backend/src/nest/storage/storage.module.ts @@ -6,9 +6,18 @@ import { IpfsModule } from '../ipfs/ipfs.module' import { SigChainModule } from '../auth/sigchain.service.module' import { OrbitDbModule } from './orbitDb/orbitdb.module' import { CommonModule } from '../common/common.module' +import { SocketModule } from '../socket/socket.module' @Module({ - imports: [CommonModule, LocalDbModule, IpfsModule, IpfsFileManagerModule, SigChainModule, OrbitDbModule], + imports: [ + CommonModule, + LocalDbModule, + IpfsModule, + IpfsFileManagerModule, + SigChainModule, + OrbitDbModule, + SocketModule, + ], providers: [StorageService], exports: [StorageService], }) diff --git a/packages/backend/src/nest/storage/storage.service.spec.ts b/packages/backend/src/nest/storage/storage.service.spec.ts index 4db9efaee2..b5201deeaf 100644 --- a/packages/backend/src/nest/storage/storage.service.spec.ts +++ b/packages/backend/src/nest/storage/storage.service.spec.ts @@ -2,7 +2,7 @@ import { jest } from '@jest/globals' import { Test, TestingModule } from '@nestjs/testing' import { prepareStore, getReduxStoreFactory, publicChannels, Store } from '@quiet/state-manager' -import { Community, Identity, NetworkStats, PublicChannel, UserProfile } from '@quiet/types' +import { Community, Identity, NetworkStats, PublicChannel, SocketEvents, UserProfile } from '@quiet/types' import path from 'path' import { TestModule } from '../common/test.module' @@ -223,6 +223,50 @@ describe('StorageService', () => { await expect(storageService.addUserProfile(profile)).resolves.not.toThrow() }) + it('should request and store cached self user profile when it is missing', async () => { + const userId = sigchainService.user.userId + const profile = { userId, nickname: 'Alice' } as UserProfile + const emit = jest.fn( + (_event: unknown, _payload: unknown, callback: (err: Error | null, responses: unknown[]) => void) => + callback(null, [{ profile }]) + ) + const timeoutSpy = jest + .spyOn(storageService.socketService.serverIoProvider.io as any, 'timeout') + .mockReturnValue({ emit }) + const addUserProfileSpy = jest.spyOn(storageService, 'addUserProfile').mockResolvedValue({ success: true }) + jest.spyOn(userProfileStore, 'getUserProfiles').mockResolvedValue([]) + + storageService.socketService.serverIoProvider.io.sockets.sockets.set('state-manager', {} as any) + + await (storageService as any).migrateMissingSelfUserProfile() + + expect(timeoutSpy).toHaveBeenCalledWith(5_000) + expect(emit).toHaveBeenCalledWith(SocketEvents.CACHED_USER_PROFILE_REQUEST, { userId }, expect.any(Function)) + expect(addUserProfileSpy).toHaveBeenCalledWith(profile) + }) + + it('should skip cached self user profile migration when self is already stored', async () => { + const userId = sigchainService.user.userId + const profile = { userId, nickname: 'Alice' } as UserProfile + const timeoutSpy = jest.spyOn(storageService.socketService.serverIoProvider.io as any, 'timeout') + jest.spyOn(userProfileStore, 'getUserProfiles').mockResolvedValue([profile]) + + storageService.socketService.serverIoProvider.io.sockets.sockets.set('state-manager', {} as any) + + await (storageService as any).migrateMissingSelfUserProfile() + + expect(timeoutSpy).not.toHaveBeenCalled() + }) + + it('should skip cached self user profile migration when no state-manager client is connected', async () => { + const timeoutSpy = jest.spyOn(storageService.socketService.serverIoProvider.io as any, 'timeout') + jest.spyOn(userProfileStore, 'getUserProfiles').mockResolvedValue([]) + + await (storageService as any).migrateMissingSelfUserProfile() + + expect(timeoutSpy).not.toHaveBeenCalled() + }) + it('should set and get identity via localDbService', async () => { await storageService.init() const identity = { userId: sigchainService.user.userId, nickname: 'Alice' } as unknown as Identity diff --git a/packages/backend/src/nest/storage/storage.service.ts b/packages/backend/src/nest/storage/storage.service.ts index 71890ad7cc..87ecb88841 100644 --- a/packages/backend/src/nest/storage/storage.service.ts +++ b/packages/backend/src/nest/storage/storage.service.ts @@ -4,6 +4,7 @@ import { type PeerId } from '@libp2p/interface' import { ConnectionProcessInfo, SocketEvents, + type CachedUserProfileResponse, type UserProfile, type UserProfilesStoredEvent, type Identity, @@ -28,6 +29,9 @@ import { DateTime } from 'luxon' import { createLibp2pAddress } from '@quiet/common' import { existsSync, readdirSync, rmSync } from 'fs' import path from 'path' +import { SocketService } from '../socket/socket.service' + +const CACHED_USER_PROFILE_REQUEST_TIMEOUT_MS = 5_000 @Injectable() export class StorageService extends EventEmitter { @@ -47,7 +51,8 @@ export class StorageService extends EventEmitter { public readonly userProfileStore: UserProfileStore, public readonly notificationTokensStore: NotificationTokensStore, public readonly channelsService: ChannelsService, - public readonly sigchainService: SigChainService + public readonly sigchainService: SigChainService, + public readonly socketService: SocketService ) { super() } @@ -101,6 +106,7 @@ export class StorageService extends EventEmitter { this.logger.info(`Initializing Databases`) await this.initDatabases() + await this.migrateMissingSelfUserProfile() if (teamId != null) { this.addTeamIdToDbMetas(teamId) @@ -117,6 +123,64 @@ export class StorageService extends EventEmitter { this.emit(StorageEvents.INITIALIZED) } + private async migrateMissingSelfUserProfile(): Promise { + const activeChain = this.sigchainService.getActiveChain(false) + if (!activeChain?.team || !activeChain.roles.amIMember()) { + this.logger.trace('Skipping cached self user profile migration; active user is not a team member') + return + } + + const selfUserId = activeChain.user.userId + const storedProfiles = await this.userProfileStore.getUserProfiles() + if (storedProfiles.some(profile => profile.userId === selfUserId)) { + this.logger.trace('Skipping cached self user profile migration; profile already exists in store', selfUserId) + return + } + if (this.socketService.serverIoProvider.io.sockets.sockets.size === 0) { + this.logger.trace('Skipping cached self user profile migration; no connected state-manager clients') + return + } + + const cachedProfile = await this.requestCachedSelfUserProfile(selfUserId) + if (!cachedProfile) { + this.logger.info('No cached self user profile returned by state-manager', selfUserId) + return + } + if (cachedProfile.userId !== selfUserId) { + this.logger.warn('Cached self user profile userId mismatch', { + expected: selfUserId, + received: cachedProfile.userId, + }) + return + } + + const response = await this.addUserProfile(cachedProfile) + if (!response.success) { + this.logger.warn('Failed to migrate cached self user profile', selfUserId, response.error) + } + } + + private async requestCachedSelfUserProfile(userId: string): Promise { + this.logger.info('Requesting cached self user profile from state-manager', userId) + return new Promise(resolve => { + this.socketService.serverIoProvider.io + .timeout(CACHED_USER_PROFILE_REQUEST_TIMEOUT_MS) + .emit( + SocketEvents.CACHED_USER_PROFILE_REQUEST, + { userId }, + (err: Error | null, responses: CachedUserProfileResponse[] = []) => { + if (err) { + this.logger.warn('Timed out requesting cached self user profile from state-manager', userId, err) + resolve(undefined) + return + } + + resolve(responses.find(response => response?.profile)?.profile) + } + ) + }) + } + public async clean() { try { await this.orbitDbService.stopSync() diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts index 3ea54824bb..43d96ea5b2 100644 --- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts +++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts @@ -31,6 +31,8 @@ import { type RemoveDownloadStatus, type ChannelSubscribedPayload, type UserProfilesStoredEvent, + type CachedUserProfileRequest, + type CachedUserProfileResponse, type UsersUpdatedEvent, SocketEvents, LaunchCommunityPayload, @@ -93,6 +95,7 @@ export function subscribe(socket: Socket) { | ReturnType | ReturnType | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -210,6 +213,22 @@ export function subscribe(socket: Socket) { emit(usersActions.updateUserProfiles(payload.profiles)) emit(messagesActions.retryVerification({ currentChannel: true })) }) + socket.on( + SocketEvents.CACHED_USER_PROFILE_REQUEST, + (payload: CachedUserProfileRequest, callback?: (response: CachedUserProfileResponse) => void) => { + logger.info(`${SocketEvents.CACHED_USER_PROFILE_REQUEST}`, payload.userId) + if (!callback) { + logger.warn(`${SocketEvents.CACHED_USER_PROFILE_REQUEST} missing response callback`, payload.userId) + return + } + emit( + usersActions.cachedUserProfileRequested({ + userId: payload.userId, + callback, + }) + ) + } + ) socket.on(SocketEvents.HCAPTCHA_CHALLENGE_REQUEST, (payload: HCaptchaChallengeRequest) => { logger.info(`${SocketEvents.HCAPTCHA_CHALLENGE_REQUEST}`, JSON.stringify(payload)) @@ -246,6 +265,7 @@ export function subscribe(socket: Socket) { socket.off(SocketEvents.USERS_UPDATED) socket.off(SocketEvents.USERS_REMOVED) socket.off(SocketEvents.USER_PROFILES_STORED) + socket.off(SocketEvents.CACHED_USER_PROFILE_REQUEST) socket.off(SocketEvents.HCAPTCHA_CHALLENGE_REQUEST) socket.off(SocketEvents.HCAPTCHA_SITE_KEY) socket.off(SocketEvents.HCAPTCHA_VERIFICATION_UPDATE) diff --git a/packages/state-manager/src/sagas/users/userProfile/respondCachedUserProfile.saga.test.ts b/packages/state-manager/src/sagas/users/userProfile/respondCachedUserProfile.saga.test.ts new file mode 100644 index 0000000000..53456233bd --- /dev/null +++ b/packages/state-manager/src/sagas/users/userProfile/respondCachedUserProfile.saga.test.ts @@ -0,0 +1,80 @@ +import { expectSaga } from 'redux-saga-test-plan' +import { type FactoryGirl } from 'factory-girl' +import { UserProfile } from '@quiet/types' +import { combineReducers } from 'redux' + +import { usersActions } from '../users.slice' +import { respondCachedUserProfileSaga } from './respondCachedUserProfile.saga' +import { Store } from '../../store.types' +import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' +import { getBaseTypesFactory } from '../../../utils/tests/factories' +import { userProfileSelectors } from './userProfile.selectors' + +describe('respondCachedUserProfileSaga', () => { + let store: Store + let baseTypesFactory: FactoryGirl + let userProfile: UserProfile + let userId: string + + beforeEach(async () => { + store = prepareStore().store + baseTypesFactory = await getBaseTypesFactory() + + userProfile = await baseTypesFactory.create('UserProfile') + userId = userProfile.userId + }) + + it('should return the cached profile for the requested user', async () => { + const callback = jest.fn() + + await expectSaga( + respondCachedUserProfileSaga, + usersActions.cachedUserProfileRequested({ + userId, + callback, + }) + ) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .provide([ + { + select: ({ selector }: any, next: any) => { + if (selector === userProfileSelectors.userProfiles) { + return { [userId]: userProfile } + } + return next() + }, + }, + ]) + .run() + + expect(callback).toHaveBeenCalledWith({ profile: userProfile }) + }) + + it('should return an empty response when no cached profile exists', async () => { + const callback = jest.fn() + + await expectSaga( + respondCachedUserProfileSaga, + usersActions.cachedUserProfileRequested({ + userId, + callback, + }) + ) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .provide([ + { + select: ({ selector }: any, next: any) => { + if (selector === userProfileSelectors.userProfiles) { + return {} + } + return next() + }, + }, + ]) + .run() + + expect(callback).toHaveBeenCalledWith({}) + }) +}) diff --git a/packages/state-manager/src/sagas/users/userProfile/respondCachedUserProfile.saga.ts b/packages/state-manager/src/sagas/users/userProfile/respondCachedUserProfile.saga.ts new file mode 100644 index 0000000000..c175ef6ee3 --- /dev/null +++ b/packages/state-manager/src/sagas/users/userProfile/respondCachedUserProfile.saga.ts @@ -0,0 +1,19 @@ +import { type PayloadAction } from '@reduxjs/toolkit' +import { call, select } from 'typed-redux-saga' +import { type CachedUserProfileResponse } from '@quiet/types' +import { type CachedUserProfileRequestedActionPayload } from '../users.slice' +import { userProfileSelectors } from './userProfile.selectors' +import { createLogger } from '../../../utils/logger' + +const logger = createLogger('respondCachedUserProfileSaga') + +export function* respondCachedUserProfileSaga( + action: PayloadAction +): Generator { + const profiles = yield* select(userProfileSelectors.userProfiles) + const profile = profiles[action.payload.userId] + const response: CachedUserProfileResponse = profile ? { profile } : {} + + logger.info('Responding to cached user profile request', action.payload.userId, Boolean(profile)) + yield* call(action.payload.callback, response) +} diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts index 57ad12a45e..e88aa2bd89 100644 --- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts @@ -11,7 +11,6 @@ import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' import { getBaseTypesFactory } from '../../../utils/tests/factories' import { combineReducers } from 'redux' import { userProfileSelectors } from './userProfile.selectors' -import { identitySelectors } from '../../identity/identity.selectors' describe('updateUserProfilesSaga', () => { let store: Store @@ -232,91 +231,16 @@ describe('updateUserProfilesSaga', () => { .run() }) - it('should migrate cached current user profile to storage when it is missing from stored profiles', async () => { - socket.registerExpectedResponse(SocketActions.SET_USER_PROFILE, { success: true }) - - const cachedProfile: UserProfile = { - ...userProfile, - fileMetadata: { - name: 'profile-file', - ext: '.jpg', - path: '/tmp/profile-file.jpg', - cid: 'profile-file-cid', - message: { - id: 'profile-file-message-id', - channelId: 'profile-photo-channel', - }, - }, - profilePhoto: { - name: 'profile-photo', - ext: '.jpg', - path: '/tmp/profile-photo.jpg', - cid: 'profile-photo-cid', - message: { - id: 'profile-photo-message-id', - channelId: 'profile-photo-channel', - }, - }, - } - const existingProfiles = { - [userId]: cachedProfile, - } - const expectedMigratedProfile: UserProfile = { - ...cachedProfile, - fileMetadata: { - ...cachedProfile.fileMetadata!, - path: null, - }, - profilePhoto: { - ...cachedProfile.profilePhoto!, - path: null, - }, - } - - await expectSaga(updateUserProfilesSaga, socket as unknown as Socket, usersActions.updateUserProfiles([])) - .withReducer(combineReducers(testReducers)) - .withState(store.getState()) - .provide([ - { - select: ({ selector }: any, next: any) => { - if (selector === userProfileSelectors.userProfiles) { - return existingProfiles - } - if (selector === identitySelectors.currentIdentity) { - return { userId } - } - return next() - }, - }, - ]) - .call.like({ - context: socket, - fn: socket.emitWithAck, - args: [ - SocketActions.SET_USER_PROFILE, - { - profile: expectedMigratedProfile, - }, - ], - }) - .put.like({ - action: { - type: usersActions.setUserProfiles.type, - payload: [cachedProfile], - }, - }) - .run() - }) - - it('should not migrate cached current user profile when it is already stored', async () => { + it('should not write cached profiles to storage when a partial update does not include them', async () => { const existingProfiles = { [userId]: userProfile, } + const remoteProfile = await baseTypesFactory.create('UserProfile') await expectSaga( updateUserProfilesSaga, socket as unknown as Socket, - usersActions.updateUserProfiles([userProfile]) + usersActions.updateUserProfiles([remoteProfile]) ) .withReducer(combineReducers(testReducers)) .withState(store.getState()) @@ -326,9 +250,6 @@ describe('updateUserProfilesSaga', () => { if (selector === userProfileSelectors.userProfiles) { return existingProfiles } - if (selector === identitySelectors.currentIdentity) { - return { userId } - } return next() }, }, @@ -337,6 +258,12 @@ describe('updateUserProfilesSaga', () => { context: socket, fn: socket.emitWithAck, }) + .put.like({ + action: { + type: usersActions.setUserProfiles.type, + payload: [userProfile, remoteProfile], + }, + }) .run() }) }) diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts index 26416de0c3..c6b3575431 100644 --- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts @@ -5,52 +5,17 @@ import { userProfileSelectors } from './userProfile.selectors' import { SocketActions, UserProfile, UserProfilesUpdatedPayload } from '@quiet/types' import { applyEmitParams, Socket } from '../../../types' import { usersActions } from '../users.slice' -import { identitySelectors } from '../../identity/identity.selectors' const logger = createLogger('updateUserProfilesSaga') export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction): Generator { logger.info(`Updating user profiles (profile count = ${action.payload.length})`) const existingProfiles = yield* select(userProfileSelectors.userProfiles) - const currentIdentity = yield* select(identitySelectors.currentIdentity) const output: UserProfilesUpdatedPayload = { new: [], updates: [], } const updates = { ...existingProfiles } - const cachedCurrentUserProfile = - currentIdentity?.userId != null ? (existingProfiles[currentIdentity.userId] as UserProfile | undefined) : undefined - const currentUserProfileIsStored = - cachedCurrentUserProfile != null && - action.payload.some(profile => profile.userId === cachedCurrentUserProfile.userId) - - if (cachedCurrentUserProfile != null && !currentUserProfileIsStored) { - logger.info('Migrating cached current user profile into storage', cachedCurrentUserProfile.userId) - const response = yield* apply( - socket, - socket.emitWithAck, - applyEmitParams(SocketActions.SET_USER_PROFILE, { - profile: { - ...cachedCurrentUserProfile, - fileMetadata: cachedCurrentUserProfile.fileMetadata - ? { - ...cachedCurrentUserProfile.fileMetadata, - path: null, - } - : undefined, - profilePhoto: cachedCurrentUserProfile.profilePhoto - ? { - ...cachedCurrentUserProfile.profilePhoto, - path: null, - } - : undefined, - }, - }) - ) - if (!response?.success) { - logger.warn('Failed to migrate cached current user profile into storage', response?.error) - } - } for (const userProfile of action.payload) { if (existingProfiles[userProfile.userId]) { diff --git a/packages/state-manager/src/sagas/users/users.master.saga.ts b/packages/state-manager/src/sagas/users/users.master.saga.ts index cbbe6e0639..2335a0daa1 100644 --- a/packages/state-manager/src/sagas/users/users.master.saga.ts +++ b/packages/state-manager/src/sagas/users/users.master.saga.ts @@ -6,6 +6,7 @@ import { saveUserProfileSaga } from './userProfile/saveUserProfile.saga' import { downloadProfilePhotosSaga } from './userProfile/downloadProfilePhotos.saga' import { createLogger } from '../../utils/logger' import { updateUserProfilesSaga } from './userProfile/updateUserProfiles.saga' +import { respondCachedUserProfileSaga } from './userProfile/respondCachedUserProfile.saga' const logger = createLogger('usersMasterSaga') @@ -16,6 +17,7 @@ export function* usersMasterSaga(socket: Socket): Generator { takeEvery(usersActions.saveUserProfile.type, saveUserProfileSaga, socket), takeEvery(usersActions.updateUserProfiles.type, updateUserProfilesSaga, socket), takeEvery(usersActions.updateUserProfiles.type, downloadProfilePhotosSaga), + takeEvery(usersActions.cachedUserProfileRequested.type, respondCachedUserProfileSaga), ]) } finally { logger.info('usersMasterSaga stopping') diff --git a/packages/state-manager/src/sagas/users/users.slice.ts b/packages/state-manager/src/sagas/users/users.slice.ts index ce8432015f..e16f66eeae 100644 --- a/packages/state-manager/src/sagas/users/users.slice.ts +++ b/packages/state-manager/src/sagas/users/users.slice.ts @@ -6,11 +6,17 @@ import { SaveUserProfileActionPayload, DeleteUserProfileActionPayload, FileMetadata, + CachedUserProfileRequest, + CachedUserProfileResponse, } from '@quiet/types' import { createLogger } from '../../utils/logger' const logger = createLogger('usersSlice') +export interface CachedUserProfileRequestedActionPayload extends CachedUserProfileRequest { + callback: (response: CachedUserProfileResponse) => void +} + export class UsersState { // Mapping of userId to UserProfile (for display data) public userProfiles: Record = {} @@ -38,6 +44,7 @@ export const usersSlice = createSlice({ return state }, updateUserProfiles: (state, _action: PayloadAction) => state, + cachedUserProfileRequested: (state, _action: PayloadAction) => state, // Sets a single user profile, overwriting the existing one setUserProfile: (state, action: PayloadAction) => { // Creating user profiles object for backwards compatibility with 2.0.1 diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index dd60b08e6a..ee49c687da 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -5,6 +5,8 @@ import { UserProfilesUpdatedPayload, UsersRemovedEvent, UsersUpdatedEvent, + CachedUserProfileRequest, + CachedUserProfileResponse, } from './user' import { type DeleteChannelPayload, @@ -140,6 +142,7 @@ export enum SocketEvents { USERS_UPDATED = 'usersUpdated', USERS_REMOVED = 'usersRemoved', USER_PROFILES_STORED = 'userProfilesStored', + CACHED_USER_PROFILE_REQUEST = 'cachedUserProfileRequest', KEYS_UPDATED = 'keysUpdated', DEVICE_CREDENTIALS_UPDATED = 'deviceCredentialsUpdated', USER_PROFILES_UPDATED = 'userProfilesUpdatedFwd', @@ -261,6 +264,10 @@ export interface SocketEventsMap { [SocketEvents.USERS_UPDATED]: EmitEvent [SocketEvents.USERS_REMOVED]: EmitEvent [SocketEvents.USER_PROFILES_STORED]: EmitEvent + [SocketEvents.CACHED_USER_PROFILE_REQUEST]: EmitEvent< + CachedUserProfileRequest, + (response?: CachedUserProfileResponse) => void + > [SocketEvents.KEYS_UPDATED]: EmitEvent [SocketEvents.DEVICE_CREDENTIALS_UPDATED]: EmitEvent [SocketEvents.USER_PROFILES_UPDATED]: EmitEvent diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 41aba5c058..bfc0b58ced 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -61,6 +61,14 @@ export interface UserProfilesStoredEvent { profiles: UserProfile[] } +export interface CachedUserProfileRequest { + userId: string +} + +export interface CachedUserProfileResponse { + profile?: UserProfile +} + export interface UserProfilesUpdatedPayload { new: UserProfile[] updates: UserProfile[] From 3c7df93d12d137e44865a75726dee633ad70b1da Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 9 Jul 2026 13:35:55 -0400 Subject: [PATCH 04/10] update submodule pointer --- 3rd-party/orbitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3rd-party/orbitdb b/3rd-party/orbitdb index dad5fe72fc..6f77812015 160000 --- a/3rd-party/orbitdb +++ b/3rd-party/orbitdb @@ -1 +1 @@ -Subproject commit dad5fe72fc4eefb880d236b3989bfb9e2fc69de8 +Subproject commit 6f778120152d6d6cdd4a432086e9ee7a080a2b9d From 2234afb32a70c08fca79236a4afee59c378404a7 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 9 Jul 2026 13:44:31 -0400 Subject: [PATCH 05/10] update userprofile sync test; Refactor IPFSBlockStorage to ensure timer cleanup after block retrieval --- .../userProfile-sync.spec.ts | 143 ++++++++++++------ .../nest/storage/orbitDb/ipfsBlockStorage.ts | 8 +- 2 files changed, 102 insertions(+), 49 deletions(-) diff --git a/packages/backend/src/nest/libp2p/integration-tests/userProfile-sync.spec.ts b/packages/backend/src/nest/libp2p/integration-tests/userProfile-sync.spec.ts index da5d80869f..3acebf6b69 100644 --- a/packages/backend/src/nest/libp2p/integration-tests/userProfile-sync.spec.ts +++ b/packages/backend/src/nest/libp2p/integration-tests/userProfile-sync.spec.ts @@ -1,7 +1,8 @@ import { jest } from '@jest/globals' import { headsAreEqual, Hash } from '@localfirst/crdx' -import { Test, TestingModule } from '@nestjs/testing' +import { TestingModule } from '@nestjs/testing' import waitForExpect from 'wait-for-expect' +import { Entry, type LogEntry } from '@orbitdb/core' import { getBaseTypesFactory } from '@quiet/state-manager' import { FactoryGirl } from 'factory-girl' @@ -15,6 +16,7 @@ import { spawnTestModules, spawnLibp2pInstancesInMemory } from '../../common/tes import { UserProfile } from '@quiet/types' import { createLogger } from '../../common/logger' import { Libp2pEvents } from '../libp2p.types' +import { EncryptedAndSignedPayload } from '../../auth/services/crypto/types' const logger = createLogger('UserProfile-sync') const N_PEERS = 3 @@ -38,6 +40,25 @@ describe('UserProfileStore OrbitDB Sync', () => { await userProfileStores[i].init() } + const getLogEntries = async (store: UserProfileStore): Promise>> => { + const entries: Array> = [] + for await (const entry of store.getStore().log.traverse()) { + entries.push(entry as LogEntry) + } + return entries + } + + const expectAliceAndBobProfiles = async (aliceNickname: string, bobNickname: string) => { + const aliceProfiles = await userProfileStores[0].getUserProfiles() + const bobProfiles = await userProfileStores[1].getUserProfiles() + expect(aliceProfiles.length).toBe(2) + expect(bobProfiles.length).toBe(2) + expect(aliceProfiles.find(p => p.userId === userIds[0])?.nickname).toBe(aliceNickname) + expect(aliceProfiles.find(p => p.userId === userIds[1])?.nickname).toBe(bobNickname) + expect(bobProfiles.find(p => p.userId === userIds[0])?.nickname).toBe(aliceNickname) + expect(bobProfiles.find(p => p.userId === userIds[1])?.nickname).toBe(bobNickname) + } + beforeAll(async () => { factory = await getBaseTypesFactory() modules = await spawnTestModules(N_PEERS) @@ -84,11 +105,15 @@ describe('UserProfileStore OrbitDB Sync', () => { afterAll(async () => { for (let i = 0; i < N_PEERS; i++) { - await userProfileStores[i].close() - await orbitDbServices[i].stop() - await ipfsServices[i].stop() - await libp2pServices[i].close() - await localDbServices[i].close() + await userProfileStores[i]?.close() + await orbitDbServices[i]?.stop() + await libp2pServices[i]?.close(false) + await ipfsServices[i]?.stop() + await libp2pServices[i]?.closeDatastore() + await localDbServices[i]?.close() + } + for (const module of modules) { + await module.close() } }) @@ -175,42 +200,78 @@ describe('UserProfileStore OrbitDB Sync', () => { ) }) - it("peer cannot update the other peer's userProfile", async () => { - // Provide a valid base64 photo - // Bob tries to update Alice's profile - await userProfileStores[1].setEntry(userIds[0], bobProfile) + it("rejects adversarial attempts to overwrite another peer's userProfile", async () => { + const aliceStore = userProfileStores[0].getStore() + const bobStore = userProfileStores[1].getStore() + const aliceEncryptedProfile = (await userProfileStores[0].getEncryptedEntries([userIds[0]]))[userIds[0]] + if (aliceEncryptedProfile == null) { + throw new Error("Alice's encrypted profile was not stored before adversarial overwrite attempts") + } - await waitForExpect( - async () => { - // expect that both Alice and Bob have the maliciousProfile in their log (this is a bug) - const aliceAllEntries: any[] = [] - for await (const entry of userProfileStores[0].getStore().log.traverse()) { - aliceAllEntries.push(entry) - } - const bobAllEntries: any[] = [] - for await (const entry of userProfileStores[1].getStore().log.traverse()) { - bobAllEntries.push(entry) - } - // TODO: after implementating the access control and identities, this should be 2 - expect(aliceAllEntries.length).toBe(3) - expect(bobAllEntries.length).toBe(3) + // Bob tries to write his own signed profile under Alice's key via the store API. + await userProfileStores[1].setEntry(userIds[0], { + ...bobProfile, + nickname: 'Bob as Alice', + }) + + // Bob signs profile contents that claim Alice's userId and tries to store them under Alice's key. + const bobSignedAliceProfile = await userProfileStores[1].encryptEntry({ + ...aliceProfile, + userId: userIds[0], + nickname: 'Alice overwritten by Bob', + }) + await bobStore.put(userIds[0], bobSignedAliceProfile) + + // Bob replays Alice's legitimate encrypted payload from his own OrbitDB writer identity. + await bobStore.put(userIds[0], aliceEncryptedProfile) + + // Bob serves a spoofed raw OrbitDB entry to Alice over the OrbitDB sync protocol. + const currentBobHeadHashes = (await bobStore.log.heads()).map( + (entry: LogEntry) => entry.hash + ) + const spoofedSyncEntry = await Entry.create( + bobStore.identity, + bobStore.log.id, + { + op: 'PUT', + key: userIds[0], + value: aliceEncryptedProfile, }, - 5000, - 100 + undefined, + currentBobHeadHashes ) + const aliceSyncErrors: Error[] = [] + const onAliceSyncError = (error: Error) => { + aliceSyncErrors.push(error) + } + aliceStore.events.on('error', onAliceSyncError) + try { + await bobStore.sync.add(spoofedSyncEntry) + await waitForExpect( + async () => { + expect( + aliceSyncErrors.some(error => { + const isAccessDenied = error.message.includes('Could not append entry') + const isSpoofedWriter = error.message.includes(spoofedSyncEntry.identity) + return isAccessDenied && isSpoofedWriter + }) + ).toBe(true) + }, + 5000, + 100 + ) + } finally { + aliceStore.events.off('error', onAliceSyncError) + } - // Alice's profile remains unchanged in the index await waitForExpect( async () => { - const aliceProfiles = await userProfileStores[0].getUserProfiles() - const bobProfiles = await userProfileStores[1].getUserProfiles() - expect(aliceProfiles.length).toBe(2) - expect(bobProfiles.length).toBe(2) - expect(aliceProfiles.find(p => p.userId === userIds[1])?.nickname).toBe('Bob') - expect(bobProfiles.find(p => p.userId === userIds[0])?.nickname).toBe('Alice') + expect(await getLogEntries(userProfileStores[0])).toHaveLength(2) + expect(await getLogEntries(userProfileStores[1])).toHaveLength(2) + await expectAliceAndBobProfiles('Alice', 'Bob') }, 5000, - 1000 + 100 ) }) @@ -224,18 +285,8 @@ describe('UserProfileStore OrbitDB Sync', () => { // Wait for sync await waitForExpect( async () => { - // expect that both Alice and Bob have the maliciousProfile in their log (this is a bug) - const aliceAllEntries: any[] = [] - for await (const entry of userProfileStores[0].getStore().log.traverse()) { - aliceAllEntries.push(entry) - } - const bobAllEntries: any[] = [] - for await (const entry of userProfileStores[1].getStore().log.traverse()) { - bobAllEntries.push(entry) - } - // TODO: after implementating the access control and identities, this should be 2 - expect(aliceAllEntries.length).toBe(5) // 2 updates + 2 initial profiles + 1 malicious profile - expect(bobAllEntries.length).toBe(5) + expect(await getLogEntries(userProfileStores[0])).toHaveLength(4) + expect(await getLogEntries(userProfileStores[1])).toHaveLength(4) }, 5000, 100 diff --git a/packages/backend/src/nest/storage/orbitDb/ipfsBlockStorage.ts b/packages/backend/src/nest/storage/orbitDb/ipfsBlockStorage.ts index bd23826f56..f07c044ce9 100644 --- a/packages/backend/src/nest/storage/orbitDb/ipfsBlockStorage.ts +++ b/packages/backend/src/nest/storage/orbitDb/ipfsBlockStorage.ts @@ -75,9 +75,11 @@ const IPFSBlockStorage = async ({ ipfs, pin, timeout }: IPFSBlockStorageParams = } else { const abortController = new AbortController() const timer = setTimeout(() => abortController.abort(), timeout ?? DefaultTimeout) - const block = await ipfs.blockstore.get(cid, abortController) - clearTimeout(timer) - return block + try { + return await ipfs.blockstore.get(cid, abortController) + } finally { + clearTimeout(timer) + } } } From ca69046504ff62b15611e29ec23b04f741225e57 Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 14 Jul 2026 11:26:22 -0400 Subject: [PATCH 06/10] Implement access control for user profile deletion; allow users to delete their own profiles and admins to delete any profile --- .../UserProfileAccessController.ts | 21 ++++++++++++++++++ .../UserProfileAccessController.unit.spec.ts | 22 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.ts b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.ts index fc038175f8..3b955a5c0d 100644 --- a/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.ts +++ b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.ts @@ -157,10 +157,31 @@ export class UserProfileAccessController { return false } + if (entry.payload.op === 'DEL') { + return this.canAppendDeleteEntry(entry, writerIdentity.id) + } + return true } } + /** + * Profile PUT entries are bound to their key and writer, so a non-admin can + * delete only the profile they created (their own profile key). + */ + private canAppendDeleteEntry(entry: LogEntry, writerId: string): boolean { + const key = entry.payload.key + if (key == null || key !== writerId) { + this.logger.warn(`User profile DELETE rejected because writer does not own the profile`, { + key, + writerId, + entryHash: entry.hash, + }) + return false + } + return true + } + private async canAppendPutEntry( entry: LogEntry, chain: SigChain, diff --git a/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.unit.spec.ts b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.unit.spec.ts index b328ee2897..0c302bf8c6 100644 --- a/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.unit.spec.ts +++ b/packages/backend/src/nest/storage/userProfile/UserProfileAccessController.unit.spec.ts @@ -99,11 +99,13 @@ const createEncryptedPayload = ({ }) as unknown as EncryptedAndSignedPayload const createEntry = ({ + op = 'PUT', key = 'writer-id', hash = `put-${key}`, value = createEncryptedPayload(), includeValue = true, }: { + op?: 'PUT' | 'DEL' key?: string hash?: string value?: EncryptedAndSignedPayload @@ -113,7 +115,7 @@ const createEntry = ({ hash, identity: 'writer-identity-hash', payload: { - op: 'PUT', + op, key, value: includeValue ? value : undefined, }, @@ -237,4 +239,22 @@ describe('UserProfileAccessController', () => { await expect(access.canAppend(createEntry({ includeValue: false }))).resolves.toBe(false) }) + + it('allows members to delete their own user profile', async () => { + const access = await createAccess(createSigchainService()) + + await expect(access.canAppend(createEntry({ op: 'DEL' }))).resolves.toBe(true) + }) + + it('rejects members deleting another user profile', async () => { + const access = await createAccess(createSigchainService()) + + await expect(access.canAppend(createEntry({ op: 'DEL', key: 'other-user-id' }))).resolves.toBe(false) + }) + + it('allows admins to delete another user profile', async () => { + const access = await createAccess(createSigchainService({ admin: true })) + + await expect(access.canAppend(createEntry({ op: 'DEL', key: 'other-user-id' }))).resolves.toBe(true) + }) }) From 78b5fc0f1209cfa8b08540258110655b2d6fc340 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 15 Jul 2026 14:00:20 -0400 Subject: [PATCH 07/10] Add tests for concurrent head ingestion in OrbitDbService and ensure deferred profile flushing in StorageService migration --- .../storage/orbitDb/orbitDb.service.spec.ts | 29 +++++++++++++++++++ .../nest/storage/orbitDb/orbitDb.service.ts | 25 ++++++++-------- .../src/nest/storage/storage.service.spec.ts | 18 ++++++++++++ .../src/nest/storage/storage.service.ts | 5 ++++ 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts index 1db0e719b0..902c178a96 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts @@ -253,4 +253,33 @@ describe('OrbitDbService', () => { await orbitDbService.create(ipfsService.ipfsInstance!) expect(OrbitDbService.events.listeners('update')).toContain(updateListener) }) + + it('ingests concurrent heads even when their Lamport clock times differ', async () => { + const firstHead = { + id: 'concurrent-log', + hash: 'zdpuAn4pdCWF7HEhYqaW45woXfTqWn4ccG24JBHbf3sDR1XHK', + bytes: new Uint8Array([1]), + next: [], + clock: { time: 1 }, + } as unknown as LogEntry + const ancestor = { + id: 'concurrent-log', + hash: 'zdpuAkTACgJnmr667GAafc5YQQYkYZHohonEhG2U9N9Q7WzmU', + bytes: new Uint8Array([2]), + next: [], + clock: { time: 2 }, + } as unknown as LogEntry + const secondHead = { + id: 'concurrent-log', + hash: 'zdpuB3HUfW7TszueRD7X5GsbRDp2Xk8hSfvyn5SoEhB1zNihi', + bytes: new Uint8Array([3]), + next: [ancestor.hash], + clock: { time: 3 }, + } as unknown as LogEntry + const joinHeadsSpy = jest.spyOn(orbitDbService as any, 'joinHeads').mockResolvedValue(undefined) + + await orbitDbService.ingestEntries([firstHead, ancestor, secondHead]) + + expect(joinHeadsSpy).toHaveBeenCalledWith('concurrent-log', [firstHead, secondHead]) + }) }) diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index 86a71ed14c..1b4a77f798 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -380,7 +380,7 @@ export class OrbitDbService { if (this.orbitDbInstance == undefined) { throw new Error('OrbitDB instance is not initialized. Call create() first.') } - const newHeads: Map = new Map() + const entriesByLog: Map = new Map() for (const entry of entries) { const cid = CID.parse(entry.hash, base58btc) await this.orbitDbInstance.ipfs.blockstore.put(cid, entry.bytes) @@ -392,20 +392,21 @@ export class OrbitDbService { } } - if (!newHeads.has(entry.id)) { - newHeads.set(entry.id, [entry]) - continue - } - const currentMaxHead = newHeads.get(entry.id)?.[0] - if (currentMaxHead?.clock && currentMaxHead.clock.time < entry.clock.time) { - newHeads.set(entry.id, [entry]) - } else if (currentMaxHead?.clock && currentMaxHead.clock.time === entry.clock.time) { - newHeads.get(entry.id)?.push(entry) - } + const logEntries = entriesByLog.get(entry.id) ?? [] + logEntries.push(entry) + entriesByLog.set(entry.id, logEntries) } + // Lamport clock order does not imply ancestry: concurrent branches can have + // different clock times. Keep every entry that is not referenced by another + // entry in this batch so independent heads are not dropped during QSS pulls. + const newHeads = Array.from(entriesByLog.entries()).map(([id, logEntries]) => { + const referencedHashes = new Set(logEntries.flatMap(entry => entry.next)) + return [id, logEntries.filter(entry => !referencedHashes.has(entry.hash))] as const + }) + // For each id, try to join heads (async, using joinQueue) - const joinAll = Array.from(newHeads.entries()).map(([id, heads]) => this.joinHeads(id, heads)) + const joinAll = newHeads.map(([id, heads]) => this.joinHeads(id, heads)) await Promise.all(joinAll) } diff --git a/packages/backend/src/nest/storage/storage.service.spec.ts b/packages/backend/src/nest/storage/storage.service.spec.ts index b5201deeaf..31be295e17 100644 --- a/packages/backend/src/nest/storage/storage.service.spec.ts +++ b/packages/backend/src/nest/storage/storage.service.spec.ts @@ -258,6 +258,24 @@ describe('StorageService', () => { expect(timeoutSpy).not.toHaveBeenCalled() }) + it('should flush a deferred self profile before requesting a cached migration', async () => { + const userId = sigchainService.user.userId + const profile = { userId, nickname: 'Alice' } as UserProfile + const storedProfiles: UserProfile[] = [] + const timeoutSpy = jest.spyOn(storageService.socketService.serverIoProvider.io as any, 'timeout') + jest.spyOn(userProfileStore, 'flushDeferredEntries').mockImplementation(async () => { + storedProfiles.push(profile) + }) + jest.spyOn(userProfileStore, 'getUserProfiles').mockImplementation(async () => storedProfiles) + + storageService.socketService.serverIoProvider.io.sockets.sockets.set('state-manager', {} as any) + + await (storageService as any).migrateMissingSelfUserProfile() + + expect(userProfileStore.flushDeferredEntries).toHaveBeenCalledTimes(1) + expect(timeoutSpy).not.toHaveBeenCalled() + }) + it('should skip cached self user profile migration when no state-manager client is connected', async () => { const timeoutSpy = jest.spyOn(storageService.socketService.serverIoProvider.io as any, 'timeout') jest.spyOn(userProfileStore, 'getUserProfiles').mockResolvedValue([]) diff --git a/packages/backend/src/nest/storage/storage.service.ts b/packages/backend/src/nest/storage/storage.service.ts index 87ecb88841..1f6d56d90a 100644 --- a/packages/backend/src/nest/storage/storage.service.ts +++ b/packages/backend/src/nest/storage/storage.service.ts @@ -130,6 +130,11 @@ export class StorageService extends EventEmitter { return } + // Fresh joins already queue the profile supplied by the join flow. Persist + // that profile first so the migration does not append a duplicate cached + // entry before startSync flushes the same deferred profile again. + await this.userProfileStore.flushDeferredEntries() + const selfUserId = activeChain.user.userId const storedProfiles = await this.userProfileStore.getUserProfiles() if (storedProfiles.some(profile => profile.userId === selfUserId)) { From 7ea778355ee9d772d4ed1845fffef3b2a096595d Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Jul 2026 00:36:22 -0400 Subject: [PATCH 08/10] remove detectOpenHandles from test scripts to avoid heap overload --- packages/backend/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index d95da929ca..e5e9c6bef9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,11 +22,11 @@ "lint": "npm run lint:no-fix -- --fix", "lint-ci": "npm run lint:no-fix", "lint-staged": "lint-staged --no-stash", - "test-nest": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" DEBUG=ipfs:*,backend:* node_modules/jest/bin/jest.js --detectOpenHandles --forceExit ./src/nest/**/*.spec.ts", - "test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" DEBUG=ipfs:*,backend:* jest --runInBand --verbose --testPathIgnorePatterns=\".src/(!?nodeTest*)|(.node_modules*)\" --detectOpenHandles --forceExit", - "test-ci": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" jest --runInBand --colors --ci --silent --verbose --detectOpenHandles --forceExit --testPathIgnorePatterns \"/node_modules/\" --testPathIgnorePatterns \"/dist/\" --testPathIgnorePatterns \"src/nest/.*\\.tor\\.spec\\.(t|j)s$\" --testPathIgnorePatterns \"src/nest/ipfs-file-manager/big-files\\.long\\.spec\\.ts$\"", - "test-ci-tor": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" jest --runInBand --colors --ci --verbose --detectOpenHandles --forceExit ./src/nest/**/*.tor.spec.ts", - "test-ci-long-running": "cross-env DEBUG=backend:* NODE_OPTIONS=\"--experimental-vm-modules\" jest --colors --ci --verbose --detectOpenHandles --forceExit ./src/nest/**/*.long.spec.ts", + "test-nest": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" DEBUG=ipfs:*,backend:* node_modules/jest/bin/jest.js --forceExit ./src/nest/**/*.spec.ts", + "test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" DEBUG=ipfs:*,backend:* jest --runInBand --verbose --testPathIgnorePatterns=\".src/(!?nodeTest*)|(.node_modules*)\" --forceExit", + "test-ci": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" jest --runInBand --colors --ci --silent --verbose --forceExit --testPathIgnorePatterns \"/node_modules/\" --testPathIgnorePatterns \"/dist/\" --testPathIgnorePatterns \"src/nest/.*\\.tor\\.spec\\.(t|j)s$\" --testPathIgnorePatterns \"src/nest/ipfs-file-manager/big-files\\.long\\.spec\\.ts$\"", + "test-ci-tor": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" jest --runInBand --colors --ci --verbose --forceExit ./src/nest/**/*.tor.spec.ts", + "test-ci-long-running": "cross-env DEBUG=backend:* NODE_OPTIONS=\"--experimental-vm-modules\" jest --colors --ci --verbose --forceExit ./src/nest/**/*.long.spec.ts", "test-connect": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" DEBUG='libp2p:websockets*' jest ./src/nodeTest/* --verbose", "test-connect-ci": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" jest ./src/nodeTest/* --colors --ci --silent --verbose", "test-replication-no-tor": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" ts-node -v && cross-env DEBUG='backend:dbSnap*,backend:localTest*' ts-node src/nodeTest/testReplicate.ts --nodesCount 1 --timeThreshold 200 --entriesCount 1000 --no-useTor", From 65debd913745ed238056767e7e6819fb4748a4e5 Mon Sep 17 00:00:00 2001 From: adrastaea Date: Fri, 17 Jul 2026 12:35:19 -0400 Subject: [PATCH 09/10] fix windows compatibility --- packages/mobile/package.json | 10 +++--- packages/mobile/scripts/apply-patch.cjs | 36 +++++++++++++++++++ .../mobile/scripts/prepare-backend-assets.cjs | 9 +++++ 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 packages/mobile/scripts/apply-patch.cjs create mode 100644 packages/mobile/scripts/prepare-backend-assets.cjs diff --git a/packages/mobile/package.json b/packages/mobile/package.json index f946e9a9fa..03a5811e0b 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -14,11 +14,11 @@ "lint-ci": "npm run lint:no-fix", "lint-staged": "lint-staged --no-stash", "gen": "plop", - "prepare-backend-assets": "mkdir -p ./nodejs-assets/nodejs-project && cp ../backend/lib/bundle.cjs ./nodejs-assets/nodejs-project/bundle.cjs", - "patch-react-native-file-logger": "patch -f -p0 --forward --binary < ./patch/rnfl/build.gradle.patch || true && patch -f -p0 --forward --binary < ./patch/rnfl/AndroidManifest.xml.patch || true", - "patch-state-manager": "node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || patch -f -d ../state-manager -p0 < ./factory-girl.patch || true", - "patch-react-native": "patch -f -p0 --forward --binary < ./react-native.patch || true", - "patch-webview-crypto": "patch -f -p0 --forward --binary < ./react-native-webview-crypto.patch || true", + "prepare-backend-assets": "node ./scripts/prepare-backend-assets.cjs", + "patch-react-native-file-logger": "node ./scripts/apply-patch.cjs ./patch/rnfl/build.gradle.patch --binary && node ./scripts/apply-patch.cjs ./patch/rnfl/AndroidManifest.xml.patch --binary", + "patch-state-manager": "node ./scripts/apply-patch.cjs ./factory-girl.patch --production-only --directory ../state-manager", + "patch-react-native": "node ./scripts/apply-patch.cjs ./react-native.patch --binary", + "patch-webview-crypto": "node ./scripts/apply-patch.cjs ./react-native-webview-crypto.patch --binary", "prepare": "npm run prepare-backend-assets && npm run patch-react-native-file-logger && npm run patch-state-manager && npm run patch-react-native && npm run patch-webview-crypto && npm run build", "version": "react-native-version --skip-tag" }, diff --git a/packages/mobile/scripts/apply-patch.cjs b/packages/mobile/scripts/apply-patch.cjs new file mode 100644 index 0000000000..809135128f --- /dev/null +++ b/packages/mobile/scripts/apply-patch.cjs @@ -0,0 +1,36 @@ +const { readFileSync } = require('node:fs') +const { resolve } = require('node:path') +const { spawnSync } = require('node:child_process') + +const args = process.argv.slice(2) +const patchPath = args[0] + +if (!patchPath) { + throw new Error('Usage: apply-patch.cjs [--binary] [--production-only] [--directory ]') +} + +if (args.includes('--production-only') && process.env.NODE_ENV !== 'production') { + process.exit(0) +} + +const directoryIndex = args.indexOf('--directory') +const cwd = directoryIndex === -1 ? process.cwd() : resolve(process.cwd(), args[directoryIndex + 1]) +const patchArgs = ['-f', '-p0', '--forward'] + +if (args.includes('--binary')) patchArgs.push('--binary') + +const result = spawnSync('patch', patchArgs, { + cwd, + input: readFileSync(resolve(process.cwd(), patchPath)), + encoding: 'utf8', + shell: process.platform === 'win32', +}) + +if (result.stdout) process.stdout.write(result.stdout) +if (result.stderr) process.stderr.write(result.stderr) + +// These patches may already be present after a previous install. Preserve the +// original prepare behavior by treating rejected/already-applied patches as OK. +if (result.error && result.error.code === 'ENOENT') { + throw new Error(`The 'patch' executable is required: ${result.error.message}`) +} diff --git a/packages/mobile/scripts/prepare-backend-assets.cjs b/packages/mobile/scripts/prepare-backend-assets.cjs new file mode 100644 index 0000000000..c52eea9ab6 --- /dev/null +++ b/packages/mobile/scripts/prepare-backend-assets.cjs @@ -0,0 +1,9 @@ +const { copyFileSync, mkdirSync } = require('node:fs') +const { resolve } = require('node:path') + +const projectDirectory = resolve(__dirname, '../nodejs-assets/nodejs-project') +const source = resolve(__dirname, '../../backend/lib/bundle.cjs') +const destination = resolve(projectDirectory, 'bundle.cjs') + +mkdirSync(projectDirectory, { recursive: true }) +copyFileSync(source, destination) From 3f07821b8ab6dc545df94b067a13dd8f2eafa635 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Jul 2026 13:02:46 -0400 Subject: [PATCH 10/10] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fee8b046..c304c01bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Tighten controls on channel metadata DB operations, move private channels to separate metadata DB [#3329](https://github.com/TryQuiet/quiet/issues/3329) * Use chain permission checks to gate channel creation, deletion and membership [#3344](https://github.com/TryQuiet/quiet/issues/3344) +* Tighten user profile store access control and validations [#3340](https://github.com/TryQuiet/quiet/issues/3340) ## [8.0.0]