diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index 6f3bae57a27..4c66f4c841e 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -51,13 +51,19 @@ List buildMentionCandidates({ required Map userCache, required Map ownerByAgentPubkey, List searchResults = const [], + Set archivedPubkeys = const {}, String? currentPubkey, }) { final candidates = []; final seen = {}; + final currentLower = currentPubkey?.toLowerCase(); + final archived = {for (final pk in archivedPubkeys) pk.toLowerCase()}; + bool isArchived(String pubkey) => + pubkey != currentLower && archived.contains(pubkey); for (final member in members) { final pk = member.pubkey.toLowerCase(); + if (isArchived(pk)) continue; if (!seen.add(pk)) continue; final profile = userCache[pk]; final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey; @@ -89,6 +95,7 @@ List buildMentionCandidates({ for (final agent in relayAgents) { final pk = agent.pubkey; + if (isArchived(pk)) continue; if (seen.contains(pk)) continue; if (!sharedAgentPubkeys.contains(pk)) continue; seen.add(pk); @@ -108,9 +115,9 @@ List buildMentionCandidates({ ); } - final currentLower = currentPubkey?.toLowerCase(); for (final profile in searchResults) { final pk = profile.pubkey.toLowerCase(); + if (isArchived(pk)) continue; if (seen.contains(pk)) continue; final ownerPubkey = ownerByAgentPubkey[pk] ?? profile.ownerPubkey; final isAgent = ownerPubkey != null || directoryPubkeys.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 85930a45995..b2e3a5f1584 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -90,6 +90,8 @@ final mentionCandidatesProvider = Provider.family ref.watch(agentDirectoryProvider).asData?.value ?? const []; final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + final archivedPubkeys = + ref.watch(archivedIdentityPubkeysProvider).asData?.value ?? const {}; final channels = channelsAsync.asData?.value ?? const []; final userCache = ref.watch(userCacheProvider); final currentPubkey = ref.watch(currentPubkeyProvider); @@ -109,6 +111,7 @@ final mentionCandidatesProvider = Provider.family userCache: userCache, ownerByAgentPubkey: owners, searchResults: searchResults, + archivedPubkeys: archivedPubkeys, currentPubkey: currentPubkey, ); diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index ea6a2ee50fd..7489e9da259 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -1,12 +1,222 @@ +import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; import '../../shared/crypto/nip_oa.dart'; import '../../shared/relay/relay.dart'; +final _hexPubkey = RegExp(r'^[0-9a-f]{64}$', caseSensitive: false); + +/// HTTP transport used to discover the active relay's NIP-11 `self` key. +/// Tests override this provider; production owns and closes one client. +final archivedIdentityHttpClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + +/// A verified relay-scoped NIP-IA snapshot. +@immutable +class ArchivedIdentitySnapshot { + final String eventId; + final int createdAt; + final Set pubkeys; + + const ArchivedIdentitySnapshot({ + required this.eventId, + required this.createdAt, + required this.pubkeys, + }); +} + +/// Reads the stable relay identity advertised by NIP-11. +/// +/// Invalid or unavailable relay metadata fails open: callers receive `null` +/// and do not hide any identity. +Future fetchRelayIdentityPubkey( + http.Client client, + String relayUrl, +) async { + try { + final uri = Uri.parse(relayUrl.trim()); + final response = await client + .get(uri, headers: const {'Accept': 'application/nostr+json'}) + .timeout(const Duration(seconds: 5)); + if (response.statusCode < 200 || response.statusCode >= 300) return null; + final document = jsonDecode(response.body); + if (document is! Map) return null; + final relayPubkey = document['self']; + if (relayPubkey is! String || !_hexPubkey.hasMatch(relayPubkey)) { + return null; + } + return relayPubkey.toLowerCase(); + } catch (_) { + return null; + } +} + +/// Verifies and parses one NIP-IA `kind:13535` snapshot. +/// +/// Trust requires all three anchors: the NIP-11 relay author, a valid NIP-01 +/// id/signature, and exactly one NIP-70 `["-"]` marker. Malformed `p` tags +/// are ignored rather than allowed to poison the complete snapshot. +ArchivedIdentitySnapshot? parseArchivedIdentitySnapshot( + NostrEvent event, + String relayPubkey, +) { + final normalizedRelayPubkey = relayPubkey.toLowerCase(); + if (event.kind != EventKind.archivedIdentities || + event.pubkey.toLowerCase() != normalizedRelayPubkey || + !_hexPubkey.hasMatch(normalizedRelayPubkey)) { + return null; + } + final nip70Markers = event.tags + .where((tag) => tag.isNotEmpty && tag.first == '-') + .toList(); + if (nip70Markers.length != 1 || nip70Markers.single.length != 1) { + return null; + } + try { + final verified = nostr.Event.fromJson(jsonEncode(event.toJson())); + if (verified.id != event.id) return null; + } catch (_) { + return null; + } + + return ArchivedIdentitySnapshot( + eventId: event.id, + createdAt: event.createdAt, + pubkeys: Set.unmodifiable({ + for (final tag in event.tags) + if (tag.length >= 2 && tag.first == 'p' && _hexPubkey.hasMatch(tag[1])) + tag[1].toLowerCase(), + }), + ); +} + +/// NIP-01 replacement ordering: newest timestamp wins; equal timestamps keep +/// the lexicographically lowest event id. +ArchivedIdentitySnapshot? latestArchivedIdentitySnapshot( + Iterable events, + String relayPubkey, +) { + ArchivedIdentitySnapshot? latest; + for (final event in events) { + final candidate = parseArchivedIdentitySnapshot(event, relayPubkey); + if (candidate == null) continue; + if (latest == null || + candidate.createdAt > latest.createdAt || + (candidate.createdAt == latest.createdAt && + candidate.eventId.compareTo(latest.eventId) < 0)) { + latest = candidate; + } + } + return latest; +} + +class ArchivedIdentityPubkeysNotifier extends AsyncNotifier> { + void Function()? _unsubscribe; + ArchivedIdentitySnapshot? _latest; + int _generation = 0; + + @override + Future> build() async { + final relayConfig = ref.watch(relayConfigProvider); + final sessionState = ref.watch(relaySessionProvider); + final generation = ++_generation; + _clearSubscription(); + ref.onDispose(() { + _generation++; + _clearSubscription(); + }); + + if (sessionState.status != SessionStatus.connected) return const {}; + final relayPubkey = await fetchRelayIdentityPubkey( + ref.read(archivedIdentityHttpClientProvider), + relayConfig.baseUrl, + ); + if (relayPubkey == null || generation != _generation) return const {}; + + final session = ref.read(relaySessionProvider.notifier); + try { + final events = await session.fetchHistory( + NostrFilters.archivedIdentities(relayPubkey), + ); + if (generation != _generation) return const {}; + _latest = latestArchivedIdentitySnapshot(events, relayPubkey); + unawaited(_subscribe(session, relayPubkey, generation)); + return _latest?.pubkeys ?? const {}; + } catch (error) { + debugPrint('[ArchivedIdentities] snapshot fetch failed: $error'); + return const {}; + } + } + + Future _subscribe( + RelaySessionNotifier session, + String relayPubkey, + int generation, + ) async { + // Overlap the history/live handoff so a snapshot published in the gap is + // replayed instead of missed. Replacement ordering deduplicates it. + final since = + (_latest?.createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000) - + 5; + try { + final unsubscribe = await session.subscribe( + NostrFilters.archivedIdentities(relayPubkey).copyWithSince(since), + (event) => _acceptLiveSnapshot(event, relayPubkey, generation), + ); + if (generation != _generation) { + unsubscribe(); + return; + } + _unsubscribe = unsubscribe; + } catch (error) { + if (generation == _generation) { + debugPrint('[ArchivedIdentities] live subscription failed: $error'); + } + } + } + + void _acceptLiveSnapshot( + NostrEvent event, + String relayPubkey, + int generation, + ) { + if (generation != _generation) return; + final candidate = parseArchivedIdentitySnapshot(event, relayPubkey); + if (candidate == null) return; + final latest = _latest; + if (latest != null && + (candidate.createdAt < latest.createdAt || + (candidate.createdAt == latest.createdAt && + candidate.eventId.compareTo(latest.eventId) >= 0))) { + return; + } + _latest = candidate; + state = AsyncData(candidate.pubkeys); + } + + void _clearSubscription() { + _unsubscribe?.call(); + _unsubscribe = null; + _latest = null; + } +} + +/// Relay-scoped identities hidden from forward-looking discovery surfaces. +/// Fail-open while disconnected, loading, or when relay proof is invalid. +final archivedIdentityPubkeysProvider = + AsyncNotifierProvider>( + ArchivedIdentityPubkeysNotifier.new, + ); + /// A relay agent parsed from its kind:10100 agent-profile event. /// /// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart index d9d7a0b6f7e..a843c3083d0 100644 --- a/mobile/lib/shared/relay/nostr_filters.dart +++ b/mobile/lib/shared/relay/nostr_filters.dart @@ -205,6 +205,13 @@ abstract final class NostrFilters { static NostrFilter relayMembers() => const NostrFilter(kinds: [EventKind.relayMembership], limit: 1); + /// Relay-scoped archived identity snapshot (NIP-IA, kind:13535). + static NostrFilter archivedIdentities(String relayPubkey) => NostrFilter( + kinds: const [EventKind.archivedIdentities], + authors: [relayPubkey.toLowerCase()], + limit: 10, + ); + /// Agent profiles (kind:10100). static NostrFilter agentProfiles() => const NostrFilter(kinds: [10100], limit: 100); diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 43b34c0e172..23049b39f27 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -16,6 +16,9 @@ abstract final class EventKind { /// Kind:13534 event containing the current relay-community membership. static const relayMembership = 13534; + + /// Kind:13535 relay-signed snapshot of archived identities (NIP-IA). + static const archivedIdentities = 13535; static const streamMessage = 9; static const nip29DeleteEvent = 9005; static const presenceUpdate = 20001; diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 9694d68535d..47863a1d596 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -280,5 +280,87 @@ void main() { expect(candidates, hasLength(1)); expect(candidates.single.isMember, isTrue); }); + + test('archived channel members are excluded', () { + final candidates = buildMentionCandidates( + members: [member(memberPubkey), member(userPubkey)], + relayAgents: const [], + sharedChannelIds: const {}, + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {memberPubkey}, + currentPubkey: userPubkey, + ); + + expect(candidates.map((candidate) => candidate.pubkey), [userPubkey]); + }); + + test( + 'archived directory agent is excluded while a live namesake remains', + () { + final liveAgentPubkey = '4' * 64; + final candidates = buildMentionCandidates( + members: const [], + relayAgents: [ + AgentDirectoryEntry( + pubkey: agentPubkey, + displayName: 'Nova', + respondTo: 'anyone', + channelIds: const ['chan-1'], + ), + AgentDirectoryEntry( + pubkey: liveAgentPubkey, + displayName: 'Nova', + respondTo: 'anyone', + channelIds: const ['chan-1'], + ), + ], + sharedChannelIds: const {'chan-1'}, + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {agentPubkey}, + currentPubkey: userPubkey, + ); + + expect(candidates, hasLength(1)); + expect(candidates.single.pubkey, liveAgentPubkey); + expect(candidates.single.displayName, 'Nova'); + }, + ); + + test('archived global-search agents are excluded', () { + final candidates = buildMentionCandidates( + members: const [], + relayAgents: const [], + sharedChannelIds: const {}, + userCache: const {}, + ownerByAgentPubkey: const {}, + searchResults: [ + UserProfile( + pubkey: agentPubkey, + displayName: 'Retired Nova', + ownerPubkey: userPubkey, + ), + ], + archivedPubkeys: {agentPubkey}, + currentPubkey: userPubkey, + ); + + expect(candidates, isEmpty); + }); + + test('current user remains visible when the relay lists self archived', () { + final candidates = buildMentionCandidates( + members: [member(userPubkey)], + relayAgents: const [], + sharedChannelIds: const {}, + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {userPubkey}, + currentPubkey: userPubkey, + ); + + expect(candidates.map((candidate) => candidate.pubkey), [userPubkey]); + }); }); } diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart index 0ea0afb2687..d41acbac0ac 100644 --- a/mobile/test/shared/mentions/agent_identity_provider_test.dart +++ b/mobile/test/shared/mentions/agent_identity_provider_test.dart @@ -1,13 +1,183 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart'; 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'; void main() { + group('NIP-IA archived identity snapshots', () { + test( + 'accepts a valid relay-signed snapshot and ignores malformed p tags', + () { + final event = _archiveSnapshot( + secretKey: _relay.secret, + createdAt: 100, + pubkeys: [_archivedA.toUpperCase(), 'not-a-pubkey'], + ); + + final parsed = parseArchivedIdentitySnapshot(event, _relayPubkey); + + expect(parsed, isNotNull); + expect(parsed!.pubkeys, {_archivedA}); + }, + ); + + test( + 'rejects wrong author, invalid signature, and missing NIP-70 marker', + () { + final wrongAuthor = _archiveSnapshot( + secretKey: _otherRelay.secret, + createdAt: 100, + pubkeys: [_archivedA], + ); + final valid = _archiveSnapshot( + secretKey: _relay.secret, + createdAt: 101, + pubkeys: [_archivedA], + ); + final invalidSignature = NostrEvent( + id: valid.id, + pubkey: valid.pubkey, + createdAt: valid.createdAt, + kind: valid.kind, + tags: valid.tags, + content: valid.content, + sig: '0' * 128, + ); + final missingMarker = _archiveSnapshot( + secretKey: _relay.secret, + createdAt: 102, + pubkeys: [_archivedA], + includeMarker: false, + ); + + expect( + parseArchivedIdentitySnapshot(wrongAuthor, _relayPubkey), + isNull, + ); + expect( + parseArchivedIdentitySnapshot(invalidSignature, _relayPubkey), + isNull, + ); + expect( + parseArchivedIdentitySnapshot(missingMarker, _relayPubkey), + isNull, + ); + }, + ); + + test('newer snapshot wins and same-time lower event id breaks ties', () { + final old = _archiveSnapshot( + secretKey: _relay.secret, + createdAt: 100, + pubkeys: [_archivedA], + ); + final newer = _archiveSnapshot( + secretKey: _relay.secret, + createdAt: 101, + pubkeys: [_archivedB], + ); + expect( + latestArchivedIdentitySnapshot([newer, old], _relayPubkey)!.pubkeys, + {_archivedB}, + ); + + final sameTimeA = _archiveSnapshot( + secretKey: _relay.secret, + createdAt: 102, + pubkeys: [_archivedA], + ); + final sameTimeB = _archiveSnapshot( + secretKey: _relay.secret, + createdAt: 102, + pubkeys: [_archivedB], + ); + final expected = sameTimeA.id.compareTo(sameTimeB.id) < 0 + ? _archivedA + : _archivedB; + expect( + latestArchivedIdentitySnapshot([ + sameTimeA, + sameTimeB, + ], _relayPubkey)!.pubkeys, + {expected}, + ); + }); + + test( + 'reads relay identity from NIP-11 and rejects malformed metadata', + () async { + final validClient = MockClient( + (_) async => http.Response(jsonEncode({'self': _relayPubkey}), 200), + ); + final malformedClient = MockClient( + (_) async => http.Response(jsonEncode({'self': 'invalid'}), 200), + ); + + expect( + await fetchRelayIdentityPubkey(validClient, 'https://relay.example'), + _relayPubkey, + ); + expect( + await fetchRelayIdentityPubkey( + malformedClient, + 'https://relay.example', + ), + isNull, + ); + }, + ); + + test('live valid replacement refreshes the provider output', () async { + final initial = _archiveSnapshot( + secretKey: _relay.secret, + createdAt: 100, + pubkeys: [_archivedA], + ); + final replacement = _archiveSnapshot( + secretKey: _relay.secret, + createdAt: 101, + pubkeys: [_archivedB], + ); + final relaySession = _ArchiveRelaySessionNotifier(initial); + final client = MockClient( + (_) async => http.Response(jsonEncode({'self': _relayPubkey}), 200), + ); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_TestRelayConfigNotifier.new), + relaySessionProvider.overrideWith(() => relaySession), + archivedIdentityHttpClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + archivedIdentityPubkeysProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + expect(await container.read(archivedIdentityPubkeysProvider.future), { + _archivedA, + }); + await relaySession.subscribed; + relaySession.emit(replacement); + await _pumpEventQueue(); + + expect(container.read(archivedIdentityPubkeysProvider).value, { + _archivedB, + }); + }); + }); + test('refreshes channel bot roles from live membership updates', () async { final relaySession = _MembershipRelaySessionNotifier([ _membershipEvent(role: 'bot'), @@ -139,6 +309,33 @@ void main() { const _channelId = '11111111-1111-4111-8111-111111111111'; const _agentPubkey = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +final _relay = nostr.Keys.generate(); +final _otherRelay = nostr.Keys.generate(); +final _relayPubkey = _relay.public; +const _archivedA = + '1111111111111111111111111111111111111111111111111111111111111111'; +const _archivedB = + '2222222222222222222222222222222222222222222222222222222222222222'; + +NostrEvent _archiveSnapshot({ + required String secretKey, + required int createdAt, + required List pubkeys, + bool includeMarker = true, +}) { + final event = nostr.Event.from( + kind: EventKind.archivedIdentities, + content: '', + tags: [ + if (includeMarker) ['-'], + for (final pubkey in pubkeys) ['p', pubkey], + ], + secretKey: secretKey, + createdAt: createdAt, + verify: false, + ); + return NostrEvent.fromJson(event.toMap()); +} NostrEvent _membershipEvent({required String role}) => NostrEvent( id: 'membership-$role', @@ -207,6 +404,43 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { } } +class _TestRelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig(baseUrl: 'https://relay.example'); +} + +class _ArchiveRelaySessionNotifier extends RelaySessionNotifier { + final NostrEvent initial; + final Completer _subscribed = Completer(); + void Function(NostrEvent)? _onEvent; + + _ArchiveRelaySessionNotifier(this.initial); + + Future get subscribed => _subscribed.future; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [initial]; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + _onEvent = onEvent; + if (!_subscribed.isCompleted) _subscribed.complete(); + return () => _onEvent = null; + } + + void emit(NostrEvent event) => _onEvent?.call(event); +} + class _LiveSubscription { final NostrFilter filter; final void Function(NostrEvent) onEvent;