fix(mobile): resolve removal-aware roster heads for discovery

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 12:55:27 -04:00
parent 882f69cbaf
commit c73ff5737e
3 changed files with 80 additions and 1 deletions

View File

@@ -22,13 +22,37 @@ Future<List<AgentDirectoryEntry>> 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<String>()
.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)

View File

@@ -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<String> respondToAllowlist;

View File

@@ -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);