diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 4cfedd5bee..193dfccd37 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -790,7 +790,7 @@ class RemoteAgentController extends ChangeNotifier { final config = RelayConfig(baseUrl: relay, nsec: key.nsec); await submitSignedEventOnce( wsUrl: config.wsUrl, - headers: await relayAuth.headers(config), + headers: await relayAuth.headers(config, fresh: true), nsec: key.nsec, kind: 30177, content: jsonEncode({ @@ -813,7 +813,7 @@ class RemoteAgentController extends ChangeNotifier { final config = RelayConfig(baseUrl: relay, nsec: key.nsec); await submitSignedEventOnce( wsUrl: config.wsUrl, - headers: await relayAuth.headers(config), + headers: await relayAuth.headers(config, fresh: true), nsec: key.nsec, kind: 5, content: '', diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index ec62fe89f0..1f1a5b620e 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -179,7 +179,11 @@ Future _deactivateCommunityPushLease( ({required kind, required content, required tags, createdAt}) async => submitSignedEventOnce( wsUrl: wsUrl, - headers: await relayAuth.headers(relayConfig, background: true), + headers: await relayAuth.headers( + relayConfig, + background: true, + fresh: true, + ), nsec: nsec, kind: kind, content: content, diff --git a/mobile/lib/shared/hosted/hosted_admission.dart b/mobile/lib/shared/hosted/hosted_admission.dart index 706e4684a4..2d30d478a6 100644 --- a/mobile/lib/shared/hosted/hosted_admission.dart +++ b/mobile/lib/shared/hosted/hosted_admission.dart @@ -162,6 +162,7 @@ class HostedAdmission { try { socket.headers = await relayAuth.headers( RelayConfig(baseUrl: community.relayUrl, nsec: community.nsec), + fresh: true, ); await socket.connect().timeout(const Duration(seconds: 30)); if (socket.state != SocketState.connected) throw AccountFailure.denied; diff --git a/mobile/lib/shared/hosted/hosted_relay_auth.dart b/mobile/lib/shared/hosted/hosted_relay_auth.dart index 793f875391..0bdda3bb59 100644 --- a/mobile/lib/shared/hosted/hosted_relay_auth.dart +++ b/mobile/lib/shared/hosted/hosted_relay_auth.dart @@ -8,6 +8,7 @@ import '../relay/relay_provider.dart'; import 'builderlab_account.dart'; import 'account_identity_store.dart'; import 'hosted_environment.dart'; +import 'nip_fi_discovery.dart'; import 'organization_environment.dart'; import 'organization_key_backup.dart'; import '../push/push_bridge.dart'; @@ -61,9 +62,10 @@ final hostedHttpClientFactoryProvider = Provider((ref) { }); /// One account session per realm, with a short-lived assertion for the current -/// relay/key. Hosted communities use [account] when [enabled]; the build's -/// organization relays use [organization]. Enable alongside backend issuance; -/// legacy and self-hosted builds stay usable. +/// relay/key. Hosted communities use [account] when [enabled] and their relay's +/// NIP-FI discovery requires evidence; the build's organization relays always +/// use [organization]. Enable alongside backend issuance; legacy and +/// self-hosted builds stay usable. class HostedRelayAuth extends ChangeNotifier { HostedRelayAuth( this.account, { @@ -72,7 +74,18 @@ class HostedRelayAuth extends ChangeNotifier { this.organizationRelay = OrganizationEnvironment.protects, this.enabled = const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH'), DateTime Function()? now, - }) : _now = now ?? DateTime.now; + NipFiRequirementFetch? discover, + }) : _now = now ?? DateTime.now, + _discover = discover ?? _fetchRequirement; + + static Future _fetchRequirement(Uri origin) async { + final client = http.Client(); + try { + return await fetchNipFiRequirement(client, origin); + } finally { + client.close(); + } + } final BuilderlabAccountClient account; final BuilderlabAccountClient? organization; @@ -82,6 +95,12 @@ class HostedRelayAuth extends ChangeNotifier { final bool Function(Uri relay) organizationRelay; final bool enabled; final DateTime Function() _now; + final NipFiRequirementFetch _discover; + // NIP-FI discovery per hosted relay origin: the read in flight, shared by + // concurrent callers, and the last result. The issuer refuses hosts that have + // not enabled NIP-FI, so those connect without evidence. + final _discovering = >{}; + final _required = {}; // One assertion per relay and key, so issuing for an inactive community (for // example a background revocation) never evicts the active session's // credentials that native media paths read synchronously. @@ -130,7 +149,12 @@ class HostedRelayAuth extends ChangeNotifier { /// recovery state. Null means protected metadata must wait for active auth. Map? cachedHeaders(RelayConfig config) { if (_disposed) return null; - if (!protects(config)) return const {}; + final client = _accountFor(config); + if (client == null) return const {}; + if (_hostedOrigin(config, client) case final origin? + when _required[origin] == false) { + return const {}; + } final pubkey = pubkeyFromNsec(config.nsec); if (pubkey == null) return null; final cached = @@ -139,7 +163,7 @@ class HostedRelayAuth extends ChangeNotifier { pubkey: pubkey, )]; if (cached == null || - !identical(cached.account, _accountFor(config)?.account) || + !identical(cached.account, client.account) || !cached.expiry.isAfter(_now())) { return null; } @@ -176,13 +200,23 @@ class HostedRelayAuth extends ChangeNotifier { /// foreground use never inherits an assertion whose key was not backed up. /// Without an assertion it gets no headers rather than a failure, so the /// relay decides. + /// + /// A hosted relay gets an assertion only when its NIP-FI discovery requires + /// one. Every caller opening a new socket passes [fresh] to reread + /// discovery, so a community that changes enforcement is picked up on its + /// next connect; HTTP requests reuse the latest discovery. Future> headers( RelayConfig config, { bool background = false, + bool fresh = false, }) async { final client = _accountFor(config); if (client == null) return const {}; try { + if (_hostedOrigin(config, client) case final origin? + when !await _requires(origin, fresh)) { + return const {}; + } return await _headers(config, client, background); } on AccountFailure catch (failure) { // Cleanup still reaches a relay that does not enforce assertions; an @@ -198,6 +232,29 @@ class HostedRelayAuth extends ChangeNotifier { } } + /// The relay origin whose discovery decides, or null for organization relays, + /// which always use their own issuer. + String? _hostedOrigin(RelayConfig config, BuilderlabAccountClient client) => + identical(client, organization) ? null : Uri.parse(config.baseUrl).origin; + + Future _requires(String origin, bool fresh) async { + final known = _required[origin]; + if (!fresh && known != null) return known; + final pending = _discovering[origin] ??= _discover(Uri.parse(origin)); + try { + return _required[origin] = await pending; + } on Exception catch (error) { + // The last answer stays for requests. Without one, or for a new + // connection, retry with backoff rather than guessing. + debugPrint('NIP-FI discovery failed for $origin: $error'); + throw AccountFailure.unavailable; + } finally { + if (identical(_discovering[origin], pending)) { + _discovering.remove(origin); + } + } + } + Future> _headers( RelayConfig config, BuilderlabAccountClient client, @@ -403,6 +460,8 @@ class HostedRelayAuth extends ChangeNotifier { void dispose() { _disposed = true; _cached.clear(); + _discovering.clear(); + _required.clear(); super.dispose(); } } diff --git a/mobile/lib/shared/hosted/nip_fi_discovery.dart b/mobile/lib/shared/hosted/nip_fi_discovery.dart new file mode 100644 index 0000000000..77a85028b7 --- /dev/null +++ b/mobile/lib/shared/hosted/nip_fi_discovery.dart @@ -0,0 +1,47 @@ +import 'package:http/http.dart' as http; + +import '../relay/nip11_document.dart'; + +/// Whether the relay at [origin] requires NIP-FI admission evidence, read from +/// its NIP-11 document. Throws on any failure or malformed document. +typedef NipFiRequirementFetch = Future Function(Uri origin); + +/// Reads [origin]'s NIP-11 document from its root, without following +/// redirects; see [fetchNip11Document]. +Future fetchNipFiRequirement( + http.Client client, + Uri origin, { + Duration deadline = nip11Deadline, +}) async => parseNipFiRequirement( + await fetchNip11Document( + client, + Uri.parse(origin.origin).replace(path: '/'), + deadline: deadline, + followRedirects: false, + ), +); + +/// NIP-FI Discovery: `limitation.federated_identity` advertises support, as a +/// shadow-mode host does too; the host-specific `requires_nip_fi` says this +/// community requires evidence. A relay without the flag requires what it +/// supports. Same requirement rule as buzz-app's desktop discovery. +bool parseNipFiRequirement(Object? document) { + if (document is! Map) { + throw const FormatException('NIP-11 document is not an object'); + } + final supports = switch (document['limitation']) { + null => false, + final Map limitation => + switch (limitation['federated_identity']) { + null || false => false, + true => true, + _ => throw const FormatException('Malformed federated_identity'), + }, + _ => throw const FormatException('Malformed NIP-11 limitation'), + }; + return switch (document['requires_nip_fi']) { + null => supports, + final bool requires => requires, + _ => throw const FormatException('Malformed requires_nip_fi'), + }; +} diff --git a/mobile/lib/shared/huddle/huddle_session.dart b/mobile/lib/shared/huddle/huddle_session.dart index 7ce06b81d2..8e5c7482ef 100644 --- a/mobile/lib/shared/huddle/huddle_session.dart +++ b/mobile/lib/shared/huddle/huddle_session.dart @@ -160,6 +160,7 @@ final huddleTransportFactoryProvider = Provider((ref) { parameters: parameters, headers: () => auth.headers( RelayConfig(baseUrl: parameters.relayWebSocketUrl, nsec: parameters.nsec), + fresh: true, ), ); }); diff --git a/mobile/lib/shared/push/push_lease_revocation_outbox.dart b/mobile/lib/shared/push/push_lease_revocation_outbox.dart index a9063d0feb..5dd4840643 100644 --- a/mobile/lib/shared/push/push_lease_revocation_outbox.dart +++ b/mobile/lib/shared/push/push_lease_revocation_outbox.dart @@ -553,6 +553,7 @@ Future publishBuzzPushLeaseRevocation( headers: await relayAuth.headers( RelayConfig(baseUrl: record.relayUrl, nsec: record.nsec), background: true, + fresh: true, ), nsec: record.nsec, kind: kind, diff --git a/mobile/lib/shared/relay/nip11_document.dart b/mobile/lib/shared/relay/nip11_document.dart new file mode 100644 index 0000000000..0c291634ad --- /dev/null +++ b/mobile/lib/shared/relay/nip11_document.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +const _maxDocumentBytes = 256 * 1024; + +/// How long a NIP-11 read may take, headers and body together. +const nip11Deadline = Duration(seconds: 10); + +/// Reads and decodes the NIP-11 document at [uri] with a size cap. One +/// wall-clock deadline covers headers and the whole body, so a relay trickling +/// bytes cannot keep the read in flight forever. No credential goes on the +/// request. Throws on a non-200 status, an oversized body, the deadline or +/// malformed JSON. +Future fetchNip11Document( + http.Client client, + Uri uri, { + Duration deadline = nip11Deadline, + bool followRedirects = true, +}) async { + final abort = Completer(); + final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future) + ..followRedirects = followRedirects + ..headers['Accept'] = 'application/nostr+json'; + StreamIterator>? body; + Future> read() async { + final response = await client.send(request); + final iterator = body = StreamIterator(response.stream); + if (response.statusCode != 200) { + throw http.ClientException('NIP-11 status ${response.statusCode}', uri); + } + final bytes = []; + while (await iterator.moveNext()) { + if (bytes.length + iterator.current.length > _maxDocumentBytes) { + throw const FormatException('NIP-11 exceeds size limit'); + } + bytes.addAll(iterator.current); + } + return bytes; + } + + try { + final bytes = await read().timeout(deadline); + return jsonDecode(utf8.decode(bytes)); + } finally { + if (!abort.isCompleted) abort.complete(); + unawaited(body?.cancel()); + } +} diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 0285df5eb0..e8581954c8 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -627,7 +627,9 @@ class RelaySessionNotifier extends Notifier { try { final auth = ref.read(hostedRelayAuthProvider); - if (auth.protects(config)) socket.headers = await auth.headers(config); + if (auth.protects(config)) { + socket.headers = await auth.headers(config, fresh: true); + } if (_disposed || generation != _connectionGeneration) return; await socket.connect(); } on AccountFailure catch (error) { diff --git a/mobile/lib/shared/verified_profiles/verified_profile_discovery_fetch.dart b/mobile/lib/shared/verified_profiles/verified_profile_discovery_fetch.dart index 47597c16bc..d0e66d1e08 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile_discovery_fetch.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile_discovery_fetch.dart @@ -1,15 +1,10 @@ -import 'dart:async'; -import 'dart:convert'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import '../relay/nip11_document.dart'; import '../relay/relay.dart'; import 'verified_profile.dart'; -const _maxInfoBytes = 256 * 1024; -const _discoveryTimeout = Duration(seconds: 10); - /// Fetches and parses the community's NIP-11 document. Throws on any failure /// or malformed response; a thrown error never changes the last known state. typedef VerifiedProfileDiscoveryFetch = @@ -21,42 +16,14 @@ final verifiedProfileHttpClientProvider = Provider((ref) { return client; }); -/// Reads the NIP-11 document at [uri] with a size cap. One wall-clock -/// deadline covers headers and the whole body, so a relay trickling bytes -/// cannot keep discovery in flight forever. +/// Reads and parses the NIP-11 document at [uri]; see [fetchNip11Document]. Future fetchVerifiedProfileDiscovery( http.Client client, Uri uri, { - Duration deadline = _discoveryTimeout, -}) async { - final abort = Completer(); - final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future) - ..headers['Accept'] = 'application/nostr+json'; - StreamIterator>? body; - Future> read() async { - final response = await client.send(request); - final iterator = body = StreamIterator(response.stream); - if (response.statusCode != 200) { - throw http.ClientException('NIP-11 status ${response.statusCode}', uri); - } - final bytes = []; - while (await iterator.moveNext()) { - if (bytes.length + iterator.current.length > _maxInfoBytes) { - throw const FormatException('NIP-11 exceeds size limit'); - } - bytes.addAll(iterator.current); - } - return bytes; - } - - try { - final bytes = await read().timeout(deadline); - return parseVerifiedProfileDiscovery(jsonDecode(utf8.decode(bytes))); - } finally { - if (!abort.isCompleted) abort.complete(); - unawaited(body?.cancel()); - } -} + Duration deadline = nip11Deadline, +}) async => parseVerifiedProfileDiscovery( + await fetchNip11Document(client, uri, deadline: deadline), +); final verifiedProfileDiscoveryFetchProvider = Provider((ref) { diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index c478ebd8b2..746e4575bf 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -160,6 +160,7 @@ class _FakeHostedRelayAuth extends HostedRelayAuth { Future> headers( RelayConfig config, { bool background = false, + bool fresh = false, }) async { await gate?.future; return {'Nostr-Federated-Identity': 'Bearer hosted-token'}; diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 13195ef4f2..2e8cf5096b 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -818,6 +818,73 @@ void main() { requiresBuilderlab: false, ); + journey( + 'a hosted community gets assertions only once its relay requires NIP-FI', + (tester, f) async { + f.relayAuthEnabled = true; + f.nipFiRequired = false; + await f.initialize(); + final community = Community.create( + name: 'One', + relayUrl: 'wss://one.communities.buzz.xyz', + pubkey: f.keys.public, + nsec: f.keys.nsec, + ); + await f.storage.save(community); + await f.storage.saveActiveId(community.id); + await f.mount(tester, openEntry: false); + final sessionSubscription = f.container.listen( + relaySessionProvider, + (_, _) {}, + ); + addTearDown(sessionSubscription.close); + final session = f.container.read(relaySessionProvider.notifier); + await f.settle(tester); + // The issuer refuses hosts without NIP-FI; the app never asks it. + expect(f.socketHeaders, isNotEmpty); + expect(f.socketHeaders, everyElement(isEmpty)); + await session.queryRelay(const []); + expect(f.sent('/query'), hasLength(1)); + expect(f.sent('/identity/assertions'), isEmpty); + expect( + find.textContaining('cannot reconnect to this community'), + findsNothing, + ); + // Cached-only reads (saved profile cards, native media) go out without + // an assertion instead of waiting for one that will never come. + final auth = f.container.read(hostedRelayAuthProvider); + final config = f.container.read(relayConfigProvider); + expect(auth.cachedHeaders(config), isEmpty); + + // Unreachable discovery fails transiently, never as a denial. + f.nipFiRequired = null; + await session.reconnect(); + await f.settle(tester); + expect(f.container.read(hostedRelayAuthProvider).failure, isNull); + // Requests keep the last answer instead of failing with discovery. + await session.queryRelay(const []); + expect(f.sent('/query'), hasLength(2)); + expect(f.sent('/identity/assertions'), isEmpty); + + // Once the community requires NIP-FI, its next connection carries one. + f.nipFiRequired = true; + final discoveries = f.discoveries; + await session.reconnect(); + await f.settle(tester); + expect(f.discoveries, greaterThan(discoveries)); + expect(f.socketHeaders.last, { + 'Nostr-Federated-Identity': 'Bearer synthetic-assertion', + }); + expect(f.sent('/identity/assertions'), hasLength(1)); + await session.queryRelay(const []); + expect(f.sent('/identity/assertions'), hasLength(1)); + expect(auth.cachedHeaders(config), { + 'Nostr-Federated-Identity': 'Bearer synthetic-assertion', + }); + }, + requiresAuthDomain: true, + ); + journey( 'saved community signs in again without changing identity or onboarding', (tester, f) async { diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index 823a408275..ab453c419a 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -75,6 +75,10 @@ class HostedJourneyFixture { int secondRelayFailures = 0; DateTime assertionNow = DateTime.now(); bool relayAuthEnabled = false; + + /// What hosted relays' NIP-FI discovery answers; null fails discovery. + bool? nipFiRequired = true; + int discoveries = 0; bool expiredSession = false; bool denyAssertion = false; bool refuseUpgrade = false; @@ -195,7 +199,7 @@ class HostedJourneyFixture { HostedEnvironment.requiresAuth(request.url.host)) { expect( request.headers['Nostr-Federated-Identity'], - 'Bearer synthetic-assertion', + nipFiRequired == true ? 'Bearer synthetic-assertion' : isNull, ); expect(request.headers.containsKey('X-BB-Session-Credential'), isFalse); } @@ -482,6 +486,11 @@ class HostedJourneyFixture { organizationEnabled && relay.host == organizationRelayHost, enabled: true, now: () => assertionNow, + discover: (_) async { + discoveries++; + return nipFiRequired ?? + (throw http.ClientException('NIP-11 unreachable')); + }, ); ref.onDispose(auth.dispose); return auth; @@ -734,7 +743,8 @@ class _RelaySocket extends RelaySocket { host == HostedJourneyFixture.organizationRelayHost // Signed out connects without one and lets the relay decide. ? anyOf(isNull, 'Bearer organization-assertion') - : HostedEnvironment.requiresAuth(host) + : HostedEnvironment.requiresAuth(host) && + fixture.nipFiRequired != false ? 'Bearer synthetic-assertion' : isNull, ); diff --git a/mobile/test/shared/hosted/nip_fi_discovery_test.dart b/mobile/test/shared/hosted/nip_fi_discovery_test.dart new file mode 100644 index 0000000000..96cb47c8d1 --- /dev/null +++ b/mobile/test/shared/hosted/nip_fi_discovery_test.dart @@ -0,0 +1,133 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/hosted/nip_fi_discovery.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + test( + 'NIP-FI requirement follows discovery, and malformed documents fail', + () { + final cases = <(Object?, bool?)>[ + // Shadow mode advertises support without requiring evidence. + ( + { + 'limitation': {'federated_identity': true}, + 'requires_nip_fi': false, + }, + false, + ), + ( + { + 'limitation': {'federated_identity': true}, + 'requires_nip_fi': true, + }, + true, + ), + // Without the host flag, a relay requires what it supports. + ( + { + 'limitation': {'federated_identity': true}, + }, + true, + ), + ( + { + 'limitation': {'federated_identity': false}, + }, + false, + ), + ({'limitation': {}}, false), + ({'name': 'relay'}, false), + ({'requires_nip_fi': true}, true), + // Null marks a malformed document. + ( + { + 'limitation': {'federated_identity': 'yes'}, + }, + null, + ), + ({'limitation': true}, null), + ({'requires_nip_fi': 'true'}, null), + ([], null), + ('relay', null), + ]; + for (final (document, required) in cases) { + if (required == null) { + expect( + () => parseNipFiRequirement(document), + throwsFormatException, + reason: '$document', + ); + } else { + expect( + parseNipFiRequirement(document), + required, + reason: '$document', + ); + } + } + }, + ); + + test( + 'discovery reads the root document, bounded and uncredentialed', + () async { + final requests = []; + Future discover( + http.StreamedResponse Function() respond, { + Duration deadline = const Duration(seconds: 10), + }) => fetchNipFiRequirement( + MockClient.streaming((request, _) async { + requests.add(request); + return respond(); + }), + Uri.parse('https://one.example.org/ignored?x=1'), + deadline: deadline, + ); + http.StreamedResponse document(Object body, {int status = 200}) => + http.StreamedResponse( + Stream.value(utf8.encode(jsonEncode(body))), + status, + ); + + expect( + await discover( + () => document({ + 'limitation': {'federated_identity': true}, + 'requires_nip_fi': false, + }), + ), + isFalse, + ); + expect(requests.single.url.toString(), 'https://one.example.org/'); + expect(requests.single.headers, {'Accept': 'application/nostr+json'}); + expect(requests.single.followRedirects, isFalse); + + await expectLater( + discover(() => document({'error': 'unknown'}, status: 404)), + throwsA(isA()), + ); + await expectLater( + discover( + () => http.StreamedResponse( + Stream.fromIterable(List.filled(300, List.filled(1024, 0x20))), + 200, + ), + ), + throwsFormatException, + ); + final stalled = StreamController>(); + addTearDown(stalled.close); + await expectLater( + discover( + () => http.StreamedResponse(stalled.stream, 200), + deadline: const Duration(milliseconds: 50), + ), + throwsA(isA()), + ); + }, + ); +} diff --git a/mobile/test/shared/hosted/relay_auth_isolation_test.dart b/mobile/test/shared/hosted/relay_auth_isolation_test.dart index 4a68b35704..009fe48c9c 100644 --- a/mobile/test/shared/hosted/relay_auth_isolation_test.dart +++ b/mobile/test/shared/hosted/relay_auth_isolation_test.dart @@ -93,6 +93,7 @@ void main() { organizationRelay: (relay) => OrganizationEnvironment.matches(relay, [relayHost]), enabled: true, + discover: (_) async => true, ); final orgHeaders = auth.headers(orgRelay); await pumpEventQueue(); diff --git a/mobile/test/shared/push/push_lease_revocation_outbox_test.dart b/mobile/test/shared/push/push_lease_revocation_outbox_test.dart index 6e624f0989..476276683f 100644 --- a/mobile/test/shared/push/push_lease_revocation_outbox_test.dart +++ b/mobile/test/shared/push/push_lease_revocation_outbox_test.dart @@ -95,6 +95,9 @@ void main() { expect(auth.configs.single.nsec, keys.nsec); // A removed community's failure must not hide the active one's recovery. expect(auth.backgrounds.single, isTrue); + // A new socket rereads NIP-FI discovery, so a community that started + // enforcing since its last read still gets the assertion. + expect(auth.freshes.single, isTrue); // Removing the community publishes its tombstone the same way. debugDefaultTargetPlatformOverride = TargetPlatform.iOS; @@ -504,14 +507,17 @@ class _AssertingRelayAuth extends HostedRelayAuth { final configs = []; final backgrounds = []; + final freshes = []; @override Future> headers( RelayConfig config, { bool background = false, + bool fresh = false, }) async { configs.add(config); backgrounds.add(background); + freshes.add(fresh); return {'Nostr-Federated-Identity': 'Bearer hosted-token'}; } } diff --git a/mobile/test/shared/relay/read_state_snapshot_transport_test.dart b/mobile/test/shared/relay/read_state_snapshot_transport_test.dart index 1fcbcab258..25ffa70623 100644 --- a/mobile/test/shared/relay/read_state_snapshot_transport_test.dart +++ b/mobile/test/shared/relay/read_state_snapshot_transport_test.dart @@ -575,6 +575,7 @@ class _SnapshotAuth extends HostedRelayAuth { Future> headers( RelayConfig config, { bool background = false, + bool fresh = false, }) async { await beforeHeaders?.call(); return {'Nostr-Federated-Identity': 'assertion'};