mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
fix(mobile): request hosted relay assertions only when NIP-FI is required (#9225)
Port the desktop NIP-FI requirement rule to mobile: hosted relay connections request an account assertion only when the relay's NIP-11 document requires NIP-FI, so communities that have not enabled it keep connecting with BUZZ_HOSTED_RELAY_AUTH on. Signed-off-by: Tom Brow <tomb@squareup.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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: '',
|
||||
|
||||
@@ -179,7 +179,11 @@ Future<void> _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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<http.Client Function()>((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<bool> _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 = <String, Future<bool>>{};
|
||||
final _required = <String, bool>{};
|
||||
// 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<String, String>? 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<Map<String, String>> 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<bool> _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<Map<String, String>> _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();
|
||||
}
|
||||
}
|
||||
|
||||
47
mobile/lib/shared/hosted/nip_fi_discovery.dart
Normal file
47
mobile/lib/shared/hosted/nip_fi_discovery.dart
Normal file
@@ -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<bool> Function(Uri origin);
|
||||
|
||||
/// Reads [origin]'s NIP-11 document from its root, without following
|
||||
/// redirects; see [fetchNip11Document].
|
||||
Future<bool> 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<String, dynamic>) {
|
||||
throw const FormatException('NIP-11 document is not an object');
|
||||
}
|
||||
final supports = switch (document['limitation']) {
|
||||
null => false,
|
||||
final Map<String, dynamic> 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'),
|
||||
};
|
||||
}
|
||||
@@ -160,6 +160,7 @@ final huddleTransportFactoryProvider = Provider<HuddleTransportFactory>((ref) {
|
||||
parameters: parameters,
|
||||
headers: () => auth.headers(
|
||||
RelayConfig(baseUrl: parameters.relayWebSocketUrl, nsec: parameters.nsec),
|
||||
fresh: true,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -553,6 +553,7 @@ Future<void> publishBuzzPushLeaseRevocation(
|
||||
headers: await relayAuth.headers(
|
||||
RelayConfig(baseUrl: record.relayUrl, nsec: record.nsec),
|
||||
background: true,
|
||||
fresh: true,
|
||||
),
|
||||
nsec: record.nsec,
|
||||
kind: kind,
|
||||
|
||||
50
mobile/lib/shared/relay/nip11_document.dart
Normal file
50
mobile/lib/shared/relay/nip11_document.dart
Normal file
@@ -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<Object?> fetchNip11Document(
|
||||
http.Client client,
|
||||
Uri uri, {
|
||||
Duration deadline = nip11Deadline,
|
||||
bool followRedirects = true,
|
||||
}) async {
|
||||
final abort = Completer<void>();
|
||||
final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future)
|
||||
..followRedirects = followRedirects
|
||||
..headers['Accept'] = 'application/nostr+json';
|
||||
StreamIterator<List<int>>? body;
|
||||
Future<List<int>> 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 = <int>[];
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -627,7 +627,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<http.Client>((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<VerifiedProfileDiscovery> fetchVerifiedProfileDiscovery(
|
||||
http.Client client,
|
||||
Uri uri, {
|
||||
Duration deadline = _discoveryTimeout,
|
||||
}) async {
|
||||
final abort = Completer<void>();
|
||||
final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future)
|
||||
..headers['Accept'] = 'application/nostr+json';
|
||||
StreamIterator<List<int>>? body;
|
||||
Future<List<int>> 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 = <int>[];
|
||||
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<VerifiedProfileDiscoveryFetch>((ref) {
|
||||
|
||||
@@ -160,6 +160,7 @@ class _FakeHostedRelayAuth extends HostedRelayAuth {
|
||||
Future<Map<String, String>> headers(
|
||||
RelayConfig config, {
|
||||
bool background = false,
|
||||
bool fresh = false,
|
||||
}) async {
|
||||
await gate?.future;
|
||||
return {'Nostr-Federated-Identity': 'Bearer hosted-token'};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
133
mobile/test/shared/hosted/nip_fi_discovery_test.dart
Normal file
133
mobile/test/shared/hosted/nip_fi_discovery_test.dart
Normal file
@@ -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': <String, dynamic>{}}, 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 = <http.BaseRequest>[];
|
||||
Future<bool> 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<http.ClientException>()),
|
||||
);
|
||||
await expectLater(
|
||||
discover(
|
||||
() => http.StreamedResponse(
|
||||
Stream.fromIterable(List.filled(300, List.filled(1024, 0x20))),
|
||||
200,
|
||||
),
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
final stalled = StreamController<List<int>>();
|
||||
addTearDown(stalled.close);
|
||||
await expectLater(
|
||||
discover(
|
||||
() => http.StreamedResponse(stalled.stream, 200),
|
||||
deadline: const Duration(milliseconds: 50),
|
||||
),
|
||||
throwsA(isA<TimeoutException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -93,6 +93,7 @@ void main() {
|
||||
organizationRelay: (relay) =>
|
||||
OrganizationEnvironment.matches(relay, [relayHost]),
|
||||
enabled: true,
|
||||
discover: (_) async => true,
|
||||
);
|
||||
final orgHeaders = auth.headers(orgRelay);
|
||||
await pumpEventQueue();
|
||||
|
||||
@@ -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 = <RelayConfig>[];
|
||||
final backgrounds = <bool>[];
|
||||
final freshes = <bool>[];
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> 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'};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,6 +575,7 @@ class _SnapshotAuth extends HostedRelayAuth {
|
||||
Future<Map<String, String>> headers(
|
||||
RelayConfig config, {
|
||||
bool background = false,
|
||||
bool fresh = false,
|
||||
}) async {
|
||||
await beforeHeaders?.call();
|
||||
return {'Nostr-Federated-Identity': 'assertion'};
|
||||
|
||||
Reference in New Issue
Block a user