mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
fix(mobile): scope remote availability to the community and account
Online status now counts only ACTIVE agents with owner-attested profiles on the active relay, so agents from another community stay offline. The account client publishes subject changes, which clear the previous account's registry immediately. A failed registry load is retried on the next load or relay reconnect. Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/agents/remote_agents.dart';
|
||||
import '../../shared/community/community_provider.dart';
|
||||
import '../../shared/crypto/nip_oa.dart';
|
||||
import '../../shared/hosted/hosted_flow.dart';
|
||||
import '../../shared/hosted/hosted_flow_provider.dart';
|
||||
import '../../shared/identity_names/identity_names_provider.dart';
|
||||
@@ -107,24 +106,6 @@ class _AgentsNavigation extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Only registry identities with verified owner profiles on this relay belong
|
||||
/// in its list. Account-wide registry entries alone are not community membership.
|
||||
Future<Set<String>> registeredCommunityAgents(
|
||||
List<Map<String, dynamic>> agents,
|
||||
String? owner,
|
||||
Future<List<NostrEvent>> Function(NostrFilter) fetch,
|
||||
) async {
|
||||
if (agents.isEmpty || owner == null) return {};
|
||||
final keys = agents.map((agent) => agent['agent_pubkey'] as String).toSet();
|
||||
final events = await fetch(NostrFilters.profilesBatch(keys.toList()));
|
||||
return {
|
||||
for (final event in events)
|
||||
if (keys.contains(event.pubkey) &&
|
||||
verifiedOaOwnerPubkey(event.tags, event.pubkey) == owner)
|
||||
event.pubkey,
|
||||
};
|
||||
}
|
||||
|
||||
/// The active community's agents, with remote-agent creation for its owner.
|
||||
class AgentsSheet extends HookConsumerWidget {
|
||||
const AgentsSheet({
|
||||
@@ -155,7 +136,10 @@ class AgentsSheet extends HookConsumerWidget {
|
||||
final registryFuture = useMemoized(() async {
|
||||
final fetch = ref.read(relaySessionProvider.notifier).fetchHistory;
|
||||
return registeredCommunityAgents(
|
||||
controller.agents,
|
||||
{
|
||||
for (final agent in controller.agents)
|
||||
agent['agent_pubkey'] as String,
|
||||
},
|
||||
controller.owner,
|
||||
fetch,
|
||||
);
|
||||
|
||||
@@ -247,7 +247,7 @@ final presenceCacheProvider =
|
||||
/// presence. Local presence is unchanged.
|
||||
final presentedPresenceProvider = Provider<Map<String, String>>((ref) {
|
||||
final presence = ref.watch(presenceCacheProvider);
|
||||
final remote = ref.watch(availableRemoteAgentsProvider);
|
||||
final remote = ref.watch(availableRemoteAgentsProvider).value ?? const {};
|
||||
if (remote.isEmpty) return presence;
|
||||
return {...presence, for (final pubkey in remote) pubkey: 'online'};
|
||||
});
|
||||
|
||||
@@ -10,12 +10,15 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../community/community.dart';
|
||||
import '../community/community_provider.dart';
|
||||
import '../crypto/nip_oa.dart';
|
||||
import '../hosted/account_identity_store.dart';
|
||||
import '../hosted/builderlab_account.dart';
|
||||
import '../hosted/hosted_environment.dart';
|
||||
import '../hosted/hosted_relay_auth.dart';
|
||||
import '../relay/nostr_filters.dart';
|
||||
import '../relay/nostr_models.dart';
|
||||
import '../relay/relay_provider.dart';
|
||||
import '../relay/relay_session.dart';
|
||||
import '../relay/signed_event_relay.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
@@ -37,11 +40,14 @@ final remoteAgentControllerProvider = Provider<RemoteAgentController>((ref) {
|
||||
return controller;
|
||||
});
|
||||
|
||||
/// Exact public keys the signed-in owner's registry reports ACTIVE.
|
||||
/// Remote workers wake on messages, so this means available, not socket presence.
|
||||
final availableRemoteAgentsProvider = Provider<Set<String>>((ref) {
|
||||
/// The active community's remote agents that the signed-in owner's registry
|
||||
/// reports ACTIVE. Remote workers wake on messages, so this means available,
|
||||
/// not socket presence.
|
||||
final availableRemoteAgentsProvider = FutureProvider<Set<String>>((ref) async {
|
||||
final community = ref.watch(activeCommunityProvider).asData?.value;
|
||||
if (!supportsRemoteAgents(community)) return const {};
|
||||
// A reconnect retries a registry load that failed.
|
||||
ref.watch(relaySessionProvider.select((session) => session.status));
|
||||
final controller = ref.watch(remoteAgentControllerProvider);
|
||||
final active = controller.activeAgents;
|
||||
void changed() {
|
||||
@@ -52,9 +58,32 @@ final availableRemoteAgentsProvider = Provider<Set<String>>((ref) {
|
||||
ref.onDispose(() => controller.removeListener(changed));
|
||||
// Loading notifies listeners, which must not happen during this build.
|
||||
scheduleMicrotask(() => controller.load(community!.relayUrl));
|
||||
return active;
|
||||
if (active.isEmpty) return const {};
|
||||
// The registry is account-wide; only agents registered here belong here.
|
||||
return registeredCommunityAgents(
|
||||
active,
|
||||
controller.owner,
|
||||
ref.read(relaySessionProvider.notifier).fetchHistory,
|
||||
);
|
||||
});
|
||||
|
||||
/// Only registry identities with verified owner profiles on this relay belong
|
||||
/// to its community. Account-wide registry entries alone are not membership.
|
||||
Future<Set<String>> registeredCommunityAgents(
|
||||
Set<String> keys,
|
||||
String? owner,
|
||||
Future<List<NostrEvent>> Function(NostrFilter) fetch,
|
||||
) async {
|
||||
if (keys.isEmpty || owner == null) return {};
|
||||
final events = await fetch(NostrFilters.profilesBatch(keys.toList()));
|
||||
return {
|
||||
for (final event in events)
|
||||
if (keys.contains(event.pubkey) &&
|
||||
verifiedOaOwnerPubkey(event.tags, event.pubkey) == owner)
|
||||
event.pubkey,
|
||||
};
|
||||
}
|
||||
|
||||
/// Owner-side registry client for Builderlab remote agents, using the same
|
||||
/// account and Beekeeper contracts as desktop.
|
||||
class RemoteAgentController extends ChangeNotifier {
|
||||
@@ -62,7 +91,9 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
required this.account,
|
||||
required AccountIdentityStore keys,
|
||||
required this.relayAuth,
|
||||
}) : _keys = keys;
|
||||
}) : _keys = keys {
|
||||
account.subjectChanges.addListener(_accountChanged);
|
||||
}
|
||||
|
||||
final BuilderlabAccountClient account;
|
||||
final AccountIdentityStore _keys;
|
||||
@@ -114,11 +145,20 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
_scope = null;
|
||||
}
|
||||
|
||||
/// Drops another account's registry as soon as the account changes.
|
||||
void _accountChanged() {
|
||||
if (_scope == null || _scope == _currentScope) return;
|
||||
_clearAccountState();
|
||||
_loadedRelay = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
/// Loads the registry once per community; opening Agents always refreshes.
|
||||
/// A failed load is retried on the next call.
|
||||
Future<void> load(String relay) async {
|
||||
if (_disposed || busy || _loadedRelay == relay) return;
|
||||
_loadedRelay = relay;
|
||||
await run(refresh);
|
||||
if (!await run(refresh) && _loadedRelay == relay) _loadedRelay = null;
|
||||
}
|
||||
|
||||
/// Runs one operation at a time and turns its failure into [status].
|
||||
@@ -517,6 +557,7 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
account.subjectChanges.removeListener(_accountChanged);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ class BuilderlabAccountClient {
|
||||
|
||||
BuilderlabAccount? get account => _account;
|
||||
|
||||
/// The signed-in subject, notifying when an account signs in, out or changes.
|
||||
ValueListenable<String?> get subjectChanges => _subject;
|
||||
final _subject = ValueNotifier<String?>(null);
|
||||
|
||||
/// Runs operations one at a time in call order, so a callback exchange that
|
||||
/// arrives during startup restore waits instead of failing.
|
||||
Future<T> _serial<T>(Future<T> Function() operation) {
|
||||
@@ -194,6 +198,7 @@ class BuilderlabAccountClient {
|
||||
BuilderlabAccount _adopt(String credential, BuilderlabAccount verified) {
|
||||
if (_disposed) throw AccountFailure.unavailable;
|
||||
_credential = credential;
|
||||
_subject.value = verified.subject;
|
||||
return _account = verified;
|
||||
}
|
||||
|
||||
@@ -296,6 +301,7 @@ class BuilderlabAccountClient {
|
||||
Future<void> _clear() async {
|
||||
_credential = null;
|
||||
_account = null;
|
||||
if (!_disposed) _subject.value = null;
|
||||
try {
|
||||
await _storage.delete(key: _storageKey);
|
||||
} catch (_) {
|
||||
@@ -363,6 +369,7 @@ class BuilderlabAccountClient {
|
||||
_disposed = true;
|
||||
_credential = null;
|
||||
_account = null;
|
||||
_subject.dispose();
|
||||
_http.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:buzz/shared/agents/remote_agents.dart';
|
||||
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
@@ -11,10 +12,28 @@ const fakeSubject = 'remote-owner';
|
||||
|
||||
/// Fakes only the Builderlab network boundary.
|
||||
class FakeRegistry extends BuilderlabAccountClient {
|
||||
FakeRegistry({this.bound, this.signedIn = true});
|
||||
FakeRegistry({this.bound, bool signedIn = true}) : _signedIn = signedIn {
|
||||
_changes.value = signedIn ? fakeSubject : null;
|
||||
}
|
||||
String? bound;
|
||||
bool signedIn;
|
||||
String subject = fakeSubject;
|
||||
bool _signedIn;
|
||||
String _subjectValue = fakeSubject;
|
||||
final _changes = ValueNotifier<String?>(null);
|
||||
|
||||
bool get signedIn => _signedIn;
|
||||
set signedIn(bool value) {
|
||||
_signedIn = value;
|
||||
_changes.value = value ? _subjectValue : null;
|
||||
}
|
||||
|
||||
String get subject => _subjectValue;
|
||||
set subject(String value) {
|
||||
_subjectValue = value;
|
||||
if (_signedIn) _changes.value = value;
|
||||
}
|
||||
|
||||
@override
|
||||
ValueListenable<String?> get subjectChanges => _changes;
|
||||
bool failInstructions = false;
|
||||
bool rejectRegistration = false;
|
||||
Object deleteStatus = 1;
|
||||
|
||||
@@ -287,7 +287,10 @@ void main() {
|
||||
await controller.run(controller.refresh);
|
||||
expect(controller.activeAgents, {fakeAgent});
|
||||
|
||||
var notified = 0;
|
||||
controller.addListener(() => notified++);
|
||||
registry.subject = 'other-owner';
|
||||
expect(notified, 1);
|
||||
expect(controller.activeAgents, isEmpty);
|
||||
// The other account has no saved key, so its registry is never listed.
|
||||
expect(await controller.run(controller.refresh), isFalse);
|
||||
@@ -295,6 +298,68 @@ void main() {
|
||||
expect(controller.owner, isNull);
|
||||
});
|
||||
|
||||
test('a failed registry load retries on the next load', () async {
|
||||
final registry = FakeRegistry(bound: 'f' * 64);
|
||||
final controller = await _controller(registry);
|
||||
final relay = _community().relayUrl;
|
||||
await controller.load(relay);
|
||||
expect(controller.status, 'Buzz identity: mismatch.');
|
||||
registry.bound = fakeOwner.public;
|
||||
await controller.load(relay);
|
||||
expect(registry.calls, ['list-agents']);
|
||||
await controller.load(relay);
|
||||
expect(registry.calls, ['list-agents']);
|
||||
});
|
||||
|
||||
test(
|
||||
'availability counts only agents registered in this community',
|
||||
() async {
|
||||
final other = nostr.Keys('0' * 63 + '3').public;
|
||||
final stranger = nostr.Keys('0' * 63 + '4');
|
||||
final elsewhere = nostr.Keys('0' * 63 + '5').public;
|
||||
final profiles = [
|
||||
NostrEvent(
|
||||
id: '1',
|
||||
pubkey: fakeAgent,
|
||||
createdAt: 1,
|
||||
kind: 0,
|
||||
tags: [signOwnerAuthorization(fakeOwner, fakeAgent)],
|
||||
content: '{}',
|
||||
sig: '',
|
||||
),
|
||||
// Attested by someone else, so not this owner's agent.
|
||||
NostrEvent(
|
||||
id: '2',
|
||||
pubkey: other,
|
||||
createdAt: 1,
|
||||
kind: 0,
|
||||
tags: [signOwnerAuthorization(stranger, other)],
|
||||
content: '{}',
|
||||
sig: '',
|
||||
),
|
||||
];
|
||||
expect(
|
||||
await registeredCommunityAgents(
|
||||
{fakeAgent, other, elsewhere},
|
||||
fakeOwner.public,
|
||||
(filter) async {
|
||||
expect(filter.kinds, [0]);
|
||||
return profiles;
|
||||
},
|
||||
),
|
||||
{fakeAgent},
|
||||
);
|
||||
expect(
|
||||
await registeredCommunityAgents(
|
||||
{fakeAgent},
|
||||
null,
|
||||
(_) async => profiles,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('owner proof verifies for only the provisioned agent key', () {
|
||||
final other = nostr.Keys('0' * 63 + '3');
|
||||
final auth = signOwnerAuthorization(fakeOwner, fakeAgent);
|
||||
@@ -317,20 +382,24 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('remote availability shows Online without changing local presence', () {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
presenceCacheProvider.overrideWith(_Presence.new),
|
||||
availableRemoteAgentsProvider.overrideWithValue({'remote'}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
expect(container.read(presentedPresenceProvider), {
|
||||
'remote': 'online',
|
||||
'local': 'away',
|
||||
});
|
||||
expect(container.read(presenceCacheProvider)['remote'], 'offline');
|
||||
});
|
||||
test(
|
||||
'remote availability shows Online without changing local presence',
|
||||
() async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
presenceCacheProvider.overrideWith(_Presence.new),
|
||||
availableRemoteAgentsProvider.overrideWith((ref) async => {'remote'}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(availableRemoteAgentsProvider.future);
|
||||
expect(container.read(presentedPresenceProvider), {
|
||||
'remote': 'online',
|
||||
'local': 'away',
|
||||
});
|
||||
expect(container.read(presenceCacheProvider)['remote'], 'offline');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _Presence extends PresenceCacheNotifier {
|
||||
|
||||
Reference in New Issue
Block a user