diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index 03575adfe1..65830d63b6 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:nostr/nostr.dart' as nostr; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,7 +9,8 @@ import '../mentions/agent_identity_provider.dart'; import '../relay/relay.dart'; import 'me_sync.dart'; -/// Owned assistants from verified profiles, including agents without kind 10100. +/// Owned assistants: the owner's managed-agent records (kind 30177), confirmed +/// by verified profiles, including agents without kind 10100. /// Agents inherit their owner's community access and need not be listed in the /// community's human membership snapshot. final meAssistantsProvider = FutureProvider>(( @@ -42,57 +44,43 @@ Future isOwnedMeAssistant(MeSync sync, String pubkey) async => pubkey: pubkey, )).any((agent) => agent.pubkey == pubkey); +/// Owner-authored NIP-AP managed-agent identity records (kind 30177). +const _kindManagedAgent = 30177; +const _pageSize = 100; +const _maxInventoryPages = 10; +final _agentPubkey = RegExp(r'^[0-9a-f]{64}$'); + Future> _loadAssistants( MeSync sync, RelaySessionNotifier session, { String? pubkey, }) async { + final candidates = pubkey == null + ? await _managedAgentPubkeys(sync, session) + : [pubkey]; final profiles = {}; - final seen = {}; - const pageSize = 100; - // The current relay only indexes single-letter NIP-01 tag filters. It - // silently drops #auth, so owner-scoped discovery needs relay support. - // Until then fail visibly at this bound rather than drain the directory. - const maxPages = 10; - for (var page = 1; page <= maxPages; page++) { + for (var offset = 0; offset < candidates.length; offset += _pageSize) { + final batch = candidates.sublist( + offset, + min(offset + _pageSize, candidates.length), + ); sync.check(); final events = await session.queryRelay([ - NostrFilter( - kinds: const [0], - authors: pubkey == null ? null : [pubkey], - limit: pageSize, - extensions: {'page': page}, - ), + NostrFilter(kinds: const [0], authors: batch, limit: batch.length), ]); sync.check(); - if (events.length > pageSize) { - throw StateError('Agent directory exceeded the requested page size.'); - } - var added = false; for (final event in events) { - if (event.kind != 0 || (pubkey != null && event.pubkey != pubkey)) { - continue; - } - if (seen.contains(event.id)) continue; + if (event.kind != 0 || !batch.contains(event.pubkey)) continue; try { nostr.Event.fromJson(jsonEncode(event.toJson())); } catch (_) { continue; } - if (!seen.add(event.id)) continue; - added = true; final previous = profiles[event.pubkey]; - if (previous == null || - event.createdAt > previous.createdAt || - (event.createdAt == previous.createdAt && - event.id.compareTo(previous.id) < 0)) { + if (previous == null || _newer(event, previous)) { profiles[event.pubkey] = event; } } - if (events.length < pageSize) break; - if (!added || page == maxPages) { - throw StateError('Could not finish loading assistants. Try again.'); - } } final agents = []; for (final event in profiles.values) { @@ -107,3 +95,72 @@ Future> _loadAssistants( } return agents; } + +/// Candidate agents from the owner's own managed-agent records, as desktop +/// discovers them. The records are discovery hints only: each candidate's +/// profile must still carry a valid NIP-OA attestation by the owner. +Future> _managedAgentPubkeys( + MeSync sync, + RelaySessionNotifier session, +) async { + final heads = {}; + NostrEvent? cursor; + for (var page = 0; ; page++) { + if (page == _maxInventoryPages) { + throw StateError('Could not finish loading assistants. Try again.'); + } + sync.check(); + final events = await session.queryRelay([ + NostrFilter( + kinds: const [_kindManagedAgent], + authors: [sync.pubkey], + limit: _pageSize, + until: cursor?.createdAt, + extensions: {'before_id': ?cursor?.id}, + ), + ]); + sync.check(); + if (events.length > _pageSize) { + throw StateError('Agent inventory exceeded the requested page size.'); + } + // Reject mismatched pages rather than presenting another account's agents. + if (events.any( + (e) => e.kind != _kindManagedAgent || e.pubkey != sync.pubkey, + )) { + throw StateError('Agent inventory response is outside its owner.'); + } + for (final event in events) { + final agent = event.getTagValue('d'); + if (agent == null) continue; + final previous = heads[agent]; + if (previous == null || _newer(event, previous)) heads[agent] = event; + } + if (events.length < _pageSize) break; + // Relay order is newest first, then ID ascending at ties. + final last = events.reduce((a, b) => _newer(a, b) ? b : a); + if (cursor != null && !_newer(cursor, last)) { + throw StateError('Could not finish loading assistants. Try again.'); + } + cursor = last; + } + return [ + for (final MapEntry(key: agent, value: event) in heads.entries) + if (_agentPubkey.hasMatch(agent) && _hasName(event)) agent, + ]; +} + +bool _newer(NostrEvent a, NostrEvent b) => + a.createdAt > b.createdAt || + (a.createdAt == b.createdAt && a.id.compareTo(b.id) < 0); + +/// Desktop omits managed-agent records without a name; match its inventory. +bool _hasName(NostrEvent event) { + try { + final body = jsonDecode(event.content); + return body is Map && + body['name'] is String && + (body['name'] as String).trim().isNotEmpty; + } catch (_) { + return false; + } +} diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index 62aee1b2a5..d203ce3a14 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:typed_data'; + import 'package:buzz/shared/me/me_assistants.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -9,11 +10,15 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; class _Relay extends RelaySessionNotifier { - final List> pages; - final requestedPages = []; - final requestedTags = >[]; - final requestedAuthors = ?>[]; - _Relay(this.pages); + final List> inventory; + final List profiles; + final inventoryFilters = >[]; + final profileFilters = >[]; + _Relay({this.inventory = const [], this.profiles = const []}); + List?> get profileAuthors => [ + for (final filter in profileFilters) + (filter['authors'] as List?)?.cast(), + ]; @override SessionState build() => const SessionState(status: SessionStatus.connected); @override @@ -21,12 +26,16 @@ class _Relay extends RelaySessionNotifier { List filters, { Duration timeout = const Duration(seconds: 8), }) async { + final filter = filters.single.toJson(); + if (filters.single.kinds.single == 30177) { + inventoryFilters.add(filter); + final page = inventoryFilters.length - 1; + return page < inventory.length ? inventory[page] : const []; + } expect(filters.single.kinds, [0]); - requestedTags.add(filters.single.toJson()); - requestedAuthors.add(filters.single.authors); - final page = filters.single.toJson()['page'] as int; - requestedPages.add(page); - return pages[page - 1]; + profileFilters.add(filter); + // Return every profile so the client must enforce the requested authors. + return profiles; } } @@ -68,6 +77,29 @@ void main() { ); } + var fakeAgents = 0; + String fakeAgent() => (++fakeAgents).toRadixString(16).padLeft(64, 'a'); + // Discovery does not verify inventory signatures (the relay does), so the + // records skip Schnorr signing to keep large pagination fixtures fast. + var records = 0; + final ownerPubkey = owner.public; + NostrEvent managed( + String d, { + nostr.Keys? by, + int time = 1, + Object? content = const {'name': 'Agent'}, + }) => NostrEvent( + id: (++records).toRadixString(16).padLeft(64, '0'), + pubkey: by?.public ?? ownerPubkey, + createdAt: time, + kind: 30177, + tags: [ + ['d', d], + ], + content: jsonEncode(content), + sig: '', + ); + ProviderContainer setup(_Relay relay) { final sync = MeSync( relay, @@ -89,22 +121,45 @@ void main() { return container; } - test('finds owned kind 0 agents without legacy directory entries', () async { + test('finds verified agents from the owner inventory only', () async { final valid = profile(agent, ownedBy: owner); final forged = valid.toJson()..['pubkey'] = other.public; - final container = setup( - _Relay([ + final otherOwned = nostr.Keys.generate(); + final unlisted = nostr.Keys.generate(); + final relay = _Relay( + inventory: [ [ - profile(owner), - profile(nostr.Keys.generate(), ownedBy: other), - NostrEvent.fromJson(forged), - valid, + managed(agent.public), + managed(otherOwned.public), + managed(other.public), ], - ]), + ], + profiles: [ + profile(owner), + profile(otherOwned, ownedBy: other), + NostrEvent.fromJson(forged), + profile(unlisted, ownedBy: owner), + valid, + ], ); - final choices = await container.read(meAssistantsProvider.future); + final choices = await setup(relay).read(meAssistantsProvider.future); expect(choices.map((a) => a.pubkey), [agent.public]); expect(choices.single.displayName, 'My assistant'); + expect(relay.inventoryFilters.single, { + 'kinds': [30177], + 'authors': [owner.public], + 'limit': 100, + }); + expect( + relay.profileAuthors.single, + unorderedEquals([agent.public, otherOwned.public, other.public]), + ); + expect(relay.profileFilters.single['limit'], 3); + }); + test('does not query profiles without managed agents', () async { + final relay = _Relay(profiles: [profile(agent, ownedBy: owner)]); + expect(await setup(relay).read(meAssistantsProvider.future), isEmpty); + expect(relay.profileFilters, isEmpty); }); for (final malformed in [7, true, 'wrong', {}, []]) { test( @@ -112,8 +167,11 @@ void main() { () async { final broken = nostr.Keys.generate(); final container = setup( - _Relay([ - [ + _Relay( + inventory: [ + [managed(broken.public), managed(agent.public)], + ], + profiles: [ profile( broken, ownedBy: owner, @@ -130,7 +188,7 @@ void main() { ), profile(agent, ownedBy: owner), ], - ]), + ), ); final choices = await container.read(meAssistantsProvider.future); expect( @@ -149,51 +207,116 @@ void main() { } test('latest profile removes obsolete ownership', () async { final container = setup( - _Relay([ - [profile(agent, ownedBy: owner), profile(agent, time: 2)], - ]), + _Relay( + inventory: [ + [managed(agent.public)], + ], + profiles: [ + profile(agent, ownedBy: owner), + profile(agent, time: 2), + ], + ), ); expect(await container.read(meAssistantsProvider.future), isEmpty); }); - test('reads beyond the first directory page', () async { - final relay = _Relay([ - [for (var i = 0; i < 100; i++) profile(owner, time: i + 1)], - [profile(agent, ownedBy: owner)], - ]); + test('skips unnamed, malformed, and superseded inventory records', () async { + final unnamed = nostr.Keys.generate(); + final renamed = nostr.Keys.generate(); + final relay = _Relay( + inventory: [ + [ + managed(renamed.public, time: 3, content: {'name': ' '}), + managed(agent.public, time: 2), + managed(renamed.public, time: 2), + managed(unnamed.public, content: {'name': 7}), + managed(agent.public.toUpperCase()), + managed('not-a-pubkey'), + ], + ], + profiles: [ + profile(agent, ownedBy: owner), + profile(unnamed, ownedBy: owner), + profile(renamed, ownedBy: owner), + ], + ); + final choices = await setup(relay).read(meAssistantsProvider.future); + expect(choices.map((a) => a.pubkey), [agent.public]); + expect(relay.profileAuthors.single, [agent.public]); + }); + test('reads beyond the first inventory page with a cursor', () async { + final first = [ + for (var i = 0; i < 100; i++) managed(fakeAgent(), time: 200 - i), + ]; + final relay = _Relay( + inventory: [ + first, + [managed(agent.public, time: 101)], + ], + profiles: [profile(agent, ownedBy: owner)], + ); expect( (await setup(relay).read(meAssistantsProvider.future)).single.pubkey, agent.public, ); - expect(relay.requestedPages, [1, 2]); + expect(relay.inventoryFilters.length, 2); + expect(relay.inventoryFilters.last['until'], 101); + expect(relay.inventoryFilters.last['before_id'], first.last.id); + expect(relay.profileAuthors.map((a) => a!.length), [100, 1]); + expect(relay.profileFilters.map((f) => f['limit']), [100, 1]); }); test( - 'directory discovery is bounded even when the relay keeps paging', + 'inventory paging is bounded even when the relay keeps paging', () async { - final repeated = [ - for (var i = 0; i < 99; i++) profile(agent, ownedBy: owner, time: i), - ]; - final relay = _Relay([ - for (var page = 0; page < 11; page++) - [...repeated, profile(agent, ownedBy: owner, time: 100 + page)], - ]); + final relay = _Relay( + inventory: [ + for (var page = 0; page < 11; page++) + [ + for (var i = 0; i < 100; i++) + managed(fakeAgent(), time: 2000 - page * 100 - i), + ], + ], + ); await expectLater( setup(relay).read(meAssistantsProvider.future), throwsStateError, ); - expect(relay.requestedPages.length, 10); - for (final filter in relay.requestedTags) { - expect(filter.containsKey('#auth'), isFalse); - expect(filter['limit'], 100); - } + expect(relay.inventoryFilters.length, 10); + expect(relay.profileFilters, isEmpty); }, ); + test('inventory paging fails when the cursor does not advance', () async { + final page = [for (var i = 0; i < 100; i++) managed(fakeAgent())]; + final relay = _Relay(inventory: [page, page]); + await expectLater( + setup(relay).read(meAssistantsProvider.future), + throwsStateError, + ); + expect(relay.inventoryFilters.length, 2); + expect(relay.profileFilters, isEmpty); + }); + test('rejects inventory records outside the owner', () async { + final relay = _Relay( + inventory: [ + [managed(agent.public, by: other)], + ], + profiles: [profile(agent, ownedBy: owner)], + ); + await expectLater( + setup(relay).read(meAssistantsProvider.future), + throwsStateError, + ); + expect(relay.profileFilters, isEmpty); + }); for (final conditions in ['kind=1', 'created_at>1', 'created_at<1']) { test( 'discovery and send revalidation reject inapplicable $conditions', () async { - final relay = _Relay([ - [profile(agent, ownedBy: owner, conditions: conditions)], - ]); + final relay = _Relay( + inventory: [ + [managed(agent.public)], + ], + profiles: [profile(agent, ownedBy: owner, conditions: conditions)], + ); final container = setup(relay); expect(await container.read(meAssistantsProvider.future), isEmpty); expect( @@ -207,28 +330,27 @@ void main() { ); } test('creation revalidates only the chosen assistant', () async { - final relay = _Relay([ - [profile(agent, ownedBy: owner)], - ]); + final relay = _Relay(profiles: [profile(agent, ownedBy: owner)]); final container = setup(relay); expect( await isOwnedMeAssistant(container.read(meSyncProvider), agent.public), isTrue, ); - expect(relay.requestedAuthors, [ + expect(relay.inventoryFilters, isEmpty); + expect(relay.profileAuthors, [ [agent.public], ]); }); test( 'creation rejects revoked ownership and unrelated returned profiles', () async { - final relay = _Relay([ - [ + final relay = _Relay( + profiles: [ profile(agent, ownedBy: owner), profile(agent, time: 2), profile(other, ownedBy: owner), ], - ]); + ); final container = setup(relay); expect( await isOwnedMeAssistant(container.read(meSyncProvider), agent.public),