diff --git a/mobile/lib/shared/mentions/agent_authorization.dart b/mobile/lib/shared/mentions/agent_authorization.dart index ec6e2438cd..1625db8580 100644 --- a/mobile/lib/shared/mentions/agent_authorization.dart +++ b/mobile/lib/shared/mentions/agent_authorization.dart @@ -22,13 +22,37 @@ Future> readAgentAuthorization( } final authority = await session.fetchRelaySelf(); check(); - final membership = await _membershipPages( + var membership = await _membershipPages( session, authority, viewer, channelId, check, ); + if (channelId == null) { + // #p discovers destinations, not authority: a replacement removing the + // viewer cannot match that filter. Re-read each exact coordinate without + // #p, and never retain a discovery snapshot if its head is absent. + final destinations = membership + .where((e) => e.kind == 39002 && e.pubkey == authority) + .map((e) => e.getTagValue('d')) + .whereType() + .toSet(); + if (destinations.length > 1000) { + throw StateError('Membership destination budget exceeded'); + } + membership = await _queryAgentFilters(session, [ + for (final destination in destinations) + NostrFilter( + kinds: const [39002], + authors: [authority], + tags: { + '#d': [destination], + }, + limit: 1, + ), + ], checkCurrent: check); + } check(); final runtime = await _queryAgentFilters(session, [ for (final key in requestedKeys) diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 7380a8b8e0..5a88e25f65 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -19,6 +19,8 @@ part 'agent_authorization.dart'; class AgentDirectoryEntry { final String pubkey; final String? displayName; + /// Owner authenticated by the agent's verified NIP-OA kind:0 attestation, + /// not a runtime ownership claim. Null means no authenticated owner here. final String? ownerPubkey; final String? respondTo; final List respondToAllowlist; diff --git a/mobile/test/shared/mentions/agent_authorization_test.dart b/mobile/test/shared/mentions/agent_authorization_test.dart index 6d5fc8c06f..2d5e4bbc27 100644 --- a/mobile/test/shared/mentions/agent_authorization_test.dart +++ b/mobile/test/shared/mentions/agent_authorization_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -149,6 +150,57 @@ void main() { }, ); + test( + 'global discovery re-reads removal heads without viewer filter', + () async { + final old = signed( + relay, + 39002, + '', + tags: [ + ['d', 'room'], + ['p', owner.public], + ['p', agent.public, '', 'bot'], + ], + ); + final removed = members(time: 101, includeViewer: false); + final session = _ReadSession([runtime], relay.public); + var removedNow = false; + session.page = (filters) { + final filter = filters.single; + expect(filter.authors, [relay.public]); + if (filter.tags.containsKey('#p')) { + expect(filter.tags['#p'], [owner.public]); + return [old]; // The removed head cannot match #p=viewer. + } + expect(filter.tags, { + '#d': ['room'], + }); + expect(filter.limit, 1); + return [removedNow ? removed : old]; + }; + final before = await read(session, destination: null); + expect(before.single.channelIds, ['room']); + removedNow = true; + final after = await read( + _ReadSession([runtime], relay.public)..page = session.page, + destination: null, + ); + expect(after, isEmpty); + expect( + buildMentionCandidates( + members: [], + relayAgents: after, + sharedChannelIds: {'room'}, + userCache: {}, + ownerByAgentPubkey: {}, + currentPubkey: owner.public, + ), + isEmpty, + ); + }, + ); + test('same-second membership tie is deterministic', () async { final yes = members(); final no = members(includeAgent: false); @@ -197,6 +249,7 @@ void main() { final session = _ReadSession([owned, policy], relay.public); var calls = 0; session.page = (filters) { + if (filters.first.tags.containsKey('#d')) return [first, last]; calls++; if (calls == 1) return List.filled(500, first); expect(filters.single.until, first.createdAt);