diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index b1804e39fe..8b2b106522 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ AB1200002F40400100A17E01 /* NativeMessagePresentationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200102F40400100A17E01 /* NativeMessagePresentationCoordinator.swift */; }; AB1200022F40400100A17E01 /* NativeReactionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200122F40400100A17E01 /* NativeReactionDetails.swift */; }; AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */; }; + AB12AA012F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB12AA022F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift */; }; AB1200032F40400100A17E09 /* IdentityFilePluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */; }; /* End PBXBuildFile section */ @@ -176,6 +177,7 @@ AB1200102F40400100A17E01 /* NativeMessagePresentationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessagePresentationCoordinator.swift; sourceTree = ""; }; AB1200122F40400100A17E01 /* NativeReactionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeReactionDetails.swift; sourceTree = ""; }; AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageImageLoaderTests.swift; sourceTree = ""; }; + AB12AA022F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiRedirectPolicyTests.swift; sourceTree = ""; }; AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFilePluginTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -215,6 +217,7 @@ BC0010000000000000000004 /* BrowserAuthBridgeTests.swift */, F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */, AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */, + AB12AA022F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift */, AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */, BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */, 331C80A0294A618700263BE5 /* Fixtures */, @@ -627,6 +630,7 @@ BC0010000000000000000003 /* BrowserAuthBridgeTests.swift in Sources */, F84380010000000000000001 /* ProfileSetupRecoveryTests.swift in Sources */, AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */, + AB12AA012F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift in Sources */, AB1200032F40400100A17E09 /* IdentityFilePluginTests.swift in Sources */, BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */, ); diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift index 6b4644fcd9..5d99d8734d 100644 --- a/mobile/ios/Runner/NativeEmojiPickerView.swift +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -557,7 +557,10 @@ actor NativeEmojiRemoteImageLoader { } private static func download(_ request: URLRequest) async throws -> UIImage { - let (bytes, response) = try await URLSession.shared.bytes(for: request) + let (bytes, response) = try await URLSession.shared.bytes( + for: request, + delegate: NativeEmojiRedirectPolicy.shared + ) guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) @@ -620,6 +623,26 @@ actor NativeEmojiRemoteImageLoader { } } +/// Mirrors the Dart hosted HTTP client, which disables redirects for requests +/// carrying the relay assertion: a redirect must not forward that bearer token +/// to another host. The 3xx then fails the load like any non-2xx response. +final class NativeEmojiRedirectPolicy: NSObject, URLSessionTaskDelegate { + static let shared = NativeEmojiRedirectPolicy() + static let relayAssertionHeader = "Nostr-Federated-Identity" + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + let carriesAssertion = + task.originalRequest?.value(forHTTPHeaderField: Self.relayAssertionHeader) != nil + completionHandler(carriesAssertion ? nil : request) + } +} + private struct TablerNativeIcon: View { let symbol: String var body: some View { diff --git a/mobile/ios/RunnerTests/NativeEmojiRedirectPolicyTests.swift b/mobile/ios/RunnerTests/NativeEmojiRedirectPolicyTests.swift new file mode 100644 index 0000000000..89e573a751 --- /dev/null +++ b/mobile/ios/RunnerTests/NativeEmojiRedirectPolicyTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import Buzz + +final class NativeEmojiRedirectPolicyTests: XCTestCase { + private let session = URLSession(configuration: .ephemeral) + + override func tearDown() { + session.invalidateAndCancel() + super.tearDown() + } + + /// Runs the production delegate for a redirect of `original` and returns + /// the request it chose to follow (nil means the redirect is refused). + private func followedRequest(for original: URLRequest) -> URLRequest? { + // A task that is never resumed still exposes `originalRequest`. + let task = session.dataTask(with: original) + let redirectTarget = URL(string: "https://elsewhere.example/avatar.png")! + let response = HTTPURLResponse( + url: original.url!, statusCode: 302, httpVersion: nil, + headerFields: ["Location": redirectTarget.absoluteString] + )! + var followed: URLRequest? + var called = false + NativeEmojiRedirectPolicy.shared.urlSession( + session, + task: task, + willPerformHTTPRedirection: response, + newRequest: URLRequest(url: redirectTarget) + ) { request in + called = true + followed = request + } + XCTAssertTrue(called, "the delegate must always call its completion handler") + return followed + } + + func testRefusesRedirectForRequestCarryingRelayAssertion() { + var request = URLRequest(url: URL(string: "https://relay.example/media/emoji.png")!) + request.setValue("token", forHTTPHeaderField: NativeEmojiRedirectPolicy.relayAssertionHeader) + + XCTAssertNil(followedRequest(for: request)) + } + + func testFollowsRedirectForOrdinaryRequest() { + let request = URLRequest(url: URL(string: "https://cdn.example/emoji.png")!) + + XCTAssertEqual( + followedRequest(for: request)?.url, + URL(string: "https://elsewhere.example/avatar.png") + ) + } + + func testAssertionHeaderNameMatchesDartBridge() { + XCTAssertEqual(NativeEmojiRedirectPolicy.relayAssertionHeader, "Nostr-Federated-Identity") + } +} diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index cf35794d46..2cabcac4cc 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -11,7 +11,10 @@ import '../../shared/widgets/app_list_card_item.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/native_message_presentation.dart'; -import '../../shared/relay/media_auth.dart'; +import '../../shared/hosted/builderlab_account.dart'; +import '../../shared/hosted/hosted_relay_auth.dart'; +import '../../shared/hosted/native_media_headers.dart'; +import '../../shared/relay/relay_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_burst.dart'; import '../../shared/emoji/emoji_avatar.dart'; @@ -725,6 +728,9 @@ Future _showNativeReactionDetails( var addRequested = false; final handled = await NativeMessagePresentation.withLease(() async { final container = ProviderScope.containerOf(context, listen: false); + final theme = Theme.of(context); + if (reactions.value.isEmpty) return true; + final nativeMediaHeaders = container.read(nativeMediaHeadersProvider); final requestId = DateTime.now().microsecondsSinceEpoch.toString(); List pubkeys() => reactions.value.expand((r) => r.userPubkeys).toSet().toList(); @@ -757,9 +763,7 @@ Future _showNativeReactionDetails( 'avatarColor': avatar.colorValue, } else if (url != null && !url.startsWith('data:')) ...{ 'url': url, - 'headers': container - .read(mediaGetAuthServiceProvider) - .headersFor(url), + 'headers': nativeMediaHeaders(url), }, }; } @@ -818,9 +822,7 @@ Future _showNativeReactionDetails( 'users': reaction.userPubkeys.toSet().toList(), if (reaction.emojiUrl case final String url) ...{ 'url': url, - 'headers': container - .read(mediaGetAuthServiceProvider) - .headersFor(url), + 'headers': nativeMediaHeaders(url), }, }, ]; @@ -839,18 +841,36 @@ Future _showNativeReactionDetails( } reactions.addListener(updateReactions); + // Native headers read the cached assertion synchronously, and that cache + // reads as empty inside its refresh margin while the live socket is still + // valid. Present at once with what is cached, refresh in the background, + // and resend the media rows if the refresh produced a new assertion, so a + // stalled issuer never delays the sheet. + final config = container.read(relayConfigProvider); + final auth = container.read(hostedRelayAuthProvider); + final cachedAuth = auth.cachedHeaders(config); + unawaited(() async { + try { + await auth.headers(config); + } on AccountFailure { + // Send without the assertion; the relay decides, and sign-in is + // offered elsewhere. + return; + } + if (disposed || mapEquals(auth.cachedHeaders(config), cachedAuth)) return; + sent.clear(); + updateReactions(); + }()); try { - final sheetColors = utilitySurfaceThemeData( - Theme.of(context), - ).colorScheme; + final sheetColors = utilitySurfaceThemeData(theme).colorScheme; final pending = NativeMessagePresentation.present('reactions', { 'requestId': requestId, 'sheetColor': sheetColors.surface.toARGB32(), 'foregroundColor': sheetColors.primary.toARGB32(), - 'outlineColor': Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.25).toARGB32(), - 'dark': Theme.of(context).brightness == Brightness.dark, + 'outlineColor': theme.colorScheme.onSurface + .withValues(alpha: 0.25) + .toARGB32(), + 'dark': theme.brightness == Brightness.dark, 'canAddReaction': onAddReaction != null, 'addReactionIconCodePoint': BuzzIcons.smilePlus.codePoint, 'profiles': profiles(), diff --git a/mobile/lib/shared/emoji/emoji_picker.dart b/mobile/lib/shared/emoji/emoji_picker.dart index 3aa06dc817..eaeeba3ea1 100644 --- a/mobile/lib/shared/emoji/emoji_picker.dart +++ b/mobile/lib/shared/emoji/emoji_picker.dart @@ -14,6 +14,7 @@ import '../emoji/emoji_data.dart'; import '../emoji/emoji_data_provider.dart'; import '../emoji/emoji_search.dart'; import '../emoji/native_emoji_glyph.dart'; +import '../hosted/hosted_relay_auth.dart'; import '../relay/relay.dart'; import '../theme/theme.dart'; import '../widgets/buzz_sheet_header.dart'; diff --git a/mobile/lib/shared/emoji/emoji_picker/ios_native_picker.dart b/mobile/lib/shared/emoji/emoji_picker/ios_native_picker.dart index aeca5c70c3..ea003dd722 100644 --- a/mobile/lib/shared/emoji/emoji_picker/ios_native_picker.dart +++ b/mobile/lib/shared/emoji/emoji_picker/ios_native_picker.dart @@ -119,6 +119,8 @@ Future _presentIosEmojiPicker({ } final recent = container.read(recentEmojiProvider); final mediaAuth = container.read(mediaGetAuthServiceProvider); + final hostedAuth = container.read(hostedRelayAuthProvider); + final relayConfig = container.read(relayConfigProvider); final prefs = container.read(savedPrefsProvider); final colors = context.colors; var dismissed = false; @@ -135,9 +137,16 @@ Future _presentIosEmojiPicker({ switch (call.method) { case 'mediaHeaders': final url = call.arguments; - return url is String - ? mediaAuth.headersFor(url) - : const {}; + if (url is! String) return const {}; + // Native downloads bypass the hosted HTTP client, so add its relay + // assertion here too: a hosted relay refuses media reads without it. + // Await the assertion before minting the short-lived media proof so a + // slow refresh cannot age it. + final assertion = await hostedAuth.headersForUrl( + relayConfig, + Uri.parse(url), + ); + return {...mediaAuth.headersFor(url), ...assertion}; case 'selected': final emoji = call.arguments; if (emoji is String && emoji.isNotEmpty) onSelect(emoji); diff --git a/mobile/lib/shared/hosted/hosted_relay_auth.dart b/mobile/lib/shared/hosted/hosted_relay_auth.dart index 376bfea949..68b5d2c43d 100644 --- a/mobile/lib/shared/hosted/hosted_relay_auth.dart +++ b/mobile/lib/shared/hosted/hosted_relay_auth.dart @@ -136,6 +136,28 @@ class HostedRelayAuth extends ChangeNotifier { return {'Nostr-Federated-Identity': 'Bearer ${cached.token}'}; } + /// [headers] for a request made outside [HostedRelayHttpClient], such as a + /// native download. Scoped like that client: only the relay's own origin + /// gets the assertion, so it never reaches a third-party host. + Future> headersForUrl(RelayConfig config, Uri url) async { + if (!_isRelayOrigin(config, url)) return const {}; + return headers(config); + } + + /// Synchronous [headersForUrl] for callers that hand headers to native code + /// up front. Uses the assertion the live relay session already holds; with + /// none cached the request goes out without it and the relay decides. + Map cachedHeadersForUrl(RelayConfig config, Uri url) { + if (!_isRelayOrigin(config, url)) return const {}; + return cachedHeaders(config) ?? const {}; + } + + static bool _isRelayOrigin(RelayConfig config, Uri url) => + (url.scheme == 'http' || url.scheme == 'https') && + // `Uri.origin` throws for a hostless URI, which a community can supply. + url.host.isNotEmpty && + url.origin == Uri.parse(config.baseUrl).origin; + Future> headers(RelayConfig config) async { final client = _accountFor(config); if (client == null) return const {}; diff --git a/mobile/lib/shared/hosted/native_media_headers.dart b/mobile/lib/shared/hosted/native_media_headers.dart new file mode 100644 index 0000000000..31d4443ec1 --- /dev/null +++ b/mobile/lib/shared/hosted/native_media_headers.dart @@ -0,0 +1,26 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../relay/relay.dart'; +import 'hosted_relay_auth.dart'; + +/// Headers for a relay media read that does not go through +/// [HostedRelayHttpClient], such as a native player or image loader that takes +/// headers up front. +/// +/// [MediaGetAuthService.headersFor] alone is not enough on a hosted relay: +/// media reads also need the relay assertion, which only the hosted client adds. +/// The assertion is scoped to the relay origin and comes from the live +/// session's cache, so building headers never blocks. +final nativeMediaHeadersProvider = + Provider Function(String)>((ref) { + final media = ref.watch(mediaGetAuthServiceProvider); + final hosted = ref.watch(hostedRelayAuthProvider); + final config = ref.watch(relayConfigProvider); + return (url) { + final uri = Uri.tryParse(url); + return { + ...media.headersFor(url), + if (uri != null) ...hosted.cachedHeadersForUrl(config, uri), + }; + }; + }); diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index 71e99abb7b..acafb37a97 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:buzz/shared/emoji/emoji_picker.dart'; import 'package:buzz/shared/emoji/recent_emoji_provider.dart'; @@ -6,6 +7,8 @@ import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/emoji/emoji_data.dart'; import 'package:buzz/shared/emoji/emoji_data_provider.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; @@ -136,6 +139,30 @@ const _relayCustomEmoji = [ ), ]; +class _FakeRelayConfigNotifier extends RelayConfigNotifier { + _FakeRelayConfigNotifier(this.nsec); + + final String nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +/// A hosted relay that issues a fixed assertion. +class _FakeHostedRelayAuth extends HostedRelayAuth { + _FakeHostedRelayAuth() : super(BuilderlabAccountClient(), enabled: false); + + /// While set, issuance waits for it, like a slow account request. + Completer? gate; + + @override + Future> headers(RelayConfig config) async { + await gate?.future; + return {'Nostr-Federated-Identity': 'Bearer hosted-token'}; + } +} + class _FakeCustomEmojiPaletteNotifier extends CustomEmojiPaletteNotifier { _FakeCustomEmojiPaletteNotifier(this.palette); @@ -704,10 +731,15 @@ void main() { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; final prefs = await _prefs(); + final nsec = nostr.Keys.generate().nsec; + var clock = DateTime.utc(2026, 11, 5, 12); final mediaAuth = MediaGetAuthService( baseUrl: 'https://relay.example', - nsec: nostr.Keys.generate().nsec, + nsec: nsec, + now: () => clock, ); + final hostedAuth = _FakeHostedRelayAuth(); + addTearDown(hostedAuth.dispose); MethodCall? presentation; final selected = []; var dismissals = 0; @@ -728,6 +760,10 @@ void main() { ), ), mediaGetAuthServiceProvider.overrideWithValue(mediaAuth), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec), + ), + hostedRelayAuthProvider.overrideWithValue(hostedAuth), ], child: MaterialApp( theme: AppTheme.light(), @@ -781,6 +817,41 @@ void main() { (relayHeaders as Map)['Authorization'], startsWith('Nostr '), ); + // Native downloads skip the hosted HTTP client, so the bridge must + // carry the relay assertion a hosted relay requires for media reads. + expect(relayHeaders['Nostr-Federated-Identity'], 'Bearer hosted-token'); + + // A slow assertion refresh must not age the 60-second media proof: + // it is minted only once the assertion is in hand. + hostedAuth.gate = Completer(); + final slowHeaders = _sendNativeEmojiPickerCall( + tester, + 'mediaHeaders', + 'https://relay.example/media/buzzbee.png', + ); + await tester.pump(); + clock = clock.add(const Duration(seconds: 90)); + hostedAuth.gate!.complete(); + final refreshed = (await slowHeaders) as Map; + hostedAuth.gate = null; + final proof = + jsonDecode( + utf8.decode( + base64Url.decode( + base64Url.normalize( + (refreshed['Authorization'] as String).substring(6), + ), + ), + ), + ) + as Map; + final expiration = (proof['tags'] as List) + .cast>() + .firstWhere((tag) => tag.first == 'expiration')[1]; + expect( + int.parse(expiration as String), + greaterThan(clock.millisecondsSinceEpoch ~/ 1000), + ); await _sendNativeEmojiPickerCall(tester, 'skinToneChanged', 4); await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}'); diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 1875d28b30..16b17e9227 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -1,7 +1,6 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; -import 'package:buzz/shared/relay/media_image.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; @@ -12,6 +11,13 @@ import 'package:buzz/features/channels/channel_identity_names_provider.dart'; import 'package:buzz/shared/identity_names/identity_names.dart'; import 'package:buzz/features/channels/message_long_press_region.dart'; import 'package:buzz/features/channels/reaction_row.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/hosted/native_media_headers.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'dart:convert'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/emoji/emoji_burst.dart'; import 'package:buzz/shared/emoji/emoji_data.dart'; @@ -37,6 +43,9 @@ const _fire = '\u{1F525}'; /// 👀 — a real reaction that is deliberately not in the positive-burst set. const _eyes = '\u{1F440}'; +const _aliceAvatarUrl = 'https://relay.example/media/alice.png'; +const _customEmojiUrl = 'https://relay.example/media/fire.png'; + TimelineReaction _reaction({ String emoji = _fire, int count = 1, @@ -297,9 +306,18 @@ void main() { emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), userCacheProvider.overrideWith( () => _FakeUserCacheNotifier({ - _alice: const UserProfile(pubkey: _alice, displayName: 'Honey'), + _alice: const UserProfile( + pubkey: _alice, + displayName: 'Honey', + avatarUrl: _aliceAvatarUrl, + ), }), ), + // Native image loads bypass the hosted HTTP client, so the sheet + // must carry the relay-auth headers for each image itself. + nativeMediaHeadersProvider.overrideWithValue( + (url) => {'Nostr-Federated-Identity': 'Bearer $url'}, + ), channelIdentityNamesProvider('channel').overrideWith( (ref) => IdentityNameSources( @@ -316,7 +334,13 @@ void main() { messageId: _messageId, channelId: 'channel', reactions: [ - _reaction(userPubkeys: [_alice, bot, roster]), + TimelineReaction( + emoji: _fire, + count: 3, + reactedByCurrentUser: false, + userPubkeys: [_alice, bot, roster], + emojiUrl: _customEmojiUrl, + ), ], onToggle: (_) {}, ), @@ -339,6 +363,14 @@ void main() { } expect(labels(calls.first), ['Honey', 'Honey (agent)', 'Roster name']); + final avatar = (calls.first['profiles'] as Map)[_alice] as Map; + expect(avatar['headers'], { + 'Nostr-Federated-Identity': 'Bearer $_aliceAvatarUrl', + }); + final reaction = (calls.first['reactions'] as List).single as Map; + expect(reaction['headers'], { + 'Nostr-Federated-Identity': 'Bearer $_customEmojiUrl', + }); // Directory/roster changes must propagate without a user-cache event. directoryName = 'Helper'; container.invalidate(channelIdentityNamesProvider('channel')); @@ -362,6 +394,211 @@ void main() { }, ); + testWidgets( + 'native reaction sheet opens at once and resends media auth after a refresh', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + FlutterSecureStorage.setMockInitialValues({}); + final keys = nostr.Keys.generate(); + final organization = Uri.parse('https://org.example'); + final config = RelayConfig( + baseUrl: 'https://relay.example', + nsec: keys.nsec, + ); + final expiry = DateTime.now().toUtc().add(const Duration(days: 1)); + await const FlutterSecureStorage().write( + key: 'buzz_hosted_session_org_example_v1', + value: jsonEncode({ + 'origin': organization.toString(), + 'credential': 'secret', + 'subject': 'member', + 'expires_at': expiry.toIso8601String(), + }), + ); + var clock = DateTime.utc(2026, 11, 4, 12); + var issued = 0; + Completer? issuerGate; + final auth = HostedRelayAuth( + BuilderlabAccountClient(), + organization: BuilderlabAccountClient( + accountOrigin: organization, + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/assertions')) { + await issuerGate?.future; + } + return switch (request.url.path) { + '/api/goose/v1/auth/me' => http.Response( + jsonEncode({ + 'subject': 'member', + 'expires_at': expiry.toIso8601String(), + }), + 200, + ), + '/api/goose/v1/buzz/nostr-identities/current' => http.Response( + jsonEncode({ + 'identity': {'pubkey_hex': keys.public}, + }), + 200, + ), + '/api/goose/v1/buzz/identity/assertions' => http.Response( + jsonEncode({ + 'assertion': 'token-${++issued}', + 'nostr_pubkey': keys.public, + 'expires_at': clock.millisecondsSinceEpoch ~/ 1000 + 120, + }), + 200, + ), + _ => http.Response('{}', 404), + }; + }), + ), + organizationRelay: (relay) => relay.host == 'relay.example', + now: () => clock, + ); + addTearDown(auth.dispose); + // The live socket's assertion, issued when the session connected. + expect(await auth.headers(config), { + 'Nostr-Federated-Identity': 'Bearer token-1', + }); + final payloads = >[]; + final reactionUpdates = >[]; + final profileUpdates = >[]; + var completion = Completer>(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + final args = call.arguments as Map; + switch (call.method) { + case 'reactions': + payloads.add(args); + return completion.future; + case 'updateReactions': + reactionUpdates.add(args['reactions'] as List); + case 'updateProfiles': + profileUpdates.add(args['profiles'] as Map); + } + return {}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(config), + ), + hostedRelayAuthProvider.overrideWithValue(auth), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier({ + _alice: const UserProfile( + pubkey: _alice, + displayName: 'Honey', + avatarUrl: _aliceAvatarUrl, + ), + }), + ), + channelIdentityNamesProvider('channel').overrideWith( + (ref) => IdentityNameSources( + profiles: ref.watch(userCacheProvider), + ).scope([_alice]), + ), + ], + child: ReactionRow( + messageId: _messageId, + channelId: 'channel', + reactions: [ + TimelineReaction( + emoji: _fire, + count: 1, + reactedByCurrentUser: false, + userPubkeys: [_alice], + emojiUrl: _customEmojiUrl, + ), + ], + onToggle: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + // Rendering the row may itself fetch media through the hosted client, + // so enter the margin only now (120s lifetime, 30s margin): the socket + // is still valid, but the synchronous cache reads as empty. + String? authOf(Object? item) => + ((item as Map)['headers'] as Map?)?['Nostr-Federated-Identity'] + as String?; + final issuedBeforeSheet = issued; + clock = clock.add(const Duration(seconds: 100)); + expect(auth.cachedHeaders(config), isNull); + // A stalled issuer must not hold the sheet back: cached names and + // emoji show at once, and media gets the assertion when it arrives. + issuerGate = Completer(); + await tester.longPress( + find.byKey(const ValueKey('reaction-pill-$_fire')), + ); + await tester.pump(); + final payload = payloads.single; + final initialProfiles = payload['profiles'] as Map; + expect((initialProfiles[_alice] as Map)['name'], 'Honey'); + expect(authOf(initialProfiles[_alice]), isNull); + expect(authOf((payload['reactions'] as List).single), isNull); + expect(issued, issuedBeforeSheet); + + issuerGate.complete(); + await tester.pumpAndSettle(); + expect(issued, issuedBeforeSheet + 1); + expect(authOf(reactionUpdates.last.single), 'Bearer token-$issued'); + expect(authOf(profileUpdates.last[_alice]), 'Bearer token-$issued'); + completion.complete({}); + await tester.pumpAndSettle(); + + // A fresh assertion needs no refresh, so nothing is resent. + reactionUpdates.clear(); + completion = Completer>(); + await tester.longPress( + find.byKey(const ValueKey('reaction-pill-$_fire')), + ); + await tester.pumpAndSettle(); + expect(payloads, hasLength(2)); + expect( + authOf((payloads.last['reactions'] as List).single), + 'Bearer token-$issued', + ); + expect(reactionUpdates, isEmpty); + completion.complete({}); + await tester.pumpAndSettle(); + + // Unmounting while a slow issuer is pending sends nothing afterwards: + // the sheet's cleanup marks it disposed before auth completes. + issuerGate = Completer(); + clock = clock.add(const Duration(seconds: 200)); + expect(auth.cachedHeaders(config), isNull); + completion = Completer>(); + await tester.longPress( + find.byKey(const ValueKey('reaction-pill-$_fire')), + ); + await tester.pump(); + expect(payloads, hasLength(3)); + await tester.pumpWidget(const SizedBox()); + completion.complete({}); + await tester.pumpAndSettle(); + reactionUpdates.clear(); + profileUpdates.clear(); + issuerGate.complete(); + await tester.pumpAndSettle(); + expect(reactionUpdates, isEmpty); + expect(profileUpdates, isEmpty); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }, + ); + testWidgets( 'native hydration sends bounded diffs and preserves emoji avatars', (tester) async { @@ -900,3 +1137,12 @@ void main() { }); }); } + +class _FixedRelayConfigNotifier extends RelayConfigNotifier { + _FixedRelayConfigNotifier(this._config); + + final RelayConfig _config; + + @override + RelayConfig build() => _config; +} diff --git a/mobile/test/shared/hosted/native_media_headers_test.dart b/mobile/test/shared/hosted/native_media_headers_test.dart new file mode 100644 index 0000000000..7c9fd7bfdc --- /dev/null +++ b/mobile/test/shared/hosted/native_media_headers_test.dart @@ -0,0 +1,58 @@ +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/hosted/native_media_headers.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +final _nsec = nostr.Keys.generate().nsec; + +class _RelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: _nsec); +} + +/// A hosted relay whose live session already holds an assertion. +class _CachedHostedRelayAuth extends HostedRelayAuth { + _CachedHostedRelayAuth() : super(BuilderlabAccountClient(), enabled: false); + + @override + Map? cachedHeaders(RelayConfig config) => { + 'Nostr-Federated-Identity': 'Bearer hosted-token', + }; +} + +void main() { + test('native media headers add the relay assertion only for relay media', () { + final hosted = _CachedHostedRelayAuth(); + addTearDown(hosted.dispose); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_RelayConfigNotifier.new), + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService(baseUrl: 'https://relay.example', nsec: _nsec), + ), + hostedRelayAuthProvider.overrideWithValue(hosted), + ], + ); + addTearDown(container.dispose); + final headersFor = container.read(nativeMediaHeadersProvider); + + final relay = headersFor('https://relay.example/media/a.png'); + expect(relay['Authorization'], startsWith('Nostr ')); + expect(relay['Nostr-Federated-Identity'], 'Bearer hosted-token'); + + for (final other in [ + 'https://cdn.example/a.png', + 'https://team.relay.example/media/a.png', + 'http://relay.example/media/a.png', + 'https:///media/a.png', + 'http:///media/a.png', + 'https://', + ]) { + expect(headersFor(other), isNot(contains('Nostr-Federated-Identity'))); + } + }); +} diff --git a/mobile/test/shared/hosted/organization_relay_auth_test.dart b/mobile/test/shared/hosted/organization_relay_auth_test.dart index 58fc27b3fe..19a98d7678 100644 --- a/mobile/test/shared/hosted/organization_relay_auth_test.dart +++ b/mobile/test/shared/hosted/organization_relay_auth_test.dart @@ -236,6 +236,25 @@ void main() { expect(requests, isEmpty); }); + test('URL headers carry the assertion only to the relay origin', () async { + await saveSession(); + final auth = relayAuth(organizationClient()); + expect( + await auth.headersForUrl( + relay, + Uri.parse('https://$relayHost/media/emoji.png'), + ), + {'Nostr-Federated-Identity': 'Bearer org-token'}, + ); + for (final other in [ + 'https://other.example.com/media/emoji.png', + 'https://team.$relayHost/media/emoji.png', + 'http://$relayHost:8080/media/emoji.png', + ]) { + expect(await auth.headersForUrl(relay, Uri.parse(other)), isEmpty); + } + }); + test('other relays never receive the organization assertion', () async { await saveSession(); final auth = relayAuth(organizationClient());