diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index ca34ffd2a1b..66b181d9e95 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -41,6 +41,15 @@ class ComposeBar extends HookConsumerWidget { final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId); final draftRevision = useRef(0); final draftIdentity = _composerDraftIdentity(ref); + final authorizationVisit = useRef(null); + final authorizationAttempt = useRef(null); + useEffect(() { + final visit = Object(); + authorizationVisit.value = visit; + return () { + if (authorizationVisit.value == visit) authorizationVisit.value = null; + }; + }, [draftKey, draftIdentity]); final isComposerExpanded = useState(false); final androidImeTransitionStarted = useState( defaultTargetPlatform != TargetPlatform.android, @@ -464,51 +473,115 @@ class ComposeBar extends HookConsumerWidget { uploadingCount.value > 0) { return; } - final submittedDraftRevision = draftRevision.value; - // Resolved before any await: see - // `_reportSendCancelledByCommunitySwitch`. - final messenger = ScaffoldMessenger.maybeOf(context); - - // Extract pubkeys for mentions present in the final text. - final selectedMentions = [ - for (final entry in mentionMap.value.entries) - if (hasMention(text, entry.key)) entry.value, - ]; - final outgoing = _OutgoingMentions(selectedMentions); - final scan = await _scanNonMemberMentions( - ref, - channelId: channelId, - selectedMentions: selectedMentions, - currentPubkey: currentPubkey, - ); + final attempt = Object(); + authorizationAttempt.value = attempt; + isSending.value = true; + void Function()? checkPreparationCurrent; + try { + final submittedDraftRevision = draftRevision.value; + var authorizationRevision = submittedDraftRevision; + final visit = authorizationVisit.value; + final config = ref.read(relayConfigProvider); + final readAuthorization = ref.read(agentAuthorizationReaderProvider); + bool isAuthorizationCurrent() => + context.mounted && + visit == authorizationVisit.value && + authorizationRevision == draftRevision.value && + identical(config, ref.read(relayConfigProvider)); + void ensureAuthorizationCurrent() { + if (!context.mounted) throw const _ComposeAuthorizationCancelled(); + if (!identical(config, ref.read(relayConfigProvider))) { + throw StateError('Community changed during authorization'); + } + if (visit != authorizationVisit.value || + authorizationRevision != draftRevision.value) { + throw const _ComposeAuthorizationCancelled(); + } + } - // Mentioning humans outside the channel prompts "Invite" / "Do - // nothing" (send without inviting) — mirrors desktop's - // NonMemberMentionDialog. Agents keep the existing silent auto-add. - if (scan.humans.isNotEmpty) { - if (!context.mounted) return; - final choice = await _promptNonMemberMention( - context, - names: [for (final candidate in scan.humans) candidate.label], - canInvite: scan.canAddMembers, + checkPreparationCurrent = ensureAuthorizationCurrent; + + Future authorize(Set keys, {bool prepare = false}) async { + if (keys.isEmpty) return; + ensureAuthorizationCurrent(); + try { + await authorizeAgentMentions( + readAuthorization, + keys, + currentPubkey, + channelId, + isAuthorizationCurrent, + prepare: prepare, + ); + } catch (_) { + // A stale request is cancellation, not an access decision. + ensureAuthorizationCurrent(); + rethrow; + } + ensureAuthorizationCurrent(); + } + + // Resolved before any await: see + // `_reportSendCancelledByCommunitySwitch`. + final messenger = ScaffoldMessenger.maybeOf(context); + + // Extract pubkeys for mentions present in the final text. + final selectedMentions = [ + for (final entry in mentionMap.value.entries) + if (hasMention(text, entry.key)) entry.value, + ]; + final outgoing = _OutgoingMentions(selectedMentions); + final intendedAgentKeys = { + for (final mention in selectedMentions) + if (mention.isAgent) mention.pubkey.toLowerCase(), + }; + final scan = await _scanNonMemberMentions( + ref, + channelId: channelId, + selectedMentions: selectedMentions, + currentPubkey: currentPubkey, ); - if (choice == null) return; // Dismissed — keep the draft, send nothing. - outgoing.resolveHumanChoice(choice, scan.humans); - } - final queuedAttachments = List<_PendingAttachment>.of(attachments.value); - final channelActions = ref.read(channelActionsProvider); + if (intendedAgentKeys.isNotEmpty) ensureAuthorizationCurrent(); + // Mentioning humans outside the channel prompts "Invite" / "Do + // nothing" (send without inviting) — mirrors desktop's + // NonMemberMentionDialog. Agents keep the existing silent auto-add. + if (scan.humans.isNotEmpty) { + if (!context.mounted) return; + final choice = await _promptNonMemberMention( + context, + names: [for (final candidate in scan.humans) candidate.label], + canInvite: scan.canAddMembers, + ); + if (intendedAgentKeys.isNotEmpty) ensureAuthorizationCurrent(); + if (choice == null) { + return; // Dismissed — keep the draft, send nothing. + } + outgoing.resolveHumanChoice(choice, scan.humans); + } + + final queuedAttachments = List<_PendingAttachment>.of( + attachments.value, + ); + final channelActions = ref.read(channelActionsProvider); - // An add that was refused doesn't block the message: it is reported and - // the un-added mentions are demoted to reference tags so the send lands. - Future addMentionedNonMembers() => outgoing.addNonMembers( - channelActions, - scan: scan, - messenger: messenger, - ); + // Agent failures stop publication; the original draft keeps its keys. + Future addMentionedNonMembers() async { + final keys = intendedAgentKeys.intersection(outgoing.pubkeys.toSet()); + await authorize(keys, prepare: true); + await outgoing.addNonMembers( + channelActions, + scan: scan, + messenger: messenger, + ); + if (!outgoing.pubkeys.toSet().containsAll(keys)) { + throw Exception( + 'Mention invitation failed. Draft kept; retry or remove the mention.', + ); + } + await authorize(keys); + } - isSending.value = true; - try { if (queuedAttachments.isEmpty) { if (!context.mounted) return; await _sendTextOnlyDraft( @@ -532,13 +605,17 @@ class ComposeBar extends HookConsumerWidget { return; } + if (intendedAgentKeys.isNotEmpty) ensureAuthorizationCurrent(); final draftText = controller.value; final draftAttachments = List<_PendingAttachment>.of(attachments.value); final draftMentions = Map.of( mentionMap.value, ); - clearComposer(); + // Agent authorization is preparation, not a detached background send. + final preparingAgents = intendedAgentKeys.isNotEmpty; + if (!preparingAgents) clearComposer(); final clearedDraftRevision = draftRevision.value; + authorizationRevision = clearedDraftRevision; uploadingCount.value += 1; uploadProgress.value = 0; isSending.value = false; @@ -578,17 +655,28 @@ class ComposeBar extends HookConsumerWidget { if (queueGeneration != uploadGeneration.value) return; await addMentionedNonMembers(); if (queueGeneration != uploadGeneration.value) return; + if (preparingAgents) { + ensureAuthorizationCurrent(); + clearComposer(); + authorizationRevision = draftRevision.value; + } await delivery( payload.content, outgoing.pubkeys, mediaTags: [...payload.mediaTags, ...outgoing.referenceTags], ); + } on _ComposeAuthorizationCancelled { + // Keep the newer draft without displaying a false access error. } catch (error) { if (cancellation.isCancelled) return; - if (context.mounted) uploadError.value = _formatUploadError(error); + if (error is StateError) { + _reportSendCancelledByCommunitySwitch(messenger); + } else if (context.mounted) { + uploadError.value = _formatUploadError(error); + } if (context.mounted && queueGeneration == uploadGeneration.value && - draftRevision.value == clearedDraftRevision) { + draftRevision.value == authorizationRevision) { controller.value = draftText; attachments.value = draftAttachments; retainedForRetry = true; @@ -598,7 +686,13 @@ class ComposeBar extends HookConsumerWidget { focusNode.requestFocus(); } } finally { - if (!retainedForRetry) { + final sourceRetainsFiles = + preparingAgents && + context.mounted && + attachments.value.any( + (item) => queuedAttachments.contains(item), + ); + if (!retainedForRetry && !sourceRetainsFiles) { await _deleteOwnedAttachments(queuedAttachments); } if (activeUploadCancellation.value == cancellation) { @@ -609,8 +703,32 @@ class ComposeBar extends HookConsumerWidget { } } }()); + } catch (error) { + var communityChanged = false; + // Failed awaits need the same scope/edit classification as success. + try { + checkPreparationCurrent?.call(); + if (error is _ComposeAuthorizationCancelled) return; + } on _ComposeAuthorizationCancelled { + return; + } on StateError { + communityChanged = true; + } + if (context.mounted) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (communityChanged) { + _reportSendCancelledByCommunitySwitch(messenger); + } else { + messenger?.showSnackBar( + SnackBar(content: Text(_composeSendErrorMessage(error))), + ); + } + } } finally { - if (context.mounted && isSending.value) isSending.value = false; + if (context.mounted && authorizationAttempt.value == attempt) { + authorizationAttempt.value = null; + isSending.value = false; + } } } diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index b712091b716..a3436f5b612 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -1,5 +1,9 @@ part of '../compose_bar.dart'; +class _ComposeAuthorizationCancelled implements Exception { + const _ComposeAuthorizationCancelled(); +} + Future _sendTextOnlyDraft({ required BuildContext context, required _MarkdownEditingController controller, @@ -49,6 +53,8 @@ Future _sendTextOnlyDraft({ outgoing.pubkeys, mediaTags: [...payload.mediaTags, ...outgoing.referenceTags], ); + } on _ComposeAuthorizationCancelled { + restoreClearedDraft(); } on StateError { restoreClearedDraft(); _reportSendCancelledByCommunitySwitch(messenger); diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index f8b4dc6cf49..00e54e21850 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -10,6 +10,7 @@ import '../../shared/relay/relay.dart'; part 'agent_policy.dart'; part 'agent_authorization.dart'; +part 'agent_publication.dart'; /// A relay agent parsed from its kind:10100 agent-profile event. /// diff --git a/mobile/lib/shared/mentions/agent_publication.dart b/mobile/lib/shared/mentions/agent_publication.dart new file mode 100644 index 00000000000..a7fb1e24732 --- /dev/null +++ b/mobile/lib/shared/mentions/agent_publication.dart @@ -0,0 +1,69 @@ +part of 'agent_identity_provider.dart'; + +/// Fresh reader used by the composer; suggestions never authorize publication. +typedef AgentAuthorizationReader = + Future> Function( + Set keys, + String? viewer, + String channelId, + bool Function() isCurrent, + ); + +/// Session-bound fresh authorization reader for exact recipient keys and one +/// destination. Re-reads verified ownership, policy and relay-signed membership; +/// cached directory suggestions are never publication authority. Query and +/// scope-change failures propagate to the caller, which must check currentness +/// before applying membership or publishing. This is not an atomic relay write. +final agentAuthorizationReaderProvider = Provider(( + ref, +) { + final session = ref.watch(relaySessionProvider.notifier); + return (keys, viewer, channelId, isCurrent) => readAgentAuthorization( + session, + keys, + viewer: viewer, + channelId: channelId, + isCurrent: isCurrent, + ); +}); + +/// Verify every intended recipient; never silently shrink the notification set. +Future authorizeAgentMentions( + AgentAuthorizationReader read, + Set keys, + String? viewer, + String channelId, + bool Function() isCurrent, { + bool prepare = false, +}) async { + if (keys.isEmpty) return; + const message = + 'Could not authorize a mentioned agent. Check its access and channel membership, then retry or remove the mention.'; + try { + if (!isCurrent()) throw Exception(message); + final agents = await read(keys, viewer, channelId, isCurrent); + if (!isCurrent()) throw Exception(message); + for (final key in keys) { + final agent = agents.where((a) => a.pubkey == key).firstOrNull; + final owned = agent?.ownerPubkey != null && agent?.ownerPubkey == viewer; + final allowed = + agent != null && + ((owned && + const [ + 'owner-only', + 'allowlist', + 'anyone', + ].contains(agent.respondTo)) || + (agent.respondTo == 'allowlist' && + agent.respondToAllowlist.contains(viewer)) || + (agent.respondTo == 'anyone' && + agent.channelIds.contains(channelId))); + if (!allowed || + (!(prepare && owned) && !agent.channelIds.contains(channelId))) { + throw Exception(message); + } + } + } catch (_) { + throw Exception(message); + } +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 23b19d95577..f22b2b3397a 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -30,6 +30,8 @@ import 'package:buzz/shared/widgets/anchored_popover_menu.dart'; import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; import 'package:shared_preferences/shared_preferences.dart'; +part 'compose_bar_test/publication_tests.dart'; + final _pngBytes = Uint8List.fromList([ 0x89, 0x50, @@ -174,6 +176,8 @@ Widget _buildComposeBar({ required ComposeBarOnSend onSend, List members = const [], Future>? membersFuture, + Future> Function()? membersLoader, + AgentAuthorizationReader? authorizationReader, List relayAgents = const [], List channels = const [], List cachedMembers = const [], @@ -207,9 +211,22 @@ Widget _buildComposeBar({ ), photoLibraryProvider.overrideWithValue(photoLibrary), currentPubkeyProvider.overrideWith((ref) => currentPubkey), - channelMembersProvider( - 'channel-1', - ).overrideWith((ref) => membersFuture ?? Future.value(members)), + channelMembersProvider('channel-1').overrideWith( + (ref) => + membersLoader?.call() ?? membersFuture ?? Future.value(members), + ), + agentAuthorizationReaderProvider.overrideWithValue( + authorizationReader ?? + (keys, viewer, channel, current) async => [ + for (final key in keys) + AgentDirectoryEntry( + pubkey: key, + respondTo: 'anyone', + ownerPubkey: viewer, + channelIds: [channel], + ), + ], + ), agentDirectoryProvider.overrideWith((ref) async => relayAgents), agentOwnersProvider.overrideWith((ref) async => const {}), relayClientProvider.overrideWithValue( @@ -641,6 +658,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier { } void main() { + _publicationTests(); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { @@ -4235,7 +4253,7 @@ void main() { addMemberAcknowledgement.complete(); await tester.pumpAndSettle(); - expect(sentContent, 'hello @Helper Bot'); + expect(sentContent, isNull); expect( tester.widget(find.byType(TextField)).controller!.text, 'newer draft', diff --git a/mobile/test/features/channels/compose_bar_test/publication_tests.dart b/mobile/test/features/channels/compose_bar_test/publication_tests.dart new file mode 100644 index 00000000000..6cb892cf86b --- /dev/null +++ b/mobile/test/features/channels/compose_bar_test/publication_tests.dart @@ -0,0 +1,282 @@ +part of '../compose_bar_test.dart'; + +void _publicationTests() { + for (final mode in [ + 'deny', + 'missing', + 'error', + 'revoke', + 'removed', + 'allow', + ]) { + testWidgets('publication authorization $mode retains exact intent', ( + tester, + ) async { + final key = 'd' * 64; + final signer = nostr.Keys.generate(); + var reads = 0; + var sent = 0; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayAgents: [_testAgent(key)], + channels: [ + _makeCurrentChannel(channelType: 'dm'), + _makeSharedMemberChannel(), + ], + authorizationReader: (keys, viewer, destination, current) async { + expect(keys, {key}); + expect(destination, 'channel-1'); + expect(current(), isTrue); + reads++; + if (mode == 'error') throw StateError('unavailable'); + if (mode == 'missing') return []; + return [ + AgentDirectoryEntry( + pubkey: key, + ownerPubkey: viewer, + respondTo: mode == 'deny' || (mode == 'revoke' && reads == 2) + ? 'nobody' + : 'anyone', + channelIds: mode == 'removed' && reads == 2 + ? [] + : ['channel-1'], + ), + ]; + }, + onSend: (_, keys, {mediaTags = const []}) async { + expect(keys, [key]); + sent++; + }, + ), + ); + await _selectAndSendAgentMention(tester); + expect(sent, mode == 'allow' ? 1 : 0); + expect( + tester.widget(find.byType(TextField)).controller!.text, + mode == 'allow' ? '' : 'hello @Helper Bot', + ); + if (mode != 'allow') { + expect( + find.textContaining('Could not authorize a mentioned agent'), + findsOneWidget, + ); + } + expect( + reads, + const ['allow', 'revoke', 'removed'].contains(mode) ? 2 : 1, + ); + }); + } + + for (final (boundary, switchCommunity) in [ + for (final boundary in ['reader', 'scan', 'scan error', 'prompt']) + for (final change in [false, true]) (boundary, change), + ]) { + testWidgets( + 'authorization cancellation $boundary reports scope change only: $switchCommunity', + (tester) async { + final key = 'd' * 64; + final signer = nostr.Keys.generate(); + final pending = Completer>(); + final members = Completer>(); + var scanPending = false; + var reads = 0; + var sent = 0; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayConfig: () => _SwitchableRelayConfigNotifier( + RelayConfig(baseUrl: 'https://relay.example', nsec: signer.nsec), + ), + membersLoader: () => boundary == 'prompt' + ? Future.value( + scanPending + ? [] + : [ + ChannelMember( + pubkey: 'a' * 64, + displayName: 'Alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ) + : scanPending + ? members.future + : Future.value([]), + relayAgents: [_testAgent(key)], + channels: [ + _makeCurrentChannel( + channelType: boundary == 'reader' ? 'dm' : 'stream', + ), + _makeSharedMemberChannel(), + ], + authorizationReader: (_, _, _, _) { + reads++; + return pending.future; + }, + onSend: (_, _, {mediaTags = const []}) async { + sent++; + }, + ), + ); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + if (boundary == 'prompt') { + await tester.enterText(find.byType(TextField), '@Helper Bot @ali'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Alice')); + await tester.pumpAndSettle(); + } else { + await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); + } + final controller = tester + .widget(find.byType(TextField)) + .controller!; + if (boundary != 'reader') { + scanPending = true; + ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ).invalidate(channelMembersProvider('channel-1')); + await tester.pump(); + } + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pump(); + expect(reads, boundary == 'reader' ? 1 : 0); + if (boundary == 'prompt') { + await tester.pump(const Duration(milliseconds: 300)); + } + if (switchCommunity) { + ProviderScope.containerOf(tester.element(find.byType(ComposeBar))) + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://other.example', nsec: signer.nsec); + await tester.pump(); + } else { + controller.text = 'new intent'; + } + if (boundary == 'prompt') { + await tester.tap(find.text('Invite')); + } + if (boundary == 'scan error') { + members.completeError(StateError('scan unavailable')); + } else if (boundary == 'scan') { + members.complete([]); + } + pending.complete([ + AgentDirectoryEntry( + pubkey: key, + respondTo: 'anyone', + channelIds: ['channel-1'], + ), + ]); + await tester.pumpAndSettle(); + expect(sent, 0); + expect( + find.text('Message not sent: the community changed'), + switchCommunity ? findsOneWidget : findsNothing, + ); + expect(find.textContaining('Could not authorize'), findsNothing); + expect(controller.text, switchCommunity ? '' : 'new intent'); + }, + ); + } + testWidgets('revocation after upload retains agent draft and attachment', ( + tester, + ) async { + final signer = nostr.Keys.generate(); + final key = 'd' * 64; + final response = Completer(); + var uploaded = false; + var reads = 0; + var sent = 0; + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: signer.nsec, + httpClient: http_testing.MockClient((_) { + uploaded = true; + return response.future; + }), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + pickGalleryImages: () async => [ + XFile.fromData(_pngBytes, name: 'tiny.png'), + ], + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: service, + currentPubkey: signer.public, + relayAgents: [_testAgent(key)], + channels: [ + _makeCurrentChannel(channelType: 'dm'), + _makeSharedMemberChannel(), + ], + authorizationReader: (_, viewer, _, _) async { + reads++; + return [ + AgentDirectoryEntry( + pubkey: key, + ownerPubkey: viewer, + respondTo: reads == 1 ? 'anyone' : 'nobody', + channelIds: ['channel-1'], + ), + ]; + }, + onSend: (_, _, {mediaTags = const []}) async { + sent++; + }, + ), + ); + await _openSystemPhotoPicker(tester); + await tester.pumpAndSettle(); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.runAsync(() async { + for (var i = 0; i < 100 && !uploaded; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + expect(uploaded, isTrue); + response.complete( + http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.png', + 'sha256': '0' * 64, + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pumpAndSettle(); + expect(reads, 2); + expect(sent, 0); + expect( + tester.widget(find.byType(TextField)).controller!.text, + 'hello @Helper Bot', + ); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); + expect( + find.textContaining('Could not authorize a mentioned agent'), + findsOneWidget, + ); + }); +}