diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index f57a651a00..ae115d29da 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -320,7 +320,10 @@ class ChannelActions { if (trimmed.isEmpty) return const []; final events = await _session.searchProfiles(trimmed, limit: limit); - return directoryUsersFromProfileEvents(events, alphabetize: false) + final people = await _ref + .read(verifiedProfilesProvider.notifier) + .searched(events); + return directoryUsersFromSearch(people, alphabetize: false) .where( (user) => _currentPubkey == null || diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index d74830ff02..aceaec21f7 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -12,6 +12,8 @@ import '../../shared/crypto/nip_oa.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/utils/string_utils.dart'; +import '../../shared/verified_profiles/verified_profile.dart'; +import '../../shared/verified_profiles/verified_profile_directory.dart'; import '../profile/profile_provider.dart'; import 'channel.dart'; import 'channel_metadata_updates.dart'; @@ -271,37 +273,35 @@ List relayMemberPubkeysFromEvents(List events) { return pubkeys.toList(); } -/// Converts kind:0 events into a deduplicated people directory. +/// Converts the people a profile search found into a directory. /// /// The directory listing is alphabetized. Search results pass /// [alphabetize] false to keep the relay's order, which ranks the best name -/// match first; each person keeps the place of their first result. +/// match first. @visibleForTesting -List directoryUsersFromProfileEvents( - List events, { +List directoryUsersFromSearch( + List people, { bool alphabetize = true, }) { - final latestByPubkey = {}; - for (final event in events) { - if (event.kind != 0) { - continue; - } - final pubkey = event.pubkey.toLowerCase(); - final current = latestByPubkey[pubkey]; - if (current == null || event.createdAt > current.createdAt) { - latestByPubkey[pubkey] = event; - } + DirectoryUser fromProfile(String pubkey, NostrEvent event) { + final profile = ProfileData.fromEvent(event); + return DirectoryUser( + pubkey: pubkey, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + nip05Handle: profile.nip05, + isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null, + ); } final users = [ - for (final event in latestByPubkey.values) - if (ProfileData.fromEvent(event) case final profile) + for (final person in people) + if (person.profile case final event?) + fromProfile(person.pubkey, event) + else DirectoryUser( - pubkey: profile.pubkey.toLowerCase(), - displayName: profile.displayName, - avatarUrl: profile.avatarUrl, - nip05Handle: profile.nip05, - isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null, + pubkey: person.pubkey, + displayName: person.verified?.displayName, ), ]; if (alphabetize) { diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index ab840014bb..521b155ea2 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -22,7 +22,7 @@ import '../../profile/presence_cache_provider.dart'; /// Typing pause before a mention query hits the relay search endpoint. const mentionSearchDebounce = Duration(milliseconds: 200); -/// Global user search for mention autocomplete — kind:0 prefix search via +/// Global user search for mention autocomplete — profile name search via /// the relay HTTP bridge. Mirrors desktop's `useInfiniteUserSearchQuery` /// feeding `useMentions` (source 4: people and agents outside the channel). /// @@ -47,21 +47,20 @@ final mentionUserSearchProvider = FutureProvider.autoDispose final session = ref.read(relaySessionProvider.notifier); final events = await session.searchProfiles(trimmed); - // Keep only the latest kind:0 event per pubkey (the bridge does not - // honor the `kinds` filter under search, and may return several - // profile revisions — mirrors desktop's `list_user_search_results`). - final latestByPubkey = {}; - for (final event in events) { - if (event.kind != 0) continue; - final pk = event.pubkey.toLowerCase(); - final current = latestByPubkey[pk]; - if (current == null || event.createdAt > current.createdAt) { - latestByPubkey[pk] = event; - } - } - + // Each person once, by their newest kind:0; someone with none, by + // their verified head. + final found = await ref + .read(verifiedProfilesProvider.notifier) + .searched(events); final people = [ - for (final event in latestByPubkey.values) _profileFromEvent(event), + for (final person in found) + if (person.profile case final profile?) + _profileFromEvent(profile) + else + UserProfile( + pubkey: person.pubkey, + displayName: person.verified?.displayName, + ), ]; // A search that outlived its query or community is not kept. if (!disposed) ref.read(mentionSearchCacheProvider).put(trimmed, people); @@ -317,7 +316,7 @@ List _chooserCandidates( candidates = [ for (final candidate in candidates) switch (verified[candidate.pubkey.toLowerCase()]) { - final profile? => candidate.withVerifiedName(profile.displayName), + final profile? => candidate.withVerifiedName(profile), null => candidate, }, ]; @@ -360,7 +359,7 @@ List verifiedMentionMatches( pubkey: key, name: value.displayName, label: value.displayName, - aliases: [value.displayName], + aliases: [value.displayName, ...verifiedAliases(value)], member: false, ), ], query).isNotEmpty) diff --git a/mobile/lib/features/channels/mentions/mention_ranking.dart b/mobile/lib/features/channels/mentions/mention_ranking.dart index 2f1bae6f4e..6f9252ae1a 100644 --- a/mobile/lib/features/channels/mentions/mention_ranking.dart +++ b/mobile/lib/features/channels/mentions/mention_ranking.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import '../../../shared/mentions/mention_rules.dart'; import '../../../shared/utils/string_utils.dart'; +import '../../../shared/verified_profiles/verified_profile.dart'; /// A mention autocomplete candidate. Mirrors the desktop's /// `MentionCandidateForRanking` (desktop/src/features/messages/lib/mentionRanking.ts). @@ -58,9 +59,12 @@ class MentionCandidate { aliases: aliases, ); - /// This candidate named by its NIP-FI verified [name], which it is then - /// matched, ranked and inserted as. The self-declared name stays searchable. - MentionCandidate withVerifiedName(String name) { + /// This candidate named by its NIP-FI [verified] display name, which it is + /// then matched, ranked and inserted as. The self-declared name stays + /// searchable, and so do the verified full name and username, which the + /// relay's profile search matches too. + MentionCandidate withVerifiedName(VerifiedProfile verified) { + final name = verified.displayName; final declared = displayName?.trim(); return MentionCandidate( pubkey: pubkey, @@ -75,8 +79,8 @@ class MentionCandidate { contextLabel: contextLabel, aliases: [ ...aliases, - if (declared != null && declared.isNotEmpty && declared != name) - declared, + for (final alias in [declared, ...verifiedAliases(verified)]) + if (alias != null && alias.isNotEmpty && alias != name) alias, ], ); } diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 84f760701a..0285df5eb0 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -24,6 +24,7 @@ import 'relay_query_response.dart'; import 'relay_rate_limit_gate.dart'; import 'relay_reconnect_policy.dart'; import 'read_state_snapshot.dart'; +import '../verified_profiles/verified_profile.dart'; import 'relay_session_types.dart'; import 'relay_socket.dart'; import 'package:buzz/shared/crypto/event_signing.dart'; @@ -168,7 +169,9 @@ class RelaySessionNotifier extends Notifier { /// A relay without that endpoint answers 404 or 405. The search then uses /// NIP-50 ([NostrFilters.searchUsers]), and so do later searches on that /// relay for [profileSearchRetry]; after that the endpoint is tried again. - /// A query or limit the endpoint does not accept also uses NIP-50. + /// A query or limit the endpoint does not accept also uses NIP-50. The + /// endpoint's verified heads (kind:30382) are kept: [searchedPeople] finds + /// people with no kind:0 by them. Future> searchProfiles( String query, { int limit = 50, @@ -191,7 +194,7 @@ class RelaySessionNotifier extends Notifier { }, timeout: timeout); return [ for (final event in events) - if (event.kind == 0) event, + if (event.kind == 0 || event.kind == verifiedProfileKind) event, ]; } on RelayException catch (error) { if (error.statusCode != 404 && error.statusCode != 405) rethrow; diff --git a/mobile/lib/shared/verified_profiles/verified_profile.dart b/mobile/lib/shared/verified_profiles/verified_profile.dart index 6da2e98f14..28ff273719 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile.dart @@ -293,3 +293,55 @@ VerifiedProfile? parseVerifiedProfile( multipleIssuers: authority.multipleIssuers, ); } + +/// The verified names besides the display name that the relay's profile +/// search matches: the full name, when there is one, and the username. +List verifiedAliases(VerifiedProfile profile) => [ + ?profile.fullName, + profile.username, +]; + +/// One person a profile search found: their newest kind:0, or, for someone +/// with none, their verified profile. +typedef SearchedPerson = ({ + String pubkey, + NostrEvent? profile, + VerifiedProfile? verified, +}); + +/// The people a profile search page found, in the relay's order: each +/// person's newest kind:0, and for a person with none, the head [authority] +/// signed, when it is an active verified profile. Any other event finds +/// nobody. Callers must have verified each head's id and signature +/// ([VerifiedProfilesNotifier.searched] does). +List searchedPeople( + List events, + VerifiedProfileAuthority? authority, +) { + final order = {}; + final profiles = {}; + final heads = {}; + for (final event in events) { + if (event.kind == 0) { + final pubkey = event.pubkey.toLowerCase(); + order.add(pubkey); + final current = profiles[pubkey]; + if (current == null || event.createdAt > current.createdAt) { + profiles[pubkey] = event; + } + } else if (authority != null) { + final subject = verifiedProfileSubject(event, authority); + if (subject == null) continue; + order.add(subject); + if (supersedes(event, heads[subject])) heads[subject] = event; + } + } + return [ + for (final pubkey in order) + if (profiles[pubkey] case final profile?) + (pubkey: pubkey, profile: profile, verified: null) + else if (parseVerifiedProfile(heads[pubkey]!, authority!, checked: false) + case final verified?) + (pubkey: pubkey, profile: null, verified: verified), + ]; +} diff --git a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart index 6aa83ec060..2f38fd17f7 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart @@ -75,6 +75,7 @@ class VerifiedProfilesNotifier extends Notifier> { bool _discoveryFresh = false; bool _connected = false; bool _discoveryInFlight = false; + Completer? _discoveryDone; bool _scheduled = false; int _generation = 0; int _interest = 0; @@ -128,6 +129,45 @@ class VerifiedProfilesNotifier extends Notifier> { _checked.clear(); } + /// The people a profile search found ([searchedPeople]). Waits for + /// discovery when the relay's authority is not known yet, and keeps only + /// heads with a valid signature. A kept head also names its subject here, + /// so the search's rows show their verified names at once. + Future> searched(List events) { + final heads = [ + for (final event in events) + if (event.kind == verifiedProfileKind) event, + ]; + // Without heads there is nothing to wait for or prove: answer at once. + if (heads.isEmpty) return SynchronousFuture(searchedPeople(events, null)); + return _searchedWithHeads(events, heads); + } + + Future> _searchedWithHeads( + List events, + List heads, + ) async { + final generation = _generation; + if (_authority == null && !_discoveryFresh) { + unawaited(_refreshDiscovery(generation)); + await _discoveryDone?.future; + } + final authority = _enabled ? _authority : null; + if (authority == null) return searchedPeople(events, null); + final proven = Set.identity()..addAll(await _prove(heads)); + if (generation == _generation && authority == _authority && ref.mounted) { + track([ + for (final head in proven) ?verifiedProfileSubject(head, authority), + ]); + _accept(proven.toList(), authority); + _publish(); + } + return searchedPeople([ + for (final event in events) + if (event.kind != verifiedProfileKind || proven.contains(event)) event, + ], authority); + } + /// Marks [pubkeys] relevant. Repeated calls coalesce into one scheduling pass. void track(Iterable pubkeys) { final valid = RegExp(r'^[0-9a-f]{64}$'); @@ -190,6 +230,7 @@ class VerifiedProfilesNotifier extends Notifier> { Future _refreshDiscovery(int generation) async { if (_discoveryInFlight || generation != _generation || !_connected) return; _discoveryInFlight = true; + final done = _discoveryDone = Completer(); try { final result = await ref.read(verifiedProfileDiscoveryFetchProvider)(); if (generation != _generation) return; @@ -214,6 +255,8 @@ class VerifiedProfilesNotifier extends Notifier> { if (generation == _generation) _discoveryFresh = false; debugPrint('[VerifiedProfiles] discovery failed: $error'); } finally { + if (identical(_discoveryDone, done)) _discoveryDone = null; + done.complete(); if (generation == _generation) { _discoveryInFlight = false; _armRefresh(generation); diff --git a/mobile/test/features/channels/add_members_sheet_names_test.dart b/mobile/test/features/channels/add_members_sheet_names_test.dart index 59c956798e..1a7e90e683 100644 --- a/mobile/test/features/channels/add_members_sheet_names_test.dart +++ b/mobile/test/features/channels/add_members_sheet_names_test.dart @@ -1,14 +1,20 @@ +import 'dart:async'; + import 'package:buzz/features/channels/add_members_sheet.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; 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:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/verified_profiles/verified_profile.dart'; +import 'package:buzz/shared/verified_profiles/verified_profile_discovery_fetch.dart'; +import 'package:buzz/shared/verified_profiles/verified_profile_proof.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/recipient_directory_session.dart'; +import '../../shared/verified_profiles/verified_profile_fixture.dart'; class _RecordingUserCache extends UserCacheNotifier { final preloaded = {}; @@ -26,6 +32,31 @@ class _RecordingUserCache extends UserCacheNotifier { } } +/// A relay whose profile search finds [heads] alone: people with a verified +/// profile and no kind:0. +class _VerifiedOnlySession extends RecipientDirectorySession { + _VerifiedOnlySession(this.heads) : super(const {}); + + final List heads; + + @override + Future> searchProfiles( + String query, { + int limit = 50, + Duration timeout = const Duration(seconds: 8), + }) async => heads; +} + +class _Actions extends Fake implements ChannelActions { + final calls = >[]; + @override + Future addMembers({ + required String channelId, + required List pubkeys, + String role = 'member', + }) async => calls.add(pubkeys); +} + void main() { testWidgets('compares with every member but loads only shown choices', ( tester, @@ -78,4 +109,85 @@ void main() { // Only the shown choice's profile is requested, not the whole roster. expect(cache.preloaded, {agent}); }); + + testWidgets( + 'finds a verified person with no profile by full name once discovery ends', + (tester) async { + // The real shape: the display name is the username. + final gabi = signedProjection( + profileTags( + subjectA, + name: 'glilienfeld', + username: 'glilienfeld', + fullName: 'Gabi Lilienfeld', + ), + ); + // Neither of these finds anyone: another key's head, and a head that + // claims the relay key with a signature it never made. + final otherKey = signedProjection( + profileTags(subjectB, name: 'gabi_other'), + author: relayKeysOther(), + ); + final claimed = signedProjection( + profileTags('c' * 64, name: 'gabi_forged'), + ); + final forged = NostrEvent.fromJson({ + ...claimed.toJson(), + 'sig': '0' * 128, + }); + final discovery = Completer(); + final actions = _Actions(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + userCacheProvider.overrideWith(_RecordingUserCache.new), + channelActionsProvider.overrideWithValue(actions), + channelMembersProvider('channel').overrideWith((ref) async => []), + relaySessionProvider.overrideWith( + () => _VerifiedOnlySession([gabi, otherKey, forged]), + ), + verifiedProfileDiscoveryFetchProvider.overrideWith( + (ref) => + () => discovery.future, + ), + verifiedProfileProofCheckerProvider.overrideWithValue( + (events) async => [ + for (final e in events) hasVerifiedEventProof(e), + ], + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: AddChannelMembersSheet( + channelId: 'channel', + existingPubkeys: {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('add-channel-members-search')), + 'gabi', + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + // The search waits for the relay to say who signs verified profiles. + expect(find.text('glilienfeld'), findsNothing); + discovery.complete(VerifiedProfilesEnabled(authority)); + await tester.pumpAndSettle(); + + expect(find.text('glilienfeld'), findsOneWidget); + expect(find.textContaining('gabi_'), findsNothing); + await tester.tap(find.text('glilienfeld')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add member')); + await tester.pumpAndSettle(); + expect(actions.calls, [ + [subjectA], + ]); + }, + ); } diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index d7ff45f64a..be61754e02 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -6,6 +6,10 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/mobile_huddle_controller.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/verified_profiles/verified_profile.dart'; + +import '../../shared/verified_profiles/verified_profile_fixture.dart'; + /// Tests for [channelDetailsFromEvent]. /// /// The function maps a kind:39000 metadata event to [ChannelDetails], and is @@ -141,47 +145,59 @@ void main() { }); test('builds an alphabetized directory from the latest profile events', () { - final users = directoryUsersFromProfileEvents([ - NostrEvent( - id: 'alice-old', - pubkey: 'alice', - createdAt: 10, - kind: 0, - tags: const [], - content: '{"display_name":"Zoe"}', - sig: 'sig', - ), - NostrEvent( - id: 'bob', - pubkey: 'bob', - createdAt: 20, - kind: 0, - tags: const [], - content: '{"display_name":"Bob"}', - sig: 'sig', - ), - NostrEvent( - id: 'alice-new', - pubkey: 'ALICE', - createdAt: 30, - kind: 0, - tags: const [], - content: - '{"display_name":"Alice","picture":"https://example.com/alice.png"}', - sig: 'sig', - ), - NostrEvent( - id: 'not-a-profile', - pubkey: 'charlie', - createdAt: 40, - kind: 1, - tags: const [], - content: '{}', - sig: 'sig', - ), - ]); + final users = directoryUsersFromSearch( + searchedPeople([ + NostrEvent( + id: 'alice-old', + pubkey: 'alice', + createdAt: 10, + kind: 0, + tags: const [], + content: '{"display_name":"Zoe"}', + sig: 'sig', + ), + NostrEvent( + id: 'bob', + pubkey: 'bob', + createdAt: 20, + kind: 0, + tags: const [], + content: '{"display_name":"Bob"}', + sig: 'sig', + ), + NostrEvent( + id: 'alice-new', + pubkey: 'ALICE', + createdAt: 30, + kind: 0, + tags: const [], + content: + '{"display_name":"Alice","picture":"https://example.com/alice.png"}', + sig: 'sig', + ), + NostrEvent( + id: 'not-a-profile', + pubkey: 'charlie', + createdAt: 40, + kind: 1, + tags: const [], + content: '{}', + sig: 'sig', + ), + // Someone with no kind:0 is listed by the relay's verified head. + signedProjection(profileTags(subjectA, name: 'Gabi Lilienfeld')), + signedProjection( + profileTags(subjectB, name: 'Forged'), + author: relayKeysOther(), + ), + ], authority), + ); - expect(users.map((user) => user.label), ['Alice', 'Bob']); + expect(users.map((user) => user.label), [ + 'Alice', + 'Bob', + 'Gabi Lilienfeld', + ]); expect(users.first.pubkey, 'alice'); expect(users.first.avatarUrl, 'https://example.com/alice.png'); }); diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index f048237c65..f065bb7dd3 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -832,7 +832,12 @@ void main() { test('posts the name query to /profiles/search with NIP-98 auth', () async { final h = harness( (_) => http.Response( - jsonEncode([profileJson(0, 'wes'), profileJson(1, 'note')]), + jsonEncode([ + profileJson(0, 'wes'), + profileJson(1, 'note'), + // A person with no kind:0 is found by their verified head. + profileJson(30382, 'head'), + ]), 200, ), ); @@ -841,7 +846,7 @@ void main() { .read(relaySessionProvider.notifier) .searchProfiles(' wes ', limit: 8); - expect(events.map((event) => event.kind), [0]); + expect(events.map((event) => event.kind), [0, 30382]); expect(h.requests, hasLength(1)); final request = h.requests.single; expect(request.method, 'POST'); diff --git a/mobile/test/shared/verified_profiles/verified_profile_test.dart b/mobile/test/shared/verified_profiles/verified_profile_test.dart index 73eb0f128c..8b4a3520c5 100644 --- a/mobile/test/shared/verified_profiles/verified_profile_test.dart +++ b/mobile/test/shared/verified_profiles/verified_profile_test.dart @@ -203,4 +203,52 @@ void main() { expect(hasVerifiedEventProof(tampered), isFalse); }); }); + + test('a profile search finds people by kind:0, or by the relay\'s head', () { + NostrEvent profile(String pubkey, String name, int createdAt) => NostrEvent( + id: '$pubkey$createdAt', + pubkey: pubkey, + createdAt: createdAt, + kind: 0, + tags: const [], + content: '{"name":"$name"}', + sig: 'sig', + ); + final c = 'c' * 64, d = 'd' * 64, e = 'e' * 64, f = 'f' * 64; + final events = [ + // A head-only person keeps the relay's rank. + signedProjection(profileTags(c, name: 'Gabi Lilienfeld')), + // A kind:0 wins over the head; the newest kind:0 counts. + profile(subjectA, 'old', 1), + signedProjection(profileTags(subjectA)), + profile(subjectA, 'Ada', 2), + // These find nobody: another key's head, a retraction, a bad subject. + signedProjection(profileTags(d), author: relayKeysOther()), + signedProjection(retractionTags(e)), + signedProjection(profileTags('F' * 64)), + profile(subjectB, 'Bea', 1), + NostrEvent( + id: f, + pubkey: f, + createdAt: 1, + kind: 1, + tags: const [], + content: 'note', + sig: 'sig', + ), + ]; + String row(SearchedPerson person) => + '${person.pubkey[0]}:' + '${person.verified?.displayName ?? 'kind0 ${person.profile!.content}'}'; + expect(searchedPeople(events, authority).map(row), [ + 'c:Gabi Lilienfeld', + 'a:kind0 {"name":"Ada"}', + 'b:kind0 {"name":"Bea"}', + ]); + // Without a known authority, no head finds anyone. + expect(searchedPeople(events, null).map(row), [ + 'a:kind0 {"name":"Ada"}', + 'b:kind0 {"name":"Bea"}', + ]); + }); }