fix(mobile): send hosted relay auth with iOS custom emoji downloads (#9056)

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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-11-05 04:27:16 -08:00
committed by GitHub
parent 920b1e64bd
commit 6b8a72e835
12 changed files with 577 additions and 22 deletions

View File

@@ -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 = "<group>"; };
AB1200122F40400100A17E01 /* NativeReactionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeReactionDetails.swift; sourceTree = "<group>"; };
AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageImageLoaderTests.swift; sourceTree = "<group>"; };
AB12AA022F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiRedirectPolicyTests.swift; sourceTree = "<group>"; };
AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFilePluginTests.swift; sourceTree = "<group>"; };
/* 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 */,
);

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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<bool> _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<String> pubkeys() =>
reactions.value.expand((r) => r.userPubkeys).toSet().toList();
@@ -757,9 +763,7 @@ Future<bool> _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<bool> _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<bool> _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(),

View File

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

View File

@@ -119,6 +119,8 @@ Future<void> _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<void> _presentIosEmojiPicker({
switch (call.method) {
case 'mediaHeaders':
final url = call.arguments;
return url is String
? mediaAuth.headersFor(url)
: const <String, String>{};
if (url is! String) return const <String, String>{};
// 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 <String, String>{...mediaAuth.headersFor(url), ...assertion};
case 'selected':
final emoji = call.arguments;
if (emoji is String && emoji.isNotEmpty) onSelect(emoji);

View File

@@ -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<Map<String, String>> 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<String, String> 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<Map<String, String>> headers(RelayConfig config) async {
final client = _accountFor(config);
if (client == null) return const {};

View File

@@ -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<Map<String, String> 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),
};
};
});

View File

@@ -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<void>? gate;
@override
Future<Map<String, String>> 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 = <String>[];
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<Object?, Object?>)['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<void>();
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<Object?, Object?>;
hostedAuth.gate = null;
final proof =
jsonDecode(
utf8.decode(
base64Url.decode(
base64Url.normalize(
(refreshed['Authorization'] as String).substring(6),
),
),
),
)
as Map<String, Object?>;
final expiration = (proof['tags'] as List)
.cast<List<Object?>>()
.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}');

View File

@@ -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<void>? 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 = <Map<Object?, Object?>>[];
final reactionUpdates = <List<Object?>>[];
final profileUpdates = <Map<Object?, Object?>>[];
var completion = Completer<Map<String, Object?>>();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
NativeMessagePresentation.channel,
(call) async {
final args = call.arguments as Map<Object?, Object?>;
switch (call.method) {
case 'reactions':
payloads.add(args);
return completion.future;
case 'updateReactions':
reactionUpdates.add(args['reactions'] as List<Object?>);
case 'updateProfiles':
profileUpdates.add(args['profiles'] as Map<Object?, Object?>);
}
return <String, Object?>{};
},
);
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<void>();
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(<String, Object?>{});
await tester.pumpAndSettle();
// A fresh assertion needs no refresh, so nothing is resent.
reactionUpdates.clear();
completion = Completer<Map<String, Object?>>();
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(<String, Object?>{});
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<void>();
clock = clock.add(const Duration(seconds: 200));
expect(auth.cachedHeaders(config), isNull);
completion = Completer<Map<String, Object?>>();
await tester.longPress(
find.byKey(const ValueKey('reaction-pill-$_fire')),
);
await tester.pump();
expect(payloads, hasLength(3));
await tester.pumpWidget(const SizedBox());
completion.complete(<String, Object?>{});
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;
}

View File

@@ -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<String, String>? 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')));
}
});
}

View File

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