From 6b8a72e8355125d24e4f4fab1e083dced4119f5c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 5 Nov 2026 04:27:16 -0800 Subject: [PATCH] fix(mobile): send hosted relay auth with iOS custom emoji downloads (#9056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS, custom emoji in the native emoji picker often render as the sparkle fallback icon instead of the image. The native picker downloads custom emoji with `URLSession` and asks Flutter for request headers over the `mediaHeaders` channel call. That call returned only the media read `Authorization` header. Hosted relays also require the `Nostr-Federated-Identity` assertion, which the Flutter image path gets from `HostedRelayHttpClient`, so relay-hosted emoji get a 401 and the picker falls back to sparkles. Emoji hosted elsewhere still load, which is why only some are missing. The `mediaHeaders` bridge now also includes the hosted relay assertion via a new `HostedRelayAuth.headersForUrl`, scoped to the relay's own origin like `HostedRelayHttpClient`. The native loader refuses redirects for requests that carry the assertion, matching the Dart client, so the token can't be forwarded to another host. The iOS reactions sheet had the same gap: it passes avatar and custom emoji URLs to native views with only the `Authorization` header. A new `nativeMediaHeadersProvider` combines both headers for any native media consumer, and the reactions sheet uses it. ### Validation - Added tests: the native picker's `mediaHeaders` call returns the relay assertion for relay media and no headers for an external emoji URL; `headersForUrl` returns the assertion only for the relay origin (not another host, subdomain or port). The reactions sheet passes the combined headers for avatars and custom emoji, and `nativeMediaHeadersProvider` merges both headers only for relay URLs. - Focused existing validation: the emoji picker and organization relay auth suites pass, and `flutter analyze` is clean. In the full mobile suite, the only failures are 10 in `notification_toggle_test`, `push_bootstrap_test` and `push_bridge_test`, and they fail the same way on `main`. - Automated full-app simulator run (iPhone 17 Pro, iOS 27, debug build of this branch with hosted auth enabled, signed in to two hosted communities through a cloned simulator keychain, driven with AXe). Covered: launch signed in, channel and DM lists, opening a channel and a DM, the native emoji picker (its Custom section shows the emoji as an image, not the sparkle fallback), sending a custom emoji message and seeing it render, reacting with the custom emoji (shown in the quick-reaction row and as a reaction pill), opening a thread, switching communities, avatars and community icons, background and resume, and a cold relaunch. Result: no crashes or regressions seen. Screenshots are omitted because they show real account and community names. - Limitations: this run does not reproduce the original bug. The hosted community's custom emoji also loaded in a temporary build with the relay assertion removed from the `mediaHeaders` bridge, so its emoji URL evidently doesn't need the assertion, and the run doesn't prove the fix. The reactions detail sheet could not be reached because simulated long presses on the pill opened the message menu, and inline image attachments were not exercised. A device build against a community whose emoji are relay-hosted is still needed to confirm the fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 Co-authored-by: studio --- mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/NativeEmojiPickerView.swift | 25 +- .../NativeEmojiRedirectPolicyTests.swift | 56 ++++ .../lib/features/channels/reaction_row.dart | 48 +++- mobile/lib/shared/emoji/emoji_picker.dart | 1 + .../emoji/emoji_picker/ios_native_picker.dart | 15 +- .../lib/shared/hosted/hosted_relay_auth.dart | 22 ++ .../shared/hosted/native_media_headers.dart | 26 ++ .../features/channels/emoji_picker_test.dart | 73 ++++- .../features/channels/reaction_row_test.dart | 252 +++++++++++++++++- .../hosted/native_media_headers_test.dart | 58 ++++ .../hosted/organization_relay_auth_test.dart | 19 ++ 12 files changed, 577 insertions(+), 22 deletions(-) create mode 100644 mobile/ios/RunnerTests/NativeEmojiRedirectPolicyTests.swift create mode 100644 mobile/lib/shared/hosted/native_media_headers.dart create mode 100644 mobile/test/shared/hosted/native_media_headers_test.dart 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());