From 1db724109c5542e2081a1c71f60b0554e870d0cf Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 13:14:15 -0400 Subject: [PATCH 1/5] fix(mobile): share ordered profile authority across discovery consumers Signed-off-by: Logan Johnson --- .../channels/mentions/mention_candidates.dart | 13 ++- .../mentions/mention_candidates_provider.dart | 5 +- .../shared/mentions/agent_authorization.dart | 5 ++ .../lib/shared/mentions/agent_discovery.dart | 4 + .../mentions/agent_identity_provider.dart | 40 +++++++--- mobile/lib/shared/mentions/agent_policy.dart | 14 +++- .../shared/profile/user_cache_provider.dart | 9 +++ .../mentions/mention_candidates_test.dart | 43 +++++++++- .../mentions/mention_readiness_test.dart | 80 +++++++++++++++++++ .../shared/mentions/agent_discovery_test.dart | 60 ++++++++++++++ .../shared/mentions/agent_owners_test.dart | 5 ++ .../profile/user_cache_provider_test.dart | 34 ++++++++ 12 files changed, 292 insertions(+), 20 deletions(-) create mode 100644 mobile/test/features/channels/mentions/mention_readiness_test.dart diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index c3501b72f23..82ecf13473f 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -57,6 +57,7 @@ List buildMentionCandidates({ required Map ownerByAgentPubkey, List searchResults = const [], String? currentPubkey, + bool directoryReady = true, }) { final candidates = []; final seen = {}; @@ -68,8 +69,10 @@ List buildMentionCandidates({ final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey; final isAgent = member.isBot || ownerPubkey != null; final policy = relayAgents.where((agent) => agent.pubkey == pk).firstOrNull; - if (policy?.ownerPubkey != null && - !agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey)) { + if (ownerPubkey != null && + (!directoryReady || + policy?.ownerPubkey == null || + !agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey))) { continue; } candidates.add( @@ -98,6 +101,7 @@ List buildMentionCandidates({ } for (final agent in relayAgents) { + if (!directoryReady && agent.ownerPubkey != null) continue; final pk = agent.pubkey; if (seen.contains(pk)) continue; if (!sharedAgentPubkeys.contains(pk)) continue; @@ -135,7 +139,10 @@ List buildMentionCandidates({ final policy = relayAgents .where((agent) => agent.pubkey == pk) .firstOrNull; - if (policy?.ownerPubkey != null && !sharedAgentPubkeys.contains(pk)) { + if (ownerPubkey != null && + (!directoryReady || + policy?.ownerPubkey == null || + !sharedAgentPubkeys.contains(pk))) { continue; } if (!ownedByCurrentUser && !sharedAgentPubkeys.contains(pk)) { diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 6e225b45017..1ef0b0c6d65 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -77,9 +77,9 @@ final mentionCandidatesProvider = Provider.family sessionStatus: sessionStatus, cachedMembers: cachedMembers, ); + final directory = ref.watch(agentDirectoryProvider); final relayAgents = - ref.watch(agentDirectoryProvider).asData?.value ?? - const []; + directory.asData?.value ?? const []; final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; final channels = channelsAsync.asData?.value ?? const []; final userCache = ref.watch(userCacheProvider); @@ -96,6 +96,7 @@ final mentionCandidatesProvider = Provider.family final candidates = buildMentionCandidates( members: members, relayAgents: relayAgents, + directoryReady: !directory.isLoading && !directory.hasError, sharedChannelIds: sharedChannelIds, userCache: userCache, ownerByAgentPubkey: owners, diff --git a/mobile/lib/shared/mentions/agent_authorization.dart b/mobile/lib/shared/mentions/agent_authorization.dart index 1625db85803..9040dc24f85 100644 --- a/mobile/lib/shared/mentions/agent_authorization.dart +++ b/mobile/lib/shared/mentions/agent_authorization.dart @@ -2,12 +2,16 @@ part of 'agent_identity_provider.dart'; /// Fresh exact-key policy and relay-signed membership. Directory entries are /// hints; only this read may authorize agent mentions at a destination. +/// [resolveProfileOwners] optionally folds fetched profiles into an ordered, +/// session-bound cache after the scope check and returns its current verified +/// owners. Omit it for side-effect-free send-time reads. Future> readAgentAuthorization( RelaySessionNotifier session, Set requestedKeys, { required String? viewer, String? channelId, required bool Function() isCurrent, + Map Function(Iterable)? resolveProfileOwners, }) async { void check() { if (!isCurrent()) throw StateError('Agent authorization scope changed'); @@ -66,6 +70,7 @@ Future> readAgentAuthorization( runtime.where((e) => requestedKeys.contains(e.pubkey)).toList(), requestedKeys: requestedKeys, checkCurrent: check, + resolveProfileOwners: resolveProfileOwners, ); check(); final latest = {}; diff --git a/mobile/lib/shared/mentions/agent_discovery.dart b/mobile/lib/shared/mentions/agent_discovery.dart index ac4bbec15b5..8d4fe358bd7 100644 --- a/mobile/lib/shared/mentions/agent_discovery.dart +++ b/mobile/lib/shared/mentions/agent_discovery.dart @@ -110,6 +110,10 @@ class _AgentDirectoryUpdates extends Notifier { ), (event) { if (!current()) return; + if (disposed) return; + if (event.kind == 0) { + ref.read(userCacheProvider.notifier).cacheProfileEvent(event); + } if (event.kind == 5 && !_isAgentCoordinateDeletion(event)) return; changed(); }, diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 89c1c9d75c5..472a14af2f3 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/crypto/nip_oa.dart'; import '../../shared/crypto/signed_event.dart'; import '../../shared/relay/relay.dart'; +import '../profile/user_cache_provider.dart'; part 'agent_policy.dart'; part 'agent_authorization.dart'; @@ -92,9 +93,26 @@ final agentDirectoryProvider = FutureProvider>(( if (!current()) return []; return readAgentAuthorization( session, - {...owned, ...events.map((event) => event.pubkey)}, + { + ...owned, ...events.map((event) => event.pubkey), + // Refresh previously authenticated owners even after runtime/policy + // discovery stops returning them (for example across reconnect). + for (final profile in ref.read(userCacheProvider).values) + if (profile.ownerPubkey != null) profile.pubkey, + }, viewer: viewer, isCurrent: current, + resolveProfileOwners: (profiles) { + if (!current()) throw StateError('Profile authority scope changed'); + final cache = ref.read(userCacheProvider.notifier); + for (final profile in profiles) { + cache.cacheProfileEvent(profile); + } + return { + for (final profile in ref.read(userCacheProvider).values) + if (profile.ownerPubkey != null) profile.pubkey: profile.ownerPubkey!, + }; + }, ); }); @@ -102,18 +120,14 @@ final agentDirectoryProvider = FutureProvider>(( /// profiles. An entry exists only when the `auth` tag verifies — mirrors /// desktop's `profile_valid_oa_owner_pubkey`. final agentOwnersProvider = FutureProvider>((ref) async { - final agents = await ref.watch(agentDirectoryProvider.future); - if (agents.isEmpty) return const {}; - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), - ); - final owners = {}; - for (final event in latestProfileEvents(events).values) { - final owner = verifiedOaOwnerPubkey(event); - if (owner != null) owners[event.pubkey.toLowerCase()] = owner; - } - return owners; + await ref.watch(agentDirectoryProvider.future); + // The profile cache is the single ordered ownership source for lifecycle, + // provenance and mention fallbacks; never re-query a parallel owner snapshot. + final profiles = ref.watch(userCacheProvider); + return { + for (final profile in profiles.values) + if (profile.ownerPubkey != null) profile.pubkey: profile.ownerPubkey!, + }; }); /// Pubkeys currently known to represent agents across the active relay. diff --git a/mobile/lib/shared/mentions/agent_policy.dart b/mobile/lib/shared/mentions/agent_policy.dart index 9d2d57a2742..61d2fbdef56 100644 --- a/mobile/lib/shared/mentions/agent_policy.dart +++ b/mobile/lib/shared/mentions/agent_policy.dart @@ -23,11 +23,16 @@ Future> _queryAgentFilters( } /// Overlay current owner-authenticated policy onto the existing runtime -/// directory. This does not expand discovery to owner-only coordinates yet. +/// directory, optionally including exact [requestedKeys] without a runtime. +/// [resolveProfileOwners] receives snapshot profiles after [checkCurrent] and +/// returns verified owners from the caller's ordered authority cache, so an +/// older snapshot cannot undo a newer live revocation. Without the callback, +/// ownership derives solely from this fresh query; no cache writes occur. Future> resolveAgentPolicies( RelaySessionNotifier session, List runtimeEvents, { Set? requestedKeys, + Map Function(Iterable)? resolveProfileOwners, void Function()? checkCurrent, }) async { final latest = {}; @@ -44,6 +49,7 @@ Future> resolveAgentPolicies( NostrFilter(kinds: const [0], authors: [key], limit: 1), ], checkCurrent: checkCurrent), ); + checkCurrent?.call(); final owners = {}; for (final profile in profiles.values) { final owner = verifiedOaOwnerPubkey(profile); @@ -51,6 +57,12 @@ Future> resolveAgentPolicies( owners[profile.pubkey] = owner; } } + if (resolveProfileOwners != null) { + final currentOwners = resolveProfileOwners(profiles.values); + owners + ..clear() + ..addEntries(currentOwners.entries.where((e) => keys.contains(e.key))); + } checkCurrent?.call(); final policies = await _queryAgentFilters(session, [ for (final owner in owners.entries) diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 91131c888d0..915a0ff33f2 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -14,6 +14,7 @@ import 'user_profile.dart'; /// kind:0 batch query (NIP-01 `authors` filter) every 50ms. class UserCacheNotifier extends Notifier> { final Set _pending = {}; + int _generation = 0; final Map _profileEventOrders = {}; Timer? _batchTimer; Completer? _batchCompleter; @@ -21,8 +22,12 @@ class UserCacheNotifier extends Notifier> { @override Map build() { ref.watch(relayConfigProvider); + ref.watch(myPubkeyProvider); + _generation++; + _pending.clear(); _profileEventOrders.clear(); ref.onDispose(() { + _generation++; _batchTimer?.cancel(); _batchTimer = null; _batchCompleter?.complete(false); @@ -72,11 +77,13 @@ class UserCacheNotifier extends Notifier> { .toSet() .toList(); if (normalized.isEmpty) return true; + final generation = _generation; try { final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( NostrFilters.profilesBatch(normalized), ); + if (generation != _generation) return false; final updated = Map.from(state); final updatedOrders = Map.from( _profileEventOrders, @@ -121,6 +128,7 @@ class UserCacheNotifier extends Notifier> { _batchCompleter = null; var succeeded = false; + final generation = _generation; try { final communityID = ref.read(activeCommunityProvider).value?.id; final session = ref.read(relaySessionProvider.notifier); @@ -128,6 +136,7 @@ class UserCacheNotifier extends Notifier> { NostrFilters.profilesBatch(pubkeys), ); + if (generation != _generation) return; final updated = Map.from(state); final updatedOrders = Map.from( _profileEventOrders, diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 9694d68535d..63d10937700 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -169,7 +169,13 @@ void main() { final ownedAgent = '2' * 64; final candidates = buildMentionCandidates( members: const [], - relayAgents: const [], + relayAgents: [ + AgentDirectoryEntry( + pubkey: ownedAgent, + ownerPubkey: userPubkey, + respondTo: 'owner-only', + ), + ], sharedChannelIds: const {}, userCache: const {}, ownerByAgentPubkey: const {}, @@ -188,6 +194,41 @@ void main() { expect(candidates.single.ownerPubkey, userPubkey); }); + test('owned members and search fail closed without ready policy', () { + final ownedAgent = '2' * 64; + for (final ready in [false, true]) { + for (final isMember in [false, true]) { + final candidates = buildMentionCandidates( + members: isMember ? [member(ownedAgent)] : [], + relayAgents: [], + directoryReady: ready, + sharedChannelIds: {}, + userCache: {}, + ownerByAgentPubkey: {ownedAgent: userPubkey}, + searchResults: isMember ? [] : [UserProfile(pubkey: ownedAgent)], + currentPubkey: userPubkey, + ); + expect(candidates, isEmpty); + } + } + final denied = buildMentionCandidates( + members: [member(ownedAgent)], + directoryReady: false, + relayAgents: [ + AgentDirectoryEntry( + pubkey: ownedAgent, + ownerPubkey: userPubkey, + respondTo: 'owner-only', + ), + ], + sharedChannelIds: {}, + userCache: {}, + ownerByAgentPubkey: {ownedAgent: userPubkey}, + currentPubkey: userPubkey, + ); + expect(denied, isEmpty); + }); + test('search results hide non-shared agents owned by someone else', () { final foreignAgent = '3' * 64; final candidates = buildMentionCandidates( diff --git a/mobile/test/features/channels/mentions/mention_readiness_test.dart b/mobile/test/features/channels/mentions/mention_readiness_test.dart new file mode 100644 index 00000000000..5a34109cf53 --- /dev/null +++ b/mobile/test/features/channels/mentions/mention_readiness_test.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../shared/mentions/agent_policy_test.dart' show PolicySession; + +class _Channels extends ChannelsNotifier { + @override + Future> build() async => []; +} + +void main() { + test( + 'production picker preserves policy loading/error instead of empty success', + () async { + final viewer = 'a' * 64; + final agent = 'b' * 64; + final human = 'c' * 64; + for (final member in [false, true]) { + final directory = Completer>(); + final c = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => PolicySession([])), + channelsProvider.overrideWith(_Channels.new), + currentPubkeyProvider.overrideWithValue(viewer), + agentDirectoryProvider.overrideWith((ref) => directory.future), + agentOwnersProvider.overrideWith((ref) async => {agent: viewer}), + channelMembersProvider('room').overrideWith( + (ref) async => [ + ChannelMember( + pubkey: human, + role: 'member', + joinedAt: DateTime(2024), + ), + if (member) + ChannelMember( + pubkey: agent, + role: 'bot', + joinedAt: DateTime(2024), + ), + ], + ), + mentionUserSearchProvider('').overrideWith( + (ref) async => [ + if (!member) UserProfile(pubkey: agent, ownerPubkey: viewer), + ], + ), + ], + ); + final picker = mentionCandidatesProvider(( + channelId: 'room', + query: '', + )); + final subscription = c.listen(picker, (_, _) {}); + await c.read(channelsProvider.future); + await c.read(agentOwnersProvider.future); + await c.read(channelMembersProvider('room').future); + await c.read(mentionUserSearchProvider('').future); + expect(c.read(picker).map((candidate) => candidate.pubkey), [human]); + directory.completeError(StateError('policy unavailable')); + await expectLater( + c.read(agentDirectoryProvider.future), + throwsStateError, + ); + await c.pump(); + expect(c.read(picker).map((candidate) => candidate.pubkey), [human]); + subscription.close(); + c.dispose(); + } + }, + ); +} diff --git a/mobile/test/shared/mentions/agent_discovery_test.dart b/mobile/test/shared/mentions/agent_discovery_test.dart index af83dd681c7..494e3c6fcad 100644 --- a/mobile/test/shared/mentions/agent_discovery_test.dart +++ b/mobile/test/shared/mentions/agent_discovery_test.dart @@ -1,6 +1,8 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -277,6 +279,64 @@ void main() { }, ); + test( + 'live revocation advances shared profiles and owners; replay cannot restore', + () async { + final events = [owned, policy]; + final session = _Session(events, relay.public); + final c = container(session); + final ownerSubscription = c.listen(agentOwnersProvider, (_, _) {}); + addTearDown(ownerSubscription.close); + addTearDown(c.dispose); + expect(await c.read(agentOwnersProvider.future), { + agent.public: owner.public, + }); + expect( + c.read(userCacheProvider)[agent.public]?.ownerPubkey, + owner.public, + ); + final revoked = profile(agent, [], createdAt: 101); + events.add(revoked); + session.deliver(revoked); + await Future.delayed(const Duration(milliseconds: 180)); + expect(await c.read(agentOwnersProvider.future), isEmpty); + expect(c.read(userCacheProvider)[agent.public]?.ownerPubkey, isNull); + events.remove( + revoked, + ); // stale snapshot must lose to delivered revocation + session.deliver(owned); + session.status!(RelaySubscriptionStatus.ready); + await Future.delayed(const Duration(milliseconds: 180)); + expect(await c.read(agentOwnersProvider.future), isEmpty); + expect(c.read(userCacheProvider)[agent.public]?.ownerPubkey, isNull); + // Ordinary-channel member fallback must not resurrect revoked ownership. + final choices = buildMentionCandidates( + members: [ + ChannelMember( + pubkey: agent.public, + role: 'bot', + joinedAt: DateTime(2024), + ), + ], + relayAgents: await c.read(agentDirectoryProvider.future), + sharedChannelIds: {'room'}, + userCache: c.read(userCacheProvider), + ownerByAgentPubkey: await c.read(agentOwnersProvider.future), + currentPubkey: owner.public, + ); + expect(choices.single.ownerPubkey, isNull); + final retiredCallback = session.changed!; + c.updateOverrides([ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(relay.public), + ]); + c.read(agentDirectoryProvider); + expect(c.read(userCacheProvider), isEmpty); + retiredCallback(owned); + expect(c.read(userCacheProvider), isEmpty); + }, + ); + test( 'owned coordinates paginate equal-time pages and reject a stalled cursor', () async { diff --git a/mobile/test/shared/mentions/agent_owners_test.dart b/mobile/test/shared/mentions/agent_owners_test.dart index 7a4d26308fb..500885fcd73 100644 --- a/mobile/test/shared/mentions/agent_owners_test.dart +++ b/mobile/test/shared/mentions/agent_owners_test.dart @@ -1,6 +1,7 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -29,6 +30,10 @@ void main() { ], ); try { + final cache = container.read(userCacheProvider.notifier); + for (final event in events) { + cache.cacheProfileEvent(event); + } return await container.read(agentOwnersProvider.future); } finally { container.dispose(); diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index ee70f90a9ac..5154feb1b06 100644 --- a/mobile/test/shared/profile/user_cache_provider_test.dart +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -114,6 +114,40 @@ void main() { expect(cache.state[agent.public]?.ownerPubkey, isNull); }); + test( + 'retired account refresh and preload cannot write into the next cache', + () async { + for (final preload in [false, true]) { + final result = Completer>(); + final session = _RecordingProfileSession(result: result.future); + final c = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue('first'), + ], + ); + final cache = c.read(userCacheProvider.notifier); + final pending = preload + ? cache.preload(['agent']) + : cache.refresh(['agent']); + if (preload) { + await Future.delayed(const Duration(milliseconds: 60)); + } + c.updateOverrides([ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue('second'), + ]); + expect(c.read(userCacheProvider), isEmpty); + result.complete([ + _profileEvent(id: 'old', createdAt: 2, name: 'Old scope'), + ]); + expect(await pending, isFalse); + expect(c.read(userCacheProvider), isEmpty); + c.dispose(); + } + }, + ); + test('non-profile history cannot poison profile order', () async { final session = _RecordingProfileSession( results: [ From e9c8be29e2b59080f8e9529a67853d950d8c6a0c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 14:01:10 -0400 Subject: [PATCH 2/5] fix(mobile): order confirmed profile writes with shared cache authority Signed-off-by: Logan Johnson --- .../features/profile/profile_provider.dart | 68 ++++---------- .../shared/profile/user_cache_provider.dart | 36 ++++---- .../profile/profile_provider_test.dart | 92 ++++++++++++++++++- .../profile/user_cache_provider_test.dart | 29 ++++++ 4 files changed, 159 insertions(+), 66 deletions(-) diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index a1a283fb499..42c922e9f23 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -5,7 +5,6 @@ import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/crypto/nip_oa.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import '../../shared/relay/relay.dart'; @@ -69,9 +68,7 @@ final profileAvatarHandoffProvider = /// WebSocket. Returns null when no nsec is configured or when the user has /// not yet published a profile. class ProfileNotifier extends AsyncNotifier { - Map _metadata = {}; bool _hasHydrated = false; - int _lastCreatedAt = 0; Future _patchQueue = Future.value(); @override @@ -92,37 +89,19 @@ class ProfileNotifier extends AsyncNotifier { final myPk = context.pubkey; if (myPk == null) { _requireCurrentWriteContext(context); - _metadata = {}; - _lastCreatedAt = 0; _hasHydrated = true; return null; } final session = context.session; final events = await session.fetchHistory(NostrFilters.profile(myPk)); - if (events.isEmpty) { - _requireCurrentWriteContext(context); - _metadata = {}; - _lastCreatedAt = 0; - _hasHydrated = true; - return null; - } - final latest = _latestProfileEvent(events)!; - final metadata = _decodeProfileMetadata(latest); - final data = ProfileData.fromEvent(latest); - final profile = UserProfile( - pubkey: data.pubkey, - displayName: data.displayName, - avatarUrl: data.avatarUrl, - about: data.about, - nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(latest), - ); _requireCurrentWriteContext(context); - _metadata = metadata; - _lastCreatedAt = latest.createdAt; + final cache = ref.read(userCacheProvider.notifier); + for (final event in events) { + cache.cacheProfileEvent(event); + } _hasHydrated = true; - return profile; + return ref.read(userCacheProvider)[myPk.toLowerCase()]; } Future refresh() async { @@ -182,9 +161,12 @@ class ProfileNotifier extends AsyncNotifier { NostrFilters.profile(pubkey), ); _requireCurrentWriteContext(context); - final currentHead = _latestProfileEvent(currentEvents); - if (_lastCreatedAt > 0 && - (currentHead == null || currentHead.createdAt < _lastCreatedAt)) { + final cache = ref.read(userCacheProvider.notifier); + for (final event in currentEvents) { + cache.cacheProfileEvent(event); + } + final currentHead = cache.profileEvent(pubkey); + if (currentHead?.id != _latestProfileEvent(currentEvents)?.id) { throw StateError('Cannot confirm the latest profile metadata.'); } final currentMetadata = currentHead == null @@ -199,10 +181,7 @@ class ProfileNotifier extends AsyncNotifier { final relay = SignedEventRelay(session: session, nsec: context.config.nsec); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final currentCreatedAt = currentHead?.createdAt ?? 0; - final previousCreatedAt = currentCreatedAt > _lastCreatedAt - ? currentCreatedAt - : _lastCreatedAt; - final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1; + final createdAt = now > currentCreatedAt ? now : currentCreatedAt + 1; NostrEvent? signedEvent; await relay.submit( kind: EventKind.profile, @@ -216,27 +195,20 @@ class ProfileNotifier extends AsyncNotifier { if (submittedEvent == null) { throw StateError('Profile update was not signed.'); } - final verifiedHead = _latestProfileEvent( - await session.fetchHistory(NostrFilters.profile(pubkey)), + final verification = await session.fetchHistory( + NostrFilters.profile(pubkey), ); _requireCurrentWriteContext(context); + for (final event in verification) { + cache.cacheProfileEvent(event); + } + final verifiedHead = _latestProfileEvent(verification); if (verifiedHead?.id != submittedEvent.id) { throw StateError('Profile changed before the update could be confirmed.'); } - _metadata = nextMetadata; - _lastCreatedAt = createdAt; - final profile = UserProfile( - pubkey: pubkey, - displayName: - _metadata['display_name'] as String? ?? _metadata['name'] as String?, - avatarUrl: _metadata['picture'] as String?, - about: _metadata['about'] as String?, - nip05Handle: _metadata['nip05'] as String?, - ownerPubkey: verifiedOaOwnerPubkey(submittedEvent), - ); - state = AsyncData(profile); - ref.read(userCacheProvider.notifier).put(profile); + cache.cacheProfileEvent(submittedEvent); + state = AsyncData(ref.read(userCacheProvider)[pubkey.toLowerCase()]); } void _requireCurrentWriteContext(_ProfileWriteContext context) { diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 915a0ff33f2..0ce0cb0c6d5 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -15,7 +15,7 @@ import 'user_profile.dart'; class UserCacheNotifier extends Notifier> { final Set _pending = {}; int _generation = 0; - final Map _profileEventOrders = {}; + final Map _profileEvents = {}; Timer? _batchTimer; Completer? _batchCompleter; @@ -25,7 +25,7 @@ class UserCacheNotifier extends Notifier> { ref.watch(myPubkeyProvider); _generation++; _pending.clear(); - _profileEventOrders.clear(); + _profileEvents.clear(); ref.onDispose(() { _generation++; _batchTimer?.cancel(); @@ -45,11 +45,19 @@ class UserCacheNotifier extends Notifier> { return null; } - /// Stores a profile that was fetched or updated outside the batch loader. + /// Seeds an absent profile for local display before signed evidence arrives. + /// Never replaces existing state; fetched/confirmed profiles must use + /// [cacheProfileEvent] so ownership and event metadata advance together. void put(UserProfile profile) { - state = {...state, profile.pubkey.toLowerCase(): profile}; + final pubkey = profile.pubkey.toLowerCase(); + if (state.containsKey(pubkey)) return; + state = {...state, pubkey: profile}; } + /// The governing kind:0 event, including metadata needed for profile edits. + NostrEvent? profileEvent(String pubkey) => + _profileEvents[pubkey.toLowerCase()]; + /// Preload profiles for a list of pubkeys (e.g. channel members). /// Returns whether the batch completed successfully. Future preload(List pubkeys) { @@ -85,13 +93,11 @@ class UserCacheNotifier extends Notifier> { ); if (generation != _generation) return false; final updated = Map.from(state); - final updatedOrders = Map.from( - _profileEventOrders, - ); + final updatedOrders = Map.from(_profileEvents); for (final event in events) { _cacheProfileEvent(event, updated, updatedOrders); } - _profileEventOrders + _profileEvents ..clear() ..addAll(updatedOrders); state = updated; @@ -138,14 +144,12 @@ class UserCacheNotifier extends Notifier> { if (generation != _generation) return; final updated = Map.from(state); - final updatedOrders = Map.from( - _profileEventOrders, - ); + final updatedOrders = Map.from(_profileEvents); for (final event in events) { _cacheProfileEvent(event, updated, updatedOrders); } - _profileEventOrders + _profileEvents ..clear() ..addAll(updatedOrders); state = updated; @@ -163,21 +167,21 @@ class UserCacheNotifier extends Notifier> { bool _cacheProfileEvent( NostrEvent event, Map profiles, [ - Map? orders, + Map? orders, ]) { if (event.kind != 0) return false; - final eventOrders = orders ?? _profileEventOrders; + final eventOrders = orders ?? _profileEvents; final pubkey = event.pubkey.toLowerCase(); final current = eventOrders[pubkey]; final isNewer = current == null || event.createdAt > current.createdAt || (event.createdAt == current.createdAt && - event.id.compareTo(current.eventId) < 0); + event.id.compareTo(current.id) < 0); if (!isNewer) return false; profiles[pubkey] = _profileFromEvent(event); - eventOrders[pubkey] = (createdAt: event.createdAt, eventId: event.id); + eventOrders[pubkey] = event; return true; } diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 239dc808a28..96281d302ec 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -77,6 +77,89 @@ void main() { ); }); + test( + 'late confirmed publication retains newer cache authority and metadata', + () async { + final keys = nostr.Keys.generate(); + final owner = nostr.Keys.generate(); + final initial = NostrEvent.fromJson( + nostr.Event.from( + secretKey: keys.secret, + createdAt: 1, + kind: 0, + tags: [_authTag(owner, keys.public)], + content: jsonEncode({'display_name': 'Owned'}), + ).toMap(), + ); + final session = _ProfileRelaySession(initial); + final container = _profileContainer(keys.nsec, session); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + await container.read(profileProvider.future); + expect(cache.profileEvent(keys.public)?.id, initial.id); + NostrEvent? revoked; + session.beforeHistoryReturn = () { + if (session.published.length != 1) return; + revoked = NostrEvent.fromJson( + nostr.Event.from( + secretKey: keys.secret, + createdAt: session.published.single.createdAt + 1, + kind: 0, + tags: const [], + content: jsonEncode({'display_name': 'Revoked', 'custom': 'new'}), + ).toMap(), + ); + cache.cacheProfileEvent(revoked!); + }; + await container.read(profileProvider.notifier).updateAbout('Late'); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Revoked', + ); + final submitted = session.published.single; + cache.put( + UserProfile( + pubkey: keys.public, + displayName: 'Unordered', + ownerPubkey: owner.public, + ), + ); + cache.cacheProfileEvent(submitted); + cache.cacheProfileEvent(revoked!); + expect(cache.profileEvent(keys.public)?.id, revoked!.id); + expect( + container.read(userCacheProvider)[keys.public]?.ownerPubkey, + isNull, + ); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Revoked', + ); + // A stale hydration response must use the same governing event, not + // recreate detached state or lower the metadata used by the next edit. + session.beforeHistoryReturn = null; + await container.read(profileProvider.notifier).refresh(); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Revoked', + ); + await expectLater( + container.read(profileProvider.notifier).updateAbout('Unsafe'), + throwsStateError, + ); + expect(session.published, hasLength(1)); + session.profile = revoked!; + await container.read(profileProvider.notifier).updateAbout('Safe'); + final next = session.published.last; + expect(next.createdAt, greaterThan(revoked!.createdAt)); + expect(next.tags, isEmpty); + expect(jsonDecode(next.content)['custom'], 'new'); + expect(cache.profileEvent(keys.public)?.id, next.id); + cache.cacheProfileEvent(revoked!); + expect(cache.profileEvent(keys.public)?.id, next.id); + }, + ); + test('clearing a display name restores the pubkey label fallback', () async { final keys = nostr.Keys.generate(); final relaySession = _ProfileRelaySession( @@ -607,7 +690,8 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier { class _ProfileRelaySession extends RelaySessionNotifier { _ProfileRelaySession(this.profile); - final NostrEvent profile; + NostrEvent profile; + void Function()? beforeHistoryReturn; final List published = []; @override @@ -617,7 +701,11 @@ class _ProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => [profile, ...published]; + }) async { + final result = [profile, ...published]; + beforeHistoryReturn?.call(); + return result; + } @override Future publish( diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index 5154feb1b06..64d979780d4 100644 --- a/mobile/test/shared/profile/user_cache_provider_test.dart +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -176,6 +177,34 @@ void main() { expect(cache.state['agent']?.displayName, 'Valid'); }); + test( + 'unordered seed is first hydration only and cannot replace an event', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + cache.put(const UserProfile(pubkey: 'AGENT', displayName: 'Local')); + expect(cache.state['agent']?.displayName, 'Local'); + expect(cache.profileEvent('agent'), isNull); + cache.cacheProfileEvent( + _profileEvent(id: 'b', createdAt: 2, name: 'New'), + ); + cache.put( + const UserProfile( + pubkey: 'agent', + displayName: 'Late', + ownerPubkey: 'owner', + ), + ); + cache.cacheProfileEvent( + _profileEvent(id: 'a', createdAt: 1, name: 'Old'), + ); + expect(cache.state['agent']?.displayName, 'New'); + expect(cache.state['agent']?.ownerPubkey, isNull); + expect(cache.profileEvent('AGENT')?.id, 'b'); + }, + ); + test('same-second profile tie keeps the lowest event id', () { final container = ProviderContainer(); addTearDown(container.dispose); From 68b042a73a714edd0fca04283300bcb09974b497 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 22:50:48 -0400 Subject: [PATCH 3/5] test(desktop): commit forum draft clears before navigation Signed-off-by: Logan Johnson --- desktop/tests/e2e/forum-agent-invitation.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/tests/e2e/forum-agent-invitation.spec.ts b/desktop/tests/e2e/forum-agent-invitation.spec.ts index b17f5fcc04f..208f1eb54fc 100644 --- a/desktop/tests/e2e/forum-agent-invitation.spec.ts +++ b/desktop/tests/e2e/forum-agent-invitation.spec.ts @@ -430,7 +430,11 @@ for (const stage of ["add", "publish"] as const) { await expect( page.getByRole("button", { name: "Remove attachment" }), ).toBeVisible(); - if (replacement !== null) + if (replacement === "") { + await page.getByTestId("message-input").press("ControlOrMeta+a"); + await page.getByTestId("message-input").press("Backspace"); + await expect(page.getByTestId("message-input")).toBeEmpty(); + } else if (replacement !== null) await page.getByTestId("message-input").fill(replacement); await navigate(1); await releaseForumGate(page); From 7d5409f37e5573c19567ca80cde78a3c4a1dd908 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 23:23:45 -0400 Subject: [PATCH 4/5] test(desktop): freeze the status expiry clock until the editor opens The seeded two-second status expired while the popover and editor setup ran, so the smoke test opened a new-status editor instead of the saved draft it intended to exercise. Freeze the browser clock before navigation, assert the saved editor state before editing, and advance the clock past the seeded deadline only after the draft is open, retaining every existing error/draft/save assertion. A sibling control test crosses the deadline before opening to prove the saved-editor precondition fails under that wrong ordering. Signed-off-by: Logan Johnson --- .../e2e/profile-custom-emoji-status.spec.ts | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts index 09060ec39ee..221ae645a13 100644 --- a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts +++ b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts @@ -196,8 +196,14 @@ test("set status dialog uses the desktop modal with shared status choices", asyn test("keeps an open status draft when the saved status expires", async ({ page, }) => { + // Freeze the browser clock before navigation so the seeded two-second + // status cannot expire while the popover/editor setup runs, and so the + // seeded node-side timestamps match the browser clock exactly. Expiry is + // crossed deliberately below, after the editor owns the saved draft. + const seededAt = new Date("2026-09-10T12:00:00.000Z"); + const nowSeconds = Math.floor(seededAt.getTime() / 1_000); + await page.clock.install({ time: seededAt }); await page.goto("/"); - const nowSeconds = Math.floor(Date.now() / 1_000); await seedMockStatus(page, { text: "Original draft", emoji: "📝", @@ -206,7 +212,20 @@ test("keeps an open status draft when the saved status expires", async ({ }); await page.getByTestId("profile-popover-set-status").click(); const dialog = page.getByTestId("set-status-dialog"); + // The editor opened on the still-saved status, not on a fresh one. + await expect(dialog.getByTestId("set-status-input")).toHaveValue( + "Original draft", + ); + await expect(dialog.getByTestId("set-status-duration")).toContainText( + "Custom", + ); + await expect(dialog.getByLabel("Choose a status emoji")).toContainText("📝"); + await expect(dialog.getByTestId("set-status-clear")).toBeVisible(); + await expect(dialog.getByText("Quick statuses", { exact: true })).toHaveCount( + 0, + ); await dialog.getByTestId("set-status-input").fill("Unsaved draft"); + await page.clock.fastForward(2_500); await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0, { timeout: 5_000, }); @@ -227,6 +246,37 @@ test("keeps an open status draft when the saved status expires", async ({ await expect(dialog.getByTestId("set-status-clear")).toBeVisible(); }); +test("opens a fresh editor when the saved status expired before opening", async ({ + page, +}) => { + // Causal control for the setup ordering above: the same frozen clock and + // seed, but the deadline is crossed before the editor opens. The dialog + // must then be a new-status editor — the saved-editor precondition + // assertions above would fail under this wrong ordering. + const seededAt = new Date("2026-09-10T12:00:00.000Z"); + const nowSeconds = Math.floor(seededAt.getTime() / 1_000); + await page.clock.install({ time: seededAt }); + await page.goto("/"); + await seedMockStatus(page, { + text: "Original draft", + emoji: "📝", + expiresAt: nowSeconds + 2, + createdAt: nowSeconds, + }); + await page.clock.fastForward(5_000); + await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0); + await page.getByTestId("profile-popover-set-status").click(); + const dialog = page.getByTestId("set-status-dialog"); + await expect(dialog.getByTestId("set-status-input")).toHaveValue(""); + await expect(dialog.getByTestId("set-status-duration")).toContainText( + "Today", + ); + await expect( + dialog.getByText("Quick statuses", { exact: true }), + ).toBeVisible(); + await expect(dialog.getByTestId("set-status-clear")).toHaveCount(0); +}); + test("new statuses default to expiring at local midnight", async ({ page }) => { await page.goto("/"); await openProfilePopover(page); From 4d83496d61f5c5945030ac3b5b1351dcb1ff8cf5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 23:45:38 -0400 Subject: [PATCH 5/5] test(desktop): pin the status expiry clock with setFixedTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clock.install({ time }) only sets the fake clock's epoch: fake time keeps flowing with real time, so the seeded two-second status could still expire during popover/editor setup on a slow machine — a 3s real delay before opening the editor reproduces the original fresh-editor failure. clock.setFixedTime pins Date.now() while timers, network, and rendering keep running (the same idiom as the channels.spec.ts ephemeral-countdown test), so the saved editor preconditions hold regardless of setup duration. Expiry is crossed deliberately by advancing the pinned date past the deadline and firing the pending timer with fastForward; the expired-before-opening control test crosses the same deadline before opening. Signed-off-by: Logan Johnson --- .../e2e/profile-custom-emoji-status.spec.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts index 221ae645a13..f713b1de788 100644 --- a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts +++ b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts @@ -196,13 +196,13 @@ test("set status dialog uses the desktop modal with shared status choices", asyn test("keeps an open status draft when the saved status expires", async ({ page, }) => { - // Freeze the browser clock before navigation so the seeded two-second - // status cannot expire while the popover/editor setup runs, and so the - // seeded node-side timestamps match the browser clock exactly. Expiry is - // crossed deliberately below, after the editor owns the saved draft. + // Pin the browser Date before navigation so the seeded two-second status + // cannot expire while the popover/editor setup runs, however slow the + // machine; timers, network, and rendering keep running, and the seeded + // node-side timestamps match the pinned Date exactly. const seededAt = new Date("2026-09-10T12:00:00.000Z"); const nowSeconds = Math.floor(seededAt.getTime() / 1_000); - await page.clock.install({ time: seededAt }); + await page.clock.setFixedTime(seededAt); await page.goto("/"); await seedMockStatus(page, { text: "Original draft", @@ -225,6 +225,8 @@ test("keeps an open status draft when the saved status expires", async ({ 0, ); await dialog.getByTestId("set-status-input").fill("Unsaved draft"); + // Advance the pinned Date past the deadline, then fire the pending timer. + await page.clock.setFixedTime(new Date(seededAt.getTime() + 3_000)); await page.clock.fastForward(2_500); await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0, { timeout: 5_000, @@ -249,13 +251,13 @@ test("keeps an open status draft when the saved status expires", async ({ test("opens a fresh editor when the saved status expired before opening", async ({ page, }) => { - // Causal control for the setup ordering above: the same frozen clock and + // Causal control for the setup ordering above: the same pinned clock and // seed, but the deadline is crossed before the editor opens. The dialog // must then be a new-status editor — the saved-editor precondition // assertions above would fail under this wrong ordering. const seededAt = new Date("2026-09-10T12:00:00.000Z"); const nowSeconds = Math.floor(seededAt.getTime() / 1_000); - await page.clock.install({ time: seededAt }); + await page.clock.setFixedTime(seededAt); await page.goto("/"); await seedMockStatus(page, { text: "Original draft", @@ -263,6 +265,7 @@ test("opens a fresh editor when the saved status expired before opening", async expiresAt: nowSeconds + 2, createdAt: nowSeconds, }); + await page.clock.setFixedTime(new Date(seededAt.getTime() + 5_000)); await page.clock.fastForward(5_000); await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0); await page.getByTestId("profile-popover-set-status").click();